@jax-data-science/sci-image-visualizer 0.2.5 → 0.2.6

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.
@@ -6675,6 +6675,13 @@ class VisualizerComponent {
6675
6675
  // the user doesn't mistake it for the final preview.
6676
6676
  this.sharpening = false;
6677
6677
  this.running = false;
6678
+ /**
6679
+ * Monotonic render generation. Bumped when a render starts, so a render that
6680
+ * has been superseded by a newer image can be recognised and made inert: its
6681
+ * callbacks return early instead of painting, clearing `running`, releasing
6682
+ * the newer render's overlay, or applying its ROIs.
6683
+ */
6684
+ this.renderToken = 0;
6678
6685
  this.zIndex = 0;
6679
6686
  this.maxIndex = 0;
6680
6687
  this.contextMenuItems = [];
@@ -6997,163 +7004,198 @@ class VisualizerComponent {
6997
7004
  this.loadedFileName = imgInfo.fileName;
6998
7005
  this.plotService.setImageMeta(this.imageInfo.imageMeta);
6999
7006
  const urls = imgInfo.urls;
7000
- if (!this.running) {
7007
+ // A newer image ALWAYS preempts an in-flight render. This was
7008
+ // `if (!this.running)`, which DROPPED the new image while the old one
7009
+ // finished: identity above had already flipped to the new file, so the
7010
+ // app reported image B as loaded while image A stayed on screen. Worse,
7011
+ // a cold image holds the slot for its whole server-side cache fill, so
7012
+ // every click for 1-2 minutes was discarded against a blank viewport
7013
+ // and a "Loading image..." overlay that never resolved.
7014
+ if (urls) {
7015
+ const token = ++this.renderToken;
7016
+ const isCurrent = () => this.renderToken === token;
7017
+ if (this.running) {
7018
+ // Stop the previous render's frame streaming (napari volume/surface
7019
+ // preload keeps fetching otherwise) and clear its sharpen flag. Its
7020
+ // callbacks are already inert via the token, so it can neither paint
7021
+ // nor release the overlay this render now owns.
7022
+ this.plotService.cancelLoading?.();
7023
+ this.sharpening = false;
7024
+ }
7001
7025
  // image size — measure the plot div directly so the toolbar height is excluded
7002
7026
  const plotDiv = document.getElementById(this.plotDivName);
7003
7027
  const screenHeight = plotDiv?.offsetHeight || 500;
7004
- if (urls) {
7005
- this.plotService.reset();
7006
- this.stackLoading = imgInfo.isStack && imgInfo.showStack;
7007
- // set max index of stack — computed before updateZIndex so a
7008
- // fresh stack (shorter or longer than whatever was previously
7009
- // loaded) clamps against ITS bounds, not the stale ones.
7010
- // One URL per slice (0-indexed), so the last reachable index is
7011
- // length-1 — earlier `length-2` dropped the final slice.
7012
- this.maxIndex = urls.length > 1 ? urls.length - 1 : 0;
7013
- // One-shot hint: jump straight to a specific slice (e.g. the
7014
- // file the user actually clicked within a numbered series).
7015
- // Consumed immediately so redelivering the same ImageInfo later
7016
- // (a plot-type switch, reloadAndPlot) doesn't reset the user's
7017
- // current scrub position back to it.
7018
- if (imgInfo.initialZIndex !== undefined) {
7019
- this.zIndex = imgInfo.initialZIndex;
7020
- imgInfo.initialZIndex = undefined;
7028
+ this.plotService.reset();
7029
+ this.stackLoading = imgInfo.isStack && imgInfo.showStack;
7030
+ // set max index of stack — computed before updateZIndex so a
7031
+ // fresh stack (shorter or longer than whatever was previously
7032
+ // loaded) clamps against ITS bounds, not the stale ones.
7033
+ // One URL per slice (0-indexed), so the last reachable index is
7034
+ // length-1 — earlier `length-2` dropped the final slice.
7035
+ this.maxIndex = urls.length > 1 ? urls.length - 1 : 0;
7036
+ // One-shot hint: jump straight to a specific slice (e.g. the
7037
+ // file the user actually clicked within a numbered series).
7038
+ // Consumed immediately so redelivering the same ImageInfo later
7039
+ // (a plot-type switch, reloadAndPlot) doesn't reset the user's
7040
+ // current scrub position back to it.
7041
+ if (imgInfo.initialZIndex !== undefined) {
7042
+ this.zIndex = imgInfo.initialZIndex;
7043
+ imgInfo.initialZIndex = undefined;
7044
+ }
7045
+ // make sure the zindex is within bounds
7046
+ this.updateZIndex(urls);
7047
+ this.running = true;
7048
+ // Multi-tier rendering (small blurry tier first, then sharpen in
7049
+ // place) — sequencing lives in RenderOrchestrator; this component
7050
+ // supplies the phase render and owns the UI flags via callbacks.
7051
+ // 3D plot types render single-pass: the in-place large pass doesn't
7052
+ // rebuild a 3D gl-mesh isosurface, so the sharpen step blanked it.
7053
+ const hasSmallTier = this.isHeatmap &&
7054
+ (imgInfo.smallUrls?.length ?? 0) === urls.length &&
7055
+ (imgInfo.smallUrls?.length ?? 0) > 0;
7056
+ const smallImgInfo = hasSmallTier ? { ...imgInfo, urls: imgInfo.smallUrls } : null;
7057
+ // Enter per-slice stack mode with the given slice→regions map and
7058
+ // seed the "previous shapes" from the now-live slice. enterStackMode
7059
+ // makes zIndex's slice live and resets undo (jit-ui#93).
7060
+ const enterStack = (slices, layout) => {
7061
+ this.plotService.enterStackMode(slices, this.zIndex, layout);
7062
+ const shapes = this.plotService
7063
+ .getRegions()
7064
+ .map((region) => region.getShape(this.plotService.getShowShapeLabel()));
7065
+ this.plotService.setPreviousShapes(shapes);
7066
+ };
7067
+ const applyRoi = () => {
7068
+ // Folder stack: a stack of self-contained per-slice files
7069
+ // (tiled === false; see loadSeriesAsStack). Each slice-file may
7070
+ // carry its own sibling "<stem>.geojson" (roiJsonStrs[z]), but a
7071
+ // fresh folder with none yet leaves roiJsonStrs undefined — key
7072
+ // off `tiled === false`, NOT roiJsonStrs, so an unannotated
7073
+ // folder stack still enters the per-slice-file layout (and saves
7074
+ // back one geojson per slice-file) rather than falling through to
7075
+ // the single-file combined path (jit-ui#93).
7076
+ if (imgInfo.isStack && imgInfo.tiled === false) {
7077
+ const perSlice = imgInfo.roiJsonStrs;
7078
+ const sliceCount = imgInfo.urls?.length ?? perSlice?.length ?? 0;
7079
+ const slices = new Map();
7080
+ for (let z = 0; z < sliceCount; z++) {
7081
+ const json = perSlice?.[z] ?? null;
7082
+ slices.set(z, json ? this.plotService.importRegions(json) : []);
7083
+ }
7084
+ enterStack(slices, 'per-slice-file');
7085
+ return;
7021
7086
  }
7022
- // make sure the zindex is within bounds
7023
- this.updateZIndex(urls);
7024
- this.running = true;
7025
- // Multi-tier rendering (small blurry tier first, then sharpen in
7026
- // place) — sequencing lives in RenderOrchestrator; this component
7027
- // supplies the phase render and owns the UI flags via callbacks.
7028
- // 3D plot types render single-pass: the in-place large pass doesn't
7029
- // rebuild a 3D gl-mesh isosurface, so the sharpen step blanked it.
7030
- const hasSmallTier = this.isHeatmap &&
7031
- (imgInfo.smallUrls?.length ?? 0) === urls.length &&
7032
- (imgInfo.smallUrls?.length ?? 0) > 0;
7033
- const smallImgInfo = hasSmallTier ? { ...imgInfo, urls: imgInfo.smallUrls } : null;
7034
- // Enter per-slice stack mode with the given slice→regions map and
7035
- // seed the "previous shapes" from the now-live slice. enterStackMode
7036
- // makes zIndex's slice live and resets undo (jit-ui#93).
7037
- const enterStack = (slices, layout) => {
7038
- this.plotService.enterStackMode(slices, this.zIndex, layout);
7039
- const shapes = this.plotService
7040
- .getRegions()
7041
- .map((region) => region.getShape(this.plotService.getShowShapeLabel()));
7042
- this.plotService.setPreviousShapes(shapes);
7043
- };
7044
- const applyRoi = () => {
7045
- // Folder stack: a stack of self-contained per-slice files
7046
- // (tiled === false; see loadSeriesAsStack). Each slice-file may
7047
- // carry its own sibling "<stem>.geojson" (roiJsonStrs[z]), but a
7048
- // fresh folder with none yet leaves roiJsonStrs undefined — key
7049
- // off `tiled === false`, NOT roiJsonStrs, so an unannotated
7050
- // folder stack still enters the per-slice-file layout (and saves
7051
- // back one geojson per slice-file) rather than falling through to
7052
- // the single-file combined path (jit-ui#93).
7053
- if (imgInfo.isStack && imgInfo.tiled === false) {
7054
- const perSlice = imgInfo.roiJsonStrs;
7055
- const sliceCount = imgInfo.urls?.length ?? perSlice?.length ?? 0;
7087
+ // Single-file z-stack: one sibling geojson holding every slice's
7088
+ // regions indexed by QuPath's geometry.plane.z. Enter per-slice
7089
+ // mode (saving back one combined z-indexed geojson) when the
7090
+ // geojson actually carries slice indices, or when there's nothing
7091
+ // yet to author against. A legacy geojson whose regions are all on
7092
+ // the default plane stays global (shown on every slice) so
7093
+ // existing single-plane annotations aren't confined to slice 0.
7094
+ const roiJson = imgInfo.roiJsonStr;
7095
+ if (imgInfo.isStack) {
7096
+ const regions = roiJson ? this.plotService.importRegions(roiJson) : [];
7097
+ const hasSliceInfo = regions.some((r) => (r.z ?? 0) !== 0);
7098
+ if (!roiJson || hasSliceInfo) {
7056
7099
  const slices = new Map();
7057
- for (let z = 0; z < sliceCount; z++) {
7058
- const json = perSlice?.[z] ?? null;
7059
- slices.set(z, json ? this.plotService.importRegions(json) : []);
7100
+ for (const r of regions) {
7101
+ const z = r.z ?? 0;
7102
+ const bucket = slices.get(z);
7103
+ if (bucket)
7104
+ bucket.push(r);
7105
+ else
7106
+ slices.set(z, [r]);
7060
7107
  }
7061
- enterStack(slices, 'per-slice-file');
7108
+ enterStack(slices, 'combined');
7062
7109
  return;
7063
7110
  }
7064
- // Single-file z-stack: one sibling geojson holding every slice's
7065
- // regions indexed by QuPath's geometry.plane.z. Enter per-slice
7066
- // mode (saving back one combined z-indexed geojson) when the
7067
- // geojson actually carries slice indices, or when there's nothing
7068
- // yet to author against. A legacy geojson whose regions are all on
7069
- // the default plane stays global (shown on every slice) so
7070
- // existing single-plane annotations aren't confined to slice 0.
7071
- const roiJson = imgInfo.roiJsonStr;
7072
- if (imgInfo.isStack) {
7073
- const regions = roiJson ? this.plotService.importRegions(roiJson) : [];
7074
- const hasSliceInfo = regions.some((r) => (r.z ?? 0) !== 0);
7075
- if (!roiJson || hasSliceInfo) {
7076
- const slices = new Map();
7077
- for (const r of regions) {
7078
- const z = r.z ?? 0;
7079
- const bucket = slices.get(z);
7080
- if (bucket)
7081
- bucket.push(r);
7082
- else
7083
- slices.set(z, [r]);
7084
- }
7085
- enterStack(slices, 'combined');
7086
- return;
7087
- }
7088
- }
7089
- // Single-plane image (or a legacy global z-stack geojson): one
7090
- // region set for the whole image.
7091
- if (roiJson) {
7092
- const regions = this.plotService.importRegions(roiJson);
7093
- this.plotService.setRegions(regions);
7094
- const shapes = regions.map((region) => region.getShape(this.plotService.getShowShapeLabel()));
7095
- this.plotService.setPreviousShapes(shapes);
7096
- }
7097
- // Loading an image's saved ROIs is not a user edit — start the
7098
- // undo history fresh so the first undo can't wipe them (jit-ui#85).
7099
- this.plotService.resetUndoHistory();
7100
- };
7101
- let overlayReleased = false;
7102
- const releaseOverlay = () => {
7103
- if (overlayReleased)
7104
- return;
7105
- overlayReleased = true;
7106
- if (imgInfo.isStack && imgInfo.showStack)
7107
- this.stackLoading = false;
7108
- this.state.setImageLoading(false);
7109
- };
7110
- new RenderOrchestrator({
7111
- // inPlace=true updates the existing render instead of rebuilding
7112
- // it, so the canvas doesn't blank during the small→large swap.
7113
- renderPhase: (phaseInfo, inPlace) => this.plotService.load(phaseInfo, this.zIndex).then((loadedImage) => {
7111
+ }
7112
+ // Single-plane image (or a legacy global z-stack geojson): one
7113
+ // region set for the whole image.
7114
+ if (roiJson) {
7115
+ const regions = this.plotService.importRegions(roiJson);
7116
+ this.plotService.setRegions(regions);
7117
+ const shapes = regions.map((region) => region.getShape(this.plotService.getShowShapeLabel()));
7118
+ this.plotService.setPreviousShapes(shapes);
7119
+ }
7120
+ // Loading an image's saved ROIs is not a user edit — start the
7121
+ // undo history fresh so the first undo can't wipe them (jit-ui#85).
7122
+ this.plotService.resetUndoHistory();
7123
+ };
7124
+ let overlayReleased = false;
7125
+ const releaseOverlay = () => {
7126
+ if (overlayReleased)
7127
+ return;
7128
+ overlayReleased = true;
7129
+ if (imgInfo.isStack && imgInfo.showStack)
7130
+ this.stackLoading = false;
7131
+ this.state.setImageLoading(false);
7132
+ };
7133
+ new RenderOrchestrator({
7134
+ // inPlace=true updates the existing render instead of rebuilding
7135
+ // it, so the canvas doesn't blank during the small→large swap.
7136
+ renderPhase: (phaseInfo, inPlace) => {
7137
+ // Superseded BEFORE this phase started — don't even issue the load.
7138
+ // RenderOrchestrator calls renderPhase once per tier and retries the
7139
+ // sharpen pass on failure, so checking only after the load resolves
7140
+ // would let a preempted render keep fetching slices/tiles for an
7141
+ // image nobody is looking at.
7142
+ if (!isCurrent())
7143
+ return Promise.resolve(null);
7144
+ return this.plotService.load(phaseInfo, this.zIndex).then((loadedImage) => {
7145
+ // Preempted while this phase was loading — drop it on the floor.
7146
+ if (!isCurrent())
7147
+ return null;
7114
7148
  // Guard against a newer click reaching us mid-render.
7115
7149
  if (phaseInfo.fileName !== loadedImage.filename)
7116
7150
  return null;
7117
7151
  return this.plotService.plot(this.plotDivName, loadedImage, phaseInfo, screenHeight, this.plotType, inPlace);
7118
- }),
7119
- smallShown: () => {
7120
- // If the file is still being cached/prepared server-side, keep the full
7121
- // cache-progress overlay up instead of dropping to the translucent "Sharpening
7122
- // preview..." spinner over a blank canvas (a large uncached image renders its
7123
- // small preview before its tiles exist). finished() releases the overlay once
7124
- // the real render lands.
7125
- if (this.isCaching)
7126
- return;
7127
- // Small tier on screen — drop the full overlay but keep a translucent spinner so
7128
- // the blurry render isn't mistaken for the final image.
7129
- releaseOverlay();
7130
- this.sharpening = true;
7131
- },
7132
- sharpenSettled: () => {
7133
- this.sharpening = false;
7134
- },
7135
- finished: (_viaSmall, logTag) => {
7136
- // Idempotent — releases now if smallShown deferred it (caching) or was skipped.
7137
- releaseOverlay();
7138
- this.running = false;
7139
- applyRoi();
7140
- console.log(logTag);
7141
- },
7142
- sharpenFailed: (err) => {
7143
- // The small tier stays on screen as the fallback — tell the
7144
- // user the sharper version isn't coming.
7145
- const msg = err?.error?.message || err?.message || err?.statusText || String(err);
7146
- this.messageService.add({
7147
- key: this.vizAlertToastKey,
7148
- severity: 'warn',
7149
- summary: 'Preview not sharpened',
7150
- detail: `The full-resolution preview did not load (${msg}). The low-resolution preview is still shown. Try clicking the image again.`,
7151
- });
7152
- this.running = false;
7153
- applyRoi();
7154
- },
7155
- }).render(imgInfo, smallImgInfo);
7156
- }
7152
+ });
7153
+ },
7154
+ smallShown: () => {
7155
+ if (!isCurrent())
7156
+ return; // superseded by a newer image
7157
+ // If the file is still being cached/prepared server-side, keep the full
7158
+ // cache-progress overlay up instead of dropping to the translucent "Sharpening
7159
+ // preview..." spinner over a blank canvas (a large uncached image renders its
7160
+ // small preview before its tiles exist). finished() releases the overlay once
7161
+ // the real render lands.
7162
+ if (this.isCaching)
7163
+ return;
7164
+ // Small tier on screen — drop the full overlay but keep a translucent spinner so
7165
+ // the blurry render isn't mistaken for the final image.
7166
+ releaseOverlay();
7167
+ this.sharpening = true;
7168
+ },
7169
+ sharpenSettled: () => {
7170
+ if (!isCurrent())
7171
+ return; // superseded by a newer image
7172
+ this.sharpening = false;
7173
+ },
7174
+ finished: (_viaSmall, logTag) => {
7175
+ if (!isCurrent())
7176
+ return; // superseded by a newer image
7177
+ // Idempotent — releases now if smallShown deferred it (caching) or was skipped.
7178
+ releaseOverlay();
7179
+ this.running = false;
7180
+ applyRoi();
7181
+ console.log(logTag);
7182
+ },
7183
+ sharpenFailed: (err) => {
7184
+ if (!isCurrent())
7185
+ return; // superseded by a newer image
7186
+ // The small tier stays on screen as the fallback — tell the
7187
+ // user the sharper version isn't coming.
7188
+ const msg = err?.error?.message || err?.message || err?.statusText || String(err);
7189
+ this.messageService.add({
7190
+ key: this.vizAlertToastKey,
7191
+ severity: 'warn',
7192
+ summary: 'Preview not sharpened',
7193
+ detail: `The full-resolution preview did not load (${msg}). The low-resolution preview is still shown. Try clicking the image again.`,
7194
+ });
7195
+ this.running = false;
7196
+ applyRoi();
7197
+ },
7198
+ }).render(imgInfo, smallImgInfo);
7157
7199
  }
7158
7200
  }
7159
7201
  },