From 5434762acc3ece3bf1f5ba925e08badc34d26f77 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 9 May 2026 11:07:36 +0800 Subject: [PATCH] fix: recalibrate enhancement scoring with dead zones Contrast score now uses linear stdevLum/1.2 centered at 50 (was miscalibrated 25-75 range centered at 75). Corrections use dead zones (score 40-60 = zero) so well-exposed images get near-zero adjustments instead of being darkened. --- .../src/operations/auto-enhance.ts | 21 ++++++++---- tests/unit/auto-enhance.test.ts | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/image-engine/src/operations/auto-enhance.ts b/packages/image-engine/src/operations/auto-enhance.ts index 7eaa0727..4c71d0ab 100644 --- a/packages/image-engine/src/operations/auto-enhance.ts +++ b/packages/image-engine/src/operations/auto-enhance.ts @@ -119,9 +119,8 @@ function computeScores( ): AnalysisScores { const exposureScore = clamp(Math.round((meanLum / 255) * 100), 0, 100); - const idealStdev = 60; - const contrastDeviation = Math.abs(stdevLum - idealStdev) / idealStdev; - const contrastScore = clamp(Math.round((1 - contrastDeviation) * 50 + 25), 0, 100); + // Linear mapping: stdev ~60 = score 50. Higher stdev = higher contrast. + const contrastScore = clamp(Math.round(stdevLum / 1.2), 0, 100); const meanR = rCh.mean; const meanG = gCh.mean; @@ -146,9 +145,9 @@ function computeScores( } function computeCorrections(scores: AnalysisScores): CorrectionParams { - const brightness = clamp(Math.round((50 - scores.exposure) * 1.2), -60, 60); - const contrast = clamp(Math.round((50 - scores.contrast) * 0.8), -40, 40); - const temperature = clamp(Math.round((50 - scores.whiteBalance) * 0.5), -30, 30); + const brightness = deadZoneCorrection(scores.exposure, 40, 60, 0.8); + const contrast = deadZoneCorrection(scores.contrast, 40, 60, 0.6); + const temperature = deadZoneCorrection(scores.whiteBalance, 40, 60, 0.5); const saturation = scores.saturation < 40 @@ -165,6 +164,16 @@ function computeCorrections(scores: AnalysisScores): CorrectionParams { return { brightness, contrast, temperature, saturation, sharpness, denoise }; } +/** + * Scores inside [lo, hi] produce zero correction. + * Scores outside scale from the dead zone edge, not from 50. + */ +function deadZoneCorrection(score: number, lo: number, hi: number, factor: number): number { + if (score >= lo && score <= hi) return 0; + if (score < lo) return clamp(Math.round((lo - score) * factor), 0, 60); + return clamp(Math.round((hi - score) * factor), -60, 0); +} + function detectIssues(scores: AnalysisScores): string[] { const issues: string[] = []; if (scores.exposure < 35) issues.push("underexposed"); diff --git a/tests/unit/auto-enhance.test.ts b/tests/unit/auto-enhance.test.ts index 817b85e4..911d2458 100644 --- a/tests/unit/auto-enhance.test.ts +++ b/tests/unit/auto-enhance.test.ts @@ -84,6 +84,40 @@ describe("analyzeImage", () => { const result = await analyzeImage(darkBuffer); expect(result.suggestedMode).toBe("low-light"); }); + + it("produces contrast score near 50 for a typical well-exposed image", async () => { + // A gradient image has stdev ~60, which should score ~50 + const gradientBuffer = await sharp( + Buffer.from( + Array.from({ length: 100 * 100 * 3 }, (_, i) => Math.floor(((i % 300) * 255) / 300)), + ), + { raw: { width: 100, height: 100, channels: 3 } }, + ) + .png() + .toBuffer(); + + const result = await analyzeImage(gradientBuffer); + expect(result.scores.contrast).toBeGreaterThanOrEqual(35); + expect(result.scores.contrast).toBeLessThanOrEqual(65); + }); + + it("produces near-zero corrections for well-exposed images (dead zone)", async () => { + // Wide spread centered at 128 gives stdev ~60 (contrast ~50) and mean ~128 (exposure ~50). + // Both scores land inside the [40,60] dead zone, so corrections should be zero. + const midGrayBuffer = await sharp( + Buffer.from( + Array.from({ length: 100 * 100 * 3 }, (_, i) => 24 + Math.floor(Math.random() * 208)), + ), + { raw: { width: 100, height: 100, channels: 3 } }, + ) + .png() + .toBuffer(); + + const result = await analyzeImage(midGrayBuffer); + // Corrections should be zero or near-zero in the dead zone + expect(Math.abs(result.corrections.brightness)).toBeLessThanOrEqual(5); + expect(Math.abs(result.corrections.contrast)).toBeLessThanOrEqual(5); + }); }); describe("scaleCorrections", () => {