From be2c36fbd4d6d43e3d182d7d981b0520b82e1a14 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 23 Apr 2026 03:00:51 +0100 Subject: [PATCH] Fix tile-graph empty cell extraction coverage --- README.md | 1 + docs/implementation-plan.md | 5 +- docs/tile-graph-algorithm-map.md | 53 +++++++++--------- src/repixelizer/tile_graph.py | 72 ++++++++++++++++++++++++- tests/test_tile_graph.py | 92 +++++++++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 92c390e..eab3b51 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Current tile-graph status: - it now keeps candidates strictly local to each output coord and only uses literal source-pixel colors, so it no longer invents averaged patch colors or borrow labels from distant regions - candidate seeding now starts from source-side atomic regions projected onto the output lattice, layered together with the old sharp/edge anchors instead of replacing them outright - the new deep-dive map in `docs/tile-graph-algorithm-map.md` walks the tile-graph path end to end and records the current fixed-lattice failure mode: the garbled `126x126` badge output is already bad at the tile-graph initial assignment stage, not because the fixed-lattice pipeline wrapper is corrupting it +- the extraction stage now has an explicit empty-cell overlap fill pass, so output cells that actually contain opaque sampled source pixels no longer silently lose their extracted region bucket under the fixed badge lattice - the latest full-emblem probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity, beating the earlier full-CUDA tile-graph baseline at `0.0283` - 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 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d0fc93c..2af6f2d 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -24,7 +24,7 @@ Verified: - compare-mode smoke run against a real emblem image - the cleaned real badge fixture in `tests/fixtures/real/ai-badge-cleaned.png` still beats naive resize on source-lattice consistency on the continuous path - the latest full-emblem tile-graph atomic probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity, beating the older full-emblem tile-graph CUDA baseline at `0.0283` -- the new algorithm map in `docs/tile-graph-algorithm-map.md` confirms that the pinned `126x126` tile-graph badge collapse is already present in the tile-graph initial assignment; the fixed-lattice pipeline path itself is reproducing that bad state faithfully rather than introducing it +- the new algorithm map in `docs/tile-graph-algorithm-map.md` confirms that the pinned `126x126` tile-graph badge collapse is already present in the tile-graph initial assignment; the fixed-lattice pipeline path itself is reproducing that bad state faithfully rather than introducing it, and the newer extraction coverage fix now guarantees that occupied output cells are not silently losing their source-region bucket under the corrected full-size lattice mapping ## Status after adjacency-first pass @@ -67,13 +67,14 @@ What landed: - `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 also records the current fixed-lattice failure anatomy on the cleaned badge: with pinned `126x126` / phase `(0.0, -0.2)`, about `50.4%` of output cells (`7999 / 15876`) have no extracted region candidate before fallback and the broken result is already bad at `tile_graph_initial_source_fidelity = 0.500884` +- 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` 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 - 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 diff --git a/docs/tile-graph-algorithm-map.md b/docs/tile-graph-algorithm-map.md index 99394e7..5a64f4f 100644 --- a/docs/tile-graph-algorithm-map.md +++ b/docs/tile-graph-algorithm-map.md @@ -19,6 +19,7 @@ On the current broken badge run: - phase: `(0.0, -0.2)` - `tile_graph_initial_source_fidelity = 0.500884` - `tile_graph_final_source_fidelity = 0.499863` +- every output cell that contains opaque sampled source pixels now does receive at least one extracted region candidate under the corrected full-size lattice mapping That tiny change means the parity-update loop is not the main failure. The model build and initial assignment are. @@ -639,28 +640,17 @@ Directly measured on `tests/fixtures/real/ai-badge-cleaned.png` with pinned `126 - connected components after segmentation: `2499` - output cells: `15876` - edge cells by `source_reference.edge_strength`: `1610` - -Region-bucket statistics before fallback: - -- cells with no extracted source-region candidates at all: `7999 / 15876` (`50.4%`) -- edge cells with no extracted source-region candidates: `325 / 1610` (`20.2%`) -- mean raw region candidates per cell: `1.24` -- mean selected region candidates per cell before fallback: `0.93` - -Selection histogram before fallback: - -- `7999` cells: `0` region candidates survive -- `1829` cells: `1` -- `5496` cells: `2` -- `306` cells: `3` -- `181` cells: `4` -- `51` cells: `5` -- `14` cells: `6` +- output cells with any opaque sampled source pixel under the full-size lattice mapping: `8167` +- output cells with any extracted source-region bucket after the empty-cell fill pass: `8169` +- occupied output cells still missing extracted region buckets: `0` +- output cells with no extracted region bucket are now background-only cells by design: `7707` What that means: -- about half the grid gets no source-region candidate at all and must fall back to lattice-derived anchors -- those anchors come from `source_reference`, which is itself conditioned on the pinned lattice +- connected-components still ignore pixels below the alpha threshold, so background-only cells do not get source-region buckets from this stage +- an earlier draft of this document overstated the loss by comparing sampled pixels against `lattice_indices(...)` on the downsampled grid instead of projecting sampled source coordinates back onto the full-size lattice; that comparison was wrong and has now been corrected +- the missing-bucket bug on occupied cells was real and is now fixed +- the fixed `126x126` output is still broken anyway, so empty extracted buckets were not the dominant cause of the collapse So if the pinned lattice is poor for tile-graph, the fallback path is poisoned immediately. @@ -730,14 +720,20 @@ is tiny. So the parity refinement loop is not "destroying" a good layout. It is starting from a bad one. -### 4. Half the grid falling back is a huge clue +### 4. The missing-bucket bug was real, but it was not the main collapse -When `7999` cells have no extracted region candidate: +The extraction stage did have a real bug: -- the model is not primarily selecting from source-owned cut tiles -- it is primarily leaning on lattice-derived fallback anchors +- it could emit source-region windows by center projection and still leave some occupied output cells without a source-region candidate -That makes the algorithm much more sensitive to lattice misfit than it looks from the outside. +That is now fixed by a final overlap-based fill pass, and with the corrected full-size lattice mapping: + +- occupied output cells missing extracted buckets: `0` + +But the fixed `126x126` output is still effectively unchanged after that fix. + +That means the dominant collapse is not "foreground cells had no extracted region bucket at all." +It is happening later, through the way lattice-conditioned references and unary ranking choose between the candidates that do exist. ### 5. The fixed-126 run is harsher because it flips extraction stride @@ -777,10 +773,13 @@ It is not yet: That distinction explains why fixing the lattice can collapse the result so dramatically. -## The Two Most Important Current Findings +## The Most Important Current Findings 1. The fixed-lattice pipeline path is correct. The corruption is not being added by the wrapper. -2. The tile-graph initial assignment is already bad on the broken `126x126` case. - The problem lives in lattice-conditioned reference building, source-region cutting/projection, candidate starvation, or unary selection before pairwise refinement has much chance to help. +2. The extraction stage really did need an exhaustiveness fix. + Occupied output cells are no longer silently losing their source-region bucket under the corrected full-size lattice mapping. + +3. The tile-graph initial assignment is still bad on the broken `126x126` case even after that fix. + So the remaining problem lives in lattice-conditioned reference building, candidate truncation, and unary selection before pairwise refinement has much chance to help. diff --git a/src/repixelizer/tile_graph.py b/src/repixelizer/tile_graph.py index f3c85b1..ad2465e 100644 --- a/src/repixelizer/tile_graph.py +++ b/src/repixelizer/tile_graph.py @@ -541,6 +541,65 @@ def _extract_source_region_tiles( key=lambda comp: (abs(float(comp["size"]) - cell_area), -float(comp["edge_peak"])), ) + def overlap_tiles_for_component(component: dict[str, float | int | np.ndarray]) -> list[dict[str, float | int | np.ndarray]]: + member_linear = np.asarray(component["member_linear"], dtype=np.int32) + if member_linear.size <= 0: + return [] + member_x = flat_x[member_linear].astype(np.float32) + member_y = flat_y[member_linear].astype(np.float32) + member_coord_x = np.clip( + np.floor((member_x + 0.5) / max(cell_w, 1e-6) - phase_x).astype(np.int32), + 0, + max(0, target_width - 1), + ) + member_coord_y = np.clip( + np.floor((member_y + 0.5) / max(cell_h, 1e-6) - phase_y).astype(np.int32), + 0, + max(0, target_height - 1), + ) + member_flat_index = member_coord_y * target_width + member_coord_x + order = np.argsort(member_flat_index, kind="stable") + sorted_flat_index = member_flat_index[order] + starts = np.concatenate( + ( + np.asarray([0], dtype=np.int64), + np.flatnonzero(sorted_flat_index[1:] != sorted_flat_index[:-1]) + 1, + ) + ) + ends = np.concatenate((starts[1:], np.asarray([sorted_flat_index.shape[0]], dtype=np.int64))) + tiles: list[dict[str, float | int | np.ndarray]] = [] + for start, end in zip(starts.tolist(), ends.tolist(), strict=False): + local_order = order[start:end] + cell_linear = member_linear[local_order] + cell_x = member_x[local_order] + cell_y = member_y[local_order] + cell_edge = flat_edge[cell_linear] + flat_index = int(sorted_flat_index[start]) + center_x = float(np.mean(cell_x)) + center_y = float(np.mean(cell_y)) + edge_peak = float(np.max(cell_edge)) if cell_edge.size else 0.0 + if edge_peak > 0.0: + rep_linear = int(cell_linear[int(np.argmax(cell_edge))]) + else: + centroid_dist = (cell_x - center_x) ** 2 + (cell_y - center_y) ** 2 + rep_linear = int(cell_linear[int(np.argmin(centroid_dist))]) + tiles.append( + { + "rep_linear": rep_linear, + "rep_rgba": flat_rgba[rep_linear].astype(np.float32), + "area_ratio": float((cell_linear.shape[0] * sample_area) / cell_area), + "coverage": float(np.clip((cell_linear.shape[0] * sample_area) / cell_area, 0.0, 1.0)), + "edge_peak": edge_peak, + "source_center_x": center_x, + "source_center_y": center_y, + "coord_x": int(flat_index % target_width), + "coord_y": int(flat_index // target_width), + "flat_index": flat_index, + "guaranteed_fill": True, + } + ) + return tiles + for component in ordered_components: component_size = int(component["size"]) if component_size < min_region_pixels: @@ -752,9 +811,16 @@ def _extract_source_region_tiles( "source_center_y": center_y, "coord_x": coord_x, "coord_y": coord_y, + "flat_index": flat_index, } ) + for component in ordered_components: + for tile in overlap_tiles_for_component(component): + if buckets[int(tile["flat_index"])]: + continue + buckets[int(tile["flat_index"])].append(tile) + return buckets @@ -966,6 +1032,10 @@ def build_tile_graph_model( edge_reference_rgba=edge_rgba_np[flat_index], prefer_edge=edge_cell, ) + selected_only_guaranteed_fill = bool(selected_regions) and all( + bool(region.get("guaranteed_fill", False)) + for region in selected_regions + ) for region in selected_regions: pixel_index = int(region["rep_linear"]) add_candidate( @@ -975,7 +1045,7 @@ def build_tile_graph_model( source_x=float(region["source_center_x"]), source_y=float(region["source_center_y"]), ) - if len(candidate_linear) == cell_start: + if len(candidate_linear) == cell_start or selected_only_guaranteed_fill: sharp_pixel = int(sharp_linear_np[flat_index]) add_candidate( sharp_pixel, diff --git a/tests/test_tile_graph.py b/tests/test_tile_graph.py index c478c21..9ea8c1c 100644 --- a/tests/test_tile_graph.py +++ b/tests/test_tile_graph.py @@ -12,7 +12,13 @@ from repixelizer.metrics import source_lattice_consistency_breakdown from repixelizer.params import SolverHyperParams from repixelizer.pipeline import run_pipeline from repixelizer.synthetic import fake_pixelize, make_emblem -from repixelizer.tile_graph import build_tile_graph_model, clear_tile_graph_model_cache, optimize_tile_graph +from repixelizer.tile_graph import ( + _extract_source_region_tiles, + _segment_atomic_source_regions, + build_tile_graph_model, + clear_tile_graph_model_cache, + optimize_tile_graph, +) from repixelizer.types import InferenceResult @@ -105,6 +111,90 @@ def test_tile_graph_candidates_are_scoped_to_their_output_coord() -> None: assert np.all(coords[:, 1] == x) +def test_tile_graph_region_extraction_covers_every_occupied_output_cell() -> None: + source = np.zeros((64, 64, 4), dtype=np.float32) + white = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32) + source[6:10, 6:58] = white + source[14:18, 6:50] = white + source[22:26, 14:58] = white + source[30:34, 6:50] = white + source[38:42, 14:58] = white + source[46:50, 6:58] = white + source[6:50, 6:10] = white + source[14:58, 54:58] = white + + inference = InferenceResult( + target_width=8, + target_height=8, + phase_x=0.0, + phase_y=-0.2, + confidence=1.0, + top_candidates=[], + ) + params = SolverHyperParams() + analysis = analyze_source(source, seed=7, device="cpu") + cell_h = source.shape[0] / inference.target_height + cell_w = source.shape[1] / inference.target_width + source_region_stride = 2 + sampled_rgba = source[::source_region_stride, ::source_region_stride].astype(np.float32) + sampled_edge = analysis.edge_map[::source_region_stride, ::source_region_stride].astype(np.float32) + sampled_y_coords = np.arange(0, source.shape[0], source_region_stride, dtype=np.int32) + sampled_x_coords = np.arange(0, source.shape[1], source_region_stride, dtype=np.int32) + sampled_y_grid, sampled_x_grid = np.meshgrid(sampled_y_coords, sampled_x_coords, indexing="ij") + sampled_flat_rgba = sampled_rgba.reshape(-1, sampled_rgba.shape[-1]).astype(np.float32) + sampled_flat_edge = sampled_edge.reshape(-1).astype(np.float32) + sampled_flat_y = sampled_y_grid.reshape(-1).astype(np.int32) + sampled_flat_x = sampled_x_grid.reshape(-1).astype(np.int32) + + components = _segment_atomic_source_regions( + source_rgba=sampled_rgba, + edge_map=sampled_edge, + alpha_floor=params.alpha_transparent_threshold, + color_threshold=params.tile_graph_component_color_threshold, + alpha_threshold=params.tile_graph_component_alpha_threshold, + device="cpu", + ) + region_buckets = _extract_source_region_tiles( + components=components, + flat_rgba=sampled_flat_rgba, + flat_edge=sampled_flat_edge, + flat_x=sampled_flat_x, + flat_y=sampled_flat_y, + cell_w=cell_w, + cell_h=cell_h, + sample_area=float(source_region_stride * source_region_stride), + target_width=inference.target_width, + target_height=inference.target_height, + phase_x=inference.phase_x, + phase_y=inference.phase_y, + min_region_area_ratio=params.tile_graph_source_region_min_area_ratio, + min_window_coverage=params.tile_graph_source_region_window_coverage, + stroke_linearity_threshold=params.tile_graph_stroke_linearity_threshold, + stroke_step_scale=params.tile_graph_stroke_step_scale, + stroke_minor_limit_scale=params.tile_graph_stroke_minor_limit_scale, + ) + + opaque = sampled_flat_rgba[:, 3] >= params.alpha_transparent_threshold + coord_x = np.clip( + np.floor((sampled_flat_x.astype(np.float32) + 0.5) / cell_w - inference.phase_x).astype(np.int32), + 0, + inference.target_width - 1, + ) + coord_y = np.clip( + np.floor((sampled_flat_y.astype(np.float32) + 0.5) / cell_h - inference.phase_y).astype(np.int32), + 0, + inference.target_height - 1, + ) + direct_indices = coord_y * inference.target_width + coord_x + occupied_direct = np.bincount( + direct_indices[opaque], + minlength=inference.target_width * inference.target_height, + ) + occupied_bucket = np.asarray([len(bucket) for bucket in region_buckets], dtype=np.int32) + + assert np.all(occupied_bucket[occupied_direct > 0] > 0) + + def test_tile_graph_projects_source_regions_instead_of_mixed_lattice_buckets() -> None: source = np.zeros((6, 12, 4), dtype=np.float32) source[..., 3] = 1.0