Remove tile-graph engine
This commit is contained in:
parent
c5b53f9861
commit
d4b5ab9ed7
18 changed files with 33 additions and 2716 deletions
18
README.md
18
README.md
|
|
@ -10,11 +10,11 @@ Instead of pretending this is a resize problem, Repixelizer treats it as lattice
|
|||
|
||||
Repixelizer was built to rescue fake pixel art, but it can also be used to generate pixel art directly from non-pixel source art when the shapes are clean and the local structure is doing something useful.
|
||||
|
||||
Rows show the source art, a plain Lanczos downscale, the default `phase-field` solver, and the `tile-graph` alternate solver. The bottom row also carries the tracked sword-tip closeup, because that tiny patch is one of the more honest tests of whether the machine is preserving structure instead of just faking confidence.
|
||||
Rows show the source art, a plain Lanczos downscale, and the live `phase-field` solver. The bottom row also carries the tracked sword-tip closeup, because that tiny patch is one of the more honest tests of whether the machine is preserving structure instead of just faking confidence.
|
||||
|
||||

|
||||
|
||||
That is the pitch in one image: two different machines, same mess, same ruler, nowhere to hide.
|
||||
That is the pitch in one image: same mess, same ruler, one machine forced to prove it actually understands the grid.
|
||||
|
||||
## Current Status
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ What exists now:
|
|||
|
||||
- lattice inference with CUDA support
|
||||
- a lean displacement-field optimizer in `src/repixelizer/phase_field.py`
|
||||
- a source-owned alternate solver in `src/repixelizer/tile_graph.py`
|
||||
- automatic diagnostics, comparisons, and benchmark runs
|
||||
- a tuning harness for offline parameter sweeps
|
||||
- metrics that finally care about visible structure instead of only pleasing the lattice accountant
|
||||
|
|
@ -36,16 +35,14 @@ What changed recently:
|
|||
- `source_structure` is reported alongside `source_fidelity`, because the old metric was happily calling better-looking images worse
|
||||
- the tracked sword-tip blemish on the AI badge has its own focused fixture in `tests/fixtures/real/ai-badge-tip-focus.json`
|
||||
|
||||
Current read on the engines:
|
||||
Current read on the engine:
|
||||
|
||||
- `phase-field` is the release path. It currently produces the best-looking badge result in the repo, especially on internal linework, even though it still widens the tracked sword-tip stroke a bit too much.
|
||||
- `tile-graph` is still valuable as the alternate machine. It keeps hard source ownership and often preserves cell identity in a different, sometimes more stubborn way, but it is slower and more temperamental on badge-scale inputs.
|
||||
|
||||
Current weak spots:
|
||||
|
||||
- `phase-field` still needs better along-stroke versus across-stroke behavior near tapered contours
|
||||
- tile-graph cold-build time is still dominated by connected-component labeling / region extraction on large fixtures
|
||||
- lattice selection is still low-confidence on some ugly generated inputs, so pinned-size iteration remains an important workflow for both engines
|
||||
- lattice selection is still low-confidence on some ugly generated inputs, so pinned-size iteration remains an important workflow
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
|
@ -59,10 +56,7 @@ Run the optimizer:
|
|||
```powershell
|
||||
repixelize input.png --out output.png
|
||||
repixelize input.png --out output.png --diagnostics-dir diagnostics --device auto
|
||||
repixelize input.png --out output.png --reconstruction-mode phase-field --diagnostics-dir diagnostics --device auto
|
||||
repixelize input.png --out output.png --reconstruction-mode tile-graph --diagnostics-dir diagnostics --device cpu
|
||||
repixelize input.png --out output.png --reconstruction-mode tile-graph --diagnostics-dir diagnostics --device cuda
|
||||
repixelize input.png --out output.png --reconstruction-mode tile-graph --target-width 126 --target-height 126 --phase-x 0.0 --phase-y -0.2 --device cuda
|
||||
repixelize input.png --out output.png --target-width 126 --target-height 126 --phase-x 0.0 --phase-y -0.2 --device cuda
|
||||
```
|
||||
|
||||
Run the optimizer plus baselines:
|
||||
|
|
@ -122,7 +116,6 @@ That keeps benchmark assets, attribution exports, and tuning runs from polluting
|
|||
- `tests`: focused regression tests
|
||||
- `docs/spec.md`: product and technical spec
|
||||
- `docs/implementation-plan.md`: working roadmap
|
||||
- `docs/tile-graph-algorithm-map.md`: detailed tile-graph dataflow and failure-point map
|
||||
- `docs/lean-optimizer-algorithm-map.md`: map of the live displacement-field optimizer
|
||||
- `examples/corpus/README.md`: local corpus layout and attribution workflow
|
||||
|
||||
|
|
@ -130,7 +123,6 @@ Core modules:
|
|||
|
||||
- `inference.py`: target size and phase inference
|
||||
- `phase_field.py`: default displacement-field optimizer
|
||||
- `tile_graph.py`: source-owned alternate reconstruction path
|
||||
- `metrics.py`: fidelity, adjacency, and motif metrics
|
||||
- `benchmark.py`: corpus benchmark runner
|
||||
- `tuning.py`: black-box hyperparameter search harness
|
||||
|
|
|
|||
|
|
@ -2,22 +2,18 @@
|
|||
|
||||
## Current machine
|
||||
|
||||
Repixelizer now has two real reconstruction engines:
|
||||
Repixelizer now has one live reconstruction engine:
|
||||
|
||||
- `phase-field`: the default optimizer in `src/repixelizer/phase_field.py`
|
||||
- `tile-graph`: the source-owned alternate solver in `src/repixelizer/tile_graph.py`
|
||||
|
||||
The old tray-based optimizer is gone. Good riddance.
|
||||
|
||||
The live pipeline is:
|
||||
|
||||
`source image -> lattice inference -> edge analysis -> phase-field or tile-graph reconstruction -> cleanup -> optional palette fit -> diagnostics`
|
||||
`source image -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`
|
||||
|
||||
## What is working
|
||||
|
||||
- lattice size and phase inference are still shared and still CUDA-capable
|
||||
- `phase-field` is now the main optimizer path and produces the best-looking badge result in the repo so far
|
||||
- `tile-graph` still provides a useful alternate path when literal source ownership matters more than smooth field behavior
|
||||
- compare mode, benchmark mode, diagnostics writing, and tuning all still work after the optimizer cutover
|
||||
- the repo now reports both:
|
||||
- `source_fidelity`: agreement with the inferred lattice portrait
|
||||
|
|
@ -48,27 +44,13 @@ What we know:
|
|||
- the tracked blemish is now narrow and specific: the sword-tip stroke widens too much in one local region
|
||||
- the tracked focus fixture is `tests/fixtures/real/ai-badge-tip-focus.json`
|
||||
|
||||
### Tile-graph
|
||||
|
||||
Useful artifacts:
|
||||
|
||||
- full emblem win: `artifacts/full-emblem-tile-graph-atomic-v3-cuda/`
|
||||
- pinned badge speed/quality checkpoint: `artifacts/tile-graph-reducebykey-v1-badge-126/`
|
||||
|
||||
What we know:
|
||||
|
||||
- source ownership is real now
|
||||
- large-fixture cold-build cost is still the main pain
|
||||
- the dominant remaining speed problem is connected-component labeling / region extraction, not the parity solver loop
|
||||
|
||||
## Maps
|
||||
|
||||
The repo now keeps the living maps only:
|
||||
The repo now keeps the living map:
|
||||
|
||||
- `docs/lean-optimizer-algorithm-map.md`
|
||||
- `docs/tile-graph-algorithm-map.md`
|
||||
|
||||
If a future pass cannot be explained cleanly against one of those maps, it should not land.
|
||||
If a future pass cannot be explained cleanly against that map, it should not land.
|
||||
|
||||
## Current priorities
|
||||
|
||||
|
|
@ -104,18 +86,6 @@ Rules:
|
|||
- if a new term lands, it must belong cleanly in the one-field objective
|
||||
- if a result looks better and the metrics disagree, fix the metrics rather than worshipping them
|
||||
|
||||
### 3. Make tile-graph less slow
|
||||
|
||||
Goal:
|
||||
|
||||
- reduce cold-build latency on badge-scale inputs without reintroducing fake ownership or averaged patch lies
|
||||
|
||||
Most likely targets:
|
||||
|
||||
- connected-component labeling
|
||||
- source-region extraction
|
||||
- cache reuse across repeated fixed-lattice runs
|
||||
|
||||
## Guardrails
|
||||
|
||||
- prefer one clear hypothesis per pass
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 395 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 259 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
|
@ -1,427 +0,0 @@
|
|||
# Tile-Graph Algorithm Map
|
||||
|
||||
## Why this map exists
|
||||
|
||||
The tile-graph path is trying to do one stubbornly honest thing:
|
||||
|
||||
Take a fake-pixel mural, split it into real opaque paint islands, slice those islands by the chosen output lattice, learn which slices continue into neighboring cells, and then reassemble the image using only those real slices.
|
||||
|
||||
When the output looks wrong, this map is here to answer:
|
||||
|
||||
Where did the machine start lying?
|
||||
|
||||
## One-sentence machine
|
||||
|
||||
The current tile-graph machine is:
|
||||
|
||||
`source image -> edge scout -> connected components -> component-cell overlap reduction -> per-cell candidate buckets -> learned same-component adjacency -> local discrete assignment`
|
||||
|
||||
Or, in pictures:
|
||||
|
||||
- the source image is the mural
|
||||
- connected components are paint islands
|
||||
- the chosen lattice is a sheet of graph paper laid over the mural
|
||||
- the overlap reducer is a cookie cutter that stamps each island against each graph-paper square it actually touches
|
||||
- the solver is the mason choosing which stamped shard goes in each final slot
|
||||
|
||||
## What was cut away
|
||||
|
||||
These ideas are gone:
|
||||
|
||||
- subsampling the mural before extraction with `source_region_stride`
|
||||
- per-component seed walking and window stepping
|
||||
- stroke-specific PCA slicing
|
||||
- ranking candidates by resemblance to a lattice portrait (`sharp_rgba`, `edge_rgba`)
|
||||
- injecting `sharp_pixel` / `edge_pixel` when extraction failed
|
||||
- sampled one-cell-away RGBA deltas pretending to be a graph
|
||||
- a dedicated tile-graph source-reference object
|
||||
|
||||
That matters because all of those were ways for the machine to stop listening to the actual source-owned tiles.
|
||||
|
||||
## Stage 0: The pipeline picks a ruler
|
||||
|
||||
Function:
|
||||
|
||||
- `run_pipeline(...)` in `src/repixelizer/pipeline.py`
|
||||
|
||||
Inputs:
|
||||
|
||||
- `source_rgba`
|
||||
- chosen `InferenceResult`
|
||||
- `target_width`
|
||||
- `target_height`
|
||||
- `phase_x`
|
||||
- `phase_y`
|
||||
|
||||
Meaning:
|
||||
|
||||
Before anything else, the system chooses the graph paper.
|
||||
|
||||
If the graph paper is wrong, every later step is cutting the mural against the wrong ruler.
|
||||
|
||||
Once the lattice is pinned, the tile-graph path now runs directly on that lattice. It no longer burns time on phase-rerank probes or hybrid sidecars.
|
||||
|
||||
## Stage 1: Edge scout
|
||||
|
||||
Function:
|
||||
|
||||
- `analyze_tile_graph_source(...)` in `src/repixelizer/analysis.py`
|
||||
|
||||
Output:
|
||||
|
||||
- `TileGraphSourceAnalysis.edge_map`
|
||||
|
||||
Meaning:
|
||||
|
||||
This is a scout walking over the mural with a lantern and chalk, marking where the paint changes sharply.
|
||||
|
||||
It is not deciding colors. It is only measuring local cliff faces.
|
||||
|
||||
Important variable:
|
||||
|
||||
- `edge_map[y, x]`
|
||||
|
||||
Why it exists:
|
||||
|
||||
- connected components need a detail hint
|
||||
- candidate budgeting still uses edge-heavy cells as "hard mode"
|
||||
|
||||
## Stage 2: Every source pixel is assigned to an output cell
|
||||
|
||||
Function:
|
||||
|
||||
- `build_tile_graph_model(...)`
|
||||
|
||||
Key variables:
|
||||
|
||||
- `cell_w`
|
||||
- `cell_h`
|
||||
- `projected_flat_index`
|
||||
- `cell_mean_rgba_flat`
|
||||
- `cell_alpha_mean_flat`
|
||||
- `cell_alpha_support_flat`
|
||||
- `cell_edge_strength_flat`
|
||||
|
||||
Meaning:
|
||||
|
||||
Imagine dropping transparent graph paper over the mural and writing the output cell index on every source pixel.
|
||||
|
||||
That gives us direct per-cell source summaries:
|
||||
|
||||
- average color already under the slot
|
||||
- average alpha already under the slot
|
||||
- strongest alpha under the slot
|
||||
- strongest edge under the slot
|
||||
|
||||
These are not a separate portrait of what the slot "should" be. They are literally summaries of the pixels already living under that slot.
|
||||
|
||||
## Stage 3: Connected components
|
||||
|
||||
Functions:
|
||||
|
||||
- `_segment_atomic_source_regions(...)`
|
||||
- `_segment_atomic_source_regions_cpu(...)`
|
||||
|
||||
Output:
|
||||
|
||||
- `AtomicRegionLabeling`
|
||||
- `pixel_linear`
|
||||
- `component_ids`
|
||||
- `component_sizes`
|
||||
- `component_count`
|
||||
|
||||
Meaning:
|
||||
|
||||
Now the mural is split into paint islands.
|
||||
|
||||
Pixels join the same island only if:
|
||||
|
||||
- they are opaque enough
|
||||
- their premultiplied colors are similar enough
|
||||
- their alpha is similar enough
|
||||
|
||||
This is literal connected-component labeling over the full-resolution source.
|
||||
|
||||
Important consequence:
|
||||
|
||||
- every opaque source pixel belongs to exactly one component
|
||||
|
||||
## Stage 4: Reduce component-cell overlaps
|
||||
|
||||
Function:
|
||||
|
||||
- `_extract_source_region_tiles(...)`
|
||||
|
||||
This is the new heart of the machine.
|
||||
|
||||
Instead of walking each component with seed queues and cell-sized windows, the reducer does something much simpler:
|
||||
|
||||
1. take every opaque labeled source pixel
|
||||
2. project it onto the chosen output lattice
|
||||
3. form a compound key: `(component_id, output_cell)`
|
||||
4. sort by that key
|
||||
5. reduce each run into one candidate shard
|
||||
|
||||
This is the same basic mental pattern as `reduce_by_key` / `segment_reduce`: sort once, then aggregate consecutive runs.
|
||||
|
||||
What each reduced shard stores:
|
||||
|
||||
- `rep_linear`
|
||||
- `rep_rgba`
|
||||
- `area_ratio`
|
||||
- `coverage`
|
||||
- `edge_peak`
|
||||
- `source_center_x`
|
||||
- `source_center_y`
|
||||
- `coord_x`, `coord_y`, `flat_index`
|
||||
- `component_id`
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
Instead of hand-carving the island with a pocketknife, we stamp every island against the graph paper and keep one shard per `(island, cell)` overlap.
|
||||
|
||||
## Stage 5: Candidate buckets
|
||||
|
||||
Still inside:
|
||||
|
||||
- `_extract_source_region_tiles(...)`
|
||||
- `_select_source_region_candidates(...)`
|
||||
|
||||
Meaning:
|
||||
|
||||
Each output cell now gets a bucket of legal shards.
|
||||
|
||||
The reducer first creates every real `(component, cell)` overlap shard.
|
||||
|
||||
Then bucket pruning keeps the best local candidates using only tile-owned evidence:
|
||||
|
||||
- area fit
|
||||
- coverage
|
||||
- edge peak
|
||||
|
||||
Important rule:
|
||||
|
||||
- if a cell has opaque source support, it must have at least one real extracted shard
|
||||
- if pruning would empty a supported cell, the best overlap shard for that cell is kept anyway
|
||||
- background or near-background cells can also get a transparent candidate
|
||||
|
||||
What this prevents:
|
||||
|
||||
- silent empty foreground buckets
|
||||
- fake sharp/edge fallback pixels
|
||||
|
||||
## Stage 6: Learn adjacency from the reduced shards
|
||||
|
||||
Still inside:
|
||||
|
||||
- `_extract_source_region_tiles(...)`
|
||||
|
||||
Meaning:
|
||||
|
||||
Each shard now asks a simpler question than before:
|
||||
|
||||
"Does my same component continue into the cell to the right, down, left, or up?"
|
||||
|
||||
If a same-component overlap shard exists in the neighboring cell, that shard's representative color becomes the expected neighbor color for that direction.
|
||||
|
||||
Stored per shard:
|
||||
|
||||
- `neighbor_rgba[4, 4]`
|
||||
- `neighbor_mask[4]`
|
||||
|
||||
This is an intentionally narrower graph than the earlier heuristic:
|
||||
|
||||
- it only learns continuity that the same connected paint island actually exhibits
|
||||
- it does not try to smooth across boundaries between different components
|
||||
|
||||
That is a carefully chosen bias:
|
||||
|
||||
- continuity within a real island is trustworthy
|
||||
- continuity across a contour is where fake smoothing usually starts
|
||||
|
||||
## Stage 7: Solver model
|
||||
|
||||
Dataclass:
|
||||
|
||||
- `TileGraphModel`
|
||||
|
||||
Current essential fields:
|
||||
|
||||
- `candidate_rgba`
|
||||
- `candidate_area_ratio`
|
||||
- `candidate_coverage`
|
||||
- `candidate_edge_peak`
|
||||
- `candidate_neighbor_rgba`
|
||||
- `candidate_neighbor_mask`
|
||||
- `cell_candidate_offsets`
|
||||
- `cell_candidate_indices`
|
||||
- `cell_mean_rgba`
|
||||
- `cell_alpha_mean`
|
||||
- `cell_edge_strength`
|
||||
|
||||
Meaning:
|
||||
|
||||
This is the tray of legal shards plus the local evidence under each output slot.
|
||||
|
||||
## Stage 8: Unary cost
|
||||
|
||||
Function:
|
||||
|
||||
- `_tile_graph_unary_cost_torch(...)`
|
||||
|
||||
Terms:
|
||||
|
||||
- `color_error`
|
||||
- candidate versus actual mean color already under the slot
|
||||
- `area_error`
|
||||
- how close the shard is to one full cell
|
||||
- `alpha_error`
|
||||
- candidate alpha versus actual mean alpha under the slot
|
||||
- `coverage_error`
|
||||
- how incomplete the overlap is
|
||||
- `edge_error`
|
||||
- candidate edge peak versus strongest edge under the slot
|
||||
|
||||
Meaning:
|
||||
|
||||
The unary cost is the bouncer at each slot asking:
|
||||
|
||||
"Does this shard honestly fit the patch of mural under this square?"
|
||||
|
||||
Important difference from the old machine:
|
||||
|
||||
- this is local source support
|
||||
- not a pre-baked portrait from another reference object
|
||||
|
||||
## Stage 9: Pairwise cost
|
||||
|
||||
Functions:
|
||||
|
||||
- `_pair_penalty_selected_torch(...)`
|
||||
- `_pair_penalty_option_right_torch(...)`
|
||||
- `_pair_penalty_option_left_torch(...)`
|
||||
- `_pair_penalty_option_down_torch(...)`
|
||||
- `_pair_penalty_option_up_torch(...)`
|
||||
|
||||
Meaning:
|
||||
|
||||
Now the mason cares about seams.
|
||||
|
||||
If a shard says "my component really continues to the right as color X," then placing a very different right neighbor costs something.
|
||||
|
||||
This is narrower than the old fake graph, but more honest:
|
||||
|
||||
- it rewards real within-component continuation
|
||||
- it stops encouraging smoothing across unrelated components
|
||||
|
||||
## Stage 10: Parity refinement
|
||||
|
||||
Function:
|
||||
|
||||
- `optimize_tile_graph(...)`
|
||||
|
||||
What it does:
|
||||
|
||||
1. build candidate buckets
|
||||
2. compute unary cost
|
||||
3. choose an initial argmin candidate per cell
|
||||
4. alternate parity updates across the grid
|
||||
5. if refinement worsens source-fidelity, keep the initial assignment
|
||||
|
||||
Meaning:
|
||||
|
||||
The mason lays an initial mosaic, then alternates black and white squares of the chessboard trying to improve local seams without moving everything at once.
|
||||
|
||||
Important truth:
|
||||
|
||||
- this is still a local discrete optimizer
|
||||
- it is not globally optimal
|
||||
- if the buckets are wrong, refinement will not save the image
|
||||
|
||||
## Data dictionary
|
||||
|
||||
### `AtomicRegionLabeling.pixel_linear`
|
||||
|
||||
- flattened source-pixel indices for every opaque labeled pixel
|
||||
|
||||
### `AtomicRegionLabeling.component_ids`
|
||||
|
||||
- component id for each `pixel_linear`
|
||||
|
||||
### `projected_flat_index`
|
||||
|
||||
- output-cell index for each source pixel under the chosen lattice
|
||||
|
||||
### `compound_key`
|
||||
|
||||
- `(component_id * output_area) + output_cell`
|
||||
|
||||
This is the reducer's key. Every identical key means "these pixels belong to the same component-cell overlap shard."
|
||||
|
||||
### `region_buckets[flat_index]`
|
||||
|
||||
- all legal source-owned shards currently available for one output cell
|
||||
|
||||
### `candidate_neighbor_rgba`
|
||||
|
||||
- expected neighboring shard colors learned from same-component continuation
|
||||
|
||||
## What this machine is optimizing for
|
||||
|
||||
In plain language:
|
||||
|
||||
- choose only real source-owned shards
|
||||
- prefer shards that genuinely fit the local patch of mural
|
||||
- preserve continuation within real paint islands
|
||||
- refuse to invent a foreground shard when extraction did not produce one
|
||||
|
||||
## What can still go wrong
|
||||
|
||||
The likely remaining lies are now:
|
||||
|
||||
- the lattice is wrong
|
||||
- component labeling merged or split the wrong source regions
|
||||
- the direct overlap shard for a difficult contour cell is too coarse
|
||||
- candidate truncation threw away the only good shard in a crowded cell
|
||||
- same-component adjacency is too narrow to express a more complex local motif
|
||||
- the parity solver settles into a mediocre local arrangement
|
||||
|
||||
## Current reality check
|
||||
|
||||
Pinned cleaned badge run after the reduce-by-key extraction rewrite:
|
||||
|
||||
- target: `126x126`
|
||||
- phase: `(0.0, -0.2)`
|
||||
- mode: `tile-graph`
|
||||
- phase rerank: off
|
||||
|
||||
Measured result:
|
||||
|
||||
- initial source-fidelity: `0.2036`
|
||||
- final source-fidelity: `0.2036`
|
||||
- the solver kept the initial assignment
|
||||
|
||||
That is a small quality regression from the previous `0.1814` cut, but it is still far better than the earlier fixed-lattice collapse around `0.5055`.
|
||||
|
||||
## Big-picture judgment
|
||||
|
||||
Does the current machine make more sense than the old one?
|
||||
|
||||
Yes.
|
||||
|
||||
The dataflow is now much cleaner:
|
||||
|
||||
1. observe the mural
|
||||
2. label the paint islands
|
||||
3. stamp those islands against the chosen lattice
|
||||
4. keep the resulting overlap shards
|
||||
5. learn only the continuation those shards actually exhibit
|
||||
6. assemble the output from those shards
|
||||
|
||||
That is the right shape.
|
||||
|
||||
The remaining problem is no longer "why is the machine inventing stories?"
|
||||
|
||||
It is:
|
||||
|
||||
"Are these overlap shards expressive enough for the finest contours, and can we compute the labels fast enough?"
|
||||
|
|
@ -41,11 +41,6 @@ def _parse_args() -> argparse.Namespace:
|
|||
default="docs/readme-assets/guard-right-crop-comparison.png",
|
||||
help="Output path for the standalone sword-guard closeup strip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-engine-sheet",
|
||||
default="docs/readme-assets/engine-comparison-sheet.png",
|
||||
help="Output path for the pinned phase-field vs tile-graph engine comparison sheet.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scratch-dir",
|
||||
default="artifacts/readme-build",
|
||||
|
|
@ -89,14 +84,6 @@ def _parse_args() -> argparse.Namespace:
|
|||
default=-0.2,
|
||||
help="Pinned lattice phase Y for the engine-vs-engine badge comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--engine-cell-bbox",
|
||||
nargs=4,
|
||||
type=int,
|
||||
default=(67, 90, 97, 122),
|
||||
metavar=("X0", "Y0", "X1", "Y1"),
|
||||
help="Tracked sword-tip crop bounds in output-grid cell coordinates for the pinned engine comparison.",
|
||||
)
|
||||
parser.add_argument("--steps", type=int, default=48, help="Optimizer step budget for both runs.")
|
||||
parser.add_argument("--device", default="cpu", choices=("auto", "cpu", "cuda"), help="Torch device for generation.")
|
||||
return parser.parse_args()
|
||||
|
|
@ -284,158 +271,6 @@ def _save_summary(path: Path, payload: dict[str, object]) -> None:
|
|||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _build_engine_comparison_sheet(
|
||||
*,
|
||||
source_rgba: np.ndarray,
|
||||
lanczos_rgba: np.ndarray,
|
||||
phase_field_rgba: np.ndarray,
|
||||
tile_graph_rgba: np.ndarray,
|
||||
focus_source_bbox: tuple[int, int, int, int],
|
||||
focus_cell_bbox: tuple[int, int, int, int],
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
) -> Image.Image:
|
||||
top_panel_width = 320
|
||||
top_panel_height = 338
|
||||
crop_panel_width = 240
|
||||
crop_panel_height = 210
|
||||
col_gap = 18
|
||||
row_gap = 18
|
||||
margin = 24
|
||||
title_height = 24
|
||||
row_title_height = 20
|
||||
|
||||
top_panels = [
|
||||
_render_panel(
|
||||
"Source",
|
||||
rgba=source_rgba,
|
||||
pixelated=False,
|
||||
panel_width=top_panel_width,
|
||||
panel_height=top_panel_height,
|
||||
subtitle="Cleaned AI fake pixel badge",
|
||||
),
|
||||
_render_panel(
|
||||
"Lanczos",
|
||||
rgba=lanczos_rgba,
|
||||
pixelated=True,
|
||||
panel_width=top_panel_width,
|
||||
panel_height=top_panel_height,
|
||||
subtitle=f"{target_width}x{target_height} naive baseline",
|
||||
),
|
||||
_render_panel(
|
||||
"Phase-field",
|
||||
rgba=phase_field_rgba,
|
||||
pixelated=True,
|
||||
panel_width=top_panel_width,
|
||||
panel_height=top_panel_height,
|
||||
subtitle=f"{target_width}x{target_height} pinned lattice",
|
||||
),
|
||||
_render_panel(
|
||||
"Tile-graph",
|
||||
rgba=tile_graph_rgba,
|
||||
pixelated=True,
|
||||
panel_width=top_panel_width,
|
||||
panel_height=top_panel_height,
|
||||
subtitle=f"{target_width}x{target_height} pinned lattice",
|
||||
),
|
||||
]
|
||||
_draw_source_bbox(
|
||||
top_panels[0],
|
||||
focus_source_bbox,
|
||||
source_width=source_rgba.shape[1],
|
||||
source_height=source_rgba.shape[0],
|
||||
)
|
||||
_draw_source_bbox(
|
||||
top_panels[1],
|
||||
focus_cell_bbox,
|
||||
source_width=lanczos_rgba.shape[1],
|
||||
source_height=lanczos_rgba.shape[0],
|
||||
)
|
||||
_draw_source_bbox(
|
||||
top_panels[2],
|
||||
focus_cell_bbox,
|
||||
source_width=phase_field_rgba.shape[1],
|
||||
source_height=phase_field_rgba.shape[0],
|
||||
)
|
||||
_draw_source_bbox(
|
||||
top_panels[3],
|
||||
focus_cell_bbox,
|
||||
source_width=tile_graph_rgba.shape[1],
|
||||
source_height=tile_graph_rgba.shape[0],
|
||||
)
|
||||
|
||||
crop_panels = [
|
||||
_render_panel(
|
||||
"Source focus",
|
||||
rgba=_crop_rgba(source_rgba, focus_source_bbox),
|
||||
pixelated=True,
|
||||
panel_width=crop_panel_width,
|
||||
panel_height=crop_panel_height,
|
||||
subtitle="Tracked sword-tip crop",
|
||||
),
|
||||
_render_panel(
|
||||
"Lanczos focus",
|
||||
rgba=_crop_rgba(lanczos_rgba, focus_cell_bbox),
|
||||
pixelated=True,
|
||||
panel_width=crop_panel_width,
|
||||
panel_height=crop_panel_height,
|
||||
subtitle="Naive resize",
|
||||
),
|
||||
_render_panel(
|
||||
"Phase-field focus",
|
||||
rgba=_crop_rgba(phase_field_rgba, focus_cell_bbox),
|
||||
pixelated=True,
|
||||
panel_width=crop_panel_width,
|
||||
panel_height=crop_panel_height,
|
||||
subtitle="Default optimizer",
|
||||
),
|
||||
_render_panel(
|
||||
"Tile-graph focus",
|
||||
rgba=_crop_rgba(tile_graph_rgba, focus_cell_bbox),
|
||||
pixelated=True,
|
||||
panel_width=crop_panel_width,
|
||||
panel_height=crop_panel_height,
|
||||
subtitle="Source-owned alternate solver",
|
||||
),
|
||||
]
|
||||
|
||||
width = margin * 2 + top_panel_width * 4 + col_gap * 3
|
||||
height = (
|
||||
margin * 2
|
||||
+ title_height
|
||||
+ row_title_height * 2
|
||||
+ top_panel_height
|
||||
+ crop_panel_height
|
||||
+ row_gap
|
||||
)
|
||||
canvas = Image.new("RGBA", (width, height), (11, 11, 13, 255))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.text(
|
||||
(margin, margin - 2),
|
||||
f"Pinned badge lattice: {target_width}x{target_height} @ ({phase_x:.1f}, {phase_y:.1f})",
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
draw.text((margin, margin + title_height), "Whole image", fill=(255, 255, 255, 255))
|
||||
top_y = margin + title_height + row_title_height
|
||||
crop_row_y = top_y + top_panel_height + row_gap
|
||||
draw.text((margin, crop_row_y), "Tracked sword-tip crop", fill=(255, 255, 255, 255))
|
||||
crop_y = crop_row_y + row_title_height
|
||||
|
||||
x = margin
|
||||
for panel in top_panels:
|
||||
canvas.alpha_composite(panel.image, (x, top_y))
|
||||
x += top_panel_width + col_gap
|
||||
|
||||
x = margin
|
||||
for panel in crop_panels:
|
||||
canvas.alpha_composite(panel.image, (x, crop_y))
|
||||
x += crop_panel_width + col_gap
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
scratch_dir = Path(args.scratch_dir)
|
||||
|
|
@ -445,10 +280,8 @@ def main() -> None:
|
|||
ai_input = Path(args.ai_input)
|
||||
out_sheet = Path(args.out_sheet)
|
||||
out_guard_crop = Path(args.out_guard_crop)
|
||||
out_engine_sheet = Path(args.out_engine_sheet)
|
||||
out_sheet.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_guard_crop.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_engine_sheet.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
vector_phase_field_result = run_pipeline(
|
||||
vector_input,
|
||||
|
|
@ -460,20 +293,6 @@ def main() -> None:
|
|||
diagnostics_dir=scratch_dir / "vector-phase-field-diag",
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode="phase-field",
|
||||
enable_phase_rerank=False,
|
||||
)
|
||||
vector_tile_graph_result = run_pipeline(
|
||||
vector_input,
|
||||
scratch_dir / "badge-vector-tile-graph.png",
|
||||
target_width=args.vector_target_size,
|
||||
target_height=args.vector_target_size,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
diagnostics_dir=scratch_dir / "vector-tile-graph-diag",
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode="tile-graph",
|
||||
enable_phase_rerank=False,
|
||||
)
|
||||
ai_result = run_pipeline(
|
||||
|
|
@ -493,20 +312,6 @@ def main() -> None:
|
|||
diagnostics_dir=scratch_dir / "engine-phase-field-diag",
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode="phase-field",
|
||||
enable_phase_rerank=False,
|
||||
)
|
||||
tile_graph_engine_result = run_pipeline(
|
||||
ai_input,
|
||||
scratch_dir / "badge-ai-tile-graph-pinned.png",
|
||||
target_width=args.engine_target_width,
|
||||
target_height=args.engine_target_height,
|
||||
phase_x=args.engine_phase_x,
|
||||
phase_y=args.engine_phase_y,
|
||||
diagnostics_dir=scratch_dir / "engine-tile-graph-diag",
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode="tile-graph",
|
||||
enable_phase_rerank=False,
|
||||
)
|
||||
|
||||
|
|
@ -554,14 +359,6 @@ def main() -> None:
|
|||
panel_height=panel_height,
|
||||
subtitle=f"{args.vector_target_size}x{args.vector_target_size} pinned lattice",
|
||||
),
|
||||
_render_panel(
|
||||
"Tile-graph",
|
||||
rgba=vector_tile_graph_result.output_rgba,
|
||||
pixelated=True,
|
||||
panel_width=panel_width,
|
||||
panel_height=panel_height,
|
||||
subtitle=f"{args.vector_target_size}x{args.vector_target_size} pinned lattice",
|
||||
),
|
||||
]
|
||||
ai_panels = [
|
||||
_render_panel(
|
||||
|
|
@ -588,14 +385,6 @@ def main() -> None:
|
|||
panel_height=panel_height,
|
||||
subtitle=f"{args.engine_target_width}x{args.engine_target_height} pinned lattice",
|
||||
),
|
||||
_render_panel(
|
||||
"Tile-graph",
|
||||
rgba=tile_graph_engine_result.output_rgba,
|
||||
pixelated=True,
|
||||
panel_width=panel_width,
|
||||
panel_height=panel_height,
|
||||
subtitle=f"{args.engine_target_width}x{args.engine_target_height} pinned lattice",
|
||||
),
|
||||
]
|
||||
|
||||
guard_bbox = _cell_bbox_to_source_bbox(
|
||||
|
|
@ -619,11 +408,6 @@ def main() -> None:
|
|||
width=vector_source.shape[1],
|
||||
height=vector_source.shape[0],
|
||||
)
|
||||
vector_tile_graph_preview = nearest_resize(
|
||||
vector_tile_graph_result.output_rgba,
|
||||
width=vector_source.shape[1],
|
||||
height=vector_source.shape[0],
|
||||
)
|
||||
for panel in vector_panels:
|
||||
_draw_source_bbox(
|
||||
panel,
|
||||
|
|
@ -635,7 +419,6 @@ def main() -> None:
|
|||
_crop_rgba(vector_source, vector_guard_inset_bbox),
|
||||
_crop_rgba(vector_lanczos_preview, vector_guard_inset_bbox),
|
||||
_crop_rgba(vector_phase_field_preview, vector_guard_inset_bbox),
|
||||
_crop_rgba(vector_tile_graph_preview, vector_guard_inset_bbox),
|
||||
]
|
||||
for panel, crop in zip(vector_panels, vector_crops, strict=True):
|
||||
_add_pip_inset(panel, _build_pip_inset(crop, width=139, height=103), margin_x=4, margin_y=4)
|
||||
|
|
@ -661,12 +444,11 @@ def main() -> None:
|
|||
_crop_rgba(ai_source, ai_guard_inset_bbox),
|
||||
_crop_rgba(ai_lanczos, ai_guard_cell_inset_bbox),
|
||||
_crop_rgba(phase_field_engine_result.output_rgba, ai_guard_cell_inset_bbox),
|
||||
_crop_rgba(tile_graph_engine_result.output_rgba, ai_guard_cell_inset_bbox),
|
||||
]
|
||||
for panel, crop in zip(ai_panels, ai_crops, strict=True):
|
||||
_add_pip_inset(panel, _build_pip_inset(crop, width=139, height=103), margin_x=4, margin_y=4)
|
||||
|
||||
width = margin * 2 + panel_width * 4 + col_gap * 3
|
||||
width = margin * 2 + panel_width * 3 + col_gap * 2
|
||||
height = margin * 2 + row_title_height * 2 + panel_height * 2 + row_gap
|
||||
canvas = Image.new("RGBA", (width, height), (11, 11, 13, 255))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
|
@ -691,27 +473,6 @@ def main() -> None:
|
|||
out_sheet.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(out_sheet)
|
||||
|
||||
engine_focus_source_bbox = _cell_bbox_to_source_bbox(
|
||||
tuple(args.engine_cell_bbox),
|
||||
source_width=ai_source.shape[1],
|
||||
source_height=ai_source.shape[0],
|
||||
target_width=args.engine_target_width,
|
||||
target_height=args.engine_target_height,
|
||||
)
|
||||
engine_sheet = _build_engine_comparison_sheet(
|
||||
source_rgba=ai_source,
|
||||
lanczos_rgba=ai_lanczos,
|
||||
phase_field_rgba=phase_field_engine_result.output_rgba,
|
||||
tile_graph_rgba=tile_graph_engine_result.output_rgba,
|
||||
focus_source_bbox=engine_focus_source_bbox,
|
||||
focus_cell_bbox=tuple(args.engine_cell_bbox),
|
||||
target_width=args.engine_target_width,
|
||||
target_height=args.engine_target_height,
|
||||
phase_x=args.engine_phase_x,
|
||||
phase_y=args.engine_phase_y,
|
||||
)
|
||||
engine_sheet.save(out_engine_sheet)
|
||||
|
||||
_save_summary(
|
||||
scratch_dir / "readme-preview-summary.json",
|
||||
{
|
||||
|
|
@ -729,18 +490,14 @@ def main() -> None:
|
|||
"engine_target_height": args.engine_target_height,
|
||||
"engine_phase_x": args.engine_phase_x,
|
||||
"engine_phase_y": args.engine_phase_y,
|
||||
"engine_cell_bbox": list(args.engine_cell_bbox),
|
||||
"engine_focus_source_bbox": list(engine_focus_source_bbox),
|
||||
"out_sheet": str(out_sheet),
|
||||
"out_guard_crop": str(out_guard_crop),
|
||||
"out_engine_sheet": str(out_engine_sheet),
|
||||
"scratch_dir": str(scratch_dir),
|
||||
},
|
||||
)
|
||||
|
||||
print(str(out_sheet))
|
||||
print(str(out_guard_crop))
|
||||
print(str(out_engine_sheet))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import numpy as np
|
||||
|
||||
from .metrics import luminance
|
||||
from .types import PhaseFieldSourceAnalysis, TileGraphSourceAnalysis
|
||||
from .types import PhaseFieldSourceAnalysis
|
||||
|
||||
|
||||
def _require_torch():
|
||||
|
|
@ -74,22 +74,3 @@ def analyze_phase_field_source(
|
|||
return PhaseFieldSourceAnalysis(
|
||||
edge_map=edge_map,
|
||||
)
|
||||
|
||||
|
||||
def analyze_tile_graph_source(
|
||||
rgba: np.ndarray,
|
||||
*,
|
||||
device: str | None = None,
|
||||
) -> TileGraphSourceAnalysis:
|
||||
if device is not None:
|
||||
torch = _require_torch()
|
||||
resolved_device = _resolve_device(torch, device)
|
||||
rgba_t = torch.from_numpy(rgba).to(device=resolved_device, dtype=torch.float32)
|
||||
edge_map_t = _compute_edge_map_torch(torch, rgba_t)
|
||||
return TileGraphSourceAnalysis(
|
||||
edge_map=edge_map_t.detach().cpu().numpy().astype(np.float32),
|
||||
)
|
||||
|
||||
return TileGraphSourceAnalysis(
|
||||
edge_map=_compute_edge_map(rgba),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,12 +33,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
target.add_argument("--seed", type=int, default=7, help="Random seed")
|
||||
target.add_argument("--steps", type=int, default=200, help="Number of optimizer steps")
|
||||
target.add_argument("--device", default="auto", choices=("auto", "cpu", "cuda"), help="Torch device")
|
||||
target.add_argument(
|
||||
"--reconstruction-mode",
|
||||
default="phase-field",
|
||||
choices=("phase-field", "tile-graph"),
|
||||
help="Reconstruction engine to run after lattice estimation",
|
||||
)
|
||||
target.add_argument(
|
||||
"--strip-background",
|
||||
action="store_true",
|
||||
|
|
@ -144,7 +138,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
seed=args.seed,
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode=args.reconstruction_mode,
|
||||
strip_background=args.strip_background,
|
||||
enable_phase_rerank=not args.skip_phase_rerank,
|
||||
)
|
||||
|
|
@ -197,7 +190,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
seed=args.seed,
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
reconstruction_mode=args.reconstruction_mode,
|
||||
strip_background=args.strip_background,
|
||||
enable_phase_rerank=not args.skip_phase_rerank,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ def run_compare(
|
|||
steps: int = 200,
|
||||
device: str = "auto",
|
||||
strip_background: bool = False,
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
diagnostics_path = Path(diagnostics_dir) if diagnostics_dir else Path(output_path).with_suffix("")
|
||||
|
|
@ -90,7 +89,6 @@ def run_compare(
|
|||
steps=steps,
|
||||
device=device,
|
||||
strip_background=strip_background,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
)
|
||||
source = result.source_rgba
|
||||
|
|
|
|||
|
|
@ -28,19 +28,6 @@ class SolverHyperParams:
|
|||
phase_rerank_confidence_threshold: float = 0.12
|
||||
phase_rerank_max_size_delta_ratio: float = 0.40
|
||||
phase_rerank_margin: float = 0.004
|
||||
tile_graph_max_candidates: int = 768
|
||||
tile_graph_max_candidates_per_coord: int = 2
|
||||
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_min_area_ratio: float = 0.06
|
||||
tile_graph_source_region_window_coverage: float = 0.12
|
||||
tile_graph_area_weight: float = 0.03
|
||||
tile_graph_alpha_weight: float = 0.25
|
||||
tile_graph_coverage_weight: float = 0.05
|
||||
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]:
|
||||
return asdict(self)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
|||
|
||||
import numpy as np
|
||||
|
||||
from .analysis import analyze_phase_field_source, analyze_tile_graph_source
|
||||
from .analysis import analyze_phase_field_source
|
||||
from .diagnostics import (
|
||||
summarize_run,
|
||||
write_alpha_preview,
|
||||
|
|
@ -29,8 +29,7 @@ from .params import SolverHyperParams
|
|||
from .phase_field import optimize_phase_field
|
||||
from .palette import load_palette, quantize_rgba, save_palette_report
|
||||
from .preprocess import strip_edge_background
|
||||
from .tile_graph import optimize_tile_graph
|
||||
from .types import InferenceResult, PhaseFieldSourceAnalysis, RunResult, TileGraphSourceAnalysis
|
||||
from .types import InferenceResult, PhaseFieldSourceAnalysis, RunResult
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
|
|
@ -50,7 +49,6 @@ def run_pipeline(
|
|||
device: str = "auto",
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
strip_background: bool = False,
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> RunResult:
|
||||
started = time.perf_counter()
|
||||
|
|
@ -84,7 +82,6 @@ def run_pipeline(
|
|||
source,
|
||||
seed=seed,
|
||||
device=device,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
)
|
||||
inference = _select_phase_candidate(
|
||||
source,
|
||||
|
|
@ -94,7 +91,6 @@ def run_pipeline(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
)
|
||||
solver, reconstruction_diagnostics = _run_reconstruction(
|
||||
|
|
@ -105,7 +101,6 @@ def run_pipeline(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
)
|
||||
cleanup = cleanup_pixels(solver.target_rgba, source_guidance=solver.guidance_strength)
|
||||
palette = load_palette(palette_path) if palette_path else None
|
||||
|
|
@ -172,7 +167,6 @@ def run_pipeline(
|
|||
"steps": steps,
|
||||
"device": device,
|
||||
"strip_background": strip_background,
|
||||
"reconstruction_mode": reconstruction_mode,
|
||||
"enable_phase_rerank": enable_phase_rerank,
|
||||
"inference_mode": inference_mode,
|
||||
"solver_params": solver_params.to_dict(),
|
||||
|
|
@ -190,10 +184,7 @@ def _prepare_analysis(
|
|||
*,
|
||||
seed: int,
|
||||
device: str,
|
||||
reconstruction_mode: str,
|
||||
) -> PhaseFieldSourceAnalysis | TileGraphSourceAnalysis:
|
||||
if reconstruction_mode == "tile-graph":
|
||||
return analyze_tile_graph_source(source, device=device)
|
||||
) -> PhaseFieldSourceAnalysis:
|
||||
return analyze_phase_field_source(source, seed=seed, device=device)
|
||||
|
||||
|
||||
|
|
@ -206,7 +197,6 @@ def _select_phase_candidate(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> InferenceResult:
|
||||
return _select_phase_candidate_with_reconstruction(
|
||||
|
|
@ -217,7 +207,6 @@ def _select_phase_candidate(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
)
|
||||
|
||||
|
|
@ -231,14 +220,11 @@ def _select_phase_candidate_with_reconstruction(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> InferenceResult:
|
||||
solver_params = solver_params or SolverHyperParams()
|
||||
if not enable_phase_rerank:
|
||||
return inference
|
||||
if reconstruction_mode != "phase-field":
|
||||
return inference
|
||||
if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.phase_rerank_confidence_threshold:
|
||||
return inference
|
||||
|
||||
|
|
@ -262,7 +248,6 @@ def _select_phase_candidate_with_reconstruction(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
reconstruction_mode=reconstruction_mode,
|
||||
)
|
||||
support = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
|
|
@ -433,34 +418,21 @@ def _run_reconstruction(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams,
|
||||
reconstruction_mode: str,
|
||||
):
|
||||
if reconstruction_mode == "tile-graph":
|
||||
return optimize_tile_graph(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
)
|
||||
if reconstruction_mode == "phase-field":
|
||||
solver = optimize_phase_field(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
)
|
||||
phase_field_metrics = getattr(solver, "stage_diagnostics", {}).get("phase_field", {})
|
||||
return solver, {
|
||||
"mode": "phase-field",
|
||||
**{
|
||||
f"phase_field_{key}": value
|
||||
for key, value in phase_field_metrics.items()
|
||||
},
|
||||
}
|
||||
raise ValueError(f"Unknown reconstruction mode: {reconstruction_mode}")
|
||||
solver = optimize_phase_field(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
)
|
||||
phase_field_metrics = getattr(solver, "stage_diagnostics", {}).get("phase_field", {})
|
||||
return solver, {
|
||||
"mode": "phase-field",
|
||||
**{
|
||||
f"phase_field_{key}": value
|
||||
for key, value in phase_field_metrics.items()
|
||||
},
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,14 +32,6 @@ class PhaseFieldSourceAnalysis:
|
|||
edge_map: np.ndarray
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TileGraphSourceAnalysis:
|
||||
edge_map: np.ndarray
|
||||
|
||||
|
||||
SourceAnalysis = PhaseFieldSourceAnalysis | TileGraphSourceAnalysis
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SourceLatticeReference:
|
||||
mean_rgba: np.ndarray
|
||||
|
|
@ -86,7 +78,7 @@ class RunResult:
|
|||
source_rgba: np.ndarray
|
||||
output_rgba: np.ndarray
|
||||
inference: InferenceResult
|
||||
analysis: PhaseFieldSourceAnalysis | TileGraphSourceAnalysis
|
||||
analysis: PhaseFieldSourceAnalysis
|
||||
solver: SolverArtifacts
|
||||
cleanup: CleanupArtifacts
|
||||
palette_result: PaletteResult | None
|
||||
|
|
|
|||
1
tests/fixtures/real/ai-badge-tip-focus.json
vendored
1
tests/fixtures/real/ai-badge-tip-focus.json
vendored
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"description": "Sword-tip focus crop for the pinned phase-field badge run. Use this when testing the widened dark contour / stroke blemish near the tip.",
|
||||
"input": "tests/fixtures/real/ai-badge-cleaned.png",
|
||||
"reconstruction_mode": "phase-field",
|
||||
"target_width": 126,
|
||||
"target_height": 126,
|
||||
"phase_x": 0.0,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import numpy as np
|
||||
|
||||
from repixelizer.analysis import analyze_phase_field_source, analyze_tile_graph_source
|
||||
from repixelizer.analysis import analyze_phase_field_source
|
||||
|
||||
|
||||
def test_phase_field_source_analysis_cpu_device_matches_default_path() -> None:
|
||||
|
|
@ -15,14 +15,3 @@ def test_phase_field_source_analysis_cpu_device_matches_default_path() -> None:
|
|||
|
||||
assert np.allclose(accelerated.edge_map, default.edge_map, atol=1e-5)
|
||||
assert not hasattr(accelerated, "cluster_map")
|
||||
|
||||
|
||||
def test_tile_graph_source_analysis_is_edge_only() -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[2:6, 3:5, :3] = np.asarray([1.0, 0.8, 0.2], dtype=np.float32)
|
||||
|
||||
analysis = analyze_tile_graph_source(source, device="cpu")
|
||||
|
||||
assert analysis.edge_map.shape == source.shape[:2]
|
||||
assert not hasattr(analysis, "cluster_map")
|
||||
|
|
|
|||
|
|
@ -127,41 +127,6 @@ def test_pipeline_can_emit_initialized_output_without_optimizer_steps(tmp_path:
|
|||
assert result.output_rgba.shape[1] == result.inference.target_width
|
||||
|
||||
|
||||
def test_fixed_tile_graph_pipeline_output_matches_refactor_baseline(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=8,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
blur_radius=0.4,
|
||||
warp_strength=0.15,
|
||||
warp_detail=4,
|
||||
seed=9,
|
||||
)
|
||||
input_path = tmp_path / "tile-baseline-input.png"
|
||||
output_path = tmp_path / "tile-baseline-output.png"
|
||||
|
||||
from repixelizer.io import save_rgba
|
||||
|
||||
save_rgba(input_path, fake)
|
||||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
reconstruction_mode="tile-graph",
|
||||
target_width=24,
|
||||
target_height=24,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
enable_phase_rerank=False,
|
||||
steps=0,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
output_hash = hashlib.sha256(np.ascontiguousarray(result.output_rgba).view(np.uint8)).hexdigest()
|
||||
assert output_hash == "1f6d497a65953ab60a37fce98e69b101955e5f0e5a06a1e32167c6b8582a23f6"
|
||||
|
||||
|
||||
def test_fixed_phase_field_pipeline_output_matches_refactor_baseline(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(
|
||||
|
|
@ -183,7 +148,6 @@ def test_fixed_phase_field_pipeline_output_matches_refactor_baseline(tmp_path: P
|
|||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
reconstruction_mode="phase-field",
|
||||
target_width=24,
|
||||
target_height=24,
|
||||
phase_x=0.1,
|
||||
|
|
@ -354,113 +318,6 @@ def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None:
|
|||
assert selected.target_height == candidate_a.target_height
|
||||
|
||||
|
||||
def test_select_phase_candidate_skips_phase_rerank_for_tile_graph(monkeypatch) -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=8, target_height=8, phase_x=0.0, phase_y=0.0, score=0.9, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=10, target_height=10, phase_x=0.2, phase_y=0.2, score=0.89, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=8,
|
||||
target_height=8,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise AssertionError("tile-graph phase rerank probe should not run")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_tile_graph", fail)
|
||||
selected = _select_phase_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
steps=32,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
reconstruction_mode="tile-graph",
|
||||
)
|
||||
assert selected.target_width == candidate_a.target_width
|
||||
assert selected.target_height == candidate_a.target_height
|
||||
|
||||
|
||||
def test_pipeline_runs_tile_graph_once_without_phase_probe_reuse(tmp_path: Path, monkeypatch) -> None:
|
||||
source = np.zeros((6, 6, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
|
||||
from repixelizer.io import save_rgba
|
||||
|
||||
save_rgba(input_path, source)
|
||||
|
||||
candidate_a = InferenceCandidate(target_width=3, target_height=3, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=4, target_height=4, phase_x=0.2, phase_y=-0.2, score=0.90, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=3,
|
||||
target_height=3,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
||||
class DummyAnalysis:
|
||||
edge_map = np.zeros((6, 6), dtype=np.float32)
|
||||
|
||||
call_count = {"tile_graph": 0}
|
||||
|
||||
def fake_optimize_tile_graph(
|
||||
source_rgba,
|
||||
inference,
|
||||
analysis,
|
||||
steps,
|
||||
seed,
|
||||
device,
|
||||
solver_params=None,
|
||||
):
|
||||
del source_rgba, analysis, steps, seed, device, solver_params
|
||||
call_count["tile_graph"] += 1
|
||||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
rgba[..., 3] = 1.0
|
||||
rgba[..., 0] = 0.2 if inference.target_width == 3 else 0.8
|
||||
return (
|
||||
SolverArtifacts(
|
||||
target_rgba=rgba,
|
||||
uv_field=np.zeros((inference.target_height, inference.target_width, 2), dtype=np.float32),
|
||||
guidance_strength=np.zeros((inference.target_height, inference.target_width), dtype=np.float32),
|
||||
initial_rgba=rgba.copy(),
|
||||
loss_history=[],
|
||||
),
|
||||
{"mode": "tile-graph", "tile_graph_candidate_count": 1},
|
||||
)
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
del source_rgba, output_rgba, target_height, phase_x, phase_y
|
||||
return {"score": 0.05 if target_width == 3 else 0.15}
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.infer_lattice", lambda *args, **kwargs: inference)
|
||||
monkeypatch.setattr("repixelizer.pipeline.analyze_tile_graph_source", lambda *args, **kwargs: DummyAnalysis())
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_tile_graph", fake_optimize_tile_graph)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_position_error", lambda *args, **kwargs: 0.1)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", lambda *args, **kwargs: 0.5)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", lambda *args, **kwargs: 0.2)
|
||||
|
||||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
reconstruction_mode="tile-graph",
|
||||
steps=200,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
assert output_path.exists()
|
||||
assert call_count["tile_graph"] == 1
|
||||
assert "reused_phase_probe_reconstruction" not in result.diagnostics["reconstruction"]
|
||||
assert result.output_rgba.shape[:2] == (3, 3)
|
||||
|
||||
|
||||
def test_pipeline_runs_phase_field_mode(tmp_path: Path, monkeypatch) -> None:
|
||||
source = np.zeros((6, 6, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
|
|
@ -507,7 +364,6 @@ def test_pipeline_runs_phase_field_mode(tmp_path: Path, monkeypatch) -> None:
|
|||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
reconstruction_mode="phase-field",
|
||||
steps=8,
|
||||
device="cpu",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,537 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from repixelizer.analysis import analyze_tile_graph_source
|
||||
from repixelizer.baselines import naive_resize_baseline
|
||||
from repixelizer.io import save_rgba
|
||||
from repixelizer.metrics import source_lattice_consistency_breakdown
|
||||
from repixelizer.params import SolverHyperParams
|
||||
from repixelizer.pipeline import run_pipeline
|
||||
from repixelizer.synthetic import fake_pixelize, make_emblem
|
||||
from repixelizer.tile_graph import (
|
||||
_extract_source_region_tiles,
|
||||
_segment_atomic_source_regions,
|
||||
build_tile_graph_model,
|
||||
clear_tile_graph_model_cache,
|
||||
optimize_tile_graph,
|
||||
)
|
||||
from repixelizer.types import InferenceResult
|
||||
|
||||
|
||||
def test_tile_graph_model_extracts_candidates_and_adjacency_from_lattice_proposals() -> None:
|
||||
lowres = np.zeros((4, 4, 4), dtype=np.float32)
|
||||
lowres[..., 3] = 1.0
|
||||
lowres[:, 1] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
|
||||
source = np.repeat(np.repeat(lowres, 8, axis=0), 8, axis=1)
|
||||
inference = InferenceResult(
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
||||
model, stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source, device="cpu"),
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
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)
|
||||
assert stats.average_choices >= 1.0
|
||||
assert stats.edge_density > 0.0
|
||||
assert stats.model_device == "cpu"
|
||||
|
||||
|
||||
def test_tile_graph_model_works_with_edge_only_analysis() -> None:
|
||||
lowres = np.zeros((4, 4, 4), dtype=np.float32)
|
||||
lowres[..., 3] = 1.0
|
||||
lowres[1:3, 1:3] = np.asarray([1.0, 0.7, 0.2, 1.0], dtype=np.float32)
|
||||
source = np.repeat(np.repeat(lowres, 8, axis=0), 8, axis=1)
|
||||
inference = InferenceResult(
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
||||
model, stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source, device="cpu"),
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
assert model.candidate_rgba.shape[0] > 0
|
||||
assert stats.model_device == "cpu"
|
||||
|
||||
|
||||
def test_tile_graph_candidates_use_literal_source_pixel_colors() -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
palette = (
|
||||
np.asarray([1.0, 0.1, 0.1, 1.0], dtype=np.float32),
|
||||
np.asarray([0.1, 1.0, 0.1, 1.0], dtype=np.float32),
|
||||
np.asarray([0.1, 0.1, 1.0, 1.0], dtype=np.float32),
|
||||
np.asarray([1.0, 1.0, 0.1, 1.0], dtype=np.float32),
|
||||
)
|
||||
for y in range(source.shape[0]):
|
||||
for x in range(source.shape[1]):
|
||||
source[y, x] = palette[(2 * (y % 2)) + (x % 2)]
|
||||
|
||||
inference = InferenceResult(
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
model, _stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source),
|
||||
solver_params=SolverHyperParams(tile_graph_max_candidates=32, tile_graph_max_candidates_per_coord=2),
|
||||
)
|
||||
|
||||
source_colors = {tuple(np.round(color, 4)) for color in source.reshape(-1, 4)}
|
||||
candidate_colors = {tuple(np.round(color, 4)) for color in model.candidate_rgba}
|
||||
|
||||
assert candidate_colors.issubset(source_colors | {tuple(np.zeros(4, dtype=np.float32))})
|
||||
|
||||
|
||||
def test_tile_graph_candidate_buckets_are_contiguous_per_output_coord() -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[:, 2:4] = np.asarray([1.0, 0.9, 0.2, 1.0], dtype=np.float32)
|
||||
source[2:6, 5:7] = np.asarray([0.2, 0.8, 1.0, 1.0], dtype=np.float32)
|
||||
inference = InferenceResult(
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
||||
model, _stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source),
|
||||
)
|
||||
|
||||
total_candidates = int(model.candidate_rgba.shape[0])
|
||||
assert int(model.cell_candidate_offsets[0]) == 0
|
||||
assert int(model.cell_candidate_offsets[-1]) == total_candidates
|
||||
assert np.array_equal(model.cell_candidate_indices, np.arange(total_candidates, dtype=np.int32))
|
||||
|
||||
|
||||
def test_tile_graph_region_extraction_covers_every_occupied_output_cell() -> None:
|
||||
source = np.zeros((64, 64, 4), dtype=np.float32)
|
||||
white = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
|
||||
source[6:10, 6:58] = white
|
||||
source[14:18, 6:50] = white
|
||||
source[22:26, 14:58] = white
|
||||
source[30:34, 6:50] = white
|
||||
source[38:42, 14:58] = white
|
||||
source[46:50, 6:58] = white
|
||||
source[6:50, 6:10] = white
|
||||
source[14:58, 54:58] = white
|
||||
|
||||
inference = InferenceResult(
|
||||
target_width=8,
|
||||
target_height=8,
|
||||
phase_x=0.0,
|
||||
phase_y=-0.2,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
params = SolverHyperParams()
|
||||
analysis = analyze_tile_graph_source(source, device="cpu")
|
||||
cell_h = source.shape[0] / inference.target_height
|
||||
cell_w = source.shape[1] / inference.target_width
|
||||
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=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,
|
||||
device="cpu",
|
||||
)
|
||||
region_buckets = _extract_source_region_tiles(
|
||||
labeling=components,
|
||||
flat_rgba=flat_rgba,
|
||||
flat_edge=flat_edge,
|
||||
flat_x=flat_x,
|
||||
flat_y=flat_y,
|
||||
cell_w=cell_w,
|
||||
cell_h=cell_h,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
min_region_area_ratio=params.tile_graph_source_region_min_area_ratio,
|
||||
min_window_coverage=params.tile_graph_source_region_window_coverage,
|
||||
)
|
||||
|
||||
opaque = flat_rgba[:, 3] >= params.alpha_transparent_threshold
|
||||
coord_x = np.clip(
|
||||
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((flat_y.astype(np.float32) + 0.5) / cell_h - inference.phase_y).astype(np.int32),
|
||||
0,
|
||||
inference.target_height - 1,
|
||||
)
|
||||
direct_indices = coord_y * inference.target_width + coord_x
|
||||
occupied_direct = np.bincount(
|
||||
direct_indices[opaque],
|
||||
minlength=inference.target_width * inference.target_height,
|
||||
)
|
||||
occupied_bucket = np.asarray([len(bucket) for bucket in region_buckets], dtype=np.int32)
|
||||
|
||||
assert np.all(occupied_bucket[occupied_direct > 0] > 0)
|
||||
|
||||
|
||||
def test_tile_graph_projects_component_overlaps_directly_onto_output_cells() -> None:
|
||||
source = np.zeros((6, 12, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
red = np.asarray([1.0, 0.0, 0.0, 1.0], dtype=np.float32)
|
||||
blue = np.asarray([0.0, 0.0, 1.0, 1.0], dtype=np.float32)
|
||||
source[:, :6] = red
|
||||
source[:, 6:] = blue
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=1,
|
||||
phase_x=0.5,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
||||
model, _stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source),
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
left_start = int(model.cell_candidate_offsets[0])
|
||||
left_end = int(model.cell_candidate_offsets[1])
|
||||
right_start = int(model.cell_candidate_offsets[1])
|
||||
right_end = int(model.cell_candidate_offsets[2])
|
||||
left_candidates = {
|
||||
tuple(np.round(color, 4))
|
||||
for color in model.candidate_rgba[model.cell_candidate_indices[left_start:left_end]]
|
||||
}
|
||||
right_candidates = {
|
||||
tuple(np.round(color, 4))
|
||||
for color in model.candidate_rgba[model.cell_candidate_indices[right_start:right_end]]
|
||||
}
|
||||
|
||||
assert tuple(np.round(red, 4)) in left_candidates
|
||||
assert tuple(np.round(blue, 4)) in right_candidates
|
||||
assert left_candidates.issubset({tuple(np.round(red, 4)), tuple(np.round(blue, 4))})
|
||||
assert right_candidates.issubset({tuple(np.round(red, 4)), tuple(np.round(blue, 4))})
|
||||
|
||||
|
||||
def test_tile_graph_edge_cells_include_multiple_same_cell_contour_colors() -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[:4, 0] = np.asarray([1.0, 0.9, 0.2, 1.0], dtype=np.float32)
|
||||
source[:4, 1] = np.asarray([0.85, 0.6, 0.1, 1.0], dtype=np.float32)
|
||||
source[:4, 2] = np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
|
||||
source[:4, 3] = np.asarray([0.9, 0.2, 0.1, 1.0], dtype=np.float32)
|
||||
source[:4, 4:] = np.asarray([0.2, 0.8, 0.2, 1.0], dtype=np.float32)
|
||||
source[4:, :] = np.asarray([0.2, 0.2, 0.8, 1.0], dtype=np.float32)
|
||||
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
model, _stats = build_tile_graph_model(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(source),
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
start = int(model.cell_candidate_offsets[0])
|
||||
end = int(model.cell_candidate_offsets[1])
|
||||
candidates = {
|
||||
tuple(np.round(color, 4))
|
||||
for color in model.candidate_rgba[model.cell_candidate_indices[start:end]]
|
||||
}
|
||||
non_black_candidates = {color for color in candidates if color[:3] != (np.float32(0.0), np.float32(0.0), np.float32(0.0))}
|
||||
|
||||
assert len(candidates) >= 3
|
||||
assert tuple(np.round(np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32), 4)) in candidates
|
||||
assert len(non_black_candidates) >= 2
|
||||
|
||||
|
||||
def test_tile_graph_reconstructs_synthetic_thin_feature_better_than_naive() -> None:
|
||||
source = np.zeros((16, 16, 4), dtype=np.float32)
|
||||
source[4:12, 8] = np.asarray([0.95, 0.95, 0.95, 1.0], dtype=np.float32)
|
||||
source[4, 7:10] = np.asarray([0.95, 0.95, 0.95, 1.0], dtype=np.float32)
|
||||
source[11, 7] = np.asarray([0.95, 0.95, 0.95, 1.0], dtype=np.float32)
|
||||
source[10, 6] = np.asarray([0.95, 0.95, 0.95, 1.0], dtype=np.float32)
|
||||
source[9, 5] = np.asarray([0.95, 0.95, 0.95, 1.0], dtype=np.float32)
|
||||
|
||||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=9,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
blur_radius=0.45,
|
||||
warp_strength=0.2,
|
||||
warp_detail=5,
|
||||
seed=11,
|
||||
)
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
artifacts, _ = optimize_tile_graph(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=analyze_tile_graph_source(fake),
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
naive = naive_resize_baseline(fake, width=16, height=16)
|
||||
|
||||
tile_score = source_lattice_consistency_breakdown(
|
||||
fake,
|
||||
artifacts.target_rgba,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
)["score"]
|
||||
initial_tile_score = source_lattice_consistency_breakdown(
|
||||
fake,
|
||||
artifacts.initial_rgba,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
)["score"]
|
||||
naive_score = source_lattice_consistency_breakdown(
|
||||
fake,
|
||||
naive,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
)["score"]
|
||||
|
||||
assert tile_score <= initial_tile_score + 1e-6
|
||||
assert tile_score <= naive_score
|
||||
|
||||
|
||||
def test_tile_graph_keeps_shallow_connected_stroke_from_fanning_out() -> None:
|
||||
source = np.zeros((48, 48, 4), dtype=np.float32)
|
||||
for y in range(48):
|
||||
x = int(round(8 + y * 0.65))
|
||||
source[max(0, y - 1) : min(48, y + 2), max(0, x - 1) : min(48, x + 2)] = np.asarray(
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
artifacts, _ = optimize_tile_graph(
|
||||
source,
|
||||
inference=InferenceResult(
|
||||
target_width=6,
|
||||
target_height=6,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
),
|
||||
analysis=analyze_tile_graph_source(source),
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
occupied_per_row = [
|
||||
int(np.count_nonzero(artifacts.target_rgba[y, :, 3] > 0.5))
|
||||
for y in range(artifacts.target_rgba.shape[0])
|
||||
]
|
||||
|
||||
assert sum(count > 1 for count in occupied_per_row) <= 1
|
||||
assert sum(occupied_per_row) <= artifacts.target_rgba.shape[0] + 1
|
||||
|
||||
|
||||
def test_tile_graph_preserves_transparent_background_on_sparse_detail() -> None:
|
||||
source = np.zeros((12, 12, 4), dtype=np.float32)
|
||||
source[5:7, 5:7] = np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
|
||||
|
||||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=8,
|
||||
phase_x=0.12,
|
||||
phase_y=-0.08,
|
||||
blur_radius=0.35,
|
||||
warp_strength=0.1,
|
||||
warp_detail=4,
|
||||
seed=5,
|
||||
)
|
||||
artifacts, _ = optimize_tile_graph(
|
||||
fake,
|
||||
inference=InferenceResult(
|
||||
target_width=12,
|
||||
target_height=12,
|
||||
phase_x=0.12,
|
||||
phase_y=-0.08,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
),
|
||||
analysis=analyze_tile_graph_source(fake),
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
alpha_zero_ratio = float((artifacts.target_rgba[..., 3] <= 0.05).mean())
|
||||
|
||||
assert alpha_zero_ratio >= 0.75
|
||||
|
||||
|
||||
def test_tile_graph_cuda_matches_cpu_on_small_case() -> None:
|
||||
torch = pytest.importorskip("torch")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA is not available in this environment")
|
||||
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.1, phase_y=-0.05, blur_radius=0.4, warp_strength=0.15, warp_detail=4)
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
cpu_analysis = analyze_tile_graph_source(fake, device="cpu")
|
||||
cuda_analysis = analyze_tile_graph_source(fake, device="cuda")
|
||||
|
||||
cpu_artifacts, cpu_diag = optimize_tile_graph(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=cpu_analysis,
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
cuda_artifacts, cuda_diag = optimize_tile_graph(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=cuda_analysis,
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cuda",
|
||||
)
|
||||
|
||||
assert cpu_diag["tile_graph_model_device"] == "cpu"
|
||||
assert cpu_diag["tile_graph_solver_device"] == "cpu"
|
||||
assert cuda_diag["tile_graph_model_device"] == "cuda"
|
||||
assert cuda_diag["tile_graph_solver_device"] == "cuda"
|
||||
assert np.allclose(cpu_artifacts.target_rgba, cuda_artifacts.target_rgba, atol=1e-5)
|
||||
|
||||
|
||||
def test_tile_graph_model_cache_reuses_fixed_lattice_build() -> None:
|
||||
clear_tile_graph_model_cache()
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.1, phase_y=-0.05, blur_radius=0.4, warp_strength=0.15, warp_detail=4)
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
analysis = analyze_tile_graph_source(fake, device="cpu")
|
||||
|
||||
first, first_stats = build_tile_graph_model(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
device="cpu",
|
||||
)
|
||||
second, second_stats = build_tile_graph_model(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
assert first_stats.cache_hit is False
|
||||
assert second_stats.cache_hit is True
|
||||
assert np.array_equal(first.candidate_rgba, second.candidate_rgba)
|
||||
|
||||
|
||||
def test_pipeline_tile_graph_mode_writes_reconstruction_diagnostics(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.15, phase_y=0.2, blur_radius=0.6, warp_strength=0.2, warp_detail=5)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "nested" / "output.png"
|
||||
diagnostics_dir = tmp_path / "diagnostics"
|
||||
|
||||
save_rgba(input_path, fake)
|
||||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
diagnostics_dir=diagnostics_dir,
|
||||
steps=24,
|
||||
reconstruction_mode="tile-graph",
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
import json
|
||||
|
||||
run_json = json.loads((diagnostics_dir / "run.json").read_text(encoding="utf-8"))
|
||||
assert output_path.exists()
|
||||
assert run_json["settings"]["reconstruction_mode"] == "tile-graph"
|
||||
assert run_json["reconstruction"]["mode"] == "tile-graph"
|
||||
assert run_json["reconstruction"]["tile_graph_model_device"] == "cpu"
|
||||
assert "tile_graph_model_cache_hit" in run_json["reconstruction"]
|
||||
assert run_json["reconstruction"]["tile_graph_candidate_count"] > 0
|
||||
assert set(run_json["source_fidelity"].keys()) == {"initial_output", "solver_target", "final_output"}
|
||||
assert result.diagnostics["reconstruction"]["mode"] == "tile-graph"
|
||||
Loading…
Reference in a new issue