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.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
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -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;
|
||||
|
||||
@@ -570,6 +570,11 @@ urlpatterns = [
|
||||
views.series_optimize_htmx,
|
||||
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>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
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,
|
||||
)
|
||||
|
||||
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'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
|
||||
)
|
||||
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":
|
||||
@@ -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('<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:
|
||||
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
|
||||
|
||||
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('<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
|
||||
]
|
||||
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(
|
||||
(
|
||||
'<div class="alert alert-success mb-0">'
|
||||
f'Created <strong>{len(created_series)}</strong> reconstruction series '
|
||||
f'(spacing={target_spacing:.2f}mm, thickness={slab_thickness:.2f}mm, mode={escape(recon_thickness_mode)}): {links}'
|
||||
'<div class="alert alert-info mb-2">'
|
||||
'Reconstruction queued. This runs in the background to avoid request timeout.'
|
||||
'</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>')
|
||||
|
||||
|
||||
@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
|
||||
def series_image_size_htmx(request, series_id):
|
||||
"""HTMX endpoint returning the total size of images in a series.
|
||||
|
||||
Reference in New Issue
Block a user