Add streaming TypeScript GUI

This commit is contained in:
Meta Vens 2026-04-24 15:02:08 +01:00
commit 66595cafe5
16 changed files with 3260 additions and 36 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ htmlcov/
build/
dist/
*.egg-info/
frontend/node_modules/
.vscode/
.idea/

29
frontend/package-lock.json generated Normal file
View file

@ -0,0 +1,29 @@
{
"name": "repixelizer-gui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "repixelizer-gui",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

13
frontend/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "repixelizer-gui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc -p ./tsconfig.json",
"watch": "tsc -w -p ./tsconfig.json"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

965
frontend/src/app.ts Normal file
View file

@ -0,0 +1,965 @@
type JsonRecord = Record<string, unknown>;
type ImageAsset = {
dataUrl: string;
width: number;
height: number;
};
type InferenceCandidate = {
target_width: number;
target_height: number;
phase_x: number;
phase_y: number;
score: number;
breakdown: Record<string, number | null | undefined>;
};
type InferencePayload = {
target_width: number;
target_height: number;
phase_x: number;
phase_y: number;
confidence: number;
top_candidates: InferenceCandidate[];
};
type FramePayload = {
step: number;
totalSteps: number;
loss: number | null;
terms: Record<string, number>;
phaseMetrics: Record<string, number>;
outputImage: ImageAsset;
samplingOverlayImage: ImageAsset;
displacementImage: ImageAsset;
lossHistory: number[];
};
type LogItem = {
label: string;
detail: string;
};
type AppState = {
file: File | null;
jobId: string | null;
status: string;
statusText: string;
sourceImage: ImageAsset | null;
preprocessedImage: ImageAsset | null;
edgeMapImage: ImageAsset | null;
latticeImage: ImageAsset | null;
guidanceImage: ImageAsset | null;
inference: InferencePayload | null;
frames: FramePayload[];
cleanupImage: ImageAsset | null;
heatmapImage: ImageAsset | null;
finalOutputImage: ImageAsset | null;
runSummary: JsonRecord | null;
selectedFrameIndex: number;
autoFollow: boolean;
eventLog: LogItem[];
paintColor: [number, number, number, number];
editorBaseAsset: ImageAsset | null;
editorDirty: boolean;
altHeld: boolean;
zoom: number;
showGrid: boolean;
currentStage: string;
};
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Missing element: ${id}`);
}
return element as T;
}
const fileInput = byId<HTMLInputElement>("fileInput");
const dropzone = byId<HTMLButtonElement>("dropzone");
const dropzoneLabel = byId<HTMLSpanElement>("dropzoneLabel");
const pickFileButton = byId<HTMLButtonElement>("pickFileButton");
const runButton = byId<HTMLButtonElement>("runButton");
const statusBadge = byId<HTMLSpanElement>("statusBadge");
const statusText = byId<HTMLParagraphElement>("statusText");
const inferenceSummary = byId<HTMLDivElement>("inferenceSummary");
const candidateList = byId<HTMLDivElement>("candidateList");
const eventLog = byId<HTMLDivElement>("eventLog");
const viewerTitle = byId<HTMLHeadingElement>("viewerTitle");
const stepSlider = byId<HTMLInputElement>("stepSlider");
const stepValue = byId<HTMLSpanElement>("stepValue");
const lossCanvas = byId<HTMLCanvasElement>("lossCanvas");
const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
const middleCanvas = byId<HTMLCanvasElement>("middleCanvas");
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
const middleVizLabel = byId<HTMLDivElement>("middleVizLabel");
const rightVizLabel = byId<HTMLDivElement>("rightVizLabel");
const metricsPanel = byId<HTMLDivElement>("metricsPanel");
const paintSwatch = byId<HTMLDivElement>("paintSwatch");
const toolMode = byId<HTMLSpanElement>("toolMode");
const zoomInput = byId<HTMLInputElement>("zoomInput");
const zoomValue = byId<HTMLSpanElement>("zoomValue");
const gridToggle = byId<HTMLInputElement>("gridToggle");
const editorCanvas = byId<HTMLCanvasElement>("editorCanvas");
const editorGridCanvas = byId<HTMLCanvasElement>("editorGridCanvas");
const editorSurface = byId<HTMLDivElement>("editorSurface");
const editorMeta = byId<HTMLDivElement>("editorMeta");
const summaryPanel = byId<HTMLDivElement>("summaryPanel");
const resetEditorButton = byId<HTMLButtonElement>("resetEditorButton");
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 deviceInput = byId<HTMLSelectElement>("deviceInput");
const stripBackgroundInput = byId<HTMLInputElement>("stripBackgroundInput");
const skipRerankInput = byId<HTMLInputElement>("skipRerankInput");
const paintInputs = {
r: byId<HTMLInputElement>("paintR"),
g: byId<HTMLInputElement>("paintG"),
b: byId<HTMLInputElement>("paintB"),
a: byId<HTMLInputElement>("paintA"),
};
const imageCache = new Map<string, Promise<HTMLImageElement>>();
const eventTypes = [
"job_state",
"job_failed",
"source_loaded",
"preprocess_completed",
"inference_candidates_ready",
"phase_rerank_started",
"phase_selection_completed",
"analysis_completed",
"phase_field_prepared",
"phase_field_initial",
"phase_field_step",
"phase_field_final",
"cleanup_completed",
"palette_completed",
"pipeline_completed",
] as const;
const state: AppState = {
file: null,
jobId: null,
status: "idle",
statusText: "Drop a fake sprite in here and let the machine explain itself for once.",
sourceImage: null,
preprocessedImage: null,
edgeMapImage: null,
latticeImage: null,
guidanceImage: null,
inference: null,
frames: [],
cleanupImage: null,
heatmapImage: null,
finalOutputImage: null,
runSummary: null,
selectedFrameIndex: 0,
autoFollow: true,
eventLog: [],
paintColor: [255, 255, 255, 255],
editorBaseAsset: null,
editorDirty: false,
altHeld: false,
zoom: Number(zoomInput.value),
showGrid: gridToggle.checked,
currentStage: "Idle",
};
let currentEventSource: EventSource | null = null;
let painting = false;
let offscreenCanvas: HTMLCanvasElement | null = null;
let offscreenContext: CanvasRenderingContext2D | null = null;
let singlePixel = new ImageData(1, 1);
function clampByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function setPaintColor(color: [number, number, number, number]): void {
state.paintColor = color;
paintInputs.r.value = String(color[0]);
paintInputs.g.value = String(color[1]);
paintInputs.b.value = String(color[2]);
paintInputs.a.value = String(color[3]);
paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
}
function updateToolMode(): void {
toolMode.textContent = state.altHeld ? "Eyedropper armed. Sample a pixel." : "Pencil. Hold Alt for eyedropper.";
}
function addLog(label: string, detail: string): void {
state.eventLog.unshift({ label, detail });
state.eventLog = state.eventLog.slice(0, 8);
renderEventLog();
}
function renderEventLog(): void {
eventLog.innerHTML = "";
if (state.eventLog.length === 0) {
eventLog.innerHTML = `<p class="muted">Nothing to report yet.</p>`;
return;
}
for (const entry of state.eventLog) {
const node = document.createElement("div");
node.className = "event-item";
node.innerHTML = `<strong>${entry.label}</strong><span>${entry.detail}</span>`;
eventLog.appendChild(node);
}
}
function setStatus(status: string, detail: string): void {
state.status = status;
state.statusText = detail;
statusBadge.textContent = status;
statusText.textContent = detail;
}
function resetRunArtifacts(): void {
state.jobId = null;
state.sourceImage = null;
state.preprocessedImage = null;
state.edgeMapImage = null;
state.latticeImage = null;
state.guidanceImage = null;
state.inference = null;
state.frames = [];
state.cleanupImage = null;
state.heatmapImage = null;
state.finalOutputImage = null;
state.runSummary = null;
state.selectedFrameIndex = 0;
state.autoFollow = true;
state.eventLog = [];
state.editorBaseAsset = null;
state.editorDirty = false;
state.currentStage = "Waiting";
}
function parseOptionalInteger(input: HTMLInputElement): number | null {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? Math.round(value) : null;
}
function parseOptionalFloat(input: HTMLInputElement): number | null {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? value : null;
}
async function loadImage(asset: ImageAsset): Promise<HTMLImageElement> {
const cached = imageCache.get(asset.dataUrl);
if (cached) {
return cached;
}
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image asset"));
image.src = asset.dataUrl;
});
imageCache.set(asset.dataUrl, promise);
return promise;
}
async function drawAsset(canvas: HTMLCanvasElement, asset: ImageAsset | null): Promise<void> {
const context = canvas.getContext("2d");
if (!context) {
return;
}
if (!asset) {
canvas.width = 640;
canvas.height = 360;
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#081012";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "rgba(255,255,255,0.2)";
context.font = "16px sans-serif";
context.fillText("Waiting for image data", 24, 32);
return;
}
const image = await loadImage(asset);
canvas.width = image.naturalWidth || asset.width;
canvas.height = image.naturalHeight || asset.height;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
function readNestedNumber(root: JsonRecord | null, path: string[]): number | null {
let current: unknown = root;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
return null;
}
current = (current as JsonRecord)[key];
}
return typeof current === "number" ? current : null;
}
function formatNumber(value: number | null, digits = 3): string {
if (value === null || !Number.isFinite(value)) {
return "n/a";
}
return value.toFixed(digits);
}
function getSelectedFrame(): FramePayload | null {
if (state.frames.length === 0) {
return null;
}
return state.frames[Math.max(0, Math.min(state.selectedFrameIndex, state.frames.length - 1))];
}
function renderInference(): void {
if (!state.inference) {
inferenceSummary.innerHTML = `<p class="muted">No lattice picked yet.</p>`;
candidateList.innerHTML = "";
return;
}
inferenceSummary.innerHTML = `
<div class="summary-card">
<strong>Chosen Grid</strong>
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
</div>
<div class="summary-card">
<strong>Phase</strong>
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
</div>
<div class="summary-card">
<strong>Confidence</strong>
<span>${formatNumber(state.inference.confidence, 3)}</span>
</div>
`;
candidateList.innerHTML = "";
const topCandidates = state.inference.top_candidates.slice(0, 6);
for (const candidate of topCandidates) {
const node = document.createElement("div");
node.className = "candidate-card";
const rerank = candidate.breakdown["phase_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);
}
}
function renderMetrics(): void {
metricsPanel.innerHTML = "";
const frame = getSelectedFrame();
const items: Array<[string, string]> = [];
if (frame) {
items.push(["Frame", `${frame.step}/${frame.totalSteps}`]);
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
for (const [key, value] of Object.entries(frame.terms)) {
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
}
} else if (state.runSummary) {
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
items.push([
"Final fidelity",
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
]);
items.push(["Source color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
} else {
items.push(["Status", state.currentStage]);
}
for (const [label, value] of items) {
const node = document.createElement("div");
node.className = "metric-chip";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
metricsPanel.appendChild(node);
}
}
function renderSummary(): void {
summaryPanel.innerHTML = "";
if (!state.runSummary) {
summaryPanel.innerHTML = `<p class="muted">Final metrics show up here after the run finishes.</p>`;
return;
}
const entries: Array<[string, number | null]> = [
["Structure score", readNestedNumber(state.runSummary, ["source_structure", "score"])],
["Edge F1", readNestedNumber(state.runSummary, ["source_structure", "edge_f1"])],
["Exact match", readNestedNumber(state.runSummary, ["source_structure", "exact_match_ratio"])],
["Final fidelity", readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])],
["Mean displacement", readNestedNumber(state.runSummary, ["optimizer_displacement", "final_output", "mean_magnitude_px"])],
];
for (const [label, value] of entries) {
const card = document.createElement("div");
card.className = "summary-card";
card.innerHTML = `<strong>${label}</strong><span>${formatNumber(value, 3)}</span>`;
summaryPanel.appendChild(card);
}
}
async function renderViewer(): Promise<void> {
const frame = getSelectedFrame();
let leftAsset = state.preprocessedImage ?? state.sourceImage;
let middleAsset = state.edgeMapImage;
let rightAsset = state.finalOutputImage ?? state.cleanupImage ?? state.sourceImage;
let leftLabel = "Source";
let middleLabel = "Edge Scout";
let rightLabel = "Output";
let title = state.currentStage;
if (state.latticeImage) {
leftAsset = state.latticeImage;
leftLabel = "Lattice Prep";
}
if (state.guidanceImage) {
middleAsset = state.guidanceImage;
middleLabel = "Guidance";
}
if (frame) {
leftAsset = frame.samplingOverlayImage;
middleAsset = frame.displacementImage;
rightAsset = frame.outputImage;
leftLabel = "Sampling Overlay";
middleLabel = "Displacement";
rightLabel = "Current Output";
title = frame.step === 0 ? "Initial nearest-sample placement" : `Solver step ${frame.step} / ${frame.totalSteps}`;
} else if (state.cleanupImage) {
middleAsset = state.heatmapImage ?? middleAsset;
middleLabel = "Cleanup Heatmap";
rightAsset = state.cleanupImage;
rightLabel = "Cleaned Output";
title = state.finalOutputImage ? "Finalized output" : "Cleanup pass";
}
viewerTitle.textContent = title;
leftVizLabel.textContent = leftLabel;
middleVizLabel.textContent = middleLabel;
rightVizLabel.textContent = rightLabel;
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(middleCanvas, middleAsset), drawAsset(rightCanvas, rightAsset)]);
renderMetrics();
}
function renderSlider(): void {
if (state.frames.length === 0) {
stepSlider.disabled = true;
stepSlider.max = "0";
stepSlider.value = "0";
stepValue.textContent = "No frames yet";
return;
}
stepSlider.disabled = false;
stepSlider.max = String(state.frames.length - 1);
stepSlider.value = String(state.selectedFrameIndex);
const frame = getSelectedFrame();
if (!frame) {
stepValue.textContent = "No frames yet";
return;
}
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
}
function renderLossChart(): void {
const context = lossCanvas.getContext("2d");
if (!context) {
return;
}
const width = lossCanvas.width;
const height = lossCanvas.height;
context.clearRect(0, 0, width, height);
context.fillStyle = "rgba(7, 15, 17, 0.96)";
context.fillRect(0, 0, width, height);
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
for (let index = 1; index <= 4; index += 1) {
const y = (height / 5) * index;
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
const losses = state.frames
.filter((frame) => typeof frame.loss === "number")
.map((frame) => frame.loss as number);
if (losses.length === 0) {
context.fillStyle = "rgba(255,255,255,0.28)";
context.font = "14px sans-serif";
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 28);
return;
}
const minLoss = Math.min(...losses);
const maxLoss = Math.max(...losses);
const span = Math.max(1e-6, maxLoss - minLoss);
context.strokeStyle = "#8fe2c8";
context.lineWidth = 3;
context.beginPath();
losses.forEach((loss, index) => {
const x = losses.length === 1 ? width / 2 : (index / (losses.length - 1)) * (width - 24) + 12;
const y = height - 18 - ((loss - minLoss) / span) * (height - 36);
if (index === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
});
context.stroke();
const selected = getSelectedFrame();
if (selected && selected.loss !== null) {
const matchingIndex = losses.findIndex((value) => Math.abs(value - selected.loss!) < 1e-6);
if (matchingIndex >= 0) {
const x = losses.length === 1 ? width / 2 : (matchingIndex / (losses.length - 1)) * (width - 24) + 12;
const y = height - 18 - (((selected.loss as number) - minLoss) / span) * (height - 36);
context.fillStyle = "#f5b76b";
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
}
}
}
async function loadEditorAsset(asset: ImageAsset | null): Promise<void> {
state.editorBaseAsset = asset;
state.editorDirty = false;
if (!asset) {
offscreenCanvas = null;
offscreenContext = null;
renderEditor();
return;
}
const image = await loadImage(asset);
offscreenCanvas = document.createElement("canvas");
offscreenCanvas.width = image.naturalWidth || asset.width;
offscreenCanvas.height = image.naturalHeight || asset.height;
offscreenContext = offscreenCanvas.getContext("2d");
if (!offscreenContext) {
return;
}
offscreenContext.imageSmoothingEnabled = false;
offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
offscreenContext.drawImage(image, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
renderEditor();
}
function renderEditor(): void {
const displayContext = editorCanvas.getContext("2d");
const gridContext = editorGridCanvas.getContext("2d");
if (!displayContext || !gridContext) {
return;
}
if (!offscreenCanvas) {
editorCanvas.width = 1;
editorCanvas.height = 1;
editorGridCanvas.width = 1;
editorGridCanvas.height = 1;
editorMeta.innerHTML = `<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>`;
return;
}
const width = offscreenCanvas.width * state.zoom;
const height = offscreenCanvas.height * state.zoom;
for (const canvas of [editorCanvas, editorGridCanvas]) {
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
displayContext.imageSmoothingEnabled = false;
displayContext.clearRect(0, 0, width, height);
displayContext.drawImage(offscreenCanvas, 0, 0, width, height);
gridContext.clearRect(0, 0, width, height);
if (state.showGrid && state.zoom >= 8) {
gridContext.strokeStyle = "rgba(255,255,255,0.12)";
gridContext.lineWidth = 1;
for (let x = 0; x <= offscreenCanvas.width; x += 1) {
const xpos = x * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(xpos, 0);
gridContext.lineTo(xpos, height);
gridContext.stroke();
}
for (let y = 0; y <= offscreenCanvas.height; y += 1) {
const ypos = y * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(0, ypos);
gridContext.lineTo(width, ypos);
gridContext.stroke();
}
}
editorMeta.innerHTML = `
<div class="summary-card">
<strong>Canvas</strong>
<span>${offscreenCanvas.width} x ${offscreenCanvas.height}</span>
</div>
<div class="summary-card">
<strong>State</strong>
<span>${state.editorDirty ? "Edited" : "Matches solver output"}</span>
</div>
`;
}
function editorPixelFromPointer(event: PointerEvent): { x: number; y: number } | null {
if (!offscreenCanvas) {
return null;
}
const rect = editorCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return null;
}
const x = Math.floor((event.clientX - rect.left) / state.zoom);
const y = Math.floor((event.clientY - rect.top) / state.zoom);
if (x < 0 || y < 0 || x >= offscreenCanvas.width || y >= offscreenCanvas.height) {
return null;
}
return { x, y };
}
function samplePaintColor(x: number, y: number): void {
if (!offscreenContext) {
return;
}
const pixel = offscreenContext.getImageData(x, y, 1, 1).data;
setPaintColor([pixel[0], pixel[1], pixel[2], pixel[3]]);
}
function paintPixel(x: number, y: number): void {
if (!offscreenContext) {
return;
}
singlePixel.data[0] = state.paintColor[0];
singlePixel.data[1] = state.paintColor[1];
singlePixel.data[2] = state.paintColor[2];
singlePixel.data[3] = state.paintColor[3];
offscreenContext.putImageData(singlePixel, x, y);
state.editorDirty = true;
renderEditor();
}
function exportEditorPng(): void {
if (!offscreenCanvas) {
return;
}
const link = document.createElement("a");
link.href = offscreenCanvas.toDataURL("image/png");
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
link.click();
}
function buildFormData(): FormData {
if (!state.file) {
throw new Error("No file selected");
}
const data = new FormData();
data.set("image", state.file);
const values: Array<[string, string | null]> = [
["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(parseOptionalInteger(stepsInput) ?? 48)],
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
["device", deviceInput.value],
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
];
for (const [key, value] of values) {
if (value !== null) {
data.set(key, value);
}
}
return data;
}
async function renderEverything(): Promise<void> {
renderInference();
renderSlider();
renderLossChart();
renderSummary();
await renderViewer();
}
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
switch (eventName) {
case "job_state":
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
break;
case "job_failed":
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
break;
case "source_loaded":
state.sourceImage = payload.sourceImage as ImageAsset;
state.currentStage = "Loaded source";
setStatus("running", "Source loaded. Survey crew is measuring the mess.");
addLog("Source", "Loaded the input image.");
break;
case "preprocess_completed":
state.preprocessedImage = payload.sourceImage as ImageAsset;
state.currentStage = "Preprocessed source";
addLog("Preprocess", "Stripped edge-connected background noise.");
break;
case "inference_candidates_ready":
state.inference = payload.inference as InferencePayload;
state.currentStage = "Scoring lattice candidates";
addLog("Inference", "Scored candidate grids and phase offsets.");
break;
case "phase_rerank_started":
state.currentStage = "Phase rerank preview";
addLog(
"Rerank",
`Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`,
);
break;
case "phase_selection_completed":
state.inference = payload.inference as InferencePayload;
state.currentStage = "Selected lattice";
addLog("Selection", "Committed to a ruler and phase.");
break;
case "analysis_completed":
state.edgeMapImage = payload.edgeMapImage as ImageAsset;
state.currentStage = "Edge scout";
addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
break;
case "phase_field_prepared":
state.latticeImage = payload.latticeImage as ImageAsset;
state.guidanceImage = payload.guidanceImage as ImageAsset;
state.currentStage = "Prepared phase field";
addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`);
break;
case "phase_field_initial":
case "phase_field_step":
case "phase_field_final": {
const frame = payload as unknown as FramePayload;
const existingIndex = state.frames.findIndex((candidate) => candidate.step === frame.step);
if (existingIndex >= 0) {
state.frames[existingIndex] = frame;
} else {
state.frames.push(frame);
state.frames.sort((a, b) => a.step - b.step);
}
if (state.autoFollow) {
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step));
}
state.currentStage = frame.step === 0 ? "Initial placement" : `Solver step ${frame.step}/${frame.totalSteps}`;
if (eventName === "phase_field_final") {
addLog("Solver", "Final nearest-source sample committed.");
}
break;
}
case "cleanup_completed":
state.cleanupImage = payload.cleanedImage as ImageAsset;
state.heatmapImage = payload.heatmapImage as ImageAsset;
state.currentStage = "Cleanup";
addLog("Cleanup", "Ran the local cleanup sweep.");
break;
case "palette_completed":
state.finalOutputImage = payload.outputImage as ImageAsset;
state.currentStage = "Final output";
addLog("Output", "Final output image is ready.");
break;
case "pipeline_completed":
state.finalOutputImage = payload.outputImage as ImageAsset;
state.runSummary = (payload.runSummary as JsonRecord | null) ?? null;
state.currentStage = "Completed";
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
addLog("Done", "Full pipeline completed.");
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
break;
default:
break;
}
await renderEverything();
}
function connectEventStream(jobId: string): void {
currentEventSource?.close();
const stream = new EventSource(`/api/jobs/${jobId}/events`);
currentEventSource = stream;
for (const eventType of eventTypes) {
stream.addEventListener(eventType, (raw) => {
const message = raw as MessageEvent<string>;
const payload = JSON.parse(message.data) as JsonRecord;
void handleEvent(eventType, payload);
});
}
stream.onerror = () => {
if (state.status === "running") {
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
}
};
}
async function startRun(): Promise<void> {
if (!state.file) {
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
return;
}
resetRunArtifacts();
renderEventLog();
runButton.disabled = true;
setStatus("queued", "Queued the run. Small miracle pending.");
addLog("Queued", `Starting ${state.file.name}.`);
await renderEverything();
try {
const response = await fetch("/api/jobs", {
method: "POST",
body: buildFormData(),
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as { jobId: string };
state.jobId = payload.jobId;
connectEventStream(payload.jobId);
} catch (error) {
runButton.disabled = false;
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
}
}
function acceptFile(file: File): void {
state.file = file;
dropzoneLabel.textContent = file.name;
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
}
dropzone.addEventListener("click", () => fileInput.click());
pickFileButton.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
acceptFile(file);
}
});
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
});
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
const file = event.dataTransfer?.files?.[0];
if (file) {
acceptFile(file);
}
});
runButton.addEventListener("click", () => {
void startRun();
});
stepSlider.addEventListener("input", () => {
state.selectedFrameIndex = Number(stepSlider.value);
state.autoFollow = state.selectedFrameIndex >= state.frames.length - 1;
void renderEverything();
});
zoomInput.addEventListener("input", () => {
state.zoom = Number(zoomInput.value);
zoomValue.textContent = `${state.zoom}x`;
renderEditor();
});
gridToggle.addEventListener("change", () => {
state.showGrid = gridToggle.checked;
renderEditor();
});
for (const input of Object.values(paintInputs)) {
input.addEventListener("change", () => {
setPaintColor([
clampByte(Number(paintInputs.r.value)),
clampByte(Number(paintInputs.g.value)),
clampByte(Number(paintInputs.b.value)),
clampByte(Number(paintInputs.a.value)),
]);
});
}
window.addEventListener("keydown", (event) => {
if (event.key === "Alt") {
state.altHeld = true;
updateToolMode();
}
});
window.addEventListener("keyup", (event) => {
if (event.key === "Alt") {
state.altHeld = false;
updateToolMode();
}
});
editorCanvas.addEventListener("pointerdown", (event) => {
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
event.preventDefault();
editorCanvas.setPointerCapture(event.pointerId);
if (event.altKey || state.altHeld) {
samplePaintColor(pixel.x, pixel.y);
updateToolMode();
return;
}
painting = true;
paintPixel(pixel.x, pixel.y);
});
editorCanvas.addEventListener("pointermove", (event) => {
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
if (event.altKey || state.altHeld) {
updateToolMode();
if (event.buttons === 1) {
samplePaintColor(pixel.x, pixel.y);
}
return;
}
if (painting && event.buttons === 1) {
paintPixel(pixel.x, pixel.y);
}
});
editorCanvas.addEventListener("pointerup", () => {
painting = false;
});
editorCanvas.addEventListener("pointerleave", () => {
painting = false;
});
resetEditorButton.addEventListener("click", () => {
void loadEditorAsset(state.editorBaseAsset);
});
downloadButton.addEventListener("click", () => {
exportEditorPng();
});
setPaintColor(state.paintColor);
updateToolMode();
zoomValue.textContent = `${state.zoom}x`;
renderEventLog();
void renderEverything();

