feat(tasks): Implement task action functionality in task overview page

This commit is contained in:
Ross
2026-05-17 21:53:30 +01:00
parent 1775b11e40
commit 75e3bca503
3 changed files with 219 additions and 46 deletions
+100 -41
View File
@@ -199,19 +199,8 @@ def series_reconstruct_task(
native_col_spacing = geom["col_spacing"]
native_z_spacing = geom["native_z_spacing"]
target_spacing = float(slice_spacing_val) if slice_spacing_val is not None else native_z_spacing
slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else target_spacing
volume_for_recon, recon_centers_mm = atlas_views._aggregate_volume_along_z(
volume,
source_positions_mm,
float(target_spacing),
float(slab_thickness),
recon_thickness_mode,
)
if volume_for_recon.shape[0] < 1:
raise ValueError("No reconstruction slices were generated")
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)))
@@ -219,9 +208,14 @@ def series_reconstruct_task(
# Reorient the reconstructed volume into patient axes order [z, y, x] so
# generated plane names are anatomically correct regardless of source plane.
source_axis_dirs = [normal_dir, row_dir, col_dir]
source_axis_spacings = [float(target_spacing), float(native_row_spacing), float(native_col_spacing)]
source_axis_sizes = [int(volume_for_recon.shape[0]), int(volume_for_recon.shape[1]), int(volume_for_recon.shape[2])]
# 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 = {}
@@ -243,7 +237,7 @@ def series_reconstruct_task(
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
vol_zyx = np.transpose(volume_for_recon, (src_z, src_y, src_x))
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)
@@ -255,9 +249,9 @@ def series_reconstruct_task(
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0, 0, 0]
z_index = int(iz)
y_index = int(iy)
x_index = int(ix)
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
@@ -275,41 +269,106 @@ def series_reconstruct_task(
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(target_spacing)))
+ (np.asarray(row_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
+ (np.asarray(col_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
+ (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):
if recon_thickness_mode == "max":
out = np.max(slab, axis=0)
elif recon_thickness_mode == "min":
out = np.min(slab, axis=0)
else:
out = np.mean(slab, axis=0)
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
centers_mm = np.arange(0.0, end_pos + (output_spacing * 0.5), output_spacing, dtype=float)
if centers_mm.size == 0:
centers_mm = np.array([0.0], dtype=float)
half = slab_thickness / 2.0
slabs = []
centers_idx = []
for center_mm in centers_mm:
mask = np.abs(axis_positions_mm - center_mm) <= (half + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
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(vol_zyx.shape[0]),
"slice_fn": lambda idx: vol_zyx[idx, :, :],
"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(spacing_z),
"image_orientation_out": [0.0, 1.0, 0.0, 1.0, 0.0, 0.0],
"position_fn": lambda idx: position_from_patient_zyx(idx, 0, 0),
"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],
"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(vol_zyx.shape[1]),
"slice_fn": lambda idx: vol_zyx[:, idx, :],
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, slabs[idx], :]),
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
"spacing_between_slices_out": float(spacing_y),
"image_orientation_out": [0.0, 0.0, 1.0, 1.0, 0.0, 0.0],
"position_fn": lambda idx: position_from_patient_zyx(0, idx, 0),
"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],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 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(vol_zyx.shape[2]),
"slice_fn": lambda idx: vol_zyx[:, :, idx],
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, :, slabs[idx]]),
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
"spacing_between_slices_out": float(spacing_x),
"image_orientation_out": [0.0, 0.0, 1.0, 0.0, 1.0, 0.0],
"position_fn": lambda idx: position_from_patient_zyx(0, 0, idx),
"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],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
}
if not plane_builders:
@@ -344,7 +403,7 @@ def series_reconstruct_task(
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ds_new.ImagePositionPatient = cfg["position_fn"](idx)
ds_new.SliceThickness = float(slab_thickness)
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
out_io = io.BytesIO()
@@ -401,8 +460,8 @@ def series_reconstruct_task(
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(target_spacing),
"slab_thickness": float(slab_thickness),
"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,
}
+71 -5
View File
@@ -51,10 +51,28 @@ Atlas Task Overview
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<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">
<thead>
<tr>
<th style="width: 36px;"></th>
<th>Task</th>
<th>Status</th>
<th>Generated Series</th>
@@ -64,11 +82,15 @@ Atlas Task Overview
<th>Finished</th>
<th>Worker</th>
<th>Error</th>
<th>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>
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
@@ -99,14 +121,58 @@ Atlas Task Overview
<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">{{ task.exception_class_path|default:"" }}</td>
<td class="small">
{% 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="9" class="text-muted">No tasks found for this filter.</td>
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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 %}
+48
View File
@@ -819,6 +819,54 @@ def task_overview(request):
},
)
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"],