feat(optimization): Implement series optimization features including downsampling, empty DICOM removal, and reconstruction options

This commit is contained in:
Ross
2026-05-16 22:30:55 +01:00
parent 5ce337c7c4
commit b9cbfd20c1
6 changed files with 544 additions and 179 deletions
@@ -5,7 +5,7 @@
<span class="series-block series-block-interactive" id="series-block-{{ series.pk }}" data-series-id="{{series.pk}}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk }}" class="series-block-title-link">
<a href="{% url 'atlas:series_detail' pk=series.pk %}" class="series-block-title-link">
{{series.get_block}}
</a>
<br>
@@ -5,9 +5,11 @@
class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#series-split-analysis-indicator">
Analyze split options
</button>
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
</div>
{% if analyze_requested %}
@@ -16,7 +18,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#series-split-submit-indicator">
{% csrf_token %}
<div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm">
@@ -27,6 +30,7 @@
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div>
</form>
<span id="series-split-submit-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %}
<div class="alert alert-secondary mb-0 small">
@@ -11,9 +11,11 @@
class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#series-tag-consistency-indicator">
Re-run analysis
</button>
<span id="series-tag-consistency-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %}
@@ -57,7 +59,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -99,7 +102,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -143,7 +147,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -168,6 +173,7 @@
</div>
<div class="modal-footer">
<span id="tag-consistency-split-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator me-auto" role="status" aria-hidden="true"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
+183 -114
View File
@@ -32,7 +32,7 @@
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
style="min-height: clamp(600px, 75vh, 1200px); resize: vertical; overflow: auto;"
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack=false>
</div>
@@ -202,10 +202,12 @@
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-modal .modal-content"
hx-swap="innerHTML"
hx-indicator="#series-tag-consistency-load-indicator"
data-bs-toggle="modal"
data-bs-target="#series-tag-consistency-modal">
Analyze tag consistency
</button>
<span id="series-tag-consistency-load-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if can_edit %}
<button class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal"
@@ -232,88 +234,139 @@
</div>
</div>
<!-- Truncate Series Modal -->
<!-- Series Optimization Modal -->
{% if can_edit %}
<div id="truncate-series-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Truncate Series & Optimize Disk Space</h5>
<h5 class="modal-title">Series Optimization</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<!-- Viewer side -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">Viewer Preview</div>
<div class="card-body p-2" style="max-height: 500px; overflow-y: auto;">
<div id="truncate-modal-viewer" class="dicom-viewer-root w-100"
style="height: 400px; background: #222;"
<div class="card-header fw-semibold">Viewer / Compare Preview</div>
<div class="card-body p-2 d-flex flex-column gap-2">
<div id="truncate-modal-viewer" class="dicom-viewer-root w-100"
style="height: 320px; background: #222;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack="false">
</div>
<div id="downsample-compare-wrap" class="d-none">
<div class="small text-muted mb-1">Downsample preview comparison</div>
<div id="downsample-compare-viewer" class="dicom-viewer-root w-100"
style="height: 240px; background: #222;"
data-auto-cache-stack="false"></div>
</div>
</div>
</div>
</div>
<!-- Controls side -->
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Truncate Range</div>
<div class="card-header fw-semibold">Operations</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<strong>⚠️ Warning:</strong> Truncation is irreversible. If you have reordered the series, this may remove the wrong images.
<strong>Warning:</strong> Destructive actions cannot be undone.
</div>
<div class="mb-3">
<label class="form-label">Start Image</label>
<div class="input-group">
<input id="truncate-lower-input" type="number" class="form-control form-control-sm" value="1" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set</button>
<form id="series-optimize-form"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#series-optimize-feedback"
hx-swap="innerHTML"
hx-indicator="#series-optimize-indicator">
{% csrf_token %}
<input type="hidden" name="start" id="opt-start-hidden" value="1">
<input type="hidden" name="end" id="opt-end-hidden" value="{{ image_url_array_and_count.1 }}">
<div class="mb-3">
<label class="form-label">Start image</label>
<div class="input-group">
<input id="truncate-lower-input" type="number" class="form-control form-control-sm" value="1" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">End Image</label>
<div class="input-group">
<input id="truncate-upper-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-upper-btn" class="btn btn-outline-secondary btn-sm" type="button">Set</button>
<div class="mb-3">
<label class="form-label">End image</label>
<div class="input-group">
<input id="truncate-upper-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-upper-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
</div>
<div class="d-flex gap-2 mb-3">
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary">Test Preview</button>
</div>
<hr>
<div class="mb-3">
<h6>Disk Space Optimization</h6>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="remove-empty-dicoms-check">
<label class="form-check-label" for="remove-empty-dicoms-check">
Remove empty DICOMs (no content)
</label>
<small class="d-block text-muted mt-1">Removes DICOM files that do not contain usable image data.</small>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary" type="button">Test preview</button>
<button class="btn btn-sm btn-danger" type="submit" name="operation" value="truncate">Apply truncate</button>
<button class="btn btn-sm btn-outline-warning" type="submit" name="operation" value="remove_empty">Remove empty DICOMs</button>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="downsample-pixels-check">
<label class="form-check-label" for="downsample-pixels-check">
Downsample pixel data (by 50%)
</label>
<small class="d-block text-muted mt-1">Reduces resolution while preserving original hash for duplicate detection.</small>
</div>
</div>
<div id="truncate-modal-output" class="alert alert-info d-none" role="alert"></div>
<hr>
<div class="mb-3">
<label for="downsample-pct" class="form-label">Downsample percentage</label>
<select id="downsample-pct" name="downsample_pct" class="form-select form-select-sm">
<option value="25">25% reduction</option>
<option value="40">40% reduction</option>
<option value="50" selected>50% reduction</option>
<option value="60">60% reduction</option>
<option value="75">75% reduction</option>
</select>
<small class="text-muted">Original hash is preserved after downsampling.</small>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-info" type="submit" name="operation" value="downsample_preview">Preview downsample compare</button>
<button class="btn btn-sm btn-outline-success" type="submit" name="operation" value="downsample">Apply downsample</button>
</div>
<hr>
<div class="mb-2">
<div class="fw-semibold small mb-2">Generate reconstructions</div>
<div class="d-flex flex-wrap gap-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="recon-sagittal">Sagittal</label>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-thickness">Slice thickness (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-thickness" name="recon_slice_thickness" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-spacing">Slice spacing (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-spacing" name="recon_slice_spacing" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
</div>
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer">
<div class="d-flex gap-2">
<button id="truncate-execute-modal-btn" class="btn btn-danger btn-sm">Execute Truncation</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="series-optimize-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">Processing operations may take time for large stacks.</small>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="modal">Close</button>
</div>
<div class="px-3 pb-3">
<div id="series-optimize-feedback" class="small"></div>
<div id="truncate-modal-output" class="alert alert-info d-none mt-2 mb-0" role="alert"></div>
</div>
</div>
</div>
@@ -338,7 +391,7 @@
if (saved) {
const viewer = document.getElementById('root');
if (viewer) {
viewer.style.minHeight = saved;
viewer.style.height = saved;
}
}
}
@@ -360,117 +413,133 @@
});
resizeObserver.observe(viewer);
restoreViewerHeight();
requestAnimationFrame(() => {
try {
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
window.dispatchEvent(new Event('resize'));
} catch (e) {
console.warn('Viewer resize dispatch failed', e);
}
});
}
// Modal truncate handlers
function syncOptimizeBounds() {
const startInput = document.getElementById('truncate-lower-input');
const endInput = document.getElementById('truncate-upper-input');
const startHidden = document.getElementById('opt-start-hidden');
const endHidden = document.getElementById('opt-end-hidden');
if (!startInput || !endInput || !startHidden || !endHidden) {
return;
}
startHidden.value = startInput.value;
endHidden.value = endInput.value;
}
// Modal optimization handlers
const truncateModal = document.getElementById('truncate-series-modal');
if (truncateModal) {
truncateModal.addEventListener('show.bs.modal', function() {
// Initialize the modal viewer
truncateModal.addEventListener('show.bs.modal', function () {
setTimeout(() => {
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (e) {
console.warn('Modal viewer init failed', e);
}
}, 200);
});
const getStackPosition = window[`getCurrentStackPosition_${apiKey}`] || window.getCurrentStackPosition_root;
document.getElementById('truncate-set-lower-btn')?.addEventListener('click', () => {
if (window.getCurrentStackPosition_root) {
const pos = window.getCurrentStackPosition_root(0) + 1;
if (getStackPosition) {
const pos = getStackPosition(0) + 1;
document.getElementById('truncate-lower-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-set-upper-btn')?.addEventListener('click', () => {
if (window.getCurrentStackPosition_root) {
const pos = window.getCurrentStackPosition_root(0) + 1;
if (getStackPosition) {
const pos = getStackPosition(0) + 1;
document.getElementById('truncate-upper-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-lower-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-upper-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-test-modal-btn')?.addEventListener('click', () => {
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1;
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1;
if (lower < 0 || upper >= totalImages || lower > upper) {
alert('Invalid range. Please check your bounds.');
return;
}
if (window.truncateStack_root) {
const ok = window.truncateStack_root(lower, upper);
const truncateFn = window[`truncateStack_${apiKey}`] || window.truncateStack_root;
if (truncateFn) {
const ok = truncateFn(lower, upper);
if (ok) {
document.getElementById('truncate-modal-output').innerHTML = `Preview shows images ${lower + 1} to ${upper + 1} (${upper - lower + 1} total).`;
document.getElementById('truncate-modal-output').classList.remove('d-none');
}
}
});
}
document.getElementById('truncate-execute-modal-btn')?.addEventListener('click', () => {
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1;
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1;
const removeEmpty = document.getElementById('remove-empty-dicoms-check').checked;
const downsample = document.getElementById('downsample-pixels-check').checked;
document.body.addEventListener('htmx:configRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
syncOptimizeBounds();
});
if (lower >= upper) {
alert('The lower bound must be less than the upper bound.');
document.body.addEventListener('htmx:afterSwap', function (event) {
if (event.target && event.target.id === 'series-optimize-feedback') {
const payload = document.getElementById('downsample-compare-payload');
if (!payload) {
return;
}
const totalToKeep = upper - lower + 1;
const confirmMsg = `Truncate series to ${totalToKeep} images?${removeEmpty ? '\n- Remove empty DICOMs' : ''}${downsample ? '\n- Downsample pixel data' : ''}`;
if (confirm(confirmMsg)) {
// Build URL with optional parameters
let truncateUrl = `{% url 'atlas:series_truncate' series.pk 0 0 %}`.replace('/0/0/', `/${lower}/${upper}/`);
if (removeEmpty) truncateUrl += '?remove_empty=1';
if (downsample) truncateUrl += (removeEmpty ? '&' : '?') + 'downsample=1';
htmx.ajax('GET', truncateUrl, '#truncate-modal-output');
// Close modal after a delay
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(truncateModal);
if (modal) modal.hide();
}, 1500);
const namedStacks = payload.dataset.namedStacks;
if (!namedStacks) {
return;
}
});
}
// Keep old inline handlers for compatibility
$("#set-lower-truncation-bound-button").click(() => {
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#set-upper-truncation-bound-button").click(() => {
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#truncate-test-button").click(() => {
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
const ok = window[`truncateStack_${apiKey}`](lower, upper);
if (ok) {
$("#truncate-output").text("The truncated images are shown above.");
const compareWrap = document.getElementById('downsample-compare-wrap');
const compareViewer = document.getElementById('downsample-compare-viewer');
if (!compareWrap || !compareViewer) {
return;
}
compareWrap.classList.remove('d-none');
compareViewer.dataset.namedStacks = namedStacks;
compareViewer.dataset.autoCacheStack = 'false';
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (err) {
console.warn('Failed to mount downsample compare viewer', err);
}
}
});
$("#truncate-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
if (lower_bound >= upper_bound) {
alert("The lower bound must be less than the upper bound.");
return;
}
if(confirm(`Truncated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
htmx.ajax("GET", truncateUrl, "#truncate-output")
}
})
syncOptimizeBounds();
$("#add-finding-button").click(() => {
$("#hidden-form").show()
+5
View File
@@ -565,6 +565,11 @@ urlpatterns = [
views.series_image_size_htmx,
name="series_image_size",
),
path(
"series/<int:series_id>/optimize/",
views.series_optimize_htmx,
name="series_optimize",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
+339 -58
View File
@@ -2,6 +2,8 @@ import json
import pprint
import csv
import random
import copy
import io
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
@@ -217,9 +219,7 @@ def series_truncate_htmx(request, series_id, start, end):
"""HTMX endpoint to truncate a series' images to the given inclusive
start/end bounds (zero-based). Returns an HTML fragment with the result.
Query parameters:
- remove_empty: Remove DICOMs with no content (all pixels zero)
- downsample: Downsample pixel data by 50% while preserving original hash
This endpoint only performs truncation for backward compatibility.
"""
series = get_object_or_404(Series, pk=series_id)
@@ -227,61 +227,18 @@ def series_truncate_htmx(request, series_id, start, end):
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = []
images_downsampled = []
remove_empty_dicoms = request.GET.get('remove_empty') in ('1', 'true', 'True')
downsample_pixels = request.GET.get('downsample') in ('1', 'true', 'True')
for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end):
# Keep images within bounds
if remove_empty_dicoms and image.image:
try:
# Check if DICOM is effectively empty (all zeros)
import pydicom
from io import BytesIO
dcm = pydicom.dcmread(BytesIO(image.image.read()))
if hasattr(dcm, 'pixel_array'):
px_array = dcm.pixel_array
# If all pixels are zero, mark for removal
if (px_array == 0).all():
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
continue
except Exception as e:
logger.warning(f"Could not analyze DICOM {image.pk}: {e}")
if downsample_pixels and image.image:
try:
# Downsample pixel data (reduce resolution by 50%)
import pydicom
import numpy as np
from io import BytesIO
dcm = pydicom.dcmread(BytesIO(image.image.read()))
if hasattr(dcm, 'pixel_array'):
original_array = dcm.pixel_array
# Downsample: take every other pixel in each dimension
downsampled = original_array[::2, ::2] if original_array.ndim == 2 else original_array[:, ::2, ::2]
dcm.pixel_array[:] = downsampled
# Save back to image field
output = BytesIO()
dcm.save_as(output, write_like_original=False)
output.seek(0)
image.image.save(f'dicom_{image.pk}.dcm', output, save=True)
images_downsampled.append(image.pk)
except Exception as e:
logger.warning(f"Could not downsample DICOM {image.pk}: {e}")
continue
else:
# Remove images outside bounds
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
# Recalculate stored series size
try:
@@ -295,16 +252,340 @@ def series_truncate_htmx(request, series_id, start, end):
"atlas/partials/truncate_result.html",
{
"images_removed": images_removed,
"images_downsampled": images_downsampled,
"images_downsampled": [],
"series": series,
"remove_empty_dicoms": remove_empty_dicoms,
"downsample_pixels": downsample_pixels,
"remove_empty_dicoms": False,
"downsample_pixels": False,
},
request=request,
)
return HttpResponse(html)
def _read_series_image_dataset(series_image):
if not series_image.image:
return None
try:
series_image.image.open("rb")
series_image.image.seek(0)
return pydicom.dcmread(series_image.image, force=True)
except Exception as exc:
logger.warning("Failed to read DICOM for image {}: {}", series_image.pk, exc)
return None
def _is_empty_dicom(ds):
try:
px = ds.pixel_array
return bool((px == 0).all())
except Exception:
return False
def _resize_array_nearest(arr, target_rows, target_cols):
import numpy as np
rows, cols = arr.shape[0], arr.shape[1]
row_idx = np.clip(np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1)
col_idx = np.clip(np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1)
return arr[np.ix_(row_idx, col_idx)]
def _downsample_dicom_dataset(ds, reduction_pct):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
factor = (100 - reduction_pct) / 100.0
px = ds.pixel_array
if px.ndim != 2:
raise ValueError("Only 2D DICOM slices are supported for this downsample operation")
target_rows = max(1, int(round(px.shape[0] * factor)))
target_cols = max(1, int(round(px.shape[1] * factor)))
resized = _resize_array_nearest(px, target_rows, target_cols)
ds.Rows = int(target_rows)
ds.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
return ds
def _create_series_derivative(source_series, description_suffix):
derived = Series.objects.get(pk=source_series.pk)
derived.pk = None
derived.id = None
derived.series_instance_uid = generate_uid()
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
derived.modified = Series.SeriesModifiedChocies.RE
derived.save()
derived.case.set(source_series.case.all())
derived.author.set(source_series.author.all())
return derived
def _series_image_urls_json(series):
image_url_array_and_count = series.get_image_url_array_and_count()
try:
urls = json.loads(image_url_array_and_count[0])
except Exception:
urls = []
return urls
@login_required
@require_http_methods(["POST"])
def series_optimize_htmx(request, series_id):
"""Run disk optimization operations for a series.
Scope: Series editors only.
Supported operations:
- remove_empty
- downsample
- downsample_preview
- reconstruct
- truncate
"""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
operation = request.POST.get("operation", "").strip()
start = int(request.POST.get("start", "1") or 1) - 1
end = int(request.POST.get("end", str(series.get_image_count())) or series.get_image_count()) - 1
if end < start:
return HttpResponse('<div class="alert alert-danger mb-0">Start must be less than or equal to end.</div>')
images = list(series.get_images())
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
if operation == "truncate":
removed = []
for idx, image in enumerate(images):
if idx < start or idx > end:
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Truncate complete. Removed <strong>{len(removed)}</strong> images.</div>'
)
if operation == "remove_empty":
removed = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
if _is_empty_dicom(ds):
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
if removed:
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
)
if operation in ("downsample", "downsample_preview"):
downsample_pct = int(request.POST.get("downsample_pct", "50") or 50)
if operation == "downsample_preview":
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
created = 0
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
except Exception as exc:
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
continue
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
new_image = SeriesImage(series=preview_series, position=image.position, upload_filename=image.upload_filename)
new_image.image.save(
f"preview_ds_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
new_image.save()
created += 1
original_urls = _series_image_urls_json(series)
preview_urls = _series_image_urls_json(preview_series)
named_stacks = json.dumps(
{
"Original": original_urls,
f"Preview {downsample_pct}%": preview_urls,
}
)
escaped_named_stacks = escape(named_stacks)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
f'Downsample preview series created with <strong>{created}</strong> images. '
f'<a class="alert-link" target="_blank" href="{preview_series.get_absolute_url()}">Open preview series</a>.'
'</div>'
f'<div id="downsample-compare-payload" data-named-stacks="{escaped_named_stacks}"></div>'
)
)
downsampled = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
original_hash = image.image_blake3_hash
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
image.image.save(
f"downsample_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
# Preserve original hash for duplicate detection continuity.
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
downsampled.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
if downsampled:
series.modified = Series.SeriesModifiedChocies.RE
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'
)
if operation == "reconstruct":
recon_planes = request.POST.getlist("recon_planes")
if not recon_planes:
return HttpResponse('<div class="alert alert-danger mb-0">Select at least one reconstruction plane.</div>')
slice_thickness = request.POST.get("recon_slice_thickness", "")
slice_spacing = request.POST.get("recon_slice_spacing", "")
try:
slice_thickness_val = float(slice_thickness) if slice_thickness else None
slice_spacing_val = float(slice_spacing) if slice_spacing else None
except ValueError:
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness and spacing must be numeric values.</div>')
dicom_items = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
arr = ds.pixel_array
if arr.ndim != 2:
continue
dicom_items.append((image, ds, arr))
except Exception:
continue
if len(dicom_items) < 2:
return HttpResponse('<div class="alert alert-warning mb-0">Need at least 2 valid DICOM images in range for reconstruction.</div>')
import numpy as np
base_shape = dicom_items[0][2].shape
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
if len(dicom_items) < 2:
return HttpResponse('<div class="alert alert-warning mb-0">Not enough consistently-sized slices for reconstruction.</div>')
volume = np.stack([item[2] for item in dicom_items], axis=0)
template_ds = dicom_items[0][1]
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])]
elif plane_norm == "coronal":
recon_slices = [volume[:, i, :] for i in range(volume.shape[1])]
elif plane_norm == "sagittal":
recon_slices = [volume[:, :, i] for i in range(volume.shape[2])]
else:
continue
recon_series = _create_series_derivative(series, f"Recon {plane_norm.title()}")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
for idx, arr2d in enumerate(recon_slices):
ds_new = copy.deepcopy(template_ds)
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
ds_new.Rows = int(arr2d.shape[0])
ds_new.Columns = int(arr2d.shape[1])
ds_new.InstanceNumber = idx + 1
ds_new.SOPInstanceUID = generate_uid()
ds_new.SeriesInstanceUID = recon_series.series_instance_uid
ds_new.PixelData = arr2d.tobytes()
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
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(series=recon_series, position=idx + 1, upload_filename=f"recon_{plane_norm}_{idx+1}.dcm")
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx+1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
created_series.append(recon_series)
if not created_series:
return HttpResponse('<div class="alert alert-warning mb-0">No reconstruction plane could be generated.</div>')
links = " ".join(
[
f'<a class="alert-link me-2" target="_blank" href="{s.get_absolute_url()}">{escape(s.description or str(s.pk))}</a>'
for s in created_series
]
)
return HttpResponse(
f'<div class="alert alert-success mb-0">Created <strong>{len(created_series)}</strong> reconstruction series: {links}</div>'
)
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
@login_required
def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series.