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
+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}; "