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 @@
{% if image.accession.is_cog %}
{% else %}
- {% comment %}Note the single quotes: https://github.com/alpinejs/alpine/issues/466{% endcomment %}
-
+ {% if zoomable %}
+
+
+
+
Scroll to zoom, click and drag to pan
+
+ {% else %}
+ {% comment %}Note the single quotes: https://github.com/alpinejs/alpine/issues/466{% endcomment %}
+
+ {% endif %}
{% endif %}
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.label }}
+
+
+
+
{{ 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.label }}
+
+
+
+
{{ 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 @@
- {% include 'core/partials/image_modal.html' with image=study_task.image include_metadata=False %}
+ {% include 'core/partials/image_modal.html' with image=study_task.image include_metadata=False zoomable=study_task.study.zoomable %}
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;