13
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"lib": ["DOM", "ES2022"],
"rootDir": "./src",
"outDir": "../src/repixelizer/gui_static",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -13,9 +13,12 @@ authors = [
{ name = "OpenAI Codex" }
]
dependencies = [
"fastapi>=0.115.0",
"numpy>=2.1.0",
"Pillow>=10.4.0",
"torch>=2.6.0"
"python-multipart>=0.0.9",
"torch>=2.6.0",
"uvicorn>=0.30.0"
]
[project.optional-dependencies]
@ -32,6 +35,9 @@ package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
repixelizer = ["gui_static/*.html", "gui_static/*.css", "gui_static/*.js"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

View file

@ -6,6 +6,7 @@ import sys
from .benchmark import run_roundtrip_benchmark
from .compare import run_compare
from .corpus import prepare_corpus
from .gui import main as gui_main
from .pipeline import run_pipeline
from .tuning import tune_solver_hyperparams
@ -113,12 +114,16 @@ def build_parser() -> argparse.ArgumentParser:
)
prepare_parser = subparsers.add_parser("prepare-corpus", help="Normalize imported corpus sheets into single sprites.")
prepare_parser.add_argument("--corpus-dir", default="examples/corpus", help="Corpus root containing originals/")
gui_parser = subparsers.add_parser("gui", help="Launch the web GUI.")
gui_parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
gui_parser.add_argument("--port", type=int, default=8000, help="Port to bind")
gui_parser.add_argument("--reload", action="store_true", help="Enable autoreload while developing the GUI server")
return parser
def main(argv: list[str] | None = None) -> int:
raw_argv = list(sys.argv[1:] if argv is None else argv)
if not raw_argv or raw_argv[0] not in {"run", "compare", "benchmark", "tune", "prepare-corpus", "-h", "--help"}:
if not raw_argv or raw_argv[0] not in {"run", "compare", "benchmark", "tune", "prepare-corpus", "gui", "-h", "--help"}:
raw_argv = ["run", *raw_argv]
parser = build_parser()
args = parser.parse_args(raw_argv)
@ -175,6 +180,8 @@ def main(argv: list[str] | None = None) -> int:
if command == "prepare-corpus":
prepare_corpus(args.corpus_dir)
return 0
if command == "gui":
return gui_main(host=args.host, port=args.port, reload=args.reload)
run_pipeline(
args.input,

420
src/repixelizer/gui.py Normal file
View file

@ -0,0 +1,420 @@
from __future__ import annotations
import base64
import io
import json
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from PIL import Image, ImageDraw
from .diagnostics import _displacement_preview_rgba
from .inference import inference_to_json
from .pipeline import run_pipeline_rgba
from .types import InferenceResult, PaletteResult
def _require_gui_dependencies():
try:
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
except ImportError as exc: # pragma: no cover - exercised only when GUI deps missing
raise RuntimeError(
"The GUI requires FastAPI, uvicorn, and python-multipart. Install the project dependencies first."
) from exc
return {
"FastAPI": FastAPI,
"File": File,
"Form": Form,
"HTTPException": HTTPException,
"UploadFile": UploadFile,
"HTMLResponse": HTMLResponse,
"JSONResponse": JSONResponse,
"RedirectResponse": RedirectResponse,
"StreamingResponse": StreamingResponse,
"StaticFiles": StaticFiles,
}
def _to_uint8(rgba: np.ndarray) -> np.ndarray:
return np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8)
def _rgba_data_url(rgba: np.ndarray) -> str:
image = Image.fromarray(_to_uint8(rgba), mode="RGBA")
with io.BytesIO() as buffer:
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _image_asset(rgba: np.ndarray) -> dict[str, Any]:
return {
"dataUrl": _rgba_data_url(rgba),
"width": int(rgba.shape[1]),
"height": int(rgba.shape[0]),
}
def _scalar_to_rgba(values: np.ndarray, *, tint: tuple[float, float, float] = (0.15, 0.85, 0.75)) -> np.ndarray:
scaled = values.astype(np.float32)
max_value = float(np.max(scaled)) if scaled.size else 0.0
if max_value > 0.0:
scaled = scaled / max_value
rgba = np.zeros((*scaled.shape, 4), dtype=np.float32)
rgba[..., 0] = scaled * float(tint[0]) + np.sqrt(scaled) * 0.08
rgba[..., 1] = scaled * float(tint[1])
rgba[..., 2] = scaled * float(tint[2]) + np.sqrt(scaled) * 0.18
rgba[..., 3] = np.clip(0.12 + scaled * 0.88, 0.0, 1.0)
return np.clip(rgba, 0.0, 1.0)
def _render_sample_overlay(source_rgba: np.ndarray, sample_x: np.ndarray, sample_y: np.ndarray) -> np.ndarray:
overlay = source_rgba.copy()
accent = np.asarray([0.10, 0.95, 0.82, 1.0], dtype=np.float32)
shadow = np.asarray([0.02, 0.08, 0.10, 1.0], dtype=np.float32)
height, width = overlay.shape[:2]
for dy, dx, color in (
(0, 0, accent),
(-1, 0, shadow),
(1, 0, shadow),
(0, -1, shadow),
(0, 1, shadow),
):
yy = np.clip(sample_y + dy, 0, height - 1)
xx = np.clip(sample_x + dx, 0, width - 1)
overlay[yy, xx] = overlay[yy, xx] * 0.22 + color[None, :] * 0.78
return np.clip(overlay, 0.0, 1.0)
def _render_lattice_overlay(source_rgba: np.ndarray, uv0_px: np.ndarray) -> np.ndarray:
image = Image.fromarray(_to_uint8(source_rgba), mode="RGBA")
draw = ImageDraw.Draw(image, "RGBA")
color = (54, 219, 198, 180)
shadow = (8, 18, 24, 170)
height, width = uv0_px.shape[:2]
for y in range(height):
row = uv0_px[y]
for x in range(width - 1):
left = row[x]
right = row[x + 1]
draw.line((float(left[0]), float(left[1]), float(right[0]), float(right[1])), fill=shadow, width=3)
draw.line((float(left[0]), float(left[1]), float(right[0]), float(right[1])), fill=color, width=1)
for x in range(width):
column = uv0_px[:, x]
for y in range(height - 1):
top = column[y]
bottom = column[y + 1]
draw.line((float(top[0]), float(top[1]), float(bottom[0]), float(bottom[1])), fill=shadow, width=3)
draw.line((float(top[0]), float(top[1]), float(bottom[0]), float(bottom[1])), fill=color, width=1)
for point in uv0_px.reshape(-1, 2):
px = float(point[0])
py = float(point[1])
draw.ellipse((px - 1.5, py - 1.5, px + 1.5, py + 1.5), fill=(255, 244, 168, 230))
return np.asarray(image, dtype=np.float32) / 255.0
def _decode_rgba(data: bytes) -> np.ndarray:
return np.asarray(Image.open(io.BytesIO(data)).convert("RGBA"), dtype=np.float32) / 255.0
def _palette_to_json(palette_result: PaletteResult | None) -> dict[str, Any] | None:
if palette_result is None:
return None
return {
"palette": [list(color) for color in palette_result.palette],
"hasIndexedOutput": palette_result.indexed_rgba is not None,
}
@dataclass(slots=True)
class GuiJob:
job_id: str
filename: str
created_at: float = field(default_factory=time.time)
status: str = "queued"
error: str | None = None
events: list[dict[str, Any]] = field(default_factory=list)
current_source_rgba: np.ndarray | None = None
current_inference: InferenceResult | None = None
run_summary: dict[str, Any] | None = None
_condition: threading.Condition = field(default_factory=threading.Condition, repr=False)
def observe(self, event: str, payload: dict[str, Any]) -> None:
serialized = self._serialize_event(event, payload)
if serialized is None:
return
self.publish(event, serialized)
def publish(self, event: str, payload: dict[str, Any]) -> None:
record = {
"id": len(self.events) + 1,
"event": event,
"payload": payload,
"timestamp": time.time(),
}
with self._condition:
self.events.append(record)
self._condition.notify_all()
def mark_running(self) -> None:
self.status = "running"
self.publish("job_state", {"status": self.status})
def mark_completed(self) -> None:
self.status = "completed"
self.publish("job_state", {"status": self.status})
def mark_failed(self, message: str) -> None:
self.status = "failed"
self.error = message
self.publish("job_failed", {"status": self.status, "message": message})
def wait_for_events(self, index: int, timeout: float = 0.5) -> list[dict[str, Any]]:
with self._condition:
if index >= len(self.events) and self.status not in {"completed", "failed"}:
self._condition.wait(timeout=timeout)
return self.events[index:]
def _serialize_event(self, event: str, payload: dict[str, Any]) -> dict[str, Any] | None:
if event == "source_loaded":
source = payload["source_rgba"]
self.current_source_rgba = source.copy()
return {
"filename": self.filename,
"sourceImage": _image_asset(source),
}
if event == "preprocess_completed":
source = payload["source_rgba"]
self.current_source_rgba = source.copy()
return {
"operation": payload.get("operation"),
"sourceImage": _image_asset(source),
}
if event in {"inference_candidates_ready", "phase_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":
return {
"previewSteps": int(payload["preview_steps"]),
"candidateCount": int(payload["candidate_count"]),
"confidence": float(payload["confidence"]),
}
if event == "analysis_completed":
edge_map = payload["edge_map"]
return {
"edgeMapImage": _image_asset(_scalar_to_rgba(edge_map, tint=(0.18, 0.84, 0.82))),
}
if event == "phase_field_prepared":
if self.current_source_rgba is None:
return None
return {
"targetWidth": int(payload["target_width"]),
"targetHeight": int(payload["target_height"]),
"cellX": float(payload["cell_x"]),
"cellY": float(payload["cell_y"]),
"latticeImage": _image_asset(_render_lattice_overlay(self.current_source_rgba, payload["uv0_px"])),
"guidanceImage": _image_asset(_scalar_to_rgba(payload["guidance"], tint=(0.92, 0.58, 0.18))),
}
if event in {"phase_field_initial", "phase_field_step", "phase_field_final"}:
if self.current_source_rgba is None:
return None
return {
"step": int(payload["step"]),
"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()},
"outputImage": _image_asset(payload["target_rgba"]),
"samplingOverlayImage": _image_asset(
_render_sample_overlay(self.current_source_rgba, payload["sample_x"], payload["sample_y"])
),
"displacementImage": _image_asset(
_displacement_preview_rgba(payload["displacement_x"], payload["displacement_y"])
),
"lossHistory": [float(value) for value in payload.get("loss_history", [])],
}
if event == "cleanup_completed":
return {
"cleanedImage": _image_asset(payload["cleaned_rgba"]),
"heatmapImage": _image_asset(_scalar_to_rgba(payload["isolated_heatmap"], tint=(0.95, 0.38, 0.24))),
}
if event == "palette_completed":
return {
"outputImage": _image_asset(payload["output_rgba"]),
"paletteMode": payload.get("palette_mode"),
"palette": _palette_to_json(payload.get("palette_result")),
}
if event == "pipeline_completed":
self.run_summary = payload.get("run_summary")
return {
"outputImage": _image_asset(payload["output_rgba"]),
"diagnostics": payload["diagnostics"],
"runSummary": self.run_summary,
}
return None
def _job_state_payload(job: GuiJob) -> dict[str, Any]:
return {
"jobId": job.job_id,
"status": job.status,
"error": job.error,
"createdAt": job.created_at,
"eventCount": len(job.events),
"runSummary": job.run_summary,
}
def _run_job(job: GuiJob, source_rgba: np.ndarray, options: dict[str, Any]) -> None:
try:
job.mark_running()
run_pipeline_rgba(
source_rgba,
target_size=options["target_size"],
target_width=options["target_width"],
target_height=options["target_height"],
phase_x=options["phase_x"],
phase_y=options["phase_y"],
palette_mode="off",
seed=options["seed"],
steps=options["steps"],
device=options["device"],
strip_background=options["strip_background"],
enable_phase_rerank=not options["skip_phase_rerank"],
observer=job.observe,
)
except Exception as exc: # pragma: no cover - exercised through manual GUI runs
job.mark_failed(str(exc))
return
job.mark_completed()
def _static_dir() -> Path:
return Path(__file__).with_name("gui_static")
def create_app():
deps = _require_gui_dependencies()
FastAPI = deps["FastAPI"]
File = deps["File"]
Form = deps["Form"]
HTTPException = deps["HTTPException"]
UploadFile = deps["UploadFile"]
HTMLResponse = deps["HTMLResponse"]
JSONResponse = deps["JSONResponse"]
RedirectResponse = deps["RedirectResponse"]
StreamingResponse = deps["StreamingResponse"]
StaticFiles = deps["StaticFiles"]
app = FastAPI(title="Repixelizer GUI", version="0.1.0")
jobs: dict[str, GuiJob] = {}
static_dir = _static_dir()
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
job = jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown job")
return JSONResponse(_job_state_payload(job))
@app.post("/api/jobs")
async def create_job(
image: UploadFile = File(...),
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 = Form(default=48),
seed: int = Form(default=7),
device: str = Form(default="auto"),
strip_background: bool = Form(default=False),
skip_phase_rerank: bool = Form(default=False),
):
raw = await image.read()
source_rgba = _decode_rgba(raw)
job = GuiJob(job_id=str(uuid.uuid4()), filename=image.filename or "input.png")
jobs[job.job_id] = job
options = {
"target_size": target_size,
"target_width": target_width,
"target_height": target_height,
"phase_x": phase_x,
"phase_y": phase_y,
"steps": max(0, int(steps)),
"seed": int(seed),
"device": device,
"strip_background": bool(strip_background),
"skip_phase_rerank": bool(skip_phase_rerank),
}
worker = threading.Thread(target=_run_job, args=(job, source_rgba, options), daemon=True)
worker.start()
return JSONResponse(
{
"jobId": job.job_id,
"status": job.status,
"eventsUrl": f"/api/jobs/{job.job_id}/events",
"stateUrl": f"/api/jobs/{job.job_id}",
}
)
@app.get("/api/jobs/{job_id}/events")
def job_events(job_id: str):
job = jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown job")
def generate():
index = 0
while True:
pending = job.wait_for_events(index)
for record in pending:
index += 1
yield (
f"id: {record['id']}\n"
f"event: {record['event']}\n"
f"data: {json.dumps(record['payload'])}\n\n"
)
if job.status in {"completed", "failed"} and index >= len(job.events):
break
yield ": keep-alive\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
if static_dir.exists():
app.mount("/app", StaticFiles(directory=static_dir, html=True), name="app")
@app.get("/")
def root():
return RedirectResponse(url="/app/")
else: # pragma: no cover - only relevant before frontend assets exist
@app.get("/")
def root_missing():
return HTMLResponse("<h1>GUI assets are missing.</h1><p>Build the TypeScript frontend first.</p>")
return app
def main(*, host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> int:
_require_gui_dependencies()
try:
import uvicorn
except ImportError as exc: # pragma: no cover - exercised only when uvicorn missing
raise RuntimeError("The GUI requires uvicorn. Install the project dependencies first.") from exc
uvicorn.run("repixelizer.gui:create_app", factory=True, host=host, port=port, reload=reload)
return 0

View file

@ -0,0 +1,839 @@
"use strict";
function byId(id) {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Missing element: ${id}`);
}
return element;
}
const fileInput = byId("fileInput");
const dropzone = byId("dropzone");
const dropzoneLabel = byId("dropzoneLabel");
const pickFileButton = byId("pickFileButton");
const runButton = byId("runButton");
const statusBadge = byId("statusBadge");
const statusText = byId("statusText");
const inferenceSummary = byId("inferenceSummary");
const candidateList = byId("candidateList");
const eventLog = byId("eventLog");
const viewerTitle = byId("viewerTitle");
const stepSlider = byId("stepSlider");
const stepValue = byId("stepValue");
const lossCanvas = byId("lossCanvas");
const leftCanvas = byId("leftCanvas");
const middleCanvas = byId("middleCanvas");
const rightCanvas = byId("rightCanvas");
const leftVizLabel = byId("leftVizLabel");
const middleVizLabel = byId("middleVizLabel");
const rightVizLabel = byId("rightVizLabel");
const metricsPanel = byId("metricsPanel");
const paintSwatch = byId("paintSwatch");
const toolMode = byId("toolMode");
const zoomInput = byId("zoomInput");
const zoomValue = byId("zoomValue");
const gridToggle = byId("gridToggle");
const editorCanvas = byId("editorCanvas");
const editorGridCanvas = byId("editorGridCanvas");
const editorSurface = byId("editorSurface");
const editorMeta = byId("editorMeta");
const summaryPanel = byId("summaryPanel");
const resetEditorButton = byId("resetEditorButton");
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 deviceInput = byId("deviceInput");
const stripBackgroundInput = byId("stripBackgroundInput");
const skipRerankInput = byId("skipRerankInput");
const paintInputs = {
r: byId("paintR"),
g: byId("paintG"),
b: byId("paintB"),
a: byId("paintA"),
};
const imageCache = new Map();
const eventTypes = [
"job_state",
"job_failed",
"source_loaded",
"preprocess_completed",
"inference_candidates_ready",
"phase_rerank_started",
"phase_selection_completed",
"analysis_completed",
"phase_field_prepared",
"phase_field_initial",
"phase_field_step",
"phase_field_final",
"cleanup_completed",
"palette_completed",
"pipeline_completed",
];
const state = {
file: null,
jobId: null,
status: "idle",
statusText: "Drop a fake sprite in here and let the machine explain itself for once.",
sourceImage: null,
preprocessedImage: null,
edgeMapImage: null,
latticeImage: null,
guidanceImage: null,
inference: null,
frames: [],
cleanupImage: null,
heatmapImage: null,
finalOutputImage: null,
runSummary: null,
selectedFrameIndex: 0,
autoFollow: true,
eventLog: [],
paintColor: [255, 255, 255, 255],
editorBaseAsset: null,
editorDirty: false,
altHeld: false,
zoom: Number(zoomInput.value),
showGrid: gridToggle.checked,
currentStage: "Idle",
};
let currentEventSource = null;
let painting = false;
let offscreenCanvas = null;
let offscreenContext = null;
let singlePixel = new ImageData(1, 1);
function clampByte(value) {
return Math.max(0, Math.min(255, Math.round(value)));
}
function setPaintColor(color) {
state.paintColor = color;
paintInputs.r.value = String(color[0]);
paintInputs.g.value = String(color[1]);
paintInputs.b.value = String(color[2]);
paintInputs.a.value = String(color[3]);
paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
}
function updateToolMode() {
toolMode.textContent = state.altHeld ? "Eyedropper armed. Sample a pixel." : "Pencil. Hold Alt for eyedropper.";
}
function addLog(label, detail) {
state.eventLog.unshift({ label, detail });
state.eventLog = state.eventLog.slice(0, 8);
renderEventLog();
}
function renderEventLog() {
eventLog.innerHTML = "";
if (state.eventLog.length === 0) {
eventLog.innerHTML = `<p class="muted">Nothing to report yet.</p>`;
return;
}
for (const entry of state.eventLog) {
const node = document.createElement("div");
node.className = "event-item";
node.innerHTML = `<strong>${entry.label}</strong><span>${entry.detail}</span>`;
eventLog.appendChild(node);
}
}
function setStatus(status, detail) {
state.status = status;
state.statusText = detail;
statusBadge.textContent = status;
statusText.textContent = detail;
}
function resetRunArtifacts() {
state.jobId = null;
state.sourceImage = null;
state.preprocessedImage = null;
state.edgeMapImage = null;
state.latticeImage = null;
state.guidanceImage = null;
state.inference = null;
state.frames = [];
state.cleanupImage = null;
state.heatmapImage = null;
state.finalOutputImage = null;
state.runSummary = null;
state.selectedFrameIndex = 0;
state.autoFollow = true;
state.eventLog = [];
state.editorBaseAsset = null;
state.editorDirty = false;
state.currentStage = "Waiting";
}
function parseOptionalInteger(input) {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? Math.round(value) : null;
}
function parseOptionalFloat(input) {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? value : null;
}
async function loadImage(asset) {
const cached = imageCache.get(asset.dataUrl);
if (cached) {
return cached;
}
const promise = new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image asset"));
image.src = asset.dataUrl;
});
imageCache.set(asset.dataUrl, promise);
return promise;
}
async function drawAsset(canvas, asset) {
const context = canvas.getContext("2d");
if (!context) {
return;
}
if (!asset) {
canvas.width = 640;
canvas.height = 360;
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#081012";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "rgba(255,255,255,0.2)";
context.font = "16px sans-serif";
context.fillText("Waiting for image data", 24, 32);
return;
}
const image = await loadImage(asset);
canvas.width = image.naturalWidth || asset.width;
canvas.height = image.naturalHeight || asset.height;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
function readNestedNumber(root, path) {
let current = root;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
return null;
}
current = current[key];
}
return typeof current === "number" ? current : null;
}
function formatNumber(value, digits = 3) {
if (value === null || !Number.isFinite(value)) {
return "n/a";
}
return value.toFixed(digits);
}
function getSelectedFrame() {
if (state.frames.length === 0) {
return null;
}
return state.frames[Math.max(0, Math.min(state.selectedFrameIndex, state.frames.length - 1))];
}
function renderInference() {
if (!state.inference) {
inferenceSummary.innerHTML = `<p class="muted">No lattice picked yet.</p>`;
candidateList.innerHTML = "";
return;
}
inferenceSummary.innerHTML = `
<div class="summary-card">
<strong>Chosen Grid</strong>
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
</div>
<div class="summary-card">
<strong>Phase</strong>
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
</div>
<div class="summary-card">
<strong>Confidence</strong>
<span>${formatNumber(state.inference.confidence, 3)}</span>
</div>
`;
candidateList.innerHTML = "";
const topCandidates = state.inference.top_candidates.slice(0, 6);
for (const candidate of topCandidates) {
const node = document.createElement("div");
node.className = "candidate-card";
const rerank = candidate.breakdown["phase_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);
}
}
function renderMetrics() {
metricsPanel.innerHTML = "";
const frame = getSelectedFrame();
const items = [];
if (frame) {
items.push(["Frame", `${frame.step}/${frame.totalSteps}`]);
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
for (const [key, value] of Object.entries(frame.terms)) {
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
}
}
else if (state.runSummary) {
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
items.push([
"Final fidelity",
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
]);
items.push(["Source color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
}
else {
items.push(["Status", state.currentStage]);
}
for (const [label, value] of items) {
const node = document.createElement("div");
node.className = "metric-chip";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
metricsPanel.appendChild(node);
}
}
function renderSummary() {
summaryPanel.innerHTML = "";
if (!state.runSummary) {
summaryPanel.innerHTML = `<p class="muted">Final metrics show up here after the run finishes.</p>`;
return;
}
const entries = [
["Structure score", readNestedNumber(state.runSummary, ["source_structure", "score"])],
["Edge F1", readNestedNumber(state.runSummary, ["source_structure", "edge_f1"])],
["Exact match", readNestedNumber(state.runSummary, ["source_structure", "exact_match_ratio"])],
["Final fidelity", readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])],
["Mean displacement", readNestedNumber(state.runSummary, ["optimizer_displacement", "final_output", "mean_magnitude_px"])],
];
for (const [label, value] of entries) {
const card = document.createElement("div");
card.className = "summary-card";
card.innerHTML = `<strong>${label}</strong><span>${formatNumber(value, 3)}</span>`;
summaryPanel.appendChild(card);
}
}
async function renderViewer() {
const frame = getSelectedFrame();
let leftAsset = state.preprocessedImage ?? state.sourceImage;
let middleAsset = state.edgeMapImage;
let rightAsset = state.finalOutputImage ?? state.cleanupImage ?? state.sourceImage;
let leftLabel = "Source";
let middleLabel = "Edge Scout";
let rightLabel = "Output";
let title = state.currentStage;
if (state.latticeImage) {
leftAsset = state.latticeImage;
leftLabel = "Lattice Prep";
}
if (state.guidanceImage) {
middleAsset = state.guidanceImage;
middleLabel = "Guidance";
}
if (frame) {
leftAsset = frame.samplingOverlayImage;
middleAsset = frame.displacementImage;
rightAsset = frame.outputImage;
leftLabel = "Sampling Overlay";
middleLabel = "Displacement";
rightLabel = "Current Output";
title = frame.step === 0 ? "Initial nearest-sample placement" : `Solver step ${frame.step} / ${frame.totalSteps}`;
}
else if (state.cleanupImage) {
middleAsset = state.heatmapImage ?? middleAsset;
middleLabel = "Cleanup Heatmap";
rightAsset = state.cleanupImage;
rightLabel = "Cleaned Output";
title = state.finalOutputImage ? "Finalized output" : "Cleanup pass";
}
viewerTitle.textContent = title;
leftVizLabel.textContent = leftLabel;
middleVizLabel.textContent = middleLabel;
rightVizLabel.textContent = rightLabel;
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(middleCanvas, middleAsset), drawAsset(rightCanvas, rightAsset)]);
renderMetrics();
}
function renderSlider() {
if (state.frames.length === 0) {
stepSlider.disabled = true;
stepSlider.max = "0";
stepSlider.value = "0";
stepValue.textContent = "No frames yet";
return;
}
stepSlider.disabled = false;
stepSlider.max = String(state.frames.length - 1);
stepSlider.value = String(state.selectedFrameIndex);
const frame = getSelectedFrame();
if (!frame) {
stepValue.textContent = "No frames yet";
return;
}
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
}
function renderLossChart() {
const context = lossCanvas.getContext("2d");
if (!context) {
return;
}
const width = lossCanvas.width;
const height = lossCanvas.height;
context.clearRect(0, 0, width, height);
context.fillStyle = "rgba(7, 15, 17, 0.96)";
context.fillRect(0, 0, width, height);
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
for (let index = 1; index <= 4; index += 1) {
const y = (height / 5) * index;
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
const losses = state.frames
.filter((frame) => typeof frame.loss === "number")
.map((frame) => frame.loss);
if (losses.length === 0) {
context.fillStyle = "rgba(255,255,255,0.28)";
context.font = "14px sans-serif";
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 28);
return;
}
const minLoss = Math.min(...losses);
const maxLoss = Math.max(...losses);
const span = Math.max(1e-6, maxLoss - minLoss);
context.strokeStyle = "#8fe2c8";
context.lineWidth = 3;
context.beginPath();
losses.forEach((loss, index) => {
const x = losses.length === 1 ? width / 2 : (index / (losses.length - 1)) * (width - 24) + 12;
const y = height - 18 - ((loss - minLoss) / span) * (height - 36);
if (index === 0) {
context.moveTo(x, y);
}
else {
context.lineTo(x, y);
}
});
context.stroke();
const selected = getSelectedFrame();
if (selected && selected.loss !== null) {
const matchingIndex = losses.findIndex((value) => Math.abs(value - selected.loss) < 1e-6);
if (matchingIndex >= 0) {
const x = losses.length === 1 ? width / 2 : (matchingIndex / (losses.length - 1)) * (width - 24) + 12;
const y = height - 18 - ((selected.loss - minLoss) / span) * (height - 36);
context.fillStyle = "#f5b76b";
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
}
}
}
async function loadEditorAsset(asset) {
state.editorBaseAsset = asset;
state.editorDirty = false;
if (!asset) {
offscreenCanvas = null;
offscreenContext = null;
renderEditor();
return;
}
const image = await loadImage(asset);
offscreenCanvas = document.createElement("canvas");
offscreenCanvas.width = image.naturalWidth || asset.width;
offscreenCanvas.height = image.naturalHeight || asset.height;
offscreenContext = offscreenCanvas.getContext("2d");
if (!offscreenContext) {
return;
}
offscreenContext.imageSmoothingEnabled = false;
offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
offscreenContext.drawImage(image, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
renderEditor();
}
function renderEditor() {
const displayContext = editorCanvas.getContext("2d");
const gridContext = editorGridCanvas.getContext("2d");
if (!displayContext || !gridContext) {
return;
}
if (!offscreenCanvas) {
editorCanvas.width = 1;
editorCanvas.height = 1;
editorGridCanvas.width = 1;
editorGridCanvas.height = 1;
editorMeta.innerHTML = `<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>`;
return;
}
const width = offscreenCanvas.width * state.zoom;
const height = offscreenCanvas.height * state.zoom;
for (const canvas of [editorCanvas, editorGridCanvas]) {
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
displayContext.imageSmoothingEnabled = false;
displayContext.clearRect(0, 0, width, height);
displayContext.drawImage(offscreenCanvas, 0, 0, width, height);
gridContext.clearRect(0, 0, width, height);
if (state.showGrid && state.zoom >= 8) {
gridContext.strokeStyle = "rgba(255,255,255,0.12)";
gridContext.lineWidth = 1;
for (let x = 0; x <= offscreenCanvas.width; x += 1) {
const xpos = x * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(xpos, 0);
gridContext.lineTo(xpos, height);
gridContext.stroke();
}
for (let y = 0; y <= offscreenCanvas.height; y += 1) {
const ypos = y * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(0, ypos);
gridContext.lineTo(width, ypos);
gridContext.stroke();
}
}
editorMeta.innerHTML = `
<div class="summary-card">
<strong>Canvas</strong>
<span>${offscreenCanvas.width} x ${offscreenCanvas.height}</span>
</div>
<div class="summary-card">
<strong>State</strong>
<span>${state.editorDirty ? "Edited" : "Matches solver output"}</span>
</div>
`;
}
function editorPixelFromPointer(event) {
if (!offscreenCanvas) {
return null;
}
const rect = editorCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return null;
}
const x = Math.floor((event.clientX - rect.left) / state.zoom);
const y = Math.floor((event.clientY - rect.top) / state.zoom);
if (x < 0 || y < 0 || x >= offscreenCanvas.width || y >= offscreenCanvas.height) {
return null;
}
return { x, y };
}
function samplePaintColor(x, y) {
if (!offscreenContext) {
return;
}
const pixel = offscreenContext.getImageData(x, y, 1, 1).data;
setPaintColor([pixel[0], pixel[1], pixel[2], pixel[3]]);
}
function paintPixel(x, y) {
if (!offscreenContext) {
return;
}
singlePixel.data[0] = state.paintColor[0];
singlePixel.data[1] = state.paintColor[1];
singlePixel.data[2] = state.paintColor[2];
singlePixel.data[3] = state.paintColor[3];
offscreenContext.putImageData(singlePixel, x, y);
state.editorDirty = true;
renderEditor();
}
function exportEditorPng() {
if (!offscreenCanvas) {
return;
}
const link = document.createElement("a");
link.href = offscreenCanvas.toDataURL("image/png");
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
link.click();
}
function buildFormData() {
if (!state.file) {
throw new Error("No file selected");
}
const data = new FormData();
data.set("image", state.file);
const values = [
["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(parseOptionalInteger(stepsInput) ?? 48)],
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
["device", deviceInput.value],
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
];
for (const [key, value] of values) {
if (value !== null) {
data.set(key, value);
}
}
return data;
}
async function renderEverything() {
renderInference();
renderSlider();
renderLossChart();
renderSummary();
await renderViewer();
}
async function handleEvent(eventName, payload) {
switch (eventName) {
case "job_state":
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
break;
case "job_failed":
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
break;
case "source_loaded":
state.sourceImage = payload.sourceImage;
state.currentStage = "Loaded source";
setStatus("running", "Source loaded. Survey crew is measuring the mess.");
addLog("Source", "Loaded the input image.");
break;
case "preprocess_completed":
state.preprocessedImage = payload.sourceImage;
state.currentStage = "Preprocessed source";
addLog("Preprocess", "Stripped edge-connected background noise.");
break;
case "inference_candidates_ready":
state.inference = payload.inference;
state.currentStage = "Scoring lattice candidates";
addLog("Inference", "Scored candidate grids and phase offsets.");
break;
case "phase_rerank_started":
state.currentStage = "Phase rerank preview";
addLog("Rerank", `Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`);
break;
case "phase_selection_completed":
state.inference = payload.inference;
state.currentStage = "Selected lattice";
addLog("Selection", "Committed to a ruler and phase.");
break;
case "analysis_completed":
state.edgeMapImage = payload.edgeMapImage;
state.currentStage = "Edge scout";
addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
break;
case "phase_field_prepared":
state.latticeImage = payload.latticeImage;
state.guidanceImage = payload.guidanceImage;
state.currentStage = "Prepared phase field";
addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`);
break;
case "phase_field_initial":
case "phase_field_step":
case "phase_field_final": {
const frame = payload;
const existingIndex = state.frames.findIndex((candidate) => candidate.step === frame.step);
if (existingIndex >= 0) {
state.frames[existingIndex] = frame;
}
else {
state.frames.push(frame);
state.frames.sort((a, b) => a.step - b.step);
}
if (state.autoFollow) {
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step));
}
state.currentStage = frame.step === 0 ? "Initial placement" : `Solver step ${frame.step}/${frame.totalSteps}`;
if (eventName === "phase_field_final") {
addLog("Solver", "Final nearest-source sample committed.");
}
break;
}
case "cleanup_completed":
state.cleanupImage = payload.cleanedImage;
state.heatmapImage = payload.heatmapImage;
state.currentStage = "Cleanup";
addLog("Cleanup", "Ran the local cleanup sweep.");
break;
case "palette_completed":
state.finalOutputImage = payload.outputImage;
state.currentStage = "Final output";
addLog("Output", "Final output image is ready.");
break;
case "pipeline_completed":
state.finalOutputImage = payload.outputImage;
state.runSummary = payload.runSummary ?? null;
state.currentStage = "Completed";
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
addLog("Done", "Full pipeline completed.");
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
break;
default:
break;
}
await renderEverything();
}
function connectEventStream(jobId) {
currentEventSource?.close();
const stream = new EventSource(`/api/jobs/${jobId}/events`);
currentEventSource = stream;
for (const eventType of eventTypes) {
stream.addEventListener(eventType, (raw) => {
const message = raw;
const payload = JSON.parse(message.data);
void handleEvent(eventType, payload);
});
}
stream.onerror = () => {
if (state.status === "running") {
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
}
};
}
async function startRun() {
if (!state.file) {
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
return;
}
resetRunArtifacts();
renderEventLog();
runButton.disabled = true;
setStatus("queued", "Queued the run. Small miracle pending.");
addLog("Queued", `Starting ${state.file.name}.`);
await renderEverything();
try {
const response = await fetch("/api/jobs", {
method: "POST",
body: buildFormData(),
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json());
state.jobId = payload.jobId;
connectEventStream(payload.jobId);
}
catch (error) {
runButton.disabled = false;
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
}
}
function acceptFile(file) {
state.file = file;
dropzoneLabel.textContent = file.name;
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
}
dropzone.addEventListener("click", () => fileInput.click());
pickFileButton.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
acceptFile(file);
}
});
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
});
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
const file = event.dataTransfer?.files?.[0];
if (file) {
acceptFile(file);
}
});
runButton.addEventListener("click", () => {
void startRun();
});
stepSlider.addEventListener("input", () => {
state.selectedFrameIndex = Number(stepSlider.value);
state.autoFollow = state.selectedFrameIndex >= state.frames.length - 1;
void renderEverything();
});
zoomInput.addEventListener("input", () => {
state.zoom = Number(zoomInput.value);
zoomValue.textContent = `${state.zoom}x`;
renderEditor();
});
gridToggle.addEventListener("change", () => {
state.showGrid = gridToggle.checked;
renderEditor();
});
for (const input of Object.values(paintInputs)) {
input.addEventListener("change", () => {
setPaintColor([
clampByte(Number(paintInputs.r.value)),
clampByte(Number(paintInputs.g.value)),
clampByte(Number(paintInputs.b.value)),
clampByte(Number(paintInputs.a.value)),
]);
});
}
window.addEventListener("keydown", (event) => {
if (event.key === "Alt") {
state.altHeld = true;
updateToolMode();
}
});
window.addEventListener("keyup", (event) => {
if (event.key === "Alt") {
state.altHeld = false;
updateToolMode();
}
});
editorCanvas.addEventListener("pointerdown", (event) => {
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
event.preventDefault();
editorCanvas.setPointerCapture(event.pointerId);
if (event.altKey || state.altHeld) {
samplePaintColor(pixel.x, pixel.y);
updateToolMode();
return;
}
painting = true;
paintPixel(pixel.x, pixel.y);
});
editorCanvas.addEventListener("pointermove", (event) => {
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
if (event.altKey || state.altHeld) {
updateToolMode();
if (event.buttons === 1) {
samplePaintColor(pixel.x, pixel.y);
}
return;
}
if (painting && event.buttons === 1) {
paintPixel(pixel.x, pixel.y);
}
});
editorCanvas.addEventListener("pointerup", () => {
painting = false;
});
editorCanvas.addEventListener("pointerleave", () => {
painting = false;
});
resetEditorButton.addEventListener("click", () => {
void loadEditorAsset(state.editorBaseAsset);
});
downloadButton.addEventListener("click", () => {
exportEditorPng();
});
setPaintColor(state.paintColor);
updateToolMode();
zoomValue.textContent = `${state.zoom}x`;
renderEventLog();
void renderEverything();

