diff --git a/atlas/tasks.py b/atlas/tasks.py
index 26b7a769..fc7234d4 100644
--- a/atlas/tasks.py
+++ b/atlas/tasks.py
@@ -3,11 +3,15 @@ from django.core.mail import send_mail
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from celery import shared_task
-from atlas.models import Case
+from atlas.models import Case, Series, SeriesImage
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 django.contrib.auth.models import User
+from django.core.files.base import ContentFile
+import copy
+import io
@shared_task()
def push_case_to_cimar_task(case_id):
@@ -49,4 +53,246 @@ def push_case_to_cimar_task(case_id):
cimar_case.refresh_study()
- return 10
\ No newline at end of file
+ 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,
+ }
\ No newline at end of file
diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html
index a295998b..e76869fe 100755
--- a/atlas/templates/atlas/series_viewer.html
+++ b/atlas/templates/atlas/series_viewer.html
@@ -294,10 +294,19 @@
Test preview
- Apply truncate
+ Apply truncate
Remove empty DICOMs
+
+
Applying truncate. Please wait...
+
+
+
+ Truncate is a dedicated step. Downsample and reconstruction always run on the full active series.
+
@@ -443,6 +452,19 @@
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() {
return document.getElementById(truncateApiKey);
}
@@ -559,8 +581,46 @@
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) {
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');
if (!payload) {
return;
diff --git a/atlas/urls.py b/atlas/urls.py
index 80e1047e..f7b6a979 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -570,6 +570,11 @@ urlpatterns = [
views.series_optimize_htmx,
name="series_optimize",
),
+ path(
+ "series/
/reconstruct/status//",
+ views.series_reconstruct_status_htmx,
+ name="series_reconstruct_status",
+ ),
path("series//images/", views.series_images_partial, name="series_images"),
path("series//authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series//finding/related", views.series_finding_related, name="series_finding_related"),
diff --git a/atlas/views.py b/atlas/views.py
index 0390c853..8a80fecd 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -163,7 +163,8 @@ from .filters import (
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_filters.views import FilterView
@@ -530,6 +531,7 @@ def series_optimize_htmx(request, series_id):
images = list(series.get_images())
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
+ full_series_images = images
if operation == "truncate":
removed = []
@@ -542,8 +544,19 @@ def series_optimize_htmx(request, series_id):
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
+
+ new_total = int(series.get_image_count())
+ payload = (
+ f'
'
+ )
return HttpResponse(
- f'Truncate complete. Removed {len(removed)} images.
'
+ (
+ ''
+ f'Truncate complete. Removed {len(removed)} images. '
+ f'Updated working range to 1-{new_total} .'
+ '
'
+ f'{payload}'
+ )
)
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}%")
created = 0
- for image in bounded_images:
+ for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
@@ -618,7 +631,7 @@ def series_optimize_htmx(request, series_id):
)
downsampled = []
- for image in bounded_images:
+ for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
@@ -672,207 +685,106 @@ def series_optimize_htmx(request, series_id):
return HttpResponse('Slice thickness must be greater than 0.
')
if slice_spacing_val is not None and slice_spacing_val <= 0:
return HttpResponse('Slice spacing must be greater than 0.
')
-
- 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('Need at least 2 valid DICOM images in range for reconstruction.
')
-
- 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('Not enough consistently-sized slices for reconstruction.
')
-
- 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('Failed to build reconstruction slabs for the requested spacing/thickness.
')
-
- if volume_for_recon.shape[0] < 1:
- return HttpResponse('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):
- 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('No reconstruction plane could be generated.
')
-
- links = " ".join(
- [
- f'{escape(s.description or str(s.pk))} '
- for s in created_series
- ]
+ async_task = series_reconstruct_task.delay(
+ series_id=series.pk,
+ user_id=request.user.pk,
+ recon_planes=recon_planes,
+ slice_thickness_val=slice_thickness_val,
+ slice_spacing_val=slice_spacing_val,
+ recon_thickness_mode=recon_thickness_mode,
)
+
return HttpResponse(
(
- ''
- f'Created
{len(created_series)} reconstruction series '
- f'(spacing={target_spacing:.2f}mm, thickness={slab_thickness:.2f}mm, mode={escape(recon_thickness_mode)}): {links}'
+ '
'
+ 'Reconstruction queued. This runs in the background to avoid request timeout.'
'
'
+ f'
'
+ '
'
+ 'Starting reconstruction task...
'
)
)
return HttpResponse('
Unknown optimize operation.
')
+@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('
Permission denied
')
+
+ 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'
'
+ '
'
+ ' '
+ 'Reconstruction is running... '
+ '
'
+ )
+ )
+
+ 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'
'
+ f'
{message}
'
+ '
'
+ f'
{current}/{total} slices processed
'
+ '
'
+ )
+ )
+
+ 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'
{escape(item.get("description", str(item.get("id", "series"))))} '
+ for item in created_series
+ ]
+ )
+ return HttpResponse(
+ (
+ '
'
+ f'Reconstruction complete. Created {len(created_series)} series: {links}'
+ '
'
+ )
+ )
+
+ return HttpResponse('
Reconstruction finished but no output series were created.
')
+
+ if state == "FAILURE":
+ err = escape(str(task_result.result))
+ return HttpResponse(
+ f'
Reconstruction failed: {err}
'
+ )
+
+ return HttpResponse(
+ f'
Task state: {escape(state)}
'
+ )
+
+
@login_required
def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series.