From cd0930736761de925012e8499be524252ba1e4d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 23 Apr 2026 03:56:03 +0100 Subject: [PATCH] Trim tile-graph to edge-only analysis --- README.md | 1 + docs/implementation-plan.md | 2 ++ docs/tile-graph-algorithm-map.md | 35 +++++++++++++---------- src/repixelizer/analysis.py | 49 +++++++++++++++++++------------- src/repixelizer/params.py | 2 -- src/repixelizer/pipeline.py | 10 +++++-- src/repixelizer/tile_graph.py | 48 +++---------------------------- tests/test_analysis.py | 14 +++++++++ tests/test_tile_graph.py | 25 ++++++++++++++++ 9 files changed, 104 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 3d46f11..5853a1f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Current tile-graph status: - source-region connected-components now have a device-side Torch path, so the expensive labeling step no longer depends on Python flood fill - 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(...)` - 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 +- tile-graph now also skips cluster analysis entirely and no longer uses the per-cell mean-color pull in unary scoring; the path keeps only edge scouting plus sharp/edge lattice references - 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` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index ae414f4..52e6213 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -63,8 +63,10 @@ What landed: - `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 +- `tile-graph` now skips source clustering entirely and uses only edge analysis plus sharp/edge lattice references during 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 +- `tests/test_analysis.py` and `tests/test_tile_graph.py` now cover the new edge-only analysis path - `docs/tile-graph-algorithm-map.md` now describes the smaller machine directly rather than documenting the removed hybrid sidecar path Current implementation note: diff --git a/docs/tile-graph-algorithm-map.md b/docs/tile-graph-algorithm-map.md index 18446a4..6c7f625 100644 --- a/docs/tile-graph-algorithm-map.md +++ b/docs/tile-graph-algorithm-map.md @@ -129,6 +129,11 @@ Plain-language picture: This is a scouting report about where the mural has edges and rough color families. It is binoculars, not ownership papers. +Current tile-graph note: + +- the tile-graph path now asks only for the edge scout report +- cluster labels and cluster preview are left empty on that path because the tile-graph machine does not use them + ### `SourceLatticeReference` - File: `src/repixelizer/types.py` @@ -157,11 +162,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` + - `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_sharp_rgba`, `reference_edge_rgba` + - `edge_strength` + - `component_count`, `edge_density`, `average_choices` Plain-language picture: @@ -263,7 +268,7 @@ File: `src/repixelizer/analysis.py` Entry point: -- `analyze_source(rgba, seed, cluster_count=6, device=None)` +- `analyze_source(rgba, seed, cluster_count=6, device=None, include_clusters=True)` Outputs: @@ -281,6 +286,7 @@ Important reality check: - tile-graph no longer uses `cluster_map` as its actual tile ownership map - the tile-graph region ownership is built later by connected components over source pixels using color/alpha thresholds +- after the pruning pass, tile-graph can skip cluster assignment entirely and still run normally because it only needs `edge_map` ## Stage 4: Optional Phase Rerank @@ -567,8 +573,6 @@ After candidate selection, the model stores: - clipped area coverage proxy - `candidate_deltas` - sampled right/down/left/up expected deltas one cell away in source space -- `reference_mean_rgba` - - lattice-conditioned per-cell mean - `reference_sharp_rgba` - lattice-conditioned per-cell exemplar - `reference_edge_rgba` @@ -616,13 +620,12 @@ Transforms CSR-style cell candidate storage into dense tensors: For each output cell and each candidate: 1. `sharp_error` -2. `mean_error` -3. `edge_error` -4. If edge cell: - - `color_error = min(sharp_error, edge_error) + mean_error * tile_graph_edge_mean_weight` -5. Else: - - `color_error = sharp_error * nonedge_sharp_weight + mean_error * nonedge_mean_weight` -6. Add: +2. `edge_error` +3. If edge cell: + - `color_error = min(sharp_error, edge_error)` +4. Else: + - `color_error = sharp_error * nonedge_sharp_weight` +5. Add: - `area_error` - `alpha_error` - `coverage_error` @@ -634,6 +637,8 @@ Important implication: This is the emotional center of the current design. The algorithm does not trust source-region ownership alone. It insists on comparing each legal chip to a portrait painted by the lattice reference first. +The portrait is smaller now than it used to be. Tile-graph no longer asks, "How close is this chip to the cell average color?" It only asks, "How close is this chip to the cell's sharp exemplar or edge exemplar?" That keeps the judge from rewarding mush just because mush looks average. + ## Stage 11: Initial Assignment File: `src/repixelizer/tile_graph.py` diff --git a/src/repixelizer/analysis.py b/src/repixelizer/analysis.py index 61e61ad..244c97b 100644 --- a/src/repixelizer/analysis.py +++ b/src/repixelizer/analysis.py @@ -113,25 +113,34 @@ def _colorize_clusters(cluster_map: np.ndarray, centers: np.ndarray) -> np.ndarr return np.concatenate([preview, alpha], axis=-1) -def analyze_source(rgba: np.ndarray, seed: int, cluster_count: int = 6, device: str | None = None) -> SourceAnalysis: +def analyze_source( + rgba: np.ndarray, + seed: int, + cluster_count: int = 6, + device: str | None = None, + *, + include_clusters: bool = True, +) -> SourceAnalysis: if device is not None: torch = _require_torch() resolved_device = _resolve_device(torch, device) rgba_t = torch.from_numpy(rgba).to(device=resolved_device, dtype=torch.float32) edge_map_t = _compute_edge_map_torch(torch, rgba_t) alpha_t = rgba_t[..., 3] - opaque_t = alpha_t > 0.05 cluster_map_t = torch.full(alpha_t.shape, -1, device=resolved_device, dtype=torch.long) centers_t = torch.zeros((0, 4), device=resolved_device, dtype=torch.float32) - if bool(torch.any(opaque_t).item()): - samples_t = rgba_t[opaque_t] - k = min(cluster_count, max(2, int(np.sqrt(int(samples_t.shape[0]) // 64 + 1)))) - centers_t, labels_t = _kmeans_torch(torch, samples_t, k=k, seed=seed) - cluster_map_t[opaque_t] = labels_t - preview_palette_t = centers_t[:, :3].clamp(0.0, 1.0) if centers_t.numel() else torch.zeros((1, 3), device=resolved_device, dtype=torch.float32) - preview_indices_t = torch.clamp(cluster_map_t, 0, max(0, preview_palette_t.shape[0] - 1)) - preview_t = preview_palette_t[preview_indices_t] - preview_t = torch.cat([preview_t, alpha_t[..., None]], dim=-1) + preview_t = torch.zeros((*alpha_t.shape, 4), device=resolved_device, dtype=torch.float32) + if include_clusters: + opaque_t = alpha_t > 0.05 + if bool(torch.any(opaque_t).item()): + samples_t = rgba_t[opaque_t] + k = min(cluster_count, max(2, int(np.sqrt(int(samples_t.shape[0]) // 64 + 1)))) + centers_t, labels_t = _kmeans_torch(torch, samples_t, k=k, seed=seed) + cluster_map_t[opaque_t] = labels_t + preview_palette_t = centers_t[:, :3].clamp(0.0, 1.0) if centers_t.numel() else torch.zeros((1, 3), device=resolved_device, dtype=torch.float32) + preview_indices_t = torch.clamp(cluster_map_t, 0, max(0, preview_palette_t.shape[0] - 1)) + preview_t = preview_palette_t[preview_indices_t] + preview_t = torch.cat([preview_t, alpha_t[..., None]], dim=-1) return SourceAnalysis( edge_map=edge_map_t.detach().cpu().numpy().astype(np.float32), cluster_map=cluster_map_t.detach().cpu().numpy().astype(np.int32), @@ -142,16 +151,18 @@ def analyze_source(rgba: np.ndarray, seed: int, cluster_count: int = 6, device: edge_map = _compute_edge_map(rgba) alpha = rgba[..., 3] - opaque = alpha > 0.05 cluster_map = np.full(alpha.shape, -1, dtype=np.int32) centers = np.zeros((0, 4), dtype=np.float32) - if np.any(opaque): - samples = rgba[opaque] - k = min(cluster_count, max(2, int(np.sqrt(samples.shape[0] // 64 + 1)))) - centers, labels = _kmeans(samples, k=k, seed=seed) - cluster_map[opaque] = labels - preview = _colorize_clusters(np.maximum(cluster_map, 0), centers if centers.size else np.zeros((1, 4), dtype=np.float32)) - preview[..., 3] = alpha + preview = np.zeros((*alpha.shape, 4), dtype=np.float32) + if include_clusters: + opaque = alpha > 0.05 + if np.any(opaque): + samples = rgba[opaque] + k = min(cluster_count, max(2, int(np.sqrt(samples.shape[0] // 64 + 1)))) + centers, labels = _kmeans(samples, k=k, seed=seed) + cluster_map[opaque] = labels + preview = _colorize_clusters(np.maximum(cluster_map, 0), centers if centers.size else np.zeros((1, 4), dtype=np.float32)) + preview[..., 3] = alpha return SourceAnalysis( edge_map=edge_map, cluster_map=cluster_map, diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index 4b9774b..fb63b4e 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -85,8 +85,6 @@ class SolverHyperParams: tile_graph_stroke_step_scale: float = 0.95 tile_graph_stroke_minor_limit_scale: float = 0.55 tile_graph_nonedge_sharp_weight: float = 0.85 - tile_graph_nonedge_mean_weight: float = 0.15 - tile_graph_edge_mean_weight: float = 0.03 tile_graph_area_weight: float = 0.03 tile_graph_alpha_weight: float = 0.25 tile_graph_coverage_weight: float = 0.05 diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index d402e45..d7dcda7 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -79,7 +79,12 @@ def run_pipeline( device=device, ) inference_mode = "fixed" - analysis = analyze_source(source, seed=seed, device=device if reconstruction_mode == "tile-graph" else None) + analysis = analyze_source( + source, + seed=seed, + device=device if reconstruction_mode == "tile-graph" else None, + include_clusters=reconstruction_mode != "tile-graph", + ) inference = _select_phase_candidate( source, inference, @@ -134,7 +139,8 @@ def run_pipeline( ) write_alpha_preview(diagnostics_path / "alpha-preview.png", source, output_rgba) write_heatmap(diagnostics_path / "noise-heatmap.png", cleanup.isolated_heatmap) - save_rgba(diagnostics_path / "cluster-preview.png", analysis.cluster_preview) + if analysis.cluster_centers.size: + save_rgba(diagnostics_path / "cluster-preview.png", analysis.cluster_preview) run_json = summarize_run(result) run_json["inference"] = inference_to_json(inference) run_json["settings"] = { diff --git a/src/repixelizer/tile_graph.py b/src/repixelizer/tile_graph.py index d3af3c6..8a7d9b5 100644 --- a/src/repixelizer/tile_graph.py +++ b/src/repixelizer/tile_graph.py @@ -60,7 +60,6 @@ class TileGraphModel: candidate_deltas: np.ndarray cell_candidate_offsets: np.ndarray cell_candidate_indices: np.ndarray - reference_mean_rgba: np.ndarray reference_sharp_rgba: np.ndarray reference_edge_rgba: np.ndarray edge_strength: np.ndarray @@ -1075,7 +1074,6 @@ def build_tile_graph_model( candidate_deltas=candidate_deltas_np, cell_candidate_offsets=cell_candidate_offsets, cell_candidate_indices=cell_candidate_indices_np, - reference_mean_rgba=source_reference.mean_rgba.astype(np.float32), reference_sharp_rgba=source_reference.sharp_rgba.astype(np.float32), reference_edge_rgba=edge_rgba_np.reshape(inference.target_height, inference.target_width, -1).astype(np.float32), edge_strength=source_reference.edge_strength.astype(np.float32), @@ -1113,42 +1111,6 @@ def _build_choice_grid(model: TileGraphModel) -> tuple[np.ndarray, np.ndarray]: return choice_indices, choice_mask -def _tile_graph_unary_cost(model: TileGraphModel, solver_params: SolverHyperParams) -> np.ndarray: - height, width = model.reference_sharp_rgba.shape[:2] - unary_cost = np.full(model.cell_candidate_indices.shape[0], np.inf, dtype=np.float32) - for y in range(height): - for x in range(width): - start, end = _cell_candidate_span(model, y, x) - if end <= start: - continue - indices = model.cell_candidate_indices[start:end] - reference_sharp = model.reference_sharp_rgba[y, x] - reference_mean = model.reference_mean_rgba[y, x] - reference_edge = model.reference_edge_rgba[y, x] - candidates = model.candidate_rgba[indices] - sharp_error = np.mean(np.abs(reference_sharp[None, :] - candidates), axis=-1) - mean_error = np.mean(np.abs(reference_mean[None, :] - candidates), axis=-1) - edge_error = np.mean(np.abs(reference_edge[None, :] - candidates), axis=-1) - if float(model.edge_strength[y, x]) >= solver_params.source_edge_detail_threshold: - color_error = np.minimum(sharp_error, edge_error) + mean_error * solver_params.tile_graph_edge_mean_weight - else: - color_error = ( - sharp_error * solver_params.tile_graph_nonedge_sharp_weight - + mean_error * solver_params.tile_graph_nonedge_mean_weight - ) - area_error = np.abs(np.log(np.clip(model.candidate_area_ratio[indices], 1e-4, None) + 1e-4)) - alpha_error = np.abs(candidates[:, 3] - reference_mean[3]) - coverage_error = 1.0 - np.clip(model.candidate_coverage[indices], 0.0, 1.0) - total_cost = ( - color_error - + area_error * solver_params.tile_graph_area_weight - + alpha_error * solver_params.tile_graph_alpha_weight - + coverage_error * solver_params.tile_graph_coverage_weight - ) - unary_cost[start:end] = total_cost.astype(np.float32) - return unary_cost - - def _tile_graph_unary_cost_torch( torch, model: TileGraphModel, @@ -1163,23 +1125,21 @@ def _tile_graph_unary_cost_torch( candidate_area_t = torch.from_numpy(model.candidate_area_ratio).to(device=device, dtype=torch.float32) candidate_coverage_t = torch.from_numpy(model.candidate_coverage).to(device=device, dtype=torch.float32) reference_sharp_t = torch.from_numpy(model.reference_sharp_rgba).to(device=device, dtype=torch.float32) - 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) 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) - mean_error_t = (reference_mean_t[..., None, :] - choice_rgba_t).abs().mean(dim=-1) edge_error_t = (reference_edge_t[..., None, :] - choice_rgba_t).abs().mean(dim=-1) edge_cell_mask_t = edge_strength_t[..., None] >= solver_params.source_edge_detail_threshold color_error = torch.where( edge_cell_mask_t, - torch.minimum(sharp_error_t, edge_error_t) + mean_error_t * solver_params.tile_graph_edge_mean_weight, - sharp_error_t * solver_params.tile_graph_nonedge_sharp_weight - + mean_error_t * solver_params.tile_graph_nonedge_mean_weight, + torch.minimum(sharp_error_t, edge_error_t), + sharp_error_t * solver_params.tile_graph_nonedge_sharp_weight, ) area_error = torch.log(candidate_area_t[choice_indices_t].clamp_min(1e-4) + 1e-4).abs() - alpha_error = (choice_rgba_t[..., 3] - reference_mean_t[..., None, 3]).abs() + alpha_reference_t = torch.maximum(reference_sharp_t[..., 3], reference_edge_t[..., 3]) + alpha_error = (choice_rgba_t[..., 3] - alpha_reference_t[..., None]).abs() coverage_error = 1.0 - candidate_coverage_t[choice_indices_t].clamp(0.0, 1.0) unary_cost_t = ( color_error diff --git a/tests/test_analysis.py b/tests/test_analysis.py index b206397..134e08f 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -18,3 +18,17 @@ def test_analyze_source_cpu_device_matches_default_path() -> None: assert np.allclose(accelerated.alpha_map, default.alpha_map, atol=1e-5) assert accelerated.cluster_map.shape == default.cluster_map.shape assert accelerated.cluster_preview.shape == default.cluster_preview.shape + + +def test_analyze_source_can_skip_cluster_analysis() -> None: + source = np.zeros((8, 8, 4), dtype=np.float32) + source[..., 3] = 1.0 + source[2:6, 3:5, :3] = np.asarray([1.0, 0.8, 0.2], dtype=np.float32) + + analysis = analyze_source(source, seed=7, device="cpu", include_clusters=False) + + assert analysis.edge_map.shape == source.shape[:2] + assert np.allclose(analysis.alpha_map, source[..., 3], atol=1e-5) + assert analysis.cluster_centers.size == 0 + assert np.all(analysis.cluster_map == -1) + assert np.count_nonzero(analysis.cluster_preview) == 0 diff --git a/tests/test_tile_graph.py b/tests/test_tile_graph.py index 9ea8c1c..d188451 100644 --- a/tests/test_tile_graph.py +++ b/tests/test_tile_graph.py @@ -47,6 +47,31 @@ def test_tile_graph_model_extracts_candidates_and_adjacency_from_lattice_proposa assert model.model_device == "cpu" +def test_tile_graph_model_works_with_edge_only_analysis() -> None: + lowres = np.zeros((4, 4, 4), dtype=np.float32) + lowres[..., 3] = 1.0 + lowres[1:3, 1:3] = np.asarray([1.0, 0.7, 0.2, 1.0], dtype=np.float32) + source = np.repeat(np.repeat(lowres, 8, axis=0), 8, axis=1) + inference = InferenceResult( + target_width=4, + target_height=4, + phase_x=0.0, + phase_y=0.0, + confidence=1.0, + top_candidates=[], + ) + + model = build_tile_graph_model( + source, + inference=inference, + analysis=analyze_source(source, seed=5, device="cpu", include_clusters=False), + device="cpu", + ) + + assert model.candidate_rgba.shape[0] > 0 + assert model.model_device == "cpu" + + def test_tile_graph_candidates_use_literal_source_pixel_colors() -> None: source = np.zeros((8, 8, 4), dtype=np.float32) source[..., 3] = 1.0