Skip to content

Releases

Isocenter 1.0.1

Choose a tag to compare

@kvnlng kvnlng released this 27 Oct 11:42
895dbb3

Added

  • docs: what a 1.x release may rename or remove, by tier (docs/api/stability.md, "Renames and removals after 1.0"). 1.0.0 shipped without this section, which was prepared for it in #892 and left out of the cut. The section carries the owner's rulings of 2026-10-01 on #892:
    • Tier 1. When a 1.x release renames a tier-1 name, the old name stays as an alias of the new one. When a 1.x release removes one, the old name stays and does what it did. Either way, a call to the old name, or a read of it where it is not callable, emits a DeprecationWarning that names the replacement or says there is none, in every later 1.x release, and 2.0 removes it. The page lists the deprecated name in tier 1 beside its replacement until then. The position of a parameter, a return shape and a documented behaviour still change only in a 2.0. This qualifies what 1.0.0 published: the page's tier-1 bullet said "A change to them is a 2.0", with no exception. It now says a 1.x release can rename or remove a tier-1 name, or a keyword parameter of one, and keep the old spelling working with a DeprecationWarning until 2.0, and that any other change is a 2.0.
    • Keyword parameters of a tier-1 callable follow the same rule. A renamed or removed keyword is still accepted, does what it did, and emits a DeprecationWarning through 1.x. 2.0 removes it.
    • Tier 2 changes only in a minor release (1.x.0), never in a patch (1.x.y), with a Breaking CHANGELOG entry that names both spellings. No alias is promised. This narrows what 1.0.0 published: the tier-2 bullet of the stability page, its "Documented but internal" section, and the [1.0.0] Highlights below say a 1.x release can change a tier-2 name with a CHANGELOG entry, which allowed a patch. The page's tier bullets, its tier-2 section and its exporter-registry subsection now say minor release and Breaking. So do the tier-2 sentences of docs/api/entities.md, docs/ocr.md, docs/analytics.md (the manifest's JSON keys, anonymized and HTML layout), docs/api/persistence.md (the audit-trail methods) and docs/api/exporters.md. The [1.0.0] Highlights are a released section and are not changed.
    • Tier 3: no notice.
    • The section also says how to see a DeprecationWarning: by default Python shows one only when it comes from code that runs as __main__.
    • How the policy and the tests coexist. tests/test_api_coherence.py's one-spelling pins forbid spellings deleted before 1.0 (scan_for_phi, export_to_parquet, write_project_secret/load_project_secret, the second config loader and others). None of them is a 1.0 name, so the policy does not reach them, and that test is unchanged. A future deprecated alias is a new public name on a tier-1 class, so tests/test_frozen_surface.py::test_every_public_name_on_a_tier_one_class_is_classified stays red until the page and TIER_ONE_NAMES classify it, which is the listing the policy promises. A deprecated keyword that is a real parameter moves the signature tables' pins, which is the intended review point. pytest.ini filters only ignore:::pydicom.*, so a future Isocenter DeprecationWarning is not made an error. test_the_stability_page_says_what_a_1x_may_rename_or_remove pins the section, its place above "Frozen at 1.0", and the minor-release rule wherever the pages state when tier 2 may change.

Changed

  • setup.py classifies Isocenter as Development Status :: 5 - Production/Stable, not 4 - Beta. 1.0.0 shipped with 4 - Beta, because this change was held in #892 and left out of the cut. PyPI shows a release's classifiers as that release uploaded them, so the 1.0.0 page keeps 4 - Beta, and the new classifier shows only from the next upload. pip ignores classifiers, so no install changes. No test ties the classifier to the version: such a test would be red on main between a change of the classifier and the record-back that sets _version.py.
  • The install instructions no longer offer a release candidate. README.md, docs/index.md, docs/installation.md and the home page's hero said Release candidates: pip install --pre isocenter. Now that 1.0.0 is final, pip install isocenter installs it, and --pre would install any later candidate instead (a 1.1.0rc1, say). The README line becomes "Check which version you have:", the lead-in the two docs pages already use, so the README's two code fences do not sit back to back. The hero keeps the rest of its note. 1.0.0 shipped with the old line: PyPI's 1.0.0 page reads the README as uploaded and keeps it, and the live 1.0/ site keeps it until the site is next deployed. .github/SECURITY.md said fixes go to "the latest 1.0 release candidate until 1.0.0 is final, then the latest 1.0.x release", and now says the latest 1.0.x release. Left as they are, because none is an install instruction: the 1.0.0rc3 placeholders in the issue templates, docs/midi-b.md's isocenter[textdetect]==1.0.0rc22 (it reproduces a measurement of that version), the notebooks' installs from git, and the historical 1.0.0rcN mentions in RELEASING.md, code comments and tests.

Isocenter 1.0.0

Choose a tag to compare

@kvnlng kvnlng released this 26 Oct 18:53
45f17be

Highlights

1.0.0 is the first stable release of Isocenter. These highlights are for a reader who upgrades from 0.9.x. They give the main changes of 1.0.0rc1 to 1.0.0rc22 and of this release. Each item names its issue. The full entry is in CHANGELOG.md, in the section of the candidate that made the change. The entries of this release follow the highlights.

What 1.0 promises

