Skip to content
Merged
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
103 changes: 103 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-05-22

Python-parity fixes plus quality-of-life additions surfaced by a code review
against `cellpose/{transforms,dynamics,models,core}.py`. Output of `segment()`
may differ from `0.1.1` — values are closer to the upstream reference. Snapshot
or regression-pinned consumers should review.

### Fixed

- **Preprocess order.** Normalize now runs **before** resize, matching
`models.py:_run_net`. Resizing first would change the per-channel percentile
statistics that drive normalization.
- **Float32 bilinear resize.** Replaced the canvas-based `diameterResize`,
which quantized each channel to uint8 (~1/255 per-pixel error) and required
`OffscreenCanvas`/`HTMLCanvas`, with a pure-JS implementation using OpenCV's
`INTER_LINEAR` pixel-center mapping + edge replication. Removes the canvas
dependency entirely and is now testable in Node.
- **`niter` scaling.** Now computed as `floor(200 / scale)` when `diameter` is
set, matching Python's `int(200 / image_scaling)`. Upscaled images get fewer
iterations, downscaled get more. Explicit user-supplied `niter` still wins.
- **`follow_flows` rounding.** Switched the final coordinate cast from
`Math.round` to `Math.trunc` to match PyTorch's `.int()`, which truncates
toward zero (it's a C-style cast). The 1-pixel shift previously split two
small overlap-zone labels off their Python counterparts in the
`three_cells_192` fixture.
- **README quickstart example.** `cellprob_threshold: 0` at the top level was
silently ignored — the actual API nests it under `dynamics:
{ cellprobThreshold }`. Also removed a stale "once published in M6
follow-up" parenthetical referencing the model URL.
- **Worker init errors no longer dropped.** `worker.onerror`,
`worker.messageerror`, and worker-side `error` messages received before
the first tile now reject the `_workerReady` promise; previously they were
only routed to pending-tile promises (empty during init) and disappeared
silently.

### Added

- **`FromPretrainedOptions.signal` now cancels the preload phase.** Previously
the abort signal was only honored during fetch and `segment()`; if the user
aborted while ORT was creating its session, nothing happened. Now an abort
during `_ensureWorker()` terminates the worker and rejects with
`AbortError`.
- **`FromPretrainedOptions.onStatus`.** New optional callback that receives
pre-ready phase strings from the inference worker (`'worker module loaded'`,
`'init message received'`, `'configuring ORT (loading WASM sidecars)'`,
`'creating ORT session (parsing 588 MB ONNX graph)'`, `'session created in
<ms> ms; describing adapter'`). Useful for showing a determinate status
during the slow first-run worker init.
- **Test coverage**: 14 → 61 tests across 11 files. New direct-coverage suites
for `buildCpsamChannels`, `percentile`, `taperMask`, `followFlows`,
`getMasks`, the new bilinear `diameterResize`, plus an end-to-end
postprocess pipeline test that mocks the inference step.

### Changed

- **Dynamics parity gate.** The `three_cells_192` fixture's mean-IoU gate
tightened from 0.55 → 0.99 (was a known divergence, now matches Python at
1.000 with all 5 per-cell IoUs at 1.000).
- **Demo image picker** surfaces errors visibly instead of silently failing.
`createImageBitmap`'s catch path now nudges users toward PNG/JPEG (TIFF
microscopy images aren't natively decoded by any browser).
- **Demo Vite dev mode** now correctly loads the inference worker. Vite's
worker plugin transforms `new URL('./inference.worker.js', ...)` to a URL
ending in `.js?worker_file`, but the source file on disk is `.ts` and the
worker plugin doesn't auto-swap extensions. Demo's `vite.config.ts` now
ships a small middleware that rewrites `.js?worker_file` →
`.ts?worker_file` when the `.ts` source exists. Dev-only; production builds
unaffected.

### Internal

- Added `'status'` message type to the worker → main thread protocol.
- Removed canvas dependency from `src/preprocess/resize.ts`.

## [0.1.1] — 2026-05-15

Patch release.

### Fixed

- README and docs: corrected Hugging Face Hub model URL to
`ballon999/cellpose-sam-onnx` and normalized URL casing to lowercase across
`docs/PLAN.md`, `docs/MILESTONE7-RESULTS.md`, and `examples/demo/index.html`.

## [0.1.0] — 2026-05-15

Initial public release. End-to-end browser segmentation pipeline:
preprocessing, IndexedDB-cached model load, WebGPU inference in a Web Worker
with `AbortSignal` cancellation, tile averaging, flow dynamics, full-image
label maps. See [`docs/PLAN.md`](./docs/PLAN.md) for the full milestone trail
and [`docs/STAGE0-RESULTS.md`](./docs/STAGE0-RESULTS.md) for parity / latency
measurements.

[0.2.0]: https://github.com/belkassaby/Cellpose.js/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/belkassaby/Cellpose.js/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/belkassaby/Cellpose.js/releases/tag/v0.1.0
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ npm install cellpose-js onnxruntime-web

You also need to host:

1. **The model**: `cpsam_fp16.onnx` (588 MB). Either upload to your own CDN, or use the public copy at `https://huggingface.co/ballon999/cellpose-sam-onnx/resolve/main/cpsam_fp16.onnx` (once published in M6 follow-up).
1. **The model**: `cpsam_fp16.onnx` (588 MB). Either upload to your own CDN, or use the public copy at `https://huggingface.co/ballon999/cellpose-sam-onnx/resolve/main/cpsam_fp16.onnx`.
2. **ORT-web's WASM/JSEP sidecars**: ORT dynamically imports `.mjs` and `.wasm` files at runtime. They must be served **same-origin** with your app (cross-origin dynamic `import()` is blocked). Either copy `node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.{wasm,mjs,jsep.wasm,jsep.mjs,asyncify.wasm,asyncify.mjs}` to your public assets, or proxy `/ort/*` to jsDelivr at build time (see `examples/demo/vite.config.ts` for the recipe).

## Quickstart
Expand All @@ -60,9 +60,9 @@ const result = await cp.segment(
{ data: imageData.data, width: imageData.width, height: imageData.height, channels: 4 },
{
diameter: 30, // estimated cell diameter in source pixels (omit for native resolution)
cellprob_threshold: 0,
chan: 0, // primary channel (0 = grayscale)
chan2: 0, // secondary channel (0 = none)
dynamics: { cellprobThreshold: 0 }, // pixels above this enter the dynamical system
onTileProgress: (done, total) => console.log(`tile ${done}/${total}`),
},
);
Expand Down
53 changes: 41 additions & 12 deletions examples/demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,34 @@ const fmt = (ms: number) => (ms < 1000 ? `${ms.toFixed(0)} ms` : `${(ms / 1000).
let cp: Cellpose | null = null;
let currentImage: SegmentInput | null = null;

async function ensureModel(): Promise<Cellpose> {
async function ensureModel(signal?: AbortSignal): Promise<Cellpose> {
if (cp) return cp;
const modelUrl = $('modelUrl').value;
const preload = ($('preload') as HTMLInputElement).checked;
const bypassCache = ($('bypassCache') as HTMLInputElement).checked;
log(`fromPretrained('${modelUrl}', { preload: ${preload}, bypassCache: ${bypassCache} })...`);
const t0 = performance.now();
let lastPctLogged = -1;
cp = await Cellpose.fromPretrained(modelUrl, {
const fetchT0 = { v: 0 };
const opts: Parameters<typeof Cellpose.fromPretrained>[1] = {
preload,
bypassCache,
onProgress: ({ loaded, total }) => {
if (!total) return;
const pct = Math.floor((loaded / total) * 100);
if (pct >= lastPctLogged + 10 || pct === 100) {
log(` fetch: ${pct}%`);
log(` fetch: ${pct}% (${fmt(performance.now() - t0)})`);
lastPctLogged = pct;
if (pct === 100) fetchT0.v = performance.now();
}
},
});
onStatus: (s) => {
const since = fetchT0.v ? ` (+${fmt(performance.now() - fetchT0.v)} since fetch:100%)` : '';
log(` worker: ${s}${since}`);
},
};
if (signal) opts.signal = signal;
cp = await Cellpose.fromPretrained(modelUrl, opts);
log(`model ready in ${fmt(performance.now() - t0)}`);
return cp;
}
Expand Down Expand Up @@ -156,7 +164,19 @@ function drawHeatmap(canvas: HTMLCanvasElement, data: Float32Array, w: number, h
}

async function loadImageFile(file: File): Promise<SegmentInput> {
const bitmap = await createImageBitmap(file);
// `createImageBitmap` is the fast path (JPEG, PNG, WebP, GIF, BMP, ICO,
// SVG in most browsers). Microscopy users often have TIFF — that format
// isn't decoded natively by either createImageBitmap or HTMLImageElement,
// so we surface a clear error in the caller's try/catch.
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch (err) {
throw new Error(
`couldn't decode ${file.type || file.name}: ${(err as Error).message}. ` +
`Browsers don't natively decode TIFF — convert to PNG or JPEG first.`,
);
}
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
Expand Down Expand Up @@ -200,7 +220,9 @@ async function run() {
($('abort') as HTMLButtonElement).disabled = false;
activeAbort = new AbortController();
try {
const model = await ensureModel();
// Pass the signal so abort also cancels a stuck model load (fetch, IDB
// write, or ORT session create), not just segment().
const model = await ensureModel(activeAbort.signal);
const adapter = await model.describeAdapter();
log(`adapter: vendor=${adapter?.vendor} arch=${adapter?.architecture}`);

Expand Down Expand Up @@ -261,12 +283,19 @@ async function run() {
$('imageFile').addEventListener('change', async (ev) => {
const f = (ev.target as HTMLInputElement).files?.[0];
if (!f) return;
currentImage = await loadImageFile(f);
drawToCanvas($('inputCanvas') as HTMLCanvasElement, currentImage);
($('go') as HTMLButtonElement).disabled = false;
log(
`loaded ${f.name}: ${currentImage.width}x${currentImage.height}, ch=${currentImage.channels}`,
);
try {
currentImage = await loadImageFile(f);
drawToCanvas($('inputCanvas') as HTMLCanvasElement, currentImage);
($('go') as HTMLButtonElement).disabled = false;
log(
`loaded ${f.name} (${f.type || 'unknown type'}): ` +
`${currentImage.width}x${currentImage.height}, ch=${currentImage.channels}`,
);
} catch (err) {
const e = err as Error;
log(`failed to load ${f.name}: ${e.message}`, 'fail');
console.error(err);
}
});
$('useSynthetic').addEventListener('click', () => {
currentImage = makeSynthetic();
Expand Down
38 changes: 37 additions & 1 deletion examples/demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import fs from 'node:fs';

/**
* The model itself is served from examples/demo/public/cpsam_fp16.onnx
Expand All @@ -14,7 +15,42 @@ import { defineConfig } from 'vite';
* also blocked. Solution: proxy /ort/* to jsdelivr — the browser sees
* same-origin URLs while bytes come from jsdelivr's CORS-enabled CDN.
*/

/**
* Vite's worker plugin transforms `new URL('./inference.worker.js',
* import.meta.url)` into a URL ending in `inference.worker.js?worker_file`.
* The library source uses `.js` so the tsc-built prod artifact resolves
* correctly. But in dev mode the file on disk is `.ts`, and the worker
* plugin doesn't auto-swap extensions like the regular resolver does — the
* request 404s, then falls through to the SPA fallback (200 OK index.html),
* and the browser silently fails to parse HTML as a JS module.
*
* This middleware rewrites `…/foo.js?worker_file…` → `…/foo.ts?worker_file…`
* when the `.ts` file exists on disk. Dev-only; the prod build path is
* unaffected.
*/
const remapWorkerTsExt = (): Plugin => ({
name: 'cellpose-demo-remap-worker-ts-ext',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const url = req.url;
if (url && url.includes('worker_file') && url.includes('.js?')) {
const [pathPart, queryPart = ''] = url.split('?');
if (pathPart && pathPart.endsWith('.js')) {
const tsPath = pathPart.replace(/\.js$/, '.ts');
const onDisk = tsPath.replace(/^\/@fs/, '');
if (fs.existsSync(onDisk)) {
req.url = `${tsPath}?${queryPart}`;
}
}
}
next();
});
},
});

export default defineConfig({
plugins: [remapWorkerTsExt()],
server: {
proxy: {
'/ort': {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cellpose-js",
"version": "0.1.1",
"version": "0.2.0",
"description": "Browser-side cellular segmentation via Cellpose-SAM, running on WebGPU.",
"type": "module",
"license": "MIT",
Expand Down
Loading
Loading