Files
SnapOtter/docs/superpowers/plans/2026-05-13-restore-photo-quality-overhaul.md
T
SnapOtter dd6ea16dd4 docs: add restore-photo quality overhaul implementation plan
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.
2026-05-13 16:52:21 +08:00

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:

  1. Remove the test "serializes mode option" (lines 72-77)
  2. Remove the test "passes mode option" (lines 358-363)
  3. Update "serializes all options together" (lines 114-128) to remove mode and add colorizeStrength:
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);
});
  1. 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:

  1. Update test "accepts auto mode with all features enabled" (lines 86-112). Remove mode: "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);
  1. Update test "accepts heavy mode with colorize enabled" (lines 114-138). Remove mode, add colorizeStrength, 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);
  1. Update test "accepts light mode with features disabled" (lines 140-165). Remove mode, 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);
  1. Update "rejects invalid mode value" test (lines 276-300). Since mode is no longer in the schema, an unknown mode field is just stripped by Zod (not rejected). Replace with a colorizeStrength out-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);
  }
});
  1. 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 in enhance_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 mode parameter, no scratch_sensitivity

  • denoise_strength default is 25 (was 40)

  • colorize_strength derived from colorizeStrength setting (divided by 100)

  • detect_scratches(result) called without sensitivity arg

  • Colorization uses colorize_strength instead of hardcoded 0.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:

  1. Remove the Mode type, MODES array, and mode-related state/effects (lines 7-13, 24, 37-38, 56 mode reference, 64 mode dependency, 67 activeMode).

  2. Add colorizeStrength state:

const [colorizeStrength, setColorizeStrength] = useState(85);
  1. Add init for colorizeStrength in the one-time init effect:
if (initialSettings.colorizeStrength != null)
  setColorizeStrength(Number(initialSettings.colorizeStrength));
  1. Change denoiseStrength default from 40 to 25:
const [denoiseStrength, setDenoiseStrength] = useState(25);
  1. Update the settings emission effect to remove mode and add colorizeStrength:
useEffect(() => {
  onChangeRef.current?.({
    scratchRemoval,
    faceEnhancement,
    fidelity: fidelity / 100,
    denoise,
    denoiseStrength,
    colorize,
    colorizeStrength,
  });
}, [scratchRemoval, faceEnhancement, fidelity, denoise, denoiseStrength, colorize, colorizeStrength]);
  1. Remove the entire mode selector JSX (lines 72-91: the "Restoration Mode" label, 3-column grid, and mode description paragraph).

  2. 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();
});
  1. 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();
});
  1. 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:

  1. images2.jpg: faces preserved (not erased), mask < 15%
  2. images.jpg: face unchanged, mask < 5% (minimal actual damage)
  3. woman-baby1.webp: baby face natural, scratches reduced, mask < 15%
  4. 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.