2025-08-12 13:33:25 +03:00

760 lines
27 KiB
HTML

{% extends "base.html" %}
{% load static %}
{% block title %}DICOM Viewer{% if study %} - {{ study.patient.get_full_name }}{% endif %}{% endblock %}
{% block css %}
<style>
.dicom-viewer {
height: calc(100vh - 200px);
background: #000;
position: relative;
overflow: hidden;
}
.viewer-toolbar {
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.viewer-controls {
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
}
.series-thumbnails {
background: rgba(0, 0, 0, 0.9);
color: white;
width: 200px;
position: absolute;
top: 60px;
bottom: 60px;
right: 0;
z-index: 1000;
overflow-y: auto;
padding: 10px;
}
.thumbnail {
width: 100%;
height: 80px;
background: #333;
margin-bottom: 10px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
}
.thumbnail.active {
border-color: #007bff;
}
.image-container {
position: absolute;
top: 60px;
bottom: 60px;
left: 0;
right: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.dicom-image {
max-width: 100%;
max-height: 100%;
cursor: crosshair;
}
.measurements-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.measurement-line {
stroke: #ff0000;
stroke-width: 2;
fill: none;
}
.measurement-text {
fill: #ff0000;
font-size: 12px;
font-family: Arial, sans-serif;
}
.patient-info {
position: absolute;
top: 70px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
border-radius: 4px;
font-size: 12px;
z-index: 1001;
}
.study-info {
position: absolute;
top: 70px;
right: 220px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
border-radius: 4px;
font-size: 12px;
z-index: 1001;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
z-index: 1002;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex align-items-center mb-3">
<div>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'radiology:dashboard' %}">Radiology</a></li>
{% if study %}
<li class="breadcrumb-item"><a href="{% url 'radiology:study_list' %}">Studies</a></li>
<li class="breadcrumb-item"><a href="{% url 'radiology:study_detail' study.pk %}">{{ study.patient.get_full_name }}</a></li>
{% endif %}
<li class="breadcrumb-item active">DICOM Viewer</li>
</ol>
<h1 class="page-header mb-0">DICOM Viewer</h1>
</div>
<div class="ms-auto">
<button type="button" class="btn btn-outline-secondary me-2" onclick="toggleFullscreen()">
<i class="fas fa-expand me-1"></i>Fullscreen
</button>
{% if study %}
<a href="{% url 'radiology:study_detail' study.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Study
</a>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="dicom-viewer" id="dicomViewer">
<!-- Toolbar -->
<div class="viewer-toolbar">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<div class="btn-group me-3" role="group">
<button type="button" class="btn btn-sm btn-outline-light" id="zoomInBtn" title="Zoom In">
<i class="fas fa-search-plus"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="zoomOutBtn" title="Zoom Out">
<i class="fas fa-search-minus"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="resetZoomBtn" title="Reset Zoom">
<i class="fas fa-expand-arrows-alt"></i>
</button>
</div>
<div class="btn-group me-3" role="group">
<button type="button" class="btn btn-sm btn-outline-light" id="panBtn" title="Pan">
<i class="fas fa-hand-paper"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="measureBtn" title="Measure">
<i class="fas fa-ruler"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="annotateBtn" title="Annotate">
<i class="fas fa-comment"></i>
</button>
</div>
<div class="btn-group me-3" role="group">
<button type="button" class="btn btn-sm btn-outline-light" id="windowLevelBtn" title="Window/Level">
<i class="fas fa-adjust"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="invertBtn" title="Invert">
<i class="fas fa-adjust"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="rotateBtn" title="Rotate">
<i class="fas fa-redo"></i>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-light" id="playBtn" title="Play/Pause">
<i class="fas fa-play"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="prevBtn" title="Previous">
<i class="fas fa-step-backward"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-light" id="nextBtn" title="Next">
<i class="fas fa-step-forward"></i>
</button>
</div>
</div>
<div class="d-flex align-items-center">
<span class="me-3" id="imageInfo">Image 1 of 1</span>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="fas fa-cog me-1"></i>Settings
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="showPreferences()"><i class="fas fa-sliders-h me-2"></i>Preferences</a></li>
<li><a class="dropdown-item" href="#" onclick="showKeyboardShortcuts()"><i class="fas fa-keyboard me-2"></i>Keyboard Shortcuts</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="exportImage()"><i class="fas fa-download me-2"></i>Export Image</a></li>
<li><a class="dropdown-item" href="#" onclick="printImage()"><i class="fas fa-print me-2"></i>Print</a></li>
</ul>
</div>
</div>
</div>
</div>
<!-- Patient Information -->
{% if study %}
<div class="patient-info">
<div><strong>{{ study.patient.get_full_name }}</strong></div>
<div>ID: {{ study.patient.patient_id }}</div>
<div>DOB: {{ study.patient.date_of_birth|date:"M d, Y" }}</div>
<div>Gender: {{ study.patient.get_gender_display }}</div>
</div>
{% endif %}
<!-- Study Information -->
{% if study %}
<div class="study-info">
<div><strong>{{ study.modality }}</strong></div>
<div>{{ study.study_date|date:"M d, Y" }}</div>
<div>{{ study.description|default:"No description" }}</div>
<div>Accession: {{ study.accession_number }}</div>
</div>
{% endif %}
<!-- Loading Spinner -->
<div class="loading-spinner" id="loadingSpinner">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="mt-2">Loading DICOM images...</div>
</div>
<!-- Image Container -->
<div class="image-container" id="imageContainer">
<canvas id="dicomCanvas" class="dicom-image"></canvas>
<!-- Measurements Overlay -->
<svg class="measurements-overlay" id="measurementsOverlay">
<!-- Measurement lines and annotations will be added here -->
</svg>
</div>
<!-- Series Thumbnails -->
<div class="series-thumbnails" id="seriesThumbnails">
<h6 class="mb-3">Series</h6>
{% if series_list %}
{% for series in series_list %}
<div class="thumbnail" data-series-id="{{ series.id }}" onclick="loadSeries('{{ series.id }}')">
<div class="p-2">
<div class="small fw-bold">{{ series.series_number }}</div>
<div class="small">{{ series.description|truncatechars:20 }}</div>
<div class="small text-muted">{{ series.image_count }} images</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted">
<i class="fas fa-images fa-2x mb-2"></i>
<div>No series available</div>
</div>
{% endif %}
</div>
<!-- Controls -->
<div class="viewer-controls">
<div class="row align-items-center">
<div class="col-md-4">
<div class="d-flex align-items-center">
<label class="form-label me-2 mb-0">Slice:</label>
<input type="range" class="form-range me-2" id="sliceSlider" min="1" max="1" value="1">
<span id="sliceInfo">1/1</span>
</div>
</div>
<div class="col-md-4">
<div class="d-flex align-items-center">
<label class="form-label me-2 mb-0">Window:</label>
<input type="range" class="form-range me-2" id="windowSlider" min="1" max="4000" value="400">
<span id="windowValue">400</span>
</div>
</div>
<div class="col-md-4">
<div class="d-flex align-items-center">
<label class="form-label me-2 mb-0">Level:</label>
<input type="range" class="form-range me-2" id="levelSlider" min="-1000" max="1000" value="40">
<span id="levelValue">40</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preferences Modal -->
<div class="modal fade" id="preferencesModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Viewer Preferences</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Default Window Preset</label>
<select class="form-select" id="defaultWindowPreset">
<option value="soft_tissue">Soft Tissue</option>
<option value="lung">Lung</option>
<option value="bone">Bone</option>
<option value="brain">Brain</option>
<option value="liver">Liver</option>
</select>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="autoPlay">
<label class="form-check-label" for="autoPlay">
Auto-play multi-frame series
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="showOverlays">
<label class="form-check-label" for="showOverlays">
Show DICOM overlays
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Interpolation</label>
<select class="form-select" id="interpolation">
<option value="nearest">Nearest Neighbor</option>
<option value="linear">Linear</option>
<option value="cubic">Cubic</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="savePreferences()">Save</button>
</div>
</div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div class="modal fade" id="shortcutsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Keyboard Shortcuts</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Navigation</h6>
<ul class="list-unstyled">
<li><kbd></kbd> <kbd></kbd> Previous/Next slice</li>
<li><kbd>Page Up</kbd> <kbd>Page Down</kbd> Previous/Next series</li>
<li><kbd>Home</kbd> <kbd>End</kbd> First/Last slice</li>
<li><kbd>Space</kbd> Play/Pause</li>
</ul>
<h6>Zoom & Pan</h6>
<ul class="list-unstyled">
<li><kbd>+</kbd> <kbd>-</kbd> Zoom in/out</li>
<li><kbd>0</kbd> Reset zoom</li>
<li><kbd>Ctrl</kbd> + <kbd>Drag</kbd> Pan</li>
</ul>
</div>
<div class="col-md-6">
<h6>Window/Level</h6>
<ul class="list-unstyled">
<li><kbd>W</kbd> Window width</li>
<li><kbd>L</kbd> Window level</li>
<li><kbd>I</kbd> Invert</li>
<li><kbd>R</kbd> Rotate</li>
</ul>
<h6>Tools</h6>
<ul class="list-unstyled">
<li><kbd>M</kbd> Measure</li>
<li><kbd>A</kbd> Annotate</li>
<li><kbd>F</kbd> Fullscreen</li>
<li><kbd>Esc</kbd> Exit fullscreen</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/cornerstone.min.js' %}"></script>
<script src="{% static 'js/cornerstoneWADOImageLoader.min.js' %}"></script>
<script src="{% static 'js/cornerstoneTools.min.js' %}"></script>
<script>
let currentSeries = null;
let currentImageIndex = 0;
let isPlaying = false;
let playInterval = null;
let measurements = [];
document.addEventListener('DOMContentLoaded', function() {
initializeDICOMViewer();
setupEventListeners();
{% if study %}
// Load first series if available
const firstThumbnail = document.querySelector('.thumbnail');
if (firstThumbnail) {
firstThumbnail.click();
}
{% endif %}
});
function initializeDICOMViewer() {
// Initialize Cornerstone
cornerstone.enable(document.getElementById('dicomCanvas'));
// Configure WADO Image Loader
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
// Initialize Cornerstone Tools
cornerstoneTools.external.cornerstone = cornerstone;
cornerstoneTools.external.Hammer = Hammer;
cornerstoneTools.init();
// Add tools
const element = document.getElementById('dicomCanvas');
cornerstoneTools.addTool(cornerstoneTools.ZoomTool);
cornerstoneTools.addTool(cornerstoneTools.PanTool);
cornerstoneTools.addTool(cornerstoneTools.LengthTool);
cornerstoneTools.addTool(cornerstoneTools.WwwcTool);
// Set default tool
cornerstoneTools.setToolActive('Wwwc', { mouseButtonMask: 1 });
}
function setupEventListeners() {
// Toolbar buttons
document.getElementById('zoomInBtn').addEventListener('click', () => zoomIn());
document.getElementById('zoomOutBtn').addEventListener('click', () => zoomOut());
document.getElementById('resetZoomBtn').addEventListener('click', () => resetZoom());
document.getElementById('playBtn').addEventListener('click', () => togglePlay());
document.getElementById('prevBtn').addEventListener('click', () => previousImage());
document.getElementById('nextBtn').addEventListener('click', () => nextImage());
// Sliders
document.getElementById('sliceSlider').addEventListener('input', (e) => {
currentImageIndex = parseInt(e.target.value) - 1;
loadCurrentImage();
});
document.getElementById('windowSlider').addEventListener('input', (e) => {
updateWindowLevel();
});
document.getElementById('levelSlider').addEventListener('input', (e) => {
updateWindowLevel();
});
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyboardShortcuts);
}
function loadSeries(seriesId) {
// Hide loading spinner and show it
document.getElementById('loadingSpinner').style.display = 'block';
// Update active thumbnail
document.querySelectorAll('.thumbnail').forEach(thumb => {
thumb.classList.remove('active');
});
document.querySelector(`[data-series-id="${seriesId}"]`).classList.add('active');
// Simulate loading series data
fetch(`/radiology/dicom/series/${seriesId}/`)
.then(response => response.json())
.then(data => {
currentSeries = data;
currentImageIndex = 0;
updateSliceSlider();
loadCurrentImage();
})
.catch(error => {
console.error('Error loading series:', error);
alert('Error loading DICOM series');
})
.finally(() => {
document.getElementById('loadingSpinner').style.display = 'none';
});
}
function loadCurrentImage() {
if (!currentSeries || !currentSeries.images) return;
const imageId = currentSeries.images[currentImageIndex];
const element = document.getElementById('dicomCanvas');
cornerstone.loadImage(imageId).then(image => {
cornerstone.displayImage(element, image);
updateImageInfo();
}).catch(error => {
console.error('Error loading image:', error);
});
}
function updateSliceSlider() {
if (!currentSeries) return;
const slider = document.getElementById('sliceSlider');
slider.max = currentSeries.images.length;
slider.value = currentImageIndex + 1;
updateImageInfo();
}
function updateImageInfo() {
if (!currentSeries) return;
const imageInfo = document.getElementById('imageInfo');
const sliceInfo = document.getElementById('sliceInfo');
imageInfo.textContent = `Image ${currentImageIndex + 1} of ${currentSeries.images.length}`;
sliceInfo.textContent = `${currentImageIndex + 1}/${currentSeries.images.length}`;
}
function updateWindowLevel() {
const windowValue = document.getElementById('windowSlider').value;
const levelValue = document.getElementById('levelSlider').value;
document.getElementById('windowValue').textContent = windowValue;
document.getElementById('levelValue').textContent = levelValue;
const element = document.getElementById('dicomCanvas');
const viewport = cornerstone.getViewport(element);
viewport.voi.windowWidth = parseInt(windowValue);
viewport.voi.windowCenter = parseInt(levelValue);
cornerstone.setViewport(element, viewport);
}
function zoomIn() {
const element = document.getElementById('dicomCanvas');
const viewport = cornerstone.getViewport(element);
viewport.scale += 0.25;
cornerstone.setViewport(element, viewport);
}
function zoomOut() {
const element = document.getElementById('dicomCanvas');
const viewport = cornerstone.getViewport(element);
viewport.scale = Math.max(0.25, viewport.scale - 0.25);
cornerstone.setViewport(element, viewport);
}
function resetZoom() {
const element = document.getElementById('dicomCanvas');
cornerstone.reset(element);
}
function togglePlay() {
if (isPlaying) {
stopPlay();
} else {
startPlay();
}
}
function startPlay() {
if (!currentSeries || currentSeries.images.length <= 1) return;
isPlaying = true;
document.getElementById('playBtn').innerHTML = '<i class="fas fa-pause"></i>';
playInterval = setInterval(() => {
nextImage();
}, 100); // 10 FPS
}
function stopPlay() {
isPlaying = false;
document.getElementById('playBtn').innerHTML = '<i class="fas fa-play"></i>';
if (playInterval) {
clearInterval(playInterval);
playInterval = null;
}
}
function previousImage() {
if (!currentSeries) return;
currentImageIndex = Math.max(0, currentImageIndex - 1);
document.getElementById('sliceSlider').value = currentImageIndex + 1;
loadCurrentImage();
}
function nextImage() {
if (!currentSeries) return;
currentImageIndex = Math.min(currentSeries.images.length - 1, currentImageIndex + 1);
// Loop back to beginning if playing
if (isPlaying && currentImageIndex === currentSeries.images.length - 1) {
currentImageIndex = 0;
}
document.getElementById('sliceSlider').value = currentImageIndex + 1;
loadCurrentImage();
}
function handleKeyboardShortcuts(event) {
switch(event.key) {
case 'ArrowUp':
event.preventDefault();
previousImage();
break;
case 'ArrowDown':
event.preventDefault();
nextImage();
break;
case ' ':
event.preventDefault();
togglePlay();
break;
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
case 'f':
case 'F':
event.preventDefault();
toggleFullscreen();
break;
case 'Escape':
if (document.fullscreenElement) {
document.exitFullscreen();
}
break;
}
}
function toggleFullscreen() {
const viewer = document.getElementById('dicomViewer');
if (!document.fullscreenElement) {
viewer.requestFullscreen().catch(err => {
console.error('Error attempting to enable fullscreen:', err);
});
} else {
document.exitFullscreen();
}
}
function showPreferences() {
const modal = new bootstrap.Modal(document.getElementById('preferencesModal'));
modal.show();
}
function showKeyboardShortcuts() {
const modal = new bootstrap.Modal(document.getElementById('shortcutsModal'));
modal.show();
}
function savePreferences() {
// Save preferences to localStorage
const preferences = {
defaultWindowPreset: document.getElementById('defaultWindowPreset').value,
autoPlay: document.getElementById('autoPlay').checked,
showOverlays: document.getElementById('showOverlays').checked,
interpolation: document.getElementById('interpolation').value
};
localStorage.setItem('dicomViewerPreferences', JSON.stringify(preferences));
const modal = bootstrap.Modal.getInstance(document.getElementById('preferencesModal'));
modal.hide();
}
function exportImage() {
const element = document.getElementById('dicomCanvas');
const canvas = element.querySelector('canvas');
if (canvas) {
const link = document.createElement('a');
link.download = `dicom_image_${currentImageIndex + 1}.png`;
link.href = canvas.toDataURL();
link.click();
}
}
function printImage() {
const element = document.getElementById('dicomCanvas');
const canvas = element.querySelector('canvas');
if (canvas) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head><title>DICOM Image</title></head>
<body style="margin:0; text-align:center;">
<img src="${canvas.toDataURL()}" style="max-width:100%; max-height:100%;">
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
}
</script>
{% endblock %}