diff --git a/app/public/cantusdata/admin/admin.py b/app/public/cantusdata/admin/admin.py index 5392b15c..e5e64013 100644 --- a/app/public/cantusdata/admin/admin.py +++ b/app/public/cantusdata/admin/admin.py @@ -29,6 +29,7 @@ class ManuscriptAdmin(ModelAdmin): # type: ignore[type-arg] actions = [reindex_in_solr, "load_chants"] ordering = ["-public", "name"] list_per_page = 200 + change_form_template = "admin/manuscript_change_form.html" fieldsets = [ ( "Metadata", diff --git a/app/public/cantusdata/helpers/iiif_helpers.py b/app/public/cantusdata/helpers/iiif_helpers.py new file mode 100644 index 00000000..b9fdd837 --- /dev/null +++ b/app/public/cantusdata/helpers/iiif_helpers.py @@ -0,0 +1,32 @@ +""" +Functions for IIIF API boilerplate. +""" + + +def construct_image_api_url(image_uri: str, **kwargs: str | int) -> str: + """ + Construct a IIIF image API request URL from an Image URI and + a set of apotion IIIF Image API parameters. Works with the Image + API v2 and v3. + + Required parameters: + - image_uri: The URI of the image to request. + + Optional parameters (see API docs, eg. https://iiif.io/api/image/2.1/): + - region: The region of the image to request. Region should be a string + that conforms to the IIIF Image API specification (see link above). + - size: The size of the image to request. + - rotation: The rotation of the image to request. + - quality: The quality of the image to request. + - format: The format of the image to request. + + + Returns: + The constructed IIIF image API request URL. + """ + region = kwargs.get("region", "full") # Default to full image + size = kwargs.get("size", "pct:50") # Default to 50% of the original size + rotation = kwargs.get("rotation", "0") # Default to no rotation + quality = kwargs.get("quality", "default") # Default to default quality + img_format = kwargs.get("format", "jpg") # Default to JPEG format + return f"{image_uri}/{region}/{size}/{rotation}/{quality}.{img_format}" diff --git a/app/public/cantusdata/management/commands/pick_neume_exemplars.py b/app/public/cantusdata/management/commands/pick_neume_exemplars.py deleted file mode 100644 index 464c228b..00000000 --- a/app/public/cantusdata/management/commands/pick_neume_exemplars.py +++ /dev/null @@ -1,247 +0,0 @@ -import os -import os.path -from itertools import chain -import re - -import pymei -from django.core.management.base import BaseCommand, make_option - -from cantusdata.models.neume_exemplar import NeumeExemplar -from cantusdata.models.manuscript import Manuscript -from cantusdata.models.folio import Folio - - -# It's pretty awful that we need to use tricks like this to work out what folio is what -FOLIO_REGEX = re.compile(r"_([a-z0-9]+)\.\w+$", re.IGNORECASE) - - -class DocumentManipulationException(Exception): - pass - - -class Command(BaseCommand): - """Automatically select exemplars for all neumes found in a manuscript""" - - option_list = BaseCommand.option_list + ( - make_option( - "--use-specific", - nargs=2, - metavar="NEUME_NAME INDEX", - help="Use the ith instance of the neume in the specified file as an exemplar (only works when " - "given an MEI file, not a directory; index begins at 0)", - ), - ) - - args = "manuscript_id file_or_directory" - - def handle(self, *args, **options): - (ms_id, target) = args - - try: - ms_id = int(ms_id) - except (ValueError, TypeError): - raise ValueError("expected a manuscript id but got {!r}".format(ms_id)) - - # Get the manuscript object, primarily to ensure that it actually exists - self.manuscript = Manuscript.objects.get(id=ms_id) - - # Exemplar - self.exemplars = { - exemplar.name: exemplar - for exemplar in NeumeExemplar.objects.filter( - folio__manuscript=self.manuscript - ) - } - - if options["use_specific"]: - if not os.path.isfile(target): - self.stderr.write( - "--use-specific only works when given a specific MEI file" - ) - return - - name, index = options["use_specific"] - - try: - index = int(index) - except (TypeError, ValueError): - self.stderr.write("expected an integer but got {}".format(index)) - return - - self.use_specific_exemplar(target, name, index) - return - - if os.path.isdir(target): - files = chain.from_iterable( - (os.path.join(p, f) for f in fs) for (p, _, fs) in os.walk(target) - ) - for file_path in files: - self.find_exemplars(file_path) - else: - self.find_exemplars(target) - - def find_exemplars(self, file_path): - try: - folio_number, folio, doc = self.load_document(file_path) - except DocumentManipulationException as e: - self.stderr.write(e.message) - return - - for neume in doc.getElementsByName("neume"): - name_attr = neume.getAttribute("name") - - if not name_attr: - continue - - name = name_attr.value - - if name in self.exemplars: - continue - - try: - self.save_exemplar(neume, name, folio_number, folio, doc) - except DocumentManipulationException as e: - self.stderr.write(e.message) - else: - self.stdout.write( - 'using the first instance on folio {} for "{}"'.format( - folio_number, name - ) - ) - - def use_specific_exemplar(self, file_path, desired_name, index): - try: - folio_number, folio, doc = self.load_document(file_path) - except DocumentManipulationException as e: - self.stderr.write(e.message) - return - - instances_found = 0 - - for neume in doc.getElementsByName("neume"): - name_attr = neume.getAttribute("name") - - if not name_attr: - continue - - name = name_attr.value - - if name != desired_name: - continue - - if instances_found == index: - old_exemplar = self.exemplars.get(desired_name) - - try: - self.save_exemplar(neume, name, folio_number, folio, doc) - except DocumentManipulationException as e: - self.stderr.write(e.message) - else: - if old_exemplar: - old_exemplar.delete() - - return - else: - instances_found += 1 - - self.stderr.write( - "could not get instance {} of neume {}: only {} instances found".format( - index, desired_name, instances_found - ) - ) - - def save_exemplar(self, neume, name, folio_number, folio, doc): - try: - zone = get_zone(doc, neume.getAttribute("facs").value) - except DocumentManipulationException as e: - raise DocumentManipulationException( - "failed to get zone for neume {}, folio {}: {}".format( - name, folio_number, e - ) - ) - - x = zone["ulx"] - y = zone["uly"] - width = zone["lrx"] - zone["ulx"] - height = zone["lry"] - zone["uly"] - - exemplar = NeumeExemplar( - name=name, - folio=folio, - x_coord=x, - y_coord=y, - width=width, - height=height, - ) - exemplar.save() - - self.exemplars[name] = exemplar - - def load_document(self, file_path): - folio_number = get_folio(file_path) - - if folio_number is None: - raise DocumentManipulationException( - "could not identify folio for file {}".format(file_path) - ) - - try: - folio = Folio.objects.get(number=folio_number, manuscript=self.manuscript) - except Folio.DoesNotExist: - raise DocumentManipulationException( - "no folio with number {} in manuscript".format(folio_number) - ) - except Folio.MultipleObjectsReturned: - raise DocumentManipulationException( - "multiple folios with number {} in manuscript...".format(folio_number) - ) - - doc = pymei.documentFromFile(file_path, False).getMeiDocument() - - return folio_number, folio, doc - - -def get_folio(file_path): - match = FOLIO_REGEX.search(file_path) - - if not match: - return None - - return match.group(1) - - -def get_zone(doc, zone_id): - zone = doc.getElementById(zone_id) - - if zone is None: - raise DocumentManipulationException( - "no element with id {} (expected a zone)".format(zone_id) - ) - - if zone.name != "zone": - raise DocumentManipulationException( - "expected #{} to be a zone but got {}".format(zone_id, zone.name) - ) - - attrs = {} - - for attr_name in ("ulx", "uly", "lrx", "lry"): - attr = zone.getAttribute(attr_name) - - if attr is None: - raise DocumentManipulationException( - "no attr {} found for zone #{}".format(attr_name, zone) - ) - - try: - value = int(attr.value) - except (TypeError, ValueError): - raise DocumentManipulationException( - "non-integer value {!r} for attribute {}, zone #{}".format( - attr.value, attr_name, zone - ) - ) - - attrs[attr_name] = value - - return attrs diff --git a/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py b/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py new file mode 100644 index 00000000..37994ca9 --- /dev/null +++ b/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.7 on 2024-09-19 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cantusdata", "0003_chant_differentiae_database"), + ] + + operations = [ + migrations.AlterModelOptions( + name="neumeexemplar", + options={"ordering": ["order"]}, + ), + migrations.AddField( + model_name="neumeexemplar", + name="order", + field=models.IntegerField( + default=1, + help_text="A helper field used to order the exemplars.\n See helpers/neume_helpers.py for an explanation of how this is used.", + ), + ), + ] diff --git a/app/public/cantusdata/models/manuscript.py b/app/public/cantusdata/models/manuscript.py index 021cbfb4..c2d0ad09 100644 --- a/app/public/cantusdata/models/manuscript.py +++ b/app/public/cantusdata/models/manuscript.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models.query import QuerySet from django.core.management import call_command from django.utils.text import slugify import threading @@ -82,7 +83,7 @@ def siglum_slug(self): return slugify(self.siglum) @property - def neume_exemplars(self): + def neume_exemplars(self) -> QuerySet[NeumeExemplar]: return NeumeExemplar.objects.filter(folio__manuscript=self) def create_solr_record(self): diff --git a/app/public/cantusdata/models/neume_exemplar.py b/app/public/cantusdata/models/neume_exemplar.py index 7dd31516..78c1efac 100644 --- a/app/public/cantusdata/models/neume_exemplar.py +++ b/app/public/cantusdata/models/neume_exemplar.py @@ -1,17 +1,20 @@ +from typing import Any + from django.db import models +from django.utils.html import format_html +from django.contrib import admin from cantusdata.models.folio import Folio +from cantusdata.helpers.iiif_helpers import construct_image_api_url +from cantusdata.helpers.neume_helpers import NEUME_NAMES - -ADMIN_IMAGE_TEMPLATE = ( - '{name}' -) ADMIN_IMAGE_HEIGHT = 100 +EXEMPLAR_IMAGE_SIDE_LENGTH = 80 class NeumeExemplar(models.Model): - """Store the coordinates of an exemplary instance of a neume of a particular type + """ + Store the coordinates of an exemplary instance of a neume of a particular type These are used in OMR search to give examples of the neumes available for some manuscript @@ -19,7 +22,7 @@ class NeumeExemplar(models.Model): class Meta: app_label = "cantusdata" - ordering = ["name"] + ordering = ["order"] name = models.CharField(max_length=255, blank=False, null=False) folio = models.ForeignKey(Folio, on_delete=models.CASCADE) @@ -28,33 +31,40 @@ class Meta: y_coord = models.IntegerField() width = models.IntegerField() height = models.IntegerField() + order = models.IntegerField( + help_text="""A helper field used to order the exemplars. + See helpers/neume_helpers.py for an explanation of how this is used.""", + default=1, + ) - def admin_image(self): - """Return HTML to display the page snippet for the exemplar + @admin.display(description="Image") + def admin_image(self) -> str: + """ + Return HTML to display the page snippet for the exemplar NOTE: This is intended for use in the admin interface, not the client """ - return ADMIN_IMAGE_TEMPLATE.format( - base_url="https://images.simssa.ca/iiif/image/cdn-hsmu-m2149l4", - siglum=self.folio.manuscript.siglum_slug, - folio=self.folio.number, - x=self.x_coord, - y=self.y_coord, - w=self.width, - h=self.height, - img_height=ADMIN_IMAGE_HEIGHT, - name=self.name, - ) - - admin_image.allow_tags = True - - def __str__(self): - return "{} - {}, {} at ({}, {}), {}x{}".format( - self.name, - self.folio.manuscript.siglum, - self.folio.number, - self.x_coord, - self.y_coord, - self.width, - self.height, - ) + image_uri = self.folio.image_uri + # If a neume exemplar has been chosen, the associated folio should have an image, + # but in case the uri was removed after selection, we should check for it here + if image_uri: + image_url = construct_image_api_url( + image_uri, + region=f"{self.x_coord},{self.y_coord},{self.width},{self.height}", + size=f"{ADMIN_IMAGE_HEIGHT},", + ) + return format_html( + "{}/", + image_url, + self.name, + ) + return "" + + def save(self, *args: Any, **kwargs: Any) -> None: + """ + Calculate the "order" field based on the neume name so that the exemplars + are ordered consistently. + """ + # Make the value of order the 1-indexed position of the neume in the list of neumes + self.order = NEUME_NAMES.index(self.name) + 1 + super().save(*args, **kwargs) diff --git a/app/public/cantusdata/serializers/neume_exemplar.py b/app/public/cantusdata/serializers/neume_exemplar.py index 94fdfc84..555dfec0 100644 --- a/app/public/cantusdata/serializers/neume_exemplar.py +++ b/app/public/cantusdata/serializers/neume_exemplar.py @@ -1,17 +1,45 @@ -from cantusdata.models.neume_exemplar import NeumeExemplar +from typing import cast + from rest_framework import serializers +from django.db.models.query import QuerySet +from cantusdata.models import NeumeExemplar, Folio + + +class NeumeExemplarListSerializer(serializers.ListSerializer[NeumeExemplar]): + def update( # type: ignore + self, + instance: QuerySet[NeumeExemplar], + validated_data: list[dict[str, str | int]], + ) -> QuerySet[NeumeExemplar]: + instance_mapping = { + neume_exemplar.name: neume_exemplar for neume_exemplar in instance + } + for data in validated_data: + # We know the value of the "name" key is a string + neume_name = cast(str, data["name"]) + neume_exemplar = instance_mapping.get(neume_name, None) + if neume_exemplar is not None: + self.child.update(neume_exemplar, data) # type: ignore[union-attr] + return instance.all() -class NeumeExemplarSerializer(serializers.ModelSerializer): + +class NeumeExemplarSerializer(serializers.ModelSerializer[NeumeExemplar]): class Meta: model = NeumeExemplar - fields = ("name", "siglum_slug", "p", "x", "y", "w", "h") - - siglum_slug = serializers.SlugField(source="folio.manuscript.siglum_slug") + fields = ["manuscript", "folio", "name", "p", "x", "y", "w", "h"] + list_serializer_class = NeumeExemplarListSerializer + manuscript: str = serializers.StringRelatedField(source="folio.manuscript.name") # type: ignore + folio: str = serializers.StringRelatedField(source="folio.number") # type: ignore # These short forms match the values given in the boxes # in Solr OMR results - p = serializers.CharField(source="folio.image_uri") + p = serializers.SlugRelatedField( + slug_field="image_uri", + queryset=Folio.objects.all(), + source="folio", + style={"base_template": "input.html"}, + ) x = serializers.IntegerField(source="x_coord") y = serializers.IntegerField(source="y_coord") w = serializers.IntegerField(source="width") diff --git a/app/public/cantusdata/templates/admin/manuscript_change_form.html b/app/public/cantusdata/templates/admin/manuscript_change_form.html new file mode 100644 index 00000000..4d919ded --- /dev/null +++ b/app/public/cantusdata/templates/admin/manuscript_change_form.html @@ -0,0 +1,9 @@ +{% extends "admin/change_form.html" %} +{% block after_related_objects %} +
+

