Spaces:
Running on Zero
Running on Zero
| """WP-13 real-photo scoring protocol (privacy-binding). | |
| READS photos from --photos-dir only. Never commits images. Optional saves go | |
| under real_outputs/ (gitignored). Result note records NUMBERS/tables only. | |
| Usage: | |
| .venv/bin/python -m synth.real_protocol \\ | |
| --photos-dir /path/to/photos \\ | |
| --photos 127,124,131 \\ | |
| [--limit N] [--dip-iters 2000] [--no-save] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import time | |
| from pathlib import Path | |
| from typing import List, Optional, Sequence | |
| import numpy as np | |
| from PIL import Image | |
| from app.api_client import SeparationResult, generate_demo_candidates | |
| from app.demix import residual_demix, stub_cleanup, DemixConfig, analyze_scan | |
| from app.preprocessing import preprocess_negative | |
| from app.scoring import rank_candidates, rank_then_merge_asymmetric | |
| from baselines.double_dip import DoubleDIPConfig, double_dip_separate | |
| from film_physics import get_film_curve | |
| from hybrid_loss import _luminance_from_rgb | |
| from scoring_policy import APP_POLICY | |
| def _match_photos(photos_dir: Path, names: Optional[Sequence[str]], limit: Optional[int]) -> List[Path]: | |
| files = sorted( | |
| p for p in photos_dir.iterdir() | |
| if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} | |
| ) | |
| if names: | |
| # Match substring (e.g. "127" → EdwardFal...-127.jpg) | |
| selected: List[Path] = [] | |
| for name in names: | |
| hits = [p for p in files if name in p.stem or name in p.name] | |
| if not hits: | |
| raise FileNotFoundError(f"No photo matching '{name}' in {photos_dir}") | |
| selected.append(hits[0]) | |
| files = selected | |
| if limit is not None: | |
| files = files[: int(limit)] | |
| return files | |
| def _std_ratio(img_a: np.ndarray, img_b: np.ndarray, obs: np.ndarray) -> tuple[float, float, float]: | |
| la = _luminance_from_rgb(img_a) | |
| lb = _luminance_from_rgb(img_b) | |
| lo = _luminance_from_rgb(obs) | |
| std_o = float(np.std(lo)) + 1e-12 | |
| ra = float(np.std(la)) / std_o | |
| rb = float(np.std(lb)) / std_o | |
| return ra, rb, min(ra, rb) | |
| def _run_one( | |
| path: Path, | |
| stock: str, | |
| dip_iters: int, | |
| save_dir: Optional[Path], | |
| dip_warm_start: bool = False, | |
| asym: bool = False, | |
| ) -> str: | |
| t_all = time.time() | |
| img = Image.open(path) | |
| pre = preprocess_negative( | |
| img, | |
| stock=stock, | |
| scan_type="positive", | |
| scan_calibration="auto_exposed", | |
| auto_trim=False, | |
| ) | |
| curve = get_film_curve(stock) | |
| lines: List[str] = [] | |
| lines.append(f"## Photo `{path.name}`") | |
| lines.append( | |
| f"- working size: {pre.rgb.shape[1]}×{pre.rgb.shape[0]} " | |
| f"(orig {pre.original_size[0]}×{pre.original_size[1]})" | |
| ) | |
| lines.append(f"- density present: {pre.density is not None}; " | |
| f"mask present: {pre.confidence_mask is not None}") | |
| lines.append("") | |
| candidates: List[SeparationResult] = [] | |
| timings: dict[str, float] = {} | |
| # Heuristics (demo) | |
| t0 = time.time() | |
| demos = generate_demo_candidates(pre.rgb, num_candidates=3) | |
| timings["heuristics"] = time.time() - t0 | |
| candidates.extend(demos) | |
| # Demix stub | |
| if pre.h_total is not None and pre.confidence_mask is not None: | |
| t0 = time.time() | |
| try: | |
| analysis = analyze_scan(pre.rgb, vlm=None) | |
| d = residual_demix( | |
| pre.rgb, | |
| pre.h_total, | |
| pre.confidence_mask, | |
| stub_cleanup, | |
| analysis, | |
| DemixConfig(iterations=2, strength=0.55), | |
| method="demix_stub", | |
| ) | |
| candidates.append(d) | |
| except Exception as exc: | |
| lines.append(f"- demix failed: {exc}") | |
| timings["demix"] = time.time() - t0 | |
| # Optional warm-start: rank structured pool first, take top (A,B) | |
| warm_pair = None | |
| if dip_warm_start and candidates: | |
| pre_rank = rank_candidates( | |
| candidates=candidates, | |
| observed_log_exposure=pre.log_exposure, | |
| observed_rgb=pre.rgb, | |
| film_curve=curve, | |
| physics_weight=1.0, | |
| perceptual_weight=0.5, | |
| density=pre.density, | |
| confidence_mask=pre.confidence_mask, | |
| policy=APP_POLICY, | |
| ) | |
| if len(pre_rank) > 0: | |
| top = pre_rank.ranked[0] | |
| warm_pair = (top.separation.image_a, top.separation.image_b) | |
| lines.append( | |
| f"- warm-start from `{top.candidate_id}` " | |
| f"(pre-rank total={top.score.total_loss:.4f})" | |
| ) | |
| # DIP with APP_POLICY (same objective as ranking) — before single rank | |
| if pre.density is not None and pre.confidence_mask is not None: | |
| t0 = time.time() | |
| try: | |
| cfg = DoubleDIPConfig( | |
| iterations=int(dip_iters), | |
| seed=0, | |
| policy=APP_POLICY, | |
| ) | |
| dip = double_dip_separate( | |
| pre.rgb, | |
| pre.log_exposure, | |
| pre.density, | |
| pre.confidence_mask, | |
| curve, | |
| config=cfg, | |
| warm_start=warm_pair, | |
| ) | |
| if dip is not None: | |
| candidates.append(dip) | |
| except Exception as exc: | |
| lines.append(f"- DIP failed: {exc}") | |
| timings["dip"] = time.time() - t0 | |
| else: | |
| lines.append("- DIP skipped (no density/mask)") | |
| # WP-14.1: rank base ONCE; score-only-merge asym (no second full LPIPS pass) | |
| t0 = time.time() | |
| asym_notes: list[str] = [] | |
| ranked, _asym_api = rank_then_merge_asymmetric( | |
| candidates, | |
| pre, | |
| film_curve=curve, | |
| physics_weight=1.0, | |
| perceptual_weight=0.5, | |
| policy=APP_POLICY, | |
| enable_asymmetric=bool(asym), | |
| anchor="auto", | |
| complete=False, # offline protocol: physics only | |
| status_notes=asym_notes, | |
| debug=False, | |
| ) | |
| for n in asym_notes: | |
| lines.append(f"- asym: {n}") | |
| timings["rank"] = time.time() - t0 | |
| timings["asym"] = 0.0 # fold into rank helper; wall is rank total | |
| lines.append( | |
| f"- candidates generated: {len(candidates)}; " | |
| f"ranked: {len(ranked)}; " | |
| f"flat rejected: {ranked.rejected_count}" | |
| ) | |
| lines.append( | |
| f"- wall times (s): heuristics={timings.get('heuristics', 0):.2f}, " | |
| f"demix={timings.get('demix', 0):.2f}, " | |
| f"asym={timings.get('asym', 0):.2f}, " | |
| f"dip={timings.get('dip', 0):.2f}, " | |
| f"rank={timings.get('rank', 0):.2f}, " | |
| f"total={time.time() - t_all:.2f}" | |
| ) | |
| lines.append("") | |
| lines.append( | |
| "| rank | id | method | total | phys | grad | excl | bal | perc | " | |
| "a | r | stdA/obs | stdB/obs | min_ratio |" | |
| ) | |
| lines.append("|---:|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|") | |
| for item in ranked: | |
| s = item.score | |
| sep = item.separation | |
| ra, rb, rmin = _std_ratio(sep.image_a, sep.image_b, pre.rgb) | |
| lines.append( | |
| f"| {item.rank} | {item.candidate_id} | {sep.method} | " | |
| f"{s.total_loss:.4f} | {s.physics_loss:.4f} | {s.physics_grad_loss:.4f} | " | |
| f"{s.exclusivity_loss:.4f} | {s.balance_loss:.4f} | {s.perceptual_loss:.4f} | " | |
| f"{s.affine_a:.3f} | {s.fitted_r:.3f} | {ra:.3f} | {rb:.3f} | {rmin:.3f} |" | |
| ) | |
| # Accept diagnostics (numbers only) | |
| best = ranked[0] if ranked else None | |
| if best is not None: | |
| ra, rb, rmin = _std_ratio(best.separation.image_a, best.separation.image_b, pre.rgb) | |
| lines.append("") | |
| lines.append( | |
| f"**Best:** `{best.candidate_id}` method={best.separation.method} " | |
| f"total={best.score.total_loss:.4f} a={best.score.affine_a:.3f} " | |
| f"r={best.score.fitted_r:.3f} min_std_ratio={rmin:.3f}" | |
| ) | |
| # DIP vs best heuristic r | |
| dip_items = [r for r in ranked if r.separation.method == "deep_prior"] | |
| heur_items = [r for r in ranked if r.separation.method.startswith("demo")] | |
| if dip_items and heur_items: | |
| dip_r = dip_items[0].score.fitted_r | |
| best_heur_r = max(h.score.fitted_r for h in heur_items) | |
| dra, drb, drmin = _std_ratio( | |
| dip_items[0].separation.image_a, | |
| dip_items[0].separation.image_b, | |
| pre.rgb, | |
| ) | |
| lines.append( | |
| f"**DIP accept check:** std_ratio_min={drmin:.4f} " | |
| f"(need >0.05 for non-flat); fitted_r={dip_r:.4f} vs best_heuristic_r={best_heur_r:.4f}" | |
| ) | |
| if save_dir is not None and best is not None: | |
| save_dir.mkdir(parents=True, exist_ok=True) | |
| stem = path.stem | |
| for label, arr in ( | |
| ("A", best.separation.image_a), | |
| ("B", best.separation.image_b), | |
| ): | |
| out = (np.clip(arr, 0, 1) * 255).astype(np.uint8) | |
| Image.fromarray(out).save(save_dir / f"{stem}_{label}.png") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def main(argv: Optional[Sequence[str]] = None) -> int: | |
| p = argparse.ArgumentParser(description="WP-13 real-photo scoring protocol") | |
| p.add_argument("--photos-dir", type=str, required=True) | |
| p.add_argument("--limit", type=int, default=None) | |
| p.add_argument("--photos", type=str, default=None, help="Comma-separated name substrings") | |
| p.add_argument("--stock", type=str, default="Portra 400") | |
| p.add_argument("--dip-iters", type=int, default=2000) | |
| p.add_argument( | |
| "--dip-warm-start", | |
| action="store_true", | |
| help="Warm-start DIP from top pre-ranked structured candidate (WP-13.1 F)", | |
| ) | |
| p.add_argument( | |
| "--asym", | |
| action="store_true", | |
| help="WP-14: add offline asymmetric subtraction candidate (asym_sub)", | |
| ) | |
| p.add_argument("--no-save", action="store_true") | |
| p.add_argument( | |
| "--output-dir", | |
| type=str, | |
| default="real_outputs", | |
| help="Gitignored dir for optional image saves (never commit)", | |
| ) | |
| args = p.parse_args(argv) | |
| photos_dir = Path(args.photos_dir) | |
| if not photos_dir.is_dir(): | |
| raise SystemExit(f"photos-dir not found: {photos_dir}") | |
| names = [s.strip() for s in args.photos.split(",")] if args.photos else None | |
| paths = _match_photos(photos_dir, names, args.limit) | |
| if not paths: | |
| raise SystemExit("No photos matched") | |
| save_dir = None if args.no_save else Path(args.output_dir) | |
| print( | |
| f"# WP-13/14 real protocol — {len(paths)} photo(s), stock={args.stock}, " | |
| f"dip_iters={args.dip_iters}, warm_start={args.dip_warm_start}, " | |
| f"asym={args.asym}" | |
| ) | |
| print(f"# photos-dir={photos_dir} (READ ONLY); saves={'off' if save_dir is None else save_dir}") | |
| print() | |
| for path in paths: | |
| print( | |
| _run_one( | |
| path, | |
| stock=args.stock, | |
| dip_iters=args.dip_iters, | |
| save_dir=save_dir, | |
| dip_warm_start=bool(args.dip_warm_start), | |
| asym=bool(args.asym), | |
| ) | |
| ) | |
| print() | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |