Browser-side Cellpose-SAM via a new Cellpose.js package, consumed by jit-ui as a new client-side operation in the processing pipeline.
Scope decisions (locked in):
- Scope is port-only — port stock CPSAM to the browser. No SlimSAM-style compression, no domain-specialized finetunes.
- WebGPU required — ship FP16, no WASM fallback in v1.
- Separate repo
Cellpose.js— not an Nx lib inside jit-ui.
| Stage / Milestone | Status | Commit |
|---|---|---|
| Stage 0 — spike gates (export parity + WebGPU latency) | ✅ PASS — see STAGE0-RESULTS.md |
(pre-repo scratch rig) |
| M1 — repo skeleton, fetch + IDB cache, ORT-WebGPU session | ✅ DONE — MILESTONE1-RESULTS.md |
a0c1955 |
| M2 — preprocess (normalize, channels, resize, tile) | ✅ DONE — MILESTONE2-RESULTS.md |
3035fdf |
| M3 — inference worker, AbortSignal, tile progress | ✅ DONE — MILESTONE3-RESULTS.md |
741f340 |
| M4 — flow dynamics postprocessing (Euler + cluster + filter) | ✅ DONE (algo) — MILESTONE4-RESULTS.md |
ddd5dc7 |
| M5 — tile coherence (pivoted to averaging-then-single-dynamics) | ✅ DONE — MILESTONE5-RESULTS.md |
8829838 |
| M6 — package polish + HF Hub upload + GitHub publish | ✅ DONE (no npm publish — local-only per scope) | df1218d, 08e8564, a5e8319, 73a3eb7 |
| M7 — jit-ui integration | ✅ DONE — MILESTONE7-RESULTS.md |
(jit-ui local; not committed per scope) |
- FP16 parity vs Python: worst max abs err 1.24e-05 (gate 1e-3).
- Steady-state inference: 277 ms / 256×256 tile.
- Postprocess (averaging + dynamics on full image): ~75 ms for a 400×400 input.
- Cold start: ~2.3 s shader compile + 1.3 s session create + ~5 s 588 MB fetch (cache hit thereafter).
- 14/14 vitest parity tests pass: preprocess (7) + dynamics (3) + averaging round-trip (4).
- GitHub: https://github.com/belkassaby/Cellpose.js (public, MIT).
mainprotected — all changes via PR. - npm:
cellpose-js@0.1.1(latest). v0.1.0 superseded. Both releases ship with signed sigstore provenance linking each tarball back to the GitHub Actions run that built it. - Model on HF Hub: https://huggingface.co/ballon999/cellpose-sam-onnx (public; 588 MB FP16 ONNX). Note the HF account is
Ballon999(canonical case), notbelkassabyas earlier drafts of this plan suggested — both belong to the same person. - CI / CD:
.github/workflows/ci-cd.yaml. Every push tomain/release/**and every PR runs: typecheck → eslint → prettier --check → vitest → build. Pushing av*.*.*tag whose commit is reachable frommainorrelease/x.y.ztriggersnpm publish --provenance. Badges in the README. - jit-ui consumer: new
cellpose-engine.tsregistered alongside the existing engines;cellpose-segmentop visible in the pipeline dialog. Dependency now referencescellpose-js: ^0.1.0from the npm registry (no longer the localfile:symlink that bootstrapped M7).
The day v0.1.0 went out, three M6-follow-up commits landed:
- CI / CD pipeline —
.github/workflows/ci-cd.yamlwith a CI job (typecheck, eslint, prettier --check, vitest, build) and a guarded Publish job that requires the tag's commit to be reachable from arelease/x.y.zbranch ormainand the tag version to matchpackage.json. ESLint 9 (flat config) + Prettier 100-col single-quote semicolon style adopted; whole codebase normalized to that baseline in one commit. README badges added (CI status, npm version, license, TypeScript strict, WebGPU runtime). - Test fixtures tracked in-repo. The
tests/fixtures*directories were previously symlinked into a scratch rig at~/cellpose-js-spike/and gitignored. That worked on the dev machine but the CI runner couldn't follow the dangling symlinks. Moved the 27 files (~25 MB) into the repo. They stay out of the npm tarball (package.json#filesrestricts publishing todist/+README+LICENSE). - npm provenance metadata. First publish attempt failed at the registry with HTTP 422:
Failed to validate repository information: package.json: "repository.url" is "", expected to match "https://github.com/belkassaby/Cellpose.js" from provenance. npm pins the publishedrepository.urlagainst the build's GitHub origin recorded in the provenance bundle. Addedrepository,homepage, andbugsfields. Re-tagged v0.1.0 at the new commit; publish succeeded.
- README HF URL fix. v0.1.0's bundled README pointed at
belkassaby/cellpose-sam-onnx— a non-existent HF account. v0.1.1 corrects this toballon999/cellpose-sam-onnxand lowercases all URL references acrossdocs/PLAN.md,docs/MILESTONE7-RESULTS.md, andexamples/demo/index.htmlfor consistency. No code or API changes.
- PR-only main. Branch protection turned on after the initial bootstrap. Every commit on
mainsince has come through a PR. Three PRs to date: #1 (provenance metadata), #2 (HF URL fix), #3 (v0.1.1 bump). All chores; no feature changes. - Release ritual. Cut
release/x.y.zoffmain(no commits on the release branch — it's just a marker), annotate-and-pushvx.y.zfrom the release branch's HEAD. CI re-runs from the tag's perspective, verifies branch + version, publishes. Total time after the bump PR merges: ~2 minutes.
- M5 algorithm change. Plan called for per-tile dynamics + IoU-based label-merging stitcher. While porting Python's
cellpose.dynamics, discovered that Python usestransforms.average_tilesto weighted-average per-tile predictions BEFORE dynamics, then runs dynamics once on the full image. After asymptotic analysis (same O(H·W·niter), better constants, smoother boundaries), pivoted to mirror Python's approach. Net code reduction (~110 LOC vs ~250 planned). - M6 build switch. Plan called for Vite library mode. The Vite output emitted absolute worker URLs (
/assets/inference.worker-XXXX.js) that webpack-based consumers (jit-ui) couldn't resolve. Switched the publish build to plaintscsonew Worker(new URL('./inference.worker.js', import.meta.url))stays relative and any modern bundler resolves it. Vite is still used for the demo dev server. - M3 worker bundling. Library-mode emitted a 63 MB worker chunk (inlined ort-web). M6 added
worker.rollupOptions.externalto keep it under 5 KB; consumers now bring their ownonnxruntime-webpeer. - ORT version drift. Plan specified ORT-web 1.20.1.
npm installresolved^1.20.1to 1.26.0, which turned out to be ~2.3× faster on the WebGPU kernels (277 ms vs 628 ms). We kept it and re-pinned~1.26.0to lock the version against the demo's proxy URL. - HF Hub account. Plan referenced
belkassaby/cellpose-sam-onnx; actual upload lives atballon999/cellpose-sam-onnx(same person, different HF username). All references in this plan and downstream code use the actual URL. - M7 added UX work beyond the plan. While integrating into jit-ui's pipeline dialog, we added: a determinate progress bar for the 588 MB cold download (replaces the indeterminate spinner), per-phase status messages (
Preprocessing image…,Running inference (tile 3 / 9)…,Computing flow dynamics…), andrequiresManualRungating on the operation descriptor so heavy ops show a "Run" button instead of re-executing on every param change. The descriptor flag also tags every existingtransformers-jsop (which had the same auto-rerun problem). - M4/M5 IoU rig deferred. Plan exit criteria asked for mean IoU ≥ 0.9 (M4) and ≥ 0.85 (M5) against a 20-real-CPSAM-image reference set. The synthetic fixtures we built validate the algorithms (single-cell IoU 1.000, empty 1.000, three-cells 0.601 due to overlap-zone noise). The 20-image real-output rig is filed as a Phase 1 follow-up rather than a blocker; jit-ui browser verification is already producing visually-sensible masks on real microscopy images.
Stage 0 status: PASS (2026-05-14, run on M1 Max). Full run report:
STAGE0-RESULTS.md.
- Spike A: worst FP32 parity error 1.24e-05 vs gate 1e-3.
- Spike B: median 628 ms/tile WebGPU vs gate 2 s; cold start ~2.3 s.
- Deployment artifact: 588 MB single-file FP16 ONNX with FP16 graph IO.
From cellpose.vit_sam and the Cellpose-SAM preprint (bioRxiv 2025.04.28):
- Backbone: SAM ViT-L image encoder, modified:
- patch size 16 → 8 (with weight downsampling
w[:,:,::16//ps,::16//ps]) window_size = 0on every block — all attention is global, no windowed attention- positional embeddings subsampled to match the new patch size
- patch size 16 → 8 (with weight downsampling
- Head: very small dense regression head —
Conv2d(256, nout·ps²)then a transposed conv that unfolds tokens back to pixels.nout = 3channels: flow_y, flow_x, cellprob. - No prompt encoder. No mask decoder. CPSAM is not promptable. It is a dense per-pixel regressor that reuses SAM weights as initialization.
- Weights: single ~1.23 GB PyTorch checkpoint at
mouseland/cellpose-samon Hugging Face. - Training: tiles around
bsize = 256, diameters 7.5–120 px, 2D only. - Post-processing (Python today): flow-field Euler integration → pixel convergence clustering → connected components → size / flow-consistency filtering. NumPy + SciPy + Numba + a touch of OpenCV. ~500 LOC of real algorithm, no model.
- SlimSAM (Chen et al., NeurIPS 2024) is channel-pruned + distilled SAM-ViT-B plus the standard SAM prompt encoder and SAM mask decoder.
Xenova/slimsam-50-uniformis the ONNX export already wired into Transformers.js (jit-ui already uses the siblingXenova/sam-vit-baseintransformers.worker.ts:238). - It is promptable mask generation, not dense regression. Outputs are
pred_masks+iou_scores, giveninput_points/input_boxes. - It is not a drop-in replacement for CPSAM's head. Plugging SlimSAM in where CPSAM goes would produce per-prompt mask candidates, not flow-derived instance masks. Making a SlimSAM-style CPSAM would require either (a) pruning CPSAM's ViT-L while keeping its flow head, or (b) training a new flow head on top of SlimSAM's encoder. Both are multi-month ML work, out of scope for this package.
- ONNX exportability of CPSAM encoder. Architecture is standard ViT ops + global attention. The only non-export-friendly bits (
torch.rand/linspacefor stochastic depth, dropout) fire only in.train()mode —.eval()exports cleanly. Confirmed in Stage 0: torch 2.12 dynamo exporter withstrict=Truesucceeds at opset 18. - Browser model size. ViT-L is 304.6 M params (measured). FP16 ONNX = 588 MB (measured, single-file self-contained). INT8 ≈ ~310 MB. jit-ui already ships a 375 MB SAM-ViT-B, so 588 MB is within precedent.
- Tile size 256 × global attention. 256 / 8 = 32 tokens per side → 1024 tokens. Global attention at 1024 tokens is fine — equivalent compute to SAM's standard 64×64 grid.
- Per-tile latency. Measured 628 ms median on M1 Max WebGPU for
(1, 3, 256, 256)FP16 input. Cold start ~2.3 s on first forward pass (shader compile). Session create ~1.3 s. - Flow dynamics in JS. The Euler integration loop is the only non-trivial bit. ~200 lines of straightforward JS, with the option to promote the hot loop to WASM later if profiling demands it. No blockers.
- Existing pipeline fit. The current
sam-auto-segmentworker (transformers.worker.ts:237) is the right template. CPSAM is simpler to integrate — one forward pass per tile, no per-point grid, then postprocessing. - Tiling. CPSAM expects ~256-px tiles. Large images need a tile-and-stitch loop with overlap and mask-merge. ~150 LOC, required for production use on whole-slide imagery.
- Browser version floor. The deployed FP16 ONNX has FP16 graph IO (input and output tensors are
float16). ORT-web 1.20 requires the nativeFloat16Arraytype for these tensors — available in Chrome ≥135 (Feb 2025) and Safari ≥17.4. Older browsers are not supported in v1.
- Browser memory at ViT-L scale. Whole-slide images at native resolution will OOM. Mitigation: tile + chunk + don't retain full-resolution embeddings.
- Cold-start UX. First-run penalty is ~2.3 s of WebGPU shader compile plus the 588 MB cold-cache model fetch from CDN. Mandatory mitigations in v1 (see Milestone 1): IndexedDB cache, eager session creation while the user configures params.
- License. Cellpose is BSD-3; SlimSAM is Apache-2.0. Both fine. The new repo can be MIT or BSD without friction.
WebGPU is the required runtime, and WebGPU runs FP16 natively well. Flow regression is more numerically sensitive than mask classification, so INT8 introduces a quality-validation budget we don't need to spend in v1. Ship FP16. Revisit INT8 only if download size complaints arrive.
FP16 production note (Stage 0 finding): post-export FP16 conversion is broken on the dynamo-exported graph — onnxconverter-common leaves dangling FP16→FP32 type mismatches, and onnxruntime.transformers.float16 generates duplicate node names. The working path is to export directly in FP16 by instantiating cellpose.vit_sam.Transformer(dtype=torch.float16) and tracing from there. This produces FP16 graph IO, which is why the browser-version floor in §1.3 applies.
Full results: STAGE0-RESULTS.md.
Scratch rig at ~/cellpose-js-spike/ (Python 3.11 venv, export scripts, ONNX
artifacts, browser harness). Both gates passed on 2026-05-14.
- NOT via
optimum-cli(cellpose'sTransformeris not a HF Transformers class). Usetorch.onnx.export(net, dummy, …)directly. Requiresonnxscriptas an extra dep for the torch 2.12 dynamo exporter. - Use
dynamic_axesfor batch only; H/W get hardcoded by the dynamo exporter but Phase 1 always tiles at 256 so this is acceptable. - Verify FP32 parity vs PyTorch on 10 deterministic random tiles: max abs error ≤ 1e-3.
- Measured worst error: 1.24e-05 (passes by ~80×).
- FP16 ONNX (export with
Transformer(dtype=torch.float16)— see §1.5 finding). - After export, merge externalized weights back into the .onnx file with
onnx.save_model(..., save_as_external_data=False)so the browser fetches a single artifact (588 MB fits comfortably under the 2 GB protobuf limit). - Load via
onnxruntime-webUMD build (dist/ort.webgpu.min.js) — the jsDelivr+esmwrapper does not work for ort-web. - Send FP16 input as native
Float16Array(not a Uint16Array bit-pattern). - Benchmark one 256×256 tile forward pass on a mid-range laptop GPU.
- Measured: 628 ms median on M1 Max (passes by ~3.2×). Cold start ~2.3 s.
Stack
- TypeScript, ESM only
- Vite library mode
onnxruntime-web1.20+ directly (UMDort.webgpu.min.js, not the ESM+esmwrapper — that 404s on jsDelivr for ort-web). We don't need@huggingface/transformers— no tokenizers or pipelines, just an ORT session.- Vitest for unit tests
- No Angular dependency
Browser support
- Chrome ≥135 (Feb 2025), Safari ≥17.4. Required for native
Float16Array, which ORT-web demands for FP16 graph IO. - WebGPU required (already a Phase 1 decision; reaffirmed by Spike B).
Weights distribution
- Host the 588 MB single-file FP16 ONNX on Hugging Face Hub under your
account (e.g.
belkassaby/cellpose-sam-onnx). - Download-and-cache via IndexedDB on first use — same UX users already accept
for the 375 MB
sam-vit-base. Mandatory in v1 (Stage 0 measured 588 MB cold-cache fetch + 2.3 s shader compile on first run). - Do not bundle weights into the npm package.
Public API
const cp = await Cellpose.fromPretrained('belkassaby/cellpose-sam-onnx', {
preload: true, // create the ORT session eagerly; trades latency at construct time for a fast first segment()
});
const result = await cp.segment(rgbaImage, {
diameter: 30,
tile: 256,
overlap: 32,
cellprob_threshold: 0.0,
flow_threshold: 0.4,
onProgress: (done, total) => {},
signal: abortController.signal,
});
// result.masks : Uint32Array — instance label map at original resolution
// result.flows : Float32Array — converted from FP16 model output for dynamics
// result.cellprob: Float32Array — converted from FP16 model outputThe FP16→FP32 conversion at the model boundary keeps the flow-dynamics implementation in plain FP32 numerics (simpler, no risk of underflow during Euler integration).
| # | Milestone | Effort | Exit criterion |
|---|---|---|---|
| 0 | Stage-0 spikes (export parity + WebGPU latency) | 2 days | ✅ DONE — see STAGE0-RESULTS.md |
| 1 | Repo skeleton, ORT-WebGPU session loader, model fetch + IndexedDB cache | 2 days | ✅ DONE (a0c1955) — model loads, identity forward pass succeeds, IDB cache hits on reload |
| 2 | Pre-processing port (cellpose.transforms): percentile normalization, diameter-resize, tiling, channel selection |
3 days | ✅ DONE (3035fdf) — 7/7 parity tests pass against numpy fixtures |
| 3 | Per-tile inference with WebGPU EP, progress callback, abort signal | 2 days | ✅ DONE (741f340) — 277 ms median (Spike B was 628 ms); abort terminates < 50 ms |
| 4 | Flow dynamics post-processing port (cellpose.dynamics): Euler integration, convergence clustering, connected components, size + flow-consistency filtering |
5 days | ✅ DONE (ddd5dc7) — single-cell IoU 1.000, empty 1.000; 20-real-image rig deferred |
| 5 | Tile stitching with IoU-based label merging in overlap regions | 2 days | ✅ DONE — pivoted (8829838) — replaced with average_tiles + single full-image dynamics (Python's actual approach; same asymptotic complexity, better constants) |
| 6 | API polish, README with quality + perf numbers, npm publish | 2 days | ✅ DONE except npm publish (scoped local-only) — df1218d, 08e8564, a5e8319, 73a3eb7 |
| 7 | jit-ui integration | 2 days | ✅ DONE — MILESTONE7-RESULTS.md. All four plan-mandated gates pass (visible, runs, overlays masks, abortable) |
Total: ~3.5 weeks of focused work, assuming both spikes pass.
See §2. Two scripts, one for export parity, one for WebGPU latency. Output is a go/no-go memo.
Status: ✅ DONE — commit a0c1955. Full report: MILESTONE1-RESULTS.md. 5 friction points documented (ORT-web ESM entry, dynamic-import same-origin requirement, etc.).
- New repo
Cellpose.js, BSD-3 or MIT. package.jsonwith ESM-only build via Vite library mode.Cellpose.fromPretrained(modelId, { preload })loads ORT session with WebGPU EP, fails fast with a clear error if any of: WebGPU is unavailable,Float16Arrayis undefined (browser too old), or session creation throws.- IndexedDB cache keyed by model ID + version hash. First-run downloads 588 MB; subsequent runs read from IndexedDB.
preload: trueoption creates the ORT session eagerly atfromPretrained()time so the 1.3 s session-create + 2.3 s cold shader compile happen while the user is configuring params, not while waiting forsegment()to start.
Status: ✅ DONE — commit 3035fdf. Full report: MILESTONE2-RESULTS.md. 7/7 parity tests pass against numpy fixtures (normalize max abs err < 1e-5, tiling bit-exact on valid region). Browser bilinear ≠ cv2.INTER_LINEAR — qualitative validation only for diameterResize in M2.
Port from cellpose.transforms:
- Normalization: per-channel 1st/99th percentile rescaling to [0, 1], optional invert.
- Resize-to-diameter: if user supplies
diameter, resize so target ≈ 30 px (CPSAM's training median). - Tiling: split image into 256×256 tiles with 32-px overlap; pad edges; output
{ tile, tx, ty }records. - Channel handling: CPSAM expects 3-ch input. Map grayscale → 3-ch; for multi-channel fluorescence, mirror Cellpose's
chan/chan2semantics with user-selectable nuclear/cyto channels.
Status: ✅ DONE — commit 741f340. Full report: MILESTONE3-RESULTS.md. 277 ms median per tile (vs Spike B 628 ms — ORT 1.26 is ~2.3× faster than 1.20). Worker offload eliminates UI jank. Abort latency < 50 ms; post-abort respawn from IDB cache works.
- WebGPU EP only.
- Input tensor:
(1, 3, 256, 256)asFloat16Array. Build from the preprocessedFloat32Arrayvia direct assignment (Float16Arrayauto-rounds on store). - One forward pass per tile → output tensor
(1, 3, 256, 256)of dtypefloat16for(flow_y, flow_x, cellprob). - Convert output to
Float32Arrayat the model boundary before handing off to dynamics. NativeFloat16Array.prototype.set(other)casts on write; or iterate once and assign into a freshFloat32Array. - Cancel-on-abort by terminating the worker, mirroring
transformers-engine.ts:182-191.
Status: ✅ DONE (algorithm) — commit ddd5dc7. Full report: MILESTONE4-RESULTS.md. Per-tile dynamics ~53 ms. single-cell IoU 1.000, empty 1.000, three-cells 0.601 (synthetic-overlap noise — 3 of 5 labels match >0.95). The 20-real-image IoU rig is filed as Phase 1 follow-up. remove_bad_flow_masks (flow-consistency filter) and fill_holes_and_remove_small_masks deferred until a real-image case demands them.
Port cellpose.dynamics:
- Threshold
cellprob > threshold(default 0). - Euler-integrate flows ~200 steps; record each pixel's convergence point.
- Histogram-bin convergence points into a 2D grid; peaks → seed labels.
- Connected components on the label map (small JS lib or hand-roll).
- Drop tiny objects; drop masks whose mean flow disagrees with re-predicted flow (
flow_threshold).
Implement in pure JS first for correctness, profile, promote hot loops to WASM only if needed.
Status: ✅ DONE — commit 8829838. Full report: MILESTONE5-RESULTS.md. The plan-as-written specified per-tile dynamics + IoU label-merging stitcher. During M4 I noticed Python uses cellpose.transforms.average_tiles to weighted-average per-tile predictions before dynamics, then runs dynamics ONCE on the full image. After asymptotic analysis (same O(H·W·niter), better constants, smoother boundaries) and a quick LOC count (~110 vs ~250), pivoted to mirror Python's approach. 4/4 round-trip tests on averageTiles pass at < 1e-5 max abs err; the synthetic 400×400 (4 blobs, 4 tiles) gives 4 contiguous instances across tile borders. Postprocess time dropped to ~74 ms (from 212 ms with per-tile dynamics).
- Run dynamics per tile.
- Merge across overlapping borders by matching labels with IoU > 0.5 in the overlap region.
- Renumber labels globally.
- Return a
Uint32Arrayinstance label map at the original (pre-resize) resolution.
Status: ✅ DONE (no npm publish, by scope decision). Commits: df1218d (initial polish), 08e8564 (HF Hub URL in demo), a5e8319 (tsc build switch + memory cap), 73a3eb7 (docs import). M6 deviated in two ways: (a) switched the public build from Vite library mode to plain tsc after the Vite library build emitted webpack-incompatible worker URLs when consumed by jit-ui; (b) shipped to HF Hub at ballon999/cellpose-sam-onnx and to GitHub at belkassaby/Cellpose.js. npm publish remains parked (local-only per scope).
- README with quality (IoU vs Python CPSAM) and perf (ms/tile, ms/megapixel) numbers.
- Versioned npm release.
- Public model ID stable.
Status: ✅ DONE — full report: MILESTONE7-RESULTS.md. Browser-verified on M1 Max + Chrome 135+ on 2026-05-15. Cold start ~9–14 s (588 MB fetch + session create + first-tile compile); warm-cache runs ~1.85–2.62 s for 6–9 tiles; abort latency <50 ms. All four plan-mandated gates pass. Deviations and additions vs the original plan:
- No jit-ui-side worker. Plan said spawn a
cellpose.worker.tsmodeled ontransformers.worker.ts. Skipped —cellpose-jsalready encapsulates its own inference worker, so wrapping it again would be a redundant hop. The engine callscp.segment()directly from the main thread; UI stays responsive because the work is already off-thread inside the package. - Allow-list isn't the right list. Plan said add
'cellpose-segment'toimage-processing.component.ts:17. That allow-list controls "region-aware" ops (diagram-prompted segmentation). cellpose-segment isn't region-aware, so no change needed there. - Added: download progress bar. Replaces the indeterminate spinner with a determinate
p-progressBarduring the 588 MB first-time fetch. Engine exposes a progress Observable; dialog subscribes viaEngineRegistryService. - Added: status messages. Engine exposes a status Observable with phase strings (
Preprocessing image…,Running inference (tile 3 / 9)…,Computing flow dynamics…). Renders alongside the spinner. - Added:
requiresManualRunflag. New field onOperationDescriptor. For ops flagged true (cellpose-segment + all transformers-js ops), param changes mark the step stale without re-executing. A "Run" button in the param panel applies the changes explicitly. Stops every keystroke from kicking off a 2-second model invocation. - Added: in-flight download dedup. The pipeline executor can re-invoke
engine.execute()before the first call settles (param-change preview behavior). Without dedup, two concurrentfromPretrainedfetches emitted to the same progress subject and the bar oscillated. Engine now maps modelUrl → in-flight Promise so concurrent calls await the same fetch. - Added: diameter memory guard. Small diameter values (e.g. 5) caused 36× pixel-area upscale and browser OOM (~300 MB canvas allocations).
diameterResizenow hard-caps output at 4096×4096 and throws a clear error otherwise.
Touchpoints in jit-ui:
Touchpoints in this repo:
apps/jit-ui/package.json— addcellpose-jsdependency.- Sibling engine (recommended over folding into the transformers.js engine): new
apps/jit-ui/src/app/main/models/processing-pipeline/engines/cellpose/cellpose-engine.tsimplementing theProcessingEngineinterface.cellpose-jsshares nothing with transformers.js beyond the worker-dispatch pattern, so keeping them separate is cleaner long-term. - New worker
engines/cellpose/cellpose.worker.tsmodeled ontransformers.worker.ts:237-307. Off-thread model load + inference. Honor the abort contract fromengine.model.ts:34. - Register in
engine-registry.service.ts. - Operation descriptor:
id: 'cellpose-segment', categorysegmentation,runsOn: 'client'. Params:model,diameter,cellprob_threshold,flow_threshold,channels. apps/jit-ui/src/app/main/components/process/image-processing/image-processing.component.ts:17— add'cellpose-segment'to the allow-list.- Overlay rendering: instance label map → colored mask via the existing
segmentColor()palette intransformers.worker.ts.
- Server-side
Cellpose.js. CPSAM on the server already exists via the JIT registry (CPSAMmodel id is inrequest.ts:472). - 3D segmentation. Python CPSAM uses a separate
gradient_tracking_3Droutine; not ported. - Training UI, model uploader, custom-model support.
- INT8 path. Revisit only if FP16 download size becomes a complaint.
- WASM fallback. WebGPU required.
- Pre-Chrome-135 / pre-Safari-17.4 browsers. Native
Float16Arrayis mandatory for FP16 graph IO. Reaching older browsers would require a working post-export FP16 conversion path (currently broken, see §1.5) or an FP32 model (~1.1 GB, double the bandwidth). - SlimSAM-style compression of CPSAM. Not part of v1; no follow-up project planned. CPSAM is already trained on the major public corpora (Cellpose 1/2/3, LIVECell, TissueNet, DeepBacs, Omnipose, NeurIPS 2022) per Pachitariu et al. 2025, so domain-specialized finetunes on those datasets are no-ops.
The port tracks a moving target. Each entry records the upstream commit reviewed and what came out of it.
Previous baseline: the 2026-05-22 parity review (upstream 571d2f4). Diffed transforms.py, dynamics.py, models.py, core.py, and vit_sam.py → vit.py across that window.
Acted on:
-
Channel passthrough became the default. Upstream's
convert_imagestopped zero-padding to 3 channels;CPSAM.forwardnow slices the patch-embed conv weights instead (F.conv2d(x, W[:, :x.shape[1]], …)). Those two are mathematically equivalent — conv against zero channels contributes nothing — so the ONNX graph needed no change. What it exposed is that upstream v4 does no channel selection at all, andchannels=now logs an explicit deprecation. Cellpose.js'schan = 0default (grayscale mean) was diverging on the default path, so passthrough is now the default withchan/chan2kept as opt-in. Behavior change for every caller on defaults. -
niterscaling gated onresample. Upstream changedniter_scale = 1 if image_scaling is None else image_scalingtoniter_scale = 1 if rescale is None or not resample else rescale. That surfaced a real bug:segment()ran dynamics at network resolution (i.e.resample=Falsegeometry) while scalingniteras if it ran at source resolution. Fixed, andresample: trueadded as the opt-in upstream-default path. -
bsizerestriction made explicit. Upstream now raises forbsize != 256on cpsam; we now rejecttile !== 256with a clear message instead of failing opaquely inside ORT.
Reviewed, no action:
normalize99unchanged;normalizePerChannel'srange > 1e-3 → else zerosstill matchesnp.ptp(...) > 0+ thecgoodzeroing pass.invertmoved out ofeval()into the normalize dict — already where we had it (NormalizeOptions).compute_masksdowncasts labels touint16below 65,536 masks. Not adopted: changingSegmentOutput.masksfromUint32Arraywould break consumers for a memory win.- 3D (
resize_image_3d,stitch3Doverflow, ortho views),random_rotate_and_resize(training, now torch-device), GUI,io.py,denoise.py— all out of scope.
Model zoo (documented, not implemented): upstream's default is now cpsam_v2, with cpdino / cpdino-vitb added. cpsam_v2 is architecturally identical to cpsam — models.get_backbone() finds no encoder.cls_token in the checkpoint and returns "sam_vitl", instantiating the same CPSAM class at ps=8 / bsize=256 / nout=3 — so the Stage-0 FP16 export recipe applies unchanged and only the weights differ. Publishing a cpsam_v2_fp16.onnx requires no library change; fromPretrained() already takes an arbitrary URL. cpdino is DINOv3 at bsize 384 and would be a separate port.
- cellpose-js code — https://github.com/belkassaby/Cellpose.js (public, MIT). Phase 1 commit trail:
a0c1955(M1) →3035fdf(M2) →741f340(M3) →ddd5dc7(M4) →8829838(M5) →df1218d+08e8564+a5e8319(M6) →73a3eb7(docs import) →c567ca6+4c1a94a+c16de78(docs: plan updates + M7 memo) →10a96a7(CI/CD + ESLint/Prettier) →98d4305(track fixtures) → PR #134dc3ab(provenance metadata) → v0.1.0 published → PR #2839d02a(README HF URL fix) → PR #3241576c(v0.1.1 bump) → v0.1.1 published. - cellpose-js on npm — https://www.npmjs.com/package/cellpose-js.
0.1.1islatest;0.1.0is superseded. Both ship with signed sigstore provenance. - CPSAM FP16 ONNX — https://huggingface.co/ballon999/cellpose-sam-onnx (public; 588 MB; ETag
52fd6881…matches the Stage-0 source SHA-256). - Per-milestone result memos —
STAGE0-RESULTS.md,MILESTONE1-RESULTS.md,MILESTONE2-RESULTS.md,MILESTONE3-RESULTS.md,MILESTONE4-RESULTS.md,MILESTONE5-RESULTS.md,MILESTONE7-RESULTS.md. (M6 didn't get its own memo — milestones M6 and onward are captured here in PLAN.md instead.)
- Cellpose-SAM: superhuman generalization for cellular segmentation (bioRxiv 2025.04.28)
- cellpose.vit_sam module source
- MouseLand/cellpose GitHub
- mouseland/cellpose-sam weights on Hugging Face
- Xenova/slimsam-50-uniform (transformers.js ONNX export)
- SlimSAM: 0.1% Data Makes Segment Anything Slim (NeurIPS 2024)
- Cellpose post-processing algorithm overview
- Transformers.js (huggingface/transformers.js)
- Omnipose (kcutler/omnipose) — bacterial segmentation fork of Cellpose
- TissueNet dataset (vanvalenlab)
- LiveCell dataset (Sartorius) — CC-BY-NC 4.0 (non-commercial)