mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix: remove all automatic third-party egress (OSM tiles, Scalar fonts, editor Google Fonts, AI model download fallbacks) Phone-home audit follow-up. The product no longer makes any automatic third-party request; user-initiated click-outs stay, and production now fails closed on missing AI models. 1. GPS leak via OSM tiles: the strip-metadata panel auto-loaded tile.openstreetmap.org tiles encoding the photo's GPS position. The Leaflet mini-map is gone; coordinates render as text plus an explicit View on map link (openstreetmap.org, opens on click only). Removed tile.openstreetmap.org from the CSP img-src, dropped the leaflet dependency, added the viewOnMap i18n key to all 21 locales. 2. Scalar docs fonts: /api/docs loaded Inter and JetBrains Mono from fonts.scalar.com. Scalar now renders with withDefaultFonts: false and both --scalar-font and --scalar-font-code pinned to system stacks; fonts.scalar.com removed from the docs CSP font-src. Verified by injecting GET /api/docs/: config carries withDefaultFonts false and the served page has no fonts.scalar.com reference. 3. Editor Google Fonts: the editor font picker built fonts.googleapis.com stylesheet URLs for 25 web fonts the served CSP already blocked. The remote loading path is deleted; the picker now offers system fonts only, with a SELF_HOSTED_FONTS seam (FontFace API, same origin) for bundling fonts later. Unknown families saved in old documents fall back to the browser default. 4. Python sidecar fails closed on model downloads: new packages/ai/python/offline_guard.py gates every runtime download fallback (inpaint, outpaint, restore, noise_removal, detect_faces, enhance_faces, face_landmarks, red_eye_removal, remove_bg, ocr, transcribe, upscale) behind SNAPOTTER_ALLOW_MODEL_DOWNLOAD=1 with an actionable error. Bundled models keep working untouched. 5. OCR and transcription library-internal downloads: unbundled PaddleOCR language and detection fallbacks now raise the guard error naming the language instead of resolving models over the network; faster-whisper gets local_files_only when downloads are off. 6. GFPGAN and CodeFormer cwd-relative weights: facexlib and codeformer-pip resolve helper weights relative to the process cwd and fetch them from GitHub when absent. They are now symlinked from the installed bundle files under MODELS_PATH/gfpgan/facelib before the libraries load, failing closed when unresolvable. Defense in depth: HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 are set in the runtime image and in the sidecar spawn env; install_feature.py lifts them for user-initiated bundle installs and restores them afterwards (it can run in-process inside the dispatcher). SNAPOTTER_ALLOW_MODEL_DOWNLOAD is documented in .env.example, default off. Validation: typecheck 9/9 workspaces, Biome clean on touched files, 5178 unit tests pass, py_compile on all touched scripts, guard behavior exercised in both dispatcher exec and per-request import modes, zero remaining runtime references to the three hosts. Docker build and live AI inference need post-merge verification on the GPU host. Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7 * fix: allow AI model downloads by default, make strict offline mode opt-in Product call: ease of use first. The download gating from the previous commit inverts its default: runtime model fetches (public model weights only, never user data) are allowed out of the box so AI tools self-heal, and SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0 becomes the explicit strict offline mode for airgapped deployments, where every fallback raises the actionable error instead of fetching. Changes: offline_guard blocks only on an explicit 0/false; the unconditional HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE image ENV is removed and bridge.ts sets those flags for the sidecar only in strict mode; .env.example documents the new default; install_feature's lift/restore stays. All bundled-path preferences, pre-existence checks, and symlink pre-placement remain, so installed bundles never trigger a download. The OSM, Scalar font, and editor font fixes are unchanged. Validation rerun: typecheck 9/9, Biome clean on touched files, 5178 unit tests pass, py_compile on touched scripts, guard behavior verified for unset/1 (allowed) and 0/false (blocked with the new message). Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7
203 lines
6.9 KiB
Python
203 lines
6.9 KiB
Python
"""Face landmark detection using MediaPipe FaceMesh for passport photo positioning."""
|
|
import sys
|
|
import json
|
|
import os
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ── Landmark extraction (shared by both APIs) ──────────────────────
|
|
|
|
# MediaPipe face mesh indices for key points
|
|
LEFT_EYE_INDICES = [33, 133, 159, 145, 160, 144, 158, 153]
|
|
RIGHT_EYE_INDICES = [362, 263, 386, 374, 385, 373, 387, 380]
|
|
CHIN_INDEX = 152
|
|
FOREHEAD_INDEX = 10
|
|
NOSE_INDEX = 1
|
|
|
|
|
|
def extract_key_points(lms):
|
|
"""Extract passport-relevant points from a list of (x, y) normalized landmarks."""
|
|
left_eye_x = sum(lms[i][0] for i in LEFT_EYE_INDICES) / len(LEFT_EYE_INDICES)
|
|
left_eye_y = sum(lms[i][1] for i in LEFT_EYE_INDICES) / len(LEFT_EYE_INDICES)
|
|
|
|
right_eye_x = sum(lms[i][0] for i in RIGHT_EYE_INDICES) / len(RIGHT_EYE_INDICES)
|
|
right_eye_y = sum(lms[i][1] for i in RIGHT_EYE_INDICES) / len(RIGHT_EYE_INDICES)
|
|
|
|
eye_center_x = (left_eye_x + right_eye_x) / 2
|
|
eye_center_y = (left_eye_y + right_eye_y) / 2
|
|
|
|
chin_x, chin_y = lms[CHIN_INDEX]
|
|
forehead_x, forehead_y = lms[FOREHEAD_INDEX]
|
|
nose_x, nose_y = lms[NOSE_INDEX]
|
|
|
|
forehead_chin_dist = chin_y - forehead_y
|
|
crown_y = forehead_y - (forehead_chin_dist * 0.15)
|
|
crown_x = forehead_x
|
|
|
|
face_center_x = (nose_x + eye_center_x) / 2
|
|
|
|
return {
|
|
"leftEye": {"x": round(left_eye_x, 6), "y": round(left_eye_y, 6)},
|
|
"rightEye": {"x": round(right_eye_x, 6), "y": round(right_eye_y, 6)},
|
|
"eyeCenter": {"x": round(eye_center_x, 6), "y": round(eye_center_y, 6)},
|
|
"chin": {"x": round(chin_x, 6), "y": round(chin_y, 6)},
|
|
"forehead": {"x": round(forehead_x, 6), "y": round(forehead_y, 6)},
|
|
"crown": {"x": round(crown_x, 6), "y": round(crown_y, 6)},
|
|
"nose": {"x": round(nose_x, 6), "y": round(nose_y, 6)},
|
|
"faceCenterX": round(face_center_x, 6),
|
|
}
|
|
|
|
|
|
# ── Old API: mp.solutions (mediapipe < 0.10.30) ───────────────────
|
|
|
|
def detect_with_solutions(img_array, max_faces=1):
|
|
"""Use the legacy mp.solutions.face_mesh API."""
|
|
import mediapipe as mp
|
|
|
|
mp_face_mesh = mp.solutions.face_mesh
|
|
face_mesh = mp_face_mesh.FaceMesh(
|
|
static_image_mode=True,
|
|
max_num_faces=max_faces,
|
|
refine_landmarks=True,
|
|
min_detection_confidence=0.5,
|
|
)
|
|
|
|
results = face_mesh.process(img_array)
|
|
face_mesh.close()
|
|
|
|
if not results.multi_face_landmarks:
|
|
return None
|
|
|
|
face_lm = results.multi_face_landmarks[0]
|
|
return [(lm.x, lm.y) for lm in face_lm.landmark]
|
|
|
|
|
|
# ── New API: mp.tasks (mediapipe >= 0.10.30) ───────────────────────
|
|
|
|
_MODELS_BASE = os.environ.get("MODELS_PATH", "/opt/models")
|
|
|
|
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task"
|
|
_DOCKER_MODEL_PATH = os.path.join(_MODELS_BASE, "mediapipe", "face_landmarker.task")
|
|
MODEL_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", ".models")
|
|
MODEL_PATH = os.path.join(MODEL_DIR, "face_landmarker.task")
|
|
|
|
|
|
def ensure_model():
|
|
"""Resolve face landmarker model. Docker path first, then local dev."""
|
|
if os.path.exists(_DOCKER_MODEL_PATH):
|
|
return _DOCKER_MODEL_PATH
|
|
if os.path.exists(MODEL_PATH):
|
|
return MODEL_PATH
|
|
from offline_guard import ensure_download_allowed
|
|
ensure_download_allowed("Face landmark model (face_landmarker.task)")
|
|
os.makedirs(MODEL_DIR, exist_ok=True)
|
|
import urllib.request
|
|
emit_progress(15, "Downloading face model")
|
|
urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
|
|
return MODEL_PATH
|
|
|
|
|
|
def detect_with_tasks(img_path, max_faces=1):
|
|
"""Use the new mp.tasks.vision.FaceLandmarker API."""
|
|
import mediapipe as mp
|
|
|
|
model_path = ensure_model()
|
|
|
|
options = mp.tasks.vision.FaceLandmarkerOptions(
|
|
base_options=mp.tasks.BaseOptions(model_asset_path=model_path),
|
|
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
|
num_faces=max_faces,
|
|
min_face_detection_confidence=0.5,
|
|
output_face_blendshapes=False,
|
|
output_facial_transformation_matrixes=False,
|
|
)
|
|
|
|
landmarker = mp.tasks.vision.FaceLandmarker.create_from_options(options)
|
|
mp_image = mp.Image.create_from_file(img_path)
|
|
result = landmarker.detect(mp_image)
|
|
landmarker.close()
|
|
|
|
if not result.face_landmarks:
|
|
return None
|
|
|
|
face_lm = result.face_landmarks[0]
|
|
return [(lm.x, lm.y) for lm in face_lm]
|
|
|
|
|
|
# ── Main ───────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
input_path = sys.argv[1]
|
|
output_path = sys.argv[2] # unused but kept for bridge.ts compatibility
|
|
settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
|
|
|
|
max_faces = settings.get("max_num_faces", 1)
|
|
|
|
try:
|
|
emit_progress(10, "Loading image")
|
|
from PIL import Image
|
|
|
|
img = Image.open(input_path).convert("RGB")
|
|
iw, ih = img.size
|
|
|
|
try:
|
|
import mediapipe as mp
|
|
import numpy as np
|
|
|
|
emit_progress(20, "Initializing face mesh")
|
|
|
|
landmarks_list = None
|
|
try:
|
|
img_array = np.array(img)
|
|
emit_progress(30, "Detecting face landmarks")
|
|
landmarks_list = detect_with_solutions(img_array, max_faces)
|
|
except AttributeError:
|
|
emit_progress(30, "Detecting face landmarks")
|
|
landmarks_list = detect_with_tasks(input_path, max_faces)
|
|
|
|
if landmarks_list is None:
|
|
print(json.dumps({
|
|
"success": True,
|
|
"faceDetected": False,
|
|
"landmarks": None,
|
|
}))
|
|
return
|
|
|
|
emit_progress(60, "Extracting key points")
|
|
key_points = extract_key_points(landmarks_list)
|
|
|
|
emit_progress(90, "Done")
|
|
|
|
print(json.dumps({
|
|
"success": True,
|
|
"faceDetected": True,
|
|
"landmarks": key_points,
|
|
"imageWidth": iw,
|
|
"imageHeight": ih,
|
|
}))
|
|
|
|
except ImportError:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": "Face landmark detection requires MediaPipe. Install with: pip install mediapipe",
|
|
}))
|
|
sys.exit(1)
|
|
|
|
except ImportError:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": "Pillow is not installed. Install with: pip install Pillow",
|
|
}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({"success": False, "error": str(e)}))
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|