2026-04-13 21:57:51 +08:00
|
|
|
"""AI photo restoration pipeline.
|
|
|
|
|
|
|
|
|
|
Multi-step pipeline for restoring old and damaged photos:
|
|
|
|
|
1. Scratch & damage detection (morphological analysis)
|
|
|
|
|
2. Damage inpainting (LaMa ONNX)
|
|
|
|
|
3. Face enhancement (CodeFormer ONNX)
|
|
|
|
|
4. Noise reduction (OpenCV NLMeans)
|
|
|
|
|
5. Optional B&W colorization (DDColor ONNX)
|
|
|
|
|
"""
|
|
|
|
|
import sys
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-04-20 15:17:08 +08:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import numpy as np
|
|
|
|
|
import cv2
|
|
|
|
|
from PIL import Image
|
|
|
|
|
except ImportError as _e:
|
2026-06-10 18:59:30 +08:00
|
|
|
_msg = str(_e)
|
|
|
|
|
_hint = "Fix with: apt-get install -y libgl1" if "libGL" in _msg else "Install opencv-python-headless, numpy, and Pillow."
|
|
|
|
|
print(json.dumps({"error": f"Missing dependency: {_msg}. {_hint}"}))
|
2026-04-20 15:17:08 +08:00
|
|
|
sys.exit(1)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Model paths ───────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-18 10:17:01 +08:00
|
|
|
_MODELS_BASE = os.environ.get("MODELS_PATH", "/opt/models")
|
|
|
|
|
|
|
|
|
|
LAMA_MODEL_DIR = os.environ.get("LAMA_MODEL_DIR", os.path.join(_MODELS_BASE, "lama"))
|
2026-04-13 21:57:51 +08:00
|
|
|
LAMA_MODEL_PATH = os.path.join(LAMA_MODEL_DIR, "lama_fp32.onnx")
|
2026-04-24 18:02:21 +08:00
|
|
|
LAMA_LOCAL_CACHE = os.path.join(os.path.expanduser("~"), ".cache", "snapotter", "lama")
|
2026-04-13 21:57:51 +08:00
|
|
|
LAMA_LOCAL_PATH = os.path.join(LAMA_LOCAL_CACHE, "lama_fp32.onnx")
|
|
|
|
|
|
2026-04-18 10:17:01 +08:00
|
|
|
CODEFORMER_MODEL_DIR = os.environ.get("CODEFORMER_MODEL_DIR", os.path.join(_MODELS_BASE, "codeformer"))
|
2026-04-13 21:57:51 +08:00
|
|
|
CODEFORMER_MODEL_PATH = os.path.join(CODEFORMER_MODEL_DIR, "codeformer.onnx")
|
|
|
|
|
CODEFORMER_LOCAL_CACHE = os.path.join(
|
2026-04-24 18:02:21 +08:00
|
|
|
os.path.expanduser("~"), ".cache", "snapotter", "codeformer"
|
2026-04-13 21:57:51 +08:00
|
|
|
)
|
|
|
|
|
CODEFORMER_LOCAL_PATH = os.path.join(CODEFORMER_LOCAL_CACHE, "codeformer.onnx")
|
|
|
|
|
|
|
|
|
|
DDCOLOR_MODEL_PATH = os.environ.get(
|
2026-04-18 10:17:01 +08:00
|
|
|
"DDCOLOR_MODEL_PATH", os.path.join(_MODELS_BASE, "ddcolor", "ddcolor.onnx")
|
2026-04-13 21:57:51 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
LAMA_MODEL_SIZE = 512
|
|
|
|
|
CODEFORMER_SIZE = 512
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Scratch detection ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
def detect_scratches(img_bgr, _sensitivity=None):
|
2026-04-13 21:57:51 +08:00
|
|
|
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
|
|
|
|
h, w = gray.shape
|
|
|
|
|
base_dim = min(h, w)
|
|
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
# Pre-filter compression artifacts before enhancement
|
|
|
|
|
filtered = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
|
|
|
|
enhanced = clahe.apply(filtered)
|
|
|
|
|
|
|
|
|
|
# Adaptive kernel sizing based on image dimensions
|
|
|
|
|
if base_dim < 300:
|
|
|
|
|
max_k = max(9, base_dim // 15)
|
|
|
|
|
kernel_sizes = [9, max_k | 1]
|
|
|
|
|
else:
|
|
|
|
|
kernel_sizes = [
|
|
|
|
|
max(9, base_dim // 80),
|
|
|
|
|
max(15, base_dim // 50),
|
|
|
|
|
max(25, base_dim // 30),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
angles = [0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5]
|
|
|
|
|
|
|
|
|
|
# Accumulate morphological responses before thresholding
|
|
|
|
|
response = np.zeros_like(gray, dtype=np.float32)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
for ksize in kernel_sizes:
|
2026-05-13 17:01:50 +08:00
|
|
|
ksize = ksize | 1
|
|
|
|
|
for angle in angles:
|
|
|
|
|
kernel = _make_line_kernel_rotated(ksize, angle)
|
2026-04-13 21:57:51 +08:00
|
|
|
blackhat = cv2.morphologyEx(enhanced, cv2.MORPH_BLACKHAT, kernel)
|
|
|
|
|
tophat = cv2.morphologyEx(enhanced, cv2.MORPH_TOPHAT, kernel)
|
|
|
|
|
combined = cv2.add(blackhat, tophat)
|
2026-05-13 17:01:50 +08:00
|
|
|
response = np.maximum(response, combined.astype(np.float32))
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
# Adaptive threshold via Otsu on the response map
|
|
|
|
|
response_u8 = np.clip(response, 0, 255).astype(np.uint8)
|
|
|
|
|
otsu_thresh, mask = cv2.threshold(response_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
|
|
|
|
2026-05-13 17:17:32 +08:00
|
|
|
if otsu_thresh < 40:
|
2026-05-13 17:01:50 +08:00
|
|
|
return np.zeros_like(gray)
|
|
|
|
|
|
2026-05-13 17:17:32 +08:00
|
|
|
# When Otsu is borderline, use a high fixed threshold to only catch strong scratches
|
|
|
|
|
if otsu_thresh < 60:
|
|
|
|
|
_, mask = cv2.threshold(response_u8, 100, 255, cv2.THRESH_BINARY)
|
|
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
# Connected component filtering
|
|
|
|
|
mask = _filter_components(mask, h * w)
|
|
|
|
|
|
2026-05-13 17:17:32 +08:00
|
|
|
# Connect nearby scratch segments (no opening - it erodes thin scratch lines)
|
2026-05-13 17:01:50 +08:00
|
|
|
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
2026-04-13 21:57:51 +08:00
|
|
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close)
|
|
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
# Coverage cap: if > 15%, keep only strongest detections
|
|
|
|
|
coverage = np.count_nonzero(mask) / (h * w)
|
|
|
|
|
if coverage > 0.15:
|
|
|
|
|
print(f"[restore] Coverage cap triggered: {coverage:.1%} > 15%, keeping strongest detections",
|
|
|
|
|
file=sys.stderr, flush=True)
|
|
|
|
|
masked_response = response_u8.copy()
|
|
|
|
|
masked_response[mask == 0] = 0
|
|
|
|
|
nonzero = masked_response[masked_response > 0]
|
|
|
|
|
if len(nonzero) > 0:
|
|
|
|
|
target_count = int(h * w * 0.15)
|
|
|
|
|
cutoff = np.percentile(nonzero, max(0, 100 * (1 - target_count / len(nonzero))))
|
|
|
|
|
_, mask = cv2.threshold(response_u8, max(cutoff, otsu_thresh), 255, cv2.THRESH_BINARY)
|
|
|
|
|
mask = _filter_components(mask, h * w)
|
|
|
|
|
|
|
|
|
|
# Dilate for cleaner inpainting boundaries
|
|
|
|
|
kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
|
|
|
mask = cv2.dilate(mask, kernel_dilate, iterations=2)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
return mask
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 17:01:50 +08:00
|
|
|
def _make_line_kernel_rotated(size, angle_deg):
|
2026-04-13 21:57:51 +08:00
|
|
|
kernel = np.zeros((size, size), np.uint8)
|
|
|
|
|
mid = size // 2
|
2026-05-13 17:01:50 +08:00
|
|
|
kernel[mid, :] = 1
|
|
|
|
|
if angle_deg == 0:
|
|
|
|
|
return kernel
|
|
|
|
|
M = cv2.getRotationMatrix2D((float(mid), float(mid)), angle_deg, 1.0)
|
|
|
|
|
rotated = cv2.warpAffine(kernel, M, (size, size),
|
|
|
|
|
flags=cv2.INTER_NEAREST,
|
|
|
|
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
|
|
|
|
if np.count_nonzero(rotated) == 0:
|
|
|
|
|
rotated[mid, mid] = 1
|
|
|
|
|
return rotated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_components(mask, total_pixels):
|
|
|
|
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
|
|
|
|
max_area = int(total_pixels * 0.05)
|
|
|
|
|
filtered = np.zeros_like(mask)
|
|
|
|
|
|
|
|
|
|
for i in range(1, num_labels):
|
|
|
|
|
area = stats[i, cv2.CC_STAT_AREA]
|
|
|
|
|
if area < 20 or area > max_area:
|
|
|
|
|
continue
|
|
|
|
|
bw = stats[i, cv2.CC_STAT_WIDTH]
|
|
|
|
|
bh = stats[i, cv2.CC_STAT_HEIGHT]
|
|
|
|
|
elongation = max(bw, bh) / max(min(bw, bh), 1)
|
|
|
|
|
if elongation >= 2.5 or area >= 200:
|
|
|
|
|
filtered[labels == i] = 255
|
|
|
|
|
|
|
|
|
|
return filtered
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── LaMa inpainting ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _get_lama_path():
|
|
|
|
|
"""Resolve LaMa model path, downloading if needed."""
|
|
|
|
|
if os.path.exists(LAMA_MODEL_PATH):
|
|
|
|
|
return LAMA_MODEL_PATH
|
|
|
|
|
if os.path.exists(LAMA_LOCAL_PATH):
|
|
|
|
|
return LAMA_LOCAL_PATH
|
|
|
|
|
# Auto-download for local dev
|
|
|
|
|
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
|
|
|
|
import urllib.request
|
|
|
|
|
url = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx"
|
|
|
|
|
urllib.request.urlretrieve(url, LAMA_LOCAL_PATH)
|
|
|
|
|
return LAMA_LOCAL_PATH
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inpaint_damage(img_bgr, mask):
|
2026-04-20 20:53:54 +08:00
|
|
|
from gpu import safe_onnx_session
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
model_path = _get_lama_path()
|
2026-04-20 21:42:19 +08:00
|
|
|
session, _device = safe_onnx_session(model_path)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
orig_h, orig_w = img_bgr.shape[:2]
|
|
|
|
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
|
|
|
|
|
2026-05-13 17:02:37 +08:00
|
|
|
if orig_h <= LAMA_MODEL_SIZE and orig_w <= LAMA_MODEL_SIZE:
|
|
|
|
|
inpainted_rgb = _inpaint_padded(img_rgb, mask, session)
|
|
|
|
|
else:
|
|
|
|
|
inpainted_rgb = _inpaint_tiled(img_rgb, mask, session)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-05-13 17:02:37 +08:00
|
|
|
# Feathered composite: only replace masked areas
|
|
|
|
|
mask_float = mask.astype(np.float32) / 255.0
|
|
|
|
|
feather_r = max(5, min(orig_w, orig_h) // 100)
|
2026-04-13 21:57:51 +08:00
|
|
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (feather_r, feather_r))
|
2026-05-13 17:02:37 +08:00
|
|
|
dilated = cv2.dilate(mask_float, kernel, iterations=1)
|
2026-04-13 21:57:51 +08:00
|
|
|
blur_size = feather_r * 2 + 1
|
|
|
|
|
alpha = cv2.GaussianBlur(dilated, (blur_size, blur_size), 0)
|
2026-05-13 17:02:37 +08:00
|
|
|
alpha = np.clip(alpha * 1.2, 0.0, 1.0)[:, :, np.newaxis]
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-05-13 17:02:37 +08:00
|
|
|
composited = (img_rgb.astype(np.float32) * (1.0 - alpha) +
|
2026-04-13 21:57:51 +08:00
|
|
|
inpainted_rgb.astype(np.float32) * alpha)
|
|
|
|
|
composited = np.clip(composited, 0, 255).astype(np.uint8)
|
|
|
|
|
|
|
|
|
|
return cv2.cvtColor(composited, cv2.COLOR_RGB2BGR)
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 17:02:37 +08:00
|
|
|
def _lama_single(session, tile_rgb, tile_mask):
|
|
|
|
|
img_input = tile_rgb.astype(np.float32) / 255.0
|
|
|
|
|
img_input = np.transpose(img_input, (2, 0, 1))[np.newaxis, ...]
|
|
|
|
|
mask_binary = (tile_mask > 127).astype(np.float32)
|
|
|
|
|
mask_input = mask_binary[np.newaxis, np.newaxis, ...]
|
|
|
|
|
outputs = session.run(None, {"image": img_input, "mask": mask_input})
|
|
|
|
|
result = outputs[0][0]
|
|
|
|
|
result = np.transpose(result, (1, 2, 0))
|
|
|
|
|
return np.clip(result, 0, 255).astype(np.uint8)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inpaint_padded(img_rgb, mask, session):
|
|
|
|
|
h, w = img_rgb.shape[:2]
|
|
|
|
|
sz = LAMA_MODEL_SIZE
|
|
|
|
|
pad_bottom = sz - h
|
|
|
|
|
pad_right = sz - w
|
|
|
|
|
padded_img = cv2.copyMakeBorder(img_rgb, 0, pad_bottom, 0, pad_right,
|
|
|
|
|
cv2.BORDER_REFLECT_101)
|
|
|
|
|
padded_mask = cv2.copyMakeBorder(mask, 0, pad_bottom, 0, pad_right,
|
|
|
|
|
cv2.BORDER_CONSTANT, value=0)
|
|
|
|
|
result = _lama_single(session, padded_img, padded_mask)
|
|
|
|
|
return result[:h, :w]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_cosine_window(size):
|
|
|
|
|
x = np.linspace(0, np.pi, size)
|
|
|
|
|
w1d = (1 - np.cos(x)) / 2
|
|
|
|
|
return np.outer(w1d, w1d).astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inpaint_tiled(img_rgb, mask, session):
|
|
|
|
|
h, w = img_rgb.shape[:2]
|
|
|
|
|
sz = LAMA_MODEL_SIZE
|
|
|
|
|
stride = 384
|
|
|
|
|
window = _make_cosine_window(sz)
|
|
|
|
|
|
|
|
|
|
result_sum = np.zeros((h, w, 3), dtype=np.float64)
|
|
|
|
|
weight_sum = np.zeros((h, w), dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
y_starts = list(range(0, max(h - sz, 0) + 1, stride))
|
|
|
|
|
if len(y_starts) == 0 or y_starts[-1] + sz < h:
|
|
|
|
|
y_starts.append(max(0, h - sz))
|
|
|
|
|
|
|
|
|
|
x_starts = list(range(0, max(w - sz, 0) + 1, stride))
|
|
|
|
|
if len(x_starts) == 0 or x_starts[-1] + sz < w:
|
|
|
|
|
x_starts.append(max(0, w - sz))
|
|
|
|
|
|
|
|
|
|
for y in y_starts:
|
|
|
|
|
for x in x_starts:
|
|
|
|
|
y2 = y + sz
|
|
|
|
|
x2 = x + sz
|
|
|
|
|
|
|
|
|
|
# Pad if tile extends beyond image
|
|
|
|
|
if y2 > h or x2 > w:
|
|
|
|
|
tile_img = cv2.copyMakeBorder(
|
|
|
|
|
img_rgb[y:min(y2, h), x:min(x2, w)],
|
|
|
|
|
0, max(0, y2 - h), 0, max(0, x2 - w),
|
|
|
|
|
cv2.BORDER_REFLECT_101)
|
|
|
|
|
tile_mask = cv2.copyMakeBorder(
|
|
|
|
|
mask[y:min(y2, h), x:min(x2, w)],
|
|
|
|
|
0, max(0, y2 - h), 0, max(0, x2 - w),
|
|
|
|
|
cv2.BORDER_CONSTANT, value=0)
|
|
|
|
|
else:
|
|
|
|
|
tile_img = img_rgb[y:y2, x:x2]
|
|
|
|
|
tile_mask = mask[y:y2, x:x2]
|
|
|
|
|
|
|
|
|
|
if np.count_nonzero(tile_mask) == 0:
|
|
|
|
|
tile_result = tile_img.astype(np.float64)
|
|
|
|
|
else:
|
|
|
|
|
tile_result = _lama_single(session, tile_img, tile_mask).astype(np.float64)
|
|
|
|
|
|
|
|
|
|
# Clip to actual image bounds
|
|
|
|
|
ey = min(y2, h) - y
|
|
|
|
|
ex = min(x2, w) - x
|
|
|
|
|
win = window[:ey, :ex]
|
|
|
|
|
|
|
|
|
|
result_sum[y:y+ey, x:x+ex] += tile_result[:ey, :ex] * win[:, :, np.newaxis]
|
|
|
|
|
weight_sum[y:y+ey, x:x+ex] += win
|
|
|
|
|
|
|
|
|
|
weight_sum = np.maximum(weight_sum, 1e-8)
|
|
|
|
|
result = result_sum / weight_sum[:, :, np.newaxis]
|
|
|
|
|
return np.clip(result, 0, 255).astype(np.uint8)
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 21:57:51 +08:00
|
|
|
# ── CodeFormer face enhancement ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _get_codeformer_path():
|
|
|
|
|
"""Resolve CodeFormer ONNX model path, downloading if needed."""
|
|
|
|
|
if os.path.exists(CODEFORMER_MODEL_PATH):
|
|
|
|
|
return CODEFORMER_MODEL_PATH
|
|
|
|
|
if os.path.exists(CODEFORMER_LOCAL_PATH):
|
|
|
|
|
return CODEFORMER_LOCAL_PATH
|
|
|
|
|
|
|
|
|
|
# Auto-download for local dev
|
|
|
|
|
os.makedirs(CODEFORMER_LOCAL_CACHE, exist_ok=True)
|
|
|
|
|
emit_progress(35, "Downloading CodeFormer model")
|
|
|
|
|
from huggingface_hub import hf_hub_download
|
|
|
|
|
hf_hub_download(
|
|
|
|
|
repo_id="facefusion/models-3.0.0",
|
|
|
|
|
filename="codeformer.onnx",
|
|
|
|
|
local_dir=CODEFORMER_LOCAL_CACHE,
|
|
|
|
|
)
|
|
|
|
|
return CODEFORMER_LOCAL_PATH
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 16:01:35 +08:00
|
|
|
# ── Model path for new mp.tasks API ─────────────────────────────────
|
|
|
|
|
|
2026-04-14 16:43:42 +08:00
|
|
|
_FACE_DETECT_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite"
|
2026-04-18 10:17:01 +08:00
|
|
|
_FACE_DETECT_DOCKER_PATH = os.path.join(_MODELS_BASE, "mediapipe", "blaze_face_short_range.tflite")
|
2026-04-14 16:18:17 +08:00
|
|
|
_FACE_DETECT_LOCAL_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", ".models")
|
2026-04-14 16:43:42 +08:00
|
|
|
_FACE_DETECT_LOCAL_PATH = os.path.join(_FACE_DETECT_LOCAL_DIR, "blaze_face_short_range.tflite")
|
2026-04-14 16:01:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_face_detect_model():
|
2026-04-14 16:18:17 +08:00
|
|
|
"""Resolve face detector model. Docker path first, then local dev."""
|
|
|
|
|
if os.path.exists(_FACE_DETECT_DOCKER_PATH):
|
|
|
|
|
return _FACE_DETECT_DOCKER_PATH
|
|
|
|
|
if os.path.exists(_FACE_DETECT_LOCAL_PATH):
|
|
|
|
|
return _FACE_DETECT_LOCAL_PATH
|
|
|
|
|
os.makedirs(_FACE_DETECT_LOCAL_DIR, exist_ok=True)
|
2026-04-14 16:01:35 +08:00
|
|
|
import urllib.request
|
|
|
|
|
emit_progress(15, "Downloading face detection model")
|
2026-04-14 16:18:17 +08:00
|
|
|
urllib.request.urlretrieve(_FACE_DETECT_MODEL_URL, _FACE_DETECT_LOCAL_PATH)
|
|
|
|
|
return _FACE_DETECT_LOCAL_PATH
|
2026-04-14 16:01:35 +08:00
|
|
|
|
|
|
|
|
|
2026-04-13 21:57:51 +08:00
|
|
|
def enhance_faces(img_bgr, fidelity=0.7):
|
|
|
|
|
"""Enhance faces in the image using CodeFormer ONNX.
|
|
|
|
|
|
|
|
|
|
1. Detect faces with MediaPipe
|
|
|
|
|
2. Crop each face with generous padding
|
|
|
|
|
3. Run CodeFormer ONNX inference
|
|
|
|
|
4. Paste enhanced face back with feathered blending
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
img_bgr: Input BGR image.
|
|
|
|
|
fidelity: 0.0 = aggressive enhancement, 1.0 = faithful to original.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple of (enhanced BGR image, number of faces found).
|
|
|
|
|
"""
|
|
|
|
|
import mediapipe as mp
|
2026-04-20 20:53:54 +08:00
|
|
|
from gpu import safe_onnx_session
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-04-21 22:25:06 +08:00
|
|
|
# Detect faces — downscale large images for reliable MediaPipe detection
|
2026-04-13 21:57:51 +08:00
|
|
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
|
|
|
|
ih, iw = img_bgr.shape[:2]
|
|
|
|
|
|
2026-04-21 22:25:06 +08:00
|
|
|
max_dim = 1920
|
|
|
|
|
longest = max(ih, iw)
|
|
|
|
|
if longest > max_dim:
|
|
|
|
|
det_scale = max_dim / longest
|
|
|
|
|
det_rgb = cv2.resize(
|
|
|
|
|
img_rgb,
|
|
|
|
|
(int(iw * det_scale), int(ih * det_scale)),
|
|
|
|
|
interpolation=cv2.INTER_AREA,
|
|
|
|
|
)
|
|
|
|
|
inv_scale = 1.0 / det_scale
|
|
|
|
|
else:
|
|
|
|
|
det_rgb = img_rgb
|
|
|
|
|
inv_scale = 1.0
|
|
|
|
|
|
2026-04-14 16:01:35 +08:00
|
|
|
try:
|
|
|
|
|
mp_face = mp.solutions.face_detection
|
2026-04-21 22:25:06 +08:00
|
|
|
all_detections = []
|
2026-04-14 16:01:35 +08:00
|
|
|
for model_sel in [0, 1]:
|
|
|
|
|
detector = mp_face.FaceDetection(
|
|
|
|
|
model_selection=model_sel, min_detection_confidence=0.4
|
|
|
|
|
)
|
2026-04-21 22:25:06 +08:00
|
|
|
results = detector.process(det_rgb)
|
2026-04-14 16:01:35 +08:00
|
|
|
detector.close()
|
2026-04-21 22:25:06 +08:00
|
|
|
for detection in (results.detections or []):
|
|
|
|
|
all_detections.append(detection)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-04-21 22:25:06 +08:00
|
|
|
if not all_detections:
|
2026-04-14 16:01:35 +08:00
|
|
|
return img_bgr, 0
|
|
|
|
|
|
2026-04-21 22:25:06 +08:00
|
|
|
dh, dw = det_rgb.shape[:2]
|
2026-04-14 16:01:35 +08:00
|
|
|
face_boxes = []
|
2026-04-21 22:25:06 +08:00
|
|
|
for detection in all_detections:
|
2026-04-14 16:01:35 +08:00
|
|
|
bbox = detection.location_data.relative_bounding_box
|
|
|
|
|
face_boxes.append({
|
2026-04-21 22:25:06 +08:00
|
|
|
"x": int(bbox.xmin * dw * inv_scale),
|
|
|
|
|
"y": int(bbox.ymin * dh * inv_scale),
|
|
|
|
|
"w": int(bbox.width * dw * inv_scale),
|
|
|
|
|
"h": int(bbox.height * dh * inv_scale),
|
2026-04-14 16:01:35 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
model_path = _ensure_face_detect_model()
|
|
|
|
|
options = mp.tasks.vision.FaceDetectorOptions(
|
|
|
|
|
base_options=mp.tasks.BaseOptions(model_asset_path=model_path),
|
|
|
|
|
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
|
|
|
|
min_detection_confidence=0.4,
|
|
|
|
|
)
|
|
|
|
|
fd = mp.tasks.vision.FaceDetector.create_from_options(options)
|
2026-04-21 22:25:06 +08:00
|
|
|
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=det_rgb)
|
2026-04-14 16:01:35 +08:00
|
|
|
result = fd.detect(mp_image)
|
|
|
|
|
fd.close()
|
|
|
|
|
|
|
|
|
|
if not result.detections:
|
|
|
|
|
return img_bgr, 0
|
|
|
|
|
|
|
|
|
|
face_boxes = []
|
|
|
|
|
for detection in result.detections:
|
|
|
|
|
bbox = detection.bounding_box
|
|
|
|
|
face_boxes.append({
|
2026-04-21 22:25:06 +08:00
|
|
|
"x": int(bbox.origin_x * inv_scale),
|
|
|
|
|
"y": int(bbox.origin_y * inv_scale),
|
|
|
|
|
"w": int(bbox.width * inv_scale),
|
|
|
|
|
"h": int(bbox.height * inv_scale),
|
2026-04-14 16:01:35 +08:00
|
|
|
})
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
# Load CodeFormer model
|
|
|
|
|
model_path = _get_codeformer_path()
|
2026-04-20 21:42:19 +08:00
|
|
|
session, _device = safe_onnx_session(model_path)
|
2026-04-13 21:57:51 +08:00
|
|
|
input_names = [inp.name for inp in session.get_inputs()]
|
|
|
|
|
|
|
|
|
|
result = img_bgr.copy()
|
|
|
|
|
faces_enhanced = 0
|
|
|
|
|
|
2026-04-20 15:28:35 +08:00
|
|
|
for i, face_box in enumerate(face_boxes):
|
2026-04-14 16:01:35 +08:00
|
|
|
x = face_box["x"]
|
|
|
|
|
y = face_box["y"]
|
|
|
|
|
w = face_box["w"]
|
|
|
|
|
h = face_box["h"]
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-05-13 17:03:57 +08:00
|
|
|
if w < 48 or h < 48:
|
2026-04-13 21:57:51 +08:00
|
|
|
continue
|
|
|
|
|
|
2026-05-13 17:03:57 +08:00
|
|
|
# Clamp fidelity for small faces to prevent over-smoothing
|
|
|
|
|
face_fidelity = fidelity
|
|
|
|
|
if max(w, h) < 120:
|
|
|
|
|
face_fidelity = max(fidelity, 0.85)
|
|
|
|
|
|
2026-04-13 21:57:51 +08:00
|
|
|
# Expand bounding box by ~80% for hair, forehead, chin
|
|
|
|
|
pad_x = int(w * 0.8)
|
|
|
|
|
pad_y = int(h * 0.8)
|
|
|
|
|
x1 = max(0, x - pad_x)
|
|
|
|
|
y1 = max(0, y - pad_y)
|
|
|
|
|
x2 = min(iw, x + w + pad_x)
|
|
|
|
|
y2 = min(ih, y + h + pad_y)
|
|
|
|
|
|
|
|
|
|
# Crop face region
|
|
|
|
|
face_crop = img_bgr[y1:y2, x1:x2].copy()
|
|
|
|
|
crop_h, crop_w = face_crop.shape[:2]
|
|
|
|
|
|
|
|
|
|
# Resize to 512x512 for CodeFormer
|
|
|
|
|
face_resized = cv2.resize(face_crop, (CODEFORMER_SIZE, CODEFORMER_SIZE),
|
|
|
|
|
interpolation=cv2.INTER_LANCZOS4)
|
|
|
|
|
|
|
|
|
|
# Preprocess: BGR -> RGB, normalize to [-1, 1]
|
|
|
|
|
face_rgb = cv2.cvtColor(face_resized, cv2.COLOR_BGR2RGB)
|
|
|
|
|
face_input = face_rgb.astype(np.float32) / 255.0
|
|
|
|
|
face_input = (face_input - 0.5) / 0.5
|
|
|
|
|
face_input = np.transpose(face_input, (2, 0, 1))
|
|
|
|
|
face_input = np.expand_dims(face_input, 0) # (1, 3, 512, 512)
|
|
|
|
|
|
|
|
|
|
# Build model inputs
|
|
|
|
|
model_inputs = {}
|
|
|
|
|
for name in input_names:
|
|
|
|
|
if name == "input":
|
|
|
|
|
model_inputs[name] = face_input.astype(np.float32)
|
|
|
|
|
elif name == "weight":
|
2026-05-13 17:03:57 +08:00
|
|
|
model_inputs[name] = np.array([face_fidelity]).astype(np.float64)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
# Run inference
|
|
|
|
|
try:
|
|
|
|
|
output = session.run(None, model_inputs)[0][0] # (3, 512, 512)
|
2026-04-17 23:06:31 +08:00
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[restore] CodeFormer inference failed for face {i}: {e}", file=sys.stderr, flush=True)
|
2026-04-13 21:57:51 +08:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Postprocess: [-1, 1] -> [0, 255], RGB -> BGR
|
|
|
|
|
output = np.clip(output, -1, 1)
|
|
|
|
|
output = (output + 1) / 2
|
|
|
|
|
output = np.transpose(output, (1, 2, 0)) # (512, 512, 3)
|
|
|
|
|
output = (output * 255.0).clip(0, 255).astype(np.uint8)
|
|
|
|
|
output_bgr = cv2.cvtColor(output, cv2.COLOR_RGB2BGR)
|
|
|
|
|
|
|
|
|
|
# Resize back to original crop size
|
|
|
|
|
enhanced_crop = cv2.resize(output_bgr, (crop_w, crop_h),
|
|
|
|
|
interpolation=cv2.INTER_LANCZOS4)
|
|
|
|
|
|
|
|
|
|
# Create feathered elliptical mask for smooth blending
|
|
|
|
|
blend_mask = np.zeros((crop_h, crop_w), dtype=np.float32)
|
|
|
|
|
center = (crop_w // 2, crop_h // 2)
|
|
|
|
|
axes = (int(crop_w * 0.42), int(crop_h * 0.45))
|
|
|
|
|
cv2.ellipse(blend_mask, center, axes, 0, 0, 360, 1.0, -1)
|
|
|
|
|
|
|
|
|
|
# Feather the mask edges
|
|
|
|
|
blur_r = max(5, min(crop_w, crop_h) // 8) | 1
|
|
|
|
|
blend_mask = cv2.GaussianBlur(blend_mask, (blur_r, blur_r), 0)
|
|
|
|
|
blend_mask = blend_mask[:, :, np.newaxis]
|
|
|
|
|
|
|
|
|
|
# Blend enhanced face into result
|
|
|
|
|
face_region = result[y1:y2, x1:x2].astype(np.float32)
|
|
|
|
|
blended = face_region * (1.0 - blend_mask) + enhanced_crop.astype(np.float32) * blend_mask
|
|
|
|
|
result[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8)
|
|
|
|
|
faces_enhanced += 1
|
|
|
|
|
|
|
|
|
|
return result, faces_enhanced
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Denoising ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def denoise_image(img_bgr, strength=40):
|
|
|
|
|
"""Apply noise reduction using Non-Local Means in LAB color space.
|
|
|
|
|
|
|
|
|
|
Processes luminance and chrominance channels independently
|
|
|
|
|
for better color preservation.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
img_bgr: Input BGR image.
|
|
|
|
|
strength: 0-100, higher = more aggressive denoising.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Denoised BGR image.
|
|
|
|
|
"""
|
|
|
|
|
if strength <= 0:
|
|
|
|
|
return img_bgr
|
|
|
|
|
|
|
|
|
|
# Map 0-100 to NLMeans filter strength
|
|
|
|
|
h = 3 + (strength / 100) * 12 # 3-15
|
|
|
|
|
|
|
|
|
|
lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
|
|
|
|
|
l_ch, a_ch, b_ch = cv2.split(lab)
|
|
|
|
|
|
|
|
|
|
# Denoise luminance channel
|
|
|
|
|
l_ch = cv2.fastNlMeansDenoising(l_ch, None, h, 7, 21)
|
|
|
|
|
|
|
|
|
|
# Lightly denoise color channels to remove chroma noise
|
|
|
|
|
color_h = h * 0.5
|
|
|
|
|
if color_h > 1:
|
|
|
|
|
a_ch = cv2.fastNlMeansDenoising(a_ch, None, color_h, 7, 21)
|
|
|
|
|
b_ch = cv2.fastNlMeansDenoising(b_ch, None, color_h, 7, 21)
|
|
|
|
|
|
|
|
|
|
result = cv2.merge([l_ch, a_ch, b_ch])
|
|
|
|
|
return cv2.cvtColor(result, cv2.COLOR_LAB2BGR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── B&W detection ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def is_grayscale(img_bgr):
|
|
|
|
|
"""Detect if an image is grayscale/B&W.
|
|
|
|
|
|
|
|
|
|
Checks if color channels are nearly identical by measuring
|
|
|
|
|
the standard deviation of channel differences.
|
|
|
|
|
"""
|
|
|
|
|
if len(img_bgr.shape) == 2:
|
|
|
|
|
return True
|
|
|
|
|
if img_bgr.shape[2] == 1:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
b, g, r = cv2.split(img_bgr)
|
|
|
|
|
diff_rg = np.abs(r.astype(np.float32) - g.astype(np.float32)).mean()
|
|
|
|
|
diff_rb = np.abs(r.astype(np.float32) - b.astype(np.float32)).mean()
|
|
|
|
|
diff_gb = np.abs(g.astype(np.float32) - b.astype(np.float32)).mean()
|
|
|
|
|
avg_diff = (diff_rg + diff_rb + diff_gb) / 3
|
|
|
|
|
|
|
|
|
|
return bool(avg_diff < 5.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── DDColor colorization ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def colorize_bw(img_bgr, intensity=0.85):
|
|
|
|
|
"""Colorize a B&W image using DDColor ONNX.
|
|
|
|
|
|
|
|
|
|
Reuses the DDColor model that the colorize tool already downloads.
|
|
|
|
|
"""
|
2026-04-20 20:53:54 +08:00
|
|
|
from gpu import safe_onnx_session
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
if not os.path.exists(DDCOLOR_MODEL_PATH):
|
2026-04-25 07:23:58 +08:00
|
|
|
raise FileNotFoundError(
|
|
|
|
|
f"DDColor model not found at {DDCOLOR_MODEL_PATH}. "
|
|
|
|
|
"Install the 'object-eraser-colorize' bundle to enable colorization."
|
|
|
|
|
)
|
2026-04-13 21:57:51 +08:00
|
|
|
|
2026-04-20 21:42:19 +08:00
|
|
|
session, _device = safe_onnx_session(DDCOLOR_MODEL_PATH)
|
2026-04-13 21:57:51 +08:00
|
|
|
input_name = session.get_inputs()[0].name
|
|
|
|
|
input_shape = session.get_inputs()[0].shape
|
|
|
|
|
model_size = (
|
|
|
|
|
input_shape[2]
|
|
|
|
|
if len(input_shape) == 4 and isinstance(input_shape[2], int)
|
|
|
|
|
else 512
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
orig_h, orig_w = img_bgr.shape[:2]
|
|
|
|
|
img_lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
|
|
|
|
|
orig_l = img_lab[:, :, 0].astype(np.float32)
|
|
|
|
|
|
|
|
|
|
# Prepare input
|
|
|
|
|
img_resized = cv2.resize(img_bgr, (model_size, model_size))
|
|
|
|
|
img_float = img_resized.astype(np.float32) / 255.0
|
|
|
|
|
img_nchw = np.transpose(img_float, (2, 0, 1))[np.newaxis, ...]
|
|
|
|
|
|
|
|
|
|
output = session.run(None, {input_name: img_nchw})[0]
|
|
|
|
|
ab_pred = output[0] # (2, model_size, model_size)
|
|
|
|
|
|
|
|
|
|
# Resize ab channels back to original
|
|
|
|
|
ab_resized = np.zeros((2, orig_h, orig_w), dtype=np.float32)
|
|
|
|
|
for i in range(2):
|
|
|
|
|
ab_resized[i] = cv2.resize(ab_pred[i], (orig_w, orig_h))
|
|
|
|
|
|
|
|
|
|
ab_a = np.clip(ab_resized[0], -128, 127)
|
|
|
|
|
ab_b = np.clip(ab_resized[1], -128, 127)
|
|
|
|
|
|
|
|
|
|
# Apply intensity blending
|
|
|
|
|
if intensity < 1.0:
|
|
|
|
|
orig_a = img_lab[:, :, 1].astype(np.float32) - 128.0
|
|
|
|
|
orig_b = img_lab[:, :, 2].astype(np.float32) - 128.0
|
|
|
|
|
ab_a = orig_a * (1 - intensity) + ab_a * intensity
|
|
|
|
|
ab_b = orig_b * (1 - intensity) + ab_b * intensity
|
|
|
|
|
|
|
|
|
|
result_lab = np.zeros((orig_h, orig_w, 3), dtype=np.uint8)
|
|
|
|
|
result_lab[:, :, 0] = np.clip(orig_l, 0, 255).astype(np.uint8)
|
|
|
|
|
result_lab[:, :, 1] = np.clip(ab_a + 128.0, 0, 255).astype(np.uint8)
|
|
|
|
|
result_lab[:, :, 2] = np.clip(ab_b + 128.0, 0, 255).astype(np.uint8)
|
|
|
|
|
|
|
|
|
|
return cv2.cvtColor(result_lab, cv2.COLOR_LAB2BGR), True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Main pipeline ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
input_path = sys.argv[1]
|
|
|
|
|
output_path = sys.argv[2]
|
|
|
|
|
settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
|
|
|
|
|
|
|
|
|
|
scratch_removal = settings.get("scratchRemoval", True)
|
|
|
|
|
face_enhancement = settings.get("faceEnhancement", True)
|
|
|
|
|
fidelity = float(settings.get("fidelity", 0.7))
|
|
|
|
|
do_denoise = settings.get("denoise", True)
|
2026-05-13 17:03:57 +08:00
|
|
|
denoise_strength = float(settings.get("denoiseStrength", 25))
|
2026-04-13 21:57:51 +08:00
|
|
|
do_colorize = settings.get("colorize", False)
|
2026-05-13 17:03:57 +08:00
|
|
|
colorize_strength = float(settings.get("colorizeStrength", 85)) / 100.0
|
2026-04-13 21:57:51 +08:00
|
|
|
|
|
|
|
|
try:
|
2026-04-20 21:42:19 +08:00
|
|
|
from gpu import gpu_available
|
|
|
|
|
device = "cuda" if gpu_available() else "cpu"
|
|
|
|
|
|
2026-04-13 21:57:51 +08:00
|
|
|
emit_progress(5, "Opening image")
|
|
|
|
|
img_bgr = cv2.imread(input_path, cv2.IMREAD_COLOR)
|
|
|
|
|
if img_bgr is None:
|
|
|
|
|
pil_img = Image.open(input_path).convert("RGB")
|
|
|
|
|
img_bgr = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
|
|
|
|
|
|
|
|
|
orig_h, orig_w = img_bgr.shape[:2]
|
|
|
|
|
result = img_bgr.copy()
|
|
|
|
|
steps_applied = []
|
|
|
|
|
|
|
|
|
|
emit_progress(8, "Analyzing photo")
|
|
|
|
|
bw_detected = is_grayscale(img_bgr)
|
|
|
|
|
scratch_coverage = 0.0
|
|
|
|
|
|
|
|
|
|
if scratch_removal:
|
|
|
|
|
emit_progress(10, "Detecting damage")
|
2026-05-13 17:03:57 +08:00
|
|
|
scratch_mask = detect_scratches(result)
|
2026-04-13 21:57:51 +08:00
|
|
|
scratch_pixels = np.count_nonzero(scratch_mask)
|
|
|
|
|
total_pixels = scratch_mask.shape[0] * scratch_mask.shape[1]
|
|
|
|
|
scratch_coverage = float(scratch_pixels / total_pixels)
|
|
|
|
|
|
2026-05-13 17:03:57 +08:00
|
|
|
if scratch_coverage > 0.001:
|
2026-04-13 21:57:51 +08:00
|
|
|
emit_progress(15, f"Repairing damage ({scratch_coverage:.1%} affected)")
|
|
|
|
|
result = inpaint_damage(result, scratch_mask)
|
|
|
|
|
steps_applied.append("scratch_removal")
|
|
|
|
|
emit_progress(30, "Damage repaired")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(15, "No significant damage detected")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(15, "Scratch removal disabled")
|
|
|
|
|
|
|
|
|
|
faces_found = 0
|
|
|
|
|
if face_enhancement:
|
|
|
|
|
emit_progress(35, "Detecting faces")
|
|
|
|
|
try:
|
|
|
|
|
result, faces_found = enhance_faces(result, fidelity)
|
|
|
|
|
if faces_found > 0:
|
|
|
|
|
steps_applied.append("face_enhancement")
|
|
|
|
|
emit_progress(65, f"Enhanced {faces_found} face{'s' if faces_found != 1 else ''}")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(65, "No faces detected")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
emit_progress(65, f"Face enhancement skipped: {str(e)[:40]}")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(65, "Face enhancement disabled")
|
|
|
|
|
|
|
|
|
|
if do_denoise and denoise_strength > 0:
|
|
|
|
|
emit_progress(70, "Reducing noise")
|
|
|
|
|
result = denoise_image(result, denoise_strength)
|
|
|
|
|
steps_applied.append("denoise")
|
|
|
|
|
emit_progress(80, "Noise reduced")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(80, "Denoising disabled")
|
|
|
|
|
|
|
|
|
|
colorized = False
|
|
|
|
|
if do_colorize and bw_detected:
|
2026-05-13 15:42:24 +08:00
|
|
|
total_pixels = orig_h * orig_w
|
|
|
|
|
has_gpu = device == "cuda"
|
|
|
|
|
max_pixels = 8_000_000 if has_gpu else 2_000_000
|
|
|
|
|
|
|
|
|
|
if total_pixels > max_pixels and not has_gpu:
|
|
|
|
|
mp = total_pixels / 1_000_000
|
|
|
|
|
emit_progress(92, f"Colorization skipped: image too large for CPU ({mp:.1f}MP, max 2MP)")
|
|
|
|
|
elif not os.path.exists(DDCOLOR_MODEL_PATH):
|
|
|
|
|
emit_progress(92, "Colorization skipped: DDColor model not installed")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(82, "Colorizing B&W photo")
|
|
|
|
|
try:
|
2026-05-13 17:03:57 +08:00
|
|
|
result, colorized = colorize_bw(result, intensity=colorize_strength)
|
2026-05-13 15:42:24 +08:00
|
|
|
if colorized:
|
|
|
|
|
steps_applied.append("colorize")
|
|
|
|
|
emit_progress(92, "Colorization complete")
|
|
|
|
|
else:
|
|
|
|
|
emit_progress(92, "Colorization model not available")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
emit_progress(92, f"Colorization skipped: {str(e)[:40]}")
|
2026-04-13 21:57:51 +08:00
|
|
|
else:
|
|
|
|
|
emit_progress(92, "Colorization skipped")
|
|
|
|
|
|
|
|
|
|
emit_progress(95, "Saving result")
|
|
|
|
|
cv2.imwrite(output_path, result)
|
|
|
|
|
|
|
|
|
|
print(json.dumps({
|
|
|
|
|
"success": True,
|
|
|
|
|
"width": orig_w,
|
|
|
|
|
"height": orig_h,
|
|
|
|
|
"steps": steps_applied,
|
|
|
|
|
"scratchCoverage": round(scratch_coverage * 100, 2),
|
|
|
|
|
"facesEnhanced": faces_found,
|
|
|
|
|
"isGrayscale": bw_detected,
|
|
|
|
|
"colorized": colorized,
|
2026-04-20 21:42:19 +08:00
|
|
|
"device": device,
|
2026-04-13 21:57:51 +08:00
|
|
|
"output_path": output_path,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(json.dumps({"success": False, "error": str(e)}))
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|