Neume Exemplars

+
+ Pick neume exemplars here. +
+
+{% endblock %} diff --git a/app/public/cantusdata/templates/admin/pick_neume_exemplars.html b/app/public/cantusdata/templates/admin/pick_neume_exemplars.html new file mode 100644 index 00000000..7501365c --- /dev/null +++ b/app/public/cantusdata/templates/admin/pick_neume_exemplars.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% block head %} + + +{% endblock %} +{% block body %} +
+
+
+

Neume Exemplars: {{ manuscript.name }}

+ {% if not ngrams_indexed %} +

+ It looks like this manuscript does not have any indexed MEI. Please index MEI for this manuscript using the index_manuscript_mei command before choosing neume exemplars. +

+ {% else %} +

Choose an exemplar for each neume.

+
+ {% csrf_token %} + {% for neume_name, initial_exemplar_data in neume_data.items %} +
+ + + + + + + +
+

+ {{ neume_name }} +

+

({{ initial_exemplar_data.neume_count }} in manuscript)

+
+ {% if initial_exemplar_data.current_neume_exemplar %} + Current exemplar for {{ neume.name }} + {% else %} + No existing exemplar for this neume. + {% endif %} + + {% for exemplar_folio, exemplar_image in neume_data.exemplars %} + Exemplar for {{ neume.name }} on folio {{ exemplar_folio }} + {% endfor %} +
+
+ {% endfor %} + +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/public/cantusdata/templates/base.html b/app/public/cantusdata/templates/base.html index 81088ea3..458aeed1 100644 --- a/app/public/cantusdata/templates/base.html +++ b/app/public/cantusdata/templates/base.html @@ -33,7 +33,7 @@