Clean phase-field release seams

This commit is contained in:
Meta Vens 2026-04-24 01:01:30 +01:00
commit 68a45359f9
6 changed files with 48 additions and 15 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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"