Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,34 @@ in a browser (see the `run`/`verify` skills for driving a browser check).

### Data flow

`index.html`'s inline module owns the single shared `state` object (images array, the `FlatRandomForest`
instance, current tool/class, camera transform) and wires up all top-level DOM event handlers. Every other
module is a set of functions that take `state` (or a specific image's entry in `state.images`) as an explicit
argument — there are no classes or singletons for app logic, only for the GPU backends and the `FlatRandomForest`.
`Quantosaurus` (`js/quantosaurus.js`) is the app core: a class (extending `EventTarget`) that owns the single
shared `state` object (images array, the `FlatRandomForest` instance, current tool/class, brush size, camera
transform) and attaches to a board element. It's the deliberate class exception alongside the GPU backends and
`FlatRandomForest`; every other module is still a set of functions that take `state` (or a specific image's
entry in `state.images`) as an explicit argument. The class is a thin wrapper: its methods call those module
functions and then dispatch a `CustomEvent` for the change. All UI is a *subscriber* — nothing reaches into
the app core's internals. `index.html`'s inline module constructs one instance, wires the taskbar controls to
its methods, and subscribes to its events; `js/ui.js:bindChrome(q)` subscribes the count badges / save
indicator / training cursor the same way. This is what makes the board embeddable (e.g. in a notebook): a host
page can `new Quantosaurus(div)` and `addEventListener` for results without any of the built-in chrome.

**Event catalog** (all `CustomEvent`s on the instance; see the header of `quantosaurus.js` for payload shapes):
`imageloadstart`/`imageadded`/`imageloaderror`/`imageremoved`/`imagereordered`, `labelschanged`, `dirtychange`,
`toolchanged`/`brushsizechanged`/`sigmachanged`/`classchanged`, `classcolorchanged`/`classnamechanged`/
`markersvisibilitychanged`, `trainingstart`/`trainingcomplete`, and `statscomputed` (carries per-class counts +
per-image detected objects). Modules dispatch through `state.events?.dispatchEvent(...)`, optional-chained so
the CPU unit tests can import `images.js`/`training.js` under Node without a dispatch target.

Per-image state (`state.images[i]`) carries: the loaded `intensityArray` (raw pixel values, not normalized),
its GPU `backend` instance, `labels` (sparse `{x, y, cls}` painted by the user), the display `windowLo`/`windowHi`
contrast bounds, and the DOM nodes for its canvas tile and sidebar row.
contrast bounds, and the DOM nodes for its canvas tile (the tile carries its own hover controls — reorder /
contrast / delete; there is no separate sidebar).

The core loop: user paints labels (`images.js:paint`) → `training.js:scheduleTraining` debounces
(`TRAIN_DEBOUNCE_MS`) → `trainAndPredictAll` gathers per-pixel features for every labeled pixel across all
images (`backend.gatherFeaturesForTraining`), trains one `FlatRandomForest` shared across all images, then
reruns `backend.runInference` per image, then recomputes per-class object counts via connected-component
labeling + stats for the sidebar badges.
labeling + stats, emitted as `statscomputed` for the count chip.

### GPU backend interface

Expand Down
590 changes: 309 additions & 281 deletions index.html

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ export const RF_CONFIG = { numTrees: 8, maxDepth: 8, numClasses: NUM_CLASSES };
// Random Forest's feature stride is keyed off this value.
export const NUM_FEATURES = 8;
export const MIN_LABELS_TO_TRAIN = 5;

// Brush radius bounds (in image pixels) shared by the brush control and the
// [ / ] hotkeys. The paint handlers read the current size off state.brushSize;
// these constants are the single source of truth for its valid range.
export const BRUSH_SIZE_MIN = 1;
export const BRUSH_SIZE_MAX = 32;
export const BRUSH_SIZE_DEFAULT = 5;

/**
* Clamps a requested brush size into [BRUSH_SIZE_MIN, BRUSH_SIZE_MAX], coercing
* non-numeric input to the default. Pure — shared by Quantosaurus.setBrushSize
* and any UI control that needs to sanitize user input before applying it.
* @param {*} size - Requested brush size (any type; coerced via Number()).
* @returns {number} An integer size within the valid range.
*/
export function clampBrushSize(size) {
const n = Math.round(Number(size));
if (!Number.isFinite(n)) return BRUSH_SIZE_DEFAULT;
return Math.min(BRUSH_SIZE_MAX, Math.max(BRUSH_SIZE_MIN, n));
}
export const CAMERA_ZOOM_MIN = 0.1;
export const CAMERA_ZOOM_MAX = 32;
export const CAMERA_ZOOM_SENSITIVITY = 0.01;
Expand Down
35 changes: 33 additions & 2 deletions js/config.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Tests for the pure config constants in config.js.
// Tests for the pure config constants and helpers in config.js.
// Dependency-free — run with: node js/config.test.mjs
import { NUM_CLASSES, DEFAULT_LABEL_COLORS, RF_CONFIG } from './config.js';
import {
NUM_CLASSES, DEFAULT_LABEL_COLORS, RF_CONFIG,
BRUSH_SIZE_MIN, BRUSH_SIZE_MAX, BRUSH_SIZE_DEFAULT, clampBrushSize,
} from './config.js';

let failures = 0;
function assert(cond, msg) {
Expand Down Expand Up @@ -32,5 +35,33 @@ function assert(cond, msg) {
);
}

// ---- Test 4: brush-size bounds are a sane, ordered range containing the default ----
{
assert(
BRUSH_SIZE_MIN <= BRUSH_SIZE_DEFAULT && BRUSH_SIZE_DEFAULT <= BRUSH_SIZE_MAX,
`BRUSH_SIZE_DEFAULT within [min, max] (${BRUSH_SIZE_MIN} <= ${BRUSH_SIZE_DEFAULT} <= ${BRUSH_SIZE_MAX})`
);
}

// ---- Test 5: clampBrushSize clamps, rounds, and rejects garbage ----
{
assert(clampBrushSize(BRUSH_SIZE_DEFAULT) === BRUSH_SIZE_DEFAULT,
`in-range size passes through (got ${clampBrushSize(BRUSH_SIZE_DEFAULT)})`);
assert(clampBrushSize(0) === BRUSH_SIZE_MIN,
`below-min clamps to BRUSH_SIZE_MIN (got ${clampBrushSize(0)})`);
assert(clampBrushSize(-5) === BRUSH_SIZE_MIN,
`negative clamps to BRUSH_SIZE_MIN (got ${clampBrushSize(-5)})`);
assert(clampBrushSize(BRUSH_SIZE_MAX + 100) === BRUSH_SIZE_MAX,
`above-max clamps to BRUSH_SIZE_MAX (got ${clampBrushSize(BRUSH_SIZE_MAX + 100)})`);
assert(clampBrushSize(4.6) === 5,
`fractional sizes round to the nearest integer (got ${clampBrushSize(4.6)})`);
assert(clampBrushSize('12') === 12,
`numeric strings coerce (got ${clampBrushSize('12')})`);
assert(clampBrushSize('garbage') === BRUSH_SIZE_DEFAULT,
`non-numeric input falls back to the default (got ${clampBrushSize('garbage')})`);
assert(clampBrushSize(NaN) === BRUSH_SIZE_DEFAULT,
`NaN falls back to the default (got ${clampBrushSize(NaN)})`);
}

console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} FAILURES`);
process.exit(failures === 0 ? 0 : 1);
154 changes: 100 additions & 54 deletions js/images.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@ import { RF_CONFIG, CENTROID_OVERLAY } from './config.js';
import { WebGpuBackend } from './backends/webgpu.js';
import { WebGl2Backend } from './backends/webgl2.js';
import { loadFileIntoArray } from './io.js';
import { createImageRow, syncUI, updateSaveIndicator } from './ui.js';
import { openContrastPopover } from './contrast.js';
import { scheduleTraining } from './training.js';
import { openContrastPopover } from './contrast.js';

// Dispatches a CustomEvent on the owning Quantosaurus instance (state.events).
// Optional-chained so state objects without a dispatch target (unit tests,
// plain-object fixtures) keep working — events are strictly additive here.
function emit(state, name, detail) {
state.events?.dispatchEvent(new CustomEvent(name, { detail }));
}

// Flips the dirty flag, emitting dirtychange only on the false->true
// transition — paint() is the hottest path in the app (fires on every
// mousemove while dragging), and dirty stays true for the rest of a typical
// painting session.
function markDirty(state) {
if (state.dirty) return;
state.dirty = true;
emit(state, 'dirtychange', { dirty: true });
}


/**
Expand Down Expand Up @@ -44,6 +60,36 @@ async function initializeBackend(canvas, labelColors) {
throw new Error("No compatible rendering backend found.");
}

/**
* Builds the floating controls overlaid on one image tile: reorder left/right,
* contrast, and delete. Buttons call the image-lifecycle functions directly
* with `state` (the tile owns them, like the paint handlers) — the contrast
* button anchors its popover to itself.
* @param {Object} state - Shared app state.
* @param {string} imgId - Id of the image this tile represents.
* @returns {HTMLDivElement} The controls overlay (not yet attached).
*/
function buildTileControls(state, imgId) {
const wrap = document.createElement('div');
wrap.className = 'tile-controls';

const mk = (cls, glyph, title, onClick) => {
const b = document.createElement('button');
b.className = `tile-btn ${cls}`;
b.textContent = glyph;
b.title = title;
b.onclick = (e) => { e.stopPropagation(); onClick(b); };
return b;
};

wrap.appendChild(mk('tile-reorder', '‹', 'Move earlier', () => reorderImage(state, imgId, -1)));
wrap.appendChild(mk('tile-reorder', '›', 'Move later', () => reorderImage(state, imgId, +1)));
wrap.appendChild(mk('tile-contrast', '◐', 'Adjust contrast', (btn) => openContrastPopover(state, imgId, btn)));
wrap.appendChild(mk('tile-delete', '✕', 'Remove image', () => deleteImage(state, imgId)));

return wrap;
}

/**
* Adds a batch of dropped/selected files, skipping unsupported types and
* duplicates (matched by name + size).
Expand All @@ -63,38 +109,33 @@ export async function addFiles(state, files) {
}

/**
* Loads a single image file, builds its sidebar row and canvas tile, spins up a
* backend, computes initial features, and registers the paint handlers. Bails
* out (cleaning up the partial DOM) on load failure or non-2D images.
* Loads a single image file, builds its canvas tile, spins up a backend,
* computes initial features, and registers the paint handlers. Emits
* imageloadstart at the top, then either imageadded on success or
* imageloaderror (cleaning up any partial DOM) on load failure, non-2D
* images, or backend-init failure — chrome tracks load progress off these.
* @param {Object} state - Shared app state; the new image is pushed to state.images.
* @param {File} file - The image file to load.
*/
export async function addImage(state, file) {
const imgId = crypto.randomUUID();
const row = createImageRow(imgId, file.name, {
onReorder: (dir) => reorderImage(state, imgId, dir),
onDelete: () => deleteImage(state, imgId),
onContrast: (anchor) => openContrastPopover(state, imgId, anchor),
});
row.classList.add('loading');
document.getElementById('img-empty').style.display = 'none';
document.getElementById('image-list').appendChild(row);
emit(state, 'imageloadstart', { id: imgId, name: file.name });

let loaded;
try {
loaded = await loadFileIntoArray(file);
} catch (err) {
console.error(err);
alert(`Failed to load ${file.name}: ${err.message}`);
row.remove();
syncUI(state);
emit(state, 'imageloaderror', {
id: imgId, name: file.name, reason: 'load-failed',
message: `Failed to load ${file.name}: ${err.message}`,
});
return;
}

if (loaded.shape.length > 2) {
console.warn(`Only 2D images are currently supported. ${file.name} has shape (${loaded.shape})`);
row.remove();
syncUI(state);
emit(state, 'imageloaderror', { id: imgId, name: file.name, reason: 'unsupported-dimensions' });
return;
}

Expand Down Expand Up @@ -124,21 +165,26 @@ export async function addImage(state, file) {
tileLabel.className = 'image-tile-label';
tileLabel.textContent = `${file.name} ${w}x${h}`;

// Per-tile controls (reorder ‹ ›, contrast ◐, delete ✕) — revealed on hover
// (or always, on coarse pointers; see .tile-controls in style.css). These
// replace the old sidebar row's buttons now that the board is the only view.
const controls = buildTileControls(state, imgId);

container.appendChild(gpuCanvas);
container.appendChild(labelCanvas);
container.appendChild(overlayCanvas);
container.appendChild(tileLabel);
document.getElementById('canvas-board').appendChild(container);
container.appendChild(controls);
state.board.appendChild(container);

let backend = null;

try {
backend = await initializeBackend(gpuCanvas, state.labelColors)
} catch (err) {
console.error(err);
container.remove();
row.remove();
syncUI(state);
emit(state, 'imageloaderror', { id: imgId, name: file.name, reason: 'no-backend' });
return;
}
await backend.allocateImage(w, h, intensityArray, range);
Expand All @@ -160,10 +206,9 @@ export async function addImage(state, file) {
labels: [],
gpuCanvas, labelCanvas, overlayCanvas, container,
_cachedRect: null,
_sidebarRow: row,
};
state.images.push(imgState);
state.dirty = true;
markDirty(state);

// Pointer events unify mouse/touch/pen. Only the primary pointer paints
// (the first touch contact, or the mouse) — a second simultaneous touch is
Expand All @@ -175,23 +220,26 @@ export async function addImage(state, file) {
imgState._cachedRect = labelCanvas.getBoundingClientRect();
state.isDrawing = true;
state.activeImageId = imgId;
const radius = parseInt(document.getElementById('brushSizeRange').value, 10);
paint(state, imgState, e, radius);
paint(state, imgState, e, state.brushSize);
});
labelCanvas.addEventListener('pointermove', (e) => {
if (!e.isPrimary) return;
if (state.isSpaceDown) return;
if (state.activePointerCount > 1) return; // a second finger joined — pause the stroke
if (state.isDrawing && state.activeImageId === imgId && state.toolMode !== 'grab') {
const radius = parseInt(document.getElementById('brushSizeRange').value, 10);
paint(state, imgState, e, radius);
paint(state, imgState, e, state.brushSize);
}
});
function endStroke(e) {
if (!e.isPrimary) return;
if (state.activeImageId === imgId) {
state.isDrawing = false;
state.activeImageId = null;
emit(state, 'labelschanged', {
id: imgId,
labelCount: imgState.labels.length,
totalLabels: state.images.reduce((s, i) => s + i.labels.length, 0),
});
scheduleTraining(state);
}
}
Expand All @@ -201,14 +249,16 @@ export async function addImage(state, file) {
const ro = new ResizeObserver(() => { imgState._cachedRect = null; });
ro.observe(labelCanvas);

row.classList.remove('loading');

syncUI(state);
emit(state, 'imageadded', {
id: imgId, name: file.name, width: w, height: h,
index: state.images.length - 1,
});
}

/**
* Moves an image one slot up or down, keeping state.images, the canvas tiles,
* and the sidebar rows in the same visual order (which is also export order).
* Moves an image one slot up or down, keeping state.images and the canvas
* tiles in the same visual order (which is also export order). Emits
* imagereordered so chrome tracking image order can follow.
* @param {Object} state - Shared app state.
* @param {string} imgId - Id of the image to move.
* @param {-1|1} direction - -1 to move up, +1 to move down.
Expand All @@ -221,22 +271,15 @@ export function reorderImage(state, imgId, direction) {

[state.images[idx], state.images[newIdx]] =
[state.images[newIdx], state.images[idx]];
state.dirty = true; // changes export lane order
updateSaveIndicator(state);
markDirty(state); // changes export lane order

const board = document.getElementById('canvas-board');
const tiles = [...board.children];
const tiles = [...state.board.children];
const a = tiles[idx];
const b = tiles[newIdx];
if (direction === -1) board.insertBefore(a, b);
else board.insertBefore(b, a);

const list = document.getElementById('image-list');
const rows = [...list.children];
const ra = rows[idx];
const rb = rows[newIdx];
if (direction === -1) list.insertBefore(ra, rb);
else list.insertBefore(rb, ra);
if (direction === -1) state.board.insertBefore(a, b);
else state.board.insertBefore(b, a);

emit(state, 'imagereordered', { id: imgId, fromIndex: idx, toIndex: newIdx });
}

/**
Expand All @@ -262,13 +305,19 @@ export function deleteImage(state, imgId) {

imgState.backend.destroy();
imgState.container.remove();
imgState._sidebarRow.remove();
state.images.splice(idx, 1);
state.dirty = true;
markDirty(state);

if (labelCount > 0) scheduleTraining(state);
emit(state, 'imageremoved', { id: imgId, index: idx });

syncUI(state);
if (labelCount > 0) {
emit(state, 'labelschanged', {
id: imgId,
labelCount: 0,
totalLabels: state.images.reduce((s, i) => s + i.labels.length, 0),
});
scheduleTraining(state);
}
}

/**
Expand Down Expand Up @@ -405,10 +454,7 @@ export function paint(state, imgState, e, radius) {

const pixels = getPixelsInRadius(x, y, radius, imgState.width, imgState.height);
if (pixels.length === 0) return;
// Only touch the DOM on the false->true transition — paint() is the
// hottest path in the app (fires on every mousemove while dragging), and
// dirty stays true for the rest of a typical painting session.
if (!state.dirty) { state.dirty = true; updateSaveIndicator(state); }
markDirty(state);

const pixelSet = new Set(pixels.map(p => `${p.x},${p.y}`));
imgState.labels = imgState.labels.filter(lbl => !pixelSet.has(`${lbl.x},${lbl.y}`));
Expand Down
Loading