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 class="series-block series-block-interactive" id="series-block-{{ series.pk }}" data-series-id="{{series.pk}}">
<span> <span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br> <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}} {{series.get_block}}
</a> </a>
<br> <br>
@@ -5,9 +5,11 @@
class="btn btn-outline-warning btn-sm" class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel" hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-indicator="#series-split-analysis-indicator">
Analyze split options Analyze split options
</button> </button>
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
</div> </div>
{% if analyze_requested %} {% if analyze_requested %}
@@ -16,7 +18,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% 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-target="#split-by-tag-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#series-split-submit-indicator">
{% csrf_token %} {% csrf_token %}
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-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> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div> </div>
</form> </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> <p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %} {% else %}
<div class="alert alert-secondary mb-0 small"> <div class="alert alert-secondary mb-0 small">
@@ -11,9 +11,11 @@
class="btn btn-outline-info btn-sm" class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel" hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-indicator="#series-tag-consistency-indicator">
Re-run analysis Re-run analysis
</button> </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 %} {% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span> <span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %} {% endif %}
@@ -57,7 +59,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% 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-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <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 %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% 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-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <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 %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% 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-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -168,6 +173,7 @@
</div> </div>
<div class="modal-footer"> <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> <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div> </div>
</div> </div>
+164 -95
View File
@@ -32,7 +32,7 @@
{% with image_url_array_and_count=series.get_image_url_array_and_count %} {% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root" <div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable" 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-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack=false> data-auto-cache-stack=false>
</div> </div>
@@ -202,10 +202,12 @@
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-modal .modal-content" hx-target="#series-tag-consistency-modal .modal-content"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#series-tag-consistency-load-indicator"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#series-tag-consistency-modal"> data-bs-target="#series-tag-consistency-modal">
Analyze tag consistency Analyze tag consistency
</button> </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 %} {% if can_edit %}
<button class="btn btn-outline-warning btn-sm" <button class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
@@ -232,89 +234,140 @@
</div> </div>
</div> </div>
<!-- Truncate Series Modal --> <!-- Series Optimization Modal -->
{% if can_edit %} {% if can_edit %}
<div id="truncate-series-modal" class="modal modal-blur fade" tabindex="-1"> <div id="truncate-series-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-xl"> <div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="row g-3"> <div class="row g-3">
<!-- Viewer side -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header fw-semibold">Viewer Preview</div> <div class="card-header fw-semibold">Viewer / Compare Preview</div>
<div class="card-body p-2" style="max-height: 500px; overflow-y: auto;"> <div class="card-body p-2 d-flex flex-column gap-2">
<div id="truncate-modal-viewer" class="dicom-viewer-root w-100" <div id="truncate-modal-viewer" class="dicom-viewer-root w-100"
style="height: 400px; background: #222;" style="height: 320px; background: #222;"
data-images="{{ image_url_array_and_count.0 }}" data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack="false"> data-auto-cache-stack="false">
</div> </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> </div>
</div> </div>
<!-- Controls side -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <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="card-body">
<div class="alert alert-warning" role="alert"> <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>
<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"> <div class="mb-3">
<label class="form-label">Start Image</label> <label class="form-label">Start image</label>
<div class="input-group"> <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}}"> <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> <button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">End Image</label> <label class="form-label">End image</label>
<div class="input-group"> <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}}"> <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> <button id="truncate-set-upper-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div> </div>
</div> </div>
<div class="d-flex gap-2 mb-3"> <div class="d-flex flex-wrap gap-2 mb-3">
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary">Test Preview</button> <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>
<hr> <hr>
<div class="mb-3"> <div class="mb-3">
<h6>Disk Space Optimization</h6> <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"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="remove-empty-dicoms-check"> <input class="form-check-input" type="checkbox" id="recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="remove-empty-dicoms-check"> <label class="form-check-label" for="recon-axial">Axial</label>
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> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="downsample-pixels-check"> <input class="form-check-input" type="checkbox" id="recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="downsample-pixels-check"> <label class="form-check-label" for="recon-coronal">Coronal</label>
Downsample pixel data (by 50%) </div>
</label> <div class="form-check">
<small class="d-block text-muted mt-1">Reduces resolution while preserving original hash for duplicate detection.</small> <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> </div>
<div id="truncate-modal-output" class="alert alert-info d-none" role="alert"></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>
<div class="card-footer"> <div class="mt-2">
<div class="d-flex gap-2"> <button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
<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>
</div> </div>
</form>
</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>
</div> </div>
</div> </div>
@@ -338,7 +391,7 @@
if (saved) { if (saved) {
const viewer = document.getElementById('root'); const viewer = document.getElementById('root');
if (viewer) { if (viewer) {
viewer.style.minHeight = saved; viewer.style.height = saved;
} }
} }
} }
@@ -360,38 +413,69 @@
}); });
resizeObserver.observe(viewer); resizeObserver.observe(viewer);
restoreViewerHeight(); 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'); const truncateModal = document.getElementById('truncate-series-modal');
if (truncateModal) { if (truncateModal) {
truncateModal.addEventListener('show.bs.modal', function() { truncateModal.addEventListener('show.bs.modal', function () {
// Initialize the modal viewer
setTimeout(() => { setTimeout(() => {
try { try {
if (window.mountDicomViewers) { if (window.mountDicomViewers) {
window.mountDicomViewers(); window.mountDicomViewers();
} }
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (e) { } catch (e) {
console.warn('Modal viewer init failed', e); console.warn('Modal viewer init failed', e);
} }
}, 200); }, 200);
}); });
const getStackPosition = window[`getCurrentStackPosition_${apiKey}`] || window.getCurrentStackPosition_root;
document.getElementById('truncate-set-lower-btn')?.addEventListener('click', () => { document.getElementById('truncate-set-lower-btn')?.addEventListener('click', () => {
if (window.getCurrentStackPosition_root) { if (getStackPosition) {
const pos = window.getCurrentStackPosition_root(0) + 1; const pos = getStackPosition(0) + 1;
document.getElementById('truncate-lower-input').value = pos; document.getElementById('truncate-lower-input').value = pos;
syncOptimizeBounds();
} }
}); });
document.getElementById('truncate-set-upper-btn')?.addEventListener('click', () => { document.getElementById('truncate-set-upper-btn')?.addEventListener('click', () => {
if (window.getCurrentStackPosition_root) { if (getStackPosition) {
const pos = window.getCurrentStackPosition_root(0) + 1; const pos = getStackPosition(0) + 1;
document.getElementById('truncate-upper-input').value = pos; 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', () => { document.getElementById('truncate-test-modal-btn')?.addEventListener('click', () => {
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1; const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1;
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1; const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1;
@@ -401,76 +485,61 @@
return; return;
} }
if (window.truncateStack_root) { const truncateFn = window[`truncateStack_${apiKey}`] || window.truncateStack_root;
const ok = window.truncateStack_root(lower, upper); if (truncateFn) {
const ok = truncateFn(lower, upper);
if (ok) { 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').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-modal-output').classList.remove('d-none');
} }
} }
}); });
}
document.getElementById('truncate-execute-modal-btn')?.addEventListener('click', () => { document.body.addEventListener('htmx:configRequest', function (event) {
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1; const form = document.getElementById('series-optimize-form');
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1; if (!form || event.target !== form) {
const removeEmpty = document.getElementById('remove-empty-dicoms-check').checked; return;
const downsample = document.getElementById('downsample-pixels-check').checked; }
syncOptimizeBounds();
});
if (lower >= upper) { document.body.addEventListener('htmx:afterSwap', function (event) {
alert('The lower bound must be less than the upper bound.'); if (event.target && event.target.id === 'series-optimize-feedback') {
const payload = document.getElementById('downsample-compare-payload');
if (!payload) {
return; return;
} }
const totalToKeep = upper - lower + 1; const namedStacks = payload.dataset.namedStacks;
const confirmMsg = `Truncate series to ${totalToKeep} images?${removeEmpty ? '\n- Remove empty DICOMs' : ''}${downsample ? '\n- Downsample pixel data' : ''}`; if (!namedStacks) {
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);
}
});
}
// 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.");
}
});
$("#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; return;
} }
if(confirm(`Truncated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) { const compareWrap = document.getElementById('downsample-compare-wrap');
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/ const compareViewer = document.getElementById('downsample-compare-viewer');
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`); if (!compareWrap || !compareViewer) {
htmx.ajax("GET", truncateUrl, "#truncate-output") 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);
}
}
});
syncOptimizeBounds();
$("#add-finding-button").click(() => { $("#add-finding-button").click(() => {
$("#hidden-form").show() $("#hidden-form").show()
+5
View File
@@ -565,6 +565,11 @@ urlpatterns = [
views.series_image_size_htmx, views.series_image_size_htmx,
name="series_image_size", 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>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"), 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"), path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
+334 -53
View File
@@ -2,6 +2,8 @@ import json
import pprint import pprint
import csv import csv
import random import random
import copy
import io
from typing import Any from typing import Any
from dal import autocomplete from dal import autocomplete
from django.db.models.base import Model as Model 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 """HTMX endpoint to truncate a series' images to the given inclusive
start/end bounds (zero-based). Returns an HTML fragment with the result. start/end bounds (zero-based). Returns an HTML fragment with the result.
Query parameters: This endpoint only performs truncation for backward compatibility.
- remove_empty: Remove DICOMs with no content (all pixels zero)
- downsample: Downsample pixel data by 50% while preserving original hash
""" """
series = get_object_or_404(Series, pk=series_id) 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>") return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = [] 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()): for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end): if n >= int(start) and n <= int(end):
# Keep images within bounds continue
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.removed = True
image.image = None image.image = None
image.save() image.save()
images_removed.append(image.pk) 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: series.modified = Series.SeriesModifiedChocies.TR
try: series.save(update_fields=["modified"])
# 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)
# Recalculate stored series size # Recalculate stored series size
try: try:
@@ -295,16 +252,340 @@ def series_truncate_htmx(request, series_id, start, end):
"atlas/partials/truncate_result.html", "atlas/partials/truncate_result.html",
{ {
"images_removed": images_removed, "images_removed": images_removed,
"images_downsampled": images_downsampled, "images_downsampled": [],
"series": series, "series": series,
"remove_empty_dicoms": remove_empty_dicoms, "remove_empty_dicoms": False,
"downsample_pixels": downsample_pixels, "downsample_pixels": False,
}, },
request=request, request=request,
) )
return HttpResponse(html) 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 @login_required
def series_image_size_htmx(request, series_id): def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series. """HTMX endpoint returning the total size of images in a series.