Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e24bf855 | ||
|
|
1c76e0696c |
+2
-2
@@ -821,7 +821,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
for series in case_obj.get_ordered_series():
|
for series in case_obj.get_ordered_series():
|
||||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
||||||
name = f"{prefix}: {series}" if prefix else str(series)
|
name = f"{prefix}: {series}" if prefix else str(series)
|
||||||
stacks.append({"name": name, "imageIds": images})
|
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@@ -1912,7 +1912,7 @@ class CaseDetail(models.Model):
|
|||||||
for series in case_obj.get_ordered_series():
|
for series in case_obj.get_ordered_series():
|
||||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||||
name = f"{prefix}: {series}" if prefix else str(series)
|
name = f"{prefix}: {series}" if prefix else str(series)
|
||||||
stacks.append({"name": name, "imageIds": images})
|
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|||||||
+7
-13
@@ -86,7 +86,6 @@ from generic.models import CimarCase
|
|||||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||||
from helpers.cimar import CimarAPI, NotFoundError
|
from helpers.cimar import CimarAPI, NotFoundError
|
||||||
from pydicom.uid import generate_uid
|
from pydicom.uid import generate_uid
|
||||||
from datetime import datetime
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -392,9 +391,6 @@ def series_reconstruct_task(
|
|||||||
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
||||||
recon_series.series_instance_uid = generate_uid()
|
recon_series.series_instance_uid = generate_uid()
|
||||||
recon_series.save(update_fields=["series_instance_uid"])
|
recon_series.save(update_fields=["series_instance_uid"])
|
||||||
now = datetime.utcnow()
|
|
||||||
date_str = now.strftime("%Y%m%d")
|
|
||||||
time_str = now.strftime("%H%M%S.%f")[:13]
|
|
||||||
|
|
||||||
for idx in range(int(cfg["count"])):
|
for idx in range(int(cfg["count"])):
|
||||||
arr2d = cfg["slice_fn"](idx)
|
arr2d = cfg["slice_fn"](idx)
|
||||||
@@ -404,18 +400,16 @@ def series_reconstruct_task(
|
|||||||
ds_new.Rows = int(arr2d.shape[0])
|
ds_new.Rows = int(arr2d.shape[0])
|
||||||
ds_new.Columns = int(arr2d.shape[1])
|
ds_new.Columns = int(arr2d.shape[1])
|
||||||
ds_new.InstanceNumber = idx + 1
|
ds_new.InstanceNumber = idx + 1
|
||||||
ds_new.SOPInstanceUID = generate_uid()
|
atlas_views._stamp_derived_dataset(
|
||||||
ds_new.SeriesInstanceUID = recon_series.series_instance_uid
|
ds_new,
|
||||||
ds_new.SeriesDescription = recon_series.description or f"Recon {plane_norm.title()}"
|
series_instance_uid=recon_series.series_instance_uid,
|
||||||
ds_new.ImageType = ["DERIVED", "SECONDARY", "MPR"]
|
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
||||||
ds_new.DerivationDescription = (
|
derivation_description=(
|
||||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||||
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
|
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
|
||||||
|
),
|
||||||
|
image_type=["DERIVED", "SECONDARY", "MPR"],
|
||||||
)
|
)
|
||||||
ds_new.ContentDate = date_str
|
|
||||||
ds_new.ContentTime = time_str
|
|
||||||
ds_new.InstanceCreationDate = date_str
|
|
||||||
ds_new.InstanceCreationTime = time_str
|
|
||||||
ds_new.PixelData = arr2d.tobytes()
|
ds_new.PixelData = arr2d.tobytes()
|
||||||
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
||||||
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
||||||
|
|||||||
@@ -18,10 +18,18 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
|
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
|
||||||
title="Open series in viewer"
|
title="Open series in primary viewport"
|
||||||
data-series-id="{{series.pk}}">
|
data-series-id="{{series.pk}}"
|
||||||
<i class="bi bi-eye"></i> View
|
data-series-name="{{ series|escape }}">
|
||||||
|
<i class="bi bi-eye"></i> Open
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-info btn-xs btn-sm series-open-new-viewport-btn"
|
||||||
|
title="Add a viewport and open series"
|
||||||
|
data-series-id="{{series.pk}}"
|
||||||
|
data-series-name="{{ series|escape }}">
|
||||||
|
<i class="bi bi-layout-split"></i> Open + viewport
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
||||||
class="btn btn-outline-secondary btn-xs btn-sm"
|
class="btn btn-outline-secondary btn-xs btn-sm"
|
||||||
@@ -1339,32 +1347,202 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Series viewer button handler: open series in viewer or navigate to series page
|
// Series viewer button handlers: open series in primary viewport or new viewport.
|
||||||
(function initSeriesViewerButtons() {
|
(function initSeriesViewerButtons() {
|
||||||
if (window.__seriesViewerButtonsBound) return;
|
if (window.__seriesViewerButtonsBound) return;
|
||||||
window.__seriesViewerButtonsBound = true;
|
window.__seriesViewerButtonsBound = true;
|
||||||
|
|
||||||
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
|
function getViewerApi(baseName, fallbackName) {
|
||||||
btn.addEventListener('click', function(e) {
|
const fn = window[`${baseName}_main_viewer`] || (fallbackName ? window[fallbackName] : null);
|
||||||
|
return typeof fn === 'function' ? fn : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewerOpen() {
|
||||||
|
const viewerDetails = document.getElementById('dicom-viewer-details');
|
||||||
|
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
|
||||||
|
viewerDetails.setAttribute('open', 'open');
|
||||||
|
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenNamedStacks(raw) {
|
||||||
|
const out = [];
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.forEach((item) => {
|
||||||
|
if (item && typeof item === 'object' && Array.isArray(item.stacks)) {
|
||||||
|
const caseId = item.caseId;
|
||||||
|
const studyId = item.studyId;
|
||||||
|
item.stacks.forEach((stack) => {
|
||||||
|
if (stack && Array.isArray(stack.imageIds)) {
|
||||||
|
out.push({
|
||||||
|
imageIds: stack.imageIds,
|
||||||
|
name: stack.name || '',
|
||||||
|
caseId: stack.caseId || caseId,
|
||||||
|
studyId: stack.studyId || studyId,
|
||||||
|
seriesId: stack.seriesId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (item && typeof item === 'object' && Array.isArray(item.imageIds)) {
|
||||||
|
out.push({
|
||||||
|
imageIds: item.imageIds,
|
||||||
|
name: item.name || '',
|
||||||
|
caseId: item.caseId || null,
|
||||||
|
studyId: item.studyId || null,
|
||||||
|
seriesId: item.seriesId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNamedStacksFromMainViewer() {
|
||||||
|
const mainViewer = document.getElementById('main_viewer');
|
||||||
|
if (!mainViewer) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = mainViewer.getAttribute('data-named-stacks') || '[]';
|
||||||
|
try {
|
||||||
|
return flattenNamedStacks(JSON.parse(raw));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse data-named-stacks', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStackBySeries(stacks, seriesId, seriesName) {
|
||||||
|
const idNum = Number(seriesId);
|
||||||
|
let match = stacks.find((stack) => Number(stack.seriesId) === idNum);
|
||||||
|
if (!match && seriesName) {
|
||||||
|
match = stacks.find((stack) => String(stack.name || '').trim() === String(seriesName).trim());
|
||||||
|
}
|
||||||
|
return match || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSeriesInPrimary(seriesId, seriesName) {
|
||||||
|
ensureViewerOpen();
|
||||||
|
const mainViewer = document.getElementById('main_viewer');
|
||||||
|
if (!mainViewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stacks = getNamedStacksFromMainViewer();
|
||||||
|
const target = findStackBySeries(stacks, seriesId, seriesName);
|
||||||
|
if (!target) {
|
||||||
|
alert('Series stack not found in this case viewer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reordered = [target, ...stacks.filter((s) => s !== target)];
|
||||||
|
mainViewer.setAttribute('data-named-stacks', JSON.stringify(reordered));
|
||||||
|
try {
|
||||||
|
await mountDicomViewersSafely();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to remount viewer', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSeriesInNewViewport(seriesId, seriesName) {
|
||||||
|
ensureViewerOpen();
|
||||||
|
const stacks = getNamedStacksFromMainViewer();
|
||||||
|
const target = findStackBySeries(stacks, seriesId, seriesName);
|
||||||
|
if (!target) {
|
||||||
|
alert('Series stack not found in this case viewer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportStateFn = getViewerApi('exportViewerState');
|
||||||
|
const importStateFn = getViewerApi('importViewerState');
|
||||||
|
|
||||||
|
if (!exportStateFn || !importStateFn) {
|
||||||
|
// Fallback: at least add stack to viewer list and auto-fill an empty viewport if available.
|
||||||
|
const loadAdditionalStackFn = getViewerApi('loadAdditionalStack');
|
||||||
|
if (loadAdditionalStackFn) {
|
||||||
|
await loadAdditionalStackFn(target.imageIds, target.studyId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = {};
|
||||||
|
try {
|
||||||
|
state = JSON.parse(exportStateFn() || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStacks = Array.isArray(state.stacks) ? [...state.stacks] : [];
|
||||||
|
const toNorm = (ids) => (Array.isArray(ids) ? ids.map((id) => String(id).replace(/^wadouri:/, '')) : []);
|
||||||
|
const targetNorm = toNorm(target.imageIds);
|
||||||
|
|
||||||
|
let targetIdx = currentStacks.findIndex((stack) => {
|
||||||
|
const ids = Array.isArray(stack?.i) ? stack.i : [];
|
||||||
|
const norm = toNorm(ids);
|
||||||
|
return norm.length === targetNorm.length && norm.every((id, i) => id === targetNorm[i]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetIdx < 0) {
|
||||||
|
currentStacks.push({ i: target.imageIds, u: target.studyId || undefined });
|
||||||
|
targetIdx = currentStacks.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = state.grid && Number.isFinite(state.grid.rows) && Number.isFinite(state.grid.cols)
|
||||||
|
? { rows: Math.max(1, Number(state.grid.rows)), cols: Math.max(1, Number(state.grid.cols)) }
|
||||||
|
: { rows: 1, cols: 1 };
|
||||||
|
|
||||||
|
if ((grid.rows * grid.cols) < 2) {
|
||||||
|
grid.cols = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numActive = Math.max(2, grid.rows * grid.cols);
|
||||||
|
const stackIdx = Array.isArray(state.stackIdx) ? [...state.stackIdx] : [];
|
||||||
|
const modes = Array.isArray(state.modes) ? [...state.modes] : [];
|
||||||
|
while (stackIdx.length < numActive) {
|
||||||
|
stackIdx.push(0);
|
||||||
|
}
|
||||||
|
while (modes.length < numActive) {
|
||||||
|
modes.push('stack');
|
||||||
|
}
|
||||||
|
stackIdx[1] = targetIdx;
|
||||||
|
modes[0] = 'stack';
|
||||||
|
modes[1] = 'stack';
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
...state,
|
||||||
|
grid,
|
||||||
|
stacks: currentStacks,
|
||||||
|
stackIdx,
|
||||||
|
modes,
|
||||||
|
};
|
||||||
|
|
||||||
|
importStateFn(JSON.stringify(nextState));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.series-open-primary-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const seriesId = this.getAttribute('data-series-id');
|
const seriesId = this.getAttribute('data-series-id');
|
||||||
|
const seriesName = this.getAttribute('data-series-name') || '';
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
alert('Series ID not found');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await openSeriesInPrimary(seriesId, seriesName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Focus the viewer details element to open it
|
document.querySelectorAll('.series-open-new-viewport-btn').forEach((btn) => {
|
||||||
const viewerDetails = document.getElementById('dicom-viewer-details');
|
btn.addEventListener('click', async function (e) {
|
||||||
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
|
e.preventDefault();
|
||||||
viewerDetails.setAttribute('open', 'open');
|
const seriesId = this.getAttribute('data-series-id');
|
||||||
// Trigger the toggle event to ensure viewer initializes
|
const seriesName = this.getAttribute('data-series-name') || '';
|
||||||
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
|
if (!seriesId) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await openSeriesInNewViewport(seriesId, seriesName);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div class="small text-uppercase text-muted fw-semibold">Series Navigation</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">Series details</a>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:task_overview' %}">Tasks</a>
|
||||||
|
{% endif %}
|
||||||
|
<button id="reset-viewport-button" class="btn btn-outline-secondary btn-sm">Reset viewport</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||||
@@ -12,14 +22,6 @@
|
|||||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-auto d-flex gap-2">
|
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
|
||||||
{% if can_edit %}
|
|
||||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
|
||||||
{% else %}
|
|
||||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +214,7 @@
|
|||||||
<button class="btn btn-outline-warning btn-sm"
|
<button class="btn btn-outline-warning btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#truncate-series-modal">
|
data-bs-target="#truncate-series-modal">
|
||||||
<i class="bi bi-scissors"></i> Truncate series
|
<i class="bi bi-sliders"></i> Series tools
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
@@ -255,7 +257,7 @@
|
|||||||
<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">Series Optimization</h5>
|
<h5 class="modal-title">Series Tools</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">
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Atlas Task Overview
|
|||||||
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
||||||
<option value="delete_completed">Delete selected COMPLETED</option>
|
<option value="delete_completed">Delete selected COMPLETED</option>
|
||||||
<option value="delete_failed">Delete selected FAILED</option>
|
<option value="delete_failed">Delete selected FAILED</option>
|
||||||
|
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
||||||
<div class="form-check ms-2">
|
<div class="form-check ms-2">
|
||||||
|
|||||||
+82
-4
@@ -307,7 +307,45 @@ def _resize_array_nearest(arr, target_rows, target_cols):
|
|||||||
return arr[np.ix_(row_idx, col_idx)]
|
return arr[np.ix_(row_idx, col_idx)]
|
||||||
|
|
||||||
|
|
||||||
def _downsample_dicom_dataset(ds, reduction_pct):
|
def _stamp_derived_dataset(
|
||||||
|
ds,
|
||||||
|
*,
|
||||||
|
series_instance_uid=None,
|
||||||
|
series_description=None,
|
||||||
|
derivation_description=None,
|
||||||
|
image_type=None,
|
||||||
|
regenerate_sop_instance_uid=True,
|
||||||
|
stamp_series_datetime=True,
|
||||||
|
):
|
||||||
|
now = timezone.now()
|
||||||
|
date_str = now.strftime("%Y%m%d")
|
||||||
|
time_str = now.strftime("%H%M%S.%f")[:13]
|
||||||
|
|
||||||
|
if regenerate_sop_instance_uid:
|
||||||
|
ds.SOPInstanceUID = generate_uid()
|
||||||
|
if series_instance_uid:
|
||||||
|
ds.SeriesInstanceUID = series_instance_uid
|
||||||
|
if series_description:
|
||||||
|
ds.SeriesDescription = series_description
|
||||||
|
|
||||||
|
ds.ImageType = image_type or ["DERIVED", "SECONDARY"]
|
||||||
|
|
||||||
|
if derivation_description:
|
||||||
|
ds.DerivationDescription = derivation_description
|
||||||
|
|
||||||
|
ds.ContentDate = date_str
|
||||||
|
ds.ContentTime = time_str
|
||||||
|
ds.InstanceCreationDate = date_str
|
||||||
|
ds.InstanceCreationTime = time_str
|
||||||
|
|
||||||
|
if stamp_series_datetime:
|
||||||
|
ds.SeriesDate = date_str
|
||||||
|
ds.SeriesTime = time_str
|
||||||
|
|
||||||
|
return ds
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, series_description=None):
|
||||||
if reduction_pct <= 0 or reduction_pct >= 100:
|
if reduction_pct <= 0 or reduction_pct >= 100:
|
||||||
raise ValueError("Downsample percentage must be between 1 and 99")
|
raise ValueError("Downsample percentage must be between 1 and 99")
|
||||||
|
|
||||||
@@ -321,11 +359,36 @@ def _downsample_dicom_dataset(ds, reduction_pct):
|
|||||||
target_cols = max(1, int(round(px.shape[1] * factor)))
|
target_cols = max(1, int(round(px.shape[1] * factor)))
|
||||||
resized = _resize_array_nearest(px, target_rows, target_cols)
|
resized = _resize_array_nearest(px, target_rows, target_cols)
|
||||||
|
|
||||||
|
derivation_description = f"Downsampled by {reduction_pct}% using nearest-neighbour resize"
|
||||||
|
_stamp_derived_dataset(
|
||||||
|
ds,
|
||||||
|
series_instance_uid=series_instance_uid,
|
||||||
|
series_description=series_description,
|
||||||
|
derivation_description=derivation_description,
|
||||||
|
image_type=["DERIVED", "SECONDARY"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(ds, "PixelSpacing") and ds.PixelSpacing and len(ds.PixelSpacing) >= 2:
|
||||||
|
row_spacing = _safe_float(ds.PixelSpacing[0], 1.0)
|
||||||
|
col_spacing = _safe_float(ds.PixelSpacing[1], 1.0)
|
||||||
|
ds.PixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
|
||||||
|
|
||||||
|
if hasattr(ds, "ImagerPixelSpacing") and ds.ImagerPixelSpacing and len(ds.ImagerPixelSpacing) >= 2:
|
||||||
|
row_spacing = _safe_float(ds.ImagerPixelSpacing[0], 1.0)
|
||||||
|
col_spacing = _safe_float(ds.ImagerPixelSpacing[1], 1.0)
|
||||||
|
ds.ImagerPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
|
||||||
|
|
||||||
|
if hasattr(ds, "NominalScannedPixelSpacing") and ds.NominalScannedPixelSpacing and len(ds.NominalScannedPixelSpacing) >= 2:
|
||||||
|
row_spacing = _safe_float(ds.NominalScannedPixelSpacing[0], 1.0)
|
||||||
|
col_spacing = _safe_float(ds.NominalScannedPixelSpacing[1], 1.0)
|
||||||
|
ds.NominalScannedPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)]
|
||||||
|
|
||||||
ds.Rows = int(target_rows)
|
ds.Rows = int(target_rows)
|
||||||
ds.Columns = int(target_cols)
|
ds.Columns = int(target_cols)
|
||||||
ds.PixelData = resized.tobytes()
|
ds.PixelData = resized.tobytes()
|
||||||
ds.pop("SmallestImagePixelValue", None)
|
ds.pop("SmallestImagePixelValue", None)
|
||||||
ds.pop("LargestImagePixelValue", None)
|
ds.pop("LargestImagePixelValue", None)
|
||||||
|
ds.pop("PixelAspectRatio", None)
|
||||||
return ds
|
return ds
|
||||||
|
|
||||||
|
|
||||||
@@ -606,7 +669,12 @@ def series_optimize_htmx(request, series_id):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
|
ds_out = _downsample_dicom_dataset(
|
||||||
|
ds,
|
||||||
|
downsample_pct,
|
||||||
|
series_instance_uid=preview_series.series_instance_uid,
|
||||||
|
series_description=preview_series.description,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
|
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
|
||||||
continue
|
continue
|
||||||
@@ -645,6 +713,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
downsampled = []
|
downsampled = []
|
||||||
|
replacement_series_uid = generate_uid()
|
||||||
for image in full_series_images:
|
for image in full_series_images:
|
||||||
ds = _read_series_image_dataset(image)
|
ds = _read_series_image_dataset(image)
|
||||||
if ds is None:
|
if ds is None:
|
||||||
@@ -652,7 +721,12 @@ def series_optimize_htmx(request, series_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
original_hash = image.image_blake3_hash
|
original_hash = image.image_blake3_hash
|
||||||
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
|
ds_out = _downsample_dicom_dataset(
|
||||||
|
ds,
|
||||||
|
downsample_pct,
|
||||||
|
series_instance_uid=replacement_series_uid,
|
||||||
|
series_description=series.description,
|
||||||
|
)
|
||||||
out_io = io.BytesIO()
|
out_io = io.BytesIO()
|
||||||
ds_out.save_as(out_io, write_like_original=False)
|
ds_out.save_as(out_io, write_like_original=False)
|
||||||
out_io.seek(0)
|
out_io.seek(0)
|
||||||
@@ -671,7 +745,8 @@ def series_optimize_htmx(request, series_id):
|
|||||||
|
|
||||||
if downsampled:
|
if downsampled:
|
||||||
series.modified = Series.SeriesModifiedChocies.RE
|
series.modified = Series.SeriesModifiedChocies.RE
|
||||||
series.save(update_fields=["modified"])
|
series.series_instance_uid = replacement_series_uid
|
||||||
|
series.save(update_fields=["modified", "series_instance_uid"])
|
||||||
|
|
||||||
return HttpResponse(
|
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>'
|
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'
|
||||||
@@ -861,6 +936,9 @@ def task_overview(request):
|
|||||||
elif action == "delete_failed":
|
elif action == "delete_failed":
|
||||||
deleted, _ = selected_qs.filter(status="FAILED").delete()
|
deleted, _ = selected_qs.filter(status="FAILED").delete()
|
||||||
messages.success(request, f"Deleted {deleted} failed task record(s).")
|
messages.success(request, f"Deleted {deleted} failed task record(s).")
|
||||||
|
elif action == "delete_selected_all":
|
||||||
|
deleted, _ = selected_qs.delete()
|
||||||
|
messages.success(request, f"Deleted {deleted} selected task record(s).")
|
||||||
else:
|
else:
|
||||||
messages.error(request, "Invalid task action requested.")
|
messages.error(request, "Invalid task action requested.")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user