mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -1,39 +1,47 @@
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { Download, Redo, Trash2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { EraserCanvasRef } from "./eraser-canvas";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function EraseObjectSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
interface EraseObjectSettingsProps {
|
||||
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 [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [progressStage, setProgressStage] = useState<string | undefined>();
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
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 () => {
|
||||
if (files.length === 0 || !maskFile) return;
|
||||
if (files.length === 0 || !eraserRef.current) return;
|
||||
|
||||
const maskBlob = await eraserRef.current.exportMask();
|
||||
if (!maskBlob) return;
|
||||
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
setProcessing(true);
|
||||
setProgressPhase("uploading");
|
||||
setProgressPercent(0);
|
||||
setProgressStage(undefined);
|
||||
setElapsed(0);
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -43,7 +51,6 @@ export function EraseObjectSettings() {
|
||||
|
||||
const clientJobId = crypto.randomUUID();
|
||||
|
||||
// Open SSE for server-side progress
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
@@ -51,12 +58,13 @@ export function EraseObjectSettings() {
|
||||
if (data.type === "single" && typeof data.percent === "number") {
|
||||
setProgressPhase("processing");
|
||||
setProgressPercent(15 + (data.percent / 100) * 85);
|
||||
setProgressStage(data.stage);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
|
||||
const maskFile = new File([maskBlob], "mask.png", { type: "image/png" });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("mask", maskFile);
|
||||
@@ -71,7 +79,6 @@ export function EraseObjectSettings() {
|
||||
xhr.upload.onload = () => {
|
||||
setProgressPhase("processing");
|
||||
setProgressPercent(15);
|
||||
setProgressStage("Starting...");
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
@@ -82,6 +89,8 @@ export function EraseObjectSettings() {
|
||||
setDownloadUrl(data.downloadUrl);
|
||||
setOriginalSize(data.originalSize);
|
||||
setProcessedSize(data.processedSize);
|
||||
setProcessedUrl(data.downloadUrl);
|
||||
setSizes(data.originalSize, data.processedSize);
|
||||
} catch {
|
||||
setError("Invalid response");
|
||||
}
|
||||
@@ -112,43 +121,57 @@ export function EraseObjectSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mask upload */}
|
||||
{/* Brush size */}
|
||||
<div>
|
||||
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground">
|
||||
Mask Image
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
|
||||
Upload a black & white mask where white areas will be erased. Create the mask in any
|
||||
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
|
||||
id="erase-object-mask"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleMaskSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
|
||||
Brush Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="eraser-brush-size"
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Fine</span>
|
||||
<span>Wide</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-2 rounded bg-muted text-[10px] text-muted-foreground space-y-1">
|
||||
<p>How to create a mask:</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
<li>Open your image in any editor</li>
|
||||
<li>Paint white over areas to erase</li>
|
||||
<li>Keep the rest black</li>
|
||||
<li>Export as PNG and upload here</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/* Clear / Undo */}
|
||||
{hasStrokes && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => eraserRef.current?.undo()}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{hasFile && !hasStrokes && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Paint over the objects you want to remove on the image.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -167,7 +190,6 @@ export function EraseObjectSettings() {
|
||||
active={processing}
|
||||
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
||||
label="Erasing object"
|
||||
stage={progressStage}
|
||||
percent={progressPercent}
|
||||
elapsed={elapsed}
|
||||
/>
|
||||
@@ -175,7 +197,7 @@ export function EraseObjectSettings() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import * as icons 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 { useParams } from "react-router-dom";
|
||||
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 { CropSettings } from "@/components/tools/crop-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 { FindDuplicatesSettings } from "@/components/tools/find-duplicates-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)
|
||||
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([
|
||||
"rotate",
|
||||
"brightness-contrast",
|
||||
@@ -76,12 +78,14 @@ const LIVE_PREVIEW_TOOLS = new Set([
|
||||
]);
|
||||
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
|
||||
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
|
||||
const INTERACTIVE_ERASER_TOOLS = new Set(["erase-object"]);
|
||||
|
||||
function ToolSettingsPanel({
|
||||
toolId,
|
||||
onPreviewTransform,
|
||||
onPreviewFilter,
|
||||
cropProps,
|
||||
eraserProps,
|
||||
}: {
|
||||
toolId: string;
|
||||
onPreviewTransform?: (t: PreviewTransform) => void;
|
||||
@@ -97,6 +101,12 @@ function ToolSettingsPanel({
|
||||
onAspectChange: (aspect: number | undefined) => void;
|
||||
onGridToggle: (show: boolean) => void;
|
||||
};
|
||||
eraserProps?: {
|
||||
eraserRef: React.RefObject<EraserCanvasRef | null>;
|
||||
hasStrokes: boolean;
|
||||
brushSize: number;
|
||||
onBrushSizeChange: (size: number) => void;
|
||||
};
|
||||
}) {
|
||||
// Phase 2: Core tools
|
||||
if (toolId === "resize") return <ResizeSettings />;
|
||||
@@ -138,7 +148,7 @@ function ToolSettingsPanel({
|
||||
if (toolId === "upscale") return <UpscaleSettings />;
|
||||
if (toolId === "ocr") return <OcrSettings />;
|
||||
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 />;
|
||||
|
||||
return (
|
||||
@@ -264,6 +274,11 @@ export function ToolPage() {
|
||||
[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
|
||||
useEffect(() => {
|
||||
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
|
||||
@@ -385,6 +400,16 @@ export function ToolPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
eraserProps={
|
||||
INTERACTIVE_ERASER_TOOLS.has(tool.id)
|
||||
? {
|
||||
eraserRef,
|
||||
hasStrokes: eraserHasStrokes,
|
||||
brushSize: eraserBrushSize,
|
||||
onBrushSizeChange: setEraserBrushSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -438,6 +463,16 @@ export function ToolPage() {
|
||||
onCropChange={setCropCrop}
|
||||
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) ? (
|
||||
<SideBySideComparison
|
||||
beforeSrc={originalBlobUrl}
|
||||
@@ -558,6 +593,16 @@ export function ToolPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
eraserProps={
|
||||
INTERACTIVE_ERASER_TOOLS.has(tool.id)
|
||||
? {
|
||||
eraserRef,
|
||||
hasStrokes: eraserHasStrokes,
|
||||
brushSize: eraserBrushSize,
|
||||
onBrushSizeChange: setEraserBrushSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -625,6 +670,16 @@ export function ToolPage() {
|
||||
onCropChange={setCropCrop}
|
||||
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) ? (
|
||||
<SideBySideComparison
|
||||
beforeSrc={originalBlobUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Object erasing / inpainting using LaMa or simple fallback."""
|
||||
"""Object erasing / inpainting using OpenCV."""
|
||||
import sys
|
||||
import json
|
||||
|
||||
@@ -14,62 +14,51 @@ def main():
|
||||
output_path = sys.argv[3]
|
||||
|
||||
try:
|
||||
emit_progress(10, "Loading inpainting model")
|
||||
emit_progress(10, "Preparing")
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
# Try lama-cleaner if available
|
||||
from lama_cleaner.model_manager import ModelManager
|
||||
from lama_cleaner.schema import Config
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
emit_progress(20, "Model loaded")
|
||||
emit_progress(20, "Ready")
|
||||
|
||||
img = Image.open(input_path).convert("RGB")
|
||||
mask = Image.open(mask_path).convert("L")
|
||||
|
||||
# Resize mask to match image if needed
|
||||
emit_progress(25, "Analyzing mask")
|
||||
emit_progress(30, "Analyzing mask")
|
||||
if mask.size != img.size:
|
||||
mask = mask.resize(img.size, Image.NEAREST)
|
||||
|
||||
import numpy as np
|
||||
|
||||
img_array = np.array(img)
|
||||
img_array = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||
mask_array = np.array(mask)
|
||||
|
||||
model_manager = ModelManager(name="lama", device="cpu")
|
||||
config = Config(
|
||||
ldm_steps=25,
|
||||
ldm_sampler="plms",
|
||||
hd_strategy="Original",
|
||||
hd_strategy_crop_margin=128,
|
||||
hd_strategy_crop_trigger_size=800,
|
||||
hd_strategy_resize_limit=800,
|
||||
)
|
||||
emit_progress(40, "Inpainting region")
|
||||
result = model_manager(img_array, mask_array, config)
|
||||
emit_progress(85, "Refining edges")
|
||||
emit_progress(95, "Saving result")
|
||||
Image.fromarray(result).save(output_path)
|
||||
method = "lama"
|
||||
# Threshold mask to binary (ensure clean white/black)
|
||||
_, mask_binary = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# Inpaint radius scales with image size for better results
|
||||
inpaint_radius = max(3, min(img_array.shape[0], img_array.shape[1]) // 200)
|
||||
|
||||
emit_progress(50, "Erasing")
|
||||
result = cv2.inpaint(img_array, mask_binary, inpaint_radius, cv2.INPAINT_TELEA)
|
||||
|
||||
emit_progress(90, "Saving")
|
||||
result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
|
||||
Image.fromarray(result_rgb).save(output_path)
|
||||
|
||||
print(json.dumps({"success": True, "method": "opencv-telea"}))
|
||||
|
||||
except ImportError:
|
||||
# LaMa not available — report error instead of silently copying
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"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)
|
||||
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:
|
||||
print(
|
||||
|
||||
Reference in New Issue
Block a user