Simplify lattice inference and hosted landing
|
|
@ -59,7 +59,7 @@ diagnostics`
|
|||
|
||||
In plainer language:
|
||||
|
||||
1. infer the target lattice size and phase
|
||||
1. infer the target lattice size
|
||||
2. build one edge scout map over the source
|
||||
3. nail down fixed lattice centers
|
||||
4. run the `phase-field` solve by optimizing one `(dx, dy)` shove vector per
|
||||
|
|
@ -110,7 +110,7 @@ Run the optimizer from the CLI:
|
|||
```powershell
|
||||
repixelize input.png --out output.png
|
||||
repixelize input.png --out output.png --diagnostics-dir diagnostics --device auto
|
||||
repixelize input.png --out output.png --target-width 126 --target-height 126 --phase-x 0.0 --phase-y -0.2 --device cuda
|
||||
repixelize input.png --out output.png --target-width 126 --target-height 126 --device cuda
|
||||
```
|
||||
|
||||
Run the optimizer plus baselines:
|
||||
|
|
@ -175,7 +175,7 @@ That keeps benchmark assets, attribution exports, and tuning runs from polluting
|
|||
|
||||
Core modules:
|
||||
|
||||
- `inference.py`: target size and phase inference
|
||||
- `inference.py`: target size inference
|
||||
- `phase_field.py`: default displacement-field optimizer
|
||||
- `metrics.py`: fidelity, adjacency, and motif metrics
|
||||
- `benchmark.py`: corpus benchmark runner
|
||||
|
|
|
|||
34
config/solver_params.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -23,9 +23,9 @@ The live pipeline is:
|
|||
|
||||
Inference now splits three ways:
|
||||
|
||||
- fixed lattice when the caller pins size and/or phase explicitly
|
||||
- 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, one cheap phase probe, no size search, no phase rerank
|
||||
- direct autocorr lattice for the hosted demo path: one consensus size, no size search, no candidate rerank
|
||||
|
||||
The hosted web shell now also has an auth seam:
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ The hosted web shell now also has an auth seam:
|
|||
|
||||
## What is working
|
||||
|
||||
- lattice size and phase inference are still shared and still CUDA-capable
|
||||
- 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:
|
||||
|
|
@ -53,7 +53,7 @@ Pinned badge case:
|
|||
|
||||
- fixture: `tests/fixtures/real/ai-badge-cleaned.png`
|
||||
- lattice: `126x126`
|
||||
- phase: `(0.0, -0.2)`
|
||||
- initial lattice centers: canonical
|
||||
- steps: `48`
|
||||
|
||||
Useful artifacts:
|
||||
|
|
@ -108,17 +108,17 @@ 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
|
||||
- the hosted demo path now skips multi-size search entirely and uses one autocorr-consensus size plus the cheap inferred phase, capped by the hosted output limit
|
||||
- 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
|
||||
- treat phase rerank as optional scaffolding, not sacred machinery
|
||||
- treat candidate rerank as optional scaffolding, not sacred machinery
|
||||
|
||||
Future options worth exploring, if autocorr stops carrying its weight:
|
||||
|
||||
- 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-and-phase prior trained on synthetic fake-pixel-art fixtures
|
||||
- tiny learned size prior trained on synthetic fake-pixel-art fixtures
|
||||
|
||||
### 3. Keep phase-field honest
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ The solver nails a regular grid over the source, gives every output cell a tiny
|
|||
|
||||
These are the pieces that actually matter:
|
||||
|
||||
- `inference`: chosen lattice width, height, phase, confidence, and optional top candidates
|
||||
- `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
|
||||
|
|
@ -65,22 +65,20 @@ 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`
|
||||
- `_select_phase_candidate_with_reconstruction(...)` in `src/repixelizer/pipeline.py`
|
||||
- `_select_candidate_with_reconstruction(...)` in `src/repixelizer/pipeline.py`
|
||||
|
||||
### Inputs
|
||||
|
||||
- source RGBA image
|
||||
- optional pinned target size / width / height / phase
|
||||
- optional `--skip-phase-rerank`
|
||||
- optional pinned target size / width / height
|
||||
- optional `--skip-candidate-rerank`
|
||||
|
||||
### Outputs
|
||||
|
||||
- `InferenceResult`
|
||||
- `target_width`
|
||||
- `target_height`
|
||||
- `phase_x`
|
||||
- `phase_y`
|
||||
- `confidence`
|
||||
- `confidence`
|
||||
- `top_candidates`
|
||||
|
||||
### What the source actually does
|
||||
|
|
@ -88,17 +86,17 @@ That means the solver is not maintaining alternate per-cell candidate sets or se
|
|||
The pipeline first decides whether the lattice is:
|
||||
|
||||
- fixed explicitly by the caller, using `infer_fixed_lattice(...)`, or
|
||||
- inferred directly from autocorr consensus with one cheap phase probe, using `infer_autocorr_lattice(...)`, or
|
||||
- inferred directly from autocorr consensus, using `infer_autocorr_lattice(...)`, or
|
||||
- searched automatically by `infer_lattice(...)`
|
||||
|
||||
The two automatic paths split like this:
|
||||
|
||||
- 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 one small phase grid, and stops there
|
||||
- `infer_lattice(...)` still builds a small candidate size family, scores a small phase grid for each size, and keeps the best candidate plus top alternates
|
||||
- `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, phase 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 `phase_rerank_preview_steps`.
|
||||
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:
|
||||
|
||||
|
|
@ -189,7 +187,7 @@ Prep does a few brutally practical things:
|
|||
2. Builds the fixed lattice centers with `_make_regular_uv_px(...)`.
|
||||
- `cell_x = source_width / target_width`
|
||||
- `cell_y = source_height / target_height`
|
||||
- `phase_x` and `phase_y` shift those centers before clipping to image bounds
|
||||
- centers are placed at the canonical middle of each inferred output cell before clipping to image bounds
|
||||
|
||||
3. Converts the source, edge map, and lattice centers to torch tensors.
|
||||
|
||||
|
|
@ -339,7 +337,7 @@ Code:
|
|||
|
||||
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 phase error instead of dying half a cell short of the right blob.
|
||||
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.
|
||||
|
||||
### Metaphor
|
||||
|
||||
|
|
@ -528,7 +526,7 @@ The solver also reports displacement statistics like:
|
|||
- local residual
|
||||
- dominant offset ratio
|
||||
|
||||
Those are the closest thing this machine has to an X-ray of its phase drift.
|
||||
Those are the closest thing this machine has to an X-ray of its field drift.
|
||||
|
||||
### Metaphor
|
||||
|
||||
|
|
@ -551,7 +549,7 @@ These pieces all belong to the same model of the machine.
|
|||
|
||||
## Current seams
|
||||
|
||||
- low-confidence phase rerank uses only a short preview solve, not the full optimized field
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ 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 and phase
|
||||
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
|
||||
|
|
@ -104,10 +104,10 @@ The tool must estimate the resolution of the fake lattice being mimicked.
|
|||
|
||||
Requirements:
|
||||
- search over candidate target sizes
|
||||
- search over subpixel phase offsets for each candidate
|
||||
- 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 but still estimate phase
|
||||
- if `--target-size` is provided, skip size search
|
||||
|
||||
Scoring goals:
|
||||
- low isolated-pixel rate
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ type ImageAsset = {
|
|||
type InferenceCandidate = {
|
||||
target_width: number;
|
||||
target_height: number;
|
||||
phase_x: number;
|
||||
phase_y: number;
|
||||
score: number;
|
||||
breakdown: Record<string, number | null | undefined>;
|
||||
};
|
||||
|
|
@ -18,8 +16,6 @@ type InferenceCandidate = {
|
|||
type InferencePayload = {
|
||||
target_width: number;
|
||||
target_height: number;
|
||||
phase_x: number;
|
||||
phase_y: number;
|
||||
confidence: number;
|
||||
top_candidates: InferenceCandidate[];
|
||||
};
|
||||
|
|
@ -29,7 +25,7 @@ type FramePayload = {
|
|||
totalSteps: number;
|
||||
loss: number | null;
|
||||
terms: Record<string, number>;
|
||||
phaseMetrics: Record<string, number>;
|
||||
solverMetrics: Record<string, number>;
|
||||
outputImage: ImageAsset;
|
||||
samplingOverlayImage: ImageAsset;
|
||||
displacementImage: ImageAsset;
|
||||
|
|
@ -84,14 +80,13 @@ type JobStateSnapshot = {
|
|||
type LatticeSearchInfo = {
|
||||
candidateCount: number;
|
||||
completedCandidates: number;
|
||||
phaseSampleCount: number;
|
||||
device: string | null;
|
||||
currentTargetWidth: number | null;
|
||||
currentTargetHeight: number | null;
|
||||
bestScore: number | null;
|
||||
};
|
||||
|
||||
type PhaseRerankInfo = {
|
||||
type candidateRerankInfo = {
|
||||
previewSteps: number;
|
||||
candidateCount: number;
|
||||
confidence: number;
|
||||
|
|
@ -99,8 +94,6 @@ type PhaseRerankInfo = {
|
|||
currentCandidateIndex: number;
|
||||
currentTargetWidth: number | null;
|
||||
currentTargetHeight: number | null;
|
||||
currentPhaseX: number | null;
|
||||
currentPhaseY: number | null;
|
||||
currentStep: number;
|
||||
currentTotalSteps: number;
|
||||
currentLoss: number | null;
|
||||
|
|
@ -134,7 +127,7 @@ type AppState = {
|
|||
inference: InferencePayload | null;
|
||||
inferenceMode: string | null;
|
||||
latticeSearch: LatticeSearchInfo | null;
|
||||
phaseRerank: PhaseRerankInfo | null;
|
||||
candidateRerank: candidateRerankInfo | null;
|
||||
phaseFieldPrep: PhaseFieldPrepInfo | null;
|
||||
frames: FramePayload[];
|
||||
cleanupImage: ImageAsset | null;
|
||||
|
|
@ -207,8 +200,6 @@ const downloadButton = byId<HTMLButtonElement>("downloadButton");
|
|||
const targetSizeInput = byId<HTMLInputElement>("targetSizeInput");
|
||||
const targetWidthInput = byId<HTMLInputElement>("targetWidthInput");
|
||||
const targetHeightInput = byId<HTMLInputElement>("targetHeightInput");
|
||||
const phaseXInput = byId<HTMLInputElement>("phaseXInput");
|
||||
const phaseYInput = byId<HTMLInputElement>("phaseYInput");
|
||||
const stepsInput = byId<HTMLInputElement>("stepsInput");
|
||||
const seedInput = byId<HTMLInputElement>("seedInput");
|
||||
const runControlsCopy = byId<HTMLParagraphElement>("runControlsCopy");
|
||||
|
|
@ -240,11 +231,11 @@ const eventTypes = [
|
|||
"lattice_search_started",
|
||||
"lattice_search_progress",
|
||||
"inference_candidates_ready",
|
||||
"phase_rerank_started",
|
||||
"phase_rerank_candidate_started",
|
||||
"phase_rerank_candidate_step",
|
||||
"phase_rerank_candidate_completed",
|
||||
"phase_selection_completed",
|
||||
"candidate_rerank_started",
|
||||
"candidate_rerank_candidate_started",
|
||||
"candidate_rerank_candidate_step",
|
||||
"candidate_rerank_candidate_completed",
|
||||
"candidate_selection_completed",
|
||||
"analysis_completed",
|
||||
"phase_field_prepared",
|
||||
"phase_field_initial",
|
||||
|
|
@ -269,7 +260,7 @@ const state: AppState = {
|
|||
inference: null,
|
||||
inferenceMode: null,
|
||||
latticeSearch: null,
|
||||
phaseRerank: null,
|
||||
candidateRerank: null,
|
||||
phaseFieldPrep: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
|
|
@ -515,7 +506,7 @@ function resetRunArtifacts(options: { preserveSourceImage?: boolean } = {}): voi
|
|||
state.inference = null;
|
||||
state.inferenceMode = null;
|
||||
state.latticeSearch = null;
|
||||
state.phaseRerank = null;
|
||||
state.candidateRerank = null;
|
||||
state.phaseFieldPrep = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
|
|
@ -687,13 +678,6 @@ function formatGridValue(width: number | null | undefined, height: number | null
|
|||
return `${width} x ${height}`;
|
||||
}
|
||||
|
||||
function formatPhaseValue(phaseX: number | null | undefined, phaseY: number | null | undefined, fallback = "pending"): string {
|
||||
if (phaseX === null || phaseX === undefined || phaseY === null || phaseY === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return `${formatNumber(phaseX, 2)}, ${formatNumber(phaseY, 2)}`;
|
||||
}
|
||||
|
||||
function renderStatusMetricItems(items: StatusMetricItem[]): void {
|
||||
statusMetrics.innerHTML = "";
|
||||
statusMetrics.style.setProperty("--status-metric-count", String(Math.max(items.length, 1)));
|
||||
|
|
@ -726,7 +710,6 @@ function renderInference(): void {
|
|||
</div>
|
||||
<div class="info-card summary-card">
|
||||
<strong>Phase</strong>
|
||||
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
|
||||
</div>
|
||||
<div class="info-card summary-card">
|
||||
<strong>Confidence</strong>
|
||||
|
|
@ -738,10 +721,9 @@ function renderInference(): void {
|
|||
for (const candidate of topCandidates) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "info-card candidate-card";
|
||||
const rerank = candidate.breakdown["phase_rerank_rank"];
|
||||
const rerank = candidate.breakdown["candidate_rerank_rank"];
|
||||
node.innerHTML = `
|
||||
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
|
||||
<span>phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}</span><br />
|
||||
<span>score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""}</span>
|
||||
`;
|
||||
candidateList.appendChild(node);
|
||||
|
|
@ -769,7 +751,6 @@ function renderStatusMetrics(): void {
|
|||
} else if (state.stageKey === "inference") {
|
||||
if (state.latticeSearch) {
|
||||
items.push({ label: "Sizes", value: `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}` });
|
||||
items.push({ label: "Phases", value: String(state.latticeSearch.phaseSampleCount) });
|
||||
items.push({
|
||||
label: "Grid",
|
||||
value: formatGridValue(state.latticeSearch.currentTargetWidth, state.latticeSearch.currentTargetHeight),
|
||||
|
|
@ -781,41 +762,36 @@ function renderStatusMetrics(): void {
|
|||
if (state.inference) {
|
||||
items.push({ label: "Mode", value: state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search" });
|
||||
items.push({ label: "Grid", value: `${state.inference.target_width} x ${state.inference.target_height}` });
|
||||
items.push({ label: "Phase", value: `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}` });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.inference.confidence, 3) });
|
||||
}
|
||||
} else if (state.stageKey === "rerank") {
|
||||
if (state.phaseRerank) {
|
||||
if (state.candidateRerank) {
|
||||
items.push({
|
||||
label: "Done",
|
||||
value: `${state.phaseRerank.completedCandidates} / ${state.phaseRerank.candidateCount}`,
|
||||
value: `${state.candidateRerank.completedCandidates} / ${state.candidateRerank.candidateCount}`,
|
||||
});
|
||||
items.push({
|
||||
label: "Candidate",
|
||||
value:
|
||||
state.phaseRerank.currentCandidateIndex > 0
|
||||
? `${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}`
|
||||
: `0 / ${state.phaseRerank.candidateCount}`,
|
||||
state.candidateRerank.currentCandidateIndex > 0
|
||||
? `${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}`
|
||||
: `0 / ${state.candidateRerank.candidateCount}`,
|
||||
});
|
||||
items.push({
|
||||
label: "Preview",
|
||||
value:
|
||||
state.phaseRerank.currentTotalSteps > 0
|
||||
? `${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps}`
|
||||
: state.phaseRerank.previewSteps > 0
|
||||
? `0 / ${state.phaseRerank.previewSteps}`
|
||||
state.candidateRerank.currentTotalSteps > 0
|
||||
? `${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps}`
|
||||
: state.candidateRerank.previewSteps > 0
|
||||
? `0 / ${state.candidateRerank.previewSteps}`
|
||||
: "skipped",
|
||||
});
|
||||
items.push({
|
||||
label: "Grid",
|
||||
value: formatGridValue(state.phaseRerank.currentTargetWidth, state.phaseRerank.currentTargetHeight),
|
||||
value: formatGridValue(state.candidateRerank.currentTargetWidth, state.candidateRerank.currentTargetHeight),
|
||||
});
|
||||
items.push({
|
||||
label: "Phase",
|
||||
value: formatPhaseValue(state.phaseRerank.currentPhaseX, state.phaseRerank.currentPhaseY),
|
||||
});
|
||||
items.push({ label: "Loss", value: formatNumber(state.phaseRerank.currentLoss, 4) });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.phaseRerank.confidence, 3) });
|
||||
items.push({ label: "Loss", value: formatNumber(state.candidateRerank.currentLoss, 4) });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.candidateRerank.confidence, 3) });
|
||||
}
|
||||
} else if (state.stageKey === "solver") {
|
||||
const solverTerms: StatusMetricItem[] = [
|
||||
|
|
@ -1513,13 +1489,11 @@ function buildFormData(): FormData {
|
|||
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
|
||||
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
|
||||
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
|
||||
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
|
||||
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
|
||||
["steps", String(normalizedSteps)],
|
||||
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
|
||||
["device", runtimeConfig?.hostedDemo ? "cpu" : deviceInput.value],
|
||||
["strip_background", runtimeConfig?.hostedDemo ? "false" : stripBackgroundInput.checked ? "true" : "false"],
|
||||
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
["skip_candidate_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
];
|
||||
for (const [key, value] of values) {
|
||||
if (value !== null) {
|
||||
|
|
@ -1591,7 +1565,6 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
state.latticeSearch = {
|
||||
candidateCount: Number(payload.candidateCount ?? 0),
|
||||
completedCandidates: 0,
|
||||
phaseSampleCount: Number(payload.phaseSampleCount ?? 0),
|
||||
device: typeof payload.device === "string" ? payload.device : null,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
|
|
@ -1600,7 +1573,7 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
setStage(
|
||||
"inference",
|
||||
"Lattice search",
|
||||
`Testing ${state.latticeSearch.candidateCount} size candidates with ${state.latticeSearch.phaseSampleCount} phase samples each.`,
|
||||
`Testing ${state.latticeSearch.candidateCount} candidate sizes from canonical cell centers.`,
|
||||
);
|
||||
addLog("Search", `Started lattice search across ${String(payload.candidateCount)} size candidates.`);
|
||||
break;
|
||||
|
|
@ -1609,7 +1582,6 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
state.latticeSearch = {
|
||||
candidateCount: Number(payload.totalCandidates ?? 0),
|
||||
completedCandidates: 0,
|
||||
phaseSampleCount: Number(payload.phaseSampleCount ?? 0),
|
||||
device: null,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
|
|
@ -1618,7 +1590,6 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
}
|
||||
state.latticeSearch.completedCandidates = Number(payload.completedCandidates ?? state.latticeSearch.completedCandidates);
|
||||
state.latticeSearch.candidateCount = Number(payload.totalCandidates ?? state.latticeSearch.candidateCount);
|
||||
state.latticeSearch.phaseSampleCount = Number(payload.phaseSampleCount ?? state.latticeSearch.phaseSampleCount);
|
||||
state.latticeSearch.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.latticeSearch.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.latticeSearch.bestScore =
|
||||
|
|
@ -1626,16 +1597,16 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
setStage(
|
||||
"inference",
|
||||
"Lattice search",
|
||||
`Size ${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}: scored ${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight} over ${state.latticeSearch.phaseSampleCount} phase samples.`,
|
||||
`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 as InferencePayload;
|
||||
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
||||
addLog("Inference", "Scored candidate grids and phase offsets.");
|
||||
addLog("Inference", "Scored candidate grid sizes.");
|
||||
break;
|
||||
case "phase_rerank_started":
|
||||
state.phaseRerank = {
|
||||
case "candidate_rerank_started":
|
||||
state.candidateRerank = {
|
||||
previewSteps: Number(payload.previewSteps ?? 0),
|
||||
candidateCount: Number(payload.candidateCount ?? 0),
|
||||
confidence: Number(payload.confidence ?? 0),
|
||||
|
|
@ -1643,82 +1614,74 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
currentCandidateIndex: 0,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
currentPhaseX: null,
|
||||
currentPhaseY: null,
|
||||
currentStep: 0,
|
||||
currentTotalSteps: 0,
|
||||
currentLoss: null,
|
||||
};
|
||||
setStage(
|
||||
"rerank",
|
||||
"Phase rerank",
|
||||
`Previewing ${state.phaseRerank.candidateCount} low-confidence candidates over ${state.phaseRerank.previewSteps} short solver steps.`,
|
||||
"Candidate rerank",
|
||||
`Previewing ${state.candidateRerank.candidateCount} low-confidence candidates over ${state.candidateRerank.previewSteps} short solver steps.`,
|
||||
);
|
||||
addLog(
|
||||
"Rerank",
|
||||
`Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`,
|
||||
);
|
||||
break;
|
||||
case "phase_rerank_candidate_started":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.currentStep = 0;
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.previewSteps ?? state.phaseRerank.previewSteps);
|
||||
state.phaseRerank.currentLoss = null;
|
||||
case "candidate_rerank_candidate_started":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.currentStep = 0;
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.previewSteps ?? state.candidateRerank.previewSteps);
|
||||
state.candidateRerank.currentLoss = null;
|
||||
setStage(
|
||||
"rerank",
|
||||
"Phase rerank",
|
||||
`Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: previewing ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight} at phase ${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}.`,
|
||||
"Candidate rerank",
|
||||
`Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: previewing ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "phase_rerank_candidate_step":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.currentStep = Number(payload.step ?? 0);
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
|
||||
state.phaseRerank.currentLoss =
|
||||
case "candidate_rerank_candidate_step":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.currentStep = Number(payload.step ?? 0);
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
|
||||
state.candidateRerank.currentLoss =
|
||||
payload.loss === null || payload.loss === undefined ? null : Number(payload.loss);
|
||||
setStage(
|
||||
"rerank",
|
||||
"Phase rerank",
|
||||
state.phaseRerank.currentTotalSteps <= 0
|
||||
? `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: preview solve skipped.`
|
||||
: `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}, preview step ${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps} on ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}.`,
|
||||
"Candidate rerank",
|
||||
state.candidateRerank.currentTotalSteps <= 0
|
||||
? `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: preview solve skipped.`
|
||||
: `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}, preview step ${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps} on ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "phase_rerank_candidate_completed":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.completedCandidates = Number(payload.completedCandidates ?? state.phaseRerank.completedCandidates);
|
||||
state.phaseRerank.currentStep = Number(payload.totalSteps ?? state.phaseRerank.currentStep);
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? state.phaseRerank.currentTotalSteps);
|
||||
state.phaseRerank.currentLoss =
|
||||
payload.finalLoss === null || payload.finalLoss === undefined ? state.phaseRerank.currentLoss : Number(payload.finalLoss);
|
||||
case "candidate_rerank_candidate_completed":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.completedCandidates = Number(payload.completedCandidates ?? state.candidateRerank.completedCandidates);
|
||||
state.candidateRerank.currentStep = Number(payload.totalSteps ?? state.candidateRerank.currentStep);
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? state.candidateRerank.currentTotalSteps);
|
||||
state.candidateRerank.currentLoss =
|
||||
payload.finalLoss === null || payload.finalLoss === undefined ? state.candidateRerank.currentLoss : Number(payload.finalLoss);
|
||||
setStage(
|
||||
"rerank",
|
||||
"Phase rerank",
|
||||
`Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount} scored. ${state.phaseRerank.completedCandidates} of ${state.phaseRerank.candidateCount} previews finished.`,
|
||||
"Candidate rerank",
|
||||
`Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount} scored. ${state.candidateRerank.completedCandidates} of ${state.candidateRerank.candidateCount} previews finished.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "phase_selection_completed":
|
||||
case "candidate_selection_completed":
|
||||
state.inference = payload.inference as InferencePayload;
|
||||
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
||||
addLog("Selection", "Committed to a ruler and phase.");
|
||||
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.");
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ 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; 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 plus the cheap inferred phase, capped by the hosted output limit. it no longer silently pins unspecified jobs to `max_output_dimension`
|
||||
- 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 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
|
||||
- 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.
|
||||
|
|
@ -57,10 +57,10 @@ 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 phase rerank off
|
||||
- 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 phase rerank through short preview reconstruction
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ 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 phase-field output used for the dense landscape row.",
|
||||
help="Tracked fixed-lattice output used for the dense landscape row.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--guard-cell-bbox",
|
||||
|
|
@ -93,20 +93,7 @@ def _parse_args() -> argparse.Namespace:
|
|||
type=int,
|
||||
default=126,
|
||||
help="Pinned target height for the canonical badge comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--phase-x",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="Pinned lattice phase X for the canonical badge comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--phase-y",
|
||||
type=float,
|
||||
default=-0.2,
|
||||
help="Pinned lattice phase Y for the canonical badge comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
) parser.add_argument(
|
||||
"--dense-target-width",
|
||||
type=int,
|
||||
default=512,
|
||||
|
|
@ -393,12 +380,10 @@ def main() -> None:
|
|||
scratch_dir / "badge-ai-phase-field-pinned.png",
|
||||
target_width=args.target_width,
|
||||
target_height=args.target_height,
|
||||
phase_x=args.phase_x,
|
||||
phase_y=args.phase_y,
|
||||
diagnostics_dir=scratch_dir / "phase-field-diag",
|
||||
steps=args.steps,
|
||||
device=args.device,
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
)
|
||||
|
||||
source_rgba = load_rgba(source_input)
|
||||
|
|
@ -429,7 +414,7 @@ def main() -> None:
|
|||
rows = [
|
||||
PreviewRow(
|
||||
row_id="badge",
|
||||
title=f"AI fake pixel art -> {args.target_width}x{args.target_height} pinned lattice @ ({args.phase_x:.1f}, {args.phase_y:.1f})",
|
||||
title=f"AI fake pixel art -> {args.target_width}x{args.target_height} pinned lattice",
|
||||
source_rgba=source_rgba,
|
||||
source_subtitle="Cleaned AI fake pixel art",
|
||||
lanczos_rgba=lanczos,
|
||||
|
|
|
|||
|
|
@ -98,16 +98,12 @@ def _build_focus_states(
|
|||
artifacts.initial_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
),
|
||||
"final": source_lattice_consistency_breakdown(
|
||||
source,
|
||||
artifacts.target_rgba,
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
),
|
||||
}
|
||||
return source, artifacts.initial_rgba, artifacts.target_rgba, (cell_x, cell_y), fidelity
|
||||
|
|
|
|||
61
scripts/run_hosted_gui.ps1
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
param(
|
||||
[string]$BindHost = "0.0.0.0",
|
||||
[int]$Port = 8000,
|
||||
[switch]$Reload,
|
||||
[ValidateSet("auto", "show", "hide")]
|
||||
[string]$QueueUI = "auto"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$python = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $python)) {
|
||||
$python = "python"
|
||||
}
|
||||
|
||||
$previousHostedDemo = $env:REPIXELIZER_HOSTED_DEMO
|
||||
$previousShowQueuePanel = $env:REPIXELIZER_SHOW_QUEUE_PANEL
|
||||
|
||||
try {
|
||||
$env:REPIXELIZER_HOSTED_DEMO = "1"
|
||||
|
||||
if ($QueueUI -eq "show") {
|
||||
$env:REPIXELIZER_SHOW_QUEUE_PANEL = "1"
|
||||
} elseif ($QueueUI -eq "hide") {
|
||||
$env:REPIXELIZER_SHOW_QUEUE_PANEL = "0"
|
||||
} else {
|
||||
Remove-Item Env:\REPIXELIZER_SHOW_QUEUE_PANEL -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$launcherArgs = @(
|
||||
(Join-Path $repoRoot "scripts\run_gui.py"),
|
||||
"--host", $BindHost,
|
||||
"--port", $Port,
|
||||
"--queue-ui", $QueueUI
|
||||
)
|
||||
|
||||
if ($Reload) {
|
||||
$launcherArgs += "--reload"
|
||||
}
|
||||
|
||||
Write-Host "Starting Repixelizer hosted GUI..."
|
||||
Write-Host " Hosted demo: $env:REPIXELIZER_HOSTED_DEMO"
|
||||
Write-Host " Bind: http://${BindHost}:${Port}"
|
||||
Write-Host " Python: $python"
|
||||
Write-Host ""
|
||||
|
||||
& $python @launcherArgs
|
||||
} finally {
|
||||
if ($null -eq $previousHostedDemo) {
|
||||
Remove-Item Env:\REPIXELIZER_HOSTED_DEMO -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:REPIXELIZER_HOSTED_DEMO = $previousHostedDemo
|
||||
}
|
||||
|
||||
if ($null -eq $previousShowQueuePanel) {
|
||||
Remove-Item Env:\REPIXELIZER_SHOW_QUEUE_PANEL -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:REPIXELIZER_SHOW_QUEUE_PANEL = $previousShowQueuePanel
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +126,8 @@ def run_roundtrip_benchmark(
|
|||
"input_width": int(fake.shape[1]),
|
||||
"input_height": int(fake.shape[0]),
|
||||
"upscale": settings["upscale"],
|
||||
"phase_x": settings["phase_x"],
|
||||
"phase_y": settings["phase_y"],
|
||||
"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"],
|
||||
|
|
@ -213,8 +213,8 @@ def _variant_settings(seed: int, profile: str) -> dict[str, Any]:
|
|||
if profile == "crisp":
|
||||
return {
|
||||
"upscale": int(rng.integers(8, 15)),
|
||||
"phase_x": float(rng.uniform(-0.45, 0.45)),
|
||||
"phase_y": float(rng.uniform(-0.45, 0.45)),
|
||||
"offset_x": float(rng.uniform(-0.45, 0.45)),
|
||||
"offset_y": float(rng.uniform(-0.45, 0.45)),
|
||||
"blur_radius": 0.0,
|
||||
"warp_strength": float(rng.uniform(0.14, 0.46)),
|
||||
"warp_detail": int(rng.integers(5, 9)),
|
||||
|
|
@ -225,8 +225,8 @@ def _variant_settings(seed: int, profile: str) -> dict[str, Any]:
|
|||
if profile == "soft":
|
||||
return {
|
||||
"upscale": int(rng.integers(8, 15)),
|
||||
"phase_x": float(rng.uniform(-0.45, 0.45)),
|
||||
"phase_y": float(rng.uniform(-0.45, 0.45)),
|
||||
"offset_x": float(rng.uniform(-0.45, 0.45)),
|
||||
"offset_y": float(rng.uniform(-0.45, 0.45)),
|
||||
"blur_radius": float(rng.uniform(0.45, 1.15)),
|
||||
"warp_strength": float(rng.uniform(0.18, 0.55)),
|
||||
"warp_detail": int(rng.integers(4, 8)),
|
||||
|
|
@ -237,8 +237,8 @@ def _variant_settings(seed: int, profile: str) -> dict[str, Any]:
|
|||
if profile == "ai":
|
||||
return {
|
||||
"upscale": int(rng.integers(8, 15)),
|
||||
"phase_x": float(rng.uniform(-0.48, 0.48)),
|
||||
"phase_y": float(rng.uniform(-0.48, 0.48)),
|
||||
"offset_x": float(rng.uniform(-0.48, 0.48)),
|
||||
"offset_y": float(rng.uniform(-0.48, 0.48)),
|
||||
"blur_radius": float(rng.uniform(0.18, 0.95)),
|
||||
"warp_strength": float(rng.uniform(0.24, 0.68)),
|
||||
"warp_detail": int(rng.integers(5, 10)),
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
target.add_argument("--target-size", type=int, default=None, help="Override the inferred target max dimension")
|
||||
target.add_argument("--target-width", type=int, default=None, help="Pin the output lattice width directly")
|
||||
target.add_argument("--target-height", type=int, default=None, help="Pin the output lattice height directly")
|
||||
target.add_argument("--phase-x", type=float, default=None, help="Pin the lattice phase offset on the x axis")
|
||||
target.add_argument("--phase-y", type=float, default=None, help="Pin the lattice phase offset on the y axis")
|
||||
target.add_argument("--palette", default=None, help="Optional palette file (.gpl, .txt, .json)")
|
||||
target.add_argument(
|
||||
"--palette-mode",
|
||||
|
|
@ -40,9 +38,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
help="Remove light neutral edge-connected backgrounds such as fake transparency checkerboards",
|
||||
)
|
||||
target.add_argument(
|
||||
"--skip-phase-rerank",
|
||||
"--skip-candidate-rerank",
|
||||
dest="skip_candidate_rerank",
|
||||
action="store_true",
|
||||
help="Skip low-confidence phase reranking and run the selected or pinned lattice directly",
|
||||
help="Skip low-confidence candidate reranking and run the selected or pinned lattice directly",
|
||||
)
|
||||
|
||||
run_parser = subparsers.add_parser("run", help="Run the optimizer.")
|
||||
|
|
@ -105,7 +104,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Only tune on the first N matching cases. Defaults to 8 when no cases are specified.",
|
||||
help="Only tune on the first N matching cases. Defaults to the full matching corpus.",
|
||||
)
|
||||
tune_parser.add_argument(
|
||||
"--infer-size",
|
||||
|
|
@ -135,8 +134,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
target_size=args.target_size,
|
||||
target_width=args.target_width,
|
||||
target_height=args.target_height,
|
||||
phase_x=args.phase_x,
|
||||
phase_y=args.phase_y,
|
||||
palette_path=args.palette,
|
||||
palette_mode=args.palette_mode,
|
||||
diagnostics_dir=args.diagnostics_dir,
|
||||
|
|
@ -144,7 +141,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
steps=args.steps,
|
||||
device=args.device,
|
||||
strip_background=args.strip_background,
|
||||
enable_phase_rerank=not args.skip_phase_rerank,
|
||||
enable_candidate_rerank=not args.skip_candidate_rerank,
|
||||
)
|
||||
return 0
|
||||
if command == "benchmark":
|
||||
|
|
@ -189,8 +186,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
target_size=args.target_size,
|
||||
target_width=args.target_width,
|
||||
target_height=args.target_height,
|
||||
phase_x=args.phase_x,
|
||||
phase_y=args.phase_y,
|
||||
palette_path=args.palette,
|
||||
palette_mode=args.palette_mode,
|
||||
diagnostics_dir=args.diagnostics_dir,
|
||||
|
|
@ -198,6 +193,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||
steps=args.steps,
|
||||
device=args.device,
|
||||
strip_background=args.strip_background,
|
||||
enable_phase_rerank=not args.skip_phase_rerank,
|
||||
enable_candidate_rerank=not args.skip_candidate_rerank,
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -61,8 +61,6 @@ def run_compare(
|
|||
target_size: int | None = None,
|
||||
target_width: int | None = None,
|
||||
target_height: int | None = None,
|
||||
phase_x: float | None = None,
|
||||
phase_y: float | None = None,
|
||||
palette_path: str | Path | None = None,
|
||||
palette_mode: str = "off",
|
||||
diagnostics_dir: str | Path | None = None,
|
||||
|
|
@ -70,7 +68,7 @@ def run_compare(
|
|||
steps: int = 200,
|
||||
device: str = "auto",
|
||||
strip_background: bool = False,
|
||||
enable_phase_rerank: bool = True,
|
||||
enable_candidate_rerank: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
diagnostics_path = Path(diagnostics_dir) if diagnostics_dir else Path(output_path).with_suffix("")
|
||||
diagnostics_path.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -80,8 +78,6 @@ def run_compare(
|
|||
target_size=target_size,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
palette_path=palette_path,
|
||||
palette_mode=palette_mode,
|
||||
diagnostics_dir=diagnostics_path,
|
||||
|
|
@ -89,7 +85,7 @@ def run_compare(
|
|||
steps=steps,
|
||||
device=device,
|
||||
strip_background=strip_background,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
enable_candidate_rerank=enable_candidate_rerank,
|
||||
)
|
||||
source = result.source_rgba
|
||||
palette = load_palette(palette_path) if palette_path else None
|
||||
|
|
|
|||
|
|
@ -29,13 +29,11 @@ def write_lattice_overlay(path: str | Path, source_rgba: np.ndarray, inference:
|
|||
height, width = source_rgba.shape[:2]
|
||||
cell_x = width / inference.target_width
|
||||
cell_y = height / inference.target_height
|
||||
phase_x = inference.phase_x * cell_x
|
||||
phase_y = inference.phase_y * cell_y
|
||||
for ix in range(inference.target_width + 1):
|
||||
x = ix * cell_x + phase_x
|
||||
x = ix * cell_x
|
||||
draw.line((x, 0, x, height), fill=(255, 255, 255, 96), width=1)
|
||||
for iy in range(inference.target_height + 1):
|
||||
y = iy * cell_y + phase_y
|
||||
y = iy * cell_y
|
||||
draw.line((0, y, width, y), fill=(255, 255, 255, 96), width=1)
|
||||
image.save(path)
|
||||
|
||||
|
|
@ -156,41 +154,33 @@ def summarize_run(result: RunResult) -> dict[str, Any]:
|
|||
result.solver.initial_rgba,
|
||||
target_width=result.inference.target_width,
|
||||
target_height=result.inference.target_height,
|
||||
phase_x=result.inference.phase_x,
|
||||
phase_y=result.inference.phase_y,
|
||||
),
|
||||
"solver_target": source_lattice_consistency_breakdown(
|
||||
result.source_rgba,
|
||||
result.solver.target_rgba,
|
||||
target_width=result.inference.target_width,
|
||||
target_height=result.inference.target_height,
|
||||
phase_x=result.inference.phase_x,
|
||||
phase_y=result.inference.phase_y,
|
||||
),
|
||||
"final_output": source_lattice_consistency_breakdown(
|
||||
result.source_rgba,
|
||||
result.output_rgba,
|
||||
target_width=result.inference.target_width,
|
||||
target_height=result.inference.target_height,
|
||||
phase_x=result.inference.phase_x,
|
||||
phase_y=result.inference.phase_y,
|
||||
),
|
||||
}
|
||||
rerank_candidates = [
|
||||
{
|
||||
"target_width": candidate.target_width,
|
||||
"target_height": candidate.target_height,
|
||||
"phase_x": candidate.phase_x,
|
||||
"phase_y": candidate.phase_y,
|
||||
"score": candidate.score,
|
||||
"phase_rerank_score": candidate.breakdown.get("phase_rerank_score"),
|
||||
"phase_rerank_rank": candidate.breakdown.get("phase_rerank_rank"),
|
||||
"phase_rerank_support_score": candidate.breakdown.get("phase_rerank_support_score"),
|
||||
"phase_rerank_size_delta_ratio": candidate.breakdown.get("phase_rerank_size_delta_ratio"),
|
||||
"phase_rerank_size_penalty": candidate.breakdown.get("phase_rerank_size_penalty"),
|
||||
"candidate_rerank_score": candidate.breakdown.get("candidate_rerank_score"),
|
||||
"candidate_rerank_rank": candidate.breakdown.get("candidate_rerank_rank"),
|
||||
"candidate_rerank_support_score": candidate.breakdown.get("candidate_rerank_support_score"),
|
||||
"candidate_rerank_size_delta_ratio": candidate.breakdown.get("candidate_rerank_size_delta_ratio"),
|
||||
"candidate_rerank_size_penalty": candidate.breakdown.get("candidate_rerank_size_penalty"),
|
||||
}
|
||||
for candidate in result.inference.top_candidates
|
||||
if "phase_rerank_score" in candidate.breakdown
|
||||
if "candidate_rerank_score" in candidate.breakdown
|
||||
]
|
||||
displacement_metrics = {}
|
||||
stage_diagnostics = result.solver.stage_diagnostics.get("displacements", {})
|
||||
|
|
@ -203,8 +193,6 @@ def summarize_run(result: RunResult) -> dict[str, Any]:
|
|||
return {
|
||||
"target_width": result.inference.target_width,
|
||||
"target_height": result.inference.target_height,
|
||||
"phase_x": result.inference.phase_x,
|
||||
"phase_y": result.inference.phase_y,
|
||||
"confidence": result.inference.confidence,
|
||||
"coherence": coherence_breakdown(result.output_rgba),
|
||||
"source_preview_reconstruction_error": reconstruction_error(source_preview, result.source_rgba),
|
||||
|
|
@ -212,7 +200,7 @@ def summarize_run(result: RunResult) -> dict[str, Any]:
|
|||
"output_colors_from_source_ratio": _source_color_ratio(result.source_rgba, result.output_rgba),
|
||||
"source_fidelity": source_fidelity,
|
||||
"source_structure": source_structure_breakdown(result.source_rgba, result.output_rgba),
|
||||
"phase_rerank_candidates": rerank_candidates,
|
||||
"candidate_rerank_candidates": rerank_candidates,
|
||||
"loss_history": result.solver.loss_history,
|
||||
"optimizer_displacement": displacement_metrics,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ def cleanup_pixels(rgba: np.ndarray, source_guidance: np.ndarray, iterations: in
|
|||
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()
|
||||
|
|
@ -57,4 +58,13 @@ def cleanup_pixels(rgba: np.ndarray, source_guidance: np.ndarray, iterations: in
|
|||
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)
|
||||
return CleanupArtifacts(cleaned_rgba=result, isolated_heatmap=heatmap)
|
||||
|
||||
|
||||
def _snap_alpha(rgba: np.ndarray, threshold: float = 0.5) -> np.ndarray:
|
||||
result = rgba.copy()
|
||||
alpha = (result[..., 3] >= threshold).astype(np.float32)
|
||||
result[..., 3] = alpha
|
||||
result[..., :3] *= alpha[..., None]
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import base64
|
||||
import html
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
|
|
@ -167,7 +168,7 @@ class HostedDemoConfig:
|
|||
def from_env(cls) -> "HostedDemoConfig":
|
||||
hosted_demo = _env_flag("REPIXELIZER_HOSTED_DEMO", False)
|
||||
defaults = {
|
||||
"max_upload_bytes": 1_048_576 if hosted_demo else 16 * 1_048_576,
|
||||
"max_upload_bytes": 2 * 1_048_576 if hosted_demo else 16 * 1_048_576,
|
||||
"max_input_dimension": 2048 if hosted_demo else 4096,
|
||||
"max_output_dimension": 256 if hosted_demo else 1024,
|
||||
"default_steps": 32 if hosted_demo else 48,
|
||||
|
|
@ -335,14 +336,14 @@ class GuiJob:
|
|||
"operation": payload.get("operation"),
|
||||
"sourceImage": _image_asset(source),
|
||||
}
|
||||
if event in {"inference_candidates_ready", "phase_selection_completed"}:
|
||||
if event in {"inference_candidates_ready", "candidate_selection_completed"}:
|
||||
inference = payload["inference"]
|
||||
self.current_inference = inference
|
||||
return {
|
||||
"inference": inference_to_json(inference),
|
||||
"inferenceMode": payload.get("inference_mode"),
|
||||
}
|
||||
if event == "phase_rerank_started":
|
||||
if event == "candidate_rerank_started":
|
||||
return {
|
||||
"previewSteps": int(payload["preview_steps"]),
|
||||
"candidateCount": int(payload["candidate_count"]),
|
||||
|
|
@ -351,7 +352,6 @@ class GuiJob:
|
|||
if event == "lattice_search_started":
|
||||
return {
|
||||
"candidateCount": int(payload["candidate_count"]),
|
||||
"phaseSampleCount": int(payload["phase_sample_count"]),
|
||||
"device": str(payload["device"]),
|
||||
}
|
||||
if event == "lattice_search_progress":
|
||||
|
|
@ -360,40 +360,33 @@ class GuiJob:
|
|||
"totalCandidates": int(payload["total_candidates"]),
|
||||
"targetWidth": int(payload["target_width"]),
|
||||
"targetHeight": int(payload["target_height"]),
|
||||
"phaseSampleCount": int(payload["phase_sample_count"]),
|
||||
"bestScore": None if payload.get("best_score") is None else float(payload["best_score"]),
|
||||
}
|
||||
if event == "phase_rerank_candidate_started":
|
||||
if event == "candidate_rerank_candidate_started":
|
||||
return {
|
||||
"candidateIndex": int(payload["candidate_index"]),
|
||||
"totalCandidates": int(payload["total_candidates"]),
|
||||
"targetWidth": int(payload["target_width"]),
|
||||
"targetHeight": int(payload["target_height"]),
|
||||
"phaseX": float(payload["phase_x"]),
|
||||
"phaseY": float(payload["phase_y"]),
|
||||
"previewSteps": int(payload["preview_steps"]),
|
||||
}
|
||||
if event == "phase_rerank_candidate_step":
|
||||
if event == "candidate_rerank_candidate_step":
|
||||
return {
|
||||
"candidateIndex": int(payload["candidate_index"]),
|
||||
"totalCandidates": int(payload["total_candidates"]),
|
||||
"targetWidth": int(payload["target_width"]),
|
||||
"targetHeight": int(payload["target_height"]),
|
||||
"phaseX": float(payload["phase_x"]),
|
||||
"phaseY": float(payload["phase_y"]),
|
||||
"step": int(payload["step"]),
|
||||
"totalSteps": int(payload["total_steps"]),
|
||||
"loss": None if payload.get("loss") is None else float(payload["loss"]),
|
||||
}
|
||||
if event == "phase_rerank_candidate_completed":
|
||||
if event == "candidate_rerank_candidate_completed":
|
||||
return {
|
||||
"candidateIndex": int(payload["candidate_index"]),
|
||||
"totalCandidates": int(payload["total_candidates"]),
|
||||
"completedCandidates": int(payload["completed_candidates"]),
|
||||
"targetWidth": int(payload["target_width"]),
|
||||
"targetHeight": int(payload["target_height"]),
|
||||
"phaseX": float(payload["phase_x"]),
|
||||
"phaseY": float(payload["phase_y"]),
|
||||
"totalSteps": int(payload["total_steps"]),
|
||||
"finalLoss": None if payload.get("final_loss") is None else float(payload["final_loss"]),
|
||||
}
|
||||
|
|
@ -427,7 +420,7 @@ class GuiJob:
|
|||
"totalSteps": int(payload["total_steps"]),
|
||||
"loss": None if payload.get("loss") is None else float(payload["loss"]),
|
||||
"terms": {key: float(value) for key, value in payload.get("terms", {}).items()},
|
||||
"phaseMetrics": {key: float(value) for key, value in payload.get("phase_metrics", {}).items()},
|
||||
"solverMetrics": {key: float(value) for key, value in payload.get("phase_metrics", {}).items()},
|
||||
"outputImage": _image_asset(payload["target_rgba"]),
|
||||
"samplingOverlayImage": _image_asset(
|
||||
_render_sample_overlay(self.current_source_rgba, payload["sample_x"], payload["sample_y"])
|
||||
|
|
@ -493,13 +486,11 @@ def _normalize_job_options(
|
|||
target_size: int | None,
|
||||
target_width: int | None,
|
||||
target_height: int | None,
|
||||
phase_x: float | None,
|
||||
phase_y: float | None,
|
||||
steps: int | None,
|
||||
seed: int,
|
||||
device: str,
|
||||
strip_background: bool,
|
||||
skip_phase_rerank: bool,
|
||||
skip_candidate_rerank: bool,
|
||||
) -> dict[str, Any]:
|
||||
normalized_target_size = _normalize_optional_positive_int("target_size", target_size)
|
||||
normalized_target_width = _normalize_optional_positive_int("target_width", target_width)
|
||||
|
|
@ -511,8 +502,6 @@ def _normalize_job_options(
|
|||
target_size=normalized_target_size,
|
||||
target_width=normalized_target_width,
|
||||
target_height=normalized_target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
|
@ -526,18 +515,16 @@ def _normalize_job_options(
|
|||
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_phase_rerank = True if config.hosted_demo else bool(skip_phase_rerank)
|
||||
normalized_skip_candidate_rerank = True if config.hosted_demo else bool(skip_candidate_rerank)
|
||||
return {
|
||||
"target_size": normalized_target_size,
|
||||
"target_width": normalized_target_width,
|
||||
"target_height": normalized_target_height,
|
||||
"phase_x": phase_x,
|
||||
"phase_y": phase_y,
|
||||
"steps": normalized_steps,
|
||||
"seed": int(seed),
|
||||
"device": normalized_device,
|
||||
"strip_background": normalized_strip_background,
|
||||
"skip_phase_rerank": normalized_skip_phase_rerank,
|
||||
"skip_candidate_rerank": normalized_skip_candidate_rerank,
|
||||
"lattice_inference_mode": inference_mode,
|
||||
"max_inferred_target_size": max_inferred_target_size,
|
||||
}
|
||||
|
|
@ -551,13 +538,11 @@ def _validate_upload_request(
|
|||
target_size: int | None,
|
||||
target_width: int | None,
|
||||
target_height: int | None,
|
||||
phase_x: float | None,
|
||||
phase_y: float | None,
|
||||
steps: int | None,
|
||||
seed: int,
|
||||
device: str,
|
||||
strip_background: bool,
|
||||
skip_phase_rerank: bool,
|
||||
skip_candidate_rerank: bool,
|
||||
) -> tuple[dict[str, Any], int, int]:
|
||||
if len(raw) > config.max_upload_bytes:
|
||||
raise ValueError(f"Upload is too large. Limit is {config.max_upload_bytes // 1024} KiB for the hosted demo.")
|
||||
|
|
@ -576,13 +561,11 @@ def _validate_upload_request(
|
|||
target_size=target_size,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
strip_background=strip_background,
|
||||
skip_phase_rerank=skip_phase_rerank,
|
||||
skip_candidate_rerank=skip_candidate_rerank,
|
||||
)
|
||||
return options, source_width, source_height
|
||||
|
||||
|
|
@ -597,14 +580,12 @@ def _execute_job(job: GuiJob) -> None:
|
|||
target_size=job.options["target_size"],
|
||||
target_width=job.options["target_width"],
|
||||
target_height=job.options["target_height"],
|
||||
phase_x=job.options["phase_x"],
|
||||
phase_y=job.options["phase_y"],
|
||||
palette_mode="off",
|
||||
seed=job.options["seed"],
|
||||
steps=job.options["steps"],
|
||||
device=job.options["device"],
|
||||
strip_background=job.options["strip_background"],
|
||||
enable_phase_rerank=not job.options["skip_phase_rerank"],
|
||||
enable_candidate_rerank=not job.options["skip_candidate_rerank"],
|
||||
lattice_inference_mode=job.options["lattice_inference_mode"],
|
||||
max_inferred_target_size=job.options["max_inferred_target_size"],
|
||||
observer=job.observe,
|
||||
|
|
@ -918,8 +899,10 @@ def _landing_page_html(
|
|||
label = provider.get("label")
|
||||
if not isinstance(slug, str) or not isinstance(label, str):
|
||||
continue
|
||||
escaped_slug = html.escape(slug, quote=True)
|
||||
escaped_label = html.escape(label)
|
||||
fragments.append(
|
||||
f'<button class="button secondary" type="button" data-provider="{slug}">Login with {label}</button>'
|
||||
f'<button class="button button-secondary" type="button" data-provider="{escaped_slug}">Login with {escaped_label}</button>'
|
||||
)
|
||||
provider_button_html = "".join(fragments)
|
||||
hero_note = (
|
||||
|
|
@ -930,445 +913,34 @@ def _landing_page_html(
|
|||
auth_notice = ""
|
||||
if auth_enabled and not auth_payload.get("providers"):
|
||||
auth_notice = (
|
||||
'<p class="footer" id="authNotice">Auth is enabled here, but no providers are configured yet. '
|
||||
'<p class="copy-tag status-text" id="authNotice">Auth is enabled here, but no providers are configured yet. '
|
||||
"The front door exists, but somebody still owes it an actual key.</p>"
|
||||
)
|
||||
elif auth_enabled:
|
||||
auth_notice = '<p class="footer" id="authNotice">Choose a provider. The main page stays put while Heimdall and the backend sort out the paperwork in another tab.</p>'
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Repixelizer</title>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap");
|
||||
:root {{
|
||||
color-scheme: dark;
|
||||
--bg-top: #01040b;
|
||||
--bg-mid: #020813;
|
||||
--bg-bottom: #01050d;
|
||||
--panel-top: #0a2239;
|
||||
--panel-bottom: #061627;
|
||||
--surface-top: #061424;
|
||||
--surface-bottom: #030b16;
|
||||
--text-bright: #f7efd8;
|
||||
--text: #efe5c9;
|
||||
--text-muted: #d3bb7f;
|
||||
--accent: #ffd84a;
|
||||
--accent-strong: #ffae1a;
|
||||
--border: #0a2740;
|
||||
--shadow: rgba(0, 0, 0, 0.55);
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{ min-height: 100%; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 3px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.035) 0 1px, transparent 1px 4px),
|
||||
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-mid) 46%, var(--bg-bottom) 100%);
|
||||
color: var(--text);
|
||||
font-family: "VT323", "Courier New", monospace;
|
||||
font-size: 26px;
|
||||
line-height: 1.22;
|
||||
letter-spacing: 0.01em;
|
||||
}}
|
||||
body::before {{
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 14% 18%, rgba(255, 216, 74, 0.08), transparent 26%),
|
||||
radial-gradient(circle at 84% 20%, rgba(255, 174, 26, 0.11), transparent 24%),
|
||||
radial-gradient(circle at 24% 76%, rgba(255, 216, 74, 0.04), transparent 22%),
|
||||
radial-gradient(circle at 78% 82%, rgba(255, 174, 26, 0.04), transparent 24%);
|
||||
opacity: 0.35;
|
||||
}}
|
||||
a {{ color: inherit; }}
|
||||
.shell {{
|
||||
width: min(1160px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 48px;
|
||||
}}
|
||||
.hero {{
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 3px solid rgba(158, 112, 22, 0.94);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 28, 46, 0.96), rgba(4, 13, 24, 0.98)),
|
||||
linear-gradient(135deg, rgba(255, 216, 74, 0.06), transparent 34%);
|
||||
box-shadow: inset 0 0 0 2px rgba(7, 24, 44, 0.9), 0 18px 52px var(--shadow);
|
||||
padding: 24px;
|
||||
}}
|
||||
.hero::after {{
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(255, 216, 74, 0.1);
|
||||
pointer-events: none;
|
||||
}}
|
||||
.eyebrow, h1, h2, .button {{
|
||||
font-family: "Press Start 2P", "VT323", monospace;
|
||||
}}
|
||||
.eyebrow {{
|
||||
margin: 0 0 18px;
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.6;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0;
|
||||
color: var(--text-bright);
|
||||
font-size: clamp(1.7rem, 4vw, 3.15rem);
|
||||
line-height: 1.15;
|
||||
max-width: 13ch;
|
||||
}}
|
||||
.lede {{
|
||||
max-width: 26ch;
|
||||
margin: 20px 0 0;
|
||||
color: var(--text);
|
||||
font-size: clamp(1.1rem, 2vw, 1.45rem);
|
||||
}}
|
||||
.hero-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}}
|
||||
.cta-row {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 54px;
|
||||
padding: 0 18px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
border: 2px solid var(--accent-strong);
|
||||
background: linear-gradient(180deg, #ffd84a, #ffb01f);
|
||||
color: #1b1202;
|
||||
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.24);
|
||||
}}
|
||||
button.button {{
|
||||
cursor: pointer;
|
||||
}}
|
||||
.button[disabled] {{
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}}
|
||||
.button.secondary {{
|
||||
background: linear-gradient(180deg, rgba(15, 40, 64, 0.96), rgba(7, 19, 32, 0.98));
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border);
|
||||
}}
|
||||
.button.ghost {{
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-color: rgba(255, 216, 74, 0.2);
|
||||
}}
|
||||
.stats, .grid {{
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}}
|
||||
.stats {{
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 22px;
|
||||
}}
|
||||
.stat, .panel {{
|
||||
border: 2px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--panel-top), var(--panel-bottom));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 216, 74, 0.05);
|
||||
}}
|
||||
.stat {{
|
||||
padding: 14px;
|
||||
}}
|
||||
.stat strong {{
|
||||
display: block;
|
||||
color: var(--text-bright);
|
||||
font-size: 1.05rem;
|
||||
}}
|
||||
.stat span {{
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}}
|
||||
.hero-note {{
|
||||
padding: 16px 18px;
|
||||
}}
|
||||
.hero-note p {{
|
||||
margin: 0 0 12px;
|
||||
}}
|
||||
.hero-note p:last-child {{
|
||||
margin-bottom: 0;
|
||||
}}
|
||||
.stack {{
|
||||
margin-top: 20px;
|
||||
}}
|
||||
.stack h2 {{
|
||||
margin: 0 0 14px;
|
||||
color: var(--text-bright);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.grid {{
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}}
|
||||
.panel {{
|
||||
padding: 16px;
|
||||
}}
|
||||
.panel p {{
|
||||
margin: 0;
|
||||
}}
|
||||
.panel strong {{
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent);
|
||||
font-family: "Press Start 2P", "VT323", monospace;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 18px;
|
||||
color: var(--text-muted);
|
||||
}}
|
||||
.status-line {{
|
||||
margin-top: 16px;
|
||||
min-height: 1.2em;
|
||||
}}
|
||||
.auth-provider-row {{
|
||||
margin-top: 14px;
|
||||
}}
|
||||
@media (max-width: 900px) {{
|
||||
.hero-grid, .grid, .stats {{
|
||||
grid-template-columns: 1fr;
|
||||
}}
|
||||
body {{
|
||||
font-size: 23px;
|
||||
}}
|
||||
.shell {{
|
||||
width: min(100% - 18px, 1160px);
|
||||
padding-top: 12px;
|
||||
padding-bottom: 24px;
|
||||
}}
|
||||
.hero {{
|
||||
padding: 18px;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Repixelizer hosted demo</p>
|
||||
<h1>Force fake pixel art back onto a real grid.</h1>
|
||||
<p class="lede">
|
||||
Repixelizer takes AI-slop pixel cosplay, infers the lattice it should have had, then drags it back onto an actual coherent mosaic.
|
||||
</p>
|
||||
<div class="cta-row" id="primaryActions">
|
||||
<a class="button" href="{primary_href}" id="primaryAction">{primary_label}</a>
|
||||
<button class="button secondary" id="authSignOut" type="button" {"hidden" if not authenticated else ""}>Sign out</button>
|
||||
<a class="button secondary" href="https://github.com/GameCult/repixelizer">View the repo</a>
|
||||
</div>
|
||||
<div class="cta-row auth-provider-row" id="authProviders">{provider_button_html}</div>
|
||||
<p class="status-line footer" id="authStateLine">
|
||||
{"Already authenticated as " + str(subject_payload.get("displayName") or subject_payload.get("accountId") or "a local creature") + "." if authenticated else ""}
|
||||
</p>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<strong>One engine</strong>
|
||||
<span>Phase-field reconstruction. No pipeline graveyard.</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>Hosted limits</strong>
|
||||
<span>{demo_limits}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>Lean inference</strong>
|
||||
<span>Autocorr lattice, cheap phase probe, then the real solve.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="panel hero-note">
|
||||
<h2>What it is for</h2>
|
||||
<p>Bad AI badge art. Mushy fake sprites. Grid-shaped lies with enough latent structure left to salvage.</p>
|
||||
<p>{hero_note}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack">
|
||||
<h2>How the machine behaves</h2>
|
||||
<div class="grid">
|
||||
<article class="panel">
|
||||
<strong>1. Read the grid</strong>
|
||||
<p>Infer one plausible lattice from autocorrelation instead of wandering through a candidate carnival all day.</p>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<strong>2. Find the phase</strong>
|
||||
<p>Probe a tiny phase grid up front so the solver does not begin life already standing in a pothole.</p>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<strong>3. Let the field work</strong>
|
||||
<p>Run one displacement field with enough leash to recover detail instead of compensating with decorative nonsense.</p>
|
||||
</article>
|
||||
</div>
|
||||
<p class="footer">Self-hosters can still go straight to <a href="/app/">the app</a>. Hosted mode just gets a front door instead of making everyone trip over raw controls immediately.</p>
|
||||
{auth_notice}
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const authConfig = {_json_html(auth_payload)};
|
||||
const sessionSubject = {_json_html(subject_payload)};
|
||||
const providerRow = document.getElementById("authProviders");
|
||||
const primaryAction = document.getElementById("primaryAction");
|
||||
const authStateLine = document.getElementById("authStateLine");
|
||||
const authNotice = document.getElementById("authNotice");
|
||||
const signOutButton = document.getElementById("authSignOut");
|
||||
const defaultPrimaryLabel = {json.dumps(primary_label)};
|
||||
let activeAttemptId = null;
|
||||
|
||||
function setNotice(text) {{
|
||||
if (authNotice) {{
|
||||
authNotice.textContent = text;
|
||||
}}
|
||||
if (authStateLine) {{
|
||||
authStateLine.textContent = text;
|
||||
}}
|
||||
}}
|
||||
|
||||
function setBusy(providerSlug, busy) {{
|
||||
if (!providerRow) {{
|
||||
return;
|
||||
}}
|
||||
for (const node of providerRow.querySelectorAll("button[data-provider]")) {{
|
||||
node.disabled = busy;
|
||||
}}
|
||||
if (primaryAction && primaryAction instanceof HTMLElement) {{
|
||||
primaryAction.textContent = busy ? `Waiting on ${{providerSlug}}...` : defaultPrimaryLabel;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function pollAttempt(attemptId, providerLabel) {{
|
||||
for (;;) {{
|
||||
const response = await fetch(`/api/auth/attempts/${{attemptId}}`, {{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
}});
|
||||
const payload = await response.json().catch(() => ({{ status: "failed", errorDescription: "Auth polling failed." }}));
|
||||
if (!response.ok) {{
|
||||
setNotice(payload.errorDescription || payload.detail || "Auth polling failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}}
|
||||
if (payload.status === "pending") {{
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||
continue;
|
||||
}}
|
||||
if (payload.status === "succeeded") {{
|
||||
const adopt = await fetch(`/api/auth/attempts/${{attemptId}}/adopt`, {{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
}});
|
||||
const adopted = await adopt.json().catch(() => ({{ status: "failed", detail: "Session adoption failed." }}));
|
||||
if (!adopt.ok) {{
|
||||
setNotice(adopted.detail || "Session adoption failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}}
|
||||
window.location.href = adopted.returnTo || "/app/";
|
||||
return;
|
||||
}}
|
||||
setNotice(payload.errorDescription || "Sign-in failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function startAuth(provider) {{
|
||||
if (!authConfig.enabled || !authConfig.startEndpoint) {{
|
||||
return;
|
||||
}}
|
||||
setBusy(provider.label, true);
|
||||
setNotice(`Opening ${{provider.label}}. If the browser throws a fit, that is still somehow considered normal web behavior.`);
|
||||
try {{
|
||||
const response = await fetch(authConfig.startEndpoint, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"content-type": "application/json",
|
||||
}},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({{ provider: provider.slug }}),
|
||||
}});
|
||||
const payload = await response.json().catch(() => ({{ detail: "Auth start failed." }}));
|
||||
if (!response.ok) {{
|
||||
setNotice(payload.detail || "Auth start failed.");
|
||||
setBusy(provider.label, false);
|
||||
return;
|
||||
}}
|
||||
activeAttemptId = payload.attemptId;
|
||||
const opened = window.open(payload.authorizationUrl, "_blank");
|
||||
if (!opened) {{
|
||||
setNotice(`Popup blocked. Opening ${{provider.label}} in this tab because the browser demanded drama.`);
|
||||
window.location.href = payload.authorizationUrl;
|
||||
return;
|
||||
}}
|
||||
void pollAttempt(payload.attemptId, provider.label);
|
||||
}} catch (error) {{
|
||||
setNotice(error instanceof Error ? error.message : "Auth start failed.");
|
||||
setBusy(provider.label, false);
|
||||
}}
|
||||
}}
|
||||
|
||||
if (authConfig.enabled && Array.isArray(authConfig.providers) && providerRow && !sessionSubject.authenticated) {{
|
||||
for (const provider of authConfig.providers) {{
|
||||
const button = providerRow.querySelector(`button[data-provider="${{provider.slug}}"]`);
|
||||
if (button instanceof HTMLButtonElement) {{
|
||||
button.addEventListener("click", () => {{
|
||||
void startAuth(provider);
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
if (primaryAction && authConfig.enabled && !sessionSubject.authenticated) {{
|
||||
primaryAction.addEventListener("click", (event) => {{
|
||||
event.preventDefault();
|
||||
const firstProvider = Array.isArray(authConfig.providers) ? authConfig.providers[0] : null;
|
||||
if (firstProvider) {{
|
||||
void startAuth(firstProvider);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
if (signOutButton) {{
|
||||
signOutButton.addEventListener("click", async () => {{
|
||||
try {{
|
||||
await fetch(authConfig.logoutUrl || "/api/auth/logout", {{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
}});
|
||||
}} finally {{
|
||||
window.location.href = "/";
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
auth_notice = '<p class="copy-tag status-text" id="authNotice">Choose a provider. The main page stays put while Heimdall and the backend sort out the paperwork in another tab.</p>'
|
||||
auth_state_text = (
|
||||
f"Already authenticated as {subject_payload.get('displayName') or subject_payload.get('accountId') or 'a local creature'}."
|
||||
if authenticated
|
||||
else ""
|
||||
)
|
||||
static_dir = _static_dir()
|
||||
styles_path = static_dir / "styles.css"
|
||||
styles_version = int(styles_path.stat().st_mtime_ns) if styles_path.exists() else 0
|
||||
landing_template = (static_dir / "landing.html").read_text(encoding="utf-8")
|
||||
return (
|
||||
landing_template.replace("{{STYLES_VERSION}}", str(styles_version))
|
||||
.replace("{{HERO_NOTE}}", html.escape(hero_note))
|
||||
.replace("{{PRIMARY_HREF}}", html.escape(primary_href, quote=True))
|
||||
.replace("{{PRIMARY_LABEL}}", html.escape(primary_label))
|
||||
.replace("{{SIGNOUT_HIDDEN}}", "hidden" if not authenticated else "")
|
||||
.replace("{{AUTH_PROVIDER_BUTTONS}}", provider_button_html)
|
||||
.replace("{{AUTH_STATE_TEXT}}", html.escape(auth_state_text))
|
||||
.replace("{{DEMO_LIMITS}}", html.escape(demo_limits))
|
||||
.replace("{{AUTH_NOTICE}}", auth_notice)
|
||||
.replace("{{AUTH_CONFIG_JSON}}", _json_html(auth_payload))
|
||||
.replace("{{SESSION_SUBJECT_JSON}}", _json_html(subject_payload))
|
||||
.replace("{{DEFAULT_PRIMARY_LABEL_JSON}}", _json_html(primary_label))
|
||||
)
|
||||
|
||||
|
||||
def create_app():
|
||||
|
|
@ -1550,13 +1122,11 @@ def create_app():
|
|||
target_size: int | None = Form(default=None),
|
||||
target_width: int | None = Form(default=None),
|
||||
target_height: int | None = Form(default=None),
|
||||
phase_x: float | None = Form(default=None),
|
||||
phase_y: float | None = Form(default=None),
|
||||
steps: int | None = Form(default=None),
|
||||
seed: int = Form(default=7),
|
||||
device: str = Form(default="auto"),
|
||||
strip_background: bool = Form(default=False),
|
||||
skip_phase_rerank: bool = Form(default=False),
|
||||
skip_candidate_rerank: bool = Form(default=False),
|
||||
):
|
||||
raw = await image.read()
|
||||
filename = image.filename or "input.png"
|
||||
|
|
@ -1568,13 +1138,11 @@ def create_app():
|
|||
target_size=target_size,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
steps=steps,
|
||||
seed=seed,
|
||||
device=device,
|
||||
strip_background=strip_background,
|
||||
skip_phase_rerank=skip_phase_rerank,
|
||||
skip_candidate_rerank=skip_candidate_rerank,
|
||||
)
|
||||
subject = access_controller.require_current_capability("queue_submit")
|
||||
job = manager.submit_job(
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ const downloadButton = byId("downloadButton");
|
|||
const targetSizeInput = byId("targetSizeInput");
|
||||
const targetWidthInput = byId("targetWidthInput");
|
||||
const targetHeightInput = byId("targetHeightInput");
|
||||
const phaseXInput = byId("phaseXInput");
|
||||
const phaseYInput = byId("phaseYInput");
|
||||
const stepsInput = byId("stepsInput");
|
||||
const seedInput = byId("seedInput");
|
||||
const runControlsCopy = byId("runControlsCopy");
|
||||
|
|
@ -70,11 +68,11 @@ const eventTypes = [
|
|||
"lattice_search_started",
|
||||
"lattice_search_progress",
|
||||
"inference_candidates_ready",
|
||||
"phase_rerank_started",
|
||||
"phase_rerank_candidate_started",
|
||||
"phase_rerank_candidate_step",
|
||||
"phase_rerank_candidate_completed",
|
||||
"phase_selection_completed",
|
||||
"candidate_rerank_started",
|
||||
"candidate_rerank_candidate_started",
|
||||
"candidate_rerank_candidate_step",
|
||||
"candidate_rerank_candidate_completed",
|
||||
"candidate_selection_completed",
|
||||
"analysis_completed",
|
||||
"phase_field_prepared",
|
||||
"phase_field_initial",
|
||||
|
|
@ -98,7 +96,7 @@ const state = {
|
|||
inference: null,
|
||||
inferenceMode: null,
|
||||
latticeSearch: null,
|
||||
phaseRerank: null,
|
||||
candidateRerank: null,
|
||||
phaseFieldPrep: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
|
|
@ -328,7 +326,7 @@ function resetRunArtifacts(options = {}) {
|
|||
state.inference = null;
|
||||
state.inferenceMode = null;
|
||||
state.latticeSearch = null;
|
||||
state.phaseRerank = null;
|
||||
state.candidateRerank = null;
|
||||
state.phaseFieldPrep = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
|
|
@ -469,12 +467,6 @@ function formatGridValue(width, height, fallback = "pending") {
|
|||
}
|
||||
return `${width} x ${height}`;
|
||||
}
|
||||
function formatPhaseValue(phaseX, phaseY, fallback = "pending") {
|
||||
if (phaseX === null || phaseX === undefined || phaseY === null || phaseY === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return `${formatNumber(phaseX, 2)}, ${formatNumber(phaseY, 2)}`;
|
||||
}
|
||||
function renderStatusMetricItems(items) {
|
||||
statusMetrics.innerHTML = "";
|
||||
statusMetrics.style.setProperty("--status-metric-count", String(Math.max(items.length, 1)));
|
||||
|
|
@ -503,10 +495,6 @@ function renderInference() {
|
|||
<strong>Chosen Grid</strong>
|
||||
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
|
||||
</div>
|
||||
<div class="info-card summary-card">
|
||||
<strong>Phase</strong>
|
||||
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
|
||||
</div>
|
||||
<div class="info-card summary-card">
|
||||
<strong>Confidence</strong>
|
||||
<span>${formatNumber(state.inference.confidence, 3)}</span>
|
||||
|
|
@ -517,10 +505,9 @@ function renderInference() {
|
|||
for (const candidate of topCandidates) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "info-card candidate-card";
|
||||
const rerank = candidate.breakdown["phase_rerank_rank"];
|
||||
const rerank = candidate.breakdown["candidate_rerank_rank"];
|
||||
node.innerHTML = `
|
||||
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
|
||||
<span>phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}</span><br />
|
||||
<span>score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""}</span>
|
||||
`;
|
||||
candidateList.appendChild(node);
|
||||
|
|
@ -547,7 +534,6 @@ function renderStatusMetrics() {
|
|||
else if (state.stageKey === "inference") {
|
||||
if (state.latticeSearch) {
|
||||
items.push({ label: "Sizes", value: `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}` });
|
||||
items.push({ label: "Phases", value: String(state.latticeSearch.phaseSampleCount) });
|
||||
items.push({
|
||||
label: "Grid",
|
||||
value: formatGridValue(state.latticeSearch.currentTargetWidth, state.latticeSearch.currentTargetHeight),
|
||||
|
|
@ -565,49 +551,44 @@ function renderStatusMetrics() {
|
|||
: state.inferenceMode ?? "search";
|
||||
items.push({ label: "Mode", value: modeLabel });
|
||||
items.push({ label: "Grid", value: `${state.inference.target_width} x ${state.inference.target_height}` });
|
||||
items.push({ label: "Phase", value: `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}` });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.inference.confidence, 3) });
|
||||
}
|
||||
}
|
||||
else if (state.stageKey === "rerank") {
|
||||
if (state.phaseRerank) {
|
||||
if (state.candidateRerank) {
|
||||
items.push({
|
||||
label: "Done",
|
||||
value: `${state.phaseRerank.completedCandidates} / ${state.phaseRerank.candidateCount}`,
|
||||
value: `${state.candidateRerank.completedCandidates} / ${state.candidateRerank.candidateCount}`,
|
||||
});
|
||||
items.push({
|
||||
label: "Candidate",
|
||||
value: state.phaseRerank.currentCandidateIndex > 0
|
||||
? `${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}`
|
||||
: `0 / ${state.phaseRerank.candidateCount}`,
|
||||
value: state.candidateRerank.currentCandidateIndex > 0
|
||||
? `${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}`
|
||||
: `0 / ${state.candidateRerank.candidateCount}`,
|
||||
});
|
||||
items.push({
|
||||
label: "Preview",
|
||||
value: state.phaseRerank.currentTotalSteps > 0
|
||||
? `${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps}`
|
||||
: state.phaseRerank.previewSteps > 0
|
||||
? `0 / ${state.phaseRerank.previewSteps}`
|
||||
value: state.candidateRerank.currentTotalSteps > 0
|
||||
? `${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps}`
|
||||
: state.candidateRerank.previewSteps > 0
|
||||
? `0 / ${state.candidateRerank.previewSteps}`
|
||||
: "skipped",
|
||||
});
|
||||
items.push({
|
||||
label: "Grid",
|
||||
value: formatGridValue(state.phaseRerank.currentTargetWidth, state.phaseRerank.currentTargetHeight),
|
||||
value: formatGridValue(state.candidateRerank.currentTargetWidth, state.candidateRerank.currentTargetHeight),
|
||||
});
|
||||
items.push({
|
||||
label: "Phase",
|
||||
value: formatPhaseValue(state.phaseRerank.currentPhaseX, state.phaseRerank.currentPhaseY),
|
||||
});
|
||||
items.push({ label: "Loss", value: formatNumber(state.phaseRerank.currentLoss, 4) });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.phaseRerank.confidence, 3) });
|
||||
items.push({ label: "Loss", value: formatNumber(state.candidateRerank.currentLoss, 4) });
|
||||
items.push({ label: "Confidence", value: formatNumber(state.candidateRerank.confidence, 3) });
|
||||
}
|
||||
}
|
||||
else if (state.stageKey === "solver") {
|
||||
const solverTerms = [
|
||||
{ label: "Coherence", value: formatNumber(frame?.terms.local_coherence ?? null, 4) },
|
||||
{ label: "Edge", value: formatNumber(frame?.terms.local_edge ?? null, 4) },
|
||||
{ label: "Smoothness", value: formatNumber(frame?.terms.smoothness ?? null, 4) },
|
||||
{ label: "Collapse", value: formatNumber(frame?.terms.collapse ?? null, 4) },
|
||||
{ label: "Magnitude", value: formatNumber(frame?.terms.magnitude ?? null, 4) },
|
||||
{ label: "Signal", value: formatNumber(frame?.terms.local_signal ?? frame?.solverMetrics.local_signal ?? null, 4) },
|
||||
{ label: "Grid align", value: formatNumber(frame?.terms.grid_alignment ?? frame?.solverMetrics.grid_alignment ?? null, 4) },
|
||||
{ label: "Smoothness", value: formatNumber(frame?.terms.smoothness ?? frame?.solverMetrics.smoothness ?? null, 4) },
|
||||
{ label: "Collapse", value: formatNumber(frame?.terms.collapse ?? frame?.solverMetrics.collapse ?? null, 4) },
|
||||
{ label: "Magnitude", value: formatNumber(frame?.terms.magnitude ?? frame?.solverMetrics.magnitude ?? null, 4) },
|
||||
];
|
||||
items.push({
|
||||
label: "Grid",
|
||||
|
|
@ -647,10 +628,18 @@ function resolveViewerPanels() {
|
|||
leftLabel = "Guidance";
|
||||
}
|
||||
if (frame) {
|
||||
leftAsset = frame.samplingOverlayImage;
|
||||
rightAsset = frame.outputImage;
|
||||
leftLabel = "Sampling Overlay";
|
||||
rightLabel = "Current Output";
|
||||
if (state.stageKey === "solver" && state.guidanceImage) {
|
||||
leftAsset = state.guidanceImage;
|
||||
rightAsset = frame.samplingOverlayImage;
|
||||
leftLabel = "Signal";
|
||||
rightLabel = "Sampling Overlay";
|
||||
}
|
||||
else {
|
||||
leftAsset = frame.samplingOverlayImage;
|
||||
rightAsset = frame.outputImage;
|
||||
leftLabel = "Sampling Overlay";
|
||||
rightLabel = "Current Output";
|
||||
}
|
||||
}
|
||||
else if (state.cleanupImage) {
|
||||
if (state.heatmapImage) {
|
||||
|
|
@ -805,12 +794,14 @@ function renderLossChart() {
|
|||
const domainMax = getLossDomainMax();
|
||||
const samples = buildLossSamples();
|
||||
const observedMaxLoss = samples.length === 0 ? null : Math.max(...samples.map((sample) => sample.loss));
|
||||
if (observedMaxLoss !== null) {
|
||||
state.lossAxisMax = Math.max(state.lossAxisMax ?? 0, observedMaxLoss);
|
||||
const observedMinLoss = samples.length === 0 ? null : Math.min(...samples.map((sample) => sample.loss));
|
||||
if (observedMaxLoss !== null && observedMinLoss !== null) {
|
||||
const observedAbsLoss = Math.max(Math.abs(observedMaxLoss), Math.abs(observedMinLoss));
|
||||
state.lossAxisMax = Math.max(state.lossAxisMax ?? 0, observedAbsLoss);
|
||||
}
|
||||
const axisMaxLoss = Math.max(0.001, state.lossAxisMax ?? observedMaxLoss ?? 1);
|
||||
const maxLoss = axisMaxLoss * 1.05;
|
||||
const minLoss = 0;
|
||||
const axisAbsLoss = Math.max(0.001, state.lossAxisMax ?? Math.max(Math.abs(observedMaxLoss ?? 1), Math.abs(observedMinLoss ?? 0)));
|
||||
const maxLoss = axisAbsLoss * 1.05;
|
||||
const minLoss = -axisAbsLoss * 1.05;
|
||||
const chartLeft = 88;
|
||||
const chartRight = width - 18;
|
||||
const chartTop = 16;
|
||||
|
|
@ -1245,13 +1236,11 @@ function buildFormData() {
|
|||
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
|
||||
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
|
||||
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
|
||||
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
|
||||
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
|
||||
["steps", String(normalizedSteps)],
|
||||
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
|
||||
["device", runtimeConfig?.hostedDemo ? "cpu" : deviceInput.value],
|
||||
["strip_background", runtimeConfig?.hostedDemo ? "false" : stripBackgroundInput.checked ? "true" : "false"],
|
||||
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
["skip_candidate_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
];
|
||||
for (const [key, value] of values) {
|
||||
if (value !== null) {
|
||||
|
|
@ -1317,13 +1306,12 @@ async function handleEvent(eventName, payload) {
|
|||
state.latticeSearch = {
|
||||
candidateCount: Number(payload.candidateCount ?? 0),
|
||||
completedCandidates: 0,
|
||||
phaseSampleCount: Number(payload.phaseSampleCount ?? 0),
|
||||
device: typeof payload.device === "string" ? payload.device : null,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
bestScore: null,
|
||||
};
|
||||
setStage("inference", "Lattice search", `Testing ${state.latticeSearch.candidateCount} size candidates with ${state.latticeSearch.phaseSampleCount} phase samples each.`);
|
||||
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.`);
|
||||
break;
|
||||
case "lattice_search_progress":
|
||||
|
|
@ -1331,7 +1319,6 @@ async function handleEvent(eventName, payload) {
|
|||
state.latticeSearch = {
|
||||
candidateCount: Number(payload.totalCandidates ?? 0),
|
||||
completedCandidates: 0,
|
||||
phaseSampleCount: Number(payload.phaseSampleCount ?? 0),
|
||||
device: null,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
|
|
@ -1340,20 +1327,19 @@ async function handleEvent(eventName, payload) {
|
|||
}
|
||||
state.latticeSearch.completedCandidates = Number(payload.completedCandidates ?? state.latticeSearch.completedCandidates);
|
||||
state.latticeSearch.candidateCount = Number(payload.totalCandidates ?? state.latticeSearch.candidateCount);
|
||||
state.latticeSearch.phaseSampleCount = Number(payload.phaseSampleCount ?? state.latticeSearch.phaseSampleCount);
|
||||
state.latticeSearch.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
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} over ${state.latticeSearch.phaseSampleCount} phase samples.`);
|
||||
setStage("inference", "Lattice search", `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;
|
||||
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
||||
addLog("Inference", "Scored candidate grids and phase offsets.");
|
||||
addLog("Inference", "Scored candidate grid sizes.");
|
||||
break;
|
||||
case "phase_rerank_started":
|
||||
state.phaseRerank = {
|
||||
case "candidate_rerank_started":
|
||||
state.candidateRerank = {
|
||||
previewSteps: Number(payload.previewSteps ?? 0),
|
||||
candidateCount: Number(payload.candidateCount ?? 0),
|
||||
confidence: Number(payload.confidence ?? 0),
|
||||
|
|
@ -1361,63 +1347,55 @@ async function handleEvent(eventName, payload) {
|
|||
currentCandidateIndex: 0,
|
||||
currentTargetWidth: null,
|
||||
currentTargetHeight: null,
|
||||
currentPhaseX: null,
|
||||
currentPhaseY: null,
|
||||
currentStep: 0,
|
||||
currentTotalSteps: 0,
|
||||
currentLoss: null,
|
||||
};
|
||||
setStage("rerank", "Phase rerank", `Previewing ${state.phaseRerank.candidateCount} low-confidence candidates over ${state.phaseRerank.previewSteps} short solver steps.`);
|
||||
setStage("rerank", "Candidate rerank", `Previewing ${state.candidateRerank.candidateCount} low-confidence candidates over ${state.candidateRerank.previewSteps} short solver steps.`);
|
||||
addLog("Rerank", `Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`);
|
||||
break;
|
||||
case "phase_rerank_candidate_started":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.currentStep = 0;
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.previewSteps ?? state.phaseRerank.previewSteps);
|
||||
state.phaseRerank.currentLoss = null;
|
||||
setStage("rerank", "Phase rerank", `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: previewing ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight} at phase ${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}.`);
|
||||
case "candidate_rerank_candidate_started":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.currentStep = 0;
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.previewSteps ?? state.candidateRerank.previewSteps);
|
||||
state.candidateRerank.currentLoss = null;
|
||||
setStage("rerank", "Candidate rerank", `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: previewing ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`);
|
||||
}
|
||||
break;
|
||||
case "phase_rerank_candidate_step":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.currentStep = Number(payload.step ?? 0);
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
|
||||
state.phaseRerank.currentLoss =
|
||||
case "candidate_rerank_candidate_step":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.currentStep = Number(payload.step ?? 0);
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
|
||||
state.candidateRerank.currentLoss =
|
||||
payload.loss === null || payload.loss === undefined ? null : Number(payload.loss);
|
||||
setStage("rerank", "Phase rerank", state.phaseRerank.currentTotalSteps <= 0
|
||||
? `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: preview solve skipped.`
|
||||
: `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}, preview step ${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps} on ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}.`);
|
||||
setStage("rerank", "Candidate rerank", state.candidateRerank.currentTotalSteps <= 0
|
||||
? `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: preview solve skipped.`
|
||||
: `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}, preview step ${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps} on ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`);
|
||||
}
|
||||
break;
|
||||
case "phase_rerank_candidate_completed":
|
||||
if (state.phaseRerank) {
|
||||
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
||||
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
||||
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
||||
state.phaseRerank.completedCandidates = Number(payload.completedCandidates ?? state.phaseRerank.completedCandidates);
|
||||
state.phaseRerank.currentStep = Number(payload.totalSteps ?? state.phaseRerank.currentStep);
|
||||
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? state.phaseRerank.currentTotalSteps);
|
||||
state.phaseRerank.currentLoss =
|
||||
payload.finalLoss === null || payload.finalLoss === undefined ? state.phaseRerank.currentLoss : Number(payload.finalLoss);
|
||||
setStage("rerank", "Phase rerank", `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount} scored. ${state.phaseRerank.completedCandidates} of ${state.phaseRerank.candidateCount} previews finished.`);
|
||||
case "candidate_rerank_candidate_completed":
|
||||
if (state.candidateRerank) {
|
||||
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
|
||||
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
||||
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
||||
state.candidateRerank.completedCandidates = Number(payload.completedCandidates ?? state.candidateRerank.completedCandidates);
|
||||
state.candidateRerank.currentStep = Number(payload.totalSteps ?? state.candidateRerank.currentStep);
|
||||
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? state.candidateRerank.currentTotalSteps);
|
||||
state.candidateRerank.currentLoss =
|
||||
payload.finalLoss === null || payload.finalLoss === undefined ? state.candidateRerank.currentLoss : Number(payload.finalLoss);
|
||||
setStage("rerank", "Candidate rerank", `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount} scored. ${state.candidateRerank.completedCandidates} of ${state.candidateRerank.candidateCount} previews finished.`);
|
||||
}
|
||||
break;
|
||||
case "phase_selection_completed":
|
||||
case "candidate_selection_completed":
|
||||
state.inference = payload.inference;
|
||||
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
||||
addLog("Selection", "Committed to a ruler and phase.");
|
||||
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.");
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
<div class="header-instructions">
|
||||
<p class="eyebrow">How To Use It</p>
|
||||
<p class="instruction-line">1. Drop an input on the right or choose a file.</p>
|
||||
<p class="instruction-line">2. Leave size and phase blank to let the machine search, or pin them if you already know the lattice.</p>
|
||||
<p class="instruction-line">2. Leave size blank to let the machine search, or pin width and height if you already know the lattice.</p>
|
||||
<p class="instruction-line">3. Run it, watch the status panel for the active stage, then patch the output by hand if the machine gets cute.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -235,34 +235,6 @@
|
|||
</span>
|
||||
<input id="targetHeightInput" class="field-control" name="targetHeight" type="number" min="1" placeholder="auto" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span class="field-label">Phase X</span>
|
||||
<span
|
||||
class="help-tip"
|
||||
tabindex="0"
|
||||
role="note"
|
||||
title="Pin the horizontal lattice phase offset in cell units. Leave this blank to let the pipeline search. Phase X and Phase Y only work when the output size is fixed."
|
||||
aria-label="Pin the horizontal lattice phase offset in cell units. Leave this blank to let the pipeline search. Phase X and Phase Y only work when the output size is fixed."
|
||||
>?</span
|
||||
>
|
||||
</span>
|
||||
<input id="phaseXInput" class="field-control" name="phaseX" type="number" step="0.05" placeholder="search" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span class="field-label">Phase Y</span>
|
||||
<span
|
||||
class="help-tip"
|
||||
tabindex="0"
|
||||
role="note"
|
||||
title="Pin the vertical lattice phase offset in cell units. Leave this blank to let the pipeline search. Phase X and Phase Y only work when the output size is fixed."
|
||||
aria-label="Pin the vertical lattice phase offset in cell units. Leave this blank to let the pipeline search. Phase X and Phase Y only work when the output size is fixed."
|
||||
>?</span
|
||||
>
|
||||
</span>
|
||||
<input id="phaseYInput" class="field-control" name="phaseY" type="number" step="0.05" placeholder="search" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span class="field-label">Steps</span>
|
||||
|
|
@ -308,13 +280,13 @@
|
|||
<label class="toggle-row ui-surface ui-frame">
|
||||
<input id="skipRerankInput" name="skipRerank" type="checkbox" />
|
||||
<span class="toggle-copy">
|
||||
<span>Skip low-confidence phase rerank</span>
|
||||
<span>Skip candidate rerank</span>
|
||||
<span
|
||||
class="help-tip"
|
||||
tabindex="0"
|
||||
role="note"
|
||||
title="Skip the preview rerank pass that rescues shaky size or phase picks when confidence is low. Faster, but more likely to keep a bad lattice guess."
|
||||
aria-label="Skip the preview rerank pass that rescues shaky size or phase picks when confidence is low. Faster, but more likely to keep a bad lattice guess."
|
||||
title="Skip the preview rerank pass that rescues shaky size picks when confidence is low. Faster, but more likely to keep a bad lattice guess."
|
||||
aria-label="Skip the preview rerank pass that rescues shaky size picks when confidence is low. Faster, but more likely to keep a bad lattice guess."
|
||||
>?</span
|
||||
>
|
||||
</span>
|
||||
|
|
|
|||
BIN
src/repixelizer/gui_static/landing-assets/character-input.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 30 KiB |
392
src/repixelizer/gui_static/landing.html
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Repixelizer</title>
|
||||
<link rel="stylesheet" href="/app/styles.css?v={{STYLES_VERSION}}">
|
||||
<style>
|
||||
.landing-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.landing-pitch {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pitch-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.pitch-copy h2 {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pitch-story {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 1.12rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pitch-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pitch-shot {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pitch-shot strong {
|
||||
color: var(--ui-accent);
|
||||
font-family: "Press Start 2P", "VT323", monospace;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pitch-shot span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pitch-image-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: clamp(280px, 28vw, 420px);
|
||||
background:
|
||||
repeating-linear-gradient(45deg, rgba(247, 239, 216, 0.06) 0 8px, rgba(0, 0, 0, 0.18) 8px 16px),
|
||||
linear-gradient(180deg, #07172c, #0a2039);
|
||||
border: 3px solid rgba(8, 36, 62, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 216, 74, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pitch-image-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.pitch-closeup img {
|
||||
object-fit: cover;
|
||||
transform: scale(3.45);
|
||||
transform-origin: 42% 63%;
|
||||
}
|
||||
|
||||
.landing-auth-strip {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
grid-template-columns: minmax(0, 1.35fr) auto minmax(280px, 0.9fr) minmax(220px, 0.65fr);
|
||||
}
|
||||
|
||||
.landing-header .editor-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-self: start;
|
||||
justify-content: stretch;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.landing-header .editor-actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.landing-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pitch-gallery {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.landing-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.landing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell app-shell">
|
||||
<section class="app-shell-header landing-header ui-surface ui-frame">
|
||||
<div class="brand-lockup">
|
||||
<div class="brand-emblem" aria-hidden="true">
|
||||
<img class="brand-emblem-art" src="/app/logos/repixelizer-logo-monogram.svg" alt="" />
|
||||
</div>
|
||||
<div class="copy-stack">
|
||||
<p class="eyebrow">Repixelizer hosted demo</p>
|
||||
<h1>Repixelizer</h1>
|
||||
<p class="copy-tag brand-tag">
|
||||
Force fake pixel art back onto a real grid, then inspect each step with a full solver view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="header-github-link"
|
||||
href="https://github.com/GameCult/repixelizer"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Repixelizer on GitHub"
|
||||
title="Repixelizer on GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 98 96" aria-hidden="true">
|
||||
<path
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="header-instructions">
|
||||
<p class="eyebrow">How to enter</p>
|
||||
<p class="instruction-line">Start from this page, authenticate if enabled, then continue into the app.</p>
|
||||
<p class="instruction-line">The hosted flow is a short handoff, not a separate experience.</p>
|
||||
<p class="instruction-line">If auth is disabled, the demo button goes straight to the live workspace.</p>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<a class="button" href="{{PRIMARY_HREF}}" id="primaryAction">{{PRIMARY_LABEL}}</a>
|
||||
<button class="button button-secondary" id="authSignOut" type="button" {{SIGNOUT_HIDDEN}}>Sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="hero panel-stack ui-surface ui-frame">
|
||||
<div class="landing-pitch">
|
||||
<div class="pitch-copy">
|
||||
<p class="eyebrow">The usual little disaster</p>
|
||||
<h2>You need pixel art for a project, but pixel art is a craft and your favorite AI is mostly vibes in a trench coat.</h2>
|
||||
<div class="pitch-story">
|
||||
<p>So you ask for a cute pill-shaped character in a hazmat suit, with a sci-fi gun and a tank on his back.</p>
|
||||
<p>Hey, that is pretty nice. Then you look closer.</p>
|
||||
<p>Oh. Oh, no. That is not pixel art. Toss that bad boy into Repixelizer and make the lattice do actual work.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pitch-gallery">
|
||||
<figure class="pitch-shot pitch-input ui-surface ui-frame">
|
||||
<strong>The AI gives you this</strong>
|
||||
<div class="pitch-image-wrap">
|
||||
<img src="/app/landing-assets/character-input.png" alt="AI-generated hazmat character before repixelizing">
|
||||
</div>
|
||||
<span>Looks charming from orbit. Suspiciously charming.</span>
|
||||
</figure>
|
||||
<figure class="pitch-shot pitch-closeup ui-surface ui-frame">
|
||||
<strong>Then you zoom in</strong>
|
||||
<div class="pitch-image-wrap">
|
||||
<img src="/app/landing-assets/character-input.png" alt="Zoomed close-up showing fake pixel-art artifacts around the hazmat symbol">
|
||||
</div>
|
||||
<span>Smears, half-cells, anti-grid nonsense. The crime scene has opinions.</span>
|
||||
</figure>
|
||||
<figure class="pitch-shot pitch-output ui-surface ui-frame">
|
||||
<strong>Repixelizer spits out this</strong>
|
||||
<div class="pitch-image-wrap">
|
||||
<img src="/app/landing-assets/character-repixelized.png" alt="Repixelized hazmat character output">
|
||||
</div>
|
||||
<span>Real cells on a real lattice, ready for cleanup or export.</span>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="landing-auth-strip">
|
||||
<p class="copy-tag">{{HERO_NOTE}}</p>
|
||||
<div class="editor-button-group" id="authProviders">{{AUTH_PROVIDER_BUTTONS}}</div>
|
||||
<p class="status-text" id="authStateLine">
|
||||
{{AUTH_STATE_TEXT}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card panel-stack ui-surface ui-frame">
|
||||
<div class="card-head">
|
||||
<div class="copy-stack">
|
||||
<p class="eyebrow">Pipeline</p>
|
||||
<h2>How the machine behaves</h2>
|
||||
<p class="copy-tag">A short machine, full of ugly truths and no unnecessary ceremony.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="landing-grid">
|
||||
<article class="info-card">
|
||||
<strong>1. Read the grid</strong>
|
||||
<span>Infer one plausible lattice from autocorrelation instead of wandering through a candidate carnival all day.</span>
|
||||
</article>
|
||||
<article class="info-card">
|
||||
<strong>2. Pick the size</strong>
|
||||
<span>Probe a tiny size check up front so the solver does not begin life already standing in a pothole.</span>
|
||||
</article>
|
||||
<article class="info-card">
|
||||
<strong>3. Let the field work</strong>
|
||||
<span>Run one displacement field with enough leash to recover detail instead of compensating with decorative nonsense.</span>
|
||||
</article>
|
||||
</div>
|
||||
<p class="copy-tag status-text">
|
||||
Self-hosters can still go straight to <a href="/app/">the app</a>. Hosted mode just gets a front door instead of making everyone trip over raw controls immediately.
|
||||
</p>
|
||||
{{AUTH_NOTICE}}
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const authConfig = {{AUTH_CONFIG_JSON}};
|
||||
const sessionSubject = {{SESSION_SUBJECT_JSON}};
|
||||
const providerRow = document.getElementById("authProviders");
|
||||
const primaryAction = document.getElementById("primaryAction");
|
||||
const authStateLine = document.getElementById("authStateLine");
|
||||
const authNotice = document.getElementById("authNotice");
|
||||
const signOutButton = document.getElementById("authSignOut");
|
||||
const defaultPrimaryLabel = {{DEFAULT_PRIMARY_LABEL_JSON}};
|
||||
let activeAttemptId = null;
|
||||
|
||||
function setNotice(text) {
|
||||
if (authNotice) {
|
||||
authNotice.textContent = text;
|
||||
}
|
||||
if (authStateLine) {
|
||||
authStateLine.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(providerSlug, busy) {
|
||||
if (!providerRow) {
|
||||
return;
|
||||
}
|
||||
for (const node of providerRow.querySelectorAll("button[data-provider]")) {
|
||||
node.disabled = busy;
|
||||
}
|
||||
if (primaryAction && primaryAction instanceof HTMLElement) {
|
||||
primaryAction.textContent = busy ? `Waiting on ${providerSlug}...` : defaultPrimaryLabel;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollAttempt(attemptId, providerLabel) {
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/auth/attempts/${attemptId}`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({ status: "failed", errorDescription: "Auth polling failed." }));
|
||||
if (!response.ok) {
|
||||
setNotice(payload.errorDescription || payload.detail || "Auth polling failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}
|
||||
if (payload.status === "pending") {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||
continue;
|
||||
}
|
||||
if (payload.status === "succeeded") {
|
||||
const adopt = await fetch(`/api/auth/attempts/${attemptId}/adopt`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const adopted = await adopt.json().catch(() => ({ status: "failed", detail: "Session adoption failed." }));
|
||||
if (!adopt.ok) {
|
||||
setNotice(adopted.detail || "Session adoption failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}
|
||||
window.location.href = adopted.returnTo || "/app/";
|
||||
return;
|
||||
}
|
||||
setNotice(payload.errorDescription || "Sign-in failed.");
|
||||
setBusy(providerLabel, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function startAuth(provider) {
|
||||
if (!authConfig.enabled || !authConfig.startEndpoint) {
|
||||
return;
|
||||
}
|
||||
setBusy(provider.label, true);
|
||||
setNotice(`Opening ${provider.label}. If the browser throws a fit, that is still somehow considered normal web behavior.`);
|
||||
try {
|
||||
const response = await fetch(authConfig.startEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ provider: provider.slug }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({ detail: "Auth start failed." }));
|
||||
if (!response.ok) {
|
||||
setNotice(payload.detail || "Auth start failed.");
|
||||
setBusy(provider.label, false);
|
||||
return;
|
||||
}
|
||||
activeAttemptId = payload.attemptId;
|
||||
const opened = window.open(payload.authorizationUrl, "_blank");
|
||||
if (!opened) {
|
||||
setNotice(`Popup blocked. Opening ${provider.label} in this tab because the browser demanded drama.`);
|
||||
window.location.href = payload.authorizationUrl;
|
||||
return;
|
||||
}
|
||||
void pollAttempt(payload.attemptId, provider.label);
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "Auth start failed.");
|
||||
setBusy(provider.label, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authConfig.enabled && Array.isArray(authConfig.providers) && providerRow && !sessionSubject.authenticated) {
|
||||
for (const button of providerRow.querySelectorAll("button[data-provider]")) {
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
const provider = authConfig.providers.find((candidate) => candidate && candidate.slug === button.dataset.provider);
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
button.addEventListener("click", () => {
|
||||
void startAuth(provider);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryAction && authConfig.enabled && !sessionSubject.authenticated) {
|
||||
primaryAction.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const firstProvider = Array.isArray(authConfig.providers) ? authConfig.providers[0] : null;
|
||||
if (firstProvider) {
|
||||
void startAuth(firstProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (signOutButton) {
|
||||
signOutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(authConfig.logoutUrl || "/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} finally {
|
||||
window.location.href = "/";
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
<div class="logo-meta">
|
||||
<p class="eyebrow">Option 1</p>
|
||||
<h2>Snapping R</h2>
|
||||
<p class="logo-notes">Chunkier monogram pulled from the reference glyph, with the phase ghost shoved to the right where it reads as drift instead of regret.</p>
|
||||
<p class="logo-notes">Chunkier monogram pulled from the reference glyph, with a right-shifted tile echo where it reads as drift instead of regret.</p>
|
||||
</div>
|
||||
<img src="./logos/repixelizer-logo-monogram.svg" alt="Snapping R monogram logo concept" />
|
||||
<div class="logo-row">
|
||||
|
|
@ -115,14 +115,14 @@
|
|||
<article class="card ui-surface ui-frame logo-card">
|
||||
<div class="logo-meta">
|
||||
<p class="eyebrow">Option 3</p>
|
||||
<h2>Phase Crosshair</h2>
|
||||
<p class="logo-notes">More machine-brain, less mascot. Feels like targeting, phase lock, and solver intent rather than "brand mark" first.</p>
|
||||
<h2>Solver Crosshair</h2>
|
||||
<p class="logo-notes">More machine-brain, less mascot. Feels like targeting, grid snap, and solver intent rather than "brand mark" first.</p>
|
||||
</div>
|
||||
<img src="./logos/repixelizer-logo-phase-crosshair.svg" alt="Phase crosshair logo concept" />
|
||||
<img src="./logos/repixelizer-logo-solver-crosshair.svg" alt="Solver crosshair logo concept" />
|
||||
<div class="logo-row">
|
||||
<span class="viz-label">Small Read</span>
|
||||
<div class="viz-card ui-surface ui-frame logo-chip">
|
||||
<img src="./logos/repixelizer-logo-phase-crosshair.svg" alt="" />
|
||||
<img src="./logos/repixelizer-logo-solver-crosshair.svg" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="12 12 232 232" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Repixelizer monogram logo concept</title>
|
||||
<desc id="desc">A discrete pixel-tile R monogram built from the reference mosaic, inset cleanly inside the rounded badge and trailed by a right-shifted phase ghost.</desc>
|
||||
<desc id="desc">A discrete pixel-tile R monogram built from the reference mosaic, inset cleanly inside the rounded badge and trailed by a right-shifted tile echo.</desc>
|
||||
<defs>
|
||||
<pattern id="grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<path d="M 12 0 L 0 0 0 12" fill="none" stroke="#ffd84a" stroke-opacity="0.08" stroke-width="1" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Repixelizer phase crosshair logo concept</title>
|
||||
<desc id="desc">A chunky pixel in a phase-lock reticle with ghost positions hinting at the search.</desc>
|
||||
<title id="title">Repixelizer solver crosshair logo concept</title>
|
||||
<desc id="desc">A chunky pixel in a solver reticle with ghost positions hinting at the search.</desc>
|
||||
<defs>
|
||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
|
@ -84,6 +84,8 @@ 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)
|
||||
if spread > max(8, center // 2) and prior_reliability < 0.55:
|
||||
return None
|
||||
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)
|
||||
|
|
@ -114,6 +116,14 @@ def _resolve_candidate_dims_from_autocorr(
|
|||
prior_reliability=prior_reliability,
|
||||
)
|
||||
if size_window is None:
|
||||
if target_size is None and hinted_sizes:
|
||||
hinted_dims = _candidate_dims_from_autocorr_hints(
|
||||
width,
|
||||
height,
|
||||
hinted_sizes=hinted_sizes,
|
||||
)
|
||||
if hinted_dims:
|
||||
return hinted_dims
|
||||
return dims
|
||||
center, radius = size_window
|
||||
size_index = 0 if width >= height else 1
|
||||
|
|
@ -121,29 +131,32 @@ def _resolve_candidate_dims_from_autocorr(
|
|||
return narrowed or dims
|
||||
|
||||
|
||||
def _direct_autocorr_target_size(
|
||||
def _candidate_dims_from_autocorr_hints(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
hinted_sizes: list[int],
|
||||
shared_prior: float,
|
||||
prior_reliability: float,
|
||||
max_target_size: int | None = None,
|
||||
) -> int:
|
||||
size_window = _autocorr_size_window(
|
||||
hinted_sizes,
|
||||
prior_reliability=prior_reliability,
|
||||
)
|
||||
major = max(width, height)
|
||||
if size_window is not None:
|
||||
target_size = int(size_window[0])
|
||||
elif hinted_sizes:
|
||||
target_size = int(round(float(np.median(np.asarray(hinted_sizes, dtype=np.float32)))))
|
||||
else:
|
||||
target_size = max(1, int(round(major / max(1e-6, shared_prior))))
|
||||
if max_target_size is not None:
|
||||
target_size = min(target_size, max(1, int(max_target_size)))
|
||||
return max(1, target_size)
|
||||
) -> list[tuple[int, int]]:
|
||||
capped_max_size = max(1, int(max_target_size)) if max_target_size is not None else None
|
||||
major_sizes: list[int] = []
|
||||
for hinted_size in hinted_sizes:
|
||||
major_size = max(1, int(round(float(hinted_size))))
|
||||
if capped_max_size is not None and major_size > capped_max_size:
|
||||
continue
|
||||
major_sizes.append(major_size)
|
||||
if not major_sizes and capped_max_size is not None:
|
||||
major_sizes.append(capped_max_size)
|
||||
|
||||
dims: list[tuple[int, int]] = []
|
||||
seen: set[tuple[int, int]] = set()
|
||||
for major_size in sorted(set(major_sizes)):
|
||||
dim = _target_dims_from_major_size(width, height, major_size)
|
||||
if dim in seen:
|
||||
continue
|
||||
dims.append(dim)
|
||||
seen.add(dim)
|
||||
return dims
|
||||
|
||||
|
||||
def _estimate_cell_size_details(profile: np.ndarray) -> AutocorrEstimate:
|
||||
|
|
@ -364,8 +377,6 @@ def _rerank_size_candidates_with_source_evidence(
|
|||
rgba,
|
||||
target_width=candidate.target_width,
|
||||
target_height=candidate.target_height,
|
||||
phase_x=candidate.phase_x,
|
||||
phase_y=candidate.phase_y,
|
||||
)
|
||||
for candidate in candidates
|
||||
]
|
||||
|
|
@ -387,8 +398,6 @@ def _rerank_size_candidates_with_source_evidence(
|
|||
InferenceCandidate(
|
||||
target_width=candidate.target_width,
|
||||
target_height=candidate.target_height,
|
||||
phase_x=candidate.phase_x,
|
||||
phase_y=candidate.phase_y,
|
||||
score=combined_score,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
|
@ -396,24 +405,21 @@ def _rerank_size_candidates_with_source_evidence(
|
|||
reranked.sort(key=lambda item: item.score, reverse=True)
|
||||
return reranked
|
||||
|
||||
def _build_phase_grid(
|
||||
def _build_sample_grid(
|
||||
torch,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_xs,
|
||||
phase_ys,
|
||||
device: str,
|
||||
):
|
||||
batch = phase_xs.shape[0]
|
||||
cell_x = width / target_width
|
||||
cell_y = height / target_height
|
||||
base_x = (torch.arange(target_width, device=device, dtype=torch.float32) + 0.5) * cell_x - 0.5
|
||||
base_y = (torch.arange(target_height, device=device, dtype=torch.float32) + 0.5) * cell_y - 0.5
|
||||
xs = base_x[None, None, :].expand(batch, target_height, target_width) + phase_xs[:, None, None] * cell_x
|
||||
ys = base_y[None, :, None].expand(batch, target_height, target_width) + phase_ys[:, None, None] * cell_y
|
||||
xs = base_x[None, None, :].expand(1, target_height, target_width)
|
||||
ys = base_y[None, :, None].expand(1, target_height, target_width)
|
||||
xs = xs.clamp(0.0, max(0.0, width - 1))
|
||||
ys = ys.clamp(0.0, max(0.0, height - 1))
|
||||
grid_x = (xs / max(1.0, width - 1)) * 2.0 - 1.0
|
||||
|
|
@ -491,7 +497,7 @@ def _coherence_breakdown_torch(torch, F, rgba_batch):
|
|||
}
|
||||
|
||||
|
||||
def _score_phase_group(
|
||||
def _score_size_candidate(
|
||||
rgba: np.ndarray,
|
||||
*,
|
||||
target_width: int,
|
||||
|
|
@ -499,30 +505,21 @@ def _score_phase_group(
|
|||
prior_cell_x: float,
|
||||
prior_cell_y: float,
|
||||
prior_reliability: float,
|
||||
phase_x_values: np.ndarray,
|
||||
phase_y_values: np.ndarray,
|
||||
device: str,
|
||||
):
|
||||
torch, F = _require_torch()
|
||||
height, width = rgba.shape[:2]
|
||||
premult_rgba = premultiply(rgba)
|
||||
source_t = torch.from_numpy(premult_rgba.transpose(2, 0, 1)[None, ...]).to(device=device, dtype=torch.float32)
|
||||
phase_xs, phase_ys = np.meshgrid(phase_x_values, phase_y_values, indexing="xy")
|
||||
phase_x_t = torch.from_numpy(phase_xs.reshape(-1).astype(np.float32)).to(device=device)
|
||||
phase_y_t = torch.from_numpy(phase_ys.reshape(-1).astype(np.float32)).to(device=device)
|
||||
batch = phase_x_t.shape[0]
|
||||
grid = _build_phase_grid(
|
||||
grid = _build_sample_grid(
|
||||
torch,
|
||||
width=width,
|
||||
height=height,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_xs=phase_x_t,
|
||||
phase_ys=phase_y_t,
|
||||
device=device,
|
||||
)
|
||||
source_batch = source_t.expand(batch, -1, -1, -1)
|
||||
sampled = F.grid_sample(source_batch, grid, align_corners=True, mode="bilinear", padding_mode="border")
|
||||
sampled = F.grid_sample(source_t, grid, align_corners=True, mode="bilinear", padding_mode="border")
|
||||
rgba_sample = _unpremultiply_batch(torch, sampled)
|
||||
breakdown = _coherence_breakdown_torch(torch, F, rgba_sample)
|
||||
cell_x = width / target_width
|
||||
|
|
@ -535,28 +532,24 @@ def _score_phase_group(
|
|||
score_np = score.detach().cpu().numpy()
|
||||
|
||||
candidates: list[InferenceCandidate] = []
|
||||
for index, phase_x in enumerate(phase_x_t.detach().cpu().numpy()):
|
||||
phase_y = float(phase_y_t[index].detach().cpu().item())
|
||||
candidate_breakdown = {
|
||||
"cluster_continuity": float(breakdown["cluster_continuity"][index].detach().cpu().item()),
|
||||
"alpha_crispness": float(breakdown["alpha_crispness"][index].detach().cpu().item()),
|
||||
"outline_straightness": float(breakdown["outline_straightness"][index].detach().cpu().item()),
|
||||
"isolated_penalty": float(breakdown["isolated_penalty"][index].detach().cpu().item()),
|
||||
"color_chatter": float(breakdown["color_chatter"][index].detach().cpu().item()),
|
||||
"coherence_score": float(breakdown["coherence_score"][index].detach().cpu().item()),
|
||||
"size_prior": size_prior,
|
||||
"size_prior_weight": size_prior_weight,
|
||||
}
|
||||
candidates.append(
|
||||
InferenceCandidate(
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=float(phase_x),
|
||||
phase_y=phase_y,
|
||||
score=float(score_np[index]),
|
||||
breakdown=candidate_breakdown,
|
||||
)
|
||||
candidate_breakdown = {
|
||||
"cluster_continuity": float(breakdown["cluster_continuity"][0].detach().cpu().item()),
|
||||
"alpha_crispness": float(breakdown["alpha_crispness"][0].detach().cpu().item()),
|
||||
"outline_straightness": float(breakdown["outline_straightness"][0].detach().cpu().item()),
|
||||
"isolated_penalty": float(breakdown["isolated_penalty"][0].detach().cpu().item()),
|
||||
"color_chatter": float(breakdown["color_chatter"][0].detach().cpu().item()),
|
||||
"coherence_score": float(breakdown["coherence_score"][0].detach().cpu().item()),
|
||||
"size_prior": size_prior,
|
||||
"size_prior_weight": size_prior_weight,
|
||||
}
|
||||
candidates.append(
|
||||
InferenceCandidate(
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
score=float(score_np[0]),
|
||||
breakdown=candidate_breakdown,
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
|
|
@ -580,7 +573,6 @@ def infer_lattice(
|
|||
autocorr_y_estimate=autocorr_y_estimate,
|
||||
shared_prior=prior_cell_x,
|
||||
)
|
||||
phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32)
|
||||
candidate_dims = _resolve_candidate_dims_from_autocorr(
|
||||
width,
|
||||
height,
|
||||
|
|
@ -588,28 +580,24 @@ def infer_lattice(
|
|||
hinted_sizes=hinted_sizes,
|
||||
prior_reliability=prior_reliability,
|
||||
)
|
||||
phase_sample_count = int(phase_values.size * phase_values.size)
|
||||
|
||||
emit_observer(
|
||||
observer,
|
||||
"lattice_search_started",
|
||||
candidate_count=len(candidate_dims),
|
||||
phase_sample_count=phase_sample_count,
|
||||
device=resolved_device,
|
||||
)
|
||||
|
||||
candidates: list[InferenceCandidate] = []
|
||||
for candidate_index, (target_width, target_height) in enumerate(candidate_dims, start=1):
|
||||
check_observer_cancelled(observer)
|
||||
scored_group = _score_phase_group(
|
||||
scored_group = _score_size_candidate(
|
||||
rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
prior_cell_x=prior_cell_x,
|
||||
prior_cell_y=prior_cell_y,
|
||||
prior_reliability=prior_reliability,
|
||||
phase_x_values=phase_values,
|
||||
phase_y_values=phase_values,
|
||||
device=resolved_device,
|
||||
)
|
||||
candidates.extend(scored_group)
|
||||
|
|
@ -620,7 +608,6 @@ def infer_lattice(
|
|||
total_candidates=len(candidate_dims),
|
||||
target_width=int(target_width),
|
||||
target_height=int(target_height),
|
||||
phase_sample_count=phase_sample_count,
|
||||
best_score=None if not scored_group else float(max(candidate.score for candidate in scored_group)),
|
||||
)
|
||||
|
||||
|
|
@ -634,8 +621,6 @@ def infer_lattice(
|
|||
return InferenceResult(
|
||||
target_width=best.target_width,
|
||||
target_height=best.target_height,
|
||||
phase_x=best.phase_x,
|
||||
phase_y=best.phase_y,
|
||||
confidence=confidence,
|
||||
top_candidates=top_candidates,
|
||||
)
|
||||
|
|
@ -660,58 +645,54 @@ def infer_autocorr_lattice(
|
|||
autocorr_y_estimate=autocorr_y_estimate,
|
||||
shared_prior=prior_cell_x,
|
||||
)
|
||||
target_size = _direct_autocorr_target_size(
|
||||
candidate_dims = _candidate_dims_from_autocorr_hints(
|
||||
width,
|
||||
height,
|
||||
hinted_sizes=hinted_sizes,
|
||||
shared_prior=prior_cell_x,
|
||||
prior_reliability=prior_reliability,
|
||||
max_target_size=max_target_size,
|
||||
)
|
||||
target_width, target_height = _target_dims_from_major_size(width, height, target_size)
|
||||
phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32)
|
||||
phase_sample_count = int(phase_values.size * phase_values.size)
|
||||
|
||||
emit_observer(
|
||||
observer,
|
||||
"lattice_search_started",
|
||||
candidate_count=1,
|
||||
phase_sample_count=phase_sample_count,
|
||||
device=resolved_device,
|
||||
)
|
||||
check_observer_cancelled(observer)
|
||||
candidates = _score_phase_group(
|
||||
rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
prior_cell_x=prior_cell_x,
|
||||
prior_cell_y=prior_cell_y,
|
||||
prior_reliability=prior_reliability,
|
||||
phase_x_values=phase_values,
|
||||
phase_y_values=phase_values,
|
||||
candidate_count=len(candidate_dims),
|
||||
device=resolved_device,
|
||||
)
|
||||
|
||||
candidates: list[InferenceCandidate] = []
|
||||
for candidate_index, (target_width, target_height) in enumerate(candidate_dims, start=1):
|
||||
check_observer_cancelled(observer)
|
||||
scored_group = _score_size_candidate(
|
||||
rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
prior_cell_x=prior_cell_x,
|
||||
prior_cell_y=prior_cell_y,
|
||||
prior_reliability=prior_reliability,
|
||||
device=resolved_device,
|
||||
)
|
||||
candidates.extend(scored_group)
|
||||
emit_observer(
|
||||
observer,
|
||||
"lattice_search_progress",
|
||||
completed_candidates=candidate_index,
|
||||
total_candidates=len(candidate_dims),
|
||||
target_width=int(target_width),
|
||||
target_height=int(target_height),
|
||||
best_score=None if not scored_group else float(max(candidate.score for candidate in scored_group)),
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: item.score, reverse=True)
|
||||
emit_observer(
|
||||
observer,
|
||||
"lattice_search_progress",
|
||||
completed_candidates=1,
|
||||
total_candidates=1,
|
||||
target_width=int(target_width),
|
||||
target_height=int(target_height),
|
||||
phase_sample_count=phase_sample_count,
|
||||
best_score=None if not candidates else float(candidates[0].score),
|
||||
)
|
||||
best = candidates[0]
|
||||
second = candidates[1] if len(candidates) > 1 else best
|
||||
size_candidates = _top_candidates_by_size(candidates, limit=len(candidates))
|
||||
reranked_candidates = _rerank_size_candidates_with_source_evidence(rgba, size_candidates)
|
||||
best = reranked_candidates[0]
|
||||
second = reranked_candidates[1] if len(reranked_candidates) > 1 else best
|
||||
confidence = max(0.0, best.score - second.score)
|
||||
return InferenceResult(
|
||||
target_width=best.target_width,
|
||||
target_height=best.target_height,
|
||||
phase_x=best.phase_x,
|
||||
phase_y=best.phase_y,
|
||||
confidence=confidence,
|
||||
top_candidates=candidates[:8],
|
||||
top_candidates=reranked_candidates[:8],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -720,26 +701,19 @@ def infer_fixed_lattice(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float | None = None,
|
||||
phase_y: float | None = None,
|
||||
device: str = "auto",
|
||||
) -> InferenceResult:
|
||||
torch, _ = _require_torch()
|
||||
resolved_device = _resolve_device(torch, device)
|
||||
prior_cell_x, prior_cell_y, prior_reliability = _estimate_lattice_prior_details(rgba)
|
||||
phase_values = np.linspace(-0.4, 0.4, num=5, dtype=np.float32)
|
||||
phase_x_values = np.asarray([phase_x], dtype=np.float32) if phase_x is not None else phase_values
|
||||
phase_y_values = np.asarray([phase_y], dtype=np.float32) if phase_y is not None else phase_values
|
||||
|
||||
candidates = _score_phase_group(
|
||||
candidates = _score_size_candidate(
|
||||
rgba,
|
||||
target_width=max(1, int(target_width)),
|
||||
target_height=max(1, int(target_height)),
|
||||
prior_cell_x=prior_cell_x,
|
||||
prior_cell_y=prior_cell_y,
|
||||
prior_reliability=prior_reliability,
|
||||
phase_x_values=phase_x_values,
|
||||
phase_y_values=phase_y_values,
|
||||
device=resolved_device,
|
||||
)
|
||||
candidates.sort(key=lambda item: item.score, reverse=True)
|
||||
|
|
@ -750,8 +724,6 @@ def infer_fixed_lattice(
|
|||
return InferenceResult(
|
||||
target_width=best.target_width,
|
||||
target_height=best.target_height,
|
||||
phase_x=best.phase_x,
|
||||
phase_y=best.phase_y,
|
||||
confidence=confidence,
|
||||
top_candidates=top_candidates,
|
||||
)
|
||||
|
|
@ -761,15 +733,11 @@ def inference_to_json(result: InferenceResult) -> dict[str, object]:
|
|||
return {
|
||||
"target_width": result.target_width,
|
||||
"target_height": result.target_height,
|
||||
"phase_x": result.phase_x,
|
||||
"phase_y": result.phase_y,
|
||||
"confidence": result.confidence,
|
||||
"top_candidates": [
|
||||
{
|
||||
"target_width": candidate.target_width,
|
||||
"target_height": candidate.target_height,
|
||||
"phase_x": candidate.phase_x,
|
||||
"phase_y": candidate.phase_y,
|
||||
"score": candidate.score,
|
||||
"breakdown": candidate.breakdown,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,16 +275,12 @@ def source_lattice_consistency_breakdown(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
alpha_threshold: float = 0.05,
|
||||
) -> dict[str, float]:
|
||||
reference = build_source_lattice_reference(
|
||||
source_rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
alpha_threshold=alpha_threshold,
|
||||
)
|
||||
cell_error = foreground_reconstruction_error(output_rgba, reference.mean_rgba, alpha_threshold=alpha_threshold, halo=0)
|
||||
|
|
@ -310,16 +306,12 @@ def source_lattice_evidence_breakdown(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
alpha_threshold: float = 0.05,
|
||||
) -> dict[str, float]:
|
||||
reference = build_source_lattice_reference(
|
||||
source_rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
alpha_threshold=alpha_threshold,
|
||||
)
|
||||
adjacency_strength = foreground_adjacency_strength(reference.sharp_rgba, alpha_threshold=alpha_threshold)
|
||||
|
|
@ -511,16 +503,12 @@ def lattice_source_rgba(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
alpha_threshold: float = 0.05,
|
||||
) -> tuple[np.ndarray, float]:
|
||||
reference = build_source_lattice_reference(
|
||||
source_rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
alpha_threshold=alpha_threshold,
|
||||
)
|
||||
return reference.mean_rgba, reference.dispersion
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -8,26 +11,92 @@ class SolverHyperParams:
|
|||
phase_field_patch_extent: float = 0.18
|
||||
phase_field_data_coherence_weight: float = 1.00
|
||||
phase_field_data_edge_weight: float = 0.35
|
||||
phase_field_smoothness_weight: float = 0.28
|
||||
phase_field_smoothness_weight: float = 0.0
|
||||
phase_field_grid_alignment_weight: float = 0.90
|
||||
phase_field_edge_gate_strength: float = 6.0
|
||||
phase_field_collapse_weight: float = 1.20
|
||||
phase_field_min_spacing_ratio: float = 0.18
|
||||
phase_field_magnitude_weight: float = 0.08
|
||||
phase_field_collapse_weight: float = 0.0
|
||||
phase_field_min_spacing_ratio: float = 0.0
|
||||
phase_field_magnitude_weight: float = 0.0
|
||||
phase_field_learning_rate: float = 0.10
|
||||
phase_field_max_displacement_ratio: float = 0.95
|
||||
phase_rerank_preview_steps: int = 8
|
||||
phase_field_max_displacement_ratio: float = 2.50
|
||||
phase_field_local_search_radius_ratio: float = 0.80
|
||||
phase_field_local_search_blend: float = 0.36
|
||||
phase_field_local_search_move_weight: float = 0.04
|
||||
phase_field_signal_weight: float = 1.00
|
||||
phase_field_signal_inner_radius_ratio: float = 0.24
|
||||
phase_field_signal_outer_radius_ratio: float = 0.56
|
||||
phase_field_signal_ring_contrast_weight: float = 1.00
|
||||
phase_field_signal_inner_variance_weight: float = 1.00
|
||||
phase_field_signal_center_edge_weight: float = 0.12
|
||||
phase_field_signal_edge_threshold: float = 0.16
|
||||
phase_field_signal_detail_radius_ratio: float = 0.80
|
||||
phase_field_signal_flatness_radius_ratio: float = 0.24
|
||||
phase_field_signal_distance_power: float = 0.70
|
||||
phase_field_signal_pyramid_levels: int = 5
|
||||
phase_field_signal_boundary_weight: float = 1.00
|
||||
phase_field_signal_curvature_weight: float = 0.65
|
||||
phase_field_signal_self_penalty: float = 3.00
|
||||
phase_field_signal_context_power: float = 0.70
|
||||
phase_field_signal_peak_power: float = 2.00
|
||||
phase_field_local_search_feature_weight: float = 0.0
|
||||
phase_field_local_coherence_small_radius_ratio: float = 0.24
|
||||
phase_field_local_coherence_medium_radius_ratio: float = 0.46
|
||||
phase_field_local_coherence_small_weight: float = 1.00
|
||||
phase_field_local_coherence_medium_weight: float = 0.0
|
||||
phase_field_local_cell_contrast_weight: float = 1.20
|
||||
phase_field_local_search_grid_weight: float = 0.0
|
||||
phase_field_local_search_interval: int = 1
|
||||
candidate_rerank_preview_steps: int = 8
|
||||
source_edge_detail_threshold: float = 0.25
|
||||
alpha_opaque_threshold: float = 0.95
|
||||
alpha_transparent_threshold: float = 0.05
|
||||
phase_rerank_support_weight: float = 0.45
|
||||
phase_rerank_edge_position_weight: float = 0.20
|
||||
phase_rerank_wobble_weight: float = 0.20
|
||||
phase_rerank_edge_concentration_weight: float = 0.10
|
||||
phase_rerank_size_penalty_weight: float = 0.18
|
||||
phase_rerank_inference_penalty_weight: float = 0.05
|
||||
phase_rerank_confidence_threshold: float = 0.12
|
||||
phase_rerank_max_size_delta_ratio: float = 0.40
|
||||
phase_rerank_margin: float = 0.004
|
||||
candidate_rerank_support_weight: float = 0.45
|
||||
candidate_rerank_edge_position_weight: float = 0.20
|
||||
candidate_rerank_wobble_weight: float = 0.20
|
||||
candidate_rerank_edge_concentration_weight: float = 0.10
|
||||
candidate_rerank_size_penalty_weight: float = 0.18
|
||||
candidate_rerank_inference_penalty_weight: float = 0.05
|
||||
candidate_rerank_confidence_threshold: float = 0.12
|
||||
candidate_rerank_max_size_delta_ratio: float = 0.40
|
||||
candidate_rerank_margin: float = 0.004
|
||||
|
||||
def to_dict(self) -> dict[str, float | int]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def default_solver_params_config_path() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "config" / "solver_params.json"
|
||||
|
||||
|
||||
def solver_params_config_path(path: str | Path | None = None) -> Path:
|
||||
if path is not None:
|
||||
return Path(path)
|
||||
env_path = os.environ.get("REPIXELIZER_SOLVER_PARAMS")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
return default_solver_params_config_path()
|
||||
|
||||
|
||||
def load_solver_params(path: str | Path | None = None) -> SolverHyperParams:
|
||||
config_path = solver_params_config_path(path)
|
||||
params = SolverHyperParams()
|
||||
if not config_path.exists():
|
||||
return params
|
||||
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Solver params config must contain a JSON object: {config_path}")
|
||||
|
||||
allowed = {field.name: field for field in fields(SolverHyperParams)}
|
||||
unknown = sorted(set(raw) - set(allowed))
|
||||
if unknown:
|
||||
names = ", ".join(unknown)
|
||||
raise ValueError(f"Unknown solver params in {config_path}: {names}")
|
||||
|
||||
values = params.to_dict()
|
||||
for key, value in raw.items():
|
||||
if isinstance(values[key], int):
|
||||
values[key] = int(value)
|
||||
else:
|
||||
values[key] = float(value)
|
||||
return SolverHyperParams(**values)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class _PhaseFieldPrep:
|
|||
cell_x: float
|
||||
cell_y: float
|
||||
patch_offsets_t: object
|
||||
feature_t: object
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
|
@ -53,13 +54,11 @@ def _make_regular_uv_px(
|
|||
width: int,
|
||||
target_height: int,
|
||||
target_width: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
) -> np.ndarray:
|
||||
cell_x = width / target_width
|
||||
cell_y = height / target_height
|
||||
xs = (np.arange(target_width, dtype=np.float32) + 0.5 + phase_x) * cell_x - 0.5
|
||||
ys = (np.arange(target_height, dtype=np.float32) + 0.5 + phase_y) * cell_y - 0.5
|
||||
xs = (np.arange(target_width, dtype=np.float32) + 0.5) * cell_x - 0.5
|
||||
ys = (np.arange(target_height, dtype=np.float32) + 0.5) * cell_y - 0.5
|
||||
xs = np.clip(xs, 0.0, max(0.0, width - 1))
|
||||
ys = np.clip(ys, 0.0, max(0.0, height - 1))
|
||||
grid_x, grid_y = np.meshgrid(xs, ys)
|
||||
|
|
@ -213,13 +212,30 @@ def _prepare_phase_field(
|
|||
cell_y = height / max(1, inference.target_height)
|
||||
source_t = torch.from_numpy(source.transpose(2, 0, 1)[None, ...]).to(device=device, dtype=torch.float32)
|
||||
edge_t = torch.from_numpy(analysis.edge_map[None, None, ...]).to(device=device, dtype=torch.float32)
|
||||
luma = (
|
||||
source[..., 0] * np.float32(0.2126)
|
||||
+ source[..., 1] * np.float32(0.7152)
|
||||
+ source[..., 2] * np.float32(0.0722)
|
||||
).astype(np.float32)
|
||||
padded_luma = np.pad(luma, 1, mode="edge")
|
||||
local_mean = (
|
||||
padded_luma[:-2, :-2]
|
||||
+ padded_luma[:-2, 1:-1]
|
||||
+ padded_luma[:-2, 2:]
|
||||
+ padded_luma[1:-1, :-2]
|
||||
+ padded_luma[1:-1, 1:-1]
|
||||
+ padded_luma[1:-1, 2:]
|
||||
+ padded_luma[2:, :-2]
|
||||
+ padded_luma[2:, 1:-1]
|
||||
+ padded_luma[2:, 2:]
|
||||
) / np.float32(9.0)
|
||||
feature = np.abs(luma - local_mean).astype(np.float32)
|
||||
feature_t = torch.from_numpy(feature[None, None, ...]).to(device=device, dtype=torch.float32)
|
||||
uv0_px = _make_regular_uv_px(
|
||||
height=height,
|
||||
width=width,
|
||||
target_height=inference.target_height,
|
||||
target_width=inference.target_width,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
)
|
||||
uv0_px_t = torch.from_numpy(uv0_px[None, ...]).to(device=device, dtype=torch.float32)
|
||||
base_x_t = uv0_px_t[..., 0]
|
||||
|
|
@ -259,6 +275,7 @@ def _prepare_phase_field(
|
|||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
patch_offsets_t=patch_offsets_t,
|
||||
feature_t=feature_t,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
|
@ -307,6 +324,16 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So
|
|||
delta_y = disp_norm[:, 1:, :, :] - disp_norm[:, :-1, :, :]
|
||||
smoothness = smoothness + (weight_y * torch.sqrt(delta_y.square().sum(dim=-1) + 1e-6)).mean()
|
||||
|
||||
grid_alignment = sampled_rgba.new_tensor(0.0)
|
||||
if pos_px.shape[2] > 1:
|
||||
step_x = (pos_px[:, :, 1:, 0] - pos_px[:, :, :-1, 0]) / max(prep.cell_x, 1e-4)
|
||||
row_y_delta = (pos_px[:, :, 1:, 1] - pos_px[:, :, :-1, 1]) / max(prep.cell_y, 1e-4)
|
||||
grid_alignment = grid_alignment + (step_x - 1.0).square().mean() + row_y_delta.square().mean()
|
||||
if pos_px.shape[1] > 1:
|
||||
step_y = (pos_px[:, 1:, :, 1] - pos_px[:, :-1, :, 1]) / max(prep.cell_y, 1e-4)
|
||||
col_x_delta = (pos_px[:, 1:, :, 0] - pos_px[:, :-1, :, 0]) / max(prep.cell_x, 1e-4)
|
||||
grid_alignment = grid_alignment + (step_y - 1.0).square().mean() + col_x_delta.square().mean()
|
||||
|
||||
collapse = sampled_rgba.new_tensor(0.0)
|
||||
min_dx = solver_params.phase_field_min_spacing_ratio * prep.cell_x
|
||||
min_dy = solver_params.phase_field_min_spacing_ratio * prep.cell_y
|
||||
|
|
@ -326,6 +353,7 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So
|
|||
local_coherence * solver_params.phase_field_data_coherence_weight
|
||||
+ local_edge * solver_params.phase_field_data_edge_weight
|
||||
+ smoothness * solver_params.phase_field_smoothness_weight
|
||||
+ grid_alignment * solver_params.phase_field_grid_alignment_weight
|
||||
+ collapse * solver_params.phase_field_collapse_weight
|
||||
+ magnitude * solver_params.phase_field_magnitude_weight
|
||||
)
|
||||
|
|
@ -333,12 +361,158 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So
|
|||
"local_coherence": local_coherence.detach(),
|
||||
"local_edge": local_edge.detach(),
|
||||
"smoothness": smoothness.detach(),
|
||||
"grid_alignment": grid_alignment.detach(),
|
||||
"collapse": collapse.detach(),
|
||||
"magnitude": magnitude.detach(),
|
||||
}
|
||||
return loss, sampled_rgba, terms
|
||||
|
||||
|
||||
def _local_candidate_displacement_step_in_place(
|
||||
torch,
|
||||
F,
|
||||
prep: _PhaseFieldPrep,
|
||||
disp_t,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
min_dx: float,
|
||||
min_dy: float,
|
||||
max_dx: float,
|
||||
max_dy: float,
|
||||
) -> None:
|
||||
radius_ratio = float(solver_params.phase_field_local_search_radius_ratio)
|
||||
blend = float(solver_params.phase_field_local_search_blend)
|
||||
if radius_ratio <= 0.0 or blend <= 0.0:
|
||||
return
|
||||
|
||||
with torch.no_grad():
|
||||
device = disp_t.device
|
||||
steps = torch.linspace(-1.0, 1.0, 5, device=device, dtype=disp_t.dtype)
|
||||
offset_y, offset_x = torch.meshgrid(steps, steps, indexing="ij")
|
||||
offsets_t = torch.stack(
|
||||
[
|
||||
offset_x.reshape(-1) * (radius_ratio * prep.cell_x),
|
||||
offset_y.reshape(-1) * (radius_ratio * prep.cell_y),
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
|
||||
pos_px = prep.uv0_px_t + disp_t
|
||||
candidate_pos = pos_px[:, :, :, None, :] + offsets_t[None, None, None, :, :]
|
||||
candidate_pos = candidate_pos.clone()
|
||||
candidate_pos[..., 0] = candidate_pos[..., 0].clamp(0.0, max(0.0, float(prep.width - 1)))
|
||||
candidate_pos[..., 1] = candidate_pos[..., 1].clamp(0.0, max(0.0, float(prep.height - 1)))
|
||||
|
||||
batch, out_h, out_w, candidates, _ = candidate_pos.shape
|
||||
flat_pos = candidate_pos.reshape(batch, out_h, out_w * candidates, 2)
|
||||
patch_rgba = _sample_patch_rgba(
|
||||
F,
|
||||
prep.source_t,
|
||||
flat_pos,
|
||||
prep.patch_offsets_t,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
).reshape(batch, out_h, out_w, candidates, -1, 4)
|
||||
patch_edge = _sample_patch_scalar(
|
||||
F,
|
||||
prep.edge_t,
|
||||
flat_pos,
|
||||
prep.patch_offsets_t,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
).reshape(batch, out_h, out_w, candidates, -1)
|
||||
candidate_feature = _sample_scalar(
|
||||
F,
|
||||
prep.feature_t,
|
||||
flat_pos,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
).reshape(batch, out_h, out_w, candidates)
|
||||
|
||||
center_rgba = patch_rgba[..., 0, :]
|
||||
neighbor_rgba = patch_rgba[..., 1:, :]
|
||||
coherence_score = (neighbor_rgba - center_rgba[..., None, :]).abs().mean(dim=(-1, -2))
|
||||
edge_score = patch_edge.mean(dim=-1)
|
||||
luma_weights = torch.tensor([0.2126, 0.7152, 0.0722], device=device, dtype=disp_t.dtype)
|
||||
center_luma = (center_rgba[..., :3] * luma_weights).sum(dim=-1)
|
||||
local_luma_mean = center_luma.mean(dim=-1, keepdim=True)
|
||||
feature_score = (center_luma - local_luma_mean).abs() + candidate_feature
|
||||
current_pos = pos_px.detach()
|
||||
grid_score = torch.zeros_like(coherence_score)
|
||||
grid_neighbors = torch.zeros_like(coherence_score)
|
||||
if out_w > 1:
|
||||
left_x = torch.empty_like(current_pos[..., 0])
|
||||
left_y = torch.empty_like(current_pos[..., 1])
|
||||
right_x = torch.empty_like(current_pos[..., 0])
|
||||
right_y = torch.empty_like(current_pos[..., 1])
|
||||
left_x[:, :, 0] = current_pos[:, :, 0, 0] - prep.cell_x
|
||||
left_x[:, :, 1:] = current_pos[:, :, :-1, 0]
|
||||
left_y[:, :, 0] = current_pos[:, :, 0, 1]
|
||||
left_y[:, :, 1:] = current_pos[:, :, :-1, 1]
|
||||
right_x[:, :, -1] = current_pos[:, :, -1, 0] + prep.cell_x
|
||||
right_x[:, :, :-1] = current_pos[:, :, 1:, 0]
|
||||
right_y[:, :, -1] = current_pos[:, :, -1, 1]
|
||||
right_y[:, :, :-1] = current_pos[:, :, 1:, 1]
|
||||
candidate_x = candidate_pos[..., 0]
|
||||
candidate_y = candidate_pos[..., 1]
|
||||
grid_score = grid_score + ((candidate_x - left_x[..., None] - prep.cell_x) / max(prep.cell_x, 1e-4)).square()
|
||||
grid_score = grid_score + ((right_x[..., None] - candidate_x - prep.cell_x) / max(prep.cell_x, 1e-4)).square()
|
||||
grid_score = grid_score + ((candidate_y - left_y[..., None]) / max(prep.cell_y, 1e-4)).square()
|
||||
grid_score = grid_score + ((candidate_y - right_y[..., None]) / max(prep.cell_y, 1e-4)).square()
|
||||
grid_neighbors = grid_neighbors + 4.0
|
||||
if out_h > 1:
|
||||
up_x = torch.empty_like(current_pos[..., 0])
|
||||
up_y = torch.empty_like(current_pos[..., 1])
|
||||
down_x = torch.empty_like(current_pos[..., 0])
|
||||
down_y = torch.empty_like(current_pos[..., 1])
|
||||
up_x[:, 0, :] = current_pos[:, 0, :, 0]
|
||||
up_x[:, 1:, :] = current_pos[:, :-1, :, 0]
|
||||
up_y[:, 0, :] = current_pos[:, 0, :, 1] - prep.cell_y
|
||||
up_y[:, 1:, :] = current_pos[:, :-1, :, 1]
|
||||
down_x[:, -1, :] = current_pos[:, -1, :, 0]
|
||||
down_x[:, :-1, :] = current_pos[:, 1:, :, 0]
|
||||
down_y[:, -1, :] = current_pos[:, -1, :, 1] + prep.cell_y
|
||||
down_y[:, :-1, :] = current_pos[:, 1:, :, 1]
|
||||
candidate_x = candidate_pos[..., 0]
|
||||
candidate_y = candidate_pos[..., 1]
|
||||
grid_score = grid_score + ((candidate_x - up_x[..., None]) / max(prep.cell_x, 1e-4)).square()
|
||||
grid_score = grid_score + ((candidate_x - down_x[..., None]) / max(prep.cell_x, 1e-4)).square()
|
||||
grid_score = grid_score + ((candidate_y - up_y[..., None] - prep.cell_y) / max(prep.cell_y, 1e-4)).square()
|
||||
grid_score = grid_score + ((down_y[..., None] - candidate_y - prep.cell_y) / max(prep.cell_y, 1e-4)).square()
|
||||
grid_neighbors = grid_neighbors + 4.0
|
||||
grid_score = grid_score / grid_neighbors.clamp_min(1.0)
|
||||
move_score = (
|
||||
(offsets_t[:, 0] / max(prep.cell_x, 1e-4)).square()
|
||||
+ (offsets_t[:, 1] / max(prep.cell_y, 1e-4)).square()
|
||||
)
|
||||
score = (
|
||||
coherence_score * solver_params.phase_field_data_coherence_weight
|
||||
+ edge_score * solver_params.phase_field_data_edge_weight
|
||||
+ move_score[None, None, None, :] * solver_params.phase_field_local_search_move_weight
|
||||
+ grid_score * solver_params.phase_field_local_search_grid_weight
|
||||
- feature_score * solver_params.phase_field_local_search_feature_weight
|
||||
)
|
||||
best_index = torch.argmin(score, dim=-1)
|
||||
gather_index = best_index[..., None, None].expand(batch, out_h, out_w, 1, 2)
|
||||
best_pos = torch.gather(candidate_pos, dim=3, index=gather_index).squeeze(3)
|
||||
target_disp = best_pos - prep.uv0_px_t
|
||||
clamped_blend = max(0.0, min(1.0, blend))
|
||||
disp_t.mul_(1.0 - clamped_blend).add_(target_disp * clamped_blend)
|
||||
|
||||
_project_displacements_in_place(
|
||||
torch,
|
||||
disp_t,
|
||||
prep.base_x_t,
|
||||
prep.base_y_t,
|
||||
min_dx=min_dx,
|
||||
min_dy=min_dy,
|
||||
max_dx=max_dx,
|
||||
max_dy=max_dy,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
)
|
||||
|
||||
|
||||
def _nearest_source_rgba(source_rgba: np.ndarray, sample_x: np.ndarray, sample_y: np.ndarray) -> np.ndarray:
|
||||
return source_rgba[sample_y, sample_x]
|
||||
|
||||
|
|
@ -391,6 +565,356 @@ def _materialize_loss_history(torch, values: list[object]) -> list[float]:
|
|||
return [float(item) for item in stacked.cpu().tolist()]
|
||||
|
||||
|
||||
def _sample_bilinear_np(field: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
||||
height, width = field.shape[:2]
|
||||
x = np.clip(x, 0.0, max(0.0, float(width - 1)))
|
||||
y = np.clip(y, 0.0, max(0.0, float(height - 1)))
|
||||
x0 = np.floor(x).astype(np.int32)
|
||||
y0 = np.floor(y).astype(np.int32)
|
||||
x1 = np.clip(x0 + 1, 0, width - 1)
|
||||
y1 = np.clip(y0 + 1, 0, height - 1)
|
||||
wx = (x - x0).astype(np.float32)
|
||||
wy = (y - y0).astype(np.float32)
|
||||
top = field[y0, x0] * (1.0 - wx) + field[y0, x1] * wx
|
||||
bottom = field[y1, x0] * (1.0 - wx) + field[y1, x1] * wx
|
||||
return top * (1.0 - wy) + bottom * wy
|
||||
|
||||
|
||||
def _resize_scalar_bilinear_np(field: np.ndarray, width: int, height: int) -> np.ndarray:
|
||||
src_h, src_w = field.shape[:2]
|
||||
xs = (np.arange(width, dtype=np.float32) + 0.5) * (src_w / max(1, width)) - 0.5
|
||||
ys = (np.arange(height, dtype=np.float32) + 0.5) * (src_h / max(1, height)) - 0.5
|
||||
grid_x, grid_y = np.meshgrid(xs, ys)
|
||||
return _sample_bilinear_np(field, grid_x, grid_y).astype(np.float32)
|
||||
|
||||
|
||||
def _feature_map_from_rgba(rgba: np.ndarray) -> np.ndarray:
|
||||
source = premultiply(rgba)
|
||||
luma = (
|
||||
source[..., 0] * np.float32(0.2126)
|
||||
+ source[..., 1] * np.float32(0.7152)
|
||||
+ source[..., 2] * np.float32(0.0722)
|
||||
).astype(np.float32)
|
||||
padded = np.pad(luma, 1, mode="edge")
|
||||
local_mean = (
|
||||
padded[:-2, :-2]
|
||||
+ padded[:-2, 1:-1]
|
||||
+ padded[:-2, 2:]
|
||||
+ padded[1:-1, :-2]
|
||||
+ padded[1:-1, 1:-1]
|
||||
+ padded[1:-1, 2:]
|
||||
+ padded[2:, :-2]
|
||||
+ padded[2:, 1:-1]
|
||||
+ padded[2:, 2:]
|
||||
) / np.float32(9.0)
|
||||
return np.abs(luma - local_mean).astype(np.float32)
|
||||
|
||||
|
||||
def _box_mean_np(field: np.ndarray, radius: int) -> np.ndarray:
|
||||
if radius <= 0:
|
||||
return field.astype(np.float32, copy=True)
|
||||
height, width = field.shape[:2]
|
||||
padded = np.pad(field.astype(np.float32), radius, mode="edge")
|
||||
integral = np.pad(
|
||||
padded,
|
||||
((1, 0), (1, 0)),
|
||||
mode="constant",
|
||||
constant_values=0.0,
|
||||
).cumsum(axis=0).cumsum(axis=1)
|
||||
size = radius * 2 + 1
|
||||
total = (
|
||||
integral[size : size + height, size : size + width]
|
||||
- integral[:height, size : size + width]
|
||||
- integral[size : size + height, :width]
|
||||
+ integral[:height, :width]
|
||||
)
|
||||
return (total / np.float32(size * size)).astype(np.float32)
|
||||
|
||||
|
||||
def _local_color_coherence_map_from_rgba(
|
||||
rgba: np.ndarray,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
radius_ratio: float,
|
||||
) -> np.ndarray:
|
||||
radius = max(1, int(round(min(cell_x, cell_y) * max(0.0, float(radius_ratio)))))
|
||||
source = premultiply(rgba).astype(np.float32)
|
||||
variance = np.zeros(source.shape[:2], dtype=np.float32)
|
||||
for channel in range(3):
|
||||
values = source[..., channel]
|
||||
mean = _box_mean_np(values, radius)
|
||||
mean_square = _box_mean_np(values * values, radius)
|
||||
variance += np.maximum(0.0, mean_square - mean * mean)
|
||||
return np.sqrt(variance / np.float32(3.0)).astype(np.float32)
|
||||
|
||||
|
||||
def _local_luma_mean_map_from_rgba(
|
||||
rgba: np.ndarray,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
radius_ratio: float,
|
||||
) -> np.ndarray:
|
||||
radius = max(1, int(round(min(cell_x, cell_y) * max(0.0, float(radius_ratio)))))
|
||||
source = premultiply(rgba).astype(np.float32)
|
||||
luma = (
|
||||
source[..., 0] * np.float32(0.2126)
|
||||
+ source[..., 1] * np.float32(0.7152)
|
||||
+ source[..., 2] * np.float32(0.0722)
|
||||
).astype(np.float32)
|
||||
return _box_mean_np(luma, radius)
|
||||
|
||||
|
||||
def _downsample_half_np(field: np.ndarray) -> np.ndarray:
|
||||
height, width = field.shape[:2]
|
||||
padded = np.pad(field.astype(np.float32), ((0, height % 2), (0, width % 2)), mode="edge")
|
||||
return (
|
||||
padded[0::2, 0::2]
|
||||
+ padded[1::2, 0::2]
|
||||
+ padded[0::2, 1::2]
|
||||
+ padded[1::2, 1::2]
|
||||
) * np.float32(0.25)
|
||||
|
||||
|
||||
def _resize_scalar_to_shape_np(field: np.ndarray, shape: tuple[int, int]) -> np.ndarray:
|
||||
height, width = shape
|
||||
src_h, src_w = field.shape[:2]
|
||||
xs = (np.arange(width, dtype=np.float32) + 0.5) * (src_w / max(1, width)) - 0.5
|
||||
ys = (np.arange(height, dtype=np.float32) + 0.5) * (src_h / max(1, height)) - 0.5
|
||||
grid_x, grid_y = np.meshgrid(xs, ys)
|
||||
return _sample_bilinear_np(field, grid_x, grid_y).astype(np.float32)
|
||||
|
||||
|
||||
def _gradient_magnitude_np(field: np.ndarray) -> np.ndarray:
|
||||
padded = np.pad(field.astype(np.float32), 1, mode="edge")
|
||||
dx = (padded[1:-1, 2:] - padded[1:-1, :-2]) * np.float32(0.5)
|
||||
dy = (padded[2:, 1:-1] - padded[:-2, 1:-1]) * np.float32(0.5)
|
||||
return np.sqrt(dx * dx + dy * dy).astype(np.float32)
|
||||
|
||||
|
||||
def _laplacian_abs_np(field: np.ndarray) -> np.ndarray:
|
||||
padded = np.pad(field.astype(np.float32), 1, mode="edge")
|
||||
laplacian = (
|
||||
padded[1:-1, :-2]
|
||||
+ padded[1:-1, 2:]
|
||||
+ padded[:-2, 1:-1]
|
||||
+ padded[2:, 1:-1]
|
||||
- padded[1:-1, 1:-1] * np.float32(4.0)
|
||||
)
|
||||
return np.abs(laplacian).astype(np.float32)
|
||||
|
||||
|
||||
def _normalize_percentile_np(values: np.ndarray, *, low_percentile: float = 5.0, high_percentile: float = 99.0) -> np.ndarray:
|
||||
low = float(np.percentile(values, low_percentile))
|
||||
high = float(np.percentile(values, high_percentile))
|
||||
if high <= low + 1e-6:
|
||||
return np.zeros_like(values, dtype=np.float32)
|
||||
return np.clip((values - np.float32(low)) / np.float32(high - low), 0.0, 1.0).astype(np.float32)
|
||||
|
||||
|
||||
def _source_signal_map_np(
|
||||
rgba: np.ndarray,
|
||||
edge_map: np.ndarray,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
) -> np.ndarray:
|
||||
source = premultiply(rgba).astype(np.float32)
|
||||
luma = (
|
||||
source[..., 0] * np.float32(0.2126)
|
||||
+ source[..., 1] * np.float32(0.7152)
|
||||
+ source[..., 2] * np.float32(0.0722)
|
||||
).astype(np.float32)
|
||||
full_shape = luma.shape
|
||||
point_energy = np.zeros(full_shape, dtype=np.float32)
|
||||
context_energy = np.zeros(full_shape, dtype=np.float32)
|
||||
level_field = luma
|
||||
levels = max(1, int(solver_params.phase_field_signal_pyramid_levels))
|
||||
for level in range(levels):
|
||||
gradient = _gradient_magnitude_np(level_field)
|
||||
curvature = _laplacian_abs_np(level_field)
|
||||
boundary = (
|
||||
gradient * np.float32(solver_params.phase_field_signal_boundary_weight)
|
||||
+ curvature * np.float32(solver_params.phase_field_signal_curvature_weight)
|
||||
).astype(np.float32)
|
||||
boundary = _normalize_percentile_np(boundary, low_percentile=10.0, high_percentile=98.0)
|
||||
context_radius = max(1, int(round(1.5 + level)))
|
||||
context = _box_mean_np(boundary, context_radius)
|
||||
level_weight = np.float32(1.0 / (1 << level))
|
||||
point_energy += _resize_scalar_to_shape_np(boundary, full_shape) * level_weight
|
||||
context_energy += _resize_scalar_to_shape_np(context, full_shape) * level_weight
|
||||
if min(level_field.shape[:2]) <= 8:
|
||||
break
|
||||
level_field = _downsample_half_np(level_field)
|
||||
|
||||
point_energy = _normalize_percentile_np(point_energy, low_percentile=5.0, high_percentile=99.0)
|
||||
context_energy = _normalize_percentile_np(context_energy, low_percentile=5.0, high_percentile=99.0)
|
||||
quiet_here = np.exp(-point_energy * np.float32(solver_params.phase_field_signal_self_penalty)).astype(np.float32)
|
||||
context = np.power(context_energy, np.float32(solver_params.phase_field_signal_context_power)).astype(np.float32)
|
||||
signal = _normalize_percentile_np(quiet_here * context, low_percentile=5.0, high_percentile=99.0)
|
||||
return np.power(signal, np.float32(solver_params.phase_field_signal_peak_power)).astype(np.float32)
|
||||
|
||||
|
||||
def _candidate_grid_score_np(
|
||||
pos: np.ndarray,
|
||||
candidate_x: np.ndarray,
|
||||
candidate_y: np.ndarray,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
) -> np.ndarray:
|
||||
pos_x = pos[..., 0]
|
||||
pos_y = pos[..., 1]
|
||||
score = np.zeros_like(candidate_x, dtype=np.float32)
|
||||
neighbors = np.zeros_like(candidate_x, dtype=np.float32)
|
||||
|
||||
if pos.shape[1] > 1:
|
||||
left_x = np.empty_like(pos_x)
|
||||
right_x = np.empty_like(pos_x)
|
||||
left_y = np.empty_like(pos_y)
|
||||
right_y = np.empty_like(pos_y)
|
||||
left_x[:, 0] = pos_x[:, 0] - cell_x
|
||||
left_x[:, 1:] = pos_x[:, :-1]
|
||||
right_x[:, -1] = pos_x[:, -1] + cell_x
|
||||
right_x[:, :-1] = pos_x[:, 1:]
|
||||
left_y[:, 0] = pos_y[:, 0]
|
||||
left_y[:, 1:] = pos_y[:, :-1]
|
||||
right_y[:, -1] = pos_y[:, -1]
|
||||
right_y[:, :-1] = pos_y[:, 1:]
|
||||
score += np.square((candidate_x - left_x - cell_x) / max(cell_x, 1e-4))
|
||||
score += np.square((right_x - candidate_x - cell_x) / max(cell_x, 1e-4))
|
||||
score += np.square((candidate_y - left_y) / max(cell_y, 1e-4))
|
||||
score += np.square((candidate_y - right_y) / max(cell_y, 1e-4))
|
||||
neighbors += 4.0
|
||||
|
||||
if pos.shape[0] > 1:
|
||||
up_x = np.empty_like(pos_x)
|
||||
down_x = np.empty_like(pos_x)
|
||||
up_y = np.empty_like(pos_y)
|
||||
down_y = np.empty_like(pos_y)
|
||||
up_x[0, :] = pos_x[0, :]
|
||||
up_x[1:, :] = pos_x[:-1, :]
|
||||
down_x[-1, :] = pos_x[-1, :]
|
||||
down_x[:-1, :] = pos_x[1:, :]
|
||||
up_y[0, :] = pos_y[0, :] - cell_y
|
||||
up_y[1:, :] = pos_y[:-1, :]
|
||||
down_y[-1, :] = pos_y[-1, :] + cell_y
|
||||
down_y[:-1, :] = pos_y[1:, :]
|
||||
score += np.square((candidate_x - up_x) / max(cell_x, 1e-4))
|
||||
score += np.square((candidate_x - down_x) / max(cell_x, 1e-4))
|
||||
score += np.square((candidate_y - up_y - cell_y) / max(cell_y, 1e-4))
|
||||
score += np.square((down_y - candidate_y - cell_y) / max(cell_y, 1e-4))
|
||||
neighbors += 4.0
|
||||
|
||||
return score / np.maximum(neighbors, 1.0)
|
||||
|
||||
|
||||
def _choose_local_preferred_positions_np(
|
||||
pos: np.ndarray,
|
||||
signal_map: np.ndarray,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> np.ndarray:
|
||||
radius_x = float(solver_params.phase_field_local_search_radius_ratio) * cell_x
|
||||
radius_y = float(solver_params.phase_field_local_search_radius_ratio) * cell_y
|
||||
if radius_x <= 0.0 or radius_y <= 0.0:
|
||||
return pos.copy()
|
||||
|
||||
offsets = np.linspace(-1.0, 1.0, 5, dtype=np.float32)
|
||||
best_score = np.full(pos.shape[:2], np.inf, dtype=np.float32)
|
||||
best = pos.copy()
|
||||
for oy in offsets:
|
||||
for ox in offsets:
|
||||
candidate_x = np.clip(pos[..., 0] + ox * radius_x, 0.0, max(0.0, float(width - 1)))
|
||||
candidate_y = np.clip(pos[..., 1] + oy * radius_y, 0.0, max(0.0, float(height - 1)))
|
||||
signal_score = _sample_bilinear_np(signal_map, candidate_x, candidate_y)
|
||||
move_score = np.float32(ox * ox + oy * oy) * np.float32(
|
||||
solver_params.phase_field_local_search_radius_ratio
|
||||
* solver_params.phase_field_local_search_radius_ratio
|
||||
)
|
||||
grid_score = _candidate_grid_score_np(pos, candidate_x, candidate_y, cell_x=cell_x, cell_y=cell_y)
|
||||
score = (
|
||||
move_score * np.float32(solver_params.phase_field_local_search_move_weight)
|
||||
+ grid_score * np.float32(solver_params.phase_field_local_search_grid_weight)
|
||||
- signal_score * np.float32(solver_params.phase_field_signal_weight)
|
||||
)
|
||||
improved = score < best_score
|
||||
best_score = np.where(improved, score, best_score)
|
||||
best[..., 0] = np.where(improved, candidate_x, best[..., 0])
|
||||
best[..., 1] = np.where(improved, candidate_y, best[..., 1])
|
||||
return best.astype(np.float32)
|
||||
|
||||
|
||||
def _relax_lattice_springs_np(
|
||||
pos: np.ndarray,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> np.ndarray:
|
||||
out = pos.copy()
|
||||
spring_step = min(0.22, max(0.0, float(solver_params.phase_field_grid_alignment_weight)) * 0.06)
|
||||
if spring_step <= 0.0:
|
||||
return out
|
||||
for _ in range(3):
|
||||
force = np.zeros_like(out, dtype=np.float32)
|
||||
if out.shape[1] > 1:
|
||||
horizontal_error = out[:, 1:, :] - out[:, :-1, :]
|
||||
horizontal_error[..., 0] -= cell_x
|
||||
force[:, :-1, :] += horizontal_error
|
||||
force[:, 1:, :] -= horizontal_error
|
||||
if out.shape[0] > 1:
|
||||
vertical_error = out[1:, :, :] - out[:-1, :, :]
|
||||
vertical_error[..., 1] -= cell_y
|
||||
force[:-1, :, :] += vertical_error
|
||||
force[1:, :, :] -= vertical_error
|
||||
out += force * np.float32(spring_step)
|
||||
out[..., 0] = np.clip(out[..., 0], 0.0, max(0.0, float(width - 1)))
|
||||
out[..., 1] = np.clip(out[..., 1], 0.0, max(0.0, float(height - 1)))
|
||||
return out
|
||||
|
||||
|
||||
def _explicit_solver_terms_np(
|
||||
pos: np.ndarray,
|
||||
signal_map: np.ndarray,
|
||||
solver_params: SolverHyperParams,
|
||||
*,
|
||||
cell_x: float,
|
||||
cell_y: float,
|
||||
) -> dict[str, float]:
|
||||
signal = _sample_bilinear_np(signal_map, pos[..., 0], pos[..., 1])
|
||||
grid_alignment = 0.0
|
||||
if pos.shape[1] > 1:
|
||||
step_x = (pos[:, 1:, 0] - pos[:, :-1, 0]) / max(cell_x, 1e-4)
|
||||
row_y_delta = (pos[:, 1:, 1] - pos[:, :-1, 1]) / max(cell_y, 1e-4)
|
||||
grid_alignment += float(np.mean(np.square(step_x - 1.0)) + np.mean(np.square(row_y_delta)))
|
||||
if pos.shape[0] > 1:
|
||||
step_y = (pos[1:, :, 1] - pos[:-1, :, 1]) / max(cell_y, 1e-4)
|
||||
col_x_delta = (pos[1:, :, 0] - pos[:-1, :, 0]) / max(cell_x, 1e-4)
|
||||
grid_alignment += float(np.mean(np.square(step_y - 1.0)) + np.mean(np.square(col_x_delta)))
|
||||
local_signal = float(np.mean(signal))
|
||||
loss = (
|
||||
grid_alignment * float(solver_params.phase_field_grid_alignment_weight)
|
||||
- local_signal * float(solver_params.phase_field_signal_weight)
|
||||
)
|
||||
return {
|
||||
"local_signal": local_signal,
|
||||
"smoothness": 0.0,
|
||||
"grid_alignment": grid_alignment,
|
||||
"collapse": 0.0,
|
||||
"magnitude": 0.0,
|
||||
"explicit_loss": float(loss),
|
||||
}
|
||||
|
||||
|
||||
def _observer_option(observer: PipelineObserver | None, name: str, default: object) -> object:
|
||||
return observer_attribute(observer, name, default)
|
||||
|
||||
|
|
@ -427,121 +951,151 @@ def optimize_phase_field(
|
|||
solver_params: SolverHyperParams | None = None,
|
||||
observer: PipelineObserver | None = None,
|
||||
) -> SolverArtifacts:
|
||||
torch, F = _require_torch()
|
||||
device = _resolve_device(torch, device)
|
||||
solver_params = solver_params or SolverHyperParams()
|
||||
torch.manual_seed(seed)
|
||||
if device == "cuda":
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
preview_stride = _observer_preview_stride(observer)
|
||||
include_snapshots = _observer_needs_phase_field_snapshot(observer)
|
||||
|
||||
prep = _prepare_phase_field(
|
||||
torch,
|
||||
F,
|
||||
height, width = rgba.shape[:2]
|
||||
cell_x = width / max(1, inference.target_width)
|
||||
cell_y = height / max(1, inference.target_height)
|
||||
uv0_px = _make_regular_uv_px(
|
||||
height=height,
|
||||
width=width,
|
||||
target_height=inference.target_height,
|
||||
target_width=inference.target_width,
|
||||
)
|
||||
uv0_norm = uv0_px.copy()
|
||||
uv0_norm[..., 0] = (uv0_norm[..., 0] / max(1.0, float(width - 1))) * 2.0 - 1.0
|
||||
uv0_norm[..., 1] = (uv0_norm[..., 1] / max(1.0, float(height - 1))) * 2.0 - 1.0
|
||||
signal_map = _source_signal_map_np(
|
||||
rgba,
|
||||
inference,
|
||||
analysis,
|
||||
analysis.edge_map,
|
||||
solver_params,
|
||||
device=device,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
)
|
||||
guidance = signal_map
|
||||
prep = _PhaseFieldPrep(
|
||||
source_t=None,
|
||||
edge_t=None,
|
||||
uv0_px_t=None,
|
||||
uv0_norm=uv0_norm,
|
||||
base_x_t=None,
|
||||
base_y_t=None,
|
||||
guidance=guidance,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
patch_offsets_t=None,
|
||||
feature_t=None,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
prepared_payload = {
|
||||
"cell_x": float(prep.cell_x),
|
||||
"cell_y": float(prep.cell_y),
|
||||
"cell_x": float(cell_x),
|
||||
"cell_y": float(cell_y),
|
||||
"target_width": int(inference.target_width),
|
||||
"target_height": int(inference.target_height),
|
||||
}
|
||||
if include_snapshots:
|
||||
prepared_payload["uv0_px"] = prep.uv0_px_t[0].detach().cpu().numpy().astype(np.float32)
|
||||
prepared_payload["guidance"] = prep.guidance.copy()
|
||||
prepared_payload["uv0_px"] = uv0_px.astype(np.float32)
|
||||
prepared_payload["guidance"] = guidance.copy()
|
||||
emit_observer(observer, "phase_field_prepared", **prepared_payload)
|
||||
|
||||
disp_t = torch.zeros_like(prep.uv0_px_t, device=device, dtype=torch.float32, requires_grad=True)
|
||||
optimizer = torch.optim.Adam([disp_t], lr=solver_params.phase_field_learning_rate)
|
||||
|
||||
initial_x = np.rint(prep.uv0_px_t[0, ..., 0].detach().cpu().numpy()).astype(np.int32).clip(0, prep.width - 1)
|
||||
initial_y = np.rint(prep.uv0_px_t[0, ..., 1].detach().cpu().numpy()).astype(np.int32).clip(0, prep.height - 1)
|
||||
pos = uv0_px.astype(np.float32).copy()
|
||||
initial_x = np.rint(pos[..., 0]).astype(np.int32).clip(0, width - 1)
|
||||
initial_y = np.rint(pos[..., 1]).astype(np.int32).clip(0, height - 1)
|
||||
initial_rgba = _nearest_source_rgba(rgba, initial_x, initial_y)
|
||||
initial_payload = {
|
||||
"step": 0,
|
||||
"total_steps": int(max(0, steps)),
|
||||
}
|
||||
if include_snapshots:
|
||||
initial_payload.update(_observer_snapshot(prep, rgba, prep.uv0_px_t[0].detach().cpu().numpy().astype(np.float32)))
|
||||
initial_payload.update(_observer_snapshot(prep, rgba, pos.astype(np.float32)))
|
||||
emit_observer(observer, "phase_field_initial", **initial_payload)
|
||||
|
||||
loss_history_raw: list[object] = []
|
||||
final_terms_raw: dict[str, object] = {}
|
||||
min_dx = solver_params.phase_field_min_spacing_ratio * prep.cell_x
|
||||
min_dy = solver_params.phase_field_min_spacing_ratio * prep.cell_y
|
||||
max_dx = solver_params.phase_field_max_displacement_ratio * prep.cell_x
|
||||
max_dy = solver_params.phase_field_max_displacement_ratio * prep.cell_y
|
||||
rng = np.random.default_rng(seed)
|
||||
pos += rng.normal(0.0, 0.015, size=pos.shape).astype(np.float32) * np.float32(min(cell_x, cell_y))
|
||||
pos[..., 0] = np.clip(pos[..., 0], 0.0, max(0.0, float(width - 1)))
|
||||
pos[..., 1] = np.clip(pos[..., 1], 0.0, max(0.0, float(height - 1)))
|
||||
|
||||
loss_history: list[float] = []
|
||||
final_terms: dict[str, float] = _explicit_solver_terms_np(
|
||||
pos,
|
||||
signal_map,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
)
|
||||
local_search_interval = max(1, int(solver_params.phase_field_local_search_interval))
|
||||
local_blend = max(0.0, min(1.0, float(solver_params.phase_field_local_search_blend)))
|
||||
|
||||
for step_index in range(max(0, steps)):
|
||||
check_observer_cancelled(observer)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss, _sampled_rgba, terms = _phase_field_loss(torch, F, prep, disp_t, solver_params)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
_project_displacements_in_place(
|
||||
torch,
|
||||
disp_t,
|
||||
prep.base_x_t,
|
||||
prep.base_y_t,
|
||||
min_dx=min_dx,
|
||||
min_dy=min_dy,
|
||||
max_dx=max_dx,
|
||||
max_dy=max_dy,
|
||||
if step_index % local_search_interval == 0:
|
||||
preferred = _choose_local_preferred_positions_np(
|
||||
pos,
|
||||
signal_map,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
pos = (pos * np.float32(1.0 - local_blend) + preferred * np.float32(local_blend)).astype(np.float32)
|
||||
pos = _relax_lattice_springs_np(
|
||||
pos,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
)
|
||||
loss_detached = loss.detach()
|
||||
loss_history_raw.append(loss_detached)
|
||||
final_terms_raw = terms
|
||||
final_terms = _explicit_solver_terms_np(
|
||||
pos,
|
||||
signal_map,
|
||||
solver_params,
|
||||
cell_x=cell_x,
|
||||
cell_y=cell_y,
|
||||
)
|
||||
loss_history.append(float(final_terms["explicit_loss"]))
|
||||
step_number = int(step_index + 1)
|
||||
if observer is not None and _should_emit_phase_field_step(step_number, int(max(0, steps)), preview_stride=preview_stride):
|
||||
payload = {
|
||||
"step": step_number,
|
||||
"total_steps": int(max(0, steps)),
|
||||
"loss": float(loss_detached.cpu().item()),
|
||||
"terms": _materialize_phase_terms(terms),
|
||||
"loss": float(final_terms["explicit_loss"]),
|
||||
"terms": final_terms.copy(),
|
||||
}
|
||||
if include_snapshots:
|
||||
current_pos = (prep.uv0_px_t + disp_t)[0].detach().cpu().numpy().astype(np.float32)
|
||||
payload.update(_observer_snapshot(prep, rgba, current_pos))
|
||||
payload.update(_observer_snapshot(prep, rgba, pos.astype(np.float32)))
|
||||
emit_observer(observer, "phase_field_step", **payload)
|
||||
|
||||
with torch.no_grad():
|
||||
check_observer_cancelled(observer)
|
||||
if steps <= 0:
|
||||
loss, _sampled_rgba, final_terms_raw = _phase_field_loss(torch, F, prep, disp_t, solver_params)
|
||||
loss_history_raw.append(loss.detach())
|
||||
final_px = prep.uv0_px_t + disp_t
|
||||
final_x = torch.round(final_px[..., 0]).clamp(0, prep.width - 1).to(dtype=torch.long)
|
||||
final_y = torch.round(final_px[..., 1]).clamp(0, prep.height - 1).to(dtype=torch.long)
|
||||
final_pos_norm = _pixel_to_normalized(final_px.clone(), width=prep.width, height=prep.height)[0].detach().cpu().numpy()
|
||||
check_observer_cancelled(observer)
|
||||
if steps <= 0:
|
||||
loss_history.append(float(final_terms["explicit_loss"]))
|
||||
|
||||
loss_history = _materialize_loss_history(torch, loss_history_raw)
|
||||
final_terms = _materialize_phase_terms(final_terms_raw)
|
||||
final_x_np = final_x[0].detach().cpu().numpy().astype(np.int32)
|
||||
final_y_np = final_y[0].detach().cpu().numpy().astype(np.int32)
|
||||
final_x_np = np.rint(pos[..., 0]).astype(np.int32).clip(0, width - 1)
|
||||
final_y_np = np.rint(pos[..., 1]).astype(np.int32).clip(0, height - 1)
|
||||
target_rgba = _nearest_source_rgba(rgba, final_x_np, final_y_np)
|
||||
final_disp = disp_t[0].detach().cpu().numpy().astype(np.float32)
|
||||
final_disp = (pos - uv0_px).astype(np.float32)
|
||||
final_pos_norm = pos.copy()
|
||||
final_pos_norm[..., 0] = (final_pos_norm[..., 0] / max(1.0, float(width - 1))) * 2.0 - 1.0
|
||||
final_pos_norm[..., 1] = (final_pos_norm[..., 1] / max(1.0, float(height - 1))) * 2.0 - 1.0
|
||||
stage_diagnostics = {
|
||||
"displacements": {
|
||||
"initial_output": _displacement_diagnostics(
|
||||
prep.uv0_norm,
|
||||
uv0_norm,
|
||||
initial_x,
|
||||
initial_y,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
width=width,
|
||||
height=height,
|
||||
),
|
||||
"final_output": _displacement_diagnostics(
|
||||
prep.uv0_norm,
|
||||
uv0_norm,
|
||||
final_x_np,
|
||||
final_y_np,
|
||||
width=prep.width,
|
||||
height=prep.height,
|
||||
width=width,
|
||||
height=height,
|
||||
),
|
||||
},
|
||||
"phase_field": {
|
||||
|
|
@ -554,13 +1108,14 @@ def optimize_phase_field(
|
|||
final_payload = {
|
||||
"step": int(max(0, steps)),
|
||||
"total_steps": int(max(0, steps)),
|
||||
"terms": final_terms.copy(),
|
||||
"phase_metrics": stage_diagnostics["phase_field"].copy(),
|
||||
"loss_history": loss_history.copy(),
|
||||
}
|
||||
if loss_history:
|
||||
final_payload["loss"] = float(loss_history[-1])
|
||||
if include_snapshots:
|
||||
final_payload.update(_observer_snapshot(prep, rgba, final_px[0].detach().cpu().numpy().astype(np.float32)))
|
||||
final_payload.update(_observer_snapshot(prep, rgba, pos.astype(np.float32)))
|
||||
emit_observer(observer, "phase_field_final", **final_payload)
|
||||
return SolverArtifacts(
|
||||
target_rgba=target_rgba,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from .metrics import (
|
|||
foreground_stroke_wobble_error,
|
||||
source_lattice_consistency_breakdown,
|
||||
)
|
||||
from .params import SolverHyperParams
|
||||
from .params import SolverHyperParams, load_solver_params
|
||||
from .phase_field import optimize_phase_field
|
||||
from .palette import load_palette, quantize_rgba, save_palette_report
|
||||
from .preprocess import strip_edge_background
|
||||
|
|
@ -40,8 +40,6 @@ def run_pipeline(
|
|||
target_size: int | None = None,
|
||||
target_width: int | None = None,
|
||||
target_height: int | None = None,
|
||||
phase_x: float | None = None,
|
||||
phase_y: float | None = None,
|
||||
palette_path: str | Path | None = None,
|
||||
palette_mode: str = "off",
|
||||
diagnostics_dir: str | Path | None = None,
|
||||
|
|
@ -50,7 +48,7 @@ def run_pipeline(
|
|||
device: str = "auto",
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
strip_background: bool = False,
|
||||
enable_phase_rerank: bool = True,
|
||||
enable_candidate_rerank: bool = True,
|
||||
lattice_inference_mode: str = "search",
|
||||
max_inferred_target_size: int | None = None,
|
||||
observer: PipelineObserver | None = None,
|
||||
|
|
@ -62,8 +60,6 @@ def run_pipeline(
|
|||
target_size=target_size,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
palette_path=palette_path,
|
||||
palette_mode=palette_mode,
|
||||
diagnostics_dir=diagnostics_dir,
|
||||
|
|
@ -72,7 +68,7 @@ def run_pipeline(
|
|||
device=device,
|
||||
solver_params=solver_params,
|
||||
strip_background=strip_background,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
enable_candidate_rerank=enable_candidate_rerank,
|
||||
lattice_inference_mode=lattice_inference_mode,
|
||||
max_inferred_target_size=max_inferred_target_size,
|
||||
observer=observer,
|
||||
|
|
@ -86,8 +82,6 @@ def run_pipeline_rgba(
|
|||
target_size: int | None = None,
|
||||
target_width: int | None = None,
|
||||
target_height: int | None = None,
|
||||
phase_x: float | None = None,
|
||||
phase_y: float | None = None,
|
||||
palette_path: str | Path | None = None,
|
||||
palette_mode: str = "off",
|
||||
diagnostics_dir: str | Path | None = None,
|
||||
|
|
@ -96,13 +90,13 @@ def run_pipeline_rgba(
|
|||
device: str = "auto",
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
strip_background: bool = False,
|
||||
enable_phase_rerank: bool = True,
|
||||
enable_candidate_rerank: bool = True,
|
||||
lattice_inference_mode: str = "search",
|
||||
max_inferred_target_size: int | None = None,
|
||||
observer: PipelineObserver | None = None,
|
||||
) -> RunResult:
|
||||
started = time.perf_counter()
|
||||
solver_params = solver_params or SolverHyperParams()
|
||||
solver_params = solver_params or load_solver_params()
|
||||
check_observer_cancelled(observer)
|
||||
emit_observer(observer, "source_loaded", source_rgba=source.copy())
|
||||
if strip_background:
|
||||
|
|
@ -123,17 +117,15 @@ def run_pipeline_rgba(
|
|||
target_size=target_size,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
)
|
||||
if fixed_dims is None:
|
||||
check_observer_cancelled(observer)
|
||||
if lattice_inference_mode == "autocorr":
|
||||
inference_label = "Autocorr lattice"
|
||||
inference_detail = "Taking one autocorr consensus size, probing phase once, and skipping size search."
|
||||
inference_detail = "Scoring autocorr candidate sizes from canonical cell centers; the phase field is free to drift."
|
||||
elif lattice_inference_mode == "search":
|
||||
inference_label = "Lattice search"
|
||||
inference_detail = "Searching for the output size and phase that best fit the input."
|
||||
inference_detail = "Searching for output size from canonical cell centers; the phase field handles local drift."
|
||||
else:
|
||||
raise ValueError(f"Unknown lattice_inference_mode '{lattice_inference_mode}'.")
|
||||
emit_observer(
|
||||
|
|
@ -161,14 +153,12 @@ def run_pipeline_rgba(
|
|||
"stage_started",
|
||||
stage="inference",
|
||||
label="Pinned lattice",
|
||||
detail="Locking in the requested size and phase instead of searching.",
|
||||
detail="Locking in the requested size from canonical cell centers; the phase field handles local drift.",
|
||||
)
|
||||
inference = infer_fixed_lattice(
|
||||
source,
|
||||
target_width=fixed_dims[0],
|
||||
target_height=fixed_dims[1],
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
device=device,
|
||||
)
|
||||
inference_mode = "fixed"
|
||||
|
|
@ -192,10 +182,10 @@ def run_pipeline_rgba(
|
|||
observer,
|
||||
"stage_started",
|
||||
stage="selection",
|
||||
label="Phase selection",
|
||||
label="Candidate selection",
|
||||
detail="Choosing the lattice candidate that deserves the full solve.",
|
||||
)
|
||||
inference = _select_phase_candidate(
|
||||
inference = _select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=analysis,
|
||||
|
|
@ -203,10 +193,10 @@ def run_pipeline_rgba(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
enable_candidate_rerank=enable_candidate_rerank,
|
||||
observer=observer,
|
||||
)
|
||||
emit_observer(observer, "phase_selection_completed", inference=inference, inference_mode=inference_mode)
|
||||
emit_observer(observer, "candidate_selection_completed", inference=inference, inference_mode=inference_mode)
|
||||
check_observer_cancelled(observer)
|
||||
emit_observer(
|
||||
observer,
|
||||
|
|
@ -288,14 +278,12 @@ def run_pipeline_rgba(
|
|||
"target_size": target_size,
|
||||
"target_width": target_width,
|
||||
"target_height": target_height,
|
||||
"phase_x": phase_x,
|
||||
"phase_y": phase_y,
|
||||
"palette_mode": palette_mode,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"device": device,
|
||||
"strip_background": strip_background,
|
||||
"enable_phase_rerank": enable_phase_rerank,
|
||||
"enable_candidate_rerank": enable_candidate_rerank,
|
||||
"lattice_inference_mode": lattice_inference_mode,
|
||||
"max_inferred_target_size": max_inferred_target_size,
|
||||
"inference_mode": inference_mode,
|
||||
|
|
@ -356,7 +344,7 @@ def _prepare_analysis(
|
|||
return analyze_phase_field_source(source, seed=seed, device=device)
|
||||
|
||||
|
||||
def _select_phase_candidate(
|
||||
def _select_candidate(
|
||||
source: np.ndarray,
|
||||
inference: InferenceResult,
|
||||
*,
|
||||
|
|
@ -365,10 +353,10 @@ def _select_phase_candidate(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
enable_phase_rerank: bool = True,
|
||||
enable_candidate_rerank: bool = True,
|
||||
observer: PipelineObserver | None = None,
|
||||
) -> InferenceResult:
|
||||
return _select_phase_candidate_with_reconstruction(
|
||||
return _select_candidate_with_reconstruction(
|
||||
source,
|
||||
inference,
|
||||
analysis=analysis,
|
||||
|
|
@ -376,12 +364,12 @@ def _select_phase_candidate(
|
|||
seed=seed,
|
||||
device=device,
|
||||
solver_params=solver_params,
|
||||
enable_phase_rerank=enable_phase_rerank,
|
||||
enable_candidate_rerank=enable_candidate_rerank,
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
|
||||
def _select_phase_candidate_with_reconstruction(
|
||||
def _select_candidate_with_reconstruction(
|
||||
source: np.ndarray,
|
||||
inference: InferenceResult,
|
||||
*,
|
||||
|
|
@ -390,20 +378,20 @@ def _select_phase_candidate_with_reconstruction(
|
|||
seed: int,
|
||||
device: str,
|
||||
solver_params: SolverHyperParams | None = None,
|
||||
enable_phase_rerank: bool = True,
|
||||
enable_candidate_rerank: bool = True,
|
||||
observer: PipelineObserver | None = None,
|
||||
) -> InferenceResult:
|
||||
solver_params = solver_params or SolverHyperParams()
|
||||
if not enable_phase_rerank:
|
||||
solver_params = solver_params or load_solver_params()
|
||||
if not enable_candidate_rerank:
|
||||
return inference
|
||||
if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.phase_rerank_confidence_threshold:
|
||||
if len(inference.top_candidates) <= 1 or inference.confidence >= solver_params.candidate_rerank_confidence_threshold:
|
||||
return inference
|
||||
|
||||
preview_steps = min(max(0, int(steps)), max(0, int(solver_params.phase_rerank_preview_steps)))
|
||||
preview_steps = min(max(0, int(steps)), max(0, int(solver_params.candidate_rerank_preview_steps)))
|
||||
top_score = float(inference.top_candidates[0].score)
|
||||
emit_observer(
|
||||
observer,
|
||||
"phase_rerank_started",
|
||||
"candidate_rerank_started",
|
||||
preview_steps=preview_steps,
|
||||
candidate_count=min(8, len(inference.top_candidates)),
|
||||
confidence=float(inference.confidence),
|
||||
|
|
@ -415,20 +403,16 @@ def _select_phase_candidate_with_reconstruction(
|
|||
candidate_inference = InferenceResult(
|
||||
target_width=candidate.target_width,
|
||||
target_height=candidate.target_height,
|
||||
phase_x=candidate.phase_x,
|
||||
phase_y=candidate.phase_y,
|
||||
confidence=inference.confidence,
|
||||
top_candidates=inference.top_candidates,
|
||||
)
|
||||
emit_observer(
|
||||
observer,
|
||||
"phase_rerank_candidate_started",
|
||||
"candidate_rerank_candidate_started",
|
||||
candidate_index=candidate_index,
|
||||
total_candidates=total_candidates,
|
||||
target_width=int(candidate.target_width),
|
||||
target_height=int(candidate.target_height),
|
||||
phase_x=float(candidate.phase_x),
|
||||
phase_y=float(candidate.phase_y),
|
||||
preview_steps=preview_steps,
|
||||
)
|
||||
|
||||
|
|
@ -440,13 +424,11 @@ def _select_phase_candidate_with_reconstruction(
|
|||
"total_candidates": total_candidates,
|
||||
"target_width": int(candidate.target_width),
|
||||
"target_height": int(candidate.target_height),
|
||||
"phase_x": float(candidate.phase_x),
|
||||
"phase_y": float(candidate.phase_y),
|
||||
}
|
||||
if event in {"phase_field_initial", "phase_field_step"}:
|
||||
emit_observer(
|
||||
observer,
|
||||
"phase_rerank_candidate_step",
|
||||
"candidate_rerank_candidate_step",
|
||||
step=int(payload["step"]),
|
||||
total_steps=int(payload["total_steps"]),
|
||||
loss=None if payload.get("loss") is None else float(payload["loss"]),
|
||||
|
|
@ -468,14 +450,12 @@ def _select_phase_candidate_with_reconstruction(
|
|||
check_observer_cancelled(observer)
|
||||
emit_observer(
|
||||
observer,
|
||||
"phase_rerank_candidate_completed",
|
||||
"candidate_rerank_candidate_completed",
|
||||
candidate_index=candidate_index,
|
||||
total_candidates=total_candidates,
|
||||
completed_candidates=candidate_index,
|
||||
target_width=int(candidate.target_width),
|
||||
target_height=int(candidate.target_height),
|
||||
phase_x=float(candidate.phase_x),
|
||||
phase_y=float(candidate.phase_y),
|
||||
total_steps=preview_steps,
|
||||
final_loss=(
|
||||
None
|
||||
|
|
@ -488,8 +468,6 @@ def _select_phase_candidate_with_reconstruction(
|
|||
candidate_artifacts.target_rgba,
|
||||
target_width=candidate.target_width,
|
||||
target_height=candidate.target_height,
|
||||
phase_x=candidate.phase_x,
|
||||
phase_y=candidate.phase_y,
|
||||
)
|
||||
preview = nearest_resize(candidate_artifacts.target_rgba, width=source.shape[1], height=source.shape[0])
|
||||
candidate_records.append(
|
||||
|
|
@ -515,7 +493,7 @@ def _select_phase_candidate_with_reconstruction(
|
|||
higher_is_better=True,
|
||||
)
|
||||
size_penalty = [
|
||||
min(1.0, float(record["size_delta_ratio"]) / max(solver_params.phase_rerank_max_size_delta_ratio, 1e-6))
|
||||
min(1.0, float(record["size_delta_ratio"]) / max(solver_params.candidate_rerank_max_size_delta_ratio, 1e-6))
|
||||
for record in candidate_records
|
||||
]
|
||||
inference_penalty = _normalize_penalty(record["inference_penalty"] for record in candidate_records)
|
||||
|
|
@ -526,32 +504,30 @@ def _select_phase_candidate_with_reconstruction(
|
|||
annotated_candidates = []
|
||||
for index, record in enumerate(candidate_records):
|
||||
candidate_inference = record["inference"]
|
||||
if float(record["size_delta_ratio"]) > solver_params.phase_rerank_max_size_delta_ratio:
|
||||
if float(record["size_delta_ratio"]) > solver_params.candidate_rerank_max_size_delta_ratio:
|
||||
continue
|
||||
rank = (
|
||||
solver_params.phase_rerank_support_weight * support_penalty[index]
|
||||
+ solver_params.phase_rerank_edge_position_weight * edge_position_penalty[index]
|
||||
+ solver_params.phase_rerank_wobble_weight * wobble_penalty[index]
|
||||
+ solver_params.phase_rerank_edge_concentration_weight * edge_concentration_penalty[index]
|
||||
+ solver_params.phase_rerank_size_penalty_weight * size_penalty[index]
|
||||
+ solver_params.phase_rerank_inference_penalty_weight * inference_penalty[index]
|
||||
solver_params.candidate_rerank_support_weight * support_penalty[index]
|
||||
+ solver_params.candidate_rerank_edge_position_weight * edge_position_penalty[index]
|
||||
+ solver_params.candidate_rerank_wobble_weight * wobble_penalty[index]
|
||||
+ solver_params.candidate_rerank_edge_concentration_weight * edge_concentration_penalty[index]
|
||||
+ solver_params.candidate_rerank_size_penalty_weight * size_penalty[index]
|
||||
+ solver_params.candidate_rerank_inference_penalty_weight * inference_penalty[index]
|
||||
)
|
||||
source_candidate = inference.top_candidates[index]
|
||||
breakdown = dict(source_candidate.breakdown)
|
||||
breakdown["phase_rerank_support_score"] = float(record["support_score"])
|
||||
breakdown["phase_rerank_edge_position_error"] = float(record["edge_position_error"])
|
||||
breakdown["phase_rerank_stroke_wobble_error"] = float(record["stroke_wobble_error"])
|
||||
breakdown["phase_rerank_edge_concentration"] = float(record["edge_concentration"])
|
||||
breakdown["phase_rerank_size_delta_ratio"] = float(record["size_delta_ratio"])
|
||||
breakdown["phase_rerank_size_penalty"] = float(size_penalty[index])
|
||||
breakdown["phase_rerank_inference_penalty"] = float(record["inference_penalty"])
|
||||
breakdown["phase_rerank_score"] = float(rank)
|
||||
breakdown["candidate_rerank_support_score"] = float(record["support_score"])
|
||||
breakdown["candidate_rerank_edge_position_error"] = float(record["edge_position_error"])
|
||||
breakdown["candidate_rerank_stroke_wobble_error"] = float(record["stroke_wobble_error"])
|
||||
breakdown["candidate_rerank_edge_concentration"] = float(record["edge_concentration"])
|
||||
breakdown["candidate_rerank_size_delta_ratio"] = float(record["size_delta_ratio"])
|
||||
breakdown["candidate_rerank_size_penalty"] = float(size_penalty[index])
|
||||
breakdown["candidate_rerank_inference_penalty"] = float(record["inference_penalty"])
|
||||
breakdown["candidate_rerank_score"] = float(rank)
|
||||
annotated_candidates.append(
|
||||
source_candidate.__class__(
|
||||
target_width=source_candidate.target_width,
|
||||
target_height=source_candidate.target_height,
|
||||
phase_x=source_candidate.phase_x,
|
||||
phase_y=source_candidate.phase_y,
|
||||
score=source_candidate.score,
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
|
@ -561,25 +537,21 @@ def _select_phase_candidate_with_reconstruction(
|
|||
if rank < best_rank:
|
||||
best_rank = rank
|
||||
best_candidate = record["inference"]
|
||||
annotated_candidates.sort(key=lambda candidate: float(candidate.breakdown.get("phase_rerank_score", float("inf"))))
|
||||
annotated_candidates.sort(key=lambda candidate: float(candidate.breakdown.get("candidate_rerank_score", float("inf"))))
|
||||
for rank, candidate in enumerate(annotated_candidates, start=1):
|
||||
candidate.breakdown["phase_rerank_rank"] = float(rank)
|
||||
candidate.breakdown["candidate_rerank_rank"] = float(rank)
|
||||
if annotated_candidates:
|
||||
best_candidate = InferenceResult(
|
||||
target_width=best_candidate.target_width,
|
||||
target_height=best_candidate.target_height,
|
||||
phase_x=best_candidate.phase_x,
|
||||
phase_y=best_candidate.phase_y,
|
||||
confidence=inference.confidence,
|
||||
top_candidates=annotated_candidates,
|
||||
)
|
||||
if baseline_rank is None or best_rank > baseline_rank - solver_params.phase_rerank_margin:
|
||||
if baseline_rank is None or best_rank > baseline_rank - solver_params.candidate_rerank_margin:
|
||||
if annotated_candidates:
|
||||
selected = InferenceResult(
|
||||
target_width=inference.target_width,
|
||||
target_height=inference.target_height,
|
||||
phase_x=inference.phase_x,
|
||||
phase_y=inference.phase_y,
|
||||
confidence=inference.confidence,
|
||||
top_candidates=annotated_candidates,
|
||||
)
|
||||
|
|
@ -595,14 +567,9 @@ def _resolve_requested_target_dims(
|
|||
target_size: int | None,
|
||||
target_width: int | None,
|
||||
target_height: int | None,
|
||||
phase_x: float | None,
|
||||
phase_y: float | None,
|
||||
) -> tuple[int, int] | None:
|
||||
explicit_size = target_width is not None or target_height is not None
|
||||
explicit_phase = phase_x is not None or phase_y is not None
|
||||
if not explicit_size and target_size is None:
|
||||
if explicit_phase:
|
||||
raise ValueError("phase_x/phase_y require target_size or target_width/target_height so the lattice can be fixed explicitly.")
|
||||
return None
|
||||
if target_size is not None and explicit_size:
|
||||
raise ValueError("target_size cannot be combined with target_width or target_height.")
|
||||
|
|
|
|||
|
|
@ -63,13 +63,11 @@ def lattice_indices(
|
|||
width: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
) -> np.ndarray:
|
||||
cell_x = width / max(1, target_width)
|
||||
cell_y = height / max(1, target_height)
|
||||
xs = (np.arange(width, dtype=np.float32) + 0.5) / cell_x - phase_x
|
||||
ys = (np.arange(height, dtype=np.float32) + 0.5) / cell_y - phase_y
|
||||
xs = (np.arange(width, dtype=np.float32) + 0.5) / cell_x
|
||||
ys = (np.arange(height, dtype=np.float32) + 0.5) / cell_y
|
||||
x_idx = np.clip(np.floor(xs).astype(np.int32), 0, max(0, target_width - 1))
|
||||
y_idx = np.clip(np.floor(ys).astype(np.int32), 0, max(0, target_height - 1))
|
||||
return y_idx[:, None] * target_width + x_idx[None, :]
|
||||
|
|
@ -82,14 +80,12 @@ def _lattice_indices_torch(
|
|||
width: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
device: str,
|
||||
):
|
||||
cell_x = width / max(1, target_width)
|
||||
cell_y = height / max(1, target_height)
|
||||
xs = (torch.arange(width, device=device, dtype=torch.float32) + 0.5) / cell_x - phase_x
|
||||
ys = (torch.arange(height, device=device, dtype=torch.float32) + 0.5) / cell_y - phase_y
|
||||
xs = (torch.arange(width, device=device, dtype=torch.float32) + 0.5) / cell_x
|
||||
ys = (torch.arange(height, device=device, dtype=torch.float32) + 0.5) / cell_y
|
||||
x_idx = torch.floor(xs).to(dtype=torch.long).clamp(0, max(0, target_width - 1))
|
||||
y_idx = torch.floor(ys).to(dtype=torch.long).clamp(0, max(0, target_height - 1))
|
||||
return y_idx[:, None] * target_width + x_idx[None, :]
|
||||
|
|
@ -129,8 +125,6 @@ def build_source_lattice_reference(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
alpha_threshold: float = 0.05,
|
||||
edge_hint: np.ndarray | None = None,
|
||||
edge_grad_x_hint: np.ndarray | None = None,
|
||||
|
|
@ -141,8 +135,6 @@ def build_source_lattice_reference(
|
|||
source_rgba,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
alpha_threshold=alpha_threshold,
|
||||
edge_hint=edge_hint,
|
||||
edge_grad_x_hint=edge_grad_x_hint,
|
||||
|
|
@ -157,8 +149,6 @@ def _build_reference_payload(
|
|||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
phase_x: float,
|
||||
phase_y: float,
|
||||
alpha_threshold: float = 0.05,
|
||||
edge_hint: np.ndarray | None = None,
|
||||
edge_grad_x_hint: np.ndarray | None = None,
|
||||
|
|
@ -182,8 +172,6 @@ def _build_reference_payload(
|
|||
width=width,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
device=resolved_device,
|
||||
)
|
||||
cell_count = max(1, target_width * target_height)
|
||||
|
|
@ -219,12 +207,12 @@ def _build_reference_payload(
|
|||
cell_x = width / max(1, target_width)
|
||||
cell_y = height / max(1, target_height)
|
||||
default_x_t = torch.clamp(
|
||||
torch.round((torch.arange(target_width, device=resolved_device, dtype=torch.float32) + 0.5 + phase_x) * cell_x - 0.5),
|
||||
torch.round((torch.arange(target_width, device=resolved_device, dtype=torch.float32) + 0.5 ) * cell_x - 0.5),
|
||||
0,
|
||||
max(0, width - 1),
|
||||
).to(dtype=torch.long)
|
||||
default_y_t = torch.clamp(
|
||||
torch.round((torch.arange(target_height, device=resolved_device, dtype=torch.float32) + 0.5 + phase_y) * cell_y - 0.5),
|
||||
torch.round((torch.arange(target_height, device=resolved_device, dtype=torch.float32) + 0.5 ) * cell_y - 0.5),
|
||||
0,
|
||||
max(0, height - 1),
|
||||
).to(dtype=torch.long)
|
||||
|
|
@ -303,8 +291,6 @@ def _build_reference_payload(
|
|||
width=width,
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=phase_x,
|
||||
phase_y=phase_y,
|
||||
)
|
||||
cell_count = max(1, target_width * target_height)
|
||||
premul = premultiply(source_rgba)
|
||||
|
|
@ -338,12 +324,12 @@ def _build_reference_payload(
|
|||
cell_x = width / max(1, target_width)
|
||||
cell_y = height / max(1, target_height)
|
||||
default_x = np.clip(
|
||||
np.rint((np.arange(target_width, dtype=np.float32) + 0.5 + phase_x) * cell_x - 0.5),
|
||||
np.rint((np.arange(target_width, dtype=np.float32) + 0.5 ) * cell_x - 0.5),
|
||||
0,
|
||||
max(0, width - 1),
|
||||
)
|
||||
default_y = np.clip(
|
||||
np.rint((np.arange(target_height, dtype=np.float32) + 0.5 + phase_y) * cell_y - 0.5),
|
||||
np.rint((np.arange(target_height, dtype=np.float32) + 0.5 ) * cell_y - 0.5),
|
||||
0,
|
||||
max(0, height - 1),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ def make_sprite(width: int = 24, height: int = 24) -> np.ndarray:
|
|||
def fake_pixelize(
|
||||
rgba: np.ndarray,
|
||||
upscale: int = 14,
|
||||
phase_x: float = 0.22,
|
||||
phase_y: float = 0.31,
|
||||
offset_x: float = 0.22,
|
||||
offset_y: float = 0.31,
|
||||
blur_radius: float = 0.7,
|
||||
warp_strength: float = 0.0,
|
||||
warp_detail: int = 4,
|
||||
|
|
@ -47,8 +47,8 @@ def fake_pixelize(
|
|||
target_rgba = np.asarray(target, dtype=np.float32) / 255.0
|
||||
height_px, width_px = target_rgba.shape[:2]
|
||||
grid_x, grid_y = np.meshgrid(np.arange(width_px, dtype=np.float32), np.arange(height_px, dtype=np.float32))
|
||||
sample_x = grid_x - phase_x * float(upscale)
|
||||
sample_y = grid_y - phase_y * float(upscale)
|
||||
sample_x = grid_x - offset_x * float(upscale)
|
||||
sample_y = grid_y - offset_y * float(upscale)
|
||||
if warp_strength > 0.0:
|
||||
rng = np.random.default_rng(seed)
|
||||
strength_px = float(upscale) * warp_strength
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import numpy as np
|
|||
|
||||
from .benchmark import run_roundtrip_benchmark
|
||||
from .diagnostics import write_json
|
||||
from .params import SolverHyperParams
|
||||
from .params import SolverHyperParams, load_solver_params
|
||||
|
||||
|
||||
def _safe_ratio(numerator: float, denominator: float) -> float:
|
||||
|
|
@ -66,144 +66,97 @@ def _mutate_linear(
|
|||
def _mutate_params(base: SolverHyperParams, rng: np.random.Generator, scale: float) -> SolverHyperParams:
|
||||
return replace(
|
||||
base,
|
||||
phase_field_patch_extent=_mutate_linear(
|
||||
phase_field_grid_alignment_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_patch_extent,
|
||||
scale=scale,
|
||||
sigma=0.035,
|
||||
low=0.05,
|
||||
high=0.35,
|
||||
),
|
||||
phase_field_data_coherence_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_data_coherence_weight,
|
||||
scale=scale,
|
||||
low=0.15,
|
||||
high=3.0,
|
||||
),
|
||||
phase_field_data_edge_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_data_edge_weight,
|
||||
scale=scale,
|
||||
low=0.05,
|
||||
high=2.5,
|
||||
),
|
||||
phase_field_smoothness_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_smoothness_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.2,
|
||||
),
|
||||
phase_field_edge_gate_strength=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_edge_gate_strength,
|
||||
base.phase_field_grid_alignment_weight,
|
||||
scale=scale,
|
||||
low=1.0,
|
||||
high=30.0,
|
||||
high=10.0,
|
||||
),
|
||||
phase_field_collapse_weight=_mutate_positive(
|
||||
phase_field_local_search_radius_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.phase_field_collapse_weight,
|
||||
base.phase_field_local_search_radius_ratio,
|
||||
scale=scale,
|
||||
low=0.05,
|
||||
high=4.0,
|
||||
sigma=0.12,
|
||||
low=0.25,
|
||||
high=1.20,
|
||||
),
|
||||
phase_field_min_spacing_ratio=_mutate_linear(
|
||||
phase_field_local_search_blend=_mutate_linear(
|
||||
rng,
|
||||
base.phase_field_min_spacing_ratio,
|
||||
base.phase_field_local_search_blend,
|
||||
scale=scale,
|
||||
sigma=0.05,
|
||||
low=0.01,
|
||||
sigma=0.055,
|
||||
low=0.06,
|
||||
high=0.45,
|
||||
),
|
||||
phase_field_magnitude_weight=_mutate_positive(
|
||||
phase_field_local_search_move_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_magnitude_weight,
|
||||
base.phase_field_local_search_move_weight,
|
||||
scale=scale,
|
||||
low=0.001,
|
||||
high=0.5,
|
||||
low=0.03,
|
||||
high=0.50,
|
||||
),
|
||||
phase_field_learning_rate=_mutate_positive(
|
||||
phase_field_signal_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_learning_rate,
|
||||
base.phase_field_signal_weight,
|
||||
scale=scale,
|
||||
low=0.005,
|
||||
high=0.5,
|
||||
low=0.05,
|
||||
high=0.80,
|
||||
),
|
||||
phase_field_max_displacement_ratio=_mutate_linear(
|
||||
phase_field_signal_boundary_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_max_displacement_ratio,
|
||||
base.phase_field_signal_boundary_weight,
|
||||
scale=scale,
|
||||
low=0.20,
|
||||
high=2.50,
|
||||
),
|
||||
phase_field_signal_curvature_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_field_signal_curvature_weight,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.10,
|
||||
high=1.0,
|
||||
high=2.00,
|
||||
),
|
||||
phase_rerank_support_weight=_mutate_positive(
|
||||
phase_field_signal_self_penalty=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_support_weight,
|
||||
base.phase_field_signal_self_penalty,
|
||||
scale=scale,
|
||||
low=0.05,
|
||||
high=1.5,
|
||||
low=1.00,
|
||||
high=8.00,
|
||||
),
|
||||
phase_rerank_edge_position_weight=_mutate_positive(
|
||||
phase_field_signal_context_power=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_edge_position_weight,
|
||||
base.phase_field_signal_context_power,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
low=0.25,
|
||||
high=2.00,
|
||||
),
|
||||
phase_rerank_wobble_weight=_mutate_positive(
|
||||
phase_field_signal_peak_power=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_wobble_weight,
|
||||
base.phase_field_signal_peak_power,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
low=1.00,
|
||||
high=5.00,
|
||||
),
|
||||
phase_rerank_edge_concentration_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_edge_concentration_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
phase_field_signal_pyramid_levels=int(
|
||||
round(
|
||||
_mutate_linear(
|
||||
rng,
|
||||
float(base.phase_field_signal_pyramid_levels),
|
||||
scale=scale,
|
||||
sigma=0.65,
|
||||
low=3.0,
|
||||
high=7.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
phase_rerank_size_penalty_weight=_mutate_positive(
|
||||
phase_field_local_search_grid_weight=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_size_penalty_weight,
|
||||
scale=scale,
|
||||
low=0.01,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_inference_penalty_weight=_mutate_positive(
|
||||
rng,
|
||||
base.phase_rerank_inference_penalty_weight,
|
||||
scale=scale,
|
||||
low=0.0,
|
||||
high=0.5,
|
||||
),
|
||||
phase_rerank_confidence_threshold=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_confidence_threshold,
|
||||
scale=scale,
|
||||
sigma=0.035,
|
||||
low=0.0,
|
||||
high=0.5,
|
||||
),
|
||||
phase_rerank_max_size_delta_ratio=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_max_size_delta_ratio,
|
||||
base.phase_field_local_search_grid_weight,
|
||||
scale=scale,
|
||||
sigma=0.08,
|
||||
low=0.05,
|
||||
high=1.0,
|
||||
),
|
||||
phase_rerank_margin=_mutate_linear(
|
||||
rng,
|
||||
base.phase_rerank_margin,
|
||||
scale=scale,
|
||||
sigma=0.0015,
|
||||
low=0.0005,
|
||||
high=0.010,
|
||||
low=0.0,
|
||||
high=0.60,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -227,8 +180,6 @@ def tune_solver_hyperparams(
|
|||
|
||||
selected_profiles = profiles or ["soft"]
|
||||
effective_limit = limit_cases
|
||||
if effective_limit is None and not include_cases:
|
||||
effective_limit = 8
|
||||
|
||||
out_path = Path(out_dir)
|
||||
if out_path.exists():
|
||||
|
|
@ -238,7 +189,7 @@ def tune_solver_hyperparams(
|
|||
best_dir = out_path / "best-benchmark"
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
best_params = SolverHyperParams()
|
||||
best_params = load_solver_params()
|
||||
best_score = float("inf")
|
||||
best_summary: dict[str, Any] | None = None
|
||||
trials_payload: list[dict[str, Any]] = []
|
||||
|
|
@ -315,6 +266,7 @@ def tune_solver_hyperparams(
|
|||
"include_cases": include_cases or [],
|
||||
"limit_cases_effective": effective_limit,
|
||||
"objective": "0.20 * error_ratio_to_naive + 0.45 * adjacency_ratio_to_naive + 0.35 * motif_ratio_to_naive; lower is better",
|
||||
"mutation_scope": "explicit solver only: signal, local search, and lattice spring parameters",
|
||||
"trial_results": trials_payload,
|
||||
"best_score": final_score["score"],
|
||||
"best_mean_row_score": final_score["mean_row_score"],
|
||||
|
|
@ -325,4 +277,5 @@ def tune_solver_hyperparams(
|
|||
"initial_benchmark": best_summary,
|
||||
}
|
||||
write_json(out_path / "tuning-results.json", payload)
|
||||
write_json(out_path / "best-solver-params.json", best_params.to_dict())
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import numpy as np
|
|||
class InferenceCandidate:
|
||||
target_width: int
|
||||
target_height: int
|
||||
phase_x: float
|
||||
phase_y: float
|
||||
score: float
|
||||
breakdown: dict[str, float]
|
||||
|
||||
|
|
@ -21,8 +19,6 @@ class InferenceCandidate:
|
|||
class InferenceResult:
|
||||
target_width: int
|
||||
target_height: int
|
||||
phase_x: float
|
||||
phase_y: float
|
||||
confidence: float
|
||||
top_candidates: list[InferenceCandidate] = field(default_factory=list)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
{"ts": "2026-04-26T04:05:06+00:00", "type": "design", "status": "accepted", "note": "Hosted auth was split into a reusable GameCult hosted-access architecture plus a thin Repixelizer-specific access profile. Recommended extraction path is an embedded shared gamecult_access layer first, with dedicated service only after a second app proves the need."}
|
||||
{"ts": "2026-04-26T04:52:53+00:00", "type": "design", "status": "accepted", "note": "Hosted convenience is the monetization path for Repixelizer; keep the code open, let self-hosting remain the escape hatch, and do not prioritize native desktop packaging unless it clearly beats hosted access on revenue, support burden, or strategy."}
|
||||
{"ts": "2026-04-26T12:56:28+00:00", "type": "research", "status": "ok", "note": "Added dense landscape fixture tests/fixtures/real/dense-landscape.png plus tracked fixed-512 output. Current automatic lattice inference on the 1254px source undershoots to 251x251, so README previews now include that case with the fixed 512x512 result beside Lanczos."}
|
||||
{"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: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 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."}
|
||||
{"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."}
|
||||
{"ts": "2026-04-26T18:19:52+00:00", "type": "design", "status": "accepted", "note": "Hosted demo jobs now use direct autocorr lattice inference with one cheap phase probe, capped by the hosted output limit. The hosted path no longer silently pins unspecified jobs to max_output_dimension, and phase rerank is forced off there while the full local workflow keeps searched autocorr inference available."}
|
||||
{"ts": "2026-04-26T18:19:52+00:00", "type": "design", "status": "accepted", "note": "Hosted demo jobs now use direct autocorr lattice inference with capped by the hosted output limit. The hosted path no longer silently pins unspecified jobs to max_output_dimension, and candidate rerank is forced off there while the full local workflow keeps searched autocorr inference available."}
|
||||
{"ts": "2026-04-26T22:40:02+00:00", "type": "design", "status": "accepted", "note": "Hosted mode now owns a distinct public front door in src/repixelizer/gui.py: / serves a landing page there, while local and self-host runs still redirect root straight to /app/. This keeps future auth and member-gating surgery in the hosted web layer instead of smearing it into the self-deploy path or the solver stack."}
|
||||
{"ts": "2026-04-26T22:57:43+00:00", "type": "design", "status": "accepted", "note": "Repixelizer is now auth-ready at the hosted web boundary without landing real Heimdall auth. src/repixelizer/access.py owns the local access seam, src/repixelizer/gui.py binds request subjects and guards hosted routes, queued jobs now carry local owner metadata, and self-host remains permissive by default unless GC_ACCESS_* turns the seam on."}
|
||||
{"ts": "2026-04-26T23:34:24+00:00", "type": "design", "status": "accepted", "note": "Repixelizer now consumes Heimdall entirely inside the hosted web layer. GC_ACCESS_MODE=heimdall creates local auth attempts, asks Heimdall to start provider OAuth, accepts the direct backend callback, verifies Heimdall Ed25519 access tokens locally, and adopts httpOnly cookie sessions without dragging provider tokens through browser URLs or solver code."}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,10 @@ active_subgoals:
|
|||
accepted_design:
|
||||
- `phase-field` in `src/repixelizer/phase_field.py` is the only live reconstruction engine.
|
||||
- The working pipeline is `source image -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`.
|
||||
- `src/repixelizer/pipeline.py` owns orchestration and phase rerank selection.
|
||||
- `src/repixelizer/pipeline.py` owns orchestration and candidate rerank selection.
|
||||
- `src/repixelizer/analysis.py` provides the normalized edge scout used for loss and cleanup guidance.
|
||||
- `src/repixelizer/phase_field.py` owns the one honest optimization variable: `disp_t`.
|
||||
- Hosted demo jobs now use a direct autocorr lattice path: one consensus size, one cheap phase probe, capped by the hosted output limit, with phase rerank disabled.
|
||||
- Hosted demo jobs now use a direct autocorr lattice path: one consensus size, capped by the hosted output limit, with candidate rerank disabled.
|
||||
- Hosted mode owns the public front door in `src/repixelizer/gui.py`: `/` is a landing page there, while local/self-host runs keep root as a plain redirect to `/app/`.
|
||||
- `src/repixelizer/access.py` now owns the hosted access seam: trusted-header ingress still works, `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally, and auth attempts are adopted into local httpOnly cookie sessions.
|
||||
- The optimizer starts from fixed lattice centers, uses a wider displacement budget than the earlier tighter leash, optimizes displacement with projected Adam, then rounds to real source pixels for final output.
|
||||
|
|
@ -77,8 +77,8 @@ accepted_design:
|
|||
- The open-source distribution story is self-hosting; the paid offer is hosted convenience, not a desktop-app paywall.
|
||||
|
||||
landed_baseline:
|
||||
- Lattice inference with fixed or searched target dimensions and optional low-confidence phase rerank.
|
||||
- Hosted demo inference uses a direct autocorr size pick plus cheap phase probe instead of multi-size search, and it no longer silently pins unspecified jobs to `max_output_dimension`.
|
||||
- Lattice inference with fixed or searched target dimensions and optional low-confidence candidate rerank.
|
||||
- Hosted demo inference uses a direct autocorr size pick instead of multi-size search, and it no longer silently pins unspecified jobs to `max_output_dimension`.
|
||||
- Edge analysis plus one displacement-field optimizer with projected constraints.
|
||||
- Compare mode, diagnostics writing, tuning, and GUI observer plumbing all working on the phase-field path.
|
||||
- `source_structure` reporting alongside `source_fidelity`.
|
||||
|
|
@ -95,5 +95,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; it keeps a tiny near-best lag plateau and uses cross-axis consensus before collapsing to one lattice family. The hosted demo path now skips multi-size lattice search and phase rerank entirely, taking one direct autocorr-consensus size plus the cheap inferred phase and respecting the hosted output cap instead of silently pinning unspecified jobs to that cap. Hosted web access is now auth-landed without smearing into the solver: `src/repixelizer/access.py` verifies Heimdall Ed25519 access tokens locally, tracks auth attempts, and adopts local cookie sessions, while `src/repixelizer/gui.py` owns provider-start routes, direct backend callback receipt, the hosted landing-page login buttons, route guards, and queue/job ownership checks. Self-host remains permissive by default unless `GC_ACCESS_*` turns the seam on. 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 hosted demo path now skips multi-size lattice search and candidate rerank entirely, taking one direct autocorr-consensus size and respecting the hosted output cap instead of silently pinning unspecified jobs to that cap. Hosted web access is now auth-landed without smearing into the solver: `src/repixelizer/access.py` verifies Heimdall Ed25519 access tokens locally, tracks auth attempts, and adopts local cookie sessions, while `src/repixelizer/gui.py` owns provider-start routes, direct backend callback receipt, the hosted landing-page login buttons, route guards, and queue/job ownership checks. Self-host remains permissive by default unless `GC_ACCESS_*` turns the seam on. 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 starting-grid error can be recovered by the field instead of requiring as much candidate-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 more hosted auth work, keep it inside `src/repixelizer/access.py` and `src/repixelizer/gui.py` with bounded polish such as provider expansion, session UX, or persistence hardening. Otherwise resume one bounded solver hypothesis for along-stroke versus across-stroke behavior in `src/repixelizer/phase_field.py` 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.
|
||||
|
|
|
|||
2
tests/fixtures/real/ai-badge-tip-focus.json
vendored
|
|
@ -3,8 +3,6 @@
|
|||
"input": "tests/fixtures/real/ai-badge-cleaned.png",
|
||||
"target_width": 126,
|
||||
"target_height": 126,
|
||||
"phase_x": 0.0,
|
||||
"phase_y": -0.2,
|
||||
"steps": 48,
|
||||
"cell_bbox": [67, 90, 97, 122]
|
||||
}
|
||||
|
|
|
|||
BIN
tests/fixtures/real/character.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
2
tests/fixtures/real/dense-landscape.json
vendored
|
|
@ -10,8 +10,6 @@
|
|||
"date": "2026-04-26",
|
||||
"target_width": 418,
|
||||
"target_height": 418,
|
||||
"phase_x": 0.2,
|
||||
"phase_y": -0.2
|
||||
},
|
||||
"preview_focus_cell_bbox": [44, 396, 124, 476],
|
||||
"notes": [
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from repixelizer.synthetic import fake_pixelize, make_emblem
|
|||
|
||||
def test_default_command_runs_without_explicit_run_subcommand(tmp_path: Path) -> None:
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.2, phase_y=0.25, blur_radius=0.4)
|
||||
fake = fake_pixelize(source, upscale=8, blur_radius=0.4)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
save_rgba(input_path, fake)
|
||||
|
|
@ -20,7 +20,7 @@ def test_default_command_runs_without_explicit_run_subcommand(tmp_path: Path) ->
|
|||
|
||||
def test_default_command_accepts_strip_background_flag(tmp_path: Path) -> None:
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.2, phase_y=0.25, blur_radius=0.4)
|
||||
fake = fake_pixelize(source, upscale=8, blur_radius=0.4)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
save_rgba(input_path, fake)
|
||||
|
|
@ -31,7 +31,7 @@ def test_default_command_accepts_strip_background_flag(tmp_path: Path) -> None:
|
|||
|
||||
def test_default_command_accepts_fixed_lattice_flags(tmp_path: Path) -> None:
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.2, phase_y=0.25, blur_radius=0.4)
|
||||
fake = fake_pixelize(source, upscale=8, blur_radius=0.4)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
save_rgba(input_path, fake)
|
||||
|
|
@ -46,11 +46,7 @@ def test_default_command_accepts_fixed_lattice_flags(tmp_path: Path) -> None:
|
|||
"16",
|
||||
"--target-height",
|
||||
"16",
|
||||
"--phase-x",
|
||||
"0.2",
|
||||
"--phase-y",
|
||||
"0.25",
|
||||
"--skip-phase-rerank",
|
||||
"--skip-candidate-rerank",
|
||||
]
|
||||
)
|
||||
assert exit_code == 0
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from repixelizer.synthetic import fake_pixelize, make_emblem
|
|||
|
||||
def test_compare_writes_comparison_artifacts(tmp_path: Path) -> None:
|
||||
source = make_emblem(32, 32)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.2, phase_y=0.3, blur_radius=0.5)
|
||||
fake = fake_pixelize(source, upscale=10, blur_radius=0.5)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
diagnostics_dir = tmp_path / "diagnostics"
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ def _response_json(response) -> dict[str, object]:
|
|||
|
||||
def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
|
||||
source = make_emblem(20, 20)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.15, phase_y=0.2, blur_radius=0.45, seed=3)
|
||||
fake = fake_pixelize(source, upscale=8, blur_radius=0.45, seed=3)
|
||||
events: list[str] = []
|
||||
|
||||
def observer(event: str, payload) -> None:
|
||||
|
|
@ -185,19 +185,17 @@ def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
|
|||
fake,
|
||||
target_width=20,
|
||||
target_height=20,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
steps=2,
|
||||
device="cpu",
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
assert events[0] == "source_loaded"
|
||||
assert events.count("stage_started") == 6
|
||||
assert events.index("inference_candidates_ready") < events.index("analysis_completed")
|
||||
assert events.index("analysis_completed") < events.index("phase_selection_completed")
|
||||
assert events.index("phase_selection_completed") < events.index("phase_field_prepared")
|
||||
assert events.index("analysis_completed") < events.index("candidate_selection_completed")
|
||||
assert events.index("candidate_selection_completed") < events.index("phase_field_prepared")
|
||||
assert "phase_field_prepared" in events
|
||||
assert "phase_field_initial" in events
|
||||
assert events.count("phase_field_step") == 2
|
||||
|
|
@ -206,7 +204,7 @@ def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
|
|||
|
||||
def test_run_pipeline_rgba_respects_phase_field_preview_stride() -> None:
|
||||
source = make_emblem(12, 12)
|
||||
fake = fake_pixelize(source, upscale=6, phase_x=0.12, phase_y=-0.08, blur_radius=0.35, seed=5)
|
||||
fake = fake_pixelize(source, upscale=6, blur_radius=0.35, seed=5)
|
||||
|
||||
class PreviewObserver:
|
||||
phase_field_preview_stride = 2
|
||||
|
|
@ -223,11 +221,9 @@ def test_run_pipeline_rgba_respects_phase_field_preview_stride() -> None:
|
|||
fake,
|
||||
target_width=12,
|
||||
target_height=12,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
steps=5,
|
||||
device="cpu",
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
|
|
@ -416,7 +412,7 @@ def test_gui_hosted_root_serves_landing_page(monkeypatch, tmp_path: Path) -> Non
|
|||
assert status == 200
|
||||
assert headers["cache-control"] == "no-store, no-cache, must-revalidate, max-age=0"
|
||||
assert "Repixelizer hosted demo" in html
|
||||
assert "Force fake pixel art back onto a real grid." in html
|
||||
assert "Force fake pixel art back onto a real grid, then inspect each step with a full solver view." in html
|
||||
assert 'href="/app/"' in html
|
||||
|
||||
|
||||
|
|
@ -532,7 +528,7 @@ def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None:
|
|||
lambda width, height, target_size, *, hinted_sizes=None: [(10, 8), (12, 10)],
|
||||
)
|
||||
|
||||
def fake_score_phase_group(
|
||||
def fake_score_size_candidate(
|
||||
rgba,
|
||||
*,
|
||||
target_width,
|
||||
|
|
@ -540,23 +536,19 @@ def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None:
|
|||
prior_cell_x,
|
||||
prior_cell_y,
|
||||
prior_reliability,
|
||||
phase_x_values,
|
||||
phase_y_values,
|
||||
device,
|
||||
):
|
||||
del rgba, prior_cell_x, prior_cell_y, prior_reliability, phase_x_values, phase_y_values, device
|
||||
del rgba, prior_cell_x, prior_cell_y, prior_reliability, device
|
||||
return [
|
||||
InferenceCandidate(
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
score=0.9 if target_width == 12 else 0.6,
|
||||
breakdown={},
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(inference_module, "_score_phase_group", fake_score_phase_group)
|
||||
monkeypatch.setattr(inference_module, "_score_size_candidate", fake_score_size_candidate)
|
||||
monkeypatch.setattr(inference_module, "_top_candidates_by_size", lambda candidates, limit: candidates[:limit])
|
||||
monkeypatch.setattr(
|
||||
inference_module,
|
||||
|
|
@ -583,16 +575,14 @@ def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None:
|
|||
assert events[2][1]["target_width"] == 12
|
||||
|
||||
|
||||
def test_phase_rerank_emits_candidate_progress_events(monkeypatch) -> None:
|
||||
def test_candidate_rerank_emits_candidate_progress_events(monkeypatch) -> None:
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[
|
||||
InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.75, breakdown={}),
|
||||
InferenceCandidate(target_width=18, target_height=18, phase_x=0.1, phase_y=-0.1, score=0.73, breakdown={}),
|
||||
InferenceCandidate(target_width=16, target_height=16, score=0.75, breakdown={}),
|
||||
InferenceCandidate(target_width=18, target_height=18, score=0.73, breakdown={}),
|
||||
],
|
||||
)
|
||||
analysis = PhaseFieldSourceAnalysis(edge_map=np.zeros((4, 4), dtype=np.float32))
|
||||
|
|
@ -629,23 +619,23 @@ def test_phase_rerank_emits_candidate_progress_events(monkeypatch) -> None:
|
|||
def observer(event: str, payload: dict[str, object]) -> None:
|
||||
events.append((event, payload))
|
||||
|
||||
pipeline_module._select_phase_candidate_with_reconstruction(
|
||||
pipeline_module._select_candidate_with_reconstruction(
|
||||
np.zeros((16, 16, 4), dtype=np.uint8),
|
||||
inference,
|
||||
analysis=analysis,
|
||||
steps=2,
|
||||
seed=1,
|
||||
device="cpu",
|
||||
solver_params=SolverHyperParams(phase_rerank_preview_steps=2, phase_rerank_confidence_threshold=1.0),
|
||||
solver_params=SolverHyperParams(candidate_rerank_preview_steps=2, candidate_rerank_confidence_threshold=1.0),
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
event_names = [event for event, _payload in events]
|
||||
assert event_names[0] == "phase_rerank_started"
|
||||
assert event_names.count("phase_rerank_candidate_started") == 2
|
||||
assert event_names.count("phase_rerank_candidate_step") == 6
|
||||
assert event_names.count("phase_rerank_candidate_completed") == 2
|
||||
first_step_payload = next(payload for event, payload in events if event == "phase_rerank_candidate_step")
|
||||
assert event_names[0] == "candidate_rerank_started"
|
||||
assert event_names.count("candidate_rerank_candidate_started") == 2
|
||||
assert event_names.count("candidate_rerank_candidate_step") == 6
|
||||
assert event_names.count("candidate_rerank_candidate_completed") == 2
|
||||
first_step_payload = next(payload for event, payload in events if event == "candidate_rerank_candidate_step")
|
||||
assert first_step_payload["candidate_index"] == 1
|
||||
assert first_step_payload["total_steps"] == 2
|
||||
|
||||
|
|
@ -866,19 +856,17 @@ def test_hosted_job_options_use_direct_autocorr_and_skip_rerank() -> None:
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
|
||||
assert options["target_size"] is None
|
||||
assert options["lattice_inference_mode"] == "autocorr"
|
||||
assert options["max_inferred_target_size"] == 256
|
||||
assert options["skip_phase_rerank"] is True
|
||||
assert options["skip_candidate_rerank"] is True
|
||||
|
||||
|
||||
def test_gui_job_routes_enforce_bound_subject_ownership(monkeypatch, tmp_path: Path) -> None:
|
||||
|
|
@ -907,13 +895,11 @@ def test_gui_job_routes_enforce_bound_subject_ownership(monkeypatch, tmp_path: P
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
|
@ -985,13 +971,11 @@ def test_gui_queue_rejects_eleventh_waiting_job(monkeypatch, tmp_path: Path) ->
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -1003,13 +987,11 @@ def test_gui_queue_rejects_eleventh_waiting_job(monkeypatch, tmp_path: Path) ->
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
|
|
@ -1044,13 +1026,11 @@ def test_gui_hosted_jobs_dispatch_direct_autocorr_pipeline(monkeypatch, tmp_path
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
deadline = time.time() + 2.0
|
||||
|
|
@ -1062,7 +1042,7 @@ def test_gui_hosted_jobs_dispatch_direct_autocorr_pipeline(monkeypatch, tmp_path
|
|||
assert calls
|
||||
assert calls[0]["lattice_inference_mode"] == "autocorr"
|
||||
assert calls[0]["max_inferred_target_size"] == 256
|
||||
assert calls[0]["enable_phase_rerank"] is False
|
||||
assert calls[0]["enable_candidate_rerank"] is False
|
||||
assert calls[0]["target_size"] is None
|
||||
|
||||
|
||||
|
|
@ -1090,13 +1070,11 @@ def test_gui_canceling_queued_job_cleans_spool_file(monkeypatch, tmp_path: Path)
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
|
|
@ -1105,13 +1083,11 @@ def test_gui_canceling_queued_job_cleans_spool_file(monkeypatch, tmp_path: Path)
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
|
@ -1158,13 +1134,11 @@ def test_gui_running_job_is_canceled_after_stale_heartbeat(monkeypatch, tmp_path
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
|
@ -1199,13 +1173,11 @@ def test_gui_rejects_oversized_upload_and_output(monkeypatch, tmp_path: Path) ->
|
|||
target_size=None,
|
||||
target_width=None,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
|
|
@ -1223,13 +1195,11 @@ def test_gui_rejects_oversized_upload_and_output(monkeypatch, tmp_path: Path) ->
|
|||
target_size=None,
|
||||
target_width=16,
|
||||
target_height=None,
|
||||
phase_x=None,
|
||||
phase_y=None,
|
||||
steps=None,
|
||||
seed=7,
|
||||
device="auto",
|
||||
strip_background=False,
|
||||
skip_phase_rerank=False,
|
||||
skip_candidate_rerank=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -141,21 +141,21 @@ def test_direct_badge_autocorr_consensus_stays_in_canonical_family() -> None:
|
|||
result = infer_autocorr_lattice(rgba, device="cpu")
|
||||
assert result.target_width in {125, 126}
|
||||
assert result.top_candidates
|
||||
assert all(candidate.target_width == result.target_width for candidate in result.top_candidates)
|
||||
assert result.top_candidates[0].target_width == result.target_width
|
||||
|
||||
|
||||
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={}),
|
||||
InferenceCandidate(target_width=113, target_height=113, phase_x=0.2, phase_y=0.0, score=0.89, breakdown={}),
|
||||
InferenceCandidate(target_width=117, target_height=117, phase_x=0.0, phase_y=0.0, score=0.88, breakdown={}),
|
||||
InferenceCandidate(target_width=128, target_height=128, phase_x=0.0, phase_y=0.0, score=0.87, breakdown={}),
|
||||
InferenceCandidate(target_width=113, target_height=113, score=0.90, breakdown={}),
|
||||
InferenceCandidate(target_width=113, target_height=113, score=0.89, breakdown={}),
|
||||
InferenceCandidate(target_width=117, target_height=117, score=0.88, breakdown={}),
|
||||
InferenceCandidate(target_width=128, target_height=128, score=0.87, breakdown={}),
|
||||
]
|
||||
selected = _top_candidates_by_size(candidates, limit=8)
|
||||
assert [(candidate.target_width, candidate.phase_x) for candidate in selected] == [
|
||||
(113, 0.0),
|
||||
(117, 0.0),
|
||||
(128, 0.0),
|
||||
assert [(candidate.target_width, candidate.target_height) for candidate in selected] == [
|
||||
(113, 113),
|
||||
(117, 117),
|
||||
(128, 128),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -164,8 +164,6 @@ def test_infer_lattice_recovers_emblem_scale() -> None:
|
|||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=12,
|
||||
phase_x=0.2,
|
||||
phase_y=0.35,
|
||||
blur_radius=0.75,
|
||||
warp_strength=0.28,
|
||||
warp_detail=6,
|
||||
|
|
@ -177,27 +175,23 @@ def test_infer_lattice_recovers_emblem_scale() -> None:
|
|||
assert result.confidence >= 0.0
|
||||
|
||||
|
||||
def test_infer_fixed_lattice_honors_exact_size_and_phase() -> None:
|
||||
def test_infer_fixed_lattice_uses_canonical_cell_centers_for_pinned_size() -> None:
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.2, phase_y=-0.2, blur_radius=0.4, seed=9)
|
||||
fake = fake_pixelize(source, upscale=10, blur_radius=0.4, seed=9)
|
||||
result = infer_fixed_lattice(
|
||||
fake,
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.2,
|
||||
phase_y=-0.2,
|
||||
device="cpu",
|
||||
)
|
||||
assert result.target_width == 16
|
||||
assert result.target_height == 16
|
||||
assert abs(result.phase_x - 0.2) <= 1e-6
|
||||
assert abs(result.phase_y + 0.2) <= 1e-6
|
||||
assert len(result.top_candidates) == 1
|
||||
|
||||
|
||||
def test_infer_fixed_lattice_searches_phase_within_pinned_size() -> None:
|
||||
def test_infer_fixed_lattice_uses_canonical_cell_centers_within_pinned_size() -> None:
|
||||
source = make_emblem(16, 16)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.2, phase_y=0.25, blur_radius=0.45, seed=3)
|
||||
fake = fake_pixelize(source, upscale=8, blur_radius=0.45, seed=3)
|
||||
result = infer_fixed_lattice(
|
||||
fake,
|
||||
target_width=16,
|
||||
|
|
@ -206,7 +200,7 @@ def test_infer_fixed_lattice_searches_phase_within_pinned_size() -> None:
|
|||
)
|
||||
assert result.target_width == 16
|
||||
assert result.target_height == 16
|
||||
assert len(result.top_candidates) > 1
|
||||
assert len(result.top_candidates) == 1
|
||||
assert all(candidate.target_width == 16 for candidate in result.top_candidates)
|
||||
assert all(candidate.target_height == 16 for candidate in result.top_candidates)
|
||||
|
||||
|
|
@ -245,13 +239,11 @@ def test_infer_lattice_honors_cooperative_cancellation(monkeypatch) -> None:
|
|||
)
|
||||
monkeypatch.setattr(
|
||||
inference_module,
|
||||
"_score_phase_group",
|
||||
"_score_size_candidate",
|
||||
lambda *args, **kwargs: [
|
||||
inference_module.InferenceCandidate(
|
||||
target_width=10,
|
||||
target_height=8,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
score=0.5,
|
||||
breakdown={},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -171,16 +171,12 @@ def test_source_lattice_consistency_prefers_matching_cell_structure() -> None:
|
|||
good,
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
)["score"]
|
||||
bad_score = source_lattice_consistency_breakdown(
|
||||
source,
|
||||
bad,
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
)["score"]
|
||||
|
||||
assert good_score < bad_score
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ def test_optimize_phase_field_emits_displacement_diagnostics_and_source_colors()
|
|||
inference = InferenceResult(
|
||||
target_width=6,
|
||||
target_height=6,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
|
@ -49,8 +47,6 @@ def test_optimize_phase_field_moves_off_zero_when_phase_is_wrong() -> None:
|
|||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=8,
|
||||
phase_x=0.18,
|
||||
phase_y=-0.14,
|
||||
blur_radius=0.35,
|
||||
warp_strength=0.12,
|
||||
warp_detail=4,
|
||||
|
|
@ -59,8 +55,6 @@ def test_optimize_phase_field_moves_off_zero_when_phase_is_wrong() -> None:
|
|||
inference = InferenceResult(
|
||||
target_width=18,
|
||||
target_height=18,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
|
@ -81,8 +75,6 @@ def test_optimize_phase_field_honors_cooperative_cancellation() -> None:
|
|||
inference = InferenceResult(
|
||||
target_width=12,
|
||||
target_height=12,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from repixelizer.analysis import analyze_phase_field_source
|
|||
from repixelizer.baselines import naive_resize_baseline
|
||||
from repixelizer.metrics import coherence_breakdown, foreground_reconstruction_error, source_lattice_consistency_breakdown
|
||||
from repixelizer.params import SolverHyperParams
|
||||
from repixelizer.pipeline import _run_reconstruction, _select_phase_candidate, run_pipeline
|
||||
from repixelizer.pipeline import _run_reconstruction, _select_candidate, run_pipeline
|
||||
from repixelizer.observe import PipelineCancelled
|
||||
from repixelizer.phase_field import optimize_phase_field
|
||||
from repixelizer.io import nearest_resize
|
||||
|
|
@ -20,7 +20,7 @@ from repixelizer.types import InferenceCandidate, InferenceResult, SolverArtifac
|
|||
|
||||
def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None:
|
||||
source = make_emblem(32, 32)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.15, phase_y=0.25, blur_radius=0.5)
|
||||
fake = fake_pixelize(source, upscale=10, blur_radius=0.5)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
diagnostics_dir = tmp_path / "diagnostics"
|
||||
|
|
@ -40,7 +40,7 @@ def test_pipeline_writes_output_and_diagnostics(tmp_path: Path) -> None:
|
|||
assert set(run_json["source_fidelity"].keys()) == {"initial_output", "solver_target", "final_output"}
|
||||
assert "source_structure" in run_json
|
||||
assert {"edge_f1", "exact_match_ratio", "score"} <= set(run_json["source_structure"].keys())
|
||||
assert "phase_rerank_candidates" in run_json
|
||||
assert "candidate_rerank_candidates" in run_json
|
||||
assert {"initial_output", "final_output"} <= set(run_json["optimizer_displacement"].keys())
|
||||
assert result.output_rgba.shape[0] == result.inference.target_height
|
||||
assert result.output_rgba.shape[1] == result.inference.target_width
|
||||
|
|
@ -51,8 +51,6 @@ def test_pipeline_beats_naive_on_roundtrip_fidelity_for_synthetic_emblem(tmp_pat
|
|||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=12,
|
||||
phase_x=0.2,
|
||||
phase_y=0.35,
|
||||
blur_radius=0.65,
|
||||
warp_strength=0.28,
|
||||
warp_detail=6,
|
||||
|
|
@ -75,7 +73,7 @@ def test_pipeline_beats_naive_on_roundtrip_fidelity_for_synthetic_emblem(tmp_pat
|
|||
|
||||
def test_pipeline_preserves_transparency_for_sprite(tmp_path: Path) -> None:
|
||||
source = make_sprite(24, 24)
|
||||
fake = fake_pixelize(source, upscale=9, phase_x=0.1, phase_y=0.22, blur_radius=0.45)
|
||||
fake = fake_pixelize(source, upscale=9, blur_radius=0.45)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
|
||||
|
|
@ -116,7 +114,7 @@ def test_pipeline_can_strip_checkerboard_background(tmp_path: Path) -> None:
|
|||
|
||||
def test_pipeline_can_emit_initialized_output_without_optimizer_steps(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.15, phase_y=0.2, blur_radius=0.6, warp_strength=0.2, warp_detail=5)
|
||||
fake = fake_pixelize(source, upscale=10, blur_radius=0.6, warp_strength=0.2, warp_detail=5)
|
||||
input_path = tmp_path / "input.png"
|
||||
output_path = tmp_path / "output.png"
|
||||
|
||||
|
|
@ -134,8 +132,6 @@ def test_fixed_phase_field_pipeline_output_matches_refactor_baseline(tmp_path: P
|
|||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=8,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
blur_radius=0.4,
|
||||
warp_strength=0.15,
|
||||
warp_detail=4,
|
||||
|
|
@ -152,33 +148,29 @@ def test_fixed_phase_field_pipeline_output_matches_refactor_baseline(tmp_path: P
|
|||
output_path,
|
||||
target_width=24,
|
||||
target_height=24,
|
||||
phase_x=0.1,
|
||||
phase_y=-0.05,
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
steps=0,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
output_hash = hashlib.sha256(np.ascontiguousarray(result.output_rgba).view(np.uint8)).hexdigest()
|
||||
assert output_hash == "9e01caa4ee4b508876d9a8360e5c8471f5ab07baf8ed1e5a08e76b9d3533c58e"
|
||||
assert output_hash == "6ff8f628e1d926e7a5f00d9dceea7decf079d2b070824e18ebe53676311631a0"
|
||||
|
||||
|
||||
def test_phase_rerank_can_override_low_confidence_inference_pick(monkeypatch) -> None:
|
||||
source = np.zeros((2, 2, 4), dtype=np.float32)
|
||||
def test_candidate_rerank_can_override_low_confidence_inference_pick(monkeypatch) -> None:
|
||||
source = np.zeros((3, 3, 4), dtype=np.float32)
|
||||
source[0, 0] = np.asarray([1.0, 0.0, 0.0, 1.0], dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.4, phase_y=0.2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.4, phase_y=0.2, score=0.909, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=3, target_height=3, score=0.909, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=candidate_a.phase_x,
|
||||
phase_y=candidate_a.phase_y,
|
||||
confidence=0.1,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
outputs = {
|
||||
(candidate_a.phase_x, candidate_a.phase_y): np.ones_like(source),
|
||||
(candidate_b.phase_x, candidate_b.phase_y): source.copy(),
|
||||
(candidate_a.target_width, candidate_a.target_height): np.ones((2, 2, 4), dtype=np.float32),
|
||||
(candidate_b.target_width, candidate_b.target_height): source.copy(),
|
||||
}
|
||||
|
||||
class DummyArtifacts:
|
||||
|
|
@ -187,24 +179,37 @@ def test_phase_rerank_can_override_low_confidence_inference_pick(monkeypatch) ->
|
|||
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
assert steps == 8
|
||||
return DummyArtifacts(outputs[(inference.phase_x, inference.phase_y)])
|
||||
return DummyArtifacts(outputs[(inference.target_width, inference.target_height)])
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr(
|
||||
"repixelizer.pipeline.source_lattice_consistency_breakdown",
|
||||
lambda source_rgba, output_rgba, *, target_width, target_height: {"score": 0.10 if target_width == 2 else 0.01},
|
||||
)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_position_error", lambda preview, source_rgba: 0.0)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", lambda preview, source_rgba: 0.0)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", lambda rgba: 0.0)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.phase_x == candidate_b.phase_x
|
||||
assert selected.phase_y == candidate_b.phase_y
|
||||
selected = _select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
steps=32,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
solver_params=SolverHyperParams(candidate_rerank_max_size_delta_ratio=1.0, candidate_rerank_margin=0.0),
|
||||
)
|
||||
assert selected.target_width == candidate_b.target_width
|
||||
assert selected.target_height == candidate_b.target_height
|
||||
|
||||
|
||||
def test_phase_rerank_preview_respects_requested_zero_steps(monkeypatch) -> None:
|
||||
def test_candidate_rerank_preview_respects_requested_zero_steps(monkeypatch) -> None:
|
||||
source = np.zeros((2, 2, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.4, phase_y=0.2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.4, phase_y=0.2, score=0.909, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=3, target_height=3, score=0.909, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=candidate_a.phase_x,
|
||||
phase_y=candidate_a.phase_y,
|
||||
confidence=0.1,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -215,22 +220,28 @@ def test_phase_rerank_preview_respects_requested_zero_steps(monkeypatch) -> None
|
|||
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
assert steps == 0
|
||||
return DummyArtifacts(np.zeros_like(source_rgba))
|
||||
return DummyArtifacts(np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32))
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
|
||||
_select_phase_candidate(source, inference, analysis=object(), steps=0, seed=7, device="cpu")
|
||||
_select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
solver_params=SolverHyperParams(candidate_rerank_max_size_delta_ratio=1.0),
|
||||
)
|
||||
|
||||
|
||||
def test_phase_rerank_honors_cooperative_cancellation(monkeypatch) -> None:
|
||||
def test_candidate_rerank_honors_cooperative_cancellation(monkeypatch) -> None:
|
||||
source = np.zeros((2, 2, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.4, phase_y=0.2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.4, phase_y=0.2, score=0.909, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=3, target_height=3, score=0.909, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=candidate_a.phase_x,
|
||||
phase_y=candidate_a.phase_y,
|
||||
confidence=0.1,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -243,7 +254,7 @@ def test_phase_rerank_honors_cooperative_cancellation(monkeypatch) -> None:
|
|||
return True
|
||||
|
||||
with pytest.raises(PipelineCancelled):
|
||||
_select_phase_candidate(
|
||||
_select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
|
|
@ -254,15 +265,13 @@ def test_phase_rerank_honors_cooperative_cancellation(monkeypatch) -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None:
|
||||
def test_candidate_rerank_can_override_to_better_size_candidate(monkeypatch) -> None:
|
||||
source = np.zeros((16, 16, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=18, target_height=18, phase_x=0.0, phase_y=0.0, score=0.88, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=18, target_height=18, score=0.88, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -275,7 +284,7 @@ def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None
|
|||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
return DummyArtifacts(rgba)
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height):
|
||||
if target_width == 16:
|
||||
return {"score": 0.10}
|
||||
return {"score": 0.01}
|
||||
|
|
@ -283,20 +292,18 @@ def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None
|
|||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
selected = _select_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.target_width == candidate_b.target_width
|
||||
assert selected.target_height == candidate_b.target_height
|
||||
|
||||
|
||||
def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
||||
def test_candidate_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
||||
source = np.zeros((16, 16, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=24, target_height=24, phase_x=0.0, phase_y=0.0, score=0.88, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=24, target_height=24, score=0.88, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -309,7 +316,7 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
|||
rgba = np.zeros((inference.target_height, inference.target_width, 4), dtype=np.float32)
|
||||
return DummyArtifacts(rgba)
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height):
|
||||
if target_width == 16:
|
||||
return {"score": 0.10}
|
||||
return {"score": 0.01}
|
||||
|
|
@ -317,36 +324,34 @@ def test_phase_rerank_rejects_large_size_jump(monkeypatch) -> None:
|
|||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
selected = _select_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.target_width == candidate_a.target_width
|
||||
assert selected.target_height == candidate_a.target_height
|
||||
|
||||
|
||||
def test_select_phase_candidate_can_skip_phase_rerank(monkeypatch) -> None:
|
||||
def test_select_candidate_can_skip_candidate_rerank(monkeypatch) -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=8, target_height=8, phase_x=0.0, phase_y=0.0, score=0.9, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=10, target_height=10, phase_x=0.2, phase_y=0.2, score=0.89, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=8, target_height=8, score=0.9, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=10, target_height=10, score=0.89, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=8,
|
||||
target_height=8,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise AssertionError("phase rerank probe should not run when disabled")
|
||||
raise AssertionError("candidate rerank probe should not run when disabled")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail)
|
||||
selected = _select_phase_candidate(
|
||||
selected = _select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
steps=32,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
)
|
||||
assert selected.target_width == candidate_a.target_width
|
||||
assert selected.target_height == candidate_a.target_height
|
||||
|
|
@ -365,8 +370,6 @@ def test_pipeline_runs_phase_field_mode(tmp_path: Path, monkeypatch) -> None:
|
|||
inference = InferenceResult(
|
||||
target_width=3,
|
||||
target_height=3,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
|
@ -407,7 +410,7 @@ def test_pipeline_runs_phase_field_mode(tmp_path: Path, monkeypatch) -> None:
|
|||
assert result.diagnostics["reconstruction"]["mode"] == "phase-field"
|
||||
|
||||
|
||||
def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, monkeypatch) -> None:
|
||||
def test_pipeline_fixed_target_skips_search_inference(tmp_path: Path, monkeypatch) -> None:
|
||||
source = np.zeros((6, 6, 4), dtype=np.float32)
|
||||
source[..., 3] = 1.0
|
||||
input_path = tmp_path / "input.png"
|
||||
|
|
@ -420,8 +423,6 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
inference = InferenceResult(
|
||||
target_width=3,
|
||||
target_height=3,
|
||||
phase_x=0.2,
|
||||
phase_y=-0.2,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
)
|
||||
|
|
@ -430,7 +431,7 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
edge_map = np.zeros((6, 6), dtype=np.float32)
|
||||
|
||||
def fail_infer(*args, **kwargs):
|
||||
raise AssertionError("full lattice search should not run for fixed target/phase")
|
||||
raise AssertionError("full lattice search should not run for fixed target")
|
||||
|
||||
def fake_infer_fixed(*args, **kwargs):
|
||||
return inference
|
||||
|
|
@ -456,9 +457,7 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
output_path,
|
||||
target_width=3,
|
||||
target_height=3,
|
||||
phase_x=0.2,
|
||||
phase_y=-0.2,
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
steps=0,
|
||||
device="cpu",
|
||||
)
|
||||
|
|
@ -466,8 +465,6 @@ def test_pipeline_fixed_target_and_phase_skip_search_inference(tmp_path: Path, m
|
|||
assert output_path.exists()
|
||||
assert result.inference.target_width == 3
|
||||
assert result.inference.target_height == 3
|
||||
assert result.inference.phase_x == 0.2
|
||||
assert result.inference.phase_y == -0.2
|
||||
|
||||
|
||||
def test_pipeline_autocorr_mode_skips_size_search(tmp_path: Path, monkeypatch) -> None:
|
||||
|
|
@ -483,11 +480,9 @@ def test_pipeline_autocorr_mode_skips_size_search(tmp_path: Path, monkeypatch) -
|
|||
inference = InferenceResult(
|
||||
target_width=3,
|
||||
target_height=3,
|
||||
phase_x=0.2,
|
||||
phase_y=-0.2,
|
||||
confidence=1.0,
|
||||
top_candidates=[
|
||||
InferenceCandidate(target_width=3, target_height=3, phase_x=0.2, phase_y=-0.2, score=0.9, breakdown={})
|
||||
InferenceCandidate(target_width=3, target_height=3, score=0.9, breakdown={})
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -520,7 +515,7 @@ def test_pipeline_autocorr_mode_skips_size_search(tmp_path: Path, monkeypatch) -
|
|||
result = run_pipeline(
|
||||
input_path,
|
||||
output_path,
|
||||
enable_phase_rerank=False,
|
||||
enable_candidate_rerank=False,
|
||||
lattice_inference_mode="autocorr",
|
||||
max_inferred_target_size=5,
|
||||
steps=0,
|
||||
|
|
@ -530,19 +525,15 @@ def test_pipeline_autocorr_mode_skips_size_search(tmp_path: Path, monkeypatch) -
|
|||
assert output_path.exists()
|
||||
assert result.inference.target_width == 3
|
||||
assert result.inference.target_height == 3
|
||||
assert result.inference.phase_x == 0.2
|
||||
assert result.inference.phase_y == -0.2
|
||||
|
||||
|
||||
def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(monkeypatch) -> None:
|
||||
def test_candidate_rerank_can_accept_low_confidence_size_jump_with_strong_support(monkeypatch) -> None:
|
||||
source = np.zeros((16, 16, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=21, target_height=21, phase_x=0.2, phase_y=-0.2, score=0.89, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=16, target_height=16, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=21, target_height=21, score=0.89, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=16,
|
||||
target_height=16,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -559,7 +550,7 @@ def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(mo
|
|||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
return DummyArtifacts(outputs[inference.target_width])
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height):
|
||||
return {"score": 0.22 if target_width == 16 else 0.03}
|
||||
|
||||
def fake_edge_position(preview, source_rgba):
|
||||
|
|
@ -577,20 +568,18 @@ def test_phase_rerank_can_accept_low_confidence_size_jump_with_strong_support(mo
|
|||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", fake_concentration)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
selected = _select_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.target_width == candidate_b.target_width
|
||||
assert selected.target_height == candidate_b.target_height
|
||||
|
||||
|
||||
def test_phase_rerank_keeps_high_confidence_candidate_without_probe(monkeypatch) -> None:
|
||||
def test_candidate_rerank_keeps_high_confidence_candidate_without_probe(monkeypatch) -> None:
|
||||
source = np.zeros((8, 8, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=8, target_height=8, phase_x=0.0, phase_y=0.0, score=0.9, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=10, target_height=10, phase_x=0.2, phase_y=0.2, score=0.89, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=8, target_height=8, score=0.9, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=10, target_height=10, score=0.89, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=8,
|
||||
target_height=8,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
confidence=0.5,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -599,20 +588,18 @@ def test_phase_rerank_keeps_high_confidence_candidate_without_probe(monkeypatch)
|
|||
raise AssertionError("rerank probe should not run for high-confidence inference")
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fail)
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
selected = _select_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.target_width == candidate_a.target_width
|
||||
assert selected.target_height == candidate_a.target_height
|
||||
|
||||
|
||||
def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
||||
def test_candidate_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
||||
source = np.zeros((4, 4, 4), dtype=np.float32)
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.2, phase_y=0.0, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.2, phase_y=0.0, score=0.909, breakdown={})
|
||||
candidate_a = InferenceCandidate(target_width=2, target_height=2, score=0.91, breakdown={})
|
||||
candidate_b = InferenceCandidate(target_width=3, target_height=3, score=0.909, breakdown={})
|
||||
inference = InferenceResult(
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=candidate_a.phase_x,
|
||||
phase_y=candidate_a.phase_y,
|
||||
confidence=0.0,
|
||||
top_candidates=[candidate_a, candidate_b],
|
||||
)
|
||||
|
|
@ -621,7 +608,7 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
|||
def __init__(self, rgba: np.ndarray) -> None:
|
||||
self.target_rgba = rgba
|
||||
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height, phase_x, phase_y):
|
||||
def fake_support(source_rgba, output_rgba, *, target_width, target_height):
|
||||
return {"score": 0.02}
|
||||
|
||||
def fake_edge_position(preview, source_rgba):
|
||||
|
|
@ -634,12 +621,12 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
|||
return 0.15 if np.mean(rgba) < 0.01 else 0.28
|
||||
|
||||
outputs = {
|
||||
candidate_a.phase_x: np.zeros((2, 2, 4), dtype=np.float32),
|
||||
candidate_b.phase_x: np.ones((2, 2, 4), dtype=np.float32),
|
||||
candidate_a.target_width: np.zeros((2, 2, 4), dtype=np.float32),
|
||||
candidate_b.target_width: np.ones((3, 3, 4), dtype=np.float32),
|
||||
}
|
||||
|
||||
def fake_optimize_phase_field(source_rgba, inference, analysis, steps, seed, device, solver_params=None):
|
||||
return DummyArtifacts(outputs[inference.phase_x])
|
||||
return DummyArtifacts(outputs[inference.target_width])
|
||||
|
||||
monkeypatch.setattr("repixelizer.pipeline.optimize_phase_field", fake_optimize_phase_field)
|
||||
monkeypatch.setattr("repixelizer.pipeline.source_lattice_consistency_breakdown", fake_support)
|
||||
|
|
@ -647,6 +634,19 @@ def test_phase_rerank_can_prefer_better_line_metrics(monkeypatch) -> None:
|
|||
monkeypatch.setattr("repixelizer.pipeline.foreground_stroke_wobble_error", fake_wobble)
|
||||
monkeypatch.setattr("repixelizer.pipeline.foreground_edge_concentration", fake_concentration)
|
||||
|
||||
selected = _select_phase_candidate(source, inference, analysis=object(), steps=32, seed=7, device="cpu")
|
||||
assert selected.phase_x == candidate_b.phase_x
|
||||
assert selected.phase_y == candidate_b.phase_y
|
||||
selected = _select_candidate(
|
||||
source,
|
||||
inference,
|
||||
analysis=object(),
|
||||
steps=32,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
solver_params=SolverHyperParams(
|
||||
candidate_rerank_size_penalty_weight=0.0,
|
||||
candidate_rerank_inference_penalty_weight=0.0,
|
||||
candidate_rerank_max_size_delta_ratio=1.0,
|
||||
candidate_rerank_margin=0.0,
|
||||
),
|
||||
)
|
||||
assert selected.target_width == candidate_b.target_width
|
||||
assert selected.target_height == candidate_b.target_height
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ def test_source_lattice_reference_recovers_stable_cell_colors() -> None:
|
|||
source,
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
)
|
||||
|
||||
assert np.allclose(reference.sharp_rgba, lowres, atol=1e-5)
|
||||
|
|
@ -44,8 +42,6 @@ def test_source_lattice_reference_tracks_edge_peaks_and_gradients() -> None:
|
|||
source,
|
||||
target_width=2,
|
||||
target_height=2,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
edge_hint=edge_hint,
|
||||
edge_grad_x_hint=edge_grad_x,
|
||||
edge_grad_y_hint=edge_grad_y,
|
||||
|
|
@ -65,15 +61,11 @@ def test_source_lattice_reference_cpu_device_matches_default_path() -> None:
|
|||
source,
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
)
|
||||
accelerated = build_source_lattice_reference(
|
||||
source,
|
||||
target_width=4,
|
||||
target_height=4,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
|
|
|
|||