Remove legacy continuous optimizer
This commit is contained in:
parent
d9fa411daf
commit
0808271d05
19 changed files with 360 additions and 3434 deletions
79
README.md
79
README.md
|
|
@ -18,60 +18,34 @@ That is the two-headed pitch in one image: force non-pixel art onto a coherent g
|
|||
|
||||
## Current Status
|
||||
|
||||
This is already a serious experimental baseline, not a toy prototype:
|
||||
This repo is past the "pile of hopeful heuristics" stage and into "real machine, still experimental."
|
||||
|
||||
- CUDA-backed inference and solver paths are wired up
|
||||
- a shared source-lattice reference now drives solver, metrics, and reranking
|
||||
- round-trip corpus benchmarking is built in
|
||||
- soft and crisp corruption profiles are supported
|
||||
- diagnostics and baseline comparisons are written automatically, including per-stage source-fidelity in `run.json`
|
||||
- a black-box tuning harness exists for longer offline parameter sweeps
|
||||
What exists now:
|
||||
|
||||
The current pipeline is especially strong on benchmarked sprite and emblem inputs where local fake-pixel texture is meaningful but the global grid is inconsistent.
|
||||
- lattice inference with CUDA support
|
||||
- a lean displacement-field optimizer in `src/repixelizer/phase_field.py`
|
||||
- a source-owned alternate solver in `--reconstruction-mode tile-graph`
|
||||
- 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
|
||||
|
||||
Recent adjacency-focused status:
|
||||
What changed recently:
|
||||
|
||||
- the cleaned AI badge regression now beats naive resize on the repo's source-lattice consistency metric under the selected lattice
|
||||
- the snap-to-refine handoff is now source-first rather than dominated by the softened representative lattice
|
||||
- low-confidence phase reranking now uses a soft size penalty instead of a hard size-jump reject
|
||||
- an experimental `tile-graph` reconstruction mode now exists behind `--reconstruction-mode tile-graph`
|
||||
- the old tray-based optimizer is gone
|
||||
- `phase-field` is now the default optimizer path
|
||||
- `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 tile-graph status:
|
||||
Current read on the engines:
|
||||
|
||||
- it now keeps candidates strictly local to each output coord and only uses literal source-pixel colors, so it no longer invents averaged patch colors or borrow labels from distant regions
|
||||
- tile-graph prep is now an edge-only path with no cluster analysis, no hybrid geometry sidecar, and no tile-graph-specific source-reference portrait
|
||||
- extraction now runs at full source resolution; the old `source_region_stride` subsampling path is gone
|
||||
- candidate selection is now source-owned: extracted tiles are ranked by their own area, coverage, and edge signal instead of by resemblance to `sharp_rgba` / `edge_rgba`
|
||||
- occupied foreground cells no longer get papered over with injected `sharp_pixel` or `edge_pixel` fallbacks; if extraction cannot produce a real tile there, the build fails instead of lying
|
||||
- the adjacency term is now learned from extracted tiles themselves, not from sampled one-cell-away RGBA deltas
|
||||
- tile extraction no longer walks components with seed queues and one-cell windows; it now directly reduces `(component_id, output_cell)` overlaps into candidate shards
|
||||
- the deep-dive map in `docs/tile-graph-algorithm-map.md` now reflects the living machine directly instead of documenting the dead portrait/stride/delta path
|
||||
- the extraction stage still has an explicit empty-cell overlap fill pass, so output cells that actually contain opaque source pixels do not silently lose their extracted region bucket
|
||||
- the latest full-emblem probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity, beating the earlier full-CUDA tile-graph baseline at `0.0283`
|
||||
- tile-graph now falls back to its initial assignment when the propagation loop would make source-lattice fidelity worse, which is currently important for preserving sharp internal contour cells
|
||||
- it still beats naive resize on the repo's synthetic thin-feature regression and now does so without letting the final propagation step blur past the initial placement
|
||||
- source-region connected-components now have a device-side Torch path, so the expensive labeling step no longer depends on Python flood fill
|
||||
- profiling the older selected badge candidate showed the main bottleneck was model construction rather than the solver loop: `build_tile_graph_model(...)` took about `142.3s`, and about `131.2s` of that was `_extract_source_region_tiles(...)`
|
||||
- after the first pruning pass, tile-graph no longer spends iteration time on pipeline rerank probes at all; it now runs the chosen or pinned lattice directly
|
||||
- tile-graph now also skips cluster analysis entirely and no longer uses sharp/edge portrait anchors in unary scoring; the path keeps only edge scouting plus direct per-cell source summaries
|
||||
- after the reduce-by-key rewrite, the large-fixture bottleneck moved: extraction is much cheaper, and the main cold-build cost is now connected-component labeling / label compression rather than per-component window cutting
|
||||
- the pipeline now has a direct-control path for iteration: `--target-width` / `--target-height` plus optional `--phase-x` / `--phase-y` let you run an exact lattice without paying the full lattice search, and `--skip-phase-rerank` lets you keep the pipeline from second-guessing that choice
|
||||
- repeated fixed-lattice `tile-graph` runs in the same Python process now reuse the expensive model build; on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the first CUDA run took about `10.2s` and the second cached rerun took about `2.1s`
|
||||
- the latest reduce-by-key pinned badge probe under `artifacts/tile-graph-reducebykey-v1-badge-126/` finishes in about `16.1s` cold and `6.0s` warm on CUDA, versus the previous full-resolution cut at about `96.9s` cold and `5.4s` warm
|
||||
- that speed pass is a real throughput win but a small quality regression: the same pinned badge case moves from `0.1814` to `0.2036` source-fidelity
|
||||
- the optimizer now has its own deep-dive map in `docs/optimizer-algorithm-map.md`, and the first cut from that map removed the old UV-optimizer name plus the continuous k-means cluster-boundary scout; the continuous path is now a fixed-lattice, source-first discrete chooser guided by real edge evidence
|
||||
- optimizer preparation is now split into a private `_OptimizerPrep` bundle, so the continuous path has a visible seam between "build the map" and "choose the pixels"
|
||||
- refine is now source-first too: the representative portrait still stabilizes snap, but refine and final structure choice no longer consult it, and the pinned badge smoke improved slightly from `0.07535` to `0.07485`
|
||||
- the next relax cut also paid off: removing the old relax-mode bonus while keeping the core relax stage improved the same pinned badge smoke again from `0.07485` to `0.07451`
|
||||
- the optimizer now emits stage displacement diagnostics too, so `run.json` can show what snap, relax handoff, relax mode, and final refine actually did to the per-cell source-pixel displacement field instead of just reporting a single fidelity score
|
||||
- those new diagnostics also settled the design argument about relax: on the pinned badge check under `artifacts/optimizer-relax-purpose-check/`, relax improves source-fidelity a bit (`0.07549` without relax to `0.07451` with it), but it does not act like a strong broad-swatch phase smoother; the displacement field stays roughly as jittery, so the current relax stage is better described as a soft consensus / handoff stabilizer over fixed local candidates
|
||||
- that diagnosis finally forced the obvious architectural verdict: the current optimizer is still a tray-based discrete chooser in a fake moustache, so the repo now also carries `docs/lean-optimizer-algorithm-map.md`, a replacement target for a real displacement-field optimizer with one field, one objective, and one final sample
|
||||
- the first version of that replacement now exists behind `--reconstruction-mode phase-field`; it is a deliberately lean displacement-field solver in `src/repixelizer/phase_field.py`, and the first pinned badge smoke under `artifacts/phase-field-v1-badge-126/` lands at `0.1461`, which is not competitive yet but at least comes from a machine that is no longer lying about what it is
|
||||
- that first phase-field run also exposed that the old `source_fidelity` metric is too loyal to the lattice accountant's mean/medoid portrait; `run.json` and compare-mode outputs now include a new `source_structure` breakdown that tracks source-size structure instead: edge support F1, stroke wobble, foreground reconstruction, and exact-match ratio
|
||||
- after the first pruning pass, tile-graph no longer participates in pipeline phase-rerank probes and no longer carries hybrid geometry priors through its unary cost; the path is now one lattice-conditioned candidate generator plus one local discrete solver
|
||||
- this fixes the core design mismatch that had allowed repeated distant labels to create big same-color patches and opaque black background blocks
|
||||
- on the current `24x24` emblem smoke case, an end-to-end `tile-graph` run dropped from about `2.57s` on CPU to `0.61s` on CUDA on this machine
|
||||
- the older hard-edge-only candidate widening pass under `artifacts/full-emblem-tile-graph-hard-edge-v2-cuda/` remains a useful negative result: more edge choices alone sharpen some cells locally but still regress full-emblem source-fidelity (`0.0377`)
|
||||
- `phase-field` is the main line now. It is the best-looking badge result the repo has produced so far, especially on internal structure, even though it still widens the sword-tip stroke a bit too much in the tracked focus crop.
|
||||
- `tile-graph` is still useful and still very real, but it remains slower and more temperamental on messy badge-scale inputs. Its best full-emblem probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` is still a strong result.
|
||||
|
||||
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
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
|
@ -149,15 +123,14 @@ That keeps benchmark assets, attribution exports, and tuning runs from polluting
|
|||
- `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/optimizer-algorithm-map.md`: detailed optimizer dataflow and contradiction map
|
||||
- `docs/lean-optimizer-algorithm-map.md`: replacement target for the stripped-down displacement-field optimizer
|
||||
- `docs/lean-optimizer-algorithm-map.md`: map of the live displacement-field optimizer
|
||||
- `examples/corpus/README.md`: local corpus layout and attribution workflow
|
||||
|
||||
Core modules:
|
||||
|
||||
- `inference.py`: target size and phase inference
|
||||
- `continuous.py`: source-aware lattice snapping and local discrete refinement
|
||||
- `phase_field.py`: experimental lean displacement-field optimizer
|
||||
- `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
|
||||
|
|
@ -192,7 +165,7 @@ For docs or regression notes, you can generate reproducible source/output closeu
|
|||
If you want the full internal state sheet instead of the docs-facing comparison:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python scripts\render_focus_crop.py --input tests\fixtures\real\ai-badge-cleaned.png --out docs\readme-assets\guard-right-crop-states.png --cell-bbox 73 20 107 44 --panels source,snap,relaxed,final --scale 16 --steps 48 --device cpu
|
||||
.venv\Scripts\python scripts\render_focus_crop.py --input tests\fixtures\real\ai-badge-cleaned.png --out docs\readme-assets\guard-right-crop-states.png --cell-bbox 73 20 107 44 --panels source,initial,final --scale 16 --steps 48 --device cpu
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
|
|
|||
|
|
@ -1,336 +1,119 @@
|
|||
# Repixelizer Implementation Plan
|
||||
|
||||
## Current state
|
||||
|
||||
The project is bootstrapped and runnable as a standalone Python repo.
|
||||
|
||||
Implemented:
|
||||
- package metadata and editable install
|
||||
- CLI entrypoint with `run` and `compare` modes
|
||||
- lattice/phase inference with coherence scoring plus source-period prior
|
||||
- source-image structural analysis, with optional Torch/CUDA paths
|
||||
- PyTorch-based continuous UV-field optimization
|
||||
- a shared source-lattice reference built from actual inferred cell assignments
|
||||
- source-first snap/refine scoring with explicit source-vs-representative weights
|
||||
- low-confidence phase reranking with soft size penalties instead of hard size-jump rejection
|
||||
- an experimental `tile-graph` reconstruction path with coord-local literal source-pixel candidates
|
||||
- stage-aware diagnostics writing, including per-stage source-fidelity and rerank traces
|
||||
- synthetic test fixtures and automated tests
|
||||
|
||||
Verified:
|
||||
- local editable install in a dedicated venv
|
||||
- full test suite currently passing before the current speed/refactor pass (`69 passed` once the new grouped-extraction path landed)
|
||||
- compare-mode smoke run against a real emblem image
|
||||
- the cleaned real badge fixture in `tests/fixtures/real/ai-badge-cleaned.png` still beats naive resize on source-lattice consistency on the continuous path
|
||||
- the latest full-emblem tile-graph atomic probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity, beating the older full-emblem tile-graph CUDA baseline at `0.0283`
|
||||
- the new algorithm map in `docs/tile-graph-algorithm-map.md` confirms that the pinned `126x126` tile-graph badge collapse is already present in the tile-graph initial assignment; the fixed-lattice pipeline path itself is reproducing that bad state faithfully rather than introducing it, and the newer extraction coverage fix now guarantees that occupied output cells are not silently losing their source-region bucket under the corrected full-size lattice mapping
|
||||
- the new architectural split kept the fixed synthetic emblem baselines byte-identical on both engines at the moment it landed: the pinned tile-graph output hashed to `31e3bc2c...6ae9` and the pinned continuous output hashed to `404748af...7903`
|
||||
|
||||
## Status after adjacency-first pass
|
||||
|
||||
What changed:
|
||||
- `src/repixelizer/source_reference.py` now owns the lattice-indexed source reference used by metrics, the solver, and phase reranking
|
||||
- snap and refine prefer sharp per-cell source evidence and can keep the relaxed-mode neighborhood in the final discrete choice
|
||||
- `run.json` now includes `source_fidelity` for `snap_initial`, `solver_target`, and `final_output`, plus `phase_rerank_candidates`
|
||||
- `scripts/render_focus_crop.py` prints stage-level source-fidelity when generating the guard/tip crop sheet
|
||||
|
||||
What that fixed:
|
||||
- the cleaned badge regression no longer loses badly to naive resize once the continuous solver starts from the selected lattice
|
||||
- the continuous solver no longer regresses relative to its own snap stage on the added thin-feature regression case
|
||||
|
||||
What is still provisional:
|
||||
- the cleaned badge still lands on the conservative `126x126` lattice candidate with low confidence, so lattice selection is improved but not "done"
|
||||
- tuning has not been rerun against the newer source-first weights
|
||||
- the baked-checkerboard badge background remains a separate background-suppression problem
|
||||
|
||||
## What is still provisional
|
||||
|
||||
The current implementation should still be treated as a strong experimental baseline, not a final algorithm.
|
||||
|
||||
The most provisional areas are:
|
||||
- lattice inference on real messy generated imagery
|
||||
- edge-cell reliability and candidate coverage in the continuous stage
|
||||
- the experimental tile-graph reconstruction path, especially on real noisy inputs
|
||||
- low-confidence reranking once multiple plausible lattice sizes survive the soft penalty
|
||||
- compare-mode metrics as a proxy for actual visual quality
|
||||
|
||||
## Near-term priorities
|
||||
|
||||
### Active pass
|
||||
|
||||
This pass is the first serious speed pass after the algorithm cleanup: replace the expensive per-component window-walk extractor with a grouped `(component_id, output_cell)` overlap reducer and then measure the tradeoff honestly.
|
||||
|
||||
What landed:
|
||||
- `tile-graph` no longer participates in pipeline phase rerank probes; rerank is now a continuous-only wrapper
|
||||
- the experimental `hybrid` path and its geometry-prior wiring have been removed
|
||||
- `tile_graph.py` no longer carries geometry-prior fields through `TileGraphModel` or tile-graph unary scoring
|
||||
- `tile-graph` now skips source clustering entirely and uses only edge analysis plus direct per-cell source summaries during unary scoring
|
||||
- `analysis.py` now has separate prep paths, but both are edge-only after the first optimizer cut; the continuous k-means cluster scout was removed because its only production job was turning coarse color partitions into fake edge guidance
|
||||
- `source_reference.py` was split so tile-graph could have its own lean prep contract, but the next cut removed tile-graph's need for any separate source-reference object at all
|
||||
- `TileGraphModel` is now solver-only state; cache metadata and build diagnostics live in a separate `TileGraphBuildStats` sidecar
|
||||
- `cli.py` now exposes only `continuous` and `tile-graph` as reconstruction engines
|
||||
- `tests/test_pipeline.py` now checks that low-confidence tile-graph runs skip rerank probes instead of rebuilding probe candidates
|
||||
- `tests/test_analysis.py` and `tests/test_tile_graph.py` now cover the new edge-only analysis path
|
||||
- `tests/test_pipeline.py` now locks in fixed continuous and tile-graph output hashes from a pre-refactor baseline so this architectural cut cannot silently drift behavior
|
||||
- `docs/tile-graph-algorithm-map.md` now describes the smaller machine directly rather than documenting the removed hybrid sidecar path
|
||||
- the old per-component queue/seed/window extractor is gone; `_extract_source_region_tiles(...)` now directly reduces `(component_id, output_cell)` overlaps into candidate shards
|
||||
- the old stroke-aware slicing machinery and its tuning knobs are gone along with that extractor
|
||||
- same-component continuation now defines tile-graph adjacency directly, replacing the previous search-heavy neighbor heuristic inside extraction
|
||||
|
||||
Current implementation note:
|
||||
- the earlier profiling result still stands conceptually: on the searched badge path, the solver loop is not the problem
|
||||
- the direct-control path is specifically for dodging those costs during iteration when we already know which lattice we want to inspect
|
||||
- tile-graph now skips pipeline rerank entirely, so iteration no longer rebuilds multiple probe candidates before the final tile-graph solve
|
||||
- on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the new grouped-extraction CUDA path now takes about `16.1s` on the first same-process run and about `6.0s` on the cached rerun, with `tile_graph_model_cache_hit=True` on the second pass
|
||||
- the current deep-dive diagnosis is that the fixed-lattice garbling is not a wrapper bug and not mainly a parity-solver bug; it is born in tile cutting, candidate truncation, local source support, and the first unary argmin assignment
|
||||
- the extraction coverage bug turned out to be real but secondary: the new `_extract_source_region_tiles(...)` overlap fill pass now guarantees that any output cell containing opaque sampled source pixels gets at least one extracted region bucket, but the pinned `126x126` badge output remains numerically unchanged (`0.4998626`), which narrows the remaining failure to candidate ranking and lattice-conditioned reference usage rather than empty region buckets
|
||||
- the latest full-emblem atomic probe under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` lands at `0.0224` source-fidelity with `34278` candidates and about `2.16` average choices per cell
|
||||
- that beats the earlier full-CUDA tile-graph baseline (`0.0283`) and materially improves on the first atomic-only attempt (`0.1571`), which chose atomic regions too eagerly and then let the solver blur them out again
|
||||
- the latest core algorithm cut removes `source_region_stride`, removes portrait-based candidate ranking, removes sharp/edge foreground fallback injection, and replaces sampled-delta pairwise scoring with adjacency learned from extracted tiles
|
||||
- that cut also removes the dedicated `TileGraphSourceReference` entirely: tile-graph now grounds itself in full-resolution extracted tiles plus direct per-cell source summaries (`cell_mean_rgba`, `cell_alpha_mean`, `cell_edge_strength`)
|
||||
- on the cleaned badge at pinned `126x126` / phase `(0.0, -0.2)`, the portrait-free cut first recovered the fixed-lattice run from roughly `0.5055` source-fidelity down to `0.1814`; the new grouped-extraction speed pass regresses a bit to `0.2036` while making the same case roughly `6x` faster cold on CUDA
|
||||
- a fresh CPU profile of the grouped extractor shows the bottleneck has moved: `build_tile_graph_model(...)` now takes about `61.0s` instead of `145.8s`, and `_extract_source_region_tiles(...)` no longer dominates; the new hot spot is `_segment_atomic_source_regions(...)` at about `59.5s`
|
||||
|
||||
Next after that:
|
||||
- split tile-graph iteration into a cached model-build phase and a near-free solve phase so weight tuning does not keep paying the `~131s` source-region cutting bill
|
||||
- extend the current fixed-lattice path so repeated CLI runs can reuse cached model artifacts across processes instead of only within one Python process
|
||||
- replace or accelerate `_segment_atomic_source_regions(...)`, because that is now the cold-build bottleneck rather than extraction
|
||||
- decide whether the continuous path should stay the only global search/rerank engine or whether tile-graph eventually needs its own lighter lattice chooser
|
||||
- evaluate whether the tile-graph propagation loop should become more contour-aware or simply stay more conservative now that the initial assignment is stronger
|
||||
- rerun tuning after the tile-graph objective and candidate sets stabilize
|
||||
- decide whether the tile-graph path should stay an alternate solver or become a candidate generator for the continuous refine stage
|
||||
|
||||
### Optimizer map
|
||||
|
||||
That documentation pass is now in `docs/optimizer-algorithm-map.md`.
|
||||
|
||||
The new map does for `continuous.py` what `docs/tile-graph-algorithm-map.md` does for tile-graph:
|
||||
|
||||
- walks the optimizer from pipeline entry to final output
|
||||
- names the real state variables the machine carries (`uv0_t`, `initial_representative_t`, `source_lattice_reference`, `source_reliability_t`, `snap_t`)
|
||||
- explains the regular UV lattice, representative portrait, source lattice portrait, source-first snap, relaxed handoff, and discrete refine stages in plain language
|
||||
- identifies the main contradictions still living in the optimizer, especially:
|
||||
- the machine maintaining two overlapping portraits of the same lattice and spending much of its complexity mediating between them
|
||||
- adjacency / motif / line structure being expressed repeatedly in several slightly different dialects
|
||||
|
||||
The first optimizer cut landed immediately after that map:
|
||||
|
||||
- `optimize_uv_field(...)` was renamed to `optimize_lattice_pixels(...)`
|
||||
- the unused `_exemplar_colors(...)` helper and its test were removed
|
||||
- continuous analysis stopped building a k-means `cluster_map`
|
||||
- `continuous.py` now uses only real source edges for guidance instead of blending in coarse cluster boundaries
|
||||
- the pinned cleaned-badge continuous smoke run under `artifacts/optimizer-cut-v1-badge-126/` still behaves coherently after the cut: snap scores `0.08100`, final scores `0.07535`, and the generated preview is `diagnostics/output-preview.png`
|
||||
|
||||
The second optimizer cut is structural:
|
||||
|
||||
- `_prepare_optimizer(...)` now owns source tensor creation, UV construction, edge gradients, source lattice reference building, detail reference building, source reliability, source deltas, guidance, and cell geometry
|
||||
- `_source_detail_delta_tensors(...)` computes premultiplied source-detail deltas once instead of repeating the same `premultiply(...)` calls for each axis
|
||||
- `optimize_lattice_pixels(...)` is now an orchestration shell around prep, snap, refine, the source-fidelity guardrail, and artifact packaging
|
||||
- the pinned cleaned-badge continuous smoke run under `artifacts/optimizer-cut-v2-prep-badge-126/` is byte-size identical to the previous preview and preserves the same source-fidelity numbers: snap `0.08100`, final `0.07535`
|
||||
|
||||
Next after that:
|
||||
|
||||
- add a focused ablation runner for pinned badge/emblem cases before cutting more behavior
|
||||
- collapse duplicated adjacency / motif / line voices only after ablation shows which stage owns each idea
|
||||
- put relax on trial now that refine itself is source-first
|
||||
## Current machine
|
||||
|
||||
The third optimizer cut is behavioral:
|
||||
|
||||
- refine no longer scores candidates against the representative portrait
|
||||
- `_structure_score(...)` no longer includes representative-match as a final tiebreaker
|
||||
- the dead refine/structure representative knobs were removed from `SolverHyperParams` and the tuning harness
|
||||
- the pinned cleaned-badge continuous smoke run under `artifacts/optimizer-cut-v3-source-refine-badge-126/` improved slightly: snap stayed `0.08100`, final moved from `0.07535` to `0.07485`
|
||||
|
||||
The fourth optimizer cut trims relax rather than removing it:
|
||||
|
||||
- the old `refine_relaxed_mode_weight` bonus is gone, so greedy refine is no longer biased toward the relaxed-mode selection on every candidate score
|
||||
- the core relax stage is still kept, because the earlier ablation showed that turning relax off entirely made the pinned badge worse
|
||||
- the pinned cleaned-badge continuous smoke run under `artifacts/optimizer-cut-v4-relax-bonus-badge-126/` improved again: snap stayed `0.08100`, final moved from `0.07485` to `0.07451`
|
||||
|
||||
Next after that:
|
||||
|
||||
- keep putting relax on trial, but now term-by-term instead of as a monolith
|
||||
- add a focused ablation runner for pinned badge/emblem cases before cutting more behavior
|
||||
- collapse duplicated adjacency / motif / line voices only after ablation shows which stage owns each idea
|
||||
|
||||
The next pass turned that "put relax on trial" note into an actual measurement seam:
|
||||
|
||||
- `SolverArtifacts` now carries stage diagnostics for continuous runs, including the exact selected-source displacement field for `snap`, `relax_handoff`, `relax_mode`, and `final_output`
|
||||
- `run.json` now includes `optimizer_displacement` metrics, and diagnostics directories now write color-coded displacement previews for those stages
|
||||
- the pinned cleaned-badge comparison under `artifacts/optimizer-relax-purpose-check/` runs the same fixed lattice twice: once with normal relax and once with `relax_iterations=0`
|
||||
|
||||
What that test says:
|
||||
|
||||
- `relax` still helps a little on the real badge objective: final source-fidelity improves from `0.07549` without relax to `0.07451` with relax
|
||||
- but the displacement field does **not** become clearly smoother in the way the original design story promised
|
||||
- on that same pinned badge run, final orthogonal displacement jitter is `1.7154` with relax versus `1.6783` without it, and final local residual is `1.1548` with relax versus `1.1312` without it
|
||||
- the honest read is that the current relax stage behaves more like a soft consensus / handoff stabilizer over fixed local candidate trays than a true large-swath phase adjustment mechanism
|
||||
|
||||
That means the next optimizer cuts should treat `relax` as a measurable subsolver with a narrower actual job, not as a sacred phase-field story we keep around out of nostalgia.
|
||||
|
||||
The next optimizer simplification pass should use that map as its cutting checklist instead of guessing from scattered helper names.
|
||||
|
||||
### Lean optimizer restart target
|
||||
|
||||
The next real optimizer move is not another incremental trim on the old tray-based solver. It is a restart toward the smaller machine now mapped in `docs/lean-optimizer-algorithm-map.md`.
|
||||
|
||||
What that new map says plainly:
|
||||
|
||||
- keep lattice inference and edge scouting
|
||||
- keep one fixed grid of cell centers
|
||||
- attach one displacement vector `(dx, dy)` to each output cell, initialized to zero
|
||||
- optimize that displacement field directly
|
||||
- use a tiny objective with only:
|
||||
- local solidity / coherence
|
||||
- smoothness
|
||||
- edge-aware smoothness
|
||||
- anti-collapse / anti-fold constraints
|
||||
- displacement magnitude regularization
|
||||
- sample the final output once from the optimized field
|
||||
Repixelizer now has two real reconstruction engines:
|
||||
|
||||
What that restart explicitly rejects:
|
||||
- `phase-field`: the default optimizer in `src/repixelizer/phase_field.py`
|
||||
- `tile-graph`: the source-owned alternate solver in `src/repixelizer/tile_graph.py`
|
||||
|
||||
- `representative_t`
|
||||
- `source_lattice_reference`
|
||||
- `source_reliability_t`
|
||||
- tray-based snap / relax / refine as the core optimizer state
|
||||
- repeated adjacency / motif / line dialects trying to compensate for each other
|
||||
The old tray-based optimizer is gone. Good riddance.
|
||||
|
||||
Why this is the right next cut:
|
||||
The live pipeline is:
|
||||
|
||||
- the current optimizer map now makes the contradiction impossible to ignore: the code is not really optimizing phase directly anymore
|
||||
- the relax diagnostics proved that one of the old justifications was overstated; the machine gets a tiny score win from relax, but not the promised broad-swatch phase drift
|
||||
- the user's target description is cleaner than the current code: find cell size, initialize a phase field at zero, wiggle it into solid areas coherently, sample, done
|
||||
`source image -> lattice inference -> edge analysis -> phase-field or tile-graph reconstruction -> cleanup -> optional palette fit -> diagnostics`
|
||||
|
||||
Next implementation steps:
|
||||
## What is working
|
||||
|
||||
1. compare the new `phase-field` mode against pinned badge and emblem runs before porting any old heuristics
|
||||
2. improve the local evidence term so the field is pulled harder into solid fake-pixel interiors
|
||||
3. tune the projection / spacing story so the field can drift farther without collapsing or freezing
|
||||
4. only then consider importing a small number of old heuristics if they clearly help
|
||||
- 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
|
||||
- `source_structure`: visible structural agreement at source size
|
||||
|
||||
What has already landed:
|
||||
That second metric exists because the first one was happily slandering the better-looking output.
|
||||
|
||||
- a brand-new `src/repixelizer/phase_field.py` module
|
||||
- an experimental `--reconstruction-mode phase-field` pipeline path
|
||||
- a direct displacement-field optimizer using only:
|
||||
- local coherence
|
||||
- local edge avoidance
|
||||
- edge-aware smoothness
|
||||
- anti-collapse spacing
|
||||
- displacement magnitude regularization
|
||||
- pinned-badge smoke artifacts under `artifacts/phase-field-v1-badge-126/`
|
||||
## Current evidence
|
||||
|
||||
First honest result:
|
||||
### Phase-field
|
||||
|
||||
- on the cleaned badge at fixed `126x126`, phase `(0.0, -0.2)`, `steps=48`, CUDA
|
||||
- the new `phase-field` mode lands at `0.1461` final source-fidelity
|
||||
- that is much worse than the current continuous path (`0.0745` on the same pinned case), but dramatically better than the earlier broken tile-graph collapse and, more importantly, it is being produced by a machine that actually matches its own description
|
||||
Pinned badge case:
|
||||
|
||||
That is a respectable first brick: not good enough, but finally the right shape.
|
||||
- fixture: `tests/fixtures/real/ai-badge-cleaned.png`
|
||||
- lattice: `126x126`
|
||||
- phase: `(0.0, -0.2)`
|
||||
- steps: `48`
|
||||
|
||||
Important metric correction:
|
||||
Useful artifacts:
|
||||
|
||||
- that same first phase-field badge run is also the clearest proof that `source_fidelity` is not enough as the repo's north star
|
||||
- visually, the phase-field output preserves the important internal badge structure better than the pinned continuous output
|
||||
- numerically, the old `source_fidelity` score still calls it worse because it rewards agreement with the lattice reference's mean / sharp portraits
|
||||
- the repo now also reports `source_structure`, a source-size structural metric that combines:
|
||||
- foreground reconstruction error
|
||||
- edge-position error
|
||||
- stroke wobble
|
||||
- edge support precision / recall / F1
|
||||
- exact-match ratio
|
||||
- on the pinned badge comparison under `artifacts/phase-field-metric-check/`, that new metric finally agrees with human judgment: `phase-field` scores `0.4244` versus continuous `0.4291` where lower is better
|
||||
- first pinned badge run: `artifacts/phase-field-v1-badge-126/`
|
||||
- tip-focused follow-up: `artifacts/phase-field-v2-badge-126/`
|
||||
- structure-metric sanity check: `artifacts/phase-field-metric-check/`
|
||||
|
||||
First focused blemish pass:
|
||||
What we know:
|
||||
|
||||
- the tracked sword-tip focus fixture now lives in `tests/fixtures/real/ai-badge-tip-focus.json`
|
||||
- the next phase-field pass tried exactly the three obvious fixes:
|
||||
- stronger local evidence
|
||||
- less timid edge-aware smoothness gating
|
||||
- a better spacing story with both preferred spacing and an upper spacing clamp
|
||||
- the resulting pinned badge run is under `artifacts/phase-field-v2-badge-126/`
|
||||
- this did move the specific blemish a little in the right direction, but only a little:
|
||||
- on the tip focus crop, `source_structure` improved from `0.4187` to `0.4179`
|
||||
- on the whole image, `source_structure` regressed slightly from `0.4244` to `0.4248`
|
||||
- honest read: the pass shaved the targeted contour swelling a bit, but not enough to call the issue solved, and it did so by spending a little of the whole-image win
|
||||
- the output preserves the important badge structure better than the deleted optimizer did
|
||||
- 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`
|
||||
|
||||
## High-value regression case
|
||||
### Tile-graph
|
||||
|
||||
### AI badge emblem with baked checkerboard background
|
||||
Useful artifacts:
|
||||
|
||||
This locally generated AI badge remains a useful stress case because it fails in a specific, repeatable way.
|
||||
- full emblem win: `artifacts/full-emblem-tile-graph-atomic-v3-cuda/`
|
||||
- pinned badge speed/quality checkpoint: `artifacts/tile-graph-reducebykey-v1-badge-126/`
|
||||
|
||||
Why it is useful:
|
||||
- the global target size is broadly plausible, so the failure is not just "picked the wrong lattice"
|
||||
- the sword tip and guard contain sharp single-pixel and near-single-pixel features at multiple orientations
|
||||
- those features are exactly the kind of local adjacency pattern Repixelizer is supposed to preserve
|
||||
What we know:
|
||||
|
||||
Current failure modes:
|
||||
- the right side of the sword tip can still wobble instead of tapering cleanly
|
||||
- thin oblique outline features can still collapse into mushy transitions
|
||||
- the right-hand guard wing can still lose short dark-light-dark adjacency motifs
|
||||
- on tile-graph specifically, the remaining failure is less about wrong colors entering the candidate pool and more about the propagation step preferring a smoother local arrangement than the sharper initial assignment
|
||||
- the new phase-field path has one obvious blemish on the pinned badge: the dark contour along the sword tip widens too much in a localized patch now recorded in `tests/fixtures/real/ai-badge-tip-focus.json`
|
||||
|
||||
Current status:
|
||||
- on the cleaned fixture, the continuous path now lands at `0.0832` source-fidelity in the reproducible probe under `artifacts/badge-final-probe/`, which is materially better than the older documented `0.1494` result and better than naive resize under the same lattice
|
||||
- on the full emblem, the tile-graph atomic path under `artifacts/full-emblem-tile-graph-atomic-v3-cuda/` now reaches `0.0224`, which is the best tile-graph full-emblem result so far in this repo
|
||||
- the remaining weakness on tile-graph is not candidate color purity anymore; it is candidate extraction cost plus deciding how aggressive the propagation step should be once the initial assignment is already strong
|
||||
- on the cleaned badge, the new CCL-backed tile-graph run under `artifacts/badge-tile-graph-ccl-cuda/` still lands at a weak `0.1800`, so the current CCL work should be treated as an infrastructure/performance step rather than a visual-quality fix
|
||||
- the new grouped-extraction badge run under `artifacts/tile-graph-reducebykey-v1-badge-126/` is the first speed-focused cut that actually changes the preprocessing profile in the right direction: cold CUDA time drops to about `16.1s`, warm cached time is about `6.0s`, and the remaining quality problem is contour precision rather than total collapse
|
||||
- the latest CPU profile shows that preprocessing is still the story, but the story changed: extraction is no longer the monster, and connected-component labeling / label compression is now the thing to attack next
|
||||
|
||||
Important note:
|
||||
- the checkerboard is baked into the source image, not transparency
|
||||
- when projected onto a `122x122` output grid it aliases into a visible `2-3-2` cadence
|
||||
- that background rhythm is expected from resampling the background pattern and should not be mistaken for proof that the inferred lattice itself is varying in size
|
||||
|
||||
What future work should improve here:
|
||||
- edge-aware source reliability so high-dispersion but high-contrast cells keep trusting source evidence
|
||||
- richer candidate generation so continuous snap/refine can reach sharp exemplars, edge peaks, and guided offsets instead of only a fixed local grid
|
||||
- short top-k refine probes for low-confidence lattice candidates before the final rerank decision
|
||||
- an atomic candidate builder that more closely matches real source-region ownership and consumption
|
||||
- a faster labeler or grouped label-reduction path so tile-graph can keep the new simpler overlap model without paying for label propagation so heavily
|
||||
- some form of coordinated local relaxation so nearby cells can move together toward a globally better contour
|
||||
|
||||
## Suggested next milestones
|
||||
- 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
|
||||
|
||||
### Milestone 1: Real-image evaluation harness
|
||||
|
||||
- add curated real-image fixtures
|
||||
- add golden metadata for expected target-size ranges
|
||||
- add a repeatable batch runner
|
||||
- emit one summary table per corpus run
|
||||
|
||||
### Milestone 2: Tile-graph extraction pass
|
||||
|
||||
- extract atomic source regions in a way that better matches actual source texel ownership
|
||||
- preserve the current sharp/edge anchors as a safety net
|
||||
- port the heavy extraction work back toward a GPU-friendly formulation
|
||||
|
||||
### Milestone 3: Solver tuning pass
|
||||
|
||||
- tune continuous-stage loss weights against the benchmark corpus after the next edge-aware and candidate-guided pass lands
|
||||
- tune tile-graph propagation aggressiveness now that the initial-assignment fallback is in place
|
||||
- reduce cases where either solver underperforms the naive baseline or settles on the wrong low-confidence lattice
|
||||
|
||||
### Milestone 4: Coordinated contour moves
|
||||
|
||||
- add contour-aware multi-cell refinement moves
|
||||
- protect short dark-light-dark edge motifs during block updates
|
||||
- reduce cases where locally correct cells still fail to form a globally smooth outline
|
||||
## Maps
|
||||
|
||||
## Risks
|
||||
|
||||
- the solver can still produce outputs that score well but feel over-smoothed
|
||||
- real generated images may contain multiple inconsistent local lattice cues
|
||||
- metrics that reward coherence can accidentally reward blandness if not balanced carefully
|
||||
- the current atomic candidate extraction can become too expensive on large fixtures unless it is reworked again
|
||||
|
||||
## Assumptions
|
||||
|
||||
- single-image quality matters more than batch throughput in the current phase
|
||||
- palette constraints are optional and should stay optional
|
||||
- the project is better served by improving automatic behavior first than by rushing into manual tooling
|
||||
- Python plus PyTorch remains the right stack for the current research/prototyping phase
|
||||
The repo now keeps the living maps only:
|
||||
|
||||
- `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.
|
||||
|
||||
## Current priorities
|
||||
|
||||
### 1. Fix the phase-field sword-tip blemish
|
||||
|
||||
Goal:
|
||||
|
||||
- keep the current structural win
|
||||
- stop widening the dark contour near the tapered sword tip
|
||||
|
||||
Current hypothesis:
|
||||
|
||||
- scalar weight nudges are not enough
|
||||
- the field needs better anisotropic behavior near sharp tapered contours
|
||||
- it likely needs to distinguish motion along a stroke from motion across a stroke
|
||||
|
||||
Tracked fixture:
|
||||
|
||||
- `tests/fixtures/real/ai-badge-tip-focus.json`
|
||||
|
||||
### 2. Keep phase-field honest
|
||||
|
||||
Rules:
|
||||
|
||||
- do not reintroduce portrait layers, candidate trays, or solver-stage religions
|
||||
- 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
|
||||
- show the output after every real reconstruction run
|
||||
- keep the maps updated
|
||||
- revert or delete machinery that does not visibly earn its keep
|
||||
|
||||
This repo already spent enough time as a Jenga tower of maybe-useful cleverness. The machine has to deserve every part.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Lean Optimizer Replacement Map
|
||||
# Lean Optimizer Algorithm Map
|
||||
|
||||
## Why this map exists
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ It is a map of the optimizer we should replace it with:
|
|||
|
||||
That is the whole dream. No watercolor portrait. No accountant's ledger. No snap religion and refine religion living in the same cathedral. Just one field, one objective, one final sample.
|
||||
|
||||
The first implementation of this restart now exists in `src/repixelizer/phase_field.py` behind `--reconstruction-mode phase-field`.
|
||||
The live implementation now exists in `src/repixelizer/phase_field.py` and is the default optimizer path.
|
||||
|
||||
That implementation is intentionally small and slightly stupid:
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ That implementation is intentionally small and slightly stupid:
|
|||
- one displacement magnitude prior
|
||||
- one final nearest source sample
|
||||
|
||||
It is not yet beating the old continuous optimizer on the pinned badge stress case, but it is finally the right kind of machine to improve.
|
||||
It is already the right kind of machine, and it now owns the main optimizer path. The remaining work is quality tuning, not architectural self-deception.
|
||||
|
||||
## One-sentence machine
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ Or, in pictures:
|
|||
|
||||
## How to read this machine
|
||||
|
||||
The replacement optimizer should carry only four important pieces of cargo:
|
||||
The optimizer should carry only four important pieces of cargo:
|
||||
|
||||
- `uv0`: the fixed lattice centers chosen by inference
|
||||
- `d`: one displacement vector `(dx, dy)` per output cell, initialized to zero
|
||||
|
|
@ -61,7 +61,6 @@ These existing pieces are still worth keeping:
|
|||
- edge analysis in `src/repixelizer/analysis.py`
|
||||
- source-lattice consistency metrics in `src/repixelizer/metrics.py`
|
||||
- diagnostics and compare-mode output in `src/repixelizer/pipeline.py` and `src/repixelizer/diagnostics.py`
|
||||
- the current optimizer in `src/repixelizer/continuous.py` as a benchmark and fallback during the replacement
|
||||
|
||||
These pieces should **not** be part of the new core:
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ After that, the new optimizer is not a politician. It is a field crew. It works
|
|||
|
||||
Current source to reuse:
|
||||
|
||||
- `_make_regular_uv(...)` in `src/repixelizer/continuous.py`
|
||||
- `_make_regular_uv_px(...)` in `src/repixelizer/phase_field.py`
|
||||
|
||||
New state:
|
||||
|
||||
|
|
@ -246,7 +245,7 @@ Hard invariants:
|
|||
|
||||
Meaning:
|
||||
|
||||
This is the heart of the replacement.
|
||||
This is the heart of the machine.
|
||||
|
||||
The field should move directly under one unified objective, not by:
|
||||
|
||||
|
|
@ -275,7 +274,7 @@ Meaning:
|
|||
|
||||
Once the field has settled, the image comes from one final honest sampling step.
|
||||
|
||||
This is where the current machine keeps cheating by spending most of its life as a discrete chooser. The replacement should do the opposite:
|
||||
This is where the current machine must keep its hands clean. The field should do the hard thinking first:
|
||||
|
||||
- optimize a field first
|
||||
- discretize once at the end
|
||||
|
|
@ -298,7 +297,7 @@ Meaning:
|
|||
|
||||
The new machine still needs adult supervision.
|
||||
|
||||
But the supervision should judge one coherent pipeline, not referee a knife fight between three subsolvers. The important diagnostics for the replacement should be:
|
||||
But the supervision should judge one coherent pipeline, not referee a knife fight between three subsolvers. The important diagnostics for this machine are:
|
||||
|
||||
- source-lattice fidelity
|
||||
- displacement magnitude statistics
|
||||
|
|
@ -306,8 +305,6 @@ But the supervision should judge one coherent pipeline, not referee a knife figh
|
|||
- topology violations prevented or projected away
|
||||
- maybe edge-crossing counts if we make that measurable
|
||||
|
||||
If the replacement loses badly to the old optimizer, we keep the old optimizer. No romance.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the inspector with the clipboard, but now the questions are cleaner.
|
||||
|
|
@ -338,9 +335,9 @@ These things should be treated as suspicious imports from the old religion:
|
|||
|
||||
If one of those comes back, it should have to win a public trial.
|
||||
|
||||
## The current replacement plan
|
||||
## The current plan
|
||||
|
||||
The lean optimizer should be built beside the current one, not by mutating the old whale in place.
|
||||
The lean optimizer is now the live optimizer. The job is no longer to justify its existence. The job is to improve it without letting it mutate back into a tray cult.
|
||||
|
||||
Recommended build order:
|
||||
|
||||
|
|
@ -353,12 +350,12 @@ Recommended build order:
|
|||
- anti-collapse
|
||||
- displacement magnitude prior
|
||||
4. Add diagnostics for displacement magnitude, smoothness, and topology health.
|
||||
5. Compare against the current optimizer on pinned badge and emblem cases.
|
||||
6. Only port any old "fancy" idea if it clearly improves the real outputs.
|
||||
5. Compare against pinned badge and emblem cases.
|
||||
6. Only port any old "fancy" idea if it clearly improves the real outputs and still fits the one-field mental model.
|
||||
|
||||
## The diamond test
|
||||
|
||||
The replacement is worth keeping only if it passes this crude, necessary test:
|
||||
The optimizer is worth keeping only if it passes this crude, necessary test:
|
||||
|
||||
Can a new reader describe the whole machine without sounding like they are defending a tax code?
|
||||
|
||||
|
|
|
|||
|
|
@ -1,634 +0,0 @@
|
|||
# Optimizer Algorithm Map
|
||||
|
||||
## Why this map exists
|
||||
|
||||
The optimizer path is trying to do one slippery thing:
|
||||
|
||||
Lay a sheet of graph paper over the source image, imagine what each output cell ought to feel like, then force every final cell to choose a real source pixel nearby without letting the whole image turn into soup.
|
||||
|
||||
When it works, the result feels like the image finally committed to one lattice.
|
||||
|
||||
When it fails, this map is here to answer:
|
||||
|
||||
Where did the machine start negotiating with itself too much?
|
||||
|
||||
This is a map of the **current** optimizer in `src/repixelizer/continuous.py`.
|
||||
|
||||
If you want the replacement target instead of the autopsy, read `docs/lean-optimizer-algorithm-map.md` too. That companion map describes the smaller displacement-field machine this repo should grow next.
|
||||
|
||||
## One-sentence machine
|
||||
|
||||
The current optimizer machine is:
|
||||
|
||||
`source image -> edge scout -> regular UV grid -> soft representative portrait + hard source lattice portrait -> source-first snap -> source-first refine -> keep snap if refine made things worse`
|
||||
|
||||
Or, in pictures:
|
||||
|
||||
- the source image is the mural
|
||||
- the inferred lattice is the sheet of graph paper
|
||||
- the UV field is where the graph-paper cell centers land on the mural
|
||||
- the representative portrait is a soft watercolor guess of each cell
|
||||
- the source lattice portrait is a ledger of which real source pixels belong to each cell
|
||||
- snap is the mason placing one actual stone into each slot
|
||||
- refine is the foreman walking the wall, nudging stones to improve the joints
|
||||
|
||||
## How to read this machine
|
||||
|
||||
The optimizer carries five important pieces of cargo all the way through:
|
||||
|
||||
- `uv_t`: the regular lattice centers, in normalized sampling coordinates
|
||||
- `representative_t`: the soft portrait made by sampling little patches around each UV center
|
||||
- `source_lattice_reference`: the hard portrait built by assigning every source pixel to one inferred output cell
|
||||
- `source_reliability_t`: the per-cell trust dial that decides how much the machine should listen to the hard portrait instead of the soft one
|
||||
- `snap_t`: the first fully discrete output, used as both a candidate answer and the anchor for later refinement
|
||||
|
||||
If we lose track of one of those, the optimizer becomes impossible to reason about.
|
||||
|
||||
The first structural cut now gathers that cargo into `_OptimizerPrep` before snap and refine begin. That does not make the algorithm better by itself, but it makes the machine easier to see: preparation builds the map, snap and refine walk it.
|
||||
|
||||
There is one extra sidecar cargo too:
|
||||
|
||||
- `guidance`: a downsampled edge map saved into `SolverArtifacts` for the later cleanup stage
|
||||
|
||||
It matters to the pipeline contract, but it is not part of the optimizer's decision-making spine.
|
||||
|
||||
For every stage below, read it in two layers:
|
||||
|
||||
- `What the source actually does`: the code-shaped account, using the real function names and variables.
|
||||
- `Metaphor`: the image to keep in your head so the data flow stays legible instead of dissolving into a swamp of tensors.
|
||||
|
||||
## Stage 0: The pipeline picks the ruler
|
||||
|
||||
Functions:
|
||||
|
||||
- `run_pipeline(...)` in `src/repixelizer/pipeline.py`
|
||||
- `_prepare_analysis(...)` in `src/repixelizer/pipeline.py`
|
||||
- `_select_phase_candidate_with_reconstruction(...)` in `src/repixelizer/pipeline.py`
|
||||
- `_run_reconstruction(...)` in `src/repixelizer/pipeline.py`
|
||||
|
||||
Inputs:
|
||||
|
||||
- `source_rgba`
|
||||
- `InferenceResult`
|
||||
- `target_width`
|
||||
- `target_height`
|
||||
- `phase_x`
|
||||
- `phase_y`
|
||||
|
||||
Meaning:
|
||||
|
||||
Before the optimizer does anything clever, the pipeline chooses the ruler, and for low-confidence continuous runs it may give that ruler one last suspicious look before letting the solver touch it.
|
||||
|
||||
This fixes:
|
||||
|
||||
- how many cells the output wall will have
|
||||
- where that cell grid is phased against the source image
|
||||
|
||||
Every later judgment is conditioned on that ruler. If it is wrong, the optimizer is making careful decisions against the wrong graph paper.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
In `run_pipeline(...)`, the pipeline first resolves whether the user pinned a target size and phase through `_resolve_requested_target_dims(...)`. If the user did not, it calls `infer_lattice(...)`; if the user did, it calls `infer_fixed_lattice(...)`. Then `_prepare_analysis(...)` builds the edge scout report. After that, `_select_phase_candidate_with_reconstruction(...)` may rerank the inferred candidates, but only when `reconstruction_mode == "continuous"`, reranking is enabled, confidence is low enough, and there is more than one top candidate. That rerank path actually runs `_run_reconstruction(...)` with `steps=0` on up to eight candidates and scores them with source-lattice support, edge-position error, stroke wobble, edge concentration, size delta, and inference penalty. Only after that does `_run_reconstruction(...)` hand the final `InferenceResult` to `optimize_lattice_pixels(...)`. Once the optimizer itself starts, the ruler is fixed.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the surveyor hammering stakes into the ground before anyone starts building, then having one brief, paranoid committee meeting about whether the stakes were hammered in the wrong place.
|
||||
After that, the argument is over. The masons do not get to kick the stakes sideways halfway through the wall just because they are having feelings.
|
||||
|
||||
## Stage 1: The edge scout walks the mural
|
||||
|
||||
Functions:
|
||||
|
||||
- `analyze_continuous_source(...)` in `src/repixelizer/analysis.py`
|
||||
- `_compute_edge_map(...)`
|
||||
|
||||
Output:
|
||||
|
||||
- `ContinuousSourceAnalysis.edge_map`
|
||||
|
||||
Meaning:
|
||||
|
||||
The optimizer sends out one scout:
|
||||
|
||||
- it chalks the cliff faces where luminance or alpha changes sharply
|
||||
|
||||
Important variables:
|
||||
|
||||
- `edge_map[y, x]`
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
The edge scout is looking for cracks and ridges.
|
||||
|
||||
The old k-means paint-by-number scout has been cut. It was letting coarse color partitions masquerade as geometry, and that did not fit the optimizer's core story.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`analyze_continuous_source(...)` in `src/repixelizer/analysis.py` now does one thing: it builds `edge_map`. `_compute_edge_map(...)` computes luminance, reads alpha, forms `dx` and `dy` from neighboring differences, and normalizes the result into one float map. The GPU path mirrors the same logic in `_compute_edge_map_torch(...)`. The output is not a segmentation, not a palette, not a region graph. It is a single heat map saying where the image folds sharply.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This scout is not painting the mural into numbered regions anymore. That old idiot got fired.
|
||||
The surviving scout just runs charcoal over the wall and leaves dark marks where the plaster catches: ridges, cracks, cliff edges, places where the picture changes its mind abruptly. That is all.
|
||||
|
||||
## Stage 2: The optimizer lays down a regular UV grid
|
||||
|
||||
Functions:
|
||||
|
||||
- `_make_regular_uv(...)`
|
||||
- `_make_patch_offsets(...)`
|
||||
|
||||
Key outputs:
|
||||
|
||||
- `uv`
|
||||
- `uv_t`
|
||||
- `offsets_t`
|
||||
|
||||
Meaning:
|
||||
|
||||
This stage drops the graph paper onto the mural.
|
||||
|
||||
`uv` is not an optimized warp. It is a perfectly regular lattice built directly from:
|
||||
|
||||
- source width and height
|
||||
- inferred output width and height
|
||||
- inferred phase
|
||||
|
||||
Each output cell gets one normalized coordinate pair saying, "my center currently lands here."
|
||||
|
||||
Then `_make_patch_offsets(...)` builds a little stencil of nearby offsets around each center. Those offsets are used to ask, "what does the neighborhood around this cell center look like?"
|
||||
|
||||
Important truth:
|
||||
|
||||
Despite the old name `optimize_uv_field(...)`, the current path never actually moves the UV field. It lays down a regular grid and keeps it.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_make_regular_uv(...)` computes `cell_x` and `cell_y` from the source size and the chosen output size, builds the cell-center coordinates with `(index + 0.5 + phase) * cell_size - 0.5`, clamps them to the image bounds, then normalizes them to `[-1, 1]` sampling coordinates. `_make_patch_offsets(...)` then creates a small fixed stencil of local offsets, scaled by cell size, around each UV center. Nothing in this stage optimizes those centers. It manufactures a ruler and a sampling stencil, then freezes both.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the crew lowering a sheet of graph paper over a fresco and taping it flat.
|
||||
The UV coordinates are the pins through the paper. The patch offsets are a tiny clear plastic stencil you can place around each pin to peek at nearby paint without moving the pin itself.
|
||||
|
||||
## Stage 3: The machine paints a soft portrait of the lattice
|
||||
|
||||
Functions:
|
||||
|
||||
- `_sample_cell_patches(...)`
|
||||
- `_representative_colors(...)`
|
||||
|
||||
Key variables:
|
||||
|
||||
- `initial_patches`
|
||||
- `representative_t`
|
||||
|
||||
Meaning:
|
||||
|
||||
For each output cell center, the optimizer samples a small cloud of nearby colors from the source image.
|
||||
|
||||
Then it asks:
|
||||
|
||||
If I blur these samples together just enough to ignore local jitter, what is the most representative color for this cell?
|
||||
|
||||
That answer becomes the representative portrait.
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
This is the machine squinting at the mural from a few steps back, trying to see what each graph-paper square "mostly wants to be."
|
||||
|
||||
Why it exists:
|
||||
|
||||
- the representative portrait is smooth and stable
|
||||
- it gives the later discrete stages something coherent to aim at
|
||||
|
||||
Why it is dangerous:
|
||||
|
||||
- this portrait is also exactly where softness can sneak in
|
||||
- if the machine listens to it too much, it starts preserving vibes instead of preserving source-owned structure
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_sample_cell_patches(...)` uses `grid_sample` to pull a little cloud of nearby RGBA samples around each UV center, flattening and reshaping the offset stencil so the source image can be sampled in one batched pass. `_representative_colors(...)` then computes `patch_mean`, measures each sampled patch color's distance from that mean, applies a softmax over the negative distances, and blends the patch colors with those weights. The result is `representative_t`: a premultiplied, smoothed per-cell color portrait. The code literally computes "which nearby samples are most typical for this cell?" and then averages toward that typicality.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the machine stepping back, squinting, and painting a watercolor thumbnail over the graph paper.
|
||||
It does not ask, "what exact shard of pigment lives here?" It asks, "if I blur out the noise and half-close my eyes, what color does this square mostly hum with?" Useful. Also suspicious. Watercolor is where the mush sneaks in wearing respectable shoes.
|
||||
|
||||
## Stage 4: The machine builds a hard ledger from the actual source assignment
|
||||
|
||||
Functions:
|
||||
|
||||
- `build_source_lattice_reference(...)` in `src/repixelizer/source_reference.py`
|
||||
- `lattice_indices(...)`
|
||||
- `_build_source_detail_reference(...)`
|
||||
- `_build_source_reliability(...)`
|
||||
- `_edge_gradient_maps(...)`
|
||||
|
||||
Key outputs:
|
||||
|
||||
- `source_lattice_reference`
|
||||
- `source_detail_reference`
|
||||
- `source_reliability_t`
|
||||
- `source_delta_x_t`
|
||||
- `source_delta_y_t`
|
||||
- `source_delta_diag_t`
|
||||
- `source_delta_anti_t`
|
||||
|
||||
Meaning:
|
||||
|
||||
Now the optimizer stops squinting and starts bookkeeping.
|
||||
|
||||
Every source pixel is assigned to exactly one inferred output cell. Once that assignment exists, the machine can compute:
|
||||
|
||||
- `mean_rgba`: the average contents of the cell
|
||||
- `sharp_rgba`: the most typical real pixel in that cell
|
||||
- `cell_dispersion`: how mixed or unstable the cell is
|
||||
- `cell_support`: how much source support the cell has
|
||||
- `cell_alpha_max`: whether the cell ever gets truly opaque
|
||||
- `edge_peak_x`, `edge_peak_y`: where the strongest edge inside that cell lives
|
||||
- `edge_strength`: how strong that edge is
|
||||
- `edge_grad_x`, `edge_grad_y`: which way the edge points locally
|
||||
|
||||
Then `_build_source_detail_reference(...)` mixes `sharp_rgba` with the color at the cell's edge peak, producing a more edge-aware source portrait.
|
||||
|
||||
Then `_build_source_reliability(...)` decides how much to trust that hard portrait.
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
If the representative portrait is a watercolor painting, the source lattice reference is an accountant's ledger.
|
||||
|
||||
It says:
|
||||
|
||||
- these are the pixels that actually live under this cell
|
||||
- this is how messy they are
|
||||
- this is where the sharpest internal edge sits
|
||||
- this is how much confidence we should have that the cell really knows what it is
|
||||
|
||||
Important seam:
|
||||
|
||||
The optimizer now has two different stories about the same lattice:
|
||||
|
||||
- a soft patch-sampled story
|
||||
- a hard source-assignment story
|
||||
|
||||
Much of the optimizer's complexity is the cost of arbitrating between those two stories.
|
||||
|
||||
Current code shape:
|
||||
|
||||
- `_prepare_optimizer(...)` owns this whole setup stage now
|
||||
- `_source_detail_delta_tensors(...)` computes the source-detail deltas once instead of rebuilding premultiplied references several times
|
||||
- `optimize_lattice_pixels(...)` now receives a prepared bundle and orchestrates snap, refine, and the final guardrail
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`build_source_lattice_reference(...)` in `src/repixelizer/source_reference.py` calls `_build_reference_payload(...)`, which first uses `lattice_indices(...)` to assign every source pixel to one output cell under the chosen size and phase. It premultiplies the source, bins pixel contributions by cell index, and computes `mean_rgba`, `cell_dispersion`, `cell_support`, and `cell_alpha_max`. Then it chooses one exemplar-like pixel per cell with `_argbest_linear_indices(...)` over `exemplar_cost_t`, where that cost is basically "distance from the cell's premultiplied mean, plus a tiny penalty if alpha falls below that mean." That produces `sharp_rgba`, `sharp_x`, and `sharp_y`. In parallel it picks edge-peak locations using the strongest `edge_hint`, giving `edge_peak_x`, `edge_peak_y`, `edge_strength`, `edge_grad_x`, and `edge_grad_y`. Back in `continuous.py`, `_build_source_detail_reference(...)` mixes the sharp pixel with the edge-peak pixel where `edge_strength` clears the configured threshold. `_build_source_reliability(...)` then turns dispersion, support, alpha, and edge strength into a trust map, while `_source_detail_delta_tensors(...)` computes the horizontal, vertical, and diagonal color-jump tensors from that hard portrait.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This stage stops squinting and opens the books.
|
||||
Every real source pixel gets marched into exactly one cell's ledger like a tax record. The machine counts how many citizens live there, how similar they are, who the most typical resident is, where the loudest knife-edge cuts through town, and whether this district is solid enough to trust. Then it writes down not just what each cell is, but how violently it expects the color to jump when you step east, south, or diagonally into the next cell.
|
||||
|
||||
## Stage 5: Snap every output cell to one real nearby source pixel
|
||||
|
||||
Function:
|
||||
|
||||
- `_snap_output_to_source_pixels(...)`
|
||||
|
||||
Key inputs:
|
||||
|
||||
- `uv_t`
|
||||
- `representative_t`
|
||||
- `source_reference_t`
|
||||
- `source_reliability_t`
|
||||
|
||||
Key working variables:
|
||||
|
||||
- `candidate_x`, `candidate_y`
|
||||
- `candidate_colors`
|
||||
- `representative_match`
|
||||
- `source_match`
|
||||
- `base_energy`
|
||||
- `desired_delta_x`
|
||||
- `desired_delta_y`
|
||||
- `desired_delta_diag`
|
||||
- `desired_delta_anti`
|
||||
- `selected`
|
||||
|
||||
Meaning:
|
||||
|
||||
This is the first moment the optimizer has to stop talking and actually place stones in the wall.
|
||||
|
||||
For each output cell:
|
||||
|
||||
1. it looks at a fixed `5x5` neighborhood of nearby source pixels around the UV center
|
||||
2. it scores those real pixels against both portraits
|
||||
3. it blends those portrait scores using `source_reliability_t`
|
||||
4. it repeatedly asks whether the chosen pixel agrees with the colors its neighbors are choosing
|
||||
5. it picks one real source pixel
|
||||
|
||||
The delta maps matter here.
|
||||
|
||||
They encode the desired color jump from one cell to the next:
|
||||
|
||||
- left to right
|
||||
- top to bottom
|
||||
- main diagonal
|
||||
- anti-diagonal
|
||||
|
||||
Those desired jumps are themselves blended between:
|
||||
|
||||
- the soft representative portrait
|
||||
- the hard source-detail portrait
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
Snap is a mason walking across the empty wall, picking one real stone for each slot while glancing left, right, up, and diagonally to keep the joints plausible.
|
||||
|
||||
Then `_harden_binary_alpha_selection(...)` makes an extra binary decision for uncertain alpha cells:
|
||||
|
||||
- if this cell should really be foreground, force the best opaque candidate
|
||||
- if this cell should really be background, force the best transparent candidate
|
||||
|
||||
That stops fuzzy half-alpha picks from surviving into the final raster.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_snap_output_to_source_pixels(...)` turns each UV center into a fixed `5x5` local neighborhood by adding the `fractions = [-0.42, -0.21, 0.0, 0.21, 0.42]` offsets scaled by `cell_x` and `cell_y`, then rounds those positions to real source pixels to get `candidate_x`, `candidate_y`, and `candidate_colors`. It measures `representative_match` against `representative_t` and `source_match` against `source_reference_t`, then fuses them with `_reference_match_energy(...)` using `source_reliability_t`. It also builds `desired_delta_x`, `desired_delta_y`, `desired_delta_diag`, and `desired_delta_anti` by blending the soft portrait's deltas with the hard portrait's deltas through `_blend_reference_delta_map(...)`. Then it does not just choose once. It starts from `selected = argmin(base_energy)`, then runs four synchronous neighbor-consistency sweeps, rebuilding `energy` from the currently selected colors and the desired deltas before taking a new per-cell argmin each time. Finally, `_harden_binary_alpha_selection(...)` snaps indecisive alpha picks to an explicitly opaque or transparent candidate when the ranking supports it.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the first time the machine has to stop daydreaming and pick actual stones.
|
||||
For each slot in the wall, it kneels over a tray of twenty-five nearby stones, weighs each one against the watercolor sketch and the accountant's ledger, then does four quick passes across the wall tightening the joints before the mortar sets. At the end it slaps the hand away from any damp half-ghost stone and says, no, pick rock or air. Commit.
|
||||
|
||||
## Stage 6: Expand the search space for refine
|
||||
|
||||
Functions:
|
||||
|
||||
- `_build_candidate_positions(...)`
|
||||
- `_reference_match_energy(...)`
|
||||
- `_blend_reference_delta_map(...)`
|
||||
|
||||
Key outputs:
|
||||
|
||||
- `candidate_x`, `candidate_y`
|
||||
- `candidate_colors`
|
||||
- `base_energy`
|
||||
- `relax_base_energy`
|
||||
|
||||
Meaning:
|
||||
|
||||
After snap, the optimizer opens a larger local search window for each cell.
|
||||
|
||||
These candidates are not just a fixed square anymore. The builder also adds:
|
||||
|
||||
- the strongest edge peak for the cell
|
||||
- gradient-guided offsets along the local edge direction
|
||||
|
||||
So the refine stage can reach sharper alternatives than the original snap neighborhood would have seen.
|
||||
|
||||
Each candidate now accumulates several costs:
|
||||
|
||||
- anchor match: how far it strays from the snapped output
|
||||
- source match: how well it fits the hard source portrait
|
||||
- alpha match
|
||||
- distance from the UV center
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
Snap chose one stone per slot.
|
||||
Now refine lays several nearby stones on the scaffold around each slot so it can ask whether a slightly different local arrangement would make a cleaner wall.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_build_candidate_positions(...)` starts from the fixed UV centers again, but now it builds a denser candidate set. It lays down a grid of fraction offsets from `refine_candidate_levels` and `refine_candidate_extent`, then appends edge-aware positions: the per-cell `edge_peak_x`/`edge_peak_y`, plus guided offsets along `edge_grad_x` and `edge_grad_y` when `edge_strength` and `cell_dispersion` say the cell is both edgy and unstable. `candidate_colors` are sampled directly from the source at those positions. The stage then computes `anchor_energy`, `source_reference_energy`, `alpha_energy`, and `distance_energy`, producing both `base_energy` for refine and the slightly softened `relax_base_energy` for the relax stage.
|
||||
|
||||
### Metaphor
|
||||
|
||||
Snap picked one stone and set it in mortar. Refine shows up with a wider tray.
|
||||
Now the foreman has the snapped stone, a few neighboring stones, the stone sitting right on the local crack line, and a couple more dragged along the crack's direction. The question stops being "what is nearby?" and becomes "what nearby alternative might preserve the cut in the image without wandering off the ruler?"
|
||||
|
||||
## Stage 7: Relax into a soft field before going fully discrete again
|
||||
|
||||
Function:
|
||||
|
||||
- `_relax_candidate_selection(...)`
|
||||
|
||||
Key variables:
|
||||
|
||||
- `probs`
|
||||
- `context_colors`
|
||||
- `final_energy`
|
||||
- `relaxed_context`
|
||||
- `handoff_energy`
|
||||
- `mode_selected`
|
||||
|
||||
Meaning:
|
||||
|
||||
This is the optimizer taking a deep breath before it starts making hard yes/no choices again.
|
||||
|
||||
Instead of choosing one candidate per cell immediately, it keeps a soft probability distribution over the candidates.
|
||||
|
||||
At each relaxation step:
|
||||
|
||||
1. it computes the expected color for each cell from those probabilities
|
||||
2. it scores every candidate against that soft neighborhood
|
||||
3. it adds structure terms for adjacency, motif, and line continuation
|
||||
4. it cools the temperature and sharpens the distribution
|
||||
|
||||
There are two families of structure terms here:
|
||||
|
||||
- anchor-facing terms: stay consistent with the snapped lattice
|
||||
- source-facing terms: stay consistent with the hard source portrait
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
This is wet plaster. Nothing is set yet, but the stones are starting to lean into a coherent arrangement.
|
||||
|
||||
At the end of relax, the optimizer keeps two things:
|
||||
|
||||
- `selected`: the best discrete choice after the handoff energy
|
||||
- `mode_selected`: the pure modal choice from the soft distribution
|
||||
|
||||
That is the machine hedging its bets before the next stage hardens everything again.
|
||||
|
||||
Recent cut:
|
||||
|
||||
- the old extra `relaxed_mode` bonus in refine is gone
|
||||
- the machine still compares the best refined state against the relaxed mode state at the end, but it no longer biases every greedy candidate score toward the relaxed mode
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_relax_candidate_selection(...)` initializes `probs` as a softmax over `-base_energy / start_temp`. On each iteration it converts those probabilities into `context_colors`, the expected color field implied by the current soft choices. It then rebuilds `final_energy` by adding pairwise delta agreement terms, optional source-facing adjacency terms, optional motif terms against both `anchor` and `source_reference`, and optional line-continuation terms. After each step, it damps and cools the distribution so the probabilities get sharper over time. When the loop ends, it builds `relaxed_context`, then forms `handoff_energy` by combining `final_energy`, a penalty for straying from `relaxed_context`, pairwise delta agreement against `relaxed_context`, and motif/line terms that now treat `relaxed_context` itself as the local template for the anchor-facing side of the handoff. It keeps both the best handoff selection `selected` and the pure modal pick `mode_selected`. The relax-mode bonus that used to bias refine directly is gone; only the end-of-stage hedge remains.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is wet plaster and half-seated stones.
|
||||
Nothing is fully committed yet. Each slot is allowed to say, "I'm 60% this dark edge stone, 30% the slightly duller one, 10% the safer interior stone." The wall is still trembling, but the trembling starts to synchronize. Temperature drops. The slurry thickens. Shapes that looked independent begin leaning into each other like bones finding their sockets.
|
||||
|
||||
### Measured reality
|
||||
|
||||
The original design goal for `relax` was larger than "soften local choices." It was supposed to let the machine adjust phase smoothly over broad swaths of the canvas instead of only making tiny local corrections.
|
||||
|
||||
The current instrumentation in `continuous.py` says the truth is weaker than that story.
|
||||
|
||||
On the pinned cleaned-badge run at `126x126`, phase `(0.0, -0.2)`, `steps=48`:
|
||||
|
||||
- with `relax_iterations=24`, final source-fidelity lands at `0.07451`
|
||||
- with `relax_iterations=0`, final source-fidelity lands at `0.07549`
|
||||
- so `relax` does help a little
|
||||
|
||||
But the displacement-field diagnostics do **not** show a dramatic large-scale phase-smoothing effect:
|
||||
|
||||
- final orthogonal displacement jitter is `1.7154` with relax versus `1.6783` without it
|
||||
- final local residual is `1.1548` with relax versus `1.1312` without it
|
||||
- final dominant-offset ratio is `0.6296` with relax versus `0.6380` without it
|
||||
|
||||
So the current `relax` earns a small fidelity gain, but it does not look like a true broad-swatch phase transport stage. It behaves more like a soft consensus pass over a fixed tray of local candidates, then hands that consensus to greedy refine.
|
||||
|
||||
## Stage 8: Greedy discrete refine
|
||||
|
||||
Function:
|
||||
|
||||
- `_discrete_refine_output(...)`
|
||||
|
||||
Key variables:
|
||||
|
||||
- `selected`
|
||||
- `best_selected`
|
||||
- `best_score`
|
||||
- `candidate_energy(...)`
|
||||
- `loss_history`
|
||||
|
||||
Meaning:
|
||||
|
||||
Now the optimizer goes cell-by-cell again, but with a richer energy than snap had.
|
||||
|
||||
It starts from:
|
||||
|
||||
- the relaxed handoff selection
|
||||
- the relaxed mode selection
|
||||
|
||||
Then it repeatedly:
|
||||
|
||||
1. computes the current selected colors
|
||||
2. scores every candidate against the current neighborhood
|
||||
3. takes the per-cell argmin
|
||||
4. measures the full structure score of the resulting lattice
|
||||
5. remembers the best discrete state it has seen
|
||||
|
||||
The structure score in `_structure_score(...)` mixes:
|
||||
|
||||
- source-boundary agreement from direct source sampling
|
||||
- adjacency agreement with the snapped anchor
|
||||
- motif agreement with the snapped anchor
|
||||
- line agreement with the snapped anchor
|
||||
- adjacency agreement with the hard source portrait
|
||||
- motif agreement with the hard source portrait
|
||||
- line agreement with the hard source portrait
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
This is the foreman walking the wall in passes, replacing one stone at a time wherever a better local fit appears, but still judging the whole wall by whether the mortar lines and repeated motifs feel right.
|
||||
|
||||
When refine finishes, it compares:
|
||||
|
||||
- the best greedy-refined state
|
||||
- the relaxed mode state
|
||||
|
||||
and keeps whichever one has the better full structure score.
|
||||
|
||||
Then it hardens alpha again before producing the final `target_rgba`.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
`_discrete_refine_output(...)` starts from the relaxed handoff and relaxed mode states, scores both with `_structure_score(...)`, and keeps the better starting point. Then for up to `iterations` passes it computes `candidate_energy(context_colors)` from `refine_base_energy`, pairwise delta terms, optional motif and line terms against the snapped anchor, and optional source-facing motif, line, and adjacency terms against the hard portrait. `_structure_score(...)` separately judges whole-lattice boundary agreement by directly sampling the source around the UV field with `_boundary_pattern_loss(...)`, then adds anchor-facing adjacency/motif/line losses and source-facing adjacency/motif/line losses. Each pass takes the per-cell argmin, scores the full discrete lattice with `_structure_score(...)`, and remembers the best whole-state selection it has seen in `best_selected`. The loop deliberately runs `passes + 1` scoring rounds so the current state is always scored before the next update is applied. At the end it compares that best greedy state against `relaxed_mode_selected`, picks the lower full-structure score, and hardens alpha one more time. After the recent cuts, this stage no longer consults the representative portrait directly; refine answers to the snapped anchor and the hard source portrait.
|
||||
|
||||
### Metaphor
|
||||
|
||||
Now the plaster has set enough that the foreman starts walking the wall with a hammer and a bad attitude.
|
||||
He taps one stone loose, tries another, steps back, judges the seam, repeats. He does not repaint the watercolor sketch anymore. He judges the wall by the actual stone joints: edge runs, repeated motifs, line continuation, whether the neighboring cuts still read like they belong to the same carving instead of a committee.
|
||||
|
||||
## Stage 9: Final sanity check against snap
|
||||
|
||||
Functions:
|
||||
|
||||
- `source_lattice_consistency_breakdown(...)`
|
||||
- final guardrail in `optimize_lattice_pixels(...)`
|
||||
|
||||
Key variables:
|
||||
|
||||
- `snap_score`
|
||||
- `target_score`
|
||||
|
||||
Meaning:
|
||||
|
||||
At the very end, the optimizer asks a brutally practical question:
|
||||
|
||||
Did all this extra cleverness actually make the result more faithful to the source lattice?
|
||||
|
||||
If not, it throws the refined answer away and keeps the snapped one.
|
||||
|
||||
Natural-language picture:
|
||||
|
||||
This is the supervisor looking at the finished wall and saying:
|
||||
|
||||
"Nice theory. But if the first honest stone placement was better, we are keeping that."
|
||||
|
||||
This guardrail matters because the refine stage is absolutely capable of making the wall feel smoother while also making it less true.
|
||||
|
||||
### What the source actually does
|
||||
|
||||
Back in `optimize_lattice_pixels(...)`, the code computes `snap_score` and `target_score` with `source_lattice_consistency_breakdown(...)`. That metric asks how well the output agrees with the inferred source lattice under the chosen size and phase. If `target_score > snap_score + 1e-6`, the refined result is discarded and `target_rgba = snap_rgba`. The machine literally keeps a receipt from before it got clever, then checks whether cleverness paid rent.
|
||||
|
||||
### Metaphor
|
||||
|
||||
This is the last adult in the room.
|
||||
The crew may have spent hours lovingly fussing over the wall, but the supervisor still walks over with a clipboard and says: if your improved version is prettier but less faithful to the stone yard we pulled from, it goes in the bin. We keep the first honest wall.
|
||||
|
||||
## What fits the machine
|
||||
|
||||
These parts fit the optimizer's core story:
|
||||
|
||||
- using a regular inferred lattice as the starting ruler
|
||||
- building a soft representative portrait from local patch samples
|
||||
- building a hard source-assignment portrait from the actual lattice ownership
|
||||
- using per-cell reliability to blend those two portraits instead of always trusting one
|
||||
- forcing snap and refine to choose real source pixels, not synthesized colors
|
||||
- letting the soft representative portrait stabilize snap, then making refine answer only to the snapped anchor and the hard source portrait
|
||||
- keeping snap if refine makes source-lattice fidelity worse
|
||||
|
||||
Those are coherent. They all serve the same broad goal: make one global lattice without drifting too far from real local source evidence.
|
||||
|
||||
## What does not fit the machine cleanly
|
||||
|
||||
These are the places where the optimizer still speaks in an overly complicated or self-contradictory voice:
|
||||
|
||||
- the machine still maintains two overlapping portraits of the same lattice, even though refine is now source-first and only snap still arbitrates between them
|
||||
- snap, relax, and refine all carry slightly different versions of adjacency, motif, line, and delta agreement; the same idea is being said three times with different accents
|
||||
- the candidate generator is local and the solver is discrete, but the vocabulary around it still sounds like a continuous deformation engine
|
||||
- the main entry point is now honestly named `optimize_lattice_pixels(...)`, the k-means boundary scout is gone, prep is split from decision-making, refine no longer consults the representative portrait, and the relax-mode bonus is gone; those cuts removed five contradictions from the previous map
|
||||
|
||||
This is probably the real cutting checklist for the next optimizer pass.
|
||||
|
||||
## The current honest shape
|
||||
|
||||
If we strip the optimizer down to what it is really doing today, the machine is:
|
||||
|
||||
`source image -> edge scout -> fixed lattice centers -> soft portrait + hard portrait -> source-first snap -> soft neighborhood relax -> source-first greedy refine -> keep snap if refine lied`
|
||||
|
||||
That is much simpler than the surrounding names make it sound.
|
||||
|
||||
## Likely next cuts
|
||||
|
||||
If we keep pruning, the highest-value cuts look like this:
|
||||
|
||||
1. Put relax on trial.
|
||||
The soft relaxation stage still seems useful, but the current measurements say it is not really acting like a large-scale phase smoother. The next question is which remaining relax terms are actually earning their keep, and whether the surviving benefit comes from handoff stabilization rather than regional phase drift.
|
||||
2. Collapse duplicate structure voices.
|
||||
Adjacency, motif, and line are important, but they should not need three near-parallel dialects unless they are truly doing different work.
|
||||
3. Decide whether snap still needs both portraits everywhere.
|
||||
Refine is already source-first now, so the next big question is whether snap still needs all of its portrait arbitration machinery or only the parts that genuinely stabilize hard cases.
|
||||
|
||||
That is the next place to swing the axe.
|
||||
27
docs/spec.md
27
docs/spec.md
|
|
@ -10,7 +10,7 @@ The project exists because generated imagery often creates appealing pixel-like
|
|||
- noisy highlights and outlines
|
||||
- fake micro-detail that collapses when downsampled
|
||||
|
||||
Repixelizer treats this as an optimization and reconstruction problem rather than a resizing problem.
|
||||
Repixelizer treats this as lattice inference plus reconstruction rather than a resize problem.
|
||||
|
||||
## Goals
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ The default workflow should be fully automatic:
|
|||
1. load the source image
|
||||
2. infer lattice size and phase
|
||||
3. analyze source structure
|
||||
4. optimize a continuous UV/sample field
|
||||
4. optimize a displacement field over the inferred lattice
|
||||
5. project that result to a real pixel grid
|
||||
6. clean up obvious discrete-grid artifacts
|
||||
7. optionally quantize to a palette
|
||||
|
|
@ -117,32 +117,30 @@ Scoring goals:
|
|||
### 2. Source analysis
|
||||
|
||||
The solver should derive automatic guidance from the image itself:
|
||||
- alpha map
|
||||
- edge map
|
||||
- coarse material/color clustering
|
||||
- alpha-aware source structure
|
||||
|
||||
This guidance is used to preserve important boundaries while allowing smoother regions to collapse into cleaner pixel clusters.
|
||||
This guidance is used to preserve important boundaries while allowing smoother regions to settle into cleaner pixel clusters.
|
||||
|
||||
### 3. Continuous optimization
|
||||
### 3. Phase-field optimization
|
||||
|
||||
The continuous stage represents each output pixel by a source UV/sample coordinate.
|
||||
The optimizer represents each output pixel by a base lattice center plus one learned displacement vector.
|
||||
|
||||
Requirements:
|
||||
- operate in premultiplied RGBA space
|
||||
- use PyTorch autograd
|
||||
- optimize a UV field initialized from the inferred lattice
|
||||
- preserve local adjacency instead of letting the sample field scramble neighborhoods
|
||||
- optimize a displacement field initialized at zero from the inferred lattice
|
||||
- preserve local adjacency instead of letting the sample field collapse or fold
|
||||
|
||||
Loss components should include:
|
||||
- reconstruction loss against the source image
|
||||
- low-distortion regularization against the initialized UV grid
|
||||
- local coherence / solidity so samples settle inside fake-pixel interiors
|
||||
- edge-aware smoothing so hard boundaries stay crisp
|
||||
- anti-speckle pressure to reduce isolated outlier pixels
|
||||
- alpha regularization to avoid muddy semi-transparent edges
|
||||
- anti-collapse spacing so neighboring cells do not stack onto the same source pixel
|
||||
- bounded displacement so the field does not wander off into the weeds
|
||||
|
||||
### 4. Discrete projection and cleanup
|
||||
|
||||
After the continuous stage, the result must be treated as a true pixel grid.
|
||||
After the phase-field stage, the result must be treated as a true pixel grid.
|
||||
|
||||
The discrete stage should:
|
||||
- evaluate local neighborhoods
|
||||
|
|
@ -177,7 +175,6 @@ Required artifacts:
|
|||
- `comparison.png`
|
||||
- `alpha-preview.png`
|
||||
- `noise-heatmap.png`
|
||||
- `cluster-preview.png`
|
||||
|
||||
Compare mode should additionally write:
|
||||
- `compare.json`
|
||||
|
|
|
|||
|
|
@ -6,35 +6,17 @@ from pathlib import Path
|
|||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from repixelizer.analysis import analyze_source
|
||||
from repixelizer.continuous import (
|
||||
_build_candidate_positions,
|
||||
_build_source_detail_reference,
|
||||
_discrete_refine_output,
|
||||
_build_source_reliability,
|
||||
_edge_gradient_maps,
|
||||
_make_patch_offsets,
|
||||
_make_regular_uv,
|
||||
_relax_candidate_selection,
|
||||
_reference_match_energy,
|
||||
_representative_colors,
|
||||
_require_torch,
|
||||
_resolve_device,
|
||||
_sample_cell_patches,
|
||||
_select_colors,
|
||||
_snap_output_to_source_pixels,
|
||||
premultiply,
|
||||
)
|
||||
from repixelizer.analysis import analyze_phase_field_source
|
||||
from repixelizer.inference import infer_lattice
|
||||
from repixelizer.io import load_rgba
|
||||
from repixelizer.metrics import source_lattice_consistency_breakdown
|
||||
from repixelizer.params import SolverHyperParams
|
||||
from repixelizer.source_reference import build_source_lattice_reference
|
||||
from repixelizer.phase_field import optimize_phase_field
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render a source/snap/relaxed/final crop sheet for a fixed output-grid region."
|
||||
description="Render a source/initial/final crop sheet for a fixed output-grid region."
|
||||
)
|
||||
parser.add_argument("--input", required=True, help="Input image path.")
|
||||
parser.add_argument("--out", required=True, help="Output PNG path.")
|
||||
|
|
@ -48,11 +30,11 @@ def _parse_args() -> argparse.Namespace:
|
|||
)
|
||||
parser.add_argument(
|
||||
"--panels",
|
||||
default="source,snap,relaxed,final",
|
||||
help="Comma-separated panel list. Supported: source,snap,relaxed,final",
|
||||
default="source,initial,final",
|
||||
help="Comma-separated panel list. Supported: source,initial,final. Legacy aliases: snap=initial, relaxed=final.",
|
||||
)
|
||||
parser.add_argument("--scale", type=int, default=16, help="Nearest-neighbor scale factor for each output cell.")
|
||||
parser.add_argument("--steps", type=int, default=64, help="Discrete refine iteration budget.")
|
||||
parser.add_argument("--steps", type=int, default=48, help="Phase-field iteration budget.")
|
||||
parser.add_argument("--device", default="auto", help="Torch device, usually auto/cpu/cuda.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
|
@ -93,215 +75,42 @@ def _build_focus_states(
|
|||
*,
|
||||
steps: int,
|
||||
device: str,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, tuple[float, float], dict[str, dict[str, float]]]:
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[float, float], dict[str, dict[str, float]]]:
|
||||
source = load_rgba(input_path)
|
||||
solver_params = SolverHyperParams()
|
||||
inference = infer_lattice(source, None, device=device)
|
||||
analysis = analyze_source(source, seed=7)
|
||||
inference = infer_lattice(source, device=device)
|
||||
analysis = analyze_phase_field_source(source, seed=7, device=device)
|
||||
artifacts = optimize_phase_field(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
steps=steps,
|
||||
seed=7,
|
||||
device=device,
|
||||
solver_params=SolverHyperParams(),
|
||||
)
|
||||
|
||||
torch, F = _require_torch()
|
||||
resolved_device = _resolve_device(torch, device)
|
||||
|
||||
premul = premultiply(source)
|
||||
height, width = premul.shape[:2]
|
||||
height, width = source.shape[:2]
|
||||
cell_x = width / max(1, inference.target_width)
|
||||
cell_y = height / max(1, inference.target_height)
|
||||
source_t = torch.from_numpy(premul.transpose(2, 0, 1)[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
uv0 = _make_regular_uv(
|
||||
height=height,
|
||||
width=width,
|
||||
target_height=inference.target_height,
|
||||
target_width=inference.target_width,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
)
|
||||
uv0_t = torch.from_numpy(uv0[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
offsets_t = torch.from_numpy(
|
||||
_make_patch_offsets(height=height, width=width, target_height=inference.target_height, target_width=inference.target_width)
|
||||
).to(device=resolved_device, dtype=torch.float32)
|
||||
source_edge = analysis.edge_map.astype(np.float32)
|
||||
edge_grad_x, edge_grad_y = _edge_gradient_maps(source_edge)
|
||||
source_lattice_reference = build_source_lattice_reference(
|
||||
source,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
alpha_threshold=solver_params.alpha_transparent_threshold,
|
||||
edge_hint=source_edge,
|
||||
edge_grad_x_hint=edge_grad_x,
|
||||
edge_grad_y_hint=edge_grad_y,
|
||||
)
|
||||
source_detail_reference = _build_source_detail_reference(source, source_lattice_reference, solver_params)
|
||||
initial_patches = _sample_cell_patches(F, source_t, uv0_t, offsets_t)
|
||||
representative_t, _ = _representative_colors(initial_patches, solver_params)
|
||||
representative_t = representative_t.detach()
|
||||
source_reference_t = torch.from_numpy(premultiply(source_detail_reference)[None, ...]).to(
|
||||
device=resolved_device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
source_reliability_t = torch.from_numpy(_build_source_reliability(source_lattice_reference, solver_params)[None, ...]).to(
|
||||
device=resolved_device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
source_delta_x_t = (
|
||||
torch.from_numpy((premultiply(source_detail_reference)[:, 1:, :] - premultiply(source_detail_reference)[:, :-1, :])[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
if inference.target_width > 1
|
||||
else None
|
||||
)
|
||||
source_delta_y_t = (
|
||||
torch.from_numpy((premultiply(source_detail_reference)[1:, :, :] - premultiply(source_detail_reference)[:-1, :, :])[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
if inference.target_height > 1
|
||||
else None
|
||||
)
|
||||
source_delta_diag_t = (
|
||||
torch.from_numpy((premultiply(source_detail_reference)[1:, 1:, :] - premultiply(source_detail_reference)[:-1, :-1, :])[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
if inference.target_height > 1 and inference.target_width > 1
|
||||
else None
|
||||
)
|
||||
source_delta_anti_t = (
|
||||
torch.from_numpy((premultiply(source_detail_reference)[1:, :-1, :] - premultiply(source_detail_reference)[:-1, 1:, :])[None, ...]).to(device=resolved_device, dtype=torch.float32)
|
||||
if inference.target_height > 1 and inference.target_width > 1
|
||||
else None
|
||||
)
|
||||
|
||||
snap_rgba = _snap_output_to_source_pixels(
|
||||
torch,
|
||||
source_t,
|
||||
uv0_t,
|
||||
representative_t,
|
||||
source_reference_t,
|
||||
source_reliability_t,
|
||||
source_lattice_reference,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
)
|
||||
snap_t = torch.from_numpy(premultiply(snap_rgba).transpose(2, 0, 1)[None, ...]).to(
|
||||
device=resolved_device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
source_hw = source_t[0].permute(1, 2, 0)
|
||||
uv = uv0_t[0]
|
||||
representative = representative_t[0]
|
||||
anchor = snap_t[0].permute(1, 2, 0)
|
||||
output_height = representative.shape[0]
|
||||
output_width = representative.shape[1]
|
||||
|
||||
candidate_levels = max(3, int(solver_params.refine_candidate_levels))
|
||||
if candidate_levels % 2 == 0:
|
||||
candidate_levels += 1
|
||||
candidate_extent = max(0.05, float(solver_params.refine_candidate_extent))
|
||||
fraction_values = np.linspace(-candidate_extent, candidate_extent, candidate_levels, dtype=np.float32)
|
||||
candidate_x, candidate_y, candidate_offset_x, candidate_offset_y = _build_candidate_positions(
|
||||
torch,
|
||||
uv,
|
||||
source_lattice_reference,
|
||||
solver_params,
|
||||
width=width,
|
||||
height=height,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
base_fraction_values=fraction_values,
|
||||
)
|
||||
candidate_colors = source_hw[candidate_y, candidate_x]
|
||||
anchor_energy = (candidate_colors - anchor[..., None, :]).abs().mean(dim=-1)
|
||||
rep_energy = (candidate_colors - representative[..., None, :]).abs().mean(dim=-1)
|
||||
source_reference_energy = (candidate_colors - source_reference_t[0][..., None, :]).abs().mean(dim=-1)
|
||||
alpha_energy = (candidate_colors[..., 3] - anchor[..., None, 3]).abs()
|
||||
distance_energy = (candidate_offset_x / max(cell_x, 1e-4)).square() + (candidate_offset_y / max(cell_y, 1e-4)).square()
|
||||
match_energy = _reference_match_energy(
|
||||
rep_energy,
|
||||
source_reference_energy,
|
||||
source_reliability_t[0],
|
||||
representative_weight=solver_params.refine_representative_match_weight,
|
||||
source_weight=solver_params.refine_source_match_weight,
|
||||
)
|
||||
relax_base_energy = (
|
||||
anchor_energy * solver_params.refine_anchor_weight * solver_params.relax_anchor_scale
|
||||
+ match_energy * solver_params.refine_representative_weight
|
||||
+ alpha_energy * solver_params.refine_alpha_weight
|
||||
+ distance_energy * solver_params.refine_distance_weight
|
||||
)
|
||||
|
||||
anchor_delta_x = anchor[:, 1:, :] - anchor[:, :-1, :] if output_width > 1 else None
|
||||
anchor_delta_y = anchor[1:, :, :] - anchor[:-1, :, :] if output_height > 1 else None
|
||||
anchor_delta_diag = anchor[1:, 1:, :] - anchor[:-1, :-1, :] if output_height > 1 and output_width > 1 else None
|
||||
anchor_delta_anti = anchor[1:, :-1, :] - anchor[:-1, 1:, :] if output_height > 1 and output_width > 1 else None
|
||||
source_weight = solver_params.refine_source_delta_weight
|
||||
anchor_weight = 1.0 - source_weight
|
||||
|
||||
def blend(anchor_delta: torch.Tensor | None, source_delta_t: torch.Tensor | None) -> torch.Tensor | None:
|
||||
if anchor_delta is None:
|
||||
return None
|
||||
if source_delta_t is None:
|
||||
return anchor_delta
|
||||
return anchor_delta * anchor_weight + source_delta_t[0] * source_weight
|
||||
|
||||
selected, _, _, _ = _relax_candidate_selection(
|
||||
torch,
|
||||
candidate_colors,
|
||||
relax_base_energy,
|
||||
anchor,
|
||||
source_reference_t[0],
|
||||
blend(anchor_delta_x, source_delta_x_t),
|
||||
blend(anchor_delta_y, source_delta_y_t),
|
||||
blend(anchor_delta_diag, source_delta_diag_t),
|
||||
blend(anchor_delta_anti, source_delta_anti_t),
|
||||
solver_params,
|
||||
iterations=solver_params.relax_iterations,
|
||||
)
|
||||
relaxed_rgba = _select_colors(candidate_colors, selected).detach().cpu().numpy()
|
||||
|
||||
final_rgba, _ = _discrete_refine_output(
|
||||
torch,
|
||||
F,
|
||||
source_t,
|
||||
uv0_t,
|
||||
representative_t,
|
||||
source_reference_t,
|
||||
source_reliability_t,
|
||||
source_lattice_reference,
|
||||
snap_t,
|
||||
source_delta_x_t,
|
||||
source_delta_y_t,
|
||||
source_delta_diag_t,
|
||||
source_delta_anti_t,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
iterations=steps,
|
||||
)
|
||||
fidelity = {}
|
||||
fidelity["snap"] = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
snap_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
)
|
||||
fidelity["relaxed"] = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
relaxed_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
)
|
||||
fidelity["final"] = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
final_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
)
|
||||
if fidelity["final"]["score"] > fidelity["snap"]["score"] + 1e-6:
|
||||
final_rgba = snap_rgba
|
||||
fidelity["final"] = fidelity["snap"]
|
||||
return source, snap_rgba, relaxed_rgba, final_rgba, (cell_x, cell_y), fidelity
|
||||
fidelity = {
|
||||
"initial": source_lattice_consistency_breakdown(
|
||||
source,
|
||||
artifacts.initial_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
),
|
||||
"final": source_lattice_consistency_breakdown(
|
||||
source,
|
||||
artifacts.target_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
),
|
||||
}
|
||||
return source, artifacts.initial_rgba, artifacts.target_rgba, (cell_x, cell_y), fidelity
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
@ -310,13 +119,21 @@ def main() -> None:
|
|||
if x1 <= x0 or y1 <= y0:
|
||||
raise SystemExit("Invalid --cell-bbox: expected X1 > X0 and Y1 > Y0.")
|
||||
|
||||
source, snap_rgba, relaxed_rgba, final_rgba, (cell_x, cell_y), fidelity = _build_focus_states(
|
||||
source, initial_rgba, final_rgba, (cell_x, cell_y), fidelity = _build_focus_states(
|
||||
args.input,
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
)
|
||||
|
||||
panel_names = [name.strip().lower() for name in args.panels.split(",") if name.strip()]
|
||||
normalized_panel_names = []
|
||||
for name in (token.strip().lower() for token in args.panels.split(",") if token.strip()):
|
||||
if name == "snap":
|
||||
normalized_panel_names.append("initial")
|
||||
elif name == "relaxed":
|
||||
normalized_panel_names.append("final")
|
||||
else:
|
||||
normalized_panel_names.append(name)
|
||||
|
||||
panels: list[Image.Image] = []
|
||||
source_bbox = (
|
||||
int(np.floor(x0 * cell_x)),
|
||||
|
|
@ -327,11 +144,10 @@ def main() -> None:
|
|||
target_size = (x1 - x0, y1 - y0)
|
||||
panel_map = {
|
||||
"source": _source_panel(source, source_bbox=source_bbox, target_size=target_size, scale=args.scale),
|
||||
"snap": _grid_panel(snap_rgba, "Snap", x0=x0, y0=y0, x1=x1, y1=y1, scale=args.scale),
|
||||
"relaxed": _grid_panel(relaxed_rgba, "Relaxed", x0=x0, y0=y0, x1=x1, y1=y1, scale=args.scale),
|
||||
"initial": _grid_panel(initial_rgba, "Initial", x0=x0, y0=y0, x1=x1, y1=y1, scale=args.scale),
|
||||
"final": _grid_panel(final_rgba, "Final", x0=x0, y0=y0, x1=x1, y1=y1, scale=args.scale),
|
||||
}
|
||||
for name in panel_names:
|
||||
for name in normalized_panel_names:
|
||||
if name not in panel_map:
|
||||
raise SystemExit(f"Unsupported panel name: {name}")
|
||||
panels.append(panel_map[name])
|
||||
|
|
@ -350,7 +166,7 @@ def main() -> None:
|
|||
{
|
||||
"cell_bbox": [x0, y0, x1, y1],
|
||||
"source_bbox": list(source_bbox),
|
||||
"panels": panel_names,
|
||||
"panels": normalized_panel_names,
|
||||
"scale": args.scale,
|
||||
"source_fidelity": fidelity,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import numpy as np
|
||||
|
||||
from .metrics import luminance
|
||||
from .types import ContinuousSourceAnalysis, TileGraphSourceAnalysis
|
||||
from .types import PhaseFieldSourceAnalysis, TileGraphSourceAnalysis
|
||||
|
||||
|
||||
def _require_torch():
|
||||
|
|
@ -55,23 +55,23 @@ def _compute_edge_map_torch(torch, rgba_t):
|
|||
return edge.to(dtype=torch.float32)
|
||||
|
||||
|
||||
def analyze_continuous_source(
|
||||
def analyze_phase_field_source(
|
||||
rgba: np.ndarray,
|
||||
seed: int,
|
||||
device: str | None = None,
|
||||
) -> ContinuousSourceAnalysis:
|
||||
) -> PhaseFieldSourceAnalysis:
|
||||
del seed
|
||||
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 ContinuousSourceAnalysis(
|
||||
return PhaseFieldSourceAnalysis(
|
||||
edge_map=edge_map_t.detach().cpu().numpy().astype(np.float32),
|
||||
)
|
||||
|
||||
edge_map = _compute_edge_map(rgba)
|
||||
return ContinuousSourceAnalysis(
|
||||
return PhaseFieldSourceAnalysis(
|
||||
edge_map=edge_map,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
target.add_argument("--device", default="auto", choices=("auto", "cpu", "cuda"), help="Torch device")
|
||||
target.add_argument(
|
||||
"--reconstruction-mode",
|
||||
default="continuous",
|
||||
choices=("continuous", "phase-field", "tile-graph"),
|
||||
default="phase-field",
|
||||
choices=("phase-field", "tile-graph"),
|
||||
help="Reconstruction engine to run after lattice estimation",
|
||||
)
|
||||
target.add_argument(
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def run_compare(
|
|||
steps: int = 200,
|
||||
device: str = "auto",
|
||||
strip_background: bool = False,
|
||||
reconstruction_mode: str = "continuous",
|
||||
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("")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -18,59 +18,7 @@ class SolverHyperParams:
|
|||
phase_field_magnitude_weight: float = 0.08
|
||||
phase_field_learning_rate: float = 0.10
|
||||
phase_field_max_displacement_ratio: float = 0.48
|
||||
representative_softmax_scale: float = 18.0
|
||||
source_edge_reliability_gain: float = 1.6
|
||||
source_edge_reliability_floor: float = 0.25
|
||||
source_edge_alpha_floor: float = 0.45
|
||||
source_edge_detail_mix: float = 0.65
|
||||
source_edge_detail_threshold: float = 0.25
|
||||
boundary_probe_scale: float = 0.22
|
||||
boundary_signed_weight: float = 0.55
|
||||
boundary_direction_weight: float = 0.30
|
||||
boundary_magnitude_weight: float = 0.15
|
||||
snap_base_match_weight: float = 0.65
|
||||
snap_representative_match_weight: float = 0.15
|
||||
snap_source_match_weight: float = 0.85
|
||||
snap_neighbor_weight: float = 0.35
|
||||
snap_diagonal_weight: float = 0.14
|
||||
snap_representative_delta_weight: float = 0.20
|
||||
snap_source_delta_weight: float = 0.80
|
||||
refine_anchor_weight: float = 0.60
|
||||
refine_source_weight: float = 0.20
|
||||
refine_alpha_weight: float = 0.10
|
||||
refine_distance_weight: float = 0.10
|
||||
refine_source_delta_weight: float = 0.25
|
||||
refine_orthogonal_weight: float = 0.24
|
||||
refine_diagonal_weight: float = 0.08
|
||||
refine_motif_weight: float = 0.12
|
||||
refine_line_weight: float = 0.10
|
||||
refine_candidate_extent: float = 0.70
|
||||
refine_candidate_levels: int = 7
|
||||
guided_candidate_edge_threshold: float = 0.06
|
||||
guided_candidate_inner_scale: float = 0.32
|
||||
guided_candidate_outer_scale: float = 0.60
|
||||
relax_iterations: int = 24
|
||||
relax_start_temperature: float = 0.95
|
||||
relax_end_temperature: float = 0.18
|
||||
relax_damping: float = 0.10
|
||||
relax_anchor_scale: float = 0.50
|
||||
relax_orthogonal_weight: float = 0.34
|
||||
relax_diagonal_weight: float = 0.12
|
||||
relax_source_adjacency_weight: float = 0.35
|
||||
relax_motif_weight: float = 0.22
|
||||
relax_line_weight: float = 0.06
|
||||
relax_source_motif_weight: float = 0.30
|
||||
relax_source_line_weight: float = 0.12
|
||||
relax_handoff_weight: float = 0.18
|
||||
structure_boundary_weight: float = 0.60
|
||||
structure_anchor_adjacency_weight: float = 0.15
|
||||
structure_anchor_motif_weight: float = 0.20
|
||||
structure_anchor_line_weight: float = 0.10
|
||||
structure_source_adjacency_weight: float = 0.35
|
||||
structure_source_motif_weight: float = 0.40
|
||||
structure_source_line_weight: float = 0.20
|
||||
alpha_foreground_threshold: float = 0.50
|
||||
alpha_representative_foreground_threshold: float = 0.60
|
||||
alpha_opaque_threshold: float = 0.95
|
||||
alpha_transparent_threshold: float = 0.05
|
||||
phase_rerank_support_weight: float = 0.45
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|||
import numpy as np
|
||||
|
||||
from .io import premultiply
|
||||
from .types import ContinuousSourceAnalysis, InferenceResult, SolverArtifacts
|
||||
from .types import InferenceResult, PhaseFieldSourceAnalysis, SolverArtifacts
|
||||
from .params import SolverHyperParams
|
||||
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ def _prepare_phase_field(
|
|||
F,
|
||||
rgba: np.ndarray,
|
||||
inference: InferenceResult,
|
||||
analysis: ContinuousSourceAnalysis,
|
||||
analysis: PhaseFieldSourceAnalysis,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
device: str,
|
||||
|
|
@ -379,7 +379,7 @@ def _nearest_source_rgba(source_rgba: np.ndarray, sample_x: np.ndarray, sample_y
|
|||
def optimize_phase_field(
|
||||
rgba: np.ndarray,
|
||||
inference: InferenceResult,
|
||||
analysis: ContinuousSourceAnalysis,
|
||||
analysis: PhaseFieldSourceAnalysis,
|
||||
steps: int,
|
||||
seed: int,
|
||||
device: str,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ from typing import Any
|
|||
|
||||
import numpy as np
|
||||
|
||||
from .analysis import analyze_continuous_source, analyze_tile_graph_source
|
||||
from .continuous import optimize_lattice_pixels
|
||||
from .analysis import analyze_phase_field_source, analyze_tile_graph_source
|
||||
from .diagnostics import (
|
||||
summarize_run,
|
||||
write_alpha_preview,
|
||||
|
|
@ -31,7 +30,7 @@ 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 ContinuousSourceAnalysis, InferenceResult, RunResult, TileGraphSourceAnalysis
|
||||
from .types import InferenceResult, PhaseFieldSourceAnalysis, RunResult, TileGraphSourceAnalysis
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
|
|
@ -51,7 +50,7 @@ def run_pipeline(
|
|||
device: str = "auto",
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
strip_background: bool = False,
|
||||
reconstruction_mode: str = "continuous",
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> RunResult:
|
||||
started = time.perf_counter()
|
||||
|
|
@ -191,10 +190,10 @@ def _prepare_analysis(
|
|||
seed: int,
|
||||
device: str,
|
||||
reconstruction_mode: str,
|
||||
) -> ContinuousSourceAnalysis | TileGraphSourceAnalysis:
|
||||
) -> PhaseFieldSourceAnalysis | TileGraphSourceAnalysis:
|
||||
if reconstruction_mode == "tile-graph":
|
||||
return analyze_tile_graph_source(source, device=device)
|
||||
return analyze_continuous_source(source, seed=seed)
|
||||
return analyze_phase_field_source(source, seed=seed, device=device)
|
||||
|
||||
|
||||
def _select_phase_candidate(
|
||||
|
|
@ -205,7 +204,7 @@ def _select_phase_candidate(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
reconstruction_mode: str = "continuous",
|
||||
reconstruction_mode: str = "phase-field",
|
||||
enable_phase_rerank: bool = True,
|
||||
) -> InferenceResult:
|
||||
return _select_phase_candidate_with_reconstruction(
|
||||
|
|
@ -228,13 +227,13 @@ def _select_phase_candidate_with_reconstruction(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
reconstruction_mode: str = "continuous",
|
||||
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 != "continuous":
|
||||
if reconstruction_mode != "phase-field":
|
||||
return inference
|
||||
if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.phase_rerank_confidence_threshold:
|
||||
return inference
|
||||
|
|
@ -451,20 +450,12 @@ def _run_reconstruction(
|
|||
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 solver.stage_diagnostics.get("phase_field", {}).items()
|
||||
for key, value in phase_field_metrics.items()
|
||||
},
|
||||
}
|
||||
solver = optimize_lattice_pixels(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analysis,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
)
|
||||
return solver, {"mode": "continuous"}
|
||||
raise ValueError(f"Unknown reconstruction mode: {reconstruction_mode}")
|
||||
|
|
|
|||
|
|
@ -38,17 +38,6 @@ def _score_summary(summary: dict[str, Any]) -> dict[str, float]:
|
|||
}
|
||||
|
||||
|
||||
def _normalize_group(values: np.ndarray) -> np.ndarray:
|
||||
values = np.clip(values.astype(np.float64), 1e-4, None)
|
||||
return values / np.sum(values)
|
||||
|
||||
|
||||
def _mutate_group(rng: np.random.Generator, values: list[float], scale: float) -> np.ndarray:
|
||||
base = np.asarray(values, dtype=np.float64)
|
||||
noise = np.exp(rng.normal(0.0, 0.28 * scale, size=base.shape))
|
||||
return _normalize_group(base * noise)
|
||||
|
||||
|
||||
def _mutate_positive(
|
||||
rng: np.random.Generator,
|
||||
value: float,
|
||||
|
|
@ -75,162 +64,160 @@ def _mutate_linear(
|
|||
|
||||
|
||||
def _mutate_params(base: SolverHyperParams, rng: np.random.Generator, scale: float) -> SolverHyperParams:
|
||||
refine_group = _mutate_group(
|
||||
rng,
|
||||
[
|
||||
base.refine_anchor_weight,
|
||||
base.refine_source_weight,
|
||||
base.refine_alpha_weight,
|
||||
base.refine_distance_weight,
|
||||
],
|
||||
scale,
|
||||
)
|
||||
structure_group = _mutate_group(
|
||||
rng,
|
||||
[
|
||||
base.structure_boundary_weight,
|
||||
base.structure_anchor_adjacency_weight,
|
||||
base.structure_anchor_motif_weight,
|
||||
base.structure_anchor_line_weight,
|
||||
],
|
||||
scale,
|
||||
)
|
||||
return replace(
|
||||
base,
|
||||
representative_softmax_scale=_mutate_positive(
|
||||
phase_field_patch_extent=_mutate_linear(
|
||||
rng,
|
||||
base.representative_softmax_scale,
|
||||
base.phase_field_patch_extent,
|
||||
scale=scale,
|
||||
low=8.0,
|
||||
high=36.0,
|
||||
),
|
||||
boundary_probe_scale=_mutate_linear(
|
||||
rng,
|
||||
base.boundary_probe_scale,
|
||||
scale=scale,
|
||||
sigma=0.03,
|
||||
low=0.14,
|
||||
high=0.32,
|
||||
),
|
||||
refine_anchor_weight=float(refine_group[0]),
|
||||
refine_source_weight=float(refine_group[1]),
|
||||
refine_alpha_weight=float(refine_group[2]),
|
||||
refine_distance_weight=float(refine_group[3]),
|
||||
refine_source_delta_weight=_mutate_linear(
|
||||
rng,
|
||||
base.refine_source_delta_weight,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
sigma=0.035,
|
||||
low=0.05,
|
||||
high=0.45,
|
||||
),
|
||||
refine_orthogonal_weight=_mutate_linear(
|
||||
rng,
|
||||
base.refine_orthogonal_weight,
|
||||
scale=scale,
|
||||
sigma=0.05,
|
||||
low=0.10,
|
||||
high=0.40,
|
||||
),
|
||||
refine_diagonal_weight=_mutate_linear(
|
||||
rng,
|
||||
base.refine_diagonal_weight,
|
||||
scale=scale,
|
||||
sigma=0.025,
|
||||
low=0.02,
|
||||
high=0.18,
|
||||
),
|
||||
refine_motif_weight=_mutate_linear(
|
||||
rng,
|
||||
base.refine_motif_weight,
|
||||
scale=scale,
|
||||
sigma=0.05,
|
||||
low=0.02,
|
||||
high=0.40,
|
||||
),
|
||||
refine_line_weight=_mutate_linear(
|
||||
rng,
|
||||
base.refine_line_weight,
|
||||
scale=scale,
|
||||
sigma=0.04,
|
||||
low=0.02,
|
||||
high=0.30,
|
||||
),
|
||||
relax_anchor_scale=_mutate_linear(
|
||||
rng,
|
||||
base.relax_anchor_scale,
|
||||
scale=scale,
|
||||
sigma=0.16,
|
||||
low=0.15,
|
||||
high=1.0,
|
||||
),
|
||||
relax_orthogonal_weight=_mutate_linear(
|
||||
rng,
|
||||
base.relax_orthogonal_weight,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.10,
|
||||
high=0.70,
|
||||
),
|
||||
relax_diagonal_weight=_mutate_linear(
|
||||
rng,
|
||||
base.relax_diagonal_weight,
|
||||
scale=scale,
|
||||
sigma=0.05,
|
||||
low=0.02,
|
||||
high=0.35,
|
||||
),
|
||||
relax_source_adjacency_weight=_mutate_linear(
|
||||
phase_field_data_coherence_weight=_mutate_positive(
|
||||
rng,
|
||||
base.relax_source_adjacency_weight,
|
||||
base.phase_field_data_coherence_weight,
|
||||
scale=scale,
|
||||
sigma=0.10,
|
||||
low=0.05,
|
||||
high=0.80,
|
||||
low=0.15,
|
||||
high=3.0,
|
||||
),
|
||||
relax_source_motif_weight=_mutate_linear(
|
||||
phase_field_data_edge_weight=_mutate_positive(
|
||||
rng,
|
||||
base.relax_source_motif_weight,
|
||||
base.phase_field_data_edge_weight,
|
||||
scale=scale,
|
||||
sigma=0.10,
|
||||
low=0.05,
|
||||
high=0.80,
|
||||
high=2.5,
|
||||
),
|
||||
relax_source_line_weight=_mutate_linear(
|
||||
phase_field_data_center_edge_weight=_mutate_positive(
|
||||
rng,
|
||||
base.relax_source_line_weight,
|
||||
base.phase_field_data_center_edge_weight,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.02,
|
||||
low=0.10,
|
||||
high=3.0,
|
||||
),
|
||||
phase_field_spacing_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_spacing_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_field_smoothness_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_smoothness_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.2,
|
||||
),
|
||||
phase_field_edge_gate_strength=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_edge_gate_strength,
|
||||
scale=scale,
|
||||
low=1.0,
|
||||
high=30.0,
|
||||
),
|
||||
phase_field_collapse_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_collapse_weight,
|
||||
scale=scale,
|
||||
low=0.05,
|
||||
high=4.0,
|
||||
),
|
||||
phase_field_min_spacing_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.phase_field_min_spacing_ratio,
|
||||
scale=scale,
|
||||
sigma=0.05,
|
||||
low=0.01,
|
||||
high=0.45,
|
||||
),
|
||||
structure_boundary_weight=float(structure_group[0]),
|
||||
structure_anchor_adjacency_weight=float(structure_group[1]),
|
||||
structure_anchor_motif_weight=float(structure_group[2]),
|
||||
structure_anchor_line_weight=float(structure_group[3]),
|
||||
structure_source_adjacency_weight=_mutate_linear(
|
||||
phase_field_max_spacing_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.structure_source_adjacency_weight,
|
||||
base.phase_field_max_spacing_ratio,
|
||||
scale=scale,
|
||||
sigma=0.12,
|
||||
low=0.05,
|
||||
high=0.90,
|
||||
low=0.8,
|
||||
high=1.8,
|
||||
),
|
||||
structure_source_motif_weight=_mutate_linear(
|
||||
phase_field_magnitude_weight=_mutate_positive(
|
||||
rng,
|
||||
base.structure_source_motif_weight,
|
||||
base.phase_field_magnitude_weight,
|
||||
scale=scale,
|
||||
sigma=0.12,
|
||||
low=0.05,
|
||||
high=0.90,
|
||||
low=0.001,
|
||||
high=0.5,
|
||||
),
|
||||
structure_source_line_weight=_mutate_linear(
|
||||
phase_field_learning_rate=_mutate_positive(
|
||||
rng,
|
||||
base.structure_source_line_weight,
|
||||
base.phase_field_learning_rate,
|
||||
scale=scale,
|
||||
low=0.005,
|
||||
high=0.5,
|
||||
),
|
||||
phase_field_max_displacement_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.phase_field_max_displacement_ratio,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.02,
|
||||
high=0.50,
|
||||
low=0.10,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_support_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_support_weight,
|
||||
scale=scale,
|
||||
low=0.05,
|
||||
high=1.5,
|
||||
),
|
||||
phase_rerank_edge_position_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_edge_position_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_wobble_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_wobble_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_edge_concentration_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_edge_concentration_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_size_penalty_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_size_penalty_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_inference_penalty_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_inference_penalty_weight,
|
||||
scale=scale,
|
||||
low=0.0,
|
||||
high=0.5,
|
||||
),
|
||||
phase_rerank_confidence_threshold=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_confidence_threshold,
|
||||
scale=scale,
|
||||
sigma=0.035,
|
||||
low=0.0,
|
||||
high=0.5,
|
||||
),
|
||||
phase_rerank_max_size_delta_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_max_size_delta_ratio,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.05,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_margin=_mutate_linear(
|
||||
rng,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class InferenceResult:
|
|||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContinuousSourceAnalysis:
|
||||
class PhaseFieldSourceAnalysis:
|
||||
edge_map: np.ndarray
|
||||
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ class TileGraphSourceAnalysis:
|
|||
edge_map: np.ndarray
|
||||
|
||||
|
||||
SourceAnalysis = ContinuousSourceAnalysis | TileGraphSourceAnalysis
|
||||
SourceAnalysis = PhaseFieldSourceAnalysis | TileGraphSourceAnalysis
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -86,7 +86,7 @@ class RunResult:
|
|||
source_rgba: np.ndarray
|
||||
output_rgba: np.ndarray
|
||||
inference: InferenceResult
|
||||
analysis: ContinuousSourceAnalysis | TileGraphSourceAnalysis
|
||||
analysis: PhaseFieldSourceAnalysis | TileGraphSourceAnalysis
|
||||
solver: SolverArtifacts
|
||||
cleanup: CleanupArtifacts
|
||||
palette_result: PaletteResult | None
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import numpy as np
|
||||
|
||||
from repixelizer.analysis import analyze_continuous_source, analyze_tile_graph_source
|
||||
from repixelizer.analysis import analyze_phase_field_source, analyze_tile_graph_source
|
||||
|
||||
|
||||
def test_continuous_source_analysis_cpu_device_matches_default_path() -> None:
|
||||
def test_phase_field_source_analysis_cpu_device_matches_default_path() -> 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)
|
||||
|
||||
default = analyze_continuous_source(source, seed=7)
|
||||
accelerated = analyze_continuous_source(source, seed=7, device="cpu")
|
||||
default = analyze_phase_field_source(source, seed=7)
|
||||
accelerated = analyze_phase_field_source(source, seed=7, device="cpu")
|
||||
|
||||
assert np.allclose(accelerated.edge_map, default.edge_map, atol=1e-5)
|
||||
assert not hasattr(accelerated, "cluster_map")
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from repixelizer.analysis import analyze_continuous_source
|
||||
from repixelizer.continuous import (
|
||||
_build_candidate_positions,
|
||||
_build_source_reliability,
|
||||
_line_pattern_loss,
|
||||
_make_regular_uv,
|
||||
optimize_lattice_pixels,
|
||||
)
|
||||
from repixelizer.metrics import source_lattice_consistency_breakdown
|
||||
from repixelizer.params import SolverHyperParams
|
||||
from repixelizer.source_reference import build_source_lattice_reference
|
||||
from repixelizer.synthetic import fake_pixelize
|
||||
from repixelizer.types import InferenceResult
|
||||
|
||||
|
||||
def test_line_pattern_loss_penalizes_wobbling_line() -> None:
|
||||
reference = torch.zeros((1, 5, 5, 4), dtype=torch.float32)
|
||||
reference[:, :, 2, :] = 1.0
|
||||
|
||||
good = reference.clone()
|
||||
bad = reference.clone()
|
||||
bad[:, 2, 2, :] = 0.0
|
||||
bad[:, 2, 3, :] = 1.0
|
||||
|
||||
good_loss = float(_line_pattern_loss(torch, good, reference).item())
|
||||
bad_loss = float(_line_pattern_loss(torch, bad, reference).item())
|
||||
|
||||
assert good_loss <= 1e-6
|
||||
assert bad_loss > good_loss + 0.01
|
||||
|
||||
|
||||
def test_source_reliability_stays_high_for_high_contrast_edge_cells() -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[:, 1, :3] = 1.0
|
||||
|
||||
analysis = analyze_continuous_source(source, seed=3)
|
||||
reference = build_source_lattice_reference(
|
||||
source,
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
edge_hint=analysis.edge_map,
|
||||
edge_grad_x_hint=np.gradient(analysis.edge_map, axis=1).astype(np.float32),
|
||||
edge_grad_y_hint=np.gradient(analysis.edge_map, axis=0).astype(np.float32),
|
||||
)
|
||||
|
||||
reliability = _build_source_reliability(reference, SolverHyperParams())
|
||||
|
||||
assert reference.cell_dispersion[1, 0] > reference.dispersion
|
||||
assert reference.edge_strength[1, 0] > 0.0
|
||||
assert reliability[1, 0] >= 0.7
|
||||
|
||||
|
||||
def test_candidate_positions_include_sharp_and_edge_guided_samples() -> None:
|
||||
source = np.zeros((6, 6, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[1:5, 2, :3] = 1.0
|
||||
|
||||
edge_hint = np.zeros((6, 6), dtype=np.float32)
|
||||
edge_hint[1:5, 2] = 1.0
|
||||
edge_grad_x = np.zeros((6, 6), dtype=np.float32)
|
||||
edge_grad_x[1:5, 2] = 1.0
|
||||
edge_grad_y = np.zeros((6, 6), dtype=np.float32)
|
||||
|
||||
reference = build_source_lattice_reference(
|
||||
source,
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
edge_hint=edge_hint,
|
||||
edge_grad_x_hint=edge_grad_x,
|
||||
edge_grad_y_hint=edge_grad_y,
|
||||
)
|
||||
uv = torch.from_numpy(_make_regular_uv(6, 6, 2, 2, 0.0, 0.0)).to(dtype=torch.float32)
|
||||
|
||||
candidate_x, candidate_y, _, _ = _build_candidate_positions(
|
||||
torch,
|
||||
uv,
|
||||
reference,
|
||||
SolverHyperParams(),
|
||||
width=6,
|
||||
height=6,
|
||||
cell_x=3.0,
|
||||
cell_y=3.0,
|
||||
base_fraction_values=np.asarray([0.0], dtype=np.float32),
|
||||
)
|
||||
|
||||
cell_candidates = {
|
||||
(int(y), int(x))
|
||||
for y, x in zip(candidate_y[0, 0].cpu().tolist(), candidate_x[0, 0].cpu().tolist())
|
||||
}
|
||||
|
||||
assert (int(reference.edge_peak_y[0, 0]), int(reference.edge_peak_x[0, 0])) in cell_candidates
|
||||
assert len(cell_candidates) > 2
|
||||
|
||||
|
||||
def test_optimize_lattice_pixels_does_not_regress_from_snap_on_thin_feature_case() -> 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_lattice_pixels(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=analyze_continuous_source(fake, seed=7),
|
||||
steps=4,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
snap_score = source_lattice_consistency_breakdown(
|
||||
fake,
|
||||
artifacts.initial_rgba,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
)["score"]
|
||||
final_score = source_lattice_consistency_breakdown(
|
||||
fake,
|
||||
artifacts.target_rgba,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.12,
|
||||
)["score"]
|
||||
|
||||
assert final_score <= snap_score + 1e-6
|
||||
|
||||
|
||||
def test_optimize_lattice_pixels_emits_displacement_diagnostics() -> None:
|
||||
source = np.zeros((12, 12, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
source[3:9, 6, :3] = 1.0
|
||||
|
||||
inference = InferenceResult(
|
||||
target_width=6,
|
||||
target_height=6,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
artifacts = optimize_lattice_pixels(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_continuous_source(source, seed=7),
|
||||
steps=2,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
displacement = artifacts.stage_diagnostics["displacements"]
|
||||
assert {"snap", "relax_handoff", "relax_mode", "final_output"} <= set(displacement.keys())
|
||||
for stage_name in ("snap", "relax_handoff", "final_output"):
|
||||
payload = displacement[stage_name]
|
||||
assert payload["displacement_x"].shape == (6, 6)
|
||||
assert payload["displacement_y"].shape == (6, 6)
|
||||
assert payload["orthogonal_jitter_px"] >= 0.0
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import numpy as np
|
||||
|
||||
from repixelizer.analysis import analyze_continuous_source
|
||||
from repixelizer.analysis import analyze_phase_field_source
|
||||
from repixelizer.phase_field import optimize_phase_field
|
||||
from repixelizer.synthetic import fake_pixelize, make_emblem
|
||||
from repixelizer.types import InferenceResult
|
||||
|
|
@ -25,7 +25,7 @@ def test_optimize_phase_field_emits_displacement_diagnostics_and_source_colors()
|
|||
artifacts = optimize_phase_field(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_continuous_source(source, seed=7),
|
||||
analysis=analyze_phase_field_source(source, seed=7),
|
||||
steps=4,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
|
|
@ -65,7 +65,7 @@ def test_optimize_phase_field_moves_off_zero_when_phase_is_wrong() -> None:
|
|||
artifacts = optimize_phase_field(
|
||||
fake,
|
||||
inference=inference,
|
||||
analysis=analyze_continuous_source(fake, seed=7),
|
||||
analysis=analyze_phase_field_source(fake, seed=7),
|
||||
steps=6,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ from pathlib import Path
|
|||
|
||||
import numpy as np
|
||||
|
||||
from repixelizer.analysis import analyze_continuous_source
|
||||
from repixelizer.analysis import analyze_phase_field_source
|
||||
from repixelizer.baselines import naive_resize_baseline
|
||||
from repixelizer.continuous import optimize_lattice_pixels
|
||||
from repixelizer.metrics import coherence_breakdown, foreground_reconstruction_error, source_lattice_consistency_breakdown
|
||||
from repixelizer.params import SolverHyperParams
|
||||
from repixelizer.pipeline import _run_reconstruction, _select_phase_candidate, run_pipeline
|
||||
from repixelizer.io import load_rgba, nearest_resize
|
||||
from repixelizer.phase_field import optimize_phase_field
|
||||
from repixelizer.io import nearest_resize
|
||||
from repixelizer.synthetic import fake_pixelize, make_emblem, make_sprite
|
||||
from repixelizer.types import InferenceCandidate, InferenceResult, SolverArtifacts
|
||||
|
||||
|
|
@ -30,8 +30,8 @@ def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None:
|
|||
assert output_path.exists()
|
||||
assert (diagnostics_dir / "run.json").exists()
|
||||
assert (diagnostics_dir / "output-preview.png").exists()
|
||||
assert (diagnostics_dir / "displacement-snap.png").exists()
|
||||
assert (diagnostics_dir / "displacement-snap-preview.png").exists()
|
||||
assert (diagnostics_dir / "displacement-initial_output.png").exists()
|
||||
assert (diagnostics_dir / "displacement-initial_output-preview.png").exists()
|
||||
import json
|
||||
|
||||
run_json = json.loads((diagnostics_dir / "run.json").read_text(encoding="utf-8"))
|
||||
|
|
@ -39,7 +39,7 @@ def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None:
|
|||
assert "source_structure" in run_json
|
||||
assert {"edge_f1", "exact_match_ratio", "score"} <= set(run_json["source_structure"].keys())
|
||||
assert "phase_rerank_candidates" in run_json
|
||||
assert {"snap", "relax_handoff", "relax_mode", "final_output"} <= set(run_json["optimizer_displacement"].keys())
|
||||
assert {"initial_output", "final_output"} <= set(run_json["optimizer_displacement"].keys())
|
||||
assert result.output_rgba.shape[0] == result.inference.target_height
|
||||
assert result.output_rgba.shape[1] == result.inference.target_width
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ def test_fixed_tile_graph_pipeline_output_matches_refactor_baseline(tmp_path: Pa
|
|||
assert output_hash == "1f6d497a65953ab60a37fce98e69b101955e5f0e5a06a1e32167c6b8582a23f6"
|
||||
|
||||
|
||||
def test_fixed_continuous_pipeline_output_matches_refactor_baseline(tmp_path: Path) -> None:
|
||||
def test_fixed_phase_field_pipeline_output_matches_refactor_baseline(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(
|
||||
source,
|
||||
|
|
@ -174,8 +174,8 @@ def test_fixed_continuous_pipeline_output_matches_refactor_baseline(tmp_path: Pa
|
|||
warp_detail=4,
|
||||
seed=9,
|
||||
)
|
||||
input_path = tmp_path / "continuous-baseline-input.png"
|
||||
output_path = tmp_path / "continuous-baseline-output.png"
|
||||
input_path = tmp_path / "phase-field-baseline-input.png"
|
||||
output_path = tmp_path / "phase-field-baseline-output.png"
|
||||
|
||||
from repixelizer.io import save_rgba
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ def test_fixed_continuous_pipeline_output_matches_refactor_baseline(tmp_path: Pa
|
|||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
reconstruction_mode="continuous",
|
||||
reconstruction_mode="phase-field",
|
||||
target_width=24,
|
||||
target_height=24,
|
||||
phase_x=0.1,
|
||||
|
|
@ -194,7 +194,7 @@ def test_fixed_continuous_pipeline_output_matches_refactor_baseline(tmp_path: Pa
|
|||
)
|
||||
|
||||
output_hash = hashlib.sha256(np.ascontiguousarray(result.output_rgba).view(np.uint8)).hexdigest()
|
||||
assert output_hash == "404748af3c7241a6602cb91fdfed1fd7e116dd14098827e33e78c7da5e3d7903"
|
||||
assert output_hash == "9e01caa4ee4b508876d9a8360e5c8471f5ab07baf8ed1e5a08e76b9d3533c58e"
|
||||
|
||||
|
||||
def test_phase_rerank_can_override_low_confidence_inference_pick(monkeypatch) -> None:
|
||||
|
|
@ -219,11 +219,11 @@ def test_phase_rerank_can_override_low_confidence_inference_pick(monkeypatch) ->
|
|||
def __init__(self, rgba: np.ndarray) -> None:
|
||||
self.target_rgba = rgba
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
assert steps == 0
|
||||
return DummyArtifacts(outputs[(inference.phase_x, inference.phase_y)])
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu")
|
||||
assert selected.phase_x == candidate_b.phase_x
|
||||
|
|
@ -247,7 +247,7 @@ def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None
|
|||
def __init__(self, rgba: np.ndarray) -> None:
|
||||
self.target_rgba = rgba
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
return DummyArtifacts(rgba)
|
||||
|
||||
|
|
@ -256,7 +256,7 @@ def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None
|
|||
return {"score": 0.10}
|
||||
return {"score": 0.01}
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu")
|
||||
|
|
@ -281,7 +281,7 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
|||
def __init__(self, rgba: np.ndarray) -> None:
|
||||
self.target_rgba = rgba
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
return DummyArtifacts(rgba)
|
||||
|
||||
|
|
@ -290,7 +290,7 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
|||
return {"score": 0.10}
|
||||
return {"score": 0.01}
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu")
|
||||
|
|
@ -314,7 +314,7 @@ def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None:
|
|||
def fail(*args, **kwargs):
|
||||
raise AssertionError("phase rerank probe should not run when disabled")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fail)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail)
|
||||
selected = _select_phase_candidate(
|
||||
source,
|
||||
inference,
|
||||
|
|
@ -356,35 +356,6 @@ def test_select_phase_candidate_skips_phase_rerank_for_tile_graph(monkeypatch) -
|
|||
assert selected.target_height == candidate_a.target_height
|
||||
|
||||
|
||||
def test_select_phase_candidate_skips_phase_rerank_for_phase_field(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("phase-field phase rerank probe should not run")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail)
|
||||
selected = _select_phase_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
seed=7,
|
||||
device="cpu",
|
||||
reconstruction_mode="phase-field",
|
||||
)
|
||||
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
|
||||
|
|
@ -502,7 +473,7 @@ def test_pipeline_runs_phase_field_mode(tmp_path: Path, monkeypatch) -> None:
|
|||
)
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.infer_lattice", lambda *args, **kwargs: inference)
|
||||
monkeypatch.setattr("repixelizer.pipeline.analyze_continuous_source", lambda *args, **kwargs: DummyAnalysis())
|
||||
monkeypatch.setattr("repixelizer.pipeline.analyze_phase_field_source", lambda *args, **kwargs: DummyAnalysis())
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
|
||||
result = run_pipeline(
|
||||
|
|
@ -546,7 +517,7 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
def fake_infer_fixed(*args, **kwargs):
|
||||
return inference
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
rgba[..., 3] = 1.0
|
||||
return SolverArtifacts(
|
||||
|
|
@ -559,8 +530,8 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
|
||||
monkeypatch.setattr("repixelizer.pipeline.infer_lattice", fail_infer)
|
||||
monkeypatch.setattr("repixelizer.pipeline.infer_fixed_lattice", fake_infer_fixed)
|
||||
monkeypatch.setattr("repixelizer.pipeline.analyze_continuous_source", lambda *args, **kwargs: DummyAnalysis())
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.analyze_phase_field_source", lambda *args, **kwargs: DummyAnalysis())
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
|
||||
result = run_pipeline(
|
||||
input_path,
|
||||
|
|
@ -603,7 +574,7 @@ def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(mo
|
|||
candidate_b.target_width: np.ones((candidate_b.target_height, candidate_b.target_width, 4), dtype=np.float32),
|
||||
}
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
return DummyArtifacts(outputs[inference.target_width])
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
|
|
@ -618,7 +589,7 @@ def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(mo
|
|||
def fake_concentration(rgba):
|
||||
return 0.18 if np.mean(rgba) < 0.1 else 0.31
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_position_error", fake_edge_position)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble)
|
||||
|
|
@ -645,7 +616,7 @@ def test_phase_rerank_keeps_high_confidence_candidate_without_probe(monkeypatch)
|
|||
def fail(*args, **kwargs):
|
||||
raise AssertionError("rerank probe should not run for high-confidence inference")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fail)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail)
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu")
|
||||
assert selected.target_width == candidate_a.target_width
|
||||
assert selected.target_height == candidate_a.target_height
|
||||
|
|
@ -685,10 +656,10 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
|||
candidate_b.phase_x: np.ones((2, 2, 4), dtype=np.float32),
|
||||
}
|
||||
|
||||
def fake_optimize_lattice_pixels(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
return DummyArtifacts(outputs[inference.phase_x])
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_lattice_pixels", fake_optimize_lattice_pixels)
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_position_error", fake_edge_position)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble)
|
||||
|
|
@ -697,44 +668,3 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
|||
selected = _select_phase_candidate(source, inference, analysis=object(), seed=7, device="cpu")
|
||||
assert selected.phase_x == candidate_b.phase_x
|
||||
assert selected.phase_y == candidate_b.phase_y
|
||||
|
||||
|
||||
def test_badge_fixture_candidate_beats_previous_source_fidelity_baselines() -> None:
|
||||
source = load_rgba("tests/fixtures/real/ai-badge-cleaned.png")
|
||||
inference = InferenceResult(
|
||||
target_width=170,
|
||||
target_height=170,
|
||||
phase_x=-0.2,
|
||||
phase_y=0.2,
|
||||
confidence=0.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
artifacts = optimize_lattice_pixels(
|
||||
source,
|
||||
inference=inference,
|
||||
analysis=analyze_continuous_source(source, seed=7),
|
||||
steps=2,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
snap_consistency = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
artifacts.initial_rgba,
|
||||
target_width=170,
|
||||
target_height=170,
|
||||
phase_x=-0.2,
|
||||
phase_y=0.2,
|
||||
)["score"]
|
||||
final_consistency = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
artifacts.target_rgba,
|
||||
target_width=170,
|
||||
target_height=170,
|
||||
phase_x=-0.2,
|
||||
phase_y=0.2,
|
||||
)["score"]
|
||||
|
||||
assert final_consistency <= 0.1494
|
||||
assert final_consistency <= 0.1369
|
||||
assert final_consistency <= snap_consistency + 1e-6
|
||||
|
|
|
|||
Loading…
Reference in a new issue