mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -33,6 +33,7 @@ async function processImageEnhancement(
|
|||||||
) {
|
) {
|
||||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||||
const analysis = await analyzeImage(inputBuffer);
|
const analysis = await analyzeImage(inputBuffer);
|
||||||
|
const meta = await sharp(inputBuffer).metadata();
|
||||||
|
|
||||||
let image = sharp(inputBuffer);
|
let image = sharp(inputBuffer);
|
||||||
image = applyCorrections(
|
image = applyCorrections(
|
||||||
@@ -41,6 +42,7 @@ async function processImageEnhancement(
|
|||||||
settings.mode,
|
settings.mode,
|
||||||
settings.intensity,
|
settings.intensity,
|
||||||
settings.corrections,
|
settings.corrections,
|
||||||
|
{ width: meta.width ?? 1, height: meta.height ?? 1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = await image
|
const buffer = await image
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: number;
|
saturation: number;
|
||||||
sharpness: number;
|
sharpness: number;
|
||||||
denoise: number;
|
denoise: number;
|
||||||
|
clahe: number;
|
||||||
|
normalise: number;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
auto: {
|
auto: {
|
||||||
@@ -29,6 +31,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 1.0,
|
saturation: 1.0,
|
||||||
sharpness: 1.0,
|
sharpness: 1.0,
|
||||||
denoise: 1.0,
|
denoise: 1.0,
|
||||||
|
clahe: 1.0,
|
||||||
|
normalise: 1.0,
|
||||||
},
|
},
|
||||||
portrait: {
|
portrait: {
|
||||||
brightness: 0.8,
|
brightness: 0.8,
|
||||||
@@ -37,6 +41,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 0.6,
|
saturation: 0.6,
|
||||||
sharpness: 0.5,
|
sharpness: 0.5,
|
||||||
denoise: 1.5,
|
denoise: 1.5,
|
||||||
|
clahe: 0.7,
|
||||||
|
normalise: 0.8,
|
||||||
},
|
},
|
||||||
landscape: {
|
landscape: {
|
||||||
brightness: 1.0,
|
brightness: 1.0,
|
||||||
@@ -45,6 +51,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 1.4,
|
saturation: 1.4,
|
||||||
sharpness: 1.5,
|
sharpness: 1.5,
|
||||||
denoise: 0.5,
|
denoise: 0.5,
|
||||||
|
clahe: 1.3,
|
||||||
|
normalise: 1.2,
|
||||||
},
|
},
|
||||||
"low-light": {
|
"low-light": {
|
||||||
brightness: 1.8,
|
brightness: 1.8,
|
||||||
@@ -53,6 +61,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 0.8,
|
saturation: 0.8,
|
||||||
sharpness: 1.2,
|
sharpness: 1.2,
|
||||||
denoise: 2.0,
|
denoise: 2.0,
|
||||||
|
clahe: 1.5,
|
||||||
|
normalise: 1.5,
|
||||||
},
|
},
|
||||||
food: {
|
food: {
|
||||||
brightness: 0.8,
|
brightness: 0.8,
|
||||||
@@ -61,6 +71,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 1.3,
|
saturation: 1.3,
|
||||||
sharpness: 1.2,
|
sharpness: 1.2,
|
||||||
denoise: 0.5,
|
denoise: 0.5,
|
||||||
|
clahe: 1.1,
|
||||||
|
normalise: 1.0,
|
||||||
},
|
},
|
||||||
document: {
|
document: {
|
||||||
brightness: 1.5,
|
brightness: 1.5,
|
||||||
@@ -69,6 +81,8 @@ const PRESET_MULTIPLIERS: Record<
|
|||||||
saturation: 0.0,
|
saturation: 0.0,
|
||||||
sharpness: 2.0,
|
sharpness: 2.0,
|
||||||
denoise: 2.0,
|
denoise: 2.0,
|
||||||
|
clahe: 2.0,
|
||||||
|
normalise: 1.5,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,56 +215,76 @@ export function applyCorrections(
|
|||||||
mode: EnhancementMode,
|
mode: EnhancementMode,
|
||||||
intensity: number,
|
intensity: number,
|
||||||
toggles: Record<string, boolean>,
|
toggles: Record<string, boolean>,
|
||||||
|
imageSize?: { width: number; height: number },
|
||||||
): Sharp {
|
): Sharp {
|
||||||
const presets = PRESET_MULTIPLIERS[mode];
|
const presets = PRESET_MULTIPLIERS[mode];
|
||||||
const scale = intensity / 50;
|
const scale = intensity / 50;
|
||||||
|
|
||||||
let result = image;
|
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) {
|
if (toggles.exposure !== false) {
|
||||||
const adj = corrections.brightness * presets.brightness * scale;
|
const adj = corrections.brightness * presets.brightness * scale;
|
||||||
if (Math.abs(adj) > 2) {
|
if (Math.abs(adj) > 2) {
|
||||||
const multiplier = clamp(1 + adj / 100, 0.2, 3.0);
|
const gamma = clamp(1 + adj / 100, 1.0, 3.0);
|
||||||
result = result.modulate({ brightness: multiplier });
|
result = result.gamma(gamma);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (toggles.whiteBalance !== false) {
|
||||||
const adj = corrections.temperature * presets.temperature * scale;
|
const adj = corrections.temperature * presets.temperature * scale;
|
||||||
if (Math.abs(adj) > 2) {
|
if (Math.abs(adj) > 2) {
|
||||||
const t = adj / 100;
|
const t = adj / 100;
|
||||||
result = result.recomb([
|
result = result.linear([1 + t * 0.15, 1 + t * 0.05, 1 - t * 0.15], [0, 0, 0]);
|
||||||
[1 + t * 0.15, 0, 0],
|
|
||||||
[0, 1 + t * 0.05, 0],
|
|
||||||
[0, 0, 1 - t * 0.15],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 5: Saturation (with small CLAHE compensation boost)
|
||||||
if (toggles.saturation !== false) {
|
if (toggles.saturation !== false) {
|
||||||
const adj = corrections.saturation * presets.saturation * scale;
|
const adj = corrections.saturation * presets.saturation * scale;
|
||||||
if (Math.abs(adj) > 2) {
|
const claheCompensation = toggles.contrast !== false && intensity > 10 ? 0.05 : 0;
|
||||||
result = result.modulate({ saturation: 1 + adj / 100 });
|
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) {
|
if (toggles.sharpness !== false) {
|
||||||
const adj = corrections.sharpness * presets.sharpness * scale;
|
const adj = corrections.sharpness * presets.sharpness * scale;
|
||||||
if (adj > 2) {
|
if (adj > 2) {
|
||||||
const sigma = 0.5 + (adj / 100) * 4;
|
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) {
|
if (toggles.denoise !== false) {
|
||||||
const adj = corrections.denoise * presets.denoise * scale;
|
const adj = corrections.denoise * presets.denoise * scale;
|
||||||
if (adj >= 2) {
|
if (adj >= 2) {
|
||||||
|
|||||||
@@ -205,3 +205,79 @@ describe("applyCorrections", () => {
|
|||||||
expect(enhancedMeta.height).toBe(originalMeta.height);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user