Build a Private DICOM Explorer
Build a local DICOM viewer with MPR, 3D views, and a privacy-aware audit.
Introduction
30 Second Summary
Medical scans can carry personal details far beyond the image itself. Opening one for a coding experiment raises a difficult question: how can you inspect it without handing the original files to another tool?
In this project, you will build a local DICOM explorer in Safari that opens a scan folder without uploading its contents. Claude Code helps you create the app while permission rules keep the original files outside its workspace.
What You'll Build
You will watch one locally chosen scan fill a four-panel explorer with three anatomical planes plus a rotatable 3D view.
By the end of this project, you'll have:
- A private local viewer where a browser-selected DICOM folder fills axial, sagittal, coronal, plus experimental 3D panels.
- Practical inspection controls for scrolling slices, adjusting window and level, measuring distance, rotating the 3D view, plus switching rendering presets.
- A privacy-aware audit that checks a separate best-effort header-scrubbed copy without printing the identifier values it compares.
- Secret Mission: Prove that Claude Code cannot read the protected scan folders while Safari still displays the same local series.
Are there any prerequisites?
You need a macOS computer plus the existing paid account described for this project. The project walks you through installing or verifying every required development tool.
Before We Start
This project creates an educational local DICOM viewer with no diagnostic use. The privacy boundary keeps original DICOM files outside the Claude Code workspace and excludes them from uploads, Claude Code conversations, and workspace test data.
Set Up a Privacy-First Workspace
Medical DICOM files can carry identifiers in headers, filenames, pixels, or transcripts. A single accidental read can pull protected information into the coding session.
This step establishes the workspace boundary before Claude Code starts. You'll verify Node.js, Python, and Safari. You'll also place the scans outside the project.
In this step, get ready to:
- Install or verify the required macOS tools.
- Create separate folders for the project and medical files.
- Start Claude Code with the privacy permissions active.
Verify the macOS tools
The viewer depends on exact runtime versions. Checking each tool first prevents package failures later.
- Press Cmd+Space to open macOS search.
- Type Terminal in the search bar.
- Press Enter to open Terminal.
- Check the installed Node.js version by running this command:
node --version
What does this check do?
The command prints the Node.js version currently available to your terminal. This project requires v26.13.0.
✔️ Node.js matches
Node.js is ready when the output is exactly v26.13.0. That runtime can support the pinned project packages.
ⓧ I see an older version
Your terminal found Node.js, but this project requires the pinned LTS release.
- Download the official node-v26.13.0.pkg installer.
- Open the downloaded installer.
- Complete the macOS installation prompts.
- Return to Terminal.
- Verify the installed version by running this command:
node --version
What confirms the upgrade?
The command asks your shell to locate Node.js again. The output should now be v26.13.0.
ⓧ Command not found
Node.js is not available to your terminal yet. The official macOS package installs the runtime system-wide.
- Download the official node-v26.13.0.pkg installer.
- Open the downloaded installer.
- Complete the macOS installation prompts.
- Return to Terminal.
- Verify the installation by running this command:
node --version
What confirms the installation?
The command now finds the installed runtime. You should see v26.13.0.
Node.js version still incorrect?
Close Terminal after the installer finishes. Open it again so the new executable is available to your shell.
Confirm that you downloaded node-v26.13.0.pkg from the official Node.js address.
Help me fix a Node.js version mismatch on macOS.
- Check the installed Python version by running this command:
python3 --version
What does this check do?
The command prints the Python runtime attached to python3. This project requires Python 3.15.2.
✔️ Python matches
Python is ready when the output is exactly Python 3.15.2. You'll use this runtime for the privacy audit later.
ⓧ I see an older version
Your macOS installation is resolving an older Python runtime. Install the pinned release before continuing.
- Visit the official Python 3.15.2 release page.
- Download the official macOS installer.
- Open the downloaded installer.
- Complete the macOS installation prompts.
- Return to Terminal.
- Verify the installed version by running this command:
python3 --version
What confirms the upgrade?
The command checks which Python runtime now answers to python3. You should see Python 3.15.2.
ⓧ Command not found
Python is not available through python3 yet. The official macOS installer adds the required runtime.
- Visit the official Python 3.15.2 release page.
- Download the official macOS installer.
- Open the downloaded installer.
- Complete the macOS installation prompts.
- Return to Terminal.
- Verify the installation by running this command:
python3 --version
What confirms the installation?
The command now checks the installed runtime. You should see Python 3.15.2.
Python version still incorrect?
Close Terminal after the installer completes. Open it again before repeating the version check.
Confirm that the installer came from the official release page for 3.15.2.
Help me fix a Python version mismatch on macOS.
Safari is included with macOS. Opening it now confirms that the browser needed for the local folder picker is available.
- Press Cmd+Space to open macOS search.
- Type Safari in the search bar.
- Press Enter to open Safari.
Safari should open to a browser window. That confirms the viewer's target browser is ready.
- Return to Terminal.
- Check whether Claude Code is installed by running this command:
claude --version
What does this check do?
The command asks the Claude Code executable to report its installed version. Any version output confirms that the command is available.
✔️ I see a version number
Claude Code is installed. You'll authenticate it after the protected workspace exists.
ⓧ Command not found
Claude Code needs to be installed before you create the coding session.
- Install Claude Code with the official macOS command:
curl -fsSL https://claude.ai/install.sh | bash
What does this command do?
The command downloads the official Claude Code installer over HTTPS. It passes that installer to your shell so the CLI becomes available.
- Close Terminal after the installation completes.
- Open Terminal again through macOS search.
- Verify the installation by running this command:
claude --version
What confirms the installation?
The command should now print a Claude Code version. That output confirms the CLI is ready for authentication.
Claude Code command still missing?
Open a fresh Terminal window after the installer finishes. This reloads the shell environment.
Confirm that the installation command completed without a network failure.
Help me troubleshoot the Claude Code installation on macOS.
Create the protected folder boundary
The project source belongs under ~/Projects/private-dicom-explorer. The original scans belong under ~/Documents/Private-DICOM.
This separation gives the permission rules a clear boundary. The second medical-data folder holds only the best-effort scrubbed output created later.
- Return to Terminal.
- Create the project boundary and display all three storage locations by running these commands:
mkdir -p ~/Projects/private-dicom-explorer/.claude ~/Documents/Private-DICOM ~/Documents/Deidentified-DICOM
ls -ld ~/Projects/private-dicom-explorer ~/Projects/private-dicom-explorer/.claude ~/Documents/Private-DICOM ~/Documents/Deidentified-DICOM
What do these commands do?
- The first command creates the project folder with its .claude configuration folder.
- The first command also creates the two medical-data folders under ~/Documents.
- The second command lists each directory so you can confirm that it exists.
You'll see one line for each requested path. The three storage areas now exist without placing any scan in the project.
Folder creation failed?
Check that each path begins with ~ so macOS resolves it from your signed-in account's home folder.
Confirm that your macOS account can write to ~/Projects and ~/Documents.
Help me create the three project folders safely.
Moving medical files deserves extra care. Finder performs this transfer without exposing the scan to Claude Code.
- Press Cmd+Space to open macOS search.
- Type Finder in the search bar.
- Press Enter to open Finder.
- Locate the original scan on your Mac.
- Drag the original scan into ~/Documents/Private-DICOM.
- Confirm that the original scan is stored inside ~/Documents/Private-DICOM.
- Confirm that no scan file appears inside ~/Projects/private-dicom-explorer.
The original scan is now outside the coding workspace. Keep every original DICOM file within that protected folder.
Three project files enforce the boundary from different angles. The settings file controls Claude Code permissions. The Markdown file gives the coding agent explicit privacy rules.
- Return to Terminal.
- Create .claude/settings.json inside ~/Projects/private-dicom-explorer by running these commands:
cd ~/Projects/private-dicom-explorer
cat > .claude/settings.json <<'EOF'
{"cleanupPeriodDays":1,"permissions":{"disableBypassPermissionsMode":"disable","disableAutoMode":"disable","blockReadsOutsideWorkingDirectories":true,"deny":["Read(~/Documents/Private-DICOM/**)","Read(~/Documents/Deidentified-DICOM/**)"]},"env":{"CLAUDE_CODE_SKIP_PROMPT_HISTORY":"1"}}
EOF
cat .claude/settings.json
How do these settings protect the workspace?
- The two Read(...) rules deny Claude Code file reads from both medical-data folders.
- The outside-workspace setting blocks reads beyond the project directory.
- The two mode settings disable bypass mode and auto mode.
- The environment setting prevents prompt history from being written.
Terminal prints the compact JSON after writing it. That output confirms the settings file contains the required permission boundary.
Settings file not created?
Confirm that Terminal is still inside ~/Projects/private-dicom-explorer.
Check that the .claude folder exists before repeating the command.
Help me create the Claude Code settings file.
- Create .gitignore inside ~/Projects/private-dicom-explorer by running these commands:
cat > .gitignore <<'EOF'
node_modules/
dist/
.DS_Store
*.dcm
private-scans/
deidentified-scans/
.claude/settings.local.json
EOF
cat .gitignore
What does this file exclude?
The ignore rules exclude dependencies and build output. They also exclude DICOM files plus common local scan-folder names.
The final rule keeps local Claude Code setting overrides out of version control. This reduces the chance of committing machine-specific configuration.
Terminal prints all seven ignore rules. The project now has a second safeguard against accidentally tracking scan files.
Ignore rules look incomplete?
Repeat the command if the printed output omits a line. The closing EOF must appear alone on its line.
Help me correct my project ignore file.
- Create CLAUDE.md inside ~/Projects/private-dicom-explorer by running these commands:
cat > CLAUDE.md <<'EOF'
# Project privacy rules
- Never read, list, glob, grep, attach, summarize, or request any file under `~/Documents/Private-DICOM` or `~/Documents/Deidentified-DICOM`.
- Never run a shell command or script whose argument points to either DICOM folder.
- Never ask the learner to paste DICOM headers, filenames, screenshots, identifiers, or loader output into the conversation.
- Build and test only with the application source code. The learner selects scan files directly in Safari.
- Do not add analytics, telemetry, remote storage, upload endpoints, remote image URLs, or external fetch calls to the viewer.
- Treat the app as educational software that is not for diagnosis.
EOF
cat CLAUDE.md
Why add project instructions?
The permission file creates technical restrictions for Claude Code's built-in file tools. CLAUDE.md supplies the privacy rules that guide every coding request in this project.
The rules also reject analytics and remote image sources. The viewer stays local-first by design.
Terminal prints the heading plus six privacy rules. The coding agent now receives the boundary in plain language.
Privacy rules not printed correctly?
Confirm that the Markdown heading begins with one # character. Check that each rule begins with a hyphen.
Help me correct the project privacy instructions.
Use the tabs below to compare the three privacy files before starting Claude Code.
✔️ Awesome, I've got everything!
Great. Double-check that all three files are saved inside ~/Projects/private-dicom-explorer.
ⓧ I'd like to double check the full code
Your .claude/settings.json file should match this exactly.
{"cleanupPeriodDays":1,"permissions":{"disableBypassPermissionsMode":"disable","disableAutoMode":"disable","blockReadsOutsideWorkingDirectories":true,"deny":["Read(~/Documents/Private-DICOM/**)","Read(~/Documents/Deidentified-DICOM/**)"]},"env":{"CLAUDE_CODE_SKIP_PROMPT_HISTORY":"1"}}
What should match?
Compare every key and path. The compact formatting matches the target settings file exactly.
Your .gitignore file should match this exactly.
node_modules/
dist/
.DS_Store
*.dcm
private-scans/
deidentified-scans/
.claude/settings.local.json
What should match?
Compare all seven entries. Each pattern protects a specific local artifact from version control.
Your CLAUDE.md file should match this exactly.
# Project privacy rules
- Never read, list, glob, grep, attach, summarize, or request any file under `~/Documents/Private-DICOM` or `~/Documents/Deidentified-DICOM`.
- Never run a shell command or script whose argument points to either DICOM folder.
- Never ask the learner to paste DICOM headers, filenames, screenshots, identifiers, or loader output into the conversation.
- Build and test only with the application source code. The learner selects scan files directly in Safari.
- Do not add analytics, telemetry, remote storage, upload endpoints, remote image URLs, or external fetch calls to the viewer.
- Treat the app as educational software that is not for diagnosis.
What should match?
Compare the heading plus all six rules. These instructions define the complete project privacy policy.
Start Claude Code in Manual mode
Claude Code loads project settings from the folder where the session starts. Launching from the protected project makes the deny rules available before any coding begins.
The first launch can open a browser login for your paid account. It can also show a workspace trust dialog before the session becomes available.
- Start Claude Code from ~/Projects/private-dicom-explorer by running these commands:
cd ~/Projects/private-dicom-explorer
claude
What do these commands do?
The first command places Terminal inside the protected project. The second starts Claude Code with that folder as its workspace.
- Complete the browser login with your paid Claude account if prompted.
- Return to the Terminal session after authentication.
- Review the workspace trust dialog.
- Confirm trust only when the workspace path is ~/Projects/private-dicom-explorer.
- Keep Manual mode selected.
- Open the permission summary by entering this Claude Code command:
/permissions
What does this command show?
The permission summary shows the rules loaded for the current project. This is where you confirm that both protected scan folders are denied.
- Confirm that Read(~/Documents/Private-DICOM/**) appears as a deny rule.
- Confirm that Read(~/Documents/Deidentified-DICOM/**) appears as a deny rule.
- Confirm that reads outside working directories are blocked.
- Confirm that bypass mode is disabled.
- Confirm that auto mode is disabled.
Your Claude Code session is now open in Manual mode with both medical-data folders denied. The privacy boundary is active before application development starts.
What does this boundary cover?
The deny rules constrain Claude Code's built-in file reads. They do not constrain arbitrary subprocesses that access files indirectly.
Keep every scan-processing command in a separate macOS Terminal. Claude Code remains focused on application source only.
The final diagnostic checks all three command-line tools while the protected Claude Code session stays open.
- Press Cmd+Space to open macOS search.
- Type Terminal in the search bar.
- Press Enter to open a second Terminal window.
Before you run the diagnostic, do you expect all three tools to report a healthy setup?
- Verify the complete toolchain by running this command:
node --version && python3 --version && claude doctor
What does this diagnostic prove?
The first two checks confirm the exact Node.js and Python runtimes. Claude Code then reports its installation and settings diagnostics.
You'll see v26.13.0 from Node.js. You'll also see Python 3.15.2 from Python. Claude Code reports its installation and settings diagnostics.
Diagnostic does not match?
Return to the outcome tab for the tool whose version differs. Repeat its official installation path before running the combined diagnostic again.
Keep both DICOM folders closed to Claude Code while troubleshooting the development tools.
Help me diagnose my final macOS tool check.
Your development tools are ready inside a privacy-guarded workspace. Next, you'll build the first local slice viewer without giving Claude Code access to the scan.
Render the First Local Slice
Your guarded workspace now keeps the original scan folders outside Claude Code's reach. The next proof happens inside Safari, where you choose the files yourself.
A single rendered DICOM slice proves that the browser can decode a local image without placing the scan inside the project. This step builds the smallest path that can demonstrate that boundary.
In this step, get ready to:
- Create the dark viewer shell with a browser-controlled folder picker.
- Install the pinned browser imaging dependencies.
- Render the first compatible local slice in Safari.
Build the local viewer shell
Vite serves the viewer from your Mac. Cornerstone3D provides the browser imaging engine.
The dicom-parser library reads the compatibility fields needed to find a renderable image. Patient-facing values never belong in the page or console.
Which package versions are pinned?
The manifest keeps every package on the versions selected for this project:
- Use private-dicom-explorer as the private package name.
- Use 8.3.5 for vite.
- Use 5.15.2 for @cornerstonejs/core.
- Use 5.15.2 for @cornerstonejs/dicom-image-loader.
- Use 5.15.2 for @cornerstonejs/tools.
- Use 1.8.21 for dicom-parser.
What should the shell contain?
- A local folder input controlled by the browser.
- A toolbar for the inspection controls added later.
- A dark two-by-two grid with Axial, Sagittal, Coronal, and 3D viewport labels.
- A panel reserved for non-identifying scan statistics.
- A privacy note that identifies the viewer as educational software that is not for diagnosis.
- A module script that loads /src/main.js.
- In the Claude Code session from earlier, ask Claude to create package.json, index.html, src/style.css, and src/main.js using the requirements above.
- Tell Claude to include dev, build, and preview scripts in the package manifest.
- Repeat that Claude must never read either DICOM folder.
- Review the proposed paths before approving the changes.
- Approve only changes inside ~/Projects/private-dicom-explorer.
- Install the pinned dependencies from the guarded project workspace by running:
npm install
What does this command do?
The command reads package.json and downloads the pinned packages into node_modules. The scan folders remain untouched because the installation only uses the project manifest.
- Confirm the installation finishes without an error.
- Confirm the command returns control after the packages finish downloading.
Dependency installation failed?
- Check that package.json is inside ~/Projects/private-dicom-explorer.
- Check that every package version matches the pinned package list above.
- Keep every troubleshooting command scoped to the project workspace.
Help me troubleshoot the dependency installation without reading either DICOM folder.
- Start the local development server by running:
npm run dev
What does this command do?
The command starts the Vite development server defined by the dev script. The process stays active so Safari can load the viewer from the printed local address.
You should see Vite print a localhost address. Leave this process running while you test the viewer.
- Copy the address printed by Vite: your Vite localhost address.
- Press Command-Space bar to open Spotlight.
- Type Safari into Spotlight.
- Press Enter to launch Safari.
- Paste your Vite localhost address into the Safari address bar.
- Press Enter to load the page.
You should see the dark Private DICOM Explorer shell with four labelled panels. The controls remain inactive because no scan has been selected.
Viewer shell not loading?
- Check that the Vite process is still running.
- Confirm that Safari is using the exact address printed by Vite.
- Check that index.html loads /src/main.js.
Help me troubleshoot the blank local viewer without accessing any scan files.
Connect the folder picker to a slice
The Browser File API gives the page access to selected file objects after you choose a folder. The application can pass those objects directly to the local image loader.
This path inspects only the geometry needed for compatibility. It does not display identifiers, UIDs, filenames, or free-text descriptions.
- Switch back to the Claude Code session from earlier.
- Ask Claude to replace the initial src/main.js logic with a temporary browser-only STACK preview.
- Require the implementation to initialize Cornerstone and the DICOM image loader.
- Require the implementation to inspect only compatibility geometry from the selected browser file objects.
- Require each compatible file to be registered through dicomImageLoader.wadouri.fileManager.add(file).
- Require the first compatible image ID to render in the Axial viewport.
- Repeat that Claude must not read, list, glob, or execute a command against either DICOM folder.
- Approve the source change only after confirming that it adds no analytics, uploads, remote image URLs, or external fetch calls.
This is the privacy-sensitive moment. Safari receives the folder selection while Claude Code remains limited to the application source.
Before you select the folder, do you expect the current rendering path to populate every panel?
- Return to Safari.
- Refresh the local viewer page.
- Click Load DICOM folder.
- Select ~/Documents/Private-DICOM in the browser picker.
- Confirm the folder selection in the picker.
You should see the first compatible slice in the Axial viewport. The Sagittal, Coronal, and 3D panels remain unpopulated.
You have proved that Safari can decode a selected local image while the protected scan folder stays outside the Claude Code workspace.
Why are three panels still empty?
The current preview renders a stack image in one plane. Those empty panels expose the limit of this first rendering path.
Multiple anatomical planes require a volume built from a compatible series. The one-slice result gives you a trusted local foundation for that next stage.
No local slice appeared?
- Refresh the page before choosing the folder again.
- Choose the Private-DICOM folder itself through Safari.
- Keep scan filenames, metadata values, screenshots, and loader output out of Claude Code while you troubleshoot.
Help me debug the browser-only STACK preview without requesting scan details.
Your local imaging path now works from folder selection to a rendered slice. Next, you can turn that single-plane proof into a shared volume for the remaining viewports.
Build MPR and Experimental 3D
Your temporary stack proved that Safari can decode a browser-selected DICOM image while Claude Code remains outside the protected scan folders.
A single slice cannot reveal anatomy from other planes. This step groups compatible instances into a browser volume for multiplanar reconstruction and experimental volume rendering.
In this step, get ready to:
- Replace the temporary stack preview with the local-series loader.
- Preload compatible slices before creating the shared volume.
- Render axial, sagittal, coronal, and experimental 3D views in Safari.
Replace the temporary stack loader
The Browser File API gives the app selected files without copying them into the project. The loader must group only files that can form a consistent local volume.
What must the loader preserve?
- The loader parses selected files locally through parseCandidate().
- Each group matches the series UID, frame of reference UID, orientation, rows, and columns.
- Multi-frame objects are excluded from this experimental path.
- The largest compatible multi-slice group is selected.
- Instances are sorted by instance number before their image IDs are created.
- Patient identifiers, UIDs, filenames, and free-text descriptions stay out of the interface.
- Switch back to the Claude Code session from earlier.
- Ask Claude Code to replace the temporary STACK preview in src/main.js with the final browser-only local-series loader using every requirement above.
Claude Code proposes an edit to the application source while the protected scan folders remain outside its workspace.
- Review the proposed changes to src/main.js.
- Reject any change that reads either DICOM folder through Claude Code.
- Approve the source-code edit.
- Confirm that src/main.js is saved.
- Return to the Safari tab from earlier.
You should see the four-panel shell with a status confirming that the viewer is ready for one DICOM folder.
Viewer initialization stopped?
Check that Claude Code replaced only src/main.js. Confirm that the pinned packages remain in package.json.
Keep scan contents out of the conversation while you debug the application source. Help me troubleshoot the browser viewer initialization without requesting DICOM files, filenames, headers, screenshots, or loader output.
Confirm preload and viewport wiring
The shared volume depends on metadata from every selected slice. Preloading fills the browser image cache before volume creation tries to assemble the series.
Why preload every image?
The local-folder path is experimental. Loading every selected image first reduces the chance that volume creation starts with missing image metadata.
The preload must finish before createAndCacheVolume() runs. That ordering is the bridge from separate browser files to one shared volume.
- In src/main.js, locate Promise.all(imageLoader.loadAndCacheImages(imageIds)).
- Confirm that the preload appears before volumeLoader.createAndCacheVolume(VOLUME_ID, { imageIds }).
- Confirm that AXIAL, SAGITTAL, and CORONAL use ORTHOGRAPHIC viewports.
- Confirm that VOLUME_3D uses the VOLUME_3D viewport type.
- Confirm that every viewport receives cornerstoneStreamingImageVolume:private-local-volume through setVolumesForViewports().
✔️ Awesome, I've got everything!
- Save src/main.js.
- Keep the Vite development server running in the terminal from earlier.
ⓧ I'd like to double check the full code
- Compare your complete src/main.js with this reference file:
import "./style.css";
import dicomParser from "dicom-parser";
import dicomImageLoader, {
init as dicomImageLoaderInit,
} from "@cornerstonejs/dicom-image-loader";
import {
CONSTANTS,
Enums,
RenderingEngine,
imageLoader,
init as coreInit,
setVolumesForViewports,
volumeLoader,
} from "@cornerstonejs/core";
import {
Enums as ToolEnums,
LengthTool,
StackScrollTool,
ToolGroupManager,
TrackballRotateTool,
WindowLevelTool,
addTool,
init as toolsInit,
} from "@cornerstonejs/tools";
const RENDERING_ENGINE_ID = "private-dicom-rendering-engine";
const VOLUME_ID = "cornerstoneStreamingImageVolume:private-local-volume";
const MPR_VIEWPORT_IDS = ["AXIAL", "SAGITTAL", "CORONAL"];
const VOLUME_VIEWPORT_ID = "VOLUME_3D";
const ALL_VIEWPORT_IDS = [...MPR_VIEWPORT_IDS, VOLUME_VIEWPORT_ID];
const folderInput = document.getElementById("folder-input");
const status = document.getElementById("status");
const windowButton = document.getElementById("window-button");
const rulerButton = document.getElementById("ruler-button");
const presetSelect = document.getElementById("preset-select");
let renderingEngine;
let mprToolGroup;
let volumeReady = false;
function setStatus(message) {
status.textContent = message;
}
function createViewportInputs() {
return [
{
viewportId: "AXIAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("axial"),
defaultOptions: {
orientation: Enums.OrientationAxis.AXIAL,
background: [0, 0, 0],
},
},
{
viewportId: "SAGITTAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("sagittal"),
defaultOptions: {
orientation: Enums.OrientationAxis.SAGITTAL,
background: [0, 0, 0],
},
},
{
viewportId: "CORONAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("coronal"),
defaultOptions: {
orientation: Enums.OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
{
viewportId: VOLUME_VIEWPORT_ID,
type: Enums.ViewportType.VOLUME_3D,
element: document.getElementById("volume-3d"),
defaultOptions: {
orientation: Enums.OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
];
}
function configureToolGroups() {
const { MouseBindings } = ToolEnums;
mprToolGroup = ToolGroupManager.createToolGroup("MPR_TOOL_GROUP");
mprToolGroup.addTool(WindowLevelTool.toolName);
mprToolGroup.addTool(LengthTool.toolName, { volumeId: VOLUME_ID });
mprToolGroup.addTool(StackScrollTool.toolName);
for (const viewportId of MPR_VIEWPORT_IDS) {
mprToolGroup.addViewport(viewportId, RENDERING_ENGINE_ID);
}
mprToolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
mprToolGroup.setToolPassive(LengthTool.toolName);
mprToolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Wheel }],
});
const volumeToolGroup = ToolGroupManager.createToolGroup("VOLUME_TOOL_GROUP");
volumeToolGroup.addTool(TrackballRotateTool.toolName);
volumeToolGroup.addViewport(VOLUME_VIEWPORT_ID, RENDERING_ENGINE_ID);
volumeToolGroup.setToolActive(TrackballRotateTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
}
function populatePresetSelect() {
for (const preset of CONSTANTS.VIEWPORT_PRESETS) {
const option = document.createElement("option");
option.value = preset.name;
option.textContent = preset.name;
presetSelect.appendChild(option);
}
const names = CONSTANTS.VIEWPORT_PRESETS.map((preset) => preset.name);
presetSelect.value = names.includes("CT-Bone") ? "CT-Bone" : names[0];
}
async function initializeViewer() {
await dicomImageLoaderInit();
await coreInit();
await toolsInit();
addTool(WindowLevelTool);
addTool(LengthTool);
addTool(StackScrollTool);
addTool(TrackballRotateTool);
renderingEngine = new RenderingEngine(RENDERING_ENGINE_ID);
renderingEngine.setViewports(createViewportInputs());
configureToolGroups();
populatePresetSelect();
setStatus("Ready. Choose one DICOM folder.");
}
async function parseCandidate(file) {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const dataSet = dicomParser.parseDicom(bytes, { untilTag: "x7fe00010" });
const seriesUid = dataSet.string("x0020000e");
const rows = dataSet.uint16("x00280010");
const columns = dataSet.uint16("x00280011");
if (!seriesUid || !rows || !columns) {
return null;
}
const numberOfFrames = dataSet.intString("x00280008") ?? 1;
const pixelSpacing = [
dataSet.floatString("x00280030", 0),
dataSet.floatString("x00280030", 1),
].filter(Number.isFinite);
return {
file,
seriesUid,
frameOfReferenceUid: dataSet.string("x00200052") ?? "",
orientation: dataSet.string("x00200037") ?? "",
instanceNumber: dataSet.intString("x00200013") ?? 0,
modality: dataSet.string("x00080060") ?? "Not reported",
rows,
columns,
numberOfFrames,
pixelSpacing,
sliceThickness: dataSet.floatString("x00180050"),
};
} catch {
return null;
}
}
async function inspectFiles(files) {
const candidates = [];
for (let index = 0; index < files.length; index += 1) {
const candidate = await parseCandidate(files[index]);
if (candidate) {
candidates.push(candidate);
}
if ((index + 1) % 25 === 0 || index + 1 === files.length) {
setStatus(`Inspecting local headers: ${index + 1} of ${files.length}`);
}
}
return candidates;
}
function chooseLargestCompatibleSeries(candidates) {
const groups = new Map();
for (const candidate of candidates) {
if (candidate.numberOfFrames !== 1) {
continue;
}
const key = [
candidate.seriesUid,
candidate.frameOfReferenceUid,
candidate.orientation,
`${candidate.rows}x${candidate.columns}`,
].join("|");
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(candidate);
}
const selected = [...groups.values()].sort((a, b) => b.length - a.length)[0];
if (!selected || selected.length < 2) {
throw new Error(
"No compatible multi-slice single-frame series was found. Enhanced multi-frame objects are outside this experimental path."
);
}
selected.sort(
(a, b) =>
a.instanceNumber - b.instanceNumber || a.file.name.localeCompare(b.file.name)
);
return selected;
}
function updateStats(series) {
const first = series[0];
const spacing =
first.pixelSpacing.length === 2
? `${first.pixelSpacing.map((value) => value.toFixed(3)).join(" × ")} mm`
: "Not reported";
const thickness = Number.isFinite(first.sliceThickness)
? `${first.sliceThickness.toFixed(3)} mm`
: "Not reported";
document.getElementById("stat-modality").textContent = first.modality;
document.getElementById("stat-slices").textContent = String(series.length);
document.getElementById("stat-matrix").textContent = `${first.rows} × ${first.columns}`;
document.getElementById("stat-spacing").textContent = spacing;
document.getElementById("stat-thickness").textContent = thickness;
}
async function loadLocalSeries(files) {
if (volumeReady) {
throw new Error("Reload the page before choosing a different folder.");
}
folderInput.disabled = true;
setStatus(`Checking ${files.length} local files...`);
const candidates = await inspectFiles(files);
const selectedSeries = chooseLargestCompatibleSeries(candidates);
setStatus(`Preloading ${selectedSeries.length} slices into browser memory...`);
const imageIds = selectedSeries.map((candidate) =>
dicomImageLoader.wadouri.fileManager.add(candidate.file)
);
await Promise.all(imageLoader.loadAndCacheImages(imageIds));
setStatus("Creating the experimental local volume...");
const volume = await volumeLoader.createAndCacheVolume(VOLUME_ID, { imageIds });
volume.load();
await setVolumesForViewports(
renderingEngine,
[{ volumeId: VOLUME_ID }],
ALL_VIEWPORT_IDS
);
const volumeViewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
volumeViewport.setProperties({ preset: presetSelect.value });
renderingEngine.renderViewports(ALL_VIEWPORT_IDS);
updateStats(selectedSeries);
volumeReady = true;
windowButton.disabled = false;
rulerButton.disabled = false;
presetSelect.disabled = false;
setStatus(`Loaded ${selectedSeries.length} slices. No scan data was uploaded by this app.`);
}
function activateMprTool(toolClass, activeButton) {
const { MouseBindings } = ToolEnums;
for (const tool of [WindowLevelTool, LengthTool]) {
mprToolGroup.setToolPassive(tool.toolName, { removeAllBindings: true });
}
mprToolGroup.setToolActive(toolClass.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
windowButton.classList.toggle("active", activeButton === windowButton);
rulerButton.classList.toggle("active", activeButton === rulerButton);
}
folderInput.addEventListener("change", async (event) => {
const files = [...event.target.files];
if (files.length === 0) {
return;
}
try {
await loadLocalSeries(files);
} catch (error) {
setStatus(`Load stopped: ${error instanceof Error ? error.message : "Unknown error"}`);
}
});
windowButton.addEventListener("click", () => {
activateMprTool(WindowLevelTool, windowButton);
});
rulerButton.addEventListener("click", () => {
activateMprTool(LengthTool, rulerButton);
});
presetSelect.addEventListener("change", () => {
if (!volumeReady) {
return;
}
const viewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
viewport.setProperties({ preset: presetSelect.value });
viewport.render();
});
initializeViewer().catch((error) => {
setStatus(`Initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`);
});
How is the full file organized?
- Initialization creates the rendering engine and four viewports.
- Header inspection builds compatible groups without displaying identifying values.
- Volume loading preloads the selected image IDs before creating the shared volume.
- The remaining event handlers connect the existing page controls to the initialized viewer.
- Correct any differences in src/main.js.
- Save src/main.js.
Load the series in all four views
The final check keeps the existing privacy boundary intact. Safari receives the folder through your explicit browser selection while Claude Code receives no scan files.
Keep the scan inside Safari
This check touches protected medical files. Select the folder only through the browser picker.
Keep filenames, metadata values, screenshots, and loader output out of Claude Code. A generic failure category is enough for safe troubleshooting.
Before you choose the folder, do you expect one compatible series to populate all four panels from the same shared volume?
- Return to the Safari tab from earlier.
- Reload the local viewer page.
- Click Load DICOM folder.
- Select Private-DICOM through the Safari folder picker.
A large series can make the preload feel slow. The changing status text shows that local header inspection and browser caching are still progressing.
✔️ All four panels are populated
You should see axial, sagittal, coronal, and 3D panels populated from the same selected series.
That is the shared local volume working. The scan stayed in browser memory while every viewport received the same volume.
ⓧ The experimental volume stopped
The status should show a generic load-stopped category. Unusual exports, enhanced multi-frame objects, missing geometry, unsupported compression, or limited GPU memory can prevent this experimental path from completing.
- Record only the generic failure category.
- Keep scan values and loader output out of Claude Code.
- Stop the local-volume experiment without weakening the folder permissions.
Need help with the failure category?
Reason from the generic category and the application source only. Help me understand a generic experimental DICOM volume-loading failure without requesting scan files, filenames, metadata values, screenshots, identifiers, or loader output.
You have tested the four-view volume path without handing scan data to Claude Code. Next, you'll add practical inspection controls and a safe geometry summary.
Add Inspection Tools and Safe Statistics
The previous step turned your local series into axial, sagittal, coronal, and experimental 3D views. Your Cornerstone3D viewer now shows the scan from several angles inside Safari.
Images alone are hard to inspect. This step adds practical controls for navigating each view. It also displays useful geometry without exposing identifying metadata.
In this step, get ready to:
- Assign inspection tools to the MPR viewports.
- Connect the ruler controls and 3D presets.
- Display a non-identifying scan summary.
Register the inspection tools
A tool group assigns mouse inputs to specific viewports. Separate groups let the same primary-drag gesture adjust an MPR image or rotate the 3D rendering.
- In src/main.js, replace the existing import section with this completed section:
import "./style.css";
import dicomParser from "dicom-parser";
import dicomImageLoader, {
init as dicomImageLoaderInit,
} from "@cornerstonejs/dicom-image-loader";
import {
CONSTANTS,
Enums,
RenderingEngine,
imageLoader,
init as coreInit,
setVolumesForViewports,
volumeLoader,
} from "@cornerstonejs/core";
import {
Enums as ToolEnums,
LengthTool,
StackScrollTool,
ToolGroupManager,
TrackballRotateTool,
WindowLevelTool,
addTool,
init as toolsInit,
} from "@cornerstonejs/tools";
What do these imports provide?
- The tools package provides windowing, measurement, scrolling, rotation, and tool-group management.
- The CONSTANTS export provides the available 3D rendering presets.
- The ToolEnums name keeps mouse bindings separate from the viewport enums stored in Enums.
- Find the top-level state declarations below the element references.
- Update that state block to match this code:
let renderingEngine;
let mprToolGroup;
let volumeReady = false;
What does this state track?
The new mprToolGroup reference stores the group shared by the three MPR viewports. Later controls use this reference to switch the primary-drag tool.
- Save src/main.js.
- Return to the Safari tab from earlier.
- Refresh the page.
The status reaches Ready. Choose one DICOM folder.. This confirms the expanded imports load without stopping initialization.
Does initialization stop?
Compare the package names with the completed import block. Check every brace around the two grouped imports.
Help me check the tool imports without using private scan data.
- Add this function below createViewportInputs():
function configureToolGroups() {
const { MouseBindings } = ToolEnums;
mprToolGroup = ToolGroupManager.createToolGroup("MPR_TOOL_GROUP");
mprToolGroup.addTool(WindowLevelTool.toolName);
mprToolGroup.addTool(LengthTool.toolName, { volumeId: VOLUME_ID });
mprToolGroup.addTool(StackScrollTool.toolName);
for (const viewportId of MPR_VIEWPORT_IDS) {
mprToolGroup.addViewport(viewportId, RENDERING_ENGINE_ID);
}
mprToolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
mprToolGroup.setToolPassive(LengthTool.toolName);
mprToolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Wheel }],
});
const volumeToolGroup = ToolGroupManager.createToolGroup("VOLUME_TOOL_GROUP");
volumeToolGroup.addTool(TrackballRotateTool.toolName);
volumeToolGroup.addViewport(VOLUME_VIEWPORT_ID, RENDERING_ENGINE_ID);
volumeToolGroup.setToolActive(TrackballRotateTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
}
How are the gestures divided?
- The MPR_TOOL_GROUP owns the axial, sagittal, and coronal viewports.
- Primary drag starts with the window and level tool.
- The mouse wheel stays assigned to slice scrolling.
- The VOLUME_TOOL_GROUP gives primary-drag rotation to the 3D viewport.
- Save src/main.js.
- Refresh Safari.
The viewer reaches its ready state again. This confirms the tool-group function is valid before initialization starts using it.
Does the page fail to become ready?
Confirm that configureToolGroups() sits outside every other function. Confirm that mprToolGroup is declared at the top level.
Help me find a placement or syntax problem in the tool groups.
- Add this function below configureToolGroups():
function populatePresetSelect() {
for (const preset of CONSTANTS.VIEWPORT_PRESETS) {
const option = document.createElement("option");
option.value = preset.name;
option.textContent = preset.name;
presetSelect.appendChild(option);
}
const names = CONSTANTS.VIEWPORT_PRESETS.map((preset) => preset.name);
presetSelect.value = names.includes("CT-Bone") ? "CT-Bone" : names[0];
}
How is the preset selected?
The selector receives one option for every preset in CONSTANTS.VIEWPORT_PRESETS. It begins with CT-Bone when that preset is available.
- Find the existing initializeViewer() function.
- Replace the entire function with this completed version:
async function initializeViewer() {
await dicomImageLoaderInit();
await coreInit();
await toolsInit();
addTool(WindowLevelTool);
addTool(LengthTool);
addTool(StackScrollTool);
addTool(TrackballRotateTool);
renderingEngine = new RenderingEngine(RENDERING_ENGINE_ID);
renderingEngine.setViewports(createViewportInputs());
configureToolGroups();
populatePresetSelect();
setStatus("Ready. Choose one DICOM folder.");
}
What happens during initialization?
- Each tool is registered before a tool group uses it.
- The rendering engine creates the viewports before assigning their interactions.
- The preset selector is populated once when the page starts.
- Save src/main.js.
- Refresh Safari.
You can now see a value inside the disabled 3D preset selector. The ready status confirms that the rendering engine and tool groups initialized together.
Is the preset selector empty?
Confirm that populatePresetSelect() appears once inside initializeViewer(). Check that both function names use identical spelling.
Help me troubleshoot an empty preset selector.
Connect the controls and safe statistics
A privacy-aware summary can describe the scan's geometry without revealing the person behind it. This viewer displays modality, slice count, matrix size, pixel spacing, and slice thickness.
- Add this function below chooseLargestCompatibleSeries():
function updateStats(series) {
const first = series[0];
const spacing =
first.pixelSpacing.length === 2
? `${first.pixelSpacing.map((value) => value.toFixed(3)).join(" × ")} mm`
: "Not reported";
const thickness = Number.isFinite(first.sliceThickness)
? `${first.sliceThickness.toFixed(3)} mm`
: "Not reported";
document.getElementById("stat-modality").textContent = first.modality;
document.getElementById("stat-slices").textContent = String(series.length);
document.getElementById("stat-matrix").textContent = `${first.rows} × ${first.columns}`;
document.getElementById("stat-spacing").textContent = spacing;
document.getElementById("stat-thickness").textContent = thickness;
}
Why are these fields displayed?
These fields describe image type and geometry. They support inspection without inserting patient names, IDs, dates, UIDs, filenames, or free-text descriptions into the interface.
Missing spacing or thickness becomes Not reported. The summary stays accurate when an export omits a geometry value.
- In loadLocalSeries(), find the section after setVolumesForViewports().
- Replace the remaining end of the function with this code:
const volumeViewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
volumeViewport.setProperties({ preset: presetSelect.value });
renderingEngine.renderViewports(ALL_VIEWPORT_IDS);
updateStats(selectedSeries);
volumeReady = true;
windowButton.disabled = false;
rulerButton.disabled = false;
presetSelect.disabled = false;
setStatus(`Loaded ${selectedSeries.length} slices. No scan data was uploaded by this app.`);
}
What happens after loading?
The selected preset is applied before the four viewports render. A successful load updates the safe summary and enables each inspection control.
The final status exposes only the number of selected slices. It confirms that this app did not upload the scan data.
- Save src/main.js.
- Refresh Safari.
- Choose Private-DICOM through the browser folder picker.
The four views render again. The buttons and preset selector become available. The scan summary replaces its placeholders with permitted values.
Do the controls stay disabled?
Confirm that the three control assignments appear after the volume is assigned to every viewport. A failed volume load correctly leaves the controls unavailable.
Help me trace the control-enabling logic without exposing private data.
The MPR buttons share the primary mouse binding. Selecting either button makes its tool active while wheel scrolling remains available.
- Add this function below loadLocalSeries():
function activateMprTool(toolClass, activeButton) {
const { MouseBindings } = ToolEnums;
for (const tool of [WindowLevelTool, LengthTool]) {
mprToolGroup.setToolPassive(tool.toolName, { removeAllBindings: true });
}
mprToolGroup.setToolActive(toolClass.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
windowButton.classList.toggle("active", activeButton === windowButton);
rulerButton.classList.toggle("active", activeButton === rulerButton);
}
How does tool switching work?
The function removes the previous primary-drag bindings from both selectable tools. It activates the chosen tool next.
The button classes show which interaction is active. The wheel binding remains attached to the separate scroll tool.
- Add these listeners below the existing folder-input listener:
windowButton.addEventListener("click", () => {
activateMprTool(WindowLevelTool, windowButton);
});
rulerButton.addEventListener("click", () => {
activateMprTool(LengthTool, rulerButton);
});
presetSelect.addEventListener("change", () => {
if (!volumeReady) {
return;
}
const viewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
viewport.setProperties({ preset: presetSelect.value });
viewport.render();
});
What do the controls trigger?
- The Window / Level button assigns primary drag to brightness and contrast adjustment.
- The Ruler button assigns primary drag to distance measurement.
- A preset change updates the 3D viewport.
- Save src/main.js.
- Refresh Safari.
- Choose Private-DICOM through the browser folder picker.
The selected series loads with active controls. You now have the complete interaction layer ready for a hands-on check.
Does a button fail to switch tools?
Confirm that each listener passes the matching tool class and button reference. Confirm that activateMprTool() sits above the listeners.
Help me troubleshoot the MPR buttons without using private scan data.
Verify every inspection control
Before you test the finished viewer, which gestures do you expect to act differently between an MPR panel and the 3D panel?
- Place the pointer over an MPR panel.
- Move the mouse wheel to change slices.
- Drag inside the MPR panel while Window / Level is active.
- Click Ruler.
- Drag between two points in an MPR panel.
- Check the distance annotation on the image.
The MPR view now supports slice scrolling, brightness adjustment, contrast adjustment, and millimetre measurements. Each gesture operates through the shared MPR tool group.
- Drag inside the 3D panel.
- Choose another option from the 3D preset selector.
- Review the scan summary for unexpected identity fields.
- Inspect the console through Safari's developer tools for scan metadata output.
The 3D rendering rotates with primary drag. Its appearance changes when you select another preset.
The summary shows only modality, selected slices, matrix, pixel spacing, and slice thickness. The interface and console contain no patient names, patient IDs, dates, UIDs, filenames, or free-text descriptions.
Is an interaction missing?
- Confirm that the folder finished loading before testing the enabled controls.
- Keep the pointer over an MPR panel when testing scrolling or measurement.
- Keep the pointer over the 3D panel when testing rotation.
Help me troubleshoot a missing interaction without using private scan data.
✔️ Awesome, I've got everything!
Great work. Your saved src/main.js now contains the complete browser-only viewer logic.
ⓧ I'd like to double check the full code
import "./style.css";
import dicomParser from "dicom-parser";
import dicomImageLoader, {
init as dicomImageLoaderInit,
} from "@cornerstonejs/dicom-image-loader";
import {
CONSTANTS,
Enums,
RenderingEngine,
imageLoader,
init as coreInit,
setVolumesForViewports,
volumeLoader,
} from "@cornerstonejs/core";
import {
Enums as ToolEnums,
LengthTool,
StackScrollTool,
ToolGroupManager,
TrackballRotateTool,
WindowLevelTool,
addTool,
init as toolsInit,
} from "@cornerstonejs/tools";
const RENDERING_ENGINE_ID = "private-dicom-rendering-engine";
const VOLUME_ID = "cornerstoneStreamingImageVolume:private-local-volume";
const MPR_VIEWPORT_IDS = ["AXIAL", "SAGITTAL", "CORONAL"];
const VOLUME_VIEWPORT_ID = "VOLUME_3D";
const ALL_VIEWPORT_IDS = [...MPR_VIEWPORT_IDS, VOLUME_VIEWPORT_ID];
const folderInput = document.getElementById("folder-input");
const status = document.getElementById("status");
const windowButton = document.getElementById("window-button");
const rulerButton = document.getElementById("ruler-button");
const presetSelect = document.getElementById("preset-select");
let renderingEngine;
let mprToolGroup;
let volumeReady = false;
function setStatus(message) {
status.textContent = message;
}
function createViewportInputs() {
return [
{
viewportId: "AXIAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("axial"),
defaultOptions: {
orientation: Enums.OrientationAxis.AXIAL,
background: [0, 0, 0],
},
},
{
viewportId: "SAGITTAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("sagittal"),
defaultOptions: {
orientation: Enums.OrientationAxis.SAGITTAL,
background: [0, 0, 0],
},
},
{
viewportId: "CORONAL",
type: Enums.ViewportType.ORTHOGRAPHIC,
element: document.getElementById("coronal"),
defaultOptions: {
orientation: Enums.OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
{
viewportId: VOLUME_VIEWPORT_ID,
type: Enums.ViewportType.VOLUME_3D,
element: document.getElementById("volume-3d"),
defaultOptions: {
orientation: Enums.OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
];
}
function configureToolGroups() {
const { MouseBindings } = ToolEnums;
mprToolGroup = ToolGroupManager.createToolGroup("MPR_TOOL_GROUP");
mprToolGroup.addTool(WindowLevelTool.toolName);
mprToolGroup.addTool(LengthTool.toolName, { volumeId: VOLUME_ID });
mprToolGroup.addTool(StackScrollTool.toolName);
for (const viewportId of MPR_VIEWPORT_IDS) {
mprToolGroup.addViewport(viewportId, RENDERING_ENGINE_ID);
}
mprToolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
mprToolGroup.setToolPassive(LengthTool.toolName);
mprToolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Wheel }],
});
const volumeToolGroup = ToolGroupManager.createToolGroup("VOLUME_TOOL_GROUP");
volumeToolGroup.addTool(TrackballRotateTool.toolName);
volumeToolGroup.addViewport(VOLUME_VIEWPORT_ID, RENDERING_ENGINE_ID);
volumeToolGroup.setToolActive(TrackballRotateTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
}
function populatePresetSelect() {
for (const preset of CONSTANTS.VIEWPORT_PRESETS) {
const option = document.createElement("option");
option.value = preset.name;
option.textContent = preset.name;
presetSelect.appendChild(option);
}
const names = CONSTANTS.VIEWPORT_PRESETS.map((preset) => preset.name);
presetSelect.value = names.includes("CT-Bone") ? "CT-Bone" : names[0];
}
async function initializeViewer() {
await dicomImageLoaderInit();
await coreInit();
await toolsInit();
addTool(WindowLevelTool);
addTool(LengthTool);
addTool(StackScrollTool);
addTool(TrackballRotateTool);
renderingEngine = new RenderingEngine(RENDERING_ENGINE_ID);
renderingEngine.setViewports(createViewportInputs());
configureToolGroups();
populatePresetSelect();
setStatus("Ready. Choose one DICOM folder.");
}
async function parseCandidate(file) {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const dataSet = dicomParser.parseDicom(bytes, { untilTag: "x7fe00010" });
const seriesUid = dataSet.string("x0020000e");
const rows = dataSet.uint16("x00280010");
const columns = dataSet.uint16("x00280011");
if (!seriesUid || !rows || !columns) {
return null;
}
const numberOfFrames = dataSet.intString("x00280008") ?? 1;
const pixelSpacing = [
dataSet.floatString("x00280030", 0),
dataSet.floatString("x00280030", 1),
].filter(Number.isFinite);
return {
file,
seriesUid,
frameOfReferenceUid: dataSet.string("x00200052") ?? "",
orientation: dataSet.string("x00200037") ?? "",
instanceNumber: dataSet.intString("x00200013") ?? 0,
modality: dataSet.string("x00080060") ?? "Not reported",
rows,
columns,
numberOfFrames,
pixelSpacing,
sliceThickness: dataSet.floatString("x00180050"),
};
} catch {
return null;
}
}
async function inspectFiles(files) {
const candidates = [];
for (let index = 0; index < files.length; index += 1) {
const candidate = await parseCandidate(files[index]);
if (candidate) {
candidates.push(candidate);
}
if ((index + 1) % 25 === 0 || index + 1 === files.length) {
setStatus(`Inspecting local headers: ${index + 1} of ${files.length}`);
}
}
return candidates;
}
function chooseLargestCompatibleSeries(candidates) {
const groups = new Map();
for (const candidate of candidates) {
if (candidate.numberOfFrames !== 1) {
continue;
}
const key = [
candidate.seriesUid,
candidate.frameOfReferenceUid,
candidate.orientation,
`${candidate.rows}x${candidate.columns}`,
].join("|");
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(candidate);
}
const selected = [...groups.values()].sort((a, b) => b.length - a.length)[0];
if (!selected || selected.length < 2) {
throw new Error(
"No compatible multi-slice single-frame series was found. Enhanced multi-frame objects are outside this experimental path."
);
}
selected.sort(
(a, b) =>
a.instanceNumber - b.instanceNumber || a.file.name.localeCompare(b.file.name)
);
return selected;
}
function updateStats(series) {
const first = series[0];
const spacing =
first.pixelSpacing.length === 2
? `${first.pixelSpacing.map((value) => value.toFixed(3)).join(" × ")} mm`
: "Not reported";
const thickness = Number.isFinite(first.sliceThickness)
? `${first.sliceThickness.toFixed(3)} mm`
: "Not reported";
document.getElementById("stat-modality").textContent = first.modality;
document.getElementById("stat-slices").textContent = String(series.length);
document.getElementById("stat-matrix").textContent = `${first.rows} × ${first.columns}`;
document.getElementById("stat-spacing").textContent = spacing;
document.getElementById("stat-thickness").textContent = thickness;
}
async function loadLocalSeries(files) {
if (volumeReady) {
throw new Error("Reload the page before choosing a different folder.");
}
folderInput.disabled = true;
setStatus(`Checking ${files.length} local files...`);
const candidates = await inspectFiles(files);
const selectedSeries = chooseLargestCompatibleSeries(candidates);
setStatus(`Preloading ${selectedSeries.length} slices into browser memory...`);
const imageIds = selectedSeries.map((candidate) =>
dicomImageLoader.wadouri.fileManager.add(candidate.file)
);
await Promise.all(imageLoader.loadAndCacheImages(imageIds));
setStatus("Creating the experimental local volume...");
const volume = await volumeLoader.createAndCacheVolume(VOLUME_ID, { imageIds });
volume.load();
await setVolumesForViewports(
renderingEngine,
[{ volumeId: VOLUME_ID }],
ALL_VIEWPORT_IDS
);
const volumeViewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
volumeViewport.setProperties({ preset: presetSelect.value });
renderingEngine.renderViewports(ALL_VIEWPORT_IDS);
updateStats(selectedSeries);
volumeReady = true;
windowButton.disabled = false;
rulerButton.disabled = false;
presetSelect.disabled = false;
setStatus(`Loaded ${selectedSeries.length} slices. No scan data was uploaded by this app.`);
}
function activateMprTool(toolClass, activeButton) {
const { MouseBindings } = ToolEnums;
for (const tool of [WindowLevelTool, LengthTool]) {
mprToolGroup.setToolPassive(tool.toolName, { removeAllBindings: true });
}
mprToolGroup.setToolActive(toolClass.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
windowButton.classList.toggle("active", activeButton === windowButton);
rulerButton.classList.toggle("active", activeButton === rulerButton);
}
folderInput.addEventListener("change", async (event) => {
const files = [...event.target.files];
if (files.length === 0) {
return;
}
try {
await loadLocalSeries(files);
} catch (error) {
setStatus(`Load stopped: ${error instanceof Error ? error.message : "Unknown error"}`);
}
});
windowButton.addEventListener("click", () => {
activateMprTool(WindowLevelTool, windowButton);
});
rulerButton.addEventListener("click", () => {
activateMprTool(LengthTool, rulerButton);
});
presetSelect.addEventListener("change", () => {
if (!volumeReady) {
return;
}
const viewport = renderingEngine.getViewport(VOLUME_VIEWPORT_ID);
viewport.setProperties({ preset: presetSelect.value });
viewport.render();
});
initializeViewer().catch((error) => {
setStatus(`Initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`);
});
You have turned the four-view rendering demo into an interactive local explorer. Next, you will create a separate header-scrubbed copy and audit it outside Claude Code.
Create and Audit a Scrubbed Copy
Your local viewer now opens the original scan without displaying identifying fields. However, the original DICOM headers may still identify a person.
This step uses best-effort de-identification to create a separate copy. You will audit that copy without printing the identifier values being compared.
In this step, get ready to:
- Create a separate header-scrubbed copy outside the project workspace.
- Audit selected identifier fields without printing their values.
- Load the scrubbed copy through Safari's folder picker.
Install the scrubber and create the copy
The deid command creates the scrubbed copy. The audit uses pydicom to read headers without loading pixel data.
Why use a separate Terminal?
The deny rules in Claude Code protect its built-in file reads. Those rules do not constrain every possible shell subprocess.
A separate macOS Terminal keeps scan processing outside the Claude Code session. This preserves the workspace boundary you established earlier.
These commands read the protected original folder only from your separate Terminal. They write a new copy to ~/Documents/Deidentified-DICOM without changing the original files.
- Press Cmd+Space to open macOS search.
- Type Terminal into the search bar.
- Press Enter to open a separate Terminal window.
- Move this Terminal to ~/Projects/private-dicom-explorer by running this command:
cd ~/Projects/private-dicom-explorer
What does this command do?
The command moves this separate Terminal into your existing project directory. It does not give Claude Code access to either protected scan folder.
- Install deid at 0.4.13 with pydicom at 3.0.2 by running this command:
python3 -m pip install deid==0.4.13 pydicom==3.0.2
What does this command install?
The command installs the pinned de-identification tool. It also installs the DICOM reader required by tools/audit_deid.py.
The exact version pins keep the command-line workflow aligned with the audit script used in this project.
- Check the installed deid version by running this command:
deid --version
What does this check show?
The command prints the installed deid version. Use the matching tab below to confirm or repair the installation.
✔️ I see the pinned version
You should see 0.4.13. The deid command is ready to create the scrubbed copy.
ⓧ I see an older version
Your Terminal is resolving an older deid installation. Reinstall both pinned packages in the active Python environment.
- Replace the older installation by running this command:
python3 -m pip install deid==0.4.13 pydicom==3.0.2
Why repeat the installation?
The explicit pins direct Python to install the project versions. Run the version check again after the installation finishes.
ⓧ Command not found
The deid command is unavailable in this Terminal environment. Install both pinned packages with the same Python executable used by the audit.
- Install the required packages by running this command:
python3 -m pip install deid==0.4.13 pydicom==3.0.2
Why use Python to install it?
Running pip through python3 ties the installation to that Python environment. Open a fresh Terminal window if the command remains unavailable after installation.
The next command can overwrite files already present in the scrubbed output folder. Your original scan folder remains unchanged.
- Create the separate header-scrubbed copy by running this command:
deid --overwrite --outfolder "$HOME/Documents/Deidentified-DICOM" identifiers --action all --input "$HOME/Documents/Private-DICOM"
What does this command do?
- The --input path identifies the protected original folder.
- The --outfolder path keeps the scrubbed copy separate from the original.
- The identifiers action applies deid's identifier replacement workflow.
- The --overwrite flag permits replacement of existing output files.
- Wait for the command to return to a fresh Terminal prompt.
The separate output folder now contains the best-effort scrubbed copies. The audit provides the gate for deciding whether you can continue.
Did the scrub command stop?
Confirm that the original folder remains at ~/Documents/Private-DICOM. Confirm that you ran the command in the separate macOS Terminal.
Keep any troubleshooting description generic. Do not copy filenames, headers, identifiers, or scan output into Claude Code.
Help me diagnose the scrub command without sharing scan data.
Create and run the privacy audit
A scrub command finishing successfully does not prove that the checked original values were replaced. The audit compares selected common identifier values in memory and prints aggregate counts only.
- Switch back to the privacy-guarded Claude Code workspace from earlier.
- Create tools/audit_deid.py with the exact script shown in the full-code tab below.
- Save tools/audit_deid.py without running it through Claude Code.
✔️ Awesome, I've got everything!
Great. Double-check that tools/audit_deid.py is saved inside ~/Projects/private-dicom-explorer.
ⓧ I'd like to double check the full code
from pathlib import Path
import sys
from pydicom import dcmread
COMMON_IDENTIFYING_FIELDS = (
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientAddress",
"PatientTelephoneNumbers",
"OtherPatientIDs",
"InstitutionName",
"InstitutionAddress",
"ReferringPhysicianName",
"AccessionNumber",
)
def normalized_value(dataset, keyword):
return str(getattr(dataset, keyword, "")).strip()
def read_headers(root):
datasets = []
skipped = 0
for path in root.rglob("*"):
if not path.is_file():
continue
try:
dataset = dcmread(path, stop_before_pixels=True, force=True)
except Exception:
skipped += 1
continue
if "SOPClassUID" in dataset:
datasets.append(dataset)
return datasets, skipped
def main():
if len(sys.argv) != 3:
print(
"Usage: python3 tools/audit_deid.py "
"<original-folder> <deidentified-folder>"
)
return 2
original_root = Path(sys.argv[1]).expanduser()
cleaned_root = Path(sys.argv[2]).expanduser()
original_datasets, original_skipped = read_headers(original_root)
cleaned_datasets, cleaned_skipped = read_headers(cleaned_root)
original_values = {
keyword: {
value
for dataset in original_datasets
if (value := normalized_value(dataset, keyword))
}
for keyword in COMMON_IDENTIFYING_FIELDS
}
reused_files = 0
identity_removed_yes = 0
for dataset in cleaned_datasets:
reuses_original = any(
(value := normalized_value(dataset, keyword))
and value in original_values[keyword]
for keyword in COMMON_IDENTIFYING_FIELDS
)
if reuses_original:
reused_files += 1
if normalized_value(dataset, "PatientIdentityRemoved").upper() == "YES":
identity_removed_yes += 1
print(f"Original DICOM files checked: {len(original_datasets)}")
print(f"Cleaned DICOM files checked: {len(cleaned_datasets)}")
print(f"Cleaned files reusing checked original identifier values: {reused_files}")
print(f"Cleaned files declaring PatientIdentityRemoved=YES: {identity_removed_yes}")
print(f"Original non-DICOM or unreadable files skipped: {original_skipped}")
print(f"Cleaned non-DICOM or unreadable files skipped: {cleaned_skipped}")
print("This is a narrow comparison audit, not a certification of de-identification.")
if not original_datasets or not cleaned_datasets or reused_files != 0:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
How does the audit protect values?
- The script uses stop_before_pixels=True so pydicom does not load pixel data for the comparison.
- The COMMON_IDENTIFYING_FIELDS tuple limits the comparison to selected common fields.
- The script keeps encountered values in memory and prints counts only.
- The main() function returns failure if either folder has no readable DICOM files or any checked value is reused.
- Return to the separate macOS Terminal from earlier.
Before you run the audit, do you expect both folders to contain readable DICOM files with no checked original values reused in the scrubbed copy?
- Audit the original folder against the scrubbed folder by running this command:
python3 tools/audit_deid.py "$HOME/Documents/Private-DICOM" "$HOME/Documents/Deidentified-DICOM"
What does this audit check?
The script reads headers from both folders. It compares selected original identifier values against every readable scrubbed file.
Only aggregate counts reach the Terminal. No compared identifier value is printed.
Proceed only when both folder counts are at least one and the reused-value count is zero. That is the critical gate cleared for this narrow comparison.
Did the audit return a failure?
Stop if either readable-file count is zero. Also stop if the reused-value count is above zero.
Do not share the scrubbed output. Keep any troubleshooting limited to the generic count category that failed.
Help me interpret a failed privacy audit without exposing scan data.
Load the scrubbed copy in Safari
The audit checks whether selected header values were reused. The final functional check confirms that the scrubbed copy still contains enough imaging data for the browser viewer.
Before you select the scrubbed folder, do you expect the image pixels and geometry to remain usable after the header workflow?
- Return to the Safari tab running the local viewer.
- Reload the page to clear the previously selected series.
- Click Load DICOM folder in the viewer header.
- Select ~/Documents/Deidentified-DICOM in the folder picker.
- Complete the folder selection without copying filenames or loader output anywhere else.
You should see the scrubbed series populate the axial, sagittal, coronal, and 3D panels. The non-identifying geometry summary should populate beside the viewports.
- Scroll an MPR panel with the mouse wheel.
- Drag inside an MPR panel with Window / Level active.
- Select Ruler and draw a distance.
- Drag inside the 3D panel to rotate the rendering.
The scrubbed copy still supports the viewer's inspection controls. Your original scan remains untouched outside the project workspace.
What has this process proved?
The audit found no reused values among the selected common identifier fields. Safari also confirmed that the resulting copy remains readable by the local viewer.
This remains best-effort de-identification. The audit does not certify burned-in text, filenames, private attributes, uncommon sequences, or complete compliance with a DICOM confidentiality profile.
Secret mission
Prove the Privacy Boundary
Test whether Claude Code is blocked from the protected scan folder while Safari retains access to files you explicitly select. This challenge turns your privacy boundary into visible evidence.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local resources, pause the running tools, or remove the project entirely. The viewer creates no cloud or metered infrastructure, so keeping it only uses storage on your Mac.
Resources you used:
- The application source in ~/Projects/private-dicom-explorer.
- Downloaded Vite dependencies in ~/Projects/private-dicom-explorer/node_modules.
- Original DICOM files in ~/Documents/Private-DICOM.
- The separate best-effort scrubbed copy in ~/Documents/Deidentified-DICOM.
- Local Claude Code settings in ~/Projects/private-dicom-explorer/.claude/settings.json plus its saved project state.
Keep everything running
No action is needed. Choose this if you are still testing the viewer or reviewing the privacy boundary.
- Keep ~/Projects/private-dicom-explorer to retain the application source plus its installed dependencies.
- Keep ~/Documents/Private-DICOM under your normal medical-data protections.
- Keep ~/Documents/Deidentified-DICOM if you still need the best-effort scrubbed copy.
- Keep the existing deny rules active for both scan folders.
What continues running?
The local development server continues only while its Terminal process remains active. The project creates no cloud service that keeps running after you close the app.
Pause - I'll come back to this later
Stop the running development tools while keeping the source files plus both scan folders. This frees the local processes without removing your work.
- Switch back to the Terminal tab running Vite if the development server is still active.
- Press Control-C to stop Vite.
- Switch back to the Terminal tab running Claude Code.
- Close that Terminal tab to end the Claude Code session.
The localhost viewer becomes unavailable after Vite stops. Your project files plus both scan folders remain on disk.
Delete - I don't want to use this again
Remove the local artifacts if you no longer need the viewer. Deletion is permanent, so complete each part only when you are ready to lose that resource.
Stop the running tools:
- Switch back to the Terminal tab running Vite if the development server is active.
- Press Control-C to stop Vite.
- Close the Terminal tab running Claude Code.
- Return to the separate Terminal outside Claude Code.
Remove the downloaded dependencies:
- Remove the dependencies from the local project by running this command:
rm -rf ~/Projects/private-dicom-explorer/node_modules
What does this remove?
This permanently removes the downloaded packages from the project. The application source remains available until you remove the full project folder.
Remove the saved Claude Code project state:
- Ask Claude Code to show the local state associated with this project by running:
claude purge ~/Projects/private-dicom-explorer
What does this command do?
This command targets the saved Claude Code state associated with the project path. It shows that state before removing it.
- Review the displayed project state.
- Approve removal only when the displayed state belongs to ~/Projects/private-dicom-explorer.
Remove the scrubbed copy:
- Delete the separate best-effort scrubbed folder by running:
rm -rf ~/Documents/Deidentified-DICOM
What does this remove?
This permanently deletes the separate scrubbed copy. The original scan folder remains untouched.
Remove the project source:
- Delete the remaining application source plus its local privacy settings by running:
rm -rf ~/Projects/private-dicom-explorer
What does this remove?
This permanently removes the application source. It also removes the audit script plus the project-level Claude Code settings.
Protect the original scan
Deleting the original scan is irreversible. Keep it unless your records policy permits removal under your medical-data obligations.
- Delete the original scan folder only when those obligations permit removal by running:
rm -rf ~/Documents/Private-DICOM
What does this remove?
This permanently deletes the original scan folder from your Mac. Recovery is outside the scope of this project.
Nice Work!
Nice Work!
You made it! Your localhost explorer now loads a user-selected DICOM series in Safari while the original scan stays outside the Claude Code workspace.
You've learned how to:
- Build a local-first DICOM explorer that loads user-selected scan files through the Browser File API. Keep original scans outside the project workspace.
- Render the selected series through MPR plus experimental 3D volume rendering using Cornerstone3D. Inspect the scan with window/level controls. Scroll through slices. Measure distances with a millimetre ruler. Rotate the volume. Switch rendering presets. Review non-identifying geometry statistics.
- Create a separate best-effort header-scrubbed copy outside the workspace. Audit common identifier reuse through count-only results. Explain the remaining limits of de-identification. Define the viewer's boundary as educational software with no diagnostic use.
- Prove the Secret Mission privacy boundary by triggering a denied Claude Code read. Confirm that Safari still renders the user-selected series through browser-granted access.
Ready to quiz yourself?