mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: replace Python seam carving with caire Go binary
Replace the Python seam-carving library with caire (esimov/caire v1.5.0), a Go-based content-aware resize engine that is faster and supports both shrinking and enlarging via seam insertion. - Add Go builder stage in Dockerfile to compile caire from source - Rewrite seam-carving.ts to call caire via execFile (no Python sidecar) - Remove content-aware-resize from PYTHON_SIDECAR_TOOLS (60s timeout) - Add new options: blur radius, edge sensitivity, square mode, face detection - Move content-aware toggle below standard resize in UI (subtler placement) - Rename "Don't enlarge" to "Limit to original size" with hover tooltip - Add smooth progress bar for medium-duration tools - Delete seam_carve.py and remove seam-carving pip dependency - Update integration tests and visual regression screenshots
This commit is contained in:
@@ -7,4 +7,3 @@ onnxruntime-gpu==1.20.1
|
||||
numpy==1.26.4
|
||||
Pillow==11.1.0
|
||||
opencv-python-headless==4.10.0.84
|
||||
seam-carving==1.1.0
|
||||
|
||||
@@ -7,4 +7,3 @@ onnxruntime==1.20.1
|
||||
numpy==1.26.4
|
||||
Pillow==11.1.0
|
||||
opencv-python-headless==4.10.0.84
|
||||
seam-carving==1.1.0
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Content-aware image resize using seam carving.
|
||||
Uses the seam-carving library (li-plus) with optional face protection via MediaPipe.
|
||||
|
||||
Args:
|
||||
sys.argv[1]: input image path
|
||||
sys.argv[2]: output image path
|
||||
sys.argv[3]: JSON settings string with keys:
|
||||
- width (int, optional): target width
|
||||
- height (int, optional): target height
|
||||
- protectFaces (bool, optional): enable face detection for protection mask
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def emit_progress(percent, stage):
|
||||
"""Emit structured progress to stderr for bridge.ts to capture."""
|
||||
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def build_face_mask(img_array):
|
||||
"""Detect faces with MediaPipe and return a boolean keep_mask."""
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import mediapipe as mp
|
||||
except ImportError:
|
||||
emit_progress(20, "MediaPipe not available, skipping face protection")
|
||||
return None
|
||||
|
||||
h, w = img_array.shape[:2]
|
||||
mask = np.zeros((h, w), dtype=bool)
|
||||
|
||||
face_detection = mp.solutions.face_detection
|
||||
detector = face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.5)
|
||||
|
||||
try:
|
||||
results = detector.process(img_array)
|
||||
if not results.detections:
|
||||
emit_progress(20, "No faces detected")
|
||||
return None
|
||||
|
||||
for detection in results.detections:
|
||||
bbox = detection.location_data.relative_bounding_box
|
||||
x = int(bbox.xmin * w)
|
||||
y = int(bbox.ymin * h)
|
||||
bw = int(bbox.width * w)
|
||||
bh = int(bbox.height * h)
|
||||
|
||||
# Add 20% padding around face
|
||||
pad_x = int(bw * 0.2)
|
||||
pad_y = int(bh * 0.2)
|
||||
x1 = max(0, x - pad_x)
|
||||
y1 = max(0, y - pad_y)
|
||||
x2 = min(w, x + bw + pad_x)
|
||||
y2 = min(h, y + bh + pad_y)
|
||||
|
||||
mask[y1:y2, x1:x2] = True
|
||||
|
||||
emit_progress(20, f"Detected {len(results.detections)} face(s)")
|
||||
return mask
|
||||
finally:
|
||||
detector.close()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4:
|
||||
print(json.dumps({"success": False, "error": "Usage: seam_carve.py <input> <output> <settings>"}))
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
try:
|
||||
settings = json.loads(sys.argv[3])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
print(json.dumps({"success": False, "error": "Invalid settings JSON"}))
|
||||
sys.exit(1)
|
||||
|
||||
target_width = settings.get("width")
|
||||
target_height = settings.get("height")
|
||||
protect_faces = settings.get("protectFaces", False)
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print(json.dumps({"success": False, "error": "Pillow/numpy not installed"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import seam_carving
|
||||
except ImportError:
|
||||
print(json.dumps({"success": False, "error": "seam-carving package not installed"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
emit_progress(0, "Loading image")
|
||||
img = Image.open(input_path).convert("RGB")
|
||||
img_array = np.array(img)
|
||||
src_h, src_w = img_array.shape[:2]
|
||||
|
||||
# Default to source dimensions if not specified
|
||||
if target_width is None:
|
||||
target_width = src_w
|
||||
if target_height is None:
|
||||
target_height = src_h
|
||||
|
||||
# Validate: shrink only
|
||||
if target_width > src_w or target_height > src_h:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": f"Content-aware resize only supports shrinking. Source is {src_w}x{src_h}, target is {target_width}x{target_height}."
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
# Nothing to do
|
||||
if target_width == src_w and target_height == src_h:
|
||||
img.save(output_path)
|
||||
print(json.dumps({"success": True, "width": src_w, "height": src_h}))
|
||||
return
|
||||
|
||||
# Warn about large images
|
||||
if src_w > 3000 or src_h > 3000:
|
||||
emit_progress(5, "Large image detected, this may take a while")
|
||||
|
||||
# Face protection mask
|
||||
keep_mask = None
|
||||
if protect_faces:
|
||||
emit_progress(10, "Detecting faces")
|
||||
keep_mask = build_face_mask(img_array)
|
||||
|
||||
emit_progress(25, "Starting seam carving")
|
||||
|
||||
# seam_carving.resize takes size as (width, height)
|
||||
result = seam_carving.resize(
|
||||
img_array,
|
||||
(target_width, target_height),
|
||||
energy_mode="backward",
|
||||
order="width-first",
|
||||
keep_mask=keep_mask,
|
||||
)
|
||||
|
||||
emit_progress(90, "Saving result")
|
||||
Image.fromarray(result).save(output_path)
|
||||
|
||||
print(json.dumps({
|
||||
"success": True,
|
||||
"width": result.shape[1],
|
||||
"height": result.shape[0],
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,19 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { type ProgressCallback, runPythonWithProgress } from "./bridge.js";
|
||||
import { promisify } from "node:util";
|
||||
import sharp from "sharp";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface SeamCarveOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
protectFaces?: boolean;
|
||||
blurRadius?: number;
|
||||
sobelThreshold?: number;
|
||||
square?: boolean;
|
||||
}
|
||||
|
||||
export interface SeamCarveResult {
|
||||
@@ -14,31 +22,77 @@ export interface SeamCarveResult {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the caire binary. Checks PATH (Docker installs to /usr/local/bin)
|
||||
* and the CAIRE_PATH env var for local development.
|
||||
*/
|
||||
let cachedCairePath: string | null = null;
|
||||
|
||||
async function findCaire(): Promise<string> {
|
||||
if (cachedCairePath) return cachedCairePath;
|
||||
|
||||
const candidates = process.env.CAIRE_PATH ? [process.env.CAIRE_PATH, "caire"] : ["caire"];
|
||||
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
await execFileAsync(cmd, ["-help"], { timeout: 5_000 });
|
||||
cachedCairePath = cmd;
|
||||
return cmd;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"caire binary not found. Install via: go install github.com/esimov/caire/cmd/caire@v1.5.0",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware resize using caire (Go seam carving engine).
|
||||
* Supports both shrinking and enlarging via seam removal/insertion.
|
||||
*/
|
||||
export async function seamCarve(
|
||||
inputBuffer: Buffer,
|
||||
outputDir: string,
|
||||
options: SeamCarveOptions = {},
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<SeamCarveResult> {
|
||||
const inputPath = join(outputDir, "input_seam_carve.png");
|
||||
const outputPath = join(outputDir, "output_seam_carve.png");
|
||||
const cairePath = await findCaire();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(outputDir, `caire-in-${id}.png`);
|
||||
const outputPath = join(outputDir, `caire-out-${id}.png`);
|
||||
|
||||
await writeFile(inputPath, inputBuffer);
|
||||
const { stdout } = await runPythonWithProgress(
|
||||
"seam_carve.py",
|
||||
[inputPath, outputPath, JSON.stringify(options)],
|
||||
{ onProgress },
|
||||
);
|
||||
try {
|
||||
await writeFile(inputPath, inputBuffer);
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Content-aware resize failed");
|
||||
// Build caire arguments
|
||||
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
||||
|
||||
if (options.square) {
|
||||
// Caire -square requires -width and -height set to the shortest edge
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const shortest = Math.min(meta.width ?? 0, meta.height ?? 0);
|
||||
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
||||
} else {
|
||||
if (options.width) args.push("-width", String(options.width));
|
||||
if (options.height) args.push("-height", String(options.height));
|
||||
}
|
||||
|
||||
if (options.protectFaces) args.push("-face");
|
||||
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
||||
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
||||
|
||||
await execFileAsync(cairePath, args, { timeout: 60_000 });
|
||||
|
||||
const buffer = await readFile(outputPath);
|
||||
const meta = await sharp(buffer).metadata();
|
||||
|
||||
return {
|
||||
buffer,
|
||||
width: meta.width ?? 0,
|
||||
height: meta.height ?? 0,
|
||||
};
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const buffer = await readFile(outputPath);
|
||||
return {
|
||||
buffer,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,5 +390,4 @@ export const PYTHON_SIDECAR_TOOLS = [
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
"content-aware-resize",
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user