@jax-data-science/sci-image-visualizer 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -395,6 +395,280 @@ const CELL_SEGMENTER = new InjectionToken('CELL_SEGMENTER', {
395
395
  factory: () => inject(CellposeSegmenterService),
396
396
  });
397
397
 
398
+ /**
399
+ * Registry of YOLOv8-seg instance-segmentation models.
400
+ *
401
+ * Mirrors {@link SAM_MODELS}: public HF-hosted ONNX by default so the tool works
402
+ * out of the box in any host, repointable at private hosting via
403
+ * {@link setYoloModelUrls}.
404
+ *
405
+ * Each entry carries the per-model *defaults* the server-side worker uses for
406
+ * that checkpoint. These are properties of how the model was trained (object
407
+ * scale, expected crowding), not UI preferences — the embryo models want heavy
408
+ * 60% tile overlap and loose merge thresholds because their objects are large
409
+ * and touch, while the eye models were trained on sparse, downsampled fields.
410
+ * Keeping them here means a host that just picks a model gets sane behaviour
411
+ * without having to know any of that.
412
+ *
413
+ * Weights point at the `model.fp16w.onnx` variant deliberately. The `.fp16`
414
+ * variant accumulates in half precision on WebGPU and, at the x-scale these
415
+ * models use, produces materially wrong boxes; `fp16w` stores weights in fp16
416
+ * and computes in fp32, so it is half the download at identical accuracy.
417
+ *
418
+ * Export tooling: the sibling `browser-onnx-tools` project.
419
+ */
420
+ const HF = 'https://huggingface.co/Ballon999';
421
+ const EYE_DEFAULTS = {
422
+ confidence: 0.6,
423
+ iouThreshold: 0.5,
424
+ mergeThreshold: 0.3,
425
+ overlapX: 0,
426
+ overlapY: 0,
427
+ };
428
+ // Embryos are large, touch each other, and are imaged at native scale — hence
429
+ // the heavy overlap and the far looser merge thresholds.
430
+ const EMBRYO_DEFAULTS = {
431
+ confidence: 0.6,
432
+ iouThreshold: 0.8,
433
+ mergeThreshold: 0.8,
434
+ overlapX: 60,
435
+ overlapY: 60,
436
+ };
437
+ const YOLO_MODELS = [
438
+ {
439
+ id: 'yolov8x-seg-opticnerve',
440
+ label: 'Optic nerve',
441
+ modelUrl: `${HF}/yolov8x-seg-opticnerve-onnx/resolve/main/model.fp16w.onnx`,
442
+ defaults: { ...EYE_DEFAULTS },
443
+ },
444
+ {
445
+ id: 'yolov8x-seg-retina',
446
+ label: 'Retina',
447
+ modelUrl: `${HF}/yolov8x-seg-retina-onnx/resolve/main/model.fp16w.onnx`,
448
+ defaults: { ...EYE_DEFAULTS },
449
+ },
450
+ {
451
+ id: 'yolov8x-seg-embryo-m2',
452
+ label: 'Embryo (M2)',
453
+ modelUrl: `${HF}/yolov8x-seg-embryo-m2-onnx/resolve/main/model.fp16w.onnx`,
454
+ defaults: { ...EMBRYO_DEFAULTS },
455
+ },
456
+ {
457
+ id: 'yolov8x-seg-embryo-m3',
458
+ label: 'Embryo (M3)',
459
+ modelUrl: `${HF}/yolov8x-seg-embryo-m3-onnx/resolve/main/model.fp16w.onnx`,
460
+ defaults: { ...EMBRYO_DEFAULTS },
461
+ },
462
+ ];
463
+ const DEFAULT_YOLO_MODEL_ID = 'yolov8x-seg-opticnerve';
464
+ let defaultModelId = DEFAULT_YOLO_MODEL_ID;
465
+ /** Look up a model definition by id. Returns undefined for an unknown id. */
466
+ function getYoloModel(id) {
467
+ return YOLO_MODELS.find((m) => m.id === id);
468
+ }
469
+ /** Whether a model has a usable weights URL configured. */
470
+ function isYoloModelReady(id) {
471
+ const m = getYoloModel(id);
472
+ return !!m && m.modelUrl.trim().length > 0;
473
+ }
474
+ /**
475
+ * Repoint a model at different hosting (e.g. a GCS bucket or a same-origin
476
+ * proxy). Call once at app init, before the first run. An empty `modelUrl`
477
+ * disables the model.
478
+ */
479
+ function setYoloModelUrls(id, urls) {
480
+ const m = getYoloModel(id);
481
+ if (!m)
482
+ return;
483
+ if (urls.modelUrl !== undefined)
484
+ m.modelUrl = urls.modelUrl;
485
+ if (urls.metaUrl !== undefined)
486
+ m.metaUrl = urls.metaUrl;
487
+ }
488
+ /** Change which model runs when a caller does not name one. */
489
+ function setDefaultYoloModel(id) {
490
+ if (getYoloModel(id))
491
+ defaultModelId = id;
492
+ }
493
+ function getDefaultYoloModelId() {
494
+ return defaultModelId;
495
+ }
496
+
497
+ /**
498
+ * Default in-library {@link IInstanceSegmenter}, backed by yolo-segdetect-js
499
+ * (YOLOv8-seg on WebGPU, falling back to WASM).
500
+ *
501
+ * `yolo-segdetect-js` is a regular dependency but is **lazy-imported** on first
502
+ * use, so apps that never run YOLO pay nothing for it — or for onnxruntime-web —
503
+ * in their initial bundle. This mirrors {@link CellposeSegmenterService}.
504
+ *
505
+ * The one structural difference from the Cellpose service: there are four YOLO
506
+ * checkpoints, not one, so instances are cached **per model id** rather than as
507
+ * a single singleton. Each entry owns its own worker and ORT session, so a host
508
+ * that switches models pays a second load but keeps the first one warm. Nothing
509
+ * evicts automatically — these are ~144 MB each, so a host that cycles through
510
+ * all four should call {@link dispose} for the ones it is done with.
511
+ */
512
+ class YoloSegmenterService {
513
+ constructor() {
514
+ this.instances = new Map();
515
+ this.loading = new Map();
516
+ }
517
+ async segmentInstances(image, opts = {}, progress) {
518
+ const modelId = opts.modelId ?? getDefaultYoloModelId();
519
+ const def = this.requireModel(modelId);
520
+ let announcedDownload = false;
521
+ const seg = await this.getModel(modelId, (loaded, total) => {
522
+ if (total)
523
+ progress?.onProgress?.(loaded / total);
524
+ progress?.onBytes?.(loaded, total);
525
+ if (loaded > 0 && !announcedDownload) {
526
+ announcedDownload = true;
527
+ progress?.onStatus?.(`Downloading ${def.label} model…`);
528
+ }
529
+ }, (status) => progress?.onStatus?.(status));
530
+ progress?.onStatus?.('Running inference…');
531
+ const result = await seg.segment({ data: image.data, width: image.width, height: image.height }, {
532
+ confThreshold: opts.confidence ?? def.defaults.confidence,
533
+ iouThreshold: opts.iouThreshold ?? def.defaults.iouThreshold,
534
+ threshold: opts.mergeThreshold ?? def.defaults.mergeThreshold,
535
+ overlapX: opts.overlapX ?? def.defaults.overlapX,
536
+ overlapY: opts.overlapY ?? def.defaults.overlapY,
537
+ ...(opts.maskThreshold !== undefined ? { maskThreshold: opts.maskThreshold } : {}),
538
+ ...(opts.maxDetections !== undefined ? { maxDetections: opts.maxDetections } : {}),
539
+ ...(opts.classFilter ? { classFilter: opts.classFilter } : {}),
540
+ ...(opts.withMasks !== undefined ? { withMasks: opts.withMasks } : {}),
541
+ ...(opts.signal ? { signal: opts.signal } : {}),
542
+ // Only worth tracing when there are masks to trace.
543
+ tracePolygons: opts.withMasks !== false,
544
+ traceOptions: {
545
+ ...(opts.simplifyTolerance !== undefined ? { simplifyTolerance: opts.simplifyTolerance } : {}),
546
+ ...(opts.minArea !== undefined ? { minArea: opts.minArea } : {}),
547
+ },
548
+ onTileProgress: (done, total) => progress?.onStatus?.(done < total ? `Running inference (tile ${done}/${total})…` : 'Tracing outlines…'),
549
+ });
550
+ // Detections without geometry are dropped rather than surfaced as empty
551
+ // regions — a detection whose mask vanished under thresholding has nothing
552
+ // meaningful to draw or edit.
553
+ const detections = [];
554
+ for (const d of result.detections) {
555
+ if (!d.polygons || d.polygons.length === 0)
556
+ continue;
557
+ detections.push({
558
+ polygons: d.polygons.map((p) => ({
559
+ exterior: p.exterior.map(([x, y]) => [x, y]),
560
+ holes: p.holes.map((h) => h.map(([x, y]) => [x, y])),
561
+ })),
562
+ box: [d.box[0], d.box[1], d.box[2], d.box[3]],
563
+ score: d.score,
564
+ classId: d.classId,
565
+ className: d.className ?? `class${d.classId}`,
566
+ });
567
+ }
568
+ return {
569
+ detections,
570
+ width: result.width,
571
+ height: result.height,
572
+ classNames: result.classNames,
573
+ };
574
+ }
575
+ /**
576
+ * The shared segmenter for a model id, created on first use and reused
577
+ * thereafter (deduped across concurrent callers). Exposed so a host's
578
+ * processing pipeline can drive richer `segment()` options on the same loaded
579
+ * session instead of loading the model twice.
580
+ */
581
+ getModel(modelId = getDefaultYoloModelId(), onProgress, onStatus) {
582
+ const existing = this.instances.get(modelId);
583
+ if (existing)
584
+ return Promise.resolve(existing);
585
+ const inFlight = this.loading.get(modelId);
586
+ if (inFlight)
587
+ return inFlight;
588
+ const def = this.requireModel(modelId);
589
+ const load = (async () => {
590
+ // Lazy: keep yolo-segdetect-js + its ORT runtime out of the initial bundle.
591
+ const { YoloSegmenter, configureOrt } = await import('yolo-segdetect-js');
592
+ configureOrt({ wasmPaths: getOrtWasmBase() });
593
+ const seg = await YoloSegmenter.fromPretrained(def.modelUrl, {
594
+ preload: true,
595
+ ...(def.metaUrl ? { metaUrl: def.metaUrl } : {}),
596
+ onProgress: ({ loaded, total }) => onProgress?.(loaded, total),
597
+ onStatus: (s) => onStatus?.(s),
598
+ });
599
+ this.instances.set(modelId, seg);
600
+ return seg;
601
+ })();
602
+ this.loading.set(modelId, load);
603
+ // A failed load must not poison the cache — the next caller should retry
604
+ // rather than inherit the rejected promise forever.
605
+ load.catch(() => this.loading.delete(modelId));
606
+ return load;
607
+ }
608
+ /** Whether a model is already loaded (warm). */
609
+ isLoaded(modelId = getDefaultYoloModelId()) {
610
+ return this.instances.has(modelId);
611
+ }
612
+ /**
613
+ * Release one model, or all of them. Safe to call on a model never loaded.
614
+ *
615
+ * In-flight loads are torn down too, not just warm ones. Simply dropping the
616
+ * pending promise and returning would let the load finish afterwards,
617
+ * re-populate {@link instances}, and strand a live worker and ORT session that
618
+ * nothing owns — disposal would have reported success while leaking. So a load
619
+ * that is still running is awaited and then released.
620
+ */
621
+ async dispose(modelId) {
622
+ const ids = modelId ? [modelId] : [...new Set([...this.instances.keys(), ...this.loading.keys()])];
623
+ await Promise.all(ids.map((id) => this.disposeOne(id)));
624
+ }
625
+ async disposeOne(id) {
626
+ const pending = this.loading.get(id);
627
+ const loaded = this.instances.get(id);
628
+ this.instances.delete(id);
629
+ this.loading.delete(id);
630
+ if (loaded)
631
+ await loaded.dispose();
632
+ if (!pending)
633
+ return;
634
+ // A pending load assigns instances[id] before its promise resolves, so by
635
+ // the time this await returns the entry is back. Clear it only if it still
636
+ // points at what *this* load produced — a newer load started in the
637
+ // meantime owns its own entry and must not be evicted.
638
+ const seg = await pending.catch(() => null);
639
+ if (!seg || seg === loaded)
640
+ return;
641
+ if (this.instances.get(id) === seg)
642
+ this.instances.delete(id);
643
+ await seg.dispose();
644
+ }
645
+ requireModel(modelId) {
646
+ const def = getYoloModel(modelId);
647
+ if (!def)
648
+ throw new Error(`Unknown YOLO model id "${modelId}".`);
649
+ if (!def.modelUrl.trim()) {
650
+ throw new Error(`YOLO model "${modelId}" is not configured — its modelUrl is empty.`);
651
+ }
652
+ return def;
653
+ }
654
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: YoloSegmenterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
655
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: YoloSegmenterService, providedIn: 'root' }); }
656
+ }
657
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: YoloSegmenterService, decorators: [{
658
+ type: Injectable,
659
+ args: [{ providedIn: 'root' }]
660
+ }] });
661
+
662
+ /**
663
+ * DI token for the automatic instance segmenter. Defaults to the in-library
664
+ * {@link YoloSegmenterService} (yolo-segdetect-js, lazy-loaded); override it in
665
+ * the host to swap implementations — for example to delegate to the server.
666
+ */
667
+ const INSTANCE_SEGMENTER = new InjectionToken('INSTANCE_SEGMENTER', {
668
+ providedIn: 'root',
669
+ factory: () => inject(YoloSegmenterService),
670
+ });
671
+
398
672
  /**
399
673
  * Unified image container used throughout the processing pipeline.
400
674
  * Decouples pipeline logic from any specific library's image format.
@@ -21784,5 +22058,5 @@ function provideVisualization() {
21784
22058
  * Generated bundle index. Do not edit.
21785
22059
  */
