@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.
- package/esm2022/lib/contracts/image.contract.mjs +1 -1
- package/esm2022/lib/implementations/osd/histogram-sampler.mjs +46 -1
- package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +194 -10
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs +238 -9
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
- package/lib/contracts/image.contract.d.ts +6 -0
- package/lib/implementations/osd/histogram-sampler.d.ts +17 -0
- package/lib/implementations/osd/openseadragon-visualizer.service.d.ts +25 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 (
|
|
15840
|
+
if (this.simpleMultichannel) {
|
|
15841
|
+
this.simpleChannelUrls = chUrls;
|
|
15777
15842
|
try {
|
|
15778
|
-
|
|
15779
|
-
|
|
15780
|
-
//
|
|
15781
|
-
|
|
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
|
|
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',
|
|
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;
|