feat(erase-object): overhaul object eraser with LaMa inpainting improvements

Update erase-object pipeline, eraser canvas, and inpainting Python script.
Add LaMa model download script and update Dockerfile for model support.
Update multi-file tool routes for consistency.
This commit is contained in:
Siddharth Kumar Sah
2026-04-13 00:48:05 +08:00
parent 92d4d2d9c6
commit 0a506efe24
17 changed files with 405 additions and 95 deletions
+4
View File
@@ -13,6 +13,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
@@ -107,6 +108,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
}
// Normalize EXIF orientation before passing to pipeline steps
fileBuffer = await autoOrient(fileBuffer);
// Parse and validate the pipeline definition
if (!pipelineRaw) {
return reply.status(400).send({ error: "No pipeline definition provided" });
+2 -1
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -65,7 +66,7 @@ export function registerCollage(app: FastifyInstance) {
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
file.buffer = await ensureSharpCompat(file.buffer);
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
}
let settings: z.infer<typeof settingsSchema>;
+4 -3
View File
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -42,9 +43,9 @@ export function registerCompare(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
bufferA = await ensureSharpCompat(bufferA);
bufferB = await ensureSharpCompat(bufferB);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
bufferA = await autoOrient(await ensureSharpCompat(bufferA));
bufferB = await autoOrient(await ensureSharpCompat(bufferB));
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
+4 -3
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -81,9 +82,9 @@ export function registerCompose(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
baseBuffer = await ensureSharpCompat(baseBuffer);
overlayBuffer = await ensureSharpCompat(overlayBuffer);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
baseBuffer = await autoOrient(await ensureSharpCompat(baseBuffer));
overlayBuffer = await autoOrient(await ensureSharpCompat(overlayBuffer));
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
+91 -5
View File
@@ -3,13 +3,30 @@ import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { inpaint } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
/**
* Object eraser / inpainting route.
* Accepts an image and a mask image, erases masked areas.
* Accepts an image and a mask image, erases masked areas using LaMa.
*/
export function registerEraseObject(app: FastifyInstance) {
app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -17,6 +34,8 @@ export function registerEraseObject(app: FastifyInstance) {
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | null = null;
let format = "png";
let quality = 95;
try {
const parts = request.parts();
@@ -35,6 +54,10 @@ export function registerEraseObject(app: FastifyInstance) {
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
quality = Number(part.value) || 95;
}
}
} catch (err) {
@@ -64,9 +87,23 @@ export function registerEraseObject(app: FastifyInstance) {
try {
request.log.info(
{ toolId: "erase-object", imageSize: imageBuffer.length, maskSize: maskBuffer.length },
{
toolId: "erase-object",
imageSize: imageBuffer.length,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Decode HEIC/HEIF input via system decoder
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
}
// Auto-orient to fix EXIF rotation
imageBuffer = await autoOrient(imageBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
@@ -94,10 +131,58 @@ export function registerEraseObject(app: FastifyInstance) {
onProgress,
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
}
} else if (format === "jpg" || format === "jpeg") {
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
finalFormat = "jpg";
} else if (format === "webp") {
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
finalFormat = "webp";
} else if (format === "tiff") {
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
finalFormat = "tiff";
} else if (format === "gif") {
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
finalFormat = "gif";
} else {
outputBuffer = resultBuffer;
finalFormat = "png";
}
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.png`;
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
try {
const previewInput =
finalFormat === "heic" || finalFormat === "heif"
? await decodeHeic(outputBuffer)
: outputBuffer;
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show fallback
}
}
if (clientJobId) {
updateSingleFileProgress({
@@ -110,8 +195,9 @@ export function registerEraseObject(app: FastifyInstance) {
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: imageBuffer.length,
processedSize: resultBuffer.length,
processedSize: outputBuffer.length,
});
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Object erasing failed");
+3 -2
View File
@@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const FAVICON_SIZES = [
@@ -62,8 +63,8 @@ export function registerFavicon(app: FastifyInstance) {
archive.pipe(reply.raw);
for (const file of uploadedFiles) {
// Decode HEIC/HEIF if needed
const decoded = await ensureSharpCompat(file.buffer);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
const decoded = await autoOrient(await ensureSharpCompat(file.buffer));
const stem = basename(file.filename, extname(file.filename));
// Single file: flat structure. Multiple files: per-image folders.
const prefix = isSingleFile ? "" : `${stem}/`;
+3 -2
View File
@@ -1,6 +1,7 @@
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
@@ -67,9 +68,9 @@ export function registerFindDuplicates(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
for (const file of files) {
file.buffer = await ensureSharpCompat(file.buffer);
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
}
// Compute hashes for all images
+3 -2
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -96,8 +97,8 @@ export function registerImageToPdf(app: FastifyInstance) {
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Decode HEIC/HEIF if needed, then convert to PNG for PDFKit compatibility
const compatBuffer = await ensureSharpCompat(file.buffer);
// Decode HEIC/HEIF if needed, normalize EXIF orientation, then convert to PNG for PDFKit
const compatBuffer = await autoOrient(await ensureSharpCompat(file.buffer));
const pngBuffer = await sharp(compatBuffer).png().toBuffer();
const meta = await sharp(pngBuffer).metadata();
+3 -2
View File
@@ -4,6 +4,7 @@ import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -58,8 +59,8 @@ export function registerSplit(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer));
const metadata = await sharp(fileBuffer).metadata();
const fullW = metadata.width ?? 0;
+2 -1
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -72,7 +73,7 @@ export function registerStitch(app: FastifyInstance) {
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
file.buffer = await ensureSharpCompat(file.buffer);
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
}
let settings: z.infer<typeof settingsSchema>;
+3 -2
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -79,8 +80,8 @@ export function registerVectorize(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer));
// Convert to BMP-compatible format for potrace (PNG)
const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer();
+4 -3
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -68,9 +69,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
mainBuffer = await ensureSharpCompat(mainBuffer);
watermarkBuffer = await ensureSharpCompat(watermarkBuffer);
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
mainBuffer = await autoOrient(await ensureSharpCompat(mainBuffer));
watermarkBuffer = await autoOrient(await ensureSharpCompat(watermarkBuffer));
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();