diff --git a/README.md b/README.md index eab3b51..3d46f11 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ Recent adjacency-focused status: - the snap-to-refine handoff is now source-first rather than dominated by the softened representative lattice - low-confidence phase reranking now uses a soft size penalty instead of a hard size-jump reject - an experimental `tile-graph` reconstruction mode now exists behind `--reconstruction-mode tile-graph` -- an experimental `hybrid` reconstruction mode now exists behind `--reconstruction-mode hybrid` Current tile-graph status: @@ -47,16 +46,14 @@ Current tile-graph status: - tile-graph now falls back to its initial assignment when the propagation loop would make source-lattice fidelity worse, which is currently important for preserving sharp internal contour cells - it still beats naive resize on the repo's synthetic thin-feature regression and now does so without letting the final propagation step blur past the initial placement - source-region connected-components now have a device-side Torch path, so the expensive labeling step no longer depends on Python flood fill -- on the real cleaned badge, the current end-to-end `tile-graph` pipeline now lands at about `270.8s` on this machine after reusing the chosen phase-rerank probe instead of rebuilding the same selected candidate a second time; the older stroke-aware badge run was about `443.1s` - profiling the selected badge candidate shows the main remaining bottleneck is model construction, not the solver loop: `build_tile_graph_model(...)` takes about `142.3s`, and about `131.2s` of that is `_extract_source_region_tiles(...)` -- the low-confidence badge rerank is also expensive because it still probes eight lattice candidates before the final pick; on the same fixture that phase takes about `223.9s`, of which about `208.1s` is reconstruction work +- after the first pruning pass, tile-graph no longer spends iteration time on pipeline rerank probes at all; it now runs the chosen or pinned lattice directly - the remaining large-fixture bottleneck is the per-component one-cell window cutting pass inside source-region extraction, which is still Python/NumPy-heavy after the GPU CCL stage - the pipeline now has a direct-control path for iteration: `--target-width` / `--target-height` plus optional `--phase-x` / `--phase-y` let you run an exact lattice without paying the full lattice search, and `--skip-phase-rerank` lets you keep the pipeline from second-guessing that choice - repeated fixed-lattice `tile-graph` runs in the same Python process now reuse the expensive model build; on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the first CUDA run took about `10.2s` and the second cached rerun took about `2.1s` - elongated source regions now get a stroke-aware slicing pass that follows their principal axis instead of only marching with cardinal queue steps, and the repo now has a shallow-stroke regression that checks for fan-out on that kind of component - that stroke-aware slicer is a real synthetic improvement but not a real-badge win yet: the latest badge probe under `artifacts/badge-tile-graph-stroke-v2-cuda/` still keeps the initial assignment and slightly regresses source-fidelity (`0.1832` vs `0.1800`) -- the new low-risk hybrid mode keeps tile-graph's source-owned candidates but scores them against a continuous-optimizer geometry prepass; on the cleaned badge it improves tile-graph from `0.1832` to `0.1785` under `artifacts/badge-hybrid-v2-cuda/` -- that hybrid gain is still modest and still far behind the stronger continuous badge result, but it is the first combined path that moves the real badge in the right direction without reintroducing smeared ownership +- 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 - the older hard-edge-only candidate widening pass under `artifacts/full-emblem-tile-graph-hard-edge-v2-cuda/` remains a useful negative result: more edge choices alone sharpen some cells locally but still regress full-emblem source-fidelity (`0.0377`) @@ -75,8 +72,7 @@ repixelize input.png --out output.png repixelize input.png --out output.png --diagnostics-dir diagnostics --device auto repixelize input.png --out output.png --reconstruction-mode tile-graph --diagnostics-dir diagnostics --device cpu repixelize input.png --out output.png --reconstruction-mode tile-graph --diagnostics-dir diagnostics --device cuda -repixelize input.png --out output.png --reconstruction-mode hybrid --diagnostics-dir diagnostics --device cuda -repixelize input.png --out output.png --reconstruction-mode tile-graph --target-width 126 --target-height 126 --phase-x 0.0 --phase-y -0.2 --skip-phase-rerank --device cuda +repixelize input.png --out output.png --reconstruction-mode tile-graph --target-width 126 --target-height 126 --phase-x 0.0 --phase-y -0.2 --device cuda ``` Run the optimizer plus baselines: diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 2af6f2d..ae414f4 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -14,7 +14,6 @@ Implemented: - source-first snap/refine scoring with explicit source-vs-representative weights - low-confidence phase reranking with soft size penalties instead of hard size-jump rejection - an experimental `tile-graph` reconstruction path with coord-local literal source-pixel candidates -- an experimental `hybrid` reconstruction path that combines a continuous geometry prepass with tile-graph ownership - stage-aware diagnostics writing, including per-stage source-fidelity and rerank traces - synthetic test fixtures and automated tests @@ -58,26 +57,23 @@ The most provisional areas are: ### Active pass -This pass focused on direct control and iteration speed for `tile-graph` and `hybrid`, so the pipeline can run exact user-chosen lattices instead of always paying for the full search-and-rerank path. +This pass is the first pruning pass after the new algorithm map: strip away the optimizer-era sidecars that are not part of the core tile-graph machine. What landed: -- `pipeline.py` now accepts pinned `target_width` / `target_height` plus optional `phase_x` / `phase_y` so the pipeline can construct a fixed inference result directly -- `inference.py` now exposes `infer_fixed_lattice(...)`, which searches only the requested phase space for one fixed lattice instead of paying the full size search -- `cli.py` and `compare.py` now expose those controls plus `--skip-phase-rerank` -- `tile_graph.py` now has a process-local model cache keyed by source content, lattice choice, device, and build-affecting tile-graph params -- `tests/test_inference.py`, `tests/test_pipeline.py`, `tests/test_tile_graph.py`, and `tests/test_cli.py` now cover fixed-lattice inference, rerank disabling, and cache reuse -- `docs/tile-graph-algorithm-map.md` now traces the tile-graph path stage by stage, including the exact variables that carry source data into connected components, region buckets, per-cell candidates, unary costs, and the initial assignment -- that map now records the corrected fixed-lattice failure anatomy on the cleaned badge: with pinned `126x126` / phase `(0.0, -0.2)`, occupied output cells are no longer losing extracted region buckets after the new overlap fill pass, yet the broken result is still already bad at `tile_graph_initial_source_fidelity = 0.500884` +- `tile-graph` no longer participates in pipeline phase rerank probes; rerank is now a continuous-only wrapper +- the experimental `hybrid` path and its geometry-prior wiring have been removed +- `tile_graph.py` no longer carries geometry-prior fields through `TileGraphModel` or tile-graph unary scoring +- `cli.py` now exposes only `continuous` and `tile-graph` as reconstruction engines +- `tests/test_pipeline.py` now checks that low-confidence tile-graph runs skip rerank probes instead of rebuilding probe candidates +- `docs/tile-graph-algorithm-map.md` now describes the smaller machine directly rather than documenting the removed hybrid sidecar path Current implementation note: -- the earlier profiling result still stands: on the searched badge path, the solver loop is not the problem; the heavy costs are `infer_lattice(...)`, low-confidence phase rerank probes, and `_extract_source_region_tiles(...)` -- the new direct-control path is specifically for dodging those costs during iteration when we already know which lattice we want to inspect +- the earlier profiling result still stands: on the searched badge path, the solver loop is not the problem; the heavy costs are `infer_lattice(...)`, continuous-mode phase rerank probes, and `_extract_source_region_tiles(...)` +- the direct-control path is specifically for dodging those costs during iteration when we already know which lattice we want to inspect +- tile-graph now skips pipeline rerank entirely, so iteration no longer rebuilds multiple probe candidates before the final tile-graph solve - on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, a fixed-lattice CUDA `tile-graph` run now took about `10.2s` on the first same-process run and about `2.1s` on the cached rerun, with the same output and a reported `tile_graph_model_cache_hit` on the second pass - the current deep-dive diagnosis is that the fixed-lattice garbling is not a wrapper bug and not mainly a parity-solver bug; it is being born in lattice-conditioned reference building, source-region cutting/projection, candidate starvation, and the first unary argmin assignment - the extraction coverage bug turned out to be real but secondary: the new `_extract_source_region_tiles(...)` overlap fill pass now guarantees that any output cell containing opaque sampled source pixels gets at least one extracted region bucket, but the pinned `126x126` badge output remains numerically unchanged (`0.4998626`), which narrows the remaining failure to candidate ranking and lattice-conditioned reference usage rather than empty region buckets -- the hybrid remains intentionally conservative: it only biases tile-graph's unary cost with the continuous prepass layout and does not yet replace the pairwise objective or the source-region builder -- on the cleaned badge, that conservative hybrid still helps: the latest run under `artifacts/badge-hybrid-v2-cuda/` lands at `0.1785`, improving on the current tile-graph badge baseline at `0.1832` -- the hybrid is still far behind the stronger continuous badge result (`0.0832`), so the seam looks promising but it is not yet enough to solve the real contour problem by itself - the latest full-emblem atomic probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity with `34278` candidates and about `2.16` average choices per cell - that beats the earlier full-CUDA tile-graph baseline (`0.0283`) and materially improves on the first atomic-only attempt (`0.1571`), which chose atomic regions too eagerly and then let the solver blur them out again @@ -85,8 +81,7 @@ Next after that: - split tile-graph iteration into a cached model-build phase and a near-free solve phase so weight tuning does not keep paying the `~131s` source-region cutting bill - extend the current fixed-lattice path so repeated CLI runs can reuse cached model artifacts across processes instead of only within one Python process - port or reformulate `_extract_source_region_tiles(...)` so it no longer spends most of the badge runtime in Python/NumPy window cutting -- reduce rerank probe cost by using cached candidate builds where possible or by using a cheaper rerank proxy before full tile-graph reconstruction -- decide whether the next hybrid pass should add geometry-aware pairwise terms before changing the source-region builder again +- decide whether the continuous path should stay the only global search/rerank engine or whether tile-graph eventually needs its own lighter lattice chooser - evaluate whether the tile-graph propagation loop should become more contour-aware or simply stay more conservative now that the initial assignment is stronger - rerun tuning after the tile-graph objective and candidate sets stabilize - decide whether the tile-graph path should stay an alternate solver or become a candidate generator for the continuous refine stage @@ -114,8 +109,7 @@ Current status: - the remaining weakness on tile-graph is not candidate color purity anymore; it is candidate extraction cost plus deciding how aggressive the propagation step should be once the initial assignment is already strong - on the cleaned badge, the new CCL-backed tile-graph run under `artifacts/badge-tile-graph-ccl-cuda/` still lands at a weak `0.1800`, so the current CCL work should be treated as an infrastructure/performance step rather than a visual-quality fix - the new stroke-aware badge run under `artifacts/badge-tile-graph-stroke-v2-cuda/` still does not solve the guard-contour problem and slightly regresses to `0.1832`, so the next stroke pass should be judged on whether it changes the source-side path model more fundamentally -- the first hybrid badge run under `artifacts/badge-hybrid-v2-cuda/` improves that tile-graph baseline to `0.1785`, which is modest but real progress in the combined direction -- on the cleaned badge, iteration is still dominated by preprocessing work rather than the tile-graph parity solver: about `44.1s` in inference, `223.9s` in low-confidence rerank probes, and `142.3s` in the final selected-candidate build before this pass started reusing the chosen phase probe +- older searched-path profiling showed that iteration was dominated by preprocessing work rather than the tile-graph parity solver: about `44.1s` in inference, `223.9s` in low-confidence rerank probes, and `142.3s` in the final selected-candidate build; the pruned tile-graph path no longer pays those rerank probes, but `_extract_source_region_tiles(...)` is still the heavy remaining step Important note: - the checkerboard is baked into the source image, not transparency diff --git a/docs/tile-graph-algorithm-map.md b/docs/tile-graph-algorithm-map.md index 5f6fab2..18446a4 100644 --- a/docs/tile-graph-algorithm-map.md +++ b/docs/tile-graph-algorithm-map.md @@ -157,12 +157,11 @@ This is the mural after the graph paper has already been pressed onto it. Each s - `candidate_coords`: output coord `(y, x)` each candidate belongs to - `candidate_area_ratio`: how much of a cell-sized source window the source region covered - `candidate_coverage`: clipped version of area coverage - - `candidate_deltas`: expected RGBA deltas to right/down/left/up neighbors sampled one cell away in source space - - `cell_candidate_offsets`, `cell_candidate_indices`: CSR-like mapping from output cells to candidate rows - - `reference_mean_rgba`, `reference_sharp_rgba`, `reference_edge_rgba` - - `edge_strength` - - `component_count`, `edge_density`, `average_choices` - - `geometry_reference_rgba`, `geometry_strength`: optional hybrid-mode priors +- `candidate_deltas`: expected RGBA deltas to right/down/left/up neighbors sampled one cell away in source space +- `cell_candidate_offsets`, `cell_candidate_indices`: CSR-like mapping from output cells to candidate rows +- `reference_mean_rgba`, `reference_sharp_rgba`, `reference_edge_rgba` +- `edge_strength` +- `component_count`, `edge_density`, `average_choices` Plain-language picture: @@ -182,8 +181,7 @@ Main variables: - `fixed_dims`: either `None` or exact `(target_width, target_height)` resolved from CLI - `inference`: one `InferenceResult` - `analysis`: one `SourceAnalysis` -- `cached_reconstruction`: optional reused probe result from phase rerank -- `solver`: `SolverArtifacts` from continuous, tile-graph, or hybrid +- `solver`: `SolverArtifacts` from continuous or tile-graph - `cleanup`: `CleanupArtifacts` - `output_rgba`: final saved output @@ -311,10 +309,10 @@ This is a dress rehearsal. The pipeline builds a few small stage versions and as Important note for tile-graph: -- this rerank is pipeline-level, not optimizer-specific -- today `optimize_tile_graph(...)` ignores `steps`, so tile-graph rerank probes are basically full tile-graph runs +- after the pruning pass, tile-graph does not participate in this stage at all +- phase rerank is now a continuous-only wrapper -That is a performance problem, but not the cause of the fixed-126 corruption. +That keeps the tile-graph machine smaller, but it also means tile-graph currently trusts the selected or pinned lattice directly. ## Stage 5: Source Lattice Reference @@ -520,20 +518,19 @@ Now each output cell has a tray of possible chips. This stage is the bouncer at ### `build_tile_graph_model(...)` main flow -1. Resolve geometry prior inputs if hybrid mode is active. -2. Check the process-local tile-graph cache. -3. Build `source_reference`. -4. Compute `cell_w`, `cell_h`. -5. Decide `source_region_stride`. -6. Build `sampled_rgba` and `sampled_edge`. -7. Segment components. -8. Extract region tiles into `region_buckets`. -9. For each output coord: +1. Check the process-local tile-graph cache. +2. Build `source_reference`. +3. Compute `cell_w`, `cell_h`. +4. Decide `source_region_stride`. +5. Build `sampled_rgba` and `sampled_edge`. +6. Segment components. +7. Extract region tiles into `region_buckets`. +8. For each output coord: - decide whether it is an edge cell from `source_reference.edge_strength` - collect region candidates from `region_buckets[flat_index]` - downselect them with `_select_source_region_candidates(...)` - if no region candidates survive, fall back to `sharp` and optionally `edge_peak` -10. Build candidate arrays and expected neighbor deltas. +9. Build candidate arrays and expected neighbor deltas. ### `_select_source_region_candidates(...)` @@ -629,7 +626,6 @@ For each output cell and each candidate: - `area_error` - `alpha_error` - `coverage_error` -7. Optionally add hybrid geometry error. Important implication: @@ -895,7 +891,6 @@ The pieces that are secondary, advisory, or ornamental are: - `cluster_map` and its color-family story - phase rerank as a performance-expensive wrapper -- hybrid geometry priors - cleanup, in the current badge case That does not mean those pieces are useless. It means they are not the heart of the machine. If the heart is wrong, polishing those side systems will not save the result. diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index d050b3f..70fe631 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -36,7 +36,7 @@ def build_parser() -> argparse.ArgumentParser: target.add_argument( "--reconstruction-mode", default="continuous", - choices=("continuous", "tile-graph", "hybrid"), + choices=("continuous", "tile-graph"), help="Reconstruction engine to run after lattice estimation", ) target.add_argument( diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index 17a11e0..4b9774b 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -92,8 +92,6 @@ class SolverHyperParams: tile_graph_coverage_weight: float = 0.05 tile_graph_delta_weight: float = 0.45 tile_graph_iterations: int = 10 - hybrid_geometry_match_weight: float = 0.35 - hybrid_geometry_edge_boost: float = 0.55 def to_dict(self) -> dict[str, float | int]: return asdict(self) diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index eb3b769..d402e45 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -32,10 +32,6 @@ from .tile_graph import optimize_tile_graph from .types import InferenceResult, RunResult -def _reuse_phase_probe_reconstruction(reconstruction_mode: str) -> bool: - return reconstruction_mode in {"tile-graph", "hybrid"} - - def run_pipeline( input_path: str | Path, output_path: str | Path, @@ -83,8 +79,8 @@ def run_pipeline( device=device, ) inference_mode = "fixed" - analysis = analyze_source(source, seed=seed, device=device if reconstruction_mode in {"tile-graph", "hybrid"} else None) - inference, cached_reconstruction = _select_phase_candidate_with_reconstruction( + analysis = analyze_source(source, seed=seed, device=device if reconstruction_mode == "tile-graph" else None) + inference = _select_phase_candidate( source, inference, analysis=analysis, @@ -94,21 +90,16 @@ def run_pipeline( reconstruction_mode=reconstruction_mode, enable_phase_rerank=enable_phase_rerank, ) - if cached_reconstruction is not None: - solver, reconstruction_diagnostics = cached_reconstruction - reconstruction_diagnostics = dict(reconstruction_diagnostics) - reconstruction_diagnostics["reused_phase_probe_reconstruction"] = True - else: - solver, reconstruction_diagnostics = _run_reconstruction( - source, - inference=inference, - analysis=analysis, - steps=steps, - seed=seed, - device=device, - solver_params=solver_params, - reconstruction_mode=reconstruction_mode, - ) + solver, reconstruction_diagnostics = _run_reconstruction( + source, + inference=inference, + analysis=analysis, + steps=steps, + seed=seed, + device=device, + solver_params=solver_params, + reconstruction_mode=reconstruction_mode, + ) cleanup = cleanup_pixels(solver.target_rgba, source_guidance=solver.guidance_strength) palette = load_palette(palette_path) if palette_path else None palette_result = quantize_rgba(cleanup.cleaned_rgba, mode=palette_mode, palette=palette) @@ -181,7 +172,7 @@ def _select_phase_candidate( reconstruction_mode: str = "continuous", enable_phase_rerank: bool = True, ) -> InferenceResult: - selected, _ = _select_phase_candidate_with_reconstruction( + return _select_phase_candidate_with_reconstruction( source, inference, analysis=analysis, @@ -191,7 +182,6 @@ def _select_phase_candidate( reconstruction_mode=reconstruction_mode, enable_phase_rerank=enable_phase_rerank, ) - return selected def _select_phase_candidate_with_reconstruction( @@ -204,12 +194,14 @@ def _select_phase_candidate_with_reconstruction( solver_params: SolverHyperParams | None = None, reconstruction_mode: str = "continuous", enable_phase_rerank: bool = True, -): +) -> InferenceResult: solver_params = solver_params or SolverHyperParams() if not enable_phase_rerank: - return inference, None + return inference + if reconstruction_mode != "continuous": + return inference if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.phase_rerank_confidence_threshold: - return inference, None + return inference top_score = float(inference.top_candidates[0].score) candidate_records: list[dict[str, float | InferenceResult]] = [] @@ -222,7 +214,7 @@ def _select_phase_candidate_with_reconstruction( confidence=inference.confidence, top_candidates=inference.top_candidates, ) - candidate_artifacts, candidate_diagnostics = _run_reconstruction( + candidate_artifacts, _candidate_diagnostics = _run_reconstruction( source, inference=candidate_inference, analysis=analysis, @@ -250,12 +242,11 @@ def _select_phase_candidate_with_reconstruction( "edge_concentration": foreground_edge_concentration(candidate_artifacts.target_rgba), "inference_penalty": top_score - float(candidate.score), "size_delta_ratio": _size_delta_ratio(inference, candidate_inference), - "cached_reconstruction": (candidate_artifacts, candidate_diagnostics), } ) if not candidate_records: - return inference, None + return inference support_penalty = _normalize_penalty(record["support_score"] for record in candidate_records) edge_position_penalty = _normalize_penalty(record["edge_position_error"] for record in candidate_records) @@ -273,8 +264,6 @@ def _select_phase_candidate_with_reconstruction( baseline_rank: float | None = None best_rank = float("inf") best_candidate = inference - best_cached_reconstruction = None - baseline_cached_reconstruction = None annotated_candidates = [] for index, record in enumerate(candidate_records): candidate_inference = record["inference"] @@ -310,11 +299,9 @@ def _select_phase_candidate_with_reconstruction( ) if baseline_rank is None: baseline_rank = rank - baseline_cached_reconstruction = record.get("cached_reconstruction") if rank < best_rank: best_rank = rank best_candidate = record["inference"] - best_cached_reconstruction = record.get("cached_reconstruction") annotated_candidates.sort(key=lambda candidate: float(candidate.breakdown.get("phase_rerank_score", float("inf")))) for rank, candidate in enumerate(annotated_candidates, start=1): candidate.breakdown["phase_rerank_rank"] = float(rank) @@ -337,11 +324,9 @@ def _select_phase_candidate_with_reconstruction( confidence=inference.confidence, top_candidates=annotated_candidates, ) - cached = baseline_cached_reconstruction if _reuse_phase_probe_reconstruction(reconstruction_mode) else None - return selected, cached - return inference, None - cached = best_cached_reconstruction if _reuse_phase_probe_reconstruction(reconstruction_mode) else None - return best_candidate, cached + return selected + return inference + return best_candidate def _resolve_requested_target_dims( @@ -420,43 +405,6 @@ def _run_reconstruction( device=device, solver_params=solver_params, ) - if reconstruction_mode == "hybrid": - geometry_solver = optimize_uv_field( - source, - inference=inference, - analysis=analysis, - steps=0, - seed=seed, - device=device, - solver_params=solver_params, - ) - hybrid_solver, hybrid_diagnostics = optimize_tile_graph( - source, - inference=inference, - analysis=analysis, - steps=steps, - seed=seed, - device=device, - solver_params=solver_params, - geometry_reference_rgba=geometry_solver.target_rgba, - geometry_guidance_strength=geometry_solver.guidance_strength, - ) - geometry_score = source_lattice_consistency_breakdown( - source, - geometry_solver.target_rgba, - target_width=inference.target_width, - target_height=inference.target_height, - phase_x=inference.phase_x, - phase_y=inference.phase_y, - )["score"] - diagnostics = dict(hybrid_diagnostics) - diagnostics["mode"] = "hybrid" - diagnostics["hybrid_geometry_prepass_mode"] = "continuous" - diagnostics["hybrid_geometry_prepass_steps"] = 0.0 - diagnostics["hybrid_geometry_prepass_source_fidelity"] = float(geometry_score) - diagnostics["hybrid_geometry_match_weight"] = float(solver_params.hybrid_geometry_match_weight) - diagnostics["hybrid_geometry_edge_boost"] = float(solver_params.hybrid_geometry_edge_boost) - return hybrid_solver, diagnostics solver = optimize_uv_field( source, inference=inference, diff --git a/src/repixelizer/tile_graph.py b/src/repixelizer/tile_graph.py index ad2465e..d3af3c6 100644 --- a/src/repixelizer/tile_graph.py +++ b/src/repixelizer/tile_graph.py @@ -68,8 +68,6 @@ class TileGraphModel: edge_density: float average_choices: float model_device: str - geometry_reference_rgba: np.ndarray | None = None - geometry_strength: np.ndarray | None = None cache_hit: bool = False @@ -877,26 +875,10 @@ def build_tile_graph_model( analysis: SourceAnalysis, solver_params: SolverHyperParams | None = None, device: str = "cpu", - geometry_reference_rgba: np.ndarray | None = None, - geometry_guidance_strength: np.ndarray | None = None, ) -> TileGraphModel: torch = _require_torch() resolved_device = _resolve_device(torch, device) solver_params = solver_params or SolverHyperParams() - geometry_reference_np = None - if geometry_reference_rgba is not None: - geometry_reference_np = np.asarray(geometry_reference_rgba, dtype=np.float32) - if geometry_reference_np.shape[:2] != (inference.target_height, inference.target_width): - raise ValueError( - "geometry_reference_rgba must match the inferred output size when provided to tile-graph." - ) - geometry_strength_np = None - if geometry_guidance_strength is not None: - geometry_strength_np = np.asarray(geometry_guidance_strength, dtype=np.float32) - if geometry_strength_np.shape != (inference.target_height, inference.target_width): - raise ValueError( - "geometry_guidance_strength must match the inferred output size when provided to tile-graph." - ) cache_key = _tile_graph_model_cache_key( source_rgba, inference=inference, @@ -905,12 +887,7 @@ def build_tile_graph_model( ) cached_model = _get_cached_tile_graph_model(cache_key) if cached_model is not None: - return replace( - cached_model, - geometry_reference_rgba=geometry_reference_np, - geometry_strength=geometry_strength_np, - cache_hit=True, - ) + return replace(cached_model, cache_hit=True) source_reference = build_source_lattice_reference( source_rgba, target_width=inference.target_width, @@ -1106,17 +1083,10 @@ def build_tile_graph_model( edge_density=edge_density, average_choices=average_choices, model_device=resolved_device, - geometry_reference_rgba=None, - geometry_strength=None, cache_hit=False, ) _store_cached_tile_graph_model(cache_key, base_model) - return replace( - base_model, - geometry_reference_rgba=geometry_reference_np, - geometry_strength=geometry_strength_np, - cache_hit=False, - ) + return replace(base_model, cache_hit=False) def _cell_candidate_span(model: TileGraphModel, y: int, x: int) -> tuple[int, int]: @@ -1175,18 +1145,6 @@ def _tile_graph_unary_cost(model: TileGraphModel, solver_params: SolverHyperPara + alpha_error * solver_params.tile_graph_alpha_weight + coverage_error * solver_params.tile_graph_coverage_weight ) - if model.geometry_reference_rgba is not None: - geometry_reference = model.geometry_reference_rgba[y, x] - geometry_error = np.mean(np.abs(geometry_reference[None, :] - candidates), axis=-1) - geometry_strength = 1.0 - if model.geometry_strength is not None: - geometry_strength += ( - float(np.clip(model.geometry_strength[y, x], 0.0, 1.0)) - * solver_params.hybrid_geometry_edge_boost - ) - total_cost = total_cost + geometry_error * ( - solver_params.hybrid_geometry_match_weight * geometry_strength - ) unary_cost[start:end] = total_cost.astype(np.float32) return unary_cost @@ -1208,17 +1166,6 @@ def _tile_graph_unary_cost_torch( reference_mean_t = torch.from_numpy(model.reference_mean_rgba).to(device=device, dtype=torch.float32) reference_edge_t = torch.from_numpy(model.reference_edge_rgba).to(device=device, dtype=torch.float32) edge_strength_t = torch.from_numpy(model.edge_strength).to(device=device, dtype=torch.float32) - geometry_reference_t = ( - torch.from_numpy(model.geometry_reference_rgba).to(device=device, dtype=torch.float32) - if model.geometry_reference_rgba is not None - else None - ) - geometry_strength_t = ( - torch.from_numpy(model.geometry_strength).to(device=device, dtype=torch.float32) - if model.geometry_strength is not None - else None - ) - choice_rgba_t = candidate_rgba_t[choice_indices_t] choice_deltas_t = candidate_deltas_t[choice_indices_t] sharp_error_t = (reference_sharp_t[..., None, :] - choice_rgba_t).abs().mean(dim=-1) @@ -1240,14 +1187,6 @@ def _tile_graph_unary_cost_torch( + alpha_error * solver_params.tile_graph_alpha_weight + coverage_error * solver_params.tile_graph_coverage_weight ) - if geometry_reference_t is not None: - geometry_error_t = (geometry_reference_t[..., None, :] - choice_rgba_t).abs().mean(dim=-1) - geometry_scale_t = 1.0 - if geometry_strength_t is not None: - geometry_scale_t = 1.0 + geometry_strength_t[..., None].clamp(0.0, 1.0) * solver_params.hybrid_geometry_edge_boost - unary_cost_t = unary_cost_t + geometry_error_t * ( - solver_params.hybrid_geometry_match_weight * geometry_scale_t - ) unary_cost_t = unary_cost_t.masked_fill(~choice_mask_t, float("inf")) return unary_cost_t, candidate_rgba_t, candidate_deltas_t, choice_rgba_t, choice_deltas_t @@ -1420,8 +1359,6 @@ def optimize_tile_graph( seed: int, device: str, solver_params: SolverHyperParams | None = None, - geometry_reference_rgba: np.ndarray | None = None, - geometry_guidance_strength: np.ndarray | None = None, ) -> tuple[SolverArtifacts, dict[str, Any]]: del steps torch = _require_torch() @@ -1436,8 +1373,6 @@ def optimize_tile_graph( analysis=analysis, solver_params=solver_params, device=resolved_device, - geometry_reference_rgba=geometry_reference_rgba, - geometry_guidance_strength=geometry_guidance_strength, ) choice_indices_np, choice_mask_np = _build_choice_grid(model) choice_indices_t = torch.from_numpy(choice_indices_np).to(device=resolved_device, dtype=torch.long) @@ -1561,7 +1496,6 @@ def optimize_tile_graph( "tile_graph_initial_source_fidelity": float(initial_source_fidelity), "tile_graph_final_source_fidelity": float(final_source_fidelity), "tile_graph_kept_initial_assignment": kept_initial_assignment, - "tile_graph_geometry_prior": model.geometry_reference_rgba is not None, } return ( SolverArtifacts( diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 485ee07..479288c 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -222,106 +222,6 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None: assert selected.target_height == candidate_a.target_height -def test_hybrid_reconstruction_passes_continuous_geometry_into_tile_graph(monkeypatch) -> None: - source = np.zeros((4, 4, 4), dtype=np.float32) - geometry_rgba = np.zeros((2, 2, 4), dtype=np.float32) - geometry_rgba[0, 0] = np.asarray([1.0, 0.0, 0.0, 1.0], dtype=np.float32) - geometry_guidance = np.asarray([[0.8, 0.2], [0.1, 0.6]], dtype=np.float32) - inference = InferenceResult( - target_width=2, - target_height=2, - phase_x=0.0, - phase_y=0.0, - confidence=1.0, - top_candidates=[], - ) - - geometry_solver = SolverArtifacts( - target_rgba=geometry_rgba, - uv_field=np.zeros((2, 2, 2), dtype=np.float32), - guidance_strength=geometry_guidance, - initial_rgba=geometry_rgba.copy(), - loss_history=[], - ) - - def fake_optimize_uv_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None): - assert steps == 0 - return geometry_solver - - def fake_optimize_tile_graph( - source_rgba, - inference, - analysis, - steps, - seed, - device, - solver_params=None, - geometry_reference_rgba=None, - geometry_guidance_strength=None, - ): - assert np.allclose(geometry_reference_rgba, geometry_rgba) - assert np.allclose(geometry_guidance_strength, geometry_guidance) - return ( - SolverArtifacts( - target_rgba=geometry_rgba.copy(), - uv_field=np.zeros((2, 2, 2), dtype=np.float32), - guidance_strength=np.zeros((2, 2), dtype=np.float32), - initial_rgba=geometry_rgba.copy(), - loss_history=[], - ), - {"mode": "tile-graph", "tile_graph_geometry_prior": True}, - ) - - monkeypatch.setattr("repixelizer.pipeline.optimize_uv_field", fake_optimize_uv_field) - monkeypatch.setattr("repixelizer.pipeline.optimize_tile_graph", fake_optimize_tile_graph) - - solver, diagnostics = _run_reconstruction( - source, - inference=inference, - analysis=object(), - steps=12, - seed=7, - device="cpu", - solver_params=SolverHyperParams(), - reconstruction_mode="hybrid", - ) - - assert np.allclose(solver.target_rgba, geometry_rgba) - assert diagnostics["mode"] == "hybrid" - assert diagnostics["hybrid_geometry_prepass_mode"] == "continuous" - assert diagnostics["tile_graph_geometry_prior"] is True - - -def test_pipeline_hybrid_mode_writes_reconstruction_diagnostics(tmp_path: Path) -> None: - source = make_emblem(16, 16) - fake = fake_pixelize(source, upscale=8, phase_x=0.1, phase_y=-0.05, blur_radius=0.4, warp_strength=0.15, warp_detail=4) - input_path = tmp_path / "input.png" - output_path = tmp_path / "output.png" - diagnostics_dir = tmp_path / "diagnostics" - - from repixelizer.io import save_rgba - - save_rgba(input_path, fake) - result = run_pipeline( - input_path, - output_path, - diagnostics_dir=diagnostics_dir, - reconstruction_mode="hybrid", - steps=0, - device="cpu", - ) - - import json - - run_json = json.loads((diagnostics_dir / "run.json").read_text(encoding="utf-8")) - assert output_path.exists() - assert run_json["settings"]["reconstruction_mode"] == "hybrid" - assert run_json["reconstruction"]["mode"] == "hybrid" - assert run_json["reconstruction"]["tile_graph_geometry_prior"] is True - assert run_json["reconstruction"]["hybrid_geometry_prepass_mode"] == "continuous" - assert result.diagnostics["reconstruction"]["mode"] == "hybrid" - - def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None: source = np.zeros((8, 8, 4), dtype=np.float32) candidate_a = InferenceCandidate(target_width=8, target_height=8, phase_x=0.0, phase_y=0.0, score=0.9, breakdown={}) @@ -351,7 +251,36 @@ def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None: assert selected.target_height == candidate_a.target_height -def test_pipeline_reuses_phase_probe_reconstruction_for_tile_graph(tmp_path: Path, monkeypatch) -> None: +def test_select_phase_candidate_skips_phase_rerank_for_tile_graph(monkeypatch) -> None: + source = np.zeros((8, 8, 4), dtype=np.float32) + candidate_a = InferenceCandidate(target_width=8, target_height=8, phase_x=0.0, phase_y=0.0, score=0.9, breakdown={}) + candidate_b = InferenceCandidate(target_width=10, target_height=10, phase_x=0.2, phase_y=0.2, score=0.89, breakdown={}) + inference = InferenceResult( + target_width=8, + target_height=8, + phase_x=0.0, + phase_y=0.0, + confidence=0.0, + top_candidates=[candidate_a, candidate_b], + ) + + def fail(*args, **kwargs): + raise AssertionError("tile-graph phase rerank probe should not run") + + monkeypatch.setattr("repixelizer.pipeline.optimize_tile_graph", fail) + selected = _select_phase_candidate( + source, + inference, + analysis=object(), + seed=7, + device="cpu", + reconstruction_mode="tile-graph", + ) + assert selected.target_width == candidate_a.target_width + assert selected.target_height == candidate_a.target_height + + +def test_pipeline_runs_tile_graph_once_without_phase_probe_reuse(tmp_path: Path, monkeypatch) -> None: source = np.zeros((6, 6, 4), dtype=np.float32) source[..., 3] = 1.0 input_path = tmp_path / "input.png" @@ -385,10 +314,8 @@ def test_pipeline_reuses_phase_probe_reconstruction_for_tile_graph(tmp_path: Pat seed, device, solver_params=None, - geometry_reference_rgba=None, - geometry_guidance_strength=None, ): - del source_rgba, analysis, steps, seed, device, solver_params, geometry_reference_rgba, geometry_guidance_strength + del source_rgba, analysis, steps, seed, device, solver_params call_count["tile_graph"] += 1 rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32) rgba[..., 3] = 1.0 @@ -425,8 +352,8 @@ def test_pipeline_reuses_phase_probe_reconstruction_for_tile_graph(tmp_path: Pat ) assert output_path.exists() - assert call_count["tile_graph"] == 2 - assert result.diagnostics["reconstruction"]["reused_phase_probe_reconstruction"] is True + assert call_count["tile_graph"] == 1 + assert "reused_phase_probe_reconstruction" not in result.diagnostics["reconstruction"] assert result.output_rgba.shape[:2] == (3, 3)