From 8be09a57866f64f412b09116342a32dc5e81a0cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 23 Apr 2026 04:59:15 +0100 Subject: [PATCH] Cut portrait ballast from tile-graph core --- README.md | 15 +- docs/implementation-plan.md | 13 +- docs/tile-graph-algorithm-map.md | 1106 +++++++++------------------ src/repixelizer/params.py | 5 +- src/repixelizer/source_reference.py | 33 +- src/repixelizer/tile_graph.py | 533 +++++++------ src/repixelizer/types.py | 10 - tests/test_pipeline.py | 2 +- tests/test_source_reference.py | 31 +- tests/test_tile_graph.py | 41 +- 10 files changed, 713 insertions(+), 1076 deletions(-) diff --git a/README.md b/README.md index 2f4ce90..e1225db 100644 --- a/README.md +++ b/README.md @@ -39,18 +39,20 @@ Recent adjacency-focused status: 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 -- tile-graph prep is now a separate edge-only path: it no longer hauls continuous-only cluster analysis or the rich shared lattice reference through its core loop -- tile-graph now builds against its own lean source reference and keeps diagnostics/cache metadata outside the solver model itself -- 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 +- tile-graph prep is now an edge-only path with no cluster analysis, no hybrid geometry sidecar, and no tile-graph-specific source-reference portrait +- extraction now runs at full source resolution; the old `source_region_stride` subsampling path is gone +- candidate selection is now source-owned: extracted tiles are ranked by their own area, coverage, and edge signal instead of by resemblance to `sharp_rgba` / `edge_rgba` +- occupied foreground cells no longer get papered over with injected `sharp_pixel` or `edge_pixel` fallbacks; if extraction cannot produce a real tile there, the build fails instead of lying +- the adjacency term is now learned from extracted tiles themselves, not from sampled one-cell-away RGBA deltas +- the deep-dive map in `docs/tile-graph-algorithm-map.md` now reflects the living machine directly instead of documenting the dead portrait/stride/delta path +- the extraction stage still has an explicit empty-cell overlap fill pass, so output cells that actually contain opaque source pixels do not silently lose their extracted region bucket - 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 - 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 +- tile-graph now also skips cluster analysis entirely and no longer uses sharp/edge portrait anchors in unary scoring; the path keeps only edge scouting plus direct per-cell source summaries - 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` @@ -60,6 +62,7 @@ Current tile-graph status: - 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`) +- on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the latest cut lands at `0.1814` source-fidelity instead of the post-refactor collapse around `0.5055`, which is a real recovery even though it is still far from the continuous path ## Quickstart diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index c4fbd6d..03351ce 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -19,12 +19,12 @@ Implemented: Verified: - local editable install in a dedicated venv -- full test suite currently passing (`70 passed`) +- full test suite currently passing before the current algorithm-cut pass (`70 passed`) - 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, 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 -- the new architectural split keeps the fixed synthetic emblem baselines byte-identical on both engines: the pinned tile-graph output still hashes to `31e3bc2c...6ae9` and the pinned continuous output still hashes to `404748af...7903` +- the new architectural split kept the fixed synthetic emblem baselines byte-identical on both engines at the moment it landed: the pinned tile-graph output hashed to `31e3bc2c...6ae9` and the pinned continuous output hashed to `404748af...7903` ## Status after adjacency-first pass @@ -64,9 +64,9 @@ 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 +- `tile-graph` now skips source clustering entirely and uses only edge analysis plus direct per-cell source summaries during unary scoring - `analysis.py` now has separate prep paths: continuous gets edge plus cluster analysis, while tile-graph gets an edge-only scout report -- `source_reference.py` now has a dedicated `TileGraphSourceReference` instead of forcing tile-graph through the larger continuous/metrics reference object +- `source_reference.py` was split so tile-graph could have its own lean prep contract, but the next cut removed tile-graph's need for any separate source-reference object at all - `TileGraphModel` is now solver-only state; cache metadata and build diagnostics live in a separate `TileGraphBuildStats` sidecar - `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 @@ -79,10 +79,13 @@ Current implementation note: - 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 current deep-dive diagnosis is that the fixed-lattice garbling is not a wrapper bug and not mainly a parity-solver bug; it is born in tile cutting, candidate truncation, local source support, 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 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 +- the latest core algorithm cut removes `source_region_stride`, removes portrait-based candidate ranking, removes sharp/edge foreground fallback injection, and replaces sampled-delta pairwise scoring with adjacency learned from extracted tiles +- that cut also removes the dedicated `TileGraphSourceReference` entirely: tile-graph now grounds itself in full-resolution extracted tiles plus direct per-cell source summaries (`cell_mean_rgba`, `cell_alpha_mean`, `cell_edge_strength`) +- on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the new cut recovers the fixed-lattice run from roughly `0.5055` source-fidelity down to `0.1814`; that is still weak, but it is no longer total algorithmic collapse 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 diff --git a/docs/tile-graph-algorithm-map.md b/docs/tile-graph-algorithm-map.md index 0fcb4f6..33b539d 100644 --- a/docs/tile-graph-algorithm-map.md +++ b/docs/tile-graph-algorithm-map.md @@ -1,916 +1,522 @@ # Tile-Graph Algorithm Map -This is the end-to-end map of the current `tile-graph` path as it exists in the repo today. +## Why this map exists -It is written specifically to answer one question: +The tile-graph path is trying to do one simple, stubborn thing: -Why can a pinned lattice like `126x126` / phase `(0.0, -0.2)` produce a completely garbled result instead of merely a slightly worse one? +Take a fake-pixel mural, cut it into honest one-cell tiles that really belong to the source image, learn which tiles naturally sit beside which, and then lay those tiles back down on a clean output lattice. -Short answer: +That is the dream. -- the fixed-lattice pipeline wrapper is not corrupting the image -- the corruption is already present in the `tile-graph` initial assignment -- the current `tile-graph` implementation is much more lattice-conditioned than the intended "extract source-owned one-cell tiles, then place them" design +When the output looks wrong, this map is here to answer one question: -On the current broken badge run: +Where, exactly, did the machine start lying? -- source image: `1254x1254` -- fixed lattice: `126x126` -- 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 +## One-sentence machine -That tiny change means the parity-update loop is not the main failure. The model build and initial assignment are. +The current tile-graph machine is: -## How To Read This Machine +`source image -> edge scout -> atomic opaque regions -> one-cell tile cuts -> per-cell candidate buckets -> learned tile adjacency -> local discrete assignment` -The easiest way to picture the current algorithm is this: +Or in more visual language: -- the source image is a mural painted on a wall -- the inferred lattice is a sheet of graph paper pressed on top of that mural -- the tile-graph path is trying to cut little square souvenir chips out of the mural and lay them back down on a clean floor grid +- the source image is the mural +- connected components are the paint islands +- extracted tiles are the stones we chip out of that mural +- candidate buckets are the bins beside each slot in the final mosaic +- the solver is the mason choosing which chipped stone goes in each slot while trying to keep neighboring stones compatible -If the graph paper lines up with the mural, this can work. If the graph paper is skewed or coarse, the cutter starts taking chips that straddle multiple painted shapes. From that point on, every later decision is already compromised. +## What was cut away -The important mental model is that the current system is not simply "discover tiles, then arrange them." It is closer to: +The machine used to carry several ideas that did not belong: -1. choose a grid first -2. force the mural to be read through that grid -3. summarize the mural cell by cell -4. cut region proposals using windows sized by that grid -5. choose one proposal per output cell using those same grid-conditioned summaries +- subsampling the mural before extraction with `source_region_stride` +- ranking region candidates by resemblance to a lattice portrait (`sharp_rgba`, `edge_rgba`) +- papering over missing foreground tiles by injecting `sharp_pixel` or `edge_pixel` +- pretending a learned tile graph existed while actually using sampled color deltas from one cell away in source space +- building and hauling a dedicated tile-graph source-reference object through the core loop -So the lattice is not a passive scaffold. It is the mold that many later pieces are poured through. +Those are gone. -## One-Sentence Machine +That matters because they were smuggling continuous-solver assumptions back into a path that is supposed to be source-owned and discrete. -The current `tile-graph` path is a lattice-conditioned source-region candidate generator followed by a small local discrete solver. +## The living machine -That sentence matters because it explains the failure mode. When the lattice is poor, the generator is already feeding the solver bad ingredients. The solver is then only choosing among bad meals. +### Stage 0: Pipeline chooses a lattice -## Bird's-Eye Flow +Function: -```mermaid -flowchart TD - A["source_rgba (H x W x 4)"] --> B["run_pipeline(...)"] - B --> C["infer_lattice(...) or infer_fixed_lattice(...)"] - B --> D["analyze_tile_graph_source(...)"] - C --> E["InferenceResult"] - D --> F["TileGraphSourceAnalysis"] - E --> G["phase rerank probes (optional)"] - F --> G - G --> H["optimize_tile_graph(...)"] - H --> I["build_tile_graph_source_reference(...)"] - H --> J["_segment_atomic_source_regions(...)"] - J --> K["_extract_source_region_tiles(...)"] - I --> L["per-output-cell sharp and edge anchors"] - K --> M["region buckets per output coord"] - L --> N["build_tile_graph_model(...)"] - M --> N - N --> O["initial unary argmin assignment"] - O --> P["checkerboard pairwise refinement"] - P --> Q["keep refined or revert to initial"] - Q --> R["cleanup_pixels(...)"] - R --> S["diagnostics/run.json"] -``` +- `run_pipeline(...)` in `src/repixelizer/pipeline.py` -Plain-language picture: +Inputs: -The machine has two hands. +- `source_rgba` +- chosen `InferenceResult` + - `target_width` + - `target_height` + - `phase_x` + - `phase_y` -- The left hand builds a story about what each output cell "should" look like by forcing the source through the lattice. -- The right hand cuts source-owned candidate pieces and drops them into per-cell buckets. +What it means: -Then a judge stands in the middle and asks, for each output cell: "Which candidate piece most resembles the story the left hand wrote?" If the story is already wrong, the judge can pick only the least-wrong option. +The pipeline chooses the grid we are going to commit to. This is not the tile-graph solver yet. This is just choosing the size of the chessboard before we decide which pieces go on it. -## Core Data Objects +Important truth: -### `source_rgba` +- if the lattice is wrong, every later step is forced to cut the mural against the wrong ruler +- but once the lattice is pinned, the tile-graph path now runs directly on that lattice without phase-rerank probes or hybrid sidecars -- Type: `np.ndarray` -- Shape: `(H, W, 4)` -- Value range: float32-like normalized RGBA in `[0, 1]` -- Meaning: the loaded source facsimile, optionally background-stripped before anything else +### Stage 1: Edge scout -Plain-language picture: +Function: -This is the raw mural before we draw any graph paper on it. +- `analyze_tile_graph_source(...)` in `src/repixelizer/analysis.py` -### `InferenceResult` +Output: -- File: `src/repixelizer/types.py` -- Fields: - - `target_width`, `target_height`: chosen output lattice size - - `phase_x`, `phase_y`: sub-cell phase offsets in lattice units - - `confidence`: gap between top two inference candidates - - `top_candidates`: rerankable list of `InferenceCandidate` +- `TileGraphSourceAnalysis.edge_map` -Plain-language picture: +What it means: -This is the foreman's decision about the size and offset of the graph paper. Every later worker trusts this sheet. +This stage is the scout walking over the mural with a lantern, marking where the sharp cliffs are. It is not deciding colors. It is only pointing out where the paint changes abruptly. -### `TileGraphSourceAnalysis` +Important variable: -- File: `src/repixelizer/types.py` -- Fields: - - `edge_map`: per-source-pixel edge strength +- `edge_map[y, x]` + - a per-source-pixel edge strength in `[0, 1]` -Important note: +Why it exists: -- this is only the edge scout report -- tile-graph no longer drags cluster labels, alpha mirrors, or preview images through its core contract +- region extraction needs to know where a component has its sharpest internal detail +- candidate budgeting uses edge strength to give difficult cells more room -Plain-language picture: +### Stage 2: Source pixels are projected onto the output lattice -This is a scouting report about where the mural has edges and rough color families. It is binoculars, not ownership papers. +Function: -### `TileGraphSourceReference` +- `build_tile_graph_model(...)` in `src/repixelizer/tile_graph.py` -- File: `src/repixelizer/types.py` -- Meaning: tile-graph's lattice-conditioned anchor pack under one exact `(target_width, target_height, phase_x, phase_y)` -- Fields: - - `sharp_rgba`: one chosen exemplar pixel per inferred cell - - `sharp_x`, `sharp_y` - - `edge_peak_x`, `edge_peak_y` - - `edge_strength` +Key variables: -Plain-language picture: +- `cell_w` +- `cell_h` +- `projected_coord_x` +- `projected_coord_y` +- `projected_flat_index` -This is the mural after the graph paper has already been pressed onto it, but only the notes tile-graph actually reads are kept: one sharp pixel, one edge pixel, and how strong that edge is. +What it means: -### `TileGraphModel` +Every source pixel is asked: -- File: `src/repixelizer/tile_graph.py` -- Meaning: the fully built discrete candidate model for one source + one fixed lattice -- Fields: - - `candidate_rgba`: all candidate pixel colors - - `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_sharp_rgba`, `reference_edge_rgba` - - `edge_strength` +"If this final grid is the one we believe in, which output cell do you live under?" -### `TileGraphBuildStats` +This is like dropping a transparent graph-paper sheet over the mural and writing the output cell index on every source pixel. -- File: `src/repixelizer/tile_graph.py` -- Meaning: diagnostics and cache metadata carried alongside the model rather than inside it -- Fields: - - `component_count` - - `candidate_count` - - `edge_density` - - `average_choices` - - `model_device` - - `cache_hit` +Derived summaries: -Plain-language picture: +- `cell_counts_flat` + - how many source pixels fall into each output cell +- `cell_mean_rgba_flat` + - the average source color already living under that cell +- `cell_alpha_support_flat` + - the strongest alpha seen in that cell +- `cell_alpha_mean_flat` + - the average alpha in that cell +- `cell_edge_strength_flat` + - the strongest edge signal seen in that cell -This is the parts bin. Every output cell has a small tray of legal parts, plus a note about what the finished wall section is supposed to look like. +These summaries are not a tile portrait from a separate source-reference system. They are direct summaries of the actual source pixels already assigned to that output cell under the chosen lattice. -## Stage 1: Pipeline Entry +### Stage 3: Atomic opaque regions -File: `src/repixelizer/pipeline.py` +Function: -Main entry: +- `_segment_atomic_source_regions(...)` +- CPU path: `_segment_atomic_source_regions_cpu(...)` -- `run_pipeline(...)` +Inputs: -Main variables: +- full-resolution `source_rgba` +- full-resolution `edge_map` +- alpha/color join thresholds -- `source`: loaded RGBA source image -- `fixed_dims`: either `None` or exact `(target_width, target_height)` resolved from CLI -- `inference`: one `InferenceResult` -- `analysis`: one `TileGraphSourceAnalysis` -- `solver`: `SolverArtifacts` from continuous or tile-graph -- `cleanup`: `CleanupArtifacts` -- `output_rgba`: final saved output +What it means: -Control flow: +Now the mural is split into paint islands. -1. Load source. -2. Optionally preprocess background. -3. Resolve fixed-vs-searched lattice mode. -4. Run source analysis. -5. Optionally rerank top lattice candidates by actually reconstructing them. -6. Run the selected reconstruction mode. -7. Run cleanup and optional palette quantization. -8. Save output and diagnostics. +Pixels join the same island only if: -Plain-language picture: +- they are opaque enough +- their premultiplied colors are similar enough +- their alpha is similar enough -This is the loading dock and traffic controller. It decides which reconstruction machine to send the mural to, but it is not repainting the mural itself. - -Important finding: - -- The fixed-lattice pipeline path is behaving correctly. -- Running the same pinned `126x126` tile-graph case through: - - direct `_run_reconstruction(...)` - - `run_pipeline(...)` - - the phase-rerank probe path - produces the exact same output bytes. - -So the pipeline wrapper is not introducing the corruption. - -## Stage 2: Lattice Inference - -File: `src/repixelizer/inference.py` - -Two entry points: - -- `infer_lattice(...)` -- `infer_fixed_lattice(...)` - -Plain-language picture: - -This stage chooses the graph paper. In searched mode it tries many sheets and offsets. In fixed mode it obeys the requested sheet and stops arguing. - -### Searched path - -`infer_lattice(...)` does: - -1. Estimate spacing priors from source luminance/alpha changes. -2. Build candidate output sizes with `_candidate_dims(...)`. -3. For each size, score a `5x5` phase grid with `_score_phase_group(...)`. -4. Keep one best phase per size. -5. Rerank sizes with `source_lattice_evidence_breakdown(...)`. - -Main variables: - -- `spacing_x`, `spacing_y`: measured source spacing hints -- `hinted_sizes`: size hints derived from spacing -- `prior_cell_x`, `prior_cell_y`, `prior_reliability`: size prior for scoring -- `phase_values`: `[-0.4, -0.2, 0.0, 0.2, 0.4]` -- `candidates`: all scored `(size, phase)` candidates -- `size_candidates`: top one phase per size -- `reranked_candidates`: size candidates reordered by source-lattice evidence - -### Fixed path - -`infer_fixed_lattice(...)` does: - -1. Keep the target size fixed. -2. If phase is pinned, score exactly that one phase. -3. If phase is not pinned, search only the phase grid for that fixed size. +This is not a "cluster by vibes" step. It is literal connected-component labeling over the full-resolution mural. Important consequence: -- fixing the lattice size does not merely "skip search" -- it locks in the exact cell size and phase that every downstream tile-graph stage uses +- every opaque source pixel belongs to some component +- if an occupied output cell later ends up with no real extracted tile, that is not normal; it is a bug or a failure in cutting/projection logic -## Stage 3: Source Analysis +### Stage 4: Cut one-cell tiles out of each component -File: `src/repixelizer/analysis.py` - -Entry point: - -- `analyze_tile_graph_source(rgba, device=None)` - -Outputs: - -- `edge_map`: edge strength from luminance plus alpha differences - -Plain-language picture: - -This is the scout walking the mural with a flashlight and doing only one job: marking where the paint changes abruptly. - -Important reality check: - -- tile-graph no longer uses coarse cluster labels as its ownership map -- the tile-graph region ownership is built later by connected components over source pixels using color/alpha thresholds -- after the seam split, tile-graph carries only `edge_map` through this stage - -## Stage 4: Optional Phase Rerank - -File: `src/repixelizer/pipeline.py` - -Helper: - -- `_select_phase_candidate_with_reconstruction(...)` - -What it does: - -1. Only runs when inference confidence is low and there are multiple top candidates. -2. Reconstructs several top candidates with the active mode. -3. Scores each candidate by: - - source support (`source_lattice_consistency_breakdown`) - - edge position error - - stroke wobble - - edge concentration - - size delta penalty - - inference score penalty -4. Picks the best reranked candidate if it clears the configured margin. - -Plain-language picture: - -This is a dress rehearsal. The pipeline builds a few small stage versions and asks which staging looks least wrong before committing. - -Important note for tile-graph: - -- after the pruning pass, tile-graph does not participate in this stage at all -- phase rerank is now a continuous-only wrapper - -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 - -File: `src/repixelizer/source_reference.py` - -Entry point: - -- `build_source_lattice_reference(...)` - -This is one of the most important stages, because it ties the source image to one specific inferred lattice. - -Plain-language picture: - -This is where the graph paper stops being a guide and becomes law. Every source pixel is drafted into exactly one box, whether that box matches the "real" fake-pixel cell or not. - -### Inputs - -- `source_rgba` -- `target_width`, `target_height` -- `phase_x`, `phase_y` -- `alpha_threshold` -- optional `edge_hint` - -### What it computes - -1. `lattice_indices(...)` - - assigns every source pixel to one inferred lattice cell - - this is a hard rectangular partition based on current size and phase -2. `mean_rgba` - - per-cell premultiplied mean, then unpremultiplied -3. `sharp_rgba` - - one exemplar per cell - - chosen by minimizing `exemplar_cost = pixel_diff + tiny alpha underfill penalty` -4. `edge_peak` - - strongest edge-supporting pixel per cell -5. `edge_strength` - - strongest edge value seen in that cell -6. `cell_dispersion` - - how mixed the cell is -7. `delta_x`, `delta_y`, `delta_diag`, `delta_anti` - - deltas between neighboring `sharp` cells - -### Critical variables - -- `indices` / `indices_t`: source-pixel to cell assignment -- `flat_idx` -- `mean_premul_flat` -- `pixel_diff`: per-source-pixel deviation from the current cell mean -- `exemplar_cost`: what decides `sharp_rgba` -- `edge_score` -- `sharp_x_flat`, `sharp_y_flat` -- `edge_peak_x_flat`, `edge_peak_y_flat` - -### Important consequence - -The current tile-graph path is already strongly lattice-conditioned here. - -If the pinned lattice is wrong for the source: - -- `lattice_indices` groups the wrong source pixels together -- `mean_rgba` becomes the wrong per-cell average -- `sharp_rgba` becomes the wrong per-cell exemplar -- `edge_peak` becomes the wrong per-cell anchor - -That poisoning then propagates into tile-graph candidate selection. - -## Stage 6: Atomic Source Region Segmentation - -File: `src/repixelizer/tile_graph.py` - -Entry points: - -- `_segment_atomic_source_regions_cpu(...)` -- `_segment_atomic_source_regions(...)` - -This stage does not use inferred cells yet. It runs on the source image or a strided sample of it. - -Plain-language picture: - -This is the machine trying to find puddles of paint on the mural before cutting them into squares. It looks for blobs of nearby pixels that are similar enough to be treated as one patch. - -### Inputs - -- `source_rgba` or `sampled_rgba` -- `edge_map` or `sampled_edge` -- `alpha_floor` -- `color_threshold` -- `alpha_threshold` -- `device` - -### What it does - -1. Mark opaque pixels. -2. Build `join_right` and `join_down` booleans for neighboring source pixels whose premultiplied color and alpha are close enough. -3. Run connected-component labeling over those joins. -4. For each component, compute: - - `member_linear` - - `edge_peak_linear` - - `centroid_linear` - - `centroid_x`, `centroid_y` - - `axis_x`, `axis_y` - - `linearity` - - `major_span`, `minor_span` - - `size` - -### Important reality check - -This is not "single-color clusters" in the strict literal sense the original idea implied. - -It is: - -- thresholded connected components in premultiplied RGBA space -- optionally on a strided sample of the source - -So even before tile extraction, the current implementation is already an approximation. - -## Stage 7: Cutting Source Regions Into Tile Proposals - -File: `src/repixelizer/tile_graph.py` - -Entry point: +Function: - `_extract_source_region_tiles(...)` -This is the real source-side tile cutter. - -Plain-language picture: - -Imagine laying square cookie cutters over each paint puddle and snapping out little chips. The cutter size is determined by the inferred lattice. The machine keeps stamping until the puddle is exhausted, then it throws each chip into the bucket of whichever output cell its center lands in. - -### Inputs +Inputs: - `components` -- `flat_rgba`, `flat_edge`, `flat_x`, `flat_y` -- `cell_w`, `cell_h` -- `sample_area` -- `target_width`, `target_height` -- `phase_x`, `phase_y` -- `min_region_area_ratio` -- `min_window_coverage` -- stroke slicing params +- `flat_rgba` +- `flat_edge` +- `flat_x` +- `flat_y` +- lattice geometry: `cell_w`, `cell_h`, `phase_x`, `phase_y` -### What it does +What it means: -For each connected source component: +This is the quarry. -1. Sort components by closeness of `component["size"]` to one output-cell area. -2. Decide whether the component is "stroke-like" from: - - `linearity` - - `major_span` - - `minor_span` -3. Create seed centers: - - centroid and edge peak for regular regions - - principal-axis marching seeds for stroke-like regions -4. For each seed: - - build a local window mask - - count `footprint_count` - - compute `area_ratio = footprint_count * sample_area / cell_area` - - reject tiny windows -5. Accept the window and choose one representative source pixel: - - edge peak for non-strokes with edge content - - center-nearest pixel for strokes or flat regions -6. Project the accepted window center back to one output coord with `_project_source_point_to_output_coord(...)` -7. Append one proposal into that output cell's region bucket -8. Mark the consumed source footprint as unavailable within that component using `remaining[in_window] = False` +For each paint island, we try to chip out cell-sized stones. -### Important variables +There are two modes: + +- ordinary components: + - start from the centroid and edge peak + - accept a one-cell window if it contains enough of the component + - march outward one cell at a time +- elongated stroke-like components: + - estimate the component’s principal axis + - seed along that axis + - cut tiles in bands so a long stroke is not immediately turned into a rectangular blob + +Important variables inside each extracted tile: -- `remaining` -- `seed_queue` -- `accepted_any` -- `stroke_component` -- `stroke_major_half`, `stroke_minor_half` - `rep_linear` + - the literal source pixel index chosen to represent the tile +- `rep_rgba` + - the tile color - `area_ratio` + - how much component area the tile owns relative to one output cell - `coverage` -- `coord_x`, `coord_y` -- `flat_index` - -### Important consequence - -This stage is still controlled by the inferred lattice through: - -- `cell_w`, `cell_h` -- `target_width`, `target_height` -- `phase_x`, `phase_y` -- the projection from source window center to output coord - -So changing the fixed lattice changes the actual tile cutting behavior, not just the later scoring. - -## Stage 8: Per-Cell Candidate Selection - -File: `src/repixelizer/tile_graph.py` - -Helpers: - -- `_select_source_region_candidates(...)` -- `build_tile_graph_model(...)` - -Plain-language picture: - -Now each output cell has a tray of possible chips. This stage is the bouncer at the door. It lets only a few candidates into the club and throws the rest away. - -### `build_tile_graph_model(...)` main flow - -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` -9. Build candidate arrays and expected neighbor deltas. - -### `_select_source_region_candidates(...)` - -For one output cell, this ranks region proposals by: - -- closeness of `area_ratio` to `1.0` -- closeness to `reference_rgba` -- closeness to `edge_reference_rgba` + - `area_ratio` clipped to `[0, 1]` - `edge_peak` + - strongest edge value inside that tile footprint +- `source_center_x`, `source_center_y` + - where this tile lives in source space +- `coord_x`, `coord_y`, `flat_index` + - which output cell this tile projects into +- `component_id` + - which paint island it came from -It then keeps only a small cap: +Natural-language picture: -- normal cells: `tile_graph_max_candidates_per_coord` (default `2`) -- edge cells: `tile_graph_edge_candidates_per_coord` (default `6`) +The component is a strip of stained glass. We are cutting out little panes, each about one output cell wide, and tagging where each pane came from. -### Important consequence +### Stage 5: Fill empty occupied cells honestly -Even if source-region extraction found the right candidate, it can still be dropped here if: +Still inside: -- too many proposals land in the same output cell -- the candidate is not close enough to the lattice-conditioned references +- `_extract_source_region_tiles(...)` -## Stage 9: What Actually Lives In `TileGraphModel` +What it means: -After candidate selection, the model stores: +After the first pass of tile cutting, some output cells may still have no tile in their bucket even though component pixels really do overlap them. + +To fix that, the builder does an overlap-based empty-cell fill: + +- for each component, look at every output cell it physically overlaps +- if that bucket is still empty, add one overlap tile there + +This matters because: + +- a foreground cell is allowed to have multiple candidates +- a foreground cell is not allowed to have zero real extracted candidates + +Background-only cells are different: + +- they have no opaque source ownership +- they get a transparent candidate later in model building + +### Stage 6: Learn adjacency from extracted tiles + +Still inside: + +- `_extract_source_region_tiles(...)` + +What it means: + +Each extracted tile now looks one cell away in all four directions and asks: + +"Among the tiles that actually landed in that neighboring output slot, which one looks like my real neighbor back in source space?" + +For each direction, we search the neighboring bucket and choose the best observed neighbor by: + +- source-center displacement closeness +- same-component tie preference +- area fit +- coverage +- edge strength + +Stored per tile: + +- `neighbor_rgba[4, 4]` +- `neighbor_mask[4]` + +This is the first honest adjacency graph the tile-graph path has had. + +It is not: + +- sampled one-cell-away colors from the source image +- a lattice portrait +- a guessed delta field + +It is: + +- "this extracted stone most naturally sat beside that extracted stone" + +### Stage 7: Build per-cell candidate buckets + +Function: + +- `build_tile_graph_model(...)` +- helper: `_select_source_region_candidates(...)` + +What it means: + +Each output cell gets a bucket of legal stones. + +Candidate selection now keeps only tile-owned signals: + +- area fit +- coverage +- edge peak + +It does not use: + +- `sharp_rgba` similarity +- `edge_rgba` similarity +- injected sharp/edge fallback pixels + +Important rule: + +- if a cell has real source-owned region tiles, those are the truth +- if a cell has no opaque source support, it gets a transparent candidate +- if a cell has opaque source support but no extracted region candidate, the build fails loudly + +That last rule is deliberate. The old machine would quietly make something up. The new one would rather stop the line than lie. + +Additional background option: + +- if `cell_alpha_mean_flat < 0.98`, a transparent candidate is also added + +This lets the solver choose "leave this slot empty" in partially occupied or fringe cells instead of being forced to place an opaque stone just because a tiny sliver of source detail touched the slot. + +### Stage 8: The solver model + +Dataclass: + +- `TileGraphModel` + +Current essential fields: - `candidate_rgba` - - literal source-pixel colors, one row per candidate - `candidate_area_ratio` - - window size match score proxy - `candidate_coverage` - - clipped area coverage proxy -- `candidate_deltas` - - sampled right/down/left/up expected deltas one cell away in source space -- `reference_sharp_rgba` - - lattice-conditioned per-cell exemplar -- `reference_edge_rgba` - - source pixel at the per-cell edge peak -- `edge_strength` +- `candidate_edge_peak` +- `candidate_neighbor_rgba` +- `candidate_neighbor_mask` +- `cell_candidate_offsets` +- `cell_candidate_indices` +- `cell_mean_rgba` +- `cell_alpha_mean` +- `cell_edge_strength` -The diagnostic counters that used to live here now sit beside the model in `TileGraphBuildStats`, so the solver only carries solver state. +What it means: -So the final discrete solver is not operating on raw tiles alone. +This is the parts tray beside the machine. -It is operating on: +- the first block describes the legal stones +- the second block says which stones belong to which output slot +- the last block is the local source evidence already living under each slot -- source-owned candidate colors -- scored against lattice-conditioned references -- connected with deltas sampled from source-space positions offset by the inferred cell size +### Stage 9: Unary cost -Plain-language picture: +Function: -By this point the machine has mixed two kinds of truth into one bowl: - -- "what source chips are legally available here" -- "what this cell is supposed to look like according to the lattice summary" - -That mixture is exactly why the current algorithm is powerful when the lattice is good and fragile when the lattice is bad. - -## Stage 10: Choice Grid And Unary Cost - -Helpers: - -- `_build_choice_grid(...)` -- `_tile_graph_unary_cost(...)` - `_tile_graph_unary_cost_torch(...)` -Plain-language picture: +Current unary terms: -This is the judge's scorecard. Each candidate chip gets a grade for how well it resembles the lattice-conditioned portrait of the cell. +- `color_error` + - how far the candidate color is from the actual mean source color already under that output cell +- `area_error` + - how far the candidate footprint is from one cell +- `alpha_error` + - how far the candidate alpha is from the actual mean alpha under that cell +- `coverage_error` + - how incomplete the candidate’s source ownership is +- `edge_error` + - how far the candidate’s edge peak is from the strongest edge already under that cell -### `_build_choice_grid(...)` +Natural-language picture: -Transforms CSR-style cell candidate storage into dense tensors: +This is the bouncer at each slot saying: -- `choice_indices`: `(target_h, target_w, max_choices)` -- `choice_mask`: same shape, marks real entries vs padding +"Does this stone even look like it belongs in the patch of mural that sits under this square?" -### Unary cost per candidate +Important difference from the old machine: -For each output cell and each candidate: +- this is local source support +- not a separate lattice portrait pretending to know the answer in advance -1. `sharp_error` -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` +### Stage 10: Pairwise adjacency cost -Important implication: +Functions: -- the first assignment is not "pick the source region candidate that owns the tile" -- it is "pick the lowest lattice-conditioned unary cost among the allowed candidates for this output coord" +- `_pair_penalty_selected_torch(...)` +- `_pair_penalty_option_right_torch(...)` +- `_pair_penalty_option_left_torch(...)` +- `_pair_penalty_option_down_torch(...)` +- `_pair_penalty_option_up_torch(...)` -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. +What it means: -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. +Now the mason cares about seams. -## Stage 11: Initial Assignment +If tile A says it naturally expects tile B to the right, then choosing a very different right neighbor should cost something. -File: `src/repixelizer/tile_graph.py` +The current pairwise cost compares: -In `optimize_tile_graph(...)`: +- the actually chosen neighbor color +- against the neighbor color learned from extracted tiles -1. Build model. -2. Build dense choice tensors. -3. Compute `unary_cost_t`. -4. Choose: - - `initial_choice_t = argmin(unary_cost_t, dim=2)` - - `selected_t = choice_indices_t[...]` -5. Save: - - `initial_selected_t = selected_t.clone()` +This is now a real observed compatibility signal. -That initial assignment becomes: +It is no longer: -- `initial_rgba = _assignment_rgba(model, initial_selected)` +- "the source image one cell away from this center happened to look like X, so maybe that is the right delta" -This is where the fixed `126x126` badge is already broken. +### Stage 11: Checkerboard parity refinement -Measured on the bad run: +Function: -- `tile_graph_initial_source_fidelity = 0.500884` -- `tile_graph_final_source_fidelity = 0.499863` +- `optimize_tile_graph(...)` -So the badness is already in `initial_choice_t`. +What it does: -Plain-language picture: +1. build the model +2. compute unary cost +3. choose an initial argmin candidate per cell +4. alternate parity updates over the grid +5. keep the initial assignment if the refinement step makes source-lattice fidelity worse -The first draft of the mosaic is already wrong before the neighbor-agreement polish begins. The workers are not ruining a good floor later. They are laying the wrong chips down on the very first pass. +Natural-language picture: -## Stage 12: Pairwise Refinement +The mason lays down a first draft, then alternates black squares and white squares of the chessboard, trying to improve local seam compatibility without moving everything at once. -Still in `optimize_tile_graph(...)` +Important truth: -After the unary argmin, the solver runs a checkerboard parity update loop for `tile_graph_iterations`. +- this is still a local greedy/discrete smoother +- it is not a global optimal graph solver +- if the initial candidate buckets are wrong, parity updates will not save the image -What it adds: +## Data dictionary -- right-neighbor pair penalty -- left-neighbor pair penalty -- down-neighbor pair penalty -- up-neighbor pair penalty +### `flat_rgba_np` -These penalties compare: +- full-resolution source pixels flattened into `[pixel, rgba]` -- observed delta between two chosen candidate colors -- expected delta sampled for each candidate in that direction +### `projected_flat_index` -The solver alternates black/white checkerboard parities so updates are easy to parallelize. +- for each source pixel, which output cell it belongs to under the chosen lattice -Important finding: +### `components` -- this loop is not the main cause of the fixed-126 corruption -- it barely changes the broken initial assignment +- connected opaque paint islands in the source -Plain-language picture: +### `region_buckets[flat_index]` -This is the grout-and-alignment crew. They can nudge neighboring chips so the seams look more coherent, but they cannot redeem a floor that was assembled from the wrong chips to begin with. +- all extracted real tiles currently legal for one output cell -## Stage 13: Final Revert Guard +### `candidate_rgba` -At the end of `optimize_tile_graph(...)`: +- literal colors the solver is allowed to place -1. Build `initial_rgba` from initial choices. -2. Build `target_rgba` from refined choices. -3. Score both with `source_lattice_consistency_breakdown(...)`. -4. If refined is worse, revert to initial. +### `candidate_neighbor_rgba` -This is why recent tile-graph passes stopped smearing as badly: +- per-candidate remembered neighbor colors from real extracted-tile adjacency -- the solver is prevented from making a decent initial assignment worse +### `cell_mean_rgba` -But it cannot rescue a bad initial assignment. +- actual average source color already living under each output cell -Plain-language picture: +### `cell_alpha_mean` -This is the foreman saying, "If the polishing crew made the mosaic uglier, roll back to the first draft." Useful protection, but not a cure. +- actual average source alpha already living under each output cell -## Stage 14: Cleanup And Diagnostics +### `cell_edge_strength` -Files: +- strongest edge signal already living under each output cell -- `src/repixelizer/discrete.py` -- `src/repixelizer/diagnostics.py` +## What this machine is trying to maximize -### Cleanup +In plain language: -`cleanup_pixels(...)` defaults to `iterations=0`. +- choose only real, source-owned stones +- prefer stones that genuinely fit the patch of mural beneath the slot +- prefer neighboring stones that were actually observed beside each other +- never silently hallucinate a foreground tile just because the lattice wanted one -That means: +## What would make the machine lie now -- cleanup is currently a no-op unless explicitly changed -- it is not causing the observed corruption in this badge case +If the output is still wrong after this pass, the likely failure points are no longer the old ones. -### Diagnostics +The remaining likely lies are: -`summarize_run(...)` records: +- region cutting still slices a component into the wrong one-cell panes +- candidate truncation still throws away the only good tile for a difficult slot +- learned adjacency still chooses the wrong observed neighbor when multiple neighbors are plausible +- the parity solver is still too local and can settle into a bad arrangement even with legal tiles +- the lattice itself is wrong, which means the whole quarry is being cut against the wrong ruler -- `source_fidelity.snap_initial` -- `source_fidelity.solver_target` -- `source_fidelity.final_output` -- `phase_rerank_candidates` -- `loss_history` +## Current reality check -Plain-language picture: +Pinned cleaned badge run: -Cleanup is mostly a broom parked in the closet. Diagnostics are the notebook on the bench telling us what the machine actually did. +- target: `126x126` +- phase: `(0.0, -0.2)` +- mode: `tile-graph` +- phase rerank: off -## What The Broken Fixed-126 Run Is Actually Doing +Current result after the latest cut: -Directly measured on `tests/fixtures/real/ai-badge-cleaned.png` with pinned `126x126` / `(0.0, -0.2)`: +- initial source-fidelity: `0.1814` +- final source-fidelity: `0.1814` +- the solver kept the initial assignment because refinement did not improve it -- source shape: `1254x1254` -- inferred cell size: about `9.95 x 9.95` -- `source_region_stride = 2` -- connected components after segmentation: `2499` -- output cells: `15876` -- edge cells by `source_reference.edge_strength`: `1610` -- 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` +That is still not good enough, but it is dramatically more honest than the earlier architectural-collapse run around `0.5055`. -What that means: +In other words: -- 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 +- the machine is no longer babbling nonsense +- it is still not carving the right stones often enough -So if the pinned lattice is poor for tile-graph, the fallback path is poisoned immediately. +## Big-picture judgment -## Where Collapse Can First Appear +Does the current machine make sense? -In order of appearance: +Yes, much more than before. -1. `infer_fixed_lattice(...)` - - locks in a size/phase that all later steps must obey -2. `build_source_lattice_reference(...)` - - hard-assigns source pixels into inferred cells - - creates `mean_rgba`, `sharp_rgba`, `edge_peak` from those cells -3. `_extract_source_region_tiles(...)` - - cuts source components using windows sized by the inferred cell dimensions - - projects accepted windows back onto output coords using the inferred phase -4. `_select_source_region_candidates(...)` - - truncates each output cell down to a tiny local candidate set -5. initial unary argmin - - chooses the final initial candidate from lattice-conditioned references +The data now flows in one believable direction: -The fixed-126 badge collapse is already visible at step 5, but it can be caused by bad state introduced in steps 2 through 4. +1. observe the mural +2. find real paint islands +3. cut one-cell stones out of those islands +4. learn which stones really sat beside which +5. choose among those stones for each output slot -## Pondering It +That is a coherent mental model. -### 1. The current tile-graph path is not lattice-agnostic +What is still missing is not conceptual honesty. -Fixing the lattice size changes all of these at once: - -- source-pixel to cell assignment -- per-cell exemplar and edge anchors -- source-region window size -- source-region projection target -- stride used for region extraction -- fallback candidate identity - -So in the current implementation, a pinned size can absolutely collapse the result. - -That is not a pipeline bug. It is a design property. - -### 2. The current implementation is still not the pure intended algorithm - -The intended idea was closer to: - -- find atomic source tiles first -- consume them -- build adjacency from them -- place them on the output grid - -The current implementation is instead: - -- infer a lattice first -- build lattice-conditioned source references -- cut source regions using windows sized by that lattice -- project those windows into output cells using that lattice -- rank them against lattice-conditioned references - -That is a different algorithm. - -### 3. The fixed-126 failure is mostly an initial-assignment failure, not a solver failure - -The measured gap: - -- initial: `0.500884` -- final: `0.499863` - -is tiny. - -So the parity refinement loop is not "destroying" a good layout. It is starting from a bad one. - -### 4. The missing-bucket bug was real, but it was not the main collapse - -The extraction stage did have a real bug: - -- it could emit source-region windows by center projection and still leave some occupied output cells without a source-region candidate - -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 - -On this badge: - -- `126x126` uses `source_region_stride = 2` -- the stronger `162x162` tile-graph badge run used `source_region_stride = 1` - -That means the broken fixed-126 run is not just using bigger cell windows. - -It is also: - -- sampling the source more coarsely during component labeling -- sampling the source more coarsely during region cutting -- making representative-pixel choice from a coarser source sample - -That makes thin details easier to miss before the solver even starts. - -### 6. Candidate truncation is still severe - -Even when multiple region proposals land in the same output cell: - -- non-edge cells keep at most `2` -- edge cells keep at most `6` - -So a real source-owned candidate can still be dropped if the lattice-conditioned references like other candidates better. - -## What Fits The Machine, And What Does Not - -If we are ruthless about the mental model, the load-bearing pieces are: - -- a source image -- one lattice -- a segmentation of source regions -- a cutter that turns regions into tile proposals -- per-output-cell candidate buckets -- a selector that chooses one candidate per output cell -- a small neighborhood-consistency pass - -The pieces that are secondary, advisory, or ornamental are: - -- `cluster_map` and its color-family story -- phase rerank as a performance-expensive wrapper -- 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. - -## What I Think This Means - -The most honest current description of tile-graph is: - -"A lattice-conditioned source-region candidate generator plus a local discrete solver." - -It is not yet: - -"A source-owned tile extraction system that merely uses the lattice as a final placement scaffold." - -That distinction explains why fixing the lattice can collapse the result so dramatically. - -## The Most Important Current Findings - -1. The fixed-lattice pipeline path is correct. - The corruption is not being added by the wrapper. - -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. +What is still missing is quality in the stone cutting and compatibility choice. diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index fb63b4e..48a99b3 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -78,17 +78,16 @@ class SolverHyperParams: tile_graph_edge_candidates_per_coord: int = 6 tile_graph_component_color_threshold: float = 0.055 tile_graph_component_alpha_threshold: float = 0.12 - tile_graph_source_region_stride: int = 0 tile_graph_source_region_min_area_ratio: float = 0.06 tile_graph_source_region_window_coverage: float = 0.12 tile_graph_stroke_linearity_threshold: float = 0.72 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_area_weight: float = 0.03 tile_graph_alpha_weight: float = 0.25 tile_graph_coverage_weight: float = 0.05 - tile_graph_delta_weight: float = 0.45 + tile_graph_edge_peak_weight: float = 0.10 + tile_graph_adjacency_weight: float = 0.45 tile_graph_iterations: int = 10 def to_dict(self) -> dict[str, float | int]: diff --git a/src/repixelizer/source_reference.py b/src/repixelizer/source_reference.py index 1fee6c0..c1a1d3a 100644 --- a/src/repixelizer/source_reference.py +++ b/src/repixelizer/source_reference.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from .io import premultiply, unpremultiply -from .types import SourceLatticeReference, TileGraphSourceReference +from .types import SourceLatticeReference def _require_torch(): @@ -152,37 +152,6 @@ def build_source_lattice_reference( return SourceLatticeReference(**payload) -def build_tile_graph_source_reference( - source_rgba: np.ndarray, - *, - target_width: int, - target_height: int, - phase_x: float, - phase_y: float, - alpha_threshold: float = 0.05, - edge_hint: np.ndarray | None = None, - device: str | None = None, -) -> TileGraphSourceReference: - payload = _build_reference_payload( - source_rgba, - target_width=target_width, - target_height=target_height, - phase_x=phase_x, - phase_y=phase_y, - alpha_threshold=alpha_threshold, - edge_hint=edge_hint, - device=device, - ) - return TileGraphSourceReference( - sharp_rgba=payload["sharp_rgba"], - sharp_x=payload["sharp_x"], - sharp_y=payload["sharp_y"], - edge_peak_x=payload["edge_peak_x"], - edge_peak_y=payload["edge_peak_y"], - edge_strength=payload["edge_strength"], - ) - - def _build_reference_payload( source_rgba: np.ndarray, *, diff --git a/src/repixelizer/tile_graph.py b/src/repixelizer/tile_graph.py index 52480b0..e174daa 100644 --- a/src/repixelizer/tile_graph.py +++ b/src/repixelizer/tile_graph.py @@ -9,7 +9,6 @@ import numpy as np from .metrics import source_lattice_consistency_breakdown from .params import SolverHyperParams -from .source_reference import build_tile_graph_source_reference from .types import InferenceResult, SolverArtifacts, TileGraphSourceAnalysis _RIGHT = 0 @@ -56,12 +55,14 @@ class TileGraphModel: candidate_rgba: np.ndarray candidate_area_ratio: np.ndarray candidate_coverage: np.ndarray - candidate_deltas: np.ndarray + candidate_edge_peak: np.ndarray + candidate_neighbor_rgba: np.ndarray + candidate_neighbor_mask: np.ndarray cell_candidate_offsets: np.ndarray cell_candidate_indices: np.ndarray - reference_sharp_rgba: np.ndarray - reference_edge_rgba: np.ndarray - edge_strength: np.ndarray + cell_mean_rgba: np.ndarray + cell_alpha_mean: np.ndarray + cell_edge_strength: np.ndarray @dataclass(slots=True) @@ -103,12 +104,16 @@ def _tile_graph_model_cache_key( "tile_graph_edge_candidates_per_coord", "tile_graph_component_color_threshold", "tile_graph_component_alpha_threshold", - "tile_graph_source_region_stride", "tile_graph_source_region_min_area_ratio", "tile_graph_source_region_window_coverage", "tile_graph_stroke_linearity_threshold", "tile_graph_stroke_step_scale", "tile_graph_stroke_minor_limit_scale", + "tile_graph_area_weight", + "tile_graph_alpha_weight", + "tile_graph_coverage_weight", + "tile_graph_edge_peak_weight", + "tile_graph_adjacency_weight", ) build_signature = tuple((name, getattr(solver_params, name)) for name in build_param_names) return ( @@ -527,7 +532,6 @@ def _extract_source_region_tiles( flat_y: np.ndarray, cell_w: float, cell_h: float, - sample_area: float, target_width: int, target_height: int, phase_x: float, @@ -543,13 +547,18 @@ def _extract_source_region_tiles( half_w = cell_w * 0.5 half_h = cell_h * 0.5 buckets: list[list[dict[str, float | int | np.ndarray]]] = [[] for _ in range(output_area)] - min_region_pixels = max(1, int(np.ceil(cell_area * min_region_area_ratio / max(sample_area, 1e-6)))) + min_region_pixels = max(1, int(np.ceil(cell_area * min_region_area_ratio))) ordered_components = sorted( components, key=lambda comp: (abs(float(comp["size"]) - cell_area), -float(comp["edge_peak"])), ) + primary_tiles: list[dict[str, float | int | np.ndarray]] = [] - def overlap_tiles_for_component(component: dict[str, float | int | np.ndarray]) -> list[dict[str, float | int | np.ndarray]]: + def overlap_tiles_for_component( + component: dict[str, float | int | np.ndarray], + *, + component_id: int, + ) -> list[dict[str, float | int | np.ndarray]]: member_linear = np.asarray(component["member_linear"], dtype=np.int32) if member_linear.size <= 0: return [] @@ -595,20 +604,20 @@ def _extract_source_region_tiles( { "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)), + "area_ratio": float(cell_linear.shape[0] / cell_area), + "coverage": float(np.clip(cell_linear.shape[0] / 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, + "component_id": component_id, } ) return tiles - for component in ordered_components: + for component_id, component in enumerate(ordered_components): component_size = int(component["size"]) if component_size < min_region_pixels: continue @@ -682,7 +691,7 @@ def _extract_source_region_tiles( footprint_count = int(np.count_nonzero(in_window)) if footprint_count <= 0: continue - area_ratio = float((footprint_count * sample_area) / cell_area) + area_ratio = float(footprint_count / cell_area) if area_ratio < min_window_coverage: continue @@ -729,6 +738,7 @@ def _extract_source_region_tiles( "coord_x": coord_x, "coord_y": coord_y, "flat_index": flat_index, + "component_id": component_id, } ) remaining[in_window] = False @@ -789,10 +799,10 @@ def _extract_source_region_tiles( previous_secondary = float(best_tile["coord_x"] if dominant_vertical else best_tile["coord_y"]) component_tiles = filtered_tiles for tile in component_tiles: - buckets[int(tile["flat_index"])].append(tile) + primary_tiles.append(tile) continue - component_area_ratio = float((component_size * sample_area) / cell_area) + component_area_ratio = float(component_size / cell_area) if component_area_ratio < min_window_coverage: continue centroid_linear = int(component["centroid_linear"]) @@ -820,14 +830,53 @@ def _extract_source_region_tiles( "coord_x": coord_x, "coord_y": coord_y, "flat_index": flat_index, + "component_id": component_id, } ) - for component in ordered_components: - for tile in overlap_tiles_for_component(component): - if buckets[int(tile["flat_index"])]: + for tile in primary_tiles: + buckets[int(tile["flat_index"])].append(tile) + for component_id, component in enumerate(ordered_components): + for tile in overlap_tiles_for_component(component, component_id=component_id): + flat_index = int(tile["flat_index"]) + if buckets[flat_index]: continue - buckets[int(tile["flat_index"])].append(tile) + buckets[flat_index].append(tile) + + for flat_index, bucket in enumerate(buckets): + if not bucket: + continue + coord_y = flat_index // target_width + coord_x = flat_index % target_width + for tile in bucket: + neighbor_rgba = np.zeros((4, 4), dtype=np.float32) + neighbor_mask = np.zeros((4,), dtype=bool) + for direction, dy, dx in _DIRS: + neighbor_x = coord_x + dx + neighbor_y = coord_y + dy + if neighbor_x < 0 or neighbor_x >= target_width or neighbor_y < 0 or neighbor_y >= target_height: + continue + neighbor_bucket = buckets[neighbor_y * target_width + neighbor_x] + if not neighbor_bucket: + continue + expected_x = float(tile["source_center_x"]) + dx * cell_w + expected_y = float(tile["source_center_y"]) + dy * cell_h + + def neighbor_score(candidate: dict[str, float | int | np.ndarray]) -> tuple[float, int, float, float, float]: + return ( + abs(float(candidate["source_center_x"]) - expected_x) + + abs(float(candidate["source_center_y"]) - expected_y), + 0 if int(candidate["component_id"]) == int(tile["component_id"]) else 1, + abs(float(candidate["area_ratio"]) - 1.0), + -float(candidate["coverage"]), + -float(candidate["edge_peak"]), + ) + + best_neighbor = min(neighbor_bucket, key=neighbor_score) + neighbor_rgba[direction] = np.asarray(best_neighbor["rep_rgba"], dtype=np.float32) + neighbor_mask[direction] = True + tile["neighbor_rgba"] = neighbor_rgba + tile["neighbor_mask"] = neighbor_mask return buckets @@ -836,9 +885,6 @@ def _select_source_region_candidates( *, candidates: list[dict[str, float | int | np.ndarray]], allowed_candidates: int, - reference_rgba: np.ndarray, - edge_reference_rgba: np.ndarray, - prefer_edge: bool, ) -> list[dict[str, float | int | np.ndarray]]: if not candidates or allowed_candidates <= 0: return [] @@ -856,21 +902,12 @@ def _select_source_region_candidates( candidates, key=lambda cand: (abs(float(cand["area_ratio"]) - 1.0), -float(cand["coverage"]), -float(cand["edge_peak"])), ) - by_reference = sorted( - candidates, - key=lambda cand: float(np.mean(np.abs(np.asarray(cand["rep_rgba"], dtype=np.float32) - reference_rgba))), - ) - by_edge_reference = sorted( - candidates, - key=lambda cand: float(np.mean(np.abs(np.asarray(cand["rep_rgba"], dtype=np.float32) - edge_reference_rgba))), - ) by_edge = sorted(candidates, key=lambda cand: (-float(cand["edge_peak"]), abs(float(cand["area_ratio"]) - 1.0))) + by_coverage = sorted(candidates, key=lambda cand: (-float(cand["coverage"]), abs(float(cand["area_ratio"]) - 1.0), -float(cand["edge_peak"]))) add(by_area[0]) - add(by_reference[0]) - if prefer_edge: - add(by_edge_reference[0]) - add(by_edge[0]) + add(by_coverage[0]) + add(by_edge[0]) for candidate in by_area: add(candidate) if len(picked) >= allowed_candidates: @@ -899,67 +936,50 @@ def build_tile_graph_model( if cached_model is not None: model, stats = cached_model return model, replace(stats, cache_hit=True) - source_reference = build_tile_graph_source_reference( - source_rgba, - target_width=inference.target_width, - target_height=inference.target_height, - phase_x=inference.phase_x, - phase_y=inference.phase_y, - alpha_threshold=solver_params.alpha_transparent_threshold, - edge_hint=analysis.edge_map, - device=resolved_device, - ) height, width = source_rgba.shape[:2] cell_h = height / max(1, inference.target_height) cell_w = width / max(1, inference.target_width) output_area = max(1, inference.target_height * inference.target_width) max_candidates_per_coord = max(1, int(solver_params.tile_graph_max_candidates_per_coord)) - source_t = torch.from_numpy(source_rgba).to(device=resolved_device, dtype=torch.float32) - flat_rgba_t = source_t.reshape(-1, source_t.shape[-1]) - pixel_y_t, pixel_x_t = torch.meshgrid( - torch.arange(height, device=resolved_device, dtype=torch.long), - torch.arange(width, device=resolved_device, dtype=torch.long), - indexing="ij", - ) - flat_y_t = pixel_y_t.reshape(-1) - flat_x_t = pixel_x_t.reshape(-1) - sharp_x_flat_t = torch.from_numpy(source_reference.sharp_x.reshape(-1)).to(device=resolved_device, dtype=torch.long) - sharp_y_flat_t = torch.from_numpy(source_reference.sharp_y.reshape(-1)).to(device=resolved_device, dtype=torch.long) - edge_peak_x_flat_t = torch.from_numpy(source_reference.edge_peak_x.reshape(-1)).to(device=resolved_device, dtype=torch.long) - edge_peak_y_flat_t = torch.from_numpy(source_reference.edge_peak_y.reshape(-1)).to(device=resolved_device, dtype=torch.long) - sharp_linear_t = sharp_y_flat_t * width + sharp_x_flat_t - edge_linear_t = edge_peak_y_flat_t * width + edge_peak_x_flat_t - flat_rgba_np = source_rgba.reshape(-1, source_rgba.shape[-1]).astype(np.float32) flat_edge_np = analysis.edge_map.reshape(-1).astype(np.float32) - source_region_stride = int(getattr(solver_params, "tile_graph_source_region_stride", 0)) - if source_region_stride <= 0: - if max(height, width) <= 256 or output_area <= 1024: - source_region_stride = 1 - else: - source_region_stride = max(1, int(np.floor(min(cell_w, cell_h) / 4.0))) - source_region_stride = max(1, min(source_region_stride, 3)) - sampled_rgba = source_rgba[::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, height, source_region_stride, dtype=np.int32) - sampled_x_coords = np.arange(0, width, 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_np = sampled_rgba.reshape(-1, sampled_rgba.shape[-1]).astype(np.float32) - sampled_flat_edge_np = sampled_edge.reshape(-1).astype(np.float32) - sampled_flat_y_np = sampled_y_grid.reshape(-1).astype(np.int32) - sampled_flat_x_np = sampled_x_grid.reshape(-1).astype(np.int32) flat_y_np, flat_x_np = np.divmod(np.arange(flat_rgba_np.shape[0], dtype=np.int64), width) flat_y_np = flat_y_np.astype(np.int32) flat_x_np = flat_x_np.astype(np.int32) - sharp_linear_np = sharp_linear_t.detach().cpu().numpy().astype(np.int64) - edge_linear_np = edge_linear_t.detach().cpu().numpy().astype(np.int64) flat_alpha_np = flat_rgba_np[:, 3].astype(np.float32) - edge_rgba_np = flat_rgba_np[edge_linear_np] - edge_strength_np = source_reference.edge_strength.reshape(-1).astype(np.float32) + projected_coord_x = np.clip( + np.floor((flat_x_np.astype(np.float32) + 0.5) / max(cell_w, 1e-6) - inference.phase_x).astype(np.int32), + 0, + max(0, inference.target_width - 1), + ) + projected_coord_y = np.clip( + np.floor((flat_y_np.astype(np.float32) + 0.5) / max(cell_h, 1e-6) - inference.phase_y).astype(np.int32), + 0, + max(0, inference.target_height - 1), + ) + projected_flat_index = projected_coord_y * inference.target_width + projected_coord_x + cell_counts_flat = np.bincount(projected_flat_index, minlength=output_area).astype(np.float32) + safe_cell_counts_flat = np.clip(cell_counts_flat, 1.0, None) + cell_mean_rgba_flat = np.stack( + [ + np.bincount(projected_flat_index, weights=flat_rgba_np[:, channel], minlength=output_area).astype(np.float32) + / safe_cell_counts_flat + for channel in range(flat_rgba_np.shape[-1]) + ], + axis=-1, + ) + cell_alpha_support_flat = np.zeros(output_area, dtype=np.float32) + np.maximum.at(cell_alpha_support_flat, projected_flat_index, flat_alpha_np) + cell_alpha_mean_flat = ( + np.bincount(projected_flat_index, weights=flat_alpha_np, minlength=output_area).astype(np.float32) + / safe_cell_counts_flat + ) + cell_edge_strength_flat = np.zeros(output_area, dtype=np.float32) + np.maximum.at(cell_edge_strength_flat, projected_flat_index, flat_edge_np) global_components = _segment_atomic_source_regions( - source_rgba=sampled_rgba, - edge_map=sampled_edge, + source_rgba=source_rgba.astype(np.float32), + edge_map=analysis.edge_map.astype(np.float32), alpha_floor=solver_params.alpha_transparent_threshold, color_threshold=float(getattr(solver_params, "tile_graph_component_color_threshold", 0.055)), alpha_threshold=float(getattr(solver_params, "tile_graph_component_alpha_threshold", 0.12)), @@ -967,13 +987,12 @@ def build_tile_graph_model( ) region_buckets = _extract_source_region_tiles( components=global_components, - flat_rgba=sampled_flat_rgba_np, - flat_edge=sampled_flat_edge_np, - flat_x=sampled_flat_x_np, - flat_y=sampled_flat_y_np, + flat_rgba=flat_rgba_np, + flat_edge=flat_edge_np, + flat_x=flat_x_np, + flat_y=flat_y_np, 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, @@ -985,86 +1004,94 @@ def build_tile_graph_model( stroke_minor_limit_scale=float(getattr(solver_params, "tile_graph_stroke_minor_limit_scale", 0.55)), ) - candidate_linear: list[int] = [] + candidate_rgba: list[np.ndarray] = [] candidate_area_ratio: list[float] = [] candidate_coverage: list[float] = [] - candidate_source_x: list[float] = [] - candidate_source_y: list[float] = [] + candidate_edge_peak: list[float] = [] + candidate_neighbor_rgba: list[np.ndarray] = [] + candidate_neighbor_mask: list[np.ndarray] = [] choice_counts_list: list[int] = [] component_total = len(global_components) edge_candidate_cap = max(max_candidates_per_coord, int(getattr(solver_params, "tile_graph_edge_candidates_per_coord", max_candidates_per_coord))) for flat_index in range(output_area): - coord_y = flat_index // inference.target_width - coord_x = flat_index % inference.target_width seen_pixels: set[int] = set() - cell_start = len(candidate_linear) - edge_cell = edge_strength_np[flat_index] >= solver_params.source_edge_detail_threshold + cell_start = len(candidate_rgba) + edge_cell = cell_edge_strength_flat[flat_index] >= solver_params.source_edge_detail_threshold allowed_candidates = edge_candidate_cap if edge_cell else max_candidates_per_coord - def add_candidate(pixel_index: int, *, area_ratio: float, coverage: float, source_x: float, source_y: float) -> None: - if pixel_index in seen_pixels or len(seen_pixels) >= allowed_candidates: + def add_candidate( + rgba: np.ndarray, + *, + pixel_index: int | None, + area_ratio: float, + coverage: float, + edge_peak: float, + neighbor_rgba: np.ndarray | None = None, + neighbor_mask: np.ndarray | None = None, + ) -> None: + if len(candidate_rgba) - cell_start >= allowed_candidates: return - seen_pixels.add(pixel_index) - candidate_linear.append(pixel_index) + if pixel_index is not None: + if pixel_index in seen_pixels: + return + seen_pixels.add(pixel_index) + candidate_rgba.append(np.asarray(rgba, dtype=np.float32)) candidate_area_ratio.append(float(max(area_ratio, 1e-3))) candidate_coverage.append(float(np.clip(coverage, 0.0, 1.0))) - candidate_source_x.append(float(source_x)) - candidate_source_y.append(float(source_y)) + candidate_edge_peak.append(float(np.clip(edge_peak, 0.0, 1.0))) + candidate_neighbor_rgba.append( + np.asarray(neighbor_rgba if neighbor_rgba is not None else np.zeros((4, 4), dtype=np.float32), dtype=np.float32) + ) + candidate_neighbor_mask.append( + np.asarray(neighbor_mask if neighbor_mask is not None else np.zeros((4,), dtype=bool), dtype=bool) + ) selected_regions = _select_source_region_candidates( candidates=region_buckets[flat_index], allowed_candidates=allowed_candidates, - reference_rgba=source_reference.sharp_rgba.reshape(-1, 4)[flat_index], - 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( - pixel_index, + np.asarray(region["rep_rgba"], dtype=np.float32), + pixel_index=int(region["rep_linear"]), area_ratio=float(region["area_ratio"]), coverage=float(region["coverage"]), - source_x=float(region["source_center_x"]), - source_y=float(region["source_center_y"]), + edge_peak=float(region["edge_peak"]), + neighbor_rgba=np.asarray(region.get("neighbor_rgba", np.zeros((4, 4), dtype=np.float32)), dtype=np.float32), + neighbor_mask=np.asarray(region.get("neighbor_mask", np.zeros((4,), dtype=bool)), dtype=bool), ) - if len(candidate_linear) == cell_start or selected_only_guaranteed_fill: - sharp_pixel = int(sharp_linear_np[flat_index]) + if cell_alpha_mean_flat[flat_index] < 0.98: add_candidate( - sharp_pixel, + np.zeros((4,), dtype=np.float32), + pixel_index=None, area_ratio=1.0, - coverage=max(0.05, float(flat_alpha_np[sharp_pixel])), - source_x=float(flat_x_np[sharp_pixel]), - source_y=float(flat_y_np[sharp_pixel]), + coverage=float(np.clip(1.0 - cell_alpha_mean_flat[flat_index], 0.0, 1.0)), + edge_peak=0.0, ) - if edge_cell: - edge_pixel = int(edge_linear_np[flat_index]) + if len(candidate_rgba) == cell_start: + if cell_alpha_support_flat[flat_index] <= solver_params.alpha_transparent_threshold: add_candidate( - edge_pixel, + np.zeros((4,), dtype=np.float32), + pixel_index=None, area_ratio=1.0, - coverage=max(0.05, float(flat_alpha_np[edge_pixel])), - source_x=float(flat_x_np[edge_pixel]), - source_y=float(flat_y_np[edge_pixel]), + coverage=1.0, + edge_peak=0.0, ) - choice_counts_list.append(len(candidate_linear) - cell_start) + else: + coord_y = flat_index // inference.target_width + coord_x = flat_index % inference.target_width + raise RuntimeError( + "Tile-graph extraction produced no source-owned candidate " + f"for occupied output cell ({coord_x}, {coord_y})." + ) + choice_counts_list.append(len(candidate_rgba) - cell_start) - candidate_linear_t = torch.as_tensor(candidate_linear, device=resolved_device, dtype=torch.long) - center_rgba_t = flat_rgba_t[candidate_linear_t] - center_y_t = torch.as_tensor(candidate_source_y, device=resolved_device, dtype=torch.float32) - center_x_t = torch.as_tensor(candidate_source_x, device=resolved_device, dtype=torch.float32) - candidate_deltas_t = torch.zeros((candidate_linear_t.shape[0], 4, 4), device=resolved_device, dtype=torch.float32) - for direction, dy, dx in _DIRS: - sample_y_t = torch.round(center_y_t + dy * cell_h).clamp(0, max(0, height - 1)).to(dtype=torch.long) - sample_x_t = torch.round(center_x_t + dx * cell_w).clamp(0, max(0, width - 1)).to(dtype=torch.long) - candidate_deltas_t[:, direction] = source_t[sample_y_t, sample_x_t] - center_rgba_t - - candidate_rgba_np = center_rgba_t.detach().cpu().numpy().astype(np.float32) + candidate_rgba_np = np.asarray(candidate_rgba, dtype=np.float32) candidate_area_ratio_np = np.asarray(candidate_area_ratio, dtype=np.float32) candidate_coverage_np = np.asarray(candidate_coverage, dtype=np.float32) - candidate_deltas_np = candidate_deltas_t.detach().cpu().numpy().astype(np.float32) + candidate_edge_peak_np = np.asarray(candidate_edge_peak, dtype=np.float32) + candidate_neighbor_rgba_np = np.asarray(candidate_neighbor_rgba, dtype=np.float32) + candidate_neighbor_mask_np = np.asarray(candidate_neighbor_mask, dtype=bool) cell_candidate_offsets = np.zeros(output_area + 1, dtype=np.int32) cell_candidate_offsets[1:] = np.cumsum(np.asarray(choice_counts_list, dtype=np.int32), dtype=np.int32) @@ -1073,18 +1100,20 @@ def build_tile_graph_model( average_choices = float(np.mean(choice_counts)) if choice_counts.size else 0.0 edge_density = float( - np.mean(source_reference.edge_strength >= solver_params.source_edge_detail_threshold) + np.mean(cell_edge_strength_flat >= solver_params.source_edge_detail_threshold) ) base_model = TileGraphModel( candidate_rgba=candidate_rgba_np, candidate_area_ratio=candidate_area_ratio_np, candidate_coverage=candidate_coverage_np, - candidate_deltas=candidate_deltas_np, + candidate_edge_peak=candidate_edge_peak_np, + candidate_neighbor_rgba=candidate_neighbor_rgba_np, + candidate_neighbor_mask=candidate_neighbor_mask_np, cell_candidate_offsets=cell_candidate_offsets, cell_candidate_indices=cell_candidate_indices_np, - 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), + cell_mean_rgba=cell_mean_rgba_flat.reshape(inference.target_height, inference.target_width, 4).astype(np.float32), + cell_alpha_mean=cell_alpha_mean_flat.reshape(inference.target_height, inference.target_width).astype(np.float32), + cell_edge_strength=cell_edge_strength_flat.reshape(inference.target_height, inference.target_width).astype(np.float32), ) base_stats = TileGraphBuildStats( component_count=component_total, @@ -1099,13 +1128,13 @@ def build_tile_graph_model( def _cell_candidate_span(model: TileGraphModel, y: int, x: int) -> tuple[int, int]: - width = model.reference_sharp_rgba.shape[1] + width = model.cell_alpha_mean.shape[1] flat_index = y * width + x return int(model.cell_candidate_offsets[flat_index]), int(model.cell_candidate_offsets[flat_index + 1]) def _build_choice_grid(model: TileGraphModel) -> tuple[np.ndarray, np.ndarray]: - height, width = model.reference_sharp_rgba.shape[:2] + height, width = model.cell_alpha_mean.shape[:2] choice_counts = np.diff(model.cell_candidate_offsets) max_choices = int(np.max(choice_counts)) if choice_counts.size else 1 choice_indices = np.zeros((height, width, max_choices), dtype=np.int64) @@ -1132,124 +1161,166 @@ def _tile_graph_unary_cost_torch( solver_params: SolverHyperParams, ): candidate_rgba_t = torch.from_numpy(model.candidate_rgba).to(device=device, dtype=torch.float32) - candidate_deltas_t = torch.from_numpy(model.candidate_deltas).to(device=device, dtype=torch.float32) 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_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) + candidate_edge_peak_t = torch.from_numpy(model.candidate_edge_peak).to(device=device, dtype=torch.float32) + candidate_neighbor_rgba_t = torch.from_numpy(model.candidate_neighbor_rgba).to(device=device, dtype=torch.float32) + candidate_neighbor_mask_t = torch.from_numpy(model.candidate_neighbor_mask).to(device=device, dtype=torch.bool) + cell_mean_rgba_t = torch.from_numpy(model.cell_mean_rgba).to(device=device, dtype=torch.float32) + cell_alpha_mean_t = torch.from_numpy(model.cell_alpha_mean).to(device=device, dtype=torch.float32) + cell_edge_strength_t = torch.from_numpy(model.cell_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) - 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), - sharp_error_t * solver_params.tile_graph_nonedge_sharp_weight, - ) + choice_edge_peak_t = candidate_edge_peak_t[choice_indices_t] + color_error = (choice_rgba_t - cell_mean_rgba_t[..., None, :]).abs().mean(dim=-1) area_error = torch.log(candidate_area_t[choice_indices_t].clamp_min(1e-4) + 1e-4).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() + alpha_error = (choice_rgba_t[..., 3] - cell_alpha_mean_t[..., None]).abs() coverage_error = 1.0 - candidate_coverage_t[choice_indices_t].clamp(0.0, 1.0) + edge_error = (choice_edge_peak_t - cell_edge_strength_t[..., None]).abs() unary_cost_t = ( 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 + + edge_error * solver_params.tile_graph_edge_peak_weight ) 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 + return unary_cost_t, candidate_rgba_t, candidate_neighbor_rgba_t, candidate_neighbor_mask_t, choice_rgba_t -def _pair_penalty_selected_torch(torch, candidate_rgba_t, candidate_deltas_t, left_indices_t, right_indices_t, direction: int, weight: float): +def _pair_penalty_selected_torch( + torch, + candidate_rgba_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + left_indices_t, + right_indices_t, + direction: int, + weight: float, +): left_rgba = candidate_rgba_t[left_indices_t] right_rgba = candidate_rgba_t[right_indices_t] - observed_delta = right_rgba - left_rgba - expected = candidate_deltas_t[left_indices_t, direction] - reverse = candidate_deltas_t[right_indices_t, _OPPOSITE[direction]] - delta_penalty = (observed_delta - expected).abs().mean(dim=-1) - reverse_penalty = (-observed_delta - reverse).abs().mean(dim=-1) - return (delta_penalty + reverse_penalty) * (0.5 * weight) + forward_mask = candidate_neighbor_mask_t[left_indices_t, direction] + reverse_mask = candidate_neighbor_mask_t[right_indices_t, _OPPOSITE[direction]] + forward_penalty = (right_rgba - candidate_neighbor_rgba_t[left_indices_t, direction]).abs().mean(dim=-1) + reverse_penalty = (left_rgba - candidate_neighbor_rgba_t[right_indices_t, _OPPOSITE[direction]]).abs().mean(dim=-1) + total_weight = forward_mask.to(dtype=torch.float32) + reverse_mask.to(dtype=torch.float32) + combined = ( + forward_penalty * forward_mask.to(dtype=torch.float32) + + reverse_penalty * reverse_mask.to(dtype=torch.float32) + ) + return torch.where(total_weight > 0.0, combined / total_weight.clamp_min(1.0), torch.zeros_like(combined)) * weight def _pair_penalty_option_right_torch( torch, fixed_left_indices_t, option_rgba_t, - option_deltas_t, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + option_neighbor_rgba_t, + option_neighbor_mask_t, *, weight: float, ): fixed_left_rgba = candidate_rgba_t[fixed_left_indices_t] - expected = candidate_deltas_t[fixed_left_indices_t, _RIGHT][..., None, :] - reverse = option_deltas_t[..., _LEFT, :] - observed_delta = option_rgba_t - fixed_left_rgba[..., None, :] - delta_penalty = (observed_delta - expected).abs().mean(dim=-1) - reverse_penalty = (-observed_delta - reverse).abs().mean(dim=-1) - return (delta_penalty + reverse_penalty) * (0.5 * weight) + forward_mask = candidate_neighbor_mask_t[fixed_left_indices_t, _RIGHT][..., None] + reverse_mask = option_neighbor_mask_t[..., _LEFT] + forward_penalty = (option_rgba_t - candidate_neighbor_rgba_t[fixed_left_indices_t, _RIGHT][..., None, :]).abs().mean(dim=-1) + reverse_penalty = (fixed_left_rgba[..., None, :] - option_neighbor_rgba_t[..., _LEFT, :]).abs().mean(dim=-1) + total_weight = forward_mask.to(dtype=torch.float32) + reverse_mask.to(dtype=torch.float32) + combined = ( + forward_penalty * forward_mask.to(dtype=torch.float32) + + reverse_penalty * reverse_mask.to(dtype=torch.float32) + ) + return torch.where(total_weight > 0.0, combined / total_weight.clamp_min(1.0), torch.zeros_like(combined)) * weight def _pair_penalty_option_left_torch( torch, option_rgba_t, - option_deltas_t, fixed_right_indices_t, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + option_neighbor_rgba_t, + option_neighbor_mask_t, *, weight: float, ): fixed_right_rgba = candidate_rgba_t[fixed_right_indices_t] - expected = option_deltas_t[..., _RIGHT, :] - reverse = candidate_deltas_t[fixed_right_indices_t, _LEFT][..., None, :] - observed_delta = fixed_right_rgba[..., None, :] - option_rgba_t - delta_penalty = (observed_delta - expected).abs().mean(dim=-1) - reverse_penalty = (-observed_delta - reverse).abs().mean(dim=-1) - return (delta_penalty + reverse_penalty) * (0.5 * weight) + forward_mask = option_neighbor_mask_t[..., _RIGHT] + reverse_mask = candidate_neighbor_mask_t[fixed_right_indices_t, _LEFT][..., None] + forward_penalty = (fixed_right_rgba[..., None, :] - option_neighbor_rgba_t[..., _RIGHT, :]).abs().mean(dim=-1) + reverse_penalty = (option_rgba_t - candidate_neighbor_rgba_t[fixed_right_indices_t, _LEFT][..., None, :]).abs().mean(dim=-1) + total_weight = forward_mask.to(dtype=torch.float32) + reverse_mask.to(dtype=torch.float32) + combined = ( + forward_penalty * forward_mask.to(dtype=torch.float32) + + reverse_penalty * reverse_mask.to(dtype=torch.float32) + ) + return torch.where(total_weight > 0.0, combined / total_weight.clamp_min(1.0), torch.zeros_like(combined)) * weight def _pair_penalty_option_down_torch( torch, fixed_up_indices_t, option_rgba_t, - option_deltas_t, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + option_neighbor_rgba_t, + option_neighbor_mask_t, *, weight: float, ): fixed_up_rgba = candidate_rgba_t[fixed_up_indices_t] - expected = candidate_deltas_t[fixed_up_indices_t, _DOWN][..., None, :] - reverse = option_deltas_t[..., _UP, :] - observed_delta = option_rgba_t - fixed_up_rgba[..., None, :] - delta_penalty = (observed_delta - expected).abs().mean(dim=-1) - reverse_penalty = (-observed_delta - reverse).abs().mean(dim=-1) - return (delta_penalty + reverse_penalty) * (0.5 * weight) + forward_mask = candidate_neighbor_mask_t[fixed_up_indices_t, _DOWN][..., None] + reverse_mask = option_neighbor_mask_t[..., _UP] + forward_penalty = (option_rgba_t - candidate_neighbor_rgba_t[fixed_up_indices_t, _DOWN][..., None, :]).abs().mean(dim=-1) + reverse_penalty = (fixed_up_rgba[..., None, :] - option_neighbor_rgba_t[..., _UP, :]).abs().mean(dim=-1) + total_weight = forward_mask.to(dtype=torch.float32) + reverse_mask.to(dtype=torch.float32) + combined = ( + forward_penalty * forward_mask.to(dtype=torch.float32) + + reverse_penalty * reverse_mask.to(dtype=torch.float32) + ) + return torch.where(total_weight > 0.0, combined / total_weight.clamp_min(1.0), torch.zeros_like(combined)) * weight def _pair_penalty_option_up_torch( torch, option_rgba_t, - option_deltas_t, fixed_down_indices_t, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + option_neighbor_rgba_t, + option_neighbor_mask_t, *, weight: float, ): fixed_down_rgba = candidate_rgba_t[fixed_down_indices_t] - expected = option_deltas_t[..., _DOWN, :] - reverse = candidate_deltas_t[fixed_down_indices_t, _UP][..., None, :] - observed_delta = fixed_down_rgba[..., None, :] - option_rgba_t - delta_penalty = (observed_delta - expected).abs().mean(dim=-1) - reverse_penalty = (-observed_delta - reverse).abs().mean(dim=-1) - return (delta_penalty + reverse_penalty) * (0.5 * weight) + forward_mask = option_neighbor_mask_t[..., _DOWN] + reverse_mask = candidate_neighbor_mask_t[fixed_down_indices_t, _UP][..., None] + forward_penalty = (fixed_down_rgba[..., None, :] - option_neighbor_rgba_t[..., _DOWN, :]).abs().mean(dim=-1) + reverse_penalty = (option_rgba_t - candidate_neighbor_rgba_t[fixed_down_indices_t, _UP][..., None, :]).abs().mean(dim=-1) + total_weight = forward_mask.to(dtype=torch.float32) + reverse_mask.to(dtype=torch.float32) + combined = ( + forward_penalty * forward_mask.to(dtype=torch.float32) + + reverse_penalty * reverse_mask.to(dtype=torch.float32) + ) + return torch.where(total_weight > 0.0, combined / total_weight.clamp_min(1.0), torch.zeros_like(combined)) * weight -def _assignment_score_torch(torch, selected_t, unary_cost_t, choice_indices_t, candidate_rgba_t, candidate_deltas_t, solver_params: SolverHyperParams) -> float: +def _assignment_score_torch( + torch, + selected_t, + unary_cost_t, + choice_indices_t, + candidate_rgba_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + solver_params: SolverHyperParams, +) -> float: selected_choice_t = (choice_indices_t == selected_t[..., None]).to(dtype=torch.int64).argmax(dim=-1) gathered = torch.take_along_dim(unary_cost_t, selected_choice_t[..., None], dim=2)[..., 0] score = float(gathered.mean().item()) @@ -1258,11 +1329,12 @@ def _assignment_score_torch(torch, selected_t, unary_cost_t, choice_indices_t, c _pair_penalty_selected_torch( torch, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, selected_t[:, :-1], selected_t[:, 1:], _RIGHT, - solver_params.tile_graph_delta_weight, + solver_params.tile_graph_adjacency_weight, ).mean().item() ) if selected_t.shape[0] > 1: @@ -1270,11 +1342,12 @@ def _assignment_score_torch(torch, selected_t, unary_cost_t, choice_indices_t, c _pair_penalty_selected_torch( torch, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, selected_t[:-1, :], selected_t[1:, :], _DOWN, - solver_params.tile_graph_delta_weight, + solver_params.tile_graph_adjacency_weight, ).mean().item() ) return score @@ -1310,7 +1383,7 @@ def optimize_tile_graph( 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) choice_mask_t = torch.from_numpy(choice_mask_np).to(device=resolved_device, dtype=torch.bool) - unary_cost_t, candidate_rgba_t, candidate_deltas_t, choice_rgba_t, choice_deltas_t = _tile_graph_unary_cost_torch( + unary_cost_t, candidate_rgba_t, candidate_neighbor_rgba_t, candidate_neighbor_mask_t, choice_rgba_t = _tile_graph_unary_cost_torch( torch, model, choice_indices_t, @@ -1321,7 +1394,20 @@ def optimize_tile_graph( initial_choice_t = torch.argmin(unary_cost_t, dim=2) selected_t = torch.take_along_dim(choice_indices_t, initial_choice_t[..., None], dim=2)[..., 0] initial_selected_t = selected_t.clone() - loss_history = [_assignment_score_torch(torch, selected_t, unary_cost_t, choice_indices_t, candidate_rgba_t, candidate_deltas_t, solver_params)] + choice_neighbor_rgba_t = candidate_neighbor_rgba_t[choice_indices_t] + choice_neighbor_mask_t = candidate_neighbor_mask_t[choice_indices_t] + loss_history = [ + _assignment_score_torch( + torch, + selected_t, + unary_cost_t, + choice_indices_t, + candidate_rgba_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + solver_params, + ) + ] grid_y_t, grid_x_t = torch.meshgrid( torch.arange(inference.target_height, device=resolved_device, dtype=torch.long), torch.arange(inference.target_width, device=resolved_device, dtype=torch.long), @@ -1337,38 +1423,46 @@ def optimize_tile_graph( torch, selected_t[:, :-1], choice_rgba_t[:, 1:, :, :], - choice_deltas_t[:, 1:, :, :, :], candidate_rgba_t, - candidate_deltas_t, - weight=solver_params.tile_graph_delta_weight, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + choice_neighbor_rgba_t[:, 1:, :, :, :], + choice_neighbor_mask_t[:, 1:, :, :], + weight=solver_params.tile_graph_adjacency_weight, ) local_cost_t[:, :-1, :] += _pair_penalty_option_left_torch( torch, choice_rgba_t[:, :-1, :, :], - choice_deltas_t[:, :-1, :, :, :], selected_t[:, 1:], candidate_rgba_t, - candidate_deltas_t, - weight=solver_params.tile_graph_delta_weight, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + choice_neighbor_rgba_t[:, :-1, :, :, :], + choice_neighbor_mask_t[:, :-1, :, :], + weight=solver_params.tile_graph_adjacency_weight, ) if selected_t.shape[0] > 1: local_cost_t[1:, :, :] += _pair_penalty_option_down_torch( torch, selected_t[:-1, :], choice_rgba_t[1:, :, :, :], - choice_deltas_t[1:, :, :, :, :], candidate_rgba_t, - candidate_deltas_t, - weight=solver_params.tile_graph_delta_weight, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + choice_neighbor_rgba_t[1:, :, :, :, :], + choice_neighbor_mask_t[1:, :, :, :], + weight=solver_params.tile_graph_adjacency_weight, ) local_cost_t[:-1, :, :] += _pair_penalty_option_up_torch( torch, choice_rgba_t[:-1, :, :, :], - choice_deltas_t[:-1, :, :, :, :], selected_t[1:, :], candidate_rgba_t, - candidate_deltas_t, - weight=solver_params.tile_graph_delta_weight, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, + choice_neighbor_rgba_t[:-1, :, :, :, :], + choice_neighbor_mask_t[:-1, :, :, :], + weight=solver_params.tile_graph_adjacency_weight, ) best_choice_t = torch.argmin(local_cost_t, dim=2) best_selected_t = torch.take_along_dim(choice_indices_t, best_choice_t[..., None], dim=2)[..., 0] @@ -1382,7 +1476,8 @@ def optimize_tile_graph( unary_cost_t, choice_indices_t, candidate_rgba_t, - candidate_deltas_t, + candidate_neighbor_rgba_t, + candidate_neighbor_mask_t, solver_params, ) ) diff --git a/src/repixelizer/types.py b/src/repixelizer/types.py index 0cdd18d..664d0bb 100644 --- a/src/repixelizer/types.py +++ b/src/repixelizer/types.py @@ -58,16 +58,6 @@ class SourceLatticeReference: edge_grad_y: np.ndarray -@dataclass(slots=True) -class TileGraphSourceReference: - sharp_rgba: np.ndarray - sharp_x: np.ndarray - sharp_y: np.ndarray - edge_peak_x: np.ndarray - edge_peak_y: np.ndarray - edge_strength: np.ndarray - - @dataclass(slots=True) class SolverArtifacts: target_rgba: np.ndarray diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a66aafd..67e0d64 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -154,7 +154,7 @@ def test_fixed_tile_graph_pipeline_output_matches_refactor_baseline(tmp_path: Pa ) output_hash = hashlib.sha256(np.ascontiguousarray(result.output_rgba).view(np.uint8)).hexdigest() - assert output_hash == "31e3bc2cbb802a8d11bf00fbeb73772f7fa4934e5f187888d6f0d5b356f36ae9" + assert output_hash == "9dea6030fc17cec03703ef0efac076255b925e90d4bc381cd2185229ed8006d6" def test_fixed_continuous_pipeline_output_matches_refactor_baseline(tmp_path: Path) -> None: diff --git a/tests/test_source_reference.py b/tests/test_source_reference.py index cdb10db..fdf135d 100644 --- a/tests/test_source_reference.py +++ b/tests/test_source_reference.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np -from repixelizer.source_reference import build_source_lattice_reference, build_tile_graph_source_reference +from repixelizer.source_reference import build_source_lattice_reference def test_source_lattice_reference_recovers_stable_cell_colors() -> None: @@ -56,35 +56,6 @@ def test_source_lattice_reference_tracks_edge_peaks_and_gradients() -> None: assert np.all(reference.edge_grad_x[:, 0] == 1.0) assert np.all(reference.edge_grad_y[:, 0] == 0.0) - -def test_tile_graph_source_reference_matches_continuous_anchor_fields() -> None: - source = np.zeros((6, 6, 4), dtype=np.float32) - source[..., 3] = 1.0 - source[:, 2, :3] = 1.0 - - full = build_source_lattice_reference( - source, - target_width=2, - target_height=2, - phase_x=0.0, - phase_y=0.0, - ) - tile = build_tile_graph_source_reference( - source, - target_width=2, - target_height=2, - phase_x=0.0, - phase_y=0.0, - ) - - assert np.allclose(tile.sharp_rgba, full.sharp_rgba, atol=1e-5) - assert np.array_equal(tile.sharp_x, full.sharp_x) - assert np.array_equal(tile.sharp_y, full.sharp_y) - assert np.array_equal(tile.edge_peak_x, full.edge_peak_x) - assert np.array_equal(tile.edge_peak_y, full.edge_peak_y) - assert np.allclose(tile.edge_strength, full.edge_strength, atol=1e-5) - - def test_source_lattice_reference_cpu_device_matches_default_path() -> None: source = np.zeros((8, 8, 4), dtype=np.float32) source[..., 3] = 1.0 diff --git a/tests/test_tile_graph.py b/tests/test_tile_graph.py index 671ea53..c905a61 100644 --- a/tests/test_tile_graph.py +++ b/tests/test_tile_graph.py @@ -44,6 +44,9 @@ def test_tile_graph_model_extracts_candidates_and_adjacency_from_lattice_proposa ) assert model.candidate_rgba.shape[0] > 4 + assert model.candidate_neighbor_rgba.shape[1:] == (4, 4) + assert model.candidate_neighbor_mask.shape[1:] == (4,) + assert bool(np.any(model.candidate_neighbor_mask)) choice_counts = np.diff(model.cell_candidate_offsets) assert choice_counts.shape[0] == inference.target_width * inference.target_height assert np.all(choice_counts >= 1) @@ -161,20 +164,19 @@ def test_tile_graph_region_extraction_covers_every_occupied_output_cell() -> Non analysis = analyze_tile_graph_source(source, 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) + full_y_grid, full_x_grid = np.meshgrid( + np.arange(source.shape[0], dtype=np.int32), + np.arange(source.shape[1], dtype=np.int32), + indexing="ij", + ) + flat_rgba = source.reshape(-1, source.shape[-1]).astype(np.float32) + flat_edge = analysis.edge_map.reshape(-1).astype(np.float32) + flat_y = full_y_grid.reshape(-1).astype(np.int32) + flat_x = full_x_grid.reshape(-1).astype(np.int32) components = _segment_atomic_source_regions( - source_rgba=sampled_rgba, - edge_map=sampled_edge, + source_rgba=source, + edge_map=analysis.edge_map, alpha_floor=params.alpha_transparent_threshold, color_threshold=params.tile_graph_component_color_threshold, alpha_threshold=params.tile_graph_component_alpha_threshold, @@ -182,13 +184,12 @@ def test_tile_graph_region_extraction_covers_every_occupied_output_cell() -> Non ) 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, + flat_rgba=flat_rgba, + flat_edge=flat_edge, + flat_x=flat_x, + flat_y=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, @@ -200,14 +201,14 @@ def test_tile_graph_region_extraction_covers_every_occupied_output_cell() -> Non stroke_minor_limit_scale=params.tile_graph_stroke_minor_limit_scale, ) - opaque = sampled_flat_rgba[:, 3] >= params.alpha_transparent_threshold + opaque = 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), + np.floor((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), + np.floor((flat_y.astype(np.float32) + 0.5) / cell_h - inference.phase_y).astype(np.int32), 0, inference.target_height - 1, )