Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff00efecf7 | ||
|
|
a9946961f1 | ||
|
|
d5e473d692 | ||
|
|
75e3bca503 | ||
|
|
1775b11e40 | ||
|
|
d77c5149f0 | ||
|
|
c2d8376e22 | ||
|
|
ab9847e22e | ||
|
|
bbf7542451 | ||
|
|
e6a20bb851 | ||
|
|
7ed0a8f378 | ||
|
|
c8d05818a4 | ||
|
|
b6716c9ae1 | ||
|
|
e368d0c7d1 | ||
|
|
b9cbfd20c1 | ||
|
|
5ce337c7c4 | ||
|
|
f4d990676a | ||
|
|
4f61e43b8c | ||
|
|
c947cba5a7 | ||
|
|
0cf655325c |
@@ -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
|
||||
```
|
||||
|
||||
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:
|
||||
- `NGINX_HTTP_PORT=8080`
|
||||
- `NGINX_HTTPS_PORT=8444`
|
||||
|
||||
+190
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
@@ -29,12 +30,19 @@ from django.forms import ModelForm
|
||||
|
||||
from reversion.admin import VersionAdmin
|
||||
from django.forms.widgets import RadioSelect
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
# from generic.models import Examination, Sign, Site, Condition
|
||||
from generic.models import Examination
|
||||
|
||||
try:
|
||||
from django_tasks_db.models import DBTaskResult
|
||||
except ImportError:
|
||||
DBTaskResult = None
|
||||
|
||||
# Register your models here.
|
||||
# admin.site.register(Examination)
|
||||
# admin.site.register(Site)
|
||||
@@ -152,3 +160,185 @@ class APITokenAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
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
|
||||
|
||||
+442
-4
@@ -2,14 +2,98 @@ from time import sleep
|
||||
from django.core.mail import send_mail
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from celery import shared_task
|
||||
from atlas.models import Case
|
||||
from enum import Enum
|
||||
|
||||
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 rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
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):
|
||||
"""Sends an email when the feedback form has been submitted."""
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
@@ -49,4 +133,358 @@ def push_case_to_cimar_task(case_id):
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -2,24 +2,40 @@
|
||||
|
||||
{% partialdef case-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 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}}
|
||||
</a>
|
||||
<br>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm select-series-btn"
|
||||
style="display: none;"
|
||||
data-series-id="{{series.pk}}">
|
||||
Select
|
||||
</button>
|
||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
</span>
|
||||
<div class="series-block-actions mt-2 small">
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-xs btn-sm select-series-btn"
|
||||
style="display: none;"
|
||||
data-series-id="{{series.pk}}">
|
||||
Select
|
||||
</button>
|
||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||
<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="#"
|
||||
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>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
@@ -162,6 +178,57 @@
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
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 */
|
||||
.atlas[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,39 +101,56 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<div>
|
||||
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
|
||||
<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 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 %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div class="mt-2">
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a target="_blank" href="/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 %}" 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a target="_blank" href="/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 %}" 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>
|
||||
{% 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">
|
||||
<details open>
|
||||
<summary>
|
||||
@@ -165,8 +182,6 @@
|
||||
</div>
|
||||
|
||||
</div> {% endcomment %}
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_discussion and case.discussion %}
|
||||
<details class="card mb-3">
|
||||
@@ -335,6 +350,14 @@
|
||||
</div>
|
||||
</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>
|
||||
label {
|
||||
vertical-align: top;
|
||||
@@ -443,6 +466,132 @@
|
||||
overflow: 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>
|
||||
|
||||
{% endblock %}
|
||||
@@ -823,4 +972,219 @@
|
||||
|
||||
</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 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 %}
|
||||
|
||||
@@ -1,47 +1,143 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
{{ collection.start_screen_content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
<section class="take-start-card mb-3">
|
||||
{{ collection.start_screen_content|safe }}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.start_screen_resources.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6>Resources</h6>
|
||||
{% for resource in collection.start_screen_resources.all %}
|
||||
<div class="small mb-1">{{ resource.get_display }}</div>
|
||||
{% if start_screen_resources %}
|
||||
<section class="take-start-card mb-3">
|
||||
<h6 class="mb-2">Resources</h6>
|
||||
<div class="d-grid gap-2">
|
||||
{% for resource in start_screen_resources %}
|
||||
<div class="small">{{ resource.get_display }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} <ul>
|
||||
{% for case in collection.cases.all %}
|
||||
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
|
||||
{% endfor %}
|
||||
</ul> {% endcomment %}
|
||||
{% if request.user.is_authenticated and valid_user %}
|
||||
<section class="take-start-card">
|
||||
<dl class="take-start-meta mb-3">
|
||||
<dt>User</dt>
|
||||
<dd>{{ request.user }}</dd>
|
||||
|
||||
User: {{request.user}}<br/>
|
||||
{% if cid_exam %}
|
||||
<dt>Started</dt>
|
||||
<dd>{{ cid_exam.start_time }}</dd>
|
||||
|
||||
{% if cid_exam %}
|
||||
Started: {{cid_exam.start_time}} <br/>
|
||||
{% if cid_exam.completed %}
|
||||
Ended: {{cid_exam.end_time}} <br/>
|
||||
{% if cid_exam.completed %}
|
||||
<dt>Ended</dt>
|
||||
<dd>{{ cid_exam.end_time }}</dd>
|
||||
{% 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"><{{ u.email }}></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 %}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<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 %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
|
||||
{% elif answer %}
|
||||
@@ -79,7 +79,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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">
|
||||
{{ question.case }}
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
|
||||
hx-target="#series-split-by-tag-panel"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#series-split-analysis-indicator">
|
||||
Analyze split options
|
||||
</button>
|
||||
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
{% if analyze_requested %}
|
||||
@@ -16,7 +18,8 @@
|
||||
action="{% 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-swap="innerHTML">
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#series-split-submit-indicator">
|
||||
{% csrf_token %}
|
||||
<div class="input-group input-group-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>
|
||||
</div>
|
||||
</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>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary mb-0 small">
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
class="btn btn-outline-info btn-sm"
|
||||
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
|
||||
hx-target="#series-tag-consistency-panel"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#series-tag-consistency-indicator">
|
||||
Re-run analysis
|
||||
</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 %}
|
||||
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
|
||||
{% endif %}
|
||||
@@ -57,7 +59,8 @@
|
||||
action="{% 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-swap="innerHTML">
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
|
||||
<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 %}"
|
||||
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
||||
hx-target="#tag-consistency-split-result"
|
||||
hx-swap="innerHTML">
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
|
||||
<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 %}"
|
||||
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
|
||||
hx-target="#tag-consistency-split-result"
|
||||
hx-swap="innerHTML">
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
|
||||
@@ -168,6 +173,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<div class="alert alert-success">
|
||||
<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>
|
||||
{% if images_removed %}
|
||||
<details class="mt-2">
|
||||
@@ -8,3 +19,9 @@
|
||||
<div class="small mt-1">{{ images_removed|join:", " }}</div>
|
||||
</details>
|
||||
{% 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 %}
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
{% 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>
|
||||
{% endwith %}
|
||||
</div>
|
||||
@@ -198,43 +202,36 @@
|
||||
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
|
||||
hx-target="#series-tag-consistency-modal .modal-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#series-tag-consistency-load-indicator"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#series-tag-consistency-modal">
|
||||
Analyze tag consistency
|
||||
</button>
|
||||
<span id="series-tag-consistency-load-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#truncate-series-modal">
|
||||
<i class="bi bi-scissors"></i> Truncate series
|
||||
</button>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<span class="small fw-semibold">Background Task Progress</span>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-info btn-sm" href="{% url 'atlas:task_overview' %}">Task Overview</a>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card">
|
||||
<div class="card-header">Truncate series</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Start</label>
|
||||
<div class="input-group">
|
||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
||||
<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">
|
||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
||||
</div>
|
||||
<div id="truncate-output" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,56 +249,552 @@
|
||||
</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>
|
||||
$(document).ready(function () {
|
||||
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(() => {
|
||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#set-upper-truncation-bound-button").click(() => {
|
||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#truncate-test-button").click(() => {
|
||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
||||
if (ok) {
|
||||
$("#truncate-output").text("The truncated images are shown above.");
|
||||
// Restore viewer height from localStorage
|
||||
function restoreViewerHeight() {
|
||||
const saved = localStorage.getItem(viewerHeightKey);
|
||||
if (saved) {
|
||||
const viewer = document.getElementById('root');
|
||||
if (viewer) {
|
||||
viewer.style.height = saved;
|
||||
}
|
||||
}
|
||||
});
|
||||
$("#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) {
|
||||
alert("The lower bound must be less than the upper bound.");
|
||||
// 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();
|
||||
});
|
||||
resizeObserver.observe(viewer);
|
||||
restoreViewerHeight();
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (window.resizeDicomViewers) {
|
||||
window.resizeDicomViewers();
|
||||
}
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} catch (e) {
|
||||
console.warn('Viewer resize dispatch failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncOptimizeBounds() {
|
||||
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;
|
||||
}
|
||||
|
||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
||||
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/
|
||||
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
|
||||
htmx.ajax("GET", truncateUrl, "#truncate-output")
|
||||
|
||||
const cardId = `persistent-recon-card-${taskId}`;
|
||||
if (document.getElementById(cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
$("#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()
|
||||
$("#clone-finding-button").hide()
|
||||
//$("#finding-form").empty().append(
|
||||
// $("#hidden-form form").clone()
|
||||
//);
|
||||
|
||||
});
|
||||
$("#cancel-add-finding-button").click((e) => {
|
||||
$("#hidden-form").hide()
|
||||
@@ -310,11 +803,10 @@
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
$("#reset-viewport-button").click(() => {
|
||||
resetViewer_root();
|
||||
|
||||
});
|
||||
|
||||
$(".view-finding-button-3d").each((n, el) => {
|
||||
$(el).click((e) => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
@@ -327,16 +819,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% if editing_finding > 0 %}
|
||||
$("#hidden-form").show()
|
||||
setTimeout(function() {
|
||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||
}, 1000)
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if request.GET.show_finding %}
|
||||
function show_finding() {
|
||||
console.log("show finding")
|
||||
@@ -382,7 +871,7 @@
|
||||
},
|
||||
error: function (xhr, errmsg, err) {
|
||||
console.log(xhr.status + ": " + xhr
|
||||
.responseText); // provide a bit more info about the error to the console
|
||||
.responseText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 %}
|
||||
@@ -86,6 +86,7 @@
|
||||
document.title = "Case: {{current_case.title}}";
|
||||
console.log('ready');
|
||||
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
|
||||
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); }
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Ensure any existing overlay is closed on new load
|
||||
try { hideResourceLocal(); } catch(_) {}
|
||||
@@ -179,33 +257,13 @@
|
||||
if (event.data.url && isMediaUrl(event.data.url)) {
|
||||
// show resource overlay and do not attempt to mount DICOM viewer
|
||||
if (showResource(event.data.url)) {
|
||||
persistViewerPayload(event.data);
|
||||
bc.postMessage({"type": "open-complete-local"});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Hide modal when data is loaded
|
||||
document.getElementById("awaiting-modal").style.display = "none";
|
||||
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();
|
||||
persistViewerPayload(event.data);
|
||||
applyViewerPayload(event.data);
|
||||
bc.postMessage({"type": "open-complete-local"});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -552,6 +552,7 @@ urlpatterns = [
|
||||
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", 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)
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||
@@ -565,6 +566,16 @@ urlpatterns = [
|
||||
views.series_image_size_htmx,
|
||||
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>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
|
||||
+679
-9
@@ -2,6 +2,8 @@ import json
|
||||
import pprint
|
||||
import csv
|
||||
import random
|
||||
import copy
|
||||
import io
|
||||
from typing import Any
|
||||
from dal import autocomplete
|
||||
from django.db.models.base import Model as Model
|
||||
@@ -161,7 +163,22 @@ from .filters import (
|
||||
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_filters.views import FilterView
|
||||
@@ -216,6 +233,8 @@ from loguru import logger
|
||||
def series_truncate_htmx(request, series_id, start, end):
|
||||
"""HTMX endpoint to truncate a series' images to the given inclusive
|
||||
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)
|
||||
|
||||
@@ -223,15 +242,18 @@ def series_truncate_htmx(request, series_id, start, end):
|
||||
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
|
||||
|
||||
images_removed = []
|
||||
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= int(start) and n <= int(end):
|
||||
# keep images within bounds
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.modified = Series.SeriesModifiedChocies.TR
|
||||
series.save(update_fields=["modified"])
|
||||
|
||||
# Recalculate stored series size
|
||||
try:
|
||||
@@ -243,12 +265,660 @@ def series_truncate_htmx(request, series_id, start, end):
|
||||
# Render a simple message fragment for HTMX swap
|
||||
html = render_to_string(
|
||||
"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,
|
||||
)
|
||||
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
|
||||
def series_image_size_htmx(request, series_id):
|
||||
"""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):
|
||||
push_case_to_cimar_task.delay(case_id=case_id)
|
||||
push_case_to_cimar_task.enqueue(case_id=case_id)
|
||||
|
||||
return HttpResponse("Push started")
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
|
||||
@@ -17,6 +17,20 @@ services:
|
||||
- ../:/usr/src/app:cached
|
||||
# Mount the app log directory so logs are visible on the host at ./logs
|
||||
- ../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:
|
||||
# Development nginx override: mount a simplified config that does not
|
||||
# reference LetsEncrypt certs so the container can start without real
|
||||
|
||||
@@ -30,6 +30,23 @@ services:
|
||||
- 3459:3459
|
||||
env_file:
|
||||
- ./.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:
|
||||
image: postgres:14.2-alpine
|
||||
volumes:
|
||||
|
||||
@@ -35,6 +35,30 @@ services:
|
||||
- "8000"
|
||||
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:
|
||||
image: nginx:stable-alpine
|
||||
restart: always
|
||||
|
||||
+14
-2
@@ -86,8 +86,8 @@ INSTALLED_APPS = [
|
||||
'django_jsonforms',
|
||||
'django_svelte_jsoneditor',
|
||||
'django_psutil_dash',
|
||||
|
||||
|
||||
'django_tasks',
|
||||
'django_tasks_db',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -382,6 +382,18 @@ CIMAR_PASSWORD = ""
|
||||
CELERY_BROKER_URL = "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:
|
||||
from .settings_local import *
|
||||
|
||||
@@ -24,3 +24,10 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
CACHES["default"] = {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
|
||||
TASKS = {
|
||||
"default": {
|
||||
"BACKEND": "django_tasks.backends.immediate.ImmediateBackend",
|
||||
"QUEUES": ["default"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#Django==3.2.13
|
||||
Django==6.0.1
|
||||
django-tasks
|
||||
django-tasks-db
|
||||
django_debug_toolbar
|
||||
django_jquery
|
||||
django_reversion
|
||||
|
||||
@@ -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
|
||||
Executable
+45
@@ -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
|
||||
@@ -333,6 +333,11 @@
|
||||
<a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a>
|
||||
</li>
|
||||
{% 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>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user