From 7b1f09f5d0c7c443ecfdebbbdd35eaa5b1976f34 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 9 May 2026 15:20:55 +0800 Subject: [PATCH] fix: CLAHE tile size and alpha channel corruption in image enhancement CLAHE width/height is tile size in pixels, not tile count. A 3px tile on a 992x1088 image created ~330x360 independent histogram regions, producing crosshatch/etching artifacts. Now uses image_dimension/8 (clamped 8-256) for ~8 tiles per axis. Also strips alpha before enhancement and re-joins after to prevent CLAHE/normalise/linear from corrupting transparency. --- .../api/src/routes/tools/image-enhancement.ts | 17 ++++ .../src/operations/auto-enhance.ts | 10 ++- tests/integration/image-enhancement.test.ts | 77 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/tools/image-enhancement.ts b/apps/api/src/routes/tools/image-enhancement.ts index 47938851..2e72d6d8 100644 --- a/apps/api/src/routes/tools/image-enhancement.ts +++ b/apps/api/src/routes/tools/image-enhancement.ts @@ -41,8 +41,18 @@ async function processImageEnhancement( const outputFormat = await resolveOutputFormat(inputBuffer, filename); const analysis = await analyzeImage(inputBuffer); const meta = await sharp(inputBuffer).metadata(); + const hasAlpha = meta.hasAlpha === true; + + let alphaBuffer: Buffer | undefined; + if (hasAlpha) { + alphaBuffer = await sharp(inputBuffer).extractChannel(3).toBuffer(); + } let image = sharp(inputBuffer); + if (hasAlpha) { + image = image.removeAlpha(); + } + image = applyCorrections( image, analysis.corrections, @@ -56,6 +66,13 @@ async function processImageEnhancement( .toFormat(outputFormat.format, { quality: outputFormat.quality }) .toBuffer(); + if (alphaBuffer) { + buffer = await sharp(buffer) + .joinChannel(alphaBuffer) + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + } + if (settings.deepEnhance && isToolInstalled("noise-removal")) { try { const jobId = randomUUID(); diff --git a/packages/image-engine/src/operations/auto-enhance.ts b/packages/image-engine/src/operations/auto-enhance.ts index a58ad520..71980a53 100644 --- a/packages/image-engine/src/operations/auto-enhance.ts +++ b/packages/image-engine/src/operations/auto-enhance.ts @@ -226,10 +226,12 @@ export function applyCorrections( // 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 }); + const w = imageSize?.width ?? 64; + const h = imageSize?.height ?? 64; + const tileW = clamp(Math.round(w / 8), 8, 256); + const tileH = clamp(Math.round(h / 8), 8, 256); + if (maxSlope >= 2 && w >= tileW && h >= tileH) { + result = result.clahe({ width: tileW, height: tileH, maxSlope }); } } diff --git a/tests/integration/image-enhancement.test.ts b/tests/integration/image-enhancement.test.ts index 1611ae23..55909567 100644 --- a/tests/integration/image-enhancement.test.ts +++ b/tests/integration/image-enhancement.test.ts @@ -530,6 +530,83 @@ describe("HEIC input enhancement", () => { }); }); +// ── Alpha channel preservation ───────────────────────────────── +describe("Alpha channel preservation", () => { + it("preserves alpha channel without crosshatch corruption", async () => { + const rgbaBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 4, + background: { r: 80, g: 120, b: 60, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + + const res = await postTool( + { mode: "auto", intensity: 80 }, + rgbaBuffer, + "rgba.png", + "image/png", + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const { data, info } = await sharp(dlRes.rawPayload) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + for (let i = 3; i < data.length; i += 4) { + expect(data[i]).toBe(255); + } + }); + + it("preserves partial transparency in PNG", async () => { + const semiTransparent = await sharp({ + create: { + width: 50, + height: 50, + channels: 4, + background: { r: 100, g: 100, b: 100, alpha: 0.5 }, + }, + }) + .png() + .toBuffer(); + + const res = await postTool( + { mode: "auto", intensity: 50 }, + semiTransparent, + "semi.png", + "image/png", + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.channels).toBe(4); + + const { data, info } = await sharp(dlRes.rawPayload) + .raw() + .toBuffer({ resolveWithObject: true }); + const alphaValues = new Set(); + for (let i = 3; i < data.length; i += info.channels) { + alphaValues.add(data[i]); + } + expect(alphaValues.size).toBe(1); + }); +}); + // ── Large file handling ───────────────────────────────────────── describe("Large file handling", () => { it("enhances a large stress image", async () => {