@jax-data-science/sci-image-visualizer 0.1.0 → 0.2.1

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 },
@@ -6720,6 +6720,11 @@ class VisualizerComponent {
6720
6720
  const caps = this.plotService.capabilities;
6721
6721
  const isStack = !!this.imageInfo?.isStack;
6722
6722
  const isGrayscale = !!this.imageInfo?.isGrayscale;
6723
+ // A multichannel image's bands are each single-band (scalar), so the scalar
6724
+ // plot modes (Heatmap / Surface / Volume / Isosurface) apply per channel even
6725
+ // though the RGB composite isn't grayscale — don't gate them out.
6726
+ const m0 = this.imageInfo?.imageMeta?.[0];
6727
+ const isMultichannel = (m0?.channelCount ?? 1) > 1 && (m0?.rgbChannels ?? 1) < 3;
6723
6728
  this.plotTypeOptions = this.plotService.getPlotTypeDescriptors()
6724
6729
  .filter((d) => {
6725
6730
  // Outside test mode, only the curated set (those with a productionLabel)
@@ -6730,7 +6735,7 @@ class VisualizerComponent {
6730
6735
  return false;
6731
6736
  if (d.requiresStack && !isStack)
6732
6737
  return false;
6733
- if (d.requiresGrayscale && !isGrayscale)
6738
+ if (d.requiresGrayscale && !isGrayscale && !isMultichannel)
6734
6739
  return false;
6735
6740
  return true;
6736
6741
  })
@@ -15137,6 +15142,51 @@ class HistogramSampler {
15137
15142
  console.warn('[viz:window] per-image window compute failed — using identity 0..255', err);
15138
15143
  }
15139
15144
  }
15145
+ /**
15146
+ * Client-side histogram for the SIMPLE (`tiled:false`) path: bin an
15147
+ * already-decoded RGBA frame's OWN pixels — no tile server, no fetch. Same
15148
+ * output shape as {@link computeImageWindow}: grayscale → [intensity];
15149
+ * RGB → [R, G, B]. This is what lets the Channels & Histogram pane work for
15150
+ * serverless images (the processing-pipeline preview, DICOM/TIFF stacks),
15151
+ * which never hit the tile / `/histogram` endpoints.
15152
+ */
15153
+ computeSimpleHistogram(z, data, gray) {
15154
+ const cR = new Array(256).fill(0);
15155
+ const cG = gray ? null : new Array(256).fill(0);
15156
+ const cB = gray ? null : new Array(256).fill(0);
15157
+ for (let i = 0; i < data.length; i += 4) {
15158
+ if (data[i + 3] === 0)
15159
+ continue; // skip transparent padding
15160
+ if (gray) {
15161
+ cR[maxRgb(data[i], data[i + 1], data[i + 2])]++;
15162
+ }
15163
+ else {
15164
+ cR[data[i]]++;
15165
+ cG[data[i + 1]]++;
15166
+ cB[data[i + 2]]++;
15167
+ }
15168
+ }
15169
+ this.sliceHistograms.set(z, gray ? [histogram256(cR)] : [histogram256(cR), histogram256(cG), histogram256(cB)]);
15170
+ this.host.onChannelHistogramsSampled();
15171
+ }
15172
+ /**
15173
+ * Per-channel histograms for a SIMPLE (`tiled:false`) MULTICHANNEL image: bin
15174
+ * each already-decoded channel plane's own pixels (no tile server). One
15175
+ * histogram per channel, so the pane's per-channel selector works serverlessly.
15176
+ */
15177
+ computeSimpleMultichannelHistograms(z, planes) {
15178
+ this.sliceHistograms.set(z, planes.map((p) => {
15179
+ const c = new Array(256).fill(0);
15180
+ const d = p.data;
15181
+ for (let i = 0; i < d.length; i += 4) {
15182
+ if (d[i + 3] === 0)
15183
+ continue;
15184
+ c[d[i]]++; // single-band plane: R=G=B, bin R
15185
+ }
15186
+ return histogram256(c);
15187
+ }));
15188
+ this.host.onChannelHistogramsSampled();
15189
+ }
15140
15190
  /**
15141
15191
  * Native-bit-depth histogram for >8-bit images, from the server `/histogram`
15142
15192
  * endpoint (the 8-bit canvas tiles can't carry 16-bit values). Cached per
@@ -15531,6 +15581,14 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15531
15581
  /** preview blob URL → full-res upscaled blob URL (see toFullResUrl), so
15532
15582
  * re-visiting a simple-mode slice doesn't re-upscale. Revoked on file change. */
15533
15583
  this.simpleFullResUrls = new Map();
15584
+ /** SERVERLESS multichannel (tiled:false + channelCount>1): the stack's
15585
+ * per-slice per-channel plane URLs, the current slice's decoded planes, and
15586
+ * the last composite blob URL. Composited client-side via display.channelRgbLut
15587
+ * (no tile server); kept OFF the tiled isMultiChannel path. */
15588
+ this.simpleMultichannel = false;
15589
+ this.simpleChannelUrls = [];
15590
+ this.simpleChannelPlanes = [];
15591
+ this.simpleCompositeUrl = null;
15534
15592
  /** Skip the full-res upscale above this longest-side dimension — a folder
15535
15593
  * stack is per-file previews (well under this); this only guards against an
15536
15594
  * accidental enormous canvas allocation. */
@@ -15662,6 +15720,12 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15662
15720
  this.colorLut = buildColormapLut(cm?.data?.value, !!rev);
15663
15721
  this.channelStates = channels;
15664
15722
  this.invertBg = !!invert;
15723
+ // Serverless multichannel: re-composite the current slice from its cached
15724
+ // planes with the new channel states + re-open (no tile-invalidate path).
15725
+ if (this.simpleMultichannel) {
15726
+ void this.recompositeAndOpen();
15727
+ return;
15728
+ }
15665
15729
  // Multichannel: each channel is its own TiledImage — visibility is the
15666
15730
  // image's opacity (window/gamma/colour are applied by recolorChannelTile
15667
15731
  // on the invalidate below). Re-applying the current slice's reveal picks up
@@ -15771,17 +15835,39 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15771
15835
  const filename = imageInfo?.fileName;
15772
15836
  this.authHeaders = {};
15773
15837
  const [width, height] = imageInfo.trueImageSize ?? [0, 0];
15774
- const rawUrl = this.simpleStack.urlFor(imageInfo, z);
15838
+ const meta = imageInfo.imageMeta?.[0];
15839
+ // SERVERLESS MULTICHANNEL: composite the slice's per-channel planes
15840
+ // client-side (no tile server), driven by the channel pane. Kept OFF the
15841
+ // tiled isMultiChannel path (see compositeSimpleMultichannel).
15842
+ const chUrls = imageInfo.channelUrls;
15843
+ this.simpleMultichannel = !!chUrls?.length && (meta?.channelCount ?? 1) > 1;
15775
15844
  let url;
15776
- if (rawUrl) {
15845
+ if (this.simpleMultichannel) {
15846
+ this.simpleChannelUrls = chUrls;
15777
15847
  try {
15778
- const previewUrl = await this.simpleStack.fetchAsBlobUrl(rawUrl);
15779
- // Upscale the (downscaled) preview to full resolution so OSD's world
15780
- // matches the full-res ROI coordinate space — see toFullResUrl.
15781
- url = await this.toFullResUrl(previewUrl, width, height);
15848
+ await this.loadSimpleChannelPlanes(chUrls[z] ?? chUrls[0]);
15849
+ url = await this.compositeSimpleMultichannel();
15850
+ // NB: histograms are binned in plot(), AFTER destroyViewer clears the
15851
+ // sampler — computing them here would be wiped by that clear.
15782
15852
  }
15783
15853
  catch (err) {
15784
- console.warn('[OSD] simple-mode slice fetch failed', err);
15854
+ console.warn('[OSD] simple multichannel composite failed', err);
15855
+ }
15856
+ }
15857
+ else {
15858
+ this.simpleChannelUrls = [];
15859
+ this.simpleChannelPlanes = [];
15860
+ const rawUrl = this.simpleStack.urlFor(imageInfo, z);
15861
+ if (rawUrl) {
15862
+ try {
15863
+ const previewUrl = await this.simpleStack.fetchAsBlobUrl(rawUrl);
15864
+ // Upscale the (downscaled) preview to full resolution so OSD's world
15865
+ // matches the full-res ROI coordinate space — see toFullResUrl.
15866
+ url = await this.toFullResUrl(previewUrl, width, height);
15867
+ }
15868
+ catch (err) {
15869
+ console.warn('[OSD] simple-mode slice fetch failed', err);
15870
+ }
15785
15871
  }
15786
15872
  }
15787
15873
  // No loadable URL (empty urls[] or the fetch failed): signal "couldn't
@@ -15790,10 +15876,9 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15790
15876
  // the router can fall back, instead of mounting an <img> with an undefined
15791
15877
  // src that throws at runtime.
15792
15878
  if (!url) {
15793
- console.warn('[OSD] simple-mode slice has no loadable URL; skipping OSD render', rawUrl);
15879
+ console.warn('[OSD] simple-mode slice has no loadable URL; skipping OSD render', filename);
15794
15880
  return { descriptor: null, infoB64: '', z, filename };
15795
15881
  }
15796
- const meta = imageInfo.imageMeta?.[0];
15797
15882
  const descriptor = {
15798
15883
  width,
15799
15884
  height,
@@ -15862,6 +15947,123 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15862
15947
  this.simpleFullResUrls.set(previewUrl, fullResUrl);
15863
15948
  return fullResUrl;
15864
15949
  }
15950
+ /** Sample a SIMPLE-mode slice's own decoded pixels into the histogram — the
15951
+ * serverless analog of the tiled samplers (no tile server). Fire-and-forget;
15952
+ * a decode failure just leaves the pane empty rather than throwing. */
15953
+ async sampleSimpleHistogram(url, z) {
15954
+ if (!url)
15955
+ return;
15956
+ try {
15957
+ const img = await this.loadImageEl(url);
15958
+ const w = img.naturalWidth, h = img.naturalHeight;
15959
+ if (!w || !h)
15960
+ return;
15961
+ const c = document.createElement('canvas');
15962
+ c.width = w;
15963
+ c.height = h;
15964
+ const ctx = c.getContext('2d', { willReadFrequently: true });
15965
+ if (!ctx)
15966
+ return;
15967
+ ctx.drawImage(img, 0, 0);
15968
+ const data = ctx.getImageData(0, 0, w, h).data;
15969
+ this.sampler.computeSimpleHistogram(z, data, this.isGrayscaleImage);
15970
+ }
15971
+ catch (err) {
15972
+ console.warn('[OSD] simple-mode histogram sample failed', err);
15973
+ }
15974
+ }
15975
+ /** Fetch + decode a slice's per-channel planes (single-band grayscale) into
15976
+ * pixel buffers, for the SERVERLESS multichannel compositor. Auth-safe: goes
15977
+ * through SimpleSliceAccessService (blob:/data: as-is; http via HttpClient). */
15978
+ async loadSimpleChannelPlanes(urls) {
15979
+ const planes = [];
15980
+ for (const u of urls ?? []) {
15981
+ try {
15982
+ const previewUrl = await this.simpleStack.fetchAsBlobUrl(u);
15983
+ const img = await this.loadImageEl(previewUrl);
15984
+ const w = img.naturalWidth, h = img.naturalHeight;
15985
+ if (!w || !h) {
15986
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
15987
+ continue;
15988
+ }
15989
+ const c = document.createElement('canvas');
15990
+ c.width = w;
15991
+ c.height = h;
15992
+ const ctx = c.getContext('2d', { willReadFrequently: true });
15993
+ if (!ctx) {
15994
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
15995
+ continue;
15996
+ }
15997
+ ctx.drawImage(img, 0, 0);
15998
+ planes.push({ data: ctx.getImageData(0, 0, w, h).data, width: w, height: h });
15999
+ }
16000
+ catch (err) {
16001
+ console.warn('[OSD] channel plane decode failed', err);
16002
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
16003
+ }
16004
+ }
16005
+ this.simpleChannelPlanes = planes;
16006
+ }
16007
+ /** Composite the cached per-channel planes into ONE RGBA image using the
16008
+ * current channel states (colour/window/gamma/visibility) — the client-side,
16009
+ * serverless analog of the tiled per-channel 'lighter' compositor
16010
+ * (same display.channelRgbLut math as recolorChannelTile, applied per-plane).
16011
+ * Returns a blob: URL of the composite PNG, revoking the previous one. */
16012
+ async compositeSimpleMultichannel() {
16013
+ const planes = this.simpleChannelPlanes;
16014
+ if (!planes.length)
16015
+ return undefined;
16016
+ const w = planes[0].width, h = planes[0].height;
16017
+ if (!w || !h)
16018
+ return undefined;
16019
+ const states = this.store.currentChannelStates();
16020
+ const out = new Uint8ClampedArray(w * h * 4);
16021
+ for (let c = 0; c < planes.length; c++) {
16022
+ const st = states[c];
16023
+ if (st && st.visible === false)
16024
+ continue; // hidden channel contributes nothing
16025
+ const plane = planes[c];
16026
+ if (plane.width !== w || plane.height !== h || !plane.data.length)
16027
+ continue;
16028
+ const { r, g, b } = this.display.channelRgbLut(st);
16029
+ const pd = plane.data;
16030
+ for (let i = 0; i < out.length; i += 4) {
16031
+ if (pd[i + 3] === 0)
16032
+ continue;
16033
+ const lum = pd[i]; // single-band plane (R=G=B)
16034
+ out[i] += r[lum]; // Uint8ClampedArray clamps → additive ('lighter')
16035
+ out[i + 1] += g[lum];
16036
+ out[i + 2] += b[lum];
16037
+ }
16038
+ }
16039
+ for (let i = 3; i < out.length; i += 4)
16040
+ out[i] = 255; // opaque
16041
+ const canvas = document.createElement('canvas');
16042
+ canvas.width = w;
16043
+ canvas.height = h;
16044
+ const ctx = canvas.getContext('2d');
16045
+ if (!ctx)
16046
+ return undefined;
16047
+ ctx.putImageData(new ImageData(out, w, h), 0, 0);
16048
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
16049
+ if (!blob)
16050
+ return undefined;
16051
+ if (this.simpleCompositeUrl)
16052
+ URL.revokeObjectURL(this.simpleCompositeUrl);
16053
+ this.simpleCompositeUrl = URL.createObjectURL(blob);
16054
+ return this.simpleCompositeUrl;
16055
+ }
16056
+ /** Re-composite the current slice from its cached planes (new channel states)
16057
+ * and re-open — the serverless analog of the tiled invalidate-on-channel-change. */
16058
+ async recompositeAndOpen() {
16059
+ const url = await this.compositeSimpleMultichannel();
16060
+ if (!url || !this.viewer)
16061
+ return;
16062
+ try {
16063
+ this.viewer.open({ type: 'image', url });
16064
+ }
16065
+ catch { /* viewer gone */ }
16066
+ }
15865
16067
  /** Load a URL into an HTMLImageElement (resolves once decoded). Uses
15866
16068
  * document.createElement rather than `new Image()` because this file's
15867
16069
  * `Image` import is image-js's decoder, not the DOM element. */
@@ -15877,6 +16079,10 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15877
16079
  for (const u of this.simpleFullResUrls.values())
15878
16080
  URL.revokeObjectURL(u);
15879
16081
  this.simpleFullResUrls.clear();
16082
+ if (this.simpleCompositeUrl) {
16083
+ URL.revokeObjectURL(this.simpleCompositeUrl);
16084
+ this.simpleCompositeUrl = null;
16085
+ }
15880
16086
  }
15881
16087
  /** Mount the viewer with a custom tile source pointing at `GET /tile`. */
15882
16088
  plot(plotDiv, imageLoaded, imageInfo, _screenHeight, _plotType, inPlace) {
@@ -15932,6 +16138,15 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15932
16138
  this.isMultiChannel = false;
15933
16139
  this.cache.clearChannelGroups();
15934
16140
  this.destroyViewer();
16141
+ // Serverless histogram — AFTER destroyViewer (its last act is sampler.clear()).
16142
+ // Multichannel bins each cached channel plane; single-band / RGB bins the
16143
+ // decoded frame's own pixels.
16144
+ if (this.simpleMultichannel) {
16145
+ this.sampler.computeSimpleMultichannelHistograms(loaded.z, this.simpleChannelPlanes);
16146
+ }
16147
+ else {
16148
+ void this.sampleSimpleHistogram(loaded.url, loaded.z);
16149
+ }
15935
16150
  }
15936
16151
  else {
15937
16152
  // Multichannel fluorescence (indexed/LUT-bearing stacks) composite client-side
@@ -16438,6 +16653,24 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
16438
16653
  if (!this.viewer)
16439
16654
  return;
16440
16655
  if (this.simpleMode) {
16656
+ if (this.simpleMultichannel) {
16657
+ const urls = this.simpleChannelUrls[z] ?? this.simpleChannelUrls[0];
16658
+ if (!urls)
16659
+ return;
16660
+ this.currentZ = z;
16661
+ this.viewportPixels = null;
16662
+ void (async () => {
16663
+ await this.loadSimpleChannelPlanes(urls);
16664
+ if (this.currentZ !== z || !this.viewer)
16665
+ return;
16666
+ this.sampler.computeSimpleMultichannelHistograms(z, this.simpleChannelPlanes);
16667
+ const url = await this.compositeSimpleMultichannel();
16668
+ if (this.currentZ !== z || !this.viewer || !url)
16669
+ return;
16670
+ this.viewer.open({ type: 'image', url });
16671
+ })();
16672
+ return;
16673
+ }
16441
16674
  const rawUrl = this.simpleUrls[z] ?? this.simpleUrls[0];
16442
16675
  if (!rawUrl)
16443
16676
  return;
@@ -16456,6 +16689,7 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
16456
16689
  console.warn('[OSD] slice re-open failed', e?.message ?? e);
16457
16690
  });
16458
16691
  this.viewer.open({ type: 'image', url });
16692
+ void this.sampleSimpleHistogram(url, z); // re-bin the scrubbed slice
16459
16693
  })
