From 1d1c2fd672385b329a8e3facb14eb2d7f0619558 Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Tue, 14 Apr 2026 10:05:57 -0400 Subject: [PATCH] Add zoomable option to studies --- .../templates/core/partials/image_modal.html | 16 ++++- isic/core/tests/test_image_modal_browser.py | 7 ++- isic/studies/forms.py | 2 + .../migrations/0006_add_study_zoomable.py | 24 ++++++++ isic/studies/models/study.py | 9 +++ isic/studies/services/__init__.py | 6 ++ .../templates/studies/study_create.html | 11 ++++ .../studies/templates/studies/study_edit.html | 11 ++++ .../templates/studies/study_task_detail.html | 9 ++- isic/studies/tests/factories.py | 1 + isic/studies/tests/test_image_zoom_browser.py | 60 +++++++++++++++++++ isic/studies/tests/test_study_browser.py | 2 + isic/studies/tests/test_views.py | 21 +++++++ isic/studies/views.py | 3 +- node-src/cog.mjs | 32 ++++++++++ 15 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 isic/studies/migrations/0006_add_study_zoomable.py create mode 100644 isic/studies/tests/test_image_zoom_browser.py diff --git a/isic/core/templates/core/partials/image_modal.html b/isic/core/templates/core/partials/image_modal.html index 0897b563e..c37246918 100644 --- a/isic/core/templates/core/partials/image_modal.html +++ b/isic/core/templates/core/partials/image_modal.html @@ -12,14 +12,24 @@ diff --git a/isic/core/tests/test_image_modal_browser.py b/isic/core/tests/test_image_modal_browser.py index e4a87d522..ccb5928ce 100644 --- a/isic/core/tests/test_image_modal_browser.py +++ b/isic/core/tests/test_image_modal_browser.py @@ -198,6 +198,7 @@ def test_study_task_image_modal_fits_viewport( collection=collection, public=False, questions=[question], + zoomable=True, ) task = StudyTask.objects.create(study=study, annotator=authenticated_user, image=image) @@ -239,8 +240,8 @@ def test_study_task_image_modal_fits_viewport( modal = page.get_by_role("dialog") expect(modal).to_be_visible() - modal_img = modal.locator("img") - expect(modal_img).to_be_visible() - expect(modal_img).to_have_js_property("complete", value=True) + # The study task modal shows the OL zoom viewer (not a plain img). + viewer = modal.locator(f"#image-{image.pk}") + expect(viewer).to_be_visible() _assert_modal_fits_viewport(modal, viewport) diff --git a/isic/studies/forms.py b/isic/studies/forms.py index d5edb5923..4e176113f 100644 --- a/isic/studies/forms.py +++ b/isic/studies/forms.py @@ -101,6 +101,7 @@ class BaseStudyForm(forms.Form): attribution = fields["attribution"] collection = fields["collection"] public = fields["public"] + zoomable = fields["zoomable"] def __init__(self, *args, **kwargs): collections = kwargs.pop("collections") @@ -151,6 +152,7 @@ class StudyEditForm(forms.Form): name = fields["name"] description = fields["description"] + zoomable = fields["zoomable"] class StudyAddAnnotatorsForm(forms.Form): diff --git a/isic/studies/migrations/0006_add_study_zoomable.py b/isic/studies/migrations/0006_add_study_zoomable.py new file mode 100644 index 000000000..4290bfe17 --- /dev/null +++ b/isic/studies/migrations/0006_add_study_zoomable.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.3 on 2026-04-14 12:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("studies", "0005_alter_annotation_annotator_alter_annotation_image_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="study", + name="zoomable", + field=models.BooleanField( + default=False, + help_text=( + "Whether annotators can zoom and pan images while completing tasks. " + "Enabling zoom may confound inter-rater agreement if experts view " + "images at different magnifications." + ), + ), + ), + ] diff --git a/isic/studies/models/study.py b/isic/studies/models/study.py index 00a03d2c5..008c3a321 100644 --- a/isic/studies/models/study.py +++ b/isic/studies/models/study.py @@ -57,6 +57,15 @@ class Meta(TimeStampedModel.Meta): ), ) + zoomable = models.BooleanField( + default=False, + help_text=( + "Whether annotators can zoom and pan images while completing tasks. " + "Enabling zoom may confound inter-rater agreement if experts view " + "images at different magnifications." + ), + ) + objects = StudyQuerySet.as_manager() def __str__(self) -> str: diff --git a/isic/studies/services/__init__.py b/isic/studies/services/__init__.py index d79560a65..e9a5aaecb 100644 --- a/isic/studies/services/__init__.py +++ b/isic/studies/services/__init__.py @@ -1,6 +1,7 @@ import itertools from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db import transaction from django.db.models.query import QuerySet @@ -18,6 +19,7 @@ def study_create( # noqa: PLR0913 description: str, collection: Collection, public: bool, + zoomable: bool = False, ) -> Study: study = Study( creator=creator, @@ -26,6 +28,7 @@ def study_create( # noqa: PLR0913 description=description, collection=collection, public=public, + zoomable=zoomable, ) study.full_clean() @@ -38,6 +41,9 @@ def study_create( # noqa: PLR0913 def study_update(*, study: Study, **fields): + if "zoomable" in fields and fields["zoomable"] != study.zoomable and study.annotations.exists(): + raise ValidationError("Zoomable cannot be changed after responses have been recorded.") + for field, value in fields.items(): setattr(study, field, value) diff --git a/isic/studies/templates/studies/study_create.html b/isic/studies/templates/studies/study_create.html index 53e8348d1..22c70536f 100644 --- a/isic/studies/templates/studies/study_create.html +++ b/isic/studies/templates/studies/study_create.html @@ -244,6 +244,17 @@ {{ base_form.public.errors }} + +
+ +
+ +

{{ base_form.zoomable.help_text }}

+ {{ base_form.zoomable.errors }} +
+
diff --git a/isic/studies/templates/studies/study_edit.html b/isic/studies/templates/studies/study_edit.html index b79b14aed..0c16b4515 100644 --- a/isic/studies/templates/studies/study_edit.html +++ b/isic/studies/templates/studies/study_edit.html @@ -38,6 +38,17 @@ {{ form.description.errors }}
+ +
+ +
+ +

{{ form.zoomable.help_text }}

+ {{ form.zoomable.errors }} +
+
diff --git a/isic/studies/templates/studies/study_task_detail.html b/isic/studies/templates/studies/study_task_detail.html index 067ad7614..78792a8e3 100644 --- a/isic/studies/templates/studies/study_task_detail.html +++ b/isic/studies/templates/studies/study_task_detail.html @@ -1,5 +1,12 @@ {% extends 'core/base.html' %} {% load humanize %} +{% load static %} + +{% block head_extra %} + {{ block.super }} + + +{% endblock %} {% block content %} @@ -60,7 +67,7 @@
diff --git a/isic/studies/tests/factories.py b/isic/studies/tests/factories.py index 2e9040e99..a5535ef59 100644 --- a/isic/studies/tests/factories.py +++ b/isic/studies/tests/factories.py @@ -63,6 +63,7 @@ class Meta: name = factory.Faker("text", max_nb_chars=100) description = factory.Faker("sentences") + attribution = factory.Faker("company") collection = factory.SubFactory(CollectionFactory) public = factory.Faker("boolean") diff --git a/isic/studies/tests/test_image_zoom_browser.py b/isic/studies/tests/test_image_zoom_browser.py new file mode 100644 index 000000000..a198bf0fb --- /dev/null +++ b/isic/studies/tests/test_image_zoom_browser.py @@ -0,0 +1,60 @@ +import pathlib + +from django.urls import reverse +from playwright.sync_api import expect +import pytest + +from isic.core.services.collection.image import collection_add_images +from isic.studies.models import Question, QuestionChoice, StudyTask + +_TEST_IMAGE = pathlib.Path(__file__).parent.parent.parent / "ingest/tests/data/ISIC_0000000.jpg" + + +@pytest.mark.playwright +def test_study_task_image_is_zoomable( + authenticated_page, authenticated_user, collection_factory, image_factory, study_factory +): + page = authenticated_page + user = authenticated_user + + collection = collection_factory(creator=user) + image = image_factory(public=True) + collection_add_images(collection=collection, image=image) + + question = Question.objects.create( + prompt="Is this benign?", type=Question.QuestionType.SELECT, official=False + ) + QuestionChoice.objects.create(question=question, text="Yes") + + study = study_factory( + creator=user, + collection=collection, + public=False, + questions=[question], + questions__required=True, + zoomable=True, + ) + + task = StudyTask.objects.create(study=study, annotator=user, image=image) + + # Serve the local test JPEG for any image request so the OL viewer can load + # the image regardless of whether the Minio URL is reachable from the browser. + page.route("**/*.jpg", lambda route: route.fulfill(path=str(_TEST_IMAGE))) + + page.goto(reverse("study-task-detail", args=[task.pk])) + + # Click the study image to open the full-screen modal; mouseenter fires first + # (setting hovered=true), then the click fires (setting open=true). + page.locator("img.max-w-full.h-auto").click() + + # The zoomable viewer container (512x512) is visible at the top of the modal. + viewer = page.locator(f"#image-{image.pk}") + expect(viewer).to_be_visible() + + # OpenLayers renders a inside the viewer container once the image loads. + # Check this while the viewer is still at the top of the dialog (before scrolling). + expect(viewer.locator("canvas")).to_be_visible() + + # The hint text confirms the zoom viewer is shown instead of a plain . + hint = page.get_by_text("Scroll to zoom, click and drag to pan") + expect(hint).to_be_visible() diff --git a/isic/studies/tests/test_study_browser.py b/isic/studies/tests/test_study_browser.py index 662fa1b97..21fa6319b 100644 --- a/isic/studies/tests/test_study_browser.py +++ b/isic/studies/tests/test_study_browser.py @@ -30,6 +30,7 @@ def test_study_create_with_official_and_custom_questions( # noqa: PLR0915 page.get_by_label("Attribution").fill(study_attribution) page.get_by_label("Collection").select_option(str(collection.pk)) page.get_by_label("Annotators").fill(user.email) + page.get_by_label("Zoomable").check() # -- Official question picker modal -- page.get_by_text("Add Official Question").click() @@ -94,6 +95,7 @@ def test_study_create_with_official_and_custom_questions( # noqa: PLR0915 assert study.collection == collection assert study.creator == user assert study.public is False + assert study.zoomable is True # The collection should be locked after study creation collection.refresh_from_db() diff --git a/isic/studies/tests/test_views.py b/isic/studies/tests/test_views.py index 3132bd962..30077f397 100644 --- a/isic/studies/tests/test_views.py +++ b/isic/studies/tests/test_views.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.urls.base import reverse from django.utils import timezone import pytest @@ -6,6 +7,7 @@ from isic.factories import UserFactory from isic.studies.models import Question, Response from isic.studies.models.question_choice import QuestionChoice +from isic.studies.services import study_update from isic.studies.tests.factories import ( AnnotationFactory, QuestionFactory, @@ -160,6 +162,25 @@ def test_study_detail_hides_image_metadata(client): assert "male" not in content +@pytest.mark.django_db +def test_study_update_zoomable_blocked_after_response(): + study = StudyFactory.create(public=False, zoomable=False) + AnnotationFactory.create(study=study) + + with pytest.raises(ValidationError, match="Zoomable cannot be changed"): + study_update(study=study, zoomable=True) + + +@pytest.mark.django_db +def test_study_update_zoomable_allowed_before_any_responses(): + study = StudyFactory.create(public=False, zoomable=False) + + study_update(study=study, zoomable=True) + + study.refresh_from_db() + assert study.zoomable is True + + @pytest.mark.django_db def test_study_task_detail_hides_image_metadata(client): image = ImageFactory.create(public=False, accession__sex="male") diff --git a/isic/studies/views.py b/isic/studies/views.py index e3615bef4..0eb9f0fbb 100644 --- a/isic/studies/views.py +++ b/isic/studies/views.py @@ -122,6 +122,7 @@ def study_create_view(request): description=base_form.cleaned_data["description"], collection=base_form.cleaned_data["collection"], public=base_form.cleaned_data["public"], + zoomable=base_form.cleaned_data["zoomable"], ) for question in official_question_formset.cleaned_data: @@ -169,7 +170,7 @@ def study_create_view(request): def study_edit(request, pk): study = get_object_or_404(Study, pk=pk) form = StudyEditForm( - request.POST or {key: getattr(study, key) for key in ["name", "description"]} + request.POST or {key: getattr(study, key) for key in ["name", "description", "zoomable"]} ) if request.method == "POST" and form.is_valid(): diff --git a/node-src/cog.mjs b/node-src/cog.mjs index badca6a1b..d7159456b 100644 --- a/node-src/cog.mjs +++ b/node-src/cog.mjs @@ -2,6 +2,10 @@ import Map from 'ol/Map.js'; import View from 'ol/View.js'; import TileLayer from 'ol/layer/WebGLTile.js'; import GeoTIFF from 'ol/source/GeoTIFF.js'; +import ImageLayer from 'ol/layer/Image.js'; +import ImageStatic from 'ol/source/ImageStatic.js'; +import Projection from 'ol/proj/Projection.js'; +import { getCenter } from 'ol/extent.js'; async function initializeCogViewer(target, url) { const source = new GeoTIFF({ @@ -35,4 +39,32 @@ async function initializeCogViewer(target, url) { }); } +async function initializeImageViewer(target, url) { + // ImageStatic requires the image extent upfront, and unlike GeoTIFF there is no + // embedded metadata OL can read — so we must load the image first to get its dimensions. + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = reject; + i.src = url; + }); + + const extent = [0, 0, img.naturalWidth, img.naturalHeight]; + const projection = new Projection({ code: 'raster', units: 'pixels', extent }); + + new Map({ + target, + layers: [new ImageLayer({ source: new ImageStatic({ url, projection, imageExtent: extent }) })], + view: new View({ + projection, + center: getCenter(extent), + zoom: 1, + minZoom: 0.5, + maxZoom: 8, + constrainOnlyCenter: true, + }), + }); +} + window.initializeCogViewer = initializeCogViewer; +window.initializeImageViewer = initializeImageViewer;