From 26c723bc20d578637cdb48e3ac8fcf29f49a202f Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Thu, 7 May 2026 15:25:25 +0200 Subject: [PATCH 1/5] Initial version of coronal/sagital/axial view for 3d volumes. --- .../annotations/js/exact-image-viewer.js | 64 +++++++ .../annotations/js/show-image-properties.js | 8 +- .../templates/annotations/annotate.html | 11 +- .../templates/annotations/annotate_v2.html | 11 +- exact/exact/images/views.py | 47 ++++- exact/util/enums.py | 6 + exact/util/nifti.py | 143 ++++++++++----- exact/util/slide_server.py | 170 ++++++++++++++---- 8 files changed, 365 insertions(+), 95 deletions(-) diff --git a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js index 74b1a486..434a0c36 100644 --- a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js +++ b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js @@ -45,6 +45,11 @@ class EXACTViewer { this.heatmapInvToggle = false; this.heatmapToggle = false; + this.currentPlane = 0; + this.mprPlanes = null; + this._mprHandler = this.onMPRPlanesAvailable.bind(this); + window.addEventListener('exactMPRPlanesAvailable', this._mprHandler); + $(document).keyup(this.handleKeyUp.bind(this)); } @@ -708,7 +713,66 @@ class EXACTViewer { return; } + onMPRPlanesAvailable(event) { + if (parseInt(event.detail.imageId) !== this.imageId) return; + this.mprPlanes = event.detail.planes; + $('#planeSelector').show(); + const planeNames = ['axial', 'coronal', 'sagittal']; + planeNames.forEach((name, idx) => { + $(`#planeBtn_${idx}`) + .off('click.mpr') + .on('click.mpr', () => this.switchPlane(idx)); + }); + this.updatePlaneButtons(); + } + + updatePlaneButtons() { + ['axial', 'coronal', 'sagittal'].forEach((_, idx) => { + $(`#planeBtn_${idx}`).toggleClass('active', idx === this.currentPlane); + }); + } + + switchPlane(plane) { + if (!this.mprPlanes || plane === this.currentPlane) return; + this.currentPlane = plane; + this.updatePlaneButtons(); + + const planeNames = ['axial', 'coronal', 'sagittal']; + const planeInfo = this.mprPlanes[planeNames[plane]]; + if (!planeInfo) return; + + const nFrames = planeInfo.nFrames; + const zDim = plane + 1; + + const tileSources = []; + for (let f = 0; f < nFrames; f++) { + tileSources.push(`${this.server_url}/images/image/${this.imageId}/${zDim}/${f + 1}/tile/`); + } + + // Rebuild the frame slider for the new plane's frame count so the + // range and value are correct before viewer.open() fires its page event. + if (this.frameSlider !== undefined) { + this.frameSlider.destroy(); + this.frameSlider = undefined; + } + if (nFrames > 1) { + this.frameSlider = new Slider("#frameSlider", { + ticks_snap_bounds: 1, + value: 1, + min: 0, + tooltip: 'always', + max: nFrames - 1 + }); + this.frameSlider.on('change', this.onFrameSliderChanged.bind(this)); + } + + this.viewer.open(tileSources); + } + destroy() { + window.removeEventListener('exactMPRPlanesAvailable', this._mprHandler); + $('#planeSelector').hide(); + if (this.gZoomSlider !== undefined) { this.gZoomSlider.destroy(); } diff --git a/exact/exact/annotations/static/annotations/js/show-image-properties.js b/exact/exact/annotations/static/annotations/js/show-image-properties.js index 0e9488c7..e82550a0 100644 --- a/exact/exact/annotations/static/annotations/js/show-image-properties.js +++ b/exact/exact/annotations/static/annotations/js/show-image-properties.js @@ -30,10 +30,16 @@ class ShowImageProperties{ let table = "" for (let [key,value] of Object.entries(data.meta_data)) { + if (key === 'planes') continue; table += ""+data.meta_data_dict[key]+""+value+'' } $("#image_info_table").html(table) -// window.dispatchEvent(new CustomEvent("sync_ProcessingJobListLoaded", {"detail": context})); + + if (data.meta_data.planes) { + window.dispatchEvent(new CustomEvent("exactMPRPlanesAvailable", { + detail: { imageId: imageId, planes: data.meta_data.planes } + })); + } }, error: function (request, status, error) { if (request.responseText !== undefined) { diff --git a/exact/exact/annotations/templates/annotations/annotate.html b/exact/exact/annotations/templates/annotations/annotate.html index 1918d4b9..5ab17d92 100644 --- a/exact/exact/annotations/templates/annotations/annotate.html +++ b/exact/exact/annotations/templates/annotations/annotate.html @@ -868,7 +868,16 @@
{{ selected_image.name }}
-
+
+
+ +
diff --git a/exact/exact/annotations/templates/annotations/annotate_v2.html b/exact/exact/annotations/templates/annotations/annotate_v2.html index 1f7cd504..878a6bf4 100644 --- a/exact/exact/annotations/templates/annotations/annotate_v2.html +++ b/exact/exact/annotations/templates/annotations/annotate_v2.html @@ -883,7 +883,16 @@
{{ selected_image.name }}
-
+
+
+ +
diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index ec913e4c..8d4c7649 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -415,13 +415,23 @@ def view_image(request, image_id, z_dimension:int=1, frame:int=1): if not image.image_set.has_perm('read', request.user): return HttpResponseForbidden() + # For single-file volumetric images (NIfTI etc.), z_dimension encodes the + # MPR plane: 1=axial, 2=coronal, 3=sagittal. Multi-file z-stacks keep + # z_dimension as a file-level selector and always use plane 0. + if image.depth == 1: + plane = max(0, z_dimension - 1) + effective_z = 1 + else: + plane = 0 + effective_z = z_dimension + cache_key = f"{image_id}_{z_dimension}_{frame}_get_dzi" value = cache.get(cache_key) if value is not None: return HttpResponse(value, content_type='application/xml') - - file_path = os.path.join(settings.IMAGE_PATH, image.path(z_dimension, frame)) - slide = image_cache.get(file_path) + + file_path = os.path.join(settings.IMAGE_PATH, image.path(effective_z, frame)) + slide = image_cache.get(file_path, plane=plane) value = slide.get_dzi("jpeg") if hasattr(cache, "delete_pattern"): @@ -459,6 +469,21 @@ def image_metadata(request, image_id) -> Response: if (slideobj.nFrames>1): meta_data['frame_type'] = slideobj.frame_type + if hasattr(slideobj, 'dimensions_for_plane') and slideobj.dimensions_for_plane(0) is not None: + plane_names = ['axial', 'coronal', 'sagittal'] + planes = {} + for p, name in enumerate(plane_names): + dims = slideobj.dimensions_for_plane(p) + if dims is not None: + planes[name] = { + 'plane': p, + 'dimensions': list(dims), + 'nFrames': slideobj.nframes_for_plane(p), + } + if planes: + meta_data['planes'] = planes + meta_data_dict['planes'] = 'MPR planes' + meta_data.update(slideobj.meta_data) meta_data_dict.update(slideobj.meta_data_dict) @@ -595,13 +620,21 @@ def view_image_tile(request, image_id, z_dimension, frame, level, tile_path): image = get_object_or_404(Image, id=image_id) if not image.image_set.has_perm('read', request.user): return HttpResponseForbidden() - - file_path = os.path.join(settings.IMAGE_PATH, image.path(z_dimension, frame)) + + if image.depth == 1: + plane = max(0, z_dimension - 1) + effective_z = 1 + else: + plane = 0 + effective_z = z_dimension + + file_path = os.path.join(settings.IMAGE_PATH, image.path(effective_z, frame)) try: - slide = image_cache.get(file_path) + slide = image_cache.get(file_path, plane=plane) - tile = slide.get_tile(level, (col, row),frame=min(frame, image.frames-1)) + n_frames = slide.nFrames if slide.nFrames is not None else image.frames + tile = slide.get_tile(level, (col, row), frame=min(frame, n_frames - 1), plane=plane) buf = PILBytesIO() tile.save(buf, format, quality=90) diff --git a/exact/util/enums.py b/exact/util/enums.py index b4cffcd9..202a8c2b 100644 --- a/exact/util/enums.py +++ b/exact/util/enums.py @@ -3,3 +3,9 @@ class FrameType: TIMESERIES = 1 UNDEFINED = 255 + +class PlaneType: + AXIAL = 0 # XY plane, sliced at z → frame = z index + CORONAL = 1 # XZ plane, sliced at y → frame = y index + SAGITTAL = 2 # YZ plane, sliced at x → frame = x index + diff --git a/exact/util/nifti.py b/exact/util/nifti.py index f69c40e7..97ac48a8 100644 --- a/exact/util/nifti.py +++ b/exact/util/nifti.py @@ -4,7 +4,7 @@ import numpy as np from PIL import Image -from util.enums import FrameType +from util.enums import FrameType, PlaneType import openslide @@ -12,8 +12,15 @@ class NIfTISlide: """OpenSlide-compatible reader for NIfTI (.nii, .nii.gz) volumetric images. - Each z-slice is presented as a Z-stack frame. Intensity is auto-windowed - using the 1st–99th percentile of a sparse sample for display. + Supports axial, coronal, and sagittal reformats via the `plane` parameter + on read_region / dimensions_for_plane / nframes_for_plane. + + Data is always reoriented to RAS+ at load time so that: + axis 0 (X) increases Right, axis 1 (Y) increases Anterior, + axis 2 (Z) increases Superior. + + Display uses radiological convention (Anterior / Superior at top, + patient Right on the left of the image) to match 3D Slicer's defaults. """ filename: str @@ -50,7 +57,7 @@ def __post_init__(self): # Flatten extra dimensions (time, channels, …) into z raw = raw.reshape(raw.shape[0], raw.shape[1], -1) - self._data = raw # shape: (X, Y, Z) + self._data = raw # shape: (X, Y, Z) in RAS+ # Derive voxel sizes from the affine rather than header.get_zooms(). # get_zooms() reads pixdim from the raw NIfTI header struct, which @@ -58,41 +65,67 @@ def __post_init__(self): # as_closest_canonical permutes the axes. The affine is always # recomputed correctly by nibabel during reorientation. vox_sizes = np.sqrt((img.affine[:3, :3] ** 2).sum(axis=0)) - sx = float(vox_sizes[0]) if vox_sizes[0] > 0 else 1.0 - sy = float(vox_sizes[1]) if vox_sizes[1] > 0 else 1.0 - sz = float(vox_sizes[2]) if len(vox_sizes) > 2 and vox_sizes[2] > 0 else 1.0 - - # NIfTI voxel sizes are in mm; EXACT expects µm for mpp - self._mppx = sx * 1000.0 - self._mppy = sy * 1000.0 - self._mppz = sz # mm, used in frame labels - - # Physical in-plane pixel dimensions after voxel aspect ratio correction. - # Normalise to the finest in-plane voxel so neither axis loses detail: - # the coarser axis is upsampled to match the finer one in physical space. - ref = min(sx, sy) - nx, ny = int(self._data.shape[0]), int(self._data.shape[1]) - self._px_width = max(1, round(nx * sx / ref)) - self._px_height = max(1, round(ny * sy / ref)) + self._sx = float(vox_sizes[0]) if vox_sizes[0] > 0 else 1.0 # mm, X = Right + self._sy = float(vox_sizes[1]) if vox_sizes[1] > 0 else 1.0 # mm, Y = Anterior + self._sz = float(vox_sizes[2]) if len(vox_sizes) > 2 and vox_sizes[2] > 0 else 1.0 # mm, Z = Superior + + # NIfTI voxel sizes are in mm; EXACT expects µm for mpp (axial plane) + self._mppx = self._sx * 1000.0 + self._mppy = self._sy * 1000.0 + + nx, ny, nz = self._data.shape + # Physical pixel dimensions for each reformat plane after aspect-ratio correction. + # The finest in-plane voxel is the reference so neither axis loses detail. + self._ax_dims = self._plane_px_dims(self._sx, self._sy, nx, ny) # axial XY + self._cor_dims = self._plane_px_dims(self._sx, self._sz, nx, nz) # coronal XZ + self._sag_dims = self._plane_px_dims(self._sy, self._sz, ny, nz) # sagittal YZ # Compute robust display window from a sparse sample to avoid a full scan flat = self._data.ravel() step = max(1, len(flat) // 100_000) sample = flat[::step].astype(np.float32) - sample = sample[sample>sample.min()] # ignore absolute minimum + sample = sample[sample > sample.min()] # ignore absolute minimum (air/background) self._wmin = float(np.percentile(sample, 1)) self._wmax = float(np.percentile(sample, 99)) if self._wmax <= self._wmin: self._wmax = self._wmin + 1.0 # ------------------------------------------------------------------ - # OpenSlide-compatible interface + # Plane helpers + # ------------------------------------------------------------------ + + @staticmethod + def _plane_px_dims(s1: float, s2: float, n1: int, n2: int) -> Tuple[int, int]: + """Return physical (width, height) in pixels for a plane with spacings s1, s2 mm.""" + ref = min(s1, s2) + return (max(1, round(n1 * s1 / ref)), max(1, round(n2 * s2 / ref))) + + def dimensions_for_plane(self, plane: int = PlaneType.AXIAL) -> Tuple[int, int]: + """Physical (width, height) in pixels for the given plane.""" + return (self._ax_dims, self._cor_dims, self._sag_dims)[plane] + + def nframes_for_plane(self, plane: int = PlaneType.AXIAL) -> int: + """Number of slices available along the normal axis of the given plane.""" + nx, ny, nz = self._data.shape + return (nz, ny, nx)[plane] + + def frame_descriptors_for_plane(self, plane: int = PlaneType.AXIAL) -> List[str]: + """Human-readable position label for each frame of the given plane.""" + nx, ny, nz = self._data.shape + if plane == PlaneType.CORONAL: + return ['y=%.2f mm' % (i * self._sy) for i in range(ny)] + if plane == PlaneType.SAGITTAL: + return ['x=%.2f mm' % (i * self._sx) for i in range(nx)] + return ['z=%.2f mm' % (i * self._sz) for i in range(nz)] + + # ------------------------------------------------------------------ + # OpenSlide-compatible interface (defaults to axial plane) # ------------------------------------------------------------------ @property def dimensions(self) -> Tuple[int, int]: - """(width, height) of one axial slice in physical pixels.""" - return (self._px_width, self._px_height) + """(width, height) of the axial plane in physical pixels.""" + return self._ax_dims @property def level_count(self) -> int: @@ -100,7 +133,7 @@ def level_count(self) -> int: @property def level_dimensions(self) -> List[Tuple[int, int]]: - return [(self._px_width, self._px_height)] + return [self._ax_dims] @property def level_downsamples(self) -> List[float]: @@ -120,7 +153,7 @@ def properties(self) -> Dict[str, str]: } # ------------------------------------------------------------------ - # Z-stack / frame interface + # Z-stack / frame interface (defaults to axial plane) # ------------------------------------------------------------------ @property @@ -133,44 +166,61 @@ def frame_type(self) -> FrameType: @property def frame_descriptors(self) -> List[str]: - return ['z=%.2f mm' % (i * self._mppz) for i in range(self.nFrames)] + return self.frame_descriptors_for_plane(PlaneType.AXIAL) @property def default_frame(self) -> int: - return self.nFrames // 2 + return self._data.shape[2] // 2 # ------------------------------------------------------------------ - # Region reading + # Rendering # ------------------------------------------------------------------ - def _render_slice(self, z_idx: int) -> np.ndarray: - """Return a uint8 RGBA array (height, width, 4) for slice z_idx. + def _render_plane(self, frame: int, plane: int = PlaneType.AXIAL) -> np.ndarray: + """Return a uint8 RGBA array at the physical pixel size for the given plane. - The output dimensions match self.dimensions (physical pixels), with - voxel aspect ratio already applied. + Radiological convention is applied in all three planes: + axial – Anterior at top, patient Right on left + coronal – Superior at top, patient Right on left + sagittal – Superior at top, Posterior on left (view from patient's left) """ - z_idx = max(0, min(z_idx, self.nFrames - 1)) - # _data shape is (X, Y, Z); transpose the XY plane to (Y, X) = (height, width). - # Then apply radiological convention: flip rows so Anterior is at the top, - # flip columns so patient Right is on the left — matching 3D Slicer's default. - slc = self._data[:, :, z_idx].astype(np.float32).T - slc = slc[::-1, ::-1] + nx, ny, nz = self._data.shape + + if plane == PlaneType.CORONAL: + y = max(0, min(frame, ny - 1)) + # data[:, y, :] → (nx, nz); .T → (nz, nx) = (height, width) + slc = self._data[:, y, :].astype(np.float32).T + slc = slc[::-1, ::-1] # Superior at top, Right on left + pw, ph = self._cor_dims + + elif plane == PlaneType.SAGITTAL: + x = max(0, min(frame, nx - 1)) + # data[x, :, :] → (ny, nz); .T → (nz, ny) = (height, width) + slc = self._data[x, :, :].astype(np.float32).T + slc = slc[::-1, ::-1] # Superior at top, Anterior on left (view from patient's right) + pw, ph = self._sag_dims + + else: # AXIAL + z = max(0, min(frame, nz - 1)) + # data[:, :, z] → (nx, ny); .T → (ny, nx) = (height, width) + slc = self._data[:, :, z].astype(np.float32).T + slc = slc[::-1, ::-1] # Anterior at top, Right on left + pw, ph = self._ax_dims + slc = np.clip( (slc - self._wmin) / (self._wmax - self._wmin) * 255.0, 0, 255, ).astype(np.uint8) rgba = np.stack([slc, slc, slc, np.full_like(slc, 255)], axis=-1) - # Resize to physical pixel dimensions when voxels are not isotropic. - if rgba.shape[1] != self._px_width or rgba.shape[0] != self._px_height: + + if rgba.shape[1] != pw or rgba.shape[0] != ph: rgba = np.array( - Image.fromarray(rgba, 'RGBA').resize( - (self._px_width, self._px_height), Image.LANCZOS - ) + Image.fromarray(rgba, 'RGBA').resize((pw, ph), Image.LANCZOS) ) return rgba def get_thumbnail(self, size: Tuple[int, int]) -> Image.Image: - rgba = self._render_slice(self.default_frame) + rgba = self._render_plane(self.default_frame, PlaneType.AXIAL) return Image.fromarray(rgba, 'RGBA').resize(size, Image.LANCZOS) def read_region( @@ -179,10 +229,11 @@ def read_region( level: int, size: Tuple[int, int], frame: int = 0, + plane: int = PlaneType.AXIAL, ) -> Image.Image: x, y = location width, height = size - rgba_full = self._render_slice(frame) + rgba_full = self._render_plane(frame, plane) img_h, img_w = rgba_full.shape[:2] canvas = np.zeros((height, width, 4), dtype=np.uint8) diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 1d20149d..eb819505 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -27,20 +27,25 @@ from util.tiffzstack import OMETiffSlide, OMETiffZStack from util.slideio import SlideIOSlide from util.nifti import NIfTISlide -from util.enums import FrameType +from util.enums import FrameType, PlaneType class zDeepZoomGenerator(DeepZoomGenerator): - def get_tile(self, level, address, frame=0): + def get_tile(self, level, address, frame=0, plane=PlaneType.AXIAL): """Return an RGB PIL.Image for a tile. level: the Deep Zoom level. - address: the address of the tile within the level as a (col, row) - tuple.""" + address: the address of the tile within the level as a (col, row) tuple. + frame: slice index within the chosen plane. + plane: PlaneType constant (AXIAL=0, CORONAL=1, SAGITTAL=2). + """ - # Read tile + # Read tile — pass plane only to handlers that declare MPR support args, z_size = self._get_tile_info(level, address) - tile = self._osr.read_region(*args, frame=frame) + if hasattr(self._osr, 'dimensions_for_plane'): + tile = self._osr.read_region(*args, frame=frame, plane=plane) + else: + tile = self._osr.read_region(*args, frame=frame) profile = tile.info.get('icc_profile') # Apply on solid background @@ -58,41 +63,54 @@ def get_tile(self, level, address, frame=0): tile.info['icc_profile'] = profile return tile - + @property def dimensions(self): - if hasattr(self._osr,'dimensions'): + if hasattr(self._osr, 'dimensions'): return self._osr.dimensions - else: - return [0] - + return [0] + + def dimensions_for_plane(self, plane: int = PlaneType.AXIAL): + """Return (width, height) for the given plane, if the handler supports it.""" + if hasattr(self._osr, 'dimensions_for_plane'): + return self._osr.dimensions_for_plane(plane) + return self.dimensions + + def nframes_for_plane(self, plane: int = PlaneType.AXIAL): + """Return the number of frames for the given plane, if the handler supports it.""" + if hasattr(self._osr, 'nframes_for_plane'): + return self._osr.nframes_for_plane(plane) + return self.nFrames + + def frame_descriptors_for_plane(self, plane: int = PlaneType.AXIAL): + """Return frame descriptor strings for the given plane, if the handler supports it.""" + if hasattr(self._osr, 'frame_descriptors_for_plane'): + return self._osr.frame_descriptors_for_plane(plane) + return getattr(self._osr, 'frame_descriptors', []) + @property def meta_data(self): - if hasattr(self._osr,'meta_data'): + if hasattr(self._osr, 'meta_data'): return self._osr.meta_data - else: - return {} + return {} @property def meta_data_dict(self): - if hasattr(self._osr,'meta_data_dict'): + if hasattr(self._osr, 'meta_data_dict'): return self._osr.meta_data_dict - else: - return {} + return {} - @property + @property def nFrames(self): - if hasattr(self._osr,'nFrames'): + if hasattr(self._osr, 'nFrames'): return self._osr.nFrames - else: - return None + return None - @property + @property def frame_type(self): - if hasattr(self._osr,'frame_type'): + if hasattr(self._osr, 'frame_type'): return self._osr.frame_type - else: - return None + return None @@ -130,8 +148,8 @@ def __reduce__(self): class OMETiffSlideWrapper(OMETiffSlide, openslide.OpenSlide): - - @property + + @property def nFrames(self): return 1 @@ -144,7 +162,7 @@ def frame_descriptors(self) -> list[str]: @property def frame_type(self): return FrameType.UNDEFINED - + def read_region(self, location, level, size, frame=0): return super().read_region(location, level, size) @@ -574,22 +592,97 @@ def getSlideHandler(path): return None +class PlaneSlideAdapter: + """Presents a single MPR plane of a volumetric handler to DeepZoomGenerator. + + DeepZoomGenerator sees the plane's physical dimensions and tile grid. + read_region transparently injects the stored plane so callers need not know + about it — the plane is fully encapsulated here. + """ + + def __init__(self, slide, plane: int): + self._slide = slide + self._plane = plane + + @property + def dimensions(self): + return self._slide.dimensions_for_plane(self._plane) + + @property + def level_count(self): + return 1 + + @property + def level_dimensions(self): + return [self._slide.dimensions_for_plane(self._plane)] + + @property + def level_downsamples(self): + return [1.0] + + def get_best_level_for_downsample(self, _downsample): + return 0 + + @property + def nFrames(self): + return self._slide.nframes_for_plane(self._plane) + + @property + def frame_descriptors(self): + if hasattr(self._slide, 'frame_descriptors_for_plane'): + return self._slide.frame_descriptors_for_plane(self._plane) + return [] + + @property + def frame_type(self): + return self._slide.frame_type + + @property + def properties(self): + return self._slide.properties + + def read_region(self, location, level, size, frame=0): + return self._slide.read_region(location, level, size, frame=frame, plane=self._plane) + + def get_thumbnail(self, size): + return self._slide.get_thumbnail(size) + + class SlideCache(object): def __init__(self, cache_size): self.cache_size = cache_size self._lock = Lock() - self._cache = OrderedDict() + self._cache = OrderedDict() # (path, plane) → zDeepZoomGenerator + self._osr_cache = {} # path → raw handler, shared across all planes - def get(self, path): + def _get_osr(self, path): + """Return the raw slide handler, loading it once and caching it per path.""" with self._lock: - if path in self._cache: - # Move to end of LRU - slide = self._cache.pop(path) - self._cache[path] = slide + if path in self._osr_cache: + return self._osr_cache[path] + osr = getSlideHandler(path) + with self._lock: + self._osr_cache[path] = osr + return osr + + def get(self, path, plane=PlaneType.AXIAL): + cache_key = (path, plane) + with self._lock: + if cache_key in self._cache: + slide = self._cache.pop(cache_key) + self._cache[cache_key] = slide return slide - osr = getSlideHandler(path) - slide = zDeepZoomGenerator(osr) + osr = self._get_osr(path) + + # For non-axial planes on volumetric handlers, wrap the handler so + # DeepZoomGenerator sees the plane's dimensions and tile grid. + if plane != PlaneType.AXIAL and hasattr(osr, 'dimensions_for_plane'): + osr_for_dz = PlaneSlideAdapter(osr, plane) + else: + osr_for_dz = osr + + slide = zDeepZoomGenerator(osr_for_dz) try: mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y] @@ -602,11 +695,10 @@ def get(self, path): slide.mpp_y = 0 with self._lock: - if path not in self._cache: + if cache_key not in self._cache: if len(self._cache) == self.cache_size: self._cache.popitem(last=False) - self._cache[path] = slide -# print('Added to cache') + self._cache[cache_key] = slide return slide class SlideFile(object): From cf5863a1add72665b889ec190313a3d672c49ead Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Thu, 7 May 2026 15:35:33 +0200 Subject: [PATCH 2/5] left-alignment for text in image list. --- .../exact/annotations/templates/annotations/annotate_v2.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exact/exact/annotations/templates/annotations/annotate_v2.html b/exact/exact/annotations/templates/annotations/annotate_v2.html index 878a6bf4..566fce56 100644 --- a/exact/exact/annotations/templates/annotations/annotate_v2.html +++ b/exact/exact/annotations/templates/annotations/annotate_v2.html @@ -101,7 +101,7 @@
{{ selected_image.image_set.name }}
-
{% for set_image in set_images %} @@ -883,7 +883,7 @@
{{ selected_image.name }}
-
+
-
+
+
- diff --git a/exact/exact/annotations/templates/annotations/annotate_v2.html b/exact/exact/annotations/templates/annotations/annotate_v2.html index 566fce56..0867a2b5 100644 --- a/exact/exact/annotations/templates/annotations/annotate_v2.html +++ b/exact/exact/annotations/templates/annotations/annotate_v2.html @@ -877,19 +877,26 @@
{{ selected_image.name }}
-
+
+
- From e8d998be77fc5332e54978442b1911effe672179 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Fri, 8 May 2026 08:08:39 +0200 Subject: [PATCH 5/5] Added spacing to three axis view. --- .../static/annotations/js/exact-image-viewer.js | 9 ++++++++- .../static/annotations/js/show-image-properties.js | 8 +++++++- exact/exact/images/views.py | 2 ++ exact/util/nifti.py | 1 + exact/util/slide_server.py | 1 + 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js index ac28a3f0..c636092e 100644 --- a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js +++ b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js @@ -720,6 +720,11 @@ class EXACTViewer { onMPRPlanesAvailable(event) { if (parseInt(event.detail.imageId) !== this.imageId) return; this.mprPlanes = event.detail.planes; + this.mprSpacing = { + x: parseFloat(event.detail.mpp_x) / 1000 || 0, // µm → mm + y: parseFloat(event.detail.mpp_y) / 1000 || 0, + z: parseFloat(event.detail.mpp_z) / 1000 || 0, + }; $('#planeSelector').show(); const planeNames = ['axial', 'coronal', 'sagittal']; planeNames.forEach((name, idx) => { @@ -986,8 +991,10 @@ class EXACTViewer { _updateMPRInfo() { const { x, y, z } = this.mprPos; + const s = this.mprSpacing; + const fmt = (v, sp) => sp > 0 ? `${v} (${(v * sp).toFixed(1)} mm)` : `${v}`; $('#mpr_info').html( - `x = ${x}y = ${y}z = ${z}` + `x = ${fmt(x, s?.x)}y = ${fmt(y, s?.y)}z = ${fmt(z, s?.z)}` ); } diff --git a/exact/exact/annotations/static/annotations/js/show-image-properties.js b/exact/exact/annotations/static/annotations/js/show-image-properties.js index e82550a0..cb3115c9 100644 --- a/exact/exact/annotations/static/annotations/js/show-image-properties.js +++ b/exact/exact/annotations/static/annotations/js/show-image-properties.js @@ -37,7 +37,13 @@ class ShowImageProperties{ if (data.meta_data.planes) { window.dispatchEvent(new CustomEvent("exactMPRPlanesAvailable", { - detail: { imageId: imageId, planes: data.meta_data.planes } + detail: { + imageId: imageId, + planes: data.meta_data.planes, + mpp_x: data.meta_data.mpp_x, + mpp_y: data.meta_data.mpp_y, + mpp_z: data.meta_data.mpp_z || 0 + } })); } }, diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index 8d4c7649..8648d8ca 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -483,6 +483,8 @@ def image_metadata(request, image_id) -> Response: if planes: meta_data['planes'] = planes meta_data_dict['planes'] = 'MPR planes' + meta_data['mpp_z'] = getattr(slideobj, 'mpp_z', 0) + meta_data_dict['mpp_z'] = 'z Resolution (microns/px)' meta_data.update(slideobj.meta_data) meta_data_dict.update(slideobj.meta_data_dict) diff --git a/exact/util/nifti.py b/exact/util/nifti.py index 97ac48a8..c3a66c11 100644 --- a/exact/util/nifti.py +++ b/exact/util/nifti.py @@ -148,6 +148,7 @@ def properties(self) -> Dict[str, str]: openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', openslide.PROPERTY_NAME_MPP_X: str(self._mppx), openslide.PROPERTY_NAME_MPP_Y: str(self._mppy), + 'openslide.mpp-z': str(self._sz * 1000.0), # µm, z-axis spacing openslide.PROPERTY_NAME_OBJECTIVE_POWER: '1', openslide.PROPERTY_NAME_VENDOR: 'NIfTI', } diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 74a81b92..187cf0ad 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -693,6 +693,7 @@ def get(self, path, plane=PlaneType.AXIAL): slide.mpp = 0 slide.mpp_x = 0 slide.mpp_y = 0 + slide.mpp_z = osr.properties.get('openslide.mpp-z', 0) with self._lock: if cache_key not in self._cache: