From 68a45359f96fec94eac440a1cecf75547587ed6c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 24 Apr 2026 01:01:30 +0100 Subject: [PATCH] Clean phase-field release seams --- docs/lean-optimizer-algorithm-map.md | 7 ++--- src/repixelizer/diagnostics.py | 2 +- src/repixelizer/params.py | 1 + src/repixelizer/pipeline.py | 7 ++++- tests/test_pipeline.py | 44 +++++++++++++++++++++++----- tests/test_tile_graph.py | 2 +- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/docs/lean-optimizer-algorithm-map.md b/docs/lean-optimizer-algorithm-map.md index 7677e97..04d0f23 100644 --- a/docs/lean-optimizer-algorithm-map.md +++ b/docs/lean-optimizer-algorithm-map.md @@ -97,7 +97,7 @@ The pipeline first decides whether the lattice is: - it scores a small phase grid for each size - it keeps the best candidate plus top alternates -After that, phase rerank may still happen, but only for `phase-field`, only when confidence is low, and only when rerank is enabled. The rerank probe is not a full solve. It calls `_run_reconstruction(...)` with `steps=0`, which means it evaluates the lattice using the zero-displacement field before any optimization movement. +After that, phase rerank may still happen, but only for `phase-field`, only when confidence is low, and only when rerank is enabled. The rerank probe is a short preview solve. It calls `_run_reconstruction(...)` with a small bounded step count, capped by both the requested solver steps and `phase_rerank_preview_steps`. That part matters. The pipeline is effectively asking: @@ -239,7 +239,7 @@ Then it immediately computes a baseline image by rounding the fixed lattice cent - `initial_y = round(uv0_px[..., 1])` - `initial_rgba = source[initial_y, initial_x]` -This is the zero-displacement output. Diagnostics still call this `snap_initial`, which is a fossil from the old optimizer. There is no snap stage anymore. It is just "the field before it moves." +This is the zero-displacement output. Diagnostics call this `initial_output`. It is simply "the field before it moves." ### Metaphor @@ -550,8 +550,7 @@ These pieces all belong to the same model of the machine. ## Current seams -- `snap_initial` is still a legacy label for the zero-displacement baseline -- low-confidence phase rerank judges lattices using a `steps=0` probe, not the full optimized field +- low-confidence phase rerank uses only a short preview solve, not the full optimized field - the edge scout only knows edge magnitude, not direction, so the solver still struggles with along-stroke versus across-stroke behavior near tapered contours These are the places where the current implementation is still a little awkward or incomplete. diff --git a/src/repixelizer/diagnostics.py b/src/repixelizer/diagnostics.py index 8806bd1..c33dec7 100644 --- a/src/repixelizer/diagnostics.py +++ b/src/repixelizer/diagnostics.py @@ -151,7 +151,7 @@ def summarize_run(result: RunResult) -> dict[str, Any]: height=result.source_rgba.shape[0], ) source_fidelity = { - "snap_initial": source_lattice_consistency_breakdown( + "initial_output": source_lattice_consistency_breakdown( result.source_rgba, result.solver.initial_rgba, target_width=result.inference.target_width, diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index 3c2ac6e..6469223 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -15,6 +15,7 @@ class SolverHyperParams: phase_field_magnitude_weight: float = 0.08 phase_field_learning_rate: float = 0.10 phase_field_max_displacement_ratio: float = 0.48 + phase_rerank_preview_steps: int = 8 source_edge_detail_threshold: float = 0.25 alpha_opaque_threshold: float = 0.95 alpha_transparent_threshold: float = 0.05 diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index 7c45387..4ba5083 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -90,6 +90,7 @@ def run_pipeline( source, inference, analysis=analysis, + steps=steps, seed=seed, device=device, solver_params=solver_params, @@ -201,6 +202,7 @@ def _select_phase_candidate( inference: InferenceResult, *, analysis, + steps: int = 0, seed: int, device: str, solver_params: SolverHyperParams | None = None, @@ -211,6 +213,7 @@ def _select_phase_candidate( source, inference, analysis=analysis, + steps=steps, seed=seed, device=device, solver_params=solver_params, @@ -224,6 +227,7 @@ def _select_phase_candidate_with_reconstruction( inference: InferenceResult, *, analysis, + steps: int = 0, seed: int, device: str, solver_params: SolverHyperParams | None = None, @@ -238,6 +242,7 @@ def _select_phase_candidate_with_reconstruction( if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.phase_rerank_confidence_threshold: return inference + preview_steps = min(max(0, int(steps)), max(0, int(solver_params.phase_rerank_preview_steps))) top_score = float(inference.top_candidates[0].score) candidate_records: list[dict[str, float | InferenceResult]] = [] for candidate in inference.top_candidates[:8]: @@ -253,7 +258,7 @@ def _select_phase_candidate_with_reconstruction( source, inference=candidate_inference, analysis=analysis, - steps=0, + steps=preview_steps, seed=seed, device=device, solver_params=solver_params, diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 664f7fe..20ad647 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -35,7 +35,7 @@ def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None: import json 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 set(run_json["source_fidelity"].keys()) == {"initial_output", "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 @@ -220,16 +220,42 @@ def test_phase_rerank_can_override_low_confidence_inference_pick(monkeypatch) -> self.target_rgba = rgba def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None): - assert steps == 0 + assert steps == 8 return DummyArtifacts(outputs[(inference.phase_x, inference.phase_y)]) monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.phase_x == candidate_b.phase_x assert selected.phase_y == candidate_b.phase_y +def test_phase_rerank_preview_respects_requested_zero_steps(monkeypatch) -> None: + source = np.zeros((2, 2, 4), dtype=np.float32) + candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.4, phase_y=0.2, score=0.91, breakdown={}) + candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.4, phase_y=0.2, score=0.909, breakdown={}) + inference = InferenceResult( + target_width=2, + target_height=2, + phase_x=candidate_a.phase_x, + phase_y=candidate_a.phase_y, + confidence=0.1, + top_candidates=[candidate_a, candidate_b], + ) + + class DummyArtifacts: + def __init__(self, rgba: np.ndarray) -> None: + self.target_rgba = rgba + + def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None): + assert steps == 0 + return DummyArtifacts(np.zeros_like(source_rgba)) + + monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field) + + _select_phase_candidate(source, inference, analysis=object(), steps=0, seed=7, device="cpu") + + def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None: source = np.zeros((16, 16, 4), dtype=np.float32) candidate_a = InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={}) @@ -259,7 +285,7 @@ def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field) monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.target_width == candidate_b.target_width assert selected.target_height == candidate_b.target_height @@ -293,7 +319,7 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None: monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field) monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.target_width == candidate_a.target_width assert selected.target_height == candidate_a.target_height @@ -319,6 +345,7 @@ def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None: source, inference, analysis=object(), + steps=32, seed=7, device="cpu", enable_phase_rerank=False, @@ -348,6 +375,7 @@ def test_select_phase_candidate_skips_phase_rerank_for_tile_graph(monkeypatch) - source, inference, analysis=object(), + steps=32, seed=7, device="cpu", reconstruction_mode="tile-graph", @@ -595,7 +623,7 @@ def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(mo monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble) monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", fake_concentration) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.target_width == candidate_b.target_width assert selected.target_height == candidate_b.target_height @@ -617,7 +645,7 @@ def test_phase_rerank_keeps_high_confidence_candidate_without_probe(monkeypatch) raise AssertionError("rerank probe should not run for high-confidence inference") monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.target_width == candidate_a.target_width assert selected.target_height == candidate_a.target_height @@ -665,6 +693,6 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None: monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble) monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", fake_concentration) - selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu") + selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu") assert selected.phase_x == candidate_b.phase_x assert selected.phase_y == candidate_b.phase_y diff --git a/tests/test_tile_graph.py b/tests/test_tile_graph.py index 09668f4..b9d335d 100644 --- a/tests/test_tile_graph.py +++ b/tests/test_tile_graph.py @@ -533,5 +533,5 @@ def test_pipeline_tile_graph_mode_writes_reconstruction_diagnostics(tmp_path: Pa assert run_json["reconstruction"]["tile_graph_model_device"] == "cpu" assert "tile_graph_model_cache_hit" in run_json["reconstruction"] assert run_json["reconstruction"]["tile_graph_candidate_count"] > 0 - assert set(run_json["source_fidelity"].keys()) == {"snap_initial", "solver_target", "final_output"} + assert set(run_json["source_fidelity"].keys()) == {"initial_output", "solver_target", "final_output"} assert result.diagnostics["reconstruction"]["mode"] == "tile-graph"