Burned-in text (OCR)
Some equipment draws the patient's name, a date or an accession number into the image itself. That text is pixels, so no phi_tags rule reaches it. What reaches it is a redaction zone: a rectangle that is set to zero in every image from one machine (see Pixel Redaction, and the worked example Redact burned-in text for one machine).
Isocenter uses Tesseract optical character recognition to help you write and check those zones:
- Find where a machine draws its text, from a sample of its images:
discover_redaction_zones(). - Check that the zones you configured cover the text actually present:
scan_pixel_content(). - Fix the zones from what the check found:
auto_remediate_config().
OCR runs only when you call one of these. None of redact(), export() or the compliance grade runs OCR: a PASS says the zones you wrote were applied, not that the rest of each image is free of text. export(check_burned_in=True) re-runs the tag scan (audit()); it does not look for text in the pixels.
Prerequisites
OCR needs two things. The ocr extra, which brings pytesseract (quoted,
because zsh expands unquoted brackets):
and the Tesseract binary, which pip cannot install:
Without either, scan_pixel_content() and discover_redaction_zones() raise
OcrUnavailableError, a RuntimeError, naming what is missing, before they scan
anything. pixel_analysis.HAS_OCR says only whether pytesseract imported; it
does not check the binary. If you point pytesseract.pytesseract.tesseract_cmd
at a tesseract that is not on PATH, the scan uses that one.
When an image cannot be read. scan_pixel_content() lists each instance
whose pixels could not be loaded, or whose OCR failed on any frame, in
report.failures and warns with the count. discover_redaction_zones() warns the
same way and counts only the instances it read in n_sources. Both write one
WARNING audit row per instance they could not read, naming it and the reason, so
the compliance report grades the run REVIEW_REQUIRED and lists each one under
"Exceptions & Errors". The rows stay in the store's audit log: a rescan that reads
everything after you fix the cause reports no failures but does not remove them,
so the store's reports still grade REVIEW_REQUIRED. If either call could read
none of the instances it tried, it raises PixelScanError, also a
RuntimeError, after the pass and the audit rows.
scan_pixel_content() runs in worker processes (threads on a free-threaded
build). discover_redaction_zones() runs in threads, but in processes when
ISOCENTER_MAX_TASKS_PER_CHILD is set. Environment Variables
lists the variables that change either choice.
Run either from a script whose top level is guarded by
if __name__ == "__main__":, as the examples below are.
Setting Up New Machines (Zone Discovery)
A new machine in your configuration has no zones: create_config() writes an empty redaction_zones list for each machine it does not recognise. The same model in the same room tends to draw its text in the same place every time. discover_redaction_zones() runs OCR over a random sample of one machine's instances and reports where text was found, so you can write zones from what the data does rather than from one screenshot.
import isocenter
if __name__ == "__main__":
session = isocenter.Session("my_project.db")
session.ingest("dicom_data/")
# One machine at a time: zones are a property of the device, not the cohort.
result = session.discover_redaction_zones(
serial_number="SN-12345",
sample_size=50,
min_confidence=60.0,
)
print(len(result)) # candidate text regions found
# Group the candidates into zones. Raise pad_x to merge words on one line.
zones = result.to_zones(pad_x=100, pad_y=10)
for z in zones:
print(f"Type: {z['type']}") # LIKELY_NAME, PROPER_NOUN, or TEXT
print(f"Zone: {z['zone']}") # [y1, y2, x1, x2]
print(f"Examples: {z['examples']}") # ['SMITH^JOHN', 'MERCY', ...]
print("-" * 20)
session.close()
sample_size is the number of instances read (default 50); min_confidence is the lowest Tesseract confidence, 0 to 100, a word needs to be kept (default 80).
What discovery returns
A DiscoveryResult holds DiscoveryCandidate records: text, confidence,
box ([x, y, w, h], OCR box space), source_index (which sampled instance it
came from) and classification. It is iterable and sized, and n_sources is the
number of instances it read.
to_zones() clusters the candidates, unions each cluster's boxes, drops any
merged box narrower or shorter than 6 pixels, and keeps only clusters seen in at
least min_occurrence of the sampled instances. The default min_occurrence
is 0.1, so text seen in fewer than 10% of the sampled images is dropped: a name
that appears in one frame out of fifty is treated as noise. To see everything
that was found, pass min_occurrence=0 or read to_dataframe(). Each zone's
zone is [y1, y2, x1, x2], the form a rule stores, and its type is
LIKELY_NAME if any member matched the name pattern (text with a ^),
PROPER_NOUN if any was a named person or organisation (with the nlp extra)
or holds a word of two or more characters (punctuation removed) that starts
with a capital letter, such as T1 or L5, and TEXT otherwise.
filter(), to_zones() and to_dataframe() are frozen for 1.x.
get_density_matrix(), visualize_heatmap(), analyze_temporal_stability()
and n_sources are documented but internal: they may change in a 1.x release,
with a changelog entry (API stability).
Inspecting the candidates
to_dataframe() needs only pandas, which Isocenter already depends on.
import re
# One row per candidate
df = result.to_dataframe()
high_conf = df[df['confidence'] > 90.0]
print(high_conf['text'].value_counts().head())
# Keep candidates that look like years OR come from the first 10 sampled images
filtered = result.filter(lambda c:
re.match(r"\d{4}", c.text) or c.source_index < 10
)
zones = filtered.to_zones()
# A number keeps candidates at or above that confidence
confident = result.filter(90.0)
Static or transient. analyze_temporal_stability() groups the candidates with no occurrence floor and labels each zone by the share of sampled images it appears in: STATIC_ALWAYS above 90%, STATIC_FREQUENT above 50%, TRANSIENT otherwise.
for item in result.analyze_temporal_stability():
print(f"Zone: {item['zone']} | Status: {item['status']} ({item['occurrence']*100:.1f}%)")
# Zone: [476, 496, 302, 400] | Status: STATIC_ALWAYS (100.0%)
# Zone: [16, 36, 21, 184] | Status: TRANSIENT (40.0%)
Where the hits fell. visualize_heatmap() prints an ASCII sketch, and get_density_matrix() returns the counts as a list of lists for matplotlib's imshow:
get_density_matrix() is not an image-space heatmap. It bins each candidate's
box centre on a grid scaled to the largest box origin among the candidates, not
to the image's Rows and Columns, so the grid stretches to fit whatever was found,
and two scans are not comparable to each other or to the image. visualize_heatmap()
uses the same grid. Take coordinates from to_zones() or from each candidate's box.
Entity Detection Modes
Discovery classifies each word in one of two ways:
- Regex heuristics (default): detects DICOM name patterns (e.g.,
Smith^John) and capitalized phrases. -
NLP (optional): with the
nlpextra and its language model,discover_redaction_zones()uses spaCy named entity recognition, which also finds names written without carets (e.g., "John Smith").The extra installs spaCy; the language model is a separate download (see Installation). Without either, discovery logs a warning and uses the regex tier rather than failing.
Applying Zones
Add the zone values to your isocenter_config.yaml.
Take them from zone["zone"] in to_zones(), which is [y1, y2, x1, x2],
not from a candidate's box, which is [x, y, w, h].
machines:
- serial_number: "SN-NEW"
redaction_zones:
# Found: LIKELY_NAME ['Smith^John'] (candidate box [20, 50, 200, 30])
- [50, 80, 20, 220]
Or in code, with session.configuration.add_rule() or update_rule() (see Programmatic Configuration), then session.configuration.save().
Checking Zones (Verification)
scan_pixel_content() runs OCR over the images of the machines you configured and reports the text your zones do not cover. It does not report text inside a zone, so labels your zones already black out do not appear.
How it works
- Match: each instance is matched to a rule by its series' Device Serial Number.
- Scan: OCR finds every text region in the image.
- Filter: each region is compared with the rule's
redaction_zones:- Covered: at least 80% of the region lies in one zone. Not reported.
PARTIAL_LEAK: more than 0% and less than 80% covered. Reported.NEW_LEAK: not covered at all. Reported.
Text of two characters or fewer is skipped as noise.
Zones the scan reads. The scan reads only zones written as a list,
[y1, y2, x1, x2]. A zone written as {roi: [...]}, the other form a rule
accepts, counts as no zone: text inside it is reported as a leak
(#814). And when two rules
share a serial, the scan reads only the first rule's zones, while redact()
applies the zones of every matching rule.
What is scanned. Only instances whose Device Serial Number equals a rule's serial_number exactly, and only when that rule has at least one zone. So:
- a session that has loaded no configuration, or whose rules are all fresh from
create_config()with emptyredaction_zones, scans nothing: it prints "No matching configured instances found to scan." and returns an empty report; - a
"*"rule is applied byredact()andexport(), but the scan never selects instances by it; - a series with no Device Serial Number is never scanned.
Load a configuration whose rules have zones first:
machines:
- serial_number: "SN-12345"
model_name: "Sono1"
redaction_zones:
- [0, 30, 0, 200] # name banner, top left
import isocenter
if __name__ == "__main__":
session = isocenter.Session("my_project.db")
session.ingest("dicom_data/")
session.load_config("isocenter_config.yaml")
# Every configured machine that has zones
report = session.scan_pixel_content()
# OR: one machine
report = session.scan_pixel_content(serial_number="SN-12345")
print(f"Found {len(report)} leaks.")
for finding in report:
print(f"{finding.metadata['leak_type']}: {finding.value} in {finding.entity_uid}")
session.close()
finding.value is the text OCR read, which may be misread (SMITH4JOHN for SMITH^JOHN), and finding.entity_uid is the SOP Instance UID. Text the scan finds is counted in section 5 of the compliance report and does not change the grade; an instance it could not read does.
After changing the zones, scan again: a report with no findings means every text region OCR found is at least 80% covered.
Automated Remediation
auto_remediate_config() turns a scan's findings into zone changes: a NEW_LEAK becomes a new zone around its text, and a PARTIAL_LEAK grows the zone that covers most of it. It changes the configuration in memory and returns the number of changes.
import isocenter
if __name__ == "__main__":
session = isocenter.Session("my_project.db")
session.load_config("isocenter_config.yaml")
# 1. Scan
report = session.scan_pixel_content()
# 2. Apply suggestions to the in-memory configuration
count = session.auto_remediate_config(report)
if count > 0:
print(f"Applied {count} fixes.")
# 3. Scan again to confirm
report_v2 = session.scan_pixel_content()
print(f"{len(report_v2)} leaks left.") # 0 when the new zones cover everything
# 4. Write the file load_config() read. To write another file,
# set session.configuration.config_path first.
session.configuration.save()
session.close()
Read the zones it added before you keep them: a zone around a word OCR misread, or around text that is not an identifier, blacks out those pixels in every image from that machine.
Configuration Reference
Your isocenter_config.yaml defines the zones used for verification. See Pixel Redaction for the full rule format.
machines:
- serial_number: "SN-12345"
model_name: "CT-Scanner-X"
redaction_zones:
# [y1, y2, x1, x2] (row start, row end, column start, column end)
- [0, 100, 0, 200] # Top-Left Info Box
- [400, 450, 400, 500] # Bottom-Right Label
API Reference
scan_pixel_content(), discover_redaction_zones() and auto_remediate_config() are documented on the Session API page, and DiscoveryResult and the verification classes on the OCR API page.