Status: EXECUTED (2026-08-03). The deferred "MinIO PACS storage wiring for
DentalRadiograph" item is complete, including a fully software-based way to test the physical X-ray connection (no real hardware needed). Verified live end-to-end:storescu→ Orthanc → Django → MinIO →DentalRadiograph→ AI flags.
DentalRadiograph had the model, upload/list views, serializers and a rule-based AI analysis hook (radiograph_ai.py) — but uploads went to local disk (MEDIA_ROOT=/app/media), minio_object_name was never populated, and there was no image view UI. Real X-ray sensors export DICOM, which must reach our sovereign MinIO over the isolated-cloud/SSH-tunnel architecture.
The physical hardware hop is now simulated entirely in software using open-source tools, so the whole pipeline is validated now and only the tunnel/hardware wiring needs re-checking at real site onboarding.
| Decision | Choice |
|---|---|
| Radiograph storage | All-in-MinIO (raw .dcm + viewable PNG) |
| Test DICOM data | Synthetic via pydicom (deterministic, offline, valid UIDs) |
| Orthanc | Deployed as permanent infra (the cloud-side DICOM listener) |
| Patient matching | DICOM PatientID = NHS number; error/log if no match |
| Tool | Role in test |
|---|---|
DCMTK storescu (osimis/dcmtk image) |
Simulates the X-ray sensor (DICOM C-STORE SCU) pushing a .dcm over the network |
Orthanc (jodogne/orthanc) |
Practice-side/cloud DICOM listener (C-STORE SCP); forwards to Django via Lua webhook |
pydicom |
Parse/validate, extract patient metadata, convert pixel_array → PNG |
| MinIO (already running) | Sovereign object storage — bucket dental-radiographs |
| Pillow / numpy | PNG conversion/rendering |
Flow under test: storescu (sensor) → Orthanc (listener) → Django webhook → Celery → pydicom → MinIO (raw/ + processed/) → DentalRadiograph record → radiograph_ai.py.
minio, pydicom, numpy added to requirements.txt (Pillow already present). Images rebuilt for app/celery_worker/celery_beat.config/secrets/env.production + dsp_clinic/settings.py):
MINIO_ENDPOINT=minio:9000, MINIO_ACCESS_KEY=minioadmin, MINIO_SECRET_KEY=MinioDemoPass123!, MINIO_BUCKET=dental-radiographs, MINIO_SECURE=falseORTHANC_URL=http://orthanc:8042, ORTHANC_AET, ORTHANC_USERNAME/PASSWORD (optional)dental/services/minio_client.py (lazy client, ensure_bucket, upload_bytes/file, get_object_bytes, object_exists, presigned_url). Both raw .dcm (raw/{patient_id}/{uid}.dcm) and PNG (processed/{patient_id}/{uid}.png) live in bucket dental-radiographs; DentalRadiograph.image.name holds the PNG object key, minio_object_name the raw key.ensure_minio_bucket (idempotent) + backfill_radiographs_to_minio (migrates legacy local-media placeholders into MinIO; --dry-run supported)./dental/radiographs/<pk>/, /dental/radiographs/<pk>/image/) serve the PNG from MinIO, falling back to the legacy media file.dental/services/dicom_ingest.py:
parse_dicom() — pydicom read with a force=True fallback, defensive on unknown/private tags (Carestream/Planmeca/Sirona proprietary data must not crash celery workers).dicom_to_png() — pixel_array → grayscale PNG; handles MONOCHROME1 (inverted) / MONOCHROME2, 8/16-bit (window/min-max normalisation), RGB/RGBA..dcm + PNG to MinIO; create/update DentalRadiograph; call radiograph_ai.analyze_radiograph.PatientID (= NHS number) → existing PATIENT user. nhs_number is an encrypted field (non-deterministic Fernet), so matching is decrypt-and-compare, optionally narrowed to a practice's appointment patients; unmatched files logged, not silently dropped./dental/api/radiographs/ingest/ — authenticated multipart DICOM upload (dispatches Celery)./dental/webhooks/orthanc/) — OnStoredInstance Lua HttpPost → dispatches Celery task ingest_orthanc_instance which pulls the instance from the Orthanc REST API (deadlock-avoided, see gotchas).process_dicom_radiograph (file-path based) + ingest_orthanc_instance (Orthanc-pull based) for async isolation.seed_dicom_samples management command — synthetic dental .dcm (bitewing/periapical/panoramic), valid numeric UIDs, PatientID = seeded patient NHS numbers, MONOCHROME1/2 × 8/16-bit variety, and one file with private tags for robustness. --ingest also runs the direct ingestion path; requires an active practice.gp_booking_app_gp_booking_network (AET ORTHANC, DICOM 4242, REST 8042), Lua webhook → Django.storescu pushes a sample to Orthanc — exercises the real "sensor sends DICOM over the network" path. (One-off: docker run --rm --network gp_booking_app_gp_booking_network -v $PWD/tmp:/data osimis/dcmtk storescu -aec ORTHANC -aet SENSOR-SIM orthanc 4242 /data/x.dcm)dental/tests.py: pydicom parsing incl. private tags; pixel→PNG (MONOCHROME1/2, 8/16-bit); full ingestion flow (DICOM → DentalRadiograph → MinIO objects → AI flags); patient matching (match + no-match). Note: the repo's Django test-DB harness currently cannot build test databases (pre-existing cross-app BigAutoField migration references dental tables on the shared default DB) — all behaviours are instead covered by the live E2E below.storescu → Orthanc → Lua webhook → Django webhook → Celery ingest_orthanc_instance → pydicom parse → patient match (NHS 4000000001) → MinIO (raw/ 525178 B + processed/ 263058 B) → DentalRadiograph created, is_ai_reviewed=True → image proxy view returns the PNG (HTTP 200, valid PNG magic).orthanc-data volume, gp_booking network; DICOM + REST bound to 127.0.0.1 only for the future socat tunnel).ensure_minio_bucket runs on demand / at startup.minio, bucket dental-radiographs created. Reachable from dental_app at http://minio:9000 (health 200).dental_orthanc (jodogne/orthanc), AET ORTHANC, DICOM 127.0.0.1:4242, REST 127.0.0.1:8042, Lua webhook loaded. On the gp_booking network.dental_app and dental_orthanc share the gp_booking network. The app is reachable by dentalapp (RFC-valid network alias) for the Orthanc webhook — the container name dental_app contains an underscore and is rejected by Django's RFC 1034/1035 host validation.redis to BOTH dental_redis and the GP app's redis (ambiguous). Dental broker/result-backend URLs now use dental_redis explicitly (and the result backend was previously missing its password).HttpPost(url, body, headers) expects headers to be a Lua TABLE, not a content-type string — passing a string crashes Orthanc (Json::LogicError, process terminate). Always pass { ['content-type'] = 'application/json' }.pip install into a running container is lost on restart — deps must be in requirements.txt and the image rebuilt. Each compose service builds its own image (dental_booking_app-app/-celery_worker/-celery_beat), so rebuild all three.docker restart does not re-read env_file — recreate the container (docker compose up -d --force-recreate) to pick up env.production changes.id__in=QuerySet against a model in another database is inlined and runs on the outer query's DB. Materialise to a list() first.radiograph_ai.py is rule-based; the AI-agent hook is optional, gated by practice feature flag ai_radiograph_analysis.manage.py test dental now builds fresh test databases and runs green (12 tests, dental/tests.py declares databases = ['default', 'sector_dental']). Fix commit 23f8490a (GP apps allowed to migrate on the shared default DB; removed the pointless users_customuser.id BigAutoField→AutoField→BigAutoField churn; db_constraint=False on infrastructure→tenancy FKs so sync_apps order is safe).Independent of the ERPNext integration. Executed 2026-08-03 (commit aed01416 on develop).
============================================================
Page: https://wiki.veripath.co.uk/development/projects/dental_app/dental_app_phase8_dicom_pacs