diff --git a/README.md b/README.md
index 5ca1f3c..6025d59 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ lattice like grown-ups.
Instead of pretending this is a resize problem, Repixelizer treats it as
lattice inference plus `phase-field` reconstruction: infer the ruler, scout the
-edges, give every output cell a tiny shove vector, nudge those shoves until the
-cells settle into quieter paint while staying in order, then sample once and go
+edges, let every output cell search a tiny local window for quieter paint,
+relax the whole sheet back into a coherent lattice, then sample once and go
home.
## GUI First
@@ -19,7 +19,7 @@ The web GUI is the main way to use this thing unless you enjoy raw flags for spo
It gives you:
- drag-and-drop input
-- live phase-field feedback while lattice search, rerank, and solving run
+- live phase-field feedback while autocorr inference, rerank, and solving run
- synchronized input/output inspection zoom for close comparison
- a built-in output editor for single-pixel cleanup with eyedropper and pencil tools
@@ -41,9 +41,9 @@ If you want to support the official hosted deployment and help us scale the poor
This sheet carries two cases: the ugly fake-pixel badge and a dense
higher-resolution landscape. The badge row shows the core salvage story. The
-landscape row shows a different failure mode: auto lattice inference still
-undershoots that source, so the `phase-field` panel is the tracked fixed
-`512x512` result next to the same cheap Lanczos baseline.
+landscape row is now generated through automatic lattice inference as well, so
+the preview shows the machine we actually ship instead of a pinned-size fairy
+tale wearing a serious hat.
Each row carries its own inset, so the tiny details have nowhere to hide.
@@ -53,18 +53,19 @@ Each row carries its own inset, so the tiny details have nowhere to hide.
Repixelizer does one thing:
-`source image -> lattice inference -> edge scout -> fixed lattice centers ->
-projected displacement-field optimization -> nearest source sample -> cleanup /
-diagnostics`
+`source image -> lattice inference -> diagnostic edge preview -> fixed lattice centers ->
+hierarchical flatness signal -> explicit local search + lattice relaxation ->
+nearest source sample -> cleanup / diagnostics`
In plainer language:
1. infer the target lattice size
-2. build one edge scout map over the source
+2. build one diagnostic edge preview over the source
3. nail down fixed lattice centers
-4. run the `phase-field` solve by optimizing one `(dx, dy)` shove vector per
- output cell
-5. sample the source once from the final displaced cell positions
+4. run the `phase-field` solve by letting each cell search a tiny local window,
+ move toward the flattest nearby source position, and relax back into a
+ coherent lattice
+5. sample the source once from the final cell positions
6. clean up the discrete result, optionally fit a palette, and write
diagnostics
@@ -85,12 +86,12 @@ What exists now:
- a tuning harness for offline parameter sweeps
- metrics that finally care about visible structure instead of only pleasing the lattice accountant
- `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`
+- a shippable explicit local-search solver config, with tuning notes in `docs/solver-tuning-lessons-2026-04-29.md`
Current weak spots:
-- `phase-field` still needs better along-stroke versus across-stroke behavior near tapered contours
-- lattice selection is still low-confidence on some ugly generated inputs, so pinned-size iteration remains an important workflow
+- `phase-field` is usable, not final; signal knobs still need a disciplined follow-up pass around the current motion basin
+- lattice selection is still low-confidence on some ugly generated inputs, so visual inspection and pinned-size iteration remain important workflows
## Quickstart
@@ -193,9 +194,8 @@ Run the focused test suite with:
## Regenerating README Assets
The README images are generated from repo-tracked fixtures, not from random
-artifacts left lying around. The dense landscape row reuses the tracked fixed
-`512x512` output fixture because auto lattice inference still undershoots that
-case:
+artifacts left lying around. The dense landscape row runs automatic lattice
+inference instead of pinning a fixed output size:
```powershell
.venv\Scripts\python scripts\generate_readme_previews.py --out-sheet docs\readme-assets\badge-example-sheet.png --out-guard-crop docs\readme-assets\guard-right-crop-comparison.png --scratch-dir artifacts\readme-build --device auto
diff --git a/config/solver_params.json b/config/solver_params.json
index 126b0fc..c635088 100644
--- a/config/solver_params.json
+++ b/config/solver_params.json
@@ -1,34 +1,25 @@
{
- "phase_field_grid_alignment_weight": 4.0,
- "phase_field_local_search_radius_ratio": 0.8,
- "phase_field_local_search_blend": 0.18,
- "phase_field_local_search_move_weight": 0.16,
- "phase_field_local_search_grid_weight": 0.0,
- "phase_field_signal_weight": 0.25,
- "phase_field_signal_inner_radius_ratio": 0.24,
- "phase_field_signal_outer_radius_ratio": 0.56,
- "phase_field_signal_ring_contrast_weight": 1.0,
- "phase_field_signal_inner_variance_weight": 1.0,
- "phase_field_signal_center_edge_weight": 0.12,
- "phase_field_signal_edge_threshold": 0.16,
- "phase_field_signal_detail_radius_ratio": 0.8,
- "phase_field_signal_flatness_radius_ratio": 0.24,
- "phase_field_signal_distance_power": 0.7,
- "phase_field_signal_pyramid_levels": 5,
- "phase_field_signal_boundary_weight": 1.0,
- "phase_field_signal_curvature_weight": 0.65,
- "phase_field_signal_self_penalty": 3.0,
- "phase_field_signal_context_power": 0.7,
- "phase_field_signal_peak_power": 2.0,
- "phase_field_local_search_interval": 1,
- "candidate_rerank_preview_steps": 8,
- "candidate_rerank_support_weight": 0.45,
- "candidate_rerank_edge_position_weight": 0.2,
- "candidate_rerank_wobble_weight": 0.2,
- "candidate_rerank_edge_concentration_weight": 0.1,
- "candidate_rerank_size_penalty_weight": 0.18,
- "candidate_rerank_inference_penalty_weight": 0.05,
- "candidate_rerank_confidence_threshold": 0.12,
- "candidate_rerank_max_size_delta_ratio": 0.4,
- "candidate_rerank_margin": 0.004
+ "phase_field_grid_alignment_weight": 4.75,
+ "phase_field_local_search_radius_ratio": 0.215,
+ "phase_field_local_search_blend": 0.24,
+ "phase_field_local_search_move_weight": 0.09,
+ "phase_field_local_search_grid_weight": 0.0,
+ "phase_field_signal_weight": 0.25,
+ "phase_field_signal_pyramid_levels": 3,
+ "phase_field_signal_level_decay": 0.35,
+ "phase_field_signal_gradient_weight": 0.05,
+ "phase_field_signal_curvature_weight": 0.65,
+ "phase_field_signal_energy_power": 1.4,
+ "phase_field_signal_peak_power": 3.25,
+ "phase_field_local_search_interval": 1,
+ "candidate_rerank_preview_steps": 8,
+ "candidate_rerank_support_weight": 0.45,
+ "candidate_rerank_edge_position_weight": 0.2,
+ "candidate_rerank_wobble_weight": 0.2,
+ "candidate_rerank_edge_concentration_weight": 0.1,
+ "candidate_rerank_size_penalty_weight": 0.18,
+ "candidate_rerank_inference_penalty_weight": 0.05,
+ "candidate_rerank_confidence_threshold": 0.12,
+ "candidate_rerank_max_size_delta_ratio": 0.4,
+ "candidate_rerank_margin": 0.004
}
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 18fbc5a..0e8db8d 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -2,7 +2,7 @@
## What this file is
-This file is the forward plan and active hypothesis ledger for Repixelizer.
+This file is the forward plan for Repixelizer.
It is not the authoritative stage-by-stage control-flow map. That lives in
`docs/lean-optimizer-algorithm-map.md`.
@@ -13,142 +13,118 @@ intent.
## Current machine
-Repixelizer now has one live reconstruction engine:
+Repixelizer has one live reconstruction engine:
- `phase-field`: the canonical reconstruction engine in `src/repixelizer/phase_field.py`
The live pipeline is:
-`source image -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`
+`source image -> lattice inference -> diagnostic edge preview -> fixed lattice centers -> hierarchical flatness signal -> explicit local search + lattice relaxation -> nearest source sample -> cleanup -> optional palette fit -> diagnostics`
-Inference now splits three ways:
+Inference splits two ways:
- fixed lattice when the caller pins size explicitly
-- searched lattice for the full local workflow
-- direct autocorr lattice for the hosted demo path: one consensus size, no size search, no candidate rerank
+- autocorr lattice when size is inferred automatically; autocorr can surface multiple candidates, and low-confidence candidates can be preview-reranked
-The hosted web shell now also has an auth seam:
+The hosted web shell has an auth seam:
- `src/repixelizer/access.py` owns the app-local access boundary and local ownership checks
- `src/repixelizer/gui.py` owns hosted route policy, landing-page behavior, and queue/job stamping
-- `GC_ACCESS_MODE=heimdall` now lands the first real shared-auth consumer path there: provider start, backend callback receipt, local JWT verification, and httpOnly session adoption
+- `GC_ACCESS_MODE=heimdall` supports provider start, backend callback receipt, local JWT verification, and httpOnly session adoption
- self-host and local runs stay permissive by default unless `GC_ACCESS_*` turns the seam on
## What is working
-- lattice size inference are still shared and still CUDA-capable
-- `phase-field` is the only canonical reconstruction path and produces the best-looking badge result in the repo so far
-- 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
+- `phase-field` is the only canonical reconstruction path.
+- The current explicit solver is fast enough for hosted interaction.
+- Compare mode, benchmark mode, diagnostics writing, GUI observer previews, and tuning harnesses exist.
+- The repo reports both `source_fidelity` and `source_structure` because lattice-only scoring can slander better-looking output.
+- Hosted landing/app flow is in place and should be the immediate product path.
-That second metric exists because the first one was happily slandering the better-looking output.
+## Current solver shipping state
-## Current evidence
+The current solver config is shippable, not final.
-### Phase-field
+Useful current values:
-Pinned badge case:
+- `phase_field_grid_alignment_weight=4.75`
+- `phase_field_local_search_radius_ratio=0.215`
+- `phase_field_local_search_blend=0.24`
+- `phase_field_local_search_move_weight=0.09`
+- `phase_field_signal_energy_power=1.4`
+- `phase_field_signal_peak_power=3.25`
-- fixture: `tests/fixtures/real/ai-badge-cleaned.png`
-- lattice: `126x126`
-- initial lattice centers: canonical
-- steps: `48`
+Tuning lesson:
-Useful artifacts:
+- broad local-search radius caused samples to poach neighboring features
+- the focused useful radius basin is around `0.20-0.22`
+- at that tighter radius, stronger blend became useful
+- signal knobs were not fully explored around the new basin
-- 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/`
-
-What we know:
-
-- 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`
-
-## Maps
-
-The repo now keeps the living map:
-
-- `docs/lean-optimizer-algorithm-map.md`
-
-If a future pass cannot be explained cleanly against that map, it should not land.
+Detailed tuning notes live in `docs/solver-tuning-lessons-2026-04-29.md`.
## Current priorities
-### 1. Fix the phase-field sword-tip blemish
+### 1. Ship the hosted experiment
Goal:
-- keep the current structural win
-- stop widening the dark contour near the tapered sword tip
+- get Repixelizer online
+- stop blocking Heimdall
+- stop Heimdall from blocking StreamPixels
-Current hypothesis:
+Practical stance:
-- 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
-
-What we just learned:
-
-- the later `d9fa411` phase-field tuning pass was a real regression, not paranoia
-- that pass strengthened the local edge penalty, added a spacing loss plus upper-spacing clamp, and made edge gating more aggressive
-- on the pinned badge case, it removed internal linework and changed `2433 / 15876` output cells relative to the original good `phase-field` run
-- reverting that tuning pass restores the original good badge result exactly; the fresh fixed run under `artifacts/phase-field-regression-fix-badge-126/` is byte-identical to the original `artifacts/phase-field-v1-recheck-badge-126/`
-
-Tracked fixture:
-
-- `tests/fixtures/real/ai-badge-tip-focus.json`
+- do not spend more time polishing solver knobs before hosted launch unless a blocking quality regression appears
+- keep hosted auth and access work isolated to `src/repixelizer/access.py` and `src/repixelizer/gui.py`
+- keep solver and pipeline code out of auth work
### 2. Keep lattice inference honest
Current stance:
-- keep the shipped searched path grounded in autocorrelation only
+- keep automatic inference grounded in autocorrelation
- let autocorr keep a small near-best lag plateau and use cross-axis consensus before collapsing to one lattice family
-- the hosted demo path now skips multi-size search entirely and uses one autocorr-consensus size , capped by the hosted output limit
-- do not trust the old pixel-walk / change-interval spacing path; it was cut after it kept pruning the badge away from the `126` family
-- do not keep the projected edge-energy spectral pass; it floor-hugged to `2px` / `626`-family nonsense on the dense landscape fixture and did not earn its keep
+- keep hosted demo inference on the autocorr path with candidate rerank available when confidence is low
+- do not revive the old pixel-walk / change-interval spacing path
+- do not revive the projected edge-energy spectral pass
- treat candidate rerank as optional scaffolding, not sacred machinery
-Future options worth exploring, if autocorr stops carrying its weight:
+### 3. Later: retune the phase-field solver deliberately
-- distance-transform / medial-radius blob sizing on softened interiors
-- small scale-space blob-size detection (`LoG` / `DoG`)
-- blur-aware correction of observed blob width before converting to lattice size
-- tiny learned size prior trained on synthetic fake-pixel-art fixtures
+The next solver pass should begin from the current live config, not from the old
+wide-radius geometry.
-### 3. Keep phase-field honest
+Recommended order:
-Rules:
+1. Sweep signal knobs around `radius=0.215, blend=0.24`.
+2. Sweep movement/coherence knobs around the best signal candidate.
+3. Test combinations only after single-knob effects are understood.
+4. Validate on focused artificial cases, real character glint/symbol cases, and the badge fixture.
+5. Run full-corpus validation at the end, not during every exploratory twitch.
-- 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
+Knobs still underexplored in the new basin:
-### 4. Keep hosted auth surgery out of the solver
-
-Current stance:
-
-- Heimdall integration belongs in the hosted web layer, not in the reconstruction pipeline
-- queued jobs now resolve ownership from local session/account metadata instead of provider ids
-- `GC_ACCESS_MODE=heimdall` is now the real hosted path: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts local cookie sessions without dragging provider tokens through browser URLs
-- `GC_ACCESS_MODE=trusted-header` still exists as the cheap ingress seam for local route-policy tests and reverse-proxy experiments
-- future auth work still belongs in `src/repixelizer/access.py` and `src/repixelizer/gui.py`, not in `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack
-
-Immediate auth follow-ups, if the user wants them:
-
-- add more providers or policy bindings without changing the solver
-- surface session/logout state inside `/app/` if hosted UX needs it
-- decide whether auth attempts or adopted sessions need persistence beyond this single-process hosted runtime
+- `phase_field_signal_weight`
+- `phase_field_signal_gradient_weight`
+- `phase_field_signal_curvature_weight`
+- `phase_field_signal_level_decay`
+- `phase_field_signal_pyramid_levels`
+- `phase_field_signal_energy_power`
+- `phase_field_signal_peak_power`
+- `phase_field_local_search_move_weight`
+- `phase_field_grid_alignment_weight`
+- `phase_field_local_search_grid_weight`
## 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
+- Prefer one clear hypothesis per pass.
+- Show the output after every real reconstruction run when doing visual tuning.
+- Keep maps updated after code changes.
+- Revert or delete machinery that does not visibly earn its keep.
+- Do not reintroduce lattice phase, portrait layers, candidate trays, or solver-stage religions casually.
+- If a new term lands, it must belong cleanly in the one-field / one-signal / one-relaxation model.
+- If a result looks better and the metrics disagree, fix the metrics rather than worshipping them.
-This repo already spent enough time as a Jenga tower of maybe-useful cleverness. The machine has to deserve every part.
+This repo already spent enough time as a Jenga tower of maybe-useful cleverness.
+The machine has to deserve every part.
diff --git a/docs/lean-optimizer-algorithm-map.md b/docs/lean-optimizer-algorithm-map.md
index 82276c5..1b99bc6 100644
--- a/docs/lean-optimizer-algorithm-map.md
+++ b/docs/lean-optimizer-algorithm-map.md
@@ -2,9 +2,11 @@
## What this file is
-This is a map of the live `phase-field` machine in the repo right now.
+This is the source-grounded map of the live `phase-field` reconstruction machine.
-`source image -> lattice inference -> edge scout -> fixed lattice centers -> projected displacement-field optimization -> nearest source sample -> cleanup / diagnostics`
+The current spine is:
+
+`source image -> lattice inference -> diagnostic edge preview -> fixed lattice centers -> hierarchical flatness signal -> explicit local search + lattice relaxation -> nearest source sample -> cleanup / diagnostics`
The relevant source lives in:
@@ -18,18 +20,19 @@ The relevant source lives in:
## One-sentence machine
-The solver nails a regular grid over the source, gives every output cell a tiny `(dx, dy)` shove vector, nudges those shoves until the cells settle into quieter paint while staying in order, then samples the source once and goes home.
+The solver lays a regular grid over the source, lets each cell look in a small local window for the flattest nearby source position, blends toward that local preference, relaxes the lattice back toward coherent spacing, then samples real source pixels once and goes home.
## Core state
These are the pieces that actually matter:
-- `inference`: chosen lattice width, height, confidence, and optional top candidates
-- `edge_map`: one normalized scout map of where the source image has strong luminance/alpha changes
-- `uv0_px`: the fixed source-space center for each output cell before any optimization
-- `disp_t`: the live displacement field, one `(dx, dy)` vector per output cell
-- `pos_px = uv0_px + disp_t`: the current sample positions in source pixel space
-- `target_rgba`: the final output grid after nearest-source sampling
+- `InferenceResult`: chosen lattice width, height, confidence, and optional top candidates
+- `edge_map`: normalized luminance/alpha diagnostic preview from `analysis.py`
+- `uv0_px`: fixed source-space center for each output cell before movement
+- `signal_map`: hierarchical flatness map built in `phase_field.py`
+- `pos`: live source-space sample positions, initialized from `uv0_px`
+- `disp = pos - uv0_px`: reported displacement field derived from live positions
+- `target_rgba`: final output grid after nearest-source sampling
Everything else is support scaffolding.
@@ -37,26 +40,28 @@ Everything else is support scaffolding.
```mermaid
flowchart TD
- A["Source RGBA"] --> B["Stage 0: Lattice inference
pipeline.py + inference.py"]
- B --> C["Stage 1: Edge scout
analysis.py"]
- C --> D["Stage 2: Phase-field prep
phase_field._prepare_phase_field"]
- D --> E["uv0_px (fixed centers)
disp_t = 0"]
- E --> F["Stage 3-5: Optimization loop
sample patch -> compute loss -> Adam step -> project"]
- F -->|repeat for steps| F
- F --> G["Stage 6: Final nearest source sample
target_rgba"]
- G --> H["Stage 7: Cleanup / palette / diagnostics
pipeline.py"]
- H --> I["Output image + comparison + run.json"]
+ A["Source RGBA"] --> B["Stage 0: Lattice inference"]
+ B --> C["Stage 1: Diagnostic edge preview"]
+ C --> D["Stage 2: Prep fixed centers + signal"]
+ D --> E["uv0_px fixed centers
pos = uv0_px + tiny seed noise"]
+ E --> F["Stage 3: Local candidate search
25 nearby positions per cell"]
+ F --> G["Stage 4: Blend toward preferred positions"]
+ G --> H["Stage 5: Lattice spring relaxation"]
+ H -->|repeat for solver steps| F
+ H --> I["Stage 6: Final nearest source sample"]
+ I --> J["Stage 7: Cleanup / palette / diagnostics"]
```
-## How to read this machine
+## Stable core
-The machine has one honest variable: the displacement field.
+- one chosen ruler after inference
+- one live position field
+- one direct hierarchical flatness signal
+- one local candidate choice per cell per step
+- one lattice relaxation pass to keep spacing coherent
+- honest final nearest-source sampling
-- `uv0_px` is fixed after inference
-- `disp_t` is optimized
-- the final image comes from rounding `uv0_px + disp_t` to real source pixels
-
-That means the solver is not maintaining alternate per-cell candidate sets or separate reference images during optimization. It is one field moving under one loss, with projection after each step to stop it from folding into paste.
+The current live solver is not projected Adam anymore. The old differentiable loss scaffolding has been removed; `optimize_phase_field(...)` is the explicit NumPy local-search path.
## Stage 0: The pipeline chooses the ruler
@@ -64,383 +69,212 @@ That means the solver is not maintaining alternate per-cell candidate sets or se
- `run_pipeline(...)` in `src/repixelizer/pipeline.py`
- `_resolve_requested_target_dims(...)` in `src/repixelizer/pipeline.py`
-- `infer_lattice(...)` and `infer_fixed_lattice(...)` in `src/repixelizer/inference.py`
+- `infer_fixed_lattice(...)` and `infer_autocorr_lattice(...)` in `src/repixelizer/inference.py`
- `_select_candidate_with_reconstruction(...)` in `src/repixelizer/pipeline.py`
### Inputs
- source RGBA image
- optional pinned target size / width / height
-- optional `--skip-candidate-rerank`
+- optional candidate rerank for low-confidence autocorr candidates
### Outputs
- `InferenceResult`
- `target_width`
- `target_height`
- - `confidence`
+ - `confidence`
- `top_candidates`
### What the source actually does
-The pipeline first decides whether the lattice is:
+The pipeline decides whether the lattice is:
-- fixed explicitly by the caller, using `infer_fixed_lattice(...)`, or
-- inferred directly from autocorr consensus, using `infer_autocorr_lattice(...)`, or
-- searched automatically by `infer_lattice(...)`
+- fixed explicitly by the caller, using `infer_fixed_lattice(...)`
+- inferred from autocorr consensus, using `infer_autocorr_lattice(...)`
-The two automatic paths split like this:
+The automatic path estimates rough cell spacing from source edge-profile autocorrelation, keeps a tiny near-best lag plateau long enough to find cross-axis consensus, then surfaces multiple candidate lattice sizes.
-- both paths estimate rough cell spacing from source edge-profile autocorrelation
-- both paths keep a tiny near-best lag plateau long enough to find cross-axis consensus instead of collapsing instantly to one lag
-- `infer_autocorr_lattice(...)` takes one consensus size, scores the canonical center-grid size check, and stops there
-- `infer_lattice(...)` still builds a small candidate size family, scores the canonical center-grid size check for each size, and keeps the best candidate plus top alternates
-
-After that, candidate rerank may still happen, but only for `phase-field`, only when confidence is low, and only when rerank is enabled. The rerank probe is a short preview solve. It calls `_run_reconstruction(...)` with a small bounded step count, capped by both the requested solver steps and `candidate_rerank_preview_steps`.
-
-That part matters. The pipeline is effectively asking:
-
-`before the field starts wiggling, which lattice already looks most plausible?`
+Low-confidence autocorr candidates can still be reranked through a short preview solve. There is no separate searched-inference path in the live pipeline.
### Metaphor
-This is the survey crew laying down the ruler and arguing about where the graph paper should go.
+This is the survey crew laying graph paper over the mural. Autocorr proposes a small pile of plausible rulers; the preview reranker may make them argue before the workers show up.
-If the user pinned the lattice, the argument is over. If the hosted path is active, the crew makes one fast autocorr-backed call and moves on. If not, the crew drags the ruler around, checks a small pile of plausible alignments, and picks the one that seems most plausible before the real workers show up.
-
-## Stage 1: The edge scout builds a danger map
+## Stage 1: The diagnostic edge preview builds a gauge
### Source
- `analyze_phase_field_source(...)` in `src/repixelizer/analysis.py`
- `_compute_edge_map(...)` and `_compute_edge_map_torch(...)` in `src/repixelizer/analysis.py`
-### Inputs
-
-- source RGBA image
-- optional torch device
-
### Outputs
- `PhaseFieldSourceAnalysis(edge_map=...)`
### What the source actually does
-The analysis stage computes a single normalized `edge_map`:
-
-- luminance is derived from RGB
-- alpha is kept as a separate structure cue
-- horizontal and vertical absolute differences are measured for both luminance and alpha
-- those differences are combined into a magnitude map
-- the map is normalized to `[0, 1]`
-
-That is all. The analysis output for this solver is only one edge map: a report about where the source is calm and where it is breaking into edges.
-
-The map is used in two places later:
-
-- inside the loss, to penalize landing on edgy regions
-- inside the smoothness term, to weaken neighbor springs across real edges
+The analysis stage computes one normalized `edge_map` from luminance and alpha differences. It marks where the source breaks into edges for diagnostics and GUI preview. It does not steer the solver; the active local-search solver follows the separate hierarchical flatness signal built directly from the source image.
### Metaphor
-This stage walks the mural with a bucket of chalk and circles every crack, seam, and paint jump.
+This stage walks the mural with chalk and circles cracks, seams, and paint jumps.
-It does not tell the solver what color to choose. It tells the solver where the floorboards are creaking.
-
-## Stage 2: Prep converts the source into field-ready cargo
+## Stage 2: Prep builds fixed centers and the flatness signal
### Source
- `_prepare_phase_field(...)` in `src/repixelizer/phase_field.py`
- `_make_regular_uv_px(...)` in `src/repixelizer/phase_field.py`
+- `_source_signal_map_np(...)` in `src/repixelizer/phase_field.py`
- `premultiply(...)` in `src/repixelizer/io.py`
-### Inputs
-
-- source RGBA
-- chosen `InferenceResult`
-- `PhaseFieldSourceAnalysis`
-- `SolverHyperParams`
-- torch device
-
-### Outputs
-
-- `_PhaseFieldPrep`
- - `source_t`
- - `edge_t`
- - `uv0_px_t`
- - `uv0_norm`
- - `base_x_t`
- - `base_y_t`
- - `guidance`
- - `cell_x`, `cell_y`
- - `patch_offsets_t`
- - `width`, `height`
-
### What the source actually does
-Prep does a few brutally practical things:
+Prep:
-1. Premultiplies the source RGBA.
- - This makes later color differences respect alpha instead of hallucinating invisible color as if it mattered equally.
+1. Premultiplies source RGBA so transparent color does not lie.
+2. Computes `cell_x` and `cell_y` from source size and target size.
+3. Places canonical fixed centers in `uv0_px`.
+4. Builds support tensors used by diagnostics and observer snapshots.
+5. Builds `signal_map`, the live local-search attraction field.
-2. Builds the fixed lattice centers with `_make_regular_uv_px(...)`.
- - `cell_x = source_width / target_width`
- - `cell_y = source_height / target_height`
- - centers are placed at the canonical middle of each inferred output cell before clipping to image bounds
+The current signal path is intentionally simple:
-3. Converts the source, edge map, and lattice centers to torch tensors.
+- compute derivative energy from luminance gradient plus Laplacian magnitude at each pyramid level
+- upsample each level back to source resolution
+- accumulate the levels with `phase_field_signal_level_decay`
+- normalize the accumulated derivative energy once
+- invert it so low-gradient / low-curvature regions become attractive flatness
+- sharpen the flatness map with `phase_field_signal_peak_power`
-4. Builds a tiny 9-point patch stencil from `phase_field_patch_extent`.
- - center
- - left / right / up / down
- - four diagonals
+What no longer happens:
-5. Downscales the edge map to output resolution and stores it as `guidance`.
- - This is not part of the optimizer proper.
- - It is later passed into `cleanup_pixels(...)` so postprocessing can avoid sanding down high-edge regions.
+- no boundary-context reward
+- no wall mask
+- no distance transform
+- no ring/context/self-penalty signal knobs
+- no lattice phase concept
### Metaphor
-This is the loading dock.
+This is the loading dock. The source gets wrapped, the ruler becomes fixed anchor points, and the solver gets a heat map where quiet paint is warm and edges are cold.
-The source gets wrapped so transparent paint stops lying about its weight. The ruler gets converted into a neat grid of scaffold anchors. Then the solver gets handed a tiny feeler gauge: one center probe and eight little taps around it so it can ask, "am I standing in a quiet patch, or on the lip of a mess?"
-
-## Stage 3: The field starts at stillness
+## Stage 3: The field starts from the ruler
### Source
- `optimize_phase_field(...)` in `src/repixelizer/phase_field.py`
-- `_nearest_source_rgba(...)` in `src/repixelizer/phase_field.py`
-
-### Inputs
-
-- `_PhaseFieldPrep`
-- `SolverHyperParams`
-- `steps`
-- `seed`
-
-### Outputs
-
-- `disp_t` initialized to zero
-- `initial_rgba`
### What the source actually does
-The solver initializes:
+The live position field starts as:
-- `disp_t = 0` everywhere
-- Adam optimizer over that tensor
+- `pos = uv0_px`
-Then it immediately computes a baseline image by rounding the fixed lattice centers to nearest integer source pixels:
-
-- `initial_x = round(uv0_px[..., 0])`
-- `initial_y = round(uv0_px[..., 1])`
-- `initial_rgba = source[initial_y, initial_x]`
-
-This is the zero-displacement output. Diagnostics call this `initial_output`. It is simply "the field before it moves."
+Diagnostics emit the zero-displacement image from those canonical centers. Then a tiny seeded noise is added to `pos` so ties in the local search do not all break in perfectly identical ways.
### Metaphor
-Every output cell begins with its boots directly under its shoulders.
+Every worker starts with boots under shoulders, then gets the slightest shove so a perfectly symmetric room does not trap everyone in the same argument forever.
-Each cell starts from the lattice center and only moves if the loss gives it a reason to move.
-
-## Stage 4: The loss samples the current hypothesis
+## Stage 4: Local search chooses preferred positions
### Source
-- `_phase_field_loss(...)` in `src/repixelizer/phase_field.py`
-- `_sample_rgba(...)`
-- `_sample_scalar(...)`
-- `_sample_patch_rgba(...)`
-- `_sample_patch_scalar(...)`
-
-### Inputs
-
-- current `disp_t`
-- `_PhaseFieldPrep`
-- `SolverHyperParams`
-
-### Outputs
-
-- scalar loss
-- sampled RGBA at current positions
-- per-term diagnostics
+- `_choose_local_preferred_positions_np(...)` in `src/repixelizer/phase_field.py`
+- `_candidate_grid_score_np(...)` in `src/repixelizer/phase_field.py`
### What the source actually does
-The live sample positions are:
+Every `phase_field_local_search_interval` steps, each sample checks a 5x5 candidate grid around its current position.
-- `pos_px = uv0_px_t + disp_t`
+The candidate window size is:
-The loss then samples two things around those positions:
+- `radius_x = phase_field_local_search_radius_ratio * cell_x`
+- `radius_y = phase_field_local_search_radius_ratio * cell_y`
-- premultiplied RGBA from `source_t`
-- edge values from `edge_t`
+Each candidate is scored as:
-Sampling is bilinear during optimization because the loss needs gradients.
-
-It then computes five terms.
-
-#### 4A. Local coherence
-
-Code:
-
-- `local_coherence = (neighbor_rgba - center_rgba[..., None, :]).abs().mean()`
+```text
+move_score * phase_field_local_search_move_weight
++ grid_score * phase_field_local_search_grid_weight
+- signal_score * phase_field_signal_weight
+```
Meaning:
-The center sample is compared against the eight patch samples around it. If the patch is internally quiet, this term stays small. If the center sits on a messy boundary or mixed cell, it rises.
+- `signal_score` rewards candidates in flat coherent source regions
+- `move_score` discourages jumping to the edge of the search window just because it can
+- `grid_score` optionally asks candidates to preserve local row/column coherence relative to immediate neighbors
-#### 4B. Local edge penalty
-
-Code:
-
-- `local_edge = patch_edge.mean()`
-
-Meaning:
-
-The same 9-point patch is sampled from the edge map. Quiet interior regions score low; sharp edges and alpha jumps score high.
-
-This is the direct shove away from "standing on a line."
-
-#### 4C. Edge-aware smoothness
-
-Code:
-
-- midpoints between neighboring sample positions are computed
-- edge strength is sampled at those midpoints
-- weights are `exp(-edge_gate_strength * edge_mid)`
-- neighbor displacement deltas are measured in normalized cell units
-
-Meaning:
-
-Neighboring cells are tied together by springs, but the springs weaken when the midpoint between them crosses a real edge. So the field prefers drifting together in flat regions and breaking rank at actual structure.
-
-#### 4D. Collapse penalty
-
-Code:
-
-- neighboring sample positions along x and y must stay at least `min_spacing_ratio * cell_size` apart
-- violations are squared with `relu(min_step - actual_step)^2`
-
-Meaning:
-
-This is the anti-crumple term. It stops adjacent output cells from collapsing onto the same source pixels or overtaking one another.
-
-#### 4E. Magnitude prior
-
-Code:
-
-- normalized displacement length squared, averaged over the field
-
-Meaning:
-
-The field pays a small tax for wandering too far. Motion has to earn its keep, but the leash is long enough to recover from moderate starting-grid error instead of dying half a cell short of the right blob.
+The best candidate becomes that cell's preferred position.
### Metaphor
-Each worker is standing on a spring-loaded square of graph paper, poking the wall in a 3x3 halo around their boot.
+Each worker looks around a tiny voting booth, asks which nearby floor tile feels calmest, and points at that tile. If the booth is too large, workers poach each other's snacks. If it is too small, nobody reaches the useful tile.
-- If the halo feels like one calm slab of paint, good.
-- If it feels jagged and noisy, bad.
-- If neighboring workers want to drift together across a smooth region, fine.
-- If they all try to collapse into the same footprint, the constraint pushes them apart.
-
-## Stage 5: Adam moves the sheet, then projection slaps it back into order
+## Stage 5: Blend and lattice relaxation move the field
### Source
-- the optimization loop in `optimize_phase_field(...)`
-- `_project_displacements_in_place(...)` in `src/repixelizer/phase_field.py`
-
-### Inputs
-
-- current `disp_t`
-- gradients from `_phase_field_loss(...)`
-- min spacing
-- max displacement bounds
-
-### Outputs
-
-- updated, projected `disp_t`
-- `loss_history`
+- `optimize_phase_field(...)` in `src/repixelizer/phase_field.py`
+- `_relax_lattice_springs_np(...)` in `src/repixelizer/phase_field.py`
+- `_explicit_solver_terms_np(...)` in `src/repixelizer/phase_field.py`
### What the source actually does
-For each step:
+After preferred positions are chosen:
-1. Zero optimizer gradients.
-2. Compute the loss and its term breakdown.
-3. Backpropagate through the bilinear samples.
-4. Take an Adam step on `disp_t`.
-5. Immediately project the resulting field back into a valid shape.
+```text
+pos = pos * (1 - phase_field_local_search_blend) + preferred * phase_field_local_search_blend
+```
-Projection is not decorative. `_project_displacements_in_place(...)` enforces:
+Then `_relax_lattice_springs_np(...)` runs three spring iterations. Horizontal neighbors are pushed toward one `cell_x` of x-spacing and same-row y-alignment. Vertical neighbors are pushed toward one `cell_y` of y-spacing and same-column x-alignment.
-- left-to-right order with minimum x spacing
-- top-to-bottom order with minimum y spacing
-- displacement clamps of `[-max_dx, max_dx]` and `[-max_dy, max_dy]`
-- image-bound clamping on the live sample positions
+The spring step is capped:
-So the live machine is not "pure unconstrained gradient descent." It is projected Adam on a constrained displacement sheet.
+```text
+spring_step = min(0.22, phase_field_grid_alignment_weight * 0.06)
+```
+
+So grid weights above the cap are equivalent for spring strength, though they still appear in diagnostic loss terms.
+
+`_explicit_solver_terms_np(...)` records a compact diagnostic loss:
+
+```text
+grid_alignment * phase_field_grid_alignment_weight
+- local_signal * phase_field_signal_weight
+```
+
+This is feedback for the GUI and logs. It is not a differentiable Adam objective.
### Metaphor
-Adam is the part of the crew that says, "fine, slide a little left, slide a little down."
+Workers take a measured step toward their preferred tile. Then the graph paper tugs everyone back into rows and columns before the next vote.
-Projection is the guide rail saying:
-
-- you two still need to stay in order
-- nobody gets to leave the mural
-- nobody stretches farther than the leash allows
-
-Without that guide rail, the field would drift into invalid configurations.
-
-## Stage 6: Final sampling throws away the bilinear training wheels
+## Stage 6: Final sampling uses real source pixels
### Source
- final section of `optimize_phase_field(...)`
-- `_nearest_source_rgba(...)`
-
-### Inputs
-
-- final `disp_t`
-- fixed `uv0_px`
-- original source RGBA
-
-### Outputs
-
-- `target_rgba`
-- final `uv_field`
-- stage diagnostics
+- `_nearest_source_rgba(...)` in `src/repixelizer/phase_field.py`
### What the source actually does
After the loop:
-1. Compute `final_px = uv0_px + disp_t`.
-2. Round those positions to integer source pixels.
-3. Index the original source RGBA with those integer positions.
+1. Round final `pos` to integer source pixels.
+2. Index the original source RGBA.
+3. Report `final_disp = pos - uv0_px` and normalized `uv_field`.
-That last part matters. The final image is not bilinear soup. Bilinear sampling is only used inside the loss so the field can be optimized. The emitted image is made of actual source pixels.
-
-The solver also packages:
-
-- `uv_field` in normalized coordinates
-- `loss_history`
-- displacement diagnostics for the initial and final states
-- summary phase-field metrics like mean displacement, max displacement, and final loss terms
+The emitted image is made of actual source pixels, not bilinear soup.
### Metaphor
-During training, the workers are allowed to squint and interpolate.
+During solving, workers can consider nearby tiles. At the end, everyone must plant a flag on one real brick in the wall.
-At the end, that privilege is revoked. They must plant the flag on a real brick in the wall. The final output is a direct source-pixel choice.
-
-## Stage 7: The pipeline does the boring adult work
+## Stage 7: The pipeline does cleanup, palette, and diagnostics
### Source
@@ -448,41 +282,15 @@ At the end, that privilege is revoked. They must plant the flag on a real brick
- palette handling in `src/repixelizer/pipeline.py` and `src/repixelizer/palette.py`
- diagnostics writing in `src/repixelizer/diagnostics.py`
-### Inputs
-
-- `target_rgba`
-- `guidance_strength`
-- optional palette settings
-- diagnostics directory
-
-### Outputs
-
-- final saved output
-- optional palette-constrained output
-- comparison sheets
-- displacement previews
-- `run.json`
-
### What the source actually does
After reconstruction:
-1. `cleanup_pixels(...)` is called.
- - Right now the default is `iterations=0`, so cleanup is usually a no-op.
- - If enabled later, it tries tiny local replacements except in high-guidance regions.
-
+1. `cleanup_pixels(...)` may run, though defaults usually leave cleanup as a no-op.
2. Optional palette quantization runs.
+3. Diagnostics are written: comparisons, overlays, alpha preview, displacement previews, and `run.json`.
-3. Diagnostics are written:
- - source/output comparison
- - lattice overlay
- - alpha preview
- - displacement previews
- - `run.json` summary
-
-### Metaphor
-
-The main machine is done. This stage is just sweeping the floor, labeling the crates, and taking pictures for the insurance claim.
+Cleanup is secondary. It is not the real solver.
## Stage 8: The machine judges itself
@@ -493,100 +301,46 @@ The main machine is done. This stage is just sweeping the floor, labeling the cr
- `source_structure_breakdown(...)` in `src/repixelizer/metrics.py`
- `_displacement_diagnostics(...)` in `src/repixelizer/phase_field.py`
-### Inputs
-
-- source image
-- initial output
-- final output
-- final displacement field
-
-### Outputs
-
-- `source_fidelity`
-- `source_structure`
-- displacement summaries
-- coherence summaries
-
### What the source actually does
-The repo keeps two families of score now:
+The repo keeps two score families:
-- `source_fidelity`
- - compares the output against a lattice-derived reference raster built from the source
- - useful, but limited by the quality of that reference
+- `source_fidelity`: agreement with a lattice-derived source reference
+- `source_structure`: visible structural agreement at source size
-- `source_structure`
- - cares about foreground reconstruction, edge placement, wobble, edge support, and exact matches
- - closer to visible structure in the source image
-
-The solver also reports displacement statistics like:
-
-- mean magnitude
-- orthogonal jitter
-- local residual
-- dominant offset ratio
-
-Those are the closest thing this machine has to an X-ray of its field drift.
+The solver also reports displacement statistics and the explicit local-search terms.
### Metaphor
-This is the inspection bench.
+The inspection bench judges both whether the output agrees with the inferred grid and whether visible linework survived the trip.
-The machine gets judged two ways:
+## Current tuned surface
-- by a lattice-derived reference, which measures agreement with the inferred grid interpretation
-- by a structure-oriented view, which measures whether visible linework and edges survived the trip
+The current shippable trial config is documented in `docs/solver-tuning-lessons-2026-04-29.md` and lives in `config/solver_params.json`.
-## Stable core
+The important tuning lesson:
-- one fixed ruler after inference
-- one real optimization variable
-- one compact loss
-- honest final nearest-source sampling
-- explicit order-preserving projection after every step
-
-These pieces all belong to the same model of the machine.
-
-## Current seams
-
-- low-confidence candidate rerank uses only a short preview solve, not the full optimized field
-- the edge scout only knows edge magnitude, not direction, so the solver still struggles with along-stroke versus across-stroke behavior near tapered contours
-
-These are the places where the current implementation is still a little awkward or incomplete.
+- wide local-search windows caused feature poaching
+- the useful radius basin is around `0.20-0.22`
+- `radius=0.215` and `blend=0.24` are the current focused winner
+- signal knobs still need a disciplined follow-up pass around that new motion basin
## Guardrails
-These are the categories of complexity that would change the nature of the solver if they came back:
+These categories of complexity should not come back casually:
-- extra per-cell reference rasters used during optimization
-- source-derived reference images that steer the field directly
-- candidate sets or candidate trays for each cell
-- multiple sequential solver stages with separate objectives
-- repeated structural score families that appear in more than one stage
+- lattice phase search or explicit lattice phase controls
+- per-cell candidate trays that persist across stages
+- extra source-derived reference rasters steering the field directly
+- multiple sequential solver religions with separate objectives
+- cleanup pretending to be the real reconstruction engine
-The whole point of this machine is that it stays one field, one loss, one final sample.
+The point of this machine is still one ruler, one position field, one local signal, one lattice relaxation, and one final source sample.
-## Audit Result
-
-This map is compatible with the current Epiphany workflow as long as it keeps
-doing one job: describing the live machine the repo actually runs.
-
-Right now it passes that test:
-
-- it names the current pipeline instead of a future fantasy machine
-- it anchors every stage to real source files
-- it keeps the prose explanation attached to the control flow instead of
- replacing it
-- it identifies the current seams and the forbidden complexity that would turn
- the solver back into a Jenga tower
-
-The important boundary is simple:
+## Documentation boundaries
- live control-flow truth belongs here
-- active hypotheses and next cuts belong in `docs/implementation-plan.md`
-- compact re-entry instructions belong in `notes/fresh-workspace-handoff.md`
+- current shipping state and re-entry instructions belong in `notes/fresh-workspace-handoff.md`
- durable project truth belongs in `state/map.yaml`
-
-If a future pass adds directional edge awareness, anisotropic behavior, or
-another real control-flow change, update this map after the code lands. If the
-change is still a hypothesis, keep it out of this file.
+- detailed tuning lessons belong in `docs/solver-tuning-lessons-2026-04-29.md`
+- forward work belongs in `docs/implementation-plan.md`
diff --git a/docs/readme-assets/badge-example-sheet.png b/docs/readme-assets/badge-example-sheet.png
index d8494e1..b91614a 100644
Binary files a/docs/readme-assets/badge-example-sheet.png and b/docs/readme-assets/badge-example-sheet.png differ
diff --git a/docs/readme-assets/guard-right-crop-comparison.png b/docs/readme-assets/guard-right-crop-comparison.png
index 7c1a4de..bcc200f 100644
Binary files a/docs/readme-assets/guard-right-crop-comparison.png and b/docs/readme-assets/guard-right-crop-comparison.png differ
diff --git a/docs/solver-tuning-lessons-2026-04-29.md b/docs/solver-tuning-lessons-2026-04-29.md
new file mode 100644
index 0000000..60cfd57
--- /dev/null
+++ b/docs/solver-tuning-lessons-2026-04-29.md
@@ -0,0 +1,266 @@
+# Solver tuning lessons - 2026-04-29
+
+This note records what the tuning pass actually taught us, what it did not
+teach us, and how the next pass should be run without constructing another
+beautiful little swamp.
+
+## Shipping state
+
+The current shipped trial config is intentionally usable, not final:
+
+```json
+{
+ "phase_field_grid_alignment_weight": 4.75,
+ "phase_field_local_search_radius_ratio": 0.215,
+ "phase_field_local_search_blend": 0.24,
+ "phase_field_local_search_move_weight": 0.09,
+ "phase_field_signal_energy_power": 1.4,
+ "phase_field_signal_peak_power": 3.25
+}
+```
+
+Everything else in `config/solver_params.json` remains as previously configured.
+
+The important caveat: this is a motion-geometry win, not a full signal-shape
+optimum. It is acceptable for shipping the hosted experiment, but it should not
+be treated as the final form of the phase-field solver.
+
+## What the pass proved
+
+The most useful discovery was that the local-search radius was much too large.
+The solver was not merely underpowered; it was letting samples inspect too much
+neighboring territory. Adjacent samples could chase the same feature, which
+looked like feature attraction but behaved like poaching.
+
+Recent focused artificial results, all with:
+
+```text
+grid=4.75
+blend=0.18 unless noted
+move=0.09
+energy=1.4
+peak=3.25
+```
+
+showed this radius curve:
+
+| Run | Radius | Score | Mean | P90 | Worst | Read |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| 068 | 0.40 | 0.187047 | 0.073598 | 0.278124 | 0.445583 | Previous best before lower bracket |
+| 071 | 0.32 | 0.144824 | 0.056821 | 0.169920 | 0.363593 | Big improvement; radius was still too large |
+| 075 | 0.24 | 0.128386 | 0.046622 | 0.126980 | 0.341534 | Better again |
+| 080 | 0.20 | 0.119077 | 0.046775 | 0.121492 | 0.306095 | Strong lower shoulder |
+| 081 | 0.22 | 0.116976 | 0.044065 | 0.116555 | 0.306714 | Strong local center |
+| 084 | 0.215 | 0.116584 | 0.044071 | 0.117496 | 0.304752 | Best radius point tested |
+| 085 | 0.225 | 0.121924 | 0.045445 | 0.131808 | 0.316814 | Upper side already regresses |
+
+The model this supports:
+
+- `radius` is the gatekeeper for whether samples solve locally or poach.
+- Very small radius eventually starves movement, as seen at `0.16`.
+- The useful basin is narrow, roughly `0.20-0.22`, with `0.215` best in the focused pass.
+- Once the search window is tight, stronger blend becomes useful because samples can move without wandering into the neighbor's lunch.
+
+Blend sweep at `radius=0.215`:
+
+| Run | Blend | Score | Mean | P90 | Worst | Read |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| 087 | 0.12 | 0.161612 | 0.062461 | 0.197631 | 0.404997 | Too timid |
+| 088 | 0.15 | 0.136534 | 0.050632 | 0.145291 | 0.356375 | Still timid |
+| 084 | 0.18 | 0.116584 | 0.044071 | 0.117496 | 0.304752 | Radius-only best |
+| 089 | 0.20 | 0.111183 | 0.041232 | 0.112633 | 0.292477 | Better |
+| 090 | 0.24 | 0.103754 | 0.038329 | 0.100917 | 0.274995 | Current focused winner |
+
+The model this supports:
+
+- After radius is tightened, blend must be retuned.
+- Low blend was compensating for an over-wide search area.
+- At the new radius, `0.24` is better than `0.18` on focused metrics.
+- We did not yet find the upper blend cliff. `0.24` won the tested set, but values above it still need bracketing later.
+
+## What the pass did not prove
+
+The pass did not exhaustively model the entire system.
+
+Specifically, after finding the new `radius/blend` basin, the later tuning did
+not sweep:
+
+- `phase_field_signal_weight`
+- `phase_field_signal_gradient_weight`
+- `phase_field_signal_curvature_weight`
+- `phase_field_signal_level_decay`
+- `phase_field_signal_pyramid_levels`
+- `phase_field_signal_energy_power` around the new basin
+- `phase_field_signal_peak_power` around the new basin
+- `phase_field_local_search_move_weight` around the new basin
+- `phase_field_grid_alignment_weight` around the new basin
+- `phase_field_local_search_grid_weight` around the new basin
+
+Earlier experiments touched some of these in older regimes, but those results
+should not be treated as final because they were gathered before the current
+motion geometry was discovered. A signal knob can look useless when the solver
+cannot move into the signal correctly. That is exactly how we wasted time
+before.
+
+The correct summary is:
+
+- Current config is shippable.
+- Motion geometry is much better understood.
+- Signal shape remains underexplored in the new basin.
+- The next tuning pass should start from `radius=0.215, blend=0.24`, not from the old wide-radius config.
+
+## Current mental model
+
+The phase-field solver now looks like this:
+
+1. Build a hierarchical flatness signal from the source image.
+2. Give each lattice sample a small local candidate window.
+3. Let it choose the locally most comfortable source position.
+4. Blend toward that local preference.
+5. Use lattice coherence pressure to keep neighboring samples from tearing the grid apart.
+
+The important part is the scale separation:
+
+- The signal says where coherent source positions are.
+- The local search radius says which nearby positions a sample is allowed to consider.
+- The blend says how fast the sample moves toward the chosen position.
+- The lattice coherence force says how much the grid resists local deformation.
+
+The mistake was treating signal strength as the main issue while the search
+window was still too broad. A broad search window turns a good signal into a
+poaching contest.
+
+## Config intents we can currently generate
+
+These are educated starting points, not final recommendations:
+
+### Hot focused winner
+
+Use when the goal is maximum focused artificial metric improvement and the live
+visual does not look too twitchy.
+
+```json
+{
+ "phase_field_grid_alignment_weight": 4.75,
+ "phase_field_local_search_radius_ratio": 0.215,
+ "phase_field_local_search_blend": 0.24,
+ "phase_field_local_search_move_weight": 0.09,
+ "phase_field_signal_energy_power": 1.4,
+ "phase_field_signal_peak_power": 3.25
+}
+```
+
+### Balanced live trial
+
+Use if the hot winner looks over-eager on real AI cases.
+
+```json
+{
+ "phase_field_grid_alignment_weight": 4.75,
+ "phase_field_local_search_radius_ratio": 0.215,
+ "phase_field_local_search_blend": 0.20,
+ "phase_field_local_search_move_weight": 0.09,
+ "phase_field_signal_energy_power": 1.4,
+ "phase_field_signal_peak_power": 3.25
+}
+```
+
+### Conservative control
+
+Use if the solver starts visibly over-moving or damaging tiny details.
+
+```json
+{
+ "phase_field_grid_alignment_weight": 4.75,
+ "phase_field_local_search_radius_ratio": 0.20,
+ "phase_field_local_search_blend": 0.18,
+ "phase_field_local_search_move_weight": 0.09,
+ "phase_field_signal_energy_power": 1.4,
+ "phase_field_signal_peak_power": 3.25
+}
+```
+
+## Next tuning protocol
+
+The next pass should not repeat the failed "long campaign then hope" shape.
+
+Run it in phases:
+
+1. Lock the current motion baseline:
+
+```text
+radius=0.215
+blend=0.24
+move=0.09
+grid=4.75
+energy=1.4
+peak=3.25
+```
+
+2. Sweep every signal knob once against that baseline:
+
+```text
+signal_weight
+gradient_weight
+curvature_weight
+level_decay
+pyramid_levels
+energy_power
+peak_power
+```
+
+3. Sweep movement/coherence knobs once against the best signal candidate:
+
+```text
+move_weight
+grid_alignment_weight
+local_search_grid_weight
+local_search_interval
+max_displacement_ratio
+```
+
+4. Only then test combinations.
+
+5. Persist every experiment immediately with:
+
+```text
+hypothesis
+patch
+score
+mean
+p90
+worst
+case-specific regressions
+interpretation
+next hypothesis
+```
+
+6. Run full-corpus validation only after focused cases stop moving.
+
+7. Do visual checks on real cases before shipping:
+
+```text
+character eye glint
+biohazard symbol
+badge sword-tip focus
+simple crisp artificial cases
+soft artificial cases
+AI-distorted artificial cases
+```
+
+## Failure mode from this pass
+
+The user asked for every knob and combination to be touched first. That did not
+happen. The pass found a real, valuable motion-geometry basin, but it then
+declared too much confidence from too little coverage.
+
+Do not repeat that.
+
+The next agent should treat this note as a partial model:
+
+- strong evidence for radius/blend behavior
+- weak evidence for signal behavior in the new basin
+- no final evidence for all-knob interactions
+
+Ship the current config because the project needs to unblock Heimdall and
+StreamPixels. Come back later with a disciplined signal-first pass.
diff --git a/docs/spec.md b/docs/spec.md
index 0399837..e77eea9 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -57,13 +57,14 @@ Repixelizer treats this as lattice inference plus reconstruction rather than a r
The canonical workflow should be fully automatic:
1. load the source image
-2. infer lattice size
-3. analyze source structure
-4. run the `phase-field` reconstruction 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
-8. write output and diagnostics
+2. infer lattice size from autocorrelation candidates, unless the caller pins it
+3. render a diagnostic edge preview
+4. build the solver's hierarchical flatness signal from the source
+5. run the `phase-field` reconstruction over the inferred lattice
+6. sample real source pixels from the final source-position field
+7. snap alpha to the final discrete mask
+8. optionally quantize to a palette
+9. write output and diagnostics
No manual masks or user-authored region hints are assumed in v1.
@@ -81,6 +82,8 @@ repixelize compare input.png --out output.png
Supported flags:
- `--target-size`
+- `--target-width`
+- `--target-height`
- `--palette`
- `--palette-mode off|fit|strict`
- `--diagnostics-dir`
@@ -103,11 +106,11 @@ Palette constraints are optional because many generated fake-pixel-art images do
The tool must estimate the resolution of the fake lattice being mimicked.
Requirements:
-- search over candidate target sizes
+- infer candidate target sizes from autocorrelation hints
- use canonical cell centers for each candidate
- score candidates using coherence-oriented metrics rather than reconstruction alone
- expose the top-ranked alternatives in diagnostics
-- if `--target-size` is provided, skip size search
+- if `--target-size`, `--target-width`, or `--target-height` is provided, skip automatic inference
Scoring goals:
- low isolated-pixel rate
@@ -117,42 +120,39 @@ Scoring goals:
- lower local color chatter
- reasonable agreement with source-derived periodicity priors
-### 2. Source analysis
+### 2. Diagnostic analysis and solver signal
-The solver should derive automatic guidance from the image itself:
-- edge map
-- alpha-aware source structure
+The pipeline keeps two source-derived views separate:
-This guidance is used to preserve important boundaries while allowing smoother regions to settle into cleaner pixel clusters.
+- a diagnostic edge preview for GUI and run inspection
+- a hierarchical flatness signal that actually steers the solver
-### 3. Phase-field optimization
+The live solver signal is built from multiscale luminance gradient and curvature energy. It rewards calm local source regions while leaving edge preview as a gauge, not a hidden steering wheel.
-The optimizer represents each output pixel by a base lattice center plus one learned displacement vector.
+### 3. Phase-field reconstruction
+
+The solver represents each output pixel as one live source-space sample position initialized at a fixed lattice center.
Requirements:
- operate in premultiplied RGBA space
-- use PyTorch autograd
-- optimize a displacement field initialized at zero from the inferred lattice
-- preserve local adjacency instead of letting the sample field collapse or fold
+- initialize from canonical fixed lattice centers
+- let each sample choose from a small local candidate window
+- blend toward the locally preferred flatter source position
+- relax row and column springs so the sample field stays coherent
+- nearest-sample the original source pixels for final output
-Loss components should include:
-- local coherence / solidity so samples settle inside fake-pixel interiors
-- edge-aware smoothing so hard boundaries stay crisp
-- 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
+The current implementation is explicit NumPy local search plus spring relaxation. It is not the old PyTorch autograd / projected-Adam displacement solver.
### 4. Discrete projection and cleanup
After the phase-field stage, the result must be treated as a true pixel grid.
The discrete stage should:
-- evaluate local neighborhoods
-- remove orphan pixels where possible
-- merge obvious micro-clusters
-- reduce accidental highlight static
-- avoid damaging strong guided boundaries
+- preserve source-sampled RGBA values
+- snap alpha to an opaque/transparent mask
+- leave structural repair to the solver rather than hiding confusion with a cleanup pass
-This stage is intentionally heuristic and local in v1.
+Cleanup is intentionally small in v1. It should not become a second solver wearing a trench coat.
### 5. Baselines and comparison mode
diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md
index 2989756..eab928b 100644
--- a/notes/fresh-workspace-handoff.md
+++ b/notes/fresh-workspace-handoff.md
@@ -1,11 +1,11 @@
# Fresh Workspace Handoff
-This is the re-entry packet for `E:\Projects\repixelizer`.
+This is the compact re-entry packet for `E:\Projects\repixelizer`.
-It is intentionally short. Historical proof belongs in git history,
-diagnostics, benchmark artifacts, and the distilled `state/evidence.jsonl`
-ledger; exact control flow belongs in `docs/lean-optimizer-algorithm-map.md`;
-forward planning belongs in `docs/implementation-plan.md`.
+Historical proof belongs in git history, diagnostics, benchmark artifacts, and
+`state/evidence.jsonl`. Exact control flow belongs in
+`docs/lean-optimizer-algorithm-map.md`. Detailed tuning lessons belong in
+`docs/solver-tuning-lessons-2026-04-29.md`.
## Rehydrate
@@ -22,127 +22,90 @@ git log --oneline -5
Get-Content '.\state\evidence.jsonl' -Tail 8
```
-Do not trust this file for the exact live HEAD. Always check git.
-
-## Current Orientation
-
-- `phase-field` is the only live reconstruction engine.
-- The live pipeline is `source -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`.
-- searched lattice inference now trusts autocorrelation only; it keeps a tiny near-best lag plateau and looks for cross-axis consensus before collapsing to one lattice family. the old change-interval / pixel-walk sizing path was cut after it kept steering the badge away from the `126` family, and the projected edge-energy spectral pass was also removed after floor-hugging to 2px / 626-family aliases on the dense landscape fixture
-- hosted demo inference now uses one direct autocorr-consensus size, capped by the hosted output limit. it no longer silently pins unspecified jobs to `max_output_dimension`
-- hosted web access now has an explicit seam in `src/repixelizer/access.py`; `src/repixelizer/gui.py` binds request subjects, guards hosted routes, and stamps queued jobs with local owner metadata
-- `GC_ACCESS_MODE=heimdall` is now landed in that web layer: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts the direct backend callback, verifies Heimdall Ed25519 access tokens locally, and adopts an httpOnly local session cookie
-- The low-confidence candidate rerank path is a short preview solve, not a second full optimizer.
-- the phase field now has a wider displacement budget, so moderate starting-grid mistakes are allowed to get corrected by the field instead of relying purely on candidate-rerank ceremony
-- Cleanup is secondary and usually a no-op; the core result is supposed to come from the solver, not cleanup cosplay.
-- The current tracked weakness is the widened dark contour near the badge sword tip on tapered shapes.
-- `source_structure` exists because lattice-only `source_fidelity` could call visibly better outputs worse.
-- The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason.
-- `E:\Projects\Heimdall\docs\architecture.md` is the future shared design for reusable GameCult-hosted access across experiments.
-- `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` is the Repixelizer-specific binding onto that future shared access layer.
-- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, provider-start / callback / adopt / logout auth routes, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`.
-- product strategy is hosted convenience first: keep the code open, let self-hosters suffer voluntarily, and do not burn time on native desktop packaging unless it clearly beats hosting on revenue, support, or strategy.
-
-## Critical Doctrine
-
-- Persistent state is the agent's mind.
-- Cut persistent memory as ruthlessly as code; stale context is bad thought, not harmless clutter.
-- Remember Jenga: growing diffs, growing notes, and growing confidence are not proof that the system still makes sense.
-- If compaction hits while source gathering or slice planning is still unpersisted, that work is gone. Re-gather it instead of pretending continuity happened.
-- Keep the machine explainable in plain language and anchored to code. If a change cannot be explained against the algorithm map, it is not ready to land.
-
-## Landed Machine
-
-The current spine:
-
-- lattice inference with fixed-size and searched-size paths
-- searched lattice inference currently trusts autocorrelation only, not the rejected pixel-walk or spectral priors
-- hosted demo inference uses the direct autocorr path and keeps candidate rerank off
-- hosted route auth belongs in `src/repixelizer/access.py` + `src/repixelizer/gui.py`, not in the solver or pipeline
-- the first Heimdall consumer path is live there: provider buttons on the landing page, direct backend callback handoff, local JWT verification, and cookie adoption
-- low-confidence candidate rerank through short preview reconstruction
-- edge analysis feeding one projected displacement-field optimizer with a wider displacement leash
-- nearest-source final sampling from `uv0_px + disp_t`
-- cleanup, optional palette fit, diagnostics, compare mode, tuning, and GUI observer events on the live path
-- `source_structure` plus `source_fidelity` in run summaries and comparisons
-- focused sword-tip fixture in `tests/fixtures/real/ai-badge-tip-focus.json`
-
-The exact current control flow is documented in
-`docs/lean-optimizer-algorithm-map.md`.
-
-## Boundaries
-
-- Do not reintroduce tray optimizers, candidate sets, portrait layers, or multi-stage solver religions casually.
-- Do not trust a metric win that makes the image look worse.
-- Do not let cleanup become the real solver.
-- Do not let `state/evidence.jsonl` turn into an activity feed.
-- Do not restart broad exploratory surgery when the current weak spot is still one bounded tapered-contour blemish.
-- Do not drift into desktop-app fantasies just because the web GUI is nice now. The default commercial path is still hosted access.
-- Do not let future Heimdall work smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. The seam is already in the hosted web layer; keep using it.
-
-## Verification Guardrails
-
-- For scaffolding or note changes, run the repo-local state helper and compaction helper.
-- For Python changes, run:
-
-```powershell
-.\.venv\Scripts\python -m pytest -q
-```
-
-- For solver behavior changes, reproduce the pinned badge case from
- `docs/implementation-plan.md`, inspect the sword-tip focus fixture, and
- compare both the image and `source_structure`.
-
-## Persistent State Hygiene
-
-Rules now in force:
-
-- `state/map.yaml` is canonical current truth.
-- `state/scratch.md` is disposable scratch.
-- `state/evidence.jsonl` is a distilled durable belief ledger.
-- `tools/repixelizer_prepare_compaction.py` is the pre-compaction persistence check; run it before and after imminent-compaction persistence passes.
-- this handoff is a compact re-entry packet.
-- `docs/implementation-plan.md` is the forward plan.
-- `docs/lean-optimizer-algorithm-map.md` is the source-grounded control-flow map.
-- `E:\Projects\Heimdall\docs\architecture.md` is future shared auth/access design, not live implementation truth.
-- `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` is the app-specific future binding, also not live implementation truth.
-- local `docs/gamecult-hosted-access-architecture.md` and
- `docs/repixelizer-hosted-access-profile.md` are redirect stubs.
-
-Do not let any one note become all of those things. That is how the tower grows
-sideways and starts calling itself architecture.
-
-## Next Real Move
-
+Do not trust this file for the exact live HEAD. Always check git before committing or
+shipping.
Do not continue implementation automatically from a rehydrate-only request.
-If the user asks to continue, choose the branch deliberately:
+## Current machine
+
+- `phase-field` is the only live reconstruction engine.
+- The live pipeline is `source -> lattice inference -> diagnostic edge preview -> fixed lattice centers -> hierarchical flatness signal -> explicit local search + lattice relaxation -> nearest source sample -> cleanup / diagnostics`.
+- The current solver is explicit local search, not projected Adam. It starts from fixed lattice centers, adds tiny seeded noise, chooses local preferred positions from a 5x5 candidate window, blends toward those positions, then relaxes lattice springs for row/column coherence.
+- The final image is emitted by nearest-source sampling from final source positions. Bilinear interpolation is not the output path.
+- The signal is direct hierarchical flatness: multiscale luminance gradient/Laplacian energy, one normalization, inverted to flatness, then sharpened. No wall mask, no distance transform, no boundary-context reward, no lattice phase.
+- `source_structure` exists alongside `source_fidelity` because lattice-only scoring can lie about visible quality.
+- Cleanup is alpha snap plus diagnostics only. It is not the real solver.
+
+## Hosted/web state
+
+- Hosted demo inference uses autocorr candidates, capped by hosted output limits.
+- Low-confidence autocorr candidates can be preview-reranked; hosted mode does not forcibly disable rerank.
+- Hosted access lives in `src/repixelizer/access.py` and `src/repixelizer/gui.py`.
+- `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally and adopts httpOnly local sessions.
+- Self-host/local runs stay permissive by default unless `GC_ACCESS_*` enables access policy.
+- Do not smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack.
+
+## Current shippable solver config
+
+The project is shipping a usable trial config so Repixelizer stops blocking
+Heimdall and StreamPixels.
+
+Live values in `config/solver_params.json`:
+
+- `phase_field_grid_alignment_weight=4.75`
+- `phase_field_local_search_radius_ratio=0.215`
+- `phase_field_local_search_blend=0.24`
+- `phase_field_local_search_move_weight=0.09`
+- `phase_field_signal_energy_power=1.4`
+- `phase_field_signal_peak_power=3.25`
+
+What the tuning pass proved:
+
+- Wide local-search windows caused neighbor feature poaching.
+- The useful radius basin is around `0.20-0.22`, with `0.215` best in the focused pass.
+- Once radius is tight, stronger blend helps; `0.24` beat lower blend settings on the focused artificial set.
+
+What it did not prove:
+
+- The signal parameters are not fully retuned around the new motion basin.
+- The current config is shippable, not final.
+
+See `docs/solver-tuning-lessons-2026-04-29.md` before doing more solver tuning.
-- auth branch: keep working only in `src/repixelizer/access.py` and
- `src/repixelizer/gui.py` for bounded Heimdall polish such as provider
- expansion, session UX, or persistence hardening
-- solver branch: take one bounded hypothesis for tapered-contour behavior in
- `src/repixelizer/phase_field.py`, check it against the pinned badge case and
- `tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual
- result or `source_structure` does not clearly improve
## Immediate Re-entry Instruction
-After compaction, first rehydrate and reorient from the listed files and git
-state. Do not continue implementation merely because the state names a next
-move. Wait for the user's next instruction unless they explicitly say to
-continue.
+Ship the hosted experiment path next. The source hygiene pass is complete:
+diagnostic analysis stays as a GUI/observer gauge, the solver does not accept
+`analysis`, stale GUI event names were removed, `docs/spec.md` matches the live
+explicit solver, and `config/solver_params.json` matches the documented
+shipping basin.
-## Active Manual Tuning State
+## Current priority
-Manual tuning is in progress; see `state/scratch.md` for the full score ledger. Do not jump straight to editing `config/solver_params.json`.
+Ship the hosted experiment and unblock Heimdall / StreamPixels.
-Current evidence:
-- Full validation, 38 cases x soft/crisp/ai, seed 23, steps 48: baseline score `0.7702`; `radius=0.60, blend=0.07` score `0.5790`, mean `0.2814`, worst `1.2734`, improved `97/114` rows.
-- Regression guard on known bad rows says tighter radius may be safer: `radius=0.50, blend=0.07` scored `0.4075` with worst `0.6949` on the guard set, beating `radius=0.60, blend=0.07` at `0.6278` / worst `1.3589`.
+Solver follow-up is later work unless the hosted launch exposes a blocking
+quality regression. The next solver tuning pass should start from the current
+live config, sweep signal knobs first, then movement/coherence knobs, then
+combinations, and only then run final full-corpus validation.
-Immediate re-entry for tuning:
-- full-validate `radius=0.50, blend=0.07`
-- full-validate `radius=0.55, blend=0.05`
-- compare against the existing full-validation `radius=0.60, blend=0.07` results in `artifacts/tuning-validation-01`
-- only then update `config/solver_params.json`
+## Guardrails
+
+- Persistent state is the agent's mind; stale state is bad thought, not harmless clutter.
+- Keep the algorithm map grounded in source.
+- Do not reintroduce lattice phase, trays, portrait layers, or multi-stage solver religions casually.
+- Do not trust a metric win that makes the image look worse.
+- Revert or discard tuning changes that do not clearly improve the real outcome.
+- Prefer shipping the hosted path over polishing solver knobs right now.
+
+## State surfaces
+
+- `state/map.yaml`: canonical durable current truth.
+- `state/scratch.md`: disposable active scratch.
+- `state/evidence.jsonl`: distilled durable belief ledger.
+- `state/branches.json`: active hypothesis tracking.
+- `docs/lean-optimizer-algorithm-map.md`: live control-flow map.
+- `docs/solver-tuning-lessons-2026-04-29.md`: latest solver tuning knowledge.
+- `docs/implementation-plan.md`: forward plan.
+- `tools/repixelizer_prepare_compaction.py`: pre-compaction hygiene check.
diff --git a/scripts/generate_readme_previews.py b/scripts/generate_readme_previews.py
index 9ac17b6..26e3bea 100644
--- a/scripts/generate_readme_previews.py
+++ b/scripts/generate_readme_previews.py
@@ -63,8 +63,8 @@ def _parse_args() -> argparse.Namespace:
)
parser.add_argument(
"--dense-phase-field-output",
- default="tests/fixtures/real/dense-landscape-fixed-512.png",
- help="Tracked fixed-lattice output used for the dense landscape row.",
+ default="",
+ help="Optional existing dense landscape output. If omitted, the dense row is regenerated with automatic lattice inference.",
)
parser.add_argument(
"--guard-cell-bbox",
@@ -93,29 +93,30 @@ def _parse_args() -> argparse.Namespace:
type=int,
default=126,
help="Pinned target height for the canonical badge comparison.",
- ) parser.add_argument(
+ )
+ parser.add_argument(
"--dense-target-width",
type=int,
default=512,
- help="Pinned target width for the dense landscape comparison.",
+ help="Fallback target width used only when --dense-phase-field-output is supplied.",
)
parser.add_argument(
"--dense-target-height",
type=int,
default=512,
- help="Pinned target height for the dense landscape comparison.",
+ help="Fallback target height used only when --dense-phase-field-output is supplied.",
)
parser.add_argument(
"--dense-auto-inferred-width",
type=int,
default=418,
- help="Current automatic lattice width guess for the dense landscape case.",
+ help="Deprecated display fallback for old fixed-output runs.",
)
parser.add_argument(
"--dense-auto-inferred-height",
type=int,
default=418,
- help="Current automatic lattice height guess for the dense landscape case.",
+ help="Deprecated display fallback for old fixed-output runs.",
)
parser.add_argument("--steps", type=int, default=48, help="Optimizer step budget for both runs.")
parser.add_argument("--device", default="cpu", choices=("auto", "cpu", "cuda"), help="Torch device for generation.")
@@ -369,7 +370,6 @@ def main() -> None:
source_input = Path(args.input)
dense_input = Path(args.dense_input)
- dense_phase_field_output = Path(args.dense_phase_field_output)
out_sheet = Path(args.out_sheet)
out_guard_crop = Path(args.out_guard_crop)
out_sheet.parent.mkdir(parents=True, exist_ok=True)
@@ -389,15 +389,43 @@ def main() -> None:
source_rgba = load_rgba(source_input)
lanczos = lanczos_resize_baseline(source_rgba, width=args.target_width, height=args.target_height)
dense_source_rgba = load_rgba(dense_input)
- dense_phase_field_rgba = load_rgba(dense_phase_field_output)
+ if args.dense_phase_field_output:
+ dense_phase_field_output = Path(args.dense_phase_field_output)
+ dense_phase_field_rgba = load_rgba(dense_phase_field_output)
+ dense_target_width = int(dense_phase_field_rgba.shape[1])
+ dense_target_height = int(dense_phase_field_rgba.shape[0])
+ dense_phase_field_subtitle = f"tracked {dense_target_width}x{dense_target_height} output"
+ dense_title = (
+ f"Dense {dense_source_rgba.shape[1]}px pixel art -> tracked "
+ f"{dense_target_width}x{dense_target_height} output"
+ )
+ dense_output_summary = str(dense_phase_field_output)
+ else:
+ dense_phase_field_result = run_pipeline(
+ dense_input,
+ scratch_dir / "dense-landscape-phase-field-auto.png",
+ diagnostics_dir=scratch_dir / "dense-phase-field-auto-diag",
+ steps=args.steps,
+ device=args.device,
+ lattice_inference_mode="autocorr",
+ )
+ dense_phase_field_rgba = dense_phase_field_result.output_rgba
+ dense_target_width = int(dense_phase_field_result.inference.target_width)
+ dense_target_height = int(dense_phase_field_result.inference.target_height)
+ dense_phase_field_subtitle = f"{dense_target_width}x{dense_target_height} inferred lattice"
+ dense_title = (
+ f"Dense {dense_source_rgba.shape[1]}px pixel art -> "
+ f"{dense_target_width}x{dense_target_height} inferred lattice"
+ )
+ dense_output_summary = str(scratch_dir / "dense-landscape-phase-field-auto.png")
dense_lanczos = lanczos_resize_baseline(
dense_source_rgba,
- width=args.dense_target_width,
- height=args.dense_target_height,
+ width=dense_target_width,
+ height=dense_target_height,
)
save_rgba(scratch_dir / "badge-ai-lanczos.png", lanczos)
- save_rgba(scratch_dir / "dense-landscape-lanczos-512.png", dense_lanczos)
+ save_rgba(scratch_dir / f"dense-landscape-lanczos-{dense_target_width}x{dense_target_height}.png", dense_lanczos)
panel_width = 320
panel_height = 360
@@ -411,6 +439,13 @@ def main() -> None:
scale_x=0.5,
scale_y=0.5,
)
+ dense_focus_cell_bbox = (
+ int(round(args.dense_focus_cell_bbox[0] * dense_target_width / max(1, args.dense_target_width))),
+ int(round(args.dense_focus_cell_bbox[1] * dense_target_height / max(1, args.dense_target_height))),
+ int(round(args.dense_focus_cell_bbox[2] * dense_target_width / max(1, args.dense_target_width))),
+ int(round(args.dense_focus_cell_bbox[3] * dense_target_height / max(1, args.dense_target_height))),
+ )
+
rows = [
PreviewRow(
row_id="badge",
@@ -427,20 +462,16 @@ def main() -> None:
),
PreviewRow(
row_id="dense-landscape",
- title=(
- "Dense 1254px pixel art -> auto inference "
- f"{args.dense_auto_inferred_width}x{args.dense_auto_inferred_height}; "
- f"fixed {args.dense_target_width}x{args.dense_target_height} shown"
- ),
+ title=dense_title,
source_rgba=dense_source_rgba,
source_subtitle=f"{dense_source_rgba.shape[1]}px source",
lanczos_rgba=dense_lanczos,
- lanczos_subtitle=f"{args.dense_target_width}x{args.dense_target_height} baseline",
+ lanczos_subtitle=f"{dense_target_width}x{dense_target_height} baseline",
phase_field_rgba=dense_phase_field_rgba,
- phase_field_subtitle=f"fixed {args.dense_target_width}x{args.dense_target_height} lattice",
- target_width=args.dense_target_width,
- target_height=args.dense_target_height,
- focus_cell_bbox=tuple(args.dense_focus_cell_bbox),
+ phase_field_subtitle=dense_phase_field_subtitle,
+ target_width=dense_target_width,
+ target_height=dense_target_height,
+ focus_cell_bbox=dense_focus_cell_bbox,
),
]
built_rows: list[tuple[PreviewRow, list[RenderedPanel], tuple[int, int, int, int]]] = []
@@ -501,7 +532,7 @@ def main() -> None:
],
"badge_input": str(source_input),
"dense_input": str(dense_input),
- "dense_phase_field_output": str(dense_phase_field_output),
+ "dense_phase_field_output": dense_output_summary,
"guard_cell_bbox": list(args.guard_cell_bbox),
"guard_source_bbox": list(guard_bbox),
"out_sheet": str(out_sheet),
diff --git a/scripts/render_focus_crop.py b/scripts/render_focus_crop.py
index c5ad35a..fe3a5b2 100644
--- a/scripts/render_focus_crop.py
+++ b/scripts/render_focus_crop.py
@@ -6,8 +6,7 @@ from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
-from repixelizer.analysis import analyze_phase_field_source
-from repixelizer.inference import infer_lattice
+from repixelizer.inference import infer_autocorr_lattice
from repixelizer.io import load_rgba
from repixelizer.metrics import source_lattice_consistency_breakdown
from repixelizer.params import SolverHyperParams
@@ -77,12 +76,10 @@ def _build_focus_states(
device: str,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[float, float], dict[str, dict[str, float]]]:
source = load_rgba(input_path)
- inference = infer_lattice(source, device=device)
- analysis = analyze_phase_field_source(source, seed=7, device=device)
+ inference = infer_autocorr_lattice(source, device=device)
artifacts = optimize_phase_field(
source,
inference=inference,
- analysis=analysis,
steps=steps,
seed=7,
device=device,
diff --git a/src/repixelizer/benchmark.py b/src/repixelizer/benchmark.py
index 9985b03..5f8b1ad 100644
--- a/src/repixelizer/benchmark.py
+++ b/src/repixelizer/benchmark.py
@@ -1,9 +1,10 @@
from __future__ import annotations
+import json
import shutil
+from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
-import json
import numpy as np
@@ -41,6 +42,7 @@ def run_roundtrip_benchmark(
limit_cases: int | None = None,
keep_existing: bool = False,
solver_params: SolverHyperParams | None = None,
+ workers: int = 1,
) -> dict[str, Any]:
corpus_path = Path(corpus_dir)
originals_dir = corpus_path / "originals"
@@ -62,113 +64,39 @@ def run_roundtrip_benchmark(
if out_path.exists() and not keep_existing:
shutil.rmtree(out_path)
out_path.mkdir(parents=True, exist_ok=True)
- rows: list[dict[str, Any]] = []
+
+ tasks: list[dict[str, Any]] = []
for case_index, original_path in enumerate(original_paths):
original = load_rgba(original_path)
- metadata = _load_metadata(original_path)
case_id = original_path.relative_to(originals_dir).with_suffix("").as_posix()
case_dir = out_path / "cases" / Path(case_id)
case_dir.mkdir(parents=True, exist_ok=True)
save_rgba(case_dir / "original.png", original)
for profile_index, profile in enumerate(selected_profiles):
for variant_index in range(variants):
- variant_seed = seed + case_index * 1000 + profile_index * 100 + variant_index
- settings = _variant_settings(variant_seed, profile=profile)
- variant_dir = case_dir / f"profile-{profile}" / f"variant-{variant_index + 1:02d}"
- diagnostics_dir = variant_dir / "diagnostics"
- diagnostics_dir.mkdir(parents=True, exist_ok=True)
- fake = fake_pixelize(original, seed=variant_seed, **settings)
- input_path = variant_dir / "input.png"
- output_path = variant_dir / "optimized.png"
- save_rgba(input_path, fake)
-
- locked_target_size = max(original.shape[0], original.shape[1]) if not infer_size else None
- result = run_pipeline(
- input_path,
- output_path,
- target_size=locked_target_size,
- diagnostics_dir=diagnostics_dir,
- seed=variant_seed,
- steps=steps,
- device=device,
- solver_params=solver_params,
+ tasks.append(
+ {
+ "original_path": original_path,
+ "originals_dir": originals_dir,
+ "out_path": out_path,
+ "case_index": case_index,
+ "profile_index": profile_index,
+ "profile": profile,
+ "variant_index": variant_index,
+ "seed": seed,
+ "steps": steps,
+ "device": device,
+ "infer_size": infer_size,
+ "solver_params": solver_params,
+ }
)
- naive = naive_resize_baseline(fake, width=original.shape[1], height=original.shape[0])
- diffusion = error_diffusion_baseline(fake, width=original.shape[1], height=original.shape[0])
- save_rgba(variant_dir / "naive.png", naive)
- save_rgba(variant_dir / "diffusion.png", diffusion)
- _save_preview(variant_dir / "optimized-preview.png", result.output_rgba, original)
- _save_preview(variant_dir / "naive-preview.png", naive, original)
- _save_preview(variant_dir / "diffusion-preview.png", diffusion, original)
-
- optimized_preview = nearest_resize(result.output_rgba, width=original.shape[1], height=original.shape[0])
- optimized_error = foreground_reconstruction_error(optimized_preview, original)
- naive_error = foreground_reconstruction_error(naive, original)
- diffusion_error = foreground_reconstruction_error(diffusion, original)
- optimized_adjacency = foreground_adjacency_error(optimized_preview, original)
- naive_adjacency = foreground_adjacency_error(naive, original)
- diffusion_adjacency = foreground_adjacency_error(diffusion, original)
- optimized_motif = foreground_motif_error(optimized_preview, original)
- naive_motif = foreground_motif_error(naive, original)
- diffusion_motif = foreground_motif_error(diffusion, original)
- row = {
- "case_id": case_id,
- "profile": profile,
- "variant": variant_index + 1,
- "source_file": str(original_path),
- "title": metadata.get("title", ""),
- "author": metadata.get("author", ""),
- "license": metadata.get("license", ""),
- "source_url": metadata.get("source_url", ""),
- "original_width": int(original.shape[1]),
- "original_height": int(original.shape[0]),
- "input_width": int(fake.shape[1]),
- "input_height": int(fake.shape[0]),
- "upscale": settings["upscale"],
- "offset_x": settings["offset_x"],
- "offset_y": settings["offset_y"],
- "blur_radius": settings["blur_radius"],
- "warp_strength": settings["warp_strength"],
- "warp_detail": settings["warp_detail"],
- "warp_sample_mode": settings["warp_sample_mode"],
- "artifact_density": settings["artifact_density"],
- "artifact_strength": settings["artifact_strength"],
- "primary_metric": "foreground_premultiplied_mae",
- "reference_foreground_coverage": foreground_coverage(original, original),
- "target_size_locked": not infer_size,
- "inferred_width": result.inference.target_width,
- "inferred_height": result.inference.target_height,
- "inference_confidence": result.inference.confidence,
- "optimized_error_to_original": optimized_error,
- "naive_error_to_original": naive_error,
- "diffusion_error_to_original": diffusion_error,
- "optimized_adjacency_error_to_original": optimized_adjacency,
- "naive_adjacency_error_to_original": naive_adjacency,
- "diffusion_adjacency_error_to_original": diffusion_adjacency,
- "optimized_motif_error_to_original": optimized_motif,
- "naive_motif_error_to_original": naive_motif,
- "diffusion_motif_error_to_original": diffusion_motif,
- "optimized_canvas_error_to_original": reconstruction_error(optimized_preview, original),
- "naive_canvas_error_to_original": reconstruction_error(naive, original),
- "diffusion_canvas_error_to_original": reconstruction_error(diffusion, original),
- "optimized_exact_match": foreground_exact_match_ratio(optimized_preview, original),
- "naive_exact_match": foreground_exact_match_ratio(naive, original),
- "diffusion_exact_match": foreground_exact_match_ratio(diffusion, original),
- "optimized_canvas_exact_match": exact_match_ratio(optimized_preview, original),
- "naive_canvas_exact_match": exact_match_ratio(naive, original),
- "diffusion_canvas_exact_match": exact_match_ratio(diffusion, original),
- "optimized_coherence": coherence_breakdown(result.output_rgba)["coherence_score"],
- "naive_coherence": coherence_breakdown(naive)["coherence_score"],
- "diffusion_coherence": coherence_breakdown(diffusion)["coherence_score"],
- "optimized_beats_naive_error": optimized_error <= naive_error,
- "optimized_beats_diffusion_error": optimized_error <= diffusion_error,
- "optimized_beats_naive_adjacency": optimized_adjacency <= naive_adjacency,
- "optimized_beats_diffusion_adjacency": optimized_adjacency <= diffusion_adjacency,
- "optimized_beats_naive_motif": optimized_motif <= naive_motif,
- "optimized_beats_diffusion_motif": optimized_motif <= diffusion_motif,
- }
- rows.append(row)
+ effective_workers = max(1, int(workers))
+ if effective_workers == 1:
+ rows = [_run_roundtrip_row(task) for task in tasks]
+ else:
+ with ProcessPoolExecutor(max_workers=effective_workers) as pool:
+ rows = list(pool.map(_run_roundtrip_row, tasks))
summary = {
"case_count": len(original_paths),
@@ -178,6 +106,7 @@ def run_roundtrip_benchmark(
"target_size_locked": not infer_size,
"primary_metric": "foreground_premultiplied_mae",
"solver_params": solver_params.to_dict() if solver_params is not None else SolverHyperParams().to_dict(),
+ "workers": effective_workers,
"cases": _summarize_cases(rows),
"rows": rows,
}
@@ -186,6 +115,134 @@ def run_roundtrip_benchmark(
return summary
+def _run_roundtrip_row(task: dict[str, Any]) -> dict[str, Any]:
+ original_path = Path(task["original_path"])
+ originals_dir = Path(task["originals_dir"])
+ out_path = Path(task["out_path"])
+ case_index = int(task["case_index"])
+ profile_index = int(task["profile_index"])
+ profile = str(task["profile"])
+ variant_index = int(task["variant_index"])
+ seed = int(task["seed"])
+ steps = int(task["steps"])
+ device = str(task["device"])
+ infer_size = bool(task["infer_size"])
+ solver_params = task["solver_params"]
+
+ _limit_worker_threads()
+
+ original = load_rgba(original_path)
+ metadata = _load_metadata(original_path)
+ case_id = original_path.relative_to(originals_dir).with_suffix("").as_posix()
+ case_dir = out_path / "cases" / Path(case_id)
+ variant_seed = seed + case_index * 1000 + profile_index * 100 + variant_index
+ settings = _variant_settings(variant_seed, profile=profile)
+ variant_dir = case_dir / f"profile-{profile}" / f"variant-{variant_index + 1:02d}"
+ diagnostics_dir = variant_dir / "diagnostics"
+ diagnostics_dir.mkdir(parents=True, exist_ok=True)
+ fake = fake_pixelize(original, seed=variant_seed, **settings)
+ input_path = variant_dir / "input.png"
+ output_path = variant_dir / "optimized.png"
+ save_rgba(input_path, fake)
+
+ locked_target_size = max(original.shape[0], original.shape[1]) if not infer_size else None
+ result = run_pipeline(
+ input_path,
+ output_path,
+ target_size=locked_target_size,
+ diagnostics_dir=diagnostics_dir,
+ seed=variant_seed,
+ steps=steps,
+ device=device,
+ solver_params=solver_params,
+ )
+
+ naive = naive_resize_baseline(fake, width=original.shape[1], height=original.shape[0])
+ diffusion = error_diffusion_baseline(fake, width=original.shape[1], height=original.shape[0])
+ save_rgba(variant_dir / "naive.png", naive)
+ save_rgba(variant_dir / "diffusion.png", diffusion)
+ _save_preview(variant_dir / "optimized-preview.png", result.output_rgba, original)
+ _save_preview(variant_dir / "naive-preview.png", naive, original)
+ _save_preview(variant_dir / "diffusion-preview.png", diffusion, original)
+
+ optimized_preview = nearest_resize(result.output_rgba, width=original.shape[1], height=original.shape[0])
+ optimized_error = foreground_reconstruction_error(optimized_preview, original)
+ naive_error = foreground_reconstruction_error(naive, original)
+ diffusion_error = foreground_reconstruction_error(diffusion, original)
+ optimized_adjacency = foreground_adjacency_error(optimized_preview, original)
+ naive_adjacency = foreground_adjacency_error(naive, original)
+ diffusion_adjacency = foreground_adjacency_error(diffusion, original)
+ optimized_motif = foreground_motif_error(optimized_preview, original)
+ naive_motif = foreground_motif_error(naive, original)
+ diffusion_motif = foreground_motif_error(diffusion, original)
+ return {
+ "case_id": case_id,
+ "profile": profile,
+ "variant": variant_index + 1,
+ "source_file": str(original_path),
+ "title": metadata.get("title", ""),
+ "author": metadata.get("author", ""),
+ "license": metadata.get("license", ""),
+ "source_url": metadata.get("source_url", ""),
+ "original_width": int(original.shape[1]),
+ "original_height": int(original.shape[0]),
+ "input_width": int(fake.shape[1]),
+ "input_height": int(fake.shape[0]),
+ "upscale": settings["upscale"],
+ "offset_x": settings["offset_x"],
+ "offset_y": settings["offset_y"],
+ "blur_radius": settings["blur_radius"],
+ "warp_strength": settings["warp_strength"],
+ "warp_detail": settings["warp_detail"],
+ "warp_sample_mode": settings["warp_sample_mode"],
+ "artifact_density": settings["artifact_density"],
+ "artifact_strength": settings["artifact_strength"],
+ "primary_metric": "foreground_premultiplied_mae",
+ "reference_foreground_coverage": foreground_coverage(original, original),
+ "target_size_locked": not infer_size,
+ "inferred_width": result.inference.target_width,
+ "inferred_height": result.inference.target_height,
+ "inference_confidence": result.inference.confidence,
+ "optimized_error_to_original": optimized_error,
+ "naive_error_to_original": naive_error,
+ "diffusion_error_to_original": diffusion_error,
+ "optimized_adjacency_error_to_original": optimized_adjacency,
+ "naive_adjacency_error_to_original": naive_adjacency,
+ "diffusion_adjacency_error_to_original": diffusion_adjacency,
+ "optimized_motif_error_to_original": optimized_motif,
+ "naive_motif_error_to_original": naive_motif,
+ "diffusion_motif_error_to_original": diffusion_motif,
+ "optimized_canvas_error_to_original": reconstruction_error(optimized_preview, original),
+ "naive_canvas_error_to_original": reconstruction_error(naive, original),
+ "diffusion_canvas_error_to_original": reconstruction_error(diffusion, original),
+ "optimized_exact_match": foreground_exact_match_ratio(optimized_preview, original),
+ "naive_exact_match": foreground_exact_match_ratio(naive, original),
+ "diffusion_exact_match": foreground_exact_match_ratio(diffusion, original),
+ "optimized_canvas_exact_match": exact_match_ratio(optimized_preview, original),
+ "naive_canvas_exact_match": exact_match_ratio(naive, original),
+ "diffusion_canvas_exact_match": exact_match_ratio(diffusion, original),
+ "optimized_coherence": coherence_breakdown(result.output_rgba)["coherence_score"],
+ "naive_coherence": coherence_breakdown(naive)["coherence_score"],
+ "diffusion_coherence": coherence_breakdown(diffusion)["coherence_score"],
+ "optimized_beats_naive_error": optimized_error <= naive_error,
+ "optimized_beats_diffusion_error": optimized_error <= diffusion_error,
+ "optimized_beats_naive_adjacency": optimized_adjacency <= naive_adjacency,
+ "optimized_beats_diffusion_adjacency": optimized_adjacency <= diffusion_adjacency,
+ "optimized_beats_naive_motif": optimized_motif <= naive_motif,
+ "optimized_beats_diffusion_motif": optimized_motif <= diffusion_motif,
+ }
+
+
+def _limit_worker_threads() -> None:
+ try:
+ import torch
+
+ torch.set_num_threads(1)
+ torch.set_num_interop_threads(1)
+ except Exception:
+ pass
+
+
def _load_metadata(original_path: Path) -> dict[str, Any]:
metadata_path = original_path.with_suffix(".json")
if not metadata_path.exists():
diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py
index fcfe502..59e2994 100644
--- a/src/repixelizer/cli.py
+++ b/src/repixelizer/cli.py
@@ -79,6 +79,7 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Keep any existing files in the benchmark output directory instead of clearing it first",
)
+ benchmark_parser.add_argument("--workers", type=int, default=1, help="Parallel benchmark worker processes")
tune_parser = subparsers.add_parser("tune", help="Run a black-box hyperparameter search on a benchmark slice.")
tune_parser.add_argument("--corpus-dir", default="examples/corpus", help="Corpus root containing originals/")
tune_parser.add_argument("--out-dir", default="artifacts/tuning", help="Directory for tuning outputs")
@@ -111,6 +112,7 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Let the pipeline infer output size instead of locking it to the original ground-truth size",
)
+ tune_parser.add_argument("--workers", type=int, default=1, help="Parallel benchmark worker processes per trial")
prepare_parser = subparsers.add_parser("prepare-corpus", help="Normalize imported corpus sheets into single sprites.")
prepare_parser.add_argument("--corpus-dir", default="examples/corpus", help="Corpus root containing originals/")
gui_parser = subparsers.add_parser("gui", help="Launch the web GUI.")
@@ -157,6 +159,7 @@ def main(argv: list[str] | None = None) -> int:
include_cases=args.case,
limit_cases=args.limit,
keep_existing=args.keep_existing,
+ workers=args.workers,
)
return 0
if command == "tune":
@@ -172,6 +175,7 @@ def main(argv: list[str] | None = None) -> int:
infer_size=args.infer_size,
include_cases=args.case,
limit_cases=args.limit,
+ workers=args.workers,
)
return 0
if command == "prepare-corpus":
diff --git a/src/repixelizer/discrete.py b/src/repixelizer/discrete.py
index f6b23aa..e754cfb 100644
--- a/src/repixelizer/discrete.py
+++ b/src/repixelizer/discrete.py
@@ -2,63 +2,12 @@ from __future__ import annotations
import numpy as np
-from .metrics import isolated_pixel_rate, luminance
from .types import CleanupArtifacts
-def _local_energy(patch: np.ndarray) -> float:
- center = patch[1, 1]
- neighbors = np.stack(
- [
- patch[1, 0],
- patch[1, 2],
- patch[0, 1],
- patch[2, 1],
- ],
- axis=0,
- )
- dist = np.linalg.norm(neighbors - center[None, :], axis=-1)
- isolation = float(np.mean(dist))
- alpha = patch[..., 3]
- alpha_kink = float(np.mean(alpha * (1.0 - alpha)))
- return isolation + alpha_kink * 0.25
-
-
-def cleanup_pixels(rgba: np.ndarray, source_guidance: np.ndarray, iterations: int = 0) -> CleanupArtifacts:
- result = rgba.copy()
- height, width = result.shape[:2]
- heatmap = np.zeros((height, width), dtype=np.float32)
- if iterations <= 0:
- result = _snap_alpha(result)
- return CleanupArtifacts(cleaned_rgba=result, isolated_heatmap=heatmap)
- for _ in range(iterations):
- updated = result.copy()
- for y in range(1, height - 1):
- for x in range(1, width - 1):
- if source_guidance[y, x] > 0.55:
- continue
- patch = result[y - 1 : y + 2, x - 1 : x + 2]
- current_energy = _local_energy(patch)
- neighbors = [
- result[y, x],
- result[y - 1, x],
- result[y + 1, x],
- result[y, x - 1],
- result[y, x + 1],
- ]
- best = result[y, x]
- best_energy = current_energy
- for candidate in neighbors[1:]:
- patch_candidate = patch.copy()
- patch_candidate[1, 1] = candidate
- energy = _local_energy(patch_candidate)
- if energy + 1e-6 < best_energy:
- best_energy = energy
- best = candidate
- updated[y, x] = best
- heatmap[y, x] = max(heatmap[y, x], current_energy - best_energy)
- result = np.clip(updated, 0.0, 1.0)
- result = _snap_alpha(result)
+def cleanup_pixels(rgba: np.ndarray) -> CleanupArtifacts:
+ result = _snap_alpha(rgba)
+ heatmap = np.zeros(result.shape[:2], dtype=np.float32)
return CleanupArtifacts(cleaned_rgba=result, isolated_heatmap=heatmap)
diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py
index 732656b..2cdeeb7 100644
--- a/src/repixelizer/gui.py
+++ b/src/repixelizer/gui.py
@@ -349,12 +349,12 @@ class GuiJob:
"candidateCount": int(payload["candidate_count"]),
"confidence": float(payload["confidence"]),
}
- if event == "lattice_search_started":
+ if event == "lattice_inference_started":
return {
"candidateCount": int(payload["candidate_count"]),
"device": str(payload["device"]),
}
- if event == "lattice_search_progress":
+ if event == "lattice_inference_progress":
return {
"completedCandidates": int(payload["completed_candidates"]),
"totalCandidates": int(payload["total_candidates"]),
@@ -410,7 +410,7 @@ class GuiJob:
"cellX": float(payload["cell_x"]),
"cellY": float(payload["cell_y"]),
"latticeImage": _image_asset(_render_lattice_overlay(self.current_source_rgba, payload["uv0_px"])),
- "guidanceImage": _image_asset(_scalar_to_rgba(payload["guidance"], tint=(0.92, 0.58, 0.18))),
+ "signalImage": _image_asset(_scalar_to_rgba(payload["signal"], tint=(0.92, 0.58, 0.18))),
}
if event in {"phase_field_initial", "phase_field_step", "phase_field_final"}:
if self.current_source_rgba is None:
@@ -513,9 +513,9 @@ def _normalize_job_options(
normalized_steps = min(requested_steps, config.max_steps)
normalized_device = "cpu" if config.hosted_demo else (device.strip() if device.strip() else "auto")
normalized_strip_background = False if config.hosted_demo else bool(strip_background)
- inference_mode = "autocorr" if config.hosted_demo and fixed_dims is None else "search"
- max_inferred_target_size = config.max_output_dimension if config.hosted_demo and fixed_dims is None else None
- normalized_skip_candidate_rerank = True if config.hosted_demo else bool(skip_candidate_rerank)
+ inference_mode = "autocorr" if fixed_dims is None else "fixed"
+ max_inferred_target_size = config.max_output_dimension if fixed_dims is None else None
+ normalized_skip_candidate_rerank = bool(skip_candidate_rerank)
return {
"target_size": normalized_target_size,
"target_width": normalized_target_width,
diff --git a/src/repixelizer/gui_static/app.js b/src/repixelizer/gui_static/app.js
index 30d74dd..25ce42e 100644
--- a/src/repixelizer/gui_static/app.js
+++ b/src/repixelizer/gui_static/app.js
@@ -65,8 +65,8 @@ const eventTypes = [
"stage_started",
"source_loaded",
"preprocess_completed",
- "lattice_search_started",
- "lattice_search_progress",
+ "lattice_inference_started",
+ "lattice_inference_progress",
"inference_candidates_ready",
"candidate_rerank_started",
"candidate_rerank_candidate_started",
@@ -92,7 +92,7 @@ const state = {
sourceImage: null,
preprocessedImage: null,
latticeImage: null,
- guidanceImage: null,
+ signalImage: null,
inference: null,
inferenceMode: null,
latticeSearch: null,
@@ -311,7 +311,7 @@ function applyRuntimeConfig(config) {
}
else {
inputCopy.textContent = "Start with the ugly input.";
- runControlsCopy.textContent = "Pin what you know. Search what you do not.";
+ runControlsCopy.textContent = "Pin what you know. Infer what you do not.";
}
}
function resetRunArtifacts(options = {}) {
@@ -322,7 +322,7 @@ function resetRunArtifacts(options = {}) {
}
state.preprocessedImage = null;
state.latticeImage = null;
- state.guidanceImage = null;
+ state.signalImage = null;
state.inference = null;
state.inferenceMode = null;
state.latticeSearch = null;
@@ -623,13 +623,13 @@ function resolveViewerPanels() {
leftAsset = state.latticeImage;
leftLabel = "Lattice Prep";
}
- if (state.guidanceImage) {
- leftAsset = state.guidanceImage;
- leftLabel = "Guidance";
+ if (state.signalImage) {
+ leftAsset = state.signalImage;
+ leftLabel = "Signal";
}
if (frame) {
- if (state.stageKey === "solver" && state.guidanceImage) {
- leftAsset = state.guidanceImage;
+ if (state.stageKey === "solver" && state.signalImage) {
+ leftAsset = state.signalImage;
rightAsset = frame.samplingOverlayImage;
leftLabel = "Signal";
rightLabel = "Sampling Overlay";
@@ -1302,7 +1302,7 @@ async function handleEvent(eventName, payload) {
state.preprocessedImage = payload.sourceImage;
addLog("Preprocess", "Stripped edge-connected background noise.");
break;
- case "lattice_search_started":
+ case "lattice_inference_started":
state.latticeSearch = {
candidateCount: Number(payload.candidateCount ?? 0),
completedCandidates: 0,
@@ -1311,10 +1311,10 @@ async function handleEvent(eventName, payload) {
currentTargetHeight: null,
bestScore: null,
};
- setStage("inference", "Lattice search", `Testing ${state.latticeSearch.candidateCount} candidate sizes from canonical cell centers.`);
- addLog("Search", `Started lattice search across ${String(payload.candidateCount)} size candidates.`);
+ setStage("inference", "Autocorr lattice", `Testing ${state.latticeSearch.candidateCount} autocorr candidate sizes from canonical cell centers.`);
+ addLog("Inference", `Started autocorr lattice inference across ${String(payload.candidateCount)} size candidates.`);
break;
- case "lattice_search_progress":
+ case "lattice_inference_progress":
if (!state.latticeSearch) {
state.latticeSearch = {
candidateCount: Number(payload.totalCandidates ?? 0),
@@ -1331,7 +1331,7 @@ async function handleEvent(eventName, payload) {
state.latticeSearch.currentTargetHeight = Number(payload.targetHeight ?? 0);
state.latticeSearch.bestScore =
payload.bestScore === null || payload.bestScore === undefined ? state.latticeSearch.bestScore : Number(payload.bestScore);
- setStage("inference", "Lattice search", `Size ${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}: scored ${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight} from canonical cell centers.`);
+ setStage("inference", "Autocorr lattice", `Size ${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}: scored ${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight} from canonical cell centers.`);
break;
case "inference_candidates_ready":
state.inference = payload.inference;
@@ -1398,11 +1398,11 @@ async function handleEvent(eventName, payload) {
addLog("Selection", "Committed to a lattice size.");
break;
case "analysis_completed":
- addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
+ addLog("Analysis", "Rendered the diagnostic edge preview.");
break;
case "phase_field_prepared":
state.latticeImage = payload.latticeImage;
- state.guidanceImage = payload.guidanceImage;
+ state.signalImage = payload.signalImage;
state.phaseFieldPrep = {
targetWidth: Number(payload.targetWidth ?? 0),
targetHeight: Number(payload.targetHeight ?? 0),
@@ -1435,8 +1435,8 @@ async function handleEvent(eventName, payload) {
case "cleanup_completed":
state.cleanupImage = payload.cleanedImage;
state.heatmapImage = payload.heatmapImage;
- setStage("cleanup", "Cleanup", "Sweeping isolated artifacts and smoothing the last stubborn junk.");
- addLog("Cleanup", "Ran the local cleanup sweep.");
+ setStage("cleanup", "Cleanup", "Snapping alpha to the final discrete mask.");
+ addLog("Cleanup", "Applied alpha snapping.");
break;
case "palette_completed":
state.finalOutputImage = payload.outputImage;
diff --git a/src/repixelizer/gui_static/index.html b/src/repixelizer/gui_static/index.html
index 6bb6838..eacdfc0 100644
--- a/src/repixelizer/gui_static/index.html
+++ b/src/repixelizer/gui_static/index.html
@@ -171,7 +171,7 @@
Pin what you know. Search what you do not.
+Pin what you know. Infer what you do not.