mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user