diff --git a/config/api_router.py b/config/api_router.py index 04de9c4..7ddcb0f 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,11 +1,19 @@ from dicom.api.views import ( + AddDicomProjectApi, CreateCircleApi, + CreateFreeHandApi, CreateRoiApi, + CreateRulerApi, + DeleteDicomProjectApi, ListCreateDicomApi, + ListCreateProjectApi, ListUpdateDicomImageNumberApi, RetrieveUpdateDeleteCircleApi, RetrieveUpdateDeleteDicomApi, + RetrieveUpdateDeleteFreeHandApi, + RetrieveUpdateDeleteProjectApi, RetrieveUpdateDeleteRoiApi, + RetrieveUpdateDeleteRulerApi, SmartFileUploadApi, ) from django.urls import include, path @@ -39,11 +47,21 @@ urlpatterns = [ CreateRoiApi.as_view(), name="create_roi", ), + path( + "/free_hand", + CreateFreeHandApi.as_view(), + name="create_free_hand", + ), path( "/circle", CreateCircleApi.as_view(), name="create_circle", ), + path( + "/ruler", + CreateRulerApi.as_view(), + name="create_ruler", + ), path( "/", ListUpdateDicomImageNumberApi.as_view(), @@ -61,11 +79,44 @@ urlpatterns = [ RetrieveUpdateDeleteRoiApi.as_view(), name="get_update_delete_roi", ), + path( + "free_hand/", + RetrieveUpdateDeleteFreeHandApi.as_view(), + name="get_update_delete_free_hand", + ), path( "circle/", RetrieveUpdateDeleteCircleApi.as_view(), name="get_update_delete_circle", ), + path( + "ruler/", + RetrieveUpdateDeleteRulerApi.as_view(), + name="get_update_delete_ruler", + ), + ] + ), + ), + path( + "project/", + include( + [ + path("", ListCreateProjectApi.as_view(), name="list_create_project"), + path( + "", + RetrieveUpdateDeleteProjectApi.as_view(), + name="get_update_delete_project", + ), + path( + "/upload", + AddDicomProjectApi.as_view(), + name="add_dicom_api", + ), + path( + "/", + DeleteDicomProjectApi.as_view(), + name="delete_dicom_api", + ), ] ), ), diff --git a/image_markuper/dicom/admin.py b/image_markuper/dicom/admin.py index 72ec33b..19b3492 100644 --- a/image_markuper/dicom/admin.py +++ b/image_markuper/dicom/admin.py @@ -1,6 +1,7 @@ -from dicom.models import Circle, Dicom, Roi +from dicom.models import Circle, Dicom, FreeHand, Roi from django.contrib import admin admin.site.register(Dicom) admin.site.register(Circle) admin.site.register(Roi) +admin.site.register(FreeHand) diff --git a/image_markuper/dicom/api/serializers.py b/image_markuper/dicom/api/serializers.py index 89927fb..f96f4a7 100644 --- a/image_markuper/dicom/api/serializers.py +++ b/image_markuper/dicom/api/serializers.py @@ -1,18 +1,19 @@ -from dicom.models import Circle, Coordinate, Dicom, Roi +from dicom.models import ( + BaseShape, + Circle, + Coordinate, + Dicom, + FreeHand, + Project, + Roi, + Ruler, +) +from dicom.services import create_coordinate from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.generics import get_object_or_404 -def create_coordinate(coordinates, obj): - for coordinate in coordinates: - Coordinate.objects.create( - x=coordinate["x"], - y=coordinate["y"], - shape=obj, - ) - - class CoordinateSerializer(serializers.ModelSerializer): class Meta: model = Coordinate @@ -34,13 +35,43 @@ class ListDicomSerializer(serializers.ModelSerializer): class BaseShapeSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=["circle", "roi"]) + model = BaseShape + + type = serializers.ChoiceField(choices=["circle", "roi", "free_hand"]) image_number = serializers.IntegerField() coordinates = CoordinateSerializer(many=True) + def create(self, validated_data): + if self.model.max_coordinates: + if len(validated_data["coordinates"]) > self.model.max_coordinates: + raise serializers.ValidationError + if self.model.min_coordinates: + if len(validated_data["coordinates"]) < self.model.min_coordinates: + raise serializers.ValidationError + dicom = get_object_or_404( + Dicom, slug=self.context["request"].parser_context["kwargs"]["slug"] + ) + obj = self.model.objects.create( + dicom=dicom, image_number=validated_data["image_number"] + ) + + create_coordinate(validated_data["coordinates"], obj) + return obj + + def update(self, obj, validated_data): + Coordinate.objects.filter(shape=obj).delete() + if self.model.max_coordinates: + if len(validated_data["coordinates"]) > self.model.max_coordinates: + raise serializers.ValidationError + if self.model.min_coordinates: + if len(validated_data["coordinates"]) < self.model.min_coordinates: + raise serializers.ValidationError + create_coordinate(validated_data["coordinates"], obj) + return obj + class BaseShapeLayerSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=["circle", "roi"]) + type = serializers.ChoiceField(choices=["circle", "roi", "free_hand"]) radius = serializers.FloatField(required=False) coordinates = CoordinateSerializer(many=True) @@ -58,31 +89,34 @@ class DicomSerializer(serializers.ModelSerializer): fields = ["file", "uploaded", "pathology_type", "shapes"] -class RoiSerializer(serializers.ModelSerializer): +class RoiSerializer(BaseShapeSerializer, serializers.ModelSerializer): coordinates = CoordinateSerializer(many=True) + model = Roi class Meta: model = Roi fields = ["id", "image_number", "coordinates"] extra_kwargs = {"id": {"read_only": True}} - def create(self, validated_data): - if "coordinates" not in validated_data: - raise serializers.ValidationError - dicom = get_object_or_404( - Dicom, slug=self.context["request"].parser_context["kwargs"]["slug"] - ) - roi = Roi.objects.create( - dicom=dicom, image_number=validated_data["image_number"] - ) - create_coordinate(validated_data["coordinates"], roi) - return roi +class FreeHandSerializer(BaseShapeSerializer, serializers.ModelSerializer): + coordinates = CoordinateSerializer(many=True) + model = FreeHand - def update(self, obj: Circle, validated_data): - Coordinate.objects.filter(shape=obj).delete() - create_coordinate(validated_data["coordinates"], obj) - return obj + class Meta: + model = FreeHand + fields = ["id", "image_number", "coordinates"] + extra_kwargs = {"id": {"read_only": True}} + + +class RulerSerializer(BaseShapeSerializer, serializers.ModelSerializer): + coordinates = CoordinateSerializer(many=True) + model = Ruler + + class Meta: + model = FreeHand + fields = ["id", "image_number", "coordinates"] + extra_kwargs = {"id": {"read_only": True}} class CircleSerializer(serializers.ModelSerializer): @@ -122,3 +156,24 @@ class CircleSerializer(serializers.ModelSerializer): class SmartFileUploadSerializer(serializers.Serializer): file = serializers.FileField() + + +class ListProjectSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="get_update_delete_project", lookup_field="slug" + ) + + class Meta: + model = Project + fields = ["url", "created"] + extra_kwargs = { + "created": {"read_only": True}, + } + + +class ProjectSerializer(serializers.ModelSerializer): + files = ListDicomSerializer(many=True) + + class Meta: + model = Project + fields = ["files", "created"] diff --git a/image_markuper/dicom/api/views.py b/image_markuper/dicom/api/views.py index e952571..1d78653 100644 --- a/image_markuper/dicom/api/views.py +++ b/image_markuper/dicom/api/views.py @@ -5,15 +5,19 @@ from rest_framework.generics import GenericAPIView, get_object_or_404 from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response -from ..models import Circle, Dicom, Roi +from ..models import Circle, Dicom, Project, Roi from ..services import process_files from .serializers import ( BaseShapeLayerSerializer, BaseShapeSerializer, CircleSerializer, DicomSerializer, + FreeHandSerializer, ListDicomSerializer, + ListProjectSerializer, + ProjectSerializer, RoiSerializer, + RulerSerializer, SmartFileUploadSerializer, create_coordinate, ) @@ -41,55 +45,110 @@ class CreateRoiApi(generics.CreateAPIView): serializer_class = RoiSerializer +class CreateFreeHandApi(generics.CreateAPIView): + serializer_class = FreeHandSerializer + + class CreateCircleApi(generics.CreateAPIView): serializer_class = CircleSerializer -class RetrieveUpdateDeleteRoiApi(generics.RetrieveUpdateDestroyAPIView): +class CreateRulerApi(generics.CreateAPIView): + serializer_class = RulerSerializer + + +class RetrieveUpdateDeleteBaseShape(generics.RetrieveUpdateDestroyAPIView): + def get_object(self): + return get_object_or_404( + self.serializer_class.Meta.model, + id=self.request.parser_context["kwargs"]["id"], + ) + + @extend_schema(description="Note: coordinated are dropped on update") + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + @extend_schema(description="Note: coordinated are dropped on update") + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class RetrieveUpdateDeleteRoiApi(RetrieveUpdateDeleteBaseShape): serializer_class = RoiSerializer - def get_object(self): - return get_object_or_404(Roi, id=self.request.parser_context["kwargs"]["id"]) - @extend_schema(description="Note: coordinated are dropped on update") - def put(self, request, *args, **kwargs): - return self.update(request, *args, **kwargs) - - @extend_schema(description="Note: coordinated are dropped on update") - def patch(self, request, *args, **kwargs): - return self.partial_update(request, *args, **kwargs) +class RetrieveUpdateDeleteFreeHandApi(RetrieveUpdateDeleteBaseShape): + serializer_class = FreeHandSerializer -class RetrieveUpdateDeleteCircleApi(generics.RetrieveUpdateDestroyAPIView): +class RetrieveUpdateDeleteCircleApi(RetrieveUpdateDeleteBaseShape): serializer_class = CircleSerializer - def get_object(self): - return get_object_or_404(Circle, id=self.request.parser_context["kwargs"]["id"]) - @extend_schema(description="Note: coordinated are dropped on update") - def patch(self, request, *args, **kwargs): - return self.partial_update(request, *args, **kwargs) - - @extend_schema(description="Note: coordinated are dropped on update") - def put(self, request, *args, **kwargs): - return self.update(request, *args, **kwargs) +class RetrieveUpdateDeleteRulerApi(RetrieveUpdateDeleteBaseShape): + serializer_class = CircleSerializer class SmartFileUploadApi(GenericAPIView): parser_classes = [MultiPartParser, FormParser] serializer_class = SmartFileUploadSerializer - @extend_schema(responses={201: DicomSerializer(many=True)}) + @extend_schema(responses={201: ListDicomSerializer(many=True)}) def post(self, request): if "file" not in request.data: raise ValidationError("no files") - d_list = process_files(request.FILES.getlist("file"), request.user) + project = process_files( + request.FILES.getlist("file"), + request.user, + ) return Response( - DicomSerializer(d_list.files.all(), many=True).data, + ListDicomSerializer(project.files.all(), many=True).data, status=status.HTTP_201_CREATED, ) +class AddDicomProjectApi(GenericAPIView): + parser_classes = [MultiPartParser, FormParser] + serializer_class = SmartFileUploadSerializer + + @extend_schema( + operation_id="add_dicom_to_project", + responses={201: ListDicomSerializer(many=True)}, + ) + def post(self, request, slug): + if "file" not in request.data: + raise ValidationError("no files") + get_object_or_404(Project, slug=slug) + project = process_files( + request.FILES.getlist("file"), + request.user, + slug, + ) + return Response( + ListDicomSerializer(project.files.all(), many=True).data, + status=status.HTTP_201_CREATED, + ) + + +class DeleteDicomProjectApi(GenericAPIView): + serializer_class = SmartFileUploadSerializer + + @extend_schema( + operation_id="add_dicom_to_project", + request=None, + responses={200: ListDicomSerializer(many=True)}, + ) + def delete(self, request, slug, dicom_slug): + project = get_object_or_404(Project, slug=slug) + project.files.filter(slug=dicom_slug).delete() + return Response( + ListDicomSerializer( + project.files.all(), many=True, context={"request": request} + ).data, + status=status.HTTP_200_OK, + ) + + class ListUpdateDicomImageNumberApi(GenericAPIView): serializer_class = BaseShapeSerializer(many=True) @@ -134,3 +193,27 @@ class ListUpdateDicomImageNumberApi(GenericAPIView): ) ] return Response(shapes, status=status.HTTP_200_OK) + + @extend_schema( + request=None, + responses={204: None}, + operation_id="delete_dicom_layer", + ) + def delete(self, request, slug, layer): + dicom = get_object_or_404(Dicom, slug=slug) + dicom.shapes.filter(image_number=layer).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ListCreateProjectApi(generics.ListCreateAPIView): + serializer_class = ListProjectSerializer + + def get_queryset(self): + return Project.objects.filter(user=self.request.user) + + +class RetrieveUpdateDeleteProjectApi(generics.RetrieveUpdateDestroyAPIView): + serializer_class = ProjectSerializer + queryset = Project.objects.all() + + lookup_field = "slug" diff --git a/image_markuper/dicom/models/__init__.py b/image_markuper/dicom/models/__init__.py index 0a6c40b..3a5359c 100644 --- a/image_markuper/dicom/models/__init__.py +++ b/image_markuper/dicom/models/__init__.py @@ -1,3 +1,3 @@ # flake8: noqa -from .base import Dicom, ListOfDicom -from .blocks import BaseShape, Circle, Coordinate, Roi +from .base import Dicom, Project +from .shapes import BaseShape, Circle, Coordinate, FreeHand, Roi, Ruler diff --git a/image_markuper/dicom/models/base.py b/image_markuper/dicom/models/base.py index ad4ef0b..f9c939f 100644 --- a/image_markuper/dicom/models/base.py +++ b/image_markuper/dicom/models/base.py @@ -6,8 +6,14 @@ from utils.files import media_upload_path User = get_user_model() -class ListOfDicom(models.Model): - pass +class Project(models.Model): + user = models.ForeignKey(User, related_name="projects", on_delete=models.CASCADE) + slug = models.SlugField(max_length=10) + + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user.username}'s project" class Dicom(models.Model): @@ -22,8 +28,8 @@ class Dicom(models.Model): uploaded = models.DateTimeField(auto_now_add=True) pathology_type = models.IntegerField(choices=PathologyType.choices, default=0) - list = models.ForeignKey( - ListOfDicom, related_name="files", null=True, on_delete=models.SET_NULL + project = models.ForeignKey( + Project, related_name="files", null=True, on_delete=models.SET_NULL ) def __str__(self): diff --git a/image_markuper/dicom/models/blocks.py b/image_markuper/dicom/models/shapes.py similarity index 78% rename from image_markuper/dicom/models/blocks.py rename to image_markuper/dicom/models/shapes.py index 4246455..d8347e7 100644 --- a/image_markuper/dicom/models/blocks.py +++ b/image_markuper/dicom/models/shapes.py @@ -4,11 +4,26 @@ from polymorphic.models import PolymorphicModel class BaseShape(PolymorphicModel): + TYPE = "no_type" + min_coordinates = None + max_coordinates = None dicom = models.ForeignKey(Dicom, related_name="shapes", on_delete=models.CASCADE) image_number = models.IntegerField() def serialize_self(self): - raise NotImplementedError + return { + "id": self.id, + "type": self.TYPE, + "image_number": self.image_number, + "coordinates": self.coordinates, + } + + def serialize_self_without_layer(self): + return { + "id": self.id, + "type": self.TYPE, + "coordinates": self.coordinates, + } @property def coordinates(self) -> [(int, int)]: @@ -33,6 +48,7 @@ class Coordinate(models.Model): class Circle(BaseShape): radius = models.FloatField() + max_coordinates = 1 def serialize_self(self): return { @@ -56,21 +72,23 @@ class Circle(BaseShape): class Roi(BaseShape): - def serialize_self(self): - return { - "id": self.id, - "type": "roi", - "image_number": self.image_number, - "coordinates": self.coordinates, - } - - def serialize_self_without_layer(self): - return { - "id": self.id, - "type": "roi", - "radius": self.radius, - "coordinates": self.coordinates, - } + TYPE = "roi" def __str__(self): return f"Roi on {self.dicom.file.name}" + + +class FreeHand(BaseShape): + TYPE = "free_hand" + + def __str__(self): + return f"FreeHand on {self.dicom.file.name}" + + +class Ruler(BaseShape): + TYPE = "ruler" + max_coordinates = 2 + min_coordinates = 2 + + def __str__(self): + return f"Ruler on {self.dicom.file.name}" diff --git a/image_markuper/dicom/services.py b/image_markuper/dicom/services.py index 3675266..2a18e34 100644 --- a/image_markuper/dicom/services.py +++ b/image_markuper/dicom/services.py @@ -5,18 +5,23 @@ import zipfile from pathlib import Path import magic -from dicom.models import Dicom, ListOfDicom +from dicom.models import Coordinate, Dicom, Project from django.core.files import File from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile from utils.generators import generate_charset -def process_files(files: list[TemporaryUploadedFile | InMemoryUploadedFile], user): - d_list = ListOfDicom.objects.create() +def process_files( + files: list[TemporaryUploadedFile | InMemoryUploadedFile], user, slug=None +): + if slug: + project = Project.objects.get(slug=slug) + else: + project = Project.objects.create(user=user) for file in files: content_type = magic.from_file(file.temporary_file_path()) if content_type == "DICOM medical imaging data": - Dicom.objects.create(file=file, list=d_list, user=user) + Dicom.objects.create(file=file, project=project, user=user) elif "Zip" in content_type: dit_path = f"/tmp/{generate_charset(10)}" os.mkdir(dit_path) @@ -32,8 +37,17 @@ def process_files(files: list[TemporaryUploadedFile | InMemoryUploadedFile], use with path.open(mode="rb") as f: Dicom.objects.create( file=File(f, name=file_in_d.split("/")[-1]), - list=d_list, + project=project, user=user, ) shutil.rmtree(dit_path) - return d_list + return project + + +def create_coordinate(coordinates, obj): + for coordinate in coordinates: + Coordinate.objects.create( + x=coordinate["x"], + y=coordinate["y"], + shape=obj, + ) diff --git a/image_markuper/dicom/signals.py b/image_markuper/dicom/signals.py index 2f138e9..47305e8 100644 --- a/image_markuper/dicom/signals.py +++ b/image_markuper/dicom/signals.py @@ -1,12 +1,24 @@ -from dicom.models import Dicom -from django.db.models.signals import pre_save +from dicom.models import Dicom, Project +from django.db.models.signals import post_save from django.dispatch import receiver from utils.generators import generate_charset -@receiver(pre_save, sender=Dicom) -def create_dicom(sender, instance: Dicom, **kwargs): - slug = generate_charset(5) - while Dicom.objects.filter(slug=slug): +@receiver(post_save, sender=Project) +def create_project(sender, instance: Project, created, **kwargs): + if created: slug = generate_charset(5) - instance.slug = slug + while Project.objects.filter(slug=slug): + slug = generate_charset(5) + instance.slug = slug + instance.save() + + +@receiver(post_save, sender=Dicom) +def create_dicom(sender, instance: Dicom, created, **kwargs): + if created: + slug = generate_charset(5) + while Dicom.objects.filter(slug=slug): + slug = generate_charset(5) + instance.slug = slug + instance.save()