mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: replace AI matting with chroma-based checkerboard detection
The transparency-fixer now directly detects the baked-in checkerboard pattern using per-pixel chroma analysis instead of BiRefNet AI matting. Achromatic pixels in the gray range are classified as background (transparent), chromatic pixels as foreground (opaque), with smooth transitions at anti-aliased edges. - No longer requires Python sidecar or background-removal bundle - Watermark removal uses Sharp median(5) filter pre-processing - Moved tool from "ai" to "utilities" category - Removed from PYTHON_SIDECAR_TOOLS and background-removal enablesTools - Near-instant processing (pure Sharp, no model inference)
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { removeBackground } from "@snapotter/ai";
|
|
||||||
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
@@ -18,8 +15,11 @@ import { updateSingleFileProgress } from "../progress.js";
|
|||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const TOOL_ID = "transparency-fixer";
|
const TOOL_ID = "transparency-fixer";
|
||||||
const DEFAULT_MODEL = "birefnet-hr-matting";
|
|
||||||
const FALLBACK_MODEL = "birefnet-general";
|
const CHROMA_OPAQUE = 35;
|
||||||
|
const CHROMA_TRANSPARENT = 8;
|
||||||
|
const GRAY_LOW = 120;
|
||||||
|
const GRAY_HIGH = 230;
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
defringe: z.number().min(0).max(100).optional().default(30),
|
defringe: z.number().min(0).max(100).optional().default(30),
|
||||||
@@ -27,13 +27,6 @@ const settingsSchema = z.object({
|
|||||||
removeWatermark: z.boolean().optional().default(false),
|
removeWatermark: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Sharp-based defringe post-processing.
|
|
||||||
*
|
|
||||||
* Removes semi-transparent fringe pixels that rembg sometimes leaves around
|
|
||||||
* hair, fur, and fine edges. Works by blurring the alpha channel and zeroing
|
|
||||||
* out pixels whose alpha falls below a computed threshold.
|
|
||||||
*/
|
|
||||||
async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer> {
|
async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer> {
|
||||||
if (intensity <= 0) return buffer;
|
if (intensity <= 0) return buffer;
|
||||||
|
|
||||||
@@ -44,13 +37,11 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer>
|
|||||||
const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
|
const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
|
||||||
const pixelCount = info.width * info.height;
|
const pixelCount = info.width * info.height;
|
||||||
|
|
||||||
// Extract alpha channel
|
|
||||||
const alpha = Buffer.alloc(pixelCount);
|
const alpha = Buffer.alloc(pixelCount);
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
alpha[i] = data[i * 4 + 3];
|
alpha[i] = data[i * 4 + 3];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blur the alpha channel
|
|
||||||
const blurRadius = Math.max(0.3, Math.round(intensity / 20));
|
const blurRadius = Math.max(0.3, Math.round(intensity / 20));
|
||||||
const blurredAlphaRaw = await sharp(alpha, {
|
const blurredAlphaRaw = await sharp(alpha, {
|
||||||
raw: { width: info.width, height: info.height, channels: 1 },
|
raw: { width: info.width, height: info.height, channels: 1 },
|
||||||
@@ -59,7 +50,6 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer>
|
|||||||
.raw()
|
.raw()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
// Threshold: zero out fringe pixels
|
|
||||||
const threshold = Math.round(128 + (intensity / 100) * 80);
|
const threshold = Math.round(128 + (intensity / 100) * 80);
|
||||||
const result = Buffer.from(data);
|
const result = Buffer.from(data);
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
@@ -78,54 +68,79 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer>
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeWatermarkMedian(buffer: Buffer): Promise<Buffer> {
|
async function fixCheckerboardTransparency(buffer: Buffer): Promise<Buffer> {
|
||||||
return sharp(buffer).median(5).toBuffer();
|
const img = sharp(buffer).ensureAlpha();
|
||||||
|
const meta = await img.metadata();
|
||||||
|
if (!meta.width || !meta.height) return buffer;
|
||||||
|
|
||||||
|
const { width, height } = meta;
|
||||||
|
const rgba = await img.raw().toBuffer();
|
||||||
|
const flat = await sharp(buffer)
|
||||||
|
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const pixelCount = width * height;
|
||||||
|
const result = Buffer.alloc(pixelCount * 4);
|
||||||
|
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const r = flat[i * 3];
|
||||||
|
const g = flat[i * 3 + 1];
|
||||||
|
const b = flat[i * 3 + 2];
|
||||||
|
const maxC = Math.max(r, g, b);
|
||||||
|
const minC = Math.min(r, g, b);
|
||||||
|
const chroma = maxC - minC;
|
||||||
|
const gray = (r + g + b) / 3;
|
||||||
|
|
||||||
|
let a: number;
|
||||||
|
if (chroma >= CHROMA_OPAQUE) {
|
||||||
|
a = 255;
|
||||||
|
} else if (chroma <= CHROMA_TRANSPARENT && gray > GRAY_LOW && gray < GRAY_HIGH) {
|
||||||
|
a = 0;
|
||||||
|
} else {
|
||||||
|
a = Math.round(
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, (chroma - CHROMA_TRANSPARENT) / (CHROMA_OPAQUE - CHROMA_TRANSPARENT)),
|
||||||
|
) * 255,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i * 4] = rgba[i * 4];
|
||||||
|
result[i * 4 + 1] = rgba[i * 4 + 1];
|
||||||
|
result[i * 4 + 2] = rgba[i * 4 + 2];
|
||||||
|
result[i * 4 + 3] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharp(result, { raw: { width, height, channels: 4 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Run transparency fix: rembg matting -> defringe -> output format.
|
|
||||||
*/
|
|
||||||
async function processTransparencyFix(
|
async function processTransparencyFix(
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
settings: z.infer<typeof settingsSchema>,
|
settings: z.infer<typeof settingsSchema>,
|
||||||
outputDir: string,
|
_outputDir: string,
|
||||||
onProgress?: (percent: number, stage: string) => void,
|
onProgress?: (percent: number, stage: string) => void,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
let workingBuffer = inputBuffer;
|
let workingBuffer = inputBuffer;
|
||||||
|
|
||||||
if (settings.removeWatermark) {
|
if (settings.removeWatermark) {
|
||||||
onProgress?.(2, "Removing watermark...");
|
onProgress?.(5, "Removing watermark...");
|
||||||
workingBuffer = await removeWatermarkMedian(workingBuffer);
|
workingBuffer = await sharp(workingBuffer).median(5).toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
let resultBuffer: Buffer;
|
onProgress?.(20, "Detecting checkerboard...");
|
||||||
|
let resultBuffer = await fixCheckerboardTransparency(workingBuffer);
|
||||||
try {
|
|
||||||
resultBuffer = await removeBackground(
|
|
||||||
workingBuffer,
|
|
||||||
outputDir,
|
|
||||||
{ model: DEFAULT_MODEL },
|
|
||||||
onProgress,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const isOom = err instanceof Error && err.message.includes("out of memory");
|
|
||||||
if (!isOom) throw err;
|
|
||||||
|
|
||||||
onProgress?.(5, `Retrying with fallback model (${FALLBACK_MODEL})`);
|
|
||||||
resultBuffer = await removeBackground(
|
|
||||||
workingBuffer,
|
|
||||||
outputDir,
|
|
||||||
{ model: FALLBACK_MODEL },
|
|
||||||
onProgress,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
onProgress?.(70, "Cleaning edges...");
|
||||||
resultBuffer = await applyDefringe(resultBuffer, settings.defringe);
|
resultBuffer = await applyDefringe(resultBuffer, settings.defringe);
|
||||||
|
|
||||||
if (settings.outputFormat === "webp") {
|
if (settings.outputFormat === "webp") {
|
||||||
resultBuffer = await sharp(resultBuffer).webp({ lossless: true }).toBuffer();
|
resultBuffer = await sharp(resultBuffer).webp({ lossless: true }).toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.(100, "Done");
|
||||||
return resultBuffer;
|
return resultBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,17 +148,6 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/v1/tools/transparency-fixer",
|
"/api/v1/tools/transparency-fixer",
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (!isToolInstalled(TOOL_ID)) {
|
|
||||||
const bundle = getBundleForTool(TOOL_ID);
|
|
||||||
return reply.status(501).send({
|
|
||||||
error: "Feature not installed",
|
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
|
||||||
feature: TOOL_BUNDLE_MAP[TOOL_ID],
|
|
||||||
featureName: bundle?.name ?? TOOL_ID,
|
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
@@ -197,21 +201,18 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Decode HEIC/HEIF before processing
|
|
||||||
if (validation.format === "heif") {
|
if (validation.format === "heif") {
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
|
||||||
if (needsCliDecode(validation.format)) {
|
if (needsCliDecode(validation.format)) {
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
request.log.error({ err, toolId: TOOL_ID }, "Input decoding failed");
|
request.log.error({ err, toolId: TOOL_ID }, "Input decoding failed");
|
||||||
@@ -238,14 +239,9 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const log = request.log;
|
const log = request.log;
|
||||||
log.info(
|
log.info({ toolId: TOOL_ID, imageSize: originalSize }, "Starting transparency fix");
|
||||||
{ toolId: TOOL_ID, imageSize: originalSize, model: DEFAULT_MODEL },
|
|
||||||
"Starting transparency fix",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reply immediately so the HTTP connection closes within proxy timeout limits.
|
const outputExt = settings.outputFormat === "webp" ? "webp" : "png";
|
||||||
// The result will be delivered via the SSE progress channel.
|
|
||||||
reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
|
|
||||||
const onProgress = (percent: number, stage: string) => {
|
const onProgress = (percent: number, stage: string) => {
|
||||||
updateSingleFileProgress({
|
updateSingleFileProgress({
|
||||||
@@ -256,9 +252,9 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const outputExt = settings.outputFormat === "webp" ? "webp" : "png";
|
// Processing is fast (no AI model), but keep async pattern for consistency
|
||||||
|
reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
|
|
||||||
// Fire-and-forget: processing happens after the response is sent
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const resultBuffer = await processTransparencyFix(
|
const resultBuffer = await processTransparencyFix(
|
||||||
fileBuffer,
|
fileBuffer,
|
||||||
@@ -298,7 +294,6 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pipeline/batch registry
|
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
toolId: TOOL_ID,
|
toolId: TOOL_ID,
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
|
|||||||
@@ -250,18 +250,18 @@ export const TOOLS: Tool[] = [
|
|||||||
route: "/content-aware-resize",
|
route: "/content-aware-resize",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "content-aware-crop",
|
id: "ai-canvas-expand",
|
||||||
name: "Content-Aware Crop",
|
name: "AI Canvas Expand",
|
||||||
description: "Extend canvas beyond image bounds with AI-powered fill",
|
description: "Expand canvas beyond image bounds with AI-powered fill",
|
||||||
category: "ai",
|
category: "ai",
|
||||||
icon: "Expand",
|
icon: "Expand",
|
||||||
route: "/content-aware-crop",
|
route: "/ai-canvas-expand",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "transparency-fixer",
|
id: "transparency-fixer",
|
||||||
name: "PNG Transparency Fixer",
|
name: "PNG Transparency Fixer",
|
||||||
description: "Fix fake transparent PNGs in one click",
|
description: "Fix fake transparent PNGs in one click",
|
||||||
category: "ai",
|
category: "utilities",
|
||||||
icon: "ShieldCheck",
|
icon: "ShieldCheck",
|
||||||
route: "/transparency-fixer",
|
route: "/transparency-fixer",
|
||||||
},
|
},
|
||||||
@@ -1247,6 +1247,5 @@ export const PYTHON_SIDECAR_TOOLS = [
|
|||||||
"red-eye-removal",
|
"red-eye-removal",
|
||||||
"restore-photo",
|
"restore-photo",
|
||||||
"passport-photo",
|
"passport-photo",
|
||||||
"transparency-fixer",
|
"ai-canvas-expand",
|
||||||
"content-aware-crop",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
|
|||||||
name: "Background Removal",
|
name: "Background Removal",
|
||||||
description: "Remove image backgrounds with AI",
|
description: "Remove image backgrounds with AI",
|
||||||
estimatedSize: "4-5 GB",
|
estimatedSize: "4-5 GB",
|
||||||
enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
|
enablesTools: ["remove-background", "passport-photo"],
|
||||||
},
|
},
|
||||||
"face-detection": {
|
"face-detection": {
|
||||||
id: "face-detection",
|
id: "face-detection",
|
||||||
@@ -40,7 +40,7 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
|
|||||||
name: "Object Eraser & Colorize",
|
name: "Object Eraser & Colorize",
|
||||||
description: "Erase objects from photos and colorize B&W images",
|
description: "Erase objects from photos and colorize B&W images",
|
||||||
estimatedSize: "1-2 GB",
|
estimatedSize: "1-2 GB",
|
||||||
enablesTools: ["erase-object", "colorize", "content-aware-crop"],
|
enablesTools: ["erase-object", "colorize", "ai-canvas-expand"],
|
||||||
},
|
},
|
||||||
"upscale-enhance": {
|
"upscale-enhance": {
|
||||||
id: "upscale-enhance",
|
id: "upscale-enhance",
|
||||||
|
|||||||
Reference in New Issue
Block a user