The API has three tiers. The API stability page names each one (#26, #787).

  • Frozen (tier 1). These names keep their spelling and behaviour for every 1.x release. They are Session, its methods and their parameters, the shapes they return, and the other callables in the frozen tables. A change to them is a 2.0.
  • Documented but internal (tier 2). These names are safe to call. A 1.x release can change them, with a changelog entry that names the old spelling and the new one.
  • Private (tier 3). Names with a leading underscore, and every module that the page does not list, can change without notice.
  • The output. For the same input, configuration and project secret, a 1.x release exports what the previous release exported, or its changelog says what changed. Each release's export of a fixed cohort is fingerprinted, and a release stops on a difference that no changelog entry names (#717).
  • The configuration. Every 1.x release loads a file that 1.0 loads. A 1.x release that applies an unchanged file differently raises the schema's minor version (#762, #782). A privacy profile name is pinned to its PS3.15 edition: basic means basic@2026c in every 1.x release (#714).
  • Plugins. The exporter registry is provisional until 1.1. In 1.0, an export through a third-party exporter grades REVIEW_REQUIRED (#527).

Before you upgrade from 0.9.x

Isocenter migrates nothing from 0.9.x (owner ruling on #790).

  • Identity tokens. Releases before 1.0 wrote the identity token into the wrong element of the Encrypted Attributes item. 1.x refuses a token in that layout by name and does not decrypt it. To recover such an identity, use Isocenter 0.9.x and the key that locked it (#790).
  • Stores. A 0.9.x store opens in 1.0. On open, 1.0 adds the columns it records into, empty, and converts no value that the store holds.
    • Its PHI statuses have no recorded policy. An export of them grades REVIEW_REQUIRED until you run audit() and anonymize() under your configuration, then save() (#555).
    • Its instances keep their source UIDs until the next audit() and anonymize() replace them (#544). Files exported before 1.0 keep their source UIDs, and 1.0 exports of the same data do not link to them by UID.
    • 0.9.x grouped files with no Patient ID under one patient, '' or UnknownPatient. Such a store keeps that grouping, because a split would give dates already shifted a second offset. While a patient's ID in the store is still '' or UnknownPatient, every open writes one WARNING row that gives only a count. Every report over the store then grades REVIEW_REQUIRED. audit(), anonymize() and save() do not remove a row already written. A group that anonymize() gave a pseudonym, in 0.9.x or in 1.0, is not counted. So a store that was anonymized before 1.0 can hold such a group with no row. To separate the subjects, ingest their source files into a new store (#584).
    • 0.9.x does not write the new columns. A status that 0.9.x saves into a store that 1.0 opened then sits beside the policy of 1.0's last scan, and 1.0 cannot detect this. Before you open a 0.9.x store in 1.0, keep a copy of it. Do not save into the 1.0 store from 0.9.x. If you go back to 0.9.x, use the copy.
  • Configuration files. A file that 0.9.x's create_config() or auto-save wrote loads unchanged, with one exception. Before 0.9.8, set_phi_tag(replacement=) saved a phi_tags rule with a replacement key. The loader refuses that key. Rename it to value (#538).
    • The loader refuses an unknown key, a value of the wrong type, and a version that this library does not read (#711, #712, #713).
    • 0.9.x cannot load privacy_profile: basic@2026c, which 1.0 writes. Write basic in a file that both must load (#714).
    • A change to the configuration no longer writes the file. Call configuration.save(), or set configuration.auto_save (#715).
  • Renamed and removed names.
    • start_patient(id=) is now patient_id=. add_rule(model=, zones=) is now model_name= and redaction_zones=. set_phi_tag(replacement=) is now value=. The old keywords raise TypeError (#26).
    • write_project_secret() and load_project_secret() are removed. A project secret stays in the store that made it, and a copy of the store is the same project (#716).
    • ConfigLoader.load_redaction_rules(), ConfigLoader.load_phi_config() and the config_path parameter of PhiInspector are removed (#729).
    • export_dataframe() requires its output path (#812).
  • Selections. A bare str as patient_ids raises TypeError. Write patient_ids=["X"] (#696). An ID or UID that names nothing is counted, and an export with one grades REVIEW_REQUIRED (#686, #725).
  • A stricter grade. A run that graded PASS in 0.9.x can grade REVIEW_REQUIRED in 1.0. The analytics page lists every condition. These are the main new ones:
    • A finding raised under the policy that nothing acted on (#573).
    • An exported status recorded under another policy (#555).
    • An entity edited after its last scan (#767).
    • An entity that no scan read, in a store that holds a recorded status (#1064).
  • Changed output under basic@2026c and the floor.
    • Each UID that Table E.1-1 codes U is replaced by a UID derived from the value and the store's project secret. One UID gets one replacement everywhere in the store, so references between exported files still resolve (#544).
    • An element that Table E.1-1 codes D gets a dummy value of its VR (#557).
    • Retired curve groups, whole overlay groups, Content Sequence, Graphic Annotation Sequence and Referenced File ID are removed (#556, #840, #848, #1133).
    • Every exported DICOM file that is not JPEG 2000 is Explicit VR Little Endian, so each element states its VR (#1107).
  • Other changed behaviour.
    • At ingest, files with no Patient ID are no longer merged into one patient. Each study that they belong to is its own patient. A store that 0.9.x wrote keeps its grouping (see Stores, above) (#584).
    • A machine rule with serial_number: "*" now also covers series with no Device Serial Number (#1108).
    • A private-creator key matches its creator in any case and spacing. Two keys for one creator with different rules are refused (#1147).

New in 1.0

  • De-identification markers. An exported DICOM file that the audit found clean, or that anonymize() de-identified, says how (#554, #978, #1011).
    • Patient Identity Removed (0012,0062) is YES, unless the file says Burned In Annotation (0028,0301) YES.
    • De-identification Method (0012,0063) names the version and the policy.
    • Longitudinal Temporal Information Modified (0028,0303) is MODIFIED when this store shifted a date in the file. It is REMOVED when every date in the file is absent, empty or its VR's dummy. Otherwise the file keeps the source's value. But a source's UNMODIFIED is not kept when this store took a top-level date out of the file.
    • A rule of any action on one of these three tags stops that stamp.
  • Burned-in text. A burned_in_text policy makes redact() read the images, then redact, report or withhold the text that it finds (#1108). The policy has three more settings (#1121):
    • scope: which images to read.
    • target: which kinds of text to act on (names, dates, IDs).
    • detector: how to find text. Use tesseract, or a text-detection model that ships with Isocenter and runs under OpenCV (the textdetect extra).
  • SCRUB. A SCRUB rule removes the identifiers inside a text element and keeps the rest of the text (#1130, #1146). It removes the kinds that you name: names, dates, IDs, organizations, phone numbers, addresses, sites and initials. With SCRUB, you can keep a structured report's content and scrub its text values (#1131).
  • More precise rules. A private-element rule can name its creator, as in '0009,"GEMS_IDEN_01",27' (#1132). A machine rule can select the series with no Device Serial Number (serial_number: ""), or select by device (match:) (#1108).
  • Waveforms. A WFDB export names coded ECG leads I, II, aVR, V1 and so on (#828, #832).
  • Documentation. The site has one folder for each minor version (#866), and it is built with Sphinx (#1155). Its tutorials run as part of the test suite (#27). A new page gives Isocenter's scores on TCIA's MIDI-B de-identification benchmark (docs/midi-b.md).

Security fixes since 0.9.x

  • Export path (GHSA-2rc2-r9r5-x7hm). Every release from 0.7.0 to 1.0.0rc15 could write a file outside the export folder when a SOP Instance UID held a path separator. Fixed in 1.0.0rc16. Its entry in CHANGELOG.md says which earlier exports to check.
  • Free text in structured reports and presentation states. Under basic and the floor, this text was exported as written. Fixed in 1.0.0rc4 and 1.0.0rc5 (#840, #848).
  • Two redaction rules on one image. Burned-in text inside a redaction zone could be exported unredacted when two rules covered one image. Fixed in 1.0.0rc10. Its entry in CHANGELOG.md says to run redact() again and export again (#908).

Added

  • docs: a MIDI-B benchmark page. docs/midi-b.md (/1.0/midi-b/) gives Isocenter 1.0.0rc22's scores on TCIA's MIDI-B de-identification benchmark, scored with the benchmark's validation script at commit ccc0939: 99.22% of the Validation set's checks and 99.23% of the Test set's, against 72.51% and 68.45% for the default configuration. It sets out the configuration part by part with...
Read more

Isocenter 1.0.0rc22

Isocenter 1.0.0rc22 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 19 Oct 18:32
5b90e71

Breaking

  • A private-creator key matches its creator in any case and spacing, and two keys for one creator with different rules are refused (#1147). A creator key (#1132) compared the creator exactly, so a file spelling it another way was silently missed: the element kept its value, no row said so, and the grade was PASS. Vendors write one creator more than one way. On the MIDI-B sets with 1.0.0rc21, the UCSF creator is UCSF BIRP PRIVATE CREATOR 011710XX in 2,126 Validation and 3,516 Test files and UCSF BIRP PRIVATE CREATOR 011710xx in 990 and 1,364. The configuration's rule, written first with one spelling and then the other, left the value of its free-text element in every file of the other spelling each time: 1,101 and then 3,771 failed Validation removal checks, with nothing in the audit or the report. pydicom 3.0.2's own private dictionary holds four creators in two cases each, and the elements the two spellings share have the same definitions: HOLOGIC/Hologic (8 shared), MITRA PRESENTATION 1.0/Mitra Presentation 1.0 (6), SPI RELEASE 1/SPI Release 1 (8) and MITRA OBJECT DOCUMENT 1.0/Mitra Object Document 1.0 (1, with another name).
    • This corrects 1.0.0rc21's entry for #1132, which said a creator's "case is compared exactly, as PS3.5 and pydicom's private_block do". PS3.5 says no such thing. 7.8.1 makes a creator an LO "identification code", used once in a group, and recommends graphic characters "so that reliable recognition is more likely"; it has no rule for comparing two creators. The exact == is pydicom's private_block, a toolkit's choice.
    • Now the key's creator and the file's are each reduced to one identity before they are compared, for every action (owner ruling Q1 (a)): surrounding spaces stripped, each run of inner spaces made one, and ASCII a-z upper-cased (config_manager._creator_identity; no other letter is touched). So '0009,"GEMS_IDEN_01",27' matches gems_iden_01, and ACME IMAGING 01 with two spaces is ACME IMAGING 01. A creator KEEP matches a case variant too, and still beats a concrete-tag key on that element.
    • The key and the row keep their spellings. A key keeps its creator as written, because the policy fingerprint hashes keys as held: folding the key would move every creator-keyed store's v1: hash and (0012,0063). The audit row names the creator as the file spells it: (creator Hologic) under a key naming HOLOGIC.
    • Breaking: two keys that name one creator at one group and offset, in different case or spacing, with rules that act differently, are refused (owner ruling Q2 (a)). load_config() raises ValueError: <path>: phi_tags keys '0009,"ACME IMAGING 01",27' and '0009,"Acme Imaging 01",27' name the same private element with rules that act differently: a creator key matches its creator whatever its case and runs of spaces (PS3.5 7.8.1 gives no rule for comparing two creators); give both keys one rule, or keep one key, and so do audit() over a policy assigned in code and PhiInspector(config_tags=). Such a file loaded in rc21, where the two keys were two rules for two spellings. Now they name one element, and letting one of a KEEP and a REMOVE win silently would be #1147's shape again. Rules that act the same load: they may differ in name, in the case of action, in value: null against no value (REPLACE writes the VR's dummy under either, though the policy fingerprint still tells them apart), and in the order of a SCRUB target, compared as a set, since the order decides only which kind a span two kinds find is counted for and the first key's rule is the one applied. The first key in the policy names the finding; the MIDI-B configuration's two UCSF keys are that case. set_phi_tag() on one of two such keys sets its rule on both, so the policy stays loadable and the element takes the new rule whichever key the scan meets first; it refuses nothing new. Two spellings of one key that differ only in hex case are still one key, with the collision WARNING, as before.
    • Not matched, as before: a creator that differs in anything else, such as GEMS_IDEN_1 against GEMS_IDEN_01, a space taken out, or a non-ASCII letter's case. Not changed: the remove_private_tags: true sweep, and the #765 private-UID-copy arm, which leaves alone an element a creator key names, now in any spelling.
    • Run audit() and anonymize() again on a store audited under 1.0.0rc21 with a creator key. The policy fingerprint does not move, so a file of the other spelling that was scanned clean under rc21 still reads as current, exports its value and draws no #555 notice until the store is audited again.
    • CONFIG_VERSION stays 2.0. No key or shape is added; this changes how the unshipped 2.0 row applies, before the final tag. The behaviour digest's opt-in SR gained a block naming B3 DIGEST as b3 digest, whose element is now removed; both "2.0" rows moved for that alone (the digest held with the fold before the fixture was widened).
    • Output: one difference, the input of synthetic:private_creators, which gained a third file, private_creators-3.dcm, whose creator is spelt gems_iden_01 (owner ruling Q8 (b)); configuration C's creator key removes its date, and A sweeps it and B keeps it as for the other two. No other cell moved: check against the rc21 recording at the two code commits exited 0 on 3.12, so neither #1147 nor #1146 changes any existing member's output.

Added

  • SCRUB reads a name cut off at the element's maximum length, and has four more kinds: phones, addresses, sites and initials (#1146). Measured on the MIDI-B sets with 1.0.0rc21 and target: [names, dates, ids] on 15 free-text elements, removal checks failed after a scrub 15,696 times on Validation and 24,385 on Test, because the identifier left had no kind to find it: a site acronym after "at" (10,231 and 20,539), staff initials after "by" (4,382 and 1,025), and facility names, street addresses and phone fragments (about 1,080 and 2,820). orgs found none of the acronyms.
    • A name cut at the VR's length. ep2d_diff_b50_400_800_tra_p3_TRACEW_DFC_MIX for Christopher Powe is a Series Description cut at LO's 64 characters for a patient POWERS^CHRISTOPHER; names matches whole words, so it left Powe. Now, in a value exactly at its VR's maximum (LO 64, SH 16, ST 1024, LT 10240 characters, PS3.5 Table 6.2-1; UC and UT have none), the last word, 3 or more letters after a space or at the start, is a name when it is the beginning of one of the instance's names. A protocol segment (..._tra) is not a word. It is read in the first round only, and a private element's limit is its recorded VR's. What it wrongly takes: a value exactly at its limit whose last word begins one of the instance's names (... SAG for a patient SAGAR^...). Not seen: a value cut by bytes under a multi-byte character set, which is shorter in characters.
    • phones (owner ruling Q3 (a)): a North American number with - or . between its groups or a bracketed area code, an optional leading 1/+1 and an optional extension, and an international number with its + (8 to 15 digits, spaces allowed after the +). It takes the issue's (606)066-5106x76, which ids cut to (606). No spaces between a North American number's groups, so Matrix 512 256 1024 is kept; it does take a number triple written like a phone number (Slices 120-240-3600, Recon 512.512.0001, kernel (512) 256-1024: 7 of 9 invented traps).
    • addresses (Q4 (a)): the full form only, a street number, one to three title-case words, a case-sensitive street suffix, then city, two-letter state and ZIP (157 Chisholm Trl Thibodaux, LA 70301). It misses a street line alone, which 4 Phase Abdomen Ct would also be (0 of 8 traps for the full form, 7 of 8 for the street line).
    • sites (Q5 (a)): 4 to 8 capitals as a whole word after a lower-case at, removed with the at (PET-CT STUDY at CHAJH becomes PET-CT STUDY). A word runs on through digits and _, so t2_tse_tra_320_p2 at HASCC_2 is kept whole rather than cut to _2. 0 of 16 anatomy and modality traps (Nodule at RUL, Imaging at MRI, PERFUSION AT REST, whose AT is upper-case); it misses 3-letter sites (at FGH). What it wrongly takes: a 4-to-8-letter clinical word in capitals after a lower-case at: Perfusion at REST becomes Perfusion.
    • initials (Q6 (a)): a lower-case by and 1 to 3 capitals as a whole word (by SS_2 is not initials), only directly after a span another kind removes from the same value: Ordered for 2675888487 by SS becomes Ordered (rc21: Ordered by SS). A free-standing Ordered by SS is kept, and so is Confirmed by CT (0 of 12 traps).
    • Each is opt-in, named in target like the others, and needs no model. Rows count each kind by its noun (removed 1 phone number, 1 address, 1 site, 1 set of initials) and quote no text. None is a burned-in text kind (owner ruling Q-B12 stands for pixels). On the public cohort's 733 distinct text values and the 54 strings of tests/support/scrub_corpus.py, none of the four took anything.

Changed

  • docs: the documentation site is built with Sphinx and pydata-sphinx-theme, not MkDocs/Material/mike. The site is the same set of pages in the same versioned folders; what builds and deploys it changed.
    • The URLs are unchanged. Pages are still served at slash URLs (dirhtml), and every heading keeps the id the MkDocs site gave it: docs/_ext/slugs.py gives each heading Python-Markdown's id, measured as 276 of 276 headings and 0 of 179 in-site fragment links broken. The deploy fails if that hook stops running, by checking for one id only it makes.
    • New landing pages at get-started/, tutorials/, guides/, api/ and about/, so each section of the navigation has a page of its own; no existing page moved.
    • **The API reference is rendered by...
Read more

Isocenter 1.0.0rc21

Isocenter 1.0.0rc21 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 17 Oct 18:16
db1f41a

Changed

  • basic@2026c and the floor remove Referenced File ID (0004,1500), an element Table E.1-1 has no row for (#1133). It is a Directory Record element of the Basic Directory IOD (the DICOMDIR, PS3.3 Annex F), not part of any composite instance, and its value is a file-set path. Sites build those from MRNs, accession numbers or dates. With no row there was no rule, so an image file carrying one exported it as written under basic, the floor and the scaffold create_config() writes, with (0012,0062) YES and a PASS grade. Found on the MIDI-B Test set with 1.0.0rc20: 64 MR instances of one series carried a two-component path. These were the only text-removal failures left in either set. The flag comes from TCIA's own review, not from HIPAA or PS3.15, but the reason is general: a directory path is free-form site data.
    • Now. The rule is REMOVE. It reaches the element wherever it sits, nested items included, and each removal writes the usual REMEDIATION_REMOVE row. tests/support/annex_e.py names it in ADDED_RULES, a table of rules on single elements the table has no row for, beside the overlay group rule (#556). Configuration lists it among the departures.

    • Only this element. By the owner's ruling, the rule is on (0004,1500) alone, not on group 0004. Referenced SOP Instance UID in File (0004,1511) keeps its U row, and the other Directory Structuring elements still have no rule.

    • Keeping it is the configuration's call. "0004,1500": {action: KEEP} keeps it, and privacy_profile: none has no rule for it.

    • basic@2026c has 649 rules where it had 648, and 415 of them are REMOVE where 414 were. Its digest and the floor's move in both PINNED_DIGESTS and FROZEN_AT_1_0 (tests/test_profile_editions.py), which the profile pin allows before the v1.0.0 tag with this entry.

    • A store audited under 1.0.0rc20 or earlier. The profile's rules are in the policy fingerprint:

      • the floor's short hash moves from v1:729f4bbf to v1:b2d62bec;
      • basic@2026c's moves from v1:d04ffd6e to v1:80408ecf.

      Exported with no new audit(), such a store draws the #555 notice: a WARNING row naming both policies, no de-identification markers, and a report that grades REVIEW_REQUIRED. Run audit() and anonymize() again before exporting it. A WARNING row an earlier export wrote stays in the store's audit log, so that store's report still grades REVIEW_REQUIRED.

    • CONFIG_VERSION stays 2.0. This changes a pinned profile's own rules before the v1.0.0 tag freezes them, as #840 and #848 did, and those rules already move the fingerprint. No key is added. The behaviour digest (tests/test_config_behaviour_is_versioned.py) does not move: none of its inputs carries the element.

    • Output: fingerprint/output.json was retaken on 3.12 and checked clean on 3.14t. compare against main's file reports 1165 differences, all 0012,0063 LO in the five DICOM arms (A.dicom, A.dicom-j2k, A.reopened.dicom, B.dicom, B.dicom-j2k): the De-identification Method's short hash.

      • Configuration A, whose policy is the floor's: v1:729f4bbf becomes v1:b2d62bec (588 files).
      • Configuration B, which keeps private tags: v1:9d04b00a becomes v1:27d1c730 (392 files).
      • The other 185 files hold values longer than the text limit, because the source carried a De-identification Method of its own, so they are recorded as a hash. They match the same two moves.

      No cohort member's export carries (0004,1500). Only pydicom's seven DICOMDIRs hold it, and each fails ingest for want of a SOP Instance UID. So no other element, path, outcome, accounting row or WFDB file changes.

  • A colour image with no Planar Configuration (0028,0006) is read color-by-pixel, under every transfer syntax but RLE Lossless; it was refused at ingest (#1134, owner rulings of 2026-10-16). Planar Configuration is Type 1C, required when Samples per Pixel is above 1, and pydicom validates the header before any decoder runs. So such a file was one of IngestSummary.failures, with the ERROR row Ingest failed for <path>: Decompression Failed: AttributeError: Missing required element: (0028,0006) 'Planar Configuration', and was missing from the export. Found on the MIDI-B Validation set with 1.0.0rc20: 14 GE ultrasound images (Explicit VR Little Endian, RGB) were refused this way, and each decodes with Planar Configuration 0.
    • Now, where the element is absent or holds no value, ingest reads the samples color-by-pixel (0) and records 0 on the instance, so a reopened store, redact() and the export read 0, and the export writes 0. A colour icon of that shape is carried the same way; it was dropped with a DATA_LOSS row. Each export of such an image logs one INFO note, Planar Configuration (0028,0006) was absent in the source file, where an image of more than one sample per pixel requires it (PS3.3 C.7.6.3.1.3); read as 0, color-by-pixel, at ingest, and written as 0. Nothing in the file confirmed that layout. ("held no value" for an empty element; prefixed At 0088,0200[0]: and so on for an icon), in the session's log, as the export's other descriptor notes are. No audit row is written and the grade does not move. Section 5 of the compliance report counts them, when there are any: * **Planar Configuration:** N image(s) read with an inferred Planar Configuration (0028,0006), as 0 (color-by-pixel), because the source file stated none. (with and M icon(s) where icons were). The count is taken over the store, so a report made before any export, or after a reopen, says the same; ComplianceReport.planar_configuration_inferred holds the two counts. Instance.get_pixel_data() reads such a source file the same way. The source file is not modified: the element is supplied on the dataset read into memory, before any check of the header, so a refusal of this library's own (#669's 1-bit sentence) still names its cause.
    • Under compression the codestream fixes the layout. PS3.5 (edition 2026d) says, for JPEG-LS (§8.2.3), "The Value of Planar Configuration (0028,0006) is irrelevant since the manner of encoding components is specified in the JPEG-LS bit stream as component, line or sample interleaved, hence it shall be set to 0", and the same for JPEG 2000 (§8.2.4) and HTJ2K (§8.2.14), "since the manner of encoding components is specified in the JPEG 2000 standard". For JPEG (§8.2.1) it says "JPEG compressed data streams are always color-by-pixel and should be specified as such", and Table 8.2.1-1 lists 0 for a colour JPEG Baseline image. pydicom's Pillow plugin (JPEG Baseline and Extended, JPEG 2000) returns color-by-pixel samples and lays them out by this element, so 0 is the value that reads them right. The imagecodecs decoders (JPEG Lossless, JPEG-LS, HTJ2K, and JPEG 2000 that Pillow cannot read) do not read it, so there the change is what the instance records and the export writes. Every export is decoded and written color-by-pixel (#1107), so 0 describes the written pixels in both arms.
    • By the owner's ruling, nothing checks the layout against the pixels, and there is no setting. A native file that is really stored color-by-plane and omits the element reads with its colours wrong.
    • Not changed: an RLE Lossless image with no Planar Configuration is still refused in pydicom's words (RLE's decoder fixes its own layout; the exclusion is the ruling); a value other than 0 or 1 is refused under a native syntax and RLE, and a declared 1 is read as before there; a declared value under a compressed syntax is #1137's (below); an image of one sample per pixel gets no element. CONFIG_VERSION stays 2.0.
    • Tests changed: four existing test files asserted the refusal, under JPEG 2000, JPEG-LS, JPEG Lossless and a native 1-bit file. The two that pinned validation's AttributeError (test_ingest_imagecodecs_fallback.py, test_a_plugin_value_error_reaches_the_fallback.py) now drop Bits Stored instead. The JPEG 2000 test in test_one_decode_answer_per_file.py now asserts the read at every door and the note at export, and its JPEG-LS YBR_FULL row moves to the new test's families; the 1-bit table loses its no-planar-configuration row, which the new test pins as #669's sentence.
    • Output: no existing golden-cohort member holds such a file (333 files read, top level and nested). A new member, synthetic:no_planar_configuration, holds native files with the element absent and empty, JPEG Baseline and JPEG-LS files and a colour icon without it, and an RLE file that pins the exclusion. Recorded under both configurations: 5 of 6 ingested, and one accounting row besides the exports, the RLE file's ERROR row in pydicom's words; no WARNING or DATA_LOSS row. (0028,0006) is 0 on the four colour images and on the icon item in each of the five DICOM arms (A.dicom, A.dicom-j2k, A.reopened.dicom, B.dicom, B.dicom-j2k), with the samples as built. fingerprint/output.json was retaken on 3.12 and checked clean on 3.14t; compare against main's file reports one difference, the member new in section 2, and nothing in sections 3 to 6.
  • docs: RELEASING.md's skip counts for 3.14t include the 17 real text-detector tests (#1121). "Cutting a release", step 4, gave 3.14t one skip fewer than 3.12, 3.13 and 3.14: 23 in a rehearsal, 22 in the publish run and 21 in a dispatch from main. Since #1123, tests.yml installs the textdetect extra and sets ISOCENTER_REQUIRE_TEXT_DETECTOR=1 only on the entries that are not free-threaded, because OpenCV publishes no free-threaded wheel. So every test of test_burned_in_text_with_the_real_text_detector.py (17) skips on 3.14t: 16 for No module named 'cv2', one because the variable is not 1. 1.0.0rc20's runs showed it. Read from the last line of each shard's Run Tests step and added over a version's twelve shards, the rehearsal from release/1.0 (run 38914679748) skipped 24 on each of 3...
Read more

Isocenter 1.0.0rc20

Isocenter 1.0.0rc20 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 16 Oct 15:06
c0e46bf

Breaking

  • A burned_in_text policy must name its scope; a block without one is refused (#1121, owner ruling of 2026-10-15). A configuration file that loaded under 1.0.0rc19 with burned_in_text: {action: redact} now raises ValueError: <file>: 'burned_in_text' needs a 'scope': one of flagged, modalities, all from load_config(). A policy assigned in code (session.configuration.burned_in_text = {...}) raises the same sentence prefixed session.configuration: from redact(), export(), save() and create_config().
    • Why the old behaviour was wrong. rc19 read every image that holds pixels. On the MIDI-B Validation set that is about 24,000 images, nearly all CT, MR and PET slices. redact() went from 0.1 s to 24 s on a 66-file sample, about 2.5 hours projected over the set. Two of the four text-free images it blanked at the default threshold were a CT scout and a PET slice. A default that reads everything is a cost and a risk the author should choose on purpose, so there is no default.
    • No final release is broken. The key shipped only in 1.0.0rc19, and no final 1.x reads a file without scope. Adding a required key after 1.0.0 would refuse files 1.0.0 loads, which is why it lands now.
    • Every rc19 record is read again. The box rule changed (Changed, below), and the policy hash each record carries now holds the box rule's version (services.BURNED_IN_TEXT_BOX_RULE = 2). So the first redact() reads every in-scope image again, with no force. Until it does, plain export() withholds those images under redact and withhold, with the existing "not checked … run redact()" row.

Added

  • burned_in_text takes scope: flagged | modalities | all, and modalities: [...] (#1121, owner rulings of 2026-10-15). Each is spelled exactly. Every scope reads only images that hold pixels.
    • flagged reads an image whose Burned In Annotation (0028,0301) says YES (stripped, any case; a value held as bytes is not read). It also reads an image that carries this store's redaction attestation, and one whose burned_in_text record, under any policy, holds a box (owner ruling Q-A1 (a)).
    • Why the two extra arms. They close a fail-open. A zone redaction writes (0028,0301) NO beside the attestation. Under action: withhold, the pass reads a YES image and records text the zone missed, and the zone then writes NO. Read by the flag alone, the next export() would find the image out of scope and write the text withhold promised to keep out. The same holds for an image a zone redacted before the policy was loaded. A record with no box does not keep an image in scope: a text-free image read under all follows a narrower scope instead of being read forever.
    • modalities reads what flagged reads, plus every image of a series one of whose Modality values is listed. A multi-valued Modality, held backslash-joined (#985), is split. modalities is required under scope: modalities and refused under any other scope. Each value must be an upper-case Code String (letters, digits, _, space, at most 16); lower case is refused, not folded. Repeats are dropped and the list is held sorted. The list must be upper case, but a source's Modality is matched upper-cased: a file that writes cr falls in modalities: [CR] (review of #1122).
    • all reads every image that holds pixels, as rc19 did.
    • One predicate for the three readers. The redact() pass, the plain-export gate and export(check_burned_in=True)'s read all ask session._in_burned_in_text_scope. An out-of-scope image is not read, and the export writes it with no row, as it writes an image with no pixels.
    • The rows and section 5. The pass's REDACTION row reads burned_in_text (action A, scope S, target T, min_confidence C): read R of N in-scope instance(s) holding pixels (O out of scope), …; under modalities the scope is written modalities [CR, DX]. Section 5's line for a pass, and for an export's read, names the scope and the out-of-scope count. BurnedInTextSummary (tier 2) gains scope and out_of_scope, both with defaults, so one built by hand still builds and renders as before.
    • The grade. A pass that selects nothing costs no PASS (owner ruling Q-A2 (a)); its row and section 5 say how many images were read and how many were out of scope. No grade condition is added.
    • MIDI-B. No file of 53,581 says YES, so flagged reads nothing there. modalities: [CR, DX, MG, US] reads 258 of the Validation set's 23,921 instances.
    • scope and modalities are not in the policy hash. They choose which images are read, not which boxes a read finds, so a record read under all is current under flagged.
    • CONFIG_VERSION stays "2.0" (owner ruling Q-X1 (a), after Q9 on #782: rows freeze at final tags, and the key shipped only in rc19). scope and modalities are added to the 2.0 row of SCHEMA_BY_VERSION. The row also gains burned_in_text_values (the action and scope value sets), so a new value is red as a new profile name is. BEHAVIOUR_BY_VERSION does not move. Neither do the three _redaction_config_hash literals: the hash shape is unchanged. _burned_in_text_policy_hash moves on purpose and is pinned by new literals.
    • Output: none for the golden cohort; no member holds the key, and fingerprint/output.json is unchanged (the check result is in the PR).
  • burned_in_text takes target: any | [names, dates, ids]: act only on the kinds of text named (#1121, owner rulings of 2026-10-15). Optional; the default any is every box, as before (Q-B1 (a)). A list of kinds, spelled exactly, acts only on words read as one of them, and leaves the rest of the text in place. [names], never a bare names; any alone, never in a list; an empty list, an unknown kind and null are refused at load with a sentence naming the values. Repeats are dropped and the list is held sorted.
    • How a word is read. The words tesseract reads at min_confidence or more with two letters or digits (exactly the boxes any keeps) are grouped into lines by where they sit: on one frame, a word joins a line when its height overlaps the line's by at least half the shorter of its own and the line's first word's (pixel_analysis.LINE_OVERLAP) and it overlaps none of the line's words side to side; each line is read left to right. The line's extent grows as it takes words, so a tilted line is followed; the height asked of a word does not. Without the side-to-side rule a small PORTABLE reaching into a large DOE, JANE was read DOE, PORTABLE JANE and the name was left (review of #1124 at bc02c11). Not fixed: a word that sits in a gap of another line, under none of its words, still joins it and is read between them. Tesseract's (block, par, line) numbers are not used: --psm 11 can split one visual line across blocks, and DOE, and JANE read apart are no name (review of #1124). Each line is read for the named kinds, and only the words a match overlaps are acted on, never the whole line (Q-B15 (a)). Nothing is compared with any patient's values: by owner ruling a burned-in name may be misspelled, and the source is not read again.
    • The kinds (new module isocenter.text_kinds, which never logs). dates: day, month and year with one separator, either order or year first, 2- or 4-digit years 1800-2199, with day and month ranges checked; YYYYMMDD; an English month name beside a day or a year; OCR's digit look-alikes read as digits inside a candidate. Times of day are not dates (Q-B13 (a)). ids: a run of letters and digits, inner -/_ allowed, of 6 or more characters holding 5 or more digits (Q-B14 (a)). names: spaCy PERSON entities from en_core_web_sm, never ORG (Q-B12 (a)), on the line as read and title-cased, plus a ^ between letters.
    • names requires spaCy (Q-B11 (b)); there is no fallback. With names in the target and spaCy or en_core_web_sm unable to load, redact() (after its drain and the OCR check, before any task, UID, row or zone) and export(check_burned_in=True) (before the tag scan) raise the new pixel_analysis.NlpUnavailableError (tier 2), an OcrUnavailableError, naming isocenter[nlp] and python -m spacy download en_core_web_sm. load_config(), save(), create_config() and plain export() never need the model, so a policy stays portable. spaCy publishes no free-threaded wheel: on 3.14t names cannot be used; dates and ids need no model. The spaCy arm runs in no CI job and is tested with a stand-in model only, so it is not verified by CI (Q-B18 (a)).
    • A narrowed target never redacts a box any would not, pinned by a property test over every subset of the kinds. So min_confidence has one meaning under every target (Q-B17 (a)), and scope, the two-letters rule and the padding hold unchanged.
    • Rows and section 5. The pass's REDACTION row names the target and, under a narrowed one, N region(s) read as names, D as dates, I as ids, K left in place (a region read as two kinds counts under each), and under names the spaCy and model versions. Each image read with text left in place gets one RISK row, burned_in_text: K text region(s) on frame(s) F of instance UID were read as none of names, dates (target) and left in place., which does not grade (Q-B16 (a)). The read after redaction counts only text of a named kind, so a label left on purpose writes no WARNING row. Section 5 says the same counts. BurnedInTextSummary gains target and kinds, with defaults.
    • export(check_burned_in=True) reads with the same classifier, needs no identifier, and so reads as well after anonymize().
    • What is never stored or quoted. The worker returns boxes and counts; no text, span or per-word kind leaves it. The record holds the counts and, under names, names_by (the spaCy and model versions); nothing read is hashed. An exception raised while the ki...
Read more

Isocenter 1.0.0rc19

Isocenter 1.0.0rc19 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 15 Oct 14:44
aec8a7a

Breaking

  • A serial_number: "*" machine rule now covers series with no Device Serial Number too; it covered only series with one (#1108, owner ruling of 2026-10-14). No call raises and no signature changes. The same configuration file now zeroes its "*" zones on every serial-less series, in redact() (which also gives each such image a new SOP Instance UID, Image Type DERIVED and Burned In Annotation NO, as for any redacted image) and in the export's zones, and scan_pixel_content() reads those series where it skipped them as "machines no rule names". A CT or MR stack whose files carry no serial is now redacted by a "*" rule its author wrote with serial-numbered devices in mind: read your "*" zones before the next redact() or export().
    • What was measured on main at f31758d, on 3.12.14 and 3.14.7t alike, with pydicom's RG3_UNCR.dcm (a Fuji CR whose files carry no Device Serial Number) and a patient's name burned into its pixels at a known box: under a configuration whose only rule was serial_number: "*" with a zone exactly over the name, redact() returned 0 ("No image matched any loaded rule's serial_number."), the export wrote all 1,498 ink pixels of the name, export(check_burned_in=True) wrote them too, scan_pixel_content() scanned nothing, and the report graded PASS.
    • Why the old behaviour was wrong. "*" was documented as "every series that has one", and services.rule_applies_to answered False for any series without a serial while MachinePixelIndex indexed only series with one, so no rule at all could reach a device that does not write its serial. 34 of the 35 burned-in images in the MIDI-B Validation set the owner measured are such series. A wildcard that silently skips the devices most likely to burn text in left a burned-in name in an export graded PASS.
    • Every door reads it one way: redact(), redact_by_machine("*", roi), the export's zones, the store-wide icon gate, scan_pixel_content() and preview_config() (which counted a "*" rule as matching nothing, because it looked the literal * up as a serial). discover_redaction_zones() compares its argument exactly, so "*" is still a literal there.
    • Attestation: an image covered by "*" alone is hashed as before ({"serial": "*", ...}), so no image already redacted under a "*" rule is redacted again; the serial-less images are redacted for the first time.
    • CONFIG_VERSION stays "2.0" and the behaviour digest's 2.0 row is re-pinned (BEHAVIOUR_BY_VERSION and SHIPPED_BEHAVIOUR), under the owner's ruling Q9 on #782 that rows freeze at final tags. This is a behaviour change of an existing file, named here: the digest's "*" scenario now redacts its five serial-less CT_small images. No (0012,0063) short hash moves: machine rules are not in the policy fingerprint.
    • Output: none for the golden cohort. Its configurations hold one exact serial rule and no "*" rule; fingerprint/output.json is unchanged (the check result is in the PR).

Added

  • A burned_in_text configuration key: redact() reads every image that holds pixels with OCR and redacts, reports or withholds the text it finds, serial or not (#1108 part B, owner rulings of 2026-10-14). Until now OCR ran only when called (scan_pixel_content(), discover_redaction_zones()), only over machines named by serial, and never changed a pixel; a series with no Device Serial Number, and text that moves from image to image, reached the export as drawn. On main at f31758d, pydicom's RG3_UNCR.dcm (a CR with no serial) with a name and a date drawn on its border was exported with every ink pixel under export() and under export(check_burned_in=True), and graded PASS.
    • The key. burned_in_text: {action: redact | report | withhold, min_confidence: 80}. action is required and spelled exactly; min_confidence is a number from 0 to 100, default 80; null is absent. An unknown key, another spelling or a value out of range raises ValueError at load_config(), naming the key. It is held on session.configuration.burned_in_text (tier 2), written by save() and create_config(), and is not in the policy fingerprint: no (0012,0063) value and no recorded PHI status moves.
    • CONFIG_VERSION stays "2.0" (owner ruling): the key is added to the 2.0 row of tests/test_config_schema_version.py's SCHEMA_BY_VERSION. A file without the key is applied exactly as before; a 2.0 build older than this one refuses a file holding it as an unknown key.
    • redact() runs it and records what it read. export(check_burned_in=True) also reads the pixels under the key, and records nothing (the Changed entry below). Workers read each image with OCR after zeroing the zones its machine rules hold, on a copy, and hand back box coordinates only, never text; the parent records the boxes, the tesseract version and what they were read under (min_confidence and the zones, not the action) on the instance, so an image is read once until those change or redact(force=True), which reads again and keeps the earlier boxes. Under redact each box is blanked on its own frame, widened by 15% of its height on every side (at least 2 pixels), in the same task as the zones; the new SOP Instance UID is derived in the parent under the project secret from the source UID, the zones and the boxes; the image is read once more. redact_by_machine() does not run it. With the key present and OCR unavailable, redact() raises OcrUnavailableError after its entry drain and before any task, zones included; with no rules and no key it still returns 0 before the drain.
    • What an OCR-only redaction writes (owner ruling Q5): DERIVED and derivation code 113062, and Burned In Annotation (0028,0301) as the source had it, because OCR finding nothing does not show an image holds no text. An image a zone also redacts gets NO as before.
    • The record is tied to the pixels it read (owner ruling on the review of #1109). It holds a sha256 digest of what the read saw: the dtype, the shape and the bytes, after the zones are zeroed on a copy, so a zone's own redaction leaves it current. A redacted image's digest moves to its redacted pixels with the read that follows the redaction. Pixels set afterwards, by set_pixel_data() or any other write, are pixels no read saw: the export treats the image as never checked, and the next redact() reads it again without force. Not Instance._pixel_hash, which hashes the bytes alone and names the frame the store holds, so between a set_pixel_data() and the next save it still names the frame that was replaced. min_confidence enters the record's policy hash as a float, so 80 and 80.0 are one policy.
    • Plain export() reads the record and runs no OCR (export(check_burned_in=True) reads the pixels instead, in the Changed entry below). It loads each image's frame once, with no lock held, to check the digest of a record that is otherwise current. Under redact and withhold, an image that holds pixels and was never read under the policy's threshold and zones, or whose pixels changed since, is withheld; under report it is written. An image with text is withheld under withhold, withheld under redact until a redaction carrying its boxes has landed, and written under report. An image with no pixels is never read, gated or counted. The EXPORT row's text gains "; N more withheld by the burned_in_text policy" when the policy withheld any; the text is unchanged otherwise.
    • Rows, all naming counts, frames and UIDs, never what OCR read. One REDACTION row per pass, entity burned_in_text: burned_in_text (action A, min_confidence C): read R of N instance(s) holding pixels, U could not be read, S already checked under this policy; text found on T (K region(s)); …. OCR by tesseract V. WARNING rows: burned_in_text: K text region(s) found on frame(s) F of instance UID; not redacted (action: report).; burned_in_text: K text region(s) still found after redaction on frame(s) F of instance UID.; an image the pass could not read, in redact()'s words for an unread instance; and at export, DICOM export to D withheld|did not withhold instance UID: its pixels were not checked for burned-in text under this configuration's burned_in_text policy; run redact()., … withheld instance UID: burned_in_text found K text region(s) on frame(s) F (action: withhold)., … that no redaction has blanked; run redact(). … did not withhold instance UID: burned_in_text found K text region(s) on frame(s) F, not redacted (action: report). ("did not withhold", not "wrote": the row is written before the file is), and burned_in_text: the read after redaction could not read instance UID in full, so its redacted pixels are not checked under this policy. A failure is quoted as its frame and exception class only when it happened while OCR ran, because tesseract's own message is not promised to hold no text it read.
    • The grade (owner ruling): no new grade condition. A redact pass that blanked text costs no PASS by itself; each WARNING row above does, under condition 2. Section 5 gains one line per pass, and its pixel-scan line no longer says no OCR ran when a pass did.
    • What OCR misses is on the OCR page: faint and very short text, text over anatomy read in fragments, and wanted labels blanked with the rest. Use report first on a cohort you have not looked at.
    • Output: none for a configuration without the key; no cohort member holds it. With the key, under redact: the pixels of each image with text found, its SOP Instance UID (and so its file name), and its Image Type and Derivation Code Sequence; Burned In Annotation is left as the source had it, and no (0012,0063) value moves. Under withhold, and under redact or withhold for an image nev...
Read more

Isocenter 1.0.0rc18

Isocenter 1.0.0rc18 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 13 Oct 00:21
96cc96e

The Breaking entries are given whole. Under Changed and Fixed each entry is given by its headline only, because the full section is longer than a GitHub release may hold: the measurements, the Output: lines and what was not changed are in CHANGELOG.md at this tag.

Breaking

  • A report over a store that has been audited grades REVIEW_REQUIRED while the store holds a patient, study or instance no scan has read; it graded PASS (#1064, owner rulings of 2026-10-11). This is a ninth grade condition. No call raises and no signature changes: generate_report() writes a different grade and one more Grade Basis line for the state below.
    • What was measured on main at 7325ff3, on 3.12.14 and 3.14.7t alike. File A is ingested, audited, anonymized and saved. File B is ingested afterwards. export() and generate_report() follow with no further audit(). B is written as the store holds it, which is as ingested, and the headline grade was PASS. With B a new study or another patient, its file holds the source's name, Patient ID, birth date, study date, Accession Number, institution, physician, Study ID and UIDs. With B a new series of A's study the file looks de-identified: the export stamps the pseudonym, the replaced Patient ID, the shifted Study Date and the replaced Study Instance UID from the owners, and the Accession Number, birth date, institution, physician, Study ID and the Series and SOP Instance UIDs are the source's. Read against the file ingested, those four stamps ((0010,0010), (0010,0020), (0008,0020), (0020,000D)) are the only top-level elements that differ.

    • Why the old grade was wrong. The headline said PASS over files the run had not read, in a store whose other files it had. Four things already told the truth about B and still do: its file carries no (0012,0062) YES; section 5 counted it (1 of 2 instance(s) have no PHI scan at their current revision); the manifest says anonymized: false; and docs/analytics.md stated the rule, "Data never scanned does not grade". The grade was the one thing a reader takes first, and it disagreed with all four.

    • The condition. In a store where any patient, study or instance holds a recorded PHI status, each patient, study or instance that holds none costs the PASS. The Grade Basis line, verbatim for the first row of the table: 3 entities never scanned, in a store that holds a recorded PHI status: no PHI status is recorded on it, by a scan or by a pass, so nothing says what the policy names was looked for in it; `audit()` reads it (patients 1, studies 1, instances 1) (1 entity for one). It names counts and never a value, a UID or a path. It is read from the store when the report is generated, as conditions 7 and 8 are, so:

      • it holds before any export (a report generated before export() already says it) and after a save and a reopen;
      • it is counted over the whole store, so an export whose subset or patient_ids left the unscanned entity out still grades, as condition 7 does for an IDENTIFIED entity;
      • it clears when a scan reads the entity. audit() alone turns the line into condition 7's; anonymize(audit()) and a second export grade PASS. No WARNING row is written at export, because a row stays in the audit log for good and would grade every later report of the store after the remedy.
    • "Holds a recorded status" is a status of any value on any patient, study or instance, including one an edit has left stale (condition 8). It is read from the raw record, which is what the store keeps: a stale status is stored (phi_status_edited, #767) and comes back on a reopen, so a store whose every status is stale still counts as audited, in the session that scanned and in the next. A Series and a nested item are neither read for it nor counted. In memory a pass leaves a status on a Series, and the store has no column for one; a rule that read it would grade a store differently from its own reopened self. Conditions 8 and 9 are disjoint (a status left behind by an edit, against no status at all), and between them they are exactly the instances section 5 counts.

    • What still says PASS, and why. Ruling Q3 on #573 stands for a store nobody ever audited: ingest() then export() with no audit() grades PASS as before, under the floor policy, under privacy_profile: none, under a KEEP configuration, after a reopen, and for WFDB. An unscanned instance there is the absence of a measurement and not a finding, and a format conversion is not a failed de-identification. Section 5 states the count either way. The condition is therefore gated, and the gate is the difference between the two cases: forgetting audit() for one late file grades REVIEW_REQUIRED, and never calling it at all does not.

    • Measured before and after, each shape through ingest(), audit(), anonymize(), export() and generate_report() on files written from pydicom's CT_small.dcm (waveform_ecg.dcm, cut to one multiplex group, for WFDB), the grade read from the report file. Identical on 3.12.14 and 3.14.7t. RR is REVIEW_REQUIRED; "x > y" is a report before and one after.

      Shape Statuses at the report (instances) main Now Line 9 counts (patients, studies, instances)
      r1: A passed and saved, B a new study, export 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      r2: the same, B a new series of A's study 1 REMEDIATED, 1 unscanned PASS RR 1, 0, 1
      e01: the same, B another patient 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e06: r1 under privacy_profile: none 1 CLEARED, 1 unscanned PASS RR 1, 1, 1
      e07: r1, saved and reopened, then export 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e08: r1, export(subset=…) leaving B out 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e09: e01, export(patient_ids=…) leaving B out 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e10: WFDB, A passed, B another patient 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e12: r1 through write_tree(), then a report 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e13: r1, a report before the export and one after 1 REMEDIATED, 1 unscanned PASS > PASS RR > RR 1, 1, 1
      e17: r1 with no export at all 1 REMEDIATED, 1 unscanned PASS RR 1, 1, 1
      e15: A audited and not passed, B a new study, export 1 IDENTIFIED, 1 unscanned RR (condition 7) RR (7 and 9) 0, 1, 1
      e19: r1 exported, then anonymize(audit()) and a second export then 1 REMEDIATED, 1 CLEARED PASS > PASS RR > PASS 1, 1, 1 then none
      r3: r1, then audit() only 1 CLEARED, 1 IDENTIFIED RR (condition 7) RR (condition 7) none
      r4: r1, then anonymize(audit()) 1 REMEDIATED, 1 CLEARED PASS PASS none
      e23: r2, then anonymize(audit()) all REMEDIATED or CLEARED PASS PASS none
      e24: e01, then anonymize(audit()) all REMEDIATED or CLEARED PASS PASS none
      e02: no audit() at all, floor policy 2 unscanned PASS PASS none
      e03: no audit(), privacy_profile: none 2 unscanned PASS PASS none
      e21: no audit(), a KEEP configuration 2 unscanned PASS PASS none
      e18: no audit(), saved and reopened 2 unscanned PASS PASS none
      e11: WFDB, no audit() 2 unscanned PASS PASS none
      e20: a KEEP configuration, audit() only 2 CLEARED PASS PASS none
      e22: e20, saved and reopened 2 CLEARED PASS PASS none

      In every row that moved, line 9 is the only reason, except e15, where condition 7's line stands beside it. r2's patients 1 is not B's patient, which is A's and holds a status: that ingest leaves a second patient in the store under the source Patient ID, holding no studies (#1065, not fixed here), and a patient with no recorded status is counted whether or not it holds a study. audit() records a status on it and anonymize() merges it away (e23).

    • Section 5's PHI Scan line said such instances "are not graded". With the gate open that is now false, so the line's second sentence says which case the report is in: Each was never scanned (grade condition 9) or was edited after its scan (condition 8), and the Grade Basis counts it. in a store that holds a recorded status, and No PHI status is recorded on any patient, study or instance in this store, so they are not graded: an entity never scanned costs the PASS only in a store that holds a recorded status (grade condition 9). in one that holds none. The first sentence, with the count, is unchanged. ComplianceReport (tier 2) gains never_scanned, the per-level counts, and phi_status_recorded, the gate.

    • Not a CONFIG_VERSION minor. The rule for a minor is the same file applied differently to the same input: findings, values a rule writes, tags a rule reaches, pixel zones or date jitter (owner ruling on #762). A grade is none of those. No configuration file is read differently, no rule raises another finding or writes another value, and the policy fingerprint in (0012,0063) does not move. Conditions 7 and 8 were added at 2.0 on the same argument.

    • The stability promise allows it: no condition is removed or narrowed in 1.x, and one may be added with a CHANGELOG entry (docs/api/stability.md, Output vocabularies). The grade's two values keep their spelling.

    • Output: none. No exported byte changes and no audit row is written or withheld; only the report's grade, its Grade Basis line and section 5's PHI Scan sentence change. fingerprint/output.json is not retaken: scripts/output_fingerprint.py records exported...

Read more

Isocenter 1.0.0rc17

Isocenter 1.0.0rc17 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 10 Oct 13:19
47c84f4

Changed

  • ci: the tesseract install retries its download inside the step, and the runners fetch every tag and origin/main (#984, #966, owner rulings 2026-10-08). Two things a release run did that its green did not show.

    • #984: a stalled mirror cost a rerun at both rc15 runs. Install tesseract was one apt-get install under a 3-minute cap. On 2026-10-07 the Ubuntu mirror left some runners' connections to apt's 30-second timeout, package after package, and the cap fired with the install still waiting: six of 32 jobs at the rc15 rehearsal (run 37677947792) and three of 32 at the rc15 publish (run 37681895889), one of them a floor job, so the upload was skipped until gh run rerun --failed. No test had run. Jobs that got through that day took up to 173 s, against a median of 13 s over 80 jobs of four runs.
    • The step now downloads in a loop: up to four attempts of apt-get install --download-only, each under timeout (75 s), 15 s apart, then one apt-get install --no-download from apt's cache and the version check as before. The bound is the retry: the installs stalled rather than failed, so a loop around an unbounded attempt would never have reached a second turn. dpkg runs outside the loop and under no timeout, so a bound cannot fire inside it. apt-get update is bounded too (45 s) and is still allowed to fail. Worst case 415 s; the step's cap goes from 3 minutes to 8 and the job's from 45 to 50, the same 5-minute margin over the step sum.
    • #966: 43 of the 61 tests a release run skipped needed git refs the runner did not have. actions/checkout fetched one commit: no tag, and off main no origin/main. tests/test_released_changelog_sections_stay_as_released.py's comparisons with each tag and with origin/main skipped (42 at the rc15 publish from its tag; 82 at its rehearsal from the branch, by the issue's count), and so did test_no_row_the_previous_final_release_shipped_has_moved, the check RELEASING says stops a release when it skips for want of tags. The issue's "13 to 20" was a local figure: the same set skipped at rc12 (57 on 3.12), rc13 (58) and the #964 dispatch (58), and #970's second comparison per section took the rehearsal to 101. The checkout now passes fetch-depth: 0.
    • What still skips on a runner, read from the four runs' logs and each test's skip: 12 parametrized rules set_phi_tag cannot spell, 4 that need coverage (the dev extra, which tests.yml deliberately leaves out), 2 by platform (1 on 3.14t), 1 until a final release carries the configuration behaviour pins, and the section being released (2 in a rehearsal, 1 in the publish run). RELEASING, "Cutting a release", step 4, now gives the count per kind of run, 21, 20 and 19, and says any other number is a finding. It also says what a job red in Install tesseract means now. It is not a test result. Attempts cut off at their bound, about seven minutes in all, are a mirror out of reach, rerun once and then waited for; four attempts that fail at once, in under a minute, are apt being refused something, and its error is read before any rerun. A red test-floor job held the upload back and a red test-supported job alone did not. Such a red does not spend the one rerun of the embargo procedure's Disclosure step 5 (#867), which stays for a test failure (owner ruling, 2026-10-08).
    • The other open question on #966: the runners collected 7,921 tests where a local run at the same commit collected 7,920. The one is tests/test_pixel_dtype_roundtrip.py::test_set_pixel_data_refuses_a_dtype_the_sidecar_cannot_carry[float128]: numpy has float128 on Linux x86-64 and not on macOS arm64, and the test adds the case where the type exists.
    • tests/test_packaging_contract.py runs the step's script with sudo, timeout, apt-get, sleep and tesseract replaced by recorders: one to three failed downloads still install, with a pause after each; four fail the step; every apt-get call that reads the network is under timeout, every timeout is run by sudo and runs no sudo itself, an install under timeout carries --download-only, and the one --no-download install is under none; and the bounds and pauses, added up, fit the cap. The order of sudo and timeout, and the loop's --download-only, had no pin until the review of #1005 found the two mutants green. test_the_gate_checkout_brings_the_tags_and_main is red without fetch-depth: 0.
    • Not verified on a runner by this change's tests: what the full fetch costs the checkout step (the repository is a 43 MiB pack; the 3-minute cap is kept), and that every test the refs wake passes there as it does locally.
    • Output: none. No file under isocenter/ changes.
  • docs: two limits are written down where a user would meet them (#772, #756, owner rulings 2026-10-08). Neither changes behaviour; each was measured on main at fd359eb on 3.12 before it was written.

    • #772, in docs/migration.md, "UIDs in stores and exports from before 1.0": a 0.9.x export of a redacted instance, re-ingested into its own store after 1.0's anonymize() has replaced that store's UIDs, is admitted as a new instance. Measured with an instance shaped as 0.9.x redaction left it (a random 1.2.826.0.1.3680043.8.498.… UID, its source UID recorded once): after anonymize() it holds its 2.25. replacement; ingesting a file that carries the random UID leaves two instances and writes no WARNING row, because the store knows the instance by its replacement and by its source UID only. Ingesting the source file again is declined, with the WARNING row that names the instance holding it. It does not stay two instances (review of #1007, measured again at that head): after the next anonymize() both hold one SOP Instance UID, the store has one instances row for the two, and export() writes one .dcm file with an ERROR row, "2 exported instances share SOP Instance UID … and were written to one file". The page says that too. Documented as a limit under the ruling that nothing written before 1.0 is accommodated; the page says to re-ingest source files.
    • #756, in docs/configuration.md, "What basic@2026c contains": a presentation state whose Bitmap Display Shutter names an overlay group keeps its Shutter Overlay Group (0018,1623) while 60xx,xxxx removes the group. Measured on a synthetic Grayscale Softcopy Presentation State (pydicom's CT_small.dcm relabelled, BITMAP shutter, one overlay plane in group 6000; no real GSPS was used): under the floor the export holds (0018,1623) = 0x6000 and no element of group 6000; with "60xx,xxxx": {action: KEEP} it holds all seven overlay elements. The page says PS3.15 Annex E is the authority on what the profile removes: for the overlay group, E.1.1's sentence on overlays, which the bullet above it already quotes ("If non-pixel data graphics or overlays contain identification, the de-identifier is required to remove them", read again in the 2026c edition, where it continues "or clean them if the Clean Graphics Option is supported"), and Table E.1-1 for every other attribute (owner ruling, 2026-10-08: the group rule is one the table does not have, so the table is not cited for it). It says that Isocenter neither keeps the group for the shutter's sake nor removes the shutter, and that KEEP on the group is how to keep such an object valid.
    • Output: none. Two pages change.
  • Assigning instance.sop_instance_uid also sets the instance's (0008,0018) element, so the export writes the file under the assigned UID (#936, owner ruling Q1 A). Instance.sop_instance_uid is tier 1. The export names a file by the field and fills it from attributes, and pydicom's writer then sets Media Storage SOP Instance UID from the dataset's own (0008,0018) whenever that is non-empty. Instance.__setattr__ moved the field and the revision and left the element. Every library writer that moves the UID sets both (UID replacement, redaction, the redaction withdrawal); only a caller's own assignment left them apart. Measured on main at fd359eb, two CT files ingested and saved, then one assignment:

    • instance.sop_instance_uid = '1.2.3.4': the export wrote 1.2.3.4.dcm carrying the earlier UID in (0008,0018) and in Media Storage SOP Instance UID, a file whose name and content disagree, with no row; written_uids held '1.2.3.4'.
    • instance.sop_instance_uid = '': the export wrote the dotfile .dcm, carrying the earlier UID, with '' in written_uids, no failure and no row.
    • A save and a reopen gave the field and the element apart, as they were: the row was keyed on the field and its attributes_json held the earlier UID.
    • Now the assignment writes attributes["0008,0018"] first, then the field, then the revision, once. 1.2.3.4.dcm carries 1.2.3.4 in both elements, and a reopened store gives the two together. An emptied UID is not this entry's any more: since 1.0.0rc16 the export's file-name rule (io_handlers.export_file_name, GHSA-2rc2-r9r5-x7hm) refuses a UID that cannot name a file before anything is written, with or without this change. Measured on the merged tree: ExportSummary.failures holds ('UNKNOWN', 'Export failed for an instance with no SOP Instance UID: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it is empty. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else.'), one ERROR audit row keyed UNKNOWN carries the same text, written_uids names the other instance only, and no file is written for it. What this change adds there is that the element is empty too. export() raises ExportError only when no instance at all reaches disk, as before.
    • No new exception, and nothing new is refused. '' is still not refused by the save (#721 refuses a UID that is not a str). None follows the field into the element, and the ...
Read more

Isocenter 1.0.0rc16

Isocenter 1.0.0rc16 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 09 Oct 11:41
7d619af

This release carries one security fix and nothing else. No commit is picked from main. Exported output changes for one kind of instance only, as the entry's **Output:** line says: an instance whose SOP Instance UID is not text, is empty, holds anything but letters, digits, _, - and ., or begins with ., is no longer written. fingerprint/output.json is as 1.0.0rc15 left it; the **Output:** line says why no member of the reference cohort can show the change, and that adding none is the owner's exception.

Security

  • export() could write a file outside the export folder, and over a file already there, when an instance's SOP Instance UID held a path separator (GHSA-2rc2-r9r5-x7hm). Every release from 0.7.0 to 1.0.0rc15 named each exported file <SOP Instance UID>.dcm and joined that name to the series folder as it stood. The three folder names go through ConfigLoader.clean_filename; the file name did not. ingest() accepts a file whose (0008,0018) is not a conformant UID, so a file from anywhere else chose the name.
    • Measured on 1.0.0rc15 (the wheel from PyPI on Python 3.12.14, and the tree at 33d8811 on 3.12.14 and 3.14.7t, pydicom 3.0.2). pydicom's CT_small.dcm with (0008,0018) set to ../../../../../escaped, ingested beside one unchanged file (ingested=2, no failure), then export("work/export_here/out") with no anonymize():
      • the file was written as work/escaped.dcm, two directories above the export folder;
      • the export reported it in written_uids, with no failure, no audit row, and wrote 2 of 2 planned instances;
      • a file already at work/escaped.dcm was replaced, because the write is a rename into place;
      • a UID of ../../../../../newdir/sub/escaped created work/newdir/sub/ and wrote into it;
      • a UID that was an absolute path was written at that path.
        The suffix is always .dcm, and the content is the exported dataset.
    • The same through a UID that is not text. A file that states (0008,0018) with VR OB or OW is held as bytes, and the name was the text of that value, quotes and all: b'../../../../../../../escaped'.dcm. Its first component, b'.., is a directory the write created inside the series folder, and the rest climbs out of it. Measured: that file, exported to work/export_here/out, was written as work/escaped'.dcm.
      • session.export(), 0.7.0 to 1.0.0rc13 (measured on the wheels of 0.7.0, 0.9.8, 1.0.0rc1 and 1.0.0rc13, with no anonymize()): written outside the folder, and from 0.9.8 the bytes value was listed in written_uids. audit() and anonymize() under the default configuration replaced it from 1.0.0rc1, as they do a UID of text (measured on 1.0.0rc1 and 1.0.0rc13).
      • session.export(), 1.0.0rc14 and 1.0.0rc15: not reached. Since #721 a save refuses a UID that is not a str, so ingest() of that file raises ValueError ("nothing was saved"), and so does the save that export() begins with (measured on both wheels).
      • DicomExporter.write_tree(), through 1.0.0rc15: written outside the folder, and the call returned normally (measured on 0.9.8, 1.0.0rc1 and 1.0.0rc15). It has no save in front of it. On 1.0.0rc14 and 1.0.0rc15 that takes a caller who goes on after ingest() raised, where the instance is still in session.store, or one who fills a DicomStore with DicomImporter.import_files and no session.
      • A UID the caller assigns as bytes or as a list did the same at write_tree() (measured on this branch before the rule judged the type; write_tree() there is 1.0.0rc15's for such a value).
    • Which calls, in which releases. For a UID of text. Measured on the wheels of 0.7.0, 0.9.8, 1.0.0rc1 and 1.0.0rc15; the releases between them are read, not run.
      • 0.7.0 to 0.9.8: every export() of such a file, with or without anonymize(). No release before 1.0.0rc1 replaced the SOP Instance UID: on 0.9.8 and on 0.7.0, audit() and anonymize() under the default configuration left it as ingested, and the export wrote work/escaped.dcm. (0.7.0 was run with use_compression=False: its default compressed export fails for every file in a 2026 environment, for an unrelated reason, having planned the same path.)
      • 1.0.0rc1 to 1.0.0rc15: an export of an instance that still holds the source's UID. That is an export() with no anonymize() before it, and an export() after a full audit() and anonymize() under a configuration that keeps (0008,0018): privacy_profile: none, or a KEEP rule on the tag (both measured). It is also a UID the caller assigns (instance.sop_instance_uid = ..., measured).
      • Not affected, from 1.0.0rc1: audit() and anonymize() under the default configuration, which replace the UID with a 2.25. one (#544; measured on 1.0.0rc1 and 1.0.0rc15; basic@2026c carries the same rule, read), and an instance redact() rewrote, which takes a derived 2.25. UID (measured).
      • DicomExporter.write_tree(), from 0.9.0, always: it applies no policy. Measured on 0.9.8, 1.0.0rc1 and 1.0.0rc15. Up to 0.8.2 that door, then save_patient, named files by Instance Number (0.7.0 measured: 0000.dcm, inside the folder).
      • 0.6.0, tagged under the name gantry and never uploaded as isocenter, cleaned the name at the session's door (read). Before 0.7.0, 0efc25d replaced that call with a name built from Instance Number, and a later commit went back to the UID without the cleaning, under the comment "Use SOP Instance UID for filename to ensure uniqueness and match tests". 0.7.0 is the first upload to PyPI, so every published release has the defect.
    • What it let someone do. Whoever can get a crafted DICOM file into a folder a user ingests and then exports decided where one .dcm file was written on that user's disk, within what the process may write, and could replace a file there. Nothing told the user: the file was counted as written.
    • How it was found. By the maintainers, while measuring another issue. No report is outstanding.
    • Now. One helper, io_handlers.export_file_name, builds the name for both doors, and refuses a UID that cannot name a file. Nothing is cleaned: the UID is also what the file carries in (0008,0018) and what written_uids reports, and a name that differed from it would be a second answer to which file is which (owner ruling, 2026-10-08).
      • The rule. A UID is refused when it is not a str, when it is empty, when ConfigLoader.clean_filename would change it, or when it begins with ., asked in that order.
        • Not a str: bytes, a list, None, a number. The type is judged before the text, because the text of a bytes is its repr. A str subclass, which is how a UI value is held, is text.
        • Empty: the name would be the hidden file .dcm (owner ruling, 2026-10-08).
        • clean_filename would change it: surrounding whitespace, a space, and every character that is not a word character, - or .: so /, \, NUL, a newline or any other control character, and each of :*?"<>|. A word character is Unicode's \w: a letter of any script, a digit or other numeric character (½, ², Ⅷ among them), and _. None of them is a separator.
        • Begins with .: that refuses ., .. and any name a directory listing hides.
      • A conformant UID is never refused, and its file name is the UID and .dcm, byte for byte as before. Neither is a non-conformant UID of safe characters (SOP1, sop-1_a).
      • session.export() writes no file for a refused instance, inside the folder or outside it, and creates no directory for it. The instance is in ExportSummary.failures under its UID, with one ERROR row keyed on the UID: Export failed for instance <UID>: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it holds a character other than a letter, a digit, '_', '-' or '.'. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else. …. The row names the instance by its UID, as every export row does, and never a path. The report grades REVIEW_REQUIRED. The other instances are written, and the EXPORT row counts the refused one as planned (wrote 1 of 2 planned instances). An export in which nothing else was planned raises ExportError, as any export that delivers nothing does.
      • An empty UID is refused the same way, and its failure and its ERROR row are keyed UNKNOWN, as every export row keys an instance with no UID (measured): Export failed for an instance with no SOP Instance UID: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it is empty. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else. No source file arrives so: ingest() refuses a file with no SOP Instance UID. It is a UID the caller assigns, or a hand-built instance.
      • A UID that is not a str reads … cannot name a file: it is held as bytes, not as text. …, naming the type and never the value. At session.export() the save's refusal (#721) still comes first and raises; the rule's is behind it.
      • DicomExporter.write_tree() refuses the same UIDs, the ones that are not text and the empty one included. It writes the other instances and then raises its RuntimeError, as for any instance it cannot write: Export incomplete. 1 failed. First error: SOP Instance UID (0008,0018) cannot name a file: ….
      • Measured after, on both interpreters, over the same inputs: no file and no directory outside the export folder in any case above; the file already at work/escaped.dcm is byte for byte what it was; the unchanged file beside the refused one is written under its own name.
    • What this does not cover, and what was checked and found sound.
      • The folder names were not a second door. A Patient ID, Study Description, Series Description, Modalit...
Read more

Isocenter 1.0.0rc15

Isocenter 1.0.0rc15 Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 07 Oct 20:55
33d8811

Exported output changes in this release in WFDB exports only, as the two entries' **Output:** lines name, where the details are. compare --base v1.0.0rc14 reports one new member of the reference cohort, synthetic:ecg_lead_codes, and nothing else; no existing member's output changes:

  • #832: in a .hea, the description field of a signal line, and in annotations.json the lead of a finding on that channel, change for three kinds of channel. A lead code with whitespace in front of it is written as the lead's name (2:1 to I). An MDC 2:3 channel whose Code Meaning says Lead III is written III where it was V1. A table-named channel whose name another signal of its record shares is written as its Code Value (II to 2:2), with one new WARNING row for that record, which grades the run REVIEW_REQUIRED where it graded PASS.
  • #963: in annotations.json, a finding loses its lead key when its mark names a channel past the record's sample columns. No row is written for it and the grade does not move.

One entry corrects the 1.0.0rc14 record and changes nothing in this release: ten of that release's **Output:** lines said less than it does (#764, #679, #926, #775, #721, #733, #722, #747, #703, #937). Each is restated as measured against 1.0.0rc13. Where the 1.0.0rc14 section's lead and one of those lines disagree, the line is the measured one.

The nine other entries' **Output:** lines say none (#804, #687, #956, #754, #753, #800, #779, #931, #935).

Changed

  • docs: the waveforms guide's quick start runs as part of the test suite, and no longer sits under if __name__ == "__main__": (#804, owner ruling Q4 A). docs/waveforms.md carries <!-- tutorial: inputs=waveform_ecg.dcm -->, so tests/test_tutorials_run.py runs it whole over the 12-lead ECG bundled with pydicom, as it runs docs/tutorials/*.md. The page's directory tree, a ```text block nothing checked, is replaced by shown output: the path export() returns, the three files beside the header, and the twelve lead names read from the .hea text.

    • Why the guard left the code. The tutorial runner executes a page's fences in a namespace whose __name__ is __tutorial__. Measured on main at bb5e6f1 with the header added and the tree removed: the quick start's one fence, under if __name__ == "__main__":, ran in 0.0 s, did nothing and raised nothing, although the folder it ingests did not exist. One true >>> line elsewhere on the page would have made it green with its quick start never run. The page now shows the code without the guard and says in prose to put it under one in a script, with the link the tutorials use.
    • The runner refuses a fence under the guard. _shape_failures gains one rule, with the others for code the runner would not run: a python fence holding an if __name__ == "__main__": test (either operand order, in a plain fence or a >>> example) is red, a fence under the main guard does not run here. It reads the fence with ast, so a comment or a string that quotes the guard is not one, and if __name__ != "__main__": is not one either. No existing run page trips it: the six tutorials name the guard in prose only.
    • Cost. One more page per run of the runner: 2.7 s on 3.12 and 1.4 s on 3.14t, measured. pytest --changed selects the runner for any .md change.
    • Output: none. Documentation and one test rule.
  • tests: a relative link or image in the documentation that reaches no file is red before merge (#687, owner ruling Q5 A). tests/test_doc_anchors.py gains broken_file_links, beside the fragment check, and test_every_documentation_file_link_resolves over the tree. The tree has no such link today (0 broken, measured at bb5e6f1 and on this branch).

    • What was true before. The issue said nothing caught a broken relative file link. mkdocs build --strict does (mkdocs 1.6.1, measured over a 17-link probe page: 9 warnings, build failed), but it runs only in docs.yml, on a tag or a dispatch, so a broken link failed the release's documentation deploy, after the tag was cut. It also passes five kinds that are dead on the published site: an absolute /page.md, a link to a page exclude_docs keeps off the site, a directory-style dir/, and raw-HTML <a href> and <img src>. And it never reads README.md, CHANGELOG.md or RELEASING.md.
    • The rules, each named in the failure message: [absolute]; [no such file]; from a page under docs/, [leaves docs/: not on the site] and [excluded from the site] (the directories exclude_docs names, read from mkdocs.yml; a pattern the check cannot read stops it); and [a directory]. A root page is held to the first, second and last against the repository, as GitHub renders it.
    • A target is compared with its directory's own listing, component by component. macOS's filesystem is case-insensitive, so Path("docs/Waveforms.md").exists() is true there and the link is dead on GitHub Pages.
    • No second rendering. Both checks read one cached parse of each page (_parsed_pages), so the link check adds 0.05 s to the file's run (measured on 3.12 and 3.14t; the file's 25 tests take 4.4 s on 3.12, of which the one rendering of the tree is 3.4 s). A test pins one convert per page over both checks. A fixture therefore builds its whole site before its first call.
    • Not covered, and said in the module docstring: a link inside a pymdownx.snippets include or a tab body, the cross-references mkdocstrings generates, nav entries, the fragment of a link into a file that is not a checked page, and external URLs, which are never fetched.
    • Output: none.
  • The released-sections test also compares each section with origin/main's, and RELEASING.md says what to check after main is merged into a branch and what a pick does to CHANGELOG.md (#956). Git placed changelog entries wrongly, with no conflict, three times on 2026-10-07. No package code changes.

    • A merge of main. #958's branch (d7e1cc9) merged the 1.0.0rc13 record-back (5f0c1ca). The merge was clean, and three ### Fixed entries, 37 lines, landed at the end of [1.0.0rc13]; the developer moved them back by hand. Reproduced with git merge and with git merge-file over the three real files, which give the same bytes.
    • What the test of #927 did with it. tests/test_released_changelog_sections_stay_as_released.py compared each released section with the newest present tag that holds it. Over that merge it is red where v1.0.0rc13 is present, and where it is not it skips [1.0.0rc13] and passes: 45 passed, 1 skipped. The newest tag is the one a clone most often lacks, because release tags sit on release/X.Y, which main does not reach, so git fetch origin main brings the record-back and never its tag. Whether the tag was present in that branch's clone at the time cannot be told now. RELEASING.md also never said to run the test after merging main.
    • Now each released section is also compared, by the same rule (byte-identical, or rewritten in place), with the same section of refs/remotes/origin/main:CHANGELOG.md, which needs no tag. Over the same merge with v1.0.0rc13 absent: 1 failed, naming [1.0.0rc13] with "106 lines where the release has 69" and the diff; over the hand-fixed merge (3f72c1d's file): 85 passed, 1 skipped.
    • What it cannot see, and skips by name: a section origin/main does not hold (a release branch's own new section, a record-back's, or a ref not fetched since the record-back), and every section where the ref does not resolve (CI's depth-1 checkout of a tag, a remote of another name, the sdist). At HEAD equal to origin/main it compares the file with itself; what is on main stays the tags' question, asked at the cut. The in-place allowance of #927 is unchanged, so an entry that rewrites an existing line is still not seen.
    • RELEASING.md, "Changes land on main", step 3: whenever main is taken into a branch, and after git fetch origin, git diff --unified=0 origin/main -- CHANGELOG.md must start every hunk above main's first released heading, and the test must pass. The fetch comes first because a stale origin/main hides the misplacement: the same merge over an origin/main at bb5e6f1 is green, with [1.0.0rc13] skipped by name.
    • RELEASING.md, "Later releases on an existing line", step 2: three of the four picks for 1.0.0rc14 (#967) applied their CHANGELOG.md hunks with no conflict onto a branch with no [Unreleased] heading, and before the last commit the file differed from main's by 7 insertions and 56 deletions. The procedure now says such a pick is left as it is, because the last commit writes the file again from main's whatever the picks left. It also says that a pick whose whole diff is CHANGELOG.md and that conflicts is resolved to main's file at that commit, where --ours would leave it empty and an empty pick reads as a forward-port. The one such pick so far, #954's at rc13 (#955), had been resolved that way with no rule granting it; rc12's picks (#928) took main's file too, in picks that also carried code. By the owner's ruling of 2026-10-07 the pick is followed by its proof, pasted into the PR body and checked by the reviewer: git diff --stat <its sha> HEAD -- . ':!isocenter/_version.py' ':!CITATION.cff' prints nothing, or exactly the files the line's left-out list, its branch-only fixes and an earlier --ours account for. A picks PR still runs no tests of its own; the release commit's integration run is what tests the branch's file.
    • Output: none.
  • The policy fingerprint is pinned for input the loader never gives it, and over what the loader does give (#754, owner ruling Q4 A). A status is compared with "v1:" plus the sha256 of configuration._canonical_policy_v1, and that form is a store format from 1.0.0. Its tests pinned it over ordinary rules. Measured...

Read more