Add source structure metric breakdown

This commit is contained in:
Meta Vens 2026-04-23 23:31:58 +01:00
commit 044ee59948
7 changed files with 140 additions and 1 deletions

View file

@ -67,6 +67,7 @@ Current tile-graph status:
- those new diagnostics also settled the design argument about relax: on the pinned badge check under `artifacts/optimizer-relax-purpose-check/`, relax improves source-fidelity a bit (`0.07549` without relax to `0.07451` with it), but it does not act like a strong broad-swatch phase smoother; the displacement field stays roughly as jittery, so the current relax stage is better described as a soft consensus / handoff stabilizer over fixed local candidates
- that diagnosis finally forced the obvious architectural verdict: the current optimizer is still a tray-based discrete chooser in a fake moustache, so the repo now also carries `docs/lean-optimizer-algorithm-map.md`, a replacement target for a real displacement-field optimizer with one field, one objective, and one final sample
- the first version of that replacement now exists behind `--reconstruction-mode phase-field`; it is a deliberately lean displacement-field solver in `src/repixelizer/phase_field.py`, and the first pinned badge smoke under `artifacts/phase-field-v1-badge-126/` lands at `0.1461`, which is not competitive yet but at least comes from a machine that is no longer lying about what it is
- that first phase-field run also exposed that the old `source_fidelity` metric is too loyal to the lattice accountant's mean/medoid portrait; `run.json` and compare-mode outputs now include a new `source_structure` breakdown that tracks source-size structure instead: edge support F1, stroke wobble, foreground reconstruction, and exact-match ratio
- after the first pruning pass, tile-graph no longer participates in pipeline phase-rerank probes and no longer carries hybrid geometry priors through its unary cost; the path is now one lattice-conditioned candidate generator plus one local discrete solver
- this fixes the core design mismatch that had allowed repeated distant labels to create big same-color patches and opaque black background blocks
- on the current `24x24` emblem smoke case, an end-to-end `tile-graph` run dropped from about `2.57s` on CPU to `0.61s` on CUDA on this machine

View file

@ -229,6 +229,19 @@ First honest result:
That is a respectable first brick: not good enough, but finally the right shape.
Important metric correction:
- that same first phase-field badge run is also the clearest proof that `source_fidelity` is not enough as the repo's north star
- visually, the phase-field output preserves the important internal badge structure better than the pinned continuous output
- numerically, the old `source_fidelity` score still calls it worse because it rewards agreement with the lattice reference's mean / sharp portraits
- the repo now also reports `source_structure`, a source-size structural metric that combines:
- foreground reconstruction error
- edge-position error
- stroke wobble
- edge support precision / recall / F1
- exact-match ratio
- on the pinned badge comparison under `artifacts/phase-field-metric-check/`, that new metric finally agrees with human judgment: `phase-field` scores `0.4244` versus continuous `0.4291` where lower is better
## High-value regression case
### AI badge emblem with baked checkerboard background

View file

