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.
This commit is contained in:
SnapOtter
2026-05-09 15:20:55 +08:00
parent 7f131d99a6
commit 7b1f09f5d0
3 changed files with 100 additions and 4 deletions
@@ -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();
@@ -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 });
}
}
@@ -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<number>();
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 () => {