docs: add restore-photo quality overhaul design spec

Evidence-based spec informed by diagnostic testing on 4 sample images.
Addresses catastrophic scratch over-detection (up to 68.7% false
coverage on small images), LaMa 512x512 resolution loss, CodeFormer
over-smoothing on small faces, and excessive NLMeans defaults.
This commit is contained in:
SnapOtter
2026-05-13 16:41:30 +08:00
parent 54f3867086
commit 32ff0f6217
@@ -0,0 +1,182 @@
# Restore Photo Quality Overhaul
**Date:** 2026-05-13
**Status:** Draft
**Scope:** Fix critical quality issues in the restore-photo pipeline, informed by diagnostic testing on real damaged photos
**Supersedes:** `docs/specs/2026-04-15-restore-photo-design.md` (approved but unimplemented; this spec is scoped to the subset of changes backed by diagnostic evidence)
---
## Diagnostic Evidence
Full pipeline was run on 4 sample images with intermediate outputs saved at each step. Key findings:
| Image | Pixels | Mask Coverage | Actual Damage | Result |
|---|---|---|---|---|
| images2.jpg | 290x174 | **68.7%** | ~3% | Faces erased, gray blobs |
| images.jpg | 188x267 | **30.6%** | ~1% | Face structure altered |
| woman-baby1.webp | 400x277 | **23.2%** | ~6% | Baby face plasticky, scratches remain |
| ai-old-photo...webp | 768x513 | **6.4%** | ~5% | Reasonable but scratches still visible |
Root causes identified (ranked by impact):
1. **Scratch detection massively over-detects** on small images (4-15x worse than reality)
2. **LaMa at 512x512** can't fully remove scratches even when correctly detected
3. **CodeFormer over-smooths small faces** (< 80px)
4. **NLMeans default strength too high** (40 maps to h=7.8, over-smooths compressed images)
Diagnostic outputs saved at `/tmp/restore-diagnostic/` with per-step intermediate images.
---
## Changes
### 1. Scratch Detection Rewrite (`restore.py :: detect_scratches`)
**Current:** 4 angles (0/45/90/135), 3 kernel sizes, fixed threshold per mode, no filtering.
**New:**
- **8 angles**: 0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5 degrees
- **Rotation-matrix kernels**: `cv2.getRotationMatrix2D` + `cv2.warpAffine` for accurate arbitrary-angle line kernels (replaces hard-coded pixel patterns)
- **Image-size-adaptive kernel count**: for images with shortest side < 300px, use only 2 kernel sizes (max kernel capped at `base_dim // 15`). For >= 300px, use 3 sizes as before
- **Pre-filtering**: light bilateral filter (`d=5, sigmaColor=50, sigmaSpace=50`) before CLAHE to suppress JPEG/WebP compression artifacts
- **Adaptive threshold**: use `cv2.THRESH_BINARY + cv2.THRESH_OTSU` per combined morphological response map instead of a fixed threshold. This auto-adapts to each image's contrast characteristics. If Otsu selects a threshold < 60, skip detection entirely (image has no significant scratches; low Otsu = unimodal histogram = no damage signal)
- **Connected component filtering**: after initial detection, run `cv2.connectedComponentsWithStats`. Keep a component if:
- Area >= 20 pixels AND (elongation >= 2.5 OR area >= 200 pixels)
- Elongation = `max(bbox_w, bbox_h) / max(min(bbox_w, bbox_h), 1)`
- Reject components with area > 5% of total image area (too large to be a scratch)
- **Coverage cap**: after filtering, compute total mask coverage. If it exceeds 15%, compute the morphological response intensity at the 85th percentile of masked pixels and re-threshold at that intensity, keeping only the strongest 15% of detections. Log a warning. This prevents the catastrophic "erase everything" failure mode
- **Remove `sensitivity` parameter**: no more light/medium/heavy. The adaptive Otsu threshold + filtering handles all cases
**Removed:** `_make_line_kernel()` (replaced by `_make_line_kernel_rotated()`)
### 2. LaMa Inpainting Resolution Fix (`restore.py :: inpaint_damage`)
**Current:** Resize entire image to 512x512, process, resize back. Destroys detail and distorts aspect ratio.
**New two-path approach:**
**Small images (both dimensions <= 512px):**
- Pad image to 512x512 using `cv2.copyMakeBorder` with `BORDER_REFLECT_101` (no resize, no aspect distortion)
- Pad mask identically (padded area = mask 0, no damage)
- Run single-pass LaMa at 512x512
- Crop result back to original dimensions
- Feathered composite with original
**Large images (any dimension > 512px):**
- Process in overlapping 512x512 tiles (stride = 384, overlap = 128)
- For edge tiles that extend past the image boundary: pad with `BORDER_REFLECT_101`
- Only process tiles where mask has > 0 damaged pixels (skip clean tiles)
- Blend tiles using raised-cosine window to eliminate seams
- Feathered composite with original (only replace masked areas)
**Improved mask dilation:**
- Current: `MORPH_ELLIPSE(3,3)`, 1 iteration = 3px dilation
- New: `MORPH_ELLIPSE(5,5)`, 2 iterations = ~8px dilation
- This ensures scratch edges are fully covered for cleaner inpainting
**Improved feathering:**
- Current: `feather_r = max(3, min(w,h) // 200)` = 3px for most images
- New: `feather_r = max(5, min(w,h) // 100)` = at least 5px, scales better
### 3. Face Enhancement Guard (`restore.py :: enhance_faces`)
**Current:** Minimum face size 24x24px. CodeFormer runs at full strength on all faces.
**New:**
- Minimum face size raised to **48x48px** (skip smaller faces entirely)
- For faces 48-120px: clamp fidelity to `max(fidelity, 0.85)` to prevent over-smoothing
- For faces > 120px: use user's fidelity setting as-is
### 4. Lower Default Denoise Strength
**Current:** `denoiseStrength` default = 40, maps to NLMeans h = 7.8
**New:** `denoiseStrength` default = **25**, maps to NLMeans h = 6.0. Still removes grain but doesn't wash out small compressed images. Users can increase manually.
### 5. Remove Mode System
**API:** Remove `mode: "auto" | "light" | "heavy"` from Zod schema. Zod strips unknown fields by default, so old clients sending `mode` will work (it's silently ignored).
**Python:** Remove `mode` parameter from `main()`. The adaptive Otsu threshold replaces the fixed threshold that `mode` controlled.
**Frontend:** Remove 3-button mode selector from `RestorePhotoControls`.
### 6. Add Colorize Strength Control
**API:** Add `colorizeStrength: z.number().min(0).max(100).default(85)` to Zod schema.
**Python:** `colorize_bw()` already accepts `intensity` parameter. Pass `colorizeStrength / 100` from main.
**Frontend:** Add slider (0-100, step 5) below Auto-Colorize checkbox, visible when colorize is checked. Same nested styling as Face Fidelity slider.
**i18n:** Add `colorizeStrength: "Colorize Strength"` to `en.ts`.
### 7. TypeScript Bridge Update (`restoration.ts`)
- Remove `mode` from `RestorePhotoOptions`
- Add `colorizeStrength?: number`
---
## Files Changed
| File | Change |
|---|---|
| `packages/ai/python/restore.py` | Detection rewrite, tiled LaMa, face guard, remove mode, colorize strength passthrough |
| `packages/ai/src/restoration.ts` | Remove `mode`, add `colorizeStrength` to `RestorePhotoOptions` |
| `apps/api/src/routes/tools/restore-photo.ts` | Remove `mode` from both Zod schemas, add `colorizeStrength`, lower `denoiseStrength` default |
| `apps/web/src/components/tools/restore-photo-settings.tsx` | Remove mode selector, add colorize strength slider, update default denoise to 25 |
| `packages/shared/src/i18n/en.ts` | Add `colorizeStrength` key |
| `tests/unit/ai/restoration.test.ts` | Remove mode tests, add colorizeStrength tests |
| `tests/integration/restore-photo.test.ts` | Remove mode tests, add colorizeStrength validation |
| `tests/e2e/restore-photo.spec.ts` | Remove mode UI test, add colorize strength slider test |
---
## What This Spec Does NOT Include
The following items from the earlier approved spec (`2026-04-15`) are deferred. They add model downloads and complexity without diagnostic evidence that they're needed:
- **Stable Diffusion ONNX inpainting** (~1.7 GB): LaMa with tiled inference at native resolution should be sufficient. If quality is still lacking after this fix, SD-inpainting can be added as a follow-up
- **NAFNet neural denoising** (~68 MB): NLMeans at lower strength is adequate. NAFNet is a future upgrade
- **CT2 colorization** (~850 MB): DDColor with adjustable strength is good enough
- **GFPGAN fallback**: CodeFormer with the face size guard should handle all cases
- **Real-ESRGAN super-resolution step**: Separate from restoration quality; can be added independently
- **New `superResolution` setting**: Deferred with the SR step
These can each be added incrementally in future PRs if testing shows they're needed.
---
## Testing
### Unit (Vitest)
- API schema rejects `mode` field gracefully (stripped, not errored)
- API schema accepts `colorizeStrength` with default 85
- `denoiseStrength` default is 25
- `colorizeStrength` out of range (101, -1) rejected
### Integration (Vitest)
- POST with `colorizeStrength: 50` accepted
- POST with old `mode: "auto"` doesn't error (backward compat)
### E2E (Playwright)
- Mode selector buttons no longer present
- Colorize strength slider appears when Auto-Colorize is checked
- Default denoise strength shows 25
### Manual verification
- Run pipeline on all 4 diagnostic sample images
- Compare before/after at each step
- Verify: mask coverage < 15% on all samples, scratches removed, faces natural, no blurriness
---
## Success Criteria
The fix is successful when all 4 diagnostic images produce visibly better results than current:
1. `images2.jpg`: faces preserved (not erased), scratches reduced
2. `images.jpg`: face unchanged (minimal actual damage detected)
3. `woman-baby1.webp`: baby face natural (not plasticky), scratches removed
4. `ai-old-photo...webp`: diagonal scratch fully removed, face natural