2026-05-04-04-58-36 优化DICOM缓存和三维融合预览

This commit is contained in:
2026-05-04 05:15:59 +08:00
parent 4aad0f815d
commit 4ef3be69f4
9 changed files with 837 additions and 118 deletions

View File

@@ -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>