From e368d0c7d1764df5c5c36562047f834b2eca0d07 Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 16 May 2026 22:42:49 +0100 Subject: [PATCH] feat(series): Add functions for inferring native spacings and resampling volumes for optimization --- atlas/templates/atlas/series_viewer.html | 53 +++++++++++++--- atlas/views.py | 81 ++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index 47a06e8a..0ae88b4b 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -455,19 +455,44 @@ }, 200); }); - const getStackPosition = window[`getCurrentStackPosition_${apiKey}`] || window.getCurrentStackPosition_root; + function resolveViewerApi(baseName) { + const candidates = [ + `${baseName}_${apiKey}`, + `${baseName}_root`, + `${baseName}_truncate_modal_viewer`, + `${baseName}_truncate-modal-viewer`, + ]; - document.getElementById('truncate-set-lower-btn')?.addEventListener('click', () => { + for (const name of candidates) { + if (typeof window[name] === 'function') { + return window[name]; + } + } + + // Fallback: first matching function name from global scope. + const dyn = Object.keys(window).find( + (k) => k.startsWith(`${baseName}_`) && typeof window[k] === 'function' + ); + return dyn ? window[dyn] : null; + } + + document.getElementById('truncate-set-lower-btn')?.addEventListener('click', (e) => { + e.preventDefault(); + const getStackPosition = resolveViewerApi('getCurrentStackPosition'); if (getStackPosition) { - const pos = getStackPosition(0) + 1; + const current = getStackPosition(0); + const pos = Number.isFinite(current) ? current + 1 : 1; document.getElementById('truncate-lower-input').value = pos; syncOptimizeBounds(); } }); - document.getElementById('truncate-set-upper-btn')?.addEventListener('click', () => { + document.getElementById('truncate-set-upper-btn')?.addEventListener('click', (e) => { + e.preventDefault(); + const getStackPosition = resolveViewerApi('getCurrentStackPosition'); if (getStackPosition) { - const pos = getStackPosition(0) + 1; + const current = getStackPosition(0); + const pos = Number.isFinite(current) ? current + 1 : 1; document.getElementById('truncate-upper-input').value = pos; syncOptimizeBounds(); } @@ -476,7 +501,8 @@ document.getElementById('truncate-lower-input')?.addEventListener('input', syncOptimizeBounds); document.getElementById('truncate-upper-input')?.addEventListener('input', syncOptimizeBounds); - document.getElementById('truncate-test-modal-btn')?.addEventListener('click', () => { + document.getElementById('truncate-test-modal-btn')?.addEventListener('click', (e) => { + e.preventDefault(); const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1; const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1; @@ -485,7 +511,7 @@ return; } - const truncateFn = window[`truncateStack_${apiKey}`] || window.truncateStack_root; + const truncateFn = resolveViewerApi('truncateStack'); if (truncateFn) { const ok = truncateFn(lower, upper); if (ok) { @@ -523,8 +549,17 @@ } compareWrap.classList.remove('d-none'); - compareViewer.dataset.namedStacks = namedStacks; - compareViewer.dataset.autoCacheStack = 'false'; + + // Recreate element to force a clean mount with new stacks. + const fresh = document.createElement('div'); + fresh.id = 'downsample-compare-viewer'; + fresh.className = 'dicom-viewer-root w-100'; + fresh.style.height = '240px'; + fresh.style.background = '#222'; + fresh.setAttribute('data-auto-cache-stack', 'false'); + fresh.setAttribute('data-named-stacks', namedStacks); + + compareViewer.replaceWith(fresh); try { if (window.mountDicomViewers) { diff --git a/atlas/views.py b/atlas/views.py index 85887793..f116ceb6 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -336,6 +336,63 @@ def _series_image_urls_json(series): return urls +def _safe_float(value, default=None): + try: + if value is None: + return default + return float(value) + except Exception: + return default + + +def _infer_native_spacings(dicom_items): + """Infer native (row, col, z) spacing in mm from a list of (image, ds, arr).""" + first_ds = dicom_items[0][1] + + row_spacing = 1.0 + col_spacing = 1.0 + if hasattr(first_ds, "PixelSpacing") and first_ds.PixelSpacing and len(first_ds.PixelSpacing) >= 2: + row_spacing = _safe_float(first_ds.PixelSpacing[0], 1.0) + col_spacing = _safe_float(first_ds.PixelSpacing[1], 1.0) + + z_spacing = _safe_float(getattr(first_ds, "SpacingBetweenSlices", None), None) + if z_spacing is None: + z_spacing = _safe_float(getattr(first_ds, "SliceThickness", None), None) + + if z_spacing is None and len(dicom_items) > 1: + try: + p0 = dicom_items[0][1].ImagePositionPatient + p1 = dicom_items[1][1].ImagePositionPatient + if p0 and p1 and len(p0) == 3 and len(p1) == 3: + import numpy as np + + z_spacing = float(np.linalg.norm(np.array(p1, dtype=float) - np.array(p0, dtype=float))) + except Exception: + z_spacing = None + + if z_spacing is None or z_spacing <= 0: + z_spacing = 1.0 + + return float(row_spacing), float(col_spacing), float(z_spacing) + + +def _resample_volume_z_nearest(volume, native_z_spacing, target_z_spacing): + """Nearest-neighbor resample along z-axis to requested spacing.""" + import numpy as np + + if target_z_spacing <= 0: + target_z_spacing = native_z_spacing + + ratio = float(native_z_spacing) / float(target_z_spacing) + target_slices = max(2, int(round(volume.shape[0] * ratio))) + src_idx = np.clip( + np.round(np.linspace(0, volume.shape[0] - 1, target_slices)).astype(int), + 0, + volume.shape[0] - 1, + ) + return volume[src_idx, :, :] + + @login_required @require_http_methods(["POST"]) def series_optimize_htmx(request, series_id): @@ -523,16 +580,23 @@ def series_optimize_htmx(request, series_id): volume = np.stack([item[2] for item in dicom_items], axis=0) template_ds = dicom_items[0][1] + native_row_spacing, native_col_spacing, native_z_spacing = _infer_native_spacings(dicom_items) + + target_spacing = slice_spacing_val if slice_spacing_val is not None else native_z_spacing + volume_for_recon = _resample_volume_z_nearest(volume, native_z_spacing, target_spacing) created_series = [] for plane in recon_planes: plane_norm = plane.lower() if plane_norm == "axial": - recon_slices = [volume[i, :, :] for i in range(volume.shape[0])] + recon_slices = [volume_for_recon[i, :, :] for i in range(volume_for_recon.shape[0])] + pixel_spacing_out = [native_row_spacing, native_col_spacing] elif plane_norm == "coronal": - recon_slices = [volume[:, i, :] for i in range(volume.shape[1])] + recon_slices = [volume_for_recon[:, i, :] for i in range(volume_for_recon.shape[1])] + pixel_spacing_out = [target_spacing, native_col_spacing] elif plane_norm == "sagittal": - recon_slices = [volume[:, :, i] for i in range(volume.shape[2])] + recon_slices = [volume_for_recon[:, :, i] for i in range(volume_for_recon.shape[2])] + pixel_spacing_out = [target_spacing, native_row_spacing] else: continue @@ -550,11 +614,14 @@ def series_optimize_htmx(request, series_id): ds_new.SOPInstanceUID = generate_uid() ds_new.SeriesInstanceUID = recon_series.series_instance_uid ds_new.PixelData = arr2d.tobytes() + ds_new.PixelSpacing = [float(pixel_spacing_out[0]), float(pixel_spacing_out[1])] - if slice_thickness_val is not None: - ds_new.SliceThickness = slice_thickness_val - if slice_spacing_val is not None: - ds_new.SpacingBetweenSlices = slice_spacing_val + ds_new.SliceThickness = ( + float(slice_thickness_val) + if slice_thickness_val is not None + else float(target_spacing) + ) + ds_new.SpacingBetweenSlices = float(target_spacing) out_io = io.BytesIO() ds_new.save_as(out_io, write_like_original=False)