2026-05-20-01-38-33 完善NII导出与位姿持久化

This commit is contained in:
2026-05-20 01:56:54 +08:00
parent 19bd706453
commit 7099bfde8d
8 changed files with 1084 additions and 145 deletions

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Dices,
Settings2,
Download,
Rotate3d,
@@ -12,18 +11,8 @@ import {
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadMask, downloadSelectedProjectExports, ProjectExportTarget } from '../lib/api';
interface ModelPreviewPayload {
fileName: string;
@@ -71,6 +60,16 @@ const defaultModelPose: ModelPose = {
scale: 1,
};
const defaultSavedPoses: SavedModelPose[] = [
{ id: 'default', name: '默认', pose: defaultModelPose },
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
];
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6;
@@ -120,6 +119,33 @@ function createDicomTexture(frame: string, width: number, height: number) {
return texture;
}
function CoordinateAxesInset() {
return (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-xl border border-white/10 bg-black/65 p-2 shadow-lg backdrop-blur-sm">
<svg width="72" height="72" viewBox="0 0 72 72" aria-hidden="true" className="block">
<defs>
<marker id="axis-arrow-red" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 Z" fill="#ef4444" />
</marker>
<marker id="axis-arrow-green" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 Z" fill="#22c55e" />
</marker>
<marker id="axis-arrow-blue" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 Z" fill="#38bdf8" />
</marker>
</defs>
<circle cx="28" cy="44" r="3" fill="#e5e7eb" />
<line x1="28" y1="44" x2="58" y2="44" stroke="#ef4444" strokeWidth="3" markerEnd="url(#axis-arrow-red)" />
<line x1="28" y1="44" x2="14" y2="58" stroke="#22c55e" strokeWidth="3" markerEnd="url(#axis-arrow-green)" />
<line x1="28" y1="44" x2="28" y2="12" stroke="#38bdf8" strokeWidth="3" markerEnd="url(#axis-arrow-blue)" />
<text x="61" y="48" fill="#fecaca" fontSize="10" fontWeight="700">X</text>
<text x="5" y="66" fill="#bbf7d0" fontSize="10" fontWeight="700">Y</text>
<text x="24" y="10" fill="#bae6fd" fontSize="10" fontWeight="700">Z</text>
</svg>
</div>
);
}
function FusionThreeView({
project,
volume,
@@ -492,6 +518,7 @@ function FusionThreeView({
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
</div>
<CoordinateAxesInset />
{loadProgress < 100 && (
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
@@ -1679,14 +1706,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [showBounds, setShowBounds] = useState(true);
const [cutEnabled, setCutEnabled] = useState(false);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
{ id: 'default', name: '默认', pose: defaultModelPose },
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
]);
const [savedPoses, setSavedPoses] = useState<SavedModelPose[]>(defaultSavedPoses);
const [selectedPoseId, setSelectedPoseId] = useState('default');
const [isRegistering, setIsRegistering] = useState(false);
const [progress, setProgress] = useState(0);
const [showExportMenu, setShowExportMenu] = useState(false);
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true,
segmentation: true,
pose: true,
});
const [project, setProject] = useState<Project | null>(null);
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
@@ -1694,15 +1721,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const handleStartRegistration = () => {
setIsRegistering(true);
setProgress(0);
};
const handleExport = async (format: 'nii' | 'nii.gz') => {
setExporting(true);
try {
await downloadMask(projectId, format);
await downloadMask(projectId, format, modelPose);
} catch (error) {
setFusionError(error instanceof Error ? error.message : '导出失败');
} finally {
@@ -1710,6 +1732,27 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
}
};
const handleExportSelected = async () => {
const selectedItems = exportOptions
.filter((option) => exportSelection[option.id])
.map((option) => option.id);
if (!selectedItems.length) {
setFusionError('请至少选择一个导出内容');
return;
}
setExporting(true);
setFusionError('');
try {
await downloadSelectedProjectExports(projectId, selectedItems, 'nii.gz', { pose: modelPose });
window.setTimeout(() => setExporting(false), selectedItems.length * 220 + 200);
setShowExportMenu(false);
} catch (error) {
setFusionError(error instanceof Error ? error.message : '导出失败');
setExporting(false);
}
};
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true,
color: fallback?.color ?? moduleColors[index % moduleColors.length],
@@ -1764,6 +1807,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
setSavedPoses(item.modelPoses?.length ? item.modelPoses : defaultSavedPoses);
setSelectedPoseId('default');
}).catch(() => {
setProject(null);
setFusionVolume(null);
@@ -1796,17 +1841,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
}
}, []);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
return () => clearTimeout(timer);
}
if (progress >= 100) {
setIsRegistering(false);
}
return undefined;
}, [isRegistering, progress]);
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
@@ -1885,20 +1919,35 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
};
const commitSavedPoses = (next: SavedModelPose[]) => {
setSavedPoses(next);
if (!project) {
return;
}
api.updateProjectModelPoses(project.id, next)
.then((updated) => {
setProject(updated);
setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next);
})
.catch(() => {
setFusionError('位姿保存失败,请稍后重试');
});
};
const saveCurrentPose = () => {
const nextPose = {
id: `pose-${Date.now()}`,
name: `位姿${savedPoses.length - 2}`,
pose: { ...modelPose },
};
setSavedPoses((current) => [...current, nextPose]);
commitSavedPoses([...savedPoses, nextPose]);
setSelectedPoseId(nextPose.id);
};
const renamePose = (poseId: string, name: string) => {
if (poseId === 'default') return;
const nextName = name.trim();
setSavedPoses((current) => current.map((item) => (
commitSavedPoses(savedPoses.map((item) => (
item.id === poseId ? { ...item, name: nextName || item.name } : item
)));
};
@@ -1935,24 +1984,52 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
{!project && <p className="text-sm text-slate-500"> DICOM </p>}
</div>
<div className="flex gap-2">
<button
onClick={handleStartRegistration}
disabled={isRegistering}
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
{isRegistering ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : <Dices size={18} />}
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Download size={18} />
{exporting ? '正在导出' : '导出 NII.GZ'}
</button>
<div className="relative">
<button
onClick={() => setShowExportMenu((value) => !value)}
disabled={exporting}
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Download size={18} />
{exporting ? '正在导出' : '导出全部 NII.GZ'}
</button>
{showExportMenu && (
<div className="absolute right-0 top-12 z-30 w-72 rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="font-bold text-slate-700"></p>
<button
onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true })}
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
>
</button>
</div>
<div className="space-y-2">
{exportOptions.map((option) => (
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
<input
type="checkbox"
checked={exportSelection[option.id]}
onChange={(event) => setExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
className="accent-emerald-600"
/>
<span className="min-w-0 flex-1">
<span className="block">{option.label}</span>
<span className="block text-[10px] text-slate-400">{option.description}</span>
</span>
</label>
))}
</div>
<button
onClick={handleExportSelected}
disabled={exporting}
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
>
</button>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, OverviewSummary, Project, SessionState, UserRecord } from '../types';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
@@ -55,6 +57,11 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({ moduleStyles }),
}),
updateProjectModelPoses: (projectId: string, modelPoses: SavedModelPose[]) =>
request<Project>(`/api/projects/${projectId}/model-poses`, {
method: 'PATCH',
body: JSON.stringify({ modelPoses }),
}),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
@@ -67,46 +74,43 @@ export const api = {
}),
};
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz') {
const response = await fetch(`/api/projects/${projectId}/export-mask?format=${encodeURIComponent(format)}`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`导出失败:${response.status}`);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? `segmentation-mask.${format}`;
const url = URL.createObjectURL(blob);
function triggerFileDownload(url: string) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function appendPose(params: URLSearchParams, pose?: ModelPose) {
if (pose) {
params.set('pose', JSON.stringify(pose));
}
}
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose) {
const params = new URLSearchParams({ format });
appendPose(params, pose);
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`);
}
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) {
const params = new URLSearchParams({ target, format });
if (target !== 'dicom') {
appendPose(params, options.pose);
}
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
}
export async function downloadSelectedProjectExports(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) {
targets.forEach((target, index) => {
window.setTimeout(() => {
void downloadProjectExport(projectId, target, format, options);
}, index * 180);
});
}
export async function downloadDicomArchive(projectId: string) {
const response = await fetch(`/api/projects/${projectId}/dicom-archive`);
if (!response.ok) {
throw new Error(`DICOM 压缩包下载失败:${response.status}`);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? `${projectId}-dicom-series.tar.gz`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
triggerFileDownload(`/api/projects/${projectId}/dicom-archive`);
}

View File

@@ -21,6 +21,7 @@ export interface Project {
exportedMaskCount?: number;
isDefault?: boolean;
moduleStyles?: Record<string, ModuleStyle>;
modelPoses?: SavedModelPose[];
}
export interface ModuleStyle {
@@ -30,6 +31,22 @@ export interface ModuleStyle {
partId: number;
}
export interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
export interface SavedModelPose {
id: string;
name: string;
pose: ModelPose;
}
export interface MaskMapping {
className: string;
color: string;