double-exposure / app /scoring.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
13 kB
"""Hybrid physics + perceptual scoring and candidate ranking."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Sequence
import numpy as np
from film_physics import PiecewiseFilmCurve
from hybrid_loss import HybridFilmLoss, HybridLossBreakdown, _luminance_from_rgb
from app.api_client import SeparationResult
from scoring_policy import ScoringPolicy, DEFAULT_POLICY, loss_kwargs
# WP-13 D4: flat-candidate guard (κ · std(observed)); value from APP_POLICY era
_FLAT_GUARD_KAPPA = 0.05
@dataclass
class RankedCandidate:
"""A separation candidate with hybrid loss breakdown."""
rank: int
separation: SeparationResult
score: HybridLossBreakdown
candidate_id: str
@dataclass
class RankingResult:
"""Scored, ordered candidates plus flat-guard metadata (WP-13.1 C)."""
ranked: List[RankedCandidate]
rejected_count: int = 0
def __len__(self) -> int:
return len(self.ranked)
def __iter__(self):
return iter(self.ranked)
def __getitem__(self, index):
return self.ranked[index]
def _is_flat_pair(
image_a: np.ndarray,
image_b: np.ndarray,
observed_rgb: np.ndarray,
kappa: float = _FLAT_GUARD_KAPPA,
) -> bool:
"""True if min(std(lum A), std(lum B)) < kappa · std(lum observed)."""
# Shared Rec.709 helper (hybrid_loss._luminance_from_rgb) — WP-13.1 D
la = _luminance_from_rgb(image_a)
lb = _luminance_from_rgb(image_b)
lo = _luminance_from_rgb(observed_rgb)
std_o = float(np.std(lo))
if std_o < 1e-12:
# Degenerate observation — do not reject (avoid empty ranking)
return False
thr = kappa * std_o
return min(float(np.std(la)), float(np.std(lb))) < thr
def _resolve_policy(
policy: Optional[ScoringPolicy],
calibration: str,
physics_grad_weight: float,
flat_guard: bool,
) -> ScoringPolicy:
"""Prefer explicit policy; else build from legacy kwargs (compat)."""
if policy is not None:
return policy
# Reconstruct reweights when calibration matches APP (behavior-preserving)
from scoring_policy import APP_POLICY
if calibration == APP_POLICY.calibration:
return ScoringPolicy(
calibration=calibration,
physics_grad_weight=physics_grad_weight,
flat_guard=flat_guard,
exclusivity_weight=APP_POLICY.exclusivity_weight,
balance_weight=APP_POLICY.balance_weight,
)
return ScoringPolicy(
calibration=calibration,
physics_grad_weight=physics_grad_weight,
flat_guard=flat_guard,
)
def score_separation(
observed_log_exposure,
observed_rgb: np.ndarray,
image_a_rgb: np.ndarray,
image_b_rgb: np.ndarray,
film_curve: PiecewiseFilmCurve,
physics_weight: float = 1.0,
perceptual_weight: float = 0.5,
density: Optional[np.ndarray] = None,
confidence_mask: Optional[np.ndarray] = None,
excl_obs: Optional[float] = None,
calibration: str = "none",
physics_grad_weight: float = 0.0,
policy: Optional[ScoringPolicy] = None,
) -> HybridLossBreakdown:
"""Score a candidate pair using ``HybridFilmLoss`` (WP-3 density path when provided).
Prefer ``policy=APP_POLICY`` from the app path. Legacy kwargs remain for
benches/tests (None policy → build from kwargs).
"""
pol = _resolve_policy(policy, calibration, physics_grad_weight, flat_guard=False)
loss_fn = HybridFilmLoss(
film_curve=film_curve,
physics_weight=physics_weight,
perceptual_weight=perceptual_weight,
**loss_kwargs(pol),
)
return loss_fn.evaluate(
observed_log_exposure=observed_log_exposure,
observed_rgb=observed_rgb,
image_a_rgb=image_a_rgb,
image_b_rgb=image_b_rgb,
density=density,
confidence_mask=confidence_mask,
excl_obs=excl_obs,
)
def rank_candidates(
candidates: Sequence[SeparationResult],
observed_log_exposure,
observed_rgb: np.ndarray,
film_curve: PiecewiseFilmCurve,
physics_weight: float = 1.0,
perceptual_weight: float = 0.5,
density: Optional[np.ndarray] = None,
confidence_mask: Optional[np.ndarray] = None,
calibration: str = "none",
physics_grad_weight: float = 0.0,
flat_guard: bool = False,
policy: Optional[ScoringPolicy] = None,
) -> RankingResult:
"""
Score and rank separation candidates by hybrid loss (lower is better).
Uses density-space physics + regularizers + K-selection when density/mask provided.
Returns RankingResult (ranked list + rejected_count).
"""
pol = _resolve_policy(policy, calibration, physics_grad_weight, flat_guard)
use_flat_guard = pol.flat_guard
# Flat-guard filter (opt-in)
rejected_count = 0
pool: List[SeparationResult] = list(candidates)
if use_flat_guard and pool:
kept: List[SeparationResult] = []
for c in pool:
if _is_flat_pair(c.image_a, c.image_b, observed_rgb):
rejected_count += 1
else:
kept.append(c)
if kept:
pool = kept
else:
# All rejected → unfiltered fallback; keep rejected_count for status
pool = list(candidates)
scored: List[RankedCandidate] = []
# Hoist frame-level K evidence once (Fix 4)
from hybrid_loss import _lum_split_gradient_overlap
excl_obs = _lum_split_gradient_overlap(observed_rgb) if observed_rgb is not None else None
for candidate in pool:
breakdown = score_separation(
observed_log_exposure=observed_log_exposure,
observed_rgb=observed_rgb,
image_a_rgb=candidate.image_a,
image_b_rgb=candidate.image_b,
film_curve=film_curve,
physics_weight=physics_weight,
perceptual_weight=perceptual_weight,
density=density,
confidence_mask=confidence_mask,
excl_obs=excl_obs,
policy=pol,
)
scored.append(
RankedCandidate(
rank=0,
separation=candidate,
score=breakdown,
candidate_id=candidate.candidate_id,
)
)
scored.sort(key=lambda c: c.score.total_loss)
for i, item in enumerate(scored, start=1):
item.rank = i
return RankingResult(ranked=scored, rejected_count=rejected_count)
def merge_new_candidates_into_ranking(
base: RankingResult,
new_candidates: Sequence[SeparationResult],
observed_log_exposure,
observed_rgb: np.ndarray,
film_curve: PiecewiseFilmCurve,
physics_weight: float = 1.0,
perceptual_weight: float = 0.5,
density: Optional[np.ndarray] = None,
confidence_mask: Optional[np.ndarray] = None,
policy: Optional[ScoringPolicy] = None,
calibration: str = "none",
physics_grad_weight: float = 0.0,
) -> RankingResult:
"""Score ONLY new candidates and merge by total_loss (WP-14.1 P0-rank-once).
Flat guard applies to new entries using the same policy as rank_candidates.
Does not re-score base.ranked.
"""
if policy is not None:
pol = policy
use_flat = bool(policy.flat_guard)
else:
pol = _resolve_policy(None, calibration, physics_grad_weight, flat_guard=False)
use_flat = False
rejected = int(base.rejected_count)
from hybrid_loss import _lum_split_gradient_overlap
excl_obs = (
_lum_split_gradient_overlap(observed_rgb) if observed_rgb is not None else None
)
pool_new: List[SeparationResult] = list(new_candidates)
if use_flat and pool_new:
kept: List[SeparationResult] = []
for c in pool_new:
if _is_flat_pair(c.image_a, c.image_b, observed_rgb):
rejected += 1
else:
kept.append(c)
# Same fallback as rank_candidates: if all new flat and no base, keep unfiltered
if kept:
pool_new = kept
elif base.ranked:
pool_new = [] # base covers the ranking
else:
pool_new = list(new_candidates)
scored_new: List[RankedCandidate] = []
for candidate in pool_new:
breakdown = score_separation(
observed_log_exposure=observed_log_exposure,
observed_rgb=observed_rgb,
image_a_rgb=candidate.image_a,
image_b_rgb=candidate.image_b,
film_curve=film_curve,
physics_weight=physics_weight,
perceptual_weight=perceptual_weight,
density=density,
confidence_mask=confidence_mask,
excl_obs=excl_obs,
policy=pol,
)
scored_new.append(
RankedCandidate(
rank=0,
separation=candidate,
score=breakdown,
candidate_id=candidate.candidate_id,
)
)
combined = list(base.ranked) + scored_new
combined.sort(key=lambda c: c.score.total_loss)
for i, item in enumerate(combined, start=1):
item.rank = i
return RankingResult(ranked=combined, rejected_count=rejected)
def rank_then_merge_asymmetric(
base_candidates: Sequence[SeparationResult],
preprocessed,
*,
film_curve: PiecewiseFilmCurve,
physics_weight: float = 1.0,
perceptual_weight: float = 0.5,
policy: Optional[ScoringPolicy] = None,
enable_asymmetric: bool = False,
anchor: str = "auto",
complete: bool = False,
status_notes: Optional[List[str]] = None,
inpaint_fn=None,
debug: bool = False,
) -> tuple:
"""Rank base pool ONCE; optionally score-only-merge asym candidates (WP-14.1).
Shared by app/main.py and synth/real_protocol.py. Never re-scores the base pool.
Returns:
(RankingResult, api_contacted: bool) — api_contacted drives consent UI.
"""
density = getattr(preprocessed, "density", None)
confidence_mask = getattr(preprocessed, "confidence_mask", None)
observed_rgb = getattr(preprocessed, "rgb", None)
observed_log_exposure = getattr(preprocessed, "log_exposure", None)
base_ranked = rank_candidates(
candidates=base_candidates,
observed_log_exposure=observed_log_exposure,
observed_rgb=observed_rgb,
film_curve=film_curve,
physics_weight=physics_weight,
perceptual_weight=perceptual_weight,
density=density,
confidence_mask=confidence_mask,
policy=policy,
)
if not enable_asymmetric or len(base_ranked) == 0:
return base_ranked, False
notes = status_notes if status_notes is not None else []
api_contacted = False
try:
from app.asymmetric import append_asymmetric
scratch: list = []
run = append_asymmetric(
scratch,
preprocessed,
base_ranked[0].separation,
anchor=anchor,
complete=complete,
status_notes=notes,
inpaint_fn=inpaint_fn,
debug=debug,
)
api_contacted = bool(run.api_contacted)
asym = run.candidates
except Exception as exc:
notes.append(f"Asymmetric recovery failed: {exc}")
return base_ranked, False
if not asym:
return base_ranked, api_contacted
merged = merge_new_candidates_into_ranking(
base_ranked,
asym,
observed_log_exposure=observed_log_exposure,
observed_rgb=observed_rgb,
film_curve=film_curve,
physics_weight=physics_weight,
perceptual_weight=perceptual_weight,
density=density,
confidence_mask=confidence_mask,
policy=policy,
)
return merged, api_contacted
def format_score_summary(
ranked: RankedCandidate,
num_candidates: int,
physics_weight: float,
perceptual_weight: float,
) -> str:
"""Human-readable score summary for the UI."""
s = ranked.score
return (
f"**Best candidate:** #{ranked.rank} of {num_candidates} "
f"(`{ranked.candidate_id}`)\n\n"
f"**Hybrid loss:** {s.total_loss:.6f} _(lower is better)_\n\n"
f"- Physics MSE: {s.physics_loss:.6f} (weight {physics_weight:.1f})\n"
f"- LPIPS: {s.perceptual_loss:.6f} (weight {perceptual_weight:.1f})"
)
def format_ranking_table(ranked_list) -> str:
"""Compact markdown table of all candidate scores (includes K-selection)."""
items = ranked_list.ranked if isinstance(ranked_list, RankingResult) else ranked_list
lines = ["| Rank | ID | Hybrid | Physics | LPIPS | K-sel | Method |", "|---|---|---|---|---|---|---|"]
for item in items:
s = item.score
ksel = s.k_selection_score
lines.append(
f"| {item.rank} | {item.candidate_id} | {s.total_loss:.4f} | "
f"{s.physics_loss:.4f} | {s.perceptual_loss:.4f} | "
f"{ksel:.2f} | {item.separation.method} |"
)
return "\n".join(lines)