feat(series): Add functions for inferring native spacings and resampling volumes for optimization
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user