From cbe1abd8c236e45bf333221a2fbea05d8272a3c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 26 Apr 2026 18:49:20 +0100 Subject: [PATCH] Drop pixel-walk lattice sizing --- docs/implementation-plan.md | 3 +- docs/lean-optimizer-algorithm-map.md | 2 +- notes/fresh-workspace-handoff.md | 4 +- src/repixelizer/inference.py | 395 ++++----------------------- state/evidence.jsonl | 1 + state/map.yaml | 2 +- tests/test_gui.py | 8 +- tests/test_inference.py | 137 +++------- tests/test_pipeline.py | 2 +- 9 files changed, 100 insertions(+), 454 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 2b632d5..43f8c94 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -93,7 +93,8 @@ Tracked fixture: Current stance: -- keep the shipped searched path grounded in change-interval evidence plus autocorrelation +- keep the shipped searched path grounded in autocorrelation only +- 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 diff --git a/docs/lean-optimizer-algorithm-map.md b/docs/lean-optimizer-algorithm-map.md index 46819ec..01d1275 100644 --- a/docs/lean-optimizer-algorithm-map.md +++ b/docs/lean-optimizer-algorithm-map.md @@ -92,7 +92,7 @@ The pipeline first decides whether the lattice is: `infer_lattice(...)` itself is already a small machine: -- it estimates rough cell spacing from source change intervals and autocorrelation +- it estimates rough cell spacing from source edge-profile autocorrelation - it builds candidate output sizes - it scores a small phase grid for each size - it keeps the best candidate plus top alternates diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index e8e05a4..7f8d4ca 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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 is back to change-interval evidence plus autocorrelation; the projected edge-energy spectral pass was tried, floor-hugged to 2px / 626-family aliases on the dense landscape fixture, and was removed +- 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 - 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. @@ -53,7 +53,7 @@ Do not trust this file for the exact live HEAD. Always check git. The current spine: - lattice inference with fixed-size and searched-size paths -- searched lattice inference currently trusts change-interval evidence plus autocorrelation, not the rejected spectral prior +- searched lattice inference currently trusts autocorrelation only, not the rejected pixel-walk or spectral priors - low-confidence phase 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` diff --git a/src/repixelizer/inference.py b/src/repixelizer/inference.py index fcd2ebc..d275b73 100644 --- a/src/repixelizer/inference.py +++ b/src/repixelizer/inference.py @@ -1,7 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass - import numpy as np from .io import premultiply @@ -10,24 +8,6 @@ from .observe import PipelineObserver, check_observer_cancelled, emit_observer from .types import InferenceCandidate, InferenceResult -@dataclass(frozen=True) -class SpacingEstimate: - spacing: float | None - confidence: float - best_cell: float | None - best_score: float - candidate_cells: tuple[float, ...] - candidate_scores: tuple[float, ...] - - -@dataclass(frozen=True) -class SpacingMode: - cell_size: float - score: float - relative_score: float - family_score: float - - def _require_torch(): try: import torch @@ -81,70 +61,43 @@ def _candidate_dims( return dims -def _major_axis_index(width: int, height: int) -> int: - return 0 if width >= height else 1 - - -def _strong_spacing_size_window( +def _autocorr_size_window( hinted_sizes: list[int], *, - spacing_x: tuple[float | None, float], - spacing_y: tuple[float | None, float], prior_reliability: float, ) -> tuple[int, int] | None: if not hinted_sizes: return None - axis_confidences = [float(confidence) for _spacing, confidence in (spacing_x, spacing_y) if confidence > 1e-6] - if not axis_confidences: - 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) - mean_confidence = float(np.mean(axis_confidences)) - max_confidence = float(max(axis_confidences)) - if prior_reliability < 0.45 or max_confidence < 0.55 or spread > 2: - return None - radius = 0 if spread == 0 and mean_confidence >= 0.72 and prior_reliability >= 0.68 else 1 + if spread == 0: + radius = 0 if prior_reliability >= 0.82 else 1 + elif spread <= 2 and prior_reliability >= 0.72: + radius = 1 + elif spread <= 4 and prior_reliability >= 0.55: + radius = 2 + else: + radius = max(3, min(12, spread + 2)) return center, radius -def _resolve_candidate_dims_from_spacing( +def _resolve_candidate_dims_from_autocorr( width: int, height: int, target_size: int | None, *, hinted_sizes: list[int], - spacing_x: tuple[float | None, float], - spacing_y: tuple[float | None, float], prior_reliability: float, - spacing_x_estimate: SpacingEstimate | None = None, - spacing_y_estimate: SpacingEstimate | None = None, ) -> list[tuple[int, int]]: - guided_sizes = _guided_target_sizes_from_spacing_spectra( - width, - height, - spacing_x_estimate=spacing_x_estimate, - spacing_y_estimate=spacing_y_estimate, - ) - dense_hints = sorted({int(size) for size in hinted_sizes} | {size for size, _radius in guided_sizes}) - dims = _candidate_dims(width, height, target_size, hinted_sizes=dense_hints) - size_window = _strong_spacing_size_window( + dims = _candidate_dims(width, height, target_size, hinted_sizes=hinted_sizes) + size_window = _autocorr_size_window( hinted_sizes, - spacing_x=spacing_x, - spacing_y=spacing_y, prior_reliability=prior_reliability, ) if size_window is None: - if not guided_sizes: - return dims - size_index = _major_axis_index(width, height) - narrowed = [ - dim - for dim in dims - if any(abs(dim[size_index] - size) <= radius for size, radius in guided_sizes) - ] - return narrowed or dims + return dims center, radius = size_window - size_index = _major_axis_index(width, height) + size_index = 0 if width >= height else 1 narrowed = [dim for dim in dims if abs(dim[size_index] - center) <= radius] return narrowed or dims @@ -165,224 +118,21 @@ def _estimate_cell_size(profile: np.ndarray) -> float: return float(best_lag) -def _empty_spacing_estimate() -> SpacingEstimate: - return SpacingEstimate( - spacing=None, - confidence=0.0, - best_cell=None, - best_score=0.0, - candidate_cells=(), - candidate_scores=(), - ) +def _edge_profiles(rgba: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + alpha = rgba[..., 3] + luminance = rgba[..., 0] * 0.2126 + rgba[..., 1] * 0.7152 + rgba[..., 2] * 0.0722 + dx = np.zeros_like(luminance) + dy = np.zeros_like(luminance) + dx[:, 1:] = np.abs(luminance[:, 1:] - luminance[:, :-1]) + np.abs(alpha[:, 1:] - alpha[:, :-1]) + dy[1:, :] = np.abs(luminance[1:, :] - luminance[:-1, :]) + np.abs(alpha[1:, :] - alpha[:-1, :]) + profile_x = (dx.mean(axis=0) + alpha.mean(axis=0) * 0.1).astype(np.float32) + profile_y = (dy.mean(axis=1) + alpha.mean(axis=1) * 0.1).astype(np.float32) + return profile_x, profile_y -def _estimate_spacing_cell_size_details(deltas: np.ndarray, axis_length: int) -> SpacingEstimate: - intervals, weights = _collect_change_intervals(deltas) - if intervals.size < 6: - return _empty_spacing_estimate() - max_candidate = min(64, max(4, axis_length // 2)) - candidates = np.arange(2, max_candidate + 1, dtype=np.float32) - scores = np.asarray([_spacing_score(intervals, weights, cell) for cell in candidates], dtype=np.float32) - best_index = int(np.argmax(scores)) - best_cell = float(candidates[best_index]) - best_score = float(scores[best_index]) - multiples = np.maximum(1.0, np.rint(intervals / best_cell)) - residual = np.abs(intervals / best_cell - multiples) - keep = residual < 0.2 - if np.any(keep): - refined_weights = weights[keep] / np.sqrt(multiples[keep]) - refined = float(np.average(intervals[keep] / multiples[keep], weights=refined_weights)) - else: - refined = best_cell - coverage = min(1.0, intervals.size / 24.0) - confidence = np.clip((best_score - 0.35) / 0.45, 0.0, 1.0) * coverage - return SpacingEstimate( - spacing=refined, - confidence=float(confidence), - best_cell=best_cell, - best_score=best_score, - candidate_cells=tuple(float(cell) for cell in candidates.tolist()), - candidate_scores=tuple(float(score) for score in scores.tolist()), - ) - - -def _estimate_spacing_cell_size(deltas: np.ndarray, axis_length: int) -> tuple[float | None, float]: - estimate = _estimate_spacing_cell_size_details(deltas, axis_length) - return estimate.spacing, estimate.confidence - - -def _collect_change_intervals(deltas: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - positive = deltas[deltas > 1e-4] - if positive.size < 8: - return np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32) - threshold = float(np.clip(np.quantile(positive, 0.72) * 0.55, 0.018, 0.16)) - intervals: list[np.ndarray] = [] - weights: list[np.ndarray] = [] - for line in deltas: - centers, strengths = _boundary_runs(line, threshold) - if centers.size < 2: - continue - line_intervals = np.diff(centers) - if line_intervals.size == 0: - continue - keep = line_intervals >= 2.0 - if not np.any(keep): - continue - line_weights = ((strengths[:-1] + strengths[1:]) * 0.5)[keep] - intervals.append(line_intervals[keep].astype(np.float32)) - weights.append(line_weights.astype(np.float32)) - if not intervals: - return np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32) - return np.concatenate(intervals), np.concatenate(weights) - - -def _boundary_runs(line: np.ndarray, threshold: float) -> tuple[np.ndarray, np.ndarray]: - indices = np.flatnonzero(line >= threshold) - if indices.size == 0: - return np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32) - splits = np.where(np.diff(indices) > 1)[0] - starts = np.concatenate([indices[:1], indices[splits + 1]]) - ends = np.concatenate([indices[splits], indices[-1:]]) - centers = (starts.astype(np.float32) + ends.astype(np.float32) + 1.0) * 0.5 - strengths = np.asarray([float(np.max(line[start : end + 1])) for start, end in zip(starts, ends)], dtype=np.float32) - return centers, strengths - - -def _spacing_score(intervals: np.ndarray, weights: np.ndarray, cell_size: float) -> float: - multiples = np.maximum(1.0, np.rint(intervals / cell_size)) - residual = np.abs(intervals / cell_size - multiples) - closeness = np.exp(-((residual / 0.18) ** 2)) - interval_weights = weights / np.sqrt(multiples) - return float(np.sum(interval_weights * closeness) / (np.sum(interval_weights) + 1e-6)) - - -def _estimate_lattice_spacing_details(rgba: np.ndarray) -> tuple[SpacingEstimate, SpacingEstimate]: - premult_rgba = premultiply(rgba) - delta_x = np.linalg.norm(premult_rgba[:, 1:] - premult_rgba[:, :-1], axis=-1) - delta_y = np.linalg.norm(premult_rgba[1:, :] - premult_rgba[:-1, :], axis=-1) - return ( - _estimate_spacing_cell_size_details(delta_x, premult_rgba.shape[1]), - _estimate_spacing_cell_size_details(delta_y, premult_rgba.shape[0]), - ) - - -def _estimate_lattice_spacing(rgba: np.ndarray) -> tuple[tuple[float | None, float], tuple[float | None, float]]: - spacing_x, spacing_y = _estimate_lattice_spacing_details(rgba) - return (spacing_x.spacing, spacing_x.confidence), (spacing_y.spacing, spacing_y.confidence) - - -def _extract_spacing_modes(estimate: SpacingEstimate) -> list[SpacingMode]: - if not estimate.candidate_cells or not estimate.candidate_scores: - return [] - cells = np.asarray(estimate.candidate_cells, dtype=np.float32) - scores = np.asarray(estimate.candidate_scores, dtype=np.float32) - if scores.size == 0: - return [] - best_score = float(np.max(scores)) - minimum_relative = 0.42 if estimate.confidence < 0.2 else 0.52 if estimate.confidence < 0.45 else 0.62 - peak_indices: list[int] = [] - for index, score in enumerate(scores): - left = float(scores[index - 1]) if index > 0 else -float("inf") - right = float(scores[index + 1]) if index + 1 < scores.size else -float("inf") - relative_score = float(score / max(1e-6, best_score)) - prominence = float(score - max(left, right, 0.0)) - if score + 1e-6 < left or score + 1e-6 < right: - continue - if relative_score < minimum_relative and prominence < 0.02: - continue - peak_indices.append(index) - if not peak_indices: - peak_indices = [int(np.argmax(scores))] - modes: list[SpacingMode] = [] - for index in peak_indices: - cell_size = float(cells[index]) - score = float(scores[index]) - relative_score = float(score / max(1e-6, best_score)) - family_score = score - for other_index in peak_indices: - if other_index == index: - continue - other_cell = float(cells[other_index]) - other_score = float(scores[other_index]) - ratio = max(cell_size, other_cell) / max(1e-6, min(cell_size, other_cell)) - harmonic = int(round(ratio)) - if harmonic < 2 or harmonic > 4 or abs(ratio - harmonic) > 0.12: - continue - harmonic_weight = 0.42 if other_cell > cell_size else 0.24 - family_score += other_score * harmonic_weight / float(np.sqrt(harmonic)) - modes.append( - SpacingMode( - cell_size=cell_size, - score=score, - relative_score=relative_score, - family_score=family_score, - ) - ) - modes.sort(key=lambda item: (item.family_score, item.score, item.cell_size), reverse=True) - selected: list[SpacingMode] = [] - for mode in modes: - if any(abs(existing.cell_size - mode.cell_size) <= 0.75 for existing in selected): - continue - selected.append(mode) - if len(selected) >= 6: - break - return selected - - -def _spacing_mode_radius(mode: SpacingMode, confidence: float) -> int: - if confidence >= 0.72 and mode.relative_score >= 0.94: - return 0 - if confidence >= 0.45 or mode.relative_score >= 0.8: - return 1 - return 2 - - -def _axis_target_sizes_from_spacing_spectrum(axis_length: int, estimate: SpacingEstimate) -> list[tuple[int, int, float]]: - suggestions: list[tuple[int, int, float]] = [] - modes = _extract_spacing_modes(estimate) - for mode in modes: - target_size = max(1, int(round(axis_length / max(1e-6, mode.cell_size)))) - suggestions.append((target_size, _spacing_mode_radius(mode, estimate.confidence), float(mode.family_score))) - if estimate.spacing is not None: - refined_size = max(1, int(round(axis_length / max(1e-6, estimate.spacing)))) - refined_weight = float(modes[0].family_score + 0.05) if modes else 1.0 - suggestions.append((refined_size, 0 if estimate.confidence >= 0.72 else 1, refined_weight)) - suggestions.sort(key=lambda item: item[2], reverse=True) - return suggestions - - -def _guided_target_sizes_from_spacing_spectra( - width: int, - height: int, - *, - spacing_x_estimate: SpacingEstimate | None, - spacing_y_estimate: SpacingEstimate | None, -) -> list[tuple[int, int]]: - guided: list[tuple[int, int, float]] = [] - if width >= height: - if spacing_x_estimate is not None: - guided.extend(_axis_target_sizes_from_spacing_spectrum(width, spacing_x_estimate)) - if spacing_y_estimate is not None: - guided.extend(_axis_target_sizes_from_spacing_spectrum(height, spacing_y_estimate)) - else: - if spacing_y_estimate is not None: - guided.extend(_axis_target_sizes_from_spacing_spectrum(height, spacing_y_estimate)) - if spacing_x_estimate is not None: - guided.extend(_axis_target_sizes_from_spacing_spectrum(width, spacing_x_estimate)) - if not guided: - return [] - guided.sort(key=lambda item: item[2], reverse=True) - merged: list[tuple[int, int, float]] = [] - for size, radius, weight in guided: - for index, (existing_size, existing_radius, existing_weight) in enumerate(merged): - if abs(existing_size - size) <= max(existing_radius, radius) + 1: - chosen_size = existing_size if existing_weight >= weight else size - merged[index] = (chosen_size, max(existing_radius, radius), max(existing_weight, weight)) - break - else: - merged.append((size, radius, weight)) - merged.sort(key=lambda item: item[2], reverse=True) - return [(size, radius) for size, radius, _weight in merged[:8]] +def _estimate_lattice_autocorr_details(rgba: np.ndarray) -> tuple[float, float]: + profile_x, profile_y = _edge_profiles(rgba) + return _estimate_cell_size(profile_x), _estimate_cell_size(profile_y) def _weighted_geometric_mean(values: list[float], weights: list[float]) -> float: @@ -395,21 +145,6 @@ def _weighted_geometric_mean(values: list[float], weights: list[float]) -> float return float(np.exp(np.dot(logs, np.asarray(safe_weights, dtype=np.float32)) / total_weight)) -def _axis_prior_from_estimates(spacing: float | None, spacing_confidence: float, autocorr: float) -> tuple[float, float]: - if spacing is None: - return float(autocorr), 0.12 - ratio = max(autocorr, spacing) / max(1e-6, min(autocorr, spacing)) - suspicious_multiple = ratio >= 1.9 and abs(ratio - round(ratio)) <= 0.2 - autocorr_weight = float(np.clip(1.0 - spacing_confidence, 0.05, 0.65)) - if suspicious_multiple and spacing_confidence >= 0.15: - autocorr_weight *= 0.35 - prior = _weighted_geometric_mean([spacing, autocorr], [1.0, autocorr_weight]) - reliability = float(np.clip(0.15 + 0.75 * spacing_confidence, 0.1, 0.9)) - if suspicious_multiple: - reliability *= 0.9 - return prior, float(reliability) - - def _combine_axis_priors(axis_priors: list[tuple[float, float]]) -> tuple[float, float]: priors = [float(prior) for prior, weight in axis_priors if weight > 1e-6] weights = [float(weight) for _, weight in axis_priors if weight > 1e-6] @@ -427,47 +162,37 @@ def _combine_axis_priors(axis_priors: list[tuple[float, float]]) -> tuple[float, def _estimate_lattice_prior_details( rgba: np.ndarray, - *, - spacing_x: tuple[float | None, float] | None = None, - spacing_y: tuple[float | None, float] | None = None, ) -> tuple[float, float, float]: - alpha = rgba[..., 3] - luminance = rgba[..., 0] * 0.2126 + rgba[..., 1] * 0.7152 + rgba[..., 2] * 0.0722 - dx = np.zeros_like(luminance) - dy = np.zeros_like(luminance) - dx[:, 1:] = np.abs(luminance[:, 1:] - luminance[:, :-1]) + np.abs(alpha[:, 1:] - alpha[:, :-1]) - dy[1:, :] = np.abs(luminance[1:, :] - luminance[:-1, :]) + np.abs(alpha[1:, :] - alpha[:-1, :]) - profile_x = (dx.mean(axis=0) + alpha.mean(axis=0) * 0.1).astype(np.float32) - profile_y = (dy.mean(axis=1) + alpha.mean(axis=1) * 0.1).astype(np.float32) - if spacing_x is None or spacing_y is None: - inferred_spacing_x, inferred_spacing_y = _estimate_lattice_spacing(rgba) - spacing_x = inferred_spacing_x if spacing_x is None else spacing_x - spacing_y = inferred_spacing_y if spacing_y is None else spacing_y - autocorr_x = _estimate_cell_size(profile_x) - autocorr_y = _estimate_cell_size(profile_y) - axis_x = _axis_prior_from_estimates(spacing_x[0], spacing_x[1], autocorr_x) - axis_y = _axis_prior_from_estimates(spacing_y[0], spacing_y[1], autocorr_y) + autocorr_x, autocorr_y = _estimate_lattice_autocorr_details(rgba) + axis_x = (autocorr_x, 1.0) + axis_y = (autocorr_y, 1.0) shared_prior, reliability = _combine_axis_priors([axis_x, axis_y]) return shared_prior, shared_prior, reliability -def _hint_target_sizes_from_spacing( +def _hint_target_sizes_from_autocorr( width: int, height: int, - spacing_x: tuple[float | None, float], - spacing_y: tuple[float | None, float], + *, + autocorr_x: float, + autocorr_y: float, + shared_prior: float, ) -> list[int]: hinted_sizes: set[int] = set() if width >= height: - if spacing_x[0] is not None and spacing_x[1] >= 0.15: - hinted_sizes.add(max(1, int(round(width / max(1e-6, spacing_x[0]))))) - if spacing_y[0] is not None and spacing_y[1] >= 0.15: - hinted_sizes.add(max(1, int(round(height / max(1e-6, spacing_y[0]))))) + major = width + minor = height + major_cell = autocorr_x + minor_cell = autocorr_y else: - if spacing_y[0] is not None and spacing_y[1] >= 0.15: - hinted_sizes.add(max(1, int(round(height / max(1e-6, spacing_y[0]))))) - if spacing_x[0] is not None and spacing_x[1] >= 0.15: - hinted_sizes.add(max(1, int(round(width / max(1e-6, spacing_x[0]))))) + 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))))) return sorted(hinted_sizes) @@ -543,12 +268,6 @@ def _rerank_size_candidates_with_source_evidence( reranked.sort(key=lambda item: item.score, reverse=True) return reranked - -def _estimate_lattice_prior(rgba: np.ndarray) -> tuple[float, float]: - prior_x, prior_y, _ = _estimate_lattice_prior_details(rgba) - return prior_x, prior_y - - def _build_phase_grid( torch, *, @@ -722,26 +441,24 @@ def infer_lattice( torch, _ = _require_torch() resolved_device = _resolve_device(torch, device) height, width = rgba.shape[:2] - spacing_x_estimate, spacing_y_estimate = _estimate_lattice_spacing_details(rgba) - spacing_x = (spacing_x_estimate.spacing, spacing_x_estimate.confidence) - spacing_y = (spacing_y_estimate.spacing, spacing_y_estimate.confidence) - hinted_sizes = _hint_target_sizes_from_spacing(width, height, spacing_x, spacing_y) + autocorr_x, autocorr_y = _estimate_lattice_autocorr_details(rgba) prior_cell_x, prior_cell_y, prior_reliability = _estimate_lattice_prior_details( rgba, - spacing_x=spacing_x, - spacing_y=spacing_y, + ) + hinted_sizes = _hint_target_sizes_from_autocorr( + width, + height, + autocorr_x=autocorr_x, + autocorr_y=autocorr_y, + shared_prior=prior_cell_x, ) phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32) - candidate_dims = _resolve_candidate_dims_from_spacing( + candidate_dims = _resolve_candidate_dims_from_autocorr( width, height, target_size, hinted_sizes=hinted_sizes, - spacing_x=spacing_x, - spacing_y=spacing_y, prior_reliability=prior_reliability, - spacing_x_estimate=spacing_x_estimate, - spacing_y_estimate=spacing_y_estimate, ) phase_sample_count = int(phase_values.size * phase_values.size) diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 71808e5..a9289c3 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -8,3 +8,4 @@ {"ts": "2026-04-26T13:14:17+00:00", "type": "research", "status": "ok", "note": "Dense landscape auto inference misses high because spacing stats split between false small-cell harmonics and mushier real-cell intervals. The spacing spectrum actually guides toward 415/418 and 251, but src/repixelizer/inference.py caps automatic major-axis candidates at 256, silently discarding the denser 415/418 branch; among the surviving sizes, reranking picks 251. Current phase/coherence scoring on the fixture is much stronger around 415-418 than at 251."} {"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."} diff --git a/state/map.yaml b/state/map.yaml index ef187c7..7d6d993 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 spectral lattice prior experiment was removed after it floor-hugged to 2px / 626-family aliases on the dense landscape fixture, so the shipped searched path is back to change-interval evidence plus autocorrelation. 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; 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. diff --git a/tests/test_gui.py b/tests/test_gui.py index b38a051..2d8f2fd 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -316,13 +316,13 @@ def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None: cuda = _FakeCuda() 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_spacing_details", - lambda rgba: (inference_module.SpacingEstimate(None, 0.0, None, 0.0, (), ()),) * 2, + "_hint_target_sizes_from_autocorr", + lambda width, height, *, autocorr_x, autocorr_y, shared_prior: [], ) - monkeypatch.setattr(inference_module, "_hint_target_sizes_from_spacing", lambda width, height, spacing_x, spacing_y: []) - monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba, **kwargs: (4.0, 4.0, 0.5)) + monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba: (4.0, 4.0, 0.5)) monkeypatch.setattr( inference_module, "_candidate_dims", diff --git a/tests/test_inference.py b/tests/test_inference.py index f335da1..5522344 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -6,14 +6,12 @@ import numpy as np import pytest from repixelizer.inference import ( - SpacingEstimate, - _estimate_lattice_spacing_details, + _estimate_lattice_autocorr_details, _estimate_lattice_prior_details, - _axis_prior_from_estimates, _candidate_dims, _combine_axis_priors, - _hint_target_sizes_from_spacing, - _resolve_candidate_dims_from_spacing, + _hint_target_sizes_from_autocorr, + _resolve_candidate_dims_from_autocorr, _top_candidates_by_size, infer_fixed_lattice, infer_lattice, @@ -24,60 +22,26 @@ from repixelizer.synthetic import fake_pixelize, make_emblem from repixelizer.types import InferenceCandidate -def _make_spacing_estimate( - *, - best_cell: int, - confidence: float, - peaks: dict[int, float], - start: int = 2, - stop: int = 24, -) -> SpacingEstimate: - candidate_cells = tuple(float(cell) for cell in range(start, stop + 1)) - candidate_scores: list[float] = [] - for cell in range(start, stop + 1): - score = 0.16 - for peak_cell, peak_score in peaks.items(): - distance = abs(cell - peak_cell) - if distance == 0: - score = max(score, peak_score) - elif distance == 1: - score = max(score, peak_score - 0.14) - candidate_scores.append(float(score)) - return SpacingEstimate( - spacing=float(best_cell), - confidence=confidence, - best_cell=float(best_cell), - best_score=float(peaks[best_cell]), - candidate_cells=candidate_cells, - candidate_scores=tuple(candidate_scores), - ) - - -def test_axis_prior_stays_close_to_spacing_when_autocorr_hits_large_multiple() -> None: - prior, reliability = _axis_prior_from_estimates(8.0, 0.75, 32.0) - assert prior < 10.0 - assert reliability > 0.6 - - 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_spacing_hints_can_add_dense_candidates_around_true_size() -> None: - hinted_sizes = _hint_target_sizes_from_spacing( +def test_autocorr_hints_can_add_dense_candidates_around_true_size() -> None: + hinted_sizes = _hint_target_sizes_from_autocorr( 1024, 1024, - (8.05, 0.74), - (4.0, 0.08), + autocorr_x=8.0, + autocorr_y=8.0, + shared_prior=8.0, ) dims = _candidate_dims(1024, 1024, None, hinted_sizes=hinted_sizes) sizes = {width for width, _ in dims} assert 128 in sizes -def test_spacing_hints_can_break_major_axis_cap_for_dense_candidates() -> None: +def test_autocorr_hints_can_break_major_axis_cap_for_dense_candidates() -> None: dims = _candidate_dims(1254, 1254, None, hinted_sizes=[418]) sizes = {width for width, _ in dims} assert 418 in sizes @@ -86,90 +50,53 @@ def test_spacing_hints_can_break_major_axis_cap_for_dense_candidates() -> None: assert 512 not in sizes -def test_strong_spacing_signal_can_collapse_candidate_search_to_single_size() -> None: - dims = _resolve_candidate_dims_from_spacing( +def test_consistent_autocorr_signal_can_collapse_candidate_search_to_single_size() -> None: + dims = _resolve_candidate_dims_from_autocorr( 1024, 1024, None, hinted_sizes=[128], - spacing_x=(8.0, 0.82), - spacing_y=(8.02, 0.78), - prior_reliability=0.74, + prior_reliability=0.85, ) sizes = {width for width, _ in dims} assert sizes == {128} -def test_weak_spacing_signal_without_spectrum_keeps_broad_candidate_search() -> None: - dims = _resolve_candidate_dims_from_spacing( +def test_divergent_autocorr_signal_keeps_candidate_search_wide_enough_for_badge() -> None: + dims = _resolve_candidate_dims_from_autocorr( 1024, 1024, None, - hinted_sizes=[128], - spacing_x=(8.0, 0.18), - spacing_y=(8.1, 0.12), - prior_reliability=0.24, + hinted_sizes=[114, 120, 125], + prior_reliability=0.9, ) sizes = {width for width, _ in dims} - assert 128 in sizes - assert len(sizes) > 10 + assert 126 in sizes + assert any(abs(size - 114) <= 1 for size in sizes) + assert any(abs(size - 125) <= 1 for size in sizes) -def test_weak_spacing_signal_uses_spectrum_modes_to_keep_candidate_search_compact() -> None: - spectrum = _make_spacing_estimate( - best_cell=8, - confidence=0.18, - peaks={4: 0.78, 8: 0.74, 16: 0.71}, - ) - dims = _resolve_candidate_dims_from_spacing( - 1024, - 1024, - None, - hinted_sizes=[128], - spacing_x=(8.0, 0.18), - spacing_y=(8.1, 0.12), - prior_reliability=0.24, - spacing_x_estimate=spectrum, - spacing_y_estimate=spectrum, - ) - sizes = {width for width, _ in dims} - assert sizes - assert len(sizes) <= 12 - assert any(abs(size - 128) <= 1 for size in sizes) - assert all(any(abs(size - target) <= 2 for target in (64, 128, 256)) for size in sizes) - - -def test_dense_landscape_candidate_search_keeps_high_guided_sizes() -> None: +def test_dense_landscape_candidate_search_keeps_autocorr_size() -> None: rgba = load_rgba(Path("tests/fixtures/real/dense-landscape.png")) - spacing_x_estimate, spacing_y_estimate = _estimate_lattice_spacing_details(rgba) - spacing_x = (spacing_x_estimate.spacing, spacing_x_estimate.confidence) - spacing_y = (spacing_y_estimate.spacing, spacing_y_estimate.confidence) - hinted_sizes = _hint_target_sizes_from_spacing( + autocorr_x, autocorr_y = _estimate_lattice_autocorr_details(rgba) + prior_x, _prior_y, prior_reliability = _estimate_lattice_prior_details(rgba) + hinted_sizes = _hint_target_sizes_from_autocorr( rgba.shape[1], rgba.shape[0], - spacing_x, - spacing_y, + autocorr_x=autocorr_x, + autocorr_y=autocorr_y, + shared_prior=prior_x, ) - _prior_x, _prior_y, prior_reliability = _estimate_lattice_prior_details( - rgba, - spacing_x=spacing_x, - spacing_y=spacing_y, - ) - dims = _resolve_candidate_dims_from_spacing( + dims = _resolve_candidate_dims_from_autocorr( rgba.shape[1], rgba.shape[0], None, hinted_sizes=hinted_sizes, - spacing_x=spacing_x, - spacing_y=spacing_y, prior_reliability=prior_reliability, - spacing_x_estimate=spacing_x_estimate, - spacing_y_estimate=spacing_y_estimate, ) sizes = {width for width, _ in dims} assert any(size > 256 for size in sizes) assert any(abs(size - 418) <= 1 for size in sizes) - assert any(abs(size - 251) <= 1 for size in sizes) def test_top_candidates_are_diversified_by_size() -> None: @@ -258,16 +185,16 @@ def test_infer_lattice_honors_cooperative_cancellation(monkeypatch) -> None: return True 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_spacing_details", - lambda rgba: (inference_module.SpacingEstimate(None, 0.0, None, 0.0, (), ()),) * 2, + "_hint_target_sizes_from_autocorr", + lambda width, height, *, autocorr_x, autocorr_y, shared_prior: [], ) - monkeypatch.setattr(inference_module, "_hint_target_sizes_from_spacing", lambda width, height, spacing_x, spacing_y: []) - monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba, **kwargs: (4.0, 4.0, 0.5)) + monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba: (4.0, 4.0, 0.5)) monkeypatch.setattr( inference_module, - "_resolve_candidate_dims_from_spacing", + "_resolve_candidate_dims_from_autocorr", lambda *args, **kwargs: [(10, 8), (12, 10)], ) monkeypatch.setattr( diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 609bb14..4959738 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -82,7 +82,7 @@ def test_pipeline_preserves_transparency_for_sprite(tmp_path: Path) -> None: from repixelizer.io import save_rgba save_rgba(input_path, fake) - result = run_pipeline(input_path, output_path, steps=20) + result = run_pipeline(input_path, output_path, target_size=24, steps=20) alpha = result.output_rgba[..., 3] assert alpha.min() < 0.05 assert alpha.max() > 0.9