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 outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||||
const analysis = await analyzeImage(inputBuffer);
|
const analysis = await analyzeImage(inputBuffer);
|
||||||
const meta = await sharp(inputBuffer).metadata();
|
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);
|
let image = sharp(inputBuffer);
|
||||||
|
if (hasAlpha) {
|
||||||
|
image = image.removeAlpha();
|
||||||
|
}
|
||||||
|
|
||||||
image = applyCorrections(
|
image = applyCorrections(
|
||||||
image,
|
image,
|
||||||
analysis.corrections,
|
analysis.corrections,
|
||||||
@@ -56,6 +66,13 @@ async function processImageEnhancement(
|
|||||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
|
if (alphaBuffer) {
|
||||||
|
buffer = await sharp(buffer)
|
||||||
|
.joinChannel(alphaBuffer)
|
||||||
|
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.deepEnhance && isToolInstalled("noise-removal")) {
|
if (settings.deepEnhance && isToolInstalled("noise-removal")) {
|
||||||
try {
|
try {
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
|
|||||||
@@ -226,10 +226,12 @@ export function applyCorrections(
|
|||||||
// maxSlope must be an integer (Sharp requirement); skip for tiny images
|
// maxSlope must be an integer (Sharp requirement); skip for tiny images
|
||||||
if (toggles.contrast !== false) {
|
if (toggles.contrast !== false) {
|
||||||
const maxSlope = clamp(Math.round(1.0 + (intensity / 100) * 4.0 * presets.clahe), 1, 10);
|
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 w = imageSize?.width ?? 64;
|
||||||
const tileSize = minDim >= 3 ? 3 : 1;
|
const h = imageSize?.height ?? 64;
|
||||||
if (maxSlope >= 2) {
|
const tileW = clamp(Math.round(w / 8), 8, 256);
|
||||||
result = result.clahe({ width: tileSize, height: tileSize, maxSlope });
|
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 ─────────────────────────────────────────
|
// ── Large file handling ─────────────────────────────────────────
|
||||||
describe("Large file handling", () => {
|
describe("Large file handling", () => {
|
||||||
it("enhances a large stress image", async () => {
|
it("enhances a large stress image", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user