Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions isic/core/templates/core/partials/image_modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,24 @@
<template x-if="hovered">
{% if image.accession.is_cog %}
<div>
<div id="image-{{ image.id }}" class="w-[512px] h-[512px] border border-gray-300 mx-auto"></div>
<div id="image-{{ image.id }}" class="w-full max-w-[512px] aspect-square border border-gray-300 mx-auto"></div>
<script type="text/javascript">
initializeCogViewer(document.getElementById('image-{{ image.id }}'), '{{ image.blob.url|safe }}');
</script>
</div>
{% else %}
{% comment %}Note the single quotes: https://github.com/alpinejs/alpine/issues/466{% endcomment %}
<img :src="'{{ image.blob.url }}'" class="max-w-full max-h-[70vh] object-contain mx-auto" />
{% if zoomable %}
<div>
<div id="image-{{ image.id }}" class="w-full max-w-[512px] aspect-square border border-gray-300 mx-auto"></div>
<script type="text/javascript">
initializeImageViewer(document.getElementById('image-{{ image.id }}'), '{{ image.blob.url|safe }}');
</script>
<p class="text-center text-sm text-gray-500 mt-2 min-h-[1.25rem]">Scroll to zoom, click and drag to pan</p>
</div>
{% else %}
{% comment %}Note the single quotes: https://github.com/alpinejs/alpine/issues/466{% endcomment %}
<img :src="'{{ image.blob.url }}'" class="max-w-full max-h-[70vh] object-contain mx-auto" />
{% endif %}
{% endif %}

</template>
Expand Down
7 changes: 4 additions & 3 deletions isic/core/tests/test_image_modal_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions isic/studies/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -151,6 +152,7 @@ class StudyEditForm(forms.Form):

name = fields["name"]
description = fields["description"]
zoomable = fields["zoomable"]


class StudyAddAnnotatorsForm(forms.Form):
Expand Down
24 changes: 24 additions & 0 deletions isic/studies/migrations/0006_add_study_zoomable.py
Original file line number Diff line number Diff line change
@@ -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."
),
),
),
]
9 changes: 9 additions & 0 deletions isic/studies/models/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions isic/studies/services/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -18,6 +19,7 @@ def study_create( # noqa: PLR0913
description: str,
collection: Collection,
public: bool,
zoomable: bool = False,
) -> Study:
study = Study(
creator=creator,
Expand All @@ -26,6 +28,7 @@ def study_create( # noqa: PLR0913
description=description,
collection=collection,
public=public,
zoomable=zoomable,
)
study.full_clean()

Expand All @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions isic/studies/templates/studies/study_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,17 @@
{{ base_form.public.errors }}
</div>
</div>

<div class="grid grid-cols-3 gap-4 items-start border-t border-gray-200 pt-5">
<label for="{{ base_form.zoomable.html_name }}" class="block text-sm font-medium text-gray-700 mt-px pt-2">
{{ base_form.zoomable.label }}
</label>
<div class="mt-1 mt-0 col-span-2">
<input type="checkbox" name="{{ base_form.zoomable.html_name }}" id="{{ base_form.zoomable.html_name }}" {% if base_form.zoomable.value %}checked{% endif %} />
<p class="mt-2 text-sm text-gray-500">{{ base_form.zoomable.help_text }}</p>
{{ base_form.zoomable.errors }}
</div>
</div>
</div>

<div class="mt-6 mt-5 space-y-6 space-y-5 border-t border-gray-200">
Expand Down
11 changes: 11 additions & 0 deletions isic/studies/templates/studies/study_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@
{{ form.description.errors }}
</div>
</div>

<div class="grid grid-cols-3 gap-4 items-start border-t border-gray-200 pt-5">
<label for="{{ form.zoomable.html_name }}" class="block text-sm font-medium text-gray-700 mt-px pt-2">
{{ form.zoomable.label }}
</label>
<div class="mt-1 mt-0 col-span-2">
<input type="checkbox" name="{{ form.zoomable.html_name }}" id="{{ form.zoomable.html_name }}" {% if form.zoomable.value %}checked{% endif %} />
<p class="mt-2 text-sm text-gray-500">{{ form.zoomable.help_text }}</p>
{{ form.zoomable.errors }}
</div>
</div>
</div>

<div class="border-t border-gray-200 py-5">
Expand Down
9 changes: 8 additions & 1 deletion isic/studies/templates/studies/study_task_detail.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
{% extends 'core/base.html' %}
{% load humanize %}
{% load static %}

{% block head_extra %}
{{ block.super }}
<script src="{% static 'core/dist/cog.js' %}"></script>
<link rel="stylesheet" href="{% static 'core/dist/ol.css' %}">
{% endblock %}

{% block content %}

Expand Down Expand Up @@ -60,7 +67,7 @@

<template x-if="true">
<div x-show="open">
{% 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 %}
</div>
</template>
</div>
Expand Down
1 change: 1 addition & 0 deletions isic/studies/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 60 additions & 0 deletions isic/studies/tests/test_image_zoom_browser.py
Original file line number Diff line number Diff line change
@@ -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 <canvas> 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 <img>.
hint = page.get_by_text("Scroll to zoom, click and drag to pan")
expect(hint).to_be_visible()
2 changes: 2 additions & 0 deletions isic/studies/tests/test_study_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions isic/studies/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.urls.base import reverse
from django.utils import timezone
import pytest
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion isic/studies/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
32 changes: 32 additions & 0 deletions node-src/cog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;