feat: Add OCR debug panel with detailed status and results for image processing
This commit is contained in:
@@ -74,6 +74,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details id="ocr-debug-panel" class="mb-3">
|
||||||
|
<summary class="fw-semibold">OCR Debug Panel</summary>
|
||||||
|
<div id="ocr-debug-content" class="small text-muted mt-2">OCR has not run yet.</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="alert alert-warning d-none anon-alert" role="alert">
|
<div class="alert alert-warning d-none anon-alert" role="alert">
|
||||||
<p class="mb-2">Potential anonymisation issues were detected. Flagged series are highlighted below.</p>
|
<p class="mb-2">Potential anonymisation issues were detected. Flagged series are highlighted below.</p>
|
||||||
<div id="anon-issue-summary" class="small"></div>
|
<div id="anon-issue-summary" class="small"></div>
|
||||||
@@ -181,6 +186,7 @@
|
|||||||
duplicateSeriesUrls: new Set(),
|
duplicateSeriesUrls: new Set(),
|
||||||
forceAnonymisationOverride: false,
|
forceAnonymisationOverride: false,
|
||||||
ocrWorker: null,
|
ocrWorker: null,
|
||||||
|
ocrDebug: null,
|
||||||
currentTagsRecord: null,
|
currentTagsRecord: null,
|
||||||
duplicateCheckInProgress: false,
|
duplicateCheckInProgress: false,
|
||||||
duplicateCheckRunId: 0,
|
duplicateCheckRunId: 0,
|
||||||
@@ -223,6 +229,67 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetOcrDebug(status = "idle", message = "OCR has not run yet.") {
|
||||||
|
window.uploadPreview.ocrDebug = {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
totalSamples: 0,
|
||||||
|
processedSamples: 0,
|
||||||
|
flaggedSeriesCount: 0,
|
||||||
|
entries: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
renderOcrDebugPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOcrDebugPanel() {
|
||||||
|
const root = document.getElementById("ocr-debug-content");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const debug = window.uploadPreview.ocrDebug;
|
||||||
|
if (!debug) {
|
||||||
|
root.innerHTML = "OCR has not run yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRows = debug.entries.slice(-30).map((entry) => {
|
||||||
|
const reasons = entry.reasons?.length ? escapeHtml(entry.reasons.join(", ")) : "No PHI pattern matched";
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(entry.seriesUid)}</td>
|
||||||
|
<td>${escapeHtml(entry.path)}</td>
|
||||||
|
<td>${entry.flagged ? '<span class="text-warning fw-semibold">Flagged</span>' : '<span class="text-success">Clear</span>'}</td>
|
||||||
|
<td>${reasons}</td>
|
||||||
|
<td><code>${escapeHtml(entry.preview)}</code></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Status:</strong> ${escapeHtml(debug.status)}<br>
|
||||||
|
<strong>Message:</strong> ${escapeHtml(debug.message)}<br>
|
||||||
|
<strong>Samples:</strong> ${debug.processedSamples}/${debug.totalSamples} · <strong>Flagged series:</strong> ${debug.flaggedSeriesCount}
|
||||||
|
</div>
|
||||||
|
${sampleRows ? `
|
||||||
|
<div class="table-responsive ocr-debug-table-wrap">
|
||||||
|
<table class="table table-sm align-middle ocr-debug-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Series UID</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Match reasons</th>
|
||||||
|
<th>OCR preview</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${sampleRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function parseDicomMetadata(file) {
|
async function parseDicomMetadata(file) {
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
@@ -664,12 +731,27 @@
|
|||||||
return window.uploadPreview.ocrWorker;
|
return window.uploadPreview.ocrWorker;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectOcrPhi(text) {
|
function getOcrPhiReasons(text) {
|
||||||
const value = (text || "").toLowerCase();
|
const value = (text || "").toLowerCase();
|
||||||
|
const reasons = [];
|
||||||
|
|
||||||
// Catch common clinical identifier markers and identifier-like tokens.
|
// Catch common clinical identifier markers and identifier-like tokens.
|
||||||
if (value.includes("accession") || value.includes("patient") || value.includes("nhs") || value.includes("hospital")) {
|
if (value.includes("accession")) reasons.push("contains 'accession'");
|
||||||
return true;
|
if (value.includes("patient")) reasons.push("contains 'patient'");
|
||||||
|
if (value.includes("nhs")) reasons.push("contains 'nhs'");
|
||||||
|
if (value.includes("hospital")) reasons.push("contains 'hospital'");
|
||||||
|
|
||||||
|
if (/(ref|rk9|rh8|ra9|rbz|rba)\d+/i.test(value)) reasons.push("site prefix + number pattern");
|
||||||
|
if (/\b\d{3}[\s-]?\d{3}[\s-]?\d{4}\b/.test(value)) reasons.push("10-digit grouped number pattern");
|
||||||
|
if (/\b\d{10}\b/.test(value)) reasons.push("10-digit number pattern");
|
||||||
|
if (/\b\d{6,8}\b/.test(value)) reasons.push("6-8 digit number pattern");
|
||||||
|
if (/\b(?:dob|d\.o\.b|birth|born)\b/.test(value)) reasons.push("date-of-birth keyword");
|
||||||
|
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOcrPhi(text) {
|
||||||
|
return getOcrPhiReasons(text).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return /(ref|rk9|rh8|ra9|rbz|rba)\d+/i.test(value)
|
return /(ref|rk9|rh8|ra9|rbz|rba)\d+/i.test(value)
|
||||||
@@ -755,17 +837,33 @@
|
|||||||
async function runOcrChecks() {
|
async function runOcrChecks() {
|
||||||
if (!document.getElementById("check-anonymisation-ocr").checked) {
|
if (!document.getElementById("check-anonymisation-ocr").checked) {
|
||||||
window.uploadPreview.records.forEach(r => { r.ocrWarning = false; });
|
window.uploadPreview.records.forEach(r => { r.ocrWarning = false; });
|
||||||
|
resetOcrDebug("disabled", "OCR check is disabled in Upload Settings.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.Tesseract || typeof window.Tesseract.createWorker !== "function") {
|
if (!window.Tesseract || typeof window.Tesseract.createWorker !== "function") {
|
||||||
window.uploadPreview.records.forEach(r => { r.ocrWarning = false; });
|
window.uploadPreview.records.forEach(r => { r.ocrWarning = false; });
|
||||||
|
resetOcrDebug("error", "OCR engine is unavailable in this browser session.");
|
||||||
toastr.error("OCR engine is unavailable in this browser session.");
|
toastr.error("OCR engine is unavailable in this browser session.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const samples = buildOcrSamples(window.uploadPreview.records);
|
const samples = buildOcrSamples(window.uploadPreview.records);
|
||||||
if (!samples.length) return;
|
if (!samples.length) {
|
||||||
|
resetOcrDebug("idle", "No included files available for OCR sampling.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.uploadPreview.ocrDebug = {
|
||||||
|
status: "running",
|
||||||
|
message: "OCR checks in progress.",
|
||||||
|
totalSamples: samples.length,
|
||||||
|
processedSamples: 0,
|
||||||
|
flaggedSeriesCount: 0,
|
||||||
|
entries: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
renderOcrDebugPanel();
|
||||||
|
|
||||||
const worker = await ensureOcrWorker();
|
const worker = await ensureOcrWorker();
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -778,17 +876,46 @@
|
|||||||
const blob = await getDownscaledOcrBlob(record.file);
|
const blob = await getDownscaledOcrBlob(record.file);
|
||||||
const result = await worker.recognize(blob);
|
const result = await worker.recognize(blob);
|
||||||
const text = result?.data?.text || "";
|
const text = result?.data?.text || "";
|
||||||
if (detectOcrPhi(text)) {
|
const reasons = getOcrPhiReasons(text);
|
||||||
|
const flagged = reasons.length > 0;
|
||||||
|
if (flagged) {
|
||||||
flaggedSeriesUids.add(record.seriesUid);
|
flaggedSeriesUids.add(record.seriesUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preview = text.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||||
|
window.uploadPreview.ocrDebug.entries.push({
|
||||||
|
seriesUid: record.seriesUid,
|
||||||
|
path: record.path,
|
||||||
|
flagged,
|
||||||
|
reasons,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore per-image OCR errors and continue with other samples.
|
// Ignore per-image OCR errors and continue with other samples.
|
||||||
|
window.uploadPreview.ocrDebug.entries.push({
|
||||||
|
seriesUid: record.seriesUid,
|
||||||
|
path: record.path,
|
||||||
|
flagged: false,
|
||||||
|
reasons: ["OCR error"],
|
||||||
|
preview: String(err?.message || "OCR failed"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.uploadPreview.ocrDebug.processedSamples = index;
|
||||||
|
window.uploadPreview.ocrDebug.flaggedSeriesCount = flaggedSeriesUids.size;
|
||||||
|
window.uploadPreview.ocrDebug.updatedAt = new Date().toISOString();
|
||||||
|
renderOcrDebugPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.uploadPreview.records.forEach((record) => {
|
window.uploadPreview.records.forEach((record) => {
|
||||||
record.ocrWarning = record.include && flaggedSeriesUids.has(record.seriesUid);
|
record.ocrWarning = record.include && flaggedSeriesUids.has(record.seriesUid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.uploadPreview.ocrDebug.status = "complete";
|
||||||
|
window.uploadPreview.ocrDebug.message = `OCR complete. ${flaggedSeriesUids.size} series flagged from ${samples.length} sampled image(s).`;
|
||||||
|
window.uploadPreview.ocrDebug.flaggedSeriesCount = flaggedSeriesUids.size;
|
||||||
|
window.uploadPreview.ocrDebug.updatedAt = new Date().toISOString();
|
||||||
|
renderOcrDebugPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function calculateBlake3Hash(file) {
|
async function calculateBlake3Hash(file) {
|
||||||
@@ -985,6 +1112,7 @@
|
|||||||
|
|
||||||
window.uploadPreview.forceAnonymisationOverride = false;
|
window.uploadPreview.forceAnonymisationOverride = false;
|
||||||
resetUploadResults();
|
resetUploadResults();
|
||||||
|
resetOcrDebug();
|
||||||
|
|
||||||
window.uploadPreview.records.forEach((r) => {
|
window.uploadPreview.records.forEach((r) => {
|
||||||
if (r.objectUrl) URL.revokeObjectURL(r.objectUrl);
|
if (r.objectUrl) URL.revokeObjectURL(r.objectUrl);
|
||||||
@@ -1296,6 +1424,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetOcrDebug();
|
||||||
|
|
||||||
document.addEventListener("click", async function (e) {
|
document.addEventListener("click", async function (e) {
|
||||||
const includeSeries = e.target.closest(".include-series");
|
const includeSeries = e.target.closest(".include-series");
|
||||||
if (includeSeries) {
|
if (includeSeries) {
|
||||||
@@ -1494,5 +1624,22 @@
|
|||||||
.table-warning th {
|
.table-warning th {
|
||||||
color: #ffc107;
|
color: #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ocr-debug-panel {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-debug-table-wrap {
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-debug-table code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user