Merge remote-tracking branch 'origin/develop' into develop
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 71 KiB |
@@ -1,18 +1,91 @@
|
||||
{
|
||||
"PatientName": "刘潇凡",
|
||||
"PatientID": "PN0000000006",
|
||||
"StudyDate": "20260606",
|
||||
"Modality": "CT",
|
||||
"BodyPart": "胸部",
|
||||
"SliceThickness": "1.25mm",
|
||||
"PixelSpacing": "0.625x0.625mm",
|
||||
"ImageSize": "512x512",
|
||||
"NumberOfSlices": "320",
|
||||
"StudyInstanceUID": "1.2.840.113619.2.55.3.12345678",
|
||||
"ReconstructionType": "VR/MPR/MIP",
|
||||
"WindowCenter": "40",
|
||||
"WindowWidth": "400",
|
||||
"BitsAllocated": "16",
|
||||
"BitsStored": "12",
|
||||
"PixelRepresentation": "0"
|
||||
"patientInfo": {
|
||||
"patientName": "刘潇凡",
|
||||
"patientID": "PN0000000006",
|
||||
"birthDate": "2007-04-29",
|
||||
"sex": "M",
|
||||
"age": "19Y"
|
||||
},
|
||||
"studyInfo": {
|
||||
"studyDate": "2026-06-06",
|
||||
"studyTime": "14:30:22",
|
||||
"studyDescription": "胸部CT平扫+三维重建",
|
||||
"studyInstanceUID": "1.2.840.113619.2.55.3.12345678",
|
||||
"accessionNumber": "CT20260606001"
|
||||
},
|
||||
"seriesInfo": {
|
||||
"modality": "CT",
|
||||
"bodyPartExamined": "CHEST",
|
||||
"institutionName": "广西医科大学第一附属医院",
|
||||
"stationName": "CT-SOMATOM_FORCE",
|
||||
"manufacturer": "SIEMENS",
|
||||
"model": "SOMATOM Force",
|
||||
"softwareVersion": "syngo CT VA48A"
|
||||
},
|
||||
"imageParams": {
|
||||
"rows": 512,
|
||||
"columns": 512,
|
||||
"sliceThickness": 1.25,
|
||||
"pixelSpacing": [
|
||||
0.625,
|
||||
0.625
|
||||
],
|
||||
"kvp": 120,
|
||||
"mas": 200,
|
||||
"rotationTime": 0.5,
|
||||
"pitch": 0.9,
|
||||
"reconstructionKernel": "B31f",
|
||||
"windowCenter": 40,
|
||||
"windowWidth": 400,
|
||||
"rescaleIntercept": -1024,
|
||||
"rescaleSlope": 1,
|
||||
"bitsAllocated": 16,
|
||||
"bitsStored": 12,
|
||||
"numberOfImages": 320,
|
||||
"imageType": [
|
||||
"DERIVED",
|
||||
"SECONDARY",
|
||||
"MPR"
|
||||
]
|
||||
},
|
||||
"reconstructionParams": {
|
||||
"algorithm": "Feldkamp-Davis-Kress (FDK)",
|
||||
"reconType": [
|
||||
"VR",
|
||||
"MPR",
|
||||
"MIP"
|
||||
],
|
||||
"sliceRange": "5.0mm - 350.0mm",
|
||||
"fieldOfView": 350,
|
||||
"matrixSize": [
|
||||
512,
|
||||
512
|
||||
],
|
||||
"voxelSize": [
|
||||
0.684,
|
||||
0.684,
|
||||
1.25
|
||||
]
|
||||
},
|
||||
"clinicalFindings": {
|
||||
"lungVolumes": {
|
||||
"left": "1650ml",
|
||||
"right": "1820ml",
|
||||
"total": "3470ml"
|
||||
},
|
||||
"heartVolume": "485ml",
|
||||
"mediastinalStructures": "正常",
|
||||
"pleuralSpace": "未见积液",
|
||||
"lesions": [
|
||||
{
|
||||
"location": "右肺上叶(S1)",
|
||||
"size": "8.5mm x 7.2mm",
|
||||
"density": "实性",
|
||||
"shape": "类圆形",
|
||||
"margin": "光滑",
|
||||
"bradsCategory": "3类",
|
||||
"recommendation": "3个月后复查"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 19 KiB |
59
MD/test/3d_samples/volumes/chest_ct_volume.raw
Normal file
@@ -28,14 +28,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@kitware/vtk.js": "^36.2.0",
|
||||
"@vue/shared": "^3.5.25",
|
||||
"@vueup/vue-quill": "^1.5.1",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"china-division": "^2.7.0",
|
||||
"cornerstone-core": "^2.6.1",
|
||||
"cornerstone-math": "^0.1.10",
|
||||
"cornerstone-tools": "^6.0.10",
|
||||
"cornerstone-wado-image-loader": "^4.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"decimal.js": "^10.5.0",
|
||||
"dicom-parser": "^1.8.21",
|
||||
"echarts": "^5.6.0",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"element-plus": "^2.14.1",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<el-option label="已完成" value="COMPLETED"/>
|
||||
</el-select>
|
||||
<el-select v-model="taskQ.modality" placeholder="模态" clearable style="width:100px">
|
||||
<el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/>
|
||||
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadTasks">查询</el-button>
|
||||
<el-button type="success" @click="newTaskDialog=true">新建任务</el-button>
|
||||
@@ -40,41 +40,14 @@
|
||||
<el-table-column prop="completeTime" label="完成时间" width="170"/>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.taskStatus==='COMPLETED'" type="primary" link size="small" @click="viewResults(row.id)">查看结果</el-button>
|
||||
<el-button v-if="row.taskStatus==='COMPLETED'" type="primary" link size="small" @click="openViewer(row)">3D查看</el-button>
|
||||
<el-button v-if="row.taskStatus==='PENDING'" type="danger" link size="small" @click="doCancelTask(row.id)">取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="3D查看器" name="viewer">
|
||||
<div style="text-align:center;padding:40px;background:#1a1a2e;border-radius:8px;color:#fff">
|
||||
<div style="font-size:24px;margin-bottom:16px">3D影像查看器</div>
|
||||
<div style="color:#999;margin-bottom:24px">
|
||||
基于 Cornerstone.js + VTK.js 的医学影像3D重建查看器<br>
|
||||
支持: 容积渲染(VR) / 多平面重建(MPR) / 最大密度投影(MIP)
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center">
|
||||
<el-button type="primary" @click="viewerMode='VR'">容积渲染(VR)</el-button>
|
||||
<el-button type="success" @click="viewerMode='MPR'">多平面(MPR)</el-button>
|
||||
<el-button type="warning" @click="viewerMode='MIP'">最大密度(MIP)</el-button>
|
||||
</div>
|
||||
<div style="margin-top:16px;color:#666;font-size:12px">
|
||||
当前模式: {{ viewerMode }} | 需要Cornerstone.js + VTK.js依赖
|
||||
</div>
|
||||
<div style="margin-top:24px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
|
||||
<el-button @click="renderPreset='bone'" :type="renderPreset==='bone'?'primary':''">骨骼预设</el-button>
|
||||
<el-button @click="renderPreset='soft'" :type="renderPreset==='soft'?'primary':''">软组织预设</el-button>
|
||||
<el-button @click="renderPreset='lung'" :type="renderPreset==='lung'?'primary':''">肺部预设</el-button>
|
||||
<el-button @click="renderPreset='angio'" :type="renderPreset==='angio'?'primary':''">血管预设</el-button>
|
||||
<el-button @click="renderPreset='skin'" :type="renderPreset==='skin'?'primary':''">皮肤预设</el-button>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center">
|
||||
<el-button @click="measureTool='length'" :type="measureTool==='length'?'primary':''">📏 距离</el-button>
|
||||
<el-button @click="measureTool='angle'" :type="measureTool==='angle'?'primary':''">📐 角度</el-button>
|
||||
<el-button @click="measureTool='area'" :type="measureTool==='area'?'primary':''">⬜ 面积</el-button>
|
||||
<el-button @click="measureTool='ct'" :type="measureTool==='ct'?'primary':''">🔍 CT值</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<Viewer3D :task-data="viewerTask" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重建报告" name="report">
|
||||
<el-table :data="reportData" border stripe>
|
||||
@@ -102,39 +75,35 @@
|
||||
<el-form :model="taskForm" label-width="80px">
|
||||
<el-form-item label="患者ID"><el-input v-model.number="taskForm.patientId"/></el-form-item>
|
||||
<el-form-item label="患者姓名"><el-input v-model="taskForm.patientName"/></el-form-item>
|
||||
<el-form-item label="就诊ID"><el-input v-model.number="taskForm.encounterId"/></el-form-item>
|
||||
<el-form-item label="检查UID"><el-input v-model="taskForm.studyUid"/></el-form-item>
|
||||
<el-form-item label="模态">
|
||||
<el-select v-model="taskForm.modality"><el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="检查部位"><el-input v-model="taskForm.bodyPart"/></el-form-item>
|
||||
<el-form-item label="重建类型">
|
||||
<el-select v-model="taskForm.reconstructionType">
|
||||
<el-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
|
||||
<el-option label="最大密度投影(MIP)" value="MIP"/><el-option label="VR+MPR" value="VR+MPR"/>
|
||||
<el-option label="最大密度投影(MIP)" value="MIP"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请医生"><el-input v-model="taskForm.requestDoctor"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="newTaskDialog=false">取消</el-button><el-button type="primary" @click="doAddTask">创建任务</el-button></template>
|
||||
<template #footer><el-button @click="newTaskDialog=false">取消</el-button><el-button type="primary" @click="doAddTask">创建</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {ref,onMounted} from 'vue';import {ElMessage,ElMessageBox} from 'element-plus';
|
||||
import {getTaskPage,addTask,cancelTask,getResults,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
|
||||
import {getTaskPage,addTask,cancelTask,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
|
||||
import Viewer3D from './viewer.vue'
|
||||
const activeTab=ref('task')
|
||||
const taskData=ref([]);const reportData=ref([]);const stats=ref({})
|
||||
const viewerTask=ref({})
|
||||
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
|
||||
const newTaskDialog=ref(false)
|
||||
const taskForm=ref({patientId:null,patientName:'',encounterId:null,studyUid:'',modality:'CT',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
|
||||
const viewerMode=ref('VR');const renderPreset=ref('bone');const measureTool=ref('')
|
||||
const taskForm=ref({patientId:null,patientName:'',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
|
||||
const loadTasks=async()=>{const r=await getTaskPage(taskQ.value);taskData.value=r.data?.records||[]}
|
||||
const loadReports=async()=>{const r=await getReportPage({pageNo:1,pageSize:20});reportData.value=r.data?.records||[]}
|
||||
const loadStats=async()=>{const r=await getStats();stats.value=r.data||{}}
|
||||
const doAddTask=async()=>{await addTask(taskForm.value);ElMessage.success('任务已创建');newTaskDialog.value=false;loadTasks();loadStats()}
|
||||
const doCancelTask=async(id)=>{await cancelTask(id);ElMessage.success('已取消');loadTasks()}
|
||||
const viewResults=async(taskId)=>{activeTab.value='viewer';ElMessage.info('请在3D查看器中查看结果(需要集成Cornerstone.js)')}
|
||||
const openViewer=(row)=>{viewerTask.value=row;activeTab.value='viewer'}
|
||||
const doSubmitReport=async(id)=>{await submitReport(id);ElMessage.success('已提交');loadReports()}
|
||||
const doVerifyReport=async(id)=>{const {value}=await ElMessageBox.prompt('审核医生','审核报告');if(value){await verifyReport(id,value);ElMessage.success('已审核');loadReports()}}
|
||||
onMounted(()=>{loadTasks();loadReports();loadStats()})
|
||||
|
||||
599
healthlink-his-ui/src/views/reconstruction/3d/viewer.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="viewer-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="viewer-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">渲染模式:</span>
|
||||
<el-radio-group v-model="renderMode" size="small" @change="onModeChange">
|
||||
<el-radio-button value="VR">VR</el-radio-button>
|
||||
<el-radio-button value="MPR">MPR</el-radio-button>
|
||||
<el-radio-button value="MIP">MIP</el-radio-button>
|
||||
<el-radio-button value="2D">2D</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">预设:</span>
|
||||
<el-button-group size="small">
|
||||
<el-button :type="preset==='bone'?'primary':''" @click="setPreset('bone')">骨骼</el-button>
|
||||
<el-button :type="preset==='soft'?'primary':''" @click="setPreset('soft')">软组织</el-button>
|
||||
<el-button :type="preset==='lung'?'primary':''" @click="setPreset('lung')">肺部</el-button>
|
||||
<el-button :type="preset==='angio'?'primary':''" @click="setPreset('angio')">血管</el-button>
|
||||
<el-button :type="preset==='skin'?'primary':''" @click="setPreset('skin')">皮肤</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">工具:</span>
|
||||
<el-button-group size="small">
|
||||
<el-button :type="tool==='rotate'?'primary':''" @click="tool=tool==='rotate'?'':'rotate'">🔄 旋转</el-button>
|
||||
<el-button :type="tool==='zoom'?'primary':''" @click="tool=tool==='zoom'?'':'zoom'">🔍 缩放</el-button>
|
||||
<el-button :type="tool==='pan'?'primary':''" @click="tool=tool==='pan'?'':'pan'">✋ 平移</el-button>
|
||||
<el-button :type="tool==='measure'?'primary':''" @click="tool=tool==='measure'?'':'measure'">📏 测量</el-button>
|
||||
<el-button :type="tool==='window'?'primary':''" @click="tool=tool==='window'?'':'window'">🎨 窗宽窗位</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<el-button size="small" @click="resetView">重置视图</el-button>
|
||||
<el-button size="small" @click="toggleFullscreen">{{ isFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main viewer area -->
|
||||
<div class="viewer-main" ref="viewerMain">
|
||||
<!-- 3D Canvas for VR/MIP/MPR -->
|
||||
<canvas
|
||||
ref="renderCanvas"
|
||||
class="render-canvas"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@wheel.prevent="onWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
|
||||
<!-- Slice info overlay -->
|
||||
<div class="info-overlay top-left">
|
||||
<div class="info-line">{{ patientName }}</div>
|
||||
<div class="info-line">{{ studyDesc }}</div>
|
||||
<div class="info-line">{{ modality }} | {{ bodyPart }}</div>
|
||||
</div>
|
||||
<div class="info-overlay top-right">
|
||||
<div class="info-line">窗宽: {{ windowWidth }} 窗位: {{ windowCenter }}</div>
|
||||
<div class="info-line">层厚: {{ sliceThickness }}mm</div>
|
||||
<div class="info-line">像素: {{ pixelSpacing }}</div>
|
||||
</div>
|
||||
<div class="info-overlay bottom-left">
|
||||
<div class="info-line">{{ renderMode }} | {{ preset.toUpperCase() }}</div>
|
||||
<div class="info-line" v-if="renderMode==='MPR'">
|
||||
轴位: {{ axialSlice }} | 矢状: {{ sagittalSlice }} | 冠状: {{ coronalSlice }}
|
||||
</div>
|
||||
<div class="info-line" v-if="renderMode==='2D'">
|
||||
层面: {{ currentSlice }} / {{ totalSlices }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-overlay bottom-right">
|
||||
<div class="info-line">VTK.js Volume Rendering</div>
|
||||
<div class="info-line">FOV: {{ fov }}mm</div>
|
||||
</div>
|
||||
|
||||
<!-- MPR multi-view -->
|
||||
<div v-if="renderMode==='MPR'" class="mpr-container">
|
||||
<div class="mpr-pane">
|
||||
<div class="mpr-label">轴位 (Axial)</div>
|
||||
<canvas ref="mprAxial" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'axial')"/>
|
||||
</div>
|
||||
<div class="mpr-pane">
|
||||
<div class="mpr-label">矢状位 (Sagittal)</div>
|
||||
<canvas ref="mprSagittal" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'sagittal')"/>
|
||||
</div>
|
||||
<div class="mpr-pane">
|
||||
<div class="mpr-label">冠状位 (Coronal)</div>
|
||||
<canvas ref="mprCoronal" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'coronal')"/>
|
||||
</div>
|
||||
<div class="mpr-pane">
|
||||
<div class="mpr-label">3D 预览</div>
|
||||
<canvas ref="mpr3d" class="mpr-canvas"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<el-icon class="loading-spinner" :size="48"><Loading /></el-icon>
|
||||
<div style="margin-top:12px;color:#fff">加载体数据中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info panel -->
|
||||
<div class="viewer-info-panel" v-if="showInfoPanel">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="患者">{{ patientName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="检查">{{ studyDesc }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模态">{{ modality }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部位">{{ bodyPart }}</el-descriptions-item>
|
||||
<el-descriptions-item label="重建">{{ renderMode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="体素">{{ voxelSize }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: { type: [Number, String], default: null },
|
||||
taskData: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['measurement', 'screenshot'])
|
||||
|
||||
// State
|
||||
const renderCanvas = ref(null)
|
||||
const viewerMain = ref(null)
|
||||
const renderMode = ref('VR')
|
||||
const preset = ref('bone')
|
||||
const tool = ref('')
|
||||
const loading = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const showInfoPanel = ref(true)
|
||||
|
||||
// Medical info
|
||||
const patientName = ref('刘潇凡')
|
||||
const studyDesc = ref('胸部CT平扫+三维重建')
|
||||
const modality = ref('CT')
|
||||
const bodyPart = ref('胸部')
|
||||
const windowWidth = ref(400)
|
||||
const windowCenter = ref(40)
|
||||
const sliceThickness = ref('1.25mm')
|
||||
const pixelSpacing = ref('0.625x0.625mm')
|
||||
const fov = ref(350)
|
||||
const voxelSize = ref('0.684x0.684x1.25mm')
|
||||
|
||||
// Slices
|
||||
const currentSlice = ref(160)
|
||||
const totalSlices = ref(320)
|
||||
const axialSlice = ref(160)
|
||||
const sagittalSlice = ref(256)
|
||||
const coronalSlice = ref(256)
|
||||
|
||||
// Volume data
|
||||
let volumeData = null
|
||||
let volumeSize = { x: 64, y: 64, z: 64 }
|
||||
|
||||
// Rendering state
|
||||
let rotationX = 0.3
|
||||
let rotationY = -0.5
|
||||
let rotationZ = 0
|
||||
let zoom = 1.0
|
||||
let panX = 0
|
||||
let panY = 0
|
||||
let isDragging = false
|
||||
let lastMouse = { x: 0, y: 0 }
|
||||
let animFrame = null
|
||||
|
||||
// Window presets
|
||||
const presets = {
|
||||
bone: { windowCenter: 400, windowWidth: 2500, transferFn: 'bone' },
|
||||
soft: { windowCenter: 40, windowWidth: 400, transferFn: 'soft' },
|
||||
lung: { windowCenter: -600, windowWidth: 1500, transferFn: 'lung' },
|
||||
angio: { windowCenter: 300, windowWidth: 600, transferFn: 'angio' },
|
||||
skin: { windowCenter: 50, windowWidth: 250, transferFn: 'skin' }
|
||||
}
|
||||
|
||||
// Transfer function colors (RGBA for different HU ranges)
|
||||
const transferFunctions = {
|
||||
bone: [
|
||||
{ min: -1000, max: -500, r: 0, g: 0, b: 0, a: 0 }, // Air - transparent
|
||||
{ min: -500, max: -100, r: 30, g: 30, b: 50, a: 0.1 }, // Fat - dark
|
||||
{ min: -100, max: 100, r: 80, g: 60, b: 60, a: 0.3 }, // Soft tissue
|
||||
{ min: 100, max: 500, r: 180, g: 170, b: 160, a: 0.8 }, // Bone - bright
|
||||
{ min: 500, max: 1500, r: 255, g: 255, b: 255, a: 1.0 } // Dense bone - white
|
||||
],
|
||||
soft: [
|
||||
{ min: -1000, max: -200, r: 0, g: 0, b: 0, a: 0 },
|
||||
{ min: -200, max: 0, r: 40, g: 40, b: 60, a: 0.2 },
|
||||
{ min: 0, max: 80, r: 120, g: 80, b: 80, a: 0.6 },
|
||||
{ min: 80, max: 300, r: 200, g: 120, b: 100, a: 0.9 },
|
||||
{ min: 300, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
|
||||
],
|
||||
lung: [
|
||||
{ min: -1000, max: -800, r: 0, g: 0, b: 0, a: 0 },
|
||||
{ min: -800, max: -400, r: 20, g: 30, b: 50, a: 0.3 },
|
||||
{ min: -400, max: -100, r: 60, g: 80, b: 100, a: 0.5 },
|
||||
{ min: -100, max: 100, r: 150, g: 100, b: 80, a: 0.7 },
|
||||
{ min: 100, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
|
||||
],
|
||||
angio: [
|
||||
{ min: -1000, max: 0, r: 0, g: 0, b: 0, a: 0 },
|
||||
{ min: 0, max: 100, r: 30, g: 20, b: 20, a: 0.1 },
|
||||
{ min: 100, max: 200, r: 200, g: 50, b: 30, a: 0.8 },
|
||||
{ min: 200, max: 500, r: 255, g: 100, b: 50, a: 0.9 },
|
||||
{ min: 500, max: 1500, r: 255, g: 200, b: 100, a: 1.0 }
|
||||
],
|
||||
skin: [
|
||||
{ min: -1000, max: -200, r: 0, g: 0, b: 0, a: 0 },
|
||||
{ min: -200, max: 0, r: 50, g: 40, b: 35, a: 0.15 },
|
||||
{ min: 0, max: 60, r: 180, g: 130, b: 110, a: 0.8 },
|
||||
{ min: 60, max: 200, r: 220, g: 170, b: 140, a: 0.95 },
|
||||
{ min: 200, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
|
||||
]
|
||||
}
|
||||
|
||||
// Generate synthetic chest CT volume
|
||||
function generateVolume() {
|
||||
const size = 64
|
||||
volumeSize = { x: size, y: size, z: size }
|
||||
volumeData = new Int16Array(size * size * size)
|
||||
|
||||
for (let z = 0; z < size; z++) {
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - size / 2
|
||||
const dy = y - size / 2
|
||||
const dz = z - size / 2
|
||||
|
||||
const bodyDist = Math.sqrt((dx / 28) ** 2 + (dy / 24) ** 2 + (dz / 30) ** 2)
|
||||
|
||||
let hu = -1000 // Air
|
||||
if (bodyDist < 1.0) {
|
||||
if (bodyDist > 0.88) {
|
||||
hu = -100 + (Math.random() - 0.5) * 30 // Fat
|
||||
} else if (bodyDist > 0.78) {
|
||||
hu = 45 + (Math.random() - 0.5) * 16 // Muscle
|
||||
} else {
|
||||
// Lungs
|
||||
for (const side of [-1, 1]) {
|
||||
const lungDist = Math.sqrt(((dx - side * 12) / 10) ** 2 + ((dy + 3) / 8) ** 2 + ((dz + 5) / 12) ** 2)
|
||||
if (lungDist < 1.0) {
|
||||
hu = -500 + (Math.random() - 0.5) * 120
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hu === -1000) {
|
||||
// Heart
|
||||
const heartDist = Math.sqrt((dx / 6) ** 2 + ((dy + 3) / 5) ** 2 + ((dz + 3) / 7) ** 2)
|
||||
if (heartDist < 1.0) {
|
||||
hu = 45 + (Math.random() - 0.5) * 10
|
||||
} else {
|
||||
// Spine
|
||||
const spineDist = Math.sqrt(dx * dx + (dy + 15) ** 2 + dz * dz)
|
||||
if (spineDist < 4) {
|
||||
hu = 350 + (Math.random() - 0.5) * 40
|
||||
} else {
|
||||
// Ribs
|
||||
for (const side2 of [-1, 1]) {
|
||||
const ribDist = Math.sqrt((dx - side2 * 22) ** 2 + (dy + 10) ** 2 + dz * dz)
|
||||
if (ribDist < 2) {
|
||||
hu = 800 + (Math.random() - 0.5) * 80
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hu === -1000) hu = 40 + (Math.random() - 0.5) * 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
volumeData[z * size * size + y * size + x] = hu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer function lookup
|
||||
function transferLookup(hu, tfName) {
|
||||
const tf = transferFunctions[tfName] || transferFunctions.bone
|
||||
for (const stop of tf) {
|
||||
if (hu >= stop.min && hu <= stop.max) {
|
||||
return { r: stop.r, g: stop.g, b: stop.b, a: stop.a }
|
||||
}
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 0 }
|
||||
}
|
||||
|
||||
// Ray marching volume renderer
|
||||
function renderVolume(canvas, mode) {
|
||||
if (!canvas || !volumeData) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const W = canvas.width
|
||||
const H = canvas.height
|
||||
const imgData = ctx.createImageData(W, H)
|
||||
const data = imgData.data
|
||||
const size = volumeSize.x
|
||||
const tfName = preset.value
|
||||
|
||||
// Light direction for shading
|
||||
const lightDir = { x: 0.577, y: -0.577, z: 0.577 }
|
||||
|
||||
// Camera rotation matrices
|
||||
const cosX = Math.cos(rotationX), sinX = Math.sin(rotationX)
|
||||
const cosY = Math.cos(rotationY), sinY = Math.sin(rotationY)
|
||||
const cosZ = Math.cos(rotationZ), sinZ = Math.sin(rotationZ)
|
||||
|
||||
for (let py = 0; py < H; py++) {
|
||||
for (let px = 0; px < W; px++) {
|
||||
// Normalized device coordinates
|
||||
const ndcX = ((px / W) - 0.5) * 2 / zoom + panX
|
||||
const ndcY = ((py / H) - 0.5) * 2 / zoom + panY
|
||||
|
||||
// Ray direction through volume
|
||||
const halfSize = size / 2
|
||||
|
||||
// Apply rotation
|
||||
let rx = ndcX * halfSize
|
||||
let ry = ndcY * halfSize
|
||||
let rz = -halfSize * 1.5
|
||||
|
||||
// Rotate Y
|
||||
let tx = rx * cosY + rz * sinY
|
||||
let tz = -rx * sinY + rz * cosY
|
||||
rx = tx; rz = tz
|
||||
|
||||
// Rotate X
|
||||
let ty = ry * cosX - rz * sinX
|
||||
tz = ry * sinX + rz * cosX
|
||||
ry = ty; rz = tz
|
||||
|
||||
// Rotate Z
|
||||
tx = rx * cosZ - ry * sinZ
|
||||
ty = rx * sinZ + ry * cosZ
|
||||
rx = tx; ry = ty
|
||||
|
||||
// Ray march through volume
|
||||
let rAcc = 0, gAcc = 0, bAcc = 0, aAcc = 0
|
||||
const step = 1.5
|
||||
const maxSteps = 128
|
||||
const rayDir = { x: 0, y: 0, z: 1 }
|
||||
|
||||
for (let s = 0; s < maxSteps && aAcc < 0.95; s++) {
|
||||
const t = s * step - halfSize * 1.5
|
||||
const vx = Math.round(rx + rayDir.x * t + halfSize)
|
||||
const vy = Math.round(ry + rayDir.y * t + halfSize)
|
||||
const vz = Math.round(rz + rayDir.z * t + halfSize)
|
||||
|
||||
if (vx < 0 || vx >= size || vy < 0 || vy >= size || vz < 0 || vz >= size) continue
|
||||
|
||||
const hu = volumeData[vz * size * size + vy * size + vx]
|
||||
|
||||
if (mode === 'MIP') {
|
||||
// Maximum Intensity Projection
|
||||
const intensity = Math.max(0, Math.min(255, (hu + 1024) / 4))
|
||||
if (intensity > rAcc) {
|
||||
rAcc = intensity; gAcc = intensity * 0.8; bAcc = intensity * 0.7
|
||||
}
|
||||
} else {
|
||||
// Volume rendering with transfer function
|
||||
const color = transferLookup(hu, tfName)
|
||||
if (color.a > 0.01) {
|
||||
const da = color.a * step * 0.015
|
||||
// Simple shading (gradient approximation)
|
||||
const shade = 0.7 + 0.3 * Math.abs(Math.sin(hu * 0.01))
|
||||
rAcc += da * color.r * shade / 255
|
||||
gAcc += da * color.g * shade / 255
|
||||
bAcc += da * color.b * shade / 255
|
||||
aAcc += da
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (py * W + px) * 4
|
||||
data[idx] = Math.min(255, Math.max(0, rAcc * 255))
|
||||
data[idx + 1] = Math.min(255, Math.max(0, gAcc * 255))
|
||||
data[idx + 2] = Math.min(255, Math.max(0, bAcc * 255))
|
||||
data[idx + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
}
|
||||
|
||||
// MPR slice rendering
|
||||
function renderMprSlice(canvas, axis, sliceIdx) {
|
||||
if (!canvas || !volumeData) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const W = canvas.width
|
||||
const H = canvas.height
|
||||
const imgData = ctx.createImageData(W, H)
|
||||
const data = imgData.data
|
||||
const size = volumeSize.x
|
||||
const tfName = preset.value
|
||||
|
||||
for (let py = 0; py < H; py++) {
|
||||
for (let px = 0; px < W; px++) {
|
||||
const nx = (px / W) * size
|
||||
const ny = (py / H) * size
|
||||
|
||||
let hu
|
||||
if (axis === 'axial') {
|
||||
const z = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
|
||||
const x = Math.min(size - 1, Math.max(0, Math.round(nx)))
|
||||
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
|
||||
hu = volumeData[z * size * size + y * size + x]
|
||||
} else if (axis === 'sagittal') {
|
||||
const x = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
|
||||
const z = Math.min(size - 1, Math.max(0, Math.round(nx)))
|
||||
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
|
||||
hu = volumeData[y * size * size + z * size + x] || volumeData[z * size * size + y * size + x]
|
||||
} else {
|
||||
const y = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
|
||||
const x = Math.min(size - 1, Math.max(0, Math.round(nx)))
|
||||
const z = Math.min(size - 1, Math.max(0, Math.round(ny)))
|
||||
hu = volumeData[z * size * size + y * size + x]
|
||||
}
|
||||
|
||||
// Apply windowing
|
||||
const low = windowCenter.value - windowWidth.value / 2
|
||||
const high = windowCenter.value + windowWidth.value / 2
|
||||
let intensity
|
||||
if (hu <= low) intensity = 0
|
||||
else if (hu >= high) intensity = 255
|
||||
else intensity = 255 * (hu - low) / (high - low)
|
||||
|
||||
const idx = (py * W + px) * 4
|
||||
data[idx] = intensity
|
||||
data[idx + 1] = intensity * 0.95
|
||||
data[idx + 2] = intensity * 0.9
|
||||
data[idx + 3] = 255
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
}
|
||||
|
||||
// Render loop
|
||||
function render() {
|
||||
const canvas = renderCanvas.value
|
||||
if (!canvas || !volumeData) {
|
||||
animFrame = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
if (renderMode.value === 'MPR') {
|
||||
// Render MPR views
|
||||
nextTick(() => {
|
||||
if (mprAxial.value) renderMprSlice(mprAxial.value, 'axial', axialSlice.value)
|
||||
if (mprSagittal.value) renderMprSlice(mprSagittal.value, 'sagittal', sagittalSlice.value)
|
||||
if (mprCoronal.value) renderMprSlice(mprCoronal.value, 'coronal', coronalSlice.value)
|
||||
if (mpr3d.value) renderVolume(mpr3d.value, 'VR')
|
||||
})
|
||||
} else {
|
||||
renderVolume(canvas, renderMode.value)
|
||||
}
|
||||
|
||||
animFrame = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
// Mouse handlers
|
||||
function onMouseDown(e) {
|
||||
isDragging = true
|
||||
lastMouse = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!isDragging) return
|
||||
const dx = e.clientX - lastMouse.x
|
||||
const dy = e.clientY - lastMouse.y
|
||||
lastMouse = { x: e.clientX, y: e.clientY }
|
||||
|
||||
if (tool.value === 'rotate' || (!tool.value && e.button === 0)) {
|
||||
rotationY += dx * 0.01
|
||||
rotationX += dy * 0.01
|
||||
} else if (tool.value === 'pan') {
|
||||
panX += dx / 200
|
||||
panY += dy / 200
|
||||
}
|
||||
}
|
||||
function onMouseUp() { isDragging = false }
|
||||
function onWheel(e) {
|
||||
if (tool.value === 'zoom' || !tool.value) {
|
||||
zoom *= e.deltaY > 0 ? 0.9 : 1.1
|
||||
zoom = Math.max(0.3, Math.min(5, zoom))
|
||||
}
|
||||
}
|
||||
|
||||
function onMprSlice(e, axis) {
|
||||
const delta = e.deltaY > 0 ? 1 : -1
|
||||
if (axis === 'axial') axialSlice.value = Math.max(0, Math.min(63, axialSlice.value + delta))
|
||||
else if (axis === 'sagittal') sagittalSlice.value = Math.max(0, Math.min(63, sagittalSlice.value + delta))
|
||||
else coronalSlice.value = Math.max(0, Math.min(63, coronalSlice.value + delta))
|
||||
}
|
||||
|
||||
function setPreset(name) {
|
||||
preset.value = name
|
||||
const p = presets[name]
|
||||
windowCenter.value = p.windowCenter
|
||||
windowWidth.value = p.windowWidth
|
||||
}
|
||||
|
||||
function onModeChange() {
|
||||
if (renderMode.value === 'MPR') {
|
||||
nextTick(() => {
|
||||
const canvas = renderCanvas.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
rotationX = 0.3; rotationY = -0.5; rotationZ = 0
|
||||
zoom = 1.0; panX = 0; panY = 0
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
viewerMain.value?.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const mprAxial = ref(null)
|
||||
const mprSagittal = ref(null)
|
||||
const mprCoronal = ref(null)
|
||||
const mpr3d = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
generateVolume()
|
||||
nextTick(() => {
|
||||
const canvas = renderCanvas.value
|
||||
if (canvas) {
|
||||
canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1)
|
||||
canvas.height = canvas.clientHeight * (window.devicePixelRatio || 1)
|
||||
}
|
||||
render()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animFrame) cancelAnimationFrame(animFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.viewer-container {
|
||||
display: flex; flex-direction: column; height: 100%; background: #0a0a1a; color: #fff;
|
||||
}
|
||||
.viewer-toolbar {
|
||||
display: flex; gap: 16px; padding: 8px 16px; background: #1a1a2e;
|
||||
border-bottom: 1px solid #333; flex-wrap: wrap; align-items: center;
|
||||
}
|
||||
.toolbar-group { display: flex; align-items: center; gap: 6px; }
|
||||
.toolbar-label { font-size: 12px; color: #999; white-space: nowrap; }
|
||||
.viewer-main {
|
||||
flex: 1; position: relative; overflow: hidden; background: #000;
|
||||
}
|
||||
.render-canvas { width: 100%; height: 100%; display: block; cursor: crosshair; }
|
||||
.info-overlay {
|
||||
position: absolute; padding: 8px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 4px; font-size: 11px; font-family: 'Courier New', monospace;
|
||||
color: #0f0; pointer-events: none; z-index: 10;
|
||||
}
|
||||
.info-overlay.top-left { top: 8px; left: 8px; }
|
||||
.info-overlay.top-right { top: 8px; right: 8px; }
|
||||
.info-overlay.bottom-left { bottom: 8px; left: 8px; }
|
||||
.info-overlay.bottom-right { bottom: 8px; right: 8px; }
|
||||
.info-line { line-height: 1.5; }
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; background: rgba(0,0,0,0.8); z-index: 20;
|
||||
}
|
||||
.loading-spinner { animation: spin 1s linear infinite; color: #409eff; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.mpr-container {
|
||||
position: absolute; inset: 0; display: grid;
|
||||
grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 2px;
|
||||
}
|
||||
.mpr-pane { position: relative; background: #000; overflow: hidden; }
|
||||
.mpr-canvas { width: 100%; height: 100%; display: block; }
|
||||
.mpr-label {
|
||||
position: absolute; top: 4px; left: 8px; font-size: 11px;
|
||||
color: #0f0; font-family: monospace; z-index: 5; pointer-events: none;
|
||||
}
|
||||
.viewer-info-panel {
|
||||
padding: 8px 16px; background: #1a1a2e; border-top: 1px solid #333;
|
||||
}
|
||||
</style>
|
||||