repixelizer/docs/tile-graph-algorithm-map.md
2026-04-23 03:00:51 +01:00

25 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-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

On the current broken badge run:

  • 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

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 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

SourceAnalysis

  • File: src/repixelizer/types.py
  • Fields:
    • edge_map: per-source-pixel edge strength
    • cluster_map: k-means color labels
    • cluster_centers: color centroids
    • alpha_map: source alpha
    • cluster_preview: diagnostic preview

Important note:

  • cluster_map is 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 unpremultiplied
    • sharp_rgba: one chosen exemplar pixel per inferred cell
    • dispersion: average per-pixel deviation from cell mean
    • lattice_indices: source-pixel to inferred-cell assignment map
    • cell_dispersion, cell_counts, cell_support, cell_alpha_max
    • sharp_x, sharp_y
    • edge_peak_x, edge_peak_y
    • edge_strength, edge_grad_x, edge_grad_y
    • delta_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 colors
    • candidate_coords: output coord (y, x) each candidate belongs to
    • candidate_area_ratio: how much of a cell-sized source window the source region covered
    • candidate_coverage: clipped version of area coverage
    • candidate_deltas: expected RGBA deltas to right/down/left/up neighbors sampled one cell away in source space
    • cell_candidate_offsets, cell_candidate_indices: CSR-like mapping from output cells to candidate rows
    • reference_mean_rgba, reference_sharp_rgba, reference_edge_rgba
    • edge_strength
    • component_count, edge_density, average_choices
    • geometry_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 image
  • fixed_dims: either None or exact (target_width, target_height) resolved from CLI
  • inference: one InferenceResult
  • analysis: one SourceAnalysis
  • cached_reconstruction: optional reused probe result from phase rerank
  • solver: SolverArtifacts from continuous, tile-graph, or hybrid
  • cleanup: CleanupArtifacts
  • output_rgba: final saved output

Control flow:

  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.

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(...)

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.

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 differences
  • cluster_map: k-means labels on opaque pixels
  • cluster_centers: k-means centroids
  • alpha_map
  • cluster_preview

Important reality check:

  • tile-graph no longer uses cluster_map as its actual tile ownership map
  • the tile-graph region ownership is built later by connected components over source pixels using color/alpha thresholds

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
  1. 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(...) ignores steps, 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_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.

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:

  • _extract_source_region_tiles(...)

This is the real source-side tile cutter.

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

What it does

For each connected source component:

  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
  1. Create seed centers:
  • centroid and edge peak for regular regions
  • principal-axis marching seeds for stroke-like regions
  1. For each seed:
  • build a local window mask
  • count footprint_count
  • compute area_ratio = footprint_count * sample_area / cell_area
  • reject tiny windows
  1. 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
  1. Project the accepted window center back to one output coord with _project_source_point_to_output_coord(...)
  2. Append one proposal into that output cell's region bucket
  3. Mark the consumed source footprint as unavailable within that component using remaining[in_window] = False

Important variables

  • remaining
  • seed_queue
  • accepted_any
  • stroke_component
  • stroke_major_half, stroke_minor_half
  • rep_linear
  • area_ratio
  • 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(...)

build_tile_graph_model(...) main flow

  1. Resolve geometry prior inputs if hybrid mode is active.
  2. Check the process-local tile-graph cache.
  3. Build source_reference.
  4. Compute cell_w, cell_h.
  5. Decide source_region_stride.
  6. Build sampled_rgba and sampled_edge.
  7. Segment components.
  8. Extract region tiles into region_buckets.
  9. 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
  1. 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
  • edge_peak

It then keeps only a small cap:

  • normal cells: tile_graph_max_candidates_per_coord (default 2)
  • edge cells: tile_graph_edge_candidates_per_coord (default 6)

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:

  1. sharp_error
  2. mean_error
  3. edge_error
  4. If edge cell:
  • color_error = min(sharp_error, edge_error) + mean_error * tile_graph_edge_mean_weight
  1. Else:
  • color_error = sharp_error * nonedge_sharp_weight + mean_error * nonedge_mean_weight
  1. Add:
  • area_error
  • alpha_error
  • coverage_error
  1. 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(...):

  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()

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.500884
  • tile_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(...):

  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.

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.py
  • src/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_initial
  • source_fidelity.solver_target
  • source_fidelity.final_output
  • phase_rerank_candidates
  • loss_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
  • output cells with any opaque sampled source pixel under the full-size lattice mapping: 8167
  • output cells with any extracted source-region bucket after the empty-cell fill pass: 8169
  • occupied output cells still missing extracted region buckets: 0
  • output cells with no extracted region bucket are now background-only cells by design: 7707

What that means:

  • connected-components still ignore pixels below the alpha threshold, so background-only cells do not get source-region buckets from this stage
  • an earlier draft of this document overstated the loss by comparing sampled pixels against lattice_indices(...) on the downsampled grid instead of projecting sampled source coordinates back onto the full-size lattice; that comparison was wrong and has now been corrected
  • the missing-bucket bug on occupied cells was real and is now fixed
  • the fixed 126x126 output is still broken anyway, so empty extracted buckets were not the dominant cause of the collapse

So if the pinned lattice is poor for tile-graph, the fallback path is poisoned immediately.

Where Collapse Can First Appear

In order of appearance:

  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 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. 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 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.