From bdb015680abf4d844356c15ce0598b3f631e13c4 Mon Sep 17 00:00:00 2001 From: nicola Date: Thu, 8 Oct 2026 11:14:20 +0200 Subject: [PATCH 1/2] added tutorial for laparoscopy annotation --- .gitignore | 3 + CONTRIBUTING.md | 9 +- annotations/tests_image_segmentation.py | 57 +++++- annotations/tests_quadrants.py | 35 +++- annotations/views_image_segmentation.py | 115 ++++++++--- annotations/views_quadrants.py | 35 ++-- .../0062_userpreference_label_colors.py | 18 ++ common/models.py | 3 + frontend/imaging/segment/bootstrap.js | 40 +--- frontend/imaging/segment/labelPanel.js | 14 +- frontend/imaging/segment/quadrants.js | 18 +- laparoscopy/tests.py | 27 +++ maxillo/views/patient_detail.py | 6 +- static/css/patient_detail.css | 56 ++++-- static/js/laparoscopy_tutorial.js | 180 ++++++++++++++++++ .../app/chunk-24KACO42.js} | 0 .../app/chunk-BZOQXIL3.js} | 0 .../app/chunk-GRRCRL43.js} | 0 .../app/chunk-HQFZGD3V.js} | 2 +- .../app/chunk-MXB2T76R.js} | 2 +- .../app/chunk-PQI4YKJY.js} | 2 +- .../app/chunk-Q6PSWBJ3.js} | 0 .../app/chunk-Q6US3M6T.js} | 0 .../app/chunk-XRDL4JCO.js} | 0 .../cornerstone/bac38813/app/image-segment.js | 1 + .../app/mesh-landmarks.js | 2 +- .../app/panoramic-cpr.js | 2 +- .../{ff220b1a => bac38813}/app/photo-stack.js | 2 +- .../{ff220b1a => bac38813}/app/volume-grid.js | 2 +- .../workers/assets/ICRPolySeg-KEYMZML6.wasm | Bin .../app/workers/computeWorker.js | 0 .../app/workers/interpolationWorker.js | 0 .../app/workers/polySegConverters.js | 2 +- .../web-workers/itk-wasm-pipeline.worker.js | 0 .../{ff220b1a => bac38813}/app/wsi-viewer.js | 2 +- .../morphological-contour-interpolation.js | 0 .../morphological-contour-interpolation.wasm | Bin ...rphological-contour-interpolation.wasm.zst | Bin .../orientation/Human.vtp | Bin .../orientation/README.md | 0 .../workers/computeWorker.js | 0 .../cornerstone/ff220b1a/app/image-segment.js | 1 - static/vendor/cornerstone/manifest.json | 2 +- static/vendor/driver.js-1.9.0/LICENSE | 21 ++ static/vendor/driver.js-1.9.0/driver.css | 2 + .../laparoscopy/patient_detail_content.html | 26 ++- 46 files changed, 548 insertions(+), 139 deletions(-) create mode 100644 common/migrations/0062_userpreference_label_colors.py create mode 100644 static/js/laparoscopy_tutorial.js rename static/vendor/cornerstone/{ff220b1a/app/chunk-O2OOUNWU.js => bac38813/app/chunk-24KACO42.js} (100%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-IVUMTZUA.js => bac38813/app/chunk-BZOQXIL3.js} (100%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-TTTJJYWU.js => bac38813/app/chunk-GRRCRL43.js} (100%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-EUNK5TYG.js => bac38813/app/chunk-HQFZGD3V.js} (99%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-WHYFTSVR.js => bac38813/app/chunk-MXB2T76R.js} (99%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-BAHQ3ALD.js => bac38813/app/chunk-PQI4YKJY.js} (99%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-HRDEDRGW.js => bac38813/app/chunk-Q6PSWBJ3.js} (100%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-6EG4FQFR.js => bac38813/app/chunk-Q6US3M6T.js} (100%) rename static/vendor/cornerstone/{ff220b1a/app/chunk-HPLBK6MT.js => bac38813/app/chunk-XRDL4JCO.js} (100%) create mode 100644 static/vendor/cornerstone/bac38813/app/image-segment.js rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/mesh-landmarks.js (98%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/panoramic-cpr.js (98%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/photo-stack.js (97%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/volume-grid.js (99%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/workers/assets/ICRPolySeg-KEYMZML6.wasm (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/workers/computeWorker.js (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/workers/interpolationWorker.js (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/workers/polySegConverters.js (99%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/workers/web-workers/itk-wasm-pipeline.worker.js (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/app/wsi-viewer.js (99%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/itk/pipelines/morphological-contour-interpolation.js (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/itk/pipelines/morphological-contour-interpolation.wasm (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/itk/pipelines/morphological-contour-interpolation.wasm.zst (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/orientation/Human.vtp (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/orientation/README.md (100%) rename static/vendor/cornerstone/{ff220b1a => bac38813}/workers/computeWorker.js (100%) delete mode 100644 static/vendor/cornerstone/ff220b1a/app/image-segment.js create mode 100644 static/vendor/driver.js-1.9.0/LICENSE create mode 100644 static/vendor/driver.js-1.9.0/driver.css diff --git a/.gitignore b/.gitignore index a70b65e9..db2f8875 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ Thumbs.db # lint-imports cache .import_linter_cache/ + +# Machine-specific setup notes (zip-dgx). +/LOCAL_SETUP.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fdef5e8..bd00266d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,10 +129,11 @@ Where the bundle fits in the system as a whole: [docs/architecture.md](docs/arch ### Third-party CDNs -CDNs are allowed. Today exactly one template uses one: -`templates/common/user_activity_stats.html` loads two pinned Chart.js scripts from -jsDelivr, both with SRI. `templates/base.html` loads **no** CDN scripts at all. Three -rules apply to anything new: +CDNs are allowed. Today two templates use one, both jsDelivr with SRI: +`templates/common/user_activity_stats.html` loads two pinned Chart.js scripts, and +`templates/laparoscopy/patient_detail_content.html` loads driver.js for the Tutorial (its +stylesheet is vendored under `static/vendor/`, because the CSP's `style-src` is `'self'`). +`templates/base.html` loads **no** CDN scripts at all. Three rules apply to anything new: - **Webfonts stay self-hosted.** A font CDN sees every page view of every visitor, which is a GDPR question a JavaScript library does not raise. IBM Plex and Font diff --git a/annotations/tests_image_segmentation.py b/annotations/tests_image_segmentation.py index 7384a696..c75994ee 100644 --- a/annotations/tests_image_segmentation.py +++ b/annotations/tests_image_segmentation.py @@ -22,7 +22,7 @@ from django.apps import apps from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.test import SimpleTestCase, TestCase +from django.test import Client, SimpleTestCase, TestCase from django.urls import reverse from annotations.adapters import image_segmentation as codec @@ -183,6 +183,11 @@ def setUp(self): self.user = User.objects.create_user(username=f"u{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106 ProjectAccess.objects.create(user=self.user, project=self.project, role="annotator") self.client.force_login(self.user) + # Labels are a project admin's to add, rename and retire. + admin = User.objects.create_user(username=f"a{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106 + ProjectAccess.objects.create(user=admin, project=self.project, role="admin") + self.admin_client = Client() + self.admin_client.force_login(admin) kw = {"patient_id": self.patient.patient_id} self.save_url = reverse("laparoscopy:api_save_image_segmentation", kwargs=kw) self.state_url = reverse("laparoscopy:api_image_segmentation_state", kwargs=kw) @@ -194,12 +199,18 @@ def setUp(self): # -- helpers ------------------------------------------------------------ def _label(self, name): - response = self.client.post( + response = self.admin_client.post( self.labels_url, json.dumps({"name": name}), content_type="application/json" ) self.assertEqual(response.status_code, 201, response.content) return response.json()["label"]["code"] + def _label_url(self, code): + return reverse( + "laparoscopy:api_image_segmentation_label", + kwargs={"patient_id": self.patient.patient_id, "code": code}, + ) + def _state(self): response = self.client.get(self.state_url) self.assertEqual(response.status_code, 200) @@ -233,7 +244,7 @@ def test_labels_get_stable_codes_and_distinct_colours(self): self.assertNotEqual(labels[0]["color"], labels[1]["color"]) def test_a_duplicate_name_is_refused_case_insensitively(self): - response = self.client.post( + response = self.admin_client.post( self.labels_url, json.dumps({"name": "liver"}), content_type="application/json" ) self.assertEqual(response.status_code, 400) @@ -243,7 +254,7 @@ def test_a_rename_changes_the_name_and_never_the_code_or_value(self): "laparoscopy:api_image_segmentation_label", kwargs={"patient_id": self.patient.patient_id, "code": self.liver}, ) - response = self.client.patch( + response = self.admin_client.patch( url, json.dumps({"name": "Hepar", "color": "#112233"}), content_type="application/json" ) label = response.json()["label"] @@ -254,11 +265,47 @@ def test_a_retired_label_is_kept_and_its_value_is_not_reused(self): "laparoscopy:api_image_segmentation_label", kwargs={"patient_id": self.patient.patient_id, "code": self.tumour}, ) - self.assertFalse(self.client.delete(url).json()["label"]["active"]) + self.assertFalse(self.admin_client.delete(url).json()["label"]["active"]) self.assertEqual(self._label("Spleen"), "l3", "retiring must not free a value") labels = {label["code"]: label for label in self._state()["labels"]} self.assertFalse(labels[self.tumour]["active"]) + def test_only_a_project_admin_adds_renames_retires_or_restores_a_label(self): + url = self._label_url(self.liver) + refused = [ + self.client.post(self.labels_url, json.dumps({"name": "Spleen"}), content_type="application/json"), + self.client.patch(url, json.dumps({"name": "Hepar"}), content_type="application/json"), + self.client.patch(url, json.dumps({"active": False}), content_type="application/json"), + self.client.patch(url, json.dumps({"name": "Hepar", "color": "#112233"}), content_type="application/json"), + self.client.delete(url), + ] + self.assertEqual([response.status_code for response in refused], [403] * 5) + labels = self.admin_client.get(self.labels_url).json()["labels"] + self.assertEqual([(label["name"], label["active"]) for label in labels], [("Liver", True), ("Tumour", True)]) + mine = {label["code"]: label["color"] for label in self._state()["labels"]} + self.assertNotEqual(mine[self.liver], "#112233", "a refused request writes nothing, colour included") + + def test_a_colour_is_the_users_own(self): + shared = self.admin_client.get(self.labels_url).json()["labels"][0]["color"] + response = self.client.patch( + self._label_url(self.liver), json.dumps({"color": "#ABCDEF"}), content_type="application/json" + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["label"]["color"], "#abcdef") + mine = {label["code"]: label["color"] for label in self._state()["labels"]} + self.assertEqual(mine[self.liver], "#abcdef") + theirs = {label["code"]: label["color"] for label in self.admin_client.get(self.state_url).json()["labels"]} + self.assertEqual(theirs[self.liver], shared, "nobody else sees it") + + # A viewer may pick colours too; a blank one goes back to the label's own. + ProjectAccess.objects.filter(user=self.user).update(role="viewer") + self.client.patch(self._label_url(self.liver), json.dumps({"color": ""}), content_type="application/json") + self.assertEqual({label["code"]: label["color"] for label in self._state()["labels"]}[self.liver], shared) + bad = self.client.patch(self._label_url(self.liver), json.dumps({"color": "red"}), content_type="application/json") + self.assertEqual(bad.status_code, 400) + missing = self.client.patch(self._label_url("l99"), json.dumps({"color": "#000000"}), content_type="application/json") + self.assertEqual(missing.status_code, 404) + # -- saving and reading ------------------------------------------------- def test_a_frame_round_trips_through_save_state_and_read(self): diff --git a/annotations/tests_quadrants.py b/annotations/tests_quadrants.py index 372b573b..33c83f67 100644 --- a/annotations/tests_quadrants.py +++ b/annotations/tests_quadrants.py @@ -9,7 +9,7 @@ from django.apps import apps from django.contrib.auth.models import User -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from annotations.models import AnnotationSet, LabelSchema @@ -51,6 +51,11 @@ def setUp(self): self.user = User.objects.create_user(username=f"u{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106 ProjectAccess.objects.create(user=self.user, project=self.project, role="annotator") self.client.force_login(self.user) + # Quadrants are a project admin's to add, rename and retire. + admin = User.objects.create_user(username=f"a{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106 + ProjectAccess.objects.create(user=admin, project=self.project, role="admin") + self.admin_client = Client() + self.admin_client.force_login(admin) kw = {"patient_id": self.patient.patient_id} self.url = reverse("laparoscopy:api_quadrants", kwargs=kw) self.labels_url = reverse("laparoscopy:api_quadrant_labels", kwargs=kw) @@ -59,7 +64,7 @@ def setUp(self): self.lower = self._label("Lower right") def _label(self, name): - response = self.client.post( + response = self.admin_client.post( self.labels_url, json.dumps({"name": name}), content_type="application/json" ) self.assertEqual(response.status_code, 201, response.content) @@ -72,7 +77,7 @@ def _put(self, markers, *, expected=None, file_id=None): return self.client.put(self.url, json.dumps(body), content_type="application/json") def test_the_vocabulary_is_separate_from_the_segmentation_labels(self): - self.client.post( + self.admin_client.post( self.seg_labels_url, json.dumps({"name": "Liver"}), content_type="application/json" ) quadrants = self.client.get(self.url).json()["labels"] @@ -131,11 +136,33 @@ def test_a_retired_quadrant_keeps_its_markers(self): "laparoscopy:api_quadrant_label", kwargs={"patient_id": self.patient.patient_id, "code": self.upper}, ) - self.assertFalse(self.client.delete(url).json()["label"]["active"]) + self.assertFalse(self.admin_client.delete(url).json()["label"]["active"]) state = self.client.get(self.client.get(self.url).wsgi_request.path).json() self.assertEqual(state["markers"], [{"timeMs": 0, "code": self.upper}]) self.assertEqual(self._put(state["markers"]).status_code, 200) + def test_quadrants_are_an_admins_but_their_colours_are_each_users(self): + url = reverse( + "laparoscopy:api_quadrant_label", + kwargs={"patient_id": self.patient.patient_id, "code": self.upper}, + ) + refused = [ + self.client.post(self.labels_url, json.dumps({"name": "Middle"}), content_type="application/json"), + self.client.patch(url, json.dumps({"name": "Top"}), content_type="application/json"), + self.client.delete(url), + ] + self.assertEqual([response.status_code for response in refused], [403] * 3) + + shared = self.admin_client.get(self.url).json()["labels"][0]["color"] + response = self.client.patch(url, json.dumps({"color": "#123456"}), content_type="application/json") + self.assertEqual(response.status_code, 200, response.content) + mine = {label["code"]: label for label in self.client.get(self.url).json()["labels"]} + self.assertEqual((mine[self.upper]["name"], mine[self.upper]["color"]), ("Upper left", "#123456")) + self.assertEqual(self.admin_client.get(self.url).json()["labels"][0]["color"], shared) + # Saving markers sends the state back: still in this user's colours. + saved = self._put([{"timeMs": 0, "code": self.upper}]).json() + self.assertEqual(saved["labels"][0]["color"], "#123456") + def test_a_project_without_the_method_cannot_save(self): self.project.annotation_methods.clear() self.assertEqual(self._put([]).status_code, 403) diff --git a/annotations/views_image_segmentation.py b/annotations/views_image_segmentation.py index 0ae915f6..7b80a433 100644 --- a/annotations/views_image_segmentation.py +++ b/annotations/views_image_segmentation.py @@ -12,7 +12,8 @@ import json from django.contrib.auth.decorators import login_required -from django.core.exceptions import ValidationError +from django.core.exceptions import PermissionDenied, ValidationError +from django.db import transaction from django.http import JsonResponse from django.views.decorators.http import require_http_methods, require_POST @@ -20,14 +21,15 @@ from annotations.constants import CoordinateSystem from annotations.services import AnnotationConflict, AnnotationNotAllowed from annotations.services import image_segmentation as service -from annotations.validators.image_segmentation import validate_time_ms +from annotations.validators.image_segmentation import validate_color, validate_time_ms from annotations.views import ( _BadRequest, _checked_file, _first_message, _patient_model, ) -from common.permissions import get_patient_for +from common.models import UserPreference +from common.permissions import get_patient_for, user_is_patient_admin def _json_body(request): @@ -58,13 +60,88 @@ def _project(patient): return project +# --- Labels: admins manage them, every user picks their own colours --------------------- +# +# Adding, renaming, retiring and restoring a label change the project's vocabulary, so they +# are a project admin's. A colour is a viewing preference: it is stored per user in +# ``common.UserPreference.label_colors`` and swapped into every label list that user is +# sent, so the label's own colour is only the default. + + +def _color_key(project, vocabulary, code): + return f"{project.pk}:{vocabulary}:{code}" + + +def _personal_colors(user, project, vocabulary, labels): + """``labels`` as ``user`` sees them: their own colour wherever they picked one.""" + preference = UserPreference.objects.filter(user=user).first() + mine = preference.label_colors if preference else {} + if not mine or project is None: + return labels + return [ + {**label, "color": mine.get(_color_key(project, vocabulary, label["code"]), label["color"])} + for label in labels + ] + + +def _set_personal_color(user, project, vocabulary, code, color): + """Store ``user``'s colour for one label; a blank colour goes back to the label's own.""" + color = validate_color(color) + key = _color_key(project, vocabulary, code) + with transaction.atomic(): + preference, _ = UserPreference.objects.select_for_update().get_or_create(user=user) + if color: + preference.label_colors[key] = color + else: + preference.label_colors.pop(key, None) + preference.save(update_fields=["label_colors", "updated_at"]) + + +def label_detail_response(request, patient_id, code, labels, missing): + """PATCH (rename/restore: admin; colour: anyone who can read) or DELETE (retire: admin). + + ``labels`` is the service that owns the vocabulary: ``create_label``, ``update_label``, + ``list_labels`` and ``VOCABULARY``. + """ + patient = _patient(request, patient_id, "admin" if request.method == "DELETE" else "read") + try: + project = _project(patient) + if request.method == "DELETE": + changes, color = {"active": False}, None + else: + body = _json_body(request) + changes = {key: body[key] for key in ("name", "active") if key in body} + color = body.get("color") + if changes and not user_is_patient_admin(request.user, patient): + raise PermissionDenied("Only a project admin can rename, retire or restore labels.") + if changes: + label = labels.update_label(project, code, **changes) + else: + label = next((item for item in labels.list_labels(project) if item["code"] == code), None) + if label is None: + raise LookupError(code) + if color is not None: + _set_personal_color(request.user, project, labels.VOCABULARY, code, color) + except _BadRequest as exc: + return JsonResponse({"error": str(exc)}, status=exc.status) + except LookupError: + return JsonResponse({"error": missing}, status=404) + except ValidationError as exc: + return JsonResponse({"error": _first_message(exc)}, status=400) + [label] = _personal_colors(request.user, project, labels.VOCABULARY, [label]) + return JsonResponse({"label": label}) + + @login_required def image_segmentation_state_api(request, patient_id): """The vocabulary, the revision a save must quote, and which frames hold masks.""" patient = _patient(request, patient_id, "read") state = service.image_segmentation_state(patient) updated_at = state["updatedAt"] - return JsonResponse({**state, "updatedAt": updated_at.isoformat() if updated_at else None}) + labels = _personal_colors(request.user, patient.project, service.VOCABULARY, state["labels"]) + return JsonResponse( + {**state, "labels": labels, "updatedAt": updated_at.isoformat() if updated_at else None} + ) @login_required @@ -173,12 +250,15 @@ def save_image_segmentation_api(request, patient_id): @login_required @require_http_methods(["GET", "POST"]) def image_segmentation_labels_api(request, patient_id): - """List (read) or create (write) the project's labels.""" - patient = _patient(request, patient_id, "read" if request.method == "GET" else "write") + """List (read) or create (admin) the project's labels.""" + patient = _patient(request, patient_id, "read" if request.method == "GET" else "admin") try: project = _project(patient) if request.method == "GET": - return JsonResponse({"labels": service.list_labels(project)}) + labels = service.list_labels(project) + return JsonResponse( + {"labels": _personal_colors(request.user, project, service.VOCABULARY, labels)} + ) body = _json_body(request) label = service.create_label( project, name=body.get("name"), color=body.get("color", "") @@ -193,26 +273,9 @@ def image_segmentation_labels_api(request, patient_id): @login_required @require_http_methods(["PATCH", "DELETE"]) def image_segmentation_label_detail_api(request, patient_id, code): - """Rename/recolour/restore (PATCH) or retire (DELETE) one label. + """Rename/restore (PATCH, admin), recolour for yourself (PATCH) or retire (DELETE, admin). DELETE retires rather than deletes: the label's pixels stay in every revision that has them, and a deleted definition would leave planes that name nothing. """ - patient = _patient(request, patient_id, "write") - try: - project = _project(patient) - if request.method == "DELETE": - changes = {"active": False} - else: - body = _json_body(request) - changes = { - key: body[key] for key in ("name", "color", "active") if key in body - } - label = service.update_label(project, code, **changes) - except _BadRequest as exc: - return JsonResponse({"error": str(exc)}, status=exc.status) - except LookupError: - return JsonResponse({"error": "No such label."}, status=404) - except ValidationError as exc: - return JsonResponse({"error": _first_message(exc)}, status=400) - return JsonResponse({"label": label}) + return label_detail_response(request, patient_id, code, service, "No such label.") diff --git a/annotations/views_quadrants.py b/annotations/views_quadrants.py index a7b90a42..20816712 100644 --- a/annotations/views_quadrants.py +++ b/annotations/views_quadrants.py @@ -16,10 +16,18 @@ _integer_or_none, _json_body, _patient, + _personal_colors, _project, + label_detail_response, ) +def _state(request, patient): + state = service.quadrant_state(patient) + labels = _personal_colors(request.user, patient.project, service.VOCABULARY, state["labels"]) + return JsonResponse({**state, "labels": labels}) + + @login_required @require_http_methods(["GET", "PUT"]) def quadrants_api(request, patient_id): @@ -29,7 +37,7 @@ def quadrants_api(request, patient_id): """ patient = _patient(request, patient_id, "read" if request.method == "GET" else "write") if request.method == "GET": - return JsonResponse(service.quadrant_state(patient)) + return _state(request, patient) try: body = _json_body(request) expected = _integer_or_none(body, "expectedRevision") @@ -51,14 +59,14 @@ def quadrants_api(request, patient_id): return JsonResponse({"error": str(exc)}, status=403) except ValidationError as exc: return JsonResponse({"error": _first_message(exc)}, status=400) - return JsonResponse(service.quadrant_state(patient)) + return _state(request, patient) @login_required @require_http_methods(["POST"]) def quadrant_labels_api(request, patient_id): - """Create a quadrant (write). The list comes with the state.""" - patient = _patient(request, patient_id, "write") + """Create a quadrant (admin). The list comes with the state.""" + patient = _patient(request, patient_id, "admin") try: body = _json_body(request) label = service.create_label( @@ -74,20 +82,5 @@ def quadrant_labels_api(request, patient_id): @login_required @require_http_methods(["PATCH", "DELETE"]) def quadrant_label_detail_api(request, patient_id, code): - """Rename/recolour/restore (PATCH) or retire (DELETE) one quadrant.""" - patient = _patient(request, patient_id, "write") - try: - project = _project(patient) - if request.method == "DELETE": - changes = {"active": False} - else: - body = _json_body(request) - changes = {k: body[k] for k in ("name", "color", "active") if k in body} - label = service.update_label(project, code, **changes) - except _BadRequest as exc: - return JsonResponse({"error": str(exc)}, status=exc.status) - except LookupError: - return JsonResponse({"error": "No such quadrant."}, status=404) - except ValidationError as exc: - return JsonResponse({"error": _first_message(exc)}, status=400) - return JsonResponse({"label": label}) + """Rename/restore (PATCH, admin), recolour for yourself (PATCH) or retire (DELETE, admin).""" + return label_detail_response(request, patient_id, code, service, "No such quadrant.") diff --git a/common/migrations/0062_userpreference_label_colors.py b/common/migrations/0062_userpreference_label_colors.py new file mode 100644 index 00000000..c1fdc297 --- /dev/null +++ b/common/migrations/0062_userpreference_label_colors.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-08 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0061_image_segmentation_method'), + ] + + operations = [ + migrations.AddField( + model_name='userpreference', + name='label_colors', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/common/models.py b/common/models.py index 063ea1e4..b6e9ab21 100644 --- a/common/models.py +++ b/common/models.py @@ -929,6 +929,9 @@ class UserPreference(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='ygg_preference') report_language = models.CharField(max_length=5, choices=LANGUAGE_CHOICES, default='it') + # The colour this user sees a project's label in, overriding the label's own: + # {"::": "#rrggbb"}. Display only -- nobody else sees it. + label_colors = models.JSONField(default=dict, blank=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): diff --git a/frontend/imaging/segment/bootstrap.js b/frontend/imaging/segment/bootstrap.js index 7de004eb..f0b1f133 100644 --- a/frontend/imaging/segment/bootstrap.js +++ b/frontend/imaging/segment/bootstrap.js @@ -98,7 +98,6 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } const messages = $(doc, 'segment-message'); const timeline = $(doc, 'video-timeline-range'); const timelineTime = $(doc, 'video-timeline-time'); - const timelineMarks = $(doc, 'video-timeline-marks'); const timelineControls = [...doc.querySelectorAll('[data-video-seek]')]; const startButton = $(doc, 'video-start'); const stopButton = $(doc, 'video-stop'); @@ -167,32 +166,6 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } quadrants?.showNow(); } - /** - * Tick the timeline wherever a frame already has a mask, so "where have I worked" is - * visible without stepping through 187 frames to find out. - */ - function renderMarks(sources) { - if (!timelineMarks) { - return; - } - const frames = sources?.[String(data.fileId)]?.frames ?? {}; - const duration = data.frameCount / data.fps; - timelineMarks.replaceChildren(); - let count = 0; - for (const timeMs of Object.keys(frames)) { - const seconds = Number(timeMs) / 1000; - if (!Number.isFinite(seconds) || seconds < 0 || seconds > duration) { - continue; - } - const tick = doc.createElement('span'); - tick.className = 'video-timeline__mark'; - tick.style.left = `${(seconds / duration) * 100}%`; - timelineMarks.appendChild(tick); - count += 1; - } - timelineMarks.title = count === 1 ? '1 annotated frame' : `${count} annotated frames`; - } - function setTimelineBusy(isBusy) { if (timeline) { timeline.disabled = isBusy; @@ -256,7 +229,7 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } selected, hidden, opacity: (code) => session?.opacityOf(code) ?? 0.5, - canEdit: Boolean(data.canModify), + canManage: Boolean(data.canManageLabels), // Visibility and opacity act on the open frame; with no session there is none. showDisplay: Boolean(session), }, @@ -369,7 +342,6 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } // A frame may be revisited before Annotation Mode closes, so keep the manifest used // by openSession current rather than treating this freshly saved frame as empty. state.sources = result.sources ?? state.sources; - renderMarks(state.sources); return { revision: result.revision }; } @@ -465,10 +437,10 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } const current = frameSource.nearestFrame(playbackTime); const frame = await frameSource.grab(current.frameIndex); state = await api.state(); - renderMarks(state.sources); // Painting needs a label, and "no labels yet" under the image was easy to miss, - // so a project's first annotation session starts with one. - if (data.canModify && !state.labels.some((label) => label.active)) { + // so a project's first annotation session starts with one -- when the user is + // allowed to add labels at all (project admins). + if (data.canManageLabels && !state.labels.some((label) => label.active)) { const { label } = await api.createLabel(FIRST_LABEL_NAME); state.labels.push(label); } @@ -606,14 +578,11 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } } }); updateTimeline(); - // The marks are useful before Annotation Mode is opened too; a failed read just leaves - // the track plain. // The labels are managed from the Labels card at any time, so the vocabulary is read on // load rather than when Annotation Mode opens. void api.state().then( (current) => { state = current; - renderMarks(current.sources); refreshPanel(); }, () => {} @@ -637,6 +606,7 @@ export async function bootstrapSegmentation({ doc, openSession, fetchImpl, ask } }), fileId: data.fileId, canModify: Boolean(data.canModify), + canManage: Boolean(data.canManageLabels), getDurationMs: () => timelineDuration() * 1000, getTimeMs: () => (session ? session.frame.timeMs : video.currentTime * 1000), ask, diff --git a/frontend/imaging/segment/labelPanel.js b/frontend/imaging/segment/labelPanel.js index b571bff2..561311eb 100644 --- a/frontend/imaging/segment/labelPanel.js +++ b/frontend/imaging/segment/labelPanel.js @@ -66,7 +66,8 @@ function element(doc, tag, props = {}, children = []) { * @param {HTMLElement} options.container emptied and refilled. * @param {Array<{code, name, color, active}>} options.labels * @param {{selected: string|null, hidden: Set, opacity: (code: string) => number, - * canEdit: boolean}} options.view + * canManage: boolean}} options.view `canManage`: add, rename and retire (project admins). + * Anyone may change a colour: it is their own, stored per user by the server. * @param {object} options.handlers `select(code)`, `toggleVisible(code)`, `opacity(code, alpha)`, * `color(code, hex)`, `rename(code)`, `retire(code)`, `add()`. */ @@ -81,7 +82,9 @@ export function renderLabelPanel({ container, labels, view, handlers }) { container.appendChild( element(doc, 'p', { class: 'segment-labels__empty', - text: view.canEdit ? `No ${noun}s yet. Add one to get started.` : `No ${noun}s.`, + text: view.canManage + ? `No ${noun}s yet. Add one to get started.` + : `No ${noun}s yet. Ask a project admin to add one.`, }) ); } @@ -101,9 +104,8 @@ export function renderLabelPanel({ container, labels, view, handlers }) { type: 'color', class: 'segment-label__swatch', value: label.color || '#3498db', - title: view.canEdit ? 'Change colour' : 'Colour', + title: 'Change colour (only you see it)', 'aria-label': `Colour of ${label.name}`, - ...(view.canEdit ? {} : { disabled: 'disabled' }), oninput: (event) => handlers.color(label.code, event.target.value), }), element(doc, 'button', { @@ -157,7 +159,7 @@ export function renderLabelPanel({ container, labels, view, handlers }) { ]) ] : []), - ...(view.canEdit + ...(view.canManage ? [ element( doc, @@ -191,7 +193,7 @@ export function renderLabelPanel({ container, labels, view, handlers }) { container.appendChild(row); } - if (view.canEdit) { + if (view.canManage) { container.appendChild( element( doc, diff --git a/frontend/imaging/segment/quadrants.js b/frontend/imaging/segment/quadrants.js index 503d6633..4802e5aa 100644 --- a/frontend/imaging/segment/quadrants.js +++ b/frontend/imaging/segment/quadrants.js @@ -125,7 +125,8 @@ function element(doc, tag, props = {}) { * @param {Document} options.doc * @param {object} options.api `createQuadrantsApi(...)`. * @param {number} options.fileId the video the markers are filed against. - * @param {boolean} options.canModify + * @param {boolean} options.canModify place and move markers. + * @param {boolean} [options.canManage] add, rename and retire quadrants (project admins). * @param {() => number} options.getDurationMs * @param {() => number} options.getTimeMs the playhead, in ms. * @param {Function} [options.ask] themed text prompt. @@ -139,6 +140,7 @@ export async function mountQuadrants({ api, fileId, canModify, + canManage = false, getDurationMs, getTimeMs, ask, @@ -209,7 +211,7 @@ export async function mountQuadrants({ selected: active, hidden: new Set(), opacity: () => 1, - canEdit: canModify, + canManage, noun: 'quadrant', showDisplay: false, selectTitle: 'Use this quadrant for "Mark here"', @@ -348,7 +350,9 @@ export async function mountQuadrants({ mark.disabled = !active; mark.querySelector('span').textContent = active ? `Mark “${find(active)?.name}” from here` - : 'Add a quadrant in Labels'; + : canManage + ? 'Add a quadrant in Labels' + : 'No quadrants yet — ask a project admin'; } renderBar(); renderLabels(); @@ -563,7 +567,13 @@ export async function mountQuadrants({ if (canModify) { items.push({ heading: 'Start a quadrant here' }); if (!activeLabels().length) { - items.push({ text: 'No quadrants yet — add one in Labels', disabled: true, run: () => {} }); + items.push({ + text: canManage + ? 'No quadrants yet — add one in Labels' + : 'No quadrants yet — ask a project admin', + disabled: true, + run: () => {}, + }); } for (const label of activeLabels()) { items.push({ diff --git a/laparoscopy/tests.py b/laparoscopy/tests.py index ad4cbc1a..43b8bfe0 100644 --- a/laparoscopy/tests.py +++ b/laparoscopy/tests.py @@ -81,6 +81,24 @@ def test_a_patient_without_video_says_so(self): self.assertContains(response, "No video uploaded for this patient.") self.assertNotContains(response, ", hence the + popoverClass scope rather than .laparoscopy-patient-detail. */ +.driver-popover.ygg-tour { + --driver-popover-font-family: var(--ygg-font-body); + background: var(--ygg-surface); + color: var(--ygg-text); + border: 1px solid var(--ygg-border); + border-radius: var(--ygg-radius-md); + box-shadow: var(--ygg-shadow-pop); + max-width: 340px; +} +.ygg-tour .driver-popover-title { font-size: 16px; } +.ygg-tour .driver-popover-progress-text, +.ygg-tour .driver-popover-close-btn { color: var(--ygg-text-muted); } +.ygg-tour .driver-popover-close-btn:hover, +.ygg-tour .driver-popover-close-btn:focus { color: var(--ygg-text); } +.ygg-tour .driver-popover-footer-btn { + background: var(--ygg-surface-alt); + color: var(--ygg-text); + border-color: var(--ygg-border-strong); + border-radius: var(--ygg-radius-sm); + padding: 4px 10px; + text-shadow: none; +} +.ygg-tour .driver-popover-footer-btn:hover, +.ygg-tour .driver-popover-footer-btn:focus { background: var(--ygg-surface-3); } +.ygg-tour .driver-popover-next-btn { + background: var(--ygg-primary); + border-color: var(--ygg-primary); + color: var(--ygg-on-primary); +} +.ygg-tour .driver-popover-next-btn:hover, +.ygg-tour .driver-popover-next-btn:focus { background: var(--ygg-primary-hover); } +/* Only the arrow's visible side takes the surface colour; the others stay transparent. */ +.ygg-tour .driver-popover-arrow-side-left { border-left-color: var(--ygg-surface); } +.ygg-tour .driver-popover-arrow-side-right { border-right-color: var(--ygg-surface); } +.ygg-tour .driver-popover-arrow-side-top { border-top-color: var(--ygg-surface); } +.ygg-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--ygg-surface); } diff --git a/static/js/laparoscopy_tutorial.js b/static/js/laparoscopy_tutorial.js new file mode 100644 index 00000000..311823c9 --- /dev/null +++ b/static/js/laparoscopy_tutorial.js @@ -0,0 +1,180 @@ +/** + * Laparoscopy patient page — the Tutorial button's guided tour (driver.js, loaded by the + * template from jsDelivr). + * + * One tour: the quadrant timeline and labels, then it opens Annotation Mode itself (the + * "Next" on that step clicks the page's own toggle) and walks the drawing tools. When the + * tour ends it closes Annotation Mode again if it was the one that opened it. Opening it + * is a real entry: on a project with no labels yet the page creates the first one. + */ +(function () { + 'use strict'; + + var button = document.getElementById('tutorial-btn'); + var toggle = document.getElementById('annotation-toggle-btn'); + if (!button || !toggle) { return; } + + var openedByTour = false; + + // The page disables the toggle while Annotation Mode opens or closes. + function whenIdle(callback) { + (function check() { + if (toggle.disabled) { window.setTimeout(check, 100); } else { callback(); } + })(); + } + + function isOpen() { + return toggle.dataset.active === 'true'; + } + + function openAnnotationMode(element, step, opts) { + if (isOpen()) { opts.driver.moveNext(); return; } + if (toggle.disabled) { return; } + openedByTour = true; + toggle.click(); + var next = document.querySelector('.ygg-tour .driver-popover-next-btn'); + if (next) { next.textContent = 'Opening…'; } + whenIdle(function () { + if (!opts.driver.isActive()) { return; } + if (isOpen()) { opts.driver.moveNext(); } else { opts.driver.destroy(); } + }); + } + + var STEPS = [ + { + element: '#video-timeline', + popover: { + title: 'Playback', + description: 'Play and pause, drag the slider to scrub, or jump −10 / −1 / +1 / +10 ' + + 'seconds.' + } + }, + { + element: '#quadrant-timeline', + popover: { + title: 'Quadrant timeline', + description: 'Which abdominal quadrant the camera is in as the procedure goes on. A quadrant ' + + 'starts at its marker and runs until the next one.' + } + }, + { + element: '[data-quadrant-mark]', + popover: { + title: 'Mark a quadrant', + description: 'Pick a quadrant in the list, move the playhead to where the camera enters it, ' + + 'then press Mark.' + } + }, + { + element: '#quadrant-bar', + popover: { + title: 'Fine-tune markers', + description: 'Drag a needle (or focus it and use ← / →) to move a marker. ' + + 'Right-click the bar or a needle to jump to, change or remove one.' + } + }, + { + element: '#labels-card', + popover: { + title: 'Labels', + description: 'Segmentation holds the regions you paint, Quadrants the ones the ' + + 'timeline uses. Click a swatch to pick your own colour (only you see it); project ' + + 'admins add, rename and remove labels.' + } + }, + { + element: '.side-panel', + popover: { + title: 'Files, Settings, Activity', + description: 'Files: the uploaded video, its processed versions and extra uploads. ' + + 'Settings: this scan\'s tags. Activity: what happened to it and when.' + } + }, + { + element: '#annotation-toggle-btn', + popover: { + title: 'Annotation Mode', + description: 'Freezes the frame you are paused on so you can paint masks on it. ' + + 'Next opens it for you.', + onNextClick: openAnnotationMode + } + }, + { + element: '#segment-viewport', + popover: { + title: 'Paint on the frame', + description: 'Left mouse button paints. Right-drag or Ctrl + scroll zooms, middle-drag pans. ' + + 'The buttons at the top right of the toolbar zoom, reset the view and undo / redo.' + } + }, + { + element: '#segment-toolbar [aria-label="Segmentation tools"]', + popover: { + title: 'Drawing tools', + description: 'Brush paints the selected label, Eraser removes it. ' + + 'Polygon: click to place points, click the first one (or press Enter) to fill; ' + + 'Backspace removes a point, Esc cancels.' + } + }, + { + element: '.segment-toolbar__label', + popover: { + title: 'Label and size', + description: 'The label you paint with, from the Labels card. Size, ' + + 'next to it, sets the brush and eraser width.' + } + }, + { + element: '#video-timeline', + popover: { + title: 'Other frames', + description: '← / → step one frame; the slider and −10 … +10 jump further.' + } + }, + { + element: '#annotation-toggle-btn', + popover: { + title: 'Saving and leaving', + description: 'Your work saves automatically (the status is under the frame). This button ' + + 'takes you back to the video.' + } + } + ]; + + // In Annotation Mode the page steps frames on ←/→ keydown; driver.js navigates on keyup. + // Swallow the keydown while the tour is open so one key press does not do both. + function swallowArrows(event) { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.stopPropagation(); + } + } + + button.addEventListener('click', function () { + if (!window.driver || !window.driver.js) { + if (typeof window.appNotify === 'function') { + window.appNotify('warning', 'The tutorial could not load. Check your connection and reload the page.'); + } + return; + } + openedByTour = false; + window.addEventListener('keydown', swallowArrows, true); + window.driver.js.driver({ + steps: STEPS, + skipMissingElement: true, // a read-only user has no Mark button + showProgress: true, + progressText: '{{current}} of {{total}}', + nextBtnText: 'Next', + prevBtnText: 'Back', + doneBtnText: 'Done', + popoverClass: 'ygg-tour', + stagePadding: 6, + stageRadius: 8, + onDestroyed: function () { + window.removeEventListener('keydown', swallowArrows, true); + if (openedByTour) { + whenIdle(function () { if (isOpen()) { toggle.click(); } }); + } + } + }).drive(); + }); +})(); diff --git a/static/vendor/cornerstone/ff220b1a/app/chunk-O2OOUNWU.js b/static/vendor/cornerstone/bac38813/app/chunk-24KACO42.js similarity index 100% rename from static/vendor/cornerstone/ff220b1a/app/chunk-O2OOUNWU.js rename to static/vendor/cornerstone/bac38813/app/chunk-24KACO42.js diff --git a/static/vendor/cornerstone/ff220b1a/app/chunk-IVUMTZUA.js b/static/vendor/cornerstone/bac38813/app/chunk-BZOQXIL3.js similarity index 100% rename from static/vendor/cornerstone/ff220b1a/app/chunk-IVUMTZUA.js rename to static/vendor/cornerstone/bac38813/app/chunk-BZOQXIL3.js diff --git a/static/vendor/cornerstone/ff220b1a/app/chunk-TTTJJYWU.js b/static/vendor/cornerstone/bac38813/app/chunk-GRRCRL43.js similarity index 100% rename from static/vendor/cornerstone/ff220b1a/app/chunk-TTTJJYWU.js rename to static/vendor/cornerstone/bac38813/app/chunk-GRRCRL43.js diff --git a/static/vendor/cornerstone/ff220b1a/app/chunk-EUNK5TYG.js b/static/vendor/cornerstone/bac38813/app/chunk-HQFZGD3V.js similarity index 99% rename from static/vendor/cornerstone/ff220b1a/app/chunk-EUNK5TYG.js rename to static/vendor/cornerstone/bac38813/app/chunk-HQFZGD3V.js index 28c8bf33..2d2d7c64 100644 --- a/static/vendor/cornerstone/ff220b1a/app/chunk-EUNK5TYG.js +++ b/static/vendor/cornerstone/bac38813/app/chunk-HQFZGD3V.js @@ -1,4 +1,4 @@ -import{a as G,b as te}from"/static/vendor/cornerstone/ff220b1a/app/chunk-HPLBK6MT.js";import{e as F}from"/static/vendor/cornerstone/ff220b1a/app/chunk-IVUMTZUA.js";import{b as z,c as ee,d as k}from"/static/vendor/cornerstone/ff220b1a/app/chunk-O2OOUNWU.js";function v({scl_slope:e,scl_inter:n}={}){let t=Number.isFinite(e)&&e!==0,o=Number.isFinite(n);return{rescaleSlope:t?e:1,rescaleIntercept:o?n:0}}function qt(e){return v(e??{})}function Jt(e,{rescaleSlope:n,rescaleIntercept:t}){return e*n+t}function ze(e){let{rescaleSlope:n,rescaleIntercept:t}=v(e??{});return!(n!==1&&t!==0)}function ke(e){return!ze(e)}function Kt(e){return ke(e)?{rescaleSlope:1,rescaleIntercept:0}:v(e??{})}function Qt(e,{rescaleSlope:n,rescaleIntercept:t}){return(e-t)/n}var _=4,x=Object.freeze({AXIAL:"axial",SAGITTAL:"sagittal",CORONAL:"coronal",RENDER:"render"}),Ge=Object.freeze([x.AXIAL,x.SAGITTAL,x.CORONAL]),L=Object.freeze({ORTHOGRAPHIC:"orthographic",VOLUME_3D:"volume3d"}),V=Object.freeze({AXIAL:"axial",SAGITTAL:"sagittal",CORONAL:"coronal"});function en(e){switch(e){case x.AXIAL:return{type:L.ORTHOGRAPHIC,orientation:V.AXIAL};case x.SAGITTAL:return{type:L.ORTHOGRAPHIC,orientation:V.SAGITTAL};case x.CORONAL:return{type:L.ORTHOGRAPHIC,orientation:V.CORONAL};case x.RENDER:return{type:L.VOLUME_3D,orientation:null};default:throw new Error(`Unknown orientation '${e}'.`)}}function M(e){return Ge.includes(e)}var ne=Object.freeze([Object.freeze({window:0,orientation:x.AXIAL,lazy:!1}),Object.freeze({window:1,orientation:x.SAGITTAL,lazy:!1}),Object.freeze({window:2,orientation:x.CORONAL,lazy:!1}),Object.freeze({window:3,orientation:x.RENDER,lazy:!1})]),oe=Object.freeze([Object.freeze({window:0,orientation:x.AXIAL,lazy:!1})]),re=Object.freeze(Array.from({length:4},(e,n)=>Object.freeze({window:n,orientation:x.AXIAL,lazy:!1})));function tn(e=[]){return new Set(e.filter(t=>!t.lazy&&M(t.orientation)).map(t=>t.orientation)).size>=2}function ie(e){return $(e),`ygg-grid-${e}`}var nn="nifti",Be="ygg-volume";function on(e){if(typeof e!="string"||e.length===0)throw new Error("A volume id needs the loader URL it is derived from.");return`${Be}:${e}`}function rn(e){return e===x.RENDER?"ygg-grid-3d":"ygg-grid-2d"}function $(e){if(!Number.isInteger(e)||e<0||e>=4)throw new Error(`Window index must be an integer in 0..3, got ${JSON.stringify(e)}.`)}function an({ViewportType:e,OrientationAxis:n}){let t=[];for(let[o,r]of Object.entries(L))e?.[o]!==r&&t.push(`ViewportType.${o}: expected '${r}', got '${e?.[o]}'`);for(let[o,r]of Object.entries(V))n?.[o]!==r&&t.push(`OrientationAxis.${o}: expected '${r}', got '${n?.[o]}'`);if(t.length)throw new Error(`Cornerstone enum values have changed under frontend/imaging/grid/layout.js: +import{a as G,b as te}from"/static/vendor/cornerstone/bac38813/app/chunk-XRDL4JCO.js";import{e as F}from"/static/vendor/cornerstone/bac38813/app/chunk-BZOQXIL3.js";import{b as z,c as ee,d as k}from"/static/vendor/cornerstone/bac38813/app/chunk-24KACO42.js";function v({scl_slope:e,scl_inter:n}={}){let t=Number.isFinite(e)&&e!==0,o=Number.isFinite(n);return{rescaleSlope:t?e:1,rescaleIntercept:o?n:0}}function qt(e){return v(e??{})}function Jt(e,{rescaleSlope:n,rescaleIntercept:t}){return e*n+t}function ze(e){let{rescaleSlope:n,rescaleIntercept:t}=v(e??{});return!(n!==1&&t!==0)}function ke(e){return!ze(e)}function Kt(e){return ke(e)?{rescaleSlope:1,rescaleIntercept:0}:v(e??{})}function Qt(e,{rescaleSlope:n,rescaleIntercept:t}){return(e-t)/n}var _=4,x=Object.freeze({AXIAL:"axial",SAGITTAL:"sagittal",CORONAL:"coronal",RENDER:"render"}),Ge=Object.freeze([x.AXIAL,x.SAGITTAL,x.CORONAL]),L=Object.freeze({ORTHOGRAPHIC:"orthographic",VOLUME_3D:"volume3d"}),V=Object.freeze({AXIAL:"axial",SAGITTAL:"sagittal",CORONAL:"coronal"});function en(e){switch(e){case x.AXIAL:return{type:L.ORTHOGRAPHIC,orientation:V.AXIAL};case x.SAGITTAL:return{type:L.ORTHOGRAPHIC,orientation:V.SAGITTAL};case x.CORONAL:return{type:L.ORTHOGRAPHIC,orientation:V.CORONAL};case x.RENDER:return{type:L.VOLUME_3D,orientation:null};default:throw new Error(`Unknown orientation '${e}'.`)}}function M(e){return Ge.includes(e)}var ne=Object.freeze([Object.freeze({window:0,orientation:x.AXIAL,lazy:!1}),Object.freeze({window:1,orientation:x.SAGITTAL,lazy:!1}),Object.freeze({window:2,orientation:x.CORONAL,lazy:!1}),Object.freeze({window:3,orientation:x.RENDER,lazy:!1})]),oe=Object.freeze([Object.freeze({window:0,orientation:x.AXIAL,lazy:!1})]),re=Object.freeze(Array.from({length:4},(e,n)=>Object.freeze({window:n,orientation:x.AXIAL,lazy:!1})));function tn(e=[]){return new Set(e.filter(t=>!t.lazy&&M(t.orientation)).map(t=>t.orientation)).size>=2}function ie(e){return $(e),`ygg-grid-${e}`}var nn="nifti",Be="ygg-volume";function on(e){if(typeof e!="string"||e.length===0)throw new Error("A volume id needs the loader URL it is derived from.");return`${Be}:${e}`}function rn(e){return e===x.RENDER?"ygg-grid-3d":"ygg-grid-2d"}function $(e){if(!Number.isInteger(e)||e<0||e>=4)throw new Error(`Window index must be an integer in 0..3, got ${JSON.stringify(e)}.`)}function an({ViewportType:e,OrientationAxis:n}){let t=[];for(let[o,r]of Object.entries(L))e?.[o]!==r&&t.push(`ViewportType.${o}: expected '${r}', got '${e?.[o]}'`);for(let[o,r]of Object.entries(V))n?.[o]!==r&&t.push(`OrientationAxis.${o}: expected '${r}', got '${n?.[o]}'`);if(t.length)throw new Error(`Cornerstone enum values have changed under frontend/imaging/grid/layout.js: `+t.join(` `)+` Update the inlined constants and the tests that pin them.`)}var Ue=1e5;function sn(){return{minimal:{enabled:!0,lineLengthInPx:Ue},mobile:{enabled:!1}}}function un(e=[]){let n=Array.from({length:4},(t,o)=>({index:o,orientation:x.AXIAL,modality:null,fileId:null,volumeId:null,loading:!1,error:null,loadGeneration:0,freeScroll:!1,lazy:!1,orientationWarning:null}));for(let t of e)$(t.window),n[t.window].orientation=t.orientation,n[t.window].lazy=!!t.lazy;return{windows:n}}function S(e,n){return $(n),e.windows[n]}function dn(e,n,{modality:t=null,fileId:o=null,volumeId:r=null}={}){let i=S(e,n);return i.loadGeneration+=1,i.loading=!0,i.error=null,i.orientationWarning=null,i.modality=t,i.fileId=o,i.volumeId=r,i.loadGeneration}function ae(e,n,t){return S(e,n).loadGeneration===t}function fn(e,n,t,{orientationWarning:o=null}={}){if(!ae(e,n,t))return!1;let r=S(e,n);return r.loading=!1,r.error=null,r.orientationWarning=o,!0}function mn(e,n,t,o){if(!ae(e,n,t))return!1;let r=S(e,n);return r.loading=!1,r.error=o,!0}function pn(e,n,t){if(!Object.values(x).includes(t))throw new Error(`Unknown orientation '${t}'.`);S(e,n).orientation=t}function gn(e,n,t){S(e,n).freeScroll=!!t}function hn(e,n){let t=S(e,n);return t.freeScroll||!M(t.orientation)?[]:e.windows.filter(o=>o.index!==n&&!o.freeScroll&&M(o.orientation)&&o.volumeId!==null).map(o=>o.index)}function yn(e,n){let t=S(e,n);return t.freeScroll||t.loading||t.volumeId===null||!M(t.orientation)?[]:We(e,t.orientation).filter(o=>o!==n&&!S(e,o).loading)}function xn(e){let n=e.windows.filter(o=>o.volumeId!==null);if(n.length<2)return[];let[t]=n;return n.some(o=>o.volumeId!==t.volumeId)?[]:n.map(o=>o.index)}function We(e,n){return e.windows.filter(t=>t.orientation===n&&!t.freeScroll&&t.volumeId!==null).map(t=>t.index)}function wn(e){return[...new Set(e.windows.map(n=>n.volumeId).filter(Boolean))]}function Xe(){return{done:!1,framesLoaded:0,framesProcessed:0,totalNumFrames:0,failures:[]}}function He(e,n){let t=Number(n?.totalNumFrames)||e.totalNumFrames,o=Number(n?.framesProcessed)||0,r=n?.success===!1?[...e.failures,{imageId:n.imageId,error:String(n.error??"unknown")}]:e.failures;return{totalNumFrames:t,framesProcessed:Math.max(e.framesProcessed,o),framesLoaded:Math.max(e.framesLoaded,Number(n?.framesLoaded)||0),failures:r,done:t>0&&o>=t}}function Ye(e){if(!e.done)return{ok:!1,message:`The volume did not finish loading: ${e.framesProcessed} of ${e.totalNumFrames||"an unknown number of"} frames were processed.`};if(e.failures.length){let[n]=e.failures;return{ok:!1,message:`${e.failures.length} of ${e.totalNumFrames} frames failed to load (first: ${n.imageId??"unknown image"} -- ${n.error}). A volume with missing frames must not be measured: the gaps read as data.`}}return{ok:!0,message:null}}function se(e,{timeoutMs:n=3e5}={}){return new Promise((t,o)=>{let r=Xe(),i=!1,a=setTimeout(()=>{i||(i=!0,o(new Error(`The volume did not finish loading within ${Math.round(n/1e3)}s (${r.framesProcessed} of ${r.totalNumFrames} frames). Refusing to measure a partially loaded volume.`)))},n),l=()=>{i=!0,clearTimeout(a);let s=Ye(r);s.ok?t(r):o(new Error(s.message))};if(e.load(s=>{i||(r=He(r,s),r.done&&l())}),!i&&e.loadStatus?.loaded){let s=e.imageIds?.length??0;r={...r,done:!0,totalNumFrames:s,framesProcessed:s},l()}})}function le(e){let n=e?.voxelManager;if(!n)throw new Error("The volume exposes no voxel manager; it has not finished loading.");let t=n.getCompleteScalarDataArray?n.getCompleteScalarDataArray():n.getScalarData?.();if(!t)throw new Error("The volume exposes no scalar data.");let o=qe(e);if(t.length===0)throw new Error("The volume cached no voxels at all. `getCompleteScalarDataArray` returns an empty array when no slice has data yet, so this is a read before the load completed rather than an empty study.");if(o&&t.length!==o)throw new Error(`The volume cached ${t.length} voxels but its dimensions imply ${o}. Measuring a short array would silently treat the missing tail as zeros.`);return t}function qe(e){let n=e?.dimensions;return!Array.isArray(n)&&!ArrayBuffer.isView(n)?0:Number(n[0])*Number(n[1])*Number(n[2])}async function En(e){let n=globalThis.nifti;if(!n)throw new Error("The vendored nifti-reader is not loaded; orientation cannot be checked.");let t=await fetch(e,{credentials:"same-origin",headers:{Range:"bytes=0-1023"}});if(!t.ok&&t.status!==206)throw new Error(`HTTP ${t.status} fetching the volume header.`);let o=await t.arrayBuffer(),r=n.isCompressed(o)?n.decompress(o):o;return n.readHeader(r)}var ce=Object.freeze([Object.freeze(["L","R"]),Object.freeze(["P","A"]),Object.freeze(["I","S"])]),ue=1e-6;function de(e){return typeof e=="number"&&Number.isFinite(e)}function me(e){let[n,t,o]=[e[0][0],e[0][1],e[0][2]],[r,i,a]=[e[1][0],e[1][1],e[1][2]],[l,s,c]=[e[2][0],e[2][1],e[2][2]];return n*(i*c-a*s)-t*(r*c-a*l)+o*(r*s-i*l)}function B(e){if(!e||e.length<4)return!1;for(let t=0;t<3;t+=1){let o=e[t];if(!o||o.length<4)return!1;for(let r=0;r<4;r+=1)if(!de(o[r]))return!1}let n=me(e);return de(n)&&Math.abs(n)>=1e-9}function Je(e){if(!B(e))return null;let n=[];for(let t=0;t<3;t+=1){let o=[e[0][t],e[1][t],e[2][t]],r=0;for(let i=1;i<3;i+=1)Math.abs(o[i])>Math.abs(o[r])&&(r=i);if(o[r]===0)return null;n.push(o[r]>0?ce[r][1]:ce[r][0])}return n.join("")}function Ke(e){let n=[e[0][3],e[1][3],e[2][3]],t=[0,1,2].map(r=>Math.sqrt(e[0][r]**2+e[1][r]**2+e[2][r]**2)),o=[];for(let r=0;r<3;r+=1)for(let i=0;i<3;i+=1)o.push(e[r][i]/t[i]);return{origin:n,orientation:o,spacing:t}}function Qe(e){let{origin:n,orientation:t,spacing:o}=Ke(e);return{origin:[-n[0],-n[1],n[2]],direction:[-t[0],-t[3],t[6],-t[1],-t[4],t[7],-t[2],-t[5],t[8]],spacing:o}}function Ze(e,n){let[t,o,r]=n;return[0,1,2].map(i=>e[i][0]*t+e[i][1]*o+e[i][2]*r+e[i][3])}function An(e,n){let[t,o,r]=Ze(e,n);return[-t,-o,r]}function et(e){let n=[0,1,2].map(o=>e.slice(o*3,o*3+3)),t=0;for(let o=0;o<3;o+=1)for(let r=o;r<3;r+=1){let i=n[o][0]*n[r][0]+n[o][1]*n[r][1]+n[o][2]*n[r][2];t=Math.max(t,Math.abs(i-(o===r?1:0)))}return t}function tt(e){let n=Number(e?.qform_code)|0,t=Number(e?.sform_code)|0;return n>=1||t>=1}function nt(e){let n=[1,2,3].map(t=>{let o=Number(e?.[t]);return Number.isFinite(o)&&o>0?o:1});return[[n[0],0,0,0],[0,n[1],0,0],[0,0,n[2],0],[0,0,0,1]]}function pe(e){let n=tt(e),t=[],o=e?.affine??null;if(n||(t.push("qform_code and sform_code are both 0: the orientation is inferred from pixel dimensions and may be mirrored (finding F2)."),B(o)||(o=nt(e?.pixDims))),!B(o))return t.push("The affine is degenerate or unreadable."),{affine:o??null,declared:n,hasMetadata:!1,axcodes:null,determinant:NaN,handedness:0,origin:[NaN,NaN,NaN],direction:new Array(9).fill(NaN),spacing:[NaN,NaN,NaN],dimensions:fe(e),orthonormalityDefect:NaN,issues:t};let r=Je(o);r===null&&t.push("The affine could not be mapped to anatomical axes.");let{origin:i,direction:a,spacing:l}=Qe(o),s=me(o),c=et(a);return c>ue&&t.push(`Direction cosines are not orthonormal to ${ue} (worst deviation ${c.toExponential(3)}).`),{affine:o,declared:n,hasMetadata:n&&r!==null,axcodes:r,determinant:s,handedness:Math.sign(s),origin:i,direction:a,spacing:l,dimensions:fe(e),orthonormalityDefect:c,issues:t}}function fe(e){let n=e?.dims;return!Array.isArray(n)&&!ArrayBuffer.isView(n)?[0,0,0]:[1,2,3].map(t=>Number(n[t])|0)}var ot="patient_lps_mm",ge="Crosshairs";function rt(e){let n=pe(e),{rescaleSlope:t,rescaleIntercept:o}=v(e);return{shape:n.dimensions,spacing_mm:n.spacing,affine:n.affine,orientation:n.axcodes,qform_code:Number(e?.qform_code)|0,sform_code:Number(e?.sform_code)|0,spatial_codes_absent:!n.declared,scl_slope:t,scl_inter:o,recorded_by:"volume-grid"}}function he({fileId:e,bundleKey:n,annotations:t,header:o,expectedRevision:r}){if(!Number.isInteger(e)||e<=0)throw new Error(`fileId must be a positive integer, got ${JSON.stringify(e)}.`);return ee(t,r),{fileId:e,...n&&n!=="primary"?{fileKey:n}:{},expectedRevision:r,coordinateSystem:ot,volumeDescriptor:rt(o),annotations:t}}var it=Object.freeze({resetView:"resetCBCTView",save:"cbctSaveMeasurements",clear:"cbctClearMeasurements",expand3D:"cbctExpand3D",annotationMode:"cbctAnnotationMode",annotationTools:"cbctAnnotationTools",status:"cbctRenderStatus"}),at="is-3d-expanded",st="[data-ygg-tool]";function lt(e){let n={};for(let[t,o]of Object.entries(it))n[t]=e.getElementById(o);return n.tools=Array.from(e.querySelectorAll(st)),n}function j(e,n){for(let t of e){let o=t.dataset.yggTool===n;t.setAttribute("aria-pressed",String(o)),t.classList?.toggle("is-active",o)}}var W="Measurements saved.",ct="Remove every measurement drawn on this study? They come back if you reload without saving.",X="Measurements removed. Save to make it permanent.";function ut(e,n){let t=globalThis.appNotify;typeof t=="function"?t(e,n):console.info(`[ygg-grid] ${e}: ${n}`)}function dt(e){return e?.annotationMode?.getAttribute?.("aria-checked")==="true"}function U({plan:e,enabled:n}){let t=!!n;if(e.annotationMode){e.annotationMode.setAttribute("aria-checked",String(t));let o=e.annotationMode.querySelector?.("[data-mode-state]");o&&(o.textContent=t?"on":"off")}e.annotationTools&&(e.annotationTools.hidden=!t)}var xe="ygg:annotation-mode:";function ft(){try{return globalThis.localStorage??null}catch{return null}}function mt(e,n){if(!e||!n)return null;try{let t=n.getItem(xe+e);return t==="on"?!0:t==="off"?!1:null}catch{return null}}function pt(e,n,t){if(!(!e||!n))try{n.setItem(xe+e,t?"on":"off")}catch{}}function we({grid:e,doc:n=globalThis.document,onSave:t,onClear:o,onAnnotationMode:r,annotationsOn:i=!1,rememberAs:a=null,storage:l=ft(),notify:s=ut}){let c=lt(n),m=[],h=d=>{c.status&&(c.status.textContent=d||"")},g=(d,p)=>async u=>{try{await p(u)}catch(f){h(`${d} failed: ${f.message}`)}};c.resetView&&(c.resetView.addEventListener("click",g("Reset view",()=>{e.resetCameras(),h("")})),m.push("resetView"));for(let d of c.tools){let p=d.dataset.yggTool;d.addEventListener("click",g(p,()=>{e.setPrimaryTool(p),j(c.tools,p),h("")}))}if(c.tools.length&&m.push("tools"),c.expand3D&&(c.expand3D.addEventListener("click",g("Expand 3D",()=>{let p=c.expand3D.ownerDocument.querySelector(".viewer-grid")?.classList.toggle(at)??!1;c.expand3D.setAttribute("aria-pressed",String(p)),e.resize?.()})),m.push("expand3D")),c.annotationMode){let p=mt(a,l)??i===!0;U({plan:c,enabled:p}),r?.(p),c.annotationMode.addEventListener("click",g("Annotation mode",()=>{let u=!dt(c);U({plan:c,enabled:u}),pt(a,l,u),r?.(u),u||j(c.tools,e.navigationTool??ge),h("")})),m.push("annotationMode")}return c.clear&&o&&(c.clear.addEventListener("click",async()=>{let d=c.clear.ownerDocument?.defaultView??globalThis;if(!(d.confirm&&!d.confirm(ct)))try{let p=await o();s(p?.level??"success",p?.message??X)}catch(p){s("danger",`Could not clear the measurements: ${p.message}`)}}),m.push("clear")),c.save&&t&&(c.save.addEventListener("click",async()=>{c.save.setAttribute("aria-busy","true");try{let d=await t();s(d?.level??"success",d?.message??W)}catch(d){s("danger",`The save failed: ${d.message}`)}finally{c.save.removeAttribute("aria-busy")}}),m.push("save")),{bound:m,setStatus:h,plan:c,setAnnotationMode:d=>{U({plan:c,enabled:d}),r?.(d)}}}function be(e){let n=e?.ownerDocument;if(!n)return{update:()=>{},done:()=>{}};let t=n.createElement("div");t.className="ygg-loading",t.setAttribute("role","status"),t.setAttribute("aria-live","polite");let o=n.createElement("div");o.className="ygg-loading__spinner",t.appendChild(o);let r=n.createElement("div");return r.className="ygg-loading__text",r.textContent="Loading volume\u2026",t.appendChild(r),e.appendChild(t),{update(i,a){if(a)r.textContent=a;else if(Number.isFinite(i)){let l=Math.min(100,Math.max(0,Math.round(i*100)));r.textContent=`Loading volume\u2026 ${l}%`}},done(){t.remove()}}}var R=Object.freeze({root:"ygg-orient",button:"ygg-orient__btn",link:"ygg-orient__link"}),gt=Object.freeze([Object.freeze({orientation:x.AXIAL,letter:"A",title:"Axial view"}),Object.freeze({orientation:x.SAGITTAL,letter:"S",title:"Sagittal view"}),Object.freeze({orientation:x.CORONAL,letter:"C",title:"Coronal view"})]),ye=Object.freeze({linked:Object.freeze({icon:"fa-link",title:"Scrolls with the other windows on this plane. Click to scroll this one on its own."}),free:Object.freeze({icon:"fa-link-slash",title:"Scrolls on its own. Click to re-sync it with the other windows on this plane."})});function Ee(e,n){if(!e)return;let t=n?ye.free:ye.linked;e.setAttribute("aria-pressed",String(!!n)),e.classList?.toggle("is-active",!!n),e.title=t.title,e.setAttribute("aria-label",t.title),e.icon&&(e.icon.className=`fas ${t.icon}`)}function ht(e,n){for(let t of e){let o=t.dataset.yggOrientation===n;t.setAttribute("aria-pressed",String(o)),t.classList?.toggle("is-active",o)}}function yt(e,{doc:n=e?.ownerDocument,freeScroll:t=!1}={}){if(!e||!n)return null;e.querySelector?.(`.${R.root}`)?.remove?.();let o=n.createElement("div");o.className=R.root,o.setAttribute("role","group"),o.setAttribute("aria-label","View plane");let r=gt.map(a=>{let l=n.createElement("button");return l.type="button",l.className=R.button,l.dataset.yggOrientation=a.orientation,l.title=a.title,l.setAttribute("aria-label",a.title),l.textContent=a.letter,o.appendChild(l),l}),i=null;if(t){i=n.createElement("button"),i.type="button",i.className=`${R.button} ${R.link}`,i.dataset.yggFreeScroll="";let a=n.createElement("i");a.setAttribute("aria-hidden","true"),i.appendChild(a),i.icon=a,Ee(i,!1),o.appendChild(i)}for(let a of["mousedown","pointerdown"])o.addEventListener(a,l=>l.stopPropagation());return o.addEventListener("contextmenu",a=>{a.stopPropagation(),a.preventDefault()}),e.appendChild(o),{root:o,buttons:r,freeScrollButton:i}}function Oe({grid:e,elements:n=[],doc:t=globalThis.document,setStatus:o=()=>{},windowOrientation:r,windowFreeScroll:i}){let a=new Map,l=typeof i=="function"&&n.length>1,s=c=>{let m=a.get(c);m&&(ht(m.buttons,r(c)),l&&Ee(m.freeScrollButton,i(c)))};for(let[c,m]of n.entries()){let h=yt(m,{doc:t,freeScroll:l});if(h){a.set(c,h),s(c);for(let g of h.buttons){let E=g.dataset.yggOrientation;g.addEventListener("click",async()=>{try{await e.setWindowOrientation(c,E),o("")}catch(d){o(`View plane failed: ${d.message}`)}s(c)})}h.freeScrollButton?.addEventListener("click",()=>{try{e.setFreeScroll(c,!i(c)),o("")}catch(g){o(`Free scroll failed: ${g.message}`)}s(c)})}}return{controls:a,refresh:s}}var xt=".modality-chip[data-modality]",D="drag-over",Ae="is-armed";var Se="application/json",Te="text/plain";function C(e){return!e||typeof e.closest!="function"?null:e.closest(".viewer-window[data-window-index]")}function Ie({doc:e,elements:n,onDrop:t}){let o=Array.from(e.querySelectorAll(xt)),r=[],i=(a,l,s,c=!1)=>{a.addEventListener(l,s,c),r.push(()=>a.removeEventListener(l,s,c))};for(let a of o)i(a,"dragstart",l=>{let s=a.dataset.modality;l.dataTransfer.setData(Te,s),l.dataTransfer.setData(Se,JSON.stringify({modality:s})),l.dataTransfer.effectAllowed="copy",a.classList.add("is-dragging")}),i(a,"dragend",()=>a.classList.remove("is-dragging"));wt({doc:e,chips:o,elements:n,onDrop:t,on:i});for(let a of n)a&&(i(a,"dragover",l=>{let s=C(l.target)||a;l.preventDefault(),l.dataTransfer.dropEffect="copy",s.classList.add(D)},!0),i(a,"dragleave",l=>{let s=C(l.target)||a;l.relatedTarget&&s.contains(l.relatedTarget)||s.classList.remove(D)},!0),i(a,"drop",l=>{let s=C(l.target)||a;l.preventDefault(),s.classList.remove(D);let c=bt(l.dataTransfer),m=Number(s.dataset.windowIndex);!c||!Number.isInteger(m)||t(m,c)},!0));return()=>r.forEach(a=>a())}function wt({doc:e,chips:n,elements:t,onDrop:o,on:r}){let i=t.filter(Boolean),a=null,l=null,s=null,c=d=>d.pointerType==="touch"||d.pointerType==="pen",m=(d,p)=>{let u=C(e.elementFromPoint?.(d,p)??null);return u&&i.includes(u)?u:null},h=d=>{s!==d&&(s?.classList.remove(D),s=d,s?.classList.add(D))},g=()=>{a?.classList.remove(Ae),a=null},E=(d,p)=>{let u=Number(d.dataset.windowIndex);p&&Number.isInteger(u)&&o(u,p)};for(let d of n){r(d,"pointerdown",u=>{c(u)&&(l={chip:d,pointerId:u.pointerId,x:u.clientX,y:u.clientY,dragging:!1},d.setPointerCapture?.(u.pointerId))}),r(d,"pointermove",u=>{if(!l||l.chip!==d||u.pointerId!==l.pointerId)return;let f=Math.hypot(u.clientX-l.x,u.clientY-l.y);!l.dragging&&f>=8&&(l.dragging=!0,g(),d.classList.add("is-dragging")),l.dragging&&(u.preventDefault?.(),h(m(u.clientX,u.clientY)))});let p=(u,f)=>{if(!l||l.chip!==d||u.pointerId!==l.pointerId)return;let{dragging:w}=l;if(l=null,d.classList.remove("is-dragging"),h(null),f)return;if(w){let y=m(u.clientX,u.clientY);y&&E(y,d.dataset.modality);return}let b=a===d;g(),b||(a=d,d.classList.add(Ae))};r(d,"pointerup",u=>p(u,!1)),r(d,"pointercancel",u=>p(u,!0))}for(let d of i)r(d,"pointerup",p=>{if(!a||!c(p))return;let u=C(p.target)||d,f=a.dataset.modality;g(),E(u,f)},!0)}function bt(e){try{let t=JSON.parse(e.getData(Se));if(t?.modality)return String(t.modality)}catch{}let n=e.getData(Te);return n?String(n):null}var Et=" // Find maximum/minimum intensity along the ray.",Ot=[" vec4 previousSelectedValue = selectedValue;"," vec4 currentValue = getTextureValue(posIS);"," selectedValue = OP(selectedValue, currentValue);"," if (previousSelectedValue != selectedValue) {"," selectedPosVC = posVC;"," selectedPosIS = posIS;"," }"].join(` diff --git a/static/vendor/cornerstone/ff220b1a/app/chunk-WHYFTSVR.js b/static/vendor/cornerstone/bac38813/app/chunk-MXB2T76R.js similarity index 99% rename from static/vendor/cornerstone/ff220b1a/app/chunk-WHYFTSVR.js rename to static/vendor/cornerstone/bac38813/app/chunk-MXB2T76R.js index d26f843b..7efbcfe7 100644 --- a/static/vendor/cornerstone/ff220b1a/app/chunk-WHYFTSVR.js +++ b/static/vendor/cornerstone/bac38813/app/chunk-MXB2T76R.js @@ -1,4 +1,4 @@ -import{a as x,b as y,c as w,d as E}from"/static/vendor/cornerstone/ff220b1a/app/chunk-IVUMTZUA.js";var v="yggweb",b=255;function D({fileId:t,filename:e,namespace:a="api",origin:o}={}){y(e);let n=E(w(x({fileId:t,filename:e,namespace:a}),{origin:o}));return`${v}:${n}`}function P(t){if(typeof t!="string")throw new Error("imageId must be a string.");let e=`${v}:`;if(!t.startsWith(e))throw new Error(`${JSON.stringify(t)} is not a ${e} imageId. This loader is registered for that scheme only.`);let a=t.slice(e.length);if(!a)throw new Error("A yggweb: imageId must carry a URL.");return a}function L({imageId:t,width:e,height:a,pixelData:o,numberOfComponents:n,voxelManagerFactory:r}){if(!Number.isInteger(e)||!Number.isInteger(a)||e<=0||a<=0)throw new Error(`A web image needs positive integer dimensions, got ${e}x${a}.`);if(n!==1&&n!==3)throw new Error(`numberOfComponents must be 1 (greyscale) or 3 (RGB), got ${n}. Four would leave vtk.js rendering the alpha channel as a fourth band.`);let i=e*a*n;if(o?.length!==i)throw new Error(`pixelData is ${o?.length} values; ${e}x${a} at ${n} components needs ${i}. A short buffer renders as a partly black image rather than as an error.`);let s=n>1,g=r({scalarData:o,width:e,height:a,numberOfComponents:n});return{imageId:t,rows:a,columns:e,height:a,width:e,color:s,rgba:!1,numberOfComponents:n,photometricInterpretation:s?"RGB":"MONOCHROME2",minPixelValue:0,maxPixelValue:b,slope:1,intercept:0,windowCenter:(b+1)/2,windowWidth:b+1,voiLUTFunction:"LINEAR",invert:!1,rowPixelSpacing:null,columnPixelSpacing:null,sizeInBytes:o.byteLength,voxelManager:g,getPixelData:()=>g.getScalarData(),getCanvas:()=>{}}}function M(t,e){if(t.length!==e*4)throw new Error(`Expected ${e*4} RGBA values, got ${t.length}.`);let a=!0;for(let n=0;nglobalThis.fetch(...r)),n=a??A;return function(i){let s=new AbortController,g=P(i);return{promise:(async()=>{let l=await o(g,{credentials:"same-origin",signal:s.signal});if(!l.ok)throw new Error(`HTTP ${l.status} loading ${g}`);let{width:d,height:p,rgba:m}=await n(await l.blob()),{pixelData:f,numberOfComponents:u}=M(m,d*p);return L({imageId:i,width:d,height:p,pixelData:f,numberOfComponents:u,voxelManagerFactory:t})})(),cancelFn:()=>s.abort()}}}async function A(t){let e=await globalThis.createImageBitmap(t);try{let o=new globalThis.OffscreenCanvas(e.width,e.height).getContext("2d",{willReadFrequently:!0});o.drawImage(e,0,0);let{data:n}=o.getImageData(0,0,e.width,e.height);return{width:e.width,height:e.height,rgba:n}}finally{e.close?.()}}var U=Object.freeze(["imagePlaneModule","imagePixelModule","generalSeriesModule","calibratedPixelSpacing"]),C="User";function I(t){let e={rows:t.height,columns:t.width,rowCosines:[1,0,0],columnCosines:[0,1,0],imagePositionPatient:[0,0,0],imageOrientationPatient:[1,0,0,0,1,0]},a=t.pixelSpacingMm;return a?{...e,rowPixelSpacing:a.y_mm,columnPixelSpacing:a.x_mm,pixelSpacing:[a.y_mm,a.x_mm]}:e}function $(t){let e=t.numberOfComponents>1;return{samplesPerPixel:e?3:1,photometricInterpretation:e?"RGB":"MONOCHROME2",rows:t.height,columns:t.width,bitsAllocated:8,bitsStored:8,highBit:7,pixelRepresentation:0,planarConfiguration:e?0:void 0,windowCenter:128,windowWidth:256}}function k(t){return{modality:"OT",seriesInstanceUID:void 0,studyInstanceUID:void 0,seriesNumber:t.index??void 0,seriesDescription:t.description??""}}function N(t){let e=t.pixelSpacingMm;if(e)return{rowPixelSpacing:e.y_mm,columnPixelSpacing:e.x_mm,type:C}}var S=new Set,O=Object.freeze({get(t){for(let e of S){let a=e.get(t);if(a)return a}}});function q(t){return S.add(t),t}function B(t=O){return function(a,o){if(typeof o!="string")return;let n=t.get(o);if(n)switch(a){case"imagePlaneModule":return I(n);case"imagePixelModule":return $(n);case"generalSeriesModule":return k(n);case"calibratedPixelSpacing":return N(n);default:return}}}var R="ygg-photo-dialog";function h(t,{title:e,icon:a,body:o,confirmLabel:n,cancelLabel:r}){let i=t.createElement("div");i.className=`modal ${R}`,i.setAttribute("tabindex","-1"),i.setAttribute("role","dialog"),i.setAttribute("aria-modal","true"),i.innerHTML=` +import{a as x,b as y,c as w,d as E}from"/static/vendor/cornerstone/bac38813/app/chunk-BZOQXIL3.js";var v="yggweb",b=255;function D({fileId:t,filename:e,namespace:a="api",origin:o}={}){y(e);let n=E(w(x({fileId:t,filename:e,namespace:a}),{origin:o}));return`${v}:${n}`}function P(t){if(typeof t!="string")throw new Error("imageId must be a string.");let e=`${v}:`;if(!t.startsWith(e))throw new Error(`${JSON.stringify(t)} is not a ${e} imageId. This loader is registered for that scheme only.`);let a=t.slice(e.length);if(!a)throw new Error("A yggweb: imageId must carry a URL.");return a}function L({imageId:t,width:e,height:a,pixelData:o,numberOfComponents:n,voxelManagerFactory:r}){if(!Number.isInteger(e)||!Number.isInteger(a)||e<=0||a<=0)throw new Error(`A web image needs positive integer dimensions, got ${e}x${a}.`);if(n!==1&&n!==3)throw new Error(`numberOfComponents must be 1 (greyscale) or 3 (RGB), got ${n}. Four would leave vtk.js rendering the alpha channel as a fourth band.`);let i=e*a*n;if(o?.length!==i)throw new Error(`pixelData is ${o?.length} values; ${e}x${a} at ${n} components needs ${i}. A short buffer renders as a partly black image rather than as an error.`);let s=n>1,g=r({scalarData:o,width:e,height:a,numberOfComponents:n});return{imageId:t,rows:a,columns:e,height:a,width:e,color:s,rgba:!1,numberOfComponents:n,photometricInterpretation:s?"RGB":"MONOCHROME2",minPixelValue:0,maxPixelValue:b,slope:1,intercept:0,windowCenter:(b+1)/2,windowWidth:b+1,voiLUTFunction:"LINEAR",invert:!1,rowPixelSpacing:null,columnPixelSpacing:null,sizeInBytes:o.byteLength,voxelManager:g,getPixelData:()=>g.getScalarData(),getCanvas:()=>{}}}function M(t,e){if(t.length!==e*4)throw new Error(`Expected ${e*4} RGBA values, got ${t.length}.`);let a=!0;for(let n=0;nglobalThis.fetch(...r)),n=a??A;return function(i){let s=new AbortController,g=P(i);return{promise:(async()=>{let l=await o(g,{credentials:"same-origin",signal:s.signal});if(!l.ok)throw new Error(`HTTP ${l.status} loading ${g}`);let{width:d,height:p,rgba:m}=await n(await l.blob()),{pixelData:f,numberOfComponents:u}=M(m,d*p);return L({imageId:i,width:d,height:p,pixelData:f,numberOfComponents:u,voxelManagerFactory:t})})(),cancelFn:()=>s.abort()}}}async function A(t){let e=await globalThis.createImageBitmap(t);try{let o=new globalThis.OffscreenCanvas(e.width,e.height).getContext("2d",{willReadFrequently:!0});o.drawImage(e,0,0);let{data:n}=o.getImageData(0,0,e.width,e.height);return{width:e.width,height:e.height,rgba:n}}finally{e.close?.()}}var U=Object.freeze(["imagePlaneModule","imagePixelModule","generalSeriesModule","calibratedPixelSpacing"]),C="User";function I(t){let e={rows:t.height,columns:t.width,rowCosines:[1,0,0],columnCosines:[0,1,0],imagePositionPatient:[0,0,0],imageOrientationPatient:[1,0,0,0,1,0]},a=t.pixelSpacingMm;return a?{...e,rowPixelSpacing:a.y_mm,columnPixelSpacing:a.x_mm,pixelSpacing:[a.y_mm,a.x_mm]}:e}function $(t){let e=t.numberOfComponents>1;return{samplesPerPixel:e?3:1,photometricInterpretation:e?"RGB":"MONOCHROME2",rows:t.height,columns:t.width,bitsAllocated:8,bitsStored:8,highBit:7,pixelRepresentation:0,planarConfiguration:e?0:void 0,windowCenter:128,windowWidth:256}}function k(t){return{modality:"OT",seriesInstanceUID:void 0,studyInstanceUID:void 0,seriesNumber:t.index??void 0,seriesDescription:t.description??""}}function N(t){let e=t.pixelSpacingMm;if(e)return{rowPixelSpacing:e.y_mm,columnPixelSpacing:e.x_mm,type:C}}var S=new Set,O=Object.freeze({get(t){for(let e of S){let a=e.get(t);if(a)return a}}});function q(t){return S.add(t),t}function B(t=O){return function(a,o){if(typeof o!="string")return;let n=t.get(o);if(n)switch(a){case"imagePlaneModule":return I(n);case"imagePixelModule":return $(n);case"generalSeriesModule":return k(n);case"calibratedPixelSpacing":return N(n);default:return}}}var R="ygg-photo-dialog";function h(t,{title:e,icon:a,body:o,confirmLabel:n,cancelLabel:r}){let i=t.createElement("div");i.className=`modal ${R}`,i.setAttribute("tabindex","-1"),i.setAttribute("role","dialog"),i.setAttribute("aria-modal","true"),i.innerHTML=`