2026-05-08-02-36-12 实现STL模型切分mask

This commit is contained in:
2026-05-08 02:45:12 +08:00
parent 7b7c555321
commit 8e0e54fc3c
6 changed files with 691 additions and 5 deletions

View File

@@ -137,6 +137,14 @@ type LibraryViewerPreview = {
window: string;
windowLabel: string;
patientId: string;
modelId?: string;
maskPixels?: number;
};
type StlModel = {
modelId: string;
name: string;
triangleCount: number;
};
type StoredDeformationJob = {
@@ -386,8 +394,18 @@ export default function App() {
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
const [isViewerLoading, setIsViewerLoading] = useState(false);
const [viewerError, setViewerError] = useState('');
const [stlModel, setStlModel] = useState<StlModel | null>(null);
const [isUploadingStl, setIsUploadingStl] = useState(false);
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
const [modelClipStart, setModelClipStart] = useState(0);
const [modelClipEnd, setModelClipEnd] = useState(0);
const [modelStartPreview, setModelStartPreview] = useState<LibraryViewerPreview | null>(null);
const [modelEndPreview, setModelEndPreview] = useState<LibraryViewerPreview | null>(null);
const [isModelMaskLoading, setIsModelMaskLoading] = useState(false);
const [modelMaskError, setModelMaskError] = useState('');
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
// --- Simulation State (Workspace) ---
const [cervicalRotation, setCervicalRotation] = useState(14.5);
@@ -427,6 +445,9 @@ export default function App() {
const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0];
const videoSourceInputDir = selectedInputDir;
const isVideoSourceReady = Boolean(videoSourceInputDir);
const viewerFrameCount = Math.max(1, viewerPreview?.count || modelStartPreview?.count || modelEndPreview?.count || libraryViewerItem?.fileCount || 1);
const clampedModelStart = Math.max(0, Math.min(viewerFrameCount - 1, modelClipStart));
const clampedModelEnd = Math.max(0, Math.min(viewerFrameCount - 1, modelClipEnd));
useEffect(() => {
if (!activeUserMenu) return;
@@ -929,6 +950,12 @@ export default function App() {
setDebouncedViewerSliceIndex('middle');
setViewerPreview(null);
setViewerError('');
setIsModelSlicingEnabled(false);
setModelClipStart(0);
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
setModelStartPreview(null);
setModelEndPreview(null);
setModelMaskError('');
};
const closeLibraryViewer = () => {
@@ -936,6 +963,10 @@ export default function App() {
setViewerPreview(null);
setViewerError('');
setIsViewerLoading(false);
setIsModelSlicingEnabled(false);
setModelStartPreview(null);
setModelEndPreview(null);
setModelMaskError('');
};
useEffect(() => {
@@ -970,6 +1001,81 @@ export default function App() {
return () => controller.abort();
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
useEffect(() => {
if (!libraryViewerItem || !viewerPreview?.count) return;
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
setModelClipEnd(current => {
if (current <= 0) return viewerPreview.count - 1;
return Math.max(0, Math.min(viewerPreview.count - 1, current));
});
}, [libraryViewerItem?.id, viewerPreview?.count]);
useEffect(() => {
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
const controller = new AbortController();
const makeUrl = (index: number) => (
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}&window=${encodeURIComponent(viewerWindow)}&modelId=${encodeURIComponent(stlModel.modelId)}`
);
setIsModelMaskLoading(true);
setModelMaskError('');
Promise.all([
fetch(makeUrl(clampedModelStart), { signal: controller.signal }).then(async response => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || '起点帧 mask 生成失败');
return data as LibraryViewerPreview;
}),
fetch(makeUrl(clampedModelEnd), { signal: controller.signal }).then(async response => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || '终点帧 mask 生成失败');
return data as LibraryViewerPreview;
}),
])
.then(([startPreview, endPreview]) => {
setModelStartPreview(startPreview);
setModelEndPreview(endPreview);
})
.catch(error => {
if ((error as Error).name !== 'AbortError') setModelMaskError((error as Error).message);
})
.finally(() => {
if (!controller.signal.aborted) setIsModelMaskLoading(false);
});
return () => controller.abort();
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]);
const uploadStlModel = () => {
stlUploadInputRef.current?.click();
};
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setIsUploadingStl(true);
try {
const response = await fetch(`${API_BASE}/api/model/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/sla',
'x-file-name': encodeURIComponent(file.name),
},
body: await file.arrayBuffer(),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'STL 上传失败');
setStlModel(data);
setIsModelSlicingEnabled(true);
showToast(`已载入 STL${data.triangleCount || 0} 个三角面`);
} catch (error) {
showToast((error as Error).message);
} finally {
setIsUploadingStl(false);
}
};
const changePassword = (userId: string, newPass: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
setPwChangeInput('');
@@ -1972,6 +2078,13 @@ export default function App() {
<X size={20} />
</button>
</div>
<input
ref={stlUploadInputRef}
type="file"
accept=".stl,model/stl,application/sla"
className="hidden"
onChange={handleStlSelected}
/>
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
<div className="space-y-5">
@@ -2011,6 +2124,7 @@ export default function App() {
</select>
</div>
{!isModelSlicingEnabled && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></p>
@@ -2027,6 +2141,74 @@ export default function App() {
className="w-full h-1.5 accent-blue-600 cursor-pointer"
/>
</div>
)}
<div className="rounded-2xl border border-slate-100 bg-white p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></p>
<p className="text-[10px] font-bold text-slate-400 mt-1 truncate">
{stlModel ? `${stlModel.name} · ${stlModel.triangleCount}` : '上传 STL 后启用真实 mask'}
</p>
</div>
<button
onClick={() => setIsModelSlicingEnabled(value => !value)}
disabled={!stlModel}
className={`px-3 py-2 rounded-xl text-[10px] font-black transition-all disabled:opacity-40 ${
isModelSlicingEnabled
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{isModelSlicingEnabled ? '已启用' : '启用'}
</button>
</div>
<button
onClick={uploadStlModel}
disabled={isUploadingStl}
className="w-full py-2.5 rounded-xl bg-slate-900 text-white text-xs font-black hover:bg-blue-600 transition-all disabled:opacity-50"
>
{isUploadingStl ? '上传解析中...' : '上传 STL 模型'}
</button>
{isModelSlicingEnabled && (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] font-mono font-black">
<span className="text-blue-600"> {clampedModelStart + 1}</span>
<span className="text-orange-600"> {clampedModelEnd + 1}</span>
</div>
<div className="relative h-8">
<div className="absolute left-0 right-0 top-3 h-1.5 rounded-full bg-slate-200"></div>
<div
className="absolute top-3 h-1.5 rounded-full bg-blue-500/60"
style={{
left: `${Math.min(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}%`,
right: `${100 - Math.max(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}%`,
}}
></div>
<input
type="range"
min="0"
max={viewerFrameCount - 1}
value={clampedModelStart}
onChange={event => setModelClipStart(parseInt(event.target.value, 10))}
className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-blue-600 cursor-pointer"
/>
<input
type="range"
min="0"
max={viewerFrameCount - 1}
value={clampedModelEnd}
onChange={event => setModelClipEnd(parseInt(event.target.value, 10))}
className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-orange-500 cursor-pointer"
/>
</div>
<p className="text-[10px] font-bold text-slate-400">
STL
</p>
</div>
)}
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<div className="flex items-center justify-between text-xs">
@@ -2045,7 +2227,36 @@ export default function App() {
</div>
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
{viewerPreview?.imageUrl && !viewerError ? (
{isModelSlicingEnabled && stlModel ? (
<div className="w-full h-full grid grid-cols-1 xl:grid-cols-2 gap-0">
{[
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
].map(item => (
<div key={item.label} className="relative min-h-[330px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
{item.preview?.imageUrl && !modelMaskError ? (
<img src={`${API_BASE}${item.preview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/35">
<ImageIcon size={38} className="mx-auto mb-3" />
<p className="text-xs font-bold">{modelMaskError || '等待 STL mask'}</p>
</div>
)}
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
<p className={`text-[10px] font-black ${item.color}`}>{item.label}</p>
<p className="text-[10px] font-mono text-white/70 mt-0.5">
{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}
</p>
</div>
<div className="absolute right-4 bottom-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
<p className="text-[10px] font-black text-white/70">
MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'}
</p>
</div>
</div>
))}
</div>
) : viewerPreview?.imageUrl && !viewerError ? (
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/35">
@@ -2053,7 +2264,7 @@ export default function App() {
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
</div>
)}
{isViewerLoading && (
{(isViewerLoading || isModelMaskLoading) && (
<div className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
...
</div>