feat(erase-object): replace mask upload with in-browser brush painting

Replace the external mask file upload workflow with an interactive
canvas-based brush tool. Users now paint directly on the image to
mark areas for erasure. Adds EraserCanvas component with adjustable
brush size, undo/clear, and mask export. Switch Python inpainting
from broken lama-cleaner to OpenCV cv2.inpaint (Telea algorithm).
Add before/after comparison slider after processing.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 17:33:17 +08:00
parent 563892774d
commit 40656a0452
4 changed files with 421 additions and 87 deletions
@@ -1,39 +1,47 @@
import { Download, Upload } from "lucide-react"; import { Download, Redo, Trash2 } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card"; import { ProgressCard } from "@/components/common/progress-card";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import type { EraserCanvasRef } from "./eraser-canvas";
function getToken(): string { function getToken(): string {
return localStorage.getItem("stirling-token") || ""; return localStorage.getItem("stirling-token") || "";
} }
export function EraseObjectSettings() { interface EraseObjectSettingsProps {
const { files, processing, error, setProcessing, setError } = useFileStore(); eraserRef: React.RefObject<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
}
const [maskFile, setMaskFile] = useState<File | null>(null); export function EraseObjectSettings({
eraserRef,
hasStrokes,
brushSize,
onBrushSizeChange: setBrushSize,
}: EraseObjectSettingsProps) {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes } =
useFileStore();
const [downloadUrl, setDownloadUrl] = useState<string | null>(null); const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number | null>(null); const [originalSize, setOriginalSize] = useState<number | null>(null);
const [processedSize, setProcessedSize] = useState<number | null>(null); const [processedSize, setProcessedSize] = useState<number | null>(null);
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle"); const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
const [progressPercent, setProgressPercent] = useState(0); const [progressPercent, setProgressPercent] = useState(0);
const [progressStage, setProgressStage] = useState<string | undefined>();
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null); const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const handleMaskSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) setMaskFile(selected);
};
const handleProcess = async () => { const handleProcess = async () => {
if (files.length === 0 || !maskFile) return; if (files.length === 0 || !eraserRef.current) return;
const maskBlob = await eraserRef.current.exportMask();
if (!maskBlob) return;
setError(null); setError(null);
setDownloadUrl(null); setDownloadUrl(null);
setProcessing(true); setProcessing(true);
setProgressPhase("uploading"); setProgressPhase("uploading");
setProgressPercent(0); setProgressPercent(0);
setProgressStage(undefined);
setElapsed(0); setElapsed(0);
const startTime = Date.now(); const startTime = Date.now();
@@ -43,7 +51,6 @@ export function EraseObjectSettings() {
const clientJobId = crypto.randomUUID(); const clientJobId = crypto.randomUUID();
// Open SSE for server-side progress
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
@@ -51,12 +58,13 @@ export function EraseObjectSettings() {
if (data.type === "single" && typeof data.percent === "number") { if (data.type === "single" && typeof data.percent === "number") {
setProgressPhase("processing"); setProgressPhase("processing");
setProgressPercent(15 + (data.percent / 100) * 85); setProgressPercent(15 + (data.percent / 100) * 85);
setProgressStage(data.stage);
} }
} catch {} } catch {}
}; };
es.onerror = () => es.close(); es.onerror = () => es.close();
const maskFile = new File([maskBlob], "mask.png", { type: "image/png" });
const formData = new FormData(); const formData = new FormData();
formData.append("file", files[0]); formData.append("file", files[0]);
formData.append("mask", maskFile); formData.append("mask", maskFile);
@@ -71,7 +79,6 @@ export function EraseObjectSettings() {
xhr.upload.onload = () => { xhr.upload.onload = () => {
setProgressPhase("processing"); setProgressPhase("processing");
setProgressPercent(15); setProgressPercent(15);
setProgressStage("Starting...");
}; };
xhr.onload = () => { xhr.onload = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current); if (elapsedRef.current) clearInterval(elapsedRef.current);
@@ -82,6 +89,8 @@ export function EraseObjectSettings() {
setDownloadUrl(data.downloadUrl); setDownloadUrl(data.downloadUrl);
setOriginalSize(data.originalSize); setOriginalSize(data.originalSize);
setProcessedSize(data.processedSize); setProcessedSize(data.processedSize);
setProcessedUrl(data.downloadUrl);
setSizes(data.originalSize, data.processedSize);
} catch { } catch {
setError("Invalid response"); setError("Invalid response");
} }
@@ -112,43 +121,57 @@ export function EraseObjectSettings() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Mask upload */} {/* Brush size */}
<div> <div>
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground"> <div className="flex justify-between items-center">
Mask Image <label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
Brush Size
</label> </label>
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5"> <span className="text-xs font-mono text-foreground">{brushSize}px</span>
Upload a black &amp; white mask where white areas will be erased. Create the mask in any </div>
image editor.
</p>
<label
htmlFor="erase-object-mask"
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
>
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{maskFile ? maskFile.name : "Select mask image..."}
</span>
<input <input
id="erase-object-mask" id="eraser-brush-size"
type="file" type="range"
accept="image/*" min={5}
onChange={handleMaskSelect} max={100}
className="hidden" value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-full mt-1"
/> />
</label> <div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Fine</span>
<span>Wide</span>
</div>
</div> </div>
{/* Info */} {/* Clear / Undo */}
<div className="p-2 rounded bg-muted text-[10px] text-muted-foreground space-y-1"> {hasStrokes && (
<p>How to create a mask:</p> <div className="flex gap-2">
<ol className="list-decimal list-inside space-y-0.5"> <button
<li>Open your image in any editor</li> type="button"
<li>Paint white over areas to erase</li> onClick={() => eraserRef.current?.undo()}
<li>Keep the rest black</li> className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 text-xs"
<li>Export as PNG and upload here</li> >
</ol> <Redo className="h-3.5 w-3.5" />
Undo
</button>
<button
type="button"
onClick={() => eraserRef.current?.clear()}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 text-xs"
>
<Trash2 className="h-3.5 w-3.5" />
Clear
</button>
</div> </div>
)}
{/* Hint */}
{hasFile && !hasStrokes && (
<p className="text-[10px] text-muted-foreground">
Paint over the objects you want to remove on the image.
</p>
)}
{/* Error */} {/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
@@ -167,7 +190,6 @@ export function EraseObjectSettings() {
active={processing} active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase} phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Erasing object" label="Erasing object"
stage={progressStage}
percent={progressPercent} percent={progressPercent}
elapsed={elapsed} elapsed={elapsed}
/> />
@@ -175,7 +197,7 @@ export function EraseObjectSettings() {
<button <button
type="button" type="button"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !maskFile || processing} disabled={!hasFile || !hasStrokes || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
Erase Object Erase Object
@@ -0,0 +1,268 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
type Point = { x: number; y: number };
type Stroke = { points: Point[]; size: number };
export interface EraserCanvasRef {
exportMask: () => Promise<Blob | null>;
clear: () => void;
undo: () => void;
}
interface EraserCanvasProps {
imageSrc: string;
brushSize: number;
onStrokeChange: (hasStrokes: boolean) => void;
}
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
{ imageSrc, brushSize, onStrokeChange },
ref,
) {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [canvasSize, setCanvasSize] = useState<{ w: number; h: number } | null>(null);
const naturalRef = useRef({ w: 0, h: 0 });
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef(false);
const currentPointsRef = useRef<Point[]>([]);
// Measure and fit image to container
const measure = useCallback(() => {
const img = imgRef.current;
const wrapper = wrapperRef.current;
if (!img || !wrapper || !img.naturalWidth) return;
naturalRef.current = { w: img.naturalWidth, h: img.naturalHeight };
const scale = Math.min(
wrapper.clientWidth / img.naturalWidth,
wrapper.clientHeight / img.naturalHeight,
);
setCanvasSize({
w: Math.floor(img.naturalWidth * scale),
h: Math.floor(img.naturalHeight * scale),
});
}, []);
// Reset strokes when image changes
useEffect(() => {
strokesRef.current = [];
currentPointsRef.current = [];
onStrokeChange(false);
setCanvasSize(null);
}, [imageSrc, onStrokeChange]);
// Redraw all strokes
const redraw = useCallback(() => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx || !canvasSize) return;
ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
for (const stroke of strokesRef.current) {
drawStroke(ctx, stroke);
}
}, [canvasSize]);
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (stroke.points.length === 1) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
ctx.arc(stroke.points[0].x, stroke.points[0].y, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.strokeStyle = "rgba(255, 60, 60, 0.4)";
ctx.lineWidth = stroke.size;
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
}
// Get canvas-relative point from event
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const raw = "touches" in e ? e.touches[0] : e;
if (!raw) return null;
return { x: raw.clientX - rect.left, y: raw.clientY - rect.top };
}, []);
const handleDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if ("touches" in e) e.preventDefault();
const pt = getPoint(e);
if (!pt) return;
drawingRef.current = true;
currentPointsRef.current = [pt];
// Immediate dot
const ctx = canvasRef.current?.getContext("2d");
if (ctx) {
ctx.beginPath();
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
ctx.arc(pt.x, pt.y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
}
},
[getPoint, brushSize],
);
const handleMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!drawingRef.current) return;
if ("touches" in e) e.preventDefault();
const pt = getPoint(e);
if (!pt) return;
currentPointsRef.current.push(pt);
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const pts = currentPointsRef.current;
if (pts.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = "rgba(255, 60, 60, 0.4)";
ctx.lineWidth = brushSize;
ctx.lineCap = "round";
ctx.moveTo(pts[pts.length - 2].x, pts[pts.length - 2].y);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
},
[getPoint, brushSize],
);
const handleUp = useCallback(() => {
if (!drawingRef.current) return;
drawingRef.current = false;
if (currentPointsRef.current.length > 0) {
strokesRef.current.push({
points: [...currentPointsRef.current],
size: brushSize,
});
currentPointsRef.current = [];
onStrokeChange(true);
redraw();
}
}, [brushSize, onStrokeChange, redraw]);
// Expose methods
useImperativeHandle(
ref,
() => ({
exportMask: async () => {
const nat = naturalRef.current;
if (!nat.w || !canvasSize || strokesRef.current.length === 0) return null;
const mask = document.createElement("canvas");
mask.width = nat.w;
mask.height = nat.h;
const ctx = mask.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = "black";
ctx.fillRect(0, 0, nat.w, nat.h);
const sx = nat.w / canvasSize.w;
const sy = nat.h / canvasSize.h;
ctx.fillStyle = "white";
ctx.strokeStyle = "white";
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const stroke of strokesRef.current) {
const scaledSize = stroke.size * Math.max(sx, sy);
if (stroke.points.length === 1) {
ctx.beginPath();
ctx.arc(
stroke.points[0].x * sx,
stroke.points[0].y * sy,
scaledSize / 2,
0,
Math.PI * 2,
);
ctx.fill();
} else {
ctx.beginPath();
ctx.lineWidth = scaledSize;
ctx.moveTo(stroke.points[0].x * sx, stroke.points[0].y * sy);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x * sx, stroke.points[i].y * sy);
}
ctx.stroke();
}
}
return new Promise<Blob | null>((resolve) => {
mask.toBlob((b) => resolve(b), "image/png");
});
},
clear: () => {
strokesRef.current = [];
currentPointsRef.current = [];
onStrokeChange(false);
redraw();
},
undo: () => {
strokesRef.current.pop();
onStrokeChange(strokesRef.current.length > 0);
redraw();
},
}),
[canvasSize, onStrokeChange, redraw],
);
return (
<div ref={wrapperRef} className="relative flex items-center justify-center w-full h-full">
{/* Hidden img for measuring natural size before canvas is ready */}
{!canvasSize && (
<img
ref={imgRef}
src={imageSrc}
onLoad={measure}
alt=""
className="max-w-full max-h-full object-contain"
/>
)}
{canvasSize && (
<div className="relative" style={{ width: canvasSize.w, height: canvasSize.h }}>
<img
ref={imgRef}
src={imageSrc}
alt="Paint over objects to erase"
className="block"
style={{ width: canvasSize.w, height: canvasSize.h }}
/>
<canvas
ref={canvasRef}
width={canvasSize.w}
height={canvasSize.h}
className="absolute inset-0 cursor-crosshair touch-none"
onMouseDown={handleDown}
onMouseMove={handleMove}
onMouseUp={handleUp}
onMouseLeave={handleUp}
onTouchStart={handleDown}
onTouchMove={handleMove}
onTouchEnd={handleUp}
/>
</div>
)}
</div>
);
});
+58 -3
View File
@@ -1,7 +1,7 @@
import { TOOLS } from "@stirling-image/shared"; import { TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react"; import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Crop } from "react-image-crop"; import type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BeforeAfterSlider } from "@/components/common/before-after-slider";
@@ -27,6 +27,8 @@ import { ConvertSettings } from "@/components/tools/convert-settings";
import { CropCanvas } from "@/components/tools/crop-canvas"; import { CropCanvas } from "@/components/tools/crop-canvas";
import { CropSettings } from "@/components/tools/crop-settings"; import { CropSettings } from "@/components/tools/crop-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings"; import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import { EraserCanvas } from "@/components/tools/eraser-canvas";
import { FaviconSettings } from "@/components/tools/favicon-settings"; import { FaviconSettings } from "@/components/tools/favicon-settings";
import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings"; import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings";
import { GifToolsSettings } from "@/components/tools/gif-tools-settings"; import { GifToolsSettings } from "@/components/tools/gif-tools-settings";
@@ -66,7 +68,7 @@ const COLOR_TOOL_IDS = new Set([
// Tools that don't need a file dropzone (they generate content or have custom UI) // Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]); const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate"]); const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate", "erase-object"]);
const LIVE_PREVIEW_TOOLS = new Set([ const LIVE_PREVIEW_TOOLS = new Set([
"rotate", "rotate",
"brightness-contrast", "brightness-contrast",
@@ -76,12 +78,14 @@ const LIVE_PREVIEW_TOOLS = new Set([
]); ]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]); const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]); const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
const INTERACTIVE_ERASER_TOOLS = new Set(["erase-object"]);
function ToolSettingsPanel({ function ToolSettingsPanel({
toolId, toolId,
onPreviewTransform, onPreviewTransform,
onPreviewFilter, onPreviewFilter,
cropProps, cropProps,
eraserProps,
}: { }: {
toolId: string; toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void; onPreviewTransform?: (t: PreviewTransform) => void;
@@ -97,6 +101,12 @@ function ToolSettingsPanel({
onAspectChange: (aspect: number | undefined) => void; onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void; onGridToggle: (show: boolean) => void;
}; };
eraserProps?: {
eraserRef: React.RefObject<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
};
}) { }) {
// Phase 2: Core tools // Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />; if (toolId === "resize") return <ResizeSettings />;
@@ -138,7 +148,7 @@ function ToolSettingsPanel({
if (toolId === "upscale") return <UpscaleSettings />; if (toolId === "upscale") return <UpscaleSettings />;
if (toolId === "ocr") return <OcrSettings />; if (toolId === "ocr") return <OcrSettings />;
if (toolId === "blur-faces") return <BlurFacesSettings />; if (toolId === "blur-faces") return <BlurFacesSettings />;
if (toolId === "erase-object") return <EraseObjectSettings />; if (toolId === "erase-object" && eraserProps) return <EraseObjectSettings {...eraserProps} />;
if (toolId === "smart-crop") return <SmartCropSettings />; if (toolId === "smart-crop") return <SmartCropSettings />;
return ( return (
@@ -264,6 +274,11 @@ export function ToolPage() {
[cropCrop, cropAspect, cropShowGrid, cropImgDimensions], [cropCrop, cropAspect, cropShowGrid, cropImgDimensions],
); );
// Eraser state
const eraserRef = useRef<EraserCanvasRef | null>(null);
const [eraserHasStrokes, setEraserHasStrokes] = useState(false);
const [eraserBrushSize, setEraserBrushSize] = useState(30);
// Reset crop state when the image changes // Reset crop state when the image changes
useEffect(() => { useEffect(() => {
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 }); setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
@@ -385,6 +400,16 @@ export function ToolPage() {
} }
: undefined : undefined
} }
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/> />
</div> </div>
@@ -438,6 +463,16 @@ export function ToolPage() {
onCropChange={setCropCrop} onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions} onImageLoad={setCropImgDimensions}
/> />
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison <SideBySideComparison
beforeSrc={originalBlobUrl} beforeSrc={originalBlobUrl}
@@ -558,6 +593,16 @@ export function ToolPage() {
} }
: undefined : undefined
} }
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/> />
</div> </div>
@@ -625,6 +670,16 @@ export function ToolPage() {
onCropChange={setCropCrop} onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions} onImageLoad={setCropImgDimensions}
/> />
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison <SideBySideComparison
beforeSrc={originalBlobUrl} beforeSrc={originalBlobUrl}
+22 -33
View File
@@ -1,4 +1,4 @@
"""Object erasing / inpainting using LaMa or simple fallback.""" """Object erasing / inpainting using OpenCV."""
import sys import sys
import json import json
@@ -14,62 +14,51 @@ def main():
output_path = sys.argv[3] output_path = sys.argv[3]
try: try:
emit_progress(10, "Loading inpainting model") emit_progress(10, "Preparing")
from PIL import Image from PIL import Image
try: try:
# Try lama-cleaner if available import cv2
from lama_cleaner.model_manager import ModelManager import numpy as np
from lama_cleaner.schema import Config
emit_progress(20, "Model loaded") emit_progress(20, "Ready")
img = Image.open(input_path).convert("RGB") img = Image.open(input_path).convert("RGB")
mask = Image.open(mask_path).convert("L") mask = Image.open(mask_path).convert("L")
# Resize mask to match image if needed # Resize mask to match image if needed
emit_progress(25, "Analyzing mask") emit_progress(30, "Analyzing mask")
if mask.size != img.size: if mask.size != img.size:
mask = mask.resize(img.size, Image.NEAREST) mask = mask.resize(img.size, Image.NEAREST)
import numpy as np img_array = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
img_array = np.array(img)
mask_array = np.array(mask) mask_array = np.array(mask)
model_manager = ModelManager(name="lama", device="cpu") # Threshold mask to binary (ensure clean white/black)
config = Config( _, mask_binary = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY)
ldm_steps=25,
ldm_sampler="plms", # Inpaint radius scales with image size for better results
hd_strategy="Original", inpaint_radius = max(3, min(img_array.shape[0], img_array.shape[1]) // 200)
hd_strategy_crop_margin=128,
hd_strategy_crop_trigger_size=800, emit_progress(50, "Erasing")
hd_strategy_resize_limit=800, result = cv2.inpaint(img_array, mask_binary, inpaint_radius, cv2.INPAINT_TELEA)
)
emit_progress(40, "Inpainting region") emit_progress(90, "Saving")
result = model_manager(img_array, mask_array, config) result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
emit_progress(85, "Refining edges") Image.fromarray(result_rgb).save(output_path)
emit_progress(95, "Saving result")
Image.fromarray(result).save(output_path) print(json.dumps({"success": True, "method": "opencv-telea"}))
method = "lama"
except ImportError: except ImportError:
# LaMa not available — report error instead of silently copying
print( print(
json.dumps( json.dumps(
{ {
"success": False, "success": False,
"error": "Object eraser requires the lama-cleaner package. Install with: pip install lama-cleaner", "error": "Object eraser requires OpenCV. Install with: pip install opencv-python-headless",
} }
) )
) )
sys.exit(1) sys.exit(1)
except Exception as e:
# LaMa installed but processing failed — still report error
print(json.dumps({"success": False, "error": f"Inpainting failed: {str(e)}"}))
sys.exit(1)
print(json.dumps({"success": True, "method": method}))
except ImportError: except ImportError:
print( print(