diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 829f584..c8b1584 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -41,10 +41,25 @@ type LogItem = { detail: string; }; +type PhaseRerankInfo = { + previewSteps: number; + candidateCount: number; + confidence: number; +}; + +type PhaseFieldPrepInfo = { + targetWidth: number; + targetHeight: number; + cellX: number; + cellY: number; +}; + type AppState = { file: File | null; jobId: string | null; status: string; + stageKey: string; + stageLabel: string; statusText: string; inputPreviewImage: ImageAsset | null; sourceImage: ImageAsset | null; @@ -52,6 +67,9 @@ type AppState = { latticeImage: ImageAsset | null; guidanceImage: ImageAsset | null; inference: InferencePayload | null; + inferenceMode: string | null; + phaseRerank: PhaseRerankInfo | null; + phaseFieldPrep: PhaseFieldPrepInfo | null; frames: FramePayload[]; cleanupImage: ImageAsset | null; heatmapImage: ImageAsset | null; @@ -87,8 +105,11 @@ const dropzoneLabel = byId("dropzoneLabel"); const inputPreviewCanvas = byId("inputPreviewCanvas"); const pickFileButton = byId("pickFileButton"); const runButton = byId("runButton"); +const statusPanel = byId("statusPanel"); const statusBadge = byId("statusBadge"); +const statusStage = byId("statusStage"); const statusText = byId("statusText"); +const statusMetrics = byId("statusMetrics"); const inferenceSummary = byId("inferenceSummary"); const candidateList = byId("candidateList"); const eventLog = byId("eventLog"); @@ -99,7 +120,6 @@ const leftCanvas = byId("leftCanvas"); const rightCanvas = byId("rightCanvas"); const leftVizLabel = byId("leftVizLabel"); const rightVizLabel = byId("rightVizLabel"); -const metricsPanel = byId("metricsPanel"); const paintSwatch = byId("paintSwatch"); const zoomInput = byId("zoomInput"); const zoomValue = byId("zoomValue"); @@ -134,6 +154,7 @@ const imageCache = new Map>(); const eventTypes = [ "job_state", "job_failed", + "stage_started", "source_loaded", "preprocess_completed", "inference_candidates_ready", @@ -153,13 +174,18 @@ const state: AppState = { file: null, jobId: null, status: "idle", - statusText: "Waiting for input.", + stageKey: "idle", + stageLabel: "Waiting for input", + statusText: "Choose a file, then run the machine.", inputPreviewImage: null, sourceImage: null, preprocessedImage: null, latticeImage: null, guidanceImage: null, inference: null, + inferenceMode: null, + phaseRerank: null, + phaseFieldPrep: null, frames: [], cleanupImage: null, heatmapImage: null, @@ -232,10 +258,21 @@ function renderEventLog(): void { } } -function setStatus(status: string, detail: string): void { +function formatStatusBadge(status: string): string { + return status.replaceAll("_", " ").toUpperCase(); +} + +function setJobState(status: string): void { state.status = status; + statusBadge.textContent = formatStatusBadge(status); + statusPanel.dataset.jobState = status; +} + +function setStage(stageKey: string, label: string, detail: string): void { + state.stageKey = stageKey; + state.stageLabel = label; state.statusText = detail; - statusBadge.textContent = status; + statusStage.textContent = label; statusText.textContent = detail; } @@ -246,6 +283,9 @@ function resetRunArtifacts(): void { state.latticeImage = null; state.guidanceImage = null; state.inference = null; + state.inferenceMode = null; + state.phaseRerank = null; + state.phaseFieldPrep = null; state.frames = []; state.cleanupImage = null; state.heatmapImage = null; @@ -419,31 +459,40 @@ function renderInference(): void { } } -function renderMetrics(): void { - metricsPanel.querySelectorAll("[data-dynamic='true']").forEach((node) => { - node.remove(); - }); - const frame = getSelectedFrame(); +function renderStatusMetrics(): void { + statusMetrics.innerHTML = ""; const items: Array<[string, string]> = []; - if (frame) { - 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)]); + const frame = getSelectedFrame(); + if (state.stageKey === "inference" || state.stageKey === "analysis" || state.stageKey === "selection") { + if (state.inference) { + items.push(["Mode", state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search"]); + items.push(["Grid", `${state.inference.target_width} x ${state.inference.target_height}`]); + items.push(["Phase", `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}`]); + items.push(["Confidence", formatNumber(state.inference.confidence, 3)]); + } + } else if (state.stageKey === "rerank") { + if (state.phaseRerank) { + items.push(["Preview steps", String(state.phaseRerank.previewSteps)]); + items.push(["Candidates", String(state.phaseRerank.candidateCount)]); + items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]); + } + } else if (state.stageKey === "solver") { + if (state.phaseFieldPrep) { + items.push(["Grid", `${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight}`]); + items.push(["Cell pitch", `${formatNumber(state.phaseFieldPrep.cellX, 1)} x ${formatNumber(state.phaseFieldPrep.cellY, 1)} px`]); + } + if (frame) { + items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]); + for (const [key, value] of Object.entries(frame.terms).slice(0, 4)) { + 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(["Input color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]); } for (const [label, value] of items) { const node = document.createElement("div"); - node.className = "metric-chip"; - node.dataset.dynamic = "true"; + node.className = "status-metric"; node.innerHTML = `${label}${value}`; - metricsPanel.appendChild(node); + statusMetrics.appendChild(node); } } @@ -500,7 +549,6 @@ async function renderViewer(): Promise { leftVizLabel.textContent = leftLabel; rightVizLabel.textContent = rightLabel; await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]); - renderMetrics(); } function renderSlider(): void { @@ -522,6 +570,40 @@ function renderSlider(): void { stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`; } +function drawWrappedCanvasText( + context: CanvasRenderingContext2D, + text: string, + options: { x: number; y: number; maxWidth: number; lineHeight: number }, +): void { + const { x, y, maxWidth, lineHeight } = options; + const words = text.split(/\s+/); + let line = ""; + let cursorY = y; + for (const word of words) { + const nextLine = line ? `${line} ${word}` : word; + if (line && context.measureText(nextLine).width > maxWidth) { + context.fillText(line, x, cursorY); + line = word; + cursorY += lineHeight; + continue; + } + line = nextLine; + } + if (line) { + context.fillText(line, x, cursorY); + } +} + +function renderLossPlaceholder(context: CanvasRenderingContext2D, width: number, heading: string, detail: string): void { + context.textBaseline = "top"; + context.fillStyle = "rgba(255, 216, 74, 0.9)"; + context.font = "12px 'Press Start 2P', monospace"; + context.fillText(heading.toUpperCase(), 18, 14); + context.fillStyle = "rgba(255,255,255,0.42)"; + context.font = "18px 'VT323', monospace"; + drawWrappedCanvasText(context, detail, { x: 18, y: 38, maxWidth: width - 36, lineHeight: 20 }); +} + function renderLossChart(): void { const context = lossCanvas.getContext("2d"); if (!context) { @@ -541,14 +623,15 @@ function renderLossChart(): void { context.lineTo(width, y); context.stroke(); } + if (state.stageKey !== "solver") { + renderLossPlaceholder(context, width, state.stageLabel, state.statusText); + return; + } 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.textBaseline = "top"; - context.fillText("Loss curve wakes up once the solver starts moving.", 18, 16); + renderLossPlaceholder(context, width, "Phase-field solve", "Loss samples land here once the solver starts moving."); return; } const minLoss = Math.min(...losses); @@ -798,6 +881,7 @@ function buildFormData(): FormData { async function renderEverything(): Promise { renderInference(); renderSlider(); + renderStatusMetrics(); renderLossChart(); renderSummary(); await renderViewer(); @@ -806,17 +890,25 @@ async function renderEverything(): Promise { async function handleEvent(eventName: string, payload: JsonRecord): Promise { switch (eventName) { case "job_state": - setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running."); + setJobState(String(payload.status ?? "working")); break; case "job_failed": - setStatus("failed", String(payload.message ?? "The GUI run fell over.")); + setJobState("failed"); + setStage("failed", "Run failed", String(payload.message ?? "The GUI run fell over.")); addLog("Failure", String(payload.message ?? "The run failed.")); runButton.disabled = false; currentEventSource?.close(); break; + case "stage_started": + setJobState("running"); + setStage( + String(payload.stage ?? "running"), + String(payload.label ?? "Working"), + String(payload.detail ?? "The pipeline is chewing on the input."), + ); + break; case "source_loaded": state.sourceImage = payload.sourceImage as ImageAsset; - setStatus("running", "Input loaded. Survey crew is measuring the mess."); addLog("Input", "Loaded the input image."); break; case "preprocess_completed": @@ -825,9 +917,20 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise a.step - b.step); } - if (state.autoFollow) { - state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step)); - } - if (eventName === "phase_field_final") { - addLog("Solver", "Final nearest-input sample committed."); - } - break; + if (state.autoFollow) { + state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step)); } + setStage( + "solver", + "Phase-field solve", + frame.totalSteps <= 0 + ? "Initial placement committed. No iterative solver steps were requested." + : `Solver step ${frame.step} / ${frame.totalSteps}. Loss and drift terms stay live in this panel while it walks.`, + ); + if (eventName === "phase_field_final") { + addLog("Solver", "Final nearest-input sample committed."); + } + break; + } case "cleanup_completed": state.cleanupImage = payload.cleanedImage as ImageAsset; state.heatmapImage = payload.heatmapImage as ImageAsset; + setStage("cleanup", "Cleanup", "Sweeping isolated artifacts and smoothing the last stubborn junk."); addLog("Cleanup", "Ran the local cleanup sweep."); break; case "palette_completed": state.finalOutputImage = payload.outputImage as ImageAsset; + setStage("output", "Final output", "Writing the finished output and parking it in the editor."); 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; - setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel."); + setJobState("completed"); + setStage("completed", "Run complete", "The output is ready. If the machine still did something stupid, fix it pixel by pixel."); addLog("Done", "Full pipeline completed."); runButton.disabled = false; currentEventSource?.close(); @@ -901,20 +1026,23 @@ function connectEventStream(jobId: string): void { } stream.onerror = () => { if (state.status === "running") { - setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it."); + setJobState("waiting"); + setStage(state.stageKey, state.stageLabel, "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."); + setJobState("idle"); + setStage("idle", "Waiting for input", "Pick a file first. The machine is not clairvoyant."); return; } resetRunArtifacts(); renderEventLog(); runButton.disabled = true; - setStatus("queued", "Queued the run. Small miracle pending."); + setJobState("queued"); + setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately."); addLog("Queued", `Starting ${state.file.name}.`); await renderEverything(); try { @@ -930,7 +1058,8 @@ async function startRun(): Promise { connectEventStream(payload.jobId); } catch (error) { runButton.disabled = false; - setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job."); + setJobState("failed"); + setStage("failed", "Run failed", error instanceof Error ? error.message : "Failed to start GUI job."); addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job."); } } @@ -938,7 +1067,8 @@ async function startRun(): Promise { async function acceptFile(file: File): Promise { state.file = file; dropzoneLabel.textContent = file.name; - setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`); + setJobState("idle"); + setStage("ready", "Ready to run", `${file.name} is loaded. Hit run when you want the machine to start sweating.`); try { const previewImage = await fileToImageAsset(file); if (state.file !== file) { @@ -950,7 +1080,8 @@ async function acceptFile(file: File): Promise { return; } state.inputPreviewImage = null; - setStatus("failed", error instanceof Error ? error.message : "Failed to load image preview."); + setJobState("failed"); + setStage("failed", "Preview failed", error instanceof Error ? error.message : "Failed to load image preview."); } await renderInputPreview(); } diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index 12a9b6a..64bde81 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -208,6 +208,12 @@ class GuiJob: "candidateCount": int(payload["candidate_count"]), "confidence": float(payload["confidence"]), } + if event == "stage_started": + return { + "stage": str(payload["stage"]), + "label": str(payload["label"]), + "detail": str(payload["detail"]), + } if event == "analysis_completed": edge_map = payload["edge_map"] return { diff --git a/src/repixelizer/gui_static/app.js b/src/repixelizer/gui_static/app.js index 021b9c0..c42498e 100644 --- a/src/repixelizer/gui_static/app.js +++ b/src/repixelizer/gui_static/app.js @@ -12,8 +12,11 @@ const dropzoneLabel = byId("dropzoneLabel"); const inputPreviewCanvas = byId("inputPreviewCanvas"); const pickFileButton = byId("pickFileButton"); const runButton = byId("runButton"); +const statusPanel = byId("statusPanel"); const statusBadge = byId("statusBadge"); +const statusStage = byId("statusStage"); const statusText = byId("statusText"); +const statusMetrics = byId("statusMetrics"); const inferenceSummary = byId("inferenceSummary"); const candidateList = byId("candidateList"); const eventLog = byId("eventLog"); @@ -24,7 +27,6 @@ const leftCanvas = byId("leftCanvas"); const rightCanvas = byId("rightCanvas"); const leftVizLabel = byId("leftVizLabel"); const rightVizLabel = byId("rightVizLabel"); -const metricsPanel = byId("metricsPanel"); const paintSwatch = byId("paintSwatch"); const zoomInput = byId("zoomInput"); const zoomValue = byId("zoomValue"); @@ -56,6 +58,7 @@ const imageCache = new Map(); const eventTypes = [ "job_state", "job_failed", + "stage_started", "source_loaded", "preprocess_completed", "inference_candidates_ready", @@ -74,13 +77,18 @@ const state = { file: null, jobId: null, status: "idle", - statusText: "Waiting for input.", + stageKey: "idle", + stageLabel: "Waiting for input", + statusText: "Choose a file, then run the machine.", inputPreviewImage: null, sourceImage: null, preprocessedImage: null, latticeImage: null, guidanceImage: null, inference: null, + inferenceMode: null, + phaseRerank: null, + phaseFieldPrep: null, frames: [], cleanupImage: null, heatmapImage: null, @@ -145,10 +153,19 @@ function renderEventLog() { eventLog.appendChild(node); } } -function setStatus(status, detail) { +function formatStatusBadge(status) { + return status.replaceAll("_", " ").toUpperCase(); +} +function setJobState(status) { state.status = status; + statusBadge.textContent = formatStatusBadge(status); + statusPanel.dataset.jobState = status; +} +function setStage(stageKey, label, detail) { + state.stageKey = stageKey; + state.stageLabel = label; state.statusText = detail; - statusBadge.textContent = status; + statusStage.textContent = label; statusText.textContent = detail; } function resetRunArtifacts() { @@ -158,6 +175,9 @@ function resetRunArtifacts() { state.latticeImage = null; state.guidanceImage = null; state.inference = null; + state.inferenceMode = null; + state.phaseRerank = null; + state.phaseFieldPrep = null; state.frames = []; state.cleanupImage = null; state.heatmapImage = null; @@ -320,32 +340,42 @@ function renderInference() { candidateList.appendChild(node); } } -function renderMetrics() { - metricsPanel.querySelectorAll("[data-dynamic='true']").forEach((node) => { - node.remove(); - }); - const frame = getSelectedFrame(); +function renderStatusMetrics() { + statusMetrics.innerHTML = ""; const items = []; - if (frame) { - 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)]); + const frame = getSelectedFrame(); + if (state.stageKey === "inference" || state.stageKey === "analysis" || state.stageKey === "selection") { + if (state.inference) { + items.push(["Mode", state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search"]); + items.push(["Grid", `${state.inference.target_width} x ${state.inference.target_height}`]); + items.push(["Phase", `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}`]); + items.push(["Confidence", formatNumber(state.inference.confidence, 3)]); } } - 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(["Input color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]); + else if (state.stageKey === "rerank") { + if (state.phaseRerank) { + items.push(["Preview steps", String(state.phaseRerank.previewSteps)]); + items.push(["Candidates", String(state.phaseRerank.candidateCount)]); + items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]); + } + } + else if (state.stageKey === "solver") { + if (state.phaseFieldPrep) { + items.push(["Grid", `${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight}`]); + items.push(["Cell pitch", `${formatNumber(state.phaseFieldPrep.cellX, 1)} x ${formatNumber(state.phaseFieldPrep.cellY, 1)} px`]); + } + if (frame) { + items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]); + for (const [key, value] of Object.entries(frame.terms).slice(0, 4)) { + items.push([key.replaceAll("_", " "), formatNumber(value, 4)]); + } + } } for (const [label, value] of items) { const node = document.createElement("div"); - node.className = "metric-chip"; - node.dataset.dynamic = "true"; + node.className = "status-metric"; node.innerHTML = `${label}${value}`; - metricsPanel.appendChild(node); + statusMetrics.appendChild(node); } } function renderSummary() { @@ -399,7 +429,6 @@ async function renderViewer() { leftVizLabel.textContent = leftLabel; rightVizLabel.textContent = rightLabel; await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]); - renderMetrics(); } function renderSlider() { if (state.frames.length === 0) { @@ -419,6 +448,34 @@ function renderSlider() { } stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`; } +function drawWrappedCanvasText(context, text, options) { + const { x, y, maxWidth, lineHeight } = options; + const words = text.split(/\s+/); + let line = ""; + let cursorY = y; + for (const word of words) { + const nextLine = line ? `${line} ${word}` : word; + if (line && context.measureText(nextLine).width > maxWidth) { + context.fillText(line, x, cursorY); + line = word; + cursorY += lineHeight; + continue; + } + line = nextLine; + } + if (line) { + context.fillText(line, x, cursorY); + } +} +function renderLossPlaceholder(context, width, heading, detail) { + context.textBaseline = "top"; + context.fillStyle = "rgba(255, 216, 74, 0.9)"; + context.font = "12px 'Press Start 2P', monospace"; + context.fillText(heading.toUpperCase(), 18, 14); + context.fillStyle = "rgba(255,255,255,0.42)"; + context.font = "18px 'VT323', monospace"; + drawWrappedCanvasText(context, detail, { x: 18, y: 38, maxWidth: width - 36, lineHeight: 20 }); +} function renderLossChart() { const context = lossCanvas.getContext("2d"); if (!context) { @@ -438,14 +495,15 @@ function renderLossChart() { context.lineTo(width, y); context.stroke(); } + if (state.stageKey !== "solver") { + renderLossPlaceholder(context, width, state.stageLabel, state.statusText); + return; + } 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.textBaseline = "top"; - context.fillText("Loss curve wakes up once the solver starts moving.", 18, 16); + renderLossPlaceholder(context, width, "Phase-field solve", "Loss samples land here once the solver starts moving."); return; } const minLoss = Math.min(...losses); @@ -679,6 +737,7 @@ function buildFormData() { async function renderEverything() { renderInference(); renderSlider(); + renderStatusMetrics(); renderLossChart(); renderSummary(); await renderViewer(); @@ -686,17 +745,21 @@ async function renderEverything() { async function handleEvent(eventName, payload) { switch (eventName) { case "job_state": - setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running."); + setJobState(String(payload.status ?? "working")); break; case "job_failed": - setStatus("failed", String(payload.message ?? "The GUI run fell over.")); + setJobState("failed"); + setStage("failed", "Run failed", String(payload.message ?? "The GUI run fell over.")); addLog("Failure", String(payload.message ?? "The run failed.")); runButton.disabled = false; currentEventSource?.close(); break; + case "stage_started": + setJobState("running"); + setStage(String(payload.stage ?? "running"), String(payload.label ?? "Working"), String(payload.detail ?? "The pipeline is chewing on the input.")); + break; case "source_loaded": state.sourceImage = payload.sourceImage; - setStatus("running", "Input loaded. Survey crew is measuring the mess."); addLog("Input", "Loaded the input image."); break; case "preprocess_completed": @@ -705,13 +768,21 @@ async function handleEvent(eventName, payload) { break; case "inference_candidates_ready": state.inference = payload.inference; + state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode; addLog("Inference", "Scored candidate grids and phase offsets."); break; case "phase_rerank_started": + state.phaseRerank = { + previewSteps: Number(payload.previewSteps ?? 0), + candidateCount: Number(payload.candidateCount ?? 0), + confidence: Number(payload.confidence ?? 0), + }; + setStage("rerank", "Phase rerank", `Previewing ${state.phaseRerank.candidateCount} low-confidence candidates over ${state.phaseRerank.previewSteps} short solver steps.`); 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.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode; addLog("Selection", "Committed to a ruler and phase."); break; case "analysis_completed": @@ -720,6 +791,13 @@ async function handleEvent(eventName, payload) { case "phase_field_prepared": state.latticeImage = payload.latticeImage; state.guidanceImage = payload.guidanceImage; + state.phaseFieldPrep = { + targetWidth: Number(payload.targetWidth ?? 0), + targetHeight: Number(payload.targetHeight ?? 0), + cellX: Number(payload.cellX ?? 0), + cellY: Number(payload.cellY ?? 0), + }; + setStage("solver", "Phase-field solve", `Pinned ${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight} lattice. Solver is walking the field now.`); addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`); break; case "phase_field_initial": @@ -737,6 +815,9 @@ async function handleEvent(eventName, payload) { if (state.autoFollow) { state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step)); } + setStage("solver", "Phase-field solve", frame.totalSteps <= 0 + ? "Initial placement committed. No iterative solver steps were requested." + : `Solver step ${frame.step} / ${frame.totalSteps}. Loss and drift terms stay live in this panel while it walks.`); if (eventName === "phase_field_final") { addLog("Solver", "Final nearest-input sample committed."); } @@ -745,16 +826,19 @@ async function handleEvent(eventName, payload) { case "cleanup_completed": state.cleanupImage = payload.cleanedImage; state.heatmapImage = payload.heatmapImage; + setStage("cleanup", "Cleanup", "Sweeping isolated artifacts and smoothing the last stubborn junk."); addLog("Cleanup", "Ran the local cleanup sweep."); break; case "palette_completed": state.finalOutputImage = payload.outputImage; + setStage("output", "Final output", "Writing the finished output and parking it in the editor."); addLog("Output", "Final output image is ready."); break; case "pipeline_completed": state.finalOutputImage = payload.outputImage; state.runSummary = payload.runSummary ?? null; - setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel."); + setJobState("completed"); + setStage("completed", "Run complete", "The output is ready. If the machine still did something stupid, fix it pixel by pixel."); addLog("Done", "Full pipeline completed."); runButton.disabled = false; currentEventSource?.close(); @@ -778,19 +862,22 @@ function connectEventStream(jobId) { } stream.onerror = () => { if (state.status === "running") { - setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it."); + setJobState("waiting"); + setStage(state.stageKey, state.stageLabel, "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."); + setJobState("idle"); + setStage("idle", "Waiting for input", "Pick a file first. The machine is not clairvoyant."); return; } resetRunArtifacts(); renderEventLog(); runButton.disabled = true; - setStatus("queued", "Queued the run. Small miracle pending."); + setJobState("queued"); + setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately."); addLog("Queued", `Starting ${state.file.name}.`); await renderEverything(); try { @@ -807,14 +894,16 @@ async function startRun() { } catch (error) { runButton.disabled = false; - setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job."); + setJobState("failed"); + setStage("failed", "Run failed", error instanceof Error ? error.message : "Failed to start GUI job."); addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job."); } } async 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.`); + setJobState("idle"); + setStage("ready", "Ready to run", `${file.name} is loaded. Hit run when you want the machine to start sweating.`); try { const previewImage = await fileToImageAsset(file); if (state.file !== file) { @@ -827,7 +916,8 @@ async function acceptFile(file) { return; } state.inputPreviewImage = null; - setStatus("failed", error instanceof Error ? error.message : "Failed to load image preview."); + setJobState("failed"); + setStage("failed", "Preview failed", error instanceof Error ? error.message : "Failed to load image preview."); } await renderInputPreview(); } diff --git a/src/repixelizer/gui_static/index.html b/src/repixelizer/gui_static/index.html index 77feefd..db5a6cb 100644 --- a/src/repixelizer/gui_static/index.html +++ b/src/repixelizer/gui_static/index.html @@ -23,6 +23,12 @@ +
+