21786
22060
 
21787
- export { ALL_TOOLBAR_TOOLS, CELL_SEGMENTER, CHANNEL_HISTOGRAM_API, CellposeSegmenterService, ChannelHistogramComponent, DEFAULT_FALLBACK_PALETTE, DEFAULT_SAM_MODEL_ID, HexColorPickerComponent, IMAGE_STATE_PORT, ImageConverterService, LUT_COLORS, MultiPolygon, NAPARI_DECIMATE_OPTIONS, NAPARI_DEFAULT_DECIMATE, PLOT_TYPE_DESCRIPTORS, PREFERENCES_PORT, PlotType, Polygon, ProcessingImage, REGION_EDITOR_API, REGION_IO_PORT, Rectangle, Region, RegionEditorComponent, RoutingVisualizerService, SAM_MODELS, ShapeSelection, TILE_ACCESS_PORT, VISUALIZER, VIZ_CONFIG, ViewerFeature, VisualizationModule, VisualizerComponent, capabilitiesOf, cropImage, defaultPresetSet, getDefaultSamModelId, getOrtWasmBase, getPlotTypeDescriptor, getSamModel, isEmptyPresetSet, isNapari3d, isNapariIsosurface, isNapariScatter, isNapariScatter3d, isNapariSurface, isNapariVolume, isSamModelReady, isThreeDimensional, provideVisualization, setDefaultSamModel, setOrtWasmBase, setSamModelUrls };
22061
+ export { ALL_TOOLBAR_TOOLS, CELL_SEGMENTER, CHANNEL_HISTOGRAM_API, CellposeSegmenterService, ChannelHistogramComponent, DEFAULT_FALLBACK_PALETTE, DEFAULT_SAM_MODEL_ID, DEFAULT_YOLO_MODEL_ID, HexColorPickerComponent, IMAGE_STATE_PORT, INSTANCE_SEGMENTER, ImageConverterService, LUT_COLORS, MultiPolygon, NAPARI_DECIMATE_OPTIONS, NAPARI_DEFAULT_DECIMATE, PLOT_TYPE_DESCRIPTORS, PREFERENCES_PORT, PlotType, Polygon, ProcessingImage, REGION_EDITOR_API, REGION_IO_PORT, Rectangle, Region, RegionEditorComponent, RoutingVisualizerService, SAM_MODELS, ShapeSelection, TILE_ACCESS_PORT, VISUALIZER, VIZ_CONFIG, ViewerFeature, VisualizationModule, VisualizerComponent, YOLO_MODELS, YoloSegmenterService, capabilitiesOf, cropImage, defaultPresetSet, getDefaultSamModelId, getDefaultYoloModelId, getOrtWasmBase, getPlotTypeDescriptor, getSamModel, getYoloModel, isEmptyPresetSet, isNapari3d, isNapariIsosurface, isNapariScatter, isNapariScatter3d, isNapariSurface, isNapariVolume, isSamModelReady, isThreeDimensional, isYoloModelReady, provideVisualization, setDefaultSamModel, setDefaultYoloModel, setOrtWasmBase, setSamModelUrls, setYoloModelUrls };
21788
22062
  //# sourceMappingURL=jax-data-science-sci-image-visualizer.mjs.map