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.
This commit is contained in:
SnapOtter
2026-05-09 11:07:36 +08:00
parent 346abd6168
commit 5434762acc
2 changed files with 49 additions and 6 deletions
@@ -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");
+34
View File
@@ -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", () => {