Add streaming TypeScript GUI
This commit is contained in:
parent
d4b5ab9ed7
commit
66595cafe5
16 changed files with 3260 additions and 36 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ htmlcov/
|
|||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
frontend/node_modules/
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
29
frontend/package-lock.json
generated
Normal file
29
frontend/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "repixelizer-gui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "repixelizer-gui",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
frontend/package.json
Normal file
13
frontend/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "repixelizer-gui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p ./tsconfig.json",
|
||||
"watch": "tsc -w -p ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
965
frontend/src/app.ts
Normal file
965
frontend/src/app.ts
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type ImageAsset = {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type InferenceCandidate = {
|
||||
target_width: number;
|
||||
target_height: number;
|
||||
phase_x: number;
|
||||
phase_y: number;
|
||||
score: number;
|
||||
breakdown: Record<string, number | null | undefined>;
|
||||
};
|
||||
|
||||
type InferencePayload = {
|
||||
target_width: number;
|
||||
target_height: number;
|
||||
phase_x: number;
|
||||
phase_y: number;
|
||||
confidence: number;
|
||||
top_candidates: InferenceCandidate[];
|
||||
};
|
||||
|
||||
type FramePayload = {
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
loss: number | null;
|
||||
terms: Record<string, number>;
|
||||
phaseMetrics: Record<string, number>;
|
||||
outputImage: ImageAsset;
|
||||
samplingOverlayImage: ImageAsset;
|
||||
displacementImage: ImageAsset;
|
||||
lossHistory: number[];
|
||||
};
|
||||
|
||||
type LogItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type AppState = {
|
||||
file: File | null;
|
||||
jobId: string | null;
|
||||
status: string;
|
||||
statusText: string;
|
||||
sourceImage: ImageAsset | null;
|
||||
preprocessedImage: ImageAsset | null;
|
||||
edgeMapImage: ImageAsset | null;
|
||||
latticeImage: ImageAsset | null;
|
||||
guidanceImage: ImageAsset | null;
|
||||
inference: InferencePayload | null;
|
||||
frames: FramePayload[];
|
||||
cleanupImage: ImageAsset | null;
|
||||
heatmapImage: ImageAsset | null;
|
||||
finalOutputImage: ImageAsset | null;
|
||||
runSummary: JsonRecord | null;
|
||||
selectedFrameIndex: number;
|
||||
autoFollow: boolean;
|
||||
eventLog: LogItem[];
|
||||
paintColor: [number, number, number, number];
|
||||
editorBaseAsset: ImageAsset | null;
|
||||
editorDirty: boolean;
|
||||
altHeld: boolean;
|
||||
zoom: number;
|
||||
showGrid: boolean;
|
||||
currentStage: string;
|
||||
};
|
||||
|
||||
function byId<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: ${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const fileInput = byId<HTMLInputElement>("fileInput");
|
||||
const dropzone = byId<HTMLButtonElement>("dropzone");
|
||||
const dropzoneLabel = byId<HTMLSpanElement>("dropzoneLabel");
|
||||
const pickFileButton = byId<HTMLButtonElement>("pickFileButton");
|
||||
const runButton = byId<HTMLButtonElement>("runButton");
|
||||
const statusBadge = byId<HTMLSpanElement>("statusBadge");
|
||||
const statusText = byId<HTMLParagraphElement>("statusText");
|
||||
const inferenceSummary = byId<HTMLDivElement>("inferenceSummary");
|
||||
const candidateList = byId<HTMLDivElement>("candidateList");
|
||||
const eventLog = byId<HTMLDivElement>("eventLog");
|
||||
const viewerTitle = byId<HTMLHeadingElement>("viewerTitle");
|
||||
const stepSlider = byId<HTMLInputElement>("stepSlider");
|
||||
const stepValue = byId<HTMLSpanElement>("stepValue");
|
||||
const lossCanvas = byId<HTMLCanvasElement>("lossCanvas");
|
||||
const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
|
||||
const middleCanvas = byId<HTMLCanvasElement>("middleCanvas");
|
||||
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
|
||||
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
|
||||
const middleVizLabel = byId<HTMLDivElement>("middleVizLabel");
|
||||
const rightVizLabel = byId<HTMLDivElement>("rightVizLabel");
|
||||
const metricsPanel = byId<HTMLDivElement>("metricsPanel");
|
||||
const paintSwatch = byId<HTMLDivElement>("paintSwatch");
|
||||
const toolMode = byId<HTMLSpanElement>("toolMode");
|
||||
const zoomInput = byId<HTMLInputElement>("zoomInput");
|
||||
const zoomValue = byId<HTMLSpanElement>("zoomValue");
|
||||
const gridToggle = byId<HTMLInputElement>("gridToggle");
|
||||
const editorCanvas = byId<HTMLCanvasElement>("editorCanvas");
|
||||
const editorGridCanvas = byId<HTMLCanvasElement>("editorGridCanvas");
|
||||
const editorSurface = byId<HTMLDivElement>("editorSurface");
|
||||
const editorMeta = byId<HTMLDivElement>("editorMeta");
|
||||
const summaryPanel = byId<HTMLDivElement>("summaryPanel");
|
||||
const resetEditorButton = byId<HTMLButtonElement>("resetEditorButton");
|
||||
const downloadButton = byId<HTMLButtonElement>("downloadButton");
|
||||
|
||||
const targetSizeInput = byId<HTMLInputElement>("targetSizeInput");
|
||||
const targetWidthInput = byId<HTMLInputElement>("targetWidthInput");
|
||||
const targetHeightInput = byId<HTMLInputElement>("targetHeightInput");
|
||||
const phaseXInput = byId<HTMLInputElement>("phaseXInput");
|
||||
const phaseYInput = byId<HTMLInputElement>("phaseYInput");
|
||||
const stepsInput = byId<HTMLInputElement>("stepsInput");
|
||||
const seedInput = byId<HTMLInputElement>("seedInput");
|
||||
const deviceInput = byId<HTMLSelectElement>("deviceInput");
|
||||
const stripBackgroundInput = byId<HTMLInputElement>("stripBackgroundInput");
|
||||
const skipRerankInput = byId<HTMLInputElement>("skipRerankInput");
|
||||
|
||||
const paintInputs = {
|
||||
r: byId<HTMLInputElement>("paintR"),
|
||||
g: byId<HTMLInputElement>("paintG"),
|
||||
b: byId<HTMLInputElement>("paintB"),
|
||||
a: byId<HTMLInputElement>("paintA"),
|
||||
};
|
||||
|
||||
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
const eventTypes = [
|
||||
"job_state",
|
||||
"job_failed",
|
||||
"source_loaded",
|
||||
"preprocess_completed",
|
||||
"inference_candidates_ready",
|
||||
"phase_rerank_started",
|
||||
"phase_selection_completed",
|
||||
"analysis_completed",
|
||||
"phase_field_prepared",
|
||||
"phase_field_initial",
|
||||
"phase_field_step",
|
||||
"phase_field_final",
|
||||
"cleanup_completed",
|
||||
"palette_completed",
|
||||
"pipeline_completed",
|
||||
] as const;
|
||||
|
||||
const state: AppState = {
|
||||
file: null,
|
||||
jobId: null,
|
||||
status: "idle",
|
||||
statusText: "Drop a fake sprite in here and let the machine explain itself for once.",
|
||||
sourceImage: null,
|
||||
preprocessedImage: null,
|
||||
edgeMapImage: null,
|
||||
latticeImage: null,
|
||||
guidanceImage: null,
|
||||
inference: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
heatmapImage: null,
|
||||
finalOutputImage: null,
|
||||
runSummary: null,
|
||||
selectedFrameIndex: 0,
|
||||
autoFollow: true,
|
||||
eventLog: [],
|
||||
paintColor: [255, 255, 255, 255],
|
||||
editorBaseAsset: null,
|
||||
editorDirty: false,
|
||||
altHeld: false,
|
||||
zoom: Number(zoomInput.value),
|
||||
showGrid: gridToggle.checked,
|
||||
currentStage: "Idle",
|
||||
};
|
||||
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let painting = false;
|
||||
let offscreenCanvas: HTMLCanvasElement | null = null;
|
||||
let offscreenContext: CanvasRenderingContext2D | null = null;
|
||||
let singlePixel = new ImageData(1, 1);
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function setPaintColor(color: [number, number, number, number]): void {
|
||||
state.paintColor = color;
|
||||
paintInputs.r.value = String(color[0]);
|
||||
paintInputs.g.value = String(color[1]);
|
||||
paintInputs.b.value = String(color[2]);
|
||||
paintInputs.a.value = String(color[3]);
|
||||
paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
|
||||
}
|
||||
|
||||
function updateToolMode(): void {
|
||||
toolMode.textContent = state.altHeld ? "Eyedropper armed. Sample a pixel." : "Pencil. Hold Alt for eyedropper.";
|
||||
}
|
||||
|
||||
function addLog(label: string, detail: string): void {
|
||||
state.eventLog.unshift({ label, detail });
|
||||
state.eventLog = state.eventLog.slice(0, 8);
|
||||
renderEventLog();
|
||||
}
|
||||
|
||||
function renderEventLog(): void {
|
||||
eventLog.innerHTML = "";
|
||||
if (state.eventLog.length === 0) {
|
||||
eventLog.innerHTML = `<p class="muted">Nothing to report yet.</p>`;
|
||||
return;
|
||||
}
|
||||
for (const entry of state.eventLog) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "event-item";
|
||||
node.innerHTML = `<strong>${entry.label}</strong><span>${entry.detail}</span>`;
|
||||
eventLog.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(status: string, detail: string): void {
|
||||
state.status = status;
|
||||
state.statusText = detail;
|
||||
statusBadge.textContent = status;
|
||||
statusText.textContent = detail;
|
||||
}
|
||||
|
||||
function resetRunArtifacts(): void {
|
||||
state.jobId = null;
|
||||
state.sourceImage = null;
|
||||
state.preprocessedImage = null;
|
||||
state.edgeMapImage = null;
|
||||
state.latticeImage = null;
|
||||
state.guidanceImage = null;
|
||||
state.inference = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
state.heatmapImage = null;
|
||||
state.finalOutputImage = null;
|
||||
state.runSummary = null;
|
||||
state.selectedFrameIndex = 0;
|
||||
state.autoFollow = true;
|
||||
state.eventLog = [];
|
||||
state.editorBaseAsset = null;
|
||||
state.editorDirty = false;
|
||||
state.currentStage = "Waiting";
|
||||
}
|
||||
|
||||
function parseOptionalInteger(input: HTMLInputElement): number | null {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
|
||||
function parseOptionalFloat(input: HTMLInputElement): number | null {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function loadImage(asset: ImageAsset): Promise<HTMLImageElement> {
|
||||
const cached = imageCache.get(asset.dataUrl);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to load image asset"));
|
||||
image.src = asset.dataUrl;
|
||||
});
|
||||
imageCache.set(asset.dataUrl, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function drawAsset(canvas: HTMLCanvasElement, asset: ImageAsset | null): Promise<void> {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!asset) {
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#081012";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "rgba(255,255,255,0.2)";
|
||||
context.font = "16px sans-serif";
|
||||
context.fillText("Waiting for image data", 24, 32);
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
canvas.width = image.naturalWidth || asset.width;
|
||||
canvas.height = image.naturalHeight || asset.height;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function readNestedNumber(root: JsonRecord | null, path: string[]): number | null {
|
||||
let current: unknown = root;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
return null;
|
||||
}
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
return typeof current === "number" ? current : null;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null, digits = 3): string {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "n/a";
|
||||
}
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
function getSelectedFrame(): FramePayload | null {
|
||||
if (state.frames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return state.frames[Math.max(0, Math.min(state.selectedFrameIndex, state.frames.length - 1))];
|
||||
}
|
||||
|
||||
function renderInference(): void {
|
||||
if (!state.inference) {
|
||||
inferenceSummary.innerHTML = `<p class="muted">No lattice picked yet.</p>`;
|
||||
candidateList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
inferenceSummary.innerHTML = `
|
||||
<div class="summary-card">
|
||||
<strong>Chosen Grid</strong>
|
||||
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>Phase</strong>
|
||||
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>Confidence</strong>
|
||||
<span>${formatNumber(state.inference.confidence, 3)}</span>
|
||||
</div>
|
||||
`;
|
||||
candidateList.innerHTML = "";
|
||||
const topCandidates = state.inference.top_candidates.slice(0, 6);
|
||||
for (const candidate of topCandidates) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "candidate-card";
|
||||
const rerank = candidate.breakdown["phase_rerank_rank"];
|
||||
node.innerHTML = `
|
||||
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
|
||||
<span>phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}</span><br />
|
||||
<span>score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""}</span>
|
||||
`;
|
||||
candidateList.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMetrics(): void {
|
||||
metricsPanel.innerHTML = "";
|
||||
const frame = getSelectedFrame();
|
||||
const items: Array<[string, string]> = [];
|
||||
if (frame) {
|
||||
items.push(["Frame", `${frame.step}/${frame.totalSteps}`]);
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
}
|
||||
} else if (state.runSummary) {
|
||||
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
|
||||
items.push([
|
||||
"Final fidelity",
|
||||
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
|
||||
]);
|
||||
items.push(["Source color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
|
||||
} else {
|
||||
items.push(["Status", state.currentStage]);
|
||||
}
|
||||
for (const [label, value] of items) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "metric-chip";
|
||||
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
|
||||
metricsPanel.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(): void {
|
||||
summaryPanel.innerHTML = "";
|
||||
if (!state.runSummary) {
|
||||
summaryPanel.innerHTML = `<p class="muted">Final metrics show up here after the run finishes.</p>`;
|
||||
return;
|
||||
}
|
||||
const entries: Array<[string, number | null]> = [
|
||||
["Structure score", readNestedNumber(state.runSummary, ["source_structure", "score"])],
|
||||
["Edge F1", readNestedNumber(state.runSummary, ["source_structure", "edge_f1"])],
|
||||
["Exact match", readNestedNumber(state.runSummary, ["source_structure", "exact_match_ratio"])],
|
||||
["Final fidelity", readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])],
|
||||
["Mean displacement", readNestedNumber(state.runSummary, ["optimizer_displacement", "final_output", "mean_magnitude_px"])],
|
||||
];
|
||||
for (const [label, value] of entries) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "summary-card";
|
||||
card.innerHTML = `<strong>${label}</strong><span>${formatNumber(value, 3)}</span>`;
|
||||
summaryPanel.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderViewer(): Promise<void> {
|
||||
const frame = getSelectedFrame();
|
||||
let leftAsset = state.preprocessedImage ?? state.sourceImage;
|
||||
let middleAsset = state.edgeMapImage;
|
||||
let rightAsset = state.finalOutputImage ?? state.cleanupImage ?? state.sourceImage;
|
||||
let leftLabel = "Source";
|
||||
let middleLabel = "Edge Scout";
|
||||
let rightLabel = "Output";
|
||||
let title = state.currentStage;
|
||||
|
||||
if (state.latticeImage) {
|
||||
leftAsset = state.latticeImage;
|
||||
leftLabel = "Lattice Prep";
|
||||
}
|
||||
if (state.guidanceImage) {
|
||||
middleAsset = state.guidanceImage;
|
||||
middleLabel = "Guidance";
|
||||
}
|
||||
if (frame) {
|
||||
leftAsset = frame.samplingOverlayImage;
|
||||
middleAsset = frame.displacementImage;
|
||||
rightAsset = frame.outputImage;
|
||||
leftLabel = "Sampling Overlay";
|
||||
middleLabel = "Displacement";
|
||||
rightLabel = "Current Output";
|
||||
title = frame.step === 0 ? "Initial nearest-sample placement" : `Solver step ${frame.step} / ${frame.totalSteps}`;
|
||||
} else if (state.cleanupImage) {
|
||||
middleAsset = state.heatmapImage ?? middleAsset;
|
||||
middleLabel = "Cleanup Heatmap";
|
||||
rightAsset = state.cleanupImage;
|
||||
rightLabel = "Cleaned Output";
|
||||
title = state.finalOutputImage ? "Finalized output" : "Cleanup pass";
|
||||
}
|
||||
|
||||
viewerTitle.textContent = title;
|
||||
leftVizLabel.textContent = leftLabel;
|
||||
middleVizLabel.textContent = middleLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(middleCanvas, middleAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
renderMetrics();
|
||||
}
|
||||
|
||||
function renderSlider(): void {
|
||||
if (state.frames.length === 0) {
|
||||
stepSlider.disabled = true;
|
||||
stepSlider.max = "0";
|
||||
stepSlider.value = "0";
|
||||
stepValue.textContent = "No frames yet";
|
||||
return;
|
||||
}
|
||||
stepSlider.disabled = false;
|
||||
stepSlider.max = String(state.frames.length - 1);
|
||||
stepSlider.value = String(state.selectedFrameIndex);
|
||||
const frame = getSelectedFrame();
|
||||
if (!frame) {
|
||||
stepValue.textContent = "No frames yet";
|
||||
return;
|
||||
}
|
||||
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
|
||||
}
|
||||
|
||||
function renderLossChart(): void {
|
||||
const context = lossCanvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const width = lossCanvas.width;
|
||||
const height = lossCanvas.height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = "rgba(7, 15, 17, 0.96)";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.strokeStyle = "rgba(255,255,255,0.08)";
|
||||
context.lineWidth = 1;
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
const y = (height / 5) * index;
|
||||
context.beginPath();
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
const losses = state.frames
|
||||
.filter((frame) => typeof frame.loss === "number")
|
||||
.map((frame) => frame.loss as number);
|
||||
if (losses.length === 0) {
|
||||
context.fillStyle = "rgba(255,255,255,0.28)";
|
||||
context.font = "14px sans-serif";
|
||||
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 28);
|
||||
return;
|
||||
}
|
||||
const minLoss = Math.min(...losses);
|
||||
const maxLoss = Math.max(...losses);
|
||||
const span = Math.max(1e-6, maxLoss - minLoss);
|
||||
context.strokeStyle = "#8fe2c8";
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
losses.forEach((loss, index) => {
|
||||
const x = losses.length === 1 ? width / 2 : (index / (losses.length - 1)) * (width - 24) + 12;
|
||||
const y = height - 18 - ((loss - minLoss) / span) * (height - 36);
|
||||
if (index === 0) {
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
context.stroke();
|
||||
|
||||
const selected = getSelectedFrame();
|
||||
if (selected && selected.loss !== null) {
|
||||
const matchingIndex = losses.findIndex((value) => Math.abs(value - selected.loss!) < 1e-6);
|
||||
if (matchingIndex >= 0) {
|
||||
const x = losses.length === 1 ? width / 2 : (matchingIndex / (losses.length - 1)) * (width - 24) + 12;
|
||||
const y = height - 18 - (((selected.loss as number) - minLoss) / span) * (height - 36);
|
||||
context.fillStyle = "#f5b76b";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEditorAsset(asset: ImageAsset | null): Promise<void> {
|
||||
state.editorBaseAsset = asset;
|
||||
state.editorDirty = false;
|
||||
if (!asset) {
|
||||
offscreenCanvas = null;
|
||||
offscreenContext = null;
|
||||
renderEditor();
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
offscreenCanvas = document.createElement("canvas");
|
||||
offscreenCanvas.width = image.naturalWidth || asset.width;
|
||||
offscreenCanvas.height = image.naturalHeight || asset.height;
|
||||
offscreenContext = offscreenCanvas.getContext("2d");
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
offscreenContext.imageSmoothingEnabled = false;
|
||||
offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
offscreenContext.drawImage(image, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
function renderEditor(): void {
|
||||
const displayContext = editorCanvas.getContext("2d");
|
||||
const gridContext = editorGridCanvas.getContext("2d");
|
||||
if (!displayContext || !gridContext) {
|
||||
return;
|
||||
}
|
||||
if (!offscreenCanvas) {
|
||||
editorCanvas.width = 1;
|
||||
editorCanvas.height = 1;
|
||||
editorGridCanvas.width = 1;
|
||||
editorGridCanvas.height = 1;
|
||||
editorMeta.innerHTML = `<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>`;
|
||||
return;
|
||||
}
|
||||
const width = offscreenCanvas.width * state.zoom;
|
||||
const height = offscreenCanvas.height * state.zoom;
|
||||
for (const canvas of [editorCanvas, editorGridCanvas]) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
displayContext.imageSmoothingEnabled = false;
|
||||
displayContext.clearRect(0, 0, width, height);
|
||||
displayContext.drawImage(offscreenCanvas, 0, 0, width, height);
|
||||
|
||||
gridContext.clearRect(0, 0, width, height);
|
||||
if (state.showGrid && state.zoom >= 8) {
|
||||
gridContext.strokeStyle = "rgba(255,255,255,0.12)";
|
||||
gridContext.lineWidth = 1;
|
||||
for (let x = 0; x <= offscreenCanvas.width; x += 1) {
|
||||
const xpos = x * state.zoom + 0.5;
|
||||
gridContext.beginPath();
|
||||
gridContext.moveTo(xpos, 0);
|
||||
gridContext.lineTo(xpos, height);
|
||||
gridContext.stroke();
|
||||
}
|
||||
for (let y = 0; y <= offscreenCanvas.height; y += 1) {
|
||||
const ypos = y * state.zoom + 0.5;
|
||||
gridContext.beginPath();
|
||||
gridContext.moveTo(0, ypos);
|
||||
gridContext.lineTo(width, ypos);
|
||||
gridContext.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
editorMeta.innerHTML = `
|
||||
<div class="summary-card">
|
||||
<strong>Canvas</strong>
|
||||
<span>${offscreenCanvas.width} x ${offscreenCanvas.height}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>State</strong>
|
||||
<span>${state.editorDirty ? "Edited" : "Matches solver output"}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function editorPixelFromPointer(event: PointerEvent): { x: number; y: number } | null {
|
||||
if (!offscreenCanvas) {
|
||||
return null;
|
||||
}
|
||||
const rect = editorCanvas.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const x = Math.floor((event.clientX - rect.left) / state.zoom);
|
||||
const y = Math.floor((event.clientY - rect.top) / state.zoom);
|
||||
if (x < 0 || y < 0 || x >= offscreenCanvas.width || y >= offscreenCanvas.height) {
|
||||
return null;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function samplePaintColor(x: number, y: number): void {
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
const pixel = offscreenContext.getImageData(x, y, 1, 1).data;
|
||||
setPaintColor([pixel[0], pixel[1], pixel[2], pixel[3]]);
|
||||
}
|
||||
|
||||
function paintPixel(x: number, y: number): void {
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
singlePixel.data[0] = state.paintColor[0];
|
||||
singlePixel.data[1] = state.paintColor[1];
|
||||
singlePixel.data[2] = state.paintColor[2];
|
||||
singlePixel.data[3] = state.paintColor[3];
|
||||
offscreenContext.putImageData(singlePixel, x, y);
|
||||
state.editorDirty = true;
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
function exportEditorPng(): void {
|
||||
if (!offscreenCanvas) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = offscreenCanvas.toDataURL("image/png");
|
||||
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
|
||||
link.click();
|
||||
}
|
||||
|
||||
function buildFormData(): FormData {
|
||||
if (!state.file) {
|
||||
throw new Error("No file selected");
|
||||
}
|
||||
const data = new FormData();
|
||||
data.set("image", state.file);
|
||||
const values: Array<[string, string | null]> = [
|
||||
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
|
||||
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
|
||||
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
|
||||
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
|
||||
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
|
||||
["steps", String(parseOptionalInteger(stepsInput) ?? 48)],
|
||||
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
|
||||
["device", deviceInput.value],
|
||||
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
|
||||
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
];
|
||||
for (const [key, value] of values) {
|
||||
if (value !== null) {
|
||||
data.set(key, value);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function renderEverything(): Promise<void> {
|
||||
renderInference();
|
||||
renderSlider();
|
||||
renderLossChart();
|
||||
renderSummary();
|
||||
await renderViewer();
|
||||
}
|
||||
|
||||
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
|
||||
switch (eventName) {
|
||||
case "job_state":
|
||||
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
|
||||
break;
|
||||
case "job_failed":
|
||||
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
|
||||
addLog("Failure", String(payload.message ?? "The run failed."));
|
||||
runButton.disabled = false;
|
||||
currentEventSource?.close();
|
||||
break;
|
||||
case "source_loaded":
|
||||
state.sourceImage = payload.sourceImage as ImageAsset;
|
||||
state.currentStage = "Loaded source";
|
||||
setStatus("running", "Source loaded. Survey crew is measuring the mess.");
|
||||
addLog("Source", "Loaded the input image.");
|
||||
break;
|
||||
case "preprocess_completed":
|
||||
state.preprocessedImage = payload.sourceImage as ImageAsset;
|
||||
state.currentStage = "Preprocessed source";
|
||||
addLog("Preprocess", "Stripped edge-connected background noise.");
|
||||
break;
|
||||
case "inference_candidates_ready":
|
||||
state.inference = payload.inference as InferencePayload;
|
||||
state.currentStage = "Scoring lattice candidates";
|
||||
addLog("Inference", "Scored candidate grids and phase offsets.");
|
||||
break;
|
||||
case "phase_rerank_started":
|
||||
state.currentStage = "Phase rerank preview";
|
||||
addLog(
|
||||
"Rerank",
|
||||
`Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`,
|
||||
);
|
||||
break;
|
||||
case "phase_selection_completed":
|
||||
state.inference = payload.inference as InferencePayload;
|
||||
state.currentStage = "Selected lattice";
|
||||
addLog("Selection", "Committed to a ruler and phase.");
|
||||
break;
|
||||
case "analysis_completed":
|
||||
state.edgeMapImage = payload.edgeMapImage as ImageAsset;
|
||||
state.currentStage = "Edge scout";
|
||||
addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
|
||||
break;
|
||||
case "phase_field_prepared":
|
||||
state.latticeImage = payload.latticeImage as ImageAsset;
|
||||
state.guidanceImage = payload.guidanceImage as ImageAsset;
|
||||
state.currentStage = "Prepared phase field";
|
||||
addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`);
|
||||
break;
|
||||
case "phase_field_initial":
|
||||
case "phase_field_step":
|
||||
case "phase_field_final": {
|
||||
const frame = payload as unknown as FramePayload;
|
||||
const existingIndex = state.frames.findIndex((candidate) => candidate.step === frame.step);
|
||||
if (existingIndex >= 0) {
|
||||
state.frames[existingIndex] = frame;
|
||||
} else {
|
||||
state.frames.push(frame);
|
||||
state.frames.sort((a, b) => a.step - b.step);
|
||||
}
|
||||
if (state.autoFollow) {
|
||||
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step));
|
||||
}
|
||||
state.currentStage = frame.step === 0 ? "Initial placement" : `Solver step ${frame.step}/${frame.totalSteps}`;
|
||||
if (eventName === "phase_field_final") {
|
||||
addLog("Solver", "Final nearest-source sample committed.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "cleanup_completed":
|
||||
state.cleanupImage = payload.cleanedImage as ImageAsset;
|
||||
state.heatmapImage = payload.heatmapImage as ImageAsset;
|
||||
state.currentStage = "Cleanup";
|
||||
addLog("Cleanup", "Ran the local cleanup sweep.");
|
||||
break;
|
||||
case "palette_completed":
|
||||
state.finalOutputImage = payload.outputImage as ImageAsset;
|
||||
state.currentStage = "Final output";
|
||||
addLog("Output", "Final output image is ready.");
|
||||
break;
|
||||
case "pipeline_completed":
|
||||
state.finalOutputImage = payload.outputImage as ImageAsset;
|
||||
state.runSummary = (payload.runSummary as JsonRecord | null) ?? null;
|
||||
state.currentStage = "Completed";
|
||||
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
|
||||
addLog("Done", "Full pipeline completed.");
|
||||
runButton.disabled = false;
|
||||
currentEventSource?.close();
|
||||
await loadEditorAsset(state.finalOutputImage);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await renderEverything();
|
||||
}
|
||||
|
||||
function connectEventStream(jobId: string): void {
|
||||
currentEventSource?.close();
|
||||
const stream = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
currentEventSource = stream;
|
||||
for (const eventType of eventTypes) {
|
||||
stream.addEventListener(eventType, (raw) => {
|
||||
const message = raw as MessageEvent<string>;
|
||||
const payload = JSON.parse(message.data) as JsonRecord;
|
||||
void handleEvent(eventType, payload);
|
||||
});
|
||||
}
|
||||
stream.onerror = () => {
|
||||
if (state.status === "running") {
|
||||
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function startRun(): Promise<void> {
|
||||
if (!state.file) {
|
||||
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
|
||||
return;
|
||||
}
|
||||
resetRunArtifacts();
|
||||
renderEventLog();
|
||||
runButton.disabled = true;
|
||||
setStatus("queued", "Queued the run. Small miracle pending.");
|
||||
addLog("Queued", `Starting ${state.file.name}.`);
|
||||
await renderEverything();
|
||||
try {
|
||||
const response = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
body: buildFormData(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const payload = (await response.json()) as { jobId: string };
|
||||
state.jobId = payload.jobId;
|
||||
connectEventStream(payload.jobId);
|
||||
} catch (error) {
|
||||
runButton.disabled = false;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
}
|
||||
}
|
||||
|
||||
function acceptFile(file: File): void {
|
||||
state.file = file;
|
||||
dropzoneLabel.textContent = file.name;
|
||||
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
}
|
||||
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
pickFileButton.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) {
|
||||
acceptFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
acceptFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
runButton.addEventListener("click", () => {
|
||||
void startRun();
|
||||
});
|
||||
|
||||
stepSlider.addEventListener("input", () => {
|
||||
state.selectedFrameIndex = Number(stepSlider.value);
|
||||
state.autoFollow = state.selectedFrameIndex >= state.frames.length - 1;
|
||||
void renderEverything();
|
||||
});
|
||||
|
||||
zoomInput.addEventListener("input", () => {
|
||||
state.zoom = Number(zoomInput.value);
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEditor();
|
||||
});
|
||||
|
||||
gridToggle.addEventListener("change", () => {
|
||||
state.showGrid = gridToggle.checked;
|
||||
renderEditor();
|
||||
});
|
||||
|
||||
for (const input of Object.values(paintInputs)) {
|
||||
input.addEventListener("change", () => {
|
||||
setPaintColor([
|
||||
clampByte(Number(paintInputs.r.value)),
|
||||
clampByte(Number(paintInputs.g.value)),
|
||||
clampByte(Number(paintInputs.b.value)),
|
||||
clampByte(Number(paintInputs.a.value)),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Alt") {
|
||||
state.altHeld = true;
|
||||
updateToolMode();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Alt") {
|
||||
state.altHeld = false;
|
||||
updateToolMode();
|
||||
}
|
||||
});
|
||||
|
||||
editorCanvas.addEventListener("pointerdown", (event) => {
|
||||
const pixel = editorPixelFromPointer(event);
|
||||
if (!pixel) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
editorCanvas.setPointerCapture(event.pointerId);
|
||||
if (event.altKey || state.altHeld) {
|
||||
samplePaintColor(pixel.x, pixel.y);
|
||||
updateToolMode();
|
||||
return;
|
||||
}
|
||||
painting = true;
|
||||
paintPixel(pixel.x, pixel.y);
|
||||
});
|
||||
|
||||
editorCanvas.addEventListener("pointermove", (event) => {
|
||||
const pixel = editorPixelFromPointer(event);
|
||||
if (!pixel) {
|
||||
return;
|
||||
}
|
||||
if (event.altKey || state.altHeld) {
|
||||
updateToolMode();
|
||||
if (event.buttons === 1) {
|
||||
samplePaintColor(pixel.x, pixel.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (painting && event.buttons === 1) {
|
||||
paintPixel(pixel.x, pixel.y);
|
||||
}
|
||||
});
|
||||
|
||||
editorCanvas.addEventListener("pointerup", () => {
|
||||
painting = false;
|
||||
});
|
||||
|
||||
editorCanvas.addEventListener("pointerleave", () => {
|
||||
painting = false;
|
||||
});
|
||||
|
||||
resetEditorButton.addEventListener("click", () => {
|
||||
void loadEditorAsset(state.editorBaseAsset);
|
||||
});
|
||||
|
||||
downloadButton.addEventListener("click", () => {
|
||||
exportEditorPng();
|
||||
});
|
||||
|
||||
setPaintColor(state.paintColor);
|
||||
updateToolMode();
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEventLog();
|
||||
void renderEverything();
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "../src/repixelizer/gui_static",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import sys
|
|||
from .benchmark import run_roundtrip_benchmark
|
||||
from .compare import run_compare
|
||||
from .corpus import prepare_corpus
|
||||
from .gui import main as gui_main
|
||||
from .pipeline import run_pipeline
|
||||
from .tuning import tune_solver_hyperparams
|
||||
|
||||
|
|
@ -113,12 +114,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
prepare_parser = subparsers.add_parser("prepare-corpus", help="Normalize imported corpus sheets into single sprites.")
|
||||
prepare_parser.add_argument("--corpus-dir", default="examples/corpus", help="Corpus root containing originals/")
|
||||
gui_parser = subparsers.add_parser("gui", help="Launch the web GUI.")
|
||||
gui_parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
|
||||
gui_parser.add_argument("--port", type=int, default=8000, help="Port to bind")
|
||||
gui_parser.add_argument("--reload", action="store_true", help="Enable autoreload while developing the GUI server")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not raw_argv or raw_argv[0] not in {"run", "compare", "benchmark", "tune", "prepare-corpus", "-h", "--help"}:
|
||||
if not raw_argv or raw_argv[0] not in {"run", "compare", "benchmark", "tune", "prepare-corpus", "gui", "-h", "--help"}:
|
||||
raw_argv = ["run", *raw_argv]
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(raw_argv)
|
||||
|
|
@ -175,6 +180,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if command == "prepare-corpus":
|
||||
prepare_corpus(args.corpus_dir)
|
||||
return 0
|
||||
if command == "gui":
|
||||
return gui_main(host=args.host, port=args.port, reload=args.reload)
|
||||
|
||||
run_pipeline(
|
||||
args.input,
|
||||
|
|
|
|||
420
src/repixelizer/gui.py
Normal file
420
src/repixelizer/gui.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .diagnostics import _displacement_preview_rgba
|
||||
from .inference import inference_to_json
|
||||
from .pipeline import run_pipeline_rgba
|
||||
from .types import InferenceResult, PaletteResult
|
||||
|
||||
|
||||
def _require_gui_dependencies():
|
||||
try:
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
except ImportError as exc: # pragma: no cover - exercised only when GUI deps missing
|
||||
raise RuntimeError(
|
||||
"The GUI requires FastAPI, uvicorn, and python-multipart. Install the project dependencies first."
|
||||
) from exc
|
||||
return {
|
||||
"FastAPI": FastAPI,
|
||||
"File": File,
|
||||
"Form": Form,
|
||||
"HTTPException": HTTPException,
|
||||
"UploadFile": UploadFile,
|
||||
"HTMLResponse": HTMLResponse,
|
||||
"JSONResponse": JSONResponse,
|
||||
"RedirectResponse": RedirectResponse,
|
||||
"StreamingResponse": StreamingResponse,
|
||||
"StaticFiles": StaticFiles,
|
||||
}
|
||||
|
||||
|
||||
def _to_uint8(rgba: np.ndarray) -> np.ndarray:
|
||||
return np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _rgba_data_url(rgba: np.ndarray) -> str:
|
||||
image = Image.fromarray(_to_uint8(rgba), mode="RGBA")
|
||||
with io.BytesIO() as buffer:
|
||||
image.save(buffer, format="PNG")
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _image_asset(rgba: np.ndarray) -> dict[str, Any]:
|
||||
return {
|
||||
"dataUrl": _rgba_data_url(rgba),
|
||||
"width": int(rgba.shape[1]),
|
||||
"height": int(rgba.shape[0]),
|
||||
}
|
||||
|
||||
|
||||
def _scalar_to_rgba(values: np.ndarray, *, tint: tuple[float, float, float] = (0.15, 0.85, 0.75)) -> np.ndarray:
|
||||
scaled = values.astype(np.float32)
|
||||
max_value = float(np.max(scaled)) if scaled.size else 0.0
|
||||
if max_value > 0.0:
|
||||
scaled = scaled / max_value
|
||||
rgba = np.zeros((*scaled.shape, 4), dtype=np.float32)
|
||||
rgba[..., 0] = scaled * float(tint[0]) + np.sqrt(scaled) * 0.08
|
||||
rgba[..., 1] = scaled * float(tint[1])
|
||||
rgba[..., 2] = scaled * float(tint[2]) + np.sqrt(scaled) * 0.18
|
||||
rgba[..., 3] = np.clip(0.12 + scaled * 0.88, 0.0, 1.0)
|
||||
return np.clip(rgba, 0.0, 1.0)
|
||||
|
||||
|
||||
def _render_sample_overlay(source_rgba: np.ndarray, sample_x: np.ndarray, sample_y: np.ndarray) -> np.ndarray:
|
||||
overlay = source_rgba.copy()
|
||||
accent = np.asarray([0.10, 0.95, 0.82, 1.0], dtype=np.float32)
|
||||
shadow = np.asarray([0.02, 0.08, 0.10, 1.0], dtype=np.float32)
|
||||
height, width = overlay.shape[:2]
|
||||
for dy, dx, color in (
|
||||
(0, 0, accent),
|
||||
(-1, 0, shadow),
|
||||
(1, 0, shadow),
|
||||
(0, -1, shadow),
|
||||
(0, 1, shadow),
|
||||
):
|
||||
yy = np.clip(sample_y + dy, 0, height - 1)
|
||||
xx = np.clip(sample_x + dx, 0, width - 1)
|
||||
overlay[yy, xx] = overlay[yy, xx] * 0.22 + color[None, :] * 0.78
|
||||
return np.clip(overlay, 0.0, 1.0)
|
||||
|
||||
|
||||
def _render_lattice_overlay(source_rgba: np.ndarray, uv0_px: np.ndarray) -> np.ndarray:
|
||||
image = Image.fromarray(_to_uint8(source_rgba), mode="RGBA")
|
||||
draw = ImageDraw.Draw(image, "RGBA")
|
||||
color = (54, 219, 198, 180)
|
||||
shadow = (8, 18, 24, 170)
|
||||
height, width = uv0_px.shape[:2]
|
||||
for y in range(height):
|
||||
row = uv0_px[y]
|
||||
for x in range(width - 1):
|
||||
left = row[x]
|
||||
right = row[x + 1]
|
||||
draw.line((float(left[0]), float(left[1]), float(right[0]), float(right[1])), fill=shadow, width=3)
|
||||
draw.line((float(left[0]), float(left[1]), float(right[0]), float(right[1])), fill=color, width=1)
|
||||
for x in range(width):
|
||||
column = uv0_px[:, x]
|
||||
for y in range(height - 1):
|
||||
top = column[y]
|
||||
bottom = column[y + 1]
|
||||
draw.line((float(top[0]), float(top[1]), float(bottom[0]), float(bottom[1])), fill=shadow, width=3)
|
||||
draw.line((float(top[0]), float(top[1]), float(bottom[0]), float(bottom[1])), fill=color, width=1)
|
||||
for point in uv0_px.reshape(-1, 2):
|
||||
px = float(point[0])
|
||||
py = float(point[1])
|
||||
draw.ellipse((px - 1.5, py - 1.5, px + 1.5, py + 1.5), fill=(255, 244, 168, 230))
|
||||
return np.asarray(image, dtype=np.float32) / 255.0
|
||||
|
||||
|
||||
def _decode_rgba(data: bytes) -> np.ndarray:
|
||||
return np.asarray(Image.open(io.BytesIO(data)).convert("RGBA"), dtype=np.float32) / 255.0
|
||||
|
||||
|
||||
def _palette_to_json(palette_result: PaletteResult | None) -> dict[str, Any] | None:
|
||||
if palette_result is None:
|
||||
return None
|
||||
return {
|
||||
"palette": [list(color) for color in palette_result.palette],
|
||||
"hasIndexedOutput": palette_result.indexed_rgba is not None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiJob:
|
||||
job_id: str
|
||||
filename: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
status: str = "queued"
|
||||
error: str | None = None
|
||||
events: list[dict[str, Any]] = field(default_factory=list)
|
||||
current_source_rgba: np.ndarray | None = None
|
||||
current_inference: InferenceResult | None = None
|
||||
run_summary: dict[str, Any] | None = None
|
||||
_condition: threading.Condition = field(default_factory=threading.Condition, repr=False)
|
||||
|
||||
def observe(self, event: str, payload: dict[str, Any]) -> None:
|
||||
serialized = self._serialize_event(event, payload)
|
||||
if serialized is None:
|
||||
return
|
||||
self.publish(event, serialized)
|
||||
|
||||
def publish(self, event: str, payload: dict[str, Any]) -> None:
|
||||
record = {
|
||||
"id": len(self.events) + 1,
|
||||
"event": event,
|
||||
"payload": payload,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
with self._condition:
|
||||
self.events.append(record)
|
||||
self._condition.notify_all()
|
||||
|
||||
def mark_running(self) -> None:
|
||||
self.status = "running"
|
||||
self.publish("job_state", {"status": self.status})
|
||||
|
||||
def mark_completed(self) -> None:
|
||||
self.status = "completed"
|
||||
self.publish("job_state", {"status": self.status})
|
||||
|
||||
def mark_failed(self, message: str) -> None:
|
||||
self.status = "failed"
|
||||
self.error = message
|
||||
self.publish("job_failed", {"status": self.status, "message": message})
|
||||
|
||||
def wait_for_events(self, index: int, timeout: float = 0.5) -> list[dict[str, Any]]:
|
||||
with self._condition:
|
||||
if index >= len(self.events) and self.status not in {"completed", "failed"}:
|
||||
self._condition.wait(timeout=timeout)
|
||||
return self.events[index:]
|
||||
|
||||
def _serialize_event(self, event: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if event == "source_loaded":
|
||||
source = payload["source_rgba"]
|
||||
self.current_source_rgba = source.copy()
|
||||
return {
|
||||
"filename": self.filename,
|
||||
"sourceImage": _image_asset(source),
|
||||
}
|
||||
if event == "preprocess_completed":
|
||||
source = payload["source_rgba"]
|
||||
self.current_source_rgba = source.copy()
|
||||
return {
|
||||
"operation": payload.get("operation"),
|
||||
"sourceImage": _image_asset(source),
|
||||
}
|
||||
if event in {"inference_candidates_ready", "phase_selection_completed"}:
|
||||
inference = payload["inference"]
|
||||
self.current_inference = inference
|
||||
return {
|
||||
"inference": inference_to_json(inference),
|
||||
"inferenceMode": payload.get("inference_mode"),
|
||||
}
|
||||
if event == "phase_rerank_started":
|
||||
return {
|
||||
"previewSteps": int(payload["preview_steps"]),
|
||||
"candidateCount": int(payload["candidate_count"]),
|
||||
"confidence": float(payload["confidence"]),
|
||||
}
|
||||
if event == "analysis_completed":
|
||||
edge_map = payload["edge_map"]
|
||||
return {
|
||||
"edgeMapImage": _image_asset(_scalar_to_rgba(edge_map, tint=(0.18, 0.84, 0.82))),
|
||||
}
|
||||
if event == "phase_field_prepared":
|
||||
if self.current_source_rgba is None:
|
||||
return None
|
||||
return {
|
||||
"targetWidth": int(payload["target_width"]),
|
||||
"targetHeight": int(payload["target_height"]),
|
||||
"cellX": float(payload["cell_x"]),
|
||||
"cellY": float(payload["cell_y"]),
|
||||
"latticeImage": _image_asset(_render_lattice_overlay(self.current_source_rgba, payload["uv0_px"])),
|
||||
"guidanceImage": _image_asset(_scalar_to_rgba(payload["guidance"], tint=(0.92, 0.58, 0.18))),
|
||||
}
|
||||
if event in {"phase_field_initial", "phase_field_step", "phase_field_final"}:
|
||||
if self.current_source_rgba is None:
|
||||
return None
|
||||
return {
|
||||
"step": int(payload["step"]),
|
||||
"totalSteps": int(payload["total_steps"]),
|
||||
"loss": None if payload.get("loss") is None else float(payload["loss"]),
|
||||
"terms": {key: float(value) for key, value in payload.get("terms", {}).items()},
|
||||
"phaseMetrics": {key: float(value) for key, value in payload.get("phase_metrics", {}).items()},
|
||||
"outputImage": _image_asset(payload["target_rgba"]),
|
||||
"samplingOverlayImage": _image_asset(
|
||||
_render_sample_overlay(self.current_source_rgba, payload["sample_x"], payload["sample_y"])
|
||||
),
|
||||
"displacementImage": _image_asset(
|
||||
_displacement_preview_rgba(payload["displacement_x"], payload["displacement_y"])
|
||||
),
|
||||
"lossHistory": [float(value) for value in payload.get("loss_history", [])],
|
||||
}
|
||||
if event == "cleanup_completed":
|
||||
return {
|
||||
"cleanedImage": _image_asset(payload["cleaned_rgba"]),
|
||||
"heatmapImage": _image_asset(_scalar_to_rgba(payload["isolated_heatmap"], tint=(0.95, 0.38, 0.24))),
|
||||
}
|
||||
if event == "palette_completed":
|
||||
return {
|
||||
"outputImage": _image_asset(payload["output_rgba"]),
|
||||
"paletteMode": payload.get("palette_mode"),
|
||||
"palette": _palette_to_json(payload.get("palette_result")),
|
||||
}
|
||||
if event == "pipeline_completed":
|
||||
self.run_summary = payload.get("run_summary")
|
||||
return {
|
||||
"outputImage": _image_asset(payload["output_rgba"]),
|
||||
"diagnostics": payload["diagnostics"],
|
||||
"runSummary": self.run_summary,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _job_state_payload(job: GuiJob) -> dict[str, Any]:
|
||||
return {
|
||||
"jobId": job.job_id,
|
||||
"status": job.status,
|
||||
"error": job.error,
|
||||
"createdAt": job.created_at,
|
||||
"eventCount": len(job.events),
|
||||
"runSummary": job.run_summary,
|
||||
}
|
||||
|
||||
|
||||
def _run_job(job: GuiJob, source_rgba: np.ndarray, options: dict[str, Any]) -> None:
|
||||
try:
|
||||
job.mark_running()
|
||||
run_pipeline_rgba(
|
||||
source_rgba,
|
||||
target_size=options["target_size"],
|
||||
target_width=options["target_width"],
|
||||
target_height=options["target_height"],
|
||||
phase_x=options["phase_x"],
|
||||
phase_y=options["phase_y"],
|
||||
palette_mode="off",
|
||||
seed=options["seed"],
|
||||
steps=options["steps"],
|
||||
device=options["device"],
|
||||
strip_background=options["strip_background"],
|
||||
enable_phase_rerank=not options["skip_phase_rerank"],
|
||||
observer=job.observe,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - exercised through manual GUI runs
|
||||
job.mark_failed(str(exc))
|
||||
return
|
||||
job.mark_completed()
|
||||
|
||||
|
||||
def _static_dir() -> Path:
|
||||
return Path(__file__).with_name("gui_static")
|
||||
|
||||
|
||||
def create_app():
|
||||
deps = _require_gui_dependencies()
|
||||
FastAPI = deps["FastAPI"]
|
||||
File = deps["File"]
|
||||
Form = deps["Form"]
|
||||
HTTPException = deps["HTTPException"]
|
||||
UploadFile = deps["UploadFile"]
|
||||
HTMLResponse = deps["HTMLResponse"]
|
||||
JSONResponse = deps["JSONResponse"]
|
||||
RedirectResponse = deps["RedirectResponse"]
|
||||
StreamingResponse = deps["StreamingResponse"]
|
||||
StaticFiles = deps["StaticFiles"]
|
||||
|
||||
app = FastAPI(title="Repixelizer GUI", version="0.1.0")
|
||||
jobs: dict[str, GuiJob] = {}
|
||||
static_dir = _static_dir()
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown job")
|
||||
return JSONResponse(_job_state_payload(job))
|
||||
|
||||
@app.post("/api/jobs")
|
||||
async def create_job(
|
||||
image: UploadFile = File(...),
|
||||
target_size: int | None = Form(default=None),
|
||||
target_width: int | None = Form(default=None),
|
||||
target_height: int | None = Form(default=None),
|
||||
phase_x: float | None = Form(default=None),
|
||||
phase_y: float | None = Form(default=None),
|
||||
steps: int = Form(default=48),
|
||||
seed: int = Form(default=7),
|
||||
device: str = Form(default="auto"),
|
||||
strip_background: bool = Form(default=False),
|
||||
skip_phase_rerank: bool = Form(default=False),
|
||||
):
|
||||
raw = await image.read()
|
||||
source_rgba = _decode_rgba(raw)
|
||||
job = GuiJob(job_id=str(uuid.uuid4()), filename=image.filename or "input.png")
|
||||
jobs[job.job_id] = job
|
||||
options = {
|
||||
"target_size": target_size,
|
||||
"target_width": target_width,
|
||||
"target_height": target_height,
|
||||
"phase_x": phase_x,
|
||||
"phase_y": phase_y,
|
||||
"steps": max(0, int(steps)),
|
||||
"seed": int(seed),
|
||||
"device": device,
|
||||
"strip_background": bool(strip_background),
|
||||
"skip_phase_rerank": bool(skip_phase_rerank),
|
||||
}
|
||||
worker = threading.Thread(target=_run_job, args=(job, source_rgba, options), daemon=True)
|
||||
worker.start()
|
||||
return JSONResponse(
|
||||
{
|
||||
"jobId": job.job_id,
|
||||
"status": job.status,
|
||||
"eventsUrl": f"/api/jobs/{job.job_id}/events",
|
||||
"stateUrl": f"/api/jobs/{job.job_id}",
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/jobs/{job_id}/events")
|
||||
def job_events(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown job")
|
||||
|
||||
def generate():
|
||||
index = 0
|
||||
while True:
|
||||
pending = job.wait_for_events(index)
|
||||
for record in pending:
|
||||
index += 1
|
||||
yield (
|
||||
f"id: {record['id']}\n"
|
||||
f"event: {record['event']}\n"
|
||||
f"data: {json.dumps(record['payload'])}\n\n"
|
||||
)
|
||||
if job.status in {"completed", "failed"} and index >= len(job.events):
|
||||
break
|
||||
yield ": keep-alive\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
if static_dir.exists():
|
||||
app.mount("/app", StaticFiles(directory=static_dir, html=True), name="app")
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return RedirectResponse(url="/app/")
|
||||
else: # pragma: no cover - only relevant before frontend assets exist
|
||||
@app.get("/")
|
||||
def root_missing():
|
||||
return HTMLResponse("<h1>GUI assets are missing.</h1><p>Build the TypeScript frontend first.</p>")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main(*, host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> int:
|
||||
_require_gui_dependencies()
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError as exc: # pragma: no cover - exercised only when uvicorn missing
|
||||
raise RuntimeError("The GUI requires uvicorn. Install the project dependencies first.") from exc
|
||||
uvicorn.run("repixelizer.gui:create_app", factory=True, host=host, port=port, reload=reload)
|
||||
return 0
|
||||
839
src/repixelizer/gui_static/app.js
Normal file
839
src/repixelizer/gui_static/app.js
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
"use strict";
|
||||
function byId(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
const fileInput = byId("fileInput");
|
||||
const dropzone = byId("dropzone");
|
||||
const dropzoneLabel = byId("dropzoneLabel");
|
||||
const pickFileButton = byId("pickFileButton");
|
||||
const runButton = byId("runButton");
|
||||
const statusBadge = byId("statusBadge");
|
||||
const statusText = byId("statusText");
|
||||
const inferenceSummary = byId("inferenceSummary");
|
||||
const candidateList = byId("candidateList");
|
||||
const eventLog = byId("eventLog");
|
||||
const viewerTitle = byId("viewerTitle");
|
||||
const stepSlider = byId("stepSlider");
|
||||
const stepValue = byId("stepValue");
|
||||
const lossCanvas = byId("lossCanvas");
|
||||
const leftCanvas = byId("leftCanvas");
|
||||
const middleCanvas = byId("middleCanvas");
|
||||
const rightCanvas = byId("rightCanvas");
|
||||
const leftVizLabel = byId("leftVizLabel");
|
||||
const middleVizLabel = byId("middleVizLabel");
|
||||
const rightVizLabel = byId("rightVizLabel");
|
||||
const metricsPanel = byId("metricsPanel");
|
||||
const paintSwatch = byId("paintSwatch");
|
||||
const toolMode = byId("toolMode");
|
||||
const zoomInput = byId("zoomInput");
|
||||
const zoomValue = byId("zoomValue");
|
||||
const gridToggle = byId("gridToggle");
|
||||
const editorCanvas = byId("editorCanvas");
|
||||
const editorGridCanvas = byId("editorGridCanvas");
|
||||
const editorSurface = byId("editorSurface");
|
||||
const editorMeta = byId("editorMeta");
|
||||
const summaryPanel = byId("summaryPanel");
|
||||
const resetEditorButton = byId("resetEditorButton");
|
||||
const downloadButton = byId("downloadButton");
|
||||
const targetSizeInput = byId("targetSizeInput");
|
||||
const targetWidthInput = byId("targetWidthInput");
|
||||
const targetHeightInput = byId("targetHeightInput");
|
||||
const phaseXInput = byId("phaseXInput");
|
||||
const phaseYInput = byId("phaseYInput");
|
||||
const stepsInput = byId("stepsInput");
|
||||
const seedInput = byId("seedInput");
|
||||
const deviceInput = byId("deviceInput");
|
||||
const stripBackgroundInput = byId("stripBackgroundInput");
|
||||
const skipRerankInput = byId("skipRerankInput");
|
||||
const paintInputs = {
|
||||
r: byId("paintR"),
|
||||
g: byId("paintG"),
|
||||
b: byId("paintB"),
|
||||
a: byId("paintA"),
|
||||
};
|
||||
const imageCache = new Map();
|
||||
const eventTypes = [
|
||||
"job_state",
|
||||
"job_failed",
|
||||
"source_loaded",
|
||||
"preprocess_completed",
|
||||
"inference_candidates_ready",
|
||||
"phase_rerank_started",
|
||||
"phase_selection_completed",
|
||||
"analysis_completed",
|
||||
"phase_field_prepared",
|
||||
"phase_field_initial",
|
||||
"phase_field_step",
|
||||
"phase_field_final",
|
||||
"cleanup_completed",
|
||||
"palette_completed",
|
||||
"pipeline_completed",
|
||||
];
|
||||
const state = {
|
||||
file: null,
|
||||
jobId: null,
|
||||
status: "idle",
|
||||
statusText: "Drop a fake sprite in here and let the machine explain itself for once.",
|
||||
sourceImage: null,
|
||||
preprocessedImage: null,
|
||||
edgeMapImage: null,
|
||||
latticeImage: null,
|
||||
guidanceImage: null,
|
||||
inference: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
heatmapImage: null,
|
||||
finalOutputImage: null,
|
||||
runSummary: null,
|
||||
selectedFrameIndex: 0,
|
||||
autoFollow: true,
|
||||
eventLog: [],
|
||||
paintColor: [255, 255, 255, 255],
|
||||
editorBaseAsset: null,
|
||||
editorDirty: false,
|
||||
altHeld: false,
|
||||
zoom: Number(zoomInput.value),
|
||||
showGrid: gridToggle.checked,
|
||||
currentStage: "Idle",
|
||||
};
|
||||
let currentEventSource = null;
|
||||
let painting = false;
|
||||
let offscreenCanvas = null;
|
||||
let offscreenContext = null;
|
||||
let singlePixel = new ImageData(1, 1);
|
||||
function clampByte(value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
function setPaintColor(color) {
|
||||
state.paintColor = color;
|
||||
paintInputs.r.value = String(color[0]);
|
||||
paintInputs.g.value = String(color[1]);
|
||||
paintInputs.b.value = String(color[2]);
|
||||
paintInputs.a.value = String(color[3]);
|
||||
paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
|
||||
}
|
||||
function updateToolMode() {
|
||||
toolMode.textContent = state.altHeld ? "Eyedropper armed. Sample a pixel." : "Pencil. Hold Alt for eyedropper.";
|
||||
}
|
||||
function addLog(label, detail) {
|
||||
state.eventLog.unshift({ label, detail });
|
||||
state.eventLog = state.eventLog.slice(0, 8);
|
||||
renderEventLog();
|
||||
}
|
||||
function renderEventLog() {
|
||||
eventLog.innerHTML = "";
|
||||
if (state.eventLog.length === 0) {
|
||||
eventLog.innerHTML = `<p class="muted">Nothing to report yet.</p>`;
|
||||
return;
|
||||
}
|
||||
for (const entry of state.eventLog) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "event-item";
|
||||
node.innerHTML = `<strong>${entry.label}</strong><span>${entry.detail}</span>`;
|
||||
eventLog.appendChild(node);
|
||||
}
|
||||
}
|
||||
function setStatus(status, detail) {
|
||||
state.status = status;
|
||||
state.statusText = detail;
|
||||
statusBadge.textContent = status;
|
||||
statusText.textContent = detail;
|
||||
}
|
||||
function resetRunArtifacts() {
|
||||
state.jobId = null;
|
||||
state.sourceImage = null;
|
||||
state.preprocessedImage = null;
|
||||
state.edgeMapImage = null;
|
||||
state.latticeImage = null;
|
||||
state.guidanceImage = null;
|
||||
state.inference = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
state.heatmapImage = null;
|
||||
state.finalOutputImage = null;
|
||||
state.runSummary = null;
|
||||
state.selectedFrameIndex = 0;
|
||||
state.autoFollow = true;
|
||||
state.eventLog = [];
|
||||
state.editorBaseAsset = null;
|
||||
state.editorDirty = false;
|
||||
state.currentStage = "Waiting";
|
||||
}
|
||||
function parseOptionalInteger(input) {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
function parseOptionalFloat(input) {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
async function loadImage(asset) {
|
||||
const cached = imageCache.get(asset.dataUrl);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to load image asset"));
|
||||
image.src = asset.dataUrl;
|
||||
});
|
||||
imageCache.set(asset.dataUrl, promise);
|
||||
return promise;
|
||||
}
|
||||
async function drawAsset(canvas, asset) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!asset) {
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#081012";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "rgba(255,255,255,0.2)";
|
||||
context.font = "16px sans-serif";
|
||||
context.fillText("Waiting for image data", 24, 32);
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
canvas.width = image.naturalWidth || asset.width;
|
||||
canvas.height = image.naturalHeight || asset.height;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
function readNestedNumber(root, path) {
|
||||
let current = root;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
return null;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return typeof current === "number" ? current : null;
|
||||
}
|
||||
function formatNumber(value, digits = 3) {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "n/a";
|
||||
}
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
function getSelectedFrame() {
|
||||
if (state.frames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return state.frames[Math.max(0, Math.min(state.selectedFrameIndex, state.frames.length - 1))];
|
||||
}
|
||||
function renderInference() {
|
||||
if (!state.inference) {
|
||||
inferenceSummary.innerHTML = `<p class="muted">No lattice picked yet.</p>`;
|
||||
candidateList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
inferenceSummary.innerHTML = `
|
||||
<div class="summary-card">
|
||||
<strong>Chosen Grid</strong>
|
||||
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>Phase</strong>
|
||||
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>Confidence</strong>
|
||||
<span>${formatNumber(state.inference.confidence, 3)}</span>
|
||||
</div>
|
||||
`;
|
||||
candidateList.innerHTML = "";
|
||||
const topCandidates = state.inference.top_candidates.slice(0, 6);
|
||||
for (const candidate of topCandidates) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "candidate-card";
|
||||
const rerank = candidate.breakdown["phase_rerank_rank"];
|
||||
node.innerHTML = `
|
||||
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
|
||||
<span>phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}</span><br />
|
||||
<span>score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""}</span>
|
||||
`;
|
||||
candidateList.appendChild(node);
|
||||
}
|
||||
}
|
||||
function renderMetrics() {
|
||||
metricsPanel.innerHTML = "";
|
||||
const frame = getSelectedFrame();
|
||||
const items = [];
|
||||
if (frame) {
|
||||
items.push(["Frame", `${frame.step}/${frame.totalSteps}`]);
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
}
|
||||
}
|
||||
else if (state.runSummary) {
|
||||
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
|
||||
items.push([
|
||||
"Final fidelity",
|
||||
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
|
||||
]);
|
||||
items.push(["Source color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
|
||||
}
|
||||
else {
|
||||
items.push(["Status", state.currentStage]);
|
||||
}
|
||||
for (const [label, value] of items) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "metric-chip";
|
||||
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
|
||||
metricsPanel.appendChild(node);
|
||||
}
|
||||
}
|
||||
function renderSummary() {
|
||||
summaryPanel.innerHTML = "";
|
||||
if (!state.runSummary) {
|
||||
summaryPanel.innerHTML = `<p class="muted">Final metrics show up here after the run finishes.</p>`;
|
||||
return;
|
||||
}
|
||||
const entries = [
|
||||
["Structure score", readNestedNumber(state.runSummary, ["source_structure", "score"])],
|
||||
["Edge F1", readNestedNumber(state.runSummary, ["source_structure", "edge_f1"])],
|
||||
["Exact match", readNestedNumber(state.runSummary, ["source_structure", "exact_match_ratio"])],
|
||||
["Final fidelity", readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])],
|
||||
["Mean displacement", readNestedNumber(state.runSummary, ["optimizer_displacement", "final_output", "mean_magnitude_px"])],
|
||||
];
|
||||
for (const [label, value] of entries) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "summary-card";
|
||||
card.innerHTML = `<strong>${label}</strong><span>${formatNumber(value, 3)}</span>`;
|
||||
summaryPanel.appendChild(card);
|
||||
}
|
||||
}
|
||||
async function renderViewer() {
|
||||
const frame = getSelectedFrame();
|
||||
let leftAsset = state.preprocessedImage ?? state.sourceImage;
|
||||
let middleAsset = state.edgeMapImage;
|
||||
let rightAsset = state.finalOutputImage ?? state.cleanupImage ?? state.sourceImage;
|
||||
let leftLabel = "Source";
|
||||
let middleLabel = "Edge Scout";
|
||||
let rightLabel = "Output";
|
||||
let title = state.currentStage;
|
||||
if (state.latticeImage) {
|
||||
leftAsset = state.latticeImage;
|
||||
leftLabel = "Lattice Prep";
|
||||
}
|
||||
if (state.guidanceImage) {
|
||||
middleAsset = state.guidanceImage;
|
||||
middleLabel = "Guidance";
|
||||
}
|
||||
if (frame) {
|
||||
leftAsset = frame.samplingOverlayImage;
|
||||
middleAsset = frame.displacementImage;
|
||||
rightAsset = frame.outputImage;
|
||||
leftLabel = "Sampling Overlay";
|
||||
middleLabel = "Displacement";
|
||||
rightLabel = "Current Output";
|
||||
title = frame.step === 0 ? "Initial nearest-sample placement" : `Solver step ${frame.step} / ${frame.totalSteps}`;
|
||||
}
|
||||
else if (state.cleanupImage) {
|
||||
middleAsset = state.heatmapImage ?? middleAsset;
|
||||
middleLabel = "Cleanup Heatmap";
|
||||
rightAsset = state.cleanupImage;
|
||||
rightLabel = "Cleaned Output";
|
||||
title = state.finalOutputImage ? "Finalized output" : "Cleanup pass";
|
||||
}
|
||||
viewerTitle.textContent = title;
|
||||
leftVizLabel.textContent = leftLabel;
|
||||
middleVizLabel.textContent = middleLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(middleCanvas, middleAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
renderMetrics();
|
||||
}
|
||||
function renderSlider() {
|
||||
if (state.frames.length === 0) {
|
||||
stepSlider.disabled = true;
|
||||
stepSlider.max = "0";
|
||||
stepSlider.value = "0";
|
||||
stepValue.textContent = "No frames yet";
|
||||
return;
|
||||
}
|
||||
stepSlider.disabled = false;
|
||||
stepSlider.max = String(state.frames.length - 1);
|
||||
stepSlider.value = String(state.selectedFrameIndex);
|
||||
const frame = getSelectedFrame();
|
||||
if (!frame) {
|
||||
stepValue.textContent = "No frames yet";
|
||||
return;
|
||||
}
|
||||
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
|
||||
}
|
||||
function renderLossChart() {
|
||||
const context = lossCanvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const width = lossCanvas.width;
|
||||
const height = lossCanvas.height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = "rgba(7, 15, 17, 0.96)";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.strokeStyle = "rgba(255,255,255,0.08)";
|
||||
context.lineWidth = 1;
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
const y = (height / 5) * index;
|
||||
context.beginPath();
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
const losses = state.frames
|
||||
.filter((frame) => typeof frame.loss === "number")
|
||||
.map((frame) => frame.loss);
|
||||
if (losses.length === 0) {
|
||||
context.fillStyle = "rgba(255,255,255,0.28)";
|
||||
context.font = "14px sans-serif";
|
||||
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 28);
|
||||
return;
|
||||
}
|
||||
const minLoss = Math.min(...losses);
|
||||
const maxLoss = Math.max(...losses);
|
||||
const span = Math.max(1e-6, maxLoss - minLoss);
|
||||
context.strokeStyle = "#8fe2c8";
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
losses.forEach((loss, index) => {
|
||||
const x = losses.length === 1 ? width / 2 : (index / (losses.length - 1)) * (width - 24) + 12;
|
||||
const y = height - 18 - ((loss - minLoss) / span) * (height - 36);
|
||||
if (index === 0) {
|
||||
context.moveTo(x, y);
|
||||
}
|
||||
else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
context.stroke();
|
||||
const selected = getSelectedFrame();
|
||||
if (selected && selected.loss !== null) {
|
||||
const matchingIndex = losses.findIndex((value) => Math.abs(value - selected.loss) < 1e-6);
|
||||
if (matchingIndex >= 0) {
|
||||
const x = losses.length === 1 ? width / 2 : (matchingIndex / (losses.length - 1)) * (width - 24) + 12;
|
||||
const y = height - 18 - ((selected.loss - minLoss) / span) * (height - 36);
|
||||
context.fillStyle = "#f5b76b";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function loadEditorAsset(asset) {
|
||||
state.editorBaseAsset = asset;
|
||||
state.editorDirty = false;
|
||||
if (!asset) {
|
||||
offscreenCanvas = null;
|
||||
offscreenContext = null;
|
||||
renderEditor();
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
offscreenCanvas = document.createElement("canvas");
|
||||
offscreenCanvas.width = image.naturalWidth || asset.width;
|
||||
offscreenCanvas.height = image.naturalHeight || asset.height;
|
||||
offscreenContext = offscreenCanvas.getContext("2d");
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
offscreenContext.imageSmoothingEnabled = false;
|
||||
offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
offscreenContext.drawImage(image, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
renderEditor();
|
||||
}
|
||||
function renderEditor() {
|
||||
const displayContext = editorCanvas.getContext("2d");
|
||||
const gridContext = editorGridCanvas.getContext("2d");
|
||||
if (!displayContext || !gridContext) {
|
||||
return;
|
||||
}
|
||||
if (!offscreenCanvas) {
|
||||
editorCanvas.width = 1;
|
||||
editorCanvas.height = 1;
|
||||
editorGridCanvas.width = 1;
|
||||
editorGridCanvas.height = 1;
|
||||
editorMeta.innerHTML = `<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>`;
|
||||
return;
|
||||
}
|
||||
const width = offscreenCanvas.width * state.zoom;
|
||||
const height = offscreenCanvas.height * state.zoom;
|
||||
for (const canvas of [editorCanvas, editorGridCanvas]) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
displayContext.imageSmoothingEnabled = false;
|
||||
displayContext.clearRect(0, 0, width, height);
|
||||
displayContext.drawImage(offscreenCanvas, 0, 0, width, height);
|
||||
gridContext.clearRect(0, 0, width, height);
|
||||
if (state.showGrid && state.zoom >= 8) {
|
||||
gridContext.strokeStyle = "rgba(255,255,255,0.12)";
|
||||
gridContext.lineWidth = 1;
|
||||
for (let x = 0; x <= offscreenCanvas.width; x += 1) {
|
||||
const xpos = x * state.zoom + 0.5;
|
||||
gridContext.beginPath();
|
||||
gridContext.moveTo(xpos, 0);
|
||||
gridContext.lineTo(xpos, height);
|
||||
gridContext.stroke();
|
||||
}
|
||||
for (let y = 0; y <= offscreenCanvas.height; y += 1) {
|
||||
const ypos = y * state.zoom + 0.5;
|
||||
gridContext.beginPath();
|
||||
gridContext.moveTo(0, ypos);
|
||||
gridContext.lineTo(width, ypos);
|
||||
gridContext.stroke();
|
||||
}
|
||||
}
|
||||
editorMeta.innerHTML = `
|
||||
<div class="summary-card">
|
||||
<strong>Canvas</strong>
|
||||
<span>${offscreenCanvas.width} x ${offscreenCanvas.height}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>State</strong>
|
||||
<span>${state.editorDirty ? "Edited" : "Matches solver output"}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
function editorPixelFromPointer(event) {
|
||||
if (!offscreenCanvas) {
|
||||
return null;
|
||||
}
|
||||
const rect = editorCanvas.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const x = Math.floor((event.clientX - rect.left) / state.zoom);
|
||||
const y = Math.floor((event.clientY - rect.top) / state.zoom);
|
||||
if (x < 0 || y < 0 || x >= offscreenCanvas.width || y >= offscreenCanvas.height) {
|
||||
return null;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
function samplePaintColor(x, y) {
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
const pixel = offscreenContext.getImageData(x, y, 1, 1).data;
|
||||
setPaintColor([pixel[0], pixel[1], pixel[2], pixel[3]]);
|
||||
}
|
||||
function paintPixel(x, y) {
|
||||
if (!offscreenContext) {
|
||||
return;
|
||||
}
|
||||
singlePixel.data[0] = state.paintColor[0];
|
||||
singlePixel.data[1] = state.paintColor[1];
|
||||
singlePixel.data[2] = state.paintColor[2];
|
||||
singlePixel.data[3] = state.paintColor[3];
|
||||
offscreenContext.putImageData(singlePixel, x, y);
|
||||
state.editorDirty = true;
|
||||
renderEditor();
|
||||
}
|
||||
function exportEditorPng() {
|
||||
if (!offscreenCanvas) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = offscreenCanvas.toDataURL("image/png");
|
||||
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
|
||||
link.click();
|
||||
}
|
||||
function buildFormData() {
|
||||
if (!state.file) {
|
||||
throw new Error("No file selected");
|
||||
}
|
||||
const data = new FormData();
|
||||
data.set("image", state.file);
|
||||
const values = [
|
||||
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
|
||||
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
|
||||
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
|
||||
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
|
||||
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
|
||||
["steps", String(parseOptionalInteger(stepsInput) ?? 48)],
|
||||
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
|
||||
["device", deviceInput.value],
|
||||
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
|
||||
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
|
||||
];
|
||||
for (const [key, value] of values) {
|
||||
if (value !== null) {
|
||||
data.set(key, value);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
async function renderEverything() {
|
||||
renderInference();
|
||||
renderSlider();
|
||||
renderLossChart();
|
||||
renderSummary();
|
||||
await renderViewer();
|
||||
}
|
||||
async function handleEvent(eventName, payload) {
|
||||
switch (eventName) {
|
||||
case "job_state":
|
||||
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
|
||||
break;
|
||||
case "job_failed":
|
||||
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
|
||||
addLog("Failure", String(payload.message ?? "The run failed."));
|
||||
runButton.disabled = false;
|
||||
currentEventSource?.close();
|
||||
break;
|
||||
case "source_loaded":
|
||||
state.sourceImage = payload.sourceImage;
|
||||
state.currentStage = "Loaded source";
|
||||
setStatus("running", "Source loaded. Survey crew is measuring the mess.");
|
||||
addLog("Source", "Loaded the input image.");
|
||||
break;
|
||||
case "preprocess_completed":
|
||||
state.preprocessedImage = payload.sourceImage;
|
||||
state.currentStage = "Preprocessed source";
|
||||
addLog("Preprocess", "Stripped edge-connected background noise.");
|
||||
break;
|
||||
case "inference_candidates_ready":
|
||||
state.inference = payload.inference;
|
||||
state.currentStage = "Scoring lattice candidates";
|
||||
addLog("Inference", "Scored candidate grids and phase offsets.");
|
||||
break;
|
||||
case "phase_rerank_started":
|
||||
state.currentStage = "Phase rerank preview";
|
||||
addLog("Rerank", `Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`);
|
||||
break;
|
||||
case "phase_selection_completed":
|
||||
state.inference = payload.inference;
|
||||
state.currentStage = "Selected lattice";
|
||||
addLog("Selection", "Committed to a ruler and phase.");
|
||||
break;
|
||||
case "analysis_completed":
|
||||
state.edgeMapImage = payload.edgeMapImage;
|
||||
state.currentStage = "Edge scout";
|
||||
addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
|
||||
break;
|
||||
case "phase_field_prepared":
|
||||
state.latticeImage = payload.latticeImage;
|
||||
state.guidanceImage = payload.guidanceImage;
|
||||
state.currentStage = "Prepared phase field";
|
||||
addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`);
|
||||
break;
|
||||
case "phase_field_initial":
|
||||
case "phase_field_step":
|
||||
case "phase_field_final": {
|
||||
const frame = payload;
|
||||
const existingIndex = state.frames.findIndex((candidate) => candidate.step === frame.step);
|
||||
if (existingIndex >= 0) {
|
||||
state.frames[existingIndex] = frame;
|
||||
}
|
||||
else {
|
||||
state.frames.push(frame);
|
||||
state.frames.sort((a, b) => a.step - b.step);
|
||||
}
|
||||
if (state.autoFollow) {
|
||||
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step));
|
||||
}
|
||||
state.currentStage = frame.step === 0 ? "Initial placement" : `Solver step ${frame.step}/${frame.totalSteps}`;
|
||||
if (eventName === "phase_field_final") {
|
||||
addLog("Solver", "Final nearest-source sample committed.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "cleanup_completed":
|
||||
state.cleanupImage = payload.cleanedImage;
|
||||
state.heatmapImage = payload.heatmapImage;
|
||||
state.currentStage = "Cleanup";
|
||||
addLog("Cleanup", "Ran the local cleanup sweep.");
|
||||
break;
|
||||
case "palette_completed":
|
||||
state.finalOutputImage = payload.outputImage;
|
||||
state.currentStage = "Final output";
|
||||
addLog("Output", "Final output image is ready.");
|
||||
break;
|
||||
case "pipeline_completed":
|
||||
state.finalOutputImage = payload.outputImage;
|
||||
state.runSummary = payload.runSummary ?? null;
|
||||
state.currentStage = "Completed";
|
||||
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
|
||||
addLog("Done", "Full pipeline completed.");
|
||||
runButton.disabled = false;
|
||||
currentEventSource?.close();
|
||||
await loadEditorAsset(state.finalOutputImage);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await renderEverything();
|
||||
}
|
||||
function connectEventStream(jobId) {
|
||||
currentEventSource?.close();
|
||||
const stream = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
currentEventSource = stream;
|
||||
for (const eventType of eventTypes) {
|
||||
stream.addEventListener(eventType, (raw) => {
|
||||
const message = raw;
|
||||
const payload = JSON.parse(message.data);
|
||||
void handleEvent(eventType, payload);
|
||||
});
|
||||
}
|
||||
stream.onerror = () => {
|
||||
if (state.status === "running") {
|
||||
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
|
||||
}
|
||||
};
|
||||
}
|
||||
async function startRun() {
|
||||
if (!state.file) {
|
||||
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
|
||||
return;
|
||||
}
|
||||
resetRunArtifacts();
|
||||
renderEventLog();
|
||||
runButton.disabled = true;
|
||||
setStatus("queued", "Queued the run. Small miracle pending.");
|
||||
addLog("Queued", `Starting ${state.file.name}.`);
|
||||
await renderEverything();
|
||||
try {
|
||||
const response = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
body: buildFormData(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const payload = (await response.json());
|
||||
state.jobId = payload.jobId;
|
||||
connectEventStream(payload.jobId);
|
||||
}
|
||||
catch (error) {
|
||||
runButton.disabled = false;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
}
|
||||
}
|
||||
function acceptFile(file) {
|
||||
state.file = file;
|
||||
dropzoneLabel.textContent = file.name;
|
||||
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
}
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
pickFileButton.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) {
|
||||
acceptFile(file);
|
||||
}
|
||||
});
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
acceptFile(file);
|
||||
}
|
||||
});
|
||||
runButton.addEventListener("click", () => {
|
||||
void startRun();
|
||||
});
|
||||
stepSlider.addEventListener("input", () => {
|
||||
state.selectedFrameIndex = Number(stepSlider.value);
|
||||
state.autoFollow = state.selectedFrameIndex >= state.frames.length - 1;
|
||||
void renderEverything();
|
||||
});
|
||||
zoomInput.addEventListener("input", () => {
|
||||
state.zoom = Number(zoomInput.value);
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEditor();
|
||||
});
|
||||
gridToggle.addEventListener("change", () => {
|
||||
state.showGrid = gridToggle.checked;
|
||||
renderEditor();
|
||||
});
|
||||
for (const input of Object.values(paintInputs)) {
|
||||
input.addEventListener("change", () => {
|
||||
setPaintColor([
|
||||
clampByte(Number(paintInputs.r.value)),
|
||||
clampByte(Number(paintInputs.g.value)),
|
||||
clampByte(Number(paintInputs.b.value)),
|
||||
clampByte(Number(paintInputs.a.value)),
|
||||
]);
|
||||
});
|
||||
}
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Alt") {
|
||||
state.altHeld = true;
|
||||
updateToolMode();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Alt") {
|
||||
state.altHeld = false;
|
||||
updateToolMode();
|
||||
}
|
||||
});
|
||||
editorCanvas.addEventListener("pointerdown", (event) => {
|
||||
const pixel = editorPixelFromPointer(event);
|
||||
if (!pixel) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
editorCanvas.setPointerCapture(event.pointerId);
|
||||
if (event.altKey || state.altHeld) {
|
||||
samplePaintColor(pixel.x, pixel.y);
|
||||
updateToolMode();
|
||||
return;
|
||||
}
|
||||
painting = true;
|
||||
paintPixel(pixel.x, pixel.y);
|
||||
});
|
||||
editorCanvas.addEventListener("pointermove", (event) => {
|
||||
const pixel = editorPixelFromPointer(event);
|
||||
if (!pixel) {
|
||||
return;
|
||||
}
|
||||
if (event.altKey || state.altHeld) {
|
||||
updateToolMode();
|
||||
if (event.buttons === 1) {
|
||||
samplePaintColor(pixel.x, pixel.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (painting && event.buttons === 1) {
|
||||
paintPixel(pixel.x, pixel.y);
|
||||
}
|
||||
});
|
||||
editorCanvas.addEventListener("pointerup", () => {
|
||||
painting = false;
|
||||
});
|
||||
editorCanvas.addEventListener("pointerleave", () => {
|
||||
painting = false;
|
||||
});
|
||||
resetEditorButton.addEventListener("click", () => {
|
||||
void loadEditorAsset(state.editorBaseAsset);
|
||||
});
|
||||
downloadButton.addEventListener("click", () => {
|
||||
exportEditorPng();
|
||||
});
|
||||
setPaintColor(state.paintColor);
|
||||
updateToolMode();
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEventLog();
|
||||
void renderEverything();
|
||||
203
src/repixelizer/gui_static/index.html
Normal file
203
src/repixelizer/gui_static/index.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Repixelizer GUI</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="masthead panel">
|
||||
<div>
|
||||
<p class="eyebrow">Live Pipeline</p>
|
||||
<h1>Repixelizer</h1>
|
||||
</div>
|
||||
<div class="status-cluster">
|
||||
<span id="statusBadge" class="status-badge">Idle</span>
|
||||
<p id="statusText" class="status-text">Drop a fake sprite in here and let the machine explain itself for once.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-grid">
|
||||
<aside class="sidebar panel">
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2>Input</h2>
|
||||
<button id="pickFileButton" class="ghost-button" type="button">Choose File</button>
|
||||
</div>
|
||||
<input id="fileInput" type="file" accept="image/png,image/*" hidden />
|
||||
<button id="dropzone" class="dropzone" type="button">
|
||||
<strong>Drop image</strong>
|
||||
<span id="dropzoneLabel">PNG, sprite scrap, cursed logo. Whatever started this.</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2>Run Controls</h2>
|
||||
</div>
|
||||
<form id="controlsForm" class="controls-grid">
|
||||
<label>
|
||||
<span>Target Size</span>
|
||||
<input id="targetSizeInput" name="targetSize" type="number" min="1" placeholder="auto" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Target Width</span>
|
||||
<input id="targetWidthInput" name="targetWidth" type="number" min="1" placeholder="auto" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Target Height</span>
|
||||
<input id="targetHeightInput" name="targetHeight" type="number" min="1" placeholder="auto" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Phase X</span>
|
||||
<input id="phaseXInput" name="phaseX" type="number" step="0.05" placeholder="search" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Phase Y</span>
|
||||
<input id="phaseYInput" name="phaseY" type="number" step="0.05" placeholder="search" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Steps</span>
|
||||
<input id="stepsInput" name="steps" type="number" min="0" value="48" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Seed</span>
|
||||
<input id="seedInput" name="seed" type="number" min="0" value="7" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Device</span>
|
||||
<select id="deviceInput" name="device">
|
||||
<option value="auto" selected>auto</option>
|
||||
<option value="cpu">cpu</option>
|
||||
<option value="cuda">cuda</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input id="stripBackgroundInput" name="stripBackground" type="checkbox" />
|
||||
<span>Strip checkerboard-ish junk</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input id="skipRerankInput" name="skipRerank" type="checkbox" />
|
||||
<span>Skip low-confidence phase rerank</span>
|
||||
</label>
|
||||
</form>
|
||||
<button id="runButton" class="primary-button" type="button">Run The Machine</button>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2>Inference</h2>
|
||||
</div>
|
||||
<div id="inferenceSummary" class="info-stack">
|
||||
<p class="muted">No lattice picked yet.</p>
|
||||
</div>
|
||||
<div id="candidateList" class="candidate-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-head">
|
||||
<h2>Run Notes</h2>
|
||||
</div>
|
||||
<div id="eventLog" class="event-log"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="panel viewer-panel">
|
||||
<div class="section-head viewer-head">
|
||||
<div>
|
||||
<p class="eyebrow">Algorithm View</p>
|
||||
<h2 id="viewerTitle">Pipeline Stages</h2>
|
||||
</div>
|
||||
<div class="scrubber">
|
||||
<label for="stepSlider">Solver Step</label>
|
||||
<input id="stepSlider" type="range" min="0" max="0" step="1" value="0" disabled />
|
||||
<span id="stepValue" class="step-value">No frames yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="lossCanvas" class="loss-canvas" width="800" height="120"></canvas>
|
||||
<div class="viewer-grid">
|
||||
<figure class="viz-card">
|
||||
<div class="viz-label" id="leftVizLabel">Source</div>
|
||||
<canvas id="leftCanvas"></canvas>
|
||||
</figure>
|
||||
<figure class="viz-card">
|
||||
<div class="viz-label" id="middleVizLabel">Scout</div>
|
||||
<canvas id="middleCanvas"></canvas>
|
||||
</figure>
|
||||
<figure class="viz-card">
|
||||
<div class="viz-label" id="rightVizLabel">Output</div>
|
||||
<canvas id="rightCanvas"></canvas>
|
||||
</figure>
|
||||
</div>
|
||||
<div id="metricsPanel" class="metrics-panel"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">Pixel Fixes</p>
|
||||
<h2>Output Editor</h2>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<span id="toolMode" class="tool-hint">Pencil. Hold Alt for eyedropper.</span>
|
||||
<button id="resetEditorButton" class="ghost-button" type="button">Reset Output</button>
|
||||
<button id="downloadButton" class="primary-button subdued" type="button">Export PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<div class="paint-block">
|
||||
<div id="paintSwatch" class="paint-swatch"></div>
|
||||
<div class="rgba-inputs">
|
||||
<label>
|
||||
<span>R</span>
|
||||
<input id="paintR" type="number" min="0" max="255" value="255" />
|
||||
</label>
|
||||
<label>
|
||||
<span>G</span>
|
||||
<input id="paintG" type="number" min="0" max="255" value="255" />
|
||||
</label>
|
||||
<label>
|
||||
<span>B</span>
|
||||
<input id="paintB" type="number" min="0" max="255" value="255" />
|
||||
</label>
|
||||
<label>
|
||||
<span>A</span>
|
||||
<input id="paintA" type="number" min="0" max="255" value="255" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="zoom-block">
|
||||
<label for="zoomInput">Zoom</label>
|
||||
<input id="zoomInput" type="range" min="4" max="32" step="1" value="12" />
|
||||
<span id="zoomValue">12x</span>
|
||||
</div>
|
||||
<label class="toggle-row compact">
|
||||
<input id="gridToggle" type="checkbox" checked />
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="editor-stage">
|
||||
<div class="checkerboard editor-surface" id="editorSurface">
|
||||
<canvas id="editorCanvas"></canvas>
|
||||
<canvas id="editorGridCanvas"></canvas>
|
||||
</div>
|
||||
<div class="editor-meta">
|
||||
<div id="editorMeta" class="info-stack">
|
||||
<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>
|
||||
</div>
|
||||
<div id="summaryPanel" class="summary-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
501
src/repixelizer/gui_static/styles.css
Normal file
501
src/repixelizer/gui_static/styles.css
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-0: #0b1214;
|
||||
--bg-1: #142126;
|
||||
--bg-2: rgba(14, 28, 32, 0.88);
|
||||
--panel-border: rgba(117, 182, 170, 0.2);
|
||||
--panel-shadow: rgba(0, 0, 0, 0.34);
|
||||
--text: #eef5ef;
|
||||
--muted: #96aba5;
|
||||
--accent: #8fe2c8;
|
||||
--accent-strong: #c2f59d;
|
||||
--warning: #f5b76b;
|
||||
--danger: #ff8172;
|
||||
--line: rgba(255, 255, 255, 0.09);
|
||||
--checker-a: rgba(255, 255, 255, 0.045);
|
||||
--checker-b: rgba(0, 0, 0, 0.12);
|
||||
--font-display: "Aptos Display", "Segoe UI Variable Display", "Trebuchet MS", sans-serif;
|
||||
--font-body: "Aptos", "Segoe UI Variable Text", "Verdana", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(143, 226, 200, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(245, 183, 107, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #081012 0%, #0e181b 100%);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1800px, calc(100vw - 32px));
|
||||
margin: 16px auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 22px 60px var(--panel-shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.masthead {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 3vw, 2.6rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-cluster {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 226, 200, 0.15);
|
||||
border: 1px solid rgba(143, 226, 200, 0.3);
|
||||
color: var(--accent);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
align-content: start;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
width: 100%;
|
||||
padding: 28px 16px;
|
||||
border: 1px dashed rgba(143, 226, 200, 0.38);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(143, 226, 200, 0.14), rgba(245, 183, 107, 0.08)),
|
||||
rgba(7, 16, 18, 0.45);
|
||||
border-radius: 20px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dropzone span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controls-grid label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls-grid span,
|
||||
.zoom-block label,
|
||||
.rgba-inputs span {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(5, 11, 13, 0.78);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(5, 11, 13, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toggle-row.compact {
|
||||
grid-column: auto;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button {
|
||||
border-radius: 16px;
|
||||
padding: 11px 16px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, #99ebc2, #f0bb75);
|
||||
color: #0a1011;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button.subdued {
|
||||
background: linear-gradient(135deg, rgba(153, 235, 194, 0.78), rgba(240, 187, 117, 0.78));
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info-stack,
|
||||
.candidate-list,
|
||||
.event-log,
|
||||
.metrics-panel,
|
||||
.summary-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.candidate-card,
|
||||
.event-item,
|
||||
.metric-chip,
|
||||
.summary-card {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.candidate-card strong,
|
||||
.event-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.viewer-panel,
|
||||
.editor-panel {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.viewer-head {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.scrubber {
|
||||
min-width: min(420px, 100%);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scrubber label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.step-value {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.loss-canvas {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(5, 11, 13, 0.62);
|
||||
}
|
||||
|
||||
.viewer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viz-card {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(5, 11, 13, 0.5);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viz-label {
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.viz-card canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(6, 14, 16, 0.72);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.metrics-panel {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.metric-chip strong,
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tool-hint {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(5, 11, 13, 0.45);
|
||||
}
|
||||
|
||||
.paint-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.paint-swatch {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.rgba-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rgba-inputs label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rgba-inputs input {
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
.zoom-block {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.checkerboard {
|
||||
background:
|
||||
linear-gradient(45deg, var(--checker-a) 25%, transparent 25%) 0 0 / 20px 20px,
|
||||
linear-gradient(-45deg, var(--checker-a) 25%, transparent 25%) 0 10px / 20px 20px,
|
||||
linear-gradient(45deg, transparent 75%, var(--checker-b) 75%) 10px -10px / 20px 20px,
|
||||
linear-gradient(-45deg, transparent 75%, var(--checker-b) 75%) -10px 0 / 20px 20px,
|
||||
rgba(5, 11, 13, 0.8);
|
||||
}
|
||||
|
||||
.editor-surface {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editor-surface canvas {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
#editorGridCanvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-meta {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.viewer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-cluster {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
13
src/repixelizer/observe.py
Normal file
13
src/repixelizer/observe.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class PipelineObserver(Protocol):
|
||||
def __call__(self, event: str, payload: dict[str, Any]) -> None: ...
|
||||
|
||||
|
||||
def emit_observer(observer: PipelineObserver | None, event: str, /, **payload: Any) -> None:
|
||||
if observer is None:
|
||||
return
|
||||
observer(event, payload)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -55,3 +55,18 @@ def test_default_command_accepts_fixed_lattice_flags(tmp_path: Path) -> None:
|
|||
)
|
||||
assert exit_code == 0
|
||||
assert output_path.exists()
|
||||
|
||||
|
||||
def test_gui_command_dispatches_to_gui_main(monkeypatch) -> None:
|
||||
called = {}
|
||||
|
||||
def fake_gui_main(*, host: str, port: int, reload: bool) -> int:
|
||||
called["host"] = host
|
||||
called["port"] = port
|
||||
called["reload"] = reload
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("repixelizer.cli.gui_main", fake_gui_main)
|
||||
exit_code = main(["gui", "--host", "127.0.0.1", "--port", "8123", "--reload"])
|
||||
assert exit_code == 0
|
||||
assert called == {"host": "127.0.0.1", "port": 8123, "reload": True}
|
||||
|
|
|
|||
37
tests/test_gui.py
Normal file
37
tests/test_gui.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from repixelizer.pipeline import run_pipeline_rgba
|
||||
from repixelizer.synthetic import fake_pixelize, make_emblem
|
||||
|
||||
|
||||
def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
|
||||
source = make_emblem(20, 20)
|
||||
fake = fake_pixelize(source, upscale=8, phase_x=0.15, phase_y=0.2, blur_radius=0.45, seed=3)
|
||||
events: list[str] = []
|
||||
|
||||
def observer(event: str, payload) -> None:
|
||||
del payload
|
||||
events.append(event)
|
||||
|
||||
run_pipeline_rgba(
|
||||
fake,
|
||||
target_width=20,
|
||||
target_height=20,
|
||||
phase_x=0.0,
|
||||
phase_y=0.0,
|
||||
steps=2,
|
||||
device="cpu",
|
||||
enable_phase_rerank=False,
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
assert events[:4] == [
|
||||
"source_loaded",
|
||||
"inference_candidates_ready",
|
||||
"analysis_completed",
|
||||
"phase_selection_completed",
|
||||
]
|
||||
assert "phase_field_prepared" in events
|
||||
assert "phase_field_initial" in events
|
||||
assert events.count("phase_field_step") == 2
|
||||
assert events[-3:] == ["cleanup_completed", "palette_completed", "pipeline_completed"]
|
||||
Loading…
Reference in a new issue