View file

@ -0,0 +1,203 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Repixelizer GUI</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app-shell">
<header class="masthead panel">
<div>
<p class="eyebrow">Live Pipeline</p>
<h1>Repixelizer</h1>
</div>
<div class="status-cluster">
<span id="statusBadge" class="status-badge">Idle</span>
<p id="statusText" class="status-text">Drop a fake sprite in here and let the machine explain itself for once.</p>
</div>
</header>
<div class="app-grid">
<aside class="sidebar panel">
<section class="section-block">
<div class="section-head">
<h2>Input</h2>
<button id="pickFileButton" class="ghost-button" type="button">Choose File</button>
</div>
<input id="fileInput" type="file" accept="image/png,image/*" hidden />
<button id="dropzone" class="dropzone" type="button">
<strong>Drop image</strong>
<span id="dropzoneLabel">PNG, sprite scrap, cursed logo. Whatever started this.</span>
</button>
</section>
<section class="section-block">
<div class="section-head">
<h2>Run Controls</h2>
</div>
<form id="controlsForm" class="controls-grid">
<label>
<span>Target Size</span>
<input id="targetSizeInput" name="targetSize" type="number" min="1" placeholder="auto" />
</label>
<label>
<span>Target Width</span>
<input id="targetWidthInput" name="targetWidth" type="number" min="1" placeholder="auto" />
</label>
<label>
<span>Target Height</span>
<input id="targetHeightInput" name="targetHeight" type="number" min="1" placeholder="auto" />
</label>
<label>
<span>Phase X</span>
<input id="phaseXInput" name="phaseX" type="number" step="0.05" placeholder="search" />
</label>
<label>
<span>Phase Y</span>
<input id="phaseYInput" name="phaseY" type="number" step="0.05" placeholder="search" />
</label>
<label>
<span>Steps</span>
<input id="stepsInput" name="steps" type="number" min="0" value="48" />
</label>
<label>
<span>Seed</span>
<input id="seedInput" name="seed" type="number" min="0" value="7" />
</label>
<label>
<span>Device</span>
<select id="deviceInput" name="device">
<option value="auto" selected>auto</option>
<option value="cpu">cpu</option>
<option value="cuda">cuda</option>
</select>
</label>
<label class="toggle-row">
<input id="stripBackgroundInput" name="stripBackground" type="checkbox" />
<span>Strip checkerboard-ish junk</span>
</label>
<label class="toggle-row">
<input id="skipRerankInput" name="skipRerank" type="checkbox" />
<span>Skip low-confidence phase rerank</span>
</label>
</form>
<button id="runButton" class="primary-button" type="button">Run The Machine</button>
</section>
<section class="section-block">
<div class="section-head">
<h2>Inference</h2>
</div>
<div id="inferenceSummary" class="info-stack">
<p class="muted">No lattice picked yet.</p>
</div>
<div id="candidateList" class="candidate-list"></div>
</section>
<section class="section-block">
<div class="section-head">
<h2>Run Notes</h2>
</div>
<div id="eventLog" class="event-log"></div>
</section>
</aside>
<main class="workspace">
<section class="panel viewer-panel">
<div class="section-head viewer-head">
<div>
<p class="eyebrow">Algorithm View</p>
<h2 id="viewerTitle">Pipeline Stages</h2>
</div>
<div class="scrubber">
<label for="stepSlider">Solver Step</label>
<input id="stepSlider" type="range" min="0" max="0" step="1" value="0" disabled />
<span id="stepValue" class="step-value">No frames yet</span>
</div>
</div>
<canvas id="lossCanvas" class="loss-canvas" width="800" height="120"></canvas>
<div class="viewer-grid">
<figure class="viz-card">
<div class="viz-label" id="leftVizLabel">Source</div>
<canvas id="leftCanvas"></canvas>
</figure>
<figure class="viz-card">
<div class="viz-label" id="middleVizLabel">Scout</div>
<canvas id="middleCanvas"></canvas>
</figure>
<figure class="viz-card">
<div class="viz-label" id="rightVizLabel">Output</div>
<canvas id="rightCanvas"></canvas>
</figure>
</div>
<div id="metricsPanel" class="metrics-panel"></div>
</section>
<section class="panel editor-panel">
<div class="section-head editor-head">
<div>
<p class="eyebrow">Pixel Fixes</p>
<h2>Output Editor</h2>
</div>
<div class="editor-actions">
<span id="toolMode" class="tool-hint">Pencil. Hold Alt for eyedropper.</span>
<button id="resetEditorButton" class="ghost-button" type="button">Reset Output</button>
<button id="downloadButton" class="primary-button subdued" type="button">Export PNG</button>
</div>
</div>
<div class="editor-toolbar">
<div class="paint-block">
<div id="paintSwatch" class="paint-swatch"></div>
<div class="rgba-inputs">
<label>
<span>R</span>
<input id="paintR" type="number" min="0" max="255" value="255" />
</label>
<label>
<span>G</span>
<input id="paintG" type="number" min="0" max="255" value="255" />
</label>
<label>
<span>B</span>
<input id="paintB" type="number" min="0" max="255" value="255" />
</label>
<label>
<span>A</span>
<input id="paintA" type="number" min="0" max="255" value="255" />
</label>
</div>
</div>
<div class="zoom-block">
<label for="zoomInput">Zoom</label>
<input id="zoomInput" type="range" min="4" max="32" step="1" value="12" />
<span id="zoomValue">12x</span>
</div>
<label class="toggle-row compact">
<input id="gridToggle" type="checkbox" checked />
<span>Grid</span>
</label>
</div>
<div class="editor-stage">
<div class="checkerboard editor-surface" id="editorSurface">
<canvas id="editorCanvas"></canvas>
<canvas id="editorGridCanvas"></canvas>
</div>
<div class="editor-meta">
<div id="editorMeta" class="info-stack">
<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>
</div>
<div id="summaryPanel" class="summary-panel"></div>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>

