feat(series): Add functions for inferring native spacings and resampling volumes for optimization

This commit is contained in:
Ross
2026-05-16 22:42:49 +01:00
parent b9cbfd20c1
commit e368d0c7d1
2 changed files with 118 additions and 16 deletions
+44 -9
View File
@@ -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) {
+74 -7
View File
@@ -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)