From 4d53e207321bf65da99b35a48410aec47b33fe0d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Apr 2026 18:11:39 +0100 Subject: [PATCH] Localize tile graph candidate placement --- README.md | 4 ++- docs/implementation-plan.md | 4 ++- src/repixelizer/params.py | 4 ++- src/repixelizer/tile_graph.py | 60 ++++++++++++++++++++++++++++++----- tests/test_tile_graph.py | 35 ++++++++++++++++++++ 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8cb52c0..584b836 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,10 @@ Current tile-graph status: - it now builds literal source-pixel candidates from connected source clusters plus lattice-aligned fallback coverage - it learns directional adjacency preferences between candidates and places them with a soft discrete propagation loop - it no longer invents averaged patch colors during candidate extraction; the current regression suite now checks that tile-graph candidates stay on real source colors +- accepted component candidates now consume a cell-sized source footprint, and far-away candidates are no longer globally admissible during placement - it already beats naive resize on the repo's synthetic thin-feature regression -- it still is not good enough to replace the default continuous path on the cleaned AI badge stress case, though the raw-pixel rewrite materially improved that badge probe and stopped the mode from jumping to an oversized lattice candidate +- it no longer fails by flooding the canvas with one opaque black tile on the cleaned AI badge stress case +- it still is not good enough to replace the default continuous path on the cleaned AI badge stress case; the latest localized pass fixes the black-canvas failure but still produces poor motif structure and a much worse score than the continuous path ## Quickstart diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9294f43..7a39d3f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -70,7 +70,8 @@ Current implementation note: Experimental tile-graph note: - there is now a separate `tile-graph` reconstruction mode that extracts literal source-pixel candidates from connected source clusters, fills gaps with lattice-aligned fallback pixels, learns candidate adjacency, and places tiles with a soft propagation loop - the raw-pixel rewrite fixed an implementation mismatch where candidates had drifted into cell-averaged patch colors instead of actual source pixels -- that path is still promising on synthetic thin-feature cases and now stays on the conservative `126x126` badge lattice instead of jumping to `154x154`, but it still underperforms the default continuous path on the cleaned badge fixture (`0.1676` source-fidelity score on the latest probe versus `0.0832` for the continuous path) +- accepted component candidates now consume their cell-sized source footprint, and placement no longer allows one distant candidate to act as a globally reusable label everywhere on the raster +- that localized pass fixed the glaring badge failure where one opaque black candidate could flood most of the canvas, but it still underperforms the default continuous path badly on the cleaned badge fixture and currently over-fragments local motifs even while background transparency is restored Next after that: - rerank low-confidence top lattice candidates with short real solver probes instead of relying only on the cheapest preview @@ -115,6 +116,7 @@ What future work should improve here: - some form of coordinated local relaxation so nearby cells can move together toward a globally better contour - optional background suppression or de-weighting when a baked checkerboard is clearly not semantic content - for the experimental tile-graph path specifically, better local candidate coverage and stronger adjacency evidence are still needed even after switching candidates back to literal source pixels +- for the experimental tile-graph path specifically, the next useful step is probably a true local candidate-set solver instead of a single global candidate matrix with coord-gating heuristics; the current architecture is still too eager to reuse a small vocabulary and too weak at preserving local motif identity Current optimizer diagnosis: - the shared source-lattice reference, source-first snap/refine scoring, and relaxed-mode handoff are now in place, so the biggest remaining losses are no longer caused by the old representative-lattice collapse diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index 89bb31c..64670c7 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -73,12 +73,14 @@ class SolverHyperParams: phase_rerank_confidence_threshold: float = 0.12 phase_rerank_max_size_delta_ratio: float = 0.40 phase_rerank_margin: float = 0.004 - tile_graph_max_candidates: int = 256 + tile_graph_max_candidates: int = 768 tile_graph_max_candidates_per_coord: int = 2 tile_graph_component_min_area_ratio: float = 0.10 tile_graph_large_component_ratio: float = 1.35 tile_graph_window_coverage_threshold: float = 0.16 tile_graph_coordinate_weight: float = 0.08 + tile_graph_coord_gate_slack: float = 2.0 + tile_graph_coord_gate_penalty: float = 8.0 tile_graph_area_weight: float = 0.03 tile_graph_count_weight: float = 0.55 tile_graph_delta_weight: float = 0.45 diff --git a/src/repixelizer/tile_graph.py b/src/repixelizer/tile_graph.py index 36f762e..e42ab11 100644 --- a/src/repixelizer/tile_graph.py +++ b/src/repixelizer/tile_graph.py @@ -47,7 +47,8 @@ class _Component: coords: np.ndarray bbox: tuple[int, int, int, int] mask: np.ndarray - integral: np.ndarray + available_mask: np.ndarray + available_integral: np.ndarray area: int centroid_y: float centroid_x: float @@ -114,23 +115,56 @@ def _sample_source_pixel(source_rgba: np.ndarray, center_y: float, center_x: flo def _window_component_coverage(component: _Component, center_y: float, center_x: float, cell_h: float, cell_w: float) -> float: - y0, x0, y1, x1 = _window_bounds(center_y, center_x, cell_h, cell_w, component.mask.shape[0] + component.bbox[0], component.mask.shape[1] + component.bbox[1]) + y0, x0, y1, x1 = _window_bounds( + center_y, + center_x, + cell_h, + cell_w, + component.available_mask.shape[0] + component.bbox[0], + component.available_mask.shape[1] + component.bbox[1], + ) bbox_y0, bbox_x0, _, _ = component.bbox local_y0 = max(0, y0 - bbox_y0) local_x0 = max(0, x0 - bbox_x0) - local_y1 = min(component.mask.shape[0], y1 - bbox_y0) - local_x1 = min(component.mask.shape[1], x1 - bbox_x0) + local_y1 = min(component.available_mask.shape[0], y1 - bbox_y0) + local_x1 = min(component.available_mask.shape[1], x1 - bbox_x0) if local_y1 <= local_y0 or local_x1 <= local_x0: return 0.0 - covered = _integral_sum(component.integral, local_y0, local_x0, local_y1, local_x1) + covered = _integral_sum(component.available_integral, local_y0, local_x0, local_y1, local_x1) total = max(1, (local_y1 - local_y0) * (local_x1 - local_x0)) return float(covered / total) def _nearest_component_pixel(component: _Component, center_y: float, center_x: float) -> tuple[float, float]: - deltas = component.coords.astype(np.float32) - np.asarray([center_y, center_x], dtype=np.float32)[None, :] + bbox_y0, bbox_x0, _, _ = component.bbox + local = component.coords - np.asarray([bbox_y0, bbox_x0], dtype=np.int32)[None, :] + available = component.available_mask[local[:, 0], local[:, 1]] + coords = component.coords[available] + if coords.shape[0] == 0: + return float(center_y), float(center_x) + deltas = coords.astype(np.float32) - np.asarray([center_y, center_x], dtype=np.float32)[None, :] best = int(np.argmin(np.sum(deltas * deltas, axis=1))) - return float(component.coords[best, 0]), float(component.coords[best, 1]) + return float(coords[best, 0]), float(coords[best, 1]) + + +def _consume_component_window(component: _Component, center_y: float, center_x: float, cell_h: float, cell_w: float) -> None: + y0, x0, y1, x1 = _window_bounds( + center_y, + center_x, + cell_h, + cell_w, + component.available_mask.shape[0] + component.bbox[0], + component.available_mask.shape[1] + component.bbox[1], + ) + bbox_y0, bbox_x0, _, _ = component.bbox + local_y0 = max(0, y0 - bbox_y0) + local_x0 = max(0, x0 - bbox_x0) + local_y1 = min(component.available_mask.shape[0], y1 - bbox_y0) + local_x1 = min(component.available_mask.shape[1], x1 - bbox_x0) + if local_y1 <= local_y0 or local_x1 <= local_x0: + return + component.available_mask[local_y0:local_y1, local_x0:local_x1] = False + component.available_integral = _component_integral(component.available_mask) def _best_stepped_center( @@ -230,7 +264,8 @@ def _extract_components(cluster_map: np.ndarray, alpha_map: np.ndarray, alpha_th coords=coords_array, bbox=(min_y, min_x, max_y + 1, max_x + 1), mask=mask, - integral=_component_integral(mask), + available_mask=mask.copy(), + available_integral=_component_integral(mask), area=int(coords_array.shape[0]), centroid_y=float(np.mean(coords_array[:, 0])), centroid_x=float(np.mean(coords_array[:, 1])), @@ -273,6 +308,8 @@ def build_tile_graph_model( step_y = max(1.0, cell_h) step_x = max(1.0, cell_w) for component in components: + if not np.any(component.available_mask): + continue area_ratio = float(component.area / max(target_area, 1e-4)) if area_ratio < solver_params.tile_graph_component_min_area_ratio: continue @@ -324,6 +361,7 @@ def build_tile_graph_model( candidate_coverage.append(coverage) candidate_deltas.append(delta_features) coord_counts[coord] = coord_counts.get(coord, 0) + 1 + _consume_component_window(component, center_y, center_x, cell_h, cell_w) added += 1 if area_ratio < solver_params.tile_graph_large_component_ratio: continue @@ -458,10 +496,16 @@ def _tile_graph_unary_cost(model: TileGraphModel, solver_params: SolverHyperPara np.abs(grid_y[..., None] - model.candidate_coords[None, None, :, 0].astype(np.float32)) + np.abs(grid_x[..., None] - model.candidate_coords[None, None, :, 1].astype(np.float32)) ) / max(1.0, float(height + width)) + nearest_coord_error = np.min(coord_error, axis=2, keepdims=True) + coord_gate = coord_error <= nearest_coord_error + ( + float(solver_params.tile_graph_coord_gate_slack) / max(1.0, float(height + width)) + ) + coord_gate[..., 0] = model.reference_mean_rgba[..., 3] <= 0.35 area_error = np.abs(np.log(np.clip(model.candidate_area_ratio, 1e-4, None) + 1e-4))[None, None, :] return ( color_error + coord_error * solver_params.tile_graph_coordinate_weight + + (~coord_gate).astype(np.float32) * solver_params.tile_graph_coord_gate_penalty + area_error * solver_params.tile_graph_area_weight ).astype(np.float32) diff --git a/tests/test_tile_graph.py b/tests/test_tile_graph.py index 3158515..d4bb02b 100644 --- a/tests/test_tile_graph.py +++ b/tests/test_tile_graph.py @@ -130,6 +130,41 @@ def test_tile_graph_reconstructs_synthetic_thin_feature_better_than_naive() -> N assert tile_score <= naive_score +def test_tile_graph_preserves_transparent_background_on_sparse_detail() -> None: + source = np.zeros((12, 12, 4), dtype=np.float32) + source[5:7, 5:7] = np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32) + + fake = fake_pixelize( + source, + upscale=8, + phase_x=0.12, + phase_y=-0.08, + blur_radius=0.35, + warp_strength=0.1, + warp_detail=4, + seed=5, + ) + artifacts, _ = optimize_tile_graph( + fake, + inference=InferenceResult( + target_width=12, + target_height=12, + phase_x=0.12, + phase_y=-0.08, + confidence=1.0, + top_candidates=[], + ), + analysis=analyze_source(fake, seed=7), + steps=0, + seed=7, + device="cpu", + ) + + alpha_zero_ratio = float((artifacts.target_rgba[..., 3] <= 0.05).mean()) + + assert alpha_zero_ratio >= 0.75 + + def test_pipeline_tile_graph_mode_writes_reconstruction_diagnostics(tmp_path: Path) -> None: source = make_emblem(24, 24) fake = fake_pixelize(source, upscale=10, phase_x=0.15, phase_y=0.2, blur_radius=0.6, warp_strength=0.2, warp_detail=5)