@ -14,10 +14,12 @@ from .metrics import (
foreground_adjacency_error,
foreground_edge_concentration,
foreground_edge_position_error,
foreground_edge_support_breakdown,
foreground_motif_error,
foreground_reconstruction_error,
foreground_stroke_wobble_error,
reconstruction_error,
source_structure_breakdown,
)
from .palette import load_palette
from .pipeline import run_pipeline
@ -111,6 +113,8 @@ def run_compare(
):
preview = nearest_resize(image, width=source.shape[1], height=source.shape[0])
coherence = coherence_breakdown(image)
edge_support = foreground_edge_support_breakdown(preview, source)
structure = source_structure_breakdown(source, image)
rows.append(
{
"name": name,
@ -125,8 +129,12 @@ def run_compare(
"foreground_edge_concentration": foreground_edge_concentration(image),
"foreground_edge_position_error": foreground_edge_position_error(preview, source),
"foreground_stroke_wobble_error": foreground_stroke_wobble_error(preview, source),
"foreground_edge_precision": edge_support["precision"],
"foreground_edge_recall": edge_support["recall"],
"foreground_edge_f1": edge_support["f1"],
"foreground_adjacency_error": foreground_adjacency_error(preview, source),
"foreground_motif_error": foreground_motif_error(preview, source),
"source_structure_score": structure["score"],
}
)
write_compare_csv(diagnostics_path / "compare.csv", rows)

View file

@ -13,6 +13,7 @@ from .metrics import (
coherence_breakdown,
foreground_reconstruction_error,
reconstruction_error,
source_structure_breakdown,
source_lattice_consistency_breakdown,
)
from .types import InferenceResult, RunResult
@ -210,6 +211,7 @@ def summarize_run(result: RunResult) -> dict[str, Any]:
"source_preview_foreground_error": foreground_reconstruction_error(source_preview, result.source_rgba),
"output_colors_from_source_ratio": _source_color_ratio(result.source_rgba, result.output_rgba),
"source_fidelity": source_fidelity,
"source_structure": source_structure_breakdown(result.source_rgba, result.output_rgba),
"phase_rerank_candidates": rerank_candidates,
"loss_history": result.solver.loss_history,
"optimizer_displacement": displacement_metrics,

View file

@ -193,6 +193,37 @@ def foreground_edge_position_error(
return float(np.mean((deficit_a[support_mask] + deficit_b[support_mask]) * 0.5))
def foreground_edge_support_breakdown(
a: np.ndarray,
b: np.ndarray,
alpha_threshold: float = 0.05,
halo: int = 1,
radius: int = 1,
distance_penalty: float = 0.35,
edge_threshold: float = 0.02,
) -> dict[str, float]:
edge_a = _edge_strength_map(a)
edge_b = _edge_strength_map(b)
mask = sprite_mask(a, b, alpha_threshold=alpha_threshold, halo=halo)
output_edge_mask = mask & (edge_a >= edge_threshold)
source_edge_mask = mask & (edge_b >= edge_threshold)
support_from_source = _best_local_edge_support(edge_b, radius=radius, distance_penalty=distance_penalty)
support_from_output = _best_local_edge_support(edge_a, radius=radius, distance_penalty=distance_penalty)
precision = float(np.mean(np.clip(support_from_source[output_edge_mask], 0.0, 1.0))) if np.any(output_edge_mask) else 1.0
recall = float(np.mean(np.clip(support_from_output[source_edge_mask], 0.0, 1.0))) if np.any(source_edge_mask) else 1.0
if precision + recall <= 1e-6:
f1 = 0.0
else:
f1 = float(2.0 * precision * recall / (precision + recall))
return {
"precision": precision,
"recall": recall,
"f1": f1,
}
def foreground_edge_concentration(
rgba: np.ndarray,
alpha_threshold: float = 0.05,
@ -300,6 +331,46 @@ def source_lattice_evidence_breakdown(
}
def source_structure_breakdown(
source_rgba: np.ndarray,
output_rgba: np.ndarray,
*,
alpha_threshold: float = 0.05,
halo: int = 1,
) -> dict[str, float]:
if output_rgba.shape[:2] != source_rgba.shape[:2]:
from .io import nearest_resize
preview = nearest_resize(output_rgba, width=source_rgba.shape[1], height=source_rgba.shape[0])
else:
preview = output_rgba
fg_recon = foreground_reconstruction_error(preview, source_rgba, alpha_threshold=alpha_threshold, halo=halo)
edge_position = foreground_edge_position_error(preview, source_rgba, alpha_threshold=alpha_threshold, halo=halo)
stroke_wobble = foreground_stroke_wobble_error(preview, source_rgba, alpha_threshold=alpha_threshold, halo=halo)
edge_support = foreground_edge_support_breakdown(preview, source_rgba, alpha_threshold=alpha_threshold, halo=halo)
exact_ratio = foreground_exact_match_ratio(preview, source_rgba, alpha_threshold=alpha_threshold, halo=halo)
wobble_penalty = float(stroke_wobble / (1.0 + stroke_wobble))
score = (
fg_recon * 0.30
+ edge_position * 0.20
+ wobble_penalty * 0.15
+ (1.0 - edge_support["f1"]) * 0.20
+ (1.0 - exact_ratio) * 0.15
)
return {
"foreground_reconstruction_error": fg_recon,
"edge_position_error": edge_position,
"stroke_wobble_error": stroke_wobble,
"stroke_wobble_penalty": wobble_penalty,
"edge_precision": edge_support["precision"],
"edge_recall": edge_support["recall"],
"edge_f1": edge_support["f1"],
"exact_match_ratio": exact_ratio,
"score": score,
}
def exact_match_ratio(a: np.ndarray, b: np.ndarray) -> float:
a8 = np.clip(np.rint(premultiply(a) * 255.0), 0, 255).astype(np.uint8)
b8 = np.clip(np.rint(premultiply(b) * 255.0), 0, 255).astype(np.uint8)

View file

@ -7,12 +7,14 @@ from repixelizer.metrics import (
foreground_adjacency_error,
foreground_edge_concentration,
foreground_edge_position_error,
foreground_edge_support_breakdown,
foreground_exact_match_ratio,
foreground_motif_error,
foreground_reconstruction_error,
foreground_stroke_wobble_error,
source_lattice_consistency_breakdown,
reconstruction_error,
source_lattice_consistency_breakdown,
source_structure_breakdown,
)
@ -112,6 +114,46 @@ def test_stroke_wobble_metric_penalizes_local_line_jitter() -> None:
assert foreground_stroke_wobble_error(original, wobbly) > 0.0
def test_edge_support_breakdown_rewards_nearby_edge_recall() -> None:
source = np.zeros((9, 9, 4), dtype=np.float32)
source[2:7, 4] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
stable = source.copy()
shifted = np.zeros_like(source)
shifted[2:7, 5] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
missing = np.zeros_like(source)
stable_support = foreground_edge_support_breakdown(stable, source)
shifted_support = foreground_edge_support_breakdown(shifted, source)
missing_support = foreground_edge_support_breakdown(missing, source)
assert stable_support["f1"] >= shifted_support["f1"] > missing_support["f1"]
def test_source_structure_breakdown_prefers_exact_and_supported_structure() -> None:
source = np.zeros((9, 9, 4), dtype=np.float32)
source[2:7, 4] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
exact = source.copy()
shifted = np.zeros_like(source)
shifted[2:7, 5] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
blurred = np.zeros_like(source)
blurred[2:7, 3] = np.asarray([0.4, 0.4, 0.4, 1.0], dtype=np.float32)
blurred[2:7, 4] = np.asarray([0.7, 0.7, 0.7, 1.0], dtype=np.float32)
blurred[2:7, 5] = np.asarray([0.4, 0.4, 0.4, 1.0], dtype=np.float32)
exact_score = source_structure_breakdown(source, exact)["score"]
shifted_score = source_structure_breakdown(source, shifted)["score"]
blurred_score = source_structure_breakdown(source, blurred)["score"]
assert exact_score < shifted_score
assert exact_score < blurred_score
def test_source_lattice_consistency_prefers_matching_cell_structure() -> None:
lowres = np.asarray(
[

View file

@ -36,6 +36,8 @@ def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None:
run_json = json.loads((diagnostics_dir / "run.json").read_text(encoding="utf-8"))
assert set(run_json["source_fidelity"].keys()) == {"snap_initial", "solver_target", "final_output"}
assert "source_structure" in run_json
assert {"edge_f1", "exact_match_ratio", "score"} <= set(run_json["source_structure"].keys())
assert "phase_rerank_candidates" in run_json
assert {"snap", "relax_handoff", "relax_mode", "final_output"} <= set(run_json["optimizer_displacement"].keys())
assert result.output_rgba.shape[0] == result.inference.target_height