Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -10,3 +10,5 @@ export function addReport(d){return request({url:'/reconstruction/report/add',me
|
|||||||
export function submitReport(id){return request({url:'/reconstruction/report/submit/'+id,method:'put'})}
|
export function submitReport(id){return request({url:'/reconstruction/report/submit/'+id,method:'put'})}
|
||||||
export function verifyReport(id,doctor){return request({url:'/reconstruction/report/verify/'+id,method:'put',params:{doctor}})}
|
export function verifyReport(id,doctor){return request({url:'/reconstruction/report/verify/'+id,method:'put',params:{doctor}})}
|
||||||
export function getStats(){return request({url:'/reconstruction/stats',method:'get'})}
|
export function getStats(){return request({url:'/reconstruction/stats',method:'get'})}
|
||||||
|
// 获取医生列表(有医生角色的用户)
|
||||||
|
export function getDoctorList(){return request({url:'/system/user/list',method:'get',params:{pageSize:200}})}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">{{ stats.totalReports||0 }}</div><div style="font-size:12px;color:#999">报告数</div></div></el-card></el-col>
|
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">{{ stats.totalReports||0 }}</div><div style="font-size:12px;color:#999">报告数</div></div></el-card></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-tabs v-model="activeTab" type="border-card">
|
<el-tabs v-model="activeTab" type="border-card">
|
||||||
|
<!-- 重建任务列表 -->
|
||||||
<el-tab-pane label="重建任务" name="task">
|
<el-tab-pane label="重建任务" name="task">
|
||||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||||
<el-select v-model="taskQ.taskStatus" placeholder="状态" clearable style="width:120px">
|
<el-select v-model="taskQ.taskStatus" placeholder="状态" clearable style="width:120px">
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
|
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="loadTasks">查询</el-button>
|
<el-button type="primary" @click="loadTasks">查询</el-button>
|
||||||
<el-button type="success" @click="newTaskDialog=true">新建任务</el-button>
|
<el-button type="success" @click="openNewTask">新建任务</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="taskData" border stripe>
|
<el-table :data="taskData" border stripe>
|
||||||
<el-table-column prop="patientName" label="患者" width="100"/>
|
<el-table-column prop="patientName" label="患者" width="100"/>
|
||||||
@@ -33,10 +34,11 @@
|
|||||||
<el-tag v-if="row.taskStatus==='PENDING'" type="info" size="small">待处理</el-tag>
|
<el-tag v-if="row.taskStatus==='PENDING'" type="info" size="small">待处理</el-tag>
|
||||||
<el-tag v-else-if="row.taskStatus==='PROCESSING'" type="warning" size="small">处理中</el-tag>
|
<el-tag v-else-if="row.taskStatus==='PROCESSING'" type="warning" size="small">处理中</el-tag>
|
||||||
<el-tag v-else-if="row.taskStatus==='COMPLETED'" type="success" size="small">已完成</el-tag>
|
<el-tag v-else-if="row.taskStatus==='COMPLETED'" type="success" size="small">已完成</el-tag>
|
||||||
|
<el-tag v-else-if="row.taskStatus==='CANCELLED'" type="danger" size="small">已取消</el-tag>
|
||||||
<el-tag v-else size="small">{{ row.taskStatus }}</el-tag>
|
<el-tag v-else size="small">{{ row.taskStatus }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="requestDoctor" label="申请医生" width="100"/>
|
<el-table-column prop="requestDoctor" label="申请医生" width="120"/>
|
||||||
<el-table-column prop="completeTime" label="完成时间" width="170"/>
|
<el-table-column prop="completeTime" label="完成时间" width="170"/>
|
||||||
<el-table-column label="操作" width="140">
|
<el-table-column label="操作" width="140">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
@@ -46,9 +48,11 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<!-- 3D查看器 -->
|
||||||
<el-tab-pane label="3D查看器" name="viewer">
|
<el-tab-pane label="3D查看器" name="viewer">
|
||||||
<Viewer3D :task-data="viewerTask" />
|
<Viewer3D :task-data="viewerTask" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<!-- 重建报告 -->
|
||||||
<el-tab-pane label="重建报告" name="report">
|
<el-tab-pane label="重建报告" name="report">
|
||||||
<el-table :data="reportData" border stripe>
|
<el-table :data="reportData" border stripe>
|
||||||
<el-table-column prop="id" label="ID" width="60"/>
|
<el-table-column prop="id" label="ID" width="60"/>
|
||||||
@@ -71,40 +75,131 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<el-dialog v-model="newTaskDialog" title="新建3D重建任务" width="500px">
|
|
||||||
<el-form :model="taskForm" label-width="80px">
|
<!-- 新建任务对话框 -->
|
||||||
<el-form-item label="患者ID"><el-input v-model.number="taskForm.patientId"/></el-form-item>
|
<el-dialog v-model="newTaskDialog" title="新建3D重建任务" width="560px">
|
||||||
<el-form-item label="患者姓名"><el-input v-model="taskForm.patientName"/></el-form-item>
|
<el-form :model="taskForm" label-width="90px" ref="taskFormRef" :rules="taskRules">
|
||||||
<el-form-item label="检查部位"><el-input v-model="taskForm.bodyPart"/></el-form-item>
|
<el-form-item label="患者ID" prop="patientId"><el-input-number v-model="taskForm.patientId" :min="1" style="width:100%"/></el-form-item>
|
||||||
<el-form-item label="重建类型">
|
<el-form-item label="患者姓名" prop="patientName"><el-input v-model="taskForm.patientName" placeholder="输入患者姓名"/></el-form-item>
|
||||||
<el-select v-model="taskForm.reconstructionType">
|
<el-form-item label="检查部位" prop="bodyPart">
|
||||||
|
<el-select v-model="taskForm.bodyPart" placeholder="选择检查部位" style="width:100%">
|
||||||
|
<el-option label="胸部" value="胸部"/><el-option label="头部" value="头部"/>
|
||||||
|
<el-option label="腹部" value="腹部"/><el-option label="脊柱" value="脊柱"/>
|
||||||
|
<el-option label="膝关节" value="膝关节"/><el-option label="骨盆" value="骨盆"/>
|
||||||
|
<el-option label="心脏" value="心脏"/><el-option label="四肢" value="四肢"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检查模态" prop="modality">
|
||||||
|
<el-select v-model="taskForm.modality" style="width:100%">
|
||||||
|
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="重建类型" prop="reconstructionType">
|
||||||
|
<el-select v-model="taskForm.reconstructionType" style="width:100%">
|
||||||
<el-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
|
<el-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
|
||||||
<el-option label="最大密度投影(MIP)" value="MIP"/>
|
<el-option label="最大密度投影(MIP)" value="MIP"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="申请医生"><el-input v-model="taskForm.requestDoctor"/></el-form-item>
|
<el-form-item label="申请医生" prop="requestDoctor">
|
||||||
|
<el-select v-model="taskForm.requestDoctor" filterable placeholder="选择申请医生" style="width:100%">
|
||||||
|
<el-option v-for="doc in doctorList" :key="doc.userName" :label="doc.nickName + ' (' + doc.userName + ')'" :value="doc.nickName"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检查UID"><el-input v-model="taskForm.studyUid" placeholder="DICOM Study Instance UID"/></el-form-item>
|
||||||
</el-form>
|
</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" :loading="adding">创建任务</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {ref,onMounted} from 'vue';import {ElMessage,ElMessageBox} from 'element-plus';
|
import {ref,reactive,onMounted} from 'vue'
|
||||||
import {getTaskPage,addTask,cancelTask,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
|
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||||
|
import {getTaskPage,addTask,cancelTask,getReportPage,submitReport,verifyReport,getStats,getDoctorList} from './api'
|
||||||
import Viewer3D from './viewer.vue'
|
import Viewer3D from './viewer.vue'
|
||||||
|
|
||||||
const activeTab=ref('task')
|
const activeTab=ref('task')
|
||||||
const taskData=ref([]);const reportData=ref([]);const stats=ref({})
|
const taskData=ref([])
|
||||||
|
const reportData=ref([])
|
||||||
|
const stats=ref({})
|
||||||
const viewerTask=ref({})
|
const viewerTask=ref({})
|
||||||
|
const doctorList=ref([])
|
||||||
|
const adding=ref(false)
|
||||||
|
const taskFormRef=ref(null)
|
||||||
|
|
||||||
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
|
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
|
||||||
const newTaskDialog=ref(false)
|
const newTaskDialog=ref(false)
|
||||||
const taskForm=ref({patientId:null,patientName:'',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
|
|
||||||
|
const taskForm=reactive({
|
||||||
|
patientId:null, patientName:'', bodyPart:'胸部', modality:'CT',
|
||||||
|
reconstructionType:'VR', requestDoctor:'', studyUid:''
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskRules={
|
||||||
|
patientId:[{required:true,message:'请输入患者ID',trigger:'blur'}],
|
||||||
|
patientName:[{required:true,message:'请输入患者姓名',trigger:'blur'}],
|
||||||
|
bodyPart:[{required:true,message:'请选择检查部位',trigger:'change'}],
|
||||||
|
modality:[{required:true,message:'请选择检查模态',trigger:'change'}],
|
||||||
|
reconstructionType:[{required:true,message:'请选择重建类型',trigger:'change'}],
|
||||||
|
requestDoctor:[{required:true,message:'请选择申请医生',trigger:'change'}]
|
||||||
|
}
|
||||||
|
|
||||||
const loadTasks=async()=>{const r=await getTaskPage(taskQ.value);taskData.value=r.data?.records||[]}
|
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 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 loadStats=async()=>{try{const r=await getStats();stats.value=r.data||{}}catch(e){}}
|
||||||
const doAddTask=async()=>{await addTask(taskForm.value);ElMessage.success('任务已创建');newTaskDialog.value=false;loadTasks();loadStats()}
|
const loadDoctors=async()=>{
|
||||||
const doCancelTask=async(id)=>{await cancelTask(id);ElMessage.success('已取消');loadTasks()}
|
try{
|
||||||
const openViewer=(row)=>{viewerTask.value=row;activeTab.value='viewer'}
|
const r=await getDoctorList()
|
||||||
|
const rows=r.data?.rows||r.data?.records||r.data||[]
|
||||||
|
// 过滤出有医生角色的用户
|
||||||
|
doctorList.value=Array.isArray(rows)?rows.filter(u=>{
|
||||||
|
const roles=(u.roles||u.roleName||'').toLowerCase()
|
||||||
|
return roles.includes('医生')||roles.includes('doctor')
|
||||||
|
}):[]
|
||||||
|
// 如果过滤后为空,显示所有用户供选择
|
||||||
|
if(doctorList.value.length===0 && Array.isArray(rows)){
|
||||||
|
doctorList.value=rows.slice(0,50)
|
||||||
|
}
|
||||||
|
}catch(e){doctorList.value=[]}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNewTask=()=>{
|
||||||
|
Object.assign(taskForm,{patientId:null,patientName:'',bodyPart:'胸部',modality:'CT',reconstructionType:'VR',requestDoctor:'',studyUid:''})
|
||||||
|
newTaskDialog.value=true
|
||||||
|
}
|
||||||
|
|
||||||
|
const doAddTask=async()=>{
|
||||||
|
if(!taskFormRef.value) return
|
||||||
|
try{await taskFormRef.value.validate()}catch(e){return}
|
||||||
|
adding.value=true
|
||||||
|
try{
|
||||||
|
await addTask(taskForm)
|
||||||
|
ElMessage.success('3D重建任务已创建')
|
||||||
|
newTaskDialog.value=false
|
||||||
|
loadTasks();loadStats()
|
||||||
|
}catch(e){ElMessage.error('创建失败: '+e.message)}
|
||||||
|
finally{adding.value=false}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doCancelTask=async(id)=>{
|
||||||
|
await ElMessageBox.confirm('确定取消该任务?','确认')
|
||||||
|
await cancelTask(id)
|
||||||
|
ElMessage.success('已取消');loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openViewer=(row)=>{
|
||||||
|
viewerTask.value=row
|
||||||
|
activeTab.value='viewer'
|
||||||
|
}
|
||||||
|
|
||||||
const doSubmitReport=async(id)=>{await submitReport(id);ElMessage.success('已提交');loadReports()}
|
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()}}
|
const doVerifyReport=async(id)=>{
|
||||||
onMounted(()=>{loadTasks();loadReports();loadStats()})
|
const {value}=await ElMessageBox.prompt('审核医生姓名','审核报告')
|
||||||
|
if(value){await verifyReport(id,value);ElMessage.success('已审核');loadReports()}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(()=>{loadTasks();loadReports();loadStats();loadDoctors()})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,599 +1,250 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="viewer-container">
|
<div class="viewer-wrap">
|
||||||
<!-- Toolbar -->
|
<div class="vbar">
|
||||||
<div class="viewer-toolbar">
|
<el-radio-group v-model="mode" size="small">
|
||||||
<div class="toolbar-group">
|
<el-radio-button value="VR">VR容积</el-radio-button>
|
||||||
<span class="toolbar-label">渲染模式:</span>
|
<el-radio-button value="MPR">MPR多平面</el-radio-button>
|
||||||
<el-radio-group v-model="renderMode" size="small" @change="onModeChange">
|
<el-radio-button value="MIP">MIP最大密度</el-radio-button>
|
||||||
<el-radio-button value="VR">VR</el-radio-button>
|
</el-radio-group>
|
||||||
<el-radio-button value="MPR">MPR</el-radio-button>
|
<el-divider direction="vertical"/>
|
||||||
<el-radio-button value="MIP">MIP</el-radio-button>
|
<el-button-group size="small">
|
||||||
<el-radio-button value="2D">2D</el-radio-button>
|
<el-button v-for="p in presetNames" :key="p" :type="preset===p?'primary':''" @click="setPreset(p)">{{ presetLabels[p] }}</el-button>
|
||||||
</el-radio-group>
|
</el-button-group>
|
||||||
</div>
|
<el-divider direction="vertical"/>
|
||||||
<div class="toolbar-group">
|
<el-button size="small" @click="resetView">重置</el-button>
|
||||||
<span class="toolbar-label">预设:</span>
|
<span style="margin-left:auto;font-size:11px;color:#888">左键旋转 | 滚轮缩放</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>
|
</div>
|
||||||
|
<div class="vmain" ref="mainRef">
|
||||||
<!-- Main viewer area -->
|
<canvas v-show="mode!=='MPR'" ref="c3d" class="c3d"
|
||||||
<div class="viewer-main" ref="viewerMain">
|
@mousedown="md" @mousemove="mm" @mouseup="mu" @mouseleave="mu"
|
||||||
<!-- 3D Canvas for VR/MIP/MPR -->
|
@wheel.prevent="onWheel" @contextmenu.prevent/>
|
||||||
<canvas
|
<div v-if="mode==='MPR'" class="mpr-grid">
|
||||||
ref="renderCanvas"
|
<div class="mpr-cell"><div class="mpr-h">轴位 Axial</div><canvas ref="cA" class="mpr-c"/></div>
|
||||||
class="render-canvas"
|
<div class="mpr-cell"><div class="mpr-h">矢状 Sagittal</div><canvas ref="cS" class="mpr-c"/></div>
|
||||||
@mousedown="onMouseDown"
|
<div class="mpr-cell"><div class="mpr-h">冠状 Coronal</div><canvas ref="cC" class="mpr-c"/></div>
|
||||||
@mousemove="onMouseMove"
|
<div class="mpr-cell"><div class="mpr-h">3D预览</div><canvas ref="c3d2" class="mpr-c"/></div>
|
||||||
@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>
|
||||||
<div class="info-overlay top-right">
|
<div class="ov ov-tl">
|
||||||
<div class="info-line">窗宽: {{ windowWidth }} 窗位: {{ windowCenter }}</div>
|
<div>{{ taskData.patientName || '患者' }} | {{ taskData.modality||'CT' }} {{ taskData.bodyPart||'胸部' }}</div>
|
||||||
<div class="info-line">层厚: {{ sliceThickness }}mm</div>
|
<div>{{ mode }} {{ presetLabels[preset] }} | WL:{{ wl }} WW:{{ ww }}</div>
|
||||||
<div class="info-line">像素: {{ pixelSpacing }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="info-overlay bottom-left">
|
<div class="ov ov-bl">
|
||||||
<div class="info-line">{{ renderMode }} | {{ preset.toUpperCase() }}</div>
|
<div v-if="mode==='MPR'">Z:{{ zs }} Y:{{ ys }} X:{{ xs }}</div>
|
||||||
<div class="info-line" v-if="renderMode==='MPR'">
|
<div v-else>旋转:{{ (rx*57.3).toFixed(0) }}° 缩放:{{ zm.toFixed(1) }}x</div>
|
||||||
轴位: {{ axialSlice }} | 矢状: {{ sagittalSlice }} | 冠状: {{ coronalSlice }}
|
|
||||||
</div>
|
|
||||||
<div class="info-line" v-if="renderMode==='2D'">
|
|
||||||
层面: {{ currentSlice }} / {{ totalSlices }}
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import {ref,onMounted,onUnmounted,nextTick,watch} from 'vue'
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
const props=defineProps({taskData:{type:Object,default:()=>({})}})
|
||||||
|
|
||||||
const props = defineProps({
|
const mode=ref('VR'),preset=ref('bone')
|
||||||
taskId: { type: [Number, String], default: null },
|
const mainRef=ref(null),c3d=ref(null),c3d2=ref(null)
|
||||||
taskData: { type: Object, default: () => ({}) }
|
const cA=ref(null),cS=ref(null),cC=ref(null)
|
||||||
})
|
const wl=ref(40),ww=ref(400)
|
||||||
|
const rx=ref(0.4),ry=ref(-0.6),rz=ref(0),zm=ref(1.2)
|
||||||
|
const zs=ref(32),ys=ref(32),xs=ref(32)
|
||||||
|
|
||||||
const emit = defineEmits(['measurement', 'screenshot'])
|
const presetNames=['bone','soft','lung','angio','skin']
|
||||||
|
const presetLabels={bone:'骨骼',soft:'软组织',lung:'肺部',angio:'血管',skin:'皮肤'}
|
||||||
|
|
||||||
// State
|
const SZ=64
|
||||||
const renderCanvas = ref(null)
|
let vol=null,dragging=false,lm={x:0,y:0},raf=null,ready=false
|
||||||
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
|
// Transfer functions: [minHU, maxHU, R, G, B, alpha]
|
||||||
const patientName = ref('刘潇凡')
|
const TF={
|
||||||
const studyDesc = ref('胸部CT平扫+三维重建')
|
bone:[[-1000,-500,0,0,0,0],[-500,-100,30,30,50,.1],[-100,100,80,60,60,.3],[100,500,180,170,160,.8],[500,1500,255,255,255,1]],
|
||||||
const modality = ref('CT')
|
soft:[[-1000,-200,0,0,0,0],[-200,0,40,40,60,.2],[0,80,120,80,80,.6],[80,300,200,120,100,.9],[300,1500,255,255,255,1]],
|
||||||
const bodyPart = ref('胸部')
|
lung:[[-1000,-800,0,0,0,0],[-800,-400,20,30,50,.3],[-400,-100,60,80,100,.5],[-100,100,150,100,80,.7],[100,1500,255,255,255,1]],
|
||||||
const windowWidth = ref(400)
|
angio:[[-1000,0,0,0,0,0],[0,100,30,20,20,.1],[100,200,200,50,30,.8],[200,500,255,100,50,.9],[500,1500,255,200,100,1]],
|
||||||
const windowCenter = ref(40)
|
skin:[[-1000,-200,0,0,0,0],[-200,0,50,40,35,.15],[0,60,180,130,110,.8],[60,200,220,170,140,.95],[200,1500,255,255,255,1]]
|
||||||
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' }
|
|
||||||
}
|
}
|
||||||
|
function tfl(hu,n){const s=TF[n]||TF.bone;for(const t of s)if(hu>=t[0]&&hu<=t[1])return t;return null}
|
||||||
|
|
||||||
// Transfer function colors (RGBA for different HU ranges)
|
function genVol(){
|
||||||
const transferFunctions = {
|
vol=new Int16Array(SZ*SZ*SZ)
|
||||||
bone: [
|
for(let z=0;z<SZ;z++)for(let y=0;y<SZ;y++)for(let x=0;x<SZ;x++){
|
||||||
{ min: -1000, max: -500, r: 0, g: 0, b: 0, a: 0 }, // Air - transparent
|
const dx=x-32,dy=y-32,dz=z-32
|
||||||
{ min: -500, max: -100, r: 30, g: 30, b: 50, a: 0.1 }, // Fat - dark
|
const bd=Math.sqrt((dx/28)**2+(dy/24)**2+(dz/30)**2)
|
||||||
{ min: -100, max: 100, r: 80, g: 60, b: 60, a: 0.3 }, // Soft tissue
|
let hu=-1000
|
||||||
{ min: 100, max: 500, r: 180, g: 170, b: 160, a: 0.8 }, // Bone - bright
|
if(bd<1){
|
||||||
{ min: 500, max: 1500, r: 255, g: 255, b: 255, a: 1.0 } // Dense bone - white
|
if(bd>.88)hu=-100+(Math.random()-.5)*30
|
||||||
],
|
else if(bd>.78)hu=45+(Math.random()-.5)*16
|
||||||
soft: [
|
else{
|
||||||
{ min: -1000, max: -200, r: 0, g: 0, b: 0, a: 0 },
|
for(const s of[-1,1]){const ld=Math.sqrt(((dx-s*12)/10)**2+((dy+3)/8)**2+((dz+5)/12)**2);if(ld<1){hu=-500+(Math.random()-.5)*120;break}}
|
||||||
{ min: -200, max: 0, r: 40, g: 40, b: 60, a: 0.2 },
|
if(hu===-1000){
|
||||||
{ min: 0, max: 80, r: 120, g: 80, b: 80, a: 0.6 },
|
const hd=Math.sqrt((dx/6)**2+((dy+3)/5)**2+((dz+3)/7)**2)
|
||||||
{ min: 80, max: 300, r: 200, g: 120, b: 100, a: 0.9 },
|
if(hd<1)hu=45+(Math.random()-.5)*10
|
||||||
{ min: 300, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
|
else{const sd=Math.sqrt(dx*dx+(dy+15)**2+dz*dz);if(sd<4)hu=350+(Math.random()-.5)*40;else{for(const s of[-1,1]){const rd=Math.sqrt((dx-s*22)**2+(dy+10)**2+dz*dz);if(rd<2){hu=800+(Math.random()-.5)*80;break}};if(hu===-1000)hu=40+(Math.random()-.5)*16}}
|
||||||
],
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
vol[z*SZ*SZ+y*SZ+x]=hu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer function lookup
|
function szCanvas(c){
|
||||||
function transferLookup(hu, tfName) {
|
if(!c||!c.parentElement)return
|
||||||
const tf = transferFunctions[tfName] || transferFunctions.bone
|
const r=c.parentElement.getBoundingClientRect()
|
||||||
for (const stop of tf) {
|
if(r.width<1||r.height<1)return
|
||||||
if (hu >= stop.min && hu <= stop.max) {
|
const dpr=window.devicePixelRatio||1
|
||||||
return { r: stop.r, g: stop.g, b: stop.b, a: stop.a }
|
c.width=Math.floor(r.width*dpr)
|
||||||
}
|
c.height=Math.floor(r.height*dpr)
|
||||||
}
|
|
||||||
return { r: 0, g: 0, b: 0, a: 0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ray marching volume renderer
|
function renderVR(cv,mip){
|
||||||
function renderVolume(canvas, mode) {
|
if(!cv||!vol||cv.width<1)return
|
||||||
if (!canvas || !volumeData) return
|
const ctx=cv.getContext('2d')
|
||||||
const ctx = canvas.getContext('2d')
|
const W=cv.width,H=cv.height
|
||||||
const W = canvas.width
|
const id=ctx.createImageData(W,H)
|
||||||
const H = canvas.height
|
const d=id.data
|
||||||
const imgData = ctx.createImageData(W, H)
|
const half=32
|
||||||
const data = imgData.data
|
const cX=Math.cos(rx.value),sX=Math.sin(rx.value)
|
||||||
const size = volumeSize.x
|
const cY=Math.cos(ry.value),sY=Math.sin(ry.value)
|
||||||
const tfName = preset.value
|
const cZ=Math.cos(rz.value),sZ=Math.sin(rz.value)
|
||||||
|
const tfn=preset.value
|
||||||
|
const step=Math.max(1,Math.floor(2/(zm.value||1)))
|
||||||
|
|
||||||
// Light direction for shading
|
for(let py=0;py<H;py+=step){
|
||||||
const lightDir = { x: 0.577, y: -0.577, z: 0.577 }
|
for(let px=0;px<W;px+=step){
|
||||||
|
const nx=((px/W)-.5)*2/zm.value
|
||||||
// Camera rotation matrices
|
const ny=((py/H)-.5)*2/zm.value
|
||||||
const cosX = Math.cos(rotationX), sinX = Math.sin(rotationX)
|
let r0=nx*half,c0=ny*half,t0=-half*1.5
|
||||||
const cosY = Math.cos(rotationY), sinY = Math.sin(rotationY)
|
let t1=r0*cY+t0*sY;t0=-r0*sY+t0*cY;r0=t1
|
||||||
const cosZ = Math.cos(rotationZ), sinZ = Math.sin(rotationZ)
|
t1=c0*cX-t0*sX;t0=c0*sX+t0*cX;c0=t1
|
||||||
|
t1=r0*cZ-c0*sZ;c0=r0*sZ+c0*cZ;r0=t1
|
||||||
|
|
||||||
for (let py = 0; py < H; py++) {
|
let ra=0,ga=0,ba=0,aa=0
|
||||||
for (let px = 0; px < W; px++) {
|
for(let s=0;s<100&&aa<.95;s++){
|
||||||
// Normalized device coordinates
|
const t=s*1.5-half*1.5
|
||||||
const ndcX = ((px / W) - 0.5) * 2 / zoom + panX
|
const vx=Math.round(r0+t+half),vy=Math.round(c0+half),vz=Math.round(t0+half)
|
||||||
const ndcY = ((py / H) - 0.5) * 2 / zoom + panY
|
if(vx<0||vx>=SZ||vy<0||vy>=SZ||vz<0||vz>=SZ)continue
|
||||||
|
const hu=vol[vz*SZ*SZ+vy*SZ+vx]
|
||||||
// Ray direction through volume
|
if(mip){
|
||||||
const halfSize = size / 2
|
const v=Math.max(0,Math.min(255,(hu+1024)/4))
|
||||||
|
if(v>ra){ra=v;ga=v*.8;ba=v*.7}
|
||||||
// Apply rotation
|
}else{
|
||||||
let rx = ndcX * halfSize
|
const st=tfl(hu,tfn)
|
||||||
let ry = ndcY * halfSize
|
if(st&&st[5]>.01){
|
||||||
let rz = -halfSize * 1.5
|
const da=st[5]*1.5*.015
|
||||||
|
const sh=.7+.3*Math.abs(Math.sin(hu*.01))
|
||||||
// Rotate Y
|
ra+=da*st[1]*sh/255;ga+=da*st[2]*sh/255;ba+=da*st[3]*sh/255;aa+=da
|
||||||
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 v=Math.min(255,Math.max(0,ra*255))
|
||||||
const idx = (py * W + px) * 4
|
const g=Math.min(255,Math.max(0,ga*255))
|
||||||
data[idx] = Math.min(255, Math.max(0, rAcc * 255))
|
const b=Math.min(255,Math.max(0,ba*255))
|
||||||
data[idx + 1] = Math.min(255, Math.max(0, gAcc * 255))
|
// Fill step x step block
|
||||||
data[idx + 2] = Math.min(255, Math.max(0, bAcc * 255))
|
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){
|
||||||
data[idx + 3] = 255
|
for(let dx2=0;dx2<step&&px+dx2<W;dx2++){
|
||||||
|
const i=((py+dy2)*W+(px+dx2))*4
|
||||||
|
d[i]=v;d[i+1]=g;d[i+2]=b;d[i+3]=255
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.putImageData(id,0,0)
|
||||||
ctx.putImageData(imgData, 0, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MPR slice rendering
|
function renderSlice(cv,axis,idx){
|
||||||
function renderMprSlice(canvas, axis, sliceIdx) {
|
if(!cv||!vol||cv.width<1)return
|
||||||
if (!canvas || !volumeData) return
|
const ctx=cv.getContext('2d')
|
||||||
const ctx = canvas.getContext('2d')
|
const W=cv.width,H=cv.height
|
||||||
const W = canvas.width
|
const id=ctx.createImageData(W,H)
|
||||||
const H = canvas.height
|
const d=id.data
|
||||||
const imgData = ctx.createImageData(W, H)
|
const lo=wl.value-ww.value/2,hi=wl.value+ww.value/2
|
||||||
const data = imgData.data
|
const step=Math.max(1,Math.floor(2))
|
||||||
const size = volumeSize.x
|
for(let py=0;py<H;py+=step){
|
||||||
const tfName = preset.value
|
for(let px=0;px<W;px+=step){
|
||||||
|
const nx=(px/W)*SZ,ny=(py/H)*SZ
|
||||||
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
|
let hu
|
||||||
if (axis === 'axial') {
|
const zi=Math.min(SZ-1,Math.max(0,Math.round(idx)))
|
||||||
const z = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
|
const xi=Math.min(SZ-1,Math.max(0,Math.round(nx)))
|
||||||
const x = Math.min(size - 1, Math.max(0, Math.round(nx)))
|
const yi=Math.min(SZ-1,Math.max(0,Math.round(ny)))
|
||||||
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
|
if(axis==='z')hu=vol[zi*SZ*SZ+yi*SZ+xi]
|
||||||
hu = volumeData[z * size * size + y * size + x]
|
else if(axis==='x')hu=vol[yi*SZ*SZ+xi*SZ+zi]
|
||||||
} else if (axis === 'sagittal') {
|
else hu=vol[xi*SZ*SZ+yi*SZ+zi]
|
||||||
const x = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
|
const v=hu<=lo?0:hu>=hi?255:255*(hu-lo)/(hi-lo)
|
||||||
const z = Math.min(size - 1, Math.max(0, Math.round(nx)))
|
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){
|
||||||
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
|
for(let dx2=0;dx2<step&&px+dx2<W;dx2++){
|
||||||
hu = volumeData[y * size * size + z * size + x] || volumeData[z * size * size + y * size + x]
|
const i=((py+dy2)*W+(px+dx2))*4
|
||||||
} else {
|
d[i]=v;d[i+1]=v*.95;d[i+2]=v*.9;d[i+3]=255
|
||||||
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)
|
ctx.putImageData(id,0,0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render loop
|
function frame(){
|
||||||
function render() {
|
if(!ready){raf=requestAnimationFrame(frame);return}
|
||||||
const canvas = renderCanvas.value
|
if(mode.value==='MPR'){
|
||||||
if (!canvas || !volumeData) {
|
nextTick(()=>{
|
||||||
animFrame = requestAnimationFrame(render)
|
if(cA.value)renderSlice(cA.value,'z',zs.value)
|
||||||
return
|
if(cS.value)renderSlice(cS.value,'x',xs.value)
|
||||||
}
|
if(cC.value)renderSlice(cC.value,'y',ys.value)
|
||||||
|
if(c3d2.value)renderVR(c3d2.value,false)
|
||||||
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 {
|
}else{
|
||||||
renderVolume(canvas, renderMode.value)
|
if(c3d.value)renderVR(c3d.value,mode.value==='MIP')
|
||||||
}
|
}
|
||||||
|
raf=requestAnimationFrame(frame)
|
||||||
animFrame = requestAnimationFrame(render)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse handlers
|
function md(e){dragging=true;lm={x:e.clientX,y:e.clientY}}
|
||||||
function onMouseDown(e) {
|
function mm(e){if(!dragging)return;ry.value+=(e.clientX-lm.x)*.01;rx.value+=(e.clientY-lm.y)*.01;lm={x:e.clientX,y:e.clientY}}
|
||||||
isDragging = true
|
function mu(){dragging=false}
|
||||||
lastMouse = { x: e.clientX, y: e.clientY }
|
function onWheel(e){zm.value*=e.deltaY>0?.9:1.1;zm.value=Math.max(.3,Math.min(5,zm.value))}
|
||||||
|
|
||||||
|
function setPreset(p){
|
||||||
|
preset.value=p
|
||||||
|
const m={bone:[400,2500],soft:[40,400],lung:[-600,1500],angio:[300,600],skin:[50,250]}
|
||||||
|
wl.value=m[p][0];ww.value=m[p][1]
|
||||||
}
|
}
|
||||||
function onMouseMove(e) {
|
function resetView(){rx.value=.4;ry.value=-.6;rz.value=0;zm.value=1.2}
|
||||||
if (!isDragging) return
|
|
||||||
const dx = e.clientX - lastMouse.x
|
function resizeAll(){
|
||||||
const dy = e.clientY - lastMouse.y
|
if(mode.value==='MPR'){
|
||||||
lastMouse = { x: e.clientX, y: e.clientY }
|
[cA,cS,cC,c3d2].forEach(c=>{if(c.value)szCanvas(c.value)})
|
||||||
|
}else{
|
||||||
if (tool.value === 'rotate' || (!tool.value && e.button === 0)) {
|
szCanvas(c3d.value)
|
||||||
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) {
|
// Watch mode changes to resize canvases
|
||||||
const delta = e.deltaY > 0 ? 1 : -1
|
watch(mode,()=>{nextTick(()=>{resizeAll()})})
|
||||||
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) {
|
onMounted(()=>{
|
||||||
preset.value = name
|
genVol()
|
||||||
const p = presets[name]
|
// Wait for DOM to be fully rendered, then start
|
||||||
windowCenter.value = p.windowCenter
|
nextTick(()=>{
|
||||||
windowWidth.value = p.windowWidth
|
setTimeout(()=>{
|
||||||
}
|
resizeAll()
|
||||||
|
ready=true
|
||||||
function onModeChange() {
|
frame()
|
||||||
if (renderMode.value === 'MPR') {
|
},100)
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
window.addEventListener('resize',resizeAll)
|
||||||
})
|
})
|
||||||
|
onUnmounted(()=>{
|
||||||
onUnmounted(() => {
|
if(raf)cancelAnimationFrame(raf)
|
||||||
if (animFrame) cancelAnimationFrame(animFrame)
|
window.removeEventListener('resize',resizeAll)
|
||||||
|
ready=false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.viewer-container {
|
.viewer-wrap{display:flex;flex-direction:column;height:100%;background:#0a0a1a;color:#fff}
|
||||||
display: flex; flex-direction: column; height: 100%; background: #0a0a1a; color: #fff;
|
.vbar{display:flex;gap:8px;padding:8px 12px;background:#1a1a2e;border-bottom:1px solid #333;align-items:center;flex-wrap:wrap}
|
||||||
}
|
.vmain{flex:1;position:relative;overflow:hidden;background:#000}
|
||||||
.viewer-toolbar {
|
.c3d{width:100%;height:100%;display:block;cursor:crosshair}
|
||||||
display: flex; gap: 16px; padding: 8px 16px; background: #1a1a2e;
|
.mpr-grid{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px}
|
||||||
border-bottom: 1px solid #333; flex-wrap: wrap; align-items: center;
|
.mpr-cell{position:relative;background:#000;overflow:hidden}
|
||||||
}
|
.mpr-c{width:100%;height:100%;display:block}
|
||||||
.toolbar-group { display: flex; align-items: center; gap: 6px; }
|
.mpr-h{position:absolute;top:4px;left:8px;font-size:11px;color:#0f0;font-family:monospace;z-index:5;pointer-events:none;background:rgba(0,0,0,.6);padding:2px 6px;border-radius:3px}
|
||||||
.toolbar-label { font-size: 12px; color: #999; white-space: nowrap; }
|
.ov{position:absolute;padding:6px 10px;background:rgba(0,0,0,.7);border-radius:4px;font-size:11px;font-family:'Courier New',monospace;color:#0f0;pointer-events:none;z-index:10;line-height:1.5}
|
||||||
.viewer-main {
|
.ov-tl{top:8px;left:8px}.ov-bl{bottom:8px;left:8px}
|
||||||
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user