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.
9.1 KiB
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):
- Scratch detection massively over-detects on small images (4-15x worse than reality)
- LaMa at 512x512 can't fully remove scratches even when correctly detected
- CodeFormer over-smooths small faces (< 80px)
- 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.warpAffinefor 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_OTSUper 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
sensitivityparameter: 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.copyMakeBorderwithBORDER_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
modefromRestorePhotoOptions - 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
superResolutionsetting: 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
modefield gracefully (stripped, not errored) - API schema accepts
colorizeStrengthwith default 85 denoiseStrengthdefault is 25colorizeStrengthout of range (101, -1) rejected
Integration (Vitest)
- POST with
colorizeStrength: 50accepted - 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:
images2.jpg: faces preserved (not erased), scratches reducedimages.jpg: face unchanged (minimal actual damage detected)woman-baby1.webp: baby face natural (not plasticky), scratches removedai-old-photo...webp: diagonal scratch fully removed, face natural