Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active April 4, 2026 22:58
Show Gist options
  • Select an option

  • Save schuhwerk/619bc69ef6309c00b4d7273eadbc3ee1 to your computer and use it in GitHub Desktop.

Select an option

Save schuhwerk/619bc69ef6309c00b4d7273eadbc3ee1 to your computer and use it in GitHub Desktop.
Single page HTML DICOM-viewer.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DICOM Viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; overflow: hidden; }
#dropzone { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 20px; }
#dropzone h1 { font-size: 28px; color: #ccc; font-weight: 300; }
#dropzone p { color: #888; font-size: 14px; }
#dropzone button { padding: 14px 36px; font-size: 18px; border: none; border-radius: 8px; background: #4a6fa5; color: #fff; cursor: pointer; }
#dropzone button:hover { background: #5a8fd5; }
#loading { display: none; color: #aaa; font-size: 16px; }
body.dragover #dropzone { outline: 3px dashed #4a6fa5; outline-offset: -20px; }
#viewer { display: none; height: 100vh; flex-direction: column; }
#toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #16213e; border-bottom: 1px solid #333; flex-shrink: 0; flex-wrap: wrap; }
#toolbar button { padding: 5px 12px; background: #2a3a5e; color: #ccc; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 13px; }
#toolbar button.active { background: #4a6fa5; color: #fff; border-color: #5a8fd5; }
#toolbar button:hover { background: #3a5a8e; }
#toolbar select { padding: 5px 8px; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; font-size: 13px; }
#toolbar .sep { width: 1px; height: 24px; background: #444; }
#toolbar .info { font-size: 13px; color: #aaa; margin-left: auto; }
#canvas-area { flex: 1; position: relative; overflow: hidden; background: #000; }
#layerGroup0, #layerGroup1 { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
#layerGroup0 { z-index: 2; } #layerGroup1 { z-index: 1; }
#overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; }
.ov { position: absolute; font: 12px/1.4 monospace; color: rgba(255,255,100,0.85); padding: 8px; }
.ov-tl { top: 0; left: 0; } .ov-tr { top: 0; right: 0; text-align: right; } .ov-bl { bottom: 0; left: 0; }
#help-text { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #555; pointer-events: none; z-index: 10; white-space: nowrap; }
</style>
</head>
<body>
<div id="dropzone">
<h1>DICOM Viewer</h1>
<button id="openBtn" onclick="pickFolder()">Open DICOM Folder</button>
<p>Or drag &amp; drop a DICOM folder here</p>
<div id="loading">Loading...</div>
</div>
<div id="viewer">
<div id="toolbar">
<button id="toolScroll" onclick="setTool('Scroll')">Scroll</button>
<button id="toolWL" onclick="setTool('WindowLevel')">W/L</button>
<button id="toolZoom" onclick="setTool('ZoomAndPan')">Zoom</button>
<span class="sep"></span>
<button onclick="resetView()">Reset</button>
<span class="sep"></span>
<button onclick="prevSeries()">&lt;</button>
<select id="seriesSelect" onchange="switchSeries(this.value)"></select>
<button onclick="nextSeries()">&gt;</button>
<span class="sep"></span>
<span id="sliceInfo" style="font-size:13px;color:#ccc;"></span>
<span class="info" id="patientInfo"></span>
</div>
<div id="canvas-area">
<div id="layerGroup0"></div>
<div id="layerGroup1"></div>
<div id="overlay">
<div class="ov ov-tl" id="ov-tl"></div>
<div class="ov ov-tr" id="ov-tr"></div>
<div class="ov ov-bl" id="ov-bl"></div>
</div>
<div id="help-text">Up/Down: slices | Left/Right: series | 1: scroll | 2: W/L | 3: zoom | R: reset</div>
</div>
</div>
<script src="https://github.com/ivmartel/dwv/releases/download/v0.30.4/dwv-0.30.4.min.js"></script>
<script>
'use strict';
// --- Decoder workers (inlined as blobs for cross-origin compat) ---
const DB = 'https://cdn.jsdelivr.net/npm/dwv@0.30.4/decoders';
(async function() {
const defs = {
'jpeg2000': [DB+'/pdfjs/jpx.js', DB+'/pdfjs/util.js', DB+'/pdfjs/arithmetic_decoder.js', DB+'/pdfjs/decode-jpeg2000.js'],
'jpeg-baseline': [DB+'/pdfjs/jpg.js', DB+'/pdfjs/decode-jpegbaseline.js'],
'jpeg-lossless': [DB+'/rii-mango/lossless-min.js', DB+'/rii-mango/decode-jpegloss.js'],
'rle': [DB+'/dwv/rle.js', DB+'/dwv/decode-rle.js']
};
const scripts = {};
for (const [key, urls] of Object.entries(defs)) {
try {
const parts = [];
for (const url of urls) {
let code = await (await fetch(url)).text();
parts.push(code.replace(/importScripts\([^)]*\);?/g, ''));
}
scripts[key] = URL.createObjectURL(new Blob(parts, { type: 'application/javascript' }));
} catch(e) {}
}
dwv.image.decoderScripts = scripts;
})();
// --- State ---
let app = null, allSeries = [], currentIdx = 0;
let manualMode = false, manualSlice = 0, activeLayer = 0, manualLoading = false;
// --- DICOM tag parser ---
function parseDicomTag(bytes, group, elem) {
const g0 = group & 0xFF, g1 = (group >> 8) & 0xFF;
const e0 = elem & 0xFF, e1 = (elem >> 8) & 0xFF;
for (let p = 132; p < bytes.length - 8; p++) {
if (bytes[p] !== g0 || bytes[p+1] !== g1 || bytes[p+2] !== e0 || bytes[p+3] !== e1) continue;
const vr = String.fromCharCode(bytes[p+4], bytes[p+5]);
let valOff, valLen;
if (/^[A-Z]{2}$/.test(vr)) {
if ('OB|OW|OF|SQ|UC|UN|UR|UT'.includes(vr)) {
valLen = bytes[p+8] | (bytes[p+9]<<8) | (bytes[p+10]<<16) | (bytes[p+11]<<24); valOff = p + 12;
} else {
valLen = bytes[p+6] | (bytes[p+7]<<8); valOff = p + 8;
}
} else {
valLen = bytes[p+4] | (bytes[p+5]<<8) | (bytes[p+6]<<16) | (bytes[p+7]<<24); valOff = p + 8;
}
if (valLen > 0 && valLen < 200 && valOff + valLen <= bytes.length)
return new TextDecoder().decode(bytes.slice(valOff, valOff + valLen)).replace(/\0/g, '').trim();
}
return null;
}
// Helper: consume file arrayBuffer and return new File (since arrayBuffer can only be read once)
function cloneFile(ab, f) {
const nf = new File([ab], f.name, { type: f.type });
nf._parentDir = f._parentDir;
Object.defineProperty(nf, 'webkitRelativePath', { value: f.webkitRelativePath || '' });
return nf;
}
// --- File input ---
function pickFolder() {
const input = document.createElement('input');
input.type = 'file'; input.webkitdirectory = true; input.multiple = true;
input.onchange = (e) => handleFiles(Array.from(e.target.files));
input.click();
}
// --- Drag and drop ---
document.addEventListener('dragenter', (e) => { e.preventDefault(); document.body.classList.add('dragover'); });
document.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
document.addEventListener('dragleave', (e) => { if (!e.relatedTarget) document.body.classList.remove('dragover'); });
document.addEventListener('drop', async (e) => {
e.preventDefault(); document.body.classList.remove('dragover');
const files = await collectDroppedFiles(e.dataTransfer);
if (files.length) handleFiles(files);
});
async function collectDroppedFiles(dt) {
if (dt.items && dt.items[0] && dt.items[0].webkitGetAsEntry) {
const entries = [];
for (let i = 0; i < dt.items.length; i++) {
const e = dt.items[i].webkitGetAsEntry();
if (e) entries.push(e);
}
return await readEntries(entries);
}
return Array.from(dt.files);
}
async function readEntries(entries) {
const files = [];
async function traverse(entry, parentDir) {
if (entry.isFile) {
const file = await new Promise((r, e) => entry.file(r, e));
file._parentDir = parentDir;
files.push(file);
} else if (entry.isDirectory) {
const reader = entry.createReader();
let batch;
do {
batch = await new Promise((r, e) => reader.readEntries(r, e));
for (const c of batch) await traverse(c, entry.name);
} while (batch.length);
}
}
for (const e of entries) await traverse(e, e.name);
return files;
}
// --- Group files into series ---
async function handleFiles(files) {
const dcmFiles = files.filter(f => !/\.(jpg|png|html|json|xml|txt|js|css)$/i.test(f.name));
if (!dcmFiles.length) { alert('No DICOM files found.'); return; }
document.getElementById('loading').style.display = 'block';
document.getElementById('loading').textContent = 'Scanning files...';
document.getElementById('openBtn').style.display = 'none';
// Group by parent directory
const dirGroups = {};
for (const f of dcmFiles) {
let dir = f._parentDir || 'default';
if (f.webkitRelativePath) {
const parts = f.webkitRelativePath.split('/');
dir = parts.length >= 2 ? parts[parts.length - 2] : 'default';
}
(dirGroups[dir] = dirGroups[dir] || []).push(f);
}
// Single directory: sub-group by SeriesUID, skip non-image modalities
const groups = {};
const nonImageMods = ['SR','PR','KO','SEG','DOC','REG'];
for (const [dir, dirFiles] of Object.entries(dirGroups)) {
if (Object.keys(dirGroups).length > 1) {
groups[dir] = dirFiles;
} else {
for (const f of dirFiles) {
try {
const ab = await f.arrayBuffer();
const bytes = new Uint8Array(ab.slice(0, 6144));
const mod = parseDicomTag(bytes, 0x0008, 0x0060);
if (mod && nonImageMods.includes(mod.toUpperCase())) continue;
const key = parseDicomTag(bytes, 0x0020, 0x000E) || dir;
(groups[key] = groups[key] || []).push(cloneFile(ab, f));
} catch(e) {
(groups[dir] = groups[dir] || []).push(f);
}
}
}
}
allSeries = Object.keys(groups).sort().map(k => ({ name: k, files: groups[k] }));
// Get series description labels
for (const s of allSeries) {
try {
const ab = await s.files[0].arrayBuffer();
const desc = parseDicomTag(new Uint8Array(ab.slice(0, 6144)), 0x0008, 0x103E);
if (desc) s.label = desc.trim();
s.files[0] = cloneFile(ab, s.files[0]);
} catch(e) {}
}
const sel = document.getElementById('seriesSelect');
sel.innerHTML = allSeries.map((s, i) =>
`<option value="${i}">${s.label || s.name} (${s.files.length})</option>`
).join('');
loadSeries(0);
}
// --- Load a series ---
async function loadSeries(idx) {
currentIdx = idx; manualMode = false; manualSlice = 0;
document.getElementById('seriesSelect').value = idx;
document.getElementById('loading').style.display = 'block';
document.getElementById('loading').textContent = `Loading ${allSeries[idx].label || allSeries[idx].name}...`;
document.getElementById('openBtn').style.display = 'none';
// Sort by InstanceNumber once
if (!allSeries[idx]._sorted) {
const items = [];
for (const f of allSeries[idx].files) {
try {
const ab = await f.arrayBuffer();
const inst = parseInt(parseDicomTag(new Uint8Array(ab.slice(0, 6144)), 0x0020, 0x0013)) || 0;
items.push({ file: cloneFile(ab, f), inst });
} catch(e) { items.push({ file: f, inst: 0 }); }
}
items.sort((a, b) => a.inst - b.inst);
allSeries[idx].files = items.map(x => x.file);
allSeries[idx]._sorted = true;
}
const seriesFiles = allSeries[idx].files;
activeLayer = 0;
for (let i = 0; i < 2; i++) {
document.getElementById('layerGroup' + i).innerHTML = '';
document.getElementById('layerGroup' + i).style.zIndex = i === 0 ? 2 : 1;
}
app = new dwv.App();
app.init({ dataViewConfigs: {'*': [{divId: 'layerGroup0'}]}, tools: { Scroll: {}, WindowLevel: {}, ZoomAndPan: {} } });
app.addEventListener('loadend', () => {
document.getElementById('dropzone').style.display = 'none';
document.getElementById('loading').style.display = 'none';
document.getElementById('viewer').style.display = 'flex';
setTimeout(() => {
try { app.onResize(); } catch(e) {}
let canScroll = false;
try { const vc = app.getViewController('0'); canScroll = vc && vc.canScroll(); } catch(e) {}
if (!canScroll && seriesFiles.length > 1) { manualMode = true; manualSlice = 0; }
setTool(manualMode ? 'WindowLevel' : 'Scroll');
showMeta(); updateOverlay();
}, 150);
});
app.addEventListener('positionchange', updateOverlay);
app.loadFiles(seriesFiles);
}
// --- Manual slice loading (double-buffered) ---
function loadManualSlice(sliceIdx) {
if (!manualMode || manualLoading) return;
const files = allSeries[currentIdx].files;
if (sliceIdx < 0 || sliceIdx >= files.length) return;
manualSlice = sliceIdx; manualLoading = true; updateOverlay();
const backId = 1 - activeLayer;
const backDiv = document.getElementById('layerGroup' + backId);
backDiv.innerHTML = '';
const backApp = new dwv.App();
backApp.init({ dataViewConfigs: {'*': [{divId: 'layerGroup' + backId}]}, tools: { WindowLevel: {}, ZoomAndPan: {} } });
backApp.addEventListener('loadend', () => {
setTimeout(() => {
try { backApp.onResize(); } catch(e) {}
document.getElementById('layerGroup' + backId).style.zIndex = 2;
document.getElementById('layerGroup' + activeLayer).style.zIndex = 1;
app = backApp; activeLayer = backId; manualLoading = false;
setTool('WindowLevel'); updateOverlay();
}, 20);
});
backApp.loadFiles([files[sliceIdx]]);
}
// --- UI helpers ---
function showMeta() {
try {
const meta = app.getMetaData('0');
if (!meta) return;
const pn = meta['00100010'] ? meta['00100010'].value[0] : '';
document.getElementById('patientInfo').textContent = (typeof pn === 'string' ? pn : (pn.Alphabetic || '')).replace(/\^/g, ', ');
document.getElementById('ov-tl').textContent = meta['0008103E'] ? meta['0008103E'].value[0] : '';
} catch(e) {}
}
function updateOverlay() {
if (!app) return;
if (manualMode) {
const total = allSeries[currentIdx].files.length;
document.getElementById('sliceInfo').textContent = document.getElementById('ov-bl').textContent = `Slice ${manualSlice + 1} / ${total}`;
try { const wl = app.getViewController('0').getWindowLevel(); if (wl) document.getElementById('ov-tr').textContent = `WC: ${Math.round(wl.center)} / WW: ${Math.round(wl.width)}`; } catch(e) {}
return;
}
try {
const vc = app.getViewController('0'); if (!vc) return;
const k = vc.getCurrentScrollIndexValue ? vc.getCurrentScrollIndexValue() : 0;
const si = vc.getScrollIndex ? vc.getScrollIndex() : 2;
let total = '?'; try { total = app.getImage(0).getGeometry().getSize().get(si); } catch(e) {}
document.getElementById('sliceInfo').textContent = document.getElementById('ov-bl').textContent = `Slice ${k + 1} / ${total}`;
const wl = vc.getWindowLevel();
if (wl) document.getElementById('ov-tr').textContent = `WC: ${Math.round(wl.center)} / WW: ${Math.round(wl.width)}`;
} catch(e) {}
}
function setTool(name) {
if (!app) return;
try { app.setTool(name); } catch(e) {}
document.querySelectorAll('#toolbar button[id^="tool"]').forEach(b =>
b.classList.toggle('active', b.id === 'tool' + name.replace('AndPan','').replace('indowLevel','L')));
}
function resetView() {
if (!app) return;
try { app.getViewController('0').setWindowLevelPreset('Default'); app.resetZoom(); } catch(e) {}
updateOverlay();
}
function switchSeries(idx) { loadSeries(parseInt(idx)); }
function prevSeries() { if (currentIdx > 0) switchSeries(currentIdx - 1); }
function nextSeries() { if (currentIdx < allSeries.length - 1) switchSeries(currentIdx + 1); }
function scrollSlice(delta) {
if (!app) return;
if (manualMode) {
const n = allSeries[currentIdx].files.length;
const s = Math.max(0, Math.min(n - 1, manualSlice + delta));
if (s !== manualSlice) loadManualSlice(s);
return;
}
try {
const vc = app.getViewController('0');
if (!vc || !vc.canScroll()) return;
for (let i = 0; i < Math.abs(delta); i++) delta > 0 ? vc.incrementScrollIndex() : vc.decrementScrollIndex();
updateOverlay();
} catch(e) {}
}
// --- Events ---
let wheelTimer = null;
document.getElementById('canvas-area').addEventListener('wheel', (e) => {
e.preventDefault();
if (wheelTimer) return;
wheelTimer = setTimeout(() => wheelTimer = null, 80);
scrollSlice(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
document.addEventListener('keydown', (e) => {
if (!app) return;
switch (e.key) {
case 'ArrowUp': e.preventDefault(); scrollSlice(-1); break;
case 'ArrowDown': e.preventDefault(); scrollSlice(1); break;
case 'ArrowLeft': e.preventDefault(); prevSeries(); break;
case 'ArrowRight': e.preventDefault(); nextSeries(); break;
case 'Home': e.preventDefault(); scrollSlice(-9999); break;
case 'End': e.preventDefault(); scrollSlice(9999); break;
case '1': setTool('Scroll'); break;
case '2': setTool('WindowLevel'); break;
case '3': setTool('ZoomAndPan'); break;
case 'r': case 'R': resetView(); break;
}
});
window.addEventListener('resize', () => { if (app) try { app.onResize(); } catch(e) {} });
setInterval(updateOverlay, 500);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment