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 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>
|
||||||
|
|||||||
@@ -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,88 +234,139 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<form id="series-optimize-form"
|
||||||
<label class="form-label">Start Image</label>
|
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
|
||||||
<div class="input-group">
|
hx-target="#series-optimize-feedback"
|
||||||
<input id="truncate-lower-input" type="number" class="form-control form-control-sm" value="1" min="1" max="{{image_url_array_and_count.1}}">
|
hx-swap="innerHTML"
|
||||||
<button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set</button>
|
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>
|
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer d-flex align-items-center gap-2">
|
||||||
<div class="d-flex gap-2">
|
<span id="series-optimize-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
|
||||||
<button id="truncate-execute-modal-btn" class="btn btn-danger btn-sm">Execute Truncation</button>
|
<small class="text-muted">Processing operations may take time for large stacks.</small>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="modal">Close</button>
|
||||||
</div>
|
</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>
|
||||||
@@ -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) {
|
||||||
|
return;
|
||||||
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
|
const compareWrap = document.getElementById('downsample-compare-wrap');
|
||||||
$("#set-lower-truncation-bound-button").click(() => {
|
const compareViewer = document.getElementById('downsample-compare-viewer');
|
||||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
if (!compareWrap || !compareViewer) {
|
||||||
});
|
return;
|
||||||
$("#set-upper-truncation-bound-button").click(() => {
|
}
|
||||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
|
||||||
});
|
compareWrap.classList.remove('d-none');
|
||||||
$("#truncate-test-button").click(() => {
|
compareViewer.dataset.namedStacks = namedStacks;
|
||||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
compareViewer.dataset.autoCacheStack = 'false';
|
||||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
|
||||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
try {
|
||||||
if (ok) {
|
if (window.mountDicomViewers) {
|
||||||
$("#truncate-output").text("The truncated images are shown above.");
|
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) {
|
syncOptimizeBounds();
|
||||||
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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$("#add-finding-button").click(() => {
|
$("#add-finding-button").click(() => {
|
||||||
$("#hidden-form").show()
|
$("#hidden-form").show()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
+338
-57
@@ -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
|
|
||||||
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:
|
|
||||||
# Remove images outside bounds
|
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)
|
|
||||||
|
series.modified = Series.SeriesModifiedChocies.TR
|
||||||
|
series.save(update_fields=["modified"])
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user