@jax-data-science/sci-image-visualizer 0.2.0 → 0.2.2

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.
@@ -166,7 +166,7 @@ const PLOT_TYPE_DESCRIPTORS = {
166
166
  // ── napari-js WebGPU (jit-ui#102). The 3D types take a runtime decimate factor. ──
167
167
  [PlotType.NAPARI_IMAGE]: { type: PlotType.NAPARI_IMAGE, label: 'Image (napari · WebGPU)', icon: 'pi pi-image', dimensions: '2d', source: 'image' },
168
168
  [PlotType.NAPARI_SCATTER]: { type: PlotType.NAPARI_SCATTER, label: 'Scatter 2D (napari · WebGPU)', icon: 'pi pi-chart-scatter', dimensions: '2d', source: 'regions' },
169
- [PlotType.NAPARI_SURFACE]: { type: PlotType.NAPARI_SURFACE, label: 'Surface (napari · WebGPU)', productionLabel: 'Surface', icon: 'assets/plotting/surface.svg', dimensions: '3d', source: 'image', requiresGrayscale: true },
169
+ [PlotType.NAPARI_SURFACE]: { type: PlotType.NAPARI_SURFACE, label: 'Surface (napari · WebGPU)', productionLabel: 'Surface', icon: 'assets/plotting/surface.svg', dimensions: '3d', source: 'image' }, // a height-field is z=intensity of one plane — works for any grayscale/RGB image (RGB→luminance), no stack needed
170
170
  [PlotType.NAPARI_SCATTER3D]: { type: PlotType.NAPARI_SCATTER3D, label: 'Scatter 3D (napari · WebGPU)', icon: 'assets/plotting/3d-coordinates.svg', dimensions: '3d', source: 'image', requiresStack: true, requiresGrayscale: true },
171
171
  [PlotType.NAPARI_VOLUME]: { type: PlotType.NAPARI_VOLUME, label: 'Volume (napari · WebGPU)', productionLabel: 'Volume', icon: 'assets/plotting/cube-3d.svg', dimensions: '3d', source: 'image', requiresStack: true, requiresGrayscale: true },
172
172
  [PlotType.NAPARI_ISOSURFACE]: { type: PlotType.NAPARI_ISOSURFACE, label: 'Isosurface (napari · WebGPU)', productionLabel: 'Isosurface', icon: 'assets/plotting/isosurface.svg', dimensions: '3d', source: 'image', requiresStack: true, requiresGrayscale: true },
@@ -3223,6 +3223,9 @@ class VisualizerStore {
3223
3223
  // metadata on load (1 "Intensity" channel for grayscale; R/G/B for RGB). The
3224
3224
  // Channels & Histogram pane edits these and both backends recolor live.
3225
3225
  this.channelStates$ = new BehaviorSubject([]);
3226
+ /** Index of the channel selected in the Channels pane — the ONE band a
3227
+ * single-scalar 3D view (the napari Surface) follows for a multichannel image. */
3228
+ this.selectedChannel$ = new BehaviorSubject(0);
3226
3229
  // The derived defaults (full window, gamma 1, default tint) for the current
3227
3230
  // image, kept so "Reset" can restore a channel's window/gamma/colour.
3228
3231
  this.defaultChannelStates = [];
@@ -3372,6 +3375,7 @@ class VisualizerStore {
3372
3375
  this.defaultChannelStates = next.map((c) => ({ ...c }));
3373
3376
  if (next.length !== this.channelStates$.value.length) {
3374
3377
  this.channelStates$.next(next);
3378
+ this.selectedChannel$.next(0); // new channel structure → reset the selected band
3375
3379
  }
3376
3380
  }
3377
3381
  /** Channels for an image:
@@ -3410,6 +3414,13 @@ class VisualizerStore {
3410
3414
  currentChannelStates() {
3411
3415
  return this.channelStates$.value;
3412
3416
  }
3417
+ /** The channel selected in the pane (drives a single-scalar 3D Surface). */
3418
+ getSelectedChannel() { return this.selectedChannel$.asObservable(); }
3419
+ currentSelectedChannel() { return this.selectedChannel$.value; }
3420
+ setSelectedChannel(index) {
3421
+ if (this.selectedChannel$.value !== index)
3422
+ this.selectedChannel$.next(index);
3423
+ }
3413
3424
  /** Patch one channel by index (immutable update). */
3414
3425
  setChannelState(index, partial) {
3415
3426
  const next = this.channelStates$.value.map((c) => c.index === index ? { ...c, ...partial, index: c.index } : c);
@@ -6251,6 +6262,7 @@ class ChannelHistogramComponent {
6251
6262
  }
6252
6263
  selectChannel(ch) {
6253
6264
  this.selected = ch;
6265
+ this.api.setSelectedChannel(ch.index); // a single-scalar 3D Surface follows the selected band
6254
6266
  this.histRetries = 0;
6255
6267
  this.loadHistogram();
6256
6268
  }
@@ -6720,6 +6732,11 @@ class VisualizerComponent {
6720
6732
  const caps = this.plotService.capabilities;
6721
6733
  const isStack = !!this.imageInfo?.isStack;
6722
6734
  const isGrayscale = !!this.imageInfo?.isGrayscale;
6735
+ // A multichannel image's bands are each single-band (scalar), so the scalar
6736
+ // plot modes (Heatmap / Surface / Volume / Isosurface) apply per channel even
6737
+ // though the RGB composite isn't grayscale — don't gate them out.
6738
+ const m0 = this.imageInfo?.imageMeta?.[0];
6739
+ const isMultichannel = (m0?.channelCount ?? 1) > 1 && (m0?.rgbChannels ?? 1) < 3;
6723
6740
  this.plotTypeOptions = this.plotService.getPlotTypeDescriptors()
6724
6741
  .filter((d) => {
6725
6742
  // Outside test mode, only the curated set (those with a productionLabel)
@@ -6730,7 +6747,7 @@ class VisualizerComponent {
6730
6747
  return false;
6731
6748
  if (d.requiresStack && !isStack)
6732
6749
  return false;
6733
- if (d.requiresGrayscale && !isGrayscale)
6750
+ if (d.requiresGrayscale && !isGrayscale && !isMultichannel)
6734
6751
  return false;
6735
6752
  return true;
6736
6753
  })
@@ -18705,6 +18722,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
18705
18722
  this.surfaceLayer = null;
18706
18723
  /** Which band the surface samples (a channel index for multichannel, else the composite). */
18707
18724
  this.surfaceChannel = undefined;
18725
+ /** True when the mounted Surface follows one band of a multichannel image. */
18726
+ this.surfaceMultichannel = false;
18708
18727
  /** Pre-loaded per-slice luminance planes (already decimated to the surface grid), keyed by z,
18709
18728
  * so the stack slider rebuilds the surface instantly. Filled by {@link preloadSurfacePlanes}. */
18710
18729
  this.surfacePlanes = new Map();
@@ -18877,7 +18896,13 @@ class NapariVisualizerService extends BaseStoreVisualizer {
18877
18896
  // beyond that one file's own extent).
18878
18897
  const info = this.loaded?.imageInfo;
18879
18898
  if (this.simpleStack.isSimple(info)) {
18880
- const url = this.simpleStack.urlFor(info, z);
18899
+ // Serverless multichannel volume: fetch the requested channel's OWN plane
18900
+ // (channelUrls[z][channel]) so each band stays distinct — the client-side
18901
+ // analog of the server's per-channel /tile?channel=c. Else the z-anchor URL.
18902
+ const chUrls = info.channelUrls;
18903
+ const url = channel != null && chUrls?.[z]?.[channel] != null
18904
+ ? chUrls[z][channel]
18905
+ : this.simpleStack.urlFor(info, z);
18881
18906
  if (!url)
18882
18907
  throw new Error(`[napari-js] no URL for slice ${z}`);
18883
18908
  return this.simpleStack.fetchAsBitmap(url);
@@ -19487,8 +19512,13 @@ class NapariVisualizerService extends BaseStoreVisualizer {
19487
19512
  async mountVolume(viewer, info, plotType) {
19488
19513
  const token = this.loadToken; // bail before rendering if a Cancel / new plot bumps this
19489
19514
  const desc = await this.ensureDescriptor();
19490
- const channelCount = desc?.channels ?? 1;
19491
- const multichannel = !!desc?.multichannel && channelCount > 1;
19515
+ // Serverless multichannel (tiled:false + channelUrls): no tile descriptor, so
19516
+ // derive the channel count from imageMeta and assemble each band from its own
19517
+ // channelUrls[z][c] plane (fetchSlice does the per-channel routing).
19518
+ const simpleMc = this.simpleStack.isSimple(info) && !!info?.channelUrls?.length
19519
+ && (info?.imageMeta?.[0]?.channelCount ?? 1) > 1;
19520
+ const channelCount = simpleMc ? info.imageMeta[0].channelCount : (desc?.channels ?? 1);
19521
+ const multichannel = simpleMc || (!!desc?.multichannel && channelCount > 1);
19492
19522
  const res = volumeResolutionFor(this.resolutionScale);
19493
19523
  const rendering = isNapariIsosurface(plotType) ? 'iso' : 'mip';
19494
19524
  const states = this.store.currentChannelStates();
@@ -19878,9 +19908,15 @@ class NapariVisualizerService extends BaseStoreVisualizer {
19878
19908
  */
19879
19909
  async mountSurface(viewer) {
19880
19910
  const desc = await this.ensureDescriptor();
19881
- const multichannel = !!desc?.multichannel && (desc?.channels ?? 1) > 1;
19882
- const states = this.store.currentChannelStates();
19883
- this.surfaceChannel = multichannel ? states.find((s) => s.visible)?.index ?? 0 : undefined;
19911
+ const info = this.loaded?.imageInfo;
19912
+ // Serverless multichannel (tiled:false + channelUrls) has no descriptor — detect
19913
+ // it from imageMeta so the Surface still follows one band.
19914
+ const simpleMc = this.simpleStack.isSimple(info) && !!info?.channelUrls?.length
19915
+ && (info?.imageMeta?.[0]?.channelCount ?? 1) > 1;
19916
+ const multichannel = simpleMc || (!!desc?.multichannel && (desc?.channels ?? 1) > 1);
19917
+ this.surfaceMultichannel = multichannel;
19918
+ // A height field is single-scalar → follow the pane-SELECTED channel.
19919
+ this.surfaceChannel = multichannel ? this.store.currentSelectedChannel() : undefined;
19884
19920
  this.surfaceMaxGrid = surfaceResolutionFor(this.resolutionScale).maxGrid;
19885
19921
  this.imageMode = 'grayscale';
19886
19922
  this.volumeMultichannel = false;
@@ -20105,13 +20141,25 @@ class NapariVisualizerService extends BaseStoreVisualizer {
20105
20141
  this.store.getReverseScale(),
20106
20142
  this.store.getInvert(),
20107
20143
  this.store.getChannelStates(),
20108
- ]).subscribe(([colormap, reverse, invert, channels]) => {
20144
+ this.store.getSelectedChannel(),
20145
+ ]).subscribe(([colormap, reverse, invert, channels, selected]) => {
20109
20146
  this.currentColormap = colormap ?? null;
20110
20147
  this.currentReverse = reverse;
20111
20148
  this.invertEnabled = invert;
20112
20149
  const layer = this.surfaceLayer;
20113
20150
  if (!layer || !this.viewer)
20114
20151
  return;
20152
+ // Selected channel changed (multichannel): re-fetch THAT band's planes and
20153
+ // rebuild the height-field for the current slice.
20154
+ if (this.surfaceMultichannel && selected !== this.surfaceChannel) {
20155
+ this.surfaceChannel = selected;
20156
+ void (async () => {
20157
+ await this.preloadSurfacePlanes(this.viewer);
20158
+ if (this.viewer)
20159
+ await this.buildSurface(this.viewer, this.loaded?.z ?? 0);
20160
+ })().catch((err) => console.error('[napari-js] surface channel switch failed:', err));
20161
+ return;
20162
+ }
20115
20163
  const st = this.surfaceState(channels);
20116
20164
  const win = [st?.min ?? 0, st?.max ?? 255];
20117
20165
  const windowChanged = !this.surfaceWindow || win[0] !== this.surfaceWindow[0] || win[1] !== this.surfaceWindow[1];
@@ -21266,6 +21314,7 @@ class RoutingVisualizerService {
21266
21314
  /** Export the underlying data (16-bit multi-band TIFF) via the active backend. */
21267
21315
  exportData() { this.renderer().exportData(); }
21268
21316
  getChannels$() { return this.store.getChannelStates(); }
21317
+ setSelectedChannel(index) { this.store.setSelectedChannel(index); }
21269
21318
  setChannelState(index, partial) {
21270
21319
  this.store.setChannelState(index, partial);
21271
21320
  }