diff --git a/.gitignore b/.gitignore index 7e507c5..b44a446 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ htmlcov/ build/ dist/ *.egg-info/ +frontend/node_modules/ .vscode/ .idea/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a0d63ee --- /dev/null +++ b/frontend/package-lock.json @@ -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" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8d54396 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/app.ts b/frontend/src/app.ts new file mode 100644 index 0000000..a49e28f --- /dev/null +++ b/frontend/src/app.ts @@ -0,0 +1,965 @@ +type JsonRecord = Record; + +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; +}; + +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; + phaseMetrics: Record; + 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(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing element: ${id}`); + } + return element as T; +} + +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", +] 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 = `

Nothing to report yet.

`; + return; + } + for (const entry of state.eventLog) { + const node = document.createElement("div"); + node.className = "event-item"; + node.innerHTML = `${entry.label}${entry.detail}`; + 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 { + 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: HTMLCanvasElement, asset: ImageAsset | null): Promise { + 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 = `

No lattice picked yet.

`; + candidateList.innerHTML = ""; + return; + } + inferenceSummary.innerHTML = ` +
+ Chosen Grid + ${state.inference.target_width} x ${state.inference.target_height} +
+
+ Phase + ${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)} +
+
+ Confidence + ${formatNumber(state.inference.confidence, 3)} +
+ `; + 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 = ` + ${candidate.target_width} x ${candidate.target_height} + phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}
+ score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""} + `; + 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 = `${label}${value}`; + metricsPanel.appendChild(node); + } +} + +function renderSummary(): void { + summaryPanel.innerHTML = ""; + if (!state.runSummary) { + summaryPanel.innerHTML = `

Final metrics show up here after the run finishes.

`; + 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 = `${label}${formatNumber(value, 3)}`; + summaryPanel.appendChild(card); + } +} + +async function renderViewer(): Promise { + 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 { + 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 = `

Final output lands here once the solver finishes its little pilgrimage.

`; + 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 = ` +
+ Canvas + ${offscreenCanvas.width} x ${offscreenCanvas.height} +
+
+ State + ${state.editorDirty ? "Edited" : "Matches solver output"} +
+ `; +} + +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 { + renderInference(); + renderSlider(); + renderLossChart(); + renderSummary(); + await renderViewer(); +} + +async function handleEvent(eventName: string, payload: JsonRecord): Promise { + 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; + 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 { + 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(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a46b02f --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/pyproject.toml b/pyproject.toml index 1311fae..a54e9fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index 2c2e03b..5d7cbc6 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -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, diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py new file mode 100644 index 0000000..01b9669 --- /dev/null +++ b/src/repixelizer/gui.py @@ -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("

GUI assets are missing.

Build the TypeScript frontend first.

") + + 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 diff --git a/src/repixelizer/gui_static/app.js b/src/repixelizer/gui_static/app.js new file mode 100644 index 0000000..32fe6aa --- /dev/null +++ b/src/repixelizer/gui_static/app.js @@ -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 = `

Nothing to report yet.

`; + return; + } + for (const entry of state.eventLog) { + const node = document.createElement("div"); + node.className = "event-item"; + node.innerHTML = `${entry.label}${entry.detail}`; + 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 = `

No lattice picked yet.

`; + candidateList.innerHTML = ""; + return; + } + inferenceSummary.innerHTML = ` +
+ Chosen Grid + ${state.inference.target_width} x ${state.inference.target_height} +
+
+ Phase + ${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)} +
+
+ Confidence + ${formatNumber(state.inference.confidence, 3)} +
+ `; + 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 = ` + ${candidate.target_width} x ${candidate.target_height} + phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}
+ score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""} + `; + 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 = `${label}${value}`; + metricsPanel.appendChild(node); + } +} +function renderSummary() { + summaryPanel.innerHTML = ""; + if (!state.runSummary) { + summaryPanel.innerHTML = `

Final metrics show up here after the run finishes.

`; + 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 = `${label}${formatNumber(value, 3)}`; + 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 = `

Final output lands here once the solver finishes its little pilgrimage.

`; + 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 = ` +
+ Canvas + ${offscreenCanvas.width} x ${offscreenCanvas.height} +
+
+ State + ${state.editorDirty ? "Edited" : "Matches solver output"} +
+ `; +} +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(); diff --git a/src/repixelizer/gui_static/index.html b/src/repixelizer/gui_static/index.html new file mode 100644 index 0000000..afd53b9 --- /dev/null +++ b/src/repixelizer/gui_static/index.html @@ -0,0 +1,203 @@ + + + + + + Repixelizer GUI + + + +
+
+
+

Live Pipeline

+

Repixelizer

+
+
+ Idle +

Drop a fake sprite in here and let the machine explain itself for once.

+
+
+ +
+ + +
+
+
+
+

Algorithm View

+

Pipeline Stages

+
+
+ + + No frames yet +
+
+ +
+
+
Source
+ +
+
+
Scout
+ +
+
+
Output
+ +
+
+
+
+ +
+
+
+

Pixel Fixes

+

Output Editor

+
+
+ Pencil. Hold Alt for eyedropper. + + +
+
+ +
+
+
+
+ + + + +
+
+
+ + + 12x +
+ +
+ +
+
+ + +
+
+
+

Final output lands here once the solver finishes its little pilgrimage.

+
+
+
+
+
+
+
+
+ + + + diff --git a/src/repixelizer/gui_static/styles.css b/src/repixelizer/gui_static/styles.css new file mode 100644 index 0000000..37f8ff9 --- /dev/null +++ b/src/repixelizer/gui_static/styles.css @@ -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; + } +} diff --git a/src/repixelizer/observe.py b/src/repixelizer/observe.py new file mode 100644 index 0000000..3c68af7 --- /dev/null +++ b/src/repixelizer/observe.py @@ -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) diff --git a/src/repixelizer/phase_field.py b/src/repixelizer/phase_field.py index f444f9a..de77546 100644 --- a/src/repixelizer/phase_field.py +++ b/src/repixelizer/phase_field.py @@ -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, diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index 2b2d7f9..d5117a1 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -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", diff --git a/tests/test_cli.py b/tests/test_cli.py index ae585a9..5a5eb21 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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} diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..e82ac3b --- /dev/null +++ b/tests/test_gui.py @@ -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"]