feat(series): Enhance series truncation with options for removing empty DICOMs and downsampling pixel data
This commit is contained in:
@@ -2,24 +2,40 @@
|
|||||||
|
|
||||||
{% partialdef case-series %}
|
{% partialdef case-series %}
|
||||||
{% for series in case.get_ordered_series %}
|
{% for series in case.get_ordered_series %}
|
||||||
<span class="series-block" id="series-block-{{ 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 %}">
|
<a href="{% url 'atlas:series_detail' pk=series.pk }}" class="series-block-title-link">
|
||||||
{{series.get_block}}
|
{{series.get_block}}
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
|
<div class="series-block-actions mt-2 small">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-primary btn-sm select-series-btn"
|
class="btn btn-outline-primary btn-xs btn-sm select-series-btn"
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
data-series-id="{{series.pk}}">
|
data-series-id="{{series.pk}}">
|
||||||
Select
|
Select
|
||||||
</button>
|
</button>
|
||||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||||
<span class="series-block-popup-link">
|
<button type="button"
|
||||||
|
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
|
||||||
|
title="Open series in viewer"
|
||||||
|
data-series-id="{{series.pk}}">
|
||||||
|
<i class="bi bi-eye"></i> View
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
||||||
|
class="btn btn-outline-secondary btn-xs btn-sm"
|
||||||
|
title="Open series page">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i> Page
|
||||||
|
</a>
|
||||||
|
<span class="series-block-popup-link ms-1">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
class="btn btn-outline-secondary btn-xs btn-sm"
|
||||||
|
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">
|
||||||
|
<i class="bi bi-window"></i> Popup
|
||||||
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -162,6 +178,57 @@
|
|||||||
border: 1px solid rgba(0,0,0,0.06);
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced series block with interactive features */
|
||||||
|
.series-block-interactive {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-block-interactive:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15);
|
||||||
|
border-color: rgba(13, 110, 253, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-block-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-block-actions .btn {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-block-actions .btn-xs {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Also support dark theme when site sets a theme attribute/class */
|
||||||
|
.atlas[data-bs-theme="dark"] .series-block,
|
||||||
|
[data-bs-theme="dark"] .series-block,
|
||||||
|
body[data-bs-theme="dark"] .series-block,
|
||||||
|
body.bg-dark .series-block,
|
||||||
|
.bg-dark .series-block,
|
||||||
|
.dark .series-block,
|
||||||
|
.theme-dark .series-block {
|
||||||
|
background: #071018 !important;
|
||||||
|
border-color: rgba(255,255,255,0.03) !important;
|
||||||
|
color: #e6eef8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas[data-bs-theme="dark"] .series-block-interactive:hover,
|
||||||
|
[data-bs-theme="dark"] .series-block-interactive:hover,
|
||||||
|
body[data-bs-theme="dark"] .series-block-interactive:hover,
|
||||||
|
body.bg-dark .series-block-interactive:hover,
|
||||||
|
.bg-dark .series-block-interactive:hover,
|
||||||
|
.dark .series-block-interactive:hover,
|
||||||
|
.theme-dark .series-block-interactive:hover {
|
||||||
|
border-color: rgba(158, 197, 255, 0.3) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(158, 197, 255, 0.1) !important;
|
||||||
|
}
|
||||||
/* Also support dark theme when site sets a theme attribute/class */
|
/* Also support dark theme when site sets a theme attribute/class */
|
||||||
.atlas[data-bs-theme="dark"] .series-block,
|
.atlas[data-bs-theme="dark"] .series-block,
|
||||||
[data-bs-theme="dark"] .series-block,
|
[data-bs-theme="dark"] .series-block,
|
||||||
@@ -1272,6 +1339,36 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Series viewer button handler: open series in viewer or navigate to series page
|
||||||
|
(function initSeriesViewerButtons() {
|
||||||
|
if (window.__seriesViewerButtonsBound) return;
|
||||||
|
window.__seriesViewerButtonsBound = true;
|
||||||
|
|
||||||
|
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const seriesId = this.getAttribute('data-series-id');
|
||||||
|
if (!seriesId) {
|
||||||
|
alert('Series ID not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the viewer details element to open it
|
||||||
|
const viewerDetails = document.getElementById('dicom-viewer-details');
|
||||||
|
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
|
||||||
|
viewerDetails.setAttribute('open', 'open');
|
||||||
|
// Trigger the toggle event to ensure viewer initializes
|
||||||
|
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Direct series loading will be implemented via API in the future.
|
||||||
|
// For now, this just opens and focuses the viewer.
|
||||||
|
// When the series page viewer API is available, we can load the specific series here:
|
||||||
|
// window.loadSeriesInViewer(seriesId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<strong>Truncate complete.</strong>
|
<strong>Truncate complete.</strong>
|
||||||
<div class="small mt-1">Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
|
<div class="small mt-2">
|
||||||
|
<div>Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
|
||||||
|
{% if images_downsampled %}
|
||||||
|
<div class="mt-1">Downsampled {{ images_downsampled|length }} images (pixel data reduced by 50%).</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if remove_empty_dicoms %}
|
||||||
|
<div class="mt-1">✓ Empty DICOM removal applied</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if downsample_pixels %}
|
||||||
|
<div class="mt-1">✓ Pixel downsampling applied (original hash preserved)</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if images_removed %}
|
{% if images_removed %}
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
@@ -8,3 +19,9 @@
|
|||||||
<div class="small mt-1">{{ images_removed|join:", " }}</div>
|
<div class="small mt-1">{{ images_removed|join:", " }}</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if images_downsampled %}
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="small">Show downsampled image IDs</summary>
|
||||||
|
<div class="small mt-1">{{ images_downsampled|join:", " }}</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -30,7 +30,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
{% 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" class="dicom-viewer-root w-100 viewer-frame-standard" data-images="{{ image_url_array_and_count.0 }}" data-auto-cache-stack=false>
|
<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;"
|
||||||
|
data-images="{{ image_url_array_and_count.0 }}"
|
||||||
|
data-auto-cache-stack=false>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,42 +206,18 @@
|
|||||||
data-bs-target="#series-tag-consistency-modal">
|
data-bs-target="#series-tag-consistency-modal">
|
||||||
Analyze tag consistency
|
Analyze tag consistency
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<div class="card">
|
<button class="btn btn-outline-warning btn-sm"
|
||||||
<div class="card-header">Truncate series</div>
|
data-bs-toggle="modal"
|
||||||
<div class="card-body">
|
data-bs-target="#truncate-series-modal">
|
||||||
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
<i class="bi bi-scissors"></i> Truncate series
|
||||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
</button>
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Start</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
|
||||||
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">End</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
|
||||||
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
|
||||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
|
||||||
</div>
|
|
||||||
<div id="truncate-output" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||||
@@ -252,11 +232,216 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Truncate Series 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>
|
||||||
|
<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;"
|
||||||
|
data-images="{{ image_url_array_and_count.0 }}"
|
||||||
|
data-auto-cache-stack="false">
|
||||||
|
</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-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.
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
const apiKey = "root"
|
const apiKey = "root"
|
||||||
|
const seriesPk = {{ series.pk }};
|
||||||
|
const totalImages = {{ image_url_array_and_count.1 }};
|
||||||
|
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
|
||||||
|
|
||||||
|
// Restore viewer height from localStorage
|
||||||
|
function restoreViewerHeight() {
|
||||||
|
const saved = localStorage.getItem(viewerHeightKey);
|
||||||
|
if (saved) {
|
||||||
|
const viewer = document.getElementById('root');
|
||||||
|
if (viewer) {
|
||||||
|
viewer.style.minHeight = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save viewer height to localStorage when resized
|
||||||
|
function persistViewerHeight() {
|
||||||
|
const viewer = document.getElementById('root');
|
||||||
|
if (viewer) {
|
||||||
|
const height = window.getComputedStyle(viewer).height;
|
||||||
|
localStorage.setItem(viewerHeightKey, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for resize events on the viewer
|
||||||
|
const viewer = document.getElementById('root');
|
||||||
|
if (viewer) {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
persistViewerHeight();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(viewer);
|
||||||
|
restoreViewerHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal truncate handlers
|
||||||
|
const truncateModal = document.getElementById('truncate-series-modal');
|
||||||
|
if (truncateModal) {
|
||||||
|
truncateModal.addEventListener('show.bs.modal', function() {
|
||||||
|
// Initialize the modal viewer
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (window.mountDicomViewers) {
|
||||||
|
window.mountDicomViewers();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Modal viewer init failed', e);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('truncate-set-lower-btn')?.addEventListener('click', () => {
|
||||||
|
if (window.getCurrentStackPosition_root) {
|
||||||
|
const pos = window.getCurrentStackPosition_root(0) + 1;
|
||||||
|
document.getElementById('truncate-lower-input').value = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('truncate-set-upper-btn')?.addEventListener('click', () => {
|
||||||
|
if (window.getCurrentStackPosition_root) {
|
||||||
|
const pos = window.getCurrentStackPosition_root(0) + 1;
|
||||||
|
document.getElementById('truncate-upper-input').value = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (lower >= upper) {
|
||||||
|
alert('The lower bound must be less than the upper bound.');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep old inline handlers for compatibility
|
||||||
$("#set-lower-truncation-bound-button").click(() => {
|
$("#set-lower-truncation-bound-button").click(() => {
|
||||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||||
});
|
});
|
||||||
@@ -280,28 +465,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
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 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}/`);
|
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
|
||||||
htmx.ajax("GET", truncateUrl, "#truncate-output")
|
htmx.ajax("GET", truncateUrl, "#truncate-output")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$("#add-finding-button").click(() => {
|
$("#add-finding-button").click(() => {
|
||||||
$("#hidden-form").show()
|
$("#hidden-form").show()
|
||||||
//dicom_element = $(".cornerstone-element").get(0);
|
|
||||||
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
|
||||||
//cornerstone.reset(dicom_element);
|
|
||||||
////cornerstone.resize(dicom_element, true);
|
|
||||||
$("#add-finding-button").hide()
|
$("#add-finding-button").hide()
|
||||||
$("#clone-finding-button").hide()
|
$("#clone-finding-button").hide()
|
||||||
//$("#finding-form").empty().append(
|
|
||||||
// $("#hidden-form form").clone()
|
|
||||||
//);
|
|
||||||
|
|
||||||
});
|
});
|
||||||
$("#cancel-add-finding-button").click((e) => {
|
$("#cancel-add-finding-button").click((e) => {
|
||||||
$("#hidden-form").hide()
|
$("#hidden-form").hide()
|
||||||
@@ -310,11 +484,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
$("#reset-viewport-button").click(() => {
|
$("#reset-viewport-button").click(() => {
|
||||||
resetViewer_root();
|
resetViewer_root();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".view-finding-button-3d").each((n, el) => {
|
$(".view-finding-button-3d").each((n, el) => {
|
||||||
$(el).click((e) => {
|
$(el).click((e) => {
|
||||||
dicom_element = $(".cornerstone-element").get(0);
|
dicom_element = $(".cornerstone-element").get(0);
|
||||||
@@ -327,16 +500,13 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
{% if editing_finding > 0 %}
|
{% if editing_finding > 0 %}
|
||||||
$("#hidden-form").show()
|
$("#hidden-form").show()
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if request.GET.show_finding %}
|
{% if request.GET.show_finding %}
|
||||||
function show_finding() {
|
function show_finding() {
|
||||||
console.log("show finding")
|
console.log("show finding")
|
||||||
@@ -382,7 +552,7 @@
|
|||||||
},
|
},
|
||||||
error: function (xhr, errmsg, err) {
|
error: function (xhr, errmsg, err) {
|
||||||
console.log(xhr.status + ": " + xhr
|
console.log(xhr.status + ": " + xhr
|
||||||
.responseText); // provide a bit more info about the error to the console
|
.responseText);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+58
-2
@@ -216,6 +216,10 @@ from loguru import logger
|
|||||||
def series_truncate_htmx(request, series_id, start, end):
|
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:
|
||||||
|
- 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)
|
||||||
|
|
||||||
@@ -223,11 +227,57 @@ 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
|
# 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
|
continue
|
||||||
else:
|
else:
|
||||||
|
# Remove images outside bounds
|
||||||
image.removed = True
|
image.removed = True
|
||||||
image.image = None
|
image.image = None
|
||||||
image.save()
|
image.save()
|
||||||
@@ -243,7 +293,13 @@ def series_truncate_htmx(request, series_id, start, end):
|
|||||||
# Render a simple message fragment for HTMX swap
|
# Render a simple message fragment for HTMX swap
|
||||||
html = render_to_string(
|
html = render_to_string(
|
||||||
"atlas/partials/truncate_result.html",
|
"atlas/partials/truncate_result.html",
|
||||||
{"images_removed": images_removed, "series": series},
|
{
|
||||||
|
"images_removed": images_removed,
|
||||||
|
"images_downsampled": images_downsampled,
|
||||||
|
"series": series,
|
||||||
|
"remove_empty_dicoms": remove_empty_dicoms,
|
||||||
|
"downsample_pixels": downsample_pixels,
|
||||||
|
},
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|||||||
Reference in New Issue
Block a user