Compare commits

..
2 Commits
6 changed files with 303 additions and 50 deletions
+2 -2
View File
@@ -821,7 +821,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
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
results = []
@@ -1912,7 +1912,7 @@ class CaseDetail(models.Model):
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
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
results = []
+7 -13
View File
@@ -86,7 +86,6 @@ from generic.models import CimarCase
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from helpers.cimar import CimarAPI, NotFoundError
from pydicom.uid import generate_uid
from datetime import datetime
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
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.series_instance_uid = generate_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"])):
arr2d = cfg["slice_fn"](idx)
@@ -404,18 +400,16 @@ def series_reconstruct_task(
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.SeriesDescription = recon_series.description or f"Recon {plane_norm.title()}"
ds_new.ImageType = ["DERIVED", "SECONDARY", "MPR"]
ds_new.DerivationDescription = (
atlas_views._stamp_derived_dataset(
ds_new,
series_instance_uid=recon_series.series_instance_uid,
series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=(
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"
),
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.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
+199 -21
View File
@@ -18,10 +18,18 @@
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<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
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
title="Open series in primary viewport"
data-series-id="{{series.pk}}"
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>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
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() {
if (window.__seriesViewerButtonsBound) return;
window.__seriesViewerButtonsBound = true;
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
function getViewerApi(baseName, fallbackName) {
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();
const seriesId = this.getAttribute('data-series-id');
const seriesName = this.getAttribute('data-series-name') || '';
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 }));
await openSeriesInPrimary(seriesId, seriesName);
});
});
document.querySelectorAll('.series-open-new-viewport-btn').forEach((btn) => {
btn.addEventListener('click', async function (e) {
e.preventDefault();
const seriesId = this.getAttribute('data-series-id');
const seriesName = this.getAttribute('data-series-name') || '';
if (!seriesId) {
return;
}
// 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);
await openSeriesInNewViewport(seriesId, seriesName);
});
});
})();
+12 -10
View File
@@ -5,6 +5,16 @@
<div class="row">
<div class="col-12 mb-3">
<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>
<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 text-muted">Author: {{ series.get_author_display }}</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>
@@ -212,7 +214,7 @@
<button class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#truncate-series-modal">
<i class="bi bi-scissors"></i> Truncate series
<i class="bi bi-sliders"></i> Series tools
</button>
<div class="card mt-2">
@@ -255,7 +257,7 @@
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
+1
View File
@@ -82,6 +82,7 @@ Atlas Task Overview
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
+82 -4
View File
@@ -307,7 +307,45 @@ def _resize_array_nearest(arr, target_rows, target_cols):
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:
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)))
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.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
ds.pop("PixelAspectRatio", None)
return ds
@@ -606,7 +669,12 @@ def series_optimize_htmx(request, series_id):
continue
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:
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
continue
@@ -645,6 +713,7 @@ def series_optimize_htmx(request, series_id):
)
downsampled = []
replacement_series_uid = generate_uid()
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
@@ -652,7 +721,12 @@ def series_optimize_htmx(request, series_id):
try:
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()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
@@ -671,7 +745,8 @@ def series_optimize_htmx(request, series_id):
if downsampled:
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(
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":
deleted, _ = selected_qs.filter(status="FAILED").delete()
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:
messages.error(request, "Invalid task action requested.")