How To Use It

+

1. Drop an input on the right or choose a file.

+

2. Leave size and phase blank to let the machine search, or pin them if you already know the lattice.

+

3. Run it, watch the status panel for the active stage, then patch the output by hand if the machine gets cute.

+
@@ -32,9 +38,16 @@
-
- Idle -

Waiting for input.

+
+
+ Idle +

Waiting for input

+
+

Choose a file, then run the machine.

+ +
diff --git a/src/repixelizer/gui_static/styles.css b/src/repixelizer/gui_static/styles.css index 255888e..b3a0361 100644 --- a/src/repixelizer/gui_static/styles.css +++ b/src/repixelizer/gui_static/styles.css @@ -262,7 +262,8 @@ p { .brand, .brand-lockup, .copy-stack, -.viewer-heading { +.viewer-heading, +.header-instructions { display: grid; gap: 10px; } @@ -328,6 +329,31 @@ p { max-width: 40ch; } +.app-shell-header { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.9fr); + gap: 16px; + align-items: start; +} + +.header-instructions { + align-content: start; + padding: 12px 14px; + border: 3px solid rgba(8, 36, 62, 0.88); + background: + radial-gradient(circle at top left, rgba(255, 216, 74, 0.12), transparent 32%), + linear-gradient(180deg, rgba(18, 58, 94, 0.95), rgba(9, 33, 62, 0.92)); + box-shadow: + inset 0 0 0 1px rgba(255, 216, 74, 0.22), + 0 0 14px rgba(255, 216, 74, 0.08); +} + +.instruction-line { + font-size: 1.04rem; + line-height: 1.18; + color: var(--ui-text); +} + .candidate-card, .event-item, .metric-chip, @@ -573,7 +599,19 @@ p { } .metrics-status-text { - max-width: 26ch; + max-width: 32ch; +} + +.status-heading { + display: grid; + gap: 8px; +} + +.status-stage { + margin: 0; + font-size: 1.22rem; + line-height: 1.1; + color: var(--ui-text-bright); } .scrubber { @@ -651,8 +689,8 @@ p { align-self: stretch; justify-self: start; padding: 14px 16px; - width: fit-content; - max-width: min(100%, 26ch); + width: 100%; + max-width: min(100%, 34ch); } .metric-scrubber { @@ -661,7 +699,85 @@ p { } .stats-grid { - grid-template-columns: fit-content(26ch) minmax(0, 1fr); + grid-template-columns: fit-content(34ch) minmax(0, 1fr); +} + +.status-activity { + position: relative; + height: 10px; + overflow: hidden; + border: 2px solid rgba(255, 216, 74, 0.18); + background: + linear-gradient(180deg, rgba(7, 20, 36, 0.85), rgba(8, 28, 48, 0.92)); +} + +.status-activity-bar { + display: block; + width: 36%; + height: 100%; + background: + linear-gradient(90deg, transparent, rgba(255, 216, 74, 0.92), rgba(143, 226, 200, 0.9), transparent); + transform: translateX(-120%); + animation: status-sweep 1.35s linear infinite; + animation-play-state: paused; +} + +#statusPanel[data-job-state="queued"] .status-activity-bar, +#statusPanel[data-job-state="running"] .status-activity-bar, +#statusPanel[data-job-state="waiting"] .status-activity-bar { + animation-play-state: running; +} + +#statusPanel[data-job-state="failed"] .status-activity-bar { + animation-play-state: paused; + transform: translateX(0); + width: 100%; + background: linear-gradient(90deg, rgba(210, 78, 62, 0.75), rgba(255, 137, 95, 0.88)); +} + +#statusPanel[data-job-state="completed"] .status-activity-bar { + animation-play-state: paused; + transform: translateX(0); + width: 100%; + background: linear-gradient(90deg, rgba(143, 226, 200, 0.82), rgba(255, 216, 74, 0.72)); +} + +.status-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; +} + +.status-metric { + min-width: 0; + padding: 8px 10px; + border: 2px solid rgba(8, 36, 62, 0.88); + background: linear-gradient(180deg, rgba(10, 35, 58, 0.96), rgba(7, 23, 42, 0.94)); +} + +.status-metric strong { + display: block; + margin-bottom: 4px; + font-size: 0.58rem; + color: var(--ui-accent); + text-transform: uppercase; +} + +.status-metric span { + display: block; + font-size: 1rem; + line-height: 1.12; + color: var(--ui-text); +} + +@keyframes status-sweep { + from { + transform: translateX(-120%); + } + + to { + transform: translateX(320%); + } } .candidate-card, @@ -808,6 +924,7 @@ select:focus-visible { } @media (max-width: 940px) { + .app-shell-header, .viewer-grid, .controls-grid { grid-template-columns: 1fr; diff --git a/src/repixelizer/pipeline.py b/src/repixelizer/pipeline.py index d5117a1..c6621db 100644 --- a/src/repixelizer/pipeline.py +++ b/src/repixelizer/pipeline.py @@ -99,6 +99,13 @@ def run_pipeline_rgba( solver_params = solver_params or SolverHyperParams() emit_observer(observer, "source_loaded", source_rgba=source.copy()) if strip_background: + emit_observer( + observer, + "stage_started", + stage="preprocess", + label="Background cleanup", + detail="Stripping edge-connected neutral junk before lattice search.", + ) source = strip_edge_background(source) emit_observer(observer, "preprocess_completed", source_rgba=source.copy(), operation="strip_background") fixed_dims = _resolve_requested_target_dims( @@ -111,9 +118,23 @@ def run_pipeline_rgba( phase_y=phase_y, ) if fixed_dims is None: + emit_observer( + observer, + "stage_started", + stage="inference", + label="Lattice search", + detail="Searching for the output size and phase that best fit the input.", + ) inference = infer_lattice(source, target_size=target_size, device=device) inference_mode = "searched" else: + emit_observer( + observer, + "stage_started", + stage="inference", + label="Pinned lattice", + detail="Locking in the requested size and phase instead of searching.", + ) inference = infer_fixed_lattice( source, target_width=fixed_dims[0], @@ -124,12 +145,26 @@ def run_pipeline_rgba( ) inference_mode = "fixed" emit_observer(observer, "inference_candidates_ready", inference=inference, inference_mode=inference_mode) + emit_observer( + observer, + "stage_started", + stage="analysis", + label="Input analysis", + detail="Mapping sharp cells, edges, and guidance before the solver starts.", + ) analysis = _prepare_analysis( source, seed=seed, device=device, ) emit_observer(observer, "analysis_completed", edge_map=analysis.edge_map.copy()) + emit_observer( + observer, + "stage_started", + stage="selection", + label="Phase selection", + detail="Choosing the lattice candidate that deserves the full solve.", + ) inference = _select_phase_candidate( source, inference, @@ -142,6 +177,17 @@ def run_pipeline_rgba( observer=observer, ) emit_observer(observer, "phase_selection_completed", inference=inference, inference_mode=inference_mode) + emit_observer( + observer, + "stage_started", + stage="solver", + label="Phase-field solve", + detail=( + "Running the solver over the pinned lattice." + if steps > 0 + else "Skipping solver drift and keeping the initial placement." + ), + ) solver, reconstruction_diagnostics = _run_reconstruction( source, inference=inference, @@ -152,6 +198,13 @@ def run_pipeline_rgba( solver_params=solver_params, observer=observer, ) + emit_observer( + observer, + "stage_started", + stage="cleanup", + label="Cleanup", + detail="Sweeping isolated noise and stubborn single-pixel junk.", + ) cleanup = cleanup_pixels(solver.target_rgba, source_guidance=solver.guidance_strength) emit_observer( observer, @@ -160,6 +213,13 @@ def run_pipeline_rgba( isolated_heatmap=cleanup.isolated_heatmap.copy(), ) palette = load_palette(palette_path) if palette_path else None + emit_observer( + observer, + "stage_started", + stage="output", + label="Final output", + detail="Writing the finished output and packaging the summary.", + ) palette_result = quantize_rgba(cleanup.cleaned_rgba, mode=palette_mode, palette=palette) output_rgba = palette_result.rgba if palette_result else cleanup.cleaned_rgba if output_path is not None: diff --git a/tests/test_gui.py b/tests/test_gui.py index 77de7ac..ce5005d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -32,16 +32,15 @@ def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None: observer=observer, ) - assert events[:4] == [ - "source_loaded", - "inference_candidates_ready", - "analysis_completed", - "phase_selection_completed", - ] + assert events[0] == "source_loaded" + assert events.count("stage_started") == 6 + assert events.index("inference_candidates_ready") < events.index("analysis_completed") + assert events.index("analysis_completed") < events.index("phase_selection_completed") + assert events.index("phase_selection_completed") < events.index("phase_field_prepared") assert "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"] + assert events[-4:] == ["cleanup_completed", "stage_started", "palette_completed", "pipeline_completed"] def test_repo_gui_runner_dispatches_to_gui_main(monkeypatch) -> None: