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

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.
@@ -15137,6 +15137,51 @@ class HistogramSampler {
15137
15137
  console.warn('[viz:window] per-image window compute failed — using identity 0..255', err);
15138
15138
  }
15139
15139
  }
15140
+ /**
15141
+ * Client-side histogram for the SIMPLE (`tiled:false`) path: bin an
15142
+ * already-decoded RGBA frame's OWN pixels — no tile server, no fetch. Same
15143
+ * output shape as {@link computeImageWindow}: grayscale → [intensity];
15144
+ * RGB → [R, G, B]. This is what lets the Channels & Histogram pane work for
15145
+ * serverless images (the processing-pipeline preview, DICOM/TIFF stacks),
15146
+ * which never hit the tile / `/histogram` endpoints.
15147
+ */
15148
+ computeSimpleHistogram(z, data, gray) {
15149
+ const cR = new Array(256).fill(0);
15150
+ const cG = gray ? null : new Array(256).fill(0);
15151
+ const cB = gray ? null : new Array(256).fill(0);
15152
+ for (let i = 0; i < data.length; i += 4) {
15153
+ if (data[i + 3] === 0)
15154
+ continue; // skip transparent padding
15155
+ if (gray) {
15156
+ cR[maxRgb(data[i], data[i + 1], data[i + 2])]++;
15157
+ }
15158
+ else {
15159
+ cR[data[i]]++;
15160
+ cG[data[i + 1]]++;
15161
+ cB[data[i + 2]]++;
15162
+ }
15163
+ }
15164
+ this.sliceHistograms.set(z, gray ? [histogram256(cR)] : [histogram256(cR), histogram256(cG), histogram256(cB)]);
15165
+ this.host.onChannelHistogramsSampled();
15166
+ }
15167
+ /**
15168
+ * Per-channel histograms for a SIMPLE (`tiled:false`) MULTICHANNEL image: bin
15169
+ * each already-decoded channel plane's own pixels (no tile server). One
15170
+ * histogram per channel, so the pane's per-channel selector works serverlessly.
15171
+ */
15172
+ computeSimpleMultichannelHistograms(z, planes) {
15173
+ this.sliceHistograms.set(z, planes.map((p) => {
15174
+ const c = new Array(256).fill(0);
15175
+ const d = p.data;
15176
+ for (let i = 0; i < d.length; i += 4) {
15177
+ if (d[i + 3] === 0)
15178
+ continue;
15179
+ c[d[i]]++; // single-band plane: R=G=B, bin R
15180
+ }
15181
+ return histogram256(c);
15182
+ }));
15183
+ this.host.onChannelHistogramsSampled();
15184
+ }
15140
15185
  /**
15141
15186
  * Native-bit-depth histogram for >8-bit images, from the server `/histogram`
15142
15187
  * endpoint (the 8-bit canvas tiles can't carry 16-bit values). Cached per
@@ -15531,6 +15576,14 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15531
15576
  /** preview blob URL → full-res upscaled blob URL (see toFullResUrl), so
15532
15577
  * re-visiting a simple-mode slice doesn't re-upscale. Revoked on file change. */
15533
15578
  this.simpleFullResUrls = new Map();
15579
+ /** SERVERLESS multichannel (tiled:false + channelCount>1): the stack's
15580
+ * per-slice per-channel plane URLs, the current slice's decoded planes, and
15581
+ * the last composite blob URL. Composited client-side via display.channelRgbLut
15582
+ * (no tile server); kept OFF the tiled isMultiChannel path. */
15583
+ this.simpleMultichannel = false;
15584
+ this.simpleChannelUrls = [];
15585
+ this.simpleChannelPlanes = [];
15586
+ this.simpleCompositeUrl = null;
15534
15587
  /** Skip the full-res upscale above this longest-side dimension — a folder
15535
15588
  * stack is per-file previews (well under this); this only guards against an
15536
15589
  * accidental enormous canvas allocation. */
@@ -15662,6 +15715,12 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15662
15715
  this.colorLut = buildColormapLut(cm?.data?.value, !!rev);
15663
15716
  this.channelStates = channels;
15664
15717
  this.invertBg = !!invert;
15718
+ // Serverless multichannel: re-composite the current slice from its cached
15719
+ // planes with the new channel states + re-open (no tile-invalidate path).
15720
+ if (this.simpleMultichannel) {
15721
+ void this.recompositeAndOpen();
15722
+ return;
15723
+ }
15665
15724
  // Multichannel: each channel is its own TiledImage — visibility is the
15666
15725
  // image's opacity (window/gamma/colour are applied by recolorChannelTile
15667
15726
  // on the invalidate below). Re-applying the current slice's reveal picks up
@@ -15771,17 +15830,39 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15771
15830
  const filename = imageInfo?.fileName;
15772
15831
  this.authHeaders = {};
15773
15832
  const [width, height] = imageInfo.trueImageSize ?? [0, 0];
15774
- const rawUrl = this.simpleStack.urlFor(imageInfo, z);
15833
+ const meta = imageInfo.imageMeta?.[0];
15834
+ // SERVERLESS MULTICHANNEL: composite the slice's per-channel planes
15835
+ // client-side (no tile server), driven by the channel pane. Kept OFF the
15836
+ // tiled isMultiChannel path (see compositeSimpleMultichannel).
15837
+ const chUrls = imageInfo.channelUrls;
15838
+ this.simpleMultichannel = !!chUrls?.length && (meta?.channelCount ?? 1) > 1;
15775
15839
  let url;
15776
- if (rawUrl) {
15840
+ if (this.simpleMultichannel) {
15841
+ this.simpleChannelUrls = chUrls;
15777
15842
  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);
15843
+ await this.loadSimpleChannelPlanes(chUrls[z] ?? chUrls[0]);
15844
+ url = await this.compositeSimpleMultichannel();
15845
+ // NB: histograms are binned in plot(), AFTER destroyViewer clears the
15846
+ // sampler — computing them here would be wiped by that clear.
15782
15847
  }
15783
15848
  catch (err) {
15784
- console.warn('[OSD] simple-mode slice fetch failed', err);
15849
+ console.warn('[OSD] simple multichannel composite failed', err);
15850
+ }
15851
+ }
15852
+ else {
15853
+ this.simpleChannelUrls = [];
15854
+ this.simpleChannelPlanes = [];
15855
+ const rawUrl = this.simpleStack.urlFor(imageInfo, z);
15856
+ if (rawUrl) {
15857
+ try {
15858
+ const previewUrl = await this.simpleStack.fetchAsBlobUrl(rawUrl);
15859
+ // Upscale the (downscaled) preview to full resolution so OSD's world
15860
+ // matches the full-res ROI coordinate space — see toFullResUrl.
15861
+ url = await this.toFullResUrl(previewUrl, width, height);
15862
+ }
15863
+ catch (err) {
15864
+ console.warn('[OSD] simple-mode slice fetch failed', err);
15865
+ }
15785
15866
  }
15786
15867
  }
15787
15868
  // No loadable URL (empty urls[] or the fetch failed): signal "couldn't
@@ -15790,10 +15871,9 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15790
15871
  // the router can fall back, instead of mounting an <img> with an undefined
15791
15872
  // src that throws at runtime.
15792
15873
  if (!url) {
15793
- console.warn('[OSD] simple-mode slice has no loadable URL; skipping OSD render', rawUrl);
15874
+ console.warn('[OSD] simple-mode slice has no loadable URL; skipping OSD render', filename);
15794
15875
  return { descriptor: null, infoB64: '', z, filename };
15795
15876
  }
15796
- const meta = imageInfo.imageMeta?.[0];
15797
15877
  const descriptor = {
15798
15878
  width,
15799
15879
  height,
@@ -15862,6 +15942,123 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15862
15942
  this.simpleFullResUrls.set(previewUrl, fullResUrl);
15863
15943
  return fullResUrl;
15864
15944
  }
15945
+ /** Sample a SIMPLE-mode slice's own decoded pixels into the histogram — the
15946
+ * serverless analog of the tiled samplers (no tile server). Fire-and-forget;
15947
+ * a decode failure just leaves the pane empty rather than throwing. */
15948
+ async sampleSimpleHistogram(url, z) {
15949
+ if (!url)
15950
+ return;
15951
+ try {
15952
+ const img = await this.loadImageEl(url);
15953
+ const w = img.naturalWidth, h = img.naturalHeight;
15954
+ if (!w || !h)
15955
+ return;
15956
+ const c = document.createElement('canvas');
15957
+ c.width = w;
15958
+ c.height = h;
15959
+ const ctx = c.getContext('2d', { willReadFrequently: true });
15960
+ if (!ctx)
15961
+ return;
15962
+ ctx.drawImage(img, 0, 0);
15963
+ const data = ctx.getImageData(0, 0, w, h).data;
15964
+ this.sampler.computeSimpleHistogram(z, data, this.isGrayscaleImage);
15965
+ }
15966
+ catch (err) {
15967
+ console.warn('[OSD] simple-mode histogram sample failed', err);
15968
+ }
15969
+ }
15970
+ /** Fetch + decode a slice's per-channel planes (single-band grayscale) into
15971
+ * pixel buffers, for the SERVERLESS multichannel compositor. Auth-safe: goes
15972
+ * through SimpleSliceAccessService (blob:/data: as-is; http via HttpClient). */
15973
+ async loadSimpleChannelPlanes(urls) {
15974
+ const planes = [];
15975
+ for (const u of urls ?? []) {
15976
+ try {
15977
+ const previewUrl = await this.simpleStack.fetchAsBlobUrl(u);
15978
+ const img = await this.loadImageEl(previewUrl);
15979
+ const w = img.naturalWidth, h = img.naturalHeight;
15980
+ if (!w || !h) {
15981
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
15982
+ continue;
15983
+ }
15984
+ const c = document.createElement('canvas');
15985
+ c.width = w;
15986
+ c.height = h;
15987
+ const ctx = c.getContext('2d', { willReadFrequently: true });
15988
+ if (!ctx) {
15989
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
15990
+ continue;
15991
+ }
15992
+ ctx.drawImage(img, 0, 0);
15993
+ planes.push({ data: ctx.getImageData(0, 0, w, h).data, width: w, height: h });
15994
+ }
15995
+ catch (err) {
15996
+ console.warn('[OSD] channel plane decode failed', err);
15997
+ planes.push({ data: new Uint8ClampedArray(0), width: 0, height: 0 });
15998
+ }
15999
+ }
16000
+ this.simpleChannelPlanes = planes;
16001
+ }
16002
+ /** Composite the cached per-channel planes into ONE RGBA image using the
16003
+ * current channel states (colour/window/gamma/visibility) — the client-side,
16004
+ * serverless analog of the tiled per-channel 'lighter' compositor
16005
+ * (same display.channelRgbLut math as recolorChannelTile, applied per-plane).
16006
+ * Returns a blob: URL of the composite PNG, revoking the previous one. */
16007
+ async compositeSimpleMultichannel() {
16008
+ const planes = this.simpleChannelPlanes;
16009
+ if (!planes.length)
16010
+ return undefined;
16011
+ const w = planes[0].width, h = planes[0].height;
16012
+ if (!w || !h)
16013
+ return undefined;
16014
+ const states = this.store.currentChannelStates();
16015
+ const out = new Uint8ClampedArray(w * h * 4);
16016
+ for (let c = 0; c < planes.length; c++) {
16017
+ const st = states[c];
16018
+ if (st && st.visible === false)
16019
+ continue; // hidden channel contributes nothing
16020
+ const plane = planes[c];
16021
+ if (plane.width !== w || plane.height !== h || !plane.data.length)
16022
+ continue;
16023
+ const { r, g, b } = this.display.channelRgbLut(st);
16024
+ const pd = plane.data;
16025
+ for (let i = 0; i < out.length; i += 4) {
16026
+ if (pd[i + 3] === 0)
16027
+ continue;
16028
+ const lum = pd[i]; // single-band plane (R=G=B)
16029
+ out[i] += r[lum]; // Uint8ClampedArray clamps → additive ('lighter')
16030
+ out[i + 1] += g[lum];
16031
+ out[i + 2] += b[lum];
16032
+ }
16033
+ }
16034
+ for (let i = 3; i < out.length; i += 4)
16035
+ out[i] = 255; // opaque
16036
+ const canvas = document.createElement('canvas');
16037
+ canvas.width = w;
16038
+ canvas.height = h;
16039
+ const ctx = canvas.getContext('2d');
16040
+ if (!ctx)
16041
+ return undefined;
16042
+ ctx.putImageData(new ImageData(out, w, h), 0, 0);
16043
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
16044
+ if (!blob)
16045
+ return undefined;
16046
+ if (this.simpleCompositeUrl)
16047
+ URL.revokeObjectURL(this.simpleCompositeUrl);
16048
+ this.simpleCompositeUrl = URL.createObjectURL(blob);
16049
+ return this.simpleCompositeUrl;
16050
+ }
16051
+ /** Re-composite the current slice from its cached planes (new channel states)
16052
+ * and re-open — the serverless analog of the tiled invalidate-on-channel-change. */
16053
+ async recompositeAndOpen() {
16054
+ const url = await this.compositeSimpleMultichannel();
16055
+ if (!url || !this.viewer)
16056
+ return;
16057
+ try {
16058
+ this.viewer.open({ type: 'image', url });
16059
+ }
16060
+ catch { /* viewer gone */ }
16061
+ }
15865
16062
  /** Load a URL into an HTMLImageElement (resolves once decoded). Uses
15866
16063
  * document.createElement rather than `new Image()` because this file's
15867
16064
  * `Image` import is image-js's decoder, not the DOM element. */
@@ -15877,6 +16074,10 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15877
16074
  for (const u of this.simpleFullResUrls.values())
15878
16075
  URL.revokeObjectURL(u);
15879
16076
  this.simpleFullResUrls.clear();
16077
+ if (this.simpleCompositeUrl) {
16078
+ URL.revokeObjectURL(this.simpleCompositeUrl);
16079
+ this.simpleCompositeUrl = null;
16080
+ }
15880
16081
  }
15881
16082
  /** Mount the viewer with a custom tile source pointing at `GET /tile`. */
15882
16083
  plot(plotDiv, imageLoaded, imageInfo, _screenHeight, _plotType, inPlace) {
@@ -15932,6 +16133,15 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
15932
16133
  this.isMultiChannel = false;
15933
16134
  this.cache.clearChannelGroups();
15934
16135
  this.destroyViewer();
16136
+ // Serverless histogram — AFTER destroyViewer (its last act is sampler.clear()).
16137
+ // Multichannel bins each cached channel plane; single-band / RGB bins the
16138
+ // decoded frame's own pixels.
16139
+ if (this.simpleMultichannel) {
16140
+ this.sampler.computeSimpleMultichannelHistograms(loaded.z, this.simpleChannelPlanes);
16141
+ }
16142
+ else {
16143
+ void this.sampleSimpleHistogram(loaded.url, loaded.z);
16144
+ }
15935
16145
  }
15936
16146
  else {
15937
16147
  // Multichannel fluorescence (indexed/LUT-bearing stacks) composite client-side
@@ -16438,6 +16648,24 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
16438
16648
  if (!this.viewer)
16439
16649
  return;
16440
16650
  if (this.simpleMode) {
16651
+ if (this.simpleMultichannel) {
16652
+ const urls = this.simpleChannelUrls[z] ?? this.simpleChannelUrls[0];
16653
+ if (!urls)
16654
+ return;
16655
+ this.currentZ = z;
16656
+ this.viewportPixels = null;
16657
+ void (async () => {
16658
+ await this.loadSimpleChannelPlanes(urls);
16659
+ if (this.currentZ !== z || !this.viewer)
16660
+ return;
16661
+ this.sampler.computeSimpleMultichannelHistograms(z, this.simpleChannelPlanes);
16662
+ const url = await this.compositeSimpleMultichannel();
16663
+ if (this.currentZ !== z || !this.viewer || !url)
16664
+ return;
16665
+ this.viewer.open({ type: 'image', url });
16666
+ })();
16667
+ return;
16668
+ }
16441
16669
  const rawUrl = this.simpleUrls[z] ?? this.simpleUrls[0];
16442
16670
  if (!rawUrl)
16443
16671
  return;
@@ -16456,6 +16684,7 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
16456
16684
  console.warn('[OSD] slice re-open failed', e?.message ?? e);
16457
16685
  });
16458
16686
  this.viewer.open({ type: 'image', url });
16687
+ void this.sampleSimpleHistogram(url, z); // re-bin the scrubbed slice
16459
16688
  })
16460
16689
  .catch((err) => console.warn('[OSD] slice fetch failed', err));
16461
16690
  return;