24 KiB
Tile-Graph Algorithm Map
This is the end-to-end map of the current tile-graph path as it exists in the repo today.
It is written specifically to answer one question:
Why can a pinned lattice like 126x126 / phase (0.0, -0.2) produce a completely garbled result instead of merely a slightly worse one?
Short answer:
- the fixed-lattice pipeline wrapper is not corrupting the image
- the corruption is already present in the
tile-graphinitial assignment - the current
tile-graphimplementation is much more lattice-conditioned than the intended "extract source-owned one-cell tiles, then place them" design
On the current broken badge run:
- source image:
1254x1254 - fixed lattice:
126x126 - phase:
(0.0, -0.2) tile_graph_initial_source_fidelity = 0.500884tile_graph_final_source_fidelity = 0.499863
That tiny change means the parity-update loop is not the main failure. The model build and initial assignment are.
Bird's-Eye Flow
flowchart TD
A["source_rgba (H x W x 4)"] --> B["run_pipeline(...)"]
B --> C["infer_lattice(...) or infer_fixed_lattice(...)"]
B --> D["analyze_source(...)"]
C --> E["InferenceResult"]
D --> F["SourceAnalysis"]
E --> G["phase rerank probes (optional)"]
F --> G
G --> H["optimize_tile_graph(...)"]
H --> I["build_source_lattice_reference(...)"]
H --> J["_segment_atomic_source_regions(...)"]
J --> K["_extract_source_region_tiles(...)"]
I --> L["per-output-cell references"]
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"]
Core Data Objects
source_rgba
- 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
InferenceResult
- File:
src/repixelizer/types.py - Fields:
target_width,target_height: chosen output lattice sizephase_x,phase_y: sub-cell phase offsets in lattice unitsconfidence: gap between top two inference candidatestop_candidates: rerankable list ofInferenceCandidate
SourceAnalysis
- File:
src/repixelizer/types.py - Fields:
edge_map: per-source-pixel edge strengthcluster_map: k-means color labelscluster_centers: color centroidsalpha_map: source alphacluster_preview: diagnostic preview
Important note:
cluster_mapis diagnostic/coarse analysis data- it is not the current tile-graph region ownership model
SourceLatticeReference
- File:
src/repixelizer/types.py - Meaning: lattice-conditioned summary of the source under one exact
(target_width, target_height, phase_x, phase_y) - Fields:
mean_rgba: average premultiplied color per inferred cell, then unpremultipliedsharp_rgba: one chosen exemplar pixel per inferred celldispersion: average per-pixel deviation from cell meanlattice_indices: source-pixel to inferred-cell assignment mapcell_dispersion,cell_counts,cell_support,cell_alpha_maxsharp_x,sharp_yedge_peak_x,edge_peak_yedge_strength,edge_grad_x,edge_grad_ydelta_x,delta_y,delta_diag,delta_anti
TileGraphModel
- 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 colorscandidate_coords: output coord(y, x)each candidate belongs tocandidate_area_ratio: how much of a cell-sized source window the source region coveredcandidate_coverage: clipped version of area coveragecandidate_deltas: expected RGBA deltas to right/down/left/up neighbors sampled one cell away in source spacecell_candidate_offsets,cell_candidate_indices: CSR-like mapping from output cells to candidate rowsreference_mean_rgba,reference_sharp_rgba,reference_edge_rgbaedge_strengthcomponent_count,edge_density,average_choicesgeometry_reference_rgba,geometry_strength: optional hybrid-mode priors
Stage 1: Pipeline Entry
File: src/repixelizer/pipeline.py
Main entry:
run_pipeline(...)
Main variables:
source: loaded RGBA source imagefixed_dims: eitherNoneor exact(target_width, target_height)resolved from CLIinference: oneInferenceResultanalysis: oneSourceAnalysiscached_reconstruction: optional reused probe result from phase reranksolver:SolverArtifactsfrom continuous, tile-graph, or hybridcleanup:CleanupArtifactsoutput_rgba: final saved output
Control flow:
- Load source.
- Optionally preprocess background.
- Resolve fixed-vs-searched lattice mode.
- Run source analysis.
- Optionally rerank top lattice candidates by actually reconstructing them.
- Run the selected reconstruction mode.
- Run cleanup and optional palette quantization.
- Save output and diagnostics.
Important finding:
- The fixed-lattice pipeline path is behaving correctly.
- Running the same pinned
126x126tile-graph case through:- direct
_run_reconstruction(...) run_pipeline(...)- the phase-rerank probe path produces the exact same output bytes.
- direct
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(...)
Searched path
infer_lattice(...) does:
- Estimate spacing priors from source luminance/alpha changes.
- Build candidate output sizes with
_candidate_dims(...). - For each size, score a
5x5phase grid with_score_phase_group(...). - Keep one best phase per size.
- Rerank sizes with
source_lattice_evidence_breakdown(...).
Main variables:
spacing_x,spacing_y: measured source spacing hintshinted_sizes: size hints derived from spacingprior_cell_x,prior_cell_y,prior_reliability: size prior for scoringphase_values:[-0.4, -0.2, 0.0, 0.2, 0.4]candidates: all scored(size, phase)candidatessize_candidates: top one phase per sizereranked_candidates: size candidates reordered by source-lattice evidence
Fixed path
infer_fixed_lattice(...) does:
- Keep the target size fixed.
- If phase is pinned, score exactly that one phase.
- If phase is not pinned, search only the phase grid for that fixed size.
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
Stage 3: Source Analysis
File: src/repixelizer/analysis.py
Entry point:
analyze_source(rgba, seed, cluster_count=6, device=None)
Outputs:
edge_map: edge strength from luminance plus alpha differencescluster_map: k-means labels on opaque pixelscluster_centers: k-means centroidsalpha_mapcluster_preview
Important reality check:
- tile-graph no longer uses
cluster_mapas its actual tile ownership map - the tile-graph region ownership is built later by connected components over source pixels using color/alpha thresholds
Stage 4: Optional Phase Rerank
File: src/repixelizer/pipeline.py
Helper:
_select_phase_candidate_with_reconstruction(...)
What it does:
- Only runs when inference confidence is low and there are multiple top candidates.
- Reconstructs several top candidates with the active mode.
- Scores each candidate by:
- source support (
source_lattice_consistency_breakdown) - edge position error
- stroke wobble
- edge concentration
- size delta penalty
- inference score penalty
- Picks the best reranked candidate if it clears the configured margin.
Important note for tile-graph:
- this rerank is pipeline-level, not optimizer-specific
- today
optimize_tile_graph(...)ignoressteps, so tile-graph rerank probes are basically full tile-graph runs
That is a performance problem, but not the cause of the fixed-126 corruption.
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.
Inputs
source_rgbatarget_width,target_heightphase_x,phase_yalpha_threshold- optional
edge_hint
What it computes
lattice_indices(...)- assigns every source pixel to one inferred lattice cell
- this is a hard rectangular partition based on current size and phase
mean_rgba- per-cell premultiplied mean, then unpremultiplied
sharp_rgba- one exemplar per cell
- chosen by minimizing
exemplar_cost = pixel_diff + tiny alpha underfill penalty
edge_peak- strongest edge-supporting pixel per cell
edge_strength- strongest edge value seen in that cell
cell_dispersion- how mixed the cell is
delta_x,delta_y,delta_diag,delta_anti- deltas between neighboring
sharpcells
- deltas between neighboring
Critical variables
indices/indices_t: source-pixel to cell assignmentflat_idxmean_premul_flatpixel_diff: per-source-pixel deviation from the current cell meanexemplar_cost: what decidessharp_rgbaedge_scoresharp_x_flat,sharp_y_flatedge_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_indicesgroups the wrong source pixels togethermean_rgbabecomes the wrong per-cell averagesharp_rgbabecomes the wrong per-cell exemplaredge_peakbecomes 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.
Inputs
source_rgbaorsampled_rgbaedge_maporsampled_edgealpha_floorcolor_thresholdalpha_thresholddevice
What it does
- Mark opaque pixels.
- Build
join_rightandjoin_downbooleans for neighboring source pixels whose premultiplied color and alpha are close enough. - Run connected-component labeling over those joins.
- For each component, compute:
member_linearedge_peak_linearcentroid_linearcentroid_x,centroid_yaxis_x,axis_ylinearitymajor_span,minor_spansize
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:
_extract_source_region_tiles(...)
This is the real source-side tile cutter.
Inputs
componentsflat_rgba,flat_edge,flat_x,flat_ycell_w,cell_hsample_areatarget_width,target_heightphase_x,phase_ymin_region_area_ratiomin_window_coverage- stroke slicing params
What it does
For each connected source component:
- Sort components by closeness of
component["size"]to one output-cell area. - Decide whether the component is "stroke-like" from:
linearitymajor_spanminor_span
- Create seed centers:
- centroid and edge peak for regular regions
- principal-axis marching seeds for stroke-like regions
- For each seed:
- build a local window mask
- count
footprint_count - compute
area_ratio = footprint_count * sample_area / cell_area - reject tiny windows
- 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
- Project the accepted window center back to one output coord with
_project_source_point_to_output_coord(...) - Append one proposal into that output cell's region bucket
- Mark the consumed source footprint as unavailable within that component using
remaining[in_window] = False
Important variables
remainingseed_queueaccepted_anystroke_componentstroke_major_half,stroke_minor_halfrep_lineararea_ratiocoveragecoord_x,coord_yflat_index
Important consequence
This stage is still controlled by the inferred lattice through:
cell_w,cell_htarget_width,target_heightphase_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(...)
build_tile_graph_model(...) main flow
- Resolve geometry prior inputs if hybrid mode is active.
- Check the process-local tile-graph cache.
- Build
source_reference. - Compute
cell_w,cell_h. - Decide
source_region_stride. - Build
sampled_rgbaandsampled_edge. - Segment components.
- Extract region tiles into
region_buckets. - 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
sharpand optionallyedge_peak
- Build candidate arrays and expected neighbor deltas.
_select_source_region_candidates(...)
For one output cell, this ranks region proposals by:
- closeness of
area_ratioto1.0 - closeness to
reference_rgba - closeness to
edge_reference_rgba edge_peak
It then keeps only a small cap:
- normal cells:
tile_graph_max_candidates_per_coord(default2) - edge cells:
tile_graph_edge_candidates_per_coord(default6)
Important consequence
Even if source-region extraction found the right candidate, it can still be dropped here if:
- too many proposals land in the same output cell
- the candidate is not close enough to the lattice-conditioned references
Stage 9: What Actually Lives In TileGraphModel
After candidate selection, the model stores:
candidate_rgba- literal source-pixel colors, one row per candidate
candidate_coords- which output cell each candidate belongs to
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_mean_rgba- lattice-conditioned per-cell mean
reference_sharp_rgba- lattice-conditioned per-cell exemplar
reference_edge_rgba- source pixel at the per-cell edge peak
edge_strength
So the final discrete solver is not operating on raw tiles alone.
It is operating on:
- source-owned candidate colors
- scored against lattice-conditioned references
- connected with deltas sampled from source-space positions offset by the inferred cell size
Stage 10: Choice Grid And Unary Cost
Helpers:
_build_choice_grid(...)_tile_graph_unary_cost(...)_tile_graph_unary_cost_torch(...)
_build_choice_grid(...)
Transforms CSR-style cell candidate storage into dense tensors:
choice_indices:(target_h, target_w, max_choices)choice_mask: same shape, marks real entries vs padding
Unary cost per candidate
For each output cell and each candidate:
sharp_errormean_erroredge_error- If edge cell:
color_error = min(sharp_error, edge_error) + mean_error * tile_graph_edge_mean_weight
- Else:
color_error = sharp_error * nonedge_sharp_weight + mean_error * nonedge_mean_weight
- Add:
area_erroralpha_errorcoverage_error
- Optionally add hybrid geometry error.
Important implication:
- 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"
Stage 11: Initial Assignment
File: src/repixelizer/tile_graph.py
In optimize_tile_graph(...):
- Build model.
- Build dense choice tensors.
- Compute
unary_cost_t. - Choose:
initial_choice_t = argmin(unary_cost_t, dim=2)selected_t = choice_indices_t[...]
- Save:
initial_selected_t = selected_t.clone()
That initial assignment becomes:
initial_rgba = _assignment_rgba(model, initial_selected)
This is where the fixed 126x126 badge is already broken.
Measured on the bad run:
tile_graph_initial_source_fidelity = 0.500884tile_graph_final_source_fidelity = 0.499863
So the badness is already in initial_choice_t.
Stage 12: Pairwise Refinement
Still in optimize_tile_graph(...)
After the unary argmin, the solver runs a checkerboard parity update loop for tile_graph_iterations.
What it adds:
- right-neighbor pair penalty
- left-neighbor pair penalty
- down-neighbor pair penalty
- up-neighbor pair penalty
These penalties compare:
- observed delta between two chosen candidate colors
- expected delta sampled for each candidate in that direction
The solver alternates black/white checkerboard parities so updates are easy to parallelize.
Important finding:
- this loop is not the main cause of the fixed-126 corruption
- it barely changes the broken initial assignment
Stage 13: Final Revert Guard
At the end of optimize_tile_graph(...):
- Build
initial_rgbafrom initial choices. - Build
target_rgbafrom refined choices. - Score both with
source_lattice_consistency_breakdown(...). - If refined is worse, revert to initial.
This is why recent tile-graph passes stopped smearing as badly:
- the solver is prevented from making a decent initial assignment worse
But it cannot rescue a bad initial assignment.
Stage 14: Cleanup And Diagnostics
Files:
src/repixelizer/discrete.pysrc/repixelizer/diagnostics.py
Cleanup
cleanup_pixels(...) defaults to iterations=0.
That means:
- cleanup is currently a no-op unless explicitly changed
- it is not causing the observed corruption in this badge case
Diagnostics
summarize_run(...) records:
source_fidelity.snap_initialsource_fidelity.solver_targetsource_fidelity.final_outputphase_rerank_candidatesloss_history
What The Broken Fixed-126 Run Is Actually Doing
Directly measured on tests/fixtures/real/ai-badge-cleaned.png with pinned 126x126 / (0.0, -0.2):
- 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
Region-bucket statistics before fallback:
- cells with no extracted source-region candidates at all:
7999 / 15876(50.4%) - edge cells with no extracted source-region candidates:
325 / 1610(20.2%) - mean raw region candidates per cell:
1.24 - mean selected region candidates per cell before fallback:
0.93
Selection histogram before fallback:
7999cells:0region candidates survive1829cells:15496cells:2306cells:3181cells:451cells:514cells:6
What that means:
- about half the grid gets no source-region candidate at all and must fall back to lattice-derived anchors
- those anchors come from
source_reference, which is itself conditioned on the pinned lattice
So if the pinned lattice is poor for tile-graph, the fallback path is poisoned immediately.
Where Collapse Can First Appear
In order of appearance:
infer_fixed_lattice(...)- locks in a size/phase that all later steps must obey
build_source_lattice_reference(...)- hard-assigns source pixels into inferred cells
- creates
mean_rgba,sharp_rgba,edge_peakfrom those cells
_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
_select_source_region_candidates(...)- truncates each output cell down to a tiny local candidate set
- initial unary argmin
- chooses the final initial candidate from lattice-conditioned references
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.
Pondering It
1. The current tile-graph path is not lattice-agnostic
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. Half the grid falling back is a huge clue
When 7999 cells have no extracted region candidate:
- the model is not primarily selecting from source-owned cut tiles
- it is primarily leaning on lattice-derived fallback anchors
That makes the algorithm much more sensitive to lattice misfit than it looks from the outside.
5. The fixed-126 run is harsher because it flips extraction stride
On this badge:
126x126usessource_region_stride = 2- the stronger
162x162tile-graph badge run usedsource_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 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 Two Most Important Current Findings
-
The fixed-lattice pipeline path is correct. The corruption is not being added by the wrapper.
-
The tile-graph initial assignment is already bad on the broken
126x126case. The problem lives in lattice-conditioned reference building, source-region cutting/projection, candidate starvation, or unary selection before pairwise refinement has much chance to help.