feat: Implement duplicate check functionality during DICOM uploads and update viewer element ID

This commit is contained in:
Ross
2026-05-11 11:53:18 +01:00
parent 84b032addb
commit b24eac862b
+152 -58
View File
@@ -93,7 +93,7 @@
</div>
</div>
<div class="card-body" style="position: relative; min-height: 450px;">
<div id="upload-dicom-viewer" class="dicom-viewer-root" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%;"></div>
<div id="uploadviewer" class="dicom-viewer-root" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%;"></div>
</div>
</div>
@@ -176,11 +176,15 @@
forceAnonymisationOverride: false,
ocrWorker: null,
currentTagsRecord: null,
duplicateCheckInProgress: false,
duplicateCheckRunId: 0,
duplicateCheckTimer: null,
};
const SITE_PREFIXES = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"];
const OCR_MAX_SERIES_SAMPLES = 40;
const OCR_CANVAS_MAX = 768;
const DUPLICATE_BATCH_SIZE = 50;
function setProgress(text) {
document.getElementById("progress-text").textContent = text;
@@ -241,6 +245,7 @@
ocrWarning: false,
duplicate: false,
duplicateUrl: "",
duplicateChecked: false,
blake3Hash: "",
objectUrl: URL.createObjectURL(file),
};
@@ -320,6 +325,9 @@
const hasSelected = window.uploadPreview.records.some(r => r.include);
const hasWarnings = hasAnonymisationWarnings();
const hasDuplicates = window.uploadPreview.records.some(r => r.include && r.duplicate);
const duplicatesEnabled = document.getElementById("check-duplicates")?.checked;
const hasPendingDuplicateChecks = duplicatesEnabled && hasSelected &&
window.uploadPreview.records.some((r) => r.include && !r.duplicateChecked);
if (!hasSelected) {
uploadButton.disabled = true;
@@ -333,10 +341,36 @@
return;
}
if (hasPendingDuplicateChecks || window.uploadPreview.duplicateCheckInProgress) {
uploadButton.disabled = true;
anonAlert.classList.add("d-none");
return;
}
anonAlert.classList.add("d-none");
uploadButton.disabled = !!hasDuplicates;
}
function getSeriesDuplicateCheckBadge(series) {
const duplicatesEnabled = document.getElementById("check-duplicates")?.checked;
if (!duplicatesEnabled) {
return '<span class="badge text-bg-secondary">Duplicate check off</span>';
}
const selectedFiles = series.files.filter((f) => f.include);
if (!selectedFiles.length) {
return '<span class="badge text-bg-secondary">Not selected</span>';
}
const checkedCount = selectedFiles.filter((f) => f.duplicateChecked).length;
if (checkedCount < selectedFiles.length) {
const badgeClass = window.uploadPreview.duplicateCheckInProgress ? "text-bg-info" : "text-bg-warning";
return `<span class="badge ${badgeClass}">Duplicate check: ${checkedCount}/${selectedFiles.length}</span>`;
}
return '<span class="badge text-bg-success">Duplicate check complete</span>';
}
function getSeriesStatusClass(series) {
const hasTagWarnings = series.files.some(f => f.tagWarnings.length > 0);
const hasOcrWarnings = series.files.some(f => f.ocrWarning);
@@ -392,6 +426,7 @@
<div class="series-flags mt-2 small">
${tagWarningCount ? `<span class="badge text-bg-warning">Tag warnings: ${tagWarningCount}</span>` : ""}
${ocrWarningCount ? `<span class="badge text-bg-warning">OCR warnings: ${ocrWarningCount}</span>` : ""}
${getSeriesDuplicateCheckBadge(series)}
${duplicateCount ? `<span class="badge text-bg-danger">Duplicates: ${duplicateCount}</span>` : ""}
</div>
<div class="series-thumbnail mt-2" data-series-thumb="${series.uid}"></div>
@@ -577,83 +612,133 @@
}
}
function scheduleDuplicateCheck() {
if (window.uploadPreview.duplicateCheckTimer) {
clearTimeout(window.uploadPreview.duplicateCheckTimer);
}
window.uploadPreview.duplicateCheckTimer = setTimeout(async () => {
await processDuplicates();
renderGroupedPreview();
}, 100);
}
async function processDuplicates() {
const dupProgress = document.getElementById("duplicate-progress");
dupProgress.innerHTML = "";
window.uploadPreview.records.forEach(r => {
window.uploadPreview.records.forEach((r) => {
r.duplicate = false;
r.duplicateUrl = "";
if (r.include) r.duplicateChecked = false;
});
document.getElementById("duplicate-series").innerHTML = "";
window.uploadPreview.duplicateSeriesUrls = new Set();
if (!document.getElementById("check-duplicates").checked) {
return;
}
const selected = window.uploadPreview.records.filter(r => r.include);
if (!selected.length) return;
const hashes = [];
let idx = 0;
for (const record of selected) {
idx += 1;
if (!record.blake3Hash) {
setProgress(`Hashing ${idx} of ${selected.length}: ${record.path}`);
record.blake3Hash = await calculateBlake3Hash(record.file);
}
hashes.push(record.blake3Hash);
}
dupProgress.innerHTML = '<span class="text-info">Checking hashes with server...</span>';
let result = {};
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const res = await fetch("{% url 'api-1:check_images_hashes' %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}",
},
body: JSON.stringify(hashes),
signal: controller.signal,
window.uploadPreview.records.forEach((r) => {
r.duplicateChecked = true;
});
clearTimeout(timeoutId);
if (res.ok) result = await res.json();
else throw new Error(`Server returned ${res.status}`);
} catch (err) {
if (err.name === "AbortError") {
dupProgress.innerHTML = '<span class="text-warning">Duplicate check timed out. Proceeding without server verification.</span>';
} else {
console.error(err);
dupProgress.innerHTML = '<span class="text-warning">Could not verify duplicates. Proceeding.</span>';
}
window.uploadPreview.duplicateCheckInProgress = false;
updateUploadButtonState();
return;
}
const selected = window.uploadPreview.records.filter((r) => r.include);
if (!selected.length) {
window.uploadPreview.duplicateCheckInProgress = false;
updateUploadButtonState();
return;
}
const runId = window.uploadPreview.duplicateCheckRunId + 1;
window.uploadPreview.duplicateCheckRunId = runId;
window.uploadPreview.duplicateCheckInProgress = true;
updateUploadButtonState();
let processed = 0;
const duplicateSeriesMap = {};
for (const record of selected) {
const info = result[record.blake3Hash];
if (!info || !info.id) continue;
record.duplicate = true;
if (info.url) {
record.duplicateUrl = info.url;
duplicateSeriesMap[info.url] = info.id;
try {
for (let offset = 0; offset < selected.length; offset += DUPLICATE_BATCH_SIZE) {
if (runId !== window.uploadPreview.duplicateCheckRunId) return;
const batch = selected.slice(offset, offset + DUPLICATE_BATCH_SIZE);
const hashes = [];
for (const record of batch) {
if (runId !== window.uploadPreview.duplicateCheckRunId) return;
if (!record.blake3Hash) {
setProgress(`Hashing ${processed + 1} of ${selected.length}: ${record.path}`);
record.blake3Hash = await calculateBlake3Hash(record.file);
}
hashes.push(record.blake3Hash);
processed += 1;
}
dupProgress.innerHTML = `<span class="text-info">Checking duplicate batch ${Math.floor(offset / DUPLICATE_BATCH_SIZE) + 1} of ${Math.ceil(selected.length / DUPLICATE_BATCH_SIZE)} (${processed}/${selected.length})...</span>`;
let result = {};
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const res = await fetch("{% url 'api-1:check_images_hashes' %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}",
},
body: JSON.stringify(hashes),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res.ok) result = await res.json();
else throw new Error(`Server returned ${res.status}`);
} catch (err) {
if (err.name === "AbortError") {
dupProgress.innerHTML = '<span class="text-warning">Duplicate check batch timed out. Upload is locked until checks complete.</span>';
} else {
console.error(err);
dupProgress.innerHTML = '<span class="text-warning">Duplicate check failed. Upload is locked until checks complete.</span>';
}
return;
}
for (const record of batch) {
const info = result[record.blake3Hash];
if (info && info.id) {
record.duplicate = true;
if (info.url) {
record.duplicateUrl = info.url;
duplicateSeriesMap[info.url] = info.id;
}
}
record.duplicateChecked = true;
}
renderGroupedPreview();
}
} finally {
if (runId === window.uploadPreview.duplicateCheckRunId) {
window.uploadPreview.duplicateCheckInProgress = false;
}
}
if (runId !== window.uploadPreview.duplicateCheckRunId) {
return;
}
if (Object.keys(duplicateSeriesMap).length) {
dupProgress.innerHTML = `<span class="text-danger fw-semibold">Duplicates detected. Remove duplicates before upload.</span>`;
const duplicateSeries = document.getElementById("duplicate-series");
duplicateSeries.innerHTML = "";
Object.entries(duplicateSeriesMap).forEach(([url, id]) => {
const item = document.createElement("li");
item.innerHTML = `<a href="${url}" target="_blank">Series ${id}</a>`;
duplicateSeries.appendChild(item);
});
} else {
dupProgress.innerHTML = '<span class="text-success">No duplicates detected.</span>';
dupProgress.innerHTML = '<span class="text-success">All duplicate checks complete. No duplicates detected.</span>';
}
updateUploadButtonState();
}
function collectSelectedRecords() {
@@ -734,10 +819,9 @@
renderGroupedPreview();
await runOcrChecks();
await processDuplicates();
renderGroupedPreview();
showLoading(false);
scheduleDuplicateCheck();
}
function* chunks(arr, n) {
@@ -831,6 +915,12 @@
return;
}
if (document.getElementById("check-duplicates").checked &&
window.uploadPreview.records.some((r) => r.include && !r.duplicateChecked)) {
alert("Duplicate checks are still running or incomplete. Please wait for all checks to finish before uploading.");
return;
}
resetUploadResults();
showLoading(true, "Uploading...");
@@ -872,7 +962,7 @@
});
}
const viewerElement = document.getElementById('upload-dicom-viewer');
const viewerElement = document.getElementById('uploadviewer');
if (!viewerElement) {
console.warn('Viewer element not found');
return;
@@ -881,6 +971,10 @@
//console.log('Clearing viewer element and loading files');
//// Clear any old viewer content before mounting dv3d
//viewerElement.innerHTML = '';
if (window.clearViewer_uploadviewer && typeof window.clearViewer_uploadviewer === 'function') {
console.log('Clearing viewer using clearViewer_uploadviewer');
//window.clearViewer_uploadviewer();
}
// Try to load files using the dv3d viewer API
if (window.loadDicomStackFromFiles && typeof window.loadDicomStackFromFiles === 'function') {
@@ -1014,7 +1108,7 @@
if (includeSeries) {
applyIncludeToSeries(includeSeries.dataset.seriesUid, includeSeries.checked);
buildSelectionSummary();
await processDuplicates();
scheduleDuplicateCheck();
renderGroupedPreview();
return;
}
@@ -1022,7 +1116,7 @@
const includeStudy = e.target.closest(".include-study");
if (includeStudy) {
applyIncludeToStudy(includeStudy.dataset.studyUid, includeStudy.dataset.value === "1");
await processDuplicates();
scheduleDuplicateCheck();
renderGroupedPreview();
return;
}
@@ -1055,7 +1149,7 @@
});
bindIfExists("check-duplicates", "change", async () => {
await processDuplicates();
scheduleDuplicateCheck();
renderGroupedPreview();
});