Compare commits

..
20 Commits
Author SHA1 Message Date
Ross ff00efecf7 feat(tasks): Enhance series_reconstruct_task with additional metadata and improved slice handling 2026-05-17 22:29:23 +01:00
Ross a9946961f1 fix(tasks): Update _reduce_slab function to accept reduce_axis parameter for flexibility in reconstruction 2026-05-17 22:20:22 +01:00
Ross d5e473d692 style(tasks): Improve task overview page layout and styling for better readability 2026-05-17 22:08:07 +01:00
Ross 75e3bca503 feat(tasks): Implement task action functionality in task overview page 2026-05-17 21:53:30 +01:00
Ross 1775b11e40 fix(tasks): Correct ImagePositionPatient assignment in series_reconstruct_task 2026-05-17 21:44:09 +01:00
Ross d77c5149f0 feat(worker): Enhance db_worker script for better error handling and add systemd service example 2026-05-17 21:36:46 +01:00
Ross c2d8376e22 feat(worker): Add db_worker service with automatic restart and logging
feat(task_overview): Display generated series in task overview
docs(README): Update documentation for background task workers and troubleshooting
2026-05-17 21:13:40 +01:00
Ross ab9847e22e feat(tasks): Add task overview page with active, completed, and failed task summaries 2026-05-17 14:30:38 +01:00
Ross bbf7542451 feat(settings): Update task backend to use django-tasks-db and add QUEUES configuration 2026-05-17 14:00:04 +01:00
Ross e6a20bb851 feat(tasks): Integrate django-tasks for task management and progress tracking 2026-05-17 12:41:17 +01:00
Ross 7ed0a8f378 feat(reconstruction): Add asynchronous series reconstruction task with progress tracking 2026-05-17 11:32:48 +01:00
Ross c8d05818a4 feat(reconstruction): Enhance volume reconstruction with geometry extraction and thickness merge options 2026-05-17 11:19:18 +01:00
Ross b6716c9ae1 feat(viewer): Refactor truncate viewer API integration and update modal handling 2026-05-17 11:04:18 +01:00
Ross e368d0c7d1 feat(series): Add functions for inferring native spacings and resampling volumes for optimization 2026-05-16 22:42:49 +01:00
Ross b9cbfd20c1 feat(optimization): Implement series optimization features including downsampling, empty DICOM removal, and reconstruction options 2026-05-16 22:30:55 +01:00
Ross 5ce337c7c4 feat(series): Enhance series truncation with options for removing empty DICOMs and downsampling pixel data 2026-05-16 22:02:50 +01:00
Ross f4d990676a feat(viewer): Enhance sidebar functionality with adjustable width and responsive behavior 2026-05-15 20:28:05 +01:00
Ross 4f61e43b8c feat(viewer): Refactor DICOM viewer structure and update responsive styles 2026-05-15 20:19:12 +01:00
Ross c947cba5a7 feat(viewer): Enhance viewer functionality with sidebar and popout support 2026-05-15 20:11:31 +01:00
Ross 0cf655325c feat: Enhance collection review and overview templates with improved styling and conditional content display 2026-05-15 20:04:13 +01:00
24 changed files with 3036 additions and 191 deletions
+63
View File
@@ -11,6 +11,69 @@ Running locally (development)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
``` ```
Background task workers (automatic)
- The compose stack now includes a dedicated `worker` service which runs:
```sh
python manage.py db_worker
```
- This means workers start automatically with `docker compose up` and restart on failure.
- To inspect worker logs:
```sh
docker compose logs -f worker
```
- To scale workers (for higher throughput):
```sh
docker compose up -d --scale worker=2
```
Troubleshooting `SIGKILL` when running manually
- A `SIGKILL` on `db_worker` is usually the kernel OOM killer (out-of-memory), not a Django exception.
- Check kernel OOM events:
```sh
dmesg -T | grep -i -E "killed process|out of memory|oom"
```
- If OOM is confirmed, prefer running worker inside compose (managed restart + container limits), reduce concurrency/parallel services, or add memory/swap.
Manual worker startup (non-Docker production)
- If you are not yet running production in Docker, start the worker using the restart wrapper script:
```sh
source .venv/bin/activate
DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh
```
- This script restarts `db_worker` if it exits unexpectedly.
- To run it in the background and keep logs:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
```
- In fish shell, follow with `disown` so the shell does not keep the job attached:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
disown
```
- For server reliability, prefer running the same script under `systemd` (auto-start on reboot, restart-on-failure).
- A starter unit file is provided at `scripts/db-worker.service.example`.
- To verify it is still running:
```sh
ps aux | grep "manage.py db_worker" | grep -v grep
tail -f logs/db_worker.log
```
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are: - By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
- `NGINX_HTTP_PORT=8080` - `NGINX_HTTP_PORT=8080`
- `NGINX_HTTPS_PORT=8444` - `NGINX_HTTPS_PORT=8444`
+190
View File
@@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.contrib import messages
from .models import ( from .models import (
Case, Case,
CaseDetail, CaseDetail,
@@ -29,12 +30,19 @@ from django.forms import ModelForm
from reversion.admin import VersionAdmin from reversion.admin import VersionAdmin
from django.forms.widgets import RadioSelect from django.forms.widgets import RadioSelect
from django.utils.html import format_html
from django.urls import reverse
from tinymce.widgets import TinyMCE from tinymce.widgets import TinyMCE
# from generic.models import Examination, Sign, Site, Condition # from generic.models import Examination, Sign, Site, Condition
from generic.models import Examination from generic.models import Examination
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
# Register your models here. # Register your models here.
# admin.site.register(Examination) # admin.site.register(Examination)
# admin.site.register(Site) # admin.site.register(Site)
@@ -152,3 +160,185 @@ class APITokenAdmin(admin.ModelAdmin):
admin.site.register(APIToken, APITokenAdmin) admin.site.register(APIToken, APITokenAdmin)
if DBTaskResult is not None:
try:
# Ensure package admin registration has run before we customize it.
import django_tasks_db.admin # noqa: F401
except Exception:
pass
def traceback_link(self, obj):
if not obj.traceback:
return "-"
url = reverse(
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
args=[obj.pk],
)
return format_html('<a href="{}">View full traceback</a>', url)
traceback_link.short_description = "Traceback"
def traceback_pretty(self, obj):
if not obj.traceback:
return ""
return format_html(
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
obj.traceback,
)
traceback_pretty.short_description = "Traceback"
@admin.action(description="Reset FAILED tasks to READY")
def reset_failed_to_ready(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
updated = failed_qs.update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
self.message_user(
request,
f"Reset {updated} failed task(s) to READY.",
level=messages.SUCCESS,
)
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
def reset_selected_to_ready(self, request, queryset):
blocked = queryset.filter(status="RUNNING").count()
reset_qs = queryset.exclude(status="RUNNING")
updated = reset_qs.update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
msg += f" Skipped {blocked} running task(s)."
self.message_user(request, msg, level=messages.SUCCESS)
@admin.action(description="Delete selected COMPLETED tasks")
def delete_selected_completed(self, request, queryset):
completed_qs = queryset.filter(status="SUCCESSFUL")
deleted, _ = completed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} completed task record(s).",
level=messages.SUCCESS,
)
@admin.action(description="Delete selected FAILED tasks")
def delete_selected_failed(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
deleted, _ = failed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} failed task record(s).",
level=messages.SUCCESS,
)
def has_add_permission(self, request):
return False
task_admin = admin.site._registry.get(DBTaskResult)
if task_admin is not None:
admin_cls = task_admin.__class__
admin_cls.list_display = (
"id",
"task_path",
"status",
"queue_name",
"priority",
"enqueued_at",
"started_at",
"finished_at",
"worker_ids",
"exception_class_path",
"traceback_link",
)
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
admin_cls.search_fields = (
"id",
"task_path",
"queue_name",
"worker_ids",
"exception_class_path",
"traceback",
)
admin_cls.ordering = ("-enqueued_at",)
admin_cls.actions = (
"reset_failed_to_ready",
"reset_selected_to_ready",
"delete_selected_completed",
"delete_selected_failed",
)
admin_cls.readonly_fields = (
"id",
"status",
"enqueued_at",
"started_at",
"finished_at",
"args_kwargs",
"priority",
"task_path",
"worker_ids",
"queue_name",
"backend_name",
"run_after",
"return_value",
"exception_class_path",
"traceback_pretty",
)
admin_cls.fieldsets = (
(
"Task",
{
"fields": (
"id",
"task_path",
"status",
"queue_name",
"priority",
"backend_name",
)
},
),
(
"Timing",
{
"fields": (
"enqueued_at",
"run_after",
"started_at",
"finished_at",
"worker_ids",
)
},
),
(
"Payload",
{"fields": ("args_kwargs", "return_value")},
),
(
"Error",
{
"fields": ("exception_class_path", "traceback_pretty"),
},
),
)
admin_cls.traceback_link = traceback_link
admin_cls.traceback_pretty = traceback_pretty
admin_cls.reset_failed_to_ready = reset_failed_to_ready
admin_cls.reset_selected_to_ready = reset_selected_to_ready
admin_cls.delete_selected_completed = delete_selected_completed
admin_cls.delete_selected_failed = delete_selected_failed
admin_cls.has_add_permission = has_add_permission
+441 -3
View File
@@ -2,14 +2,98 @@ from time import sleep
from django.core.mail import send_mail 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 enum import Enum
from atlas.models import Case
try:
from django_tasks import task
HAS_DJANGO_TASKS = True
except ImportError:
from celery import shared_task
from celery.result import AsyncResult
HAS_DJANGO_TASKS = False
class _CompatTaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
class _CompatError:
def __init__(self, traceback):
self.traceback = traceback
class _CompatTaskResult:
def __init__(self, async_result):
self._async_result = async_result
@property
def id(self):
return self._async_result.id
@property
def status(self):
state = self._async_result.state
if state in ("PENDING",):
return _CompatTaskResultStatus.READY
if state in ("STARTED", "RETRY"):
return _CompatTaskResultStatus.RUNNING
if state == "SUCCESS":
return _CompatTaskResultStatus.SUCCESSFUL
if state == "FAILURE":
return _CompatTaskResultStatus.FAILED
return _CompatTaskResultStatus.RUNNING
def refresh(self):
return self
@property
def return_value(self):
return self._async_result.result
@property
def errors(self):
if self._async_result.state == "FAILURE" and self._async_result.traceback:
return [_CompatError(self._async_result.traceback)]
return []
def task(func=None, *, takes_context=False, **kwargs):
def decorator(inner_func):
if takes_context:
@shared_task(bind=True)
def wrapped(self, *args, **inner_kwargs):
class _Ctx:
class _TaskResultRef:
id = self.request.id
task_result = _TaskResultRef()
attempt = 1
return inner_func(_Ctx(), *args, **inner_kwargs)
else:
wrapped = shared_task(inner_func)
wrapped.enqueue = wrapped.delay
wrapped.get_result = lambda result_id: _CompatTaskResult(AsyncResult(result_id))
return wrapped
if func is not None:
return decorator(func)
return decorator
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 datetime import datetime
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.core.cache import cache
import copy
import io
@shared_task() @task
def push_case_to_cimar_task(case_id): def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted.""" """Sends an email when the feedback form has been submitted."""
case = get_object_or_404(Case, pk=case_id) case = get_object_or_404(Case, pk=case_id)
@@ -50,3 +134,357 @@ def push_case_to_cimar_task(case_id):
cimar_case.refresh_study() cimar_case.refresh_study()
return 10 return 10
@task(takes_context=True)
def series_reconstruct_task(
context,
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
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
timeout=60 * 60,
)
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]
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"]
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
# Determine source acquisition plane from normal vector.
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
# Reorient the reconstructed volume into patient axes order [z, y, x] so
# generated plane names are anatomically correct regardless of source plane.
# NOTE: DICOM stores ImageOrientationPatient as:
# - first triplet: direction across columns (x-axis in pixel grid)
# - second triplet: direction across rows (y-axis in pixel grid)
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
# map axis spacing/indexing explicitly to avoid directional mixups.
source_axis_dirs = [normal_dir, col_dir, row_dir]
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
source_for_patient_axis = {}
sign_for_patient_axis = {}
patient_axes_claimed = set()
for src_axis, vec in enumerate(source_axis_dirs):
patient_axis = int(np.argmax(np.abs(vec)))
if patient_axis in patient_axes_claimed:
raise ValueError("Could not infer unique source orientation axes for reconstruction")
patient_axes_claimed.add(patient_axis)
source_for_patient_axis[patient_axis] = src_axis
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
src_x = source_for_patient_axis[0]
src_y = source_for_patient_axis[1]
src_z = source_for_patient_axis[2]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
if sign_for_patient_axis[2] < 0:
vol_zyx = np.flip(vol_zyx, axis=0)
if sign_for_patient_axis[1] < 0:
vol_zyx = np.flip(vol_zyx, axis=1)
if sign_for_patient_axis[0] < 0:
vol_zyx = np.flip(vol_zyx, axis=2)
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0, 0, 0]
z_index = float(iz)
y_index = float(iy)
x_index = float(ix)
if sign_for_patient_axis[2] < 0:
z_index = source_axis_sizes[src_z] - 1 - z_index
if sign_for_patient_axis[1] < 0:
y_index = source_axis_sizes[src_y] - 1 - y_index
if sign_for_patient_axis[0] < 0:
x_index = source_axis_sizes[src_x] - 1 - x_index
src_indices[src_z] = z_index
src_indices[src_y] = y_index
src_indices[src_x] = x_index
return src_indices
def position_from_patient_zyx(iz, iy, ix):
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
pos = (
np.asarray(origin_ipp, dtype=float)
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
def _reduce_slab(slab, reduce_axis=0):
if recon_thickness_mode == "max":
out = np.max(slab, axis=reduce_axis)
elif recon_thickness_mode == "min":
out = np.min(slab, axis=reduce_axis)
else:
out = np.mean(slab, axis=reduce_axis)
if np.issubdtype(volume.dtype, np.integer):
info = np.iinfo(volume.dtype)
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
else:
out = out.astype(volume.dtype, copy=False)
return out
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
if output_spacing <= 0:
raise ValueError("Slice spacing must be greater than 0")
if slab_thickness <= 0:
raise ValueError("Slice thickness must be greater than 0")
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if start_positions_mm.size == 0:
start_positions_mm = np.array([0.0], dtype=float)
slabs = []
centers_idx = []
for start_mm in start_positions_mm:
end_mm = start_mm + slab_thickness
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
center_mm = start_mm + (slab_thickness / 2.0)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
plane_builders = {}
for plane in recon_planes:
plane_norm = plane.lower()
if plane_norm == "axial":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
"plane_normal_out": [0.0, 0.0, 1.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
}
elif plane_norm == "coronal":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1)),
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [0.0, 1.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), centers[idx], 0.0),
}
elif plane_norm == "sagittal":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2)),
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [1.0, 0.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), 0.0, centers[idx]),
}
if not plane_builders:
raise ValueError("No valid reconstruction planes selected")
total_slices = sum(v["count"] for v in plane_builders.values())
cache.set(
progress_key,
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
timeout=60 * 60,
)
processed = 0
created_series = []
for plane_norm, cfg in plane_builders.items():
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
now = datetime.utcnow()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
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.SeriesDescription = recon_series.description or f"Recon {plane_norm.title()}"
ds_new.ImageType = ["DERIVED", "SECONDARY", "MPR"]
ds_new.DerivationDescription = (
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
)
ds_new.ContentDate = date_str
ds_new.ContentTime = time_str
ds_new.InstanceCreationDate = date_str
ds_new.InstanceCreationTime = time_str
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ipp = cfg["position_fn"](idx)
ds_new.ImagePositionPatient = ipp
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
try:
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
except Exception:
pass
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
cache.set(
progress_key,
{
"current": processed,
"total": total_slices,
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
},
timeout=60 * 60,
)
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),
)
cache.set(
progress_key,
{
"current": total_slices,
"total": total_slices,
"message": "Reconstruction complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
"mode": recon_thickness_mode,
"source_plane": source_plane,
}
+102 -5
View File
@@ -2,24 +2,40 @@
{% partialdef case-series %} {% partialdef case-series %}
{% for series in case.get_ordered_series %} {% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}"> <span class="series-block series-block-interactive" id="series-block-{{ series.pk }}" data-series-id="{{series.pk}}">
<span> <span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br> <span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"> <a href="{% url 'atlas:series_detail' pk=series.pk %}" class="series-block-title-link">
{{series.get_block}} {{series.get_block}}
</a> </a>
<br> <br>
<div class="series-block-actions mt-2 small">
<button type="button" <button type="button"
class="btn btn-outline-primary btn-sm select-series-btn" class="btn btn-outline-primary btn-xs btn-sm select-series-btn"
style="display: none;" style="display: none;"
data-series-id="{{series.pk}}"> data-series-id="{{series.pk}}">
Select Select
</button> </button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}"> <input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link"> <button type="button"
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
title="Open series in viewer"
data-series-id="{{series.pk}}">
<i class="bi bi-eye"></i> View
</button>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
class="btn btn-outline-secondary btn-xs btn-sm"
title="Open series page">
<i class="bi bi-box-arrow-up-right"></i> Page
</a>
<span class="series-block-popup-link ms-1">
<a href="#" <a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a> class="btn btn-outline-secondary btn-xs btn-sm"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">
<i class="bi bi-window"></i> Popup
</a>
</span> </span>
</div>
</span> </span>
</span> </span>
{% endfor %} {% endfor %}
@@ -162,6 +178,57 @@
border: 1px solid rgba(0,0,0,0.06); border: 1px solid rgba(0,0,0,0.06);
text-align: left; text-align: left;
} }
/* Enhanced series block with interactive features */
.series-block-interactive {
transition: all 0.2s ease;
cursor: pointer;
}
.series-block-interactive:hover {
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15);
border-color: rgba(13, 110, 253, 0.3);
}
.series-block-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.series-block-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.series-block-actions .btn-xs {
min-width: auto;
}
/* Also support dark theme when site sets a theme attribute/class */
.atlas[data-bs-theme="dark"] .series-block,
[data-bs-theme="dark"] .series-block,
body[data-bs-theme="dark"] .series-block,
body.bg-dark .series-block,
.bg-dark .series-block,
.dark .series-block,
.theme-dark .series-block {
background: #071018 !important;
border-color: rgba(255,255,255,0.03) !important;
color: #e6eef8 !important;
}
.atlas[data-bs-theme="dark"] .series-block-interactive:hover,
[data-bs-theme="dark"] .series-block-interactive:hover,
body[data-bs-theme="dark"] .series-block-interactive:hover,
body.bg-dark .series-block-interactive:hover,
.bg-dark .series-block-interactive:hover,
.dark .series-block-interactive:hover,
.theme-dark .series-block-interactive:hover {
border-color: rgba(158, 197, 255, 0.3) !important;
box-shadow: 0 2px 8px rgba(158, 197, 255, 0.1) !important;
}
/* Also support dark theme when site sets a theme attribute/class */ /* Also support dark theme when site sets a theme attribute/class */
.atlas[data-bs-theme="dark"] .series-block, .atlas[data-bs-theme="dark"] .series-block,
[data-bs-theme="dark"] .series-block, [data-bs-theme="dark"] .series-block,
@@ -1272,6 +1339,36 @@
} }
})(); })();
// Series viewer button handler: open series in viewer or navigate to series page
(function initSeriesViewerButtons() {
if (window.__seriesViewerButtonsBound) return;
window.__seriesViewerButtonsBound = true;
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const seriesId = this.getAttribute('data-series-id');
if (!seriesId) {
alert('Series ID not found');
return;
}
// Focus the viewer details element to open it
const viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
viewerDetails.setAttribute('open', 'open');
// Trigger the toggle event to ensure viewer initializes
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
}
// Note: Direct series loading will be implemented via API in the future.
// For now, this just opens and focuses the viewer.
// When the series page viewer API is available, we can load the specific series here:
// window.loadSeriesInViewer(seriesId);
});
});
})();
}); });
@@ -101,21 +101,45 @@
</div> </div>
{% endif %} {% endif %}
{% if collection.show_ohif_viewer %} {% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div> <div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
<span class="text-muted fw-semibold small me-auto">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button>
</div>
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
</div>
</div>
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
{% if question_completed %} {% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe> <iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %} {% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe> <iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% if collection.show_ohif_viewer_link %} {% if collection.show_ohif_viewer_link %}
<div> <div class="mt-2">
{% if question_completed %} {% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %} {% else %}
@@ -123,16 +147,9 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div>
{% endif %}
{% if collection.show_built_in_viewer %}
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
{% comment %} <div class="pre-whitespace multi-image-block"> {% comment %} <div class="pre-whitespace multi-image-block">
<details open> <details open>
@@ -165,8 +182,6 @@
</div> </div>
</div> {% endcomment %} </div> {% endcomment %}
{% else %}
{% endif %}
{% if show_discussion and case.discussion %} {% if show_discussion and case.discussion %}
<details class="card mb-3"> <details class="card mb-3">
@@ -335,6 +350,14 @@
</div> </div>
</aside> </aside>
<aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar">
<div class="viewer-sidebar-header">
<span class="fw-semibold">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
</div>
<div id="viewerSidebarBody" class="viewer-sidebar-body"></div>
</aside>
<style> <style>
label { label {
vertical-align: top; vertical-align: top;
@@ -443,6 +466,132 @@
overflow: auto; overflow: auto;
flex: 1 1 auto; flex: 1 1 auto;
} }
.viewer-resizable-container {
position: relative;
overflow: hidden;
height: clamp(560px, 78vh, 920px);
min-height: 320px;
max-height: 85vh;
resize: vertical;
}
.viewer-resizable-container .dicom-viewer-root {
width: 100% !important;
height: 100% !important;
min-height: 0;
max-height: none !important;
}
#viewer-container {
width: 100%;
}
#viewer-container #main_viewer {
box-sizing: border-box;
}
@media (max-width: 991.98px) {
.viewer-resizable-container {
height: clamp(360px, 60vh, 640px);
min-height: 280px;
}
}
.viewer-resize-handle {
height: 10px;
background: var(--bs-secondary-bg);
border: 1px solid var(--bs-border-color);
border-top: none;
border-radius: 0 0 4px 4px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
touch-action: none;
display: none;
}
.viewer-resize-handle:hover {
background: var(--bs-tertiary-bg);
}
.viewer-resize-grip-inner {
width: 40px;
height: 3px;
background: var(--bs-secondary-color);
border-radius: 2px;
opacity: 0.4;
}
.viewer-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(850px, 95vw);
min-width: 360px;
max-width: 95vw;
height: 100vh;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
box-shadow: -8px 0 18px rgba(0, 0, 0, 0.35);
z-index: 1045;
transform: translateX(100%);
transition: transform 0.2s ease-in-out;
display: flex;
flex-direction: column;
resize: horizontal;
overflow: auto;
}
.viewer-sidebar.open {
transform: translateX(0);
}
.viewer-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
flex-shrink: 0;
}
.viewer-sidebar-body {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer-sidebar-body #viewer-moveable {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.viewer-sidebar-body #viewer-container {
flex: 1 1 auto;
height: auto !important;
}
.viewer-sidebar-body .viewer-resize-handle {
display: none;
}
body.viewer-sidebar-open {
margin-right: var(--viewer-sidebar-width, min(850px, 95vw));
transition: margin-right 0.2s ease-in-out;
}
@media (max-width: 991.98px) {
body.viewer-sidebar-open {
margin-right: 0;
}
}
</style> </style>
{% endblock %} {% endblock %}
@@ -823,4 +972,219 @@
</script> </script>
<script type="text/javascript">
(function () {
function initViewerControls() {
var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var popoutBtn = document.getElementById('viewer-popout-btn');
var mainViewerEl = document.getElementById('main_viewer');
var rootStyle = document.documentElement.style;
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return;
}
if (panel.dataset.viewerControlsBound === '1') {
return;
}
panel.dataset.viewerControlsBound = '1';
var savedH = localStorage.getItem(HEIGHT_KEY);
if (savedH && !isNaN(parseInt(savedH, 10))) {
container.style.height = parseInt(savedH, 10) + 'px';
}
function applySidebarWidth(px) {
if (!sidebarEl || !px || isNaN(px)) return;
var clamped = Math.max(360, Math.min(window.innerWidth * 0.95, Math.round(px)));
sidebarEl.style.width = clamped + 'px';
rootStyle.setProperty('--viewer-sidebar-width', clamped + 'px');
}
var savedW = localStorage.getItem(SIDEBAR_W_KEY);
if (savedW && !isNaN(parseInt(savedW, 10))) {
applySidebarWidth(parseInt(savedW, 10));
} else if (sidebarEl) {
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
}
if (sidebarEl && 'ResizeObserver' in window) {
var sidebarResizeObserver = new ResizeObserver(function () {
var width = Math.round(sidebarEl.getBoundingClientRect().width);
rootStyle.setProperty('--viewer-sidebar-width', width + 'px');
localStorage.setItem(SIDEBAR_W_KEY, width);
});
sidebarResizeObserver.observe(sidebarEl);
}
var persistHeight = function () {
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
};
if ('ResizeObserver' in window) {
var ro = new ResizeObserver(function () {
persistHeight();
});
ro.observe(container);
} else {
window.addEventListener('mouseup', persistHeight);
}
if (resizeHandle) {
resizeHandle.addEventListener('pointerdown', function (e) {
e.preventDefault();
});
}
function openViewerSidebar() {
if (!sidebarEl || !sidebarBody) return;
sidebarBody.appendChild(moveable);
sidebarEl.classList.add('open');
document.body.classList.add('viewer-sidebar-open');
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
localStorage.setItem(MODE_KEY, 'sidebar');
}
function closeViewerSidebar() {
if (!sidebarEl) return;
moveableHome.appendChild(moveable);
sidebarEl.classList.remove('open');
document.body.classList.remove('viewer-sidebar-open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>';
sidebarBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
localStorage.removeItem(MODE_KEY);
}
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
} else {
openViewerSidebar();
}
});
}
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener('click', closeViewerSidebar);
}
var bc = null;
try { bc = new BroadcastChannel('viva_load'); } catch (e) {}
function getViewerPayload() {
var namedStacks = mainViewerEl.getAttribute('data-named-stacks');
var images = mainViewerEl.getAttribute('data-images');
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : '';
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : '';
return {
type: 'open',
namedStacks: namedStacks || '',
images: images || '',
viewerstate: viewerState || '',
annotations: annotations || ''
};
}
function broadcastViewerState(payload) {
if (!payload) return;
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
if (bc) {
try { bc.postMessage(payload); } catch (e) {}
}
}
function showPopoutPlaceholder() {
var existing = panel.querySelector('.viewer-popout-placeholder');
if (existing) return;
var placeholder = document.createElement('div');
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
panel.insertBefore(placeholder, moveableHome);
moveableHome.style.display = 'none';
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
}
function hidePopoutPlaceholder() {
var placeholder = panel.querySelector('.viewer-popout-placeholder');
if (placeholder) placeholder.remove();
moveableHome.style.display = '';
}
function openPopout() {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
}
var payload = getViewerPayload();
broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
setTimeout(function () { broadcastViewerState(payload); }, 700);
localStorage.setItem(MODE_KEY, 'popout');
showPopoutPlaceholder();
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
function closePopout() {
hidePopoutPlaceholder();
localStorage.removeItem(MODE_KEY);
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
}
if (popoutBtn) {
popoutBtn.addEventListener('click', function () {
if (localStorage.getItem(MODE_KEY) === 'popout') {
closePopout();
} else {
openPopout();
}
});
}
var savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'sidebar') {
openViewerSidebar();
} else if (savedMode === 'popout' && popoutBtn) {
showPopoutPlaceholder();
setTimeout(function () { broadcastViewerState(getViewerPayload()); }, 300);
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initViewerControls);
} else {
initViewerControls();
}
})();
</script>
{% endblock js %} {% endblock js %}
@@ -1,47 +1,143 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% block content %} {% block content %}
<h2>Collection: {{exam}}</h2> <style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Review: {{ collection.name }}</h2>
<span class="badge bg-light text-dark">Review Collection</span>
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and browse completed cases.</p>
</section>
{% if collection.start_screen_content %} {% if collection.start_screen_content %}
<div class="card mb-3"> <section class="take-start-card mb-3">
<div class="card-body">
{{ collection.start_screen_content|safe }} {{ collection.start_screen_content|safe }}
</div> </section>
</div>
{% endif %} {% endif %}
{% if collection.start_screen_resources.exists %} {% if start_screen_resources %}
<div class="card mb-3"> <section class="take-start-card mb-3">
<div class="card-body"> <h6 class="mb-2">Resources</h6>
<h6>Resources</h6> <div class="d-grid gap-2">
{% for resource in collection.start_screen_resources.all %} {% for resource in start_screen_resources %}
<div class="small mb-1">{{ resource.get_display }}</div> <div class="small">{{ resource.get_display }}</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </section>
{% endif %} {% endif %}
{% comment %} <ul> {% if request.user.is_authenticated and valid_user %}
{% for case in collection.cases.all %} <section class="take-start-card">
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li> <dl class="take-start-meta mb-3">
{% endfor %} <dt>User</dt>
</ul> {% endcomment %} <dd>{{ request.user }}</dd>
User: {{request.user}}<br/>
{% if cid_exam %} {% if cid_exam %}
Started: {{cid_exam.start_time}} <br/> <dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam.completed %} {% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/> <dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %} {% endif %}
{% endif %} {% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
Browse
</a>
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they do not have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% else %}
<section class="take-start-card">
<p class="mb-0">
Please sign in to access this review collection.
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">Log in here</a>.
</p>
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% endblock %} {% endblock %}
@@ -57,7 +57,7 @@
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2"> <div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div> <div>
<div class="fw-semibold"> <div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %} Case {{ forloop.counter }}{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %} {% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span> <span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %} {% elif answer %}
@@ -79,7 +79,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if collection.show_title_post %} {% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}
<div class="small text-muted"> <div class="small text-muted">
{{ question.case }} {{ question.case }}
</div> </div>
@@ -5,9 +5,11 @@
class="btn btn-outline-warning btn-sm" class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel" hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-indicator="#series-split-analysis-indicator">
Analyze split options Analyze split options
</button> </button>
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
</div> </div>
{% if analyze_requested %} {% if analyze_requested %}
@@ -16,7 +18,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}" hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result" hx-target="#split-by-tag-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#series-split-submit-indicator">
{% csrf_token %} {% csrf_token %}
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm"> <select name="dicom_tag" class="form-select form-select-sm">
@@ -27,6 +30,7 @@
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div> </div>
</form> </form>
<span id="series-split-submit-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p> <p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %} {% else %}
<div class="alert alert-secondary mb-0 small"> <div class="alert alert-secondary mb-0 small">
@@ -11,9 +11,11 @@
class="btn btn-outline-info btn-sm" class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel" hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-indicator="#series-tag-consistency-indicator">
Re-run analysis Re-run analysis
</button> </button>
<span id="series-tag-consistency-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if analyze_requested %} {% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span> <span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %} {% endif %}
@@ -57,7 +59,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}" hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result" hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -99,7 +102,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}" hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result" hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -143,7 +147,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}" action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}" hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result" hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}"> <input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button> <button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -168,6 +173,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<span id="tag-consistency-split-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator me-auto" role="status" aria-hidden="true"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div> </div>
</div> </div>
@@ -1,6 +1,17 @@
<div class="alert alert-success"> <div class="alert alert-success">
<strong>Truncate complete.</strong> <strong>Truncate complete.</strong>
<div class="small mt-1">Removed {{ images_removed|length }} images from series {{ series.pk }}.</div> <div class="small mt-2">
<div>Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
{% if images_downsampled %}
<div class="mt-1">Downsampled {{ images_downsampled|length }} images (pixel data reduced by 50%).</div>
{% endif %}
{% if remove_empty_dicoms %}
<div class="mt-1">✓ Empty DICOM removal applied</div>
{% endif %}
{% if downsample_pixels %}
<div class="mt-1">✓ Pixel downsampling applied (original hash preserved)</div>
{% endif %}
</div>
</div> </div>
{% if images_removed %} {% if images_removed %}
<details class="mt-2"> <details class="mt-2">
@@ -8,3 +19,9 @@
<div class="small mt-1">{{ images_removed|join:", " }}</div> <div class="small mt-1">{{ images_removed|join:", " }}</div>
</details> </details>
{% endif %} {% endif %}
{% if images_downsampled %}
<details class="mt-2">
<summary class="small">Show downsampled image IDs</summary>
<div class="small mt-1">{{ images_downsampled|join:", " }}</div>
</details>
{% endif %}
+553 -64
View File
@@ -30,7 +30,11 @@
<div class="card"> <div class="card">
<div class="card-body p-2"> <div class="card-body p-2">
{% with image_url_array_and_count=series.get_image_url_array_and_count %} {% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root" class="dicom-viewer-root w-100 viewer-frame-standard" data-images="{{ image_url_array_and_count.0 }}" data-auto-cache-stack=false> <div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack=false>
</div> </div>
{% endwith %} {% endwith %}
</div> </div>
@@ -198,46 +202,39 @@
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1" hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-modal .modal-content" hx-target="#series-tag-consistency-modal .modal-content"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#series-tag-consistency-load-indicator"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#series-tag-consistency-modal"> data-bs-target="#series-tag-consistency-modal">
Analyze tag consistency Analyze tag consistency
</button> </button>
</div> <span id="series-tag-consistency-load-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
</div>
{% if can_edit %} {% if can_edit %}
<div class="card"> <button class="btn btn-outline-warning btn-sm"
<div class="card-header">Truncate series</div> data-bs-toggle="modal"
<div class="card-body"> data-bs-target="#truncate-series-modal">
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p> <i class="bi bi-scissors"></i> Truncate series
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div> </button>
<div class="mb-2">
<label class="form-label">Start</label> <div class="card mt-2">
<div class="input-group"> <div class="card-header d-flex align-items-center justify-content-between">
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1"> <span class="small fw-semibold">Background Task Progress</span>
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">End</label>
<div class="input-group">
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button> <a class="btn btn-outline-info btn-sm" href="{% url 'atlas:task_overview' %}">Task Overview</a>
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button> <button id="clear-recon-monitors-btn" type="button" class="btn btn-outline-secondary btn-sm">Clear</button>
</div>
</div>
<div class="card-body p-2">
<div id="persistent-recon-tasks" class="d-flex flex-column gap-2">
<div id="persistent-recon-empty" class="small text-muted">No tracked reconstruction tasks yet.</div>
</div> </div>
<div id="truncate-output" class="mt-2"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1"> <div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
@@ -252,56 +249,552 @@
</div> </div>
</div> </div>
<!-- Series Optimization Modal -->
{% if can_edit %}
<div id="truncate-series-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Series Optimization</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">Viewer / Compare Preview</div>
<div class="card-body p-2 d-flex flex-column gap-2">
<div id="truncate_viewer" class="dicom-viewer-root w-100"
style="height: 460px; min-height: 360px; resize: vertical; overflow: auto; background: #222;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack="false">
</div>
<div class="small text-muted">Downsample compare preview loads into this viewer as stack options.</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Operations</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Destructive actions cannot be undone.
</div>
<form id="series-optimize-form"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#series-optimize-feedback"
hx-swap="innerHTML"
hx-indicator="#series-optimize-indicator">
{% csrf_token %}
<input type="hidden" name="start" id="opt-start-hidden" value="1">
<input type="hidden" name="end" id="opt-end-hidden" value="{{ image_url_array_and_count.1 }}">
<div class="mb-3">
<label class="form-label">Start image</label>
<div class="input-group">
<input id="truncate-lower-input" type="number" class="form-control form-control-sm" value="1" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">End image</label>
<div class="input-group">
<input id="truncate-upper-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-upper-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
<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-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">
<label for="downsample-pct" class="form-label">Downsample percentage</label>
<select id="downsample-pct" name="downsample_pct" class="form-select form-select-sm">
<option value="25">25% reduction</option>
<option value="40">40% reduction</option>
<option value="50" selected>50% reduction</option>
<option value="60">60% reduction</option>
<option value="75">75% reduction</option>
</select>
<small class="text-muted">Original hash is preserved after downsampling.</small>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-info" type="submit" name="operation" value="downsample_preview">Preview downsample compare</button>
<button class="btn btn-sm btn-outline-success" type="submit" name="operation" value="downsample">Apply downsample</button>
</div>
<hr>
<div class="mb-2">
<div class="fw-semibold small mb-2">Generate reconstructions</div>
<div class="d-flex flex-wrap gap-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="recon-sagittal">Sagittal</label>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-thickness">Slice thickness (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-thickness" name="recon_slice_thickness" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-spacing">Slice spacing (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-spacing" name="recon_slice_spacing" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
<div class="col-12">
<label class="form-label small mb-1" for="recon-thickness-mode">Thickness merge mode</label>
<select id="recon-thickness-mode" name="recon_thickness_mode" class="form-select form-select-sm">
<option value="mean" selected>Mean (default)</option>
<option value="max">Max intensity</option>
<option value="min">Min intensity</option>
</select>
<small class="text-muted">Thickness controls slab merge depth. Spacing controls distance between generated slices.</small>
</div>
</div>
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="series-optimize-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">Processing operations may take time for large stacks.</small>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="modal">Close</button>
</div>
<div class="px-3 pb-3">
<div id="series-optimize-feedback" class="small"></div>
<div id="truncate-modal-output" class="alert alert-info d-none mt-2 mb-0" role="alert"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<script> <script>
$(document).ready(function () { $(document).ready(function () {
const apiKey = "root" const apiKey = "root"
const truncateApiKey = "truncate_viewer";
const seriesPk = {{ series.pk }};
const totalImages = {{ image_url_array_and_count.1 }};
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
const truncateViewerHeightKey = `truncate_viewer_height_${seriesPk}`;
const reconTaskStorageKey = `series_recon_tasks_${seriesPk}`;
$("#set-lower-truncation-bound-button").click(() => { // Restore viewer height from localStorage
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1); function restoreViewerHeight() {
const saved = localStorage.getItem(viewerHeightKey);
if (saved) {
const viewer = document.getElementById('root');
if (viewer) {
viewer.style.height = saved;
}
}
}
// Save viewer height to localStorage when resized
function persistViewerHeight() {
const viewer = document.getElementById('root');
if (viewer) {
const height = window.getComputedStyle(viewer).height;
localStorage.setItem(viewerHeightKey, height);
}
}
// Listen for resize events on the viewer
const viewer = document.getElementById('root');
if (viewer) {
const resizeObserver = new ResizeObserver(() => {
persistViewerHeight();
}); });
$("#set-upper-truncation-bound-button").click(() => { resizeObserver.observe(viewer);
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1); restoreViewerHeight();
}); requestAnimationFrame(() => {
$("#truncate-test-button").click(() => { try {
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1; if (window.resizeDicomViewers) {
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1; window.resizeDicomViewers();
const ok = window[`truncateStack_${apiKey}`](lower, upper); }
if (ok) { window.dispatchEvent(new Event('resize'));
$("#truncate-output").text("The truncated images are shown above."); } catch (e) {
console.warn('Viewer resize dispatch failed', e);
} }
}); });
$("#truncate-button").click(() => { }
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
if (lower_bound >= upper_bound) { function syncOptimizeBounds() {
alert("The lower bound must be less than the upper bound."); const startInput = document.getElementById('truncate-lower-input');
const endInput = document.getElementById('truncate-upper-input');
const startHidden = document.getElementById('opt-start-hidden');
const endHidden = document.getElementById('opt-end-hidden');
if (!startInput || !endInput || !startHidden || !endHidden) {
return;
}
startHidden.value = startInput.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 getStoredReconTasks() {
try {
const raw = localStorage.getItem(reconTaskStorageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
function setStoredReconTasks(items) {
localStorage.setItem(reconTaskStorageKey, JSON.stringify(items));
}
function upsertStoredReconTask(taskId, statusUrl) {
const current = getStoredReconTasks();
const existing = current.find((t) => t.taskId === taskId);
if (existing) {
existing.statusUrl = statusUrl;
existing.addedAt = Date.now();
} else {
current.unshift({ taskId, statusUrl, addedAt: Date.now() });
}
setStoredReconTasks(current.slice(0, 25));
}
function removeStoredReconTask(taskId) {
const filtered = getStoredReconTasks().filter((t) => t.taskId !== taskId);
setStoredReconTasks(filtered);
}
function createPersistentTaskMonitor(taskId, statusUrl) {
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
if (!wrap) {
return; return;
} }
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) { const cardId = `persistent-recon-card-${taskId}`;
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/ if (document.getElementById(cardId)) {
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`); return;
htmx.ajax("GET", truncateUrl, "#truncate-output")
} }
empty?.classList.add('d-none');
}) const card = document.createElement('div');
card.id = cardId;
card.className = 'border rounded p-2 bg-body-tertiary';
const statusId = `persistent-recon-status-${taskId}`;
const statusUrlWithTarget = `${statusUrl}?target_id=${statusId}`;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="small fw-semibold">Recon task ${taskId}</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-remove-task-id="${taskId}">Hide</button>
</div>
<div id="${statusId}" hx-get="${statusUrlWithTarget}" hx-trigger="load, every 4s" hx-swap="outerHTML">
<div class="small text-muted">Loading task status...</div>
</div>
`;
wrap.prepend(card);
card.querySelector('[data-remove-task-id]')?.addEventListener('click', () => {
removeStoredReconTask(taskId);
card.remove();
if (!wrap.querySelector('[id^="persistent-recon-card-"]')) {
empty?.classList.remove('d-none');
}
});
if (window.htmx) {
window.htmx.process(card);
}
}
function renderStoredReconTaskMonitors() {
const tasks = getStoredReconTasks();
tasks.forEach((task) => {
if (task?.taskId && task?.statusUrl) {
createPersistentTaskMonitor(task.taskId, task.statusUrl);
}
});
}
function getTruncateViewerElement() {
return document.getElementById(truncateApiKey);
}
function persistTruncateViewerHeight() {
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
const height = window.getComputedStyle(modalViewer).height;
localStorage.setItem(truncateViewerHeightKey, height);
}
function applyTruncateViewerHeight(el) {
if (!el) {
return;
}
const saved = localStorage.getItem(truncateViewerHeightKey);
if (saved) {
el.style.height = saved;
}
}
function observeTruncateViewerResize() {
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
applyTruncateViewerHeight(modalViewer);
const observer = new ResizeObserver(() => {
persistTruncateViewerHeight();
});
observer.observe(modalViewer);
}
// Modal optimization handlers
const truncateModal = document.getElementById('truncate-series-modal');
if (truncateModal) {
truncateModal.addEventListener('show.bs.modal', function () {
observeTruncateViewerResize();
setTimeout(() => {
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (e) {
console.warn('Modal viewer init failed', e);
}
}, 200);
});
function getTruncateViewerApi(baseName) {
const fn = window[`${baseName}_${truncateApiKey}`];
return typeof fn === 'function' ? fn : null;
}
document.getElementById('truncate-set-lower-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const getStackPosition = getTruncateViewerApi('getCurrentStackPosition');
if (getStackPosition) {
const current = getStackPosition(0);
const pos = Number.isFinite(current) ? current + 1 : 1;
document.getElementById('truncate-lower-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-set-upper-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const getStackPosition = getTruncateViewerApi('getCurrentStackPosition');
if (getStackPosition) {
const current = getStackPosition(0);
const pos = Number.isFinite(current) ? current + 1 : 1;
document.getElementById('truncate-upper-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-lower-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-upper-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-test-modal-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1;
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1;
if (lower < 0 || upper >= totalImages || lower > upper) {
alert('Invalid range. Please check your bounds.');
return;
}
const truncateFn = getTruncateViewerApi('truncateStack');
if (truncateFn) {
const ok = truncateFn(lower, upper);
if (ok) {
document.getElementById('truncate-modal-output').innerHTML = `Preview shows images ${lower + 1} to ${upper + 1} (${upper - lower + 1} total).`;
document.getElementById('truncate-modal-output').classList.remove('d-none');
}
} else {
document.getElementById('truncate-modal-output').innerHTML = 'Truncate viewer API not ready yet. Reopen the modal and try again.';
document.getElementById('truncate-modal-output').classList.remove('d-none');
}
});
}
document.body.addEventListener('htmx:configRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
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 reconPayload = document.getElementById('recon-task-payload');
if (reconPayload) {
const taskId = reconPayload.dataset.taskId;
const statusUrl = reconPayload.dataset.statusUrl;
if (taskId && statusUrl) {
upsertStoredReconTask(taskId, statusUrl);
createPersistentTaskMonitor(taskId, statusUrl);
}
}
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;
}
const namedStacks = payload.dataset.namedStacks;
if (!namedStacks) {
return;
}
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
const existingHeight = window.getComputedStyle(modalViewer).height;
// Recreate the same modal viewer element to force a clean remount with compare stacks.
const fresh = document.createElement('div');
fresh.id = truncateApiKey;
fresh.className = 'dicom-viewer-root w-100';
fresh.style.height = existingHeight;
fresh.style.minHeight = '360px';
fresh.style.resize = 'vertical';
fresh.style.overflow = 'auto';
fresh.style.background = '#222';
fresh.setAttribute('data-auto-cache-stack', 'false');
fresh.setAttribute('data-named-stacks', namedStacks);
modalViewer.replaceWith(fresh);
observeTruncateViewerResize();
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (err) {
console.warn('Failed to mount downsample compare viewer', err);
}
}
});
syncOptimizeBounds();
renderStoredReconTaskMonitors();
document.getElementById('clear-recon-monitors-btn')?.addEventListener('click', () => {
setStoredReconTasks([]);
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
wrap?.querySelectorAll('[id^="persistent-recon-card-"]')?.forEach((node) => node.remove());
empty?.classList.remove('d-none');
});
$("#add-finding-button").click(() => { $("#add-finding-button").click(() => {
$("#hidden-form").show() $("#hidden-form").show()
//dicom_element = $(".cornerstone-element").get(0);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide() $("#add-finding-button").hide()
$("#clone-finding-button").hide() $("#clone-finding-button").hide()
//$("#finding-form").empty().append(
// $("#hidden-form form").clone()
//);
}); });
$("#cancel-add-finding-button").click((e) => { $("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide() $("#hidden-form").hide()
@@ -310,11 +803,10 @@
e.preventDefault(); e.preventDefault();
}); });
$("#reset-viewport-button").click(() => { $("#reset-viewport-button").click(() => {
resetViewer_root(); resetViewer_root();
}); });
$(".view-finding-button-3d").each((n, el) => { $(".view-finding-button-3d").each((n, el) => {
$(el).click((e) => { $(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0); dicom_element = $(".cornerstone-element").get(0);
@@ -327,16 +819,13 @@
}); });
}); });
{% if editing_finding > 0 %} {% if editing_finding > 0 %}
$("#hidden-form").show() $("#hidden-form").show()
setTimeout(function() { setTimeout(function() {
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click"); $(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
}, 1000) }, 1000)
{% endif %} {% endif %}
{% if request.GET.show_finding %} {% if request.GET.show_finding %}
function show_finding() { function show_finding() {
console.log("show finding") console.log("show finding")
@@ -382,7 +871,7 @@
}, },
error: function (xhr, errmsg, err) { error: function (xhr, errmsg, err) {
console.log(xhr.status + ": " + xhr console.log(xhr.status + ": " + xhr
.responseText); // provide a bit more info about the error to the console .responseText);
} }
}); });
}); });
+200
View File
@@ -0,0 +1,200 @@
{% extends "base.html" %}
{% load static %}
{% load django_htmx %}
{% block title %}
Atlas Task Overview
{% endblock title %}
{% block content %}
<div class="container-fluid py-3 px-3">
<style>
.task-overview-table {
min-width: 1650px;
}
.task-overview-table th,
.task-overview-table td {
white-space: nowrap;
vertical-align: middle;
}
.task-overview-table .task-col,
.task-overview-table .generated-col,
.task-overview-table .error-col,
.task-overview-table .actions-col {
white-space: normal;
}
.task-overview-table .generated-col {
min-width: 220px;
}
</style>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h4 mb-0">Atlas Task Overview</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
</div>
</div>
{% if backend_missing %}
<div class="alert alert-warning">
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body py-2">
<div class="small text-muted">Active</div>
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body py-2">
<div class="small text-muted">Completed</div>
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body py-2">
<div class="small text-muted">Failed</div>
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
</div>
</div>
</div>
</div>
<form method="post" class="mb-3">
{% csrf_token %}
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
<option value="">Choose task action...</option>
<option value="reset_failed">Reset FAILED to READY</option>
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
<input class="form-check-input" type="checkbox" id="select-all-tasks">
<label class="form-check-label small" for="select-all-tasks">Select all</label>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle task-overview-table">
<thead>
<tr>
<th style="width: 36px;"></th>
<th class="task-col">Task</th>
<th>Status</th>
<th class="generated-col">Generated Series</th>
<th>Queue</th>
<th>Enqueued</th>
<th>Started</th>
<th>Finished</th>
<th>Worker</th>
<th class="error-col">Error</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<input class="form-check-input task-select" type="checkbox" name="task_ids" value="{{ task.id }}">
</td>
<td class="task-col">
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
</td>
<td>
{% if task.status == "FAILED" %}
<span class="badge bg-danger">{{ task.status }}</span>
{% elif task.status == "SUCCESSFUL" %}
<span class="badge bg-success">{{ task.status }}</span>
{% elif task.status == "RUNNING" or task.status == "READY" %}
<span class="badge bg-warning text-dark">{{ task.status }}</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td class="small generated-col">
{% if task.generated_series %}
{% for series in task.generated_series %}
<a class="d-block" target="_blank" href="{{ series.url }}">{{ series.description }}</a>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ task.queue_name }}</td>
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.worker_ids }}</td>
<td class="small text-danger error-col">{{ task.exception_class_path|default:"" }}</td>
<td class="small actions-col">
{% if task.traceback %}
<button
class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#traceback-{{ task.id }}"
aria-expanded="false"
aria-controls="traceback-{{ task.id }}"
>
View full traceback
</button>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% if task.traceback %}
<tr class="collapse" id="traceback-{{ task.id }}">
<td></td>
<td colspan="10">
<div class="border rounded bg-dark-subtle p-2">
<div class="small fw-semibold mb-1">Full traceback</div>
<pre class="small mb-0" style="white-space: pre-wrap;">{{ task.traceback }}</pre>
</div>
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(function () {
const selectAll = document.getElementById('select-all-tasks');
const rows = Array.from(document.querySelectorAll('.task-select'));
if (!selectAll || !rows.length) {
return;
}
selectAll.addEventListener('change', function () {
rows.forEach((cb) => {
cb.checked = selectAll.checked;
});
});
})();
</script>
</div>
{% endblock content %}
+81 -23
View File
@@ -86,6 +86,7 @@
document.title = "Case: {{current_case.title}}"; document.title = "Case: {{current_case.title}}";
console.log('ready'); console.log('ready');
const bc = new BroadcastChannel("viva_load"); const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// Centralized hide helper and delegated close handling for local viewer // Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; } function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
@@ -114,6 +115,83 @@
} catch (err) { console.warn('Viewer initialization deferred/fallback:', err); } } catch (err) { console.warn('Viewer initialization deferred/fallback:', err); }
} }
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
var savedPayload = readPersistedViewerPayload();
if (savedPayload) {
applyViewerPayload(savedPayload);
}
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => { bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load // Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {} try { hideResourceLocal(); } catch(_) {}
@@ -179,33 +257,13 @@
if (event.data.url && isMediaUrl(event.data.url)) { if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer // show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) { if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"}); bc.postMessage({"type": "open-complete-local"});
return; return;
} }
} }
// Hide modal when data is loaded persistViewerPayload(event.data);
document.getElementById("awaiting-modal").style.display = "none"; applyViewerPayload(event.data);
document.getElementById("root").dataset.images = event.data.images;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
}
mountDicomViewersSafely();
bc.postMessage({"type": "open-complete-local"}); bc.postMessage({"type": "open-complete-local"});
} }
}; };
+11
View File
@@ -552,6 +552,7 @@ urlpatterns = [
path("structure/create", views.StructureCreate.as_view(), name="structure_create"), path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"), path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"), path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
path("tasks/overview/", views.task_overview, name="task_overview"),
# TODO: case context series viewing (so that we can view series in the context of a case) # TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"), path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"), path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
@@ -565,6 +566,16 @@ urlpatterns = [
views.series_image_size_htmx, views.series_image_size_htmx,
name="series_image_size", name="series_image_size",
), ),
path(
"series/<int:series_id>/optimize/",
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>/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"),
+675 -5
View File
@@ -2,6 +2,8 @@ import json
import pprint import pprint
import csv import csv
import random import random
import copy
import io
from typing import Any from typing import Any
from dal import autocomplete from dal import autocomplete
from django.db.models.base import Model as Model from django.db.models.base import Model as Model
@@ -161,7 +163,22 @@ from .filters import (
NormalCaseFilter, NormalCaseFilter,
) )
from .tasks import push_case_to_cimar_task from .tasks import push_case_to_cimar_task, series_reconstruct_task
try:
from django_tasks import TaskResultStatus
except ImportError:
from enum import Enum
class TaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
from django_tables2 import SingleTableView, SingleTableMixin from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView from django_filters.views import FilterView
@@ -216,6 +233,8 @@ from loguru import logger
def series_truncate_htmx(request, series_id, start, end): def series_truncate_htmx(request, series_id, start, end):
"""HTMX endpoint to truncate a series' images to the given inclusive """HTMX endpoint to truncate a series' images to the given inclusive
start/end bounds (zero-based). Returns an HTML fragment with the result. start/end bounds (zero-based). Returns an HTML fragment with the result.
This endpoint only performs truncation for backward compatibility.
""" """
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
@@ -223,16 +242,19 @@ def series_truncate_htmx(request, series_id, start, end):
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>") return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = [] images_removed = []
for n, image in enumerate(series.get_images()): for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end): if n >= int(start) and n <= int(end):
# keep images within bounds
continue continue
else:
image.removed = True image.removed = True
image.image = None image.image = None
image.save() image.save()
images_removed.append(image.pk) images_removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
# Recalculate stored series size # Recalculate stored series size
try: try:
series.get_total_image_size(refresh=True) series.get_total_image_size(refresh=True)
@@ -243,12 +265,660 @@ def series_truncate_htmx(request, series_id, start, end):
# Render a simple message fragment for HTMX swap # Render a simple message fragment for HTMX swap
html = render_to_string( html = render_to_string(
"atlas/partials/truncate_result.html", "atlas/partials/truncate_result.html",
{"images_removed": images_removed, "series": series}, {
"images_removed": images_removed,
"images_downsampled": [],
"series": series,
"remove_empty_dicoms": False,
"downsample_pixels": False,
},
request=request, request=request,
) )
return HttpResponse(html) return HttpResponse(html)
def _read_series_image_dataset(series_image):
if not series_image.image:
return None
try:
series_image.image.open("rb")
series_image.image.seek(0)
return pydicom.dcmread(series_image.image, force=True)
except Exception as exc:
logger.warning("Failed to read DICOM for image {}: {}", series_image.pk, exc)
return None
def _is_empty_dicom(ds):
try:
px = ds.pixel_array
return bool((px == 0).all())
except Exception:
return False
def _resize_array_nearest(arr, target_rows, target_cols):
import numpy as np
rows, cols = arr.shape[0], arr.shape[1]
row_idx = np.clip(np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1)
col_idx = np.clip(np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1)
return arr[np.ix_(row_idx, col_idx)]
def _downsample_dicom_dataset(ds, reduction_pct):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
factor = (100 - reduction_pct) / 100.0
px = ds.pixel_array
if px.ndim != 2:
raise ValueError("Only 2D DICOM slices are supported for this downsample operation")
target_rows = max(1, int(round(px.shape[0] * factor)))
target_cols = max(1, int(round(px.shape[1] * factor)))
resized = _resize_array_nearest(px, target_rows, target_cols)
ds.Rows = int(target_rows)
ds.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
return ds
def _create_series_derivative(source_series, description_suffix):
derived = Series.objects.get(pk=source_series.pk)
derived.pk = None
derived.id = None
derived.series_instance_uid = generate_uid()
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
derived.modified = Series.SeriesModifiedChocies.RE
derived.save()
derived.case.set(source_series.case.all())
derived.author.set(source_series.author.all())
return derived
def _series_image_urls_json(series):
image_url_array_and_count = series.get_image_url_array_and_count()
try:
urls = json.loads(image_url_array_and_count[0])
except Exception:
urls = []
return urls
def _safe_float(value, default=None):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _infer_native_spacings(dicom_items):
"""Infer native (row, col, z) spacing in mm from a list of (image, ds, arr)."""
first_ds = dicom_items[0][1]
row_spacing = 1.0
col_spacing = 1.0
if hasattr(first_ds, "PixelSpacing") and first_ds.PixelSpacing and len(first_ds.PixelSpacing) >= 2:
row_spacing = _safe_float(first_ds.PixelSpacing[0], 1.0)
col_spacing = _safe_float(first_ds.PixelSpacing[1], 1.0)
z_spacing = _safe_float(getattr(first_ds, "SpacingBetweenSlices", None), None)
if z_spacing is None:
z_spacing = _safe_float(getattr(first_ds, "SliceThickness", None), None)
if z_spacing is None and len(dicom_items) > 1:
try:
p0 = dicom_items[0][1].ImagePositionPatient
p1 = dicom_items[1][1].ImagePositionPatient
if p0 and p1 and len(p0) == 3 and len(p1) == 3:
import numpy as np
z_spacing = float(np.linalg.norm(np.array(p1, dtype=float) - np.array(p0, dtype=float)))
except Exception:
z_spacing = None
if z_spacing is None or z_spacing <= 0:
z_spacing = 1.0
return float(row_spacing), float(col_spacing), float(z_spacing)
def _extract_recon_geometry(dicom_items):
"""Build sorted geometry context for reconstruction from (image, ds, arr) items."""
import numpy as np
row_spacing, col_spacing, native_z_spacing = _infer_native_spacings(dicom_items)
first_ds = dicom_items[0][1]
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
iop = getattr(first_ds, "ImageOrientationPatient", None)
if iop and len(iop) >= 6:
try:
row_dir = np.array([float(iop[0]), float(iop[1]), float(iop[2])], dtype=float)
col_dir = np.array([float(iop[3]), float(iop[4]), float(iop[5])], dtype=float)
except Exception:
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
if np.linalg.norm(row_dir) == 0 or np.linalg.norm(col_dir) == 0:
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
row_dir = row_dir / np.linalg.norm(row_dir)
col_dir = col_dir / np.linalg.norm(col_dir)
normal_dir = np.cross(row_dir, col_dir)
if np.linalg.norm(normal_dir) == 0:
normal_dir = np.array([0.0, 0.0, 1.0], dtype=float)
normal_dir = normal_dir / np.linalg.norm(normal_dir)
decorated = []
for idx, item in enumerate(dicom_items):
ds = item[1]
ipp = None
try:
ipp_attr = getattr(ds, "ImagePositionPatient", None)
if ipp_attr and len(ipp_attr) >= 3:
ipp = np.array([float(ipp_attr[0]), float(ipp_attr[1]), float(ipp_attr[2])], dtype=float)
except Exception:
ipp = None
if ipp is None:
ipp = np.array([0.0, 0.0, idx * native_z_spacing], dtype=float)
proj = float(np.dot(ipp, normal_dir))
decorated.append((proj, ipp, item))
decorated.sort(key=lambda x: x[0])
sorted_items = [d[2] for d in decorated]
sorted_ipps = [d[1] for d in decorated]
sorted_proj = np.array([d[0] for d in decorated], dtype=float)
if len(sorted_proj) > 1:
diffs = np.diff(sorted_proj)
valid_diffs = np.abs(diffs[np.abs(diffs) > 1e-6])
if valid_diffs.size > 0:
native_z_spacing = float(np.median(np.abs(valid_diffs)))
if native_z_spacing <= 0:
native_z_spacing = 1.0
origin_ipp = sorted_ipps[0] if sorted_ipps else np.array([0.0, 0.0, 0.0], dtype=float)
source_positions_mm = sorted_proj - sorted_proj[0] if len(sorted_proj) > 0 else np.array([0.0], dtype=float)
return {
"sorted_items": sorted_items,
"source_positions_mm": source_positions_mm,
"origin_ipp": origin_ipp,
"row_dir": row_dir,
"col_dir": col_dir,
"normal_dir": normal_dir,
"row_spacing": float(row_spacing),
"col_spacing": float(col_spacing),
"native_z_spacing": float(native_z_spacing),
}
def _aggregate_volume_along_z(volume, source_positions_mm, target_spacing, slab_thickness, mode):
"""Aggregate a z-volume into slabbed slices using spacing/thickness in mm."""
import numpy as np
if target_spacing <= 0:
raise ValueError("Target spacing must be positive")
if slab_thickness <= 0:
raise ValueError("Slab thickness must be positive")
source_positions_mm = np.asarray(source_positions_mm, dtype=float)
if source_positions_mm.size != volume.shape[0]:
raise ValueError("Source position count must match volume depth")
start_pos = float(source_positions_mm[0])
end_pos = float(source_positions_mm[-1])
centers = np.arange(start_pos, end_pos + (target_spacing * 0.5), target_spacing, dtype=float)
if centers.size == 0:
centers = np.array([start_pos], dtype=float)
half = slab_thickness / 2.0
slabs = []
for center in centers:
mask = np.abs(source_positions_mm - center) <= (half + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(source_positions_mm - center)))
slab = volume[nearest : nearest + 1, :, :]
else:
slab = volume[mask, :, :]
if mode == "max":
out = np.max(slab, axis=0)
elif mode == "min":
out = np.min(slab, axis=0)
else:
out = np.mean(slab, axis=0)
slabs.append(out)
out_volume = np.stack(slabs, axis=0)
if np.issubdtype(volume.dtype, np.integer):
info = np.iinfo(volume.dtype)
out_volume = np.clip(np.rint(out_volume), info.min, info.max).astype(volume.dtype)
else:
out_volume = out_volume.astype(volume.dtype, copy=False)
return out_volume, centers
@login_required
@require_http_methods(["POST"])
def series_optimize_htmx(request, series_id):
"""Run disk optimization operations for a series.
Scope: Series editors only.
Supported operations:
- remove_empty
- downsample
- downsample_preview
- reconstruct
- truncate
"""
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>')
operation = request.POST.get("operation", "").strip()
start = int(request.POST.get("start", "1") or 1) - 1
end = int(request.POST.get("end", str(series.get_image_count())) or series.get_image_count()) - 1
if end < start:
return HttpResponse('<div class="alert alert-danger mb-0">Start must be less than or equal to end.</div>')
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 = []
for idx, image in enumerate(images):
if idx < start or idx > end:
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
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(
(
'<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":
removed = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
if _is_empty_dicom(ds):
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
if removed:
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
)
if operation in ("downsample", "downsample_preview"):
downsample_pct = int(request.POST.get("downsample_pct", "50") or 50)
if operation == "downsample_preview":
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
created = 0
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
except Exception as exc:
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
continue
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
new_image = SeriesImage(series=preview_series, position=image.position, upload_filename=image.upload_filename)
new_image.image.save(
f"preview_ds_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
new_image.save()
created += 1
original_urls = _series_image_urls_json(series)
preview_urls = _series_image_urls_json(preview_series)
named_stacks = json.dumps(
{
"Original": original_urls,
f"Preview {downsample_pct}%": preview_urls,
}
)
escaped_named_stacks = escape(named_stacks)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
f'Downsample preview series created with <strong>{created}</strong> images. '
f'<a class="alert-link" target="_blank" href="{preview_series.get_absolute_url()}">Open preview series</a>.'
'</div>'
f'<div id="downsample-compare-payload" data-named-stacks="{escaped_named_stacks}"></div>'
)
)
downsampled = []
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
original_hash = image.image_blake3_hash
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
image.image.save(
f"downsample_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
# Preserve original hash for duplicate detection continuity.
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
downsampled.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
if downsampled:
series.modified = Series.SeriesModifiedChocies.RE
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'
)
if operation == "reconstruct":
recon_planes = request.POST.getlist("recon_planes")
if not recon_planes:
return HttpResponse('<div class="alert alert-danger mb-0">Select at least one reconstruction plane.</div>')
slice_thickness = request.POST.get("recon_slice_thickness", "")
slice_spacing = request.POST.get("recon_slice_spacing", "")
recon_thickness_mode = (request.POST.get("recon_thickness_mode", "mean") or "mean").strip().lower()
if recon_thickness_mode not in ("mean", "max", "min"):
return HttpResponse('<div class="alert alert-danger mb-0">Invalid thickness merge mode.</div>')
try:
slice_thickness_val = float(slice_thickness) if slice_thickness else None
slice_spacing_val = float(slice_spacing) if slice_spacing else None
except ValueError:
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness and spacing must be numeric values.</div>')
if slice_thickness_val is not None and slice_thickness_val <= 0:
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>')
task_result = series_reconstruct_task.enqueue(
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-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": task_result.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>'
f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></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)
target_id = (request.GET.get("target_id") or "recon-task-status").strip() or "recon-task-status"
target_id = escape(target_id)
if not series.check_user_can_edit(request.user):
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
try:
task_result = series_reconstruct_task.get_result(task_id)
except NotImplementedError:
return HttpResponse(
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
)
except Exception:
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
task_result.refresh()
state = task_result.status
poll_url = reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_id})
progress = cache.get(f"series_reconstruct_progress:{task_id}") or {}
current = int(progress.get("current", 0) or 0)
total = int(progress.get("total", 0) or 0)
message = escape(str(progress.get("message", "Reconstruction is running...")))
pct = int((current / total) * 100) if total > 0 else 0
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
return HttpResponse(
(
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" 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 == TaskResultStatus.SUCCESSFUL:
try:
payload = task_result.return_value if isinstance(task_result.return_value, dict) else {}
except ValueError:
payload = {}
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(
(
f'<div id="{target_id}" class="alert alert-success mb-0">'
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
'</div>'
)
)
return HttpResponse(f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
if state == TaskResultStatus.FAILED:
err = "Unknown failure"
if task_result.errors:
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
return HttpResponse(
f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
)
return HttpResponse(
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required
@user_is_atlas_editor
def task_overview(request):
"""Atlas admin-style task overview page with active/completed/failed task summaries."""
if DBTaskResult is None:
return render(
request,
"atlas/task_overview.html",
{
"tasks": [],
"counts": {"active": 0, "completed": 0, "failed": 0},
"backend_missing": True,
},
)
if request.method == "POST":
action = (request.POST.get("task_action") or "").strip()
selected_ids = [task_id for task_id in request.POST.getlist("task_ids") if task_id]
if not selected_ids:
messages.warning(request, "Select at least one task to run an action.")
return redirect(f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}")
selected_qs = DBTaskResult.objects.filter(id__in=selected_ids)
if action == "reset_failed":
updated = selected_qs.filter(status="FAILED").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
messages.success(request, f"Reset {updated} failed task(s) to READY.")
elif action == "reset_selected":
blocked = selected_qs.filter(status="RUNNING").count()
updated = selected_qs.exclude(status="RUNNING").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
msg += f" Skipped {blocked} running task(s)."
messages.success(request, msg)
elif action == "delete_completed":
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
messages.success(request, f"Deleted {deleted} completed task record(s).")
elif action == "delete_failed":
deleted, _ = selected_qs.filter(status="FAILED").delete()
messages.success(request, f"Deleted {deleted} failed task record(s).")
else:
messages.error(request, "Invalid task action requested.")
status_q = (request.GET.get("status") or "all").strip().lower()
return redirect(f"{reverse('atlas:task_overview')}?status={status_q}")
selected_status = (request.GET.get("status") or "all").strip().lower()
status_map = {
"active": ["READY", "RUNNING"],
"completed": ["SUCCESSFUL"],
"failed": ["FAILED"],
}
base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
if selected_status in status_map:
tasks = list(base_qs.filter(status__in=status_map[selected_status])[:300])
else:
tasks = list(base_qs[:300])
for task in tasks:
generated_series = []
payload = task.return_value
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = None
if isinstance(payload, dict):
for item in payload.get("created_series", []) or []:
if not isinstance(item, dict):
continue
url = item.get("url")
description = item.get("description") or str(item.get("id", "Series"))
if url:
generated_series.append({"url": url, "description": description})
task.generated_series = generated_series
counts = {
"active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
"completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
"failed": DBTaskResult.objects.filter(status="FAILED").count(),
}
return render(
request,
"atlas/task_overview.html",
{
"tasks": tasks,
"counts": counts,
"selected_status": selected_status,
"backend_missing": False,
},
)
@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.
@@ -8222,7 +8892,7 @@ def collection_reset_answers(request, exam_id: int):
def push_case_to_cimar(request, case_id): def push_case_to_cimar(request, case_id):
push_case_to_cimar_task.delay(case_id=case_id) push_case_to_cimar_task.enqueue(case_id=case_id)
return HttpResponse("Push started") return HttpResponse("Push started")
case = get_object_or_404(Case, pk=case_id) case = get_object_or_404(Case, pk=case_id)
+14
View File
@@ -17,6 +17,20 @@ services:
- ../:/usr/src/app:cached - ../:/usr/src/app:cached
# Mount the app log directory so logs are visible on the host at ./logs # Mount the app log directory so logs are visible on the host at ./logs
- ../logs:/var/log/rad - ../logs:/var/log/rad
worker:
env_file:
- ../.env.dev
volumes:
- ../:/usr/src/app:cached
- ../logs:/var/log/rad
entrypoint:
[
"sh",
"-c",
"python manage.py migrate --noinput && exec python manage.py db_worker",
]
command: []
nginx: nginx:
# Development nginx override: mount a simplified config that does not # Development nginx override: mount a simplified config that does not
# reference LetsEncrypt certs so the container can start without real # reference LetsEncrypt certs so the container can start without real
+17
View File
@@ -30,6 +30,23 @@ services:
- 3459:3459 - 3459:3459
env_file: env_file:
- ./.env.dev.local - ./.env.dev.local
worker:
build:
context: ..
dockerfile: rad/Dockerfile
command: python manage.py db_worker
restart: unless-stopped
volumes:
- ../:/usr/src/app
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
env_file:
- ./.env.dev.local
depends_on:
- web
- db
db: db:
image: postgres:14.2-alpine image: postgres:14.2-alpine
volumes: volumes:
+24
View File
@@ -35,6 +35,30 @@ services:
- "8000" - "8000"
command: ["/usr/src/app/entrypoint.sh"] command: ["/usr/src/app/entrypoint.sh"]
worker:
build:
context: ..
dockerfile: rad/Dockerfile.prod
restart: always
env_file:
- ../.env.${COMPOSE_ENV:-prod}
environment:
- DJANGO_SETTINGS_MODULE=rad.settings
depends_on:
redis:
condition: service_healthy
web:
condition: service_started
volumes:
- ../deploy/settings_local.py:/usr/src/app/rad/settings_local.py:ro
- ../logs:/var/log/rad:rw
entrypoint:
[
"sh",
"-c",
"python manage.py migrate --noinput && exec python manage.py db_worker",
]
nginx: nginx:
image: nginx:stable-alpine image: nginx:stable-alpine
restart: always restart: always
+14 -2
View File
@@ -86,8 +86,8 @@ INSTALLED_APPS = [
'django_jsonforms', 'django_jsonforms',
'django_svelte_jsoneditor', 'django_svelte_jsoneditor',
'django_psutil_dash', 'django_psutil_dash',
'django_tasks',
'django_tasks_db',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -382,6 +382,18 @@ CIMAR_PASSWORD = ""
CELERY_BROKER_URL = "redis://redis:6379" CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379" CELERY_RESULT_BACKEND = "redis://redis:6379"
# Django 6 task framework settings.
# Use DJANGO_TASK_BACKEND to select your backend implementation.
TASKS = {
"default": {
"BACKEND": os.environ.get(
"DJANGO_TASK_BACKEND",
"django_tasks_db.DatabaseBackend",
),
"QUEUES": ["default"],
}
}
try: try:
from .settings_local import * from .settings_local import *
+7
View File
@@ -24,3 +24,10 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
CACHES["default"] = { CACHES["default"] = {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
} }
TASKS = {
"default": {
"BACKEND": "django_tasks.backends.immediate.ImmediateBackend",
"QUEUES": ["default"],
}
}
+2
View File
@@ -1,5 +1,7 @@
#Django==3.2.13 #Django==3.2.13
Django==6.0.1 Django==6.0.1
django-tasks
django-tasks-db
django_debug_toolbar django_debug_toolbar
django_jquery django_jquery
django_reversion django_reversion
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=PENRA django-tasks db_worker
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/home/ross/web/rad
Environment=DJANGO_SETTINGS_MODULE=rad.settings
ExecStart=/home/ross/web/rad/scripts/run-db-worker.sh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-$ROOT_DIR/.venv/bin/python}"
DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-rad.settings}"
RESTART_DELAY="${RESTART_DELAY:-3}"
MAX_RESTARTS="${MAX_RESTARTS:-0}"
if [ ! -x "$PYTHON_BIN" ]; then
echo "Python binary not found or not executable: $PYTHON_BIN" >&2
exit 1
fi
export DJANGO_SETTINGS_MODULE
restart_count=0
echo "Starting persistent db_worker loop"
echo "Root: $ROOT_DIR"
echo "Settings: $DJANGO_SETTINGS_MODULE"
echo "Python: $PYTHON_BIN"
while true; do
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Launching db_worker"
if "$PYTHON_BIN" "$ROOT_DIR/manage.py" db_worker; then
exit_code=0
else
exit_code=$?
fi
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] db_worker exited with code $exit_code"
if [ "$exit_code" -eq 137 ] || [ "$exit_code" -eq 9 ]; then
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] db_worker appears to have been SIGKILL'ed (likely OOM)." >&2
fi
if [ "$MAX_RESTARTS" -gt 0 ] && [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
echo "Max restarts reached ($MAX_RESTARTS). Exiting." >&2
exit "$exit_code"
fi
restart_count=$((restart_count + 1))
echo "Restarting in ${RESTART_DELAY}s (restart #$restart_count)"
sleep "$RESTART_DELAY"
done
+5
View File
@@ -333,6 +333,11 @@
<a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a> <a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a>
</li> </li>
{% endif %} {% endif %}
{% if request.user.is_superuser or request.user|has_group:"atlas_editor" %}
<li>
<a class="dropdown-item" href="{% url 'atlas:task_overview' %}">Atlas tasks overview</a>
</li>
{% endif %}
</ul> </ul>
</li> </li>
{% endif %} {% endif %}