Skip to content

Add an image preview to the DICOM Explorer series view #420

Description

@samuelvkwong

Motivation

ADIT has no way to look at pixel data. A user who only has ADIT access (the brainstorming example: a PhD student transferring studies for a professor, without an account on the PACS viewer) cannot check what a series actually contains before transferring it. The explorer stops at series-level text metadata, selective transfer offers a study zip download (#151), and the DICOMweb API is not usable from a browser session. The ask is a quick preview to sanity-check a series, not a diagnostic viewer. Low priority.

No issue in openradx/adit, openradx/radis or openradx/adit-radis-shared asks for a viewer or preview (titles and bodies searched for viewer, preview, OHIF, cornerstone, thumbnail on 2026-09-25). TODO.md:92 lists "Preview uploaded images" under the upload portal, which is the same UI component with a different data source.

Current behaviour

  • The explorer URL tree ends at .../series/<series_uid>/ (adit/dicom_explorer/urls.py:49-53). The series page renders four text cards (series_detail.html:14-17); _series_infos.html shows UID, number, description, modality and image count. dicom_explorer.js contains only "use strict";.
  • DicomDataCollector wraps find_patients/find_studies/find_series only and passes the 3 s DICOM_EXPLORER_RESPONSE_TIMEOUT (adit/settings/base.py:455) as the DIMSE timeout (dicom_data_collector.py:12-13). Every explorer request runs in an executor under asyncio.wait_for with the same 3 s (views.py:71-86); on timeout the thread is not cancelled.
  • Access: dicom_explorer.query_dicom_server (models.py:13-17, dummy PermissionSupport model) plus accessible_by_user(user, "source") of the active group (views.py:56,135). DicomExplorerLockedMixin exists but is unused (mixins.py:6-8, # TODO: Use in dicom explorer).
  • DicomOperator.find_images (adit/core/utils/dicom_operator.py:321) and fetch_image (:433-466, WADO-RS > C-GET > C-MOVE) exist but are not used by the explorer. Both need a PatientID: fetch_image takes it as a positional argument, find_images raises without it on patient-root-only servers (:355-361). For a single instance, _fetch_images_with_c_move short-circuits on SOPInstanceUID (:578-579), so no image-level C-FIND is needed, but the receiver round trip (:673) and the 30 s C_MOVE_DOWNLOAD_TIMEOUT inactivity window (base.py:458, :743) still apply.
  • The DICOMweb API cannot serve a browser preview: token-only auth (base.py:229-231, Authorization: Token), all_groups=True access semantics (dicom_web/views.py:54) unlike the explorer, can_retrieve not enforced (:196), no /frames, /rendered, /thumbnail or WADO-URI route (dicom_web/urls.py:28), multipart-only retrieve responses (views.py:204), and /metadata fetches full pixel data before stripping it (views.py:262-268).
  • The Download a single study directly in the web browser #151 download (selective_transfer/views.py:61-63, PR Allow direct download of a study from selective transfer query results #248) is the closest precedent: login_required async view, selective_transfer.can_download_study (selective_transfer/models.py:8-11), route carrying patients/<patient_id> (selective_transfer/urls.py:33), accessible_by_user(user, "source") in the downloader (dicom_downloader.py:153). The upload app is the precedent for same-origin session-authenticated browser calls (upload/views.py:96-114, upload.js:364-371).
  • Vendored JS: dcmjs 0.30.3, dicom-web-anonymizer 0.3.0, dicomweb-client 0.5.2, committed under adit/upload/static/vendor and refreshed by copy_statics (cli.py:56-74), all loaded as classic <script src> globals (upload_layout.html:5-7). No cornerstone/OHIF/dwv anywhere. Production statics use whitenoise CompressedManifestStaticFilesStorage (settings/production.py:16).

Proposal

Staged, so stage 1 can be accepted alone.

  1. Instance listing, no pixels. Add collect_images to DicomDataCollector and an instance list on the series page (lazy via HTMX or a .../instances/ route): InstanceNumber, SOPClassUID, ImageType, Rows/Columns, NumberOfFrames, sorted by InstanceNumber, limited via limit_results (a CT series can have 1000+ instances; DICOM_EXPLORER_RESULT_LIMIT is 101, base.py:451). This already answers "is this the series I think it is" and has no privacy implications beyond today's explorer.
  2. Single-instance endpoint + minimal preview. A login_required async view returning one instance as application/dicom (write_dataset to BytesIO), route carrying patient_id like the download route, same server access check as the explorer (active group), own timeout setting (tens of seconds, not the 3 s C-FIND timeout), Cache-Control: no-store, no pseudonymization (consistent with the explorer's identifying metadata). Client: Preview button on the series page, middle instance first, Prev/Next over the stage-1 list, default window/level, "No preview available" for non-image SOP classes and on errors. Run the fetch via sync_to_async(thread_sensitive=False) like DicomDownloader (dicom_downloader.py:113), not the explorer's uncancelled executor pattern.
  3. Selective transfer integration only after Add series level search and transfer to SelectiveTransfer #141 adds series rows; out of scope here.

Open questions

  1. Permission. A per-instance application/dicom endpoint is, by construction, a scriptable full-series download; gating it with query_dicom_server alone would undo the query/download separation Download a single study directly in the web browser #151 introduced, and unlike the zip download it is unpseudonymized and never passes EXCLUDE_MODALITIES (dicom_downloader.py:156-158). Require selective_transfer.can_download_study, or add dicom_explorer.can_preview_images? Either way existing groups get nothing automatically; every deployment must grant it.
  2. Which servers. Offer preview on every source server, or only where WADO-RS or C-GET is supported? On C-MOVE-only servers each slice is a receiver round trip of seconds and can block up to 30 s; fast scrolling spawns concurrent C-MOVEs against the PACS. A per-user concurrency cap or a short-lived per-series cache (temporary PHI on the web node) may be needed from the start.
  3. JS delivery. cornerstone3D (ESM, wasm codecs, web workers loaded by relative URL, which manifest hashing renames) fits the current vendoring badly; legacy cornerstone-core + cornerstone-wado-image-loader + dicom-parser ship UMD bundles that fit the <script src> setup. Not verified against current npm builds. A viewer library also should not live under adit/upload/static/vendor.
  4. Scope. Single slice with prev/next and window/level only? Multi-frame, enhanced CT/MR, compressed transfer syntaxes and SR/PR/KO/SEG need explicit handling; MODALITIES_EXCLUDED_FROM_NIFTI_CONVERSION (base.py:522) could be generalised for the "no preview" set.
  5. Locked/suspended. Should the new view honour DicomExplorerSettings.locked and fix the unused-mixin TODO in passing?
  6. Usage accounting. The DICOMweb API records APIUsage (dicom_web/models.py:18-32); the explorer and download record nothing. Should preview fetches be counted?

Implementation notes

Options, cheapest first:

  • (a) Instance metadata listing only (stage 1). Pure C-FIND, no new permission, no JS.
  • (b) Per-server viewer URL template on DicomServer plus a link-out button, mirroring the RADIS pacs_link. Cheap, but does not serve users without a PACS viewer account, which is the stated use case, and overlaps with the brainstorming bullets on the RADIS PACS viewer URL and opening RADIS reports from ADIT; better handled as one cross-referenced "viewer link" issue than folded in here.
  • (c) Server-rendered PNG thumbnail via pydicom pixel_array + pillow. No JS, but pillow and numpy are only transitive (uv.lock:2063, :2332), no pylibjpeg/gdcm is locked, so JPEG-family syntaxes are not guaranteed to decode, and VOI LUT / MONOCHROME1 handling is needed. pydicom is 3.0.2 (uv.lock:2799-2800).
  • (d) Explorer-scoped application/dicom endpoint + cornerstone panel (stage 2, recommended). Reuses fetch_image and the Download a single study directly in the web browser #151 view pattern, works for PACS without DICOMweb, keeps the DICOMweb API and its permission TODOs untouched, and the same client component can later serve TODO.md:92. Same-origin requests carry the session cookie; only GET is used so CSRF does not apply.
  • (e) Session auth on the existing WADO views + client-side multipart parsing with the vendored dicomweb-client. Widens the API auth surface and inherits all_groups=True and the unenforced can_retrieve. Not recommended.
  • (f) OHIF over ADIT's DICOMweb. Needs cheap series /metadata, /frames or WADO-URI, thumbnails, CORS or same-origin hosting, an auth story (OHIF's ?token= query parameter per its docs; header format vs ADIT's Token scheme not verified) and iframe embedding against XFrameOptionsMiddleware (base.py:106). On a C-MOVE PACS every frame is a C-MOVE. Out of scope.

Tests: explorer tests use AsyncClient with mocker.patch("adit.dicom_explorer.views.DicomDataCollector") (tests/test_views.py:81); a view calling DicomOperator directly needs another patch target. Add: permission denied for a user with query_dicom_server only, a Cache-Control assertion, an SR instance returning "no preview", and one acceptance test against ORTHANC1 like dicom_web/tests/acceptance/test_wadors.py. Docs: docs/user-docs/user-guide.md:185-191 (section 7), features.md.

All of the above is from reading the checkout at f30d498; the app was not run.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions