# 1.13.0: OrthancPluginLoadDicomInstance() reads the whole DICOM file in "EmptyPixelData" and "UntilPixelData" modes

**URL:** https://discourse.orthanc-server.org/t/1-13-0-orthancpluginloaddicominstance-reads-the-whole-dicom-file-in-emptypixeldata-and-untilpixeldata-modes/6489
**Category:** General
**Created:** [October 14, 2026, 3:07pm UTC](https://discourse.orthanc-server.org/t/1-13-0-orthancpluginloaddicominstance-reads-the-whole-dicom-file-in-emptypixeldata-and-untilpixeldata-modes/6489 "2026-10-14T15:07:26Z")
**Posts on this page:** 1
**Page:** 1

<div class="post-metadata">

### Author: ![a.sallai](https://discourse.orthanc-server.org/letter_avatar_proxy/v4/letter/a/779978/32.png) [@a.sallai](https://discourse.orthanc-server.org/u/a.sallai)
#### Post date: [October 14, 2026, 3:07pm UTC](https://discourse.orthanc-server.org/t/1-13-0-orthancpluginloaddicominstance-reads-the-whole-dicom-file-in-emptypixeldata-and-untilpixeldata-modes/6489/1 "2026-10-14T15:07:26Z")

</div>

Hi orthancteam,

Disclaimer: the following summary is based on a couple of Claude sessions (Opus 5.5). These findings would have taken us many days or it would have been simply impossible with our team’s limited knowledge of the Orthanc codebase.  
This report was originally written by AI but it was thoroughly reviewed and reworded by me (a human!).

After upgrading from `orthancteam/orthanc:26.6.1` (Orthanc 1.12.11, DICOMweb 1.23) to `26.9.0` (Orthanc 1.13.0, DICOMweb 1.24), the storage reads of one of our production sites went up about 4x, and we had to roll back. We traced it to one change in the plugin SDK, which looks unintended to us.

### What changed

`OrthancPluginLoadDicomInstance()` with `OrthancPluginLoadDicomInstanceMode_EmptyPixelData` (or `_UntilPixelData`) now reads and parses the **whole** DICOM file. Orthanc 1.12.11 read the file only up to the stored `PixelDataOffset`.

The SDK documents `_UntilPixelData` as “Load the whole DICOM file until pixel data, which speeds up the loading”. 1.13.0 still removes the pixel data from the result, so plugins get the same answer, but only after reading and parsing the whole file, so that speed-up is lost.

In `OrthancServer/Plugins/Engine/OrthancPlugins.cpp`, `ApplyLoadDicomInstance()`:

- 1.12.11 called `bool ServerContext::ReadDicomUntilPixelData(std::string&, ...)` and only fell back to `ReadDicom()` if that failed.
- 1.13.0 calls `ServerContext::ReadParsedDicom()`, which reads the whole attachment.

The change comes from [da03aa08b9aa](https://orthanc.uclouvain.be/hg/orthanc/rev/da03aa08b9aa) (“removed ServerContext::ReadDicom()”, `streaming` branch), which removed the `ReadDicom()` fallback and replaced the whole block with `ReadParsedDicom()`. The next changeset, [616e673d1499](https://orthanc.uclouvain.be/hg/orthanc/rev/616e673d1499), replaced the old `ReadDicomUntilPixelData()` with a new `DicomDataSource::Dicom* ServerContext::ReadDicomUntilPixelData()` that does the whole-file fallback itself. It is used by the REST API, but the plugin SDK path was not switched to it. The `default` branch still has `ReadParsedDicom()` there.

This looks like an oversight to us, but it may be deliberate, for example to fill the new DICOM parser cache with fully parsed files. If so, please consider restoring the header-only read for these two modes anyway: they explicitly don’t return the pixel data, and `PixelDataOffset` seems to have been introduced in 1.9.1 for exactly this kind of read.

The DICOMweb plugin calls this function for every instance when it builds “Full” series metadata (`WadoRs.cpp`, `DicomInstance::Load(..., OrthancPluginLoadDicomInstanceMode_EmptyPixelData)`). DICOMweb 1.23 and 1.24 make the same call, so the plugin version doesn’t matter here.

### Reproduction

The script below follows the same path as our production site. It starts both images with the DICOMweb metadata cache at its default (enabled) and `StableAge` set to 2 seconds. It stores one synthetic 4 MB instance, waits until DICOMweb has pre-computed the series metadata after the `StableSeries` event, and prints the storage reads logged in the meantime. The storage cache is disabled only because Orthanc puts every new file into it: with a single small instance, the file would be read from memory and no storage read would be logged. (The same happens for a direct `/dicom-web/studies/.../series/.../metadata` request, which uses the same call.)

```auto
== orthancteam/orthanc:26.6.1 (Orthanc 1.12.11), DICOM file size: 4195166 bytes
Reading attachment "…" of "DICOM" content type (range from 0 to 850)
Read range of attachment "…" (850bytes in 147.00us = 46.26Mbps)

== orthancteam/orthanc:26.9.0 (Orthanc 1.13.0), DICOM file size: 4195166 bytes
Reading attachment "…" of "DICOM" content type (range from 0 to 4195166)
Read range of attachment "…" (4.00MB in 3.23ms = 10.40Gbps)
Parsing DICOM attachment: …

```

> **repro.sh (needs docker, curl, jq and python3)**
>
> ```bash
> #!/usr/bin/env bash
> set -euo pipefail
> 
> IMAGES=(
> "orthancteam/orthanc:26.6.1" # Orthanc 1.12.11, DICOMweb 1.23
> "orthancteam/orthanc:26.9.0" # Orthanc 1.13.0, DICOMweb 1.24
> )
> 
> # One synthetic 2048x2048 8-bit grayscale image as PNG (~4 MB of pixel data once in DICOM).
> make_png_data_uri() {
> python3 - <<'EOF'
> import base64, struct, zlib
> w = h = 2048
> raw = b"".join(b"\x00" + bytes((x * 7 + y * 13) % 256 for x in range(w)) for y in range(h))
> def chunk(kind, data):
> return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
> png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 0, 0, 0, 0))
> + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
> print("data:image/png;base64," + base64.b64encode(png).decode())
> EOF
> }
> 
> run_one() {
> local image=$1 name port url
> 
> name="orthanc-read-repro-$RANDOM"
> docker run -d --rm --name "$name" -p 127.0.0.1::8042 \
> -e VERBOSE_ENABLED=true \
> -e DICOM_WEB_PLUGIN_ENABLED=true \
> -e ORTHANC__AUTHENTICATION_ENABLED=false \
> -e ORTHANC__MAXIMUM_STORAGE_CACHE_SIZE=0 \
> -e ORTHANC__STABLE_AGE=2 \
> -e ORTHANC __DICOM_WEB__ SERIES_METADATA=Full \
> "$image" >/dev/null
> trap "docker rm -f $name >/dev/null 2>&1" RETURN
> 
> port=$(docker port "$name" 8042 | head -n1 | cut -d: -f2)
> url="http://127.0.0.1:$port"
> until curl -sf "$url/system" >/dev/null; do sleep 1; done
> 
> local version instance series file_size log_lines
> version=$(curl -sf "$url/system" | jq -r .Version)
> 
> # Store one instance, and keep only the log lines written after that.
> log_lines=$(docker logs "$name" 2>&1 | wc -l)
> instance=$(jq -n --arg png "$(make_png_data_uri)" \
> '{Tags: {PatientID: "REPRO", Modality: "OT"}, Content: $png}' |
> curl -sf -X POST "$url/tools/create-dicom" -d @- | jq -r .ID)
> series=$(curl -sf "$url/instances/$instance" | jq -r .ParentSeries)
> file_size=$(curl -sf "$url/instances/$instance/attachments/dicom/size")
> 
> # Once the series is stable, DICOMweb pre-computes its metadata and stores it as
> # attachment 4301 of the series. Wait for that attachment.
> until curl -sf "$url/series/$series/attachments" | jq -e 'index("4301")' >/dev/null; do sleep 1; done
> 
> echo "== $image (Orthanc $version), DICOM file size: $file_size bytes"
> docker logs "$name" 2>&1 | tail -n +"$((log_lines + 1))" |
> grep -E 'Reading attachment|Read (range of )?attachment|Parsing DICOM attachment' |
> sed -E 's/^.*\] //'
> echo
> }
> 
> for image in "${IMAGES[@]}"; do
> run_one "$image"
> done
> 
> ```

### Impact in production

Our site receives a lot of data (around 1,500 series become stable per hour during the day). It uses the PostgreSQL index, filesystem storage on an NFS share through the delayed-deletion plugin, and DICOMweb with `SeriesMetadata: "Full"`, because our OHIF viewer needs tags that aren’t main DICOM tags. The DICOMweb metadata cache is enabled (the default).

Since the DICOMweb plugin pre-computes the series metadata on every `StableSeries` event, every stable series now costs a full read of all its files. Same hour, same weekday, before and after the upgrade:

| | 1.12.11 | 1.13.0 |
| --- | --- | --- |
| Series metadata pre-computations (`StableSeries`) | 1,521 | 1,440 |
| Instances loaded by those pre-computations | 170,818 | 124,046 |
| DICOM file reads | 172,983 | 145,951 |
| Data read from storage | 9.4 GB | 37.3 GB |
| Median size per read | 7.4 KB | 204 KB |
| Average size per read | 57 KB | 267 KB |
| Reads under 16 KB | 82.8% | 0.5% |

The instances loaded by the pre-computations account for almost all reads, and with 1.13.0 each one reads the whole file instead of a few KB of header. The median read went from 7.4 KB (a header) to 204 KB (a whole file). The 1.12.11 average is higher than its median because that hour also had some whole-file reads, for example for frame requests and ZIP downloads. On NFS, the extra reads also made every other read slower, including the DICOMweb `/metadata` requests from the viewer.

### Our setup makes this worse, but the fix matters for everyone

Our numbers are larger than what a typical site would see, for three reasons:

- **We receive a lot of data.** Around 1,500 series become stable per hour during the day.
- **We receive many series more than once.** Our workflow re-sends studies, sometimes several times. Each time, the series becomes stable again, DICOMweb pre-computes its metadata again, and every file of the series is read again. In the measured hours, the pre-computations covered only 1,141 (1.12.11) and 1,159 (1.13.0) distinct series.
- **Our storage is on NFS.** Every extra byte read costs more there than on a local disk.

The re-sends are a problem on our side, and we are fixing them.

The regression itself still affects normal workloads:

- **Every series is pre-computed at least once** , when it becomes stable. With 1.13.0, that reads every file of the series in full, instead of only the header. In the reproduction above, that is 4 MB instead of 850 bytes per instance.
- **The same happens for any request that has to build the metadata on demand** : a `/metadata` request when the cache is missing or out of date, or every request when the metadata cache is disabled.
- **The same applies to any other plugin** that loads instances with `EmptyPixelData` or `UntilPixelData`.

So even on a site where each series arrives only once, Orthanc 1.13.0 reads back roughly as much data as it receives, shortly after receiving it. On 1.12.11 that was a few percent: only the headers, with a median of 7.4 KB per file on our site.

Orthanc’s storage cache can hide part of this on quieter sites, because a file that is read back shortly after it arrives may still be in memory. At our busy site, the cache served under 1% of reads in a busy hour and about 6% over a whole day. At another site of ours (a much smaller one) running 1.13.0, it served 72%.

The difference is largest on network or object storage, and with large images, where the pixel data is most of the file.

### Suggested fix

Use the new `ReadDicomUntilPixelData()` again in these two modes. It already falls back to reading the whole file when no `PixelDataOffset` is stored, as 1.12 did. Against the 1.13.0 sources, this is a one-line change:

```diff
--- OrthancServer/Plugins/Engine/OrthancPlugins.cpp
+++ OrthancServer/Plugins/Engine/OrthancPlugins.cpp
@@ -5157,7 +5157,7 @@
 
         {
           PImpl::ServerContextReference lock(*pimpl_);
- std::unique_ptr<DicomDataSource::Dicom> loaded(lock.GetContext().ReadParsedDicom(params.instanceId));
+ std::unique_ptr<DicomDataSource::Dicom> loaded(lock.GetContext().ReadDicomUntilPixelData(params.instanceId));
           parsed.reset(loaded->Clone());
         }
 

```

We tested it like this:

- We built the 1.13.0 `Orthanc` binary twice, unpatched and patched, with the same builder image and CMake options as the 26.9.0 release (`orthancteam/orthanc-builder-base:resolute-20260811.1-stable`). We then put each binary into the official `orthancteam/orthanc:26.9.0` image.
- With the reproduction script, the unpatched build reads the whole 4 MB file, like the official 26.9.0. The patched build reads 850 bytes, like 1.12.11, and logs `Parsing DICOM attachment (until pixel data)`.
- The DICOMweb series metadata of a test instance (36 tags, including a sequence) is identical in the patched build, the official 26.9.0 and the official 26.6.1.

We also ran Orthanc’s own tests with the patch:

- **Unit tests** , built from the patched source as the official build does: all 356 tests in 82 test suites passed (14 tests are disabled in the source).
- **Integration tests** , with the `docker/integration-tests` setup of orthanc-builder 26.9.0 and orthanc-tests revision 7e211998c2e2 (the last one before the 26.9.0 build), against the patched image. We ran the three configurations that go through the changed code:
  - core tests (`docker-compose.sqlite.yml`): 248 tests, OK (1 skipped: `test_httpClient_lua`, “[httpbin.org](http://httpbin.org) is down as of 2022-12-22”)
  - core tests with storage compression (`docker-compose.sqlite-compression.yml`): 248 tests, OK (same test skipped)
  - DICOMweb tests (`docker-compose.dicomweb.yml`): 42 tests, OK

We didn’t run the other integration-test configurations (PostgreSQL, MySQL, ODBC, S3, TLS, WSI, and so on).

For now we are back on 26.6.1.

Thanks a lot for what you do!
