diff --git a/.gitignore b/.gitignore index 4149894..868db7c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ env.bak/ venv.bak/ .env.dev .env.prod +.env-zip-dgx # Django *.log @@ -200,3 +201,6 @@ ANALYSIS.md MODULE_DESIGN.md Trace-*.json .tmp_trace_*.tsv +Makefile +CLAUDE.md +.code-review-graph \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 42aeba5..5d41bed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \ ca-certificates \ gnupg \ lsb-release \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # Install Docker CLI diff --git a/common/job_routing.py b/common/job_routing.py index ab1da52..09446c1 100644 --- a/common/job_routing.py +++ b/common/job_routing.py @@ -17,7 +17,9 @@ def _sanitize_queue_name(name: Optional[str], *, default: str) -> str: def _project_slug_for_job(job: Any) -> Optional[str]: try: - if getattr(job, "domain", "") == "brain": + domain = getattr(job, "domain", "") + + if domain == "brain": patient = getattr(job, "brain_patient", None) if patient is None: voice_caption = getattr(job, "brain_voice_caption", None) @@ -28,6 +30,17 @@ def _project_slug_for_job(job: Any) -> Optional[str]: ) return "brain" if patient is not None else None + if domain == "laparoscopy": + patient = getattr(job, "laparoscopy_patient", None) + if patient is None: + voice_caption = getattr(job, "laparoscopy_voice_caption", None) + patient = ( + getattr(voice_caption, "patient", None) + if voice_caption is not None + else None + ) + return "laparoscopy" if patient is not None else None + patient = getattr(job, "patient", None) if patient is None: voice_caption = getattr(job, "voice_caption", None) diff --git a/common/migrations/0024_fileregistry_laparoscopy_patient_and_more.py b/common/migrations/0024_fileregistry_laparoscopy_patient_and_more.py new file mode 100644 index 0000000..24f4990 --- /dev/null +++ b/common/migrations/0024_fileregistry_laparoscopy_patient_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.4 on 2026-05-04 09:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0023_rename_common_file_domain_22309d_idx_maxillo_fil_domain_760eb4_idx_and_more'), + ('laparoscopy', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='fileregistry', + name='laparoscopy_patient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='laparoscopy.patient'), + ), + migrations.AddField( + model_name='fileregistry', + name='laparoscopy_voice_caption', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='laparoscopy.voicecaption'), + ), + migrations.AddField( + model_name='job', + name='laparoscopy_patient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='laparoscopy.patient'), + ), + migrations.AddField( + model_name='job', + name='laparoscopy_voice_caption', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='laparoscopy.voicecaption'), + ), + migrations.AddField( + model_name='processingjob', + name='laparoscopy_patient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='processing_jobs', to='laparoscopy.patient'), + ), + migrations.AddField( + model_name='processingjob', + name='laparoscopy_voice_caption', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='processing_jobs', to='laparoscopy.voicecaption'), + ), + migrations.AlterField( + model_name='fileregistry', + name='domain', + field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20), + ), + migrations.AlterField( + model_name='fileregistry', + name='file_type', + field=models.CharField(choices=[('cbct_raw', 'CBCT Raw'), ('cbct_processed', 'CBCT Processed'), ('ios_raw_upper', 'IOS Raw Upper'), ('ios_raw_lower', 'IOS Raw Lower'), ('ios_processed_upper', 'IOS Processed Upper'), ('ios_processed_lower', 'IOS Processed Lower'), ('audio_raw', 'Audio Raw'), ('audio_processed', 'Audio Processed Text'), ('bite_classification', 'Bite Classification Results'), ('rgb_image', 'RGB Image'), ('volume_raw', 'Volume Raw'), ('volume_processed', 'Volume Processed'), ('image_raw', 'Image Raw'), ('image_processed', 'Image Processed'), ('generic_raw', 'Generic Raw'), ('generic_processed', 'Generic Processed'), ('braintumor_mri_t1_raw', 'Brain MRI T1 Raw'), ('braintumor_mri_t1_processed', 'Brain MRI T1 Processed'), ('braintumor_mri_t1c_raw', 'Brain MRI T1c Raw'), ('braintumor_mri_t1c_processed', 'Brain MRI T1c Processed'), ('braintumor_mri_t2_raw', 'Brain MRI T2 Raw'), ('braintumor_mri_t2_processed', 'Brain MRI T2 Processed'), ('braintumor_mri_flair_raw', 'Brain MRI FLAIR Raw'), ('braintumor_mri_flair_processed', 'Brain MRI FLAIR Processed'), ('intraoral_raw', 'Intraoral Photographs Raw'), ('intraoral_processed', 'Intraoral Photographs Processed'), ('teleradiography_raw', 'Teleradiography Raw'), ('teleradiography_processed', 'Teleradiography Processed'), ('panoramic_raw', 'panoramic Raw'), ('panoramic_processed', 'panoramic Processed'), ('video_raw', 'Video Raw'), ('video_processed', 'Video Processed')], max_length=255), + ), + migrations.AlterField( + model_name='job', + name='domain', + field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20), + ), + migrations.AlterField( + model_name='processingjob', + name='domain', + field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20), + ), + ] diff --git a/common/models.py b/common/models.py index df06067..6a3f4ff 100644 --- a/common/models.py +++ b/common/models.py @@ -159,6 +159,7 @@ class Job(models.Model): DOMAIN_CHOICES = [ ('maxillo', 'Maxillo'), ('brain', 'Brain'), + ('laparoscopy', 'Laparoscopy'), ] STATUS_CHOICES = [ @@ -177,8 +178,10 @@ class Job(models.Model): domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo') patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) + laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) + laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True) # IO input_file_path = models.CharField(max_length=500, help_text='Primary input object key', blank=True) @@ -289,6 +292,7 @@ class ProcessingJob(models.Model): DOMAIN_CHOICES = [ ('maxillo', 'Maxillo'), ('brain', 'Brain'), + ('laparoscopy', 'Laparoscopy'), ] JOB_TYPE_CHOICES = [ @@ -314,8 +318,10 @@ class ProcessingJob(models.Model): domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo') patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) + laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) + laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True) # File paths input_file_path = models.CharField(max_length=500, help_text='Input object key') @@ -430,6 +436,7 @@ class FileRegistry(models.Model): DOMAIN_CHOICES = [ ('maxillo', 'Maxillo'), ('brain', 'Brain'), + ('laparoscopy', 'Laparoscopy'), ] FILE_TYPE_CHOICES = [ @@ -465,6 +472,9 @@ class FileRegistry(models.Model): ('teleradiography_processed', 'Teleradiography Processed'), ('panoramic_raw', 'panoramic Raw'), ('panoramic_processed', 'panoramic Processed'), + # Generic video modality (used by laparoscopy and any future video domain) + ('video_raw', 'Video Raw'), + ('video_processed', 'Video Processed'), ] file_type = models.CharField(max_length=255, choices=FILE_TYPE_CHOICES) @@ -477,8 +487,10 @@ class FileRegistry(models.Model): domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo') patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True) brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True) + laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True) voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True) brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True) + laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True) processing_job = models.ForeignKey('common.Job', on_delete=models.CASCADE, related_name='files', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) metadata = models.JSONField(default=dict, blank=True, help_text='Additional file metadata') diff --git a/common/object_storage.py b/common/object_storage.py index dc190b6..b86d0a4 100644 --- a/common/object_storage.py +++ b/common/object_storage.py @@ -80,6 +80,12 @@ def __init__( config=Config( s3={"addressing_style": self.addressing_style or "path"}, retries={"max_attempts": 3, "mode": "standard"}, + # Garage can advertise flexible checksums that botocore then + # rejects on plain get_object reads. Keep validation only for + # operations that explicitly require it. + # An optimization for video stream + request_checksum_calculation="when_required", + response_checksum_validation="when_required", ), ) @@ -164,6 +170,24 @@ def get(self, key: str) -> Tuple[BinaryIO, ObjectInfo]: ) return resp["Body"], info + def get_range(self, key: str, byte_range: str) -> Tuple[BinaryIO, ObjectInfo]: + """Fetch a byte range of an object (e.g. byte_range='bytes=0-1023').""" + key_n = self.normalize_key(key) + try: + resp = self._client.get_object(Bucket=self.bucket, Key=key_n, Range=byte_range) + except ClientError as exc: + code = self._client_error_code(exc) + if code in {"NoSuchKey", "404", "NotFound"}: + raise FileNotFoundError(key) from exc + raise ObjectStorageError(str(exc)) from exc + info = ObjectInfo( + key=key, + content_length=resp.get("ContentLength"), + content_type=resp.get("ContentType"), + etag=(resp.get("ETag") or "").strip('"') or None, + ) + return resp["Body"], info + def iter_bytes( self, key: str, *, chunk_size: int = 1024 * 1024 ) -> Generator[bytes, None, None]: diff --git a/laparoscopy/__init__.py b/laparoscopy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laparoscopy/admin.py b/laparoscopy/admin.py new file mode 100644 index 0000000..5129043 --- /dev/null +++ b/laparoscopy/admin.py @@ -0,0 +1,104 @@ +from django.contrib import admin +from django.utils.html import format_html + +from .models import ( + Classification, + Dataset, + Export, + Folder, + Patient, + QuadrantClassificationMarker, + QuadrantType, + Tag, + VoiceCaption, +) + + +class QuadrantClassificationMarkerInline(admin.TabularInline): + model = QuadrantClassificationMarker + extra = 0 + fields = ['time_ms', 'quadrant_type', 'created_by', 'updated_by', 'updated_at'] + readonly_fields = ['created_by', 'updated_by', 'updated_at'] + ordering = ['time_ms', 'id'] + + +@admin.register(Dataset) +class DatasetAdmin(admin.ModelAdmin): + list_display = ['name', 'created_at', 'created_by'] + search_fields = ['name', 'description'] + + +@admin.register(Folder) +class FolderAdmin(admin.ModelAdmin): + list_display = ['name', 'parent', 'created_at', 'created_by'] + search_fields = ['name'] + + +@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + list_display = ['name', 'created_at'] + search_fields = ['name'] + + +@admin.register(Patient) +class PatientAdmin(admin.ModelAdmin): + list_display = ['patient_id', 'name', 'visibility', 'folder', 'uploaded_at', 'uploaded_by'] + list_filter = ['visibility', 'uploaded_at'] + search_fields = ['patient_id', 'name'] + inlines = [QuadrantClassificationMarkerInline] + + +@admin.register(Classification) +class ClassificationAdmin(admin.ModelAdmin): + list_display = ['id', 'patient', 'classifier', 'timestamp'] + list_filter = ['classifier', 'timestamp'] + + +@admin.register(VoiceCaption) +class VoiceCaptionAdmin(admin.ModelAdmin): + list_display = ['id', 'patient', 'user', 'modality', 'processing_status', 'created_at'] + list_filter = ['modality', 'processing_status', 'created_at'] + + +@admin.register(Export) +class ExportAdmin(admin.ModelAdmin): + list_display = ['id', 'user', 'status', 'patient_count', 'file_size', 'created_at', 'completed_at'] + list_filter = ['status', 'share_mode', 'created_at', 'completed_at'] + search_fields = ['id', 'user__username', 'query_summary', 'file_path', 'share_token'] + readonly_fields = ['created_at', 'started_at', 'completed_at', 'shared_at'] + + +@admin.register(QuadrantType) +class QuadrantTypeAdmin(admin.ModelAdmin): + list_display = ['id', 'project', 'name', 'color_preview', 'color', 'order', 'marker_count'] + list_filter = ['project'] + search_fields = ['name', 'project__name', 'project__slug'] + ordering = ['project__name', 'order', 'name'] + + @admin.display(description='Color') + def color_preview(self, obj): + return format_html( + '', + obj.color, + ) + + @admin.display(description='Markers') + def marker_count(self, obj): + return obj.markers.count() + + +@admin.register(QuadrantClassificationMarker) +class QuadrantClassificationMarkerAdmin(admin.ModelAdmin): + list_display = ['id', 'patient', 'patient_name', 'quadrant_type', 'time_seconds', 'created_by', 'updated_by', 'updated_at'] + list_filter = ['quadrant_type', 'patient__visibility', 'created_at', 'updated_at'] + search_fields = ['patient__patient_id', 'patient__name', 'quadrant_type__name'] + autocomplete_fields = ['patient', 'quadrant_type', 'created_by', 'updated_by'] + ordering = ['patient_id', 'time_ms', 'id'] + + @admin.display(description='Patient Name') + def patient_name(self, obj): + return obj.patient.name + + @admin.display(description='Time (s)', ordering='time_ms') + def time_seconds(self, obj): + return f'{obj.time_ms / 1000:.3f}' diff --git a/laparoscopy/apps.py b/laparoscopy/apps.py new file mode 100644 index 0000000..cd01a1c --- /dev/null +++ b/laparoscopy/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LaparoscopyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'laparoscopy' diff --git a/laparoscopy/export_processor.py b/laparoscopy/export_processor.py new file mode 100644 index 0000000..1b4b505 --- /dev/null +++ b/laparoscopy/export_processor.py @@ -0,0 +1,455 @@ +"""Laparoscopy-specific export processor for subsampled video and NPZ masks.""" + +import io +import json +import logging +import math +import os +import subprocess +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw +from django.utils import timezone + +from common.models import FileRegistry, Project +from common.object_storage import download_to_tempfile, get_object_storage +from .models import Export, Folder, Patient, RegionAnnotation, RegionType + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class LaparoscopyExportSource: + patient: Patient + video_file: FileRegistry + + +def _normalize_folder_ids(folder_ids): + normalized = [] + for raw_value in folder_ids or []: + try: + folder_id = int(raw_value) + except (TypeError, ValueError): + continue + if folder_id > 0: + normalized.append(folder_id) + return normalized + + +def _sanitize_archive_component(value): + cleaned = "".join( + c for c in str(value or "") if c.isalnum() or c in ("_", "-", " ") + ).strip() + return cleaned.replace(" ", "_") or "item" + + +def get_laparoscopy_export_folders(): + folders = list(Folder.objects.order_by("name", "id")) + data = [] + for folder in folders: + data.append( + { + "folder": folder, + "full_path": folder.get_full_path(), + "patient_count": int(folder.patients.count()), + } + ) + data.sort(key=lambda item: item["full_path"].lower()) + return data + + +def get_laparoscopy_region_types(): + project = Project.objects.filter(slug="laparoscopy").first() + if not project: + return [] + return list(RegionType.objects.filter(project=project).order_by("order", "name", "id")) + + +def list_laparoscopy_export_sources(folder_ids): + normalized_folder_ids = _normalize_folder_ids(folder_ids) + patients = list(Patient.objects.filter(folder_id__in=normalized_folder_ids).order_by("patient_id")) + if not patients: + return patients, [] + + patient_ids = [patient.patient_id for patient in patients] + latest_video_by_patient = {} + subsampled_videos = ( + FileRegistry.objects.filter( + domain="laparoscopy", + file_type="video_processed", + subtype="subsampled", + laparoscopy_patient_id__in=patient_ids, + ) + .select_related("laparoscopy_patient") + .order_by("laparoscopy_patient_id", "-created_at", "-id") + ) + for video_file in subsampled_videos: + latest_video_by_patient.setdefault(video_file.laparoscopy_patient_id, video_file) + + sources = [] + for patient in patients: + video_file = latest_video_by_patient.get(patient.patient_id) + if video_file is not None: + sources.append(LaparoscopyExportSource(patient=patient, video_file=video_file)) + + return patients, sources + + +def build_laparoscopy_export_preview(folder_ids): + patients, sources = list_laparoscopy_export_sources(folder_ids) + total_size = sum(int(source.video_file.file_size or 0) for source in sources) + return { + "patient_count": len(patients), + "exportable_patient_count": len(sources), + "file_count": len(sources), + "estimated_size_bytes": total_size, + } + + +class LaparoscopyExportProcessor: + """Generate laparoscopy exports with one NPZ mask stack per subsampled frame.""" + + def __init__(self, export): + self.export = export + self.query_params = export.query_params or {} + self.folder_ids = _normalize_folder_ids(self.query_params.get("folder_ids", [])) + + def _update_progress(self, message, percent=None): + update_kw = {"progress_message": message} + if percent is not None: + update_kw["progress_percent"] = min(100, max(0, int(percent))) + Export.objects.filter(pk=self.export.pk).update(**update_kw) + + def _probe_video(self, local_video_path): + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-count_packets", + "-show_entries", + "stream=width,height,avg_frame_rate,nb_frames,nb_read_packets,duration", + "-of", + "json", + local_video_path, + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + except FileNotFoundError as exc: + raise RuntimeError( + "ffprobe is required in the web container to export laparoscopy videos." + ) from exc + if result.returncode != 0: + raise RuntimeError( + f"ffprobe failed for {local_video_path}: {(result.stderr or result.stdout).strip()}" + ) + + payload = json.loads(result.stdout or "{}") + streams = payload.get("streams") or [] + if not streams: + raise RuntimeError(f"No video stream found in {local_video_path}") + stream = streams[0] + + width = int(stream.get("width") or 0) + height = int(stream.get("height") or 0) + if width <= 0 or height <= 0: + raise RuntimeError(f"Invalid video dimensions in {local_video_path}") + + fps_raw = str(stream.get("avg_frame_rate") or "") + fps = 0.0 + if "/" in fps_raw: + num_raw, den_raw = fps_raw.split("/", 1) + try: + num = float(num_raw) + den = float(den_raw) + if den: + fps = num / den + except (TypeError, ValueError, ZeroDivisionError): + fps = 0.0 + elif fps_raw: + try: + fps = float(fps_raw) + except (TypeError, ValueError): + fps = 0.0 + if not math.isfinite(fps) or fps <= 0: + fps = 1.0 + + frame_count = 0 + for key in ("nb_frames", "nb_read_packets"): + raw_value = stream.get(key) + try: + parsed = int(raw_value) + except (TypeError, ValueError): + parsed = 0 + if parsed > 0: + frame_count = parsed + break + if frame_count <= 0: + try: + duration = float(stream.get("duration") or 0) + except (TypeError, ValueError): + duration = 0.0 + if duration > 0: + frame_count = max(1, int(math.ceil(duration * fps - 1e-9))) + if frame_count <= 0: + raise RuntimeError(f"Could not determine frame count for {local_video_path}") + + return { + "width": width, + "height": height, + "fps": float(fps), + "frame_count": int(frame_count), + } + + @staticmethod + def _frame_index_for_time(frame_time, fps, frame_count): + try: + frame_time = float(frame_time) + except (TypeError, ValueError): + frame_time = 0.0 + if not math.isfinite(frame_time) or frame_time < 0: + frame_time = 0.0 + frame_index = int(round(frame_time * fps)) + return min(max(frame_index, 0), max(frame_count - 1, 0)) + + @staticmethod + def _clamp_coord(value, upper_bound): + try: + value = float(value) + except (TypeError, ValueError): + value = 0.0 + if not math.isfinite(value): + value = 0.0 + if upper_bound <= 0: + return 0 + return min(max(int(round(value)), 0), upper_bound - 1) + + def _annotation_pairs(self, annotation, width, height): + points = annotation.points if isinstance(annotation.points, list) else [] + if len(points) < 4 or len(points) % 2 != 0: + return [] + pairs = [] + for idx in range(0, len(points), 2): + x = self._clamp_coord(points[idx], width) + y = self._clamp_coord(points[idx + 1], height) + pairs.append((x, y)) + return pairs + + def _draw_polyline(self, draw, pairs, fill_value, stroke_width): + if len(pairs) < 2: + return + draw.line(pairs, fill=fill_value, width=stroke_width) + radius = max(1, stroke_width // 2) + for x, y in pairs: + draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=fill_value) + + def _apply_annotation_to_layer(self, image, annotation, width, height): + pairs = self._annotation_pairs(annotation, width, height) + if not pairs: + return + + draw = ImageDraw.Draw(image) + tool = str(annotation.tool or "").strip().lower() + stroke_width = max(1, int(round(float(annotation.stroke_width or 1.0)))) + + if tool == "polygon": + if len(pairs) >= 3: + draw.polygon(pairs, fill=255, outline=255) + return + + fill_value = 0 if tool == "eraser" else 255 + self._draw_polyline(draw, pairs, fill_value, stroke_width) + + def _build_frame_annotation_map(self, patient, class_axis_by_region_type_id, fps, frame_count): + annotations = ( + RegionAnnotation.objects.filter( + patient=patient, + region_type_id__in=class_axis_by_region_type_id.keys(), + ) + .select_related("region_type") + .order_by("created_at", "id") + ) + frame_map = {} + for annotation in annotations: + class_index = class_axis_by_region_type_id.get(annotation.region_type_id) + if class_index is None: + continue + frame_index = self._frame_index_for_time(annotation.frame_time, fps, frame_count) + frame_map.setdefault(frame_index, []).append((class_index, annotation)) + return frame_map + + def _render_frame_masks(self, width, height, class_count, frame_annotations): + if class_count <= 0: + return np.zeros((0, height, width), dtype=np.uint8) + + layer_images = [Image.new("L", (width, height), 0) for _ in range(class_count)] + for class_index, annotation in frame_annotations: + if class_index < 0 or class_index >= class_count: + continue + self._apply_annotation_to_layer( + layer_images[class_index], annotation, width=width, height=height + ) + + layers = [] + for image in layer_images: + layer = (np.asarray(image, dtype=np.uint8) > 0).astype(np.uint8) + layers.append(layer) + return np.stack(layers, axis=0) + + def process_export(self): + try: + patients, sources = list_laparoscopy_export_sources(self.folder_ids) + if not patients: + self.export.mark_failed("No laparoscopy patients match the selected folders.") + return + + if not sources: + self.export.mark_failed( + "No selected laparoscopy patients have a subsampled video available for export." + ) + return + + region_types = get_laparoscopy_region_types() + if not region_types: + self.export.mark_failed("No laparoscopy region types are configured for export.") + return + + self.export.patient_count = len(sources) + self.export.save(update_fields=["patient_count"]) + self._update_progress(f"Collected {len(sources)} exportable laparoscopy patients", 5) + + class_axis = [] + class_axis_by_region_type_id = {} + for axis_index, region_type in enumerate(region_types): + class_axis_by_region_type_id[region_type.id] = axis_index + class_axis.append( + { + "axis": axis_index, + "region_type_id": region_type.id, + "name": region_type.name, + "color": region_type.color, + "order": region_type.order, + } + ) + + timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") + filename = f"export_{self.export.id}_{timestamp}.zip" + storage_key = f"exports/{filename}" + storage = get_object_storage() + + manifest = { + "format_version": 1, + "project": "laparoscopy", + "export_id": self.export.id, + "generated_at": timezone.now().isoformat(), + "frame_sampling_fps": 1.0, + "classes": class_axis, + "patients": [], + "query": { + "folder_ids": self.folder_ids, + "mask_format": "npz_multilayer", + "include_all_frames": True, + "video_subtype": "subsampled", + }, + } + + with tempfile.TemporaryDirectory(prefix="tf_laparoscopy_export_") as tmpdir: + export_path = os.path.join(tmpdir, filename) + with zipfile.ZipFile(export_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for patient_index, source in enumerate(sources, start=1): + patient = source.patient + progress_base = 10 + int(80 * (patient_index - 1) / max(len(sources), 1)) + self._update_progress( + f"Exporting patient {patient_index}/{len(sources)} (ID {patient.patient_id})", + progress_base, + ) + + suffix = Path(source.video_file.file_path or "").suffix or ".mp4" + with download_to_tempfile(source.video_file.file_path, suffix=suffix) as local_video_path: + video_meta = self._probe_video(local_video_path) + frame_annotations = self._build_frame_annotation_map( + patient, + class_axis_by_region_type_id, + fps=video_meta["fps"], + frame_count=video_meta["frame_count"], + ) + + patient_folder = _sanitize_archive_component( + f"patient_{patient.patient_id}_{patient.name or ''}" + ) + video_ext = Path(source.video_file.file_path or local_video_path).suffix or ".mp4" + video_zip_path = f"{patient_folder}/video/subsampled{video_ext}" + zipf.write(local_video_path, video_zip_path) + + frame_progress_interval = max(1, video_meta["frame_count"] // 10) + for frame_index in range(video_meta["frame_count"]): + masks = self._render_frame_masks( + width=video_meta["width"], + height=video_meta["height"], + class_count=len(class_axis), + frame_annotations=frame_annotations.get(frame_index, []), + ) + buffer = io.BytesIO() + np.savez_compressed(buffer, masks=masks) + zipf.writestr( + f"{patient_folder}/masks/frame_{frame_index:06d}.npz", + buffer.getvalue(), + ) + if (frame_index + 1) % frame_progress_interval == 0: + patient_pct = (frame_index + 1) / max(video_meta["frame_count"], 1) + self._update_progress( + ( + f"Writing masks for patient {patient.patient_id} " + f"({frame_index + 1}/{video_meta['frame_count']} frames)" + ), + progress_base + int(80 / max(len(sources), 1) * patient_pct), + ) + + manifest["patients"].append( + { + "patient_id": patient.patient_id, + "name": patient.name, + "video_file_id": source.video_file.id, + "video_file_key": source.video_file.file_path, + "zip_video_path": video_zip_path, + "frame_count": video_meta["frame_count"], + "width": video_meta["width"], + "height": video_meta["height"], + "fps": video_meta["fps"], + } + ) + + zipf.writestr("manifest.json", json.dumps(manifest, indent=2).encode("utf-8")) + + self._update_progress("Uploading export ZIP...", 95) + storage.upload_file( + export_path, + key=storage_key, + content_type="application/zip", + metadata={ + "export_id": str(self.export.id), + "user_id": str(getattr(self.export, "user_id", "") or ""), + }, + ) + actual_size = os.path.getsize(export_path) + + self.export.mark_completed(file_path=storage_key, file_size=actual_size) + logger.info( + "Laparoscopy export %s completed successfully. Size: %s bytes", + self.export.id, + actual_size, + ) + except Exception as exc: + logger.error( + "Error processing laparoscopy export %s: %s", + self.export.id, + exc, + exc_info=True, + ) + self.export.mark_failed(str(exc)) diff --git a/laparoscopy/file_utils.py b/laparoscopy/file_utils.py new file mode 100644 index 0000000..1ca1a72 --- /dev/null +++ b/laparoscopy/file_utils.py @@ -0,0 +1,90 @@ +import os +import logging + +from django.utils import timezone + +from common.models import FileRegistry, Job +from maxillo.file_utils import ( + _raw_key_prefix_for, + _upload_uploaded_file_to_storage, + _entity_fk_kwargs, +) + +logger = logging.getLogger(__name__) + + +def save_video_to_dataset(patient, video_file): + """Upload a video file and create a pending processing job with the standard video payload. + + Returns: + tuple: (FileRegistry | None, Job | None) + """ + original_name = video_file.name + ext = os.path.splitext(original_name)[1].lower() or ".mp4" + filename = f"video_patient_{patient.patient_id}{ext}" + key = f"{_raw_key_prefix_for(patient, 'video')}/{filename}" + key, file_size, file_hash = _upload_uploaded_file_to_storage( + key=key, uploaded_file=video_file + ) + + modality_fk = None + try: + from common.models import Modality as _Modality + modality_fk = _Modality.objects.filter(slug="video").first() + except Exception: + pass + + try: + fr = FileRegistry.objects.create( + file_type="video_raw", + file_path=key, + file_size=file_size, + file_hash=file_hash, + **_entity_fk_kwargs(patient), + modality=modality_fk, + metadata={ + "original_filename": original_name, + "uploaded_at": timezone.now().isoformat(), + "modality_slug": "video", + }, + ) + except Exception: + logger.exception("Failed to create FileRegistry for video; proceeding to create Job anyway") + fr = None + + job_obj = None + try: + job_obj = Job.objects.create( + modality_slug="video", + **_entity_fk_kwargs(patient), + input_file_path=key, + status="pending", + output_files={ + "schema_version": 1, + "input_type": "video", + "processing_profile": "laparoscopy_video_v1", + "expected_outputs": ["compressed", "subsampled"], + "derivatives": { + "compressed": { + "type": "video", + "make_primary": True, + "container": "mp4", + "video_codec": "h264", + }, + "subsampled": { + "type": "video", + "make_primary": False, + "container": "mp4", + "video_codec": "h264", + "sampling": { + "mode": "fps", + "target_fps": 1.0, + }, + }, + }, + }, + ) + except Exception as e: + logger.error(f"Failed to create Job for video: {e}") + + return fr, job_obj diff --git a/laparoscopy/forms.py b/laparoscopy/forms.py new file mode 100644 index 0000000..8e76441 --- /dev/null +++ b/laparoscopy/forms.py @@ -0,0 +1,157 @@ +from django import forms + +from .models import Classification, Dataset, Folder, Patient, Tag + + +class PatientForm(forms.ModelForm): + class Meta: + model = Patient + fields = [] + + +class PatientUploadForm(forms.ModelForm): + video = forms.FileField( + required=False, + label='Video', + widget=forms.FileInput( + attrs={ + 'class': 'form-control', + 'accept': '.mp4,.avi', + } + ), + ) + + folder = forms.ModelChoiceField( + queryset=Folder.objects.all().order_by('name'), + required=False, + widget=forms.Select(attrs={'class': 'form-control'}), + ) + tags_text = forms.CharField( + required=False, + help_text='Comma-separated tags', + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. caseA, urgent'}), + ) + + class Meta: + model = Patient + fields = ['name', 'visibility', 'folder'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Patient X'}), + 'visibility': forms.Select(attrs={'class': 'form-control'}), + } + labels = { + 'name': 'Patient Name', + 'visibility': 'Visibility', + 'folder': 'Folder', + } + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + if user and hasattr(user, 'profile'): + if user.profile.is_student_developer(): + self.fields['visibility'].choices = [('debug', 'Debug')] + self.fields['visibility'].initial = 'debug' + self.fields['visibility'].widget.attrs['readonly'] = True + elif user.profile.is_admin(): + self.fields['visibility'].choices = Patient.VISIBILITY_CHOICES + else: + self.fields['visibility'].choices = [ + ('public', 'Public'), + ('private', 'Private'), + ] + + def save(self, commit=True): + instance = super().save(commit) + tags_text = self.cleaned_data.get('tags_text', '') or '' + tag_names = [tag.strip() for tag in tags_text.split(',') if tag.strip()] + if commit and tag_names: + tags = [] + for name in tag_names: + tag, _ = Tag.objects.get_or_create(name=name) + tags.append(tag) + instance.tags.set(tags + list(instance.tags.all())) + return instance + + +class ClassificationForm(forms.ModelForm): + class Meta: + model = Classification + fields = ['notes'] + widgets = { + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + +class PatientManagementForm(forms.ModelForm): + folder = forms.ModelChoiceField( + queryset=Folder.objects.all().order_by('name'), + required=False, + widget=forms.Select(attrs={'class': 'form-select form-select-sm'}), + ) + tags_text = forms.CharField( + required=False, + help_text='Comma-separated tags', + widget=forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'e.g. caseA, urgent'}), + ) + + class Meta: + model = Patient + fields = ['name', 'visibility', 'dataset', 'folder'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'Patient name'}), + 'visibility': forms.Select(attrs={'class': 'form-select form-select-sm'}), + 'dataset': forms.Select(attrs={'class': 'form-select form-select-sm'}), + } + labels = { + 'name': 'Name', + 'visibility': 'Visibility', + 'dataset': 'Dataset', + 'folder': 'Folder', + } + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields['dataset'].empty_label = 'No Dataset' + self.fields['dataset'].required = False + if self.instance and self.instance.pk: + self.fields['tags_text'].initial = ', '.join(self.instance.tag_names()) + + if user and hasattr(user, 'profile'): + if user.profile.is_student_developer(): + self.fields['visibility'].choices = [('debug', 'Debug')] + elif user.profile.is_admin(): + self.fields['visibility'].choices = Patient.VISIBILITY_CHOICES + else: + self.fields['visibility'].choices = [ + ('public', 'Public'), + ('private', 'Private'), + ] + + def clean(self): + cleaned_data = super().clean() + name = cleaned_data.get('name') + if name and len(name.strip()) == 0: + raise forms.ValidationError('Patient name cannot be empty.') + return cleaned_data + + def save(self, commit=True): + instance = super().save(commit) + tags_text = self.cleaned_data.get('tags_text', '') or '' + tag_names = [tag.strip() for tag in tags_text.split(',') if tag.strip()] + if commit: + tags = [] + for name in tag_names: + tag, _ = Tag.objects.get_or_create(name=name) + tags.append(tag) + instance.tags.set(tags) + return instance + + +class DatasetForm(forms.ModelForm): + class Meta: + model = Dataset + fields = ['name', 'description'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control form-control-sm', 'placeholder': 'Dataset name'}), + 'description': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2, 'placeholder': 'Optional description'}), + } diff --git a/laparoscopy/management/__init__.py b/laparoscopy/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laparoscopy/management/commands/__init__.py b/laparoscopy/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laparoscopy/management/commands/setup_laparoscopy_modalities.py b/laparoscopy/management/commands/setup_laparoscopy_modalities.py new file mode 100644 index 0000000..241a122 --- /dev/null +++ b/laparoscopy/management/commands/setup_laparoscopy_modalities.py @@ -0,0 +1,56 @@ +from django.core.management.base import BaseCommand +from common.models import Project, Modality + + +class Command(BaseCommand): + help = 'Create Laparoscopy project and register video modality' + + def handle(self, *args, **options): + project, project_created = Project.objects.get_or_create( + slug='laparoscopy', + defaults={ + 'name': 'Laparoscopy', + 'description': 'Laparoscopic surgery video project', + 'icon': 'fas fa-video', + 'is_active': True, + } + ) + + if project_created: + self.stdout.write(self.style.SUCCESS(f'Created project: {project.name}')) + else: + self.stdout.write(self.style.WARNING(f'Project already exists: {project.name}')) + + modalities_data = [ + { + 'name': 'Video', + 'slug': 'video', + 'description': 'Surgical video recording (.mp4, .avi)', + 'icon': 'fas fa-video', + 'label': 'Video', + 'supported_extensions': ['.mp4', '.avi'], + 'requires_multiple_files': False, + 'is_active': True, + }, + ] + + for modality_data in modalities_data: + modality, created = Modality.objects.get_or_create( + slug=modality_data['slug'], + defaults=modality_data, + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'Created modality: {modality.name}')) + else: + self.stdout.write(self.style.WARNING(f'Modality already exists: {modality.name}')) + for key, value in modality_data.items(): + if key != 'slug': + setattr(modality, key, value) + modality.save() + self.stdout.write(self.style.SUCCESS(f'Updated modality: {modality.name}')) + + project.modalities.add(modality) + self.stdout.write(self.style.SUCCESS(f'Linked {modality.name} to {project.name} project')) + + self.stdout.write(self.style.SUCCESS(f'\nSuccessfully configured Laparoscopy project with {len(modalities_data)} modality')) diff --git a/laparoscopy/migrations/0001_initial.py b/laparoscopy/migrations/0001_initial.py new file mode 100644 index 0000000..8f7cb7b --- /dev/null +++ b/laparoscopy/migrations/0001_initial.py @@ -0,0 +1,182 @@ +# Generated by Django 5.2.4 on 2026-05-04 09:08 + +import django.db.models.deletion +import laparoscopy.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('common', '0023_rename_common_file_domain_22309d_idx_maxillo_fil_domain_760eb4_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Dataset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='laparoscopy_datasets_created', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_dataset', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Folder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='laparoscopy_folders_created', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='laparoscopy.folder')), + ], + options={ + 'db_table': 'laparoscopy_folder', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Patient', + fields=[ + ('patient_id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=100)), + ('upper_scan_raw', models.FileField(blank=True, null=True, upload_to=laparoscopy.models.laparoscopy_scan_upload_path)), + ('lower_scan_raw', models.FileField(blank=True, null=True, upload_to=laparoscopy.models.laparoscopy_scan_upload_path)), + ('upper_scan_norm', models.FileField(blank=True, null=True, upload_to=laparoscopy.models.laparoscopy_normalized_scan_path)), + ('lower_scan_norm', models.FileField(blank=True, null=True, upload_to=laparoscopy.models.laparoscopy_normalized_scan_path)), + ('cbct', models.FileField(blank=True, null=True, upload_to=laparoscopy.models.laparoscopy_cbct_upload_path)), + ('ios_processing_status', models.CharField(choices=[('not_uploaded', 'Not Uploaded'), ('processing', 'Processing'), ('processed', 'Processed'), ('failed', 'Processing Failed')], default='not_uploaded', help_text='Processing status for intra-oral scans (upper and lower)', max_length=20)), + ('cbct_processing_status', models.CharField(choices=[('not_uploaded', 'Not Uploaded'), ('processing', 'Processing'), ('processed', 'Processed'), ('failed', 'Processing Failed')], default='not_uploaded', help_text='Processing status for CBCT scan', max_length=20)), + ('visibility', models.CharField(choices=[('public', 'Public'), ('private', 'Private'), ('debug', 'Debug')], default='private', max_length=10)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('dataset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patients', to='laparoscopy.dataset')), + ('folder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patients', to='laparoscopy.folder')), + ('modalities', models.ManyToManyField(blank=True, help_text='Modalities available for this patient', related_name='laparoscopy_patients', to='common.modality')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='laparoscopy_patients_uploaded', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_patient', + 'ordering': ['-uploaded_at'], + }, + ), + migrations.CreateModel( + name='Classification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('classifier', models.CharField(choices=[('manual', 'Manual'), ('pipeline', 'Pipeline')], default='manual', max_length=10)), + ('notes', models.TextField(blank=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('annotator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='laparoscopy_classifications_authored', to=settings.AUTH_USER_MODEL)), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='classifications', to='laparoscopy.patient')), + ], + options={ + 'db_table': 'laparoscopy_classification', + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'laparoscopy_tag', + 'ordering': ['name'], + 'indexes': [models.Index(fields=['name'], name='laparoscopy_name_667528_idx')], + }, + ), + migrations.AddField( + model_name='patient', + name='tags', + field=models.ManyToManyField(blank=True, related_name='patients', to='laparoscopy.tag'), + ), + migrations.CreateModel( + name='VoiceCaption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modality', models.CharField(blank=True, default='', max_length=255)), + ('duration', models.FloatField(help_text='Duration of audio recording in seconds')), + ('text_caption', models.TextField(blank=True, null=True)), + ('original_text_caption', models.TextField(blank=True, null=True)), + ('is_edited', models.BooleanField(default=False)), + ('edit_history', models.JSONField(blank=True, default=list)), + ('processing_status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='voice_captions', to='laparoscopy.patient')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_voice_captions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_voicecaption', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='folder', + index=models.Index(fields=['parent'], name='laparoscopy_parent__eabfd0_idx'), + ), + migrations.AddIndex( + model_name='folder', + index=models.Index(fields=['name'], name='laparoscopy_name_f7cfea_idx'), + ), + migrations.AlterUniqueTogether( + name='folder', + unique_together={('name', 'parent')}, + ), + migrations.AddIndex( + model_name='classification', + index=models.Index(fields=['patient', 'classifier'], name='laparoscopy_patient_fcac41_idx'), + ), + migrations.AddIndex( + model_name='classification', + index=models.Index(fields=['classifier'], name='laparoscopy_classif_24566b_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['visibility'], name='laparoscopy_visibil_bee2f8_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['uploaded_at'], name='laparoscopy_uploade_9a3089_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['folder'], name='laparoscopy_folder__f9826a_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['name'], name='laparoscopy_name_f7349e_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['visibility', 'uploaded_at'], name='laparoscopy_visibil_73d22a_idx'), + ), + migrations.AddIndex( + model_name='patient', + index=models.Index(fields=['folder', 'visibility'], name='laparoscopy_folder__db1089_idx'), + ), + migrations.AddIndex( + model_name='voicecaption', + index=models.Index(fields=['patient', 'processing_status'], name='laparoscopy_patient_74be01_idx'), + ), + migrations.AddIndex( + model_name='voicecaption', + index=models.Index(fields=['processing_status'], name='laparoscopy_process_f264f0_idx'), + ), + migrations.AddIndex( + model_name='voicecaption', + index=models.Index(fields=['user'], name='laparoscopy_user_id_38a7e2_idx'), + ), + ] diff --git a/laparoscopy/migrations/0002_quadranttype_regiontype_quadrantclassificationmarker_and_more.py b/laparoscopy/migrations/0002_quadranttype_regiontype_quadrantclassificationmarker_and_more.py new file mode 100644 index 0000000..bc9bb48 --- /dev/null +++ b/laparoscopy/migrations/0002_quadranttype_regiontype_quadrantclassificationmarker_and_more.py @@ -0,0 +1,114 @@ +# Generated by Django 5.2.4 on 2026-05-05 12:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0024_fileregistry_laparoscopy_patient_and_more'), + ('laparoscopy', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='QuadrantType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('color', models.CharField(default='#e74c3c', max_length=7)), + ('order', models.PositiveIntegerField(default=0)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_quadrant_types', to='common.project')), + ], + options={ + 'db_table': 'laparoscopy_quadranttype', + 'ordering': ['order', 'name'], + 'unique_together': {('project', 'name')}, + }, + ), + migrations.CreateModel( + name='RegionType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('color', models.CharField(default='#3498db', max_length=7)), + ('order', models.PositiveIntegerField(default=0)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_region_types', to='common.project')), + ], + options={ + 'db_table': 'laparoscopy_regiontype', + 'ordering': ['order', 'name'], + 'unique_together': {('project', 'name')}, + }, + ), + migrations.CreateModel( + name='QuadrantClassificationMarker', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time_ms', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_laparoscopy_quadrant_markers', to=settings.AUTH_USER_MODEL)), + ('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quadrant_markers', to='laparoscopy.patient')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_laparoscopy_quadrant_markers', to=settings.AUTH_USER_MODEL)), + ('quadrant_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='markers', to='laparoscopy.quadranttype')), + ], + options={ + 'db_table': 'laparoscopy_quadrantclassificationmarker', + 'ordering': ['time_ms', 'id'], + 'indexes': [models.Index(fields=['patient', 'time_ms'], name='laparoscopy_patient_e98295_idx'), models.Index(fields=['patient', 'quadrant_type'], name='laparoscopy_patient_0b0895_idx')], + 'constraints': [models.UniqueConstraint(fields=('patient', 'time_ms'), name='laparo_unique_patient_quadrant_marker_time')], + }, + ), + migrations.CreateModel( + name='QuadrantTypeUserColor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('color', models.CharField(max_length=7)), + ('quadrant_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_colors', to='laparoscopy.quadranttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_quadrant_colors', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_quadranttypeusercolor', + 'unique_together': {('quadrant_type', 'user')}, + }, + ), + migrations.CreateModel( + name='RegionAnnotation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tool', models.CharField(choices=[('brush', 'Brush'), ('eraser', 'Eraser'), ('polygon', 'Polygon')], max_length=20)), + ('frame_time', models.FloatField(default=0.0)), + ('points', models.JSONField(default=list)), + ('prompt_points', models.JSONField(blank=True, default=list)), + ('stroke_width', models.FloatField(default=1.0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_laparoscopy_annotations', to=settings.AUTH_USER_MODEL)), + ('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='region_annotations', to='laparoscopy.patient')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_laparoscopy_annotations', to=settings.AUTH_USER_MODEL)), + ('region_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='annotations', to='laparoscopy.regiontype')), + ], + options={ + 'db_table': 'laparoscopy_regionannotation', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['patient', 'frame_time'], name='laparoscopy_patient_f56b1e_idx'), models.Index(fields=['patient', 'region_type'], name='laparoscopy_patient_728243_idx')], + }, + ), + migrations.CreateModel( + name='RegionTypeUserColor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('color', models.CharField(max_length=7)), + ('region_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_colors', to='laparoscopy.regiontype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_region_colors', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_regiontypeusercolor', + 'unique_together': {('region_type', 'user')}, + }, + ), + ] diff --git a/laparoscopy/migrations/0003_export.py b/laparoscopy/migrations/0003_export.py new file mode 100644 index 0000000..9101352 --- /dev/null +++ b/laparoscopy/migrations/0003_export.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.4 on 2026-05-11 10:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('laparoscopy', '0002_quadranttype_regiontype_quadrantclassificationmarker_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Export', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('query_params', models.JSONField(default=dict, help_text='Stores folder_ids, modality_slugs, and filters')), + ('query_summary', models.CharField(blank=True, help_text='Human-readable query summary', max_length=500)), + ('file_path', models.CharField(blank=True, help_text='Path to generated ZIP file', max_length=1000)), + ('file_size', models.BigIntegerField(default=0, help_text='Size of export file in bytes')), + ('patient_count', models.IntegerField(default=0, help_text='Number of patients in export')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, help_text='When processing started', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When processing completed', null=True)), + ('error_message', models.TextField(blank=True, help_text='Error message if export failed')), + ('share_mode', models.CharField(choices=[('private', 'Private'), ('authenticated', 'Any logged-in user'), ('public', 'Anyone with link')], default='private', help_text='Controls who can access the share link', max_length=20)), + ('share_token', models.CharField(blank=True, help_text='Random token used for share link access', max_length=64, null=True, unique=True)), + ('shared_at', models.DateTimeField(blank=True, help_text='When sharing was last enabled', null=True)), + ('progress_message', models.CharField(blank=True, help_text='Current phase or progress text', max_length=255)), + ('progress_percent', models.IntegerField(blank=True, help_text='Progress 0-100', null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='laparoscopy_exports', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'laparoscopy_export', + 'ordering': ['-created_at'], + 'indexes': [ + models.Index(fields=['user', 'status'], name='laparoscop_user_id_3b9fd5_idx'), + models.Index(fields=['status', 'created_at'], name='laparoscop_status_634320_idx'), + ], + }, + ), + ] diff --git a/laparoscopy/migrations/0004_rename_laparoscop_user_id_3b9fd5_idx_laparoscopy_user_id_2fb695_idx_and_more.py b/laparoscopy/migrations/0004_rename_laparoscop_user_id_3b9fd5_idx_laparoscopy_user_id_2fb695_idx_and_more.py new file mode 100644 index 0000000..eb2e001 --- /dev/null +++ b/laparoscopy/migrations/0004_rename_laparoscop_user_id_3b9fd5_idx_laparoscopy_user_id_2fb695_idx_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2026-05-11 18:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('laparoscopy', '0003_export'), + ] + + operations = [ + migrations.RenameIndex( + model_name='export', + new_name='laparoscopy_user_id_2fb695_idx', + old_name='laparoscop_user_id_3b9fd5_idx', + ), + migrations.RenameIndex( + model_name='export', + new_name='laparoscopy_status_1d47e2_idx', + old_name='laparoscop_status_634320_idx', + ), + ] diff --git a/laparoscopy/migrations/__init__.py b/laparoscopy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laparoscopy/models.py b/laparoscopy/models.py new file mode 100644 index 0000000..675c016 --- /dev/null +++ b/laparoscopy/models.py @@ -0,0 +1,713 @@ +import secrets + +from django.contrib.auth.models import User +from django.db import models +from django.utils import timezone + +import logging + +from common.models import Modality + + +logger = logging.getLogger(__name__) + + +def laparoscopy_scan_upload_path(instance, filename): + return f"laparoscopy/patient_{instance.patient_id}/raw/{filename}" + + +def laparoscopy_normalized_scan_path(instance, filename): + return f"laparoscopy/patient_{instance.patient_id}/normalized/{filename}" + + +def laparoscopy_cbct_upload_path(instance, filename): + return f"laparoscopy/patient_{instance.patient_id}/cbct/{filename}" + + +class ActivePatientManager(models.Manager): + """Default manager that hides soft-deleted patients.""" + + def get_queryset(self): + return super().get_queryset().filter(deleted=False) + + +class Dataset(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='laparoscopy_datasets_created', + ) + + class Meta: + db_table = 'laparoscopy_dataset' + ordering = ['name'] + + def __str__(self): + return self.name + + +class Folder(models.Model): + name = models.CharField(max_length=100) + parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='laparoscopy_folders_created', + ) + + class Meta: + db_table = 'laparoscopy_folder' + unique_together = ('name', 'parent') + ordering = ['name'] + indexes = [ + models.Index(fields=['parent']), + models.Index(fields=['name']), + ] + + def __str__(self): + return self.get_full_path() + + def get_full_path(self): + parts = [] + node = self + while node: + parts.append(node.name) + node = node.parent + return '/'.join(reversed(parts)) + + +class Tag(models.Model): + name = models.CharField(max_length=50, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'laparoscopy_tag' + ordering = ['name'] + indexes = [ + models.Index(fields=['name']), + ] + + def __str__(self): + return self.name + + +class Patient(models.Model): + VISIBILITY_CHOICES = [ + ('public', 'Public'), + ('private', 'Private'), + ('debug', 'Debug'), + ] + + PROCESSING_STATUS_CHOICES = [ + ('not_uploaded', 'Not Uploaded'), + ('processing', 'Processing'), + ('processed', 'Processed'), + ('failed', 'Processing Failed'), + ] + + patient_id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100, blank=True) + dataset = models.ForeignKey(Dataset, on_delete=models.SET_NULL, null=True, blank=True, related_name='patients') + modalities = models.ManyToManyField( + Modality, + blank=True, + related_name='laparoscopy_patients', + help_text='Modalities available for this patient', + ) + folder = models.ForeignKey('Folder', on_delete=models.SET_NULL, null=True, blank=True, related_name='patients') + tags = models.ManyToManyField('Tag', blank=True, related_name='patients') + + upper_scan_raw = models.FileField(upload_to=laparoscopy_scan_upload_path, blank=True, null=True) + lower_scan_raw = models.FileField(upload_to=laparoscopy_scan_upload_path, blank=True, null=True) + upper_scan_norm = models.FileField(upload_to=laparoscopy_normalized_scan_path, blank=True, null=True) + lower_scan_norm = models.FileField(upload_to=laparoscopy_normalized_scan_path, blank=True, null=True) + cbct = models.FileField(upload_to=laparoscopy_cbct_upload_path, blank=True, null=True) + + ios_processing_status = models.CharField( + max_length=20, + choices=PROCESSING_STATUS_CHOICES, + default='not_uploaded', + help_text='Processing status for intra-oral scans (upper and lower)', + ) + cbct_processing_status = models.CharField( + max_length=20, + choices=PROCESSING_STATUS_CHOICES, + default='not_uploaded', + help_text='Processing status for CBCT scan', + ) + + visibility = models.CharField(max_length=10, choices=VISIBILITY_CHOICES, default='private') + deleted = models.BooleanField(default=False, db_index=True) + uploaded_at = models.DateTimeField(auto_now_add=True) + uploaded_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='laparoscopy_patients_uploaded', + ) + + objects = ActivePatientManager() + all_objects = models.Manager() + + class Meta: + db_table = 'laparoscopy_patient' + ordering = ['-uploaded_at'] + indexes = [ + models.Index(fields=['visibility']), + models.Index(fields=['uploaded_at']), + models.Index(fields=['folder']), + models.Index(fields=['name']), + models.Index(fields=['visibility', 'uploaded_at']), + models.Index(fields=['folder', 'visibility']), + ] + + def __str__(self): + return f"Patient {self.patient_id} - {self.name}" + + @property + def project(self): + from common.models import Project + return Project.objects.filter(slug='laparoscopy').first() + + @property + def project_id(self): + project = self.project + return project.id if project else None + + @property + def files(self): + from common.models import FileRegistry + return FileRegistry.objects.filter(domain='laparoscopy', laparoscopy_patient=self) + + @property + def processing_jobs(self): + from common.models import ProcessingJob + return ProcessingJob.objects.filter(domain='laparoscopy', laparoscopy_patient=self) + + def tag_names(self): + return list(self.tags.values_list('name', flat=True)) + + def save(self, *args, **kwargs): + creating = self._state.adding + if self.upper_scan_raw and self.lower_scan_raw and self.ios_processing_status == 'not_uploaded': + self.ios_processing_status = 'processing' + if self.cbct and self.cbct_processing_status == 'not_uploaded': + self.cbct_processing_status = 'processing' + + super().save(*args, **kwargs) + + if creating and (self.name is None or self.name.strip() == ''): + self.name = f"Patient {self.patient_id}" + super().save(update_fields=['name']) + + def has_ios_scans(self): + if self.upper_scan_raw and self.lower_scan_raw: + return True + try: + upper_raw = self.files.filter(file_type='ios_raw_upper').exists() + lower_raw = self.files.filter(file_type='ios_raw_lower').exists() + upper_processed = self.files.filter(file_type='ios_processed_upper').exists() + lower_processed = self.files.filter(file_type='ios_processed_lower').exists() + return (upper_raw or upper_processed) and (lower_raw or lower_processed) + except Exception as exc: + logger.error('Error checking IOS files for laparoscopy patient %s: %s', self.patient_id, exc, exc_info=True) + return False + + def has_cbct_scan(self): + if self.cbct: + return True + try: + has_raw = self.files.filter(file_type='cbct_raw').exists() + has_processed = self.files.filter(file_type='cbct_processed').exists() + return has_raw or has_processed + except Exception as exc: + logger.error('Error checking CBCT files for laparoscopy patient %s: %s', self.patient_id, exc, exc_info=True) + return False + + def has_video(self): + try: + return self.files.filter(file_type='video_raw').exists() + except Exception as exc: + logger.error('Error checking video files for laparoscopy patient %s: %s', self.patient_id, exc, exc_info=True) + return False + + def is_ios_processed(self): + return self.ios_processing_status == 'processed' + + def is_cbct_processed(self): + return self.cbct_processing_status == 'processed' + + def has_rgb_images(self): + try: + return self.files.filter(file_type='rgb_image').exists() + except Exception as exc: + logger.error('Error checking RGB files for laparoscopy patient %s: %s', self.patient_id, exc, exc_info=True) + return False + + def get_rgb_images(self): + return self.files.filter(file_type='rgb_image').order_by('-created_at') + + def get_raw_files(self): + return self.files.filter(file_type__in=['video_raw', 'audio_raw']) + + def get_processed_files(self): + return self.files.filter(file_type__in=['video_processed', 'audio_processed']) + + def get_cbct_raw_file(self): + from common.models import FileRegistry + try: + return self.files.get(file_type='cbct_raw') + except FileRegistry.DoesNotExist: + return None + + def get_cbct_processed_file(self): + from common.models import FileRegistry + try: + return self.files.get(file_type='cbct_processed') + except FileRegistry.DoesNotExist: + return None + + def get_ios_raw_files(self): + from common.models import FileRegistry + upper = None + lower = None + try: + upper = self.files.get(file_type='ios_raw_upper') + except FileRegistry.DoesNotExist: + pass + try: + lower = self.files.get(file_type='ios_raw_lower') + except FileRegistry.DoesNotExist: + pass + return {'upper': upper, 'lower': lower} + + def get_ios_processed_files(self): + from common.models import FileRegistry + upper = None + lower = None + try: + upper = self.files.get(file_type='ios_processed_upper') + except FileRegistry.DoesNotExist: + pass + try: + lower = self.files.get(file_type='ios_processed_lower') + except FileRegistry.DoesNotExist: + pass + return {'upper': upper, 'lower': lower} + + +class Classification(models.Model): + CLASSIFIER_CHOICES = [ + ('manual', 'Manual'), + ('pipeline', 'Pipeline'), + ] + + patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name='classifications', null=True, blank=True) + classifier = models.CharField(max_length=10, choices=CLASSIFIER_CHOICES, default='manual') + notes = models.TextField(blank=True) + annotator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='laparoscopy_classifications_authored', + ) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'laparoscopy_classification' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['patient', 'classifier']), + models.Index(fields=['classifier']), + ] + + def __str__(self): + return f"Classification {self.id} - {self.get_classifier_display()}" + + +class RegionType(models.Model): + project = models.ForeignKey( + 'common.Project', on_delete=models.CASCADE, related_name="laparoscopy_region_types" + ) + name = models.CharField(max_length=100) + color = models.CharField(max_length=7, default="#3498db") + order = models.PositiveIntegerField(default=0) + + class Meta: + db_table = 'laparoscopy_regiontype' + ordering = ["order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return f"{self.project} / {self.name}" + + +class QuadrantType(models.Model): + project = models.ForeignKey( + 'common.Project', on_delete=models.CASCADE, related_name="laparoscopy_quadrant_types" + ) + name = models.CharField(max_length=100) + color = models.CharField(max_length=7, default="#e74c3c") + order = models.PositiveIntegerField(default=0) + + class Meta: + db_table = 'laparoscopy_quadranttype' + ordering = ["order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return f"{self.project} / {self.name}" + + +class RegionTypeUserColor(models.Model): + region_type = models.ForeignKey( + RegionType, on_delete=models.CASCADE, related_name="user_colors" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="laparoscopy_region_colors" + ) + color = models.CharField(max_length=7) + + class Meta: + db_table = 'laparoscopy_regiontypeusercolor' + unique_together = ("region_type", "user") + + +class QuadrantTypeUserColor(models.Model): + quadrant_type = models.ForeignKey( + QuadrantType, on_delete=models.CASCADE, related_name="user_colors" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="laparoscopy_quadrant_colors" + ) + color = models.CharField(max_length=7) + + class Meta: + db_table = 'laparoscopy_quadranttypeusercolor' + unique_together = ("quadrant_type", "user") + + +class QuadrantClassificationMarker(models.Model): + patient = models.ForeignKey( + 'laparoscopy.Patient', + on_delete=models.CASCADE, + related_name="quadrant_markers", + ) + quadrant_type = models.ForeignKey( + QuadrantType, on_delete=models.CASCADE, related_name="markers" + ) + time_ms = models.PositiveIntegerField(default=0) + created_by = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, + related_name="created_laparoscopy_quadrant_markers" + ) + updated_by = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, + related_name="updated_laparoscopy_quadrant_markers" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'laparoscopy_quadrantclassificationmarker' + ordering = ["time_ms", "id"] + constraints = [ + models.UniqueConstraint( + fields=["patient", "time_ms"], + name="laparo_unique_patient_quadrant_marker_time", + ) + ] + indexes = [ + models.Index(fields=["patient", "time_ms"]), + models.Index(fields=["patient", "quadrant_type"]), + ] + + def __str__(self): + return f"Marker {self.id} patient {self.patient_id} @ {self.time_ms}ms" + + +class RegionAnnotation(models.Model): + TOOL_CHOICES = [ + ("brush", "Brush"), + ("eraser", "Eraser"), + ("polygon", "Polygon"), + ] + + patient = models.ForeignKey( + 'laparoscopy.Patient', + on_delete=models.CASCADE, + related_name="region_annotations", + ) + region_type = models.ForeignKey( + RegionType, on_delete=models.CASCADE, related_name="annotations" + ) + tool = models.CharField(max_length=20, choices=TOOL_CHOICES) + frame_time = models.FloatField(default=0.0) + points = models.JSONField(default=list) + prompt_points = models.JSONField(default=list, blank=True) + stroke_width = models.FloatField(default=1.0) + created_by = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, + related_name="created_laparoscopy_annotations" + ) + updated_by = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, + related_name="updated_laparoscopy_annotations" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'laparoscopy_regionannotation' + ordering = ["created_at"] + indexes = [ + models.Index(fields=["patient", "frame_time"]), + models.Index(fields=["patient", "region_type"]), + ] + + def __str__(self): + return f"Annotation {self.id} ({self.tool}) on patient {self.patient_id}" + + +class VoiceCaption(models.Model): + PROCESSING_STATUS_CHOICES = [ + ('pending', 'Pending'), + ('processing', 'Processing'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ] + + patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name='voice_captions', null=True, blank=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='laparoscopy_voice_captions') + modality = models.CharField(max_length=255, default='', blank=True) + duration = models.FloatField(help_text='Duration of audio recording in seconds') + text_caption = models.TextField(blank=True, null=True) + original_text_caption = models.TextField(blank=True, null=True) + is_edited = models.BooleanField(default=False) + edit_history = models.JSONField(default=list, blank=True) + processing_status = models.CharField(max_length=20, choices=PROCESSING_STATUS_CHOICES, default='pending') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'laparoscopy_voicecaption' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['patient', 'processing_status']), + models.Index(fields=['processing_status']), + models.Index(fields=['user']), + ] + + def __str__(self): + return f"VoiceCaption {self.id} - {self.patient_id}" + + @property + def files(self): + from common.models import FileRegistry + return FileRegistry.objects.filter(domain='laparoscopy', laparoscopy_voice_caption=self) + + @property + def processing_jobs(self): + from common.models import ProcessingJob + return ProcessingJob.objects.filter(domain='laparoscopy', laparoscopy_voice_caption=self) + + def get_modality_display(self): + try: + if not self.modality: + return 'Undefined' + mod = Modality.objects.filter(slug=self.modality).first() + if mod: + return getattr(mod, 'label', '') or getattr(mod, 'name', '') or self.modality.upper() + return self.modality.upper() + except Exception: + return (self.modality or 'Undefined').upper() + + def get_display_duration(self): + if self.duration == 0: + return 'Text' + minutes = int(self.duration // 60) + seconds = int(self.duration % 60) + if minutes > 0: + return f"{minutes}:{seconds:02d}" + return f"{seconds}s" + + def get_quality_status(self): + if self.duration == 0: + return {'color': 'success', 'message': 'Text'} + if self.duration <= 30: + return {'color': 'danger', 'message': 'Short'} + if self.duration <= 45: + return {'color': 'warning', 'message': 'Good'} + return {'color': 'success', 'message': 'Perfect'} + + def is_processed(self): + if self.duration == 0: + return self.processing_status == 'completed' and self.text_caption + return self.processing_status == 'completed' and self.text_caption and self.text_caption != '[Audio processed but no transcription available]' + + def get_processing_display_text(self): + if self.processing_status == 'completed': + if self.text_caption and self.text_caption != '[Audio processed but no transcription available]': + return self.text_caption + return '[Audio processed but no transcription available]' + if self.processing_status == 'processing': + return 'Converting speech to text...' + if self.processing_status == 'failed': + return 'Processing failed' + return 'Preprocessing audio...' + + def get_display_text_caption(self): + if self.is_processed(): + text = self.text_caption + if self.is_edited: + text += ' [edited]' + return text + return self.get_processing_display_text() + + def save_original_transcription(self): + if self.text_caption and not self.original_text_caption: + self.original_text_caption = self.text_caption + + def edit_transcription(self, new_text, user): + if not self.is_processed(): + raise ValueError('Cannot edit transcription that is not yet processed') + if not self.original_text_caption: + self.original_text_caption = self.text_caption + edit_record = { + 'timestamp': timezone.now().isoformat(), + 'user_id': user.id, + 'username': user.username, + 'previous_text': self.text_caption, + 'new_text': new_text, + } + if not self.edit_history: + self.edit_history = [] + self.edit_history.append(edit_record) + self.text_caption = new_text + self.is_edited = True + self.save() + + def revert_to_original(self, user): + if not self.original_text_caption: + raise ValueError('No original transcription to revert to') + revert_record = { + 'timestamp': timezone.now().isoformat(), + 'user_id': user.id, + 'username': user.username, + 'action': 'reverted_to_original', + 'previous_text': self.text_caption, + 'reverted_text': self.original_text_caption, + } + if not self.edit_history: + self.edit_history = [] + self.edit_history.append(revert_record) + self.text_caption = self.original_text_caption + self.is_edited = False + self.save() + + def get_audio_file(self): + from common.models import FileRegistry + try: + return self.files.get(file_type='audio_raw') + except FileRegistry.DoesNotExist: + return None + + def get_processed_text_file(self): + from common.models import FileRegistry + try: + return self.files.get(file_type='audio_processed') + except FileRegistry.DoesNotExist: + return None + + def get_pending_jobs(self): + return self.processing_jobs.filter(status__in=['pending', 'processing', 'retrying']) + + +class Export(models.Model): + SHARE_MODE_CHOICES = [ + ('private', 'Private'), + ('authenticated', 'Any logged-in user'), + ('public', 'Anyone with link'), + ] + + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('processing', 'Processing'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='laparoscopy_exports') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + query_params = models.JSONField(default=dict, help_text='Stores folder_ids, modality_slugs, and filters') + query_summary = models.CharField(max_length=500, blank=True, help_text='Human-readable query summary') + file_path = models.CharField(max_length=1000, blank=True, help_text='Path to generated ZIP file') + file_size = models.BigIntegerField(default=0, help_text='Size of export file in bytes') + patient_count = models.IntegerField(default=0, help_text='Number of patients in export') + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True, help_text='When processing started') + completed_at = models.DateTimeField(null=True, blank=True, help_text='When processing completed') + error_message = models.TextField(blank=True, help_text='Error message if export failed') + share_mode = models.CharField( + max_length=20, + choices=SHARE_MODE_CHOICES, + default='private', + help_text='Controls who can access the share link', + ) + share_token = models.CharField( + max_length=64, + unique=True, + null=True, + blank=True, + help_text='Random token used for share link access', + ) + shared_at = models.DateTimeField(null=True, blank=True, help_text='When sharing was last enabled') + progress_message = models.CharField(max_length=255, blank=True, help_text='Current phase or progress text') + progress_percent = models.IntegerField(null=True, blank=True, help_text='Progress 0-100') + + class Meta: + db_table = 'laparoscopy_export' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'status']), + models.Index(fields=['status', 'created_at']), + ] + + def __str__(self): + return f"Export {self.id} - {self.get_status_display()}" + + def mark_processing(self): + self.status = 'processing' + self.started_at = timezone.now() + self.save() + + def mark_completed(self, file_path=None, file_size=None): + self.status = 'completed' + self.completed_at = timezone.now() + self.progress_message = '' + self.progress_percent = None + if file_path: + self.file_path = file_path + if file_size is not None: + self.file_size = file_size + self.save() + + def mark_failed(self, error_message=''): + self.status = 'failed' + self.completed_at = timezone.now() + self.error_message = error_message + self.save() + + def ensure_share_token(self, force_new=False): + if force_new or not self.share_token: + self.share_token = secrets.token_urlsafe(32) + self.save(update_fields=['share_token']) + return self.share_token diff --git a/laparoscopy/tests.py b/laparoscopy/tests.py new file mode 100644 index 0000000..e1379cc --- /dev/null +++ b/laparoscopy/tests.py @@ -0,0 +1,286 @@ +import io +import json +import shutil +import tempfile +import zipfile +from contextlib import contextmanager +from unittest.mock import patch + +import numpy as np +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test import TestCase +from django.urls import reverse + +from common.models import FileRegistry, Modality, Project, ProjectAccess +from laparoscopy.export_processor import LaparoscopyExportProcessor +from laparoscopy.models import Export, Folder, Patient, RegionAnnotation, RegionType +from maxillo.models import Export as MaxilloExport + + +@contextmanager +def _yield_path(path, suffix=""): + del suffix + yield path + + +class _FakeStorage: + def __init__(self): + fd, self.uploaded_path = tempfile.mkstemp(prefix="lap_export_", suffix=".zip") + self.uploaded_key = None + self.last_metadata = None + self._closed = False + self._fd = fd + self.close() + + def upload_file(self, local_path, *, key, content_type=None, metadata=None): + del content_type + self.uploaded_key = key + self.last_metadata = metadata or {} + shutil.copyfile(local_path, self.uploaded_path) + + def close(self): + if self._closed: + return + self._closed = True + try: + if self._fd is not None: + import os + + os.close(self._fd) + except OSError: + pass + self._fd = None + + def cleanup(self): + self.close() + try: + import os + + os.remove(self.uploaded_path) + except OSError: + pass + + +class LaparoscopyExportBaseTestCase(TestCase): + def setUp(self): + super().setUp() + self.user = User.objects.create_user(username="lap-admin", password="pw") + self.project = Project.objects.create(name="laparoscopy", slug="laparoscopy") + self.video_modality = Modality.objects.create(name="Video", slug="video") + self.project.modalities.add(self.video_modality) + ProjectAccess.objects.create(user=self.user, project=self.project, role="admin") + self.folder = Folder.objects.create(name="Case Batch") + + def create_patient(self, name="Patient", folder=None): + return Patient.objects.create(name=name, folder=folder or self.folder) + + def create_subsampled_video(self, patient, file_path=None, file_size=128): + return FileRegistry.objects.create( + domain="laparoscopy", + file_type="video_processed", + subtype="subsampled", + modality=self.video_modality, + laparoscopy_patient=patient, + file_path=file_path or f"laparoscopy/patient_{patient.patient_id}/subsampled.mp4", + file_size=file_size, + file_hash=f"hash-{patient.patient_id}-{file_size}", + ) + + def create_export(self): + return Export.objects.create( + user=self.user, + status="pending", + query_params={ + "domain": "laparoscopy", + "export_variant": "video_masks_v1", + "folder_ids": [self.folder.id], + "mask_format": "npz_multilayer", + "include_all_frames": True, + "video_subtype": "subsampled", + }, + query_summary="laparoscopy export", + ) + + def create_region_type(self, name, order): + return RegionType.objects.create( + project=self.project, + name=name, + color="#00aa00" if order == 0 else "#aa0000", + order=order, + ) + + +class LaparoscopyExportViewTests(LaparoscopyExportBaseTestCase): + def setUp(self): + super().setUp() + self.client.force_login(self.user) + + def test_export_preview_counts_only_patients_with_subsampled_video(self): + self.create_subsampled_video(self.create_patient(name="Exportable"), file_size=321) + self.create_patient(name="MissingVideo") + + response = self.client.post( + reverse("laparoscopy:export_preview"), + data=json.dumps({"folder_ids": [self.folder.id]}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["success"]) + self.assertEqual(payload["patient_count"], 2) + self.assertEqual(payload["exportable_patient_count"], 1) + self.assertEqual(payload["file_count"], 1) + self.assertEqual(payload["estimated_size_bytes"], 321) + + def test_export_list_is_filtered_to_laparoscopy_exports(self): + lap_export = Export.objects.create( + user=self.user, + status="pending", + query_params={"folder_ids": [self.folder.id]}, + query_summary="lap", + ) + MaxilloExport.objects.create( + user=self.user, + status="pending", + query_params={"folder_ids": [999]}, + query_summary="maxillo", + ) + + response = self.client.get(reverse("laparoscopy:export_list")) + + self.assertEqual(response.status_code, 200) + exports = [item["export"].id for item in response.context["page_obj"].object_list] + self.assertEqual(exports, [lap_export.id]) + + +class LaparoscopyExportProcessorTests(LaparoscopyExportBaseTestCase): + def _run_processor(self, export, local_video_path, probe_result): + storage = _FakeStorage() + self.addCleanup(storage.cleanup) + + with patch("laparoscopy.export_processor.download_to_tempfile", side_effect=lambda key, suffix="": _yield_path(local_video_path, suffix)): + with patch("laparoscopy.export_processor.get_object_storage", return_value=storage): + with patch.object(LaparoscopyExportProcessor, "_probe_video", return_value=probe_result): + processor = LaparoscopyExportProcessor(export) + processor.process_export() + + export.refresh_from_db() + self.assertEqual(export.status, "completed") + return storage.uploaded_path + + def test_processor_exports_all_frames_and_preserves_overlaps(self): + patient = self.create_patient(name="Overlap Case") + self.create_subsampled_video(patient) + region_a = self.create_region_type("Region A", 0) + region_b = self.create_region_type("Region B", 1) + RegionAnnotation.objects.create( + patient=patient, + region_type=region_a, + tool="polygon", + frame_time=0.0, + points=[1, 1, 6, 1, 6, 6, 1, 6], + stroke_width=1.0, + created_by=self.user, + updated_by=self.user, + ) + RegionAnnotation.objects.create( + patient=patient, + region_type=region_b, + tool="polygon", + frame_time=0.0, + points=[2, 2, 5, 2, 5, 5, 2, 5], + stroke_width=1.0, + created_by=self.user, + updated_by=self.user, + ) + export = self.create_export() + + with tempfile.NamedTemporaryFile(suffix=".mp4") as video_file: + video_file.write(b"fake-video") + video_file.flush() + uploaded_zip_path = self._run_processor( + export, + local_video_path=video_file.name, + probe_result={"width": 8, "height": 8, "fps": 1.0, "frame_count": 2}, + ) + + with zipfile.ZipFile(uploaded_zip_path) as zipf: + manifest = json.loads(zipf.read("manifest.json").decode("utf-8")) + patient_folder = manifest["patients"][0]["zip_video_path"].split("/video/")[0] + + with np.load(io.BytesIO(zipf.read(f"{patient_folder}/masks/frame_000000.npz"))) as frame_zero: + masks_zero = frame_zero["masks"] + with np.load(io.BytesIO(zipf.read(f"{patient_folder}/masks/frame_000001.npz"))) as frame_one: + masks_one = frame_one["masks"] + + self.assertEqual(masks_zero.shape, (2, 8, 8)) + self.assertEqual(int(masks_zero[0, 3, 3]), 1) + self.assertEqual(int(masks_zero[1, 3, 3]), 1) + self.assertEqual(int(masks_one.sum()), 0) + + def test_processor_eraser_only_clears_its_own_layer(self): + patient = self.create_patient(name="Eraser Case") + self.create_subsampled_video(patient) + region_a = self.create_region_type("Region A", 0) + region_b = self.create_region_type("Region B", 1) + RegionAnnotation.objects.create( + patient=patient, + region_type=region_a, + tool="polygon", + frame_time=0.0, + points=[1, 1, 6, 1, 6, 6, 1, 6], + stroke_width=1.0, + created_by=self.user, + updated_by=self.user, + ) + RegionAnnotation.objects.create( + patient=patient, + region_type=region_b, + tool="polygon", + frame_time=0.0, + points=[1, 1, 6, 1, 6, 6, 1, 6], + stroke_width=1.0, + created_by=self.user, + updated_by=self.user, + ) + RegionAnnotation.objects.create( + patient=patient, + region_type=region_a, + tool="eraser", + frame_time=0.0, + points=[1, 4, 6, 4], + stroke_width=3.0, + created_by=self.user, + updated_by=self.user, + ) + export = self.create_export() + + with tempfile.NamedTemporaryFile(suffix=".mp4") as video_file: + video_file.write(b"fake-video") + video_file.flush() + uploaded_zip_path = self._run_processor( + export, + local_video_path=video_file.name, + probe_result={"width": 8, "height": 8, "fps": 1.0, "frame_count": 1}, + ) + + with zipfile.ZipFile(uploaded_zip_path) as zipf: + manifest = json.loads(zipf.read("manifest.json").decode("utf-8")) + patient_folder = manifest["patients"][0]["zip_video_path"].split("/video/")[0] + with np.load(io.BytesIO(zipf.read(f"{patient_folder}/masks/frame_000000.npz"))) as frame_zero: + masks_zero = frame_zero["masks"] + + self.assertEqual(int(masks_zero[0, 4, 4]), 0) + self.assertEqual(int(masks_zero[1, 4, 4]), 1) + + def test_run_export_command_accepts_laparoscopy_domain(self): + export = self.create_export() + + with patch("maxillo.management.commands.run_export.LaparoscopyExportProcessor") as processor_cls: + processor_cls.return_value.process_export.return_value = None + call_command("run_export", export.id, "--domain", "laparoscopy") + + processor_cls.assert_called_once() + processor_cls.return_value.process_export.assert_called_once_with() diff --git a/laparoscopy/urls.py b/laparoscopy/urls.py new file mode 100644 index 0000000..a958bd4 --- /dev/null +++ b/laparoscopy/urls.py @@ -0,0 +1,44 @@ +from django.urls import path, include +from django.shortcuts import redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from common.models import Project, ProjectAccess +from laparoscopy import views as laparo_views + + +@login_required +def set_laparoscopy(request): + proj = Project.objects.filter(slug='laparoscopy').first() + if not proj: + proj = Project.objects.create(name='laparoscopy', slug='laparoscopy') + + if not (request.user.profile.is_admin or request.user.profile.is_student_developer): + has_access = ProjectAccess.objects.filter(user=request.user, project=proj).exists() + if not has_access: + messages.error(request, "You don't have access to the laparoscopy project.") + return redirect('home') + + request.session['current_project_id'] = proj.id + return redirect('laparoscopy:patient_list') + + +urlpatterns = [ + path('', set_laparoscopy, name='laparoscopy_home'), + + # Quadrant classification API + path('api/patient//quadrant-markers/', laparo_views.patient_quadrant_markers, name='patient_quadrant_markers'), + path('api/quadrant-types/', laparo_views.quadrant_types, name='quadrant_types'), + path('api/quadrant-types//', laparo_views.quadrant_type_detail, name='quadrant_type_detail'), + + # Region annotation API + path('api/patient//annotations/', laparo_views.patient_region_annotations, name='patient_region_annotations'), + path('api/annotations//', laparo_views.region_annotation_detail, name='region_annotation_detail'), + path('api/region-types/', laparo_views.region_types, name='region_types'), + path('api/region-types//', laparo_views.region_type_detail, name='region_type_detail'), + + # Magic Tool worker proxy API + path('api/worker/session-ready/', laparo_views.worker_session_ready, name='worker_session_ready'), + path('api/worker/session-prompt/', laparo_views.worker_session_prompt, name='worker_session_prompt'), + + path('', include(('maxillo.app_urls', 'maxillo'), namespace='laparoscopy')), +] diff --git a/laparoscopy/views.py b/laparoscopy/views.py new file mode 100644 index 0000000..d6bf652 --- /dev/null +++ b/laparoscopy/views.py @@ -0,0 +1,1045 @@ +import json +import logging +import math +import os +import re +import base64 +from urllib import error as urllib_error +from urllib import request as urllib_request + +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError, transaction +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from laparoscopy.models import ( + QuadrantClassificationMarker, + QuadrantType, + QuadrantTypeUserColor, + RegionAnnotation, + RegionType, + RegionTypeUserColor, +) + + +logger = logging.getLogger(__name__) + + +# ─── helpers ────────────────────────────────────────────────────────────────── + +def _get_profile(request): + return getattr(request.user, "profile", None) + + +def _parse_json_body(request): + if not request.body: + return {} + try: + return json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise ValueError("Invalid JSON body") + + +def _worker_url(request, path, specific_env): + specific = (os.getenv(specific_env) or "").strip() + if specific: + return specific + base = (os.getenv("WORKER_BASE_URL") or "").strip() + if not base: + host = (request.get_host() or "localhost").split(":", 1)[0] + scheme = "https" if request.is_secure() else "http" + base = f"{scheme}://{host}" + return f"{base.rstrip('/')}{path}" + + +def _worker_json_request(worker_url, payload, timeout): + req = urllib_request.Request( + worker_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib_request.urlopen(req, timeout=timeout) as resp: + status_code = resp.getcode() + body = resp.read().decode("utf-8") + try: + worker_response = json.loads(body) if body else {} + except json.JSONDecodeError: + worker_response = body + return status_code, worker_response + + +def _is_hex_color(value): + return isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) is not None + + +def _next_type_order(model_cls, project): + last_order = ( + model_cls.objects.filter(project=project) + .order_by("-order") + .values_list("order", flat=True) + .first() + ) + return 0 if last_order is None else last_order + 1 + + +def _patient_model(): + from django.apps import apps + return apps.get_model("laparoscopy", "Patient") + + +def _patient_permissions(profile, patient): + if not profile: + return False, False + can_view = False + if profile.is_admin(): + can_view = True + elif profile.is_annotator() and patient.visibility != "debug": + can_view = True + elif profile.is_student_developer() and patient.visibility == "debug": + can_view = True + elif patient.visibility == "public": + can_view = True + return can_view, can_view + + +def _normalize_float(value, field_name): + try: + parsed = float(value) + except (TypeError, ValueError): + raise ValueError(f"{field_name} must be numeric") + if not math.isfinite(parsed): + raise ValueError(f"{field_name} must be finite") + return parsed + + +# ─── types helpers ──────────────────────────────────────────────────────────── + +def _types_payload(project, user, TypeModel, ColorModel, type_fk): + types = list(TypeModel.objects.filter(project=project).order_by("order", "name")) + user_colors = { + getattr(pref, type_fk + "_id"): pref.color + for pref in ColorModel.objects.filter(**{type_fk + "__project": project}, user=user) + } + return [{"id": t.id, "name": t.name, "color": user_colors.get(t.id, t.color)} for t in types] + + +def _handle_type_list(request, TypeModel, ColorModel, type_fk, default_color): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + if request.method == "GET": + return JsonResponse( + {"types": _types_payload(profile.project, request.user, TypeModel, ColorModel, type_fk)} + ) + + if not profile.is_admin(): + return JsonResponse({"error": "Administrator access required"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + name = (data.get("name") or "").strip() + if not name: + return JsonResponse({"error": "name is required"}, status=400) + + color = data.get("color", default_color) + if not _is_hex_color(color): + return JsonResponse({"error": "color must be a hex value like " + default_color}, status=400) + + obj, created = TypeModel.objects.get_or_create( + project=profile.project, + name=name, + defaults={"color": color, "order": _next_type_order(TypeModel, profile.project)}, + ) + if not created and obj.color != color: + obj.color = color + obj.save(update_fields=["color"]) + + return JsonResponse({"id": obj.id, "name": obj.name, "color": obj.color}, status=201 if created else 200) + + +def _handle_type_detail(request, pk, TypeModel, ColorModel, type_fk, conflict_msg, before_delete=None): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + obj = get_object_or_404(TypeModel, pk=pk, project=profile.project) + + if request.method == "PATCH": + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + has_name = "name" in data + has_color = "color" in data + if not has_name and not has_color: + return JsonResponse({"error": "At least one of name or color is required"}, status=400) + + if has_name and not profile.is_admin(): + return JsonResponse({"error": "Administrator access required for rename"}, status=403) + + if has_name: + name = (data.get("name") or "").strip() + if not name: + return JsonResponse({"error": "name cannot be empty"}, status=400) + obj.name = name + try: + obj.save() + except IntegrityError: + return JsonResponse({"error": conflict_msg}, status=400) + + if has_color: + color = data.get("color") + if not _is_hex_color(color): + return JsonResponse({"error": "color must be a hex value like #3498db"}, status=400) + ColorModel.objects.update_or_create( + **{type_fk: obj, "user": request.user}, defaults={"color": color} + ) + + effective_color = ( + ColorModel.objects.filter(**{type_fk: obj}, user=request.user) + .values_list("color", flat=True) + .first() + or obj.color + ) + return JsonResponse({"id": obj.id, "name": obj.name, "color": effective_color}) + + # DELETE + if not profile.is_admin(): + return JsonResponse({"error": "Administrator access required"}, status=403) + + if before_delete is not None: + maybe_response = before_delete(request, profile, obj) + if maybe_response is not None: + return maybe_response + + obj.delete() + return HttpResponse(status=204) + + +def _quadrant_type_delete_hook(request, profile, obj): + markers_qs = QuadrantClassificationMarker.objects.filter(quadrant_type=obj) + if not markers_qs.exists(): + return None + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + replacement_id = data.get("replacement_id") + if replacement_id in [None, ""]: + return JsonResponse({"error": "replacement_id is required to delete a type in use"}, status=400) + try: + replacement_id = int(replacement_id) + except (TypeError, ValueError): + return JsonResponse({"error": "replacement_id must be an integer"}, status=400) + + if replacement_id == obj.id: + return JsonResponse({"error": "replacement_id must differ from the deleted type"}, status=400) + + replacement = QuadrantType.objects.filter(id=replacement_id, project=profile.project).first() + if replacement is None: + return JsonResponse({"error": "replacement_id must belong to the active project"}, status=400) + + markers_qs.update(quadrant_type=replacement, updated_by=request.user) + return None + + +# ─── types views ────────────────────────────────────────────────────────────── + +@login_required +@require_http_methods(["GET", "POST"]) +def region_types(request): + return _handle_type_list(request, RegionType, RegionTypeUserColor, "region_type", "#3498db") + + +@login_required +@require_http_methods(["PATCH", "DELETE"]) +def region_type_detail(request, pk): + return _handle_type_detail( + request, pk, RegionType, RegionTypeUserColor, "region_type", + "A region type with this name already exists", + ) + + +@login_required +@require_http_methods(["GET", "POST"]) +def quadrant_types(request): + return _handle_type_list(request, QuadrantType, QuadrantTypeUserColor, "quadrant_type", "#e74c3c") + + +@login_required +@require_http_methods(["PATCH", "DELETE"]) +def quadrant_type_detail(request, pk): + return _handle_type_detail( + request, pk, QuadrantType, QuadrantTypeUserColor, "quadrant_type", + "A quadrant type with this name already exists", + before_delete=_quadrant_type_delete_hook, + ) + + +# ─── annotation helpers ─────────────────────────────────────────────────────── + +def _normalize_points(points): + if not isinstance(points, list): + raise ValueError("points must be a list") + if len(points) < 4: + raise ValueError("points must include at least two vertices") + if len(points) % 2 != 0: + raise ValueError("points length must be even") + normalized = [] + for value in points: + if isinstance(value, bool): + raise ValueError("points must contain numeric values") + normalized.append(_normalize_float(value, "points")) + return normalized + + +def _normalize_prompt_points(prompt_points): + if prompt_points is None: + return [] + if not isinstance(prompt_points, list): + raise ValueError("prompt_points must be a list") + normalized = [] + for idx, raw_point in enumerate(prompt_points): + prefix = f"prompt_points[{idx}]" + if not isinstance(raw_point, dict): + raise ValueError(f"{prefix} must be an object") + if "x" not in raw_point or "y" not in raw_point: + raise ValueError(f"{prefix} must include x and y") + x = _normalize_float(raw_point.get("x"), f"{prefix}.x") + y = _normalize_float(raw_point.get("y"), f"{prefix}.y") + if x < 0 or x > 1 or y < 0 or y > 1: + raise ValueError(f"{prefix} coordinates must be normalized between 0 and 1") + raw_label = raw_point.get("label", 1) + if isinstance(raw_label, bool): + raise ValueError(f"{prefix}.label must be 0 or 1") + try: + label = int(raw_label) + except (TypeError, ValueError): + raise ValueError(f"{prefix}.label must be an integer") + if label not in (0, 1): + raise ValueError(f"{prefix}.label must be 0 or 1") + normalized.append({"x": x, "y": y, "label": label}) + return normalized + + +def _annotation_payload(annotation): + return { + "id": annotation.id, + "patient_id": annotation.patient_id, + "region_type_id": annotation.region_type_id, + "region_type_name": annotation.region_type.name, + "tool": annotation.tool, + "frame_time": annotation.frame_time, + "points": annotation.points, + "prompt_points": annotation.prompt_points, + "stroke_width": annotation.stroke_width, + "created_by_id": annotation.created_by_id, + "created_by_username": annotation.created_by.username if annotation.created_by_id else None, + "updated_by_id": annotation.updated_by_id, + "updated_by_username": annotation.updated_by.username if annotation.updated_by_id else None, + "created_at": annotation.created_at.isoformat() if annotation.created_at else None, + "updated_at": annotation.updated_at.isoformat() if annotation.updated_at else None, + } + + +def _quadrant_marker_payload(marker): + return { + "id": marker.id, + "patient_id": marker.patient_id, + "quadrant_type_id": marker.quadrant_type_id, + "time_ms": marker.time_ms, + } + + +def _normalize_time_ms(value): + if isinstance(value, bool): + raise ValueError("time_ms must be numeric") + try: + parsed = int(round(float(value))) + except (TypeError, ValueError): + raise ValueError("time_ms must be numeric") + if parsed < 0: + raise ValueError("time_ms must be >= 0") + return parsed + + +def _normalize_quadrant_marker_items(raw_markers, project): + if not isinstance(raw_markers, list): + raise ValueError("markers must be a list") + + parsed_items = [] + quadrant_type_ids = set() + + for index, raw in enumerate(raw_markers): + if not isinstance(raw, dict): + raise ValueError("each marker must be an object") + + raw_marker_id = raw.get("id") + marker_id = None + if raw_marker_id not in [None, ""]: + try: + marker_id = int(raw_marker_id) + except (TypeError, ValueError): + raise ValueError("marker id must be an integer") + if marker_id <= 0: + raise ValueError("marker id must be > 0") + + raw_quadrant_type_id = raw.get("quadrant_type_id") + if raw_quadrant_type_id in [None, ""]: + raise ValueError("quadrant_type_id is required") + try: + quadrant_type_id = int(raw_quadrant_type_id) + except (TypeError, ValueError): + raise ValueError("quadrant_type_id must be an integer") + + time_ms = _normalize_time_ms(raw.get("time_ms")) + parsed = {"order": index, "id": marker_id, "quadrant_type_id": quadrant_type_id, "time_ms": time_ms} + parsed_items.append(parsed) + quadrant_type_ids.add(quadrant_type_id) + + quadrant_types = { + obj.id: obj + for obj in QuadrantType.objects.filter(project=project, id__in=quadrant_type_ids) + } + if len(quadrant_types) != len(quadrant_type_ids): + raise ValueError("quadrant_type_id must belong to the active project") + + for item in parsed_items: + item["quadrant_type"] = quadrant_types[item["quadrant_type_id"]] + + sorted_items = sorted(parsed_items, key=lambda item: (item["time_ms"], item["order"])) + + dedup_same_time = [] + for item in sorted_items: + if dedup_same_time and dedup_same_time[-1]["time_ms"] == item["time_ms"]: + dedup_same_time[-1] = item + else: + dedup_same_time.append(item) + + compacted = [] + for item in dedup_same_time: + if compacted and compacted[-1]["quadrant_type_id"] == item["quadrant_type_id"]: + continue + compacted.append(item) + + return compacted + + +def _replace_patient_quadrant_markers(patient, user, marker_items): + with transaction.atomic(): + keep_ids = [] + for item in marker_items: + marker, created = QuadrantClassificationMarker.objects.update_or_create( + patient=patient, + time_ms=item["time_ms"], + defaults={"quadrant_type": item["quadrant_type"], "updated_by": user}, + ) + if created: + marker.created_by = user + marker.save(update_fields=["created_by"]) + keep_ids.append(marker.id) + + stale_qs = QuadrantClassificationMarker.objects.filter(patient=patient) + if keep_ids: + stale_qs.exclude(id__in=keep_ids).delete() + else: + stale_qs.delete() + + return list( + QuadrantClassificationMarker.objects.filter(patient=patient) + .select_related("quadrant_type") + .order_by("time_ms", "id") + ) + + +# ─── annotation views ───────────────────────────────────────────────────────── + +@login_required +@require_http_methods(["GET", "PUT"]) +def patient_quadrant_markers(request, patient_id): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + Patient = _patient_model() + patient = get_object_or_404(Patient, patient_id=patient_id) + can_view, can_modify = _patient_permissions(profile, patient) + if not can_view: + return JsonResponse({"error": "Permission denied"}, status=403) + + if request.method == "GET": + markers = ( + QuadrantClassificationMarker.objects.filter(patient=patient) + .select_related("quadrant_type") + .order_by("time_ms", "id") + ) + return JsonResponse({"markers": [_quadrant_marker_payload(m) for m in markers]}) + + if not can_modify: + return JsonResponse({"error": "Permission denied"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + try: + normalized_items = _normalize_quadrant_marker_items(data.get("markers"), profile.project) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + + markers = _replace_patient_quadrant_markers(patient=patient, user=request.user, marker_items=normalized_items) + return JsonResponse({"markers": [_quadrant_marker_payload(m) for m in markers]}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def patient_region_annotations(request, patient_id): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + Patient = _patient_model() + patient = get_object_or_404(Patient, patient_id=patient_id) + can_view, can_modify = _patient_permissions(profile, patient) + if not can_view: + return JsonResponse({"error": "Permission denied"}, status=403) + + if request.method == "GET": + annotations = ( + RegionAnnotation.objects.filter(patient=patient) + .select_related("region_type", "created_by", "updated_by") + .order_by("created_at") + ) + return JsonResponse({"annotations": [_annotation_payload(a) for a in annotations]}) + + if not can_modify: + return JsonResponse({"error": "Permission denied"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + region_type_id = data.get("region_type_id") + if region_type_id in [None, ""]: + return JsonResponse({"error": "region_type_id is required"}, status=400) + try: + region_type_id = int(region_type_id) + except (TypeError, ValueError): + return JsonResponse({"error": "region_type_id must be an integer"}, status=400) + + tool = str(data.get("tool") or "").strip().lower() + allowed_tools = {value for value, _ in RegionAnnotation.TOOL_CHOICES} + if tool not in allowed_tools: + return JsonResponse({"error": "tool must be one of brush, eraser, polygon"}, status=400) + + try: + frame_time = _normalize_float(data.get("frame_time", 0.0), "frame_time") + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + if frame_time < 0: + return JsonResponse({"error": "frame_time must be >= 0"}, status=400) + + try: + points = _normalize_points(data.get("points")) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + + try: + prompt_points = _normalize_prompt_points(data.get("prompt_points", [])) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + + if tool == "polygon" and len(points) < 6: + return JsonResponse({"error": "polygon requires at least three vertices"}, status=400) + + try: + stroke_width = _normalize_float(data.get("stroke_width", 1.0), "stroke_width") + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + if stroke_width <= 0: + return JsonResponse({"error": "stroke_width must be > 0"}, status=400) + + region_type = get_object_or_404(RegionType, id=region_type_id, project=profile.project) + + annotation = RegionAnnotation.objects.create( + patient=patient, region_type=region_type, tool=tool, + frame_time=frame_time, points=points, prompt_points=prompt_points, + stroke_width=stroke_width, created_by=request.user, updated_by=request.user, + ) + annotation = RegionAnnotation.objects.select_related( + "region_type", "created_by", "updated_by" + ).get(id=annotation.id) + return JsonResponse(_annotation_payload(annotation), status=201) + + +@login_required +@require_http_methods(["PATCH", "DELETE"]) +def region_annotation_detail(request, annotation_id): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + annotation = get_object_or_404( + RegionAnnotation.objects.select_related("patient", "region_type", "created_by", "updated_by"), + id=annotation_id, + ) + patient = annotation.patient + can_view, can_modify = _patient_permissions(profile, patient) + if not can_view: + return JsonResponse({"error": "Permission denied"}, status=403) + + if request.method == "DELETE": + if not can_modify: + return JsonResponse({"error": "Permission denied"}, status=403) + annotation.delete() + return HttpResponse(status=204) + + if not can_modify: + return JsonResponse({"error": "Permission denied"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + has_region = "region_type_id" in data + has_points = "points" in data + has_prompt_points = "prompt_points" in data + has_frame_time = "frame_time" in data + has_stroke_width = "stroke_width" in data + + if not (has_region or has_points or has_prompt_points or has_frame_time or has_stroke_width): + return JsonResponse( + {"error": "At least one of region_type_id, points, prompt_points, frame_time, stroke_width is required"}, + status=400, + ) + + changes = {} + + if has_region: + try: + region_type_id = int(data.get("region_type_id")) + except (TypeError, ValueError): + return JsonResponse({"error": "region_type_id must be an integer"}, status=400) + region_type = get_object_or_404(RegionType, id=region_type_id, project=profile.project) + if region_type.id != annotation.region_type_id: + annotation.region_type = region_type + changes["region_type_id"] = region_type.id + + if has_points: + try: + points = _normalize_points(data.get("points")) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + if annotation.tool == "polygon" and len(points) < 6: + return JsonResponse({"error": "polygon requires at least three vertices"}, status=400) + annotation.points = points + changes["points"] = points + + if has_prompt_points: + try: + prompt_points = _normalize_prompt_points(data.get("prompt_points")) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + annotation.prompt_points = prompt_points + changes["prompt_points"] = prompt_points + + if has_frame_time: + try: + frame_time = _normalize_float(data.get("frame_time"), "frame_time") + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + if frame_time < 0: + return JsonResponse({"error": "frame_time must be >= 0"}, status=400) + annotation.frame_time = frame_time + changes["frame_time"] = frame_time + + if has_stroke_width: + try: + stroke_width = _normalize_float(data.get("stroke_width"), "stroke_width") + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + if stroke_width <= 0: + return JsonResponse({"error": "stroke_width must be > 0"}, status=400) + annotation.stroke_width = stroke_width + changes["stroke_width"] = stroke_width + + if not changes: + return JsonResponse(_annotation_payload(annotation)) + + annotation.updated_by = request.user + annotation.save() + annotation.refresh_from_db() + return JsonResponse(_annotation_payload(annotation)) + + +# ─── Magic Tool worker proxy ────────────────────────────────────────────────── + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def worker_session_ready(request): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + patient_id = data.get("patientId") + video_source = (data.get("video_source") or "").strip() + video_id = (data.get("video_id") or "").strip() + + if patient_id is None: + return JsonResponse({"error": "patientId is required"}, status=400) + if not video_source: + return JsonResponse({"error": "video_source is required"}, status=400) + if not video_id: + return JsonResponse({"error": "video_id is required"}, status=400) + + try: + patient_id = int(patient_id) + except (TypeError, ValueError): + return JsonResponse({"error": "patientId must be an integer"}, status=400) + + Patient = _patient_model() + patient = get_object_or_404(Patient, patient_id=patient_id) + can_view, _ = _patient_permissions(profile, patient) + if not can_view: + return JsonResponse({"error": "Permission denied"}, status=403) + + worker_url = _worker_url(request, "/api/session/ready/", "WORKER_SESSION_READY_URL") + try: + status_code, worker_response = _worker_json_request( + worker_url, + {"video_source": video_source, "video_id": video_id}, + timeout=10, + ) + except urllib_error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + logger.error( + "Worker session-ready HTTP error %s for laparoscopy patient %s: %s", + exc.code, + patient.patient_id, + error_body, + ) + return JsonResponse( + { + "error": "Worker session ready request failed", + "worker_status": exc.code, + "worker_response": error_body, + }, + status=502, + ) + except urllib_error.URLError as exc: + logger.error( + "Worker session-ready network error for laparoscopy patient %s: %s", + patient.patient_id, + exc, + ) + return JsonResponse( + {"error": "Worker service unavailable", "details": str(exc)}, + status=502, + ) + + return JsonResponse( + {"success": True, "worker_status": status_code, "worker_response": worker_response} + ) + + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def worker_session_prompt(request): + profile = _get_profile(request) + if not profile: + return JsonResponse({"error": "No active project"}, status=403) + + try: + data = _parse_json_body(request) + except ValueError: + return JsonResponse({"error": "Invalid JSON body"}, status=400) + + patient_id = data.get("patientId") + video_id = (data.get("video_id") or "").strip() + frame_timestamp_raw = data.get("frame_timestamp") + window_seconds_raw = data.get("window_seconds", 5.0) + normalized_default = bool(data.get("normalized", True)) + + if patient_id is None: + return JsonResponse({"error": "patientId is required"}, status=400) + if not video_id: + return JsonResponse({"error": "video_id is required"}, status=400) + + try: + patient_id = int(patient_id) + except (TypeError, ValueError): + return JsonResponse({"error": "patientId must be an integer"}, status=400) + + try: + frame_timestamp = float(frame_timestamp_raw) + except (TypeError, ValueError): + return JsonResponse({"error": "frame_timestamp must be numeric"}, status=400) + if frame_timestamp < 0: + return JsonResponse({"error": "frame_timestamp must be >= 0"}, status=400) + + try: + window_seconds = float(window_seconds_raw) + except (TypeError, ValueError): + return JsonResponse({"error": "window_seconds must be numeric"}, status=400) + if window_seconds <= 0: + return JsonResponse({"error": "window_seconds must be > 0"}, status=400) + + def _parse_points(points_raw, point_labels_raw, normalized_flag, prefix): + if points_raw is None and point_labels_raw is None: + return None, None, None + if not isinstance(points_raw, list) or not points_raw: + return None, None, f"{prefix}points must be a non-empty list" + if not isinstance(point_labels_raw, list): + return None, None, f"{prefix}point_labels must be a list" + if len(point_labels_raw) != len(points_raw): + return None, None, f"{prefix}point_labels length must match points length" + + points = [] + labels = [] + for idx, point in enumerate(points_raw): + if not isinstance(point, (list, tuple)) or len(point) != 2: + return None, None, f"{prefix}points[{idx}] must be a [x, y] pair" + try: + x = float(point[0]) + y = float(point[1]) + except (TypeError, ValueError): + return None, None, f"{prefix}points[{idx}] must contain numeric values" + if normalized_flag and (x < 0 or x > 1 or y < 0 or y > 1): + return None, None, f"{prefix}points[{idx}] must be normalized between 0 and 1" + points.append([x, y]) + + for idx, label in enumerate(point_labels_raw): + if isinstance(label, bool): + return None, None, f"{prefix}point_labels[{idx}] must be 0 or 1" + try: + parsed_label = int(label) + except (TypeError, ValueError): + return None, None, f"{prefix}point_labels[{idx}] must be an integer" + if parsed_label not in (0, 1): + return None, None, f"{prefix}point_labels[{idx}] must be 0 or 1" + labels.append(parsed_label) + + return points, labels, None + + def _parse_box(box_raw, normalized_flag, prefix): + if box_raw is None: + return None, None + if not isinstance(box_raw, list) or len(box_raw) != 2: + return None, f"{prefix}box must be [[x1, y1], [x2, y2]]" + + parsed_box = [] + for corner_idx, corner in enumerate(box_raw): + if not isinstance(corner, (list, tuple)) or len(corner) != 2: + return None, f"{prefix}box[{corner_idx}] must be [x, y]" + try: + x = float(corner[0]) + y = float(corner[1]) + except (TypeError, ValueError): + return None, f"{prefix}box[{corner_idx}] must contain numeric values" + if normalized_flag and (x < 0 or x > 1 or y < 0 or y > 1): + return None, f"{prefix}box[{corner_idx}] must be normalized between 0 and 1" + parsed_box.append([x, y]) + return parsed_box, None + + def _parse_mask(mask_b64_raw, mask_shape_raw, mask_encoding_raw, prefix): + if mask_b64_raw is None and mask_shape_raw is None and mask_encoding_raw is None: + return None, None, None, None + if not mask_b64_raw: + return None, None, None, f"{prefix}mask_b64 is required when mask_shape is provided" + if mask_encoding_raw is None: + return None, None, None, f"{prefix}mask_encoding is required when mask_b64 is provided" + if not isinstance(mask_shape_raw, list) or len(mask_shape_raw) != 2: + return None, None, None, f"{prefix}mask_shape must be [height, width]" + try: + mask_h = int(mask_shape_raw[0]) + mask_w = int(mask_shape_raw[1]) + except (TypeError, ValueError): + return None, None, None, f"{prefix}mask_shape values must be integers" + if mask_h <= 0 or mask_w <= 0: + return None, None, None, f"{prefix}mask_shape values must be > 0" + + mask_encoding = str(mask_encoding_raw) + if mask_encoding != "bitpack_u1_v1": + return None, None, None, f"{prefix}mask_encoding must be bitpack_u1_v1" + + try: + mask_bytes = base64.b64decode(str(mask_b64_raw), validate=True) + except Exception: + return None, None, None, f"{prefix}mask_b64 must be valid base64" + + expected_bytes = (mask_h * mask_w + 7) // 8 + if len(mask_bytes) != expected_bytes: + return ( + None, + None, + None, + f"{prefix}mask_b64 byte length must match bit-packed mask_shape (expected {expected_bytes}, got {len(mask_bytes)})", + ) + return str(mask_b64_raw), [mask_h, mask_w], mask_encoding, None + + regions_raw = data.get("regions") + legacy_prompt_fields = ( + "region_id", + "class_name", + "class_id", + "points", + "point_labels", + "box", + "mask_b64", + "mask_encoding", + "mask_shape", + ) + + if regions_raw is not None and any(field in data for field in legacy_prompt_fields): + return JsonResponse( + {"error": "use either regions or top-level prompt fields, not both"}, + status=400, + ) + + if regions_raw is None: + regions_raw = [ + { + "region_id": data.get("region_id", "1"), + "class_name": data.get("class_name", "unknown"), + "class_id": data.get("class_id"), + "points": data.get("points"), + "point_labels": data.get("point_labels"), + "box": data.get("box"), + "mask_b64": data.get("mask_b64"), + "mask_encoding": data.get("mask_encoding"), + "mask_shape": data.get("mask_shape"), + "normalized": normalized_default, + } + ] + + if not isinstance(regions_raw, list) or not regions_raw: + return JsonResponse({"error": "regions must be a non-empty list"}, status=400) + + regions_payload = [] + seen_region_ids = set() + for ridx, region_raw in enumerate(regions_raw): + prefix = f"regions[{ridx}]." + if not isinstance(region_raw, dict): + return JsonResponse({"error": f"regions[{ridx}] must be an object"}, status=400) + + region_id = region_raw.get("region_id") + if region_id is None or str(region_id).strip() == "": + return JsonResponse({"error": f"{prefix}region_id is required"}, status=400) + region_id = str(region_id).strip() + if region_id in seen_region_ids: + return JsonResponse({"error": "region_id values must be unique"}, status=400) + seen_region_ids.add(region_id) + + region_normalized = bool(region_raw.get("normalized", normalized_default)) + points, point_labels, err = _parse_points( + region_raw.get("points"), region_raw.get("point_labels"), region_normalized, prefix + ) + if err: + return JsonResponse({"error": err}, status=400) + + box, err = _parse_box(region_raw.get("box"), region_normalized, prefix) + if err: + return JsonResponse({"error": err}, status=400) + + mask_b64, mask_shape, mask_encoding, err = _parse_mask( + region_raw.get("mask_b64"), + region_raw.get("mask_shape"), + region_raw.get("mask_encoding"), + prefix, + ) + if err: + return JsonResponse({"error": err}, status=400) + + if points is None and box is None and mask_b64 is None: + return JsonResponse( + {"error": f"{prefix}must include at least one prompt: points, box, or mask"}, + status=400, + ) + + payload_region = { + "region_id": region_id, + "class_name": str(region_raw.get("class_name") or "unknown"), + "normalized": region_normalized, + } + class_id = region_raw.get("class_id") + if class_id is not None: + payload_region["class_id"] = str(class_id) + if points is not None: + payload_region["points"] = points + payload_region["point_labels"] = point_labels + if box is not None: + payload_region["box"] = box + if mask_b64 is not None: + payload_region["mask_b64"] = mask_b64 + payload_region["mask_encoding"] = mask_encoding + payload_region["mask_shape"] = mask_shape + + regions_payload.append(payload_region) + + Patient = _patient_model() + patient = get_object_or_404(Patient, patient_id=patient_id) + can_view, _ = _patient_permissions(profile, patient) + if not can_view: + return JsonResponse({"error": "Permission denied"}, status=403) + + worker_url = _worker_url(request, "/api/session/prompt/", "WORKER_SESSION_PROMPT_URL") + payload = { + "video_id": video_id, + "frame_timestamp": frame_timestamp, + "regions": regions_payload, + "window_seconds": window_seconds, + "normalized": normalized_default, + } + + try: + status_code, worker_response = _worker_json_request(worker_url, payload, timeout=20) + except urllib_error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + logger.error( + "Worker session-prompt HTTP error %s for laparoscopy patient %s: %s", + exc.code, + patient.patient_id, + error_body, + ) + return JsonResponse( + { + "error": "Worker session prompt request failed", + "worker_status": exc.code, + "worker_response": error_body, + }, + status=502, + ) + except urllib_error.URLError as exc: + logger.error( + "Worker session-prompt network error for laparoscopy patient %s: %s", + patient.patient_id, + exc, + ) + return JsonResponse( + {"error": "Worker service unavailable", "details": str(exc)}, + status=502, + ) + + return JsonResponse( + {"success": True, "worker_status": status_code, "worker_response": worker_response} + ) diff --git a/maxillo/api_views/files.py b/maxillo/api_views/files.py index ccc2ce6..4a66515 100644 --- a/maxillo/api_views/files.py +++ b/maxillo/api_views/files.py @@ -1,11 +1,13 @@ """File serving and registry API endpoints.""" -from django.http import JsonResponse, Http404 +from django.http import JsonResponse, Http404, StreamingHttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from django.db import models +import contextlib import os +import re import logging import traceback import mimetypes @@ -55,16 +57,21 @@ def serve_file(request, file_id): and request.resolver_match.namespace ) or "maxillo" file_domain = file_obj.domain or request_namespace - if file_domain not in ["maxillo", "brain"]: + if file_domain not in ["maxillo", "brain", "laparoscopy"]: file_domain = request_namespace if not artifact_exists(resolved_file_path): raise Http404("File not found") # Authentication: Check if user has access to the patient associated with this file - patient = file_obj.brain_patient if file_domain == "brain" else file_obj.patient + if file_domain == "brain": + patient = file_obj.brain_patient + elif file_domain == "laparoscopy": + patient = file_obj.laparoscopy_patient + else: + patient = file_obj.patient if not patient: - patient = file_obj.patient or file_obj.brain_patient + patient = file_obj.patient or file_obj.brain_patient or file_obj.laparoscopy_patient if patient: if getattr(patient, "deleted", False): return JsonResponse({"error": "Patient not found"}, status=404) @@ -135,6 +142,54 @@ def serve_file(request, file_id): else f"file_{file_obj.id}" ) ) + safe_filename = filename.replace("\n", " ").replace("\r", " ") + + # Video and audio files need Range-request support so browsers can seek. + if content_type and (content_type.startswith("video/") or content_type.startswith("audio/")): + total_size = file_obj.file_size or 0 + range_header = request.META.get("HTTP_RANGE", "").strip() + + if range_header and total_size > 0: + m = re.match(r"bytes=(\d+)-(\d*)", range_header) + if m: + start = int(m.group(1)) + end = int(m.group(2)) if m.group(2) else total_size - 1 + end = min(end, total_size - 1) + length = end - start + 1 + try: + from common.object_storage import get_object_storage as _get_os + body, _ = _get_os().get_range(resolved_file_path, f"bytes={start}-{end}") + def _iter(b, chunk=512 * 1024): + try: + while True: + data = b.read(chunk) + if not data: + break + yield data + finally: + with contextlib.suppress(Exception): + b.close() + resp = StreamingHttpResponse(_iter(body), status=206, content_type=content_type) + resp["Content-Range"] = f"bytes {start}-{end}/{total_size}" + resp["Content-Length"] = str(length) + resp["Accept-Ranges"] = "bytes" + resp["Content-Disposition"] = f'inline; filename="{safe_filename}"' + return resp + except Exception as e: + logger.warning(f"Range fetch failed for file {file_id}, falling back: {e}") + + # Full response — still advertise Range support and Content-Length + resp = streaming_response( + path_or_key=resolved_file_path, + content_type=content_type, + filename=safe_filename, + as_attachment=False, + ) + resp["Accept-Ranges"] = "bytes" + if total_size > 0: + resp["Content-Length"] = str(total_size) + return resp + return streaming_response( path_or_key=resolved_file_path, content_type=content_type, diff --git a/maxillo/file_utils.py b/maxillo/file_utils.py index 74298cf..84e3f1a 100644 --- a/maxillo/file_utils.py +++ b/maxillo/file_utils.py @@ -104,6 +104,8 @@ def _domain_for_patient(patient) -> str: app_label = getattr(getattr(patient, "_meta", None), "app_label", "") if app_label == "brain": return "brain" + if app_label == "laparoscopy": + return "laparoscopy" return "maxillo" @@ -114,11 +116,20 @@ def _entity_fk_kwargs(patient): "domain": "brain", "brain_patient": patient, "patient": None, + "laparoscopy_patient": None, + } + if domain == "laparoscopy": + return { + "domain": "laparoscopy", + "laparoscopy_patient": patient, + "patient": None, + "brain_patient": None, } return { "domain": "maxillo", "patient": patient, "brain_patient": None, + "laparoscopy_patient": None, } @@ -129,6 +140,11 @@ def _entity_filter_kwargs(patient): "domain": "brain", "brain_patient": patient, } + if domain == "laparoscopy": + return { + "domain": "laparoscopy", + "laparoscopy_patient": patient, + } return { "domain": "maxillo", "patient": patient, @@ -142,59 +158,92 @@ def _voice_entity_fk_kwargs(voice_caption): return { "brain_voice_caption": voice_caption, "voice_caption": None, + "laparoscopy_voice_caption": None, + } + if domain == "laparoscopy": + return { + "laparoscopy_voice_caption": voice_caption, + "voice_caption": None, + "brain_voice_caption": None, } return { "voice_caption": voice_caption, "brain_voice_caption": None, + "laparoscopy_voice_caption": None, } def _project_slug_from_patient(patient) -> str: domain = _domain_for_patient(patient) - return "brain" if domain == "brain" else "maxillo" + if domain == "brain": + return "brain" + if domain == "laparoscopy": + return "laparoscopy" + return "maxillo" def _domain_for_job(job) -> str: - if getattr(job, "domain", None) in ["brain", "maxillo"]: + if getattr(job, "domain", None) in ["brain", "maxillo", "laparoscopy"]: return job.domain if getattr(job, "brain_patient_id", None) or getattr( job, "brain_voice_caption_id", None ): return "brain" + if getattr(job, "laparoscopy_patient_id", None) or getattr( + job, "laparoscopy_voice_caption_id", None + ): + return "laparoscopy" return "maxillo" def _job_patient(job): - return ( - getattr(job, "brain_patient", None) - if _domain_for_job(job) == "brain" - else getattr(job, "patient", None) - ) + domain = _domain_for_job(job) + if domain == "brain": + return getattr(job, "brain_patient", None) + if domain == "laparoscopy": + return getattr(job, "laparoscopy_patient", None) + return getattr(job, "patient", None) def _job_voice_caption(job): - return ( - getattr(job, "brain_voice_caption", None) - if _domain_for_job(job) == "brain" - else getattr(job, "voice_caption", None) - ) + domain = _domain_for_job(job) + if domain == "brain": + return getattr(job, "brain_voice_caption", None) + if domain == "laparoscopy": + return getattr(job, "laparoscopy_voice_caption", None) + return getattr(job, "voice_caption", None) def _job_entity_fk_kwargs(job): - if _domain_for_job(job) == "brain": + domain = _domain_for_job(job) + if domain == "brain": return { "domain": "brain", "brain_patient": _job_patient(job), "patient": None, + "laparoscopy_patient": None, "brain_voice_caption": _job_voice_caption(job), "voice_caption": None, + "laparoscopy_voice_caption": None, + } + if domain == "laparoscopy": + return { + "domain": "laparoscopy", + "laparoscopy_patient": _job_patient(job), + "patient": None, + "brain_patient": None, + "laparoscopy_voice_caption": _job_voice_caption(job), + "voice_caption": None, + "brain_voice_caption": None, } return { "domain": "maxillo", "patient": _job_patient(job), "brain_patient": None, + "laparoscopy_patient": None, "voice_caption": _job_voice_caption(job), "brain_voice_caption": None, + "laparoscopy_voice_caption": None, } @@ -976,7 +1025,8 @@ def mark_job_completed(job_id, output_files, logs=None): try: job = Job.objects.select_related( - "patient", "brain_patient", "voice_caption", "brain_voice_caption" + "patient", "brain_patient", "laparoscopy_patient", + "voice_caption", "brain_voice_caption", "laparoscopy_voice_caption", ).get(id=job_id) logger.info( f"Found job: {job.id}, modality: {job.modality_slug}, status: {job.status}" @@ -1195,6 +1245,41 @@ def mark_job_completed(job_id, output_files, logs=None): output_files["skipped_confirmed_segmentations"] = skipped_confirmed_count job.output_files = output_files job.save(update_fields=["output_files"]) + elif job.modality_slug == "video": + # Video jobs produce two named outputs ("compressed", "subsampled"). + # Store each as video_processed with subtype= so the + # player can reliably select only the compressed derivative. + modality_fk = None + try: + from common.models import Modality as _Modality + modality_fk = _Modality.objects.filter(slug="video").first() + except Exception: + pass + for output_key, out_spec in output_files.items(): + path_or_key = _resolve_output_path_or_key(out_spec) + logger.info(f"Processing video output: key={output_key}, path={path_or_key}") + if not path_or_key or not artifact_exists(path_or_key): + logger.warning(f"Video output not found: {path_or_key}") + continue + file_size, file_hash = _size_hash_for_path_or_key(path_or_key) + FileRegistry.objects.update_or_create( + file_path=path_or_key, + defaults={ + "file_type": "video_processed", + "subtype": output_key, + "modality": modality_fk, + "file_size": file_size or 0, + "file_hash": file_hash or "object", + "processing_job": job, + **_job_entity_fk_kwargs(job), + "metadata": { + "processed_at": timezone.now().isoformat(), + "output_key": output_key, + "logs": logs if logs else "", + }, + }, + ) + logger.info(f"Video FileRegistry entry stored: subtype={output_key}") else: # For non-CBCT modalities, register simple outputs idempotently. # Bite classification has a dedicated handler below. @@ -1434,7 +1519,8 @@ def mark_job_failed(job_id, error_msg, can_retry=True): """ try: job = Job.objects.select_related( - "patient", "brain_patient", "voice_caption", "brain_voice_caption" + "patient", "brain_patient", "laparoscopy_patient", + "voice_caption", "brain_voice_caption", "laparoscopy_voice_caption", ).get(id=job_id) job_patient = _job_patient(job) job_voice_caption = _job_voice_caption(job) diff --git a/maxillo/management/commands/run_export.py b/maxillo/management/commands/run_export.py index 2763bb6..1a56155 100644 --- a/maxillo/management/commands/run_export.py +++ b/maxillo/management/commands/run_export.py @@ -3,6 +3,8 @@ from django.core.management.base import BaseCommand, CommandError from brain.models import Export as BrainExport +from laparoscopy.export_processor import LaparoscopyExportProcessor +from laparoscopy.models import Export as LaparoscopyExport from ...models import Export as MaxilloExport from ...utils.export_processor import ExportProcessor @@ -16,7 +18,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('export_id', type=int) - parser.add_argument('--domain', choices=['maxillo', 'brain']) + parser.add_argument('--domain', choices=['maxillo', 'brain', 'laparoscopy']) def handle(self, *args, **options): export_id = options['export_id'] @@ -25,6 +27,8 @@ def handle(self, *args, **options): export = None if domain == 'brain': export = BrainExport.objects.filter(id=export_id).first() + elif domain == 'laparoscopy': + export = LaparoscopyExport.objects.filter(id=export_id).first() elif domain == 'maxillo': export = MaxilloExport.objects.filter(id=export_id).first() else: @@ -33,6 +37,10 @@ def handle(self, *args, **options): export = BrainExport.objects.filter(id=export_id).first() if export: domain = 'brain' + else: + export = LaparoscopyExport.objects.filter(id=export_id).first() + if export: + domain = 'laparoscopy' else: domain = 'maxillo' @@ -40,13 +48,21 @@ def handle(self, *args, **options): raise CommandError(f'Export {export_id} not found') if not domain: - domain = 'brain' if export.__class__.__module__.startswith('brain.') else 'maxillo' + if export.__class__.__module__.startswith('brain.'): + domain = 'brain' + elif export.__class__.__module__.startswith('laparoscopy.'): + domain = 'laparoscopy' + else: + domain = 'maxillo' if export.status == 'pending': export.mark_processing() logger.info('Running export %s for domain %s', export_id, domain) - processor = ExportProcessor(export, domain=domain) + if domain == 'laparoscopy': + processor = LaparoscopyExportProcessor(export) + else: + processor = ExportProcessor(export, domain=domain) processor.process_export() self.stdout.write(self.style.SUCCESS(f'Export {export_id} finished with status {export.status}')) diff --git a/maxillo/utils/export_processor.py b/maxillo/utils/export_processor.py index d802f20..f960691 100644 --- a/maxillo/utils/export_processor.py +++ b/maxillo/utils/export_processor.py @@ -743,10 +743,13 @@ def start_export_processing(export_id, domain="maxillo"): """ from ..models import Export as MaxilloExport from brain.models import Export as BrainExport + from laparoscopy.models import Export as LaparoscopyExport try: if domain == "brain": export = BrainExport.objects.filter(id=export_id).first() + elif domain == "laparoscopy": + export = LaparoscopyExport.objects.filter(id=export_id).first() else: export = MaxilloExport.objects.filter(id=export_id).first() @@ -782,7 +785,7 @@ def start_export_processing(export_id, domain="maxillo"): try: export = MaxilloExport.objects.filter( id=export_id - ).first() or BrainExport.objects.get(id=export_id) + ).first() or LaparoscopyExport.objects.filter(id=export_id).first() or BrainExport.objects.get(id=export_id) export.mark_failed(str(e)) except Exception: pass diff --git a/maxillo/views/domain.py b/maxillo/views/domain.py index 2da0585..b86f00c 100644 --- a/maxillo/views/domain.py +++ b/maxillo/views/domain.py @@ -1,4 +1,4 @@ -"""Helpers to route maxillo/brain namespaces to the correct domain models/forms.""" +"""Helpers to route maxillo/brain/laparoscopy namespaces to the correct domain models/forms.""" from django.apps import apps @@ -11,13 +11,20 @@ def is_brain_namespace(request): return get_namespace(request) == 'brain' +def is_laparoscopy_namespace(request): + return get_namespace(request) == 'laparoscopy' + + def get_domain_models(request): - if is_brain_namespace(request): + ns = get_namespace(request) + if ns == 'brain': app_label = 'brain' + elif ns == 'laparoscopy': + app_label = 'laparoscopy' else: app_label = 'maxillo' - return { + models = { 'Patient': apps.get_model(app_label, 'Patient'), 'Folder': apps.get_model(app_label, 'Folder'), 'Tag': apps.get_model(app_label, 'Tag'), @@ -26,6 +33,7 @@ def get_domain_models(request): 'VoiceCaption': apps.get_model(app_label, 'VoiceCaption'), 'Export': apps.get_model(app_label, 'Export'), } + return models def get_canonical_models(): @@ -42,7 +50,8 @@ def get_canonical_models(): def get_domain_forms(request): - if is_brain_namespace(request): + ns = get_namespace(request) + if ns == 'brain': from brain.forms import ( ClassificationForm, DatasetForm, @@ -50,6 +59,14 @@ def get_domain_forms(request): PatientManagementForm, PatientUploadForm, ) + elif ns == 'laparoscopy': + from laparoscopy.forms import ( + ClassificationForm, + DatasetForm, + PatientForm, + PatientManagementForm, + PatientUploadForm, + ) else: from ..forms import ( ClassificationForm, diff --git a/maxillo/views/export.py b/maxillo/views/export.py index 93ad240..da590b0 100644 --- a/maxillo/views/export.py +++ b/maxillo/views/export.py @@ -122,6 +122,11 @@ def _build_shared_download_url(request, share_token): ) +def _get_export_for_request_or_404(request, export_id): + ExportModel = get_domain_models(request)["Export"] + return get_object_or_404(ExportModel.objects.all(), id=export_id) + + def _shared_export_availability(request, share_token): """Return export and availability status for shared access.""" ExportModel = get_domain_models(request)["Export"] @@ -146,6 +151,75 @@ def is_admin(user): return user.is_staff or user.profile.is_admin() +def _laparoscopy_export_query_summary(folder_count): + return ", ".join( + [ + f"{folder_count} folder{'s' if folder_count != 1 else ''}", + "Laparoscopy subsampled videos", + "Per-frame multilayer NPZ masks", + "All subsampled frames", + ] + ) + + +def _laparoscopy_export_new(request, ExportModel): + from laparoscopy.export_processor import get_laparoscopy_export_folders + + if request.method == "POST": + folder_ids = request.POST.getlist("folder_ids") + if not folder_ids: + messages.error(request, "Please select at least one folder.") + return redirect_with_namespace(request, "export_new") + + query_params = { + "domain": "laparoscopy", + "export_variant": "video_masks_v1", + "folder_ids": [int(fid) for fid in folder_ids], + "mask_format": "npz_multilayer", + "include_all_frames": True, + "video_subtype": "subsampled", + } + export = ExportModel.objects.create( + user=request.user, + status="pending", + query_params=query_params, + query_summary=_laparoscopy_export_query_summary(len(folder_ids)), + ) + + from ..utils.export_processor import start_export_processing + + start_export_processing(export.id, "laparoscopy") + messages.success(request, f"Export #{export.id} created and processing started.") + return redirect_with_namespace(request, "export_list") + + return render( + request, + "laparoscopy/export_new.html", + { + "folders": get_laparoscopy_export_folders(), + "ns": get_namespace(request), + }, + ) + + +def _laparoscopy_export_preview(folder_ids): + from laparoscopy.export_processor import build_laparoscopy_export_preview + + preview = build_laparoscopy_export_preview(folder_ids) + size_bytes = int(preview["estimated_size_bytes"] or 0) + return JsonResponse( + { + "success": True, + "patient_count": preview["patient_count"], + "folder_count": len(folder_ids), + "exportable_patient_count": preview["exportable_patient_count"], + "file_count": preview["file_count"], + "estimated_size": format_file_size(size_bytes), + "estimated_size_bytes": size_bytes, + } + ) + + @login_required @user_passes_test(is_admin) def export_list(request): @@ -199,6 +273,9 @@ def export_new(request): """Create new export page with folder/modality selection.""" domain_models = get_domain_models(request) ExportModel = domain_models["Export"] + if get_namespace(request) == "laparoscopy": + return _laparoscopy_export_new(request, ExportModel) + FolderModel = domain_models["Folder"] PatientModel = domain_models["Patient"] @@ -236,6 +313,7 @@ def export_new(request): # Create export record query_params = { + "domain": get_namespace(request), "folder_ids": [int(fid) for fid in folder_ids], "modality_slugs": modality_slugs, "filters": filters, @@ -366,6 +444,9 @@ def export_preview(request): else: folder_ids = [int(fid) for fid in folder_ids if fid] + if domain == "laparoscopy": + return _laparoscopy_export_preview(folder_ids) + if isinstance(modality_slugs, str): modality_slugs = modality_slugs.split(",") if modality_slugs else [] @@ -608,7 +689,7 @@ def _recover_stuck_export(export): @user_passes_test(is_admin) def export_status(request, export_id): """AJAX endpoint to get current export status.""" - export = get_object_or_404(get_domain_models(request)["Export"], id=export_id) + export = _get_export_for_request_or_404(request, export_id) # Check permissions if export.user != request.user and not request.user.is_staff: @@ -650,7 +731,7 @@ def export_status(request, export_id): @user_passes_test(is_admin) def export_download(request, export_id): """Download export ZIP file.""" - export = get_object_or_404(get_domain_models(request)["Export"], id=export_id) + export = _get_export_for_request_or_404(request, export_id) # Check permissions if export.user != request.user and not request.user.is_staff: @@ -691,7 +772,7 @@ def export_download(request, export_id): @require_POST def export_share_update(request, export_id): """Update share settings for a completed export.""" - export = get_object_or_404(get_domain_models(request)["Export"], id=export_id) + export = _get_export_for_request_or_404(request, export_id) if export.user != request.user and not request.user.is_staff: return JsonResponse( @@ -814,7 +895,7 @@ def export_shared_download(request, share_token): @require_POST def export_delete(request, export_id): """Delete export record and optionally the ZIP file.""" - export = get_object_or_404(get_domain_models(request)["Export"], id=export_id) + export = _get_export_for_request_or_404(request, export_id) # Check permissions if export.user != request.user and not request.user.is_staff: diff --git a/maxillo/views/patient_detail.py b/maxillo/views/patient_detail.py index fd5f03d..5911ae7 100644 --- a/maxillo/views/patient_detail.py +++ b/maxillo/views/patient_detail.py @@ -10,7 +10,7 @@ from common.file_access import exists as artifact_exists -from .domain import get_domain_forms, get_domain_models +from .domain import get_domain_forms, get_domain_models, get_namespace from .helpers import redirect_with_namespace, render_with_fallback logger = logging.getLogger(__name__) @@ -390,6 +390,38 @@ def patient_detail(request, patient_id): context['allowed_modality_slugs'] = [m.slug for m in allowed_modalities] except Exception: pass + try: + from django.db.models import Case, When, IntegerField as _IntegerField + from django.urls import reverse as _reverse + ns = get_namespace(request) + video_file = patient.files.filter( + file_type__in=['video_processed', 'video_raw'] + ).annotate( + _prio=Case( + When(file_type='video_processed', subtype='compressed', then=0), + When(file_type='video_processed', then=1), + default=2, + output_field=_IntegerField(), + ) + ).order_by('_prio', '-created_at').first() + if video_file: + context['video_file'] = video_file + context['video_url'] = _reverse(f'{ns}:api_serve_file', kwargs={'file_id': video_file.id}) + context['has_video'] = bool(video_file) + subsampled_file = patient.files.filter( + file_type='video_processed', subtype='subsampled' + ).order_by('-created_at').first() + worker_source_file = subsampled_file or video_file + if subsampled_file: + context['subsampled_video_url'] = _reverse(f'{ns}:api_serve_file', kwargs={'file_id': subsampled_file.id}) + if worker_source_file and getattr(worker_source_file, 'file_path', None): + context['worker_video_source_ref'] = worker_source_file.file_path + context['worker_video_source_file_id'] = worker_source_file.id + except Exception: + context['has_video'] = False + context['video_url'] = None + context['worker_video_source_ref'] = None + context['worker_video_source_file_id'] = None return render_with_fallback(request, 'patient_detail', context) @login_required diff --git a/maxillo/views/patient_list.py b/maxillo/views/patient_list.py index 6231cfa..d66c0c9 100644 --- a/maxillo/views/patient_list.py +++ b/maxillo/views/patient_list.py @@ -21,6 +21,12 @@ def _get_domain_models(request): apps.get_model('brain', 'Folder'), apps.get_model('brain', 'Tag'), ) + if ns == 'laparoscopy': + return ( + apps.get_model('laparoscopy', 'Patient'), + apps.get_model('laparoscopy', 'Folder'), + apps.get_model('laparoscopy', 'Tag'), + ) return MaxilloPatient, MaxilloFolder, MaxilloTag def home(request): diff --git a/maxillo/views/patient_upload.py b/maxillo/views/patient_upload.py index 1117867..2266c48 100644 --- a/maxillo/views/patient_upload.py +++ b/maxillo/views/patient_upload.py @@ -2,6 +2,7 @@ from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.contrib import messages +from django.http import JsonResponse from common.models import Project from .domain import get_domain_forms, get_domain_models @@ -196,6 +197,32 @@ def upload_patient(request): except Exception as e: messages.error(request, f"Error saving {display_name}: {e}") + # Generic video modality + video_file = request.FILES.get('video') + video_error = None + if video_file: + try: + modality = Modality.objects.get(slug='video') + patient.modalities.add(modality) + + from laparoscopy.file_utils import save_video_to_dataset + fr, job = save_video_to_dataset(patient, video_file) + if fr: + uploaded_modalities.append('Video') + if job: + processing_job_ids.append(job.id) + else: + video_error = 'Video file could not be saved (storage may be unavailable).' + except Exception as e: + video_error = f"Error saving Video: {e}" + + is_xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + if video_error: + messages.error(request, video_error) + if is_xhr: + return JsonResponse({'ok': False, 'error': video_error}, status=400) + if uploaded_modalities: unique_modalities = list(dict.fromkeys(uploaded_modalities)) summary_message = ( @@ -209,6 +236,16 @@ def upload_patient(request): messages.success(request, summary_message) else: messages.success(request, 'Patient uploaded successfully!') + + if is_xhr: + from django.urls import reverse, NoReverseMatch + ns = (getattr(request, 'resolver_match', None) and request.resolver_match.namespace) or 'maxillo' + try: + redirect_url = reverse(f"{ns}:patient_list") + except NoReverseMatch: + redirect_url = reverse('maxillo:patient_list') + return JsonResponse({'ok': True, 'redirect': redirect_url}) + return redirect_with_namespace(request, 'patient_list') else: patient_form = PatientForm() diff --git a/static/css/patient_detail.css b/static/css/patient_detail.css index bfa3c9c..f4ccc46 100644 --- a/static/css/patient_detail.css +++ b/static/css/patient_detail.css @@ -1193,4 +1193,156 @@ .modality-option.active:hover { background-color: #0b5ed7; -} +} + +/* Laparoscopy temporal classification timeline */ +.laparoscopy-patient-detail #timeline-class-list .timeline-class-item { + cursor: pointer; + flex: 1 1 calc(50% - 0.25rem); + min-width: 0; + border-left: 4px solid transparent; +} + +.laparoscopy-patient-detail #timeline-class-list .timeline-class-name { + line-height: 1.2; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-track-wrap { + position: relative; + height: 30px; + border-radius: 8px; + background: linear-gradient(180deg, #f8f9fa 0%, #eef1f4 100%); + border: 1px solid #ced4da; + cursor: pointer; + user-select: none; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-track { + position: absolute; + left: 8px; + right: 8px; + top: 50%; + height: 4px; + transform: translateY(-50%); + border-radius: 999px; + background: #adb5bd; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-pins-layer { + position: absolute; + left: 8px; + right: 8px; + top: 0; + bottom: 0; + pointer-events: none; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-segments-layer { + position: absolute; + left: 8px; + right: 8px; + top: 0; + bottom: 0; + pointer-events: none; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-segment { + position: absolute; + top: 50%; + height: 8px; + transform: translateY(-50%); + border-radius: 999px; + background: var(--segment-color, #6c757d); + opacity: 0.7; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-pin { + position: absolute; + top: 0; + transform: translateX(-50%); + width: 14px; + height: 30px; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + pointer-events: auto; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-pin::before { + content: ''; + position: absolute; + top: 2px; + left: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + transform: translateX(-50%); + background: var(--pin-color, #0d6efd); + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.22); +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-pin::after { + content: ''; + position: absolute; + top: 12px; + left: 50%; + width: 2px; + height: 16px; + transform: translateX(-50%); + background: var(--pin-color, #0d6efd); + border-radius: 2px; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-pin.is-selected::before { + width: 12px; + height: 12px; + top: 1px; + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.65); +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-playhead { + position: absolute; + top: 0; + left: 8px; + transform: translateX(-50%); + width: 12px; + height: 30px; + border: 0; + background: transparent; + padding: 0; + cursor: ew-resize; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-playhead::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + width: 2px; + height: 30px; + transform: translateX(-50%); + background: #dc3545; + border-radius: 1px; +} + +.laparoscopy-patient-detail #temporal-classification-bar .timeline-playhead::after { + content: ''; + position: absolute; + left: 50%; + top: 0; + width: 12px; + height: 12px; + transform: translateX(-50%); + border-radius: 50%; + background: #dc3545; + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +@media (max-width: 768px) { + .laparoscopy-patient-detail #temporal-classification-bar #timeline-active-class { + margin-left: 0 !important; + } +} diff --git a/static/js/laparoscopy/laparoscopy_annotator.js b/static/js/laparoscopy/laparoscopy_annotator.js new file mode 100644 index 0000000..8c96e71 --- /dev/null +++ b/static/js/laparoscopy/laparoscopy_annotator.js @@ -0,0 +1,1042 @@ +/** + * laparoscopy_annotator.js + * + * Video frame-by-frame annotation tool using Konva.js. + * + * ── Zoom / pan ────────────────────────────────────────────────────────────── + * Zoom is CSS transform (translate + scale) on a shared inner wrapper (wrapEl) + * that holds both the