Improve live GUI pipeline feedback

This commit is contained in:
Meta Vens 2026-04-24 18:25:02 +01:00
commit 74da1dfa6a
7 changed files with 514 additions and 98 deletions

View file

@ -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<HTMLSpanElement>("dropzoneLabel");
const inputPreviewCanvas = byId<HTMLCanvasElement>("inputPreviewCanvas");
const pickFileButton = byId<HTMLButtonElement>("pickFileButton");
const runButton = byId<HTMLButtonElement>("runButton");
const statusPanel = byId<HTMLDivElement>("statusPanel");
const statusBadge = byId<HTMLSpanElement>("statusBadge");
const statusStage = byId<HTMLParagraphElement>("statusStage");
const statusText = byId<HTMLParagraphElement>("statusText");
const statusMetrics = byId<HTMLDivElement>("statusMetrics");
const inferenceSummary = byId<HTMLDivElement>("inferenceSummary");
const candidateList = byId<HTMLDivElement>("candidateList");
const eventLog = byId<HTMLDivElement>("eventLog");
@ -99,7 +120,6 @@ const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
const rightVizLabel = byId<HTMLDivElement>("rightVizLabel");
const metricsPanel = byId<HTMLDivElement>("metricsPanel");
const paintSwatch = byId<HTMLDivElement>("paintSwatch");
const zoomInput = byId<HTMLInputElement>("zoomInput");
const zoomValue = byId<HTMLSpanElement>("zoomValue");
@ -134,6 +154,7 @@ const imageCache = new Map<string, Promise<HTMLImageElement>>();
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<HTMLElement>("[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 = `<strong>${label}</strong><span>${value}</span>`;
metricsPanel.appendChild(node);
statusMetrics.appendChild(node);
}
}
@ -500,7 +549,6 @@ async function renderViewer(): Promise<void> {
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<void> {
renderInference();
renderSlider();
renderStatusMetrics();
renderLossChart();
renderSummary();
await renderViewer();
@ -806,17 +890,25 @@ async function renderEverything(): Promise<void> {
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
switch (eventName) {
case "job_state":
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
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<void
break;
case "inference_candidates_ready":
state.inference = payload.inference as InferencePayload;
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
addLog("Inference", "Scored candidate grids and phase offsets.");
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.`,
@ -835,6 +938,7 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
break;
case "phase_selection_completed":
state.inference = payload.inference as InferencePayload;
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
addLog("Selection", "Committed to a ruler and phase.");
break;
case "analysis_completed":
@ -843,6 +947,17 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
case "phase_field_prepared":
state.latticeImage = payload.latticeImage as ImageAsset;
state.guidanceImage = payload.guidanceImage as ImageAsset;
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":
@ -856,27 +971,37 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
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));
}
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<void> {
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<void> {
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<void> {
async function acceptFile(file: File): Promise<void> {
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<void> {
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();
}

View file

@ -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 {

View file

@ -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 = `<strong>${label}</strong><span>${value}</span>`;
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();
}

View file

@ -23,6 +23,12 @@
</div>
</div>
</div>
<div class="header-instructions">
<p class="eyebrow">How To Use It</p>
<p class="instruction-line">1. Drop an input on the right or choose a file.</p>
<p class="instruction-line">2. Leave size and phase blank to let the machine search, or pin them if you already know the lattice.</p>
<p class="instruction-line">3. Run it, watch the status panel for the active stage, then patch the output by hand if the machine gets cute.</p>
</div>
</header>
<section class="hero viewer-panel">
@ -32,9 +38,16 @@
</div>
</div>
<div id="metricsPanel" class="metrics-panel stats-grid">
<div class="metric-chip metric-status-block">
<span id="statusBadge" class="status-badge">Idle</span>
<p id="statusText" class="status-text metrics-status-text">Waiting for input.</p>
<div id="statusPanel" class="metric-chip metric-status-block" data-job-state="idle">
<div class="status-heading">
<span id="statusBadge" class="status-badge">Idle</span>
<p id="statusStage" class="status-stage">Waiting for input</p>
</div>
<p id="statusText" class="status-text metrics-status-text">Choose a file, then run the machine.</p>
<div class="status-activity" aria-hidden="true">
<span class="status-activity-bar"></span>
</div>
<div id="statusMetrics" class="status-metrics"></div>
</div>
<div class="metric-chip scrubber metric-scrubber">
<label for="stepSlider">Solver Step</label>

View file

@ -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;

View file

@ -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:

View file

@ -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: