Tighten autocorr lattice consensus

This commit is contained in:
Meta Vens 2026-04-26 18:59:39 +01:00
commit 338ecc7b24
8 changed files with 178 additions and 33 deletions

View file

@ -94,6 +94,7 @@ Tracked fixture:
Current stance:
- keep the shipped searched path grounded in autocorrelation only
- let autocorr keep a small near-best lag plateau and use cross-axis consensus before collapsing to one lattice family
- 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
- treat phase rerank as optional scaffolding, not sacred machinery

View file

@ -93,6 +93,7 @@ The pipeline first decides whether the lattice is:
`infer_lattice(...)` itself is already a small machine:
- it estimates rough cell spacing from source edge-profile autocorrelation
- it keeps a tiny near-best lag plateau long enough to find cross-axis consensus instead of collapsing instantly to one lag
- it builds candidate output sizes
- it scores a small phase grid for each size
- it keeps the best candidate plus top alternates

View file

@ -28,7 +28,7 @@ Do not trust this file for the exact live HEAD. Always check git.
- `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; 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
- 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
- The low-confidence phase rerank path is a short preview solve, not a second full optimizer.
- the phase field now has a wider displacement budget, so moderate phase mistakes are allowed to get corrected by the field instead of relying purely on rerank ceremony
- Cleanup is secondary and usually a no-op; the core result is supposed to come from the solver, not cleanup cosplay.

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .io import premultiply
@ -8,6 +10,14 @@ from .observe import PipelineObserver, check_observer_cancelled, emit_observer
from .types import InferenceCandidate, InferenceResult
@dataclass(frozen=True)
class AutocorrEstimate:
best_lag: float
best_score: float
candidate_lags: tuple[float, ...]
candidate_scores: tuple[float, ...]
def _require_torch():
try:
import torch
@ -70,8 +80,13 @@ def _autocorr_size_window(
return None
center = int(round(float(np.median(np.asarray(hinted_sizes, dtype=np.float32)))))
spread = max(abs(int(size) - center) for size in hinted_sizes)
center_support = hinted_sizes.count(center)
support_ratio = center_support / max(1, len(hinted_sizes))
nearest_other = min((abs(int(size) - center) for size in hinted_sizes if int(size) != center), default=0)
if spread == 0:
radius = 0 if prior_reliability >= 0.82 else 1
elif support_ratio >= 0.5 and prior_reliability >= 0.82:
radius = 1 if nearest_other <= 2 else 2
elif spread <= 2 and prior_reliability >= 0.72:
radius = 1
elif spread <= 4 and prior_reliability >= 0.55:
@ -102,20 +117,35 @@ def _resolve_candidate_dims_from_autocorr(
return narrowed or dims
def _estimate_cell_size(profile: np.ndarray) -> float:
def _estimate_cell_size_details(profile: np.ndarray) -> AutocorrEstimate:
centered = profile.astype(np.float32) - float(np.mean(profile))
max_lag = min(64, max(4, profile.shape[0] // 3))
best_lag = 1
lags = np.arange(2, max_lag + 1, dtype=np.float32)
if lags.size == 0:
return AutocorrEstimate(best_lag=1.0, best_score=0.0, candidate_lags=(), candidate_scores=())
scores: list[float] = []
best_lag = float(lags[0])
best_score = -float("inf")
for lag in range(2, max_lag + 1):
for lag_value in lags:
lag = int(lag_value)
left = centered[:-lag]
right = centered[lag:]
denom = float(np.linalg.norm(left) * np.linalg.norm(right)) + 1e-6
score = float(np.dot(left, right) / denom)
scores.append(score)
if score > best_score:
best_score = score
best_lag = lag
return float(best_lag)
best_lag = float(lag)
return AutocorrEstimate(
best_lag=best_lag,
best_score=float(best_score),
candidate_lags=tuple(float(lag) for lag in lags.tolist()),
candidate_scores=tuple(float(score) for score in scores),
)
def _estimate_cell_size(profile: np.ndarray) -> float:
return _estimate_cell_size_details(profile).best_lag
def _edge_profiles(rgba: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
@ -130,9 +160,52 @@ def _edge_profiles(rgba: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
return profile_x, profile_y
def _estimate_lattice_autocorr_details(rgba: np.ndarray) -> tuple[float, float]:
def _estimate_lattice_autocorr_details(rgba: np.ndarray) -> tuple[AutocorrEstimate, AutocorrEstimate]:
profile_x, profile_y = _edge_profiles(rgba)
return _estimate_cell_size(profile_x), _estimate_cell_size(profile_y)
return _estimate_cell_size_details(profile_x), _estimate_cell_size_details(profile_y)
def _autocorr_plateau_lags(estimate: AutocorrEstimate, min_relative: float = 0.96) -> list[tuple[float, float]]:
if not estimate.candidate_lags or not estimate.candidate_scores:
return []
lags = np.asarray(estimate.candidate_lags, dtype=np.float32)
scores = np.asarray(estimate.candidate_scores, dtype=np.float32)
best_index = int(np.argmax(scores))
threshold = float(scores[best_index]) * min_relative
keep = {best_index}
left = best_index - 1
while left >= 0 and float(scores[left]) >= threshold:
keep.add(left)
left -= 1
right = best_index + 1
while right < scores.size and float(scores[right]) >= threshold:
keep.add(right)
right += 1
return [(float(lags[index]), float(scores[index])) for index in sorted(keep)]
def _consensus_autocorr_lag(
estimate_x: AutocorrEstimate,
estimate_y: AutocorrEstimate,
) -> tuple[float, float] | None:
plateau_x = _autocorr_plateau_lags(estimate_x)
plateau_y = _autocorr_plateau_lags(estimate_y)
if not plateau_x or not plateau_y:
return None
best: tuple[float, float] | None = None
best_score = -float("inf")
for lag_x, score_x in plateau_x:
for lag_y, score_y in plateau_y:
if abs(lag_x - lag_y) > 1.0:
continue
combined_score = score_x + score_y - abs(lag_x - lag_y) * 0.05
candidate_lag = _weighted_geometric_mean([lag_x, lag_y], [score_x, score_y])
# When ambiguity is nearly tied, prefer the denser reading.
combined_score += (1.0 / max(1e-6, candidate_lag)) * 0.01
if combined_score > best_score:
best_score = combined_score
best = (candidate_lag, combined_score)
return best
def _weighted_geometric_mean(values: list[float], weights: list[float]) -> float:
@ -164,8 +237,23 @@ def _estimate_lattice_prior_details(
rgba: np.ndarray,
) -> tuple[float, float, float]:
autocorr_x, autocorr_y = _estimate_lattice_autocorr_details(rgba)
axis_x = (autocorr_x, 1.0)
axis_y = (autocorr_y, 1.0)
consensus = _consensus_autocorr_lag(autocorr_x, autocorr_y)
if consensus is not None:
shared_prior = float(consensus[0])
best_x = max(1e-6, autocorr_x.best_score)
best_y = max(1e-6, autocorr_y.best_score)
relative_x = next(
(score / best_x for lag, score in _autocorr_plateau_lags(autocorr_x) if abs(lag - shared_prior) <= 1.0),
autocorr_x.best_score / best_x,
)
relative_y = next(
(score / best_y for lag, score in _autocorr_plateau_lags(autocorr_y) if abs(lag - shared_prior) <= 1.0),
autocorr_y.best_score / best_y,
)
reliability = float(np.clip(0.55 + 0.4 * ((relative_x + relative_y) * 0.5), 0.55, 0.95))
return shared_prior, shared_prior, reliability
axis_x = (autocorr_x.best_lag, 1.0)
axis_y = (autocorr_y.best_lag, 1.0)
shared_prior, reliability = _combine_axis_priors([axis_x, axis_y])
return shared_prior, shared_prior, reliability
@ -174,25 +262,36 @@ def _hint_target_sizes_from_autocorr(
width: int,
height: int,
*,
autocorr_x: float,
autocorr_y: float,
autocorr_x_estimate: AutocorrEstimate,
autocorr_y_estimate: AutocorrEstimate,
shared_prior: float,
) -> list[int]:
hinted_sizes: set[int] = set()
hinted_sizes: list[int] = []
if width >= height:
major = width
minor = height
major_cell = autocorr_x
minor_cell = autocorr_y
major_estimate = autocorr_x_estimate
minor_estimate = autocorr_y_estimate
else:
major = height
minor = width
major_cell = autocorr_y
minor_cell = autocorr_x
hinted_sizes.add(max(1, int(round(major / max(1e-6, major_cell)))))
minor_target = max(1, int(round(minor / max(1e-6, minor_cell))))
hinted_sizes.add(max(1, int(round(major * minor_target / max(1, minor)))))
hinted_sizes.add(max(1, int(round(major / max(1e-6, shared_prior)))))
major_estimate = autocorr_y_estimate
minor_estimate = autocorr_x_estimate
hinted_sizes.extend(
max(1, int(round(major / max(1e-6, lag))))
for lag, _score in _autocorr_plateau_lags(major_estimate)
)
for lag, _score in _autocorr_plateau_lags(minor_estimate):
minor_target = max(1, int(round(minor / max(1e-6, lag))))
hinted_sizes.append(max(1, int(round(major * minor_target / max(1, minor)))))
consensus = _consensus_autocorr_lag(autocorr_x_estimate, autocorr_y_estimate)
if consensus is not None:
consensus_size = max(1, int(round(major / max(1e-6, consensus[0]))))
hinted_sizes.append(consensus_size)
hinted_sizes.append(consensus_size)
hinted_sizes.append(max(1, int(round(major / max(1e-6, shared_prior)))))
return sorted(hinted_sizes)
@ -441,15 +540,15 @@ def infer_lattice(
torch, _ = _require_torch()
resolved_device = _resolve_device(torch, device)
height, width = rgba.shape[:2]
autocorr_x, autocorr_y = _estimate_lattice_autocorr_details(rgba)
autocorr_x_estimate, autocorr_y_estimate = _estimate_lattice_autocorr_details(rgba)
prior_cell_x, prior_cell_y, prior_reliability = _estimate_lattice_prior_details(
rgba,
)
hinted_sizes = _hint_target_sizes_from_autocorr(
width,
height,
autocorr_x=autocorr_x,
autocorr_y=autocorr_y,
autocorr_x_estimate=autocorr_x_estimate,
autocorr_y_estimate=autocorr_y_estimate,
shared_prior=prior_cell_x,
)
phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32)

View file

@ -9,3 +9,4 @@
{"ts": "2026-04-26T13:53:17+00:00", "type": "research", "status": "ok", "note": "Relaxed automatic candidate search so high guided sizes from the spacing spectrum can survive above the old 256 cap. On tests/fixtures/real/dense-landscape.png, auto inference now lands at 418x418 instead of 251x251; still short of the fixed 512x512 target, but no longer throwing away most of the detail before scoring starts."}
{"ts": "2026-04-26T15:46:23+00:00", "type": "research", "status": "rejected", "note": "Tried a projected edge-energy spectral lattice prior and removed it before ship. On tests/fixtures/real/dense-landscape.png it floor-hugged to a 2px rhythm and widened the candidate family toward bogus 626-size aliases, so the searched path is back to change-interval evidence plus autocorrelation."}
{"ts": "2026-04-26T17:46:30+00:00", "type": "research", "status": "ok", "note": "Removed pixel-walk / change-interval sizing from src/repixelizer/inference.py and switched searched lattice inference to autocorrelation-only priors and candidate hints. Badge auto inference no longer gets pruned into the old 115-family by spacing-spectrum guidance; it now searches inside the 120-126 neighborhood while dense landscape still lands at 418x418."}
{"ts": "2026-04-26T17:58:43+00:00", "type": "research", "status": "ok", "note": "Autocorr inference now keeps a tiny near-best lag plateau and searches for cross-axis consensus before collapsing to one lattice size. On the real badge fixture this preserves x-axis support for 10px alongside 11px, raises the shared prior to the 125-family, and restores auto inference to 125 with top candidates 125/126/124/127/123 while dense landscape remains 418x418."}

View file

@ -90,5 +90,5 @@ rejected_paths:
reason: the projected edge-energy spectral pass floor-hugged to 2px / 626-family aliases on the dense landscape fixture and did not justify its complexity
current_status:
summary: The repo has one live reconstruction engine, `phase-field`, wired through the main pipeline with lattice inference, edge analysis, projected displacement optimization, cleanup, optional palette fitting, diagnostics, compare mode, tuning, and GUI observer events. The searched lattice path now trusts autocorrelation only; the spectral prior experiment was removed after it floor-hugged to 2px / 626-family aliases on the dense landscape fixture, and the old change-interval / pixel-walk sizing path was cut after it kept pruning the badge away from the `126` family. The solver now has a wider displacement budget so moderate phase error can be recovered by the field instead of requiring as much rerank ceremony. `source_structure` now rides alongside `source_fidelity` because lattice-only scoring was lying about visible quality. The tracked weakness is still the widened dark contour near the badge sword tip on tapered shapes.
summary: The repo has one live reconstruction engine, `phase-field`, wired through the main pipeline with lattice inference, edge analysis, projected displacement optimization, cleanup, optional palette fitting, diagnostics, compare mode, tuning, and GUI observer events. The searched lattice path now trusts autocorrelation only; it keeps a tiny near-best lag plateau and uses cross-axis consensus before collapsing to one lattice family. The spectral prior experiment was removed after it floor-hugged to 2px / 626-family aliases on the dense landscape fixture, and the old change-interval / pixel-walk sizing path was cut after it kept pruning the badge away from the `126` family. The solver now has a wider displacement budget so moderate phase error can be recovered by the field instead of requiring as much rerank ceremony. `source_structure` now rides alongside `source_fidelity` because lattice-only scoring was lying about visible quality. The tracked weakness is still the widened dark contour near the badge sword tip on tapered shapes.
next_action: After rehydrate, if the user wants continued solver work, take one bounded hypothesis aimed at along-stroke versus across-stroke behavior near tapered contours in `src/repixelizer/phase_field.py`, validate it against the pinned badge case and `tests/fixtures/real/ai-badge-tip-focus.json`, and revert immediately if the visual result or `source_structure` does not clearly improve.

View file

@ -315,12 +315,18 @@ def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None:
class _FakeTorch:
cuda = _FakeCuda()
fake_estimate = inference_module.AutocorrEstimate(
best_lag=4.0,
best_score=0.9,
candidate_lags=(4.0,),
candidate_scores=(0.9,),
)
monkeypatch.setattr(inference_module, "_require_torch", lambda: (_FakeTorch(), object()))
monkeypatch.setattr(inference_module, "_estimate_lattice_autocorr_details", lambda rgba: (4.0, 4.0))
monkeypatch.setattr(inference_module, "_estimate_lattice_autocorr_details", lambda rgba: (fake_estimate, fake_estimate))
monkeypatch.setattr(
inference_module,
"_hint_target_sizes_from_autocorr",
lambda width, height, *, autocorr_x, autocorr_y, shared_prior: [],
lambda width, height, *, autocorr_x_estimate, autocorr_y_estimate, shared_prior: [],
)
monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba: (4.0, 4.0, 0.5))
monkeypatch.setattr(

View file

@ -6,6 +6,8 @@ import numpy as np
import pytest
from repixelizer.inference import (
AutocorrEstimate,
_consensus_autocorr_lag,
_estimate_lattice_autocorr_details,
_estimate_lattice_prior_details,
_candidate_dims,
@ -22,18 +24,44 @@ from repixelizer.synthetic import fake_pixelize, make_emblem
from repixelizer.types import InferenceCandidate
def _make_autocorr_estimate(
*,
best_lag: int,
peaks: dict[int, float],
start: int = 2,
stop: int = 24,
) -> AutocorrEstimate:
candidate_lags = tuple(float(lag) for lag in range(start, stop + 1))
candidate_scores = tuple(float(peaks.get(lag, 0.1)) for lag in range(start, stop + 1))
return AutocorrEstimate(
best_lag=float(best_lag),
best_score=float(peaks[best_lag]),
candidate_lags=candidate_lags,
candidate_scores=candidate_scores,
)
def test_combined_axis_prior_prefers_consistent_shared_cell_size() -> None:
shared_prior, reliability = _combine_axis_priors([(9.0, 0.7), (11.2, 0.2)])
assert 8.5 < shared_prior < 10.5
assert 0.2 < reliability < 0.6
def test_autocorr_consensus_prefers_shared_dense_neighbor() -> None:
estimate_x = _make_autocorr_estimate(best_lag=11, peaks={10: 0.724, 11: 0.732})
estimate_y = _make_autocorr_estimate(best_lag=10, peaks={10: 0.878, 11: 0.859})
consensus = _consensus_autocorr_lag(estimate_x, estimate_y)
assert consensus is not None
assert 9.9 <= consensus[0] <= 10.2
def test_autocorr_hints_can_add_dense_candidates_around_true_size() -> None:
estimate = _make_autocorr_estimate(best_lag=8, peaks={8: 0.92})
hinted_sizes = _hint_target_sizes_from_autocorr(
1024,
1024,
autocorr_x=8.0,
autocorr_y=8.0,
autocorr_x_estimate=estimate,
autocorr_y_estimate=estimate,
shared_prior=8.0,
)
dims = _candidate_dims(1024, 1024, None, hinted_sizes=hinted_sizes)
@ -83,8 +111,8 @@ def test_dense_landscape_candidate_search_keeps_autocorr_size() -> None:
hinted_sizes = _hint_target_sizes_from_autocorr(
rgba.shape[1],
rgba.shape[0],
autocorr_x=autocorr_x,
autocorr_y=autocorr_y,
autocorr_x_estimate=autocorr_x,
autocorr_y_estimate=autocorr_y,
shared_prior=prior_x,
)
dims = _resolve_candidate_dims_from_autocorr(
@ -99,6 +127,14 @@ def test_dense_landscape_candidate_search_keeps_autocorr_size() -> None:
assert any(abs(size - 418) <= 1 for size in sizes)
def test_badge_autocorr_consensus_stays_in_canonical_family() -> None:
rgba = load_rgba(Path("tests/fixtures/real/ai-badge-cleaned.png"))
result = infer_lattice(rgba, device="cpu")
assert result.target_width in {125, 126}
assert result.top_candidates
assert result.top_candidates[0].target_width in {125, 126}
def test_top_candidates_are_diversified_by_size() -> None:
candidates = [
InferenceCandidate(target_width=113, target_height=113, phase_x=0.0, phase_y=0.0, score=0.90, breakdown={}),
@ -184,12 +220,13 @@ def test_infer_lattice_honors_cooperative_cancellation(monkeypatch) -> None:
def check_cancelled(self) -> bool:
return True
fake_estimate = _make_autocorr_estimate(best_lag=4, peaks={4: 0.9})
monkeypatch.setattr(inference_module, "_require_torch", lambda: (_FakeTorch(), object()))
monkeypatch.setattr(inference_module, "_estimate_lattice_autocorr_details", lambda rgba: (4.0, 4.0))
monkeypatch.setattr(inference_module, "_estimate_lattice_autocorr_details", lambda rgba: (fake_estimate, fake_estimate))
monkeypatch.setattr(
inference_module,
"_hint_target_sizes_from_autocorr",
lambda width, height, *, autocorr_x, autocorr_y, shared_prior: [],
lambda width, height, *, autocorr_x_estimate, autocorr_y_estimate, shared_prior: [],
)
monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba: (4.0, 4.0, 0.5))
monkeypatch.setattr(