feat(reconstruction): Add asynchronous series reconstruction task with progress tracking
This commit is contained in:
+248
-2
@@ -3,11 +3,15 @@ from django.core.mail import send_mail
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from atlas.models import Case
|
from atlas.models import Case, Series, SeriesImage
|
||||||
from generic.models import CimarCase
|
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 django.contrib.auth.models import User
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
import copy
|
||||||
|
import io
|
||||||
|
|
||||||
@shared_task()
|
@shared_task()
|
||||||
def push_case_to_cimar_task(case_id):
|
def push_case_to_cimar_task(case_id):
|
||||||
@@ -49,4 +53,246 @@ def push_case_to_cimar_task(case_id):
|
|||||||
|
|
||||||
cimar_case.refresh_study()
|
cimar_case.refresh_study()
|
||||||
|
|
||||||
return 10
|
return 10
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def series_reconstruct_task(
|
||||||
|
self,
|
||||||
|
series_id,
|
||||||
|
user_id,
|
||||||
|
recon_planes,
|
||||||
|
slice_thickness_val=None,
|
||||||
|
slice_spacing_val=None,
|
||||||
|
recon_thickness_mode="mean",
|
||||||
|
):
|
||||||
|
"""Generate reconstructions asynchronously for a series with progress updates."""
|
||||||
|
import numpy as np
|
||||||
|
from loguru import logger
|
||||||
|
from atlas import views as atlas_views
|
||||||
|
|
||||||
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
|
||||||
|
if not series.check_user_can_edit(user):
|
||||||
|
raise PermissionError("Permission denied")
|
||||||
|
|
||||||
|
images = list(series.get_images())
|
||||||
|
dicom_items = []
|
||||||
|
for image in images:
|
||||||
|
ds = atlas_views._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:
|
||||||
|
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ValueError("Not enough consistently-sized slices for reconstruction")
|
||||||
|
|
||||||
|
geom = atlas_views._extract_recon_geometry(dicom_items)
|
||||||
|
sorted_items = geom["sorted_items"]
|
||||||
|
volume = np.stack([item[2] for item in sorted_items], axis=0)
|
||||||
|
|
||||||
|
template_ds = sorted_items[0][1]
|
||||||
|
source_positions_mm = geom["source_positions_mm"]
|
||||||
|
origin_ipp = geom["origin_ipp"]
|
||||||
|
row_dir = geom["row_dir"]
|
||||||
|
col_dir = geom["col_dir"]
|
||||||
|
normal_dir = geom["normal_dir"]
|
||||||
|
native_row_spacing = geom["row_spacing"]
|
||||||
|
native_col_spacing = geom["col_spacing"]
|
||||||
|
native_z_spacing = geom["native_z_spacing"]
|
||||||
|
|
||||||
|
target_spacing = float(slice_spacing_val) if slice_spacing_val is not None else native_z_spacing
|
||||||
|
slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else target_spacing
|
||||||
|
|
||||||
|
volume_for_recon, recon_centers_mm = atlas_views._aggregate_volume_along_z(
|
||||||
|
volume,
|
||||||
|
source_positions_mm,
|
||||||
|
float(target_spacing),
|
||||||
|
float(slab_thickness),
|
||||||
|
recon_thickness_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if volume_for_recon.shape[0] < 1:
|
||||||
|
raise ValueError("No reconstruction slices were generated")
|
||||||
|
|
||||||
|
base_z_offset = float(recon_centers_mm[0]) if len(recon_centers_mm) > 0 else 0.0
|
||||||
|
|
||||||
|
def build_position(origin, row_vector, col_vector, stack_vector, row_index=0, col_index=0, stack_offset=0.0):
|
||||||
|
pos = (
|
||||||
|
np.asarray(origin, dtype=float)
|
||||||
|
+ (np.asarray(stack_vector, dtype=float) * float(stack_offset))
|
||||||
|
+ (np.asarray(row_vector, dtype=float) * (float(row_index) * float(native_row_spacing)))
|
||||||
|
+ (np.asarray(col_vector, dtype=float) * (float(col_index) * float(native_col_spacing)))
|
||||||
|
)
|
||||||
|
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
||||||
|
|
||||||
|
plane_slices = {}
|
||||||
|
for plane in recon_planes:
|
||||||
|
plane_norm = plane.lower()
|
||||||
|
if plane_norm == "axial":
|
||||||
|
recon_slices = [volume_for_recon[i, :, :] for i in range(volume_for_recon.shape[0])]
|
||||||
|
pixel_spacing_out = [native_row_spacing, native_col_spacing]
|
||||||
|
spacing_between_slices_out = float(target_spacing)
|
||||||
|
image_orientation_out = [
|
||||||
|
float(row_dir[0]),
|
||||||
|
float(row_dir[1]),
|
||||||
|
float(row_dir[2]),
|
||||||
|
float(col_dir[0]),
|
||||||
|
float(col_dir[1]),
|
||||||
|
float(col_dir[2]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def position_for_index(idx):
|
||||||
|
return build_position(
|
||||||
|
origin_ipp,
|
||||||
|
row_dir,
|
||||||
|
col_dir,
|
||||||
|
normal_dir,
|
||||||
|
row_index=0,
|
||||||
|
col_index=0,
|
||||||
|
stack_offset=float(recon_centers_mm[idx]),
|
||||||
|
)
|
||||||
|
elif plane_norm == "coronal":
|
||||||
|
recon_slices = [volume_for_recon[:, i, :] for i in range(volume_for_recon.shape[1])]
|
||||||
|
pixel_spacing_out = [target_spacing, native_col_spacing]
|
||||||
|
spacing_between_slices_out = float(native_row_spacing)
|
||||||
|
image_orientation_out = [
|
||||||
|
float(normal_dir[0]),
|
||||||
|
float(normal_dir[1]),
|
||||||
|
float(normal_dir[2]),
|
||||||
|
float(col_dir[0]),
|
||||||
|
float(col_dir[1]),
|
||||||
|
float(col_dir[2]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def position_for_index(idx):
|
||||||
|
return build_position(
|
||||||
|
origin_ipp,
|
||||||
|
row_dir,
|
||||||
|
col_dir,
|
||||||
|
normal_dir,
|
||||||
|
row_index=idx,
|
||||||
|
col_index=0,
|
||||||
|
stack_offset=base_z_offset,
|
||||||
|
)
|
||||||
|
elif plane_norm == "sagittal":
|
||||||
|
recon_slices = [volume_for_recon[:, :, i] for i in range(volume_for_recon.shape[2])]
|
||||||
|
pixel_spacing_out = [target_spacing, native_row_spacing]
|
||||||
|
spacing_between_slices_out = float(native_col_spacing)
|
||||||
|
image_orientation_out = [
|
||||||
|
float(normal_dir[0]),
|
||||||
|
float(normal_dir[1]),
|
||||||
|
float(normal_dir[2]),
|
||||||
|
float(row_dir[0]),
|
||||||
|
float(row_dir[1]),
|
||||||
|
float(row_dir[2]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def position_for_index(idx):
|
||||||
|
return build_position(
|
||||||
|
origin_ipp,
|
||||||
|
row_dir,
|
||||||
|
col_dir,
|
||||||
|
normal_dir,
|
||||||
|
row_index=0,
|
||||||
|
col_index=idx,
|
||||||
|
stack_offset=base_z_offset,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
plane_slices[plane_norm] = {
|
||||||
|
"recon_slices": recon_slices,
|
||||||
|
"pixel_spacing_out": pixel_spacing_out,
|
||||||
|
"spacing_between_slices_out": spacing_between_slices_out,
|
||||||
|
"image_orientation_out": image_orientation_out,
|
||||||
|
"position_for_index": position_for_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not plane_slices:
|
||||||
|
raise ValueError("No valid reconstruction planes selected")
|
||||||
|
|
||||||
|
total_slices = sum(len(v["recon_slices"]) for v in plane_slices.values())
|
||||||
|
processed = 0
|
||||||
|
created_series = []
|
||||||
|
|
||||||
|
for plane_norm, cfg in plane_slices.items():
|
||||||
|
recon_series = atlas_views._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(cfg["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()
|
||||||
|
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
||||||
|
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
||||||
|
ds_new.ImagePositionPatient = cfg["position_for_index"](idx)
|
||||||
|
ds_new.SliceThickness = float(slab_thickness)
|
||||||
|
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={
|
||||||
|
"current": processed,
|
||||||
|
"total": total_slices,
|
||||||
|
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
created_series.append(
|
||||||
|
{
|
||||||
|
"id": recon_series.pk,
|
||||||
|
"url": recon_series.get_absolute_url(),
|
||||||
|
"description": recon_series.description or str(recon_series.pk),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Reconstruction task complete for series {} with {} outputs",
|
||||||
|
series.pk,
|
||||||
|
len(created_series),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"series_id": series.pk,
|
||||||
|
"created_series": created_series,
|
||||||
|
"target_spacing": float(target_spacing),
|
||||||
|
"slab_thickness": float(slab_thickness),
|
||||||
|
"mode": recon_thickness_mode,
|
||||||
|
}
|
||||||
@@ -294,10 +294,19 @@
|
|||||||
|
|
||||||
<div class="d-flex flex-wrap 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" type="button">Test preview</button>
|
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary" type="button">Test preview</button>
|
||||||
<button class="btn btn-sm btn-danger" type="submit" name="operation" value="truncate">Apply truncate</button>
|
<button id="truncate-apply-btn" 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>
|
<button class="btn btn-sm btn-outline-warning" type="submit" name="operation" value="remove_empty">Remove empty DICOMs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="truncate-progress" class="d-none mb-3">
|
||||||
|
<div class="small text-warning mb-1">Applying truncate. Please wait...</div>
|
||||||
|
<div class="progress" style="height: 8px;">
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-animated bg-warning" style="width: 100%" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted mb-3">Truncate is a dedicated step. Downsample and reconstruction always run on the full active series.</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -443,6 +452,19 @@
|
|||||||
endHidden.value = endInput.value;
|
endHidden.value = endInput.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBoundsInputs(startValue, endValue) {
|
||||||
|
const startInput = document.getElementById('truncate-lower-input');
|
||||||
|
const endInput = document.getElementById('truncate-upper-input');
|
||||||
|
if (!startInput || !endInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startInput.value = String(startValue);
|
||||||
|
endInput.value = String(endValue);
|
||||||
|
startInput.max = String(endValue);
|
||||||
|
endInput.max = String(endValue);
|
||||||
|
syncOptimizeBounds();
|
||||||
|
}
|
||||||
|
|
||||||
function getTruncateViewerElement() {
|
function getTruncateViewerElement() {
|
||||||
return document.getElementById(truncateApiKey);
|
return document.getElementById(truncateApiKey);
|
||||||
}
|
}
|
||||||
@@ -559,8 +581,46 @@
|
|||||||
syncOptimizeBounds();
|
syncOptimizeBounds();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:beforeRequest', function (event) {
|
||||||
|
const form = document.getElementById('series-optimize-form');
|
||||||
|
if (!form || event.target !== form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const op = event.detail?.parameters?.operation || '';
|
||||||
|
const truncateProgress = document.getElementById('truncate-progress');
|
||||||
|
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
|
||||||
|
if (op === 'truncate') {
|
||||||
|
truncateProgress?.classList.remove('d-none');
|
||||||
|
if (truncateApplyBtn) {
|
||||||
|
truncateApplyBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function (event) {
|
||||||
|
const form = document.getElementById('series-optimize-form');
|
||||||
|
if (!form || event.target !== form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const truncateProgress = document.getElementById('truncate-progress');
|
||||||
|
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
|
||||||
|
truncateProgress?.classList.add('d-none');
|
||||||
|
if (truncateApplyBtn) {
|
||||||
|
truncateApplyBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||||
if (event.target && event.target.id === 'series-optimize-feedback') {
|
if (event.target && event.target.id === 'series-optimize-feedback') {
|
||||||
|
const truncatePayload = document.getElementById('truncate-range-payload');
|
||||||
|
if (truncatePayload) {
|
||||||
|
const start = parseInt(truncatePayload.dataset.start || '1', 10);
|
||||||
|
const end = parseInt(truncatePayload.dataset.end || '1', 10);
|
||||||
|
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
||||||
|
updateBoundsInputs(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = document.getElementById('downsample-compare-payload');
|
const payload = document.getElementById('downsample-compare-payload');
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -570,6 +570,11 @@ urlpatterns = [
|
|||||||
views.series_optimize_htmx,
|
views.series_optimize_htmx,
|
||||||
name="series_optimize",
|
name="series_optimize",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"series/<int:series_id>/reconstruct/status/<str:task_id>/",
|
||||||
|
views.series_reconstruct_status_htmx,
|
||||||
|
name="series_reconstruct_status",
|
||||||
|
),
|
||||||
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"),
|
||||||
|
|||||||
+107
-195
@@ -163,7 +163,8 @@ from .filters import (
|
|||||||
NormalCaseFilter,
|
NormalCaseFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .tasks import push_case_to_cimar_task
|
from .tasks import push_case_to_cimar_task, series_reconstruct_task
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
|
||||||
from django_tables2 import SingleTableView, SingleTableMixin
|
from django_tables2 import SingleTableView, SingleTableMixin
|
||||||
from django_filters.views import FilterView
|
from django_filters.views import FilterView
|
||||||
@@ -530,6 +531,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
|
|
||||||
images = list(series.get_images())
|
images = list(series.get_images())
|
||||||
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
|
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
|
||||||
|
full_series_images = images
|
||||||
|
|
||||||
if operation == "truncate":
|
if operation == "truncate":
|
||||||
removed = []
|
removed = []
|
||||||
@@ -542,8 +544,19 @@ def series_optimize_htmx(request, series_id):
|
|||||||
|
|
||||||
series.modified = Series.SeriesModifiedChocies.TR
|
series.modified = Series.SeriesModifiedChocies.TR
|
||||||
series.save(update_fields=["modified"])
|
series.save(update_fields=["modified"])
|
||||||
|
|
||||||
|
new_total = int(series.get_image_count())
|
||||||
|
payload = (
|
||||||
|
f'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
|
||||||
|
)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
f'<div class="alert alert-success mb-0">Truncate complete. Removed <strong>{len(removed)}</strong> images.</div>'
|
(
|
||||||
|
'<div class="alert alert-success mb-2">'
|
||||||
|
f'Truncate complete. Removed <strong>{len(removed)}</strong> images. '
|
||||||
|
f'Updated working range to <strong>1-{new_total}</strong>.'
|
||||||
|
'</div>'
|
||||||
|
f'{payload}'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if operation == "remove_empty":
|
if operation == "remove_empty":
|
||||||
@@ -573,7 +586,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
|
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
|
||||||
created = 0
|
created = 0
|
||||||
|
|
||||||
for image in bounded_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:
|
||||||
continue
|
continue
|
||||||
@@ -618,7 +631,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
downsampled = []
|
downsampled = []
|
||||||
for image in bounded_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:
|
||||||
continue
|
continue
|
||||||
@@ -672,207 +685,106 @@ def series_optimize_htmx(request, series_id):
|
|||||||
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>')
|
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>')
|
||||||
if slice_spacing_val is not None and slice_spacing_val <= 0:
|
if slice_spacing_val is not None and slice_spacing_val <= 0:
|
||||||
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
|
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
|
||||||
|
async_task = series_reconstruct_task.delay(
|
||||||
dicom_items = []
|
series_id=series.pk,
|
||||||
for image in bounded_images:
|
user_id=request.user.pk,
|
||||||
ds = _read_series_image_dataset(image)
|
recon_planes=recon_planes,
|
||||||
if ds is None:
|
slice_thickness_val=slice_thickness_val,
|
||||||
continue
|
slice_spacing_val=slice_spacing_val,
|
||||||
try:
|
recon_thickness_mode=recon_thickness_mode,
|
||||||
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>')
|
|
||||||
|
|
||||||
geom = _extract_recon_geometry(dicom_items)
|
|
||||||
sorted_items = geom["sorted_items"]
|
|
||||||
volume = np.stack([item[2] for item in sorted_items], axis=0)
|
|
||||||
|
|
||||||
template_ds = sorted_items[0][1]
|
|
||||||
source_positions_mm = geom["source_positions_mm"]
|
|
||||||
origin_ipp = geom["origin_ipp"]
|
|
||||||
row_dir = geom["row_dir"]
|
|
||||||
col_dir = geom["col_dir"]
|
|
||||||
normal_dir = geom["normal_dir"]
|
|
||||||
native_row_spacing = geom["row_spacing"]
|
|
||||||
native_col_spacing = geom["col_spacing"]
|
|
||||||
native_z_spacing = geom["native_z_spacing"]
|
|
||||||
|
|
||||||
target_spacing = slice_spacing_val if slice_spacing_val is not None else native_z_spacing
|
|
||||||
slab_thickness = slice_thickness_val if slice_thickness_val is not None else target_spacing
|
|
||||||
|
|
||||||
try:
|
|
||||||
volume_for_recon, recon_centers_mm = _aggregate_volume_along_z(
|
|
||||||
volume,
|
|
||||||
source_positions_mm,
|
|
||||||
float(target_spacing),
|
|
||||||
float(slab_thickness),
|
|
||||||
recon_thickness_mode,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Reconstruction aggregation failed for series {}: {}", series.pk, exc)
|
|
||||||
return HttpResponse('<div class="alert alert-danger mb-0">Failed to build reconstruction slabs for the requested spacing/thickness.</div>')
|
|
||||||
|
|
||||||
if volume_for_recon.shape[0] < 1:
|
|
||||||
return HttpResponse('<div class="alert alert-warning mb-0">No reconstruction slices were generated.</div>')
|
|
||||||
|
|
||||||
base_z_offset = float(recon_centers_mm[0]) if len(recon_centers_mm) > 0 else 0.0
|
|
||||||
|
|
||||||
def build_position(origin, row_vector, col_vector, stack_vector, row_index=0, col_index=0, stack_offset=0.0):
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
pos = (
|
|
||||||
np.asarray(origin, dtype=float)
|
|
||||||
+ (np.asarray(stack_vector, dtype=float) * float(stack_offset))
|
|
||||||
+ (np.asarray(row_vector, dtype=float) * (float(row_index) * float(native_row_spacing)))
|
|
||||||
+ (np.asarray(col_vector, dtype=float) * (float(col_index) * float(native_col_spacing)))
|
|
||||||
)
|
|
||||||
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
|
||||||
|
|
||||||
created_series = []
|
|
||||||
|
|
||||||
for plane in recon_planes:
|
|
||||||
plane_norm = plane.lower()
|
|
||||||
if plane_norm == "axial":
|
|
||||||
recon_slices = [volume_for_recon[i, :, :] for i in range(volume_for_recon.shape[0])]
|
|
||||||
pixel_spacing_out = [native_row_spacing, native_col_spacing]
|
|
||||||
spacing_between_slices_out = float(target_spacing)
|
|
||||||
image_orientation_out = [
|
|
||||||
float(row_dir[0]),
|
|
||||||
float(row_dir[1]),
|
|
||||||
float(row_dir[2]),
|
|
||||||
float(col_dir[0]),
|
|
||||||
float(col_dir[1]),
|
|
||||||
float(col_dir[2]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def position_for_index(idx):
|
|
||||||
return build_position(
|
|
||||||
origin_ipp,
|
|
||||||
row_dir,
|
|
||||||
col_dir,
|
|
||||||
normal_dir,
|
|
||||||
row_index=0,
|
|
||||||
col_index=0,
|
|
||||||
stack_offset=float(recon_centers_mm[idx]),
|
|
||||||
)
|
|
||||||
elif plane_norm == "coronal":
|
|
||||||
recon_slices = [volume_for_recon[:, i, :] for i in range(volume_for_recon.shape[1])]
|
|
||||||
pixel_spacing_out = [target_spacing, native_col_spacing]
|
|
||||||
spacing_between_slices_out = float(native_row_spacing)
|
|
||||||
image_orientation_out = [
|
|
||||||
float(normal_dir[0]),
|
|
||||||
float(normal_dir[1]),
|
|
||||||
float(normal_dir[2]),
|
|
||||||
float(col_dir[0]),
|
|
||||||
float(col_dir[1]),
|
|
||||||
float(col_dir[2]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def position_for_index(idx):
|
|
||||||
return build_position(
|
|
||||||
origin_ipp,
|
|
||||||
row_dir,
|
|
||||||
col_dir,
|
|
||||||
normal_dir,
|
|
||||||
row_index=idx,
|
|
||||||
col_index=0,
|
|
||||||
stack_offset=base_z_offset,
|
|
||||||
)
|
|
||||||
elif plane_norm == "sagittal":
|
|
||||||
recon_slices = [volume_for_recon[:, :, i] for i in range(volume_for_recon.shape[2])]
|
|
||||||
pixel_spacing_out = [target_spacing, native_row_spacing]
|
|
||||||
spacing_between_slices_out = float(native_col_spacing)
|
|
||||||
image_orientation_out = [
|
|
||||||
float(normal_dir[0]),
|
|
||||||
float(normal_dir[1]),
|
|
||||||
float(normal_dir[2]),
|
|
||||||
float(row_dir[0]),
|
|
||||||
float(row_dir[1]),
|
|
||||||
float(row_dir[2]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def position_for_index(idx):
|
|
||||||
return build_position(
|
|
||||||
origin_ipp,
|
|
||||||
row_dir,
|
|
||||||
col_dir,
|
|
||||||
normal_dir,
|
|
||||||
row_index=0,
|
|
||||||
col_index=idx,
|
|
||||||
stack_offset=base_z_offset,
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
ds_new.PixelSpacing = [float(pixel_spacing_out[0]), float(pixel_spacing_out[1])]
|
|
||||||
ds_new.ImageOrientationPatient = image_orientation_out
|
|
||||||
ds_new.ImagePositionPatient = position_for_index(idx)
|
|
||||||
|
|
||||||
ds_new.SliceThickness = float(slab_thickness)
|
|
||||||
ds_new.SpacingBetweenSlices = float(spacing_between_slices_out)
|
|
||||||
|
|
||||||
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(
|
return HttpResponse(
|
||||||
(
|
(
|
||||||
'<div class="alert alert-success mb-0">'
|
'<div class="alert alert-info mb-2">'
|
||||||
f'Created <strong>{len(created_series)}</strong> reconstruction series '
|
'Reconstruction queued. This runs in the background to avoid request timeout.'
|
||||||
f'(spacing={target_spacing:.2f}mm, thickness={slab_thickness:.2f}mm, mode={escape(recon_thickness_mode)}): {links}'
|
|
||||||
'</div>'
|
'</div>'
|
||||||
|
f'<div id="recon-task-status" hx-get="{reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": async_task.id})}" '
|
||||||
|
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
|
||||||
|
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
|
||||||
|
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
|
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def series_reconstruct_status_htmx(request, series_id, task_id):
|
||||||
|
"""HTMX endpoint returning progress for a queued series reconstruction task."""
|
||||||
|
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>')
|
||||||
|
|
||||||
|
task_result = AsyncResult(task_id)
|
||||||
|
state = task_result.state
|
||||||
|
|
||||||
|
poll_url = reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_id})
|
||||||
|
|
||||||
|
if state in ("PENDING", "STARTED", "RETRY"):
|
||||||
|
return HttpResponse(
|
||||||
|
(
|
||||||
|
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
|
||||||
|
'<div class="d-flex align-items-center gap-2">'
|
||||||
|
'<span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
|
||||||
|
'<span class="small text-muted">Reconstruction is running...</span>'
|
||||||
|
'</div></div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if state == "PROGRESS":
|
||||||
|
meta = task_result.info or {}
|
||||||
|
current = int(meta.get("current", 0) or 0)
|
||||||
|
total = int(meta.get("total", 0) or 0)
|
||||||
|
message = escape(str(meta.get("message", "Reconstruction running...")))
|
||||||
|
pct = int((current / total) * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
(
|
||||||
|
f'<div id="recon-task-status" hx-get="{poll_url}" hx-trigger="every 2s" hx-swap="outerHTML">'
|
||||||
|
f'<div class="small text-muted mb-1">{message}</div>'
|
||||||
|
'<div class="progress" style="height: 10px;">'
|
||||||
|
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
|
||||||
|
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
|
||||||
|
'</div>'
|
||||||
|
f'<div class="small text-muted mt-1">{current}/{total} slices processed</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if state == "SUCCESS":
|
||||||
|
payload = task_result.result if isinstance(task_result.result, dict) else {}
|
||||||
|
created_series = payload.get("created_series", [])
|
||||||
|
|
||||||
|
if created_series:
|
||||||
|
links = " ".join(
|
||||||
|
[
|
||||||
|
f'<a class="alert-link me-2" target="_blank" href="{escape(item.get("url", "#"))}">{escape(item.get("description", str(item.get("id", "series"))))}</a>'
|
||||||
|
for item in created_series
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return HttpResponse(
|
||||||
|
(
|
||||||
|
'<div id="recon-task-status" class="alert alert-success mb-0">'
|
||||||
|
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse('<div id="recon-task-status" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
|
||||||
|
|
||||||
|
if state == "FAILURE":
|
||||||
|
err = escape(str(task_result.result))
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="recon-task-status" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="recon-task-status" class="alert alert-secondary mb-0">Task state: {escape(state)}</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