fix: replace enhancement pipeline with CLAHE + normalise + gamma

CLAHE provides adaptive local contrast, normalise stretches the
histogram, and gamma adjusts exposure perceptually. Replaces the old
modulate/linear pipeline that compounded errors and darkened images.
Preset multipliers now include clahe and normalise entries.

Key fixes beyond the spec:
- maxSlope rounded to integer (Sharp requirement)
- White balance uses linear() instead of recomb() to avoid float-cast
  that breaks CLAHE in the libvips pipeline
- CLAHE tile size adapts to image dimensions (1x1 for tiny images)
- Gamma clamped to Sharp's valid range (1.0-3.0)
- Normalise lower/upper correctly mapped to percentile cutoffs
This commit is contained in:
SnapOtter
2026-05-09 11:24:00 +08:00
parent 4bcdc67aa0
commit b61be9e438
3 changed files with 131 additions and 19 deletions
+76
View File
@@ -205,3 +205,79 @@ describe("applyCorrections", () => {
expect(enhancedMeta.height).toBe(originalMeta.height);
});
});
describe("applyCorrections pipeline (CLAHE + normalise + gamma)", () => {
it("does not darken a well-exposed image", async () => {
const analysis = await analyzeImage(PNG_200x150);
const image = sharp(PNG_200x150);
const enhanced = applyCorrections(image, analysis.corrections, "auto", 50, {});
const enhancedBuf = await enhanced.toBuffer();
const origStats = await sharp(PNG_200x150).stats();
const enhStats = await sharp(enhancedBuf).stats();
const origLum =
origStats.channels[0].mean * 0.299 +
origStats.channels[1].mean * 0.587 +
origStats.channels[2].mean * 0.114;
const enhLum =
enhStats.channels[0].mean * 0.299 +
enhStats.channels[1].mean * 0.587 +
enhStats.channels[2].mean * 0.114;
// Enhanced image should not be more than 5% darker
expect(enhLum).toBeGreaterThan(origLum * 0.95);
});
it("brightens a dark image", async () => {
const darkBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 30, g: 30, b: 30 } },
})
.png()
.toBuffer();
const analysis = await analyzeImage(darkBuffer);
const enhanced = applyCorrections(sharp(darkBuffer), analysis.corrections, "auto", 50, {});
const enhancedBuf = await enhanced.toBuffer();
const origStats = await sharp(darkBuffer).stats();
const enhStats = await sharp(enhancedBuf).stats();
const origLum =
origStats.channels[0].mean * 0.299 +
origStats.channels[1].mean * 0.587 +
origStats.channels[2].mean * 0.114;
const enhLum =
enhStats.channels[0].mean * 0.299 +
enhStats.channels[1].mean * 0.587 +
enhStats.channels[2].mean * 0.114;
expect(enhLum).toBeGreaterThan(origLum * 1.1);
});
it("applies CLAHE at intensity 0 with no visible effect", async () => {
const image = sharp(PNG_200x150);
const corrections = {
brightness: 0,
contrast: 0,
temperature: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
};
const enhanced = applyCorrections(image, corrections, "auto", 0, {});
const enhancedBuf = await enhanced.toBuffer();
const origStats = await sharp(PNG_200x150).stats();
const enhStats = await sharp(enhancedBuf).stats();
const origLum =
origStats.channels[0].mean * 0.299 +
origStats.channels[1].mean * 0.587 +
origStats.channels[2].mean * 0.114;
const enhLum =
enhStats.channels[0].mean * 0.299 +
enhStats.channels[1].mean * 0.587 +
enhStats.channels[2].mean * 0.114;
expect(Math.abs(enhLum - origLum)).toBeLessThan(origLum * 0.15);
});
});