16460
16694
  .catch((err) => console.warn('[OSD] slice fetch failed', err));
16461
16695
  return;
@@ -18648,7 +18882,13 @@ class NapariVisualizerService extends BaseStoreVisualizer {
18648
18882
  // beyond that one file's own extent).
18649
18883
  const info = this.loaded?.imageInfo;
18650
18884
  if (this.simpleStack.isSimple(info)) {
18651
- const url = this.simpleStack.urlFor(info, z);
18885
+ // Serverless multichannel volume: fetch the requested channel's OWN plane
18886
+ // (channelUrls[z][channel]) so each band stays distinct — the client-side
18887
+ // analog of the server's per-channel /tile?channel=c. Else the z-anchor URL.
18888
+ const chUrls = info.channelUrls;
18889
+ const url = channel != null && chUrls?.[z]?.[channel] != null
18890
+ ? chUrls[z][channel]
18891
+ : this.simpleStack.urlFor(info, z);
18652
18892
  if (!url)
18653
18893
  throw new Error(`[napari-js] no URL for slice ${z}`);
18654
18894
  return this.simpleStack.fetchAsBitmap(url);
@@ -19258,8 +19498,13 @@ class NapariVisualizerService extends BaseStoreVisualizer {
19258
19498
  async mountVolume(viewer, info, plotType) {
19259
19499
  const token = this.loadToken; // bail before rendering if a Cancel / new plot bumps this
19260
19500
  const desc = await this.ensureDescriptor();
19261
- const channelCount = desc?.channels ?? 1;
19262
- const multichannel = !!desc?.multichannel && channelCount > 1;
19501
+ // Serverless multichannel (tiled:false + channelUrls): no tile descriptor, so
19502
+ // derive the channel count from imageMeta and assemble each band from its own
19503
+ // channelUrls[z][c] plane (fetchSlice does the per-channel routing).
19504
+ const simpleMc = this.simpleStack.isSimple(info) && !!info?.channelUrls?.length
19505
+ && (info?.imageMeta?.[0]?.channelCount ?? 1) > 1;
19506
+ const channelCount = simpleMc ? info.imageMeta[0].channelCount : (desc?.channels ?? 1);
19507
+ const multichannel = simpleMc || (!!desc?.multichannel && channelCount > 1);
19263
19508
  const res = volumeResolutionFor(this.resolutionScale);
19264
19509
  const rendering = isNapariIsosurface(plotType) ? 'iso' : 'mip';
19265
19510
  const states = this.store.currentChannelStates();