8 tasks covering: TS bridge update, API schema changes, Python scratch detection rewrite, tiled LaMa inpainting, face enhancement guard, frontend settings, E2E tests, and diagnostic verification.
36 KiB
Restore Photo Quality Overhaul - Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix catastrophic scratch over-detection, LaMa resolution loss, face over-smoothing, and excessive denoising in the restore-photo pipeline.
Architecture: The Python pipeline (restore.py) gets 4 rewrites: scratch detection (8-angle + Otsu + component filtering + coverage cap), tiled LaMa inpainting (pad for small, tile for large), face enhancement guard (min 48px, fidelity clamp for small faces), and main function (remove mode, add colorizeStrength). The TS bridge, API route, frontend, and i18n each get small schema/UI changes. All existing tests are updated to match.
Tech Stack: Python (OpenCV, NumPy, ONNX Runtime), TypeScript (Vitest, Zod, Fastify), React (Tailwind), Playwright
Spec: docs/superpowers/specs/2026-05-13-restore-photo-quality-overhaul-design.md
Task 1: Update TypeScript bridge and unit tests
Files:
-
Modify:
packages/ai/src/restoration.ts -
Modify:
tests/unit/ai/restoration.test.ts -
Step 1: Update unit tests - remove mode, add colorizeStrength
In tests/unit/ai/restoration.test.ts, make these changes:
- Remove the test
"serializes mode option"(lines 72-77) - Remove the test
"passes mode option"(lines 358-363) - Update
"serializes all options together"(lines 114-128) to removemodeand addcolorizeStrength:
it("serializes all options together", async () => {
const allOptions = {
scratchRemoval: true,
faceEnhancement: true,
fidelity: 0.8,
denoise: true,
denoiseStrength: 0.5,
colorize: true,
colorizeStrength: 75,
};
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR, allOptions);
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
expect(JSON.parse(args[2])).toEqual(allOptions);
});
- Add a new test for colorizeStrength serialization after the colorize test (after line 112):
it("serializes colorizeStrength option", async () => {
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR, { colorizeStrength: 60 });
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
expect(JSON.parse(args[2])).toEqual({ colorizeStrength: 60 });
});
- Step 2: Run tests to verify they fail
Run: pnpm vitest run tests/unit/ai/restoration.test.ts
Expected: The "serializes all options together" test fails because RestorePhotoOptions still has mode and lacks colorizeStrength. The new colorizeStrength test fails because the type doesn't exist yet.
- Step 3: Update the TypeScript bridge interface
In packages/ai/src/restoration.ts, update the RestorePhotoOptions interface:
Remove:
mode?: string;
Add:
colorizeStrength?: number;
The final interface should be:
export interface RestorePhotoOptions {
scratchRemoval?: boolean;
faceEnhancement?: boolean;
fidelity?: number;
denoise?: boolean;
denoiseStrength?: number;
colorize?: boolean;
colorizeStrength?: number;
}
- Step 4: Run tests to verify they pass
Run: pnpm vitest run tests/unit/ai/restoration.test.ts
Expected: All tests pass.
- Step 5: Commit
git add packages/ai/src/restoration.ts tests/unit/ai/restoration.test.ts
git commit -m "refactor(ai): remove mode, add colorizeStrength to restore options"
Task 2: Update API route schema and integration tests
Files:
-
Modify:
apps/api/src/routes/tools/restore-photo.ts -
Modify:
tests/integration/restore-photo.test.ts -
Step 1: Update the Zod schemas in the API route
In apps/api/src/routes/tools/restore-photo.ts, update the settingsSchema (lines 21-29). Remove mode, add colorizeStrength, change denoiseStrength default to 25:
const settingsSchema = z.object({
scratchRemoval: z.boolean().default(true),
faceEnhancement: z.boolean().default(true),
fidelity: z.number().min(0).max(1).default(0.7),
denoise: z.boolean().default(true),
denoiseStrength: z.number().min(0).max(100).default(25),
colorize: z.boolean().default(false),
colorizeStrength: z.number().min(0).max(100).default(85),
});
Update the fire-and-forget processing block (lines 177-189) to pass colorizeStrength and remove mode:
const result = await restorePhoto(
fileBuffer,
join(workspacePath, "output"),
{
scratchRemoval: settings.scratchRemoval,
faceEnhancement: settings.faceEnhancement,
fidelity: settings.fidelity,
denoise: settings.denoise,
denoiseStrength: settings.denoiseStrength,
colorize: settings.colorize,
colorizeStrength: settings.colorizeStrength,
},
onProgress,
);
Remove mode: settings.mode from the log.info call at line 158. Change to:
log.info(
{ toolId: "restore-photo", imageSize: originalSize },
"Starting photo restoration",
);
Update the registerToolProcessFn schema (lines 259-267) to match the same changes:
registerToolProcessFn({
toolId: "restore-photo",
settingsSchema: z.object({
scratchRemoval: z.boolean().default(true),
faceEnhancement: z.boolean().default(true),
fidelity: z.number().min(0).max(1).default(0.7),
denoise: z.boolean().default(true),
denoiseStrength: z.number().min(0).max(100).default(25),
colorize: z.boolean().default(false),
colorizeStrength: z.number().min(0).max(100).default(85),
}),
And update the pipeline process function (lines 273-281) to remove mode and add colorizeStrength:
const result = await restorePhoto(orientedBuffer, join(workspacePath, "output"), {
scratchRemoval: s.scratchRemoval,
faceEnhancement: s.faceEnhancement,
fidelity: s.fidelity,
denoise: s.denoise,
denoiseStrength: s.denoiseStrength,
colorize: s.colorize,
colorizeStrength: s.colorizeStrength,
});
- Step 2: Update integration tests
In tests/integration/restore-photo.test.ts:
- Update test
"accepts auto mode with all features enabled"(lines 86-112). Removemode: "auto"from settings, rename test:
it("accepts all features enabled", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
scratchRemoval: true,
faceEnhancement: true,
denoise: true,
denoiseStrength: 25,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
- Update test
"accepts heavy mode with colorize enabled"(lines 114-138). Removemode, addcolorizeStrength, rename test:
it("accepts colorize with custom strength", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
colorize: true,
colorizeStrength: 50,
fidelity: 0.9,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
- Update test
"accepts light mode with features disabled"(lines 140-165). Removemode, rename:
it("accepts all features disabled", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
scratchRemoval: false,
faceEnhancement: false,
denoise: false,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
- Update
"rejects invalid mode value"test (lines 276-300). Sincemodeis no longer in the schema, an unknownmodefield is just stripped by Zod (not rejected). Replace with acolorizeStrengthout-of-range test:
it("rejects colorizeStrength out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ colorizeStrength: 150 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
- Add a backward-compat test after the validation tests:
it("ignores old mode field gracefully", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ mode: "heavy", scratchRemoval: true }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
- Step 3: Run unit and integration tests
Run: pnpm vitest run tests/unit/ai/restoration.test.ts tests/integration/restore-photo.test.ts
Expected: All pass. (Integration tests that hit the API will get 501 since the feature isn't installed locally, but 501 is an accepted status.)
- Step 4: Commit
git add apps/api/src/routes/tools/restore-photo.ts tests/integration/restore-photo.test.ts
git commit -m "refactor(api): remove mode, add colorizeStrength, lower denoise default to 25"
Task 3: Rewrite Python scratch detection
Files:
-
Modify:
packages/ai/python/restore.py(lines 52-127:detect_scratches+_make_line_kernel) -
Step 1: Replace scratch detection functions
In packages/ai/python/restore.py, replace lines 52-127 (the detect_scratches function and _make_line_kernel function) with:
# ── Scratch detection ─────────────────────────────────────────────────
def detect_scratches(img_bgr, _sensitivity=None):
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
h, w = gray.shape
base_dim = min(h, w)
# Pre-filter compression artifacts before enhancement
filtered = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
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)
for ksize in kernel_sizes:
ksize = ksize | 1
for angle in angles:
kernel = _make_line_kernel_rotated(ksize, angle)
blackhat = cv2.morphologyEx(enhanced, cv2.MORPH_BLACKHAT, kernel)
tophat = cv2.morphologyEx(enhanced, cv2.MORPH_TOPHAT, kernel)
combined = cv2.add(blackhat, tophat)
response = np.maximum(response, combined.astype(np.float32))
# 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)
if otsu_thresh < 60:
return np.zeros_like(gray)
# Connected component filtering
mask = _filter_components(mask, h * w)
# Post-processing
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open)
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close)
# 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)
return mask
def _make_line_kernel_rotated(size, angle_deg):
kernel = np.zeros((size, size), np.uint8)
mid = size // 2
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
- Step 2: Verify scratch detection on diagnostic images
Run:
source /tmp/restore-venv/bin/activate && python3 -c "
import sys; sys.path.insert(0, 'packages/ai/python')
import cv2, numpy as np
from PIL import Image
from restore import detect_scratches
for path in [
'/Users/sidd/Downloads/sample/woman-baby1.webp',
'/Users/sidd/Downloads/sample/images2.jpg',
'/Users/sidd/Downloads/sample/images.jpg',
'/Users/sidd/Downloads/sample/ai-old-photo-restoration-example-before.webp',
]:
img = Image.open(path).convert('RGB')
img_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
mask = detect_scratches(img_bgr)
h, w = img_bgr.shape[:2]
cov = np.count_nonzero(mask) / (h * w) * 100
print(f'{path.split(\"/\")[-1]:50s} {cov:6.2f}% ({w}x{h})')
"
Expected: All coverages should be < 15%. The small images (images2.jpg, images.jpg) should drop dramatically from 68.7%/30.6% to single digits. If any image exceeds 15%, the coverage cap should activate.
- Step 3: Commit
git add packages/ai/python/restore.py
git commit -m "fix(ai): rewrite scratch detection with 8-angle Otsu and component filtering"
Task 4: Rewrite LaMa inpainting for native resolution
Files:
-
Modify:
packages/ai/python/restore.py(lines 145-199:inpaint_damage) -
Step 1: Replace inpaint_damage function
In packages/ai/python/restore.py, replace the inpaint_damage function (lines 145-199) with:
def inpaint_damage(img_bgr, mask):
from gpu import safe_onnx_session
model_path = _get_lama_path()
session, _device = safe_onnx_session(model_path)
orig_h, orig_w = img_bgr.shape[:2]
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
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)
# Feathered composite: only replace masked areas
mask_float = mask.astype(np.float32) / 255.0
feather_r = max(5, min(orig_w, orig_h) // 100)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (feather_r, feather_r))
dilated = cv2.dilate(mask_float, kernel, iterations=1)
blur_size = feather_r * 2 + 1
alpha = cv2.GaussianBlur(dilated, (blur_size, blur_size), 0)
alpha = np.clip(alpha * 1.2, 0.0, 1.0)[:, :, np.newaxis]
composited = (img_rgb.astype(np.float32) * (1.0 - alpha) +
inpainted_rgb.astype(np.float32) * alpha)
composited = np.clip(composited, 0, 255).astype(np.uint8)
return cv2.cvtColor(composited, cv2.COLOR_RGB2BGR)
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)
- Step 2: Verify inpainting on a sample image
Run:
source /tmp/restore-venv/bin/activate && python3 -c "
import sys; sys.path.insert(0, 'packages/ai/python')
import cv2, numpy as np
from PIL import Image
from restore import detect_scratches, inpaint_damage
img = Image.open('/Users/sidd/Downloads/sample/woman-baby1.webp').convert('RGB')
img_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
mask = detect_scratches(img_bgr)
result = inpaint_damage(img_bgr, mask)
cv2.imwrite('/tmp/restore-diagnostic/v2_inpaint.png', result)
print(f'Inpainting complete: {result.shape}')
"
Expected: Completes without error. Output image at /tmp/restore-diagnostic/v2_inpaint.png should show scratch reduction without destroying faces.
- Step 3: Commit
git add packages/ai/python/restore.py
git commit -m "fix(ai): tiled LaMa inpainting at native resolution"
Task 5: Face enhancement guard and main pipeline update
Files:
-
Modify:
packages/ai/python/restore.py(lines 339-414: face loop inenhance_faces, lines 537-674:main) -
Step 1: Update enhance_faces minimum size and fidelity clamping
In packages/ai/python/restore.py, in the enhance_faces function, change the face size check (line 345) from:
if w < 24 or h < 24:
continue
to:
if w < 48 or h < 48:
continue
# Clamp fidelity for small faces to prevent over-smoothing
face_fidelity = fidelity
if max(w, h) < 120:
face_fidelity = max(fidelity, 0.85)
Then update line 377 where fidelity is used for the weight model input to use face_fidelity instead:
elif name == "weight":
model_inputs[name] = np.array([face_fidelity]).astype(np.float64)
- Step 2: Rewrite the main function
Replace the entire main() function (lines 537-674) with:
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)
denoise_strength = float(settings.get("denoiseStrength", 25))
do_colorize = settings.get("colorize", False)
colorize_strength = float(settings.get("colorizeStrength", 85)) / 100.0
try:
from gpu import gpu_available
device = "cuda" if gpu_available() else "cpu"
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")
scratch_mask = detect_scratches(result)
scratch_pixels = np.count_nonzero(scratch_mask)
total_pixels = scratch_mask.shape[0] * scratch_mask.shape[1]
scratch_coverage = float(scratch_pixels / total_pixels)
if scratch_coverage > 0.001:
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:
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:
result, colorized = colorize_bw(result, intensity=colorize_strength)
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]}")
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,
"device": device,
"output_path": output_path,
}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
Key differences from current:
-
No
modeparameter, noscratch_sensitivity -
denoise_strengthdefault is 25 (was 40) -
colorize_strengthderived fromcolorizeStrengthsetting (divided by 100) -
detect_scratches(result)called without sensitivity arg -
Colorization uses
colorize_strengthinstead of hardcoded0.85 -
Step 3: Run full pipeline on diagnostic images
Run:
source /tmp/restore-venv/bin/activate && python3 -c "
import sys, os; sys.path.insert(0, 'packages/ai/python')
os.environ.setdefault('MODELS_PATH', '/opt/models')
import cv2, numpy as np
from PIL import Image
from restore import detect_scratches, inpaint_damage, enhance_faces, denoise_image
for path in ['/Users/sidd/Downloads/sample/woman-baby1.webp',
'/Users/sidd/Downloads/sample/images2.jpg',
'/Users/sidd/Downloads/sample/ai-old-photo-restoration-example-before.webp']:
name = os.path.splitext(os.path.basename(path))[0]
img = Image.open(path).convert('RGB')
img_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
mask = detect_scratches(img_bgr)
cov = np.count_nonzero(mask) / (img_bgr.shape[0]*img_bgr.shape[1])
print(f'{name}: mask={cov:.1%}')
if cov > 0.001:
img_bgr = inpaint_damage(img_bgr, mask)
result, n = enhance_faces(img_bgr, 0.7)
result = denoise_image(result, 25)
cv2.imwrite(f'/tmp/restore-diagnostic/{name}_v2_final.png', result)
print(f' faces={n}, saved')
"
Expected: All complete without error. Mask coverages in single digits. Output images should preserve faces and show scratch reduction.
- Step 4: Commit
git add packages/ai/python/restore.py
git commit -m "fix(ai): face guard for small faces, remove mode system, add colorizeStrength"
Task 6: Frontend settings and i18n
Files:
-
Modify:
apps/web/src/components/tools/restore-photo-settings.tsx -
Modify:
packages/shared/src/i18n/en.ts -
Step 1: Add i18n key
In packages/shared/src/i18n/en.ts, find the tools section and add colorizeStrength. Add it near other tool-related keys:
colorizeStrength: "Colorize Strength",
- Step 2: Update the frontend settings component
In apps/web/src/components/tools/restore-photo-settings.tsx:
-
Remove the
Modetype,MODESarray, and mode-related state/effects (lines 7-13, 24, 37-38, 56modereference, 64modedependency, 67activeMode). -
Add
colorizeStrengthstate:
const [colorizeStrength, setColorizeStrength] = useState(85);
- Add init for
colorizeStrengthin the one-time init effect:
if (initialSettings.colorizeStrength != null)
setColorizeStrength(Number(initialSettings.colorizeStrength));
- Change denoiseStrength default from 40 to 25:
const [denoiseStrength, setDenoiseStrength] = useState(25);
- Update the settings emission effect to remove
modeand addcolorizeStrength:
useEffect(() => {
onChangeRef.current?.({
scratchRemoval,
faceEnhancement,
fidelity: fidelity / 100,
denoise,
denoiseStrength,
colorize,
colorizeStrength,
});
}, [scratchRemoval, faceEnhancement, fidelity, denoise, denoiseStrength, colorize, colorizeStrength]);
-
Remove the entire mode selector JSX (lines 72-91: the "Restoration Mode" label, 3-column grid, and mode description paragraph).
-
Add a colorize strength slider after the Auto-Colorize checkbox, inside the same conditional pattern as the face fidelity slider:
{colorize && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Colorize Strength</p>
<span className="text-xs font-mono tabular-nums">{colorizeStrength}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={colorizeStrength}
onChange={(e) => setColorizeStrength(Number(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none bg-muted accent-primary"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Subtle</span>
<span>Vivid</span>
</div>
</div>
)}
- Step 3: Run typecheck
Run: pnpm typecheck
Expected: No type errors.
- Step 4: Commit
git add apps/web/src/components/tools/restore-photo-settings.tsx packages/shared/src/i18n/en.ts
git commit -m "feat(web): remove mode selector, add colorize strength slider"
Task 7: Update E2E tests
Files:
-
Modify:
tests/e2e/restore-photo.spec.ts -
Step 1: Update the UI controls test
In tests/e2e/restore-photo.spec.ts, update the "page loads with correct UI controls" test (lines 35-51). Remove the mode button assertions and add colorize strength test:
test("page loads with correct UI controls", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Mode buttons should NOT be present
await expect(page.getByRole("button", { name: "Light" })).not.toBeVisible();
await expect(page.getByRole("button", { name: "Auto" })).not.toBeVisible();
await expect(page.getByRole("button", { name: "Heavy" })).not.toBeVisible();
// Feature checkboxes
await expect(page.getByText("Scratch Removal")).toBeVisible();
await expect(page.getByText("Face Enhancement")).toBeVisible();
await expect(page.getByText("Noise Reduction")).toBeVisible();
await expect(page.getByText("Auto-Colorize")).toBeVisible();
// Submit button disabled with no file
await expect(page.getByTestId("restore-photo-submit")).toBeDisabled();
});
- Add a test for the colorize strength slider after the denoise strength test (after line 100):
test("colorize strength slider visible only when auto-colorize enabled", async ({
loggedInPage: page,
}) => {
await skipIfFeatureNotInstalled(page);
const strengthLabel = page.getByText("Colorize Strength");
// Auto-colorize is OFF by default - strength hidden
await expect(strengthLabel).not.toBeVisible();
// Enable auto-colorize - strength visible
await page.getByText("Auto-Colorize").click();
await expect(strengthLabel).toBeVisible();
// Disable auto-colorize - strength hidden again
await page.getByText("Auto-Colorize").click();
await expect(strengthLabel).not.toBeVisible();
});
- Update
"JPG - auto mode restores and shows download"test name (line 102) to remove "auto mode":
test("JPG - restores and shows download", async ({ loggedInPage: page }) => {
- Step 2: Run lint check
Run: pnpm lint
Expected: No errors.
- Step 3: Commit
git add tests/e2e/restore-photo.spec.ts
git commit -m "test(e2e): update restore-photo tests for mode removal and colorize strength"
Task 8: Diagnostic verification
Files: None (verification only)
- Step 1: Run full pipeline on all 4 diagnostic images
source /tmp/restore-venv/bin/activate && python3 -c "
import sys, os; sys.path.insert(0, 'packages/ai/python')
os.environ.setdefault('MODELS_PATH', '/opt/models')
import cv2, numpy as np
from PIL import Image
from restore import detect_scratches, inpaint_damage, enhance_faces, denoise_image
samples = [
'/Users/sidd/Downloads/sample/woman-baby1.webp',
'/Users/sidd/Downloads/sample/images2.jpg',
'/Users/sidd/Downloads/sample/images.jpg',
'/Users/sidd/Downloads/sample/ai-old-photo-restoration-example-before.webp',
]
for path in samples:
name = os.path.splitext(os.path.basename(path))[0]
img = Image.open(path).convert('RGB')
img_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
h, w = img_bgr.shape[:2]
mask = detect_scratches(img_bgr)
cov = np.count_nonzero(mask) / (h * w)
print(f'{name} ({w}x{h}): mask={cov:.1%}')
result = img_bgr.copy()
if cov > 0.001:
result = inpaint_damage(result, mask)
result, n = enhance_faces(result, 0.7)
result = denoise_image(result, 25)
cv2.imwrite(f'/tmp/restore-diagnostic/{name}_v2_final.png', result)
# Save mask overlay for comparison
overlay = img_bgr.copy()
overlay[mask > 0] = [0, 0, 255]
cv2.imwrite(f'/tmp/restore-diagnostic/{name}_v2_mask.png',
cv2.addWeighted(img_bgr, 0.7, overlay, 0.3, 0))
print(f' faces={n}, saved to /tmp/restore-diagnostic/{name}_v2_*.png')
"
- Step 2: Visually compare results
Open each _v2_final.png and compare against the originals. Success criteria from the spec:
images2.jpg: faces preserved (not erased), mask < 15%images.jpg: face unchanged, mask < 5% (minimal actual damage)woman-baby1.webp: baby face natural, scratches reduced, mask < 15%ai-old-photo...webp: scratches reduced, face natural, mask < 15%
- Step 3: Run all tests
pnpm vitest run tests/unit/ai/restoration.test.ts
pnpm vitest run tests/integration/restore-photo.test.ts
pnpm lint
pnpm typecheck
Expected: All pass.