feat(optimization): Implement series optimization features including downsampling, empty DICOM removal, and reconstruction options
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user