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
@@ -33,6 +33,7 @@ async function processImageEnhancement(
) {
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const analysis = await analyzeImage(inputBuffer);
const meta = await sharp(inputBuffer).metadata();
let image = sharp(inputBuffer);
image = applyCorrections(
@@ -41,6 +42,7 @@ async function processImageEnhancement(
settings.mode,
settings.intensity,
settings.corrections,
{ width: meta.width ?? 1, height: meta.height ?? 1 },
);
const buffer = await image
@@ -20,6 +20,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: number;
sharpness: number;
denoise: number;
clahe: number;
normalise: number;
}
> = {
auto: {
@@ -29,6 +31,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 1.0,
sharpness: 1.0,
denoise: 1.0,
clahe: 1.0,
normalise: 1.0,
},
portrait: {
brightness: 0.8,
@@ -37,6 +41,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 0.6,
sharpness: 0.5,
denoise: 1.5,
clahe: 0.7,
normalise: 0.8,
},
landscape: {
brightness: 1.0,
@@ -45,6 +51,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 1.4,
sharpness: 1.5,
denoise: 0.5,
clahe: 1.3,
normalise: 1.2,
},
"low-light": {
brightness: 1.8,
@@ -53,6 +61,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 0.8,
sharpness: 1.2,
denoise: 2.0,
clahe: 1.5,
normalise: 1.5,
},
food: {
brightness: 0.8,
@@ -61,6 +71,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 1.3,
sharpness: 1.2,
denoise: 0.5,
clahe: 1.1,
normalise: 1.0,
},
document: {
brightness: 1.5,
@@ -69,6 +81,8 @@ const PRESET_MULTIPLIERS: Record<
saturation: 0.0,
sharpness: 2.0,
denoise: 2.0,
clahe: 2.0,
normalise: 1.5,
},
};
@@ -201,56 +215,76 @@ export function applyCorrections(
mode: EnhancementMode,
intensity: number,
toggles: Record<string, boolean>,
imageSize?: { width: number; height: number },
): Sharp {
const presets = PRESET_MULTIPLIERS[mode];
const scale = intensity / 50;
let result = image;
// Step 1: CLAHE - adaptive local contrast enhancement
// maxSlope must be an integer (Sharp requirement); skip for tiny images
if (toggles.contrast !== false) {
const maxSlope = clamp(Math.round(1.0 + (intensity / 100) * 4.0 * presets.clahe), 1, 10);
const minDim = imageSize ? Math.min(imageSize.width, imageSize.height) : 4;
const tileSize = minDim >= 3 ? 3 : 1;
if (maxSlope >= 2) {
result = result.clahe({ width: tileSize, height: tileSize, maxSlope });
}
}
// Step 2: Normalise - auto-levels histogram stretch
// lower = percentile below which pixels are clipped to black (0-99)
// upper = percentile above which pixels are clipped to white (1-100)
if (toggles.exposure !== false) {
const baseClip = 5 - (intensity / 100) * 4.5;
const clipPct = clamp(Math.round(baseClip * presets.normalise), 0, 10);
const lower = clipPct;
const upper = 100 - clipPct;
if (lower < upper) {
result = result.normalise({ lower, upper });
}
}
// Step 3: Gamma - perceptual exposure correction (only outside dead zone)
if (toggles.exposure !== false) {
const adj = corrections.brightness * presets.brightness * scale;
if (Math.abs(adj) > 2) {
const multiplier = clamp(1 + adj / 100, 0.2, 3.0);
result = result.modulate({ brightness: multiplier });
}
}
if (toggles.contrast !== false) {
const adj = corrections.contrast * presets.contrast * scale;
if (Math.abs(adj) > 2) {
const slope = 1 + adj / 100;
const intercept = 128 * (1 - slope);
result = result.linear(slope, intercept);
const gamma = clamp(1 + adj / 100, 1.0, 3.0);
result = result.gamma(gamma);
}
}
// Step 4: White balance via per-channel linear scaling
// Uses linear() instead of recomb() to avoid float-cast that breaks CLAHE
if (toggles.whiteBalance !== false) {
const adj = corrections.temperature * presets.temperature * scale;
if (Math.abs(adj) > 2) {
const t = adj / 100;
result = result.recomb([
[1 + t * 0.15, 0, 0],
[0, 1 + t * 0.05, 0],
[0, 0, 1 - t * 0.15],
]);
result = result.linear([1 + t * 0.15, 1 + t * 0.05, 1 - t * 0.15], [0, 0, 0]);
}
}
// Step 5: Saturation (with small CLAHE compensation boost)
if (toggles.saturation !== false) {
const adj = corrections.saturation * presets.saturation * scale;
if (Math.abs(adj) > 2) {
result = result.modulate({ saturation: 1 + adj / 100 });
const claheCompensation = toggles.contrast !== false && intensity > 10 ? 0.05 : 0;
const satMul = 1 + adj / 100 + claheCompensation;
if (Math.abs(satMul - 1) > 0.02) {
result = result.modulate({ saturation: clamp(satMul, 0.2, 3.0) });
}
}
// Step 6: Sharpen with flat parameter to avoid sharpening noise
if (toggles.sharpness !== false) {
const adj = corrections.sharpness * presets.sharpness * scale;
if (adj > 2) {
const sigma = 0.5 + (adj / 100) * 4;
result = result.sharpen({ sigma });
result = result.sharpen({ sigma, flat: 1.0 });
}
}
// Denoise via median (kept for backward compat, Deep Enhance uses SCUNet)
if (toggles.denoise !== false) {
const adj = corrections.denoise * presets.denoise * scale;
if (adj >= 2) {
+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);
});
});