Add downsample series functionality and related UI updates

- Implemented asynchronous downsample task for series with progress tracking.
- Added new URL endpoint for downsample status.
- Updated Series model to include source_series_instance_uid.
- Enhanced case display template with new buttons for series actions.
- Added password reset functionality in user profile with appropriate alerts.
- Created migration for new source_series_instance_uid field in Series model.
This commit is contained in:
Ross
2026-05-18 16:20:38 +01:00
parent c55797636a
commit 63ea4cdf23
12 changed files with 606 additions and 294 deletions
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
]
operations = [
migrations.AddField(
model_name="series",
name="source_series_instance_uid",
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
]
+1
View File
@@ -1132,6 +1132,7 @@ class Series(SeriesBase):
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
+101 -2
View File
@@ -92,6 +92,106 @@ from django.core.cache import cache
import copy
import io
@task(takes_context=True)
def series_downsample_task(context, series_id, user_id, downsample_pct):
"""Downsample a series asynchronously while preserving hash continuity."""
from loguru import logger
from atlas import views as atlas_views
progress_key = f"series_downsample_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing downsample..."},
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())
total_images = len(images)
replacement_series_uid = generate_uid()
source_series_uid = series.series_instance_uid
updated_images = []
cache.set(
progress_key,
{"current": 0, "total": total_images, "message": "Downsample started"},
timeout=60 * 60,
)
for idx, image in enumerate(images, start=1):
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
cache.set(
progress_key,
{"current": idx, "total": total_images, "message": f"Skipping unreadable image ({idx}/{total_images})"},
timeout=60 * 60,
)
continue
try:
original_hash = image.image_blake3_hash
ds_out = atlas_views._downsample_dicom_dataset(
ds,
int(downsample_pct),
series_instance_uid=replacement_series_uid,
source_series_instance_uid=source_series_uid,
series_description=series.description,
)
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,
)
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
updated_images.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
cache.set(
progress_key,
{
"current": idx,
"total": total_images,
"message": f"Downsampling image {idx}/{total_images}",
},
timeout=60 * 60,
)
if updated_images:
series.modified = Series.SeriesModifiedChocies.RE
series.source_series_instance_uid = source_series_uid
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
cache.set(
progress_key,
{
"current": total_images,
"total": total_images,
"message": "Downsample complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"downsample_pct": int(downsample_pct),
"updated_images": len(updated_images),
"series_instance_uid": replacement_series_uid,
"source_series_instance_uid": source_series_uid,
}
@task
def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted."""
@@ -389,8 +489,6 @@ def series_reconstruct_task(
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"])
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
@@ -403,6 +501,7 @@ def series_reconstruct_task(
atlas_views._stamp_derived_dataset(
ds_new,
series_instance_uid=recon_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=(
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
+31 -32
View File
@@ -13,7 +13,7 @@
draggable="true">
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<div class="series-card-shell">
<button type="button"
<div type="button"
class="series-card-main"
data-series-action="details"
data-series-id="{{series.pk}}"
@@ -37,32 +37,31 @@
<span class="series-card-image-count">{{ series.visible_image_count }} image{{ series.visible_image_count|pluralize }}</span>
</div>
<div class="series-card-description">{{ series.description|default:"No description" }}</div>
<button type="button"
class="btn btn-outline-info btn-sm series-open-primary-btn"
title="View series in viewer"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
<i class="bi bi-eye"></i> View1
</button>
<div class="series-card-inline-actions">
<button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<button type="button"
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
title="View series in a new pane"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}"
tabindex="-1"
aria-hidden="true">
<i class="bi bi-layout-split"></i> View in new pane
</button>
</div>
</div>
</button>
<div class="series-card-controls">
<button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<button type="button"
class="btn btn-outline-info btn-sm series-open-primary-btn"
title="View series in viewer"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
<i class="bi bi-eye"></i> View
</button>
<button type="button"
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
title="View series in a new pane"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}"
tabindex="-1"
aria-hidden="true">
<i class="bi bi-layout-split"></i> View in new pane
</button>
</div>
</div>
</article>
@@ -2122,7 +2121,7 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
gap: 0.3rem;
}
.series-card-heading {
font-size: 0.85rem;
@@ -2145,14 +2144,14 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
.series-card-controls {
.series-card-inline-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: auto;
margin-top: 0.2rem;
}
.series-card-controls .btn {
flex: 1;
min-width: 0;
.series-card-inline-actions .btn {
flex: 0 0 auto;
padding: 0.3rem 0.4rem;
font-size: 0.75rem;
}
+5
View File
@@ -576,6 +576,11 @@ urlpatterns = [
views.series_reconstruct_status_htmx,
name="series_reconstruct_status",
),
path(
"series/<int:series_id>/downsample/status/<str:task_id>/",
views.series_downsample_status_htmx,
name="series_downsample_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"),
+105 -38
View File
@@ -165,7 +165,7 @@ from .filters import (
NormalCaseFilter,
)
from .tasks import push_case_to_cimar_task, series_reconstruct_task
from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
try:
from django_tasks import TaskResultStatus
except ImportError:
@@ -313,6 +313,7 @@ def _stamp_derived_dataset(
ds,
*,
series_instance_uid=None,
source_series_instance_uid=None,
series_description=None,
derivation_description=None,
image_type=None,
@@ -322,6 +323,8 @@ def _stamp_derived_dataset(
now = timezone.now()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
source_sop_instance_uid = getattr(ds, "SOPInstanceUID", None)
source_sop_class_uid = getattr(ds, "SOPClassUID", None)
if regenerate_sop_instance_uid:
ds.SOPInstanceUID = generate_uid()
@@ -335,6 +338,18 @@ def _stamp_derived_dataset(
if derivation_description:
ds.DerivationDescription = derivation_description
if source_series_instance_uid:
series_ref = pydicom.dataset.Dataset()
series_ref.SeriesInstanceUID = str(source_series_instance_uid)
ds.ReferencedSeriesSequence = pydicom.sequence.Sequence([series_ref])
if source_sop_instance_uid:
source_ref = pydicom.dataset.Dataset()
if source_sop_class_uid:
source_ref.ReferencedSOPClassUID = str(source_sop_class_uid)
source_ref.ReferencedSOPInstanceUID = str(source_sop_instance_uid)
ds.SourceImageSequence = pydicom.sequence.Sequence([source_ref])
ds.ContentDate = date_str
ds.ContentTime = time_str
ds.InstanceCreationDate = date_str
@@ -347,7 +362,14 @@ def _stamp_derived_dataset(
return ds
def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, series_description=None):
def _downsample_dicom_dataset(
ds,
reduction_pct,
*,
series_instance_uid=None,
source_series_instance_uid=None,
series_description=None,
):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
@@ -365,6 +387,7 @@ def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, se
_stamp_derived_dataset(
ds,
series_instance_uid=series_instance_uid,
source_series_instance_uid=source_series_instance_uid,
series_description=series_description,
derivation_description=derivation_description,
image_type=["DERIVED", "SECONDARY"],
@@ -399,6 +422,7 @@ def _create_series_derivative(source_series, description_suffix):
derived.pk = None
derived.id = None
derived.series_instance_uid = generate_uid()
derived.source_series_instance_uid = source_series.series_instance_uid
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
derived.modified = Series.SeriesModifiedChocies.RE
derived.save()
@@ -675,6 +699,7 @@ def series_optimize_htmx(request, series_id):
ds,
downsample_pct,
series_instance_uid=preview_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=preview_series.description,
)
except Exception as exc:
@@ -714,44 +739,22 @@ def series_optimize_htmx(request, series_id):
)
)
downsampled = []
replacement_series_uid = generate_uid()
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,
series_instance_uid=replacement_series_uid,
series_description=series.description,
)
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.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "series_instance_uid"])
task_result = series_downsample_task.enqueue(
series_id=series.pk,
user_id=request.user.pk,
downsample_pct=downsample_pct,
)
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>'
(
'<div class="alert alert-info mb-2">'
'Downsample queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="downsample-task-status" hx-get="{reverse("atlas:series_downsample_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 downsample task...</span></div></div>'
)
)
if operation == "reconstruct":
@@ -881,6 +884,70 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
)
@login_required
def series_downsample_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series downsample task."""
series = get_object_or_404(Series, pk=series_id)
target_id = (request.GET.get("target_id") or "downsample-task-status").strip() or "downsample-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_downsample_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_downsample_status", kwargs={"series_id": series.pk, "task_id": task_id})
progress = cache.get(f"series_downsample_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", "Downsample 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} images processed</div>'
'</div>'
)
)
if state == TaskResultStatus.SUCCESSFUL:
payload = task_result.return_value or {}
updated = int(payload.get("updated_images", 0) or 0)
downsample_pct = escape(str(payload.get("downsample_pct", "")))
return HttpResponse(
f'<div id="{target_id}" class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{updated}</strong> images while preserving original hashes.</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">Downsample 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):