View file

@ -0,0 +1,501 @@
:root {
color-scheme: dark;
--bg-0: #0b1214;
--bg-1: #142126;
--bg-2: rgba(14, 28, 32, 0.88);
--panel-border: rgba(117, 182, 170, 0.2);
--panel-shadow: rgba(0, 0, 0, 0.34);
--text: #eef5ef;
--muted: #96aba5;
--accent: #8fe2c8;
--accent-strong: #c2f59d;
--warning: #f5b76b;
--danger: #ff8172;
--line: rgba(255, 255, 255, 0.09);
--checker-a: rgba(255, 255, 255, 0.045);
--checker-b: rgba(0, 0, 0, 0.12);
--font-display: "Aptos Display", "Segoe UI Variable Display", "Trebuchet MS", sans-serif;
--font-body: "Aptos", "Segoe UI Variable Text", "Verdana", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(143, 226, 200, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(245, 183, 107, 0.16), transparent 24%),
linear-gradient(180deg, #081012 0%, #0e181b 100%);
color: var(--text);
font-family: var(--font-body);
}
body {
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
.app-shell {
width: min(1800px, calc(100vw - 32px));
margin: 16px auto;
display: grid;
gap: 16px;
}
.panel {
background: var(--bg-2);
border: 1px solid var(--panel-border);
border-radius: 24px;
box-shadow: 0 22px 60px var(--panel-shadow);
backdrop-filter: blur(16px);
}
.masthead {
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
}
h1,
h2 {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.01em;
}
h1 {
font-size: clamp(1.8rem, 3vw, 2.6rem);
}
h2 {
font-size: 1.1rem;
}
.status-cluster {
display: grid;
justify-items: end;
gap: 6px;
max-width: 520px;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
background: rgba(143, 226, 200, 0.15);
border: 1px solid rgba(143, 226, 200, 0.3);
color: var(--accent);
font-size: 0.92rem;
font-weight: 700;
}
.status-text {
margin: 0;
color: var(--muted);
text-align: right;
}
.app-grid {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 16px;
}
.sidebar,
.workspace {
display: grid;
gap: 16px;
}
.sidebar {
align-content: start;
padding: 18px;
}
.workspace {
min-width: 0;
}
.section-block {
display: grid;
gap: 12px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dropzone {
width: 100%;
padding: 28px 16px;
border: 1px dashed rgba(143, 226, 200, 0.38);
background:
linear-gradient(135deg, rgba(143, 226, 200, 0.14), rgba(245, 183, 107, 0.08)),
rgba(7, 16, 18, 0.45);
border-radius: 20px;
display: grid;
gap: 8px;
text-align: left;
color: var(--text);
cursor: pointer;
}
.dropzone strong {
font-size: 1.1rem;
}
.dropzone span {
color: var(--muted);
}
.controls-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.controls-grid label {
display: grid;
gap: 6px;
}
.controls-grid span,
.zoom-block label,
.rgba-inputs span {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
input[type="number"],
select {
width: 100%;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(5, 11, 13, 0.78);
color: var(--text);
}
.toggle-row {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(5, 11, 13, 0.5);
color: var(--text);
}
.toggle-row.compact {
grid-column: auto;
padding-inline: 14px;
}
.primary-button,
.ghost-button {
border-radius: 16px;
padding: 11px 16px;
border: 1px solid transparent;
cursor: pointer;
}
.primary-button {
background: linear-gradient(135deg, #99ebc2, #f0bb75);
color: #0a1011;
font-weight: 700;
}
.primary-button.subdued {
background: linear-gradient(135deg, rgba(153, 235, 194, 0.78), rgba(240, 187, 117, 0.78));
}
.ghost-button {
background: rgba(255, 255, 255, 0.05);
border-color: var(--line);
color: var(--text);
}
.info-stack,
.candidate-list,
.event-log,
.metrics-panel,
.summary-panel {
display: grid;
gap: 8px;
}
.muted {
margin: 0;
color: var(--muted);
}
.candidate-card,
.event-item,
.metric-chip,
.summary-card {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
}
.candidate-card strong,
.event-item strong {
display: block;
margin-bottom: 4px;
}
.viewer-panel,
.editor-panel {
padding: 18px;
display: grid;
gap: 14px;
}
.viewer-head {
align-items: end;
}
.scrubber {
min-width: min(420px, 100%);
display: grid;
gap: 8px;
}
.scrubber label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.step-value {
color: var(--muted);
}
.loss-canvas {
width: 100%;
height: 120px;
border-radius: 18px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(5, 11, 13, 0.62);
}
.viewer-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.viz-card {
margin: 0;
padding: 12px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(5, 11, 13, 0.5);
display: grid;
gap: 10px;
min-width: 0;
}
.viz-label {
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.viz-card canvas {
width: 100%;
height: auto;
display: block;
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(6, 14, 16, 0.72);
image-rendering: pixelated;
}
.metrics-panel {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.metric-chip strong,
.summary-card strong {
display: block;
margin-bottom: 4px;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.editor-head {
align-items: start;
}
.editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.tool-hint {
color: var(--muted);
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 12px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(5, 11, 13, 0.45);
}
.paint-block {
display: flex;
align-items: center;
gap: 12px;
}
.paint-swatch {
width: 52px;
height: 52px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.rgba-inputs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.rgba-inputs label {
display: grid;
gap: 6px;
}
.rgba-inputs input {
width: 82px;
}
.zoom-block {
display: grid;
gap: 6px;
}
.editor-stage {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 14px;
align-items: start;
}
.checkerboard {
background:
linear-gradient(45deg, var(--checker-a) 25%, transparent 25%) 0 0 / 20px 20px,
linear-gradient(-45deg, var(--checker-a) 25%, transparent 25%) 0 10px / 20px 20px,
linear-gradient(45deg, transparent 75%, var(--checker-b) 75%) 10px -10px / 20px 20px,
linear-gradient(-45deg, transparent 75%, var(--checker-b) 75%) -10px 0 / 20px 20px,
rgba(5, 11, 13, 0.8);
}
.editor-surface {
position: relative;
min-height: 360px;
overflow: auto;
border-radius: 20px;
border: 1px solid var(--line);
padding: 16px;
}
.editor-surface canvas {
position: absolute;
top: 16px;
left: 16px;
image-rendering: pixelated;
}
#editorGridCanvas {
pointer-events: none;
}
.editor-meta {
display: grid;
gap: 12px;
}
.summary-panel {
align-content: start;
}
input[type="range"] {
width: 100%;
}
@media (max-width: 1200px) {
.app-grid {
grid-template-columns: 1fr;
}
.editor-stage {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.viewer-grid {
grid-template-columns: 1fr;
}
.controls-grid {
grid-template-columns: 1fr;
}
.status-cluster {
justify-items: start;
text-align: left;
}
.masthead {
align-items: start;
flex-direction: column;
}
}

View file

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any, Protocol
class PipelineObserver(Protocol):
def __call__(self, event: str, payload: dict[str, Any]) -> None: ...
def emit_observer(observer: PipelineObserver | None, event: str, /, **payload: Any) -> None:
if observer is None:
return
observer(event, payload)

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass
import numpy as np
from .io import premultiply
from .observe import PipelineObserver, emit_observer
from .types import InferenceResult, PhaseFieldSourceAnalysis, SolverArtifacts
from .params import SolverHyperParams
@ -342,6 +343,31 @@ def _nearest_source_rgba(source_rgba: np.ndarray, sample_x: np.ndarray, sample_y
return source_rgba[sample_y, sample_x]
def _observer_snapshot(
prep: _PhaseFieldPrep,
rgba: np.ndarray,
pos_px: np.ndarray,
) -> dict[str, np.ndarray]:
sample_x = np.rint(pos_px[..., 0]).astype(np.int32).clip(0, prep.width - 1)
sample_y = np.rint(pos_px[..., 1]).astype(np.int32).clip(0, prep.height - 1)
displacement = _displacement_diagnostics(
prep.uv0_norm,
sample_x,
sample_y,
width=prep.width,
height=prep.height,
)
return {
"target_rgba": _nearest_source_rgba(rgba, sample_x, sample_y),
"sample_x": sample_x,
"sample_y": sample_y,
"pos_x_px": pos_px[..., 0].astype(np.float32),
"pos_y_px": pos_px[..., 1].astype(np.float32),
"displacement_x": displacement["displacement_x"],
"displacement_y": displacement["displacement_y"],
}
def optimize_phase_field(
rgba: np.ndarray,
inference: InferenceResult,
@ -350,6 +376,7 @@ def optimize_phase_field(
seed: int,
device: str,
solver_params: SolverHyperParams | None = None,
observer: PipelineObserver | None = None,
) -> SolverArtifacts:
torch, F = _require_torch()
device = _resolve_device(torch, device)
@ -367,6 +394,16 @@ def optimize_phase_field(
solver_params,
device=device,
)
emit_observer(
observer,
"phase_field_prepared",
uv0_px=prep.uv0_px_t[0].detach().cpu().numpy().astype(np.float32),
guidance=prep.guidance.copy(),
cell_x=float(prep.cell_x),
cell_y=float(prep.cell_y),
target_width=int(inference.target_width),
target_height=int(inference.target_height),
)
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)
@ -374,6 +411,13 @@ def optimize_phase_field(
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)
initial_rgba = _nearest_source_rgba(rgba, initial_x, initial_y)
emit_observer(
observer,
"phase_field_initial",
step=0,
total_steps=int(max(0, steps)),
**_observer_snapshot(prep, rgba, prep.uv0_px_t[0].detach().cpu().numpy().astype(np.float32)),
)
loss_history: list[float] = []
final_terms: dict[str, float] = {}
@ -382,7 +426,7 @@ def optimize_phase_field(
max_dx = solver_params.phase_field_max_displacement_ratio * prep.cell_x
max_dy = solver_params.phase_field_max_displacement_ratio * prep.cell_y
for _ in range(max(0, steps)):
for step_index in range(max(0, steps)):
optimizer.zero_grad(set_to_none=True)
loss, _sampled_rgba, terms = _phase_field_loss(torch, F, prep, disp_t, solver_params)
loss.backward()
@ -399,8 +443,20 @@ def optimize_phase_field(
width=prep.width,
height=prep.height,
)
loss_history.append(float(loss.detach().cpu().item()))
loss_value = float(loss.detach().cpu().item())
loss_history.append(loss_value)
final_terms = terms
if observer is not None:
current_pos = (prep.uv0_px_t + disp_t)[0].detach().cpu().numpy().astype(np.float32)
emit_observer(
observer,
"phase_field_step",
step=int(step_index + 1),
total_steps=int(max(0, steps)),
loss=loss_value,
terms=terms.copy(),
**_observer_snapshot(prep, rgba, current_pos),
)
with torch.no_grad():
if steps <= 0:
@ -439,6 +495,15 @@ def optimize_phase_field(
**final_terms,
},
}
emit_observer(
observer,
"phase_field_final",
step=int(max(0, steps)),
total_steps=int(max(0, steps)),
phase_metrics=stage_diagnostics["phase_field"].copy(),
loss_history=loss_history.copy(),
**_observer_snapshot(prep, rgba, final_px[0].detach().cpu().numpy().astype(np.float32)),
)
return SolverArtifacts(
target_rgba=target_rgba,
uv_field=final_pos_norm,

View file

@ -6,6 +6,7 @@ from typing import Any
import numpy as np
from .observe import PipelineObserver, emit_observer
from .analysis import analyze_phase_field_source
from .diagnostics import (
summarize_run,
@ -50,12 +51,56 @@ def run_pipeline(
solver_params: SolverHyperParams | None = None,
strip_background: bool = False,
enable_phase_rerank: bool = True,
observer: PipelineObserver | None = None,
) -> RunResult:
source = load_rgba(input_path)
return run_pipeline_rgba(
source,
output_path=output_path,
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,
seed=seed,
steps=steps,
device=device,
solver_params=solver_params,
strip_background=strip_background,
enable_phase_rerank=enable_phase_rerank,
observer=observer,
)
def run_pipeline_rgba(
source: np.ndarray,
*,
output_path: str | Path | None = None,
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,
seed: int = 7,
steps: int = 200,
device: str = "auto",
solver_params: SolverHyperParams | None = None,
strip_background: bool = False,
enable_phase_rerank: bool = True,
observer: PipelineObserver | None = None,
) -> RunResult:
started = time.perf_counter()
solver_params = solver_params or SolverHyperParams()
source = load_rgba(input_path)
emit_observer(observer, "source_loaded", source_rgba=source.copy())
if strip_background:
source = strip_edge_background(source)
emit_observer(observer, "preprocess_completed", source_rgba=source.copy(), operation="strip_background")
fixed_dims = _resolve_requested_target_dims(
source_width=source.shape[1],
source_height=source.shape[0],
@ -78,11 +123,13 @@ def run_pipeline(
device=device,
)
inference_mode = "fixed"
emit_observer(observer, "inference_candidates_ready", inference=inference, inference_mode=inference_mode)
analysis = _prepare_analysis(
source,
seed=seed,
device=device,
)
emit_observer(observer, "analysis_completed", edge_map=analysis.edge_map.copy())
inference = _select_phase_candidate(
source,
inference,
@ -92,7 +139,9 @@ def run_pipeline(
device=device,
solver_params=solver_params,
enable_phase_rerank=enable_phase_rerank,
observer=observer,
)
emit_observer(observer, "phase_selection_completed", inference=inference, inference_mode=inference_mode)
solver, reconstruction_diagnostics = _run_reconstruction(
source,
inference=inference,
@ -101,13 +150,28 @@ def run_pipeline(
seed=seed,
device=device,
solver_params=solver_params,
observer=observer,
)
cleanup = cleanup_pixels(solver.target_rgba, source_guidance=solver.guidance_strength)
emit_observer(
observer,
"cleanup_completed",
cleaned_rgba=cleanup.cleaned_rgba.copy(),
isolated_heatmap=cleanup.isolated_heatmap.copy(),
)
palette = load_palette(palette_path) if palette_path else None
palette_result = quantize_rgba(cleanup.cleaned_rgba, mode=palette_mode, palette=palette)
output_rgba = palette_result.rgba if palette_result else cleanup.cleaned_rgba
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
save_rgba(output_path, output_rgba)
if output_path is not None:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
save_rgba(output_path, output_rgba)
emit_observer(
observer,
"palette_completed",
output_rgba=output_rgba.copy(),
palette_mode=palette_mode,
palette_result=palette_result,
)
diagnostics: dict[str, Any] = {
"elapsed_seconds": time.perf_counter() - started,
@ -123,6 +187,34 @@ def run_pipeline(
palette_result=palette_result,
diagnostics=diagnostics,
)
run_summary: dict[str, Any] | None = None
if diagnostics_dir or observer is not None:
run_summary = summarize_run(result)
run_summary["inference"] = inference_to_json(inference)
run_summary["settings"] = {
"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,
"inference_mode": inference_mode,
"solver_params": solver_params.to_dict(),
}
if diagnostics.get("reconstruction"):
run_summary["reconstruction"] = diagnostics["reconstruction"]
emit_observer(
observer,
"pipeline_completed",
output_rgba=output_rgba.copy(),
diagnostics=diagnostics.copy(),
run_summary=run_summary,
)
if diagnostics_dir:
diagnostics_path = Path(diagnostics_dir)
diagnostics_path.mkdir(parents=True, exist_ok=True)
@ -154,26 +246,7 @@ def run_pipeline(
height=output_rgba.shape[0] * 8,
),
)
run_json = summarize_run(result)
run_json["inference"] = inference_to_json(inference)
run_json["settings"] = {
"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,
"inference_mode": inference_mode,
"solver_params": solver_params.to_dict(),
}
if diagnostics.get("reconstruction"):
run_json["reconstruction"] = diagnostics["reconstruction"]
write_json(diagnostics_path / "run.json", run_json)
write_json(diagnostics_path / "run.json", run_summary or {})
if palette_result is not None:
save_palette_report(diagnostics_path / "palette-report.json", palette_result.palette)
return result
@ -198,6 +271,7 @@ def _select_phase_candidate(
device: str,
solver_params: SolverHyperParams | None = None,
enable_phase_rerank: bool = True,
observer: PipelineObserver | None = None,
) -> InferenceResult:
return _select_phase_candidate_with_reconstruction(
source,
@ -208,6 +282,7 @@ def _select_phase_candidate(
device=device,
solver_params=solver_params,
enable_phase_rerank=enable_phase_rerank,
observer=observer,
)
@ -221,6 +296,7 @@ def _select_phase_candidate_with_reconstruction(
device: str,
solver_params: SolverHyperParams | None = None,
enable_phase_rerank: bool = True,
observer: PipelineObserver | None = None,
) -> InferenceResult:
solver_params = solver_params or SolverHyperParams()
if not enable_phase_rerank:
@ -230,6 +306,13 @@ def _select_phase_candidate_with_reconstruction(
preview_steps = min(max(0, int(steps)), max(0, int(solver_params.phase_rerank_preview_steps)))
top_score = float(inference.top_candidates[0].score)
emit_observer(
observer,
"phase_rerank_started",
preview_steps=preview_steps,
candidate_count=min(8, len(inference.top_candidates)),
confidence=float(inference.confidence),
)
candidate_records: list[dict[str, float | InferenceResult]] = []
for candidate in inference.top_candidates[:8]:
candidate_inference = InferenceResult(
@ -248,6 +331,7 @@ def _select_phase_candidate_with_reconstruction(
seed=seed,
device=device,
solver_params=solver_params,
observer=None,
)
support = source_lattice_consistency_breakdown(
source,
@ -418,16 +502,29 @@ def _run_reconstruction(
seed: int,
device: str,
solver_params: SolverHyperParams,
observer: PipelineObserver | None = None,
):
solver = optimize_phase_field(
source,
inference=inference,
analysis=analysis,
steps=steps,
seed=seed,
device=device,
solver_params=solver_params,
)
if observer is None:
solver = optimize_phase_field(
source,
inference=inference,
analysis=analysis,
steps=steps,
seed=seed,
device=device,
solver_params=solver_params,
)
else:
solver = optimize_phase_field(
source,
inference=inference,
analysis=analysis,
steps=steps,
seed=seed,
device=device,
solver_params=solver_params,
observer=observer,
)
phase_field_metrics = getattr(solver, "stage_diagnostics", {}).get("phase_field", {})
return solver, {
"mode": "phase-field",

View file

@ -55,3 +55,18 @@ def test_default_command_accepts_fixed_lattice_flags(tmp_path: Path) -> None:
)
assert exit_code == 0
assert output_path.exists()
def test_gui_command_dispatches_to_gui_main(monkeypatch) -> None:
called = {}
def fake_gui_main(*, host: str, port: int, reload: bool) -> int:
called["host"] = host
called["port"] = port
called["reload"] = reload
return 0
monkeypatch.setattr("repixelizer.cli.gui_main", fake_gui_main)
exit_code = main(["gui", "--host", "127.0.0.1", "--port", "8123", "--reload"])
assert exit_code == 0
assert called == {"host": "127.0.0.1", "port": 8123, "reload": True}

37
tests/test_gui.py Normal file
View file

@ -0,0 +1,37 @@
from __future__ import annotations
from repixelizer.pipeline import run_pipeline_rgba
from repixelizer.synthetic import fake_pixelize, make_emblem
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)
events: list[str] = []
def observer(event: str, payload) -> None:
del payload
events.append(event)
run_pipeline_rgba(
fake,
target_width=20,
target_height=20,
phase_x=0.0,
phase_y=0.0,
steps=2,
device="cpu",
enable_phase_rerank=False,
observer=observer,
)
assert events[:4] == [
"source_loaded",
"inference_candidates_ready",
"analysis_completed",
"phase_selection_completed",
]
assert "phase_field_prepared" in events
assert "phase_field_initial" in events
assert events.count("phase_field_step") == 2
assert events[-3:] == ["cleanup_completed", "palette_completed", "pipeline_completed"]