Files
SnapOtter/packages/ai/python/inpaint.py
T
SnapOtterandGitHub 6e3a14ec6b fix: remove automatic third-party egress of user data + optional strict offline mode (OSM tiles, Scalar fonts, editor fonts, AI model downloads) (#422)
* 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
2026-07-04 05:46:52 +00:00

172 lines
6.0 KiB
Python

"""Object erasing / inpainting using LaMa (Large Mask Inpainting) via ONNX."""
import sys
import os
import json
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)
# Resolve the LaMa ONNX model path.
# Docker places it at /opt/models/lama/lama_fp32.onnx.
# For local dev, check a user-writable cache dir.
_MODELS_BASE = os.environ.get("MODELS_PATH", "/opt/models")
LAMA_MODEL_DIR = os.environ.get("LAMA_MODEL_DIR", os.path.join(_MODELS_BASE, "lama"))
LAMA_MODEL_PATH = os.path.join(LAMA_MODEL_DIR, "lama_fp32.onnx")
LAMA_LOCAL_CACHE = os.path.join(os.path.expanduser("~"), ".cache", "snapotter", "lama")
LAMA_LOCAL_PATH = os.path.join(LAMA_LOCAL_CACHE, "lama_fp32.onnx")
LAMA_HF_URL = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx"
# The ONNX model expects 512x512 fixed input.
MODEL_SIZE = 512
def _get_model_path():
"""Return path to the LaMa ONNX model, downloading only if allowed."""
if os.path.exists(LAMA_MODEL_PATH):
return LAMA_MODEL_PATH
if os.path.exists(LAMA_LOCAL_PATH):
return LAMA_LOCAL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("LaMa inpainting model (lama_fp32.onnx)")
emit_progress(5, "Downloading LaMa model")
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
import urllib.request
urllib.request.urlretrieve(LAMA_HF_URL, LAMA_LOCAL_PATH)
return LAMA_LOCAL_PATH
def _preprocess_image(img_array):
"""Convert HWC uint8 RGB image to NCHW float32 [0,1] at MODEL_SIZE."""
import cv2
import numpy as np
resized = cv2.resize(img_array, (MODEL_SIZE, MODEL_SIZE), interpolation=cv2.INTER_AREA)
# HWC -> CHW, normalize to [0, 1], add batch dim
chw = np.transpose(resized, (2, 0, 1)).astype(np.float32) / 255.0
return chw[np.newaxis, ...] # (1, 3, 512, 512)
def _preprocess_mask(mask_array):
"""Convert HW uint8 grayscale mask to NC(1)HW float32 binary at MODEL_SIZE."""
import cv2
import numpy as np
resized = cv2.resize(mask_array, (MODEL_SIZE, MODEL_SIZE), interpolation=cv2.INTER_NEAREST)
# Threshold to binary 0/1
binary = (resized > 127).astype(np.float32)
return binary[np.newaxis, np.newaxis, ...] # (1, 1, 512, 512)
def _feathered_composite(original, inpainted, mask, feather_radius=5):
"""Composite inpainted region into original using a feathered mask.
This preserves full quality in non-masked areas and smoothly blends
the inpainted region at the boundary.
"""
import cv2
import numpy as np
# Dilate mask slightly for smoother transition
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (feather_radius, feather_radius))
dilated = cv2.dilate(mask.astype(np.uint8), kernel, iterations=1)
# Gaussian blur the dilated mask for feathering
blur_size = feather_radius * 2 + 1
alpha = cv2.GaussianBlur(dilated.astype(np.float32), (blur_size, blur_size), 0)
alpha = np.clip(alpha, 0.0, 1.0)
# Expand alpha to 3 channels
alpha_3ch = alpha[:, :, np.newaxis]
# Composite: original * (1 - alpha) + inpainted * alpha
result = (original.astype(np.float32) * (1.0 - alpha_3ch) +
inpainted.astype(np.float32) * alpha_3ch)
return np.clip(result, 0, 255).astype(np.uint8)
def main():
input_path = sys.argv[1]
mask_path = sys.argv[2]
output_path = sys.argv[3]
try:
emit_progress(5, "Preparing")
from PIL import Image
import numpy as np
try:
import cv2
import onnxruntime
except ImportError as e:
msg = str(e)
hint = "Fix with: apt-get install -y libgl1" if "libGL" in msg else "Requires opencv-python-headless and onnxruntime."
print(json.dumps({
"success": False,
"error": f"Missing dependency: {msg}. {hint}",
}))
sys.exit(1)
emit_progress(10, "Loading model")
model_path = _get_model_path()
from gpu import safe_onnx_session
session, _device = safe_onnx_session(model_path)
emit_progress(20, "Loading images")
img = Image.open(input_path).convert("RGB")
mask = Image.open(mask_path).convert("L")
orig_w, orig_h = img.size
img_array = np.array(img)
mask_array = np.array(mask)
# Resize mask to match image if needed
if mask_array.shape[:2] != img_array.shape[:2]:
mask_array = cv2.resize(
mask_array, (orig_w, orig_h), interpolation=cv2.INTER_NEAREST
)
# Threshold mask to binary
_, mask_binary = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY)
emit_progress(30, "Preprocessing")
img_input = _preprocess_image(img_array)
mask_input = _preprocess_mask(mask_binary)
emit_progress(40, "Erasing objects")
outputs = session.run(
None,
{"image": img_input, "mask": mask_input},
)
emit_progress(75, "Compositing")
# Output shape: (1, 3, 512, 512) with values in [0, 255]
raw_output = outputs[0][0] # (3, 512, 512)
raw_output = np.transpose(raw_output, (1, 2, 0)) # (512, 512, 3)
raw_output = np.clip(raw_output, 0, 255).astype(np.uint8)
# Resize inpainted result back to original dimensions
inpainted_full = cv2.resize(raw_output, (orig_w, orig_h), interpolation=cv2.INTER_LANCZOS4)
# Feathered composite: preserve quality outside mask, blend at edges
mask_full = mask_binary.astype(np.float32) / 255.0
feather_r = max(3, min(orig_w, orig_h) // 200)
result = _feathered_composite(img_array, inpainted_full, mask_full, feather_r)
emit_progress(90, "Saving")
Image.fromarray(result).save(output_path)
print(json.dumps({"success": True, "method": "lama-onnx"}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
if __name__ == "__main__":
main()