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 @@
- +
+
+
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'
' + '
' + 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.