2026-05-04-04-58-36 优化DICOM缓存和三维融合预览
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Dices,
|
||||
@@ -14,21 +14,37 @@ import {
|
||||
Plus,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei';
|
||||
import { MaskMapping, Project } from '../types';
|
||||
import { DicomPreview, MaskMapping, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
function InteractiveModel({ offset }: { offset: [number, number, number] }) {
|
||||
function FusionDicomCanvas({ preview }: { preview: DicomPreview }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext('2d');
|
||||
if (!canvas || !context) return;
|
||||
|
||||
const binary = atob(preview.pixels);
|
||||
const imageData = context.createImageData(preview.width, preview.height);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
const value = binary.charCodeAt(i);
|
||||
const offset = i * 4;
|
||||
imageData.data[offset] = value;
|
||||
imageData.data[offset + 1] = value;
|
||||
imageData.data[offset + 2] = value;
|
||||
imageData.data[offset + 3] = 255;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<mesh position={offset}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#3b82f6" transparent opacity={0.6} />
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<boxGeometry args={[2.05, 2.05, 2.05]} />
|
||||
<meshBasicMaterial color="#ffffff" wireframe />
|
||||
</mesh>
|
||||
</mesh>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +54,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [fusionPreview, setFusionPreview] = useState<DicomPreview | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportMessage, setExportMessage] = useState('准备就绪');
|
||||
|
||||
@@ -66,9 +83,25 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getProject(projectId).then(setProject).catch(() => setProject(null));
|
||||
api.getProject(projectId).then((item) => {
|
||||
setProject(item);
|
||||
const middleSlice = Math.floor((item.dicomCount || 1) / 2);
|
||||
setSlice(middleSlice);
|
||||
return api.getDicomPreview(item.id, middleSlice, 'axial', 'soft');
|
||||
}).then(setFusionPreview).catch(() => {
|
||||
setProject(null);
|
||||
setFusionPreview(null);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
api.getDicomPreview(project.id, slice, 'axial', 'soft').then(setFusionPreview).catch(() => setFusionPreview(null));
|
||||
}, 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [project?.id, slice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRegistering && progress < 100) {
|
||||
const timer = setTimeout(() => setProgress(p => p + 2), 50);
|
||||
@@ -86,6 +119,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<p className="text-slate-500 mt-1">
|
||||
{project ? `${project.name} · ${project.dicomPath} ↔ ${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
|
||||
</p>
|
||||
{project && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
|
||||
<span className="rounded-lg bg-blue-50 px-3 py-1 text-blue-700">当前项目:{project.name}</span>
|
||||
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">DICOM {project.dicomCount}</span>
|
||||
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">STL {project.modelCount ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -117,24 +157,28 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<Rotate3d size={18} className="text-blue-500" />
|
||||
影像与模型融合视角
|
||||
</h3>
|
||||
<span className="text-[10px] font-mono text-slate-400">Layer: {slice}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400">Layer: {slice + 1}/{project?.dicomCount ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
|
||||
<div className="absolute inset-0 z-0 opacity-40">
|
||||
<div className="w-full h-full flex items-center justify-center p-12">
|
||||
<div className="w-full h-full border-2 border-white/5 rounded-full flex items-center justify-center anonymous-dicom-grid" />
|
||||
</div>
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center p-8">
|
||||
<div className="relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
{fusionPreview ? (
|
||||
<FusionDicomCanvas preview={fusionPreview} />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40">正在载入 DICOM...</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[58%] w-[58%] -translate-x-1/2 -translate-y-1/2 rounded-[46%_54%_44%_56%] border-2 border-blue-400/90 bg-blue-500/20 shadow-[0_0_40px_rgba(59,130,246,0.35)]"
|
||||
style={{ transform: `translate(calc(-50% + ${offset[0] * 5}px), -50%)` }}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 h-[64%] w-[64%] -translate-x-1/2 -translate-y-1/2 rounded-[52%_48%_57%_43%] border border-emerald-300/70 bg-emerald-400/10" />
|
||||
<div className="absolute inset-x-0 top-1/2 h-px bg-cyan-400/25" />
|
||||
<div className="absolute inset-y-0 left-1/2 w-px bg-cyan-400/25" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-10">
|
||||
<Canvas>
|
||||
<PerspectiveCamera makeDefault position={[3, 3, 3]} />
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<InteractiveModel offset={offset} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
<div className="absolute left-4 top-4 z-20 rounded-xl bg-black/60 px-3 py-2 text-[10px] font-mono text-white/50">
|
||||
DICOM 与 STL 已等比例归一化并中心对齐
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
|
||||
@@ -149,6 +193,18 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
onChange={(e) => setOffset([Number(e.target.value), offset[1], offset[2]])}
|
||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-white uppercase opacity-60">切片</span>
|
||||
<span className="text-[9px] text-blue-300">{slice + 1}/{project?.dicomCount ?? 0}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max((project?.dicomCount ?? 1) - 1, 0)}
|
||||
value={slice}
|
||||
onChange={(e) => setSlice(Number(e.target.value))}
|
||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user