From 916ac3b69c3ddb55605032b35bcb045894b1ebf9 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 11 May 2026 12:27:28 +0100 Subject: [PATCH] feat: Improve OCR sample selection and detection of clinical identifiers --- atlas/templates/atlas/new_uploads.html | 69 ++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/atlas/templates/atlas/new_uploads.html b/atlas/templates/atlas/new_uploads.html index 671dffd5..7f168fc5 100644 --- a/atlas/templates/atlas/new_uploads.html +++ b/atlas/templates/atlas/new_uploads.html @@ -60,7 +60,7 @@
- OCR now runs sequentially on one sample image per series (capped) to reduce browser memory use. + OCR runs sequentially on multiple sample images per series (capped) to improve burned-in text detection.
@@ -188,7 +188,8 @@ }; const SITE_PREFIXES = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"]; - const OCR_MAX_SERIES_SAMPLES = 40; + const OCR_SERIES_SAMPLE_COUNT = 3; + const OCR_MAX_SAMPLES = 120; const OCR_CANVAS_MAX = 768; const DUPLICATE_BATCH_SIZE = 500; @@ -665,7 +666,49 @@ function detectOcrPhi(text) { const value = (text || "").toLowerCase(); - return value.includes("accession") || value.includes("patient") || /(ref|rk9|rh8|ra9|rbz|rba)\d+/i.test(value); + + // Catch common clinical identifier markers and identifier-like tokens. + if (value.includes("accession") || value.includes("patient") || value.includes("nhs") || value.includes("hospital")) { + return true; + } + + return /(ref|rk9|rh8|ra9|rbz|rba)\d+/i.test(value) + || /\b\d{3}[\s-]?\d{3}[\s-]?\d{4}\b/.test(value) + || /\b\d{10}\b/.test(value) + || /\b\d{6,8}\b/.test(value) + || /\b(?:dob|d\.o\.b|birth|born)\b/.test(value); + } + + function buildOcrSamples(records) { + const includeRecords = records.filter((record) => record.include); + const seriesMap = {}; + for (const record of includeRecords) { + if (!seriesMap[record.seriesUid]) { + seriesMap[record.seriesUid] = []; + } + seriesMap[record.seriesUid].push(record); + } + + const samples = []; + for (const seriesRecords of Object.values(seriesMap)) { + if (!seriesRecords.length) continue; + + const candidateIndexes = [ + 0, + Math.floor((seriesRecords.length - 1) / 2), + seriesRecords.length - 1, + ]; + + const used = new Set(); + for (const index of candidateIndexes) { + if (used.has(index)) continue; + used.add(index); + samples.push(seriesRecords[index]); + if (used.size >= OCR_SERIES_SAMPLE_COUNT) break; + } + } + + return samples.slice(0, OCR_MAX_SAMPLES); } async function getDownscaledOcrBlob(file) { @@ -715,16 +758,18 @@ return; } - const sampleBySeries = {}; - for (const record of window.uploadPreview.records) { - if (!sampleBySeries[record.seriesUid]) sampleBySeries[record.seriesUid] = record; + if (!window.Tesseract || typeof window.Tesseract.createWorker !== "function") { + window.uploadPreview.records.forEach(r => { r.ocrWarning = false; }); + toastr.error("OCR engine is unavailable in this browser session."); + return; } - const samples = Object.values(sampleBySeries).slice(0, OCR_MAX_SERIES_SAMPLES); + const samples = buildOcrSamples(window.uploadPreview.records); if (!samples.length) return; const worker = await ensureOcrWorker(); let index = 0; + const flaggedSeriesUids = new Set(); for (const record of samples) { index += 1; @@ -733,11 +778,17 @@ const blob = await getDownscaledOcrBlob(record.file); const result = await worker.recognize(blob); const text = result?.data?.text || ""; - record.ocrWarning = detectOcrPhi(text); + if (detectOcrPhi(text)) { + flaggedSeriesUids.add(record.seriesUid); + } } catch (err) { - record.ocrWarning = false; + // Ignore per-image OCR errors and continue with other samples. } } + + window.uploadPreview.records.forEach((record) => { + record.ocrWarning = record.include && flaggedSeriesUids.has(record.seriesUid); + }); } async function calculateBlake3Hash(file) {