mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(blur-faces): switch from MediaPipe to OpenCV and auto-orient images
Fix face detection failure caused by MediaPipe 0.10.33 removing the mp.solutions API. Replace with OpenCV Haar cascade which works reliably in headless Docker. Add autoOrient() call before detection to handle EXIF-rotated phone photos. Remove technical jargon from UI.
This commit is contained in:
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { blurFaces } from "@stirling-image/ai";
|
import { blurFaces } from "@stirling-image/ai";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -52,6 +53,10 @@ export function registerBlurFaces(app: FastifyInstance) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
|
||||||
|
// Auto-orient to fix EXIF rotation before face detection
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const workspacePath = await createWorkspace(jobId);
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ export function BlurFacesSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
Uses MediaPipe for face detection. Automatically detects and blurs all faces in 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>}
|
||||||
|
|
||||||
@@ -91,7 +86,6 @@ export function BlurFacesSettings() {
|
|||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Blurring faces"
|
label="Blurring faces"
|
||||||
stage={progress.stage}
|
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Face detection and blurring using MediaPipe."""
|
"""Face detection and blurring using OpenCV."""
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -17,70 +17,79 @@ def main():
|
|||||||
sensitivity = settings.get("sensitivity", 0.5)
|
sensitivity = settings.get("sensitivity", 0.5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
emit_progress(10, "Loading face detection model")
|
emit_progress(10, "Preparing")
|
||||||
from PIL import Image, ImageFilter
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
img = Image.open(input_path).convert("RGB")
|
img = Image.open(input_path).convert("RGB")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import mediapipe as mp
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
emit_progress(20, "Model ready")
|
emit_progress(20, "Ready")
|
||||||
|
|
||||||
mp_face = mp.solutions.face_detection
|
# Load Haar cascade for face detection
|
||||||
|
haar_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
||||||
|
face_cascade = cv2.CascadeClassifier(haar_path)
|
||||||
|
|
||||||
with mp_face.FaceDetection(
|
# Convert to grayscale for detection
|
||||||
min_detection_confidence=sensitivity
|
img_array = np.array(img)
|
||||||
) as detector:
|
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
||||||
img_array = np.array(img)
|
|
||||||
emit_progress(25, "Scanning for faces")
|
|
||||||
results = detector.process(img_array)
|
|
||||||
|
|
||||||
faces = []
|
# Map sensitivity (0.1-0.9) to minNeighbors (8-2)
|
||||||
emit_progress(50, f"Found {len(results.detections or [])} faces")
|
# Higher sensitivity = fewer required neighbors = more detections
|
||||||
if results.detections:
|
min_neighbors = max(2, int(8 - sensitivity * 7))
|
||||||
for i, detection in enumerate(results.detections):
|
|
||||||
bbox = detection.location_data.relative_bounding_box
|
|
||||||
x = int(bbox.xmin * img.width)
|
|
||||||
y = int(bbox.ymin * img.height)
|
|
||||||
w = int(bbox.width * img.width)
|
|
||||||
h = int(bbox.height * img.height)
|
|
||||||
|
|
||||||
# Add some padding around the face
|
emit_progress(25, "Scanning for faces")
|
||||||
pad = int(max(w, h) * 0.1)
|
faces_detected = face_cascade.detectMultiScale(
|
||||||
x1 = max(0, x - pad)
|
gray,
|
||||||
y1 = max(0, y - pad)
|
scaleFactor=1.1,
|
||||||
x2 = min(img.width, x + w + pad)
|
minNeighbors=min_neighbors,
|
||||||
y2 = min(img.height, y + h + pad)
|
minSize=(30, 30),
|
||||||
|
)
|
||||||
|
|
||||||
face_region = img.crop((x1, y1, x2, y2))
|
faces = []
|
||||||
blurred = face_region.filter(
|
num_faces = len(faces_detected)
|
||||||
ImageFilter.GaussianBlur(blur_radius)
|
emit_progress(50, f"Found {num_faces} face{'s' if num_faces != 1 else ''}")
|
||||||
)
|
|
||||||
img.paste(blurred, (x1, y1))
|
|
||||||
faces.append({"x": x, "y": y, "w": w, "h": h})
|
|
||||||
emit_progress(50 + int((i + 1) / max(len(results.detections), 1) * 40), f"Blurring face {i + 1} of {len(results.detections)}")
|
|
||||||
|
|
||||||
emit_progress(95, "Saving result")
|
if num_faces > 0:
|
||||||
img.save(output_path)
|
for i, (x, y, w, h) in enumerate(faces_detected):
|
||||||
print(
|
# Add padding around the face
|
||||||
json.dumps(
|
pad = int(max(w, h) * 0.1)
|
||||||
{
|
x1 = max(0, x - pad)
|
||||||
"success": True,
|
y1 = max(0, y - pad)
|
||||||
"facesDetected": len(faces),
|
x2 = min(img.width, x + w + pad)
|
||||||
"faces": faces,
|
y2 = min(img.height, y + h + pad)
|
||||||
}
|
|
||||||
|
face_region = img.crop((x1, y1, x2, y2))
|
||||||
|
blurred = face_region.filter(
|
||||||
|
ImageFilter.GaussianBlur(blur_radius)
|
||||||
)
|
)
|
||||||
|
img.paste(blurred, (x1, y1))
|
||||||
|
faces.append({"x": int(x), "y": int(y), "w": int(w), "h": int(h)})
|
||||||
|
emit_progress(
|
||||||
|
50 + int((i + 1) / num_faces * 40),
|
||||||
|
f"Blurring face {i + 1} of {num_faces}",
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_progress(95, "Saving result")
|
||||||
|
img.save(output_path)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"facesDetected": len(faces),
|
||||||
|
"faces": faces,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# MediaPipe not available — report error clearly
|
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Face detection requires the mediapipe package. Install with: pip install mediapipe",
|
"error": "Face detection requires OpenCV. Install with: pip install opencv-python-headless",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user