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 { 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">
|
||||||
</label>
|
Brush Size
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
|
</label>
|
||||||
Upload a black & white mask where white areas will be erased. Create the mask in any
|
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
||||||
image editor.
|
</div>
|
||||||
</p>
|
<input
|
||||||
<label
|
id="eraser-brush-size"
|
||||||
htmlFor="erase-object-mask"
|
type="range"
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
|
min={5}
|
||||||
>
|
max={100}
|
||||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
value={brushSize}
|
||||||
<span className="text-xs text-muted-foreground">
|
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||||
{maskFile ? maskFile.name : "Select mask image..."}
|
className="w-full mt-1"
|
||||||
</span>
|
/>
|
||||||
<input
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
id="erase-object-mask"
|
<span>Fine</span>
|
||||||
type="file"
|
<span>Wide</span>
|
||||||
accept="image/*"
|
</div>
|
||||||
onChange={handleMaskSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</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" />
|
||||||
</div>
|
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 */}
|
||||||
{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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user