2026-05-08-02-36-12 实现STL模型切分mask
This commit is contained in:
@@ -137,6 +137,14 @@ type LibraryViewerPreview = {
|
|||||||
window: string;
|
window: string;
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
|
modelId?: string;
|
||||||
|
maskPixels?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StlModel = {
|
||||||
|
modelId: string;
|
||||||
|
name: string;
|
||||||
|
triangleCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredDeformationJob = {
|
type StoredDeformationJob = {
|
||||||
@@ -386,8 +394,18 @@ export default function App() {
|
|||||||
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
||||||
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
||||||
const [viewerError, setViewerError] = useState('');
|
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 folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// --- Simulation State (Workspace) ---
|
// --- Simulation State (Workspace) ---
|
||||||
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
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 selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0];
|
||||||
const videoSourceInputDir = selectedInputDir;
|
const videoSourceInputDir = selectedInputDir;
|
||||||
const isVideoSourceReady = Boolean(videoSourceInputDir);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeUserMenu) return;
|
if (!activeUserMenu) return;
|
||||||
@@ -929,6 +950,12 @@ export default function App() {
|
|||||||
setDebouncedViewerSliceIndex('middle');
|
setDebouncedViewerSliceIndex('middle');
|
||||||
setViewerPreview(null);
|
setViewerPreview(null);
|
||||||
setViewerError('');
|
setViewerError('');
|
||||||
|
setIsModelSlicingEnabled(false);
|
||||||
|
setModelClipStart(0);
|
||||||
|
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
||||||
|
setModelStartPreview(null);
|
||||||
|
setModelEndPreview(null);
|
||||||
|
setModelMaskError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeLibraryViewer = () => {
|
const closeLibraryViewer = () => {
|
||||||
@@ -936,6 +963,10 @@ export default function App() {
|
|||||||
setViewerPreview(null);
|
setViewerPreview(null);
|
||||||
setViewerError('');
|
setViewerError('');
|
||||||
setIsViewerLoading(false);
|
setIsViewerLoading(false);
|
||||||
|
setIsModelSlicingEnabled(false);
|
||||||
|
setModelStartPreview(null);
|
||||||
|
setModelEndPreview(null);
|
||||||
|
setModelMaskError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -970,6 +1001,81 @@ export default function App() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
|
}, [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) => {
|
const changePassword = (userId: string, newPass: string) => {
|
||||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||||
setPwChangeInput('');
|
setPwChangeInput('');
|
||||||
@@ -1972,6 +2078,13 @@ export default function App() {
|
|||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -2011,6 +2124,7 @@ export default function App() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isModelSlicingEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">切片</p>
|
<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"
|
className="w-full h-1.5 accent-blue-600 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
@@ -2045,7 +2227,36 @@ export default function App() {
|
|||||||
</div>
|
</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">
|
<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" />
|
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-white/35">
|
<div className="text-center text-white/35">
|
||||||
@@ -2053,7 +2264,7 @@ export default function App() {
|
|||||||
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
|
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
|
||||||
</div>
|
</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 className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
|
||||||
载入中...
|
载入中...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
251
web_backend.py
251
web_backend.py
@@ -2,6 +2,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -17,6 +18,7 @@ from urllib.parse import parse_qs, quote, unquote, urlparse
|
|||||||
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
||||||
|
|
||||||
import pydicom
|
import pydicom
|
||||||
|
import numpy as np
|
||||||
from pydicom.multival import MultiValue
|
from pydicom.multival import MultiValue
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
@@ -50,6 +52,9 @@ RESULT_DIR = APP_DIR / "web_results"
|
|||||||
JOBS_META = RESULT_DIR / "jobs.json"
|
JOBS_META = RESULT_DIR / "jobs.json"
|
||||||
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
|
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
|
||||||
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
||||||
|
MODEL_DIR = LIBRARY_DIR / "_stl_models"
|
||||||
|
STL_MODEL_CACHE = {}
|
||||||
|
STL_MODEL_CACHE_LOCK = threading.Lock()
|
||||||
VIEWER_WINDOWS = {
|
VIEWER_WINDOWS = {
|
||||||
"default": {"label": "默认", "low": -500, "high": 1200},
|
"default": {"label": "默认", "low": -500, "high": 1200},
|
||||||
"bone": {"label": "骨窗", "low": -500, "high": 1800},
|
"bone": {"label": "骨窗", "low": -500, "high": 1800},
|
||||||
@@ -73,6 +78,11 @@ def safe_filename(name):
|
|||||||
return "".join(char if char.isalnum() or char in "._-" else "_" for char in Path(name).name)
|
return "".join(char if char.isalnum() or char in "._-" else "_" for char in Path(name).name)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_model_filename(name):
|
||||||
|
name = safe_filename(name or "model.stl")
|
||||||
|
return name if name.lower().endswith(".stl") else f"{name}.stl"
|
||||||
|
|
||||||
|
|
||||||
def normalized_username(username):
|
def normalized_username(username):
|
||||||
username = str(username or "").strip()
|
username = str(username or "").strip()
|
||||||
return username or "anonymous"
|
return username or "anonymous"
|
||||||
@@ -231,6 +241,222 @@ def clear_dicom_caches(dicom_dir=None):
|
|||||||
DICOM_VOLUME_CACHE.pop(cache_key, None)
|
DICOM_VOLUME_CACHE.pop(cache_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ascii_stl(text):
|
||||||
|
vertices = []
|
||||||
|
triangles = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) == 4 and parts[0].lower() == "vertex":
|
||||||
|
try:
|
||||||
|
vertices.append([float(parts[1]), float(parts[2]), float(parts[3])])
|
||||||
|
except ValueError:
|
||||||
|
vertices = []
|
||||||
|
break
|
||||||
|
if len(vertices) == 3:
|
||||||
|
triangles.append(vertices)
|
||||||
|
vertices = []
|
||||||
|
if not triangles:
|
||||||
|
raise RuntimeError("STL 文件中没有可解析的三角面。")
|
||||||
|
return np.asarray(triangles, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_stl(data):
|
||||||
|
if len(data) < 84:
|
||||||
|
raise RuntimeError("STL 文件过小,无法解析。")
|
||||||
|
triangle_count = struct.unpack_from("<I", data, 80)[0]
|
||||||
|
expected_size = 84 + triangle_count * 50
|
||||||
|
if triangle_count <= 0 or expected_size > len(data):
|
||||||
|
raise RuntimeError("Binary STL 三角面数量异常。")
|
||||||
|
|
||||||
|
triangles = np.zeros((triangle_count, 3, 3), dtype=np.float32)
|
||||||
|
offset = 84
|
||||||
|
for index in range(triangle_count):
|
||||||
|
values = struct.unpack_from("<12fH", data, offset)
|
||||||
|
triangles[index] = np.asarray(values[3:12], dtype=np.float32).reshape(3, 3)
|
||||||
|
offset += 50
|
||||||
|
return triangles
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stl_bytes(data):
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
if text.lstrip().lower().startswith("solid"):
|
||||||
|
try:
|
||||||
|
return parse_ascii_stl(text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return parse_binary_stl(data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_uploaded_stl(headers, body):
|
||||||
|
if not body:
|
||||||
|
raise RuntimeError("上传的 STL 文件为空。")
|
||||||
|
safe_mkdir(MODEL_DIR)
|
||||||
|
source_name = safe_model_filename(unquote(headers.get("x-file-name", "model.stl")))
|
||||||
|
model_id = uuid.uuid4().hex[:12]
|
||||||
|
model_path = MODEL_DIR / f"{model_id}_{source_name}"
|
||||||
|
model_path.write_bytes(body)
|
||||||
|
triangles = parse_stl_bytes(body)
|
||||||
|
bounds_min = triangles.reshape(-1, 3).min(axis=0).tolist()
|
||||||
|
bounds_max = triangles.reshape(-1, 3).max(axis=0).tolist()
|
||||||
|
with STL_MODEL_CACHE_LOCK:
|
||||||
|
STL_MODEL_CACHE[model_id] = {
|
||||||
|
"path": model_path,
|
||||||
|
"triangles": triangles,
|
||||||
|
"name": source_name,
|
||||||
|
"bounds": [bounds_min, bounds_max],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"modelId": model_id,
|
||||||
|
"name": source_name,
|
||||||
|
"triangleCount": int(triangles.shape[0]),
|
||||||
|
"bounds": [bounds_min, bounds_max],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_stl_model(model_id):
|
||||||
|
model_id = safe_filename(model_id)
|
||||||
|
if not model_id:
|
||||||
|
raise RuntimeError("模型 ID 为空。")
|
||||||
|
with STL_MODEL_CACHE_LOCK:
|
||||||
|
cached = STL_MODEL_CACHE.get(model_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
matches = list(MODEL_DIR.glob(f"{model_id}_*.stl"))
|
||||||
|
if not matches:
|
||||||
|
raise RuntimeError("没有找到已上传的 STL 模型。")
|
||||||
|
model_path = matches[0]
|
||||||
|
triangles = parse_stl_bytes(model_path.read_bytes())
|
||||||
|
bounds_min = triangles.reshape(-1, 3).min(axis=0).tolist()
|
||||||
|
bounds_max = triangles.reshape(-1, 3).max(axis=0).tolist()
|
||||||
|
model = {
|
||||||
|
"path": model_path,
|
||||||
|
"triangles": triangles,
|
||||||
|
"name": model_path.name.split("_", 1)[1] if "_" in model_path.name else model_path.name,
|
||||||
|
"bounds": [bounds_min, bounds_max],
|
||||||
|
}
|
||||||
|
with STL_MODEL_CACHE_LOCK:
|
||||||
|
STL_MODEL_CACHE[model_id] = model
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def dicom_geometry(dicom_dir):
|
||||||
|
dicom_files = sorted_dicom_files(dicom_dir)
|
||||||
|
if not dicom_files:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
first = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True)
|
||||||
|
last = pydicom.dcmread(str(dicom_files[-1]), stop_before_pixels=True, force=True)
|
||||||
|
orientation = np.asarray(first.ImageOrientationPatient, dtype=np.float64)
|
||||||
|
col_dir = orientation[:3]
|
||||||
|
row_dir = orientation[3:]
|
||||||
|
slice_dir = np.cross(col_dir, row_dir)
|
||||||
|
pixel_spacing = np.asarray(first.PixelSpacing, dtype=np.float64)
|
||||||
|
row_spacing = float(pixel_spacing[0])
|
||||||
|
col_spacing = float(pixel_spacing[1])
|
||||||
|
first_pos = np.asarray(first.ImagePositionPatient, dtype=np.float64)
|
||||||
|
last_pos = np.asarray(last.ImagePositionPatient, dtype=np.float64)
|
||||||
|
slice_spacing = float(np.linalg.norm(last_pos - first_pos) / max(1, len(dicom_files) - 1))
|
||||||
|
if slice_spacing <= 0:
|
||||||
|
slice_spacing = float(getattr(first, "SliceThickness", 1) or 1)
|
||||||
|
basis = np.column_stack([
|
||||||
|
slice_dir * slice_spacing,
|
||||||
|
row_dir * row_spacing,
|
||||||
|
col_dir * col_spacing,
|
||||||
|
])
|
||||||
|
inverse = np.linalg.inv(basis)
|
||||||
|
return {
|
||||||
|
"origin": first_pos,
|
||||||
|
"inverse": inverse,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stl_triangles_to_voxels(triangles, dicom_dir):
|
||||||
|
geometry = dicom_geometry(dicom_dir)
|
||||||
|
if not geometry:
|
||||||
|
return triangles.astype(np.float32)
|
||||||
|
points = triangles.reshape(-1, 3).astype(np.float64)
|
||||||
|
voxel_points = (points - geometry["origin"]) @ geometry["inverse"].T
|
||||||
|
return voxel_points.reshape(triangles.shape).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def triangle_plane_segment(triangle, axis, value):
|
||||||
|
intersections = []
|
||||||
|
for start, end in [(triangle[0], triangle[1]), (triangle[1], triangle[2]), (triangle[2], triangle[0])]:
|
||||||
|
start_delta = float(start[axis] - value)
|
||||||
|
end_delta = float(end[axis] - value)
|
||||||
|
if abs(start_delta) < 1e-4 and abs(end_delta) < 1e-4:
|
||||||
|
continue
|
||||||
|
if abs(start_delta) < 1e-4:
|
||||||
|
intersections.append(start)
|
||||||
|
if start_delta * end_delta < 0:
|
||||||
|
t = start_delta / (start_delta - end_delta)
|
||||||
|
intersections.append(start + t * (end - start))
|
||||||
|
elif abs(end_delta) < 1e-4:
|
||||||
|
intersections.append(end)
|
||||||
|
|
||||||
|
unique = []
|
||||||
|
for point in intersections:
|
||||||
|
if not any(np.linalg.norm(point - existing) < 1e-3 for existing in unique):
|
||||||
|
unique.append(point)
|
||||||
|
if len(unique) >= 2:
|
||||||
|
return unique[0], unique[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_stl_slice_mask(triangles, plane, index, image_shape):
|
||||||
|
height, width = image_shape
|
||||||
|
axis = 1 if plane == "coronal" else 2
|
||||||
|
mask_image = Image.new("L", (width, height), 0)
|
||||||
|
draw = ImageDraw.Draw(mask_image)
|
||||||
|
segment_count = 0
|
||||||
|
|
||||||
|
for triangle in triangles:
|
||||||
|
segment = triangle_plane_segment(triangle, axis, index)
|
||||||
|
if not segment:
|
||||||
|
continue
|
||||||
|
points = []
|
||||||
|
for point in segment:
|
||||||
|
if plane == "coronal":
|
||||||
|
x_value = float(point[2])
|
||||||
|
else:
|
||||||
|
x_value = float(point[1])
|
||||||
|
y_value = float(point[0])
|
||||||
|
points.append((x_value, y_value))
|
||||||
|
draw.line(points, fill=255, width=2)
|
||||||
|
segment_count += 1
|
||||||
|
|
||||||
|
if segment_count == 0:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
mask = np.asarray(mask_image) > 0
|
||||||
|
try:
|
||||||
|
from scipy.ndimage import binary_fill_holes
|
||||||
|
filled = binary_fill_holes(mask)
|
||||||
|
if int(filled.sum()) > int(mask.sum()):
|
||||||
|
mask = filled
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
mask_pixels = int(mask.sum())
|
||||||
|
if mask_pixels == 0:
|
||||||
|
return None, 0
|
||||||
|
return Image.fromarray((mask.astype(np.uint8) * 255), mode="L"), mask_pixels
|
||||||
|
|
||||||
|
|
||||||
|
def overlay_mask_on_preview(preview, mask):
|
||||||
|
overlay = Image.new("RGBA", preview.size, (255, 120, 20, 0))
|
||||||
|
alpha = mask.resize(preview.size, Image.Resampling.NEAREST)
|
||||||
|
overlay.putalpha(alpha.point(lambda value: 118 if value else 0))
|
||||||
|
preview_rgba = preview.convert("RGBA")
|
||||||
|
preview_rgba.alpha_composite(overlay)
|
||||||
|
return preview_rgba.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
def find_library_item(item_id):
|
def find_library_item(item_id):
|
||||||
return next((item for item in list_library() if item["id"] == item_id), None)
|
return next((item for item in list_library() if item["id"] == item_id), None)
|
||||||
|
|
||||||
@@ -273,7 +499,7 @@ def make_library_slice_preview(item_id, index):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_library_reformat_preview(item_id, plane, index, window):
|
def make_library_reformat_preview(item_id, plane, index, window, model_id=""):
|
||||||
item = find_library_item(item_id)
|
item = find_library_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise RuntimeError("影像库中没有找到该数据。")
|
raise RuntimeError("影像库中没有找到该数据。")
|
||||||
@@ -290,6 +516,8 @@ def make_library_reformat_preview(item_id, plane, index, window):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return count // 2
|
return count // 2
|
||||||
|
|
||||||
|
mask = None
|
||||||
|
mask_pixels = 0
|
||||||
if plane == "coronal":
|
if plane == "coronal":
|
||||||
count = volume.shape[1]
|
count = volume.shape[1]
|
||||||
index = normalize_reformat_index(index, count)
|
index = normalize_reformat_index(index, count)
|
||||||
@@ -301,12 +529,20 @@ def make_library_reformat_preview(item_id, plane, index, window):
|
|||||||
index = max(0, min(index, count - 1))
|
index = max(0, min(index, count - 1))
|
||||||
image = volume[:, :, index]
|
image = volume[:, :, index]
|
||||||
|
|
||||||
|
if model_id:
|
||||||
|
model = load_stl_model(model_id)
|
||||||
|
triangles = stl_triangles_to_voxels(model["triangles"], item["dicomPath"])
|
||||||
|
mask, mask_pixels = make_stl_slice_mask(triangles, plane, index, image.shape)
|
||||||
|
|
||||||
cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat"
|
cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat"
|
||||||
safe_mkdir(cache_dir)
|
safe_mkdir(cache_dir)
|
||||||
preview_path = cache_dir / f"{plane}_{window}_{index:04d}.png"
|
model_suffix = f"_model_{safe_filename(model_id)}" if model_id else ""
|
||||||
|
preview_path = cache_dir / f"{plane}_{window}_{index:04d}{model_suffix}.png"
|
||||||
if not preview_path.exists():
|
if not preview_path.exists():
|
||||||
preset = VIEWER_WINDOWS[window]
|
preset = VIEWER_WINDOWS[window]
|
||||||
preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB")
|
preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB")
|
||||||
|
if mask is not None:
|
||||||
|
preview = overlay_mask_on_preview(preview, mask)
|
||||||
preview = fit_image(preview, 960, 720)
|
preview = fit_image(preview, 960, 720)
|
||||||
preview.save(preview_path, format="PNG")
|
preview.save(preview_path, format="PNG")
|
||||||
|
|
||||||
@@ -318,6 +554,8 @@ def make_library_reformat_preview(item_id, plane, index, window):
|
|||||||
"window": window,
|
"window": window,
|
||||||
"windowLabel": VIEWER_WINDOWS[window]["label"],
|
"windowLabel": VIEWER_WINDOWS[window]["label"],
|
||||||
"patientId": item["patientId"],
|
"patientId": item["patientId"],
|
||||||
|
"modelId": model_id,
|
||||||
|
"maskPixels": mask_pixels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -995,7 +1233,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
plane = params.get("plane", ["coronal"])[0]
|
plane = params.get("plane", ["coronal"])[0]
|
||||||
index = params.get("index", ["0"])[0]
|
index = params.get("index", ["0"])[0]
|
||||||
window = params.get("window", ["default"])[0]
|
window = params.get("window", ["default"])[0]
|
||||||
self.send_json(make_library_reformat_preview(item_id, plane, index, window))
|
model_id = params.get("modelId", [""])[0]
|
||||||
|
self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id))
|
||||||
return
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/library/info":
|
if parsed.path == "/api/library/info":
|
||||||
@@ -1065,6 +1304,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(upload_library_item(self.headers, body), status=201)
|
self.send_json(upload_library_item(self.headers, body), status=201)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/model/upload":
|
||||||
|
body = self.read_bytes()
|
||||||
|
self.send_json(save_uploaded_stl(self.headers, body), status=201)
|
||||||
|
return
|
||||||
|
|
||||||
body = self.read_json()
|
body = self.read_json()
|
||||||
if parsed.path == "/api/demo/reset":
|
if parsed.path == "/api/demo/reset":
|
||||||
self.send_json(reset_demo_environment())
|
self.send_json(reset_demo_environment())
|
||||||
@@ -1267,6 +1511,7 @@ def main():
|
|||||||
safe_mkdir(APP_DIR / "ppt_video")
|
safe_mkdir(APP_DIR / "ppt_video")
|
||||||
safe_mkdir(LIBRARY_DIR)
|
safe_mkdir(LIBRARY_DIR)
|
||||||
safe_mkdir(RESULT_DIR)
|
safe_mkdir(RESULT_DIR)
|
||||||
|
safe_mkdir(MODEL_DIR)
|
||||||
load_persisted_jobs()
|
load_persisted_jobs()
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
print(f"Head CT Morph backend running at http://{HOST}:{PORT}")
|
print(f"Head CT Morph backend running at http://{HOST}:{PORT}")
|
||||||
|
|||||||
65
工程分析/实现方案-2026-05-08-02-36-12.md
Normal file
65
工程分析/实现方案-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 实现方案
|
||||||
|
|
||||||
|
开始时间:2026-05-08-02-36-12
|
||||||
|
|
||||||
|
## 本次方案路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-08-02-36-12.md`
|
||||||
|
|
||||||
|
## 实现目标
|
||||||
|
|
||||||
|
在 DICOM 阅览中实现真实 STL 模型切分 mask,并用单个双端点进度条控制起点/终点帧。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `web_backend.py`
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 后端新增 STL 模型目录和缓存:
|
||||||
|
- `MODEL_DIR = web_library/_stl_models`
|
||||||
|
- `STL_MODEL_CACHE`
|
||||||
|
- 支持 ASCII STL 和 binary STL 解析为三角面数组。
|
||||||
|
2. 后端新增 STL 上传接口:
|
||||||
|
- `POST /api/model/upload`
|
||||||
|
- 使用请求体保存 STL 文件。
|
||||||
|
- 返回 `modelId`、`name`、`triangleCount`。
|
||||||
|
3. 后端新增 DICOM 几何元数据提取:
|
||||||
|
- 基于排序后的 DICOM 文件读取 `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing`。
|
||||||
|
- 构造 patient 坐标到体素坐标的转换。
|
||||||
|
- 元数据不足时降级为 STL 已是体素坐标。
|
||||||
|
4. 后端增强 `/api/library/reformat-preview`:
|
||||||
|
- 支持 `modelId` 参数。
|
||||||
|
- 当传入模型时,根据 `plane` 和 `index` 计算 STL 三角面与当前 DICOM 切片平面的交线。
|
||||||
|
- 将交线栅格化并填充形成 mask,叠加到 DICOM PNG 上。
|
||||||
|
- 返回 `maskPixels`,便于前端知道该帧是否有模型穿透。
|
||||||
|
5. 前端 DICOM 阅览新增模型切分控制:
|
||||||
|
- STL 文件上传按钮。
|
||||||
|
- 模型切分开关。
|
||||||
|
- 一个双端点进度条控制起点/终点,两个端点允许交叉。
|
||||||
|
- 起点/终点数值按当前切片总数约束。
|
||||||
|
6. 前端模型切分展示:
|
||||||
|
- 模型切分关闭:保留当前单帧 DICOM 阅览。
|
||||||
|
- 模型切分开启:显示起点帧、终点帧两张 DICOM 图,并请求后端叠加真实 STL mask。
|
||||||
|
- 不再显示无意义的 CT MASK 图片或伪造圆圈 mask。
|
||||||
|
7. 执行测试方案。
|
||||||
|
8. 更新 `工程分析/经验记录.md`。
|
||||||
|
9. 提交并推送 Gitea,commit 信息使用 `2026-05-08-02-36-12 实现STL模型切分mask`。
|
||||||
|
10. 重新部署到 `http://192.168.3.11:3005/`。
|
||||||
|
|
||||||
|
## 回滚思路
|
||||||
|
|
||||||
|
若 STL mask 功能不符合预期,可回滚 `web_backend.py` 中 STL 上传/解析/mask 叠加逻辑,以及 `WebSite/src/App.tsx` 中模型切分 UI,恢复纯 DICOM 阅览。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- STL 解析结果和 DICOM 体数据都使用缓存,避免高频重复解析。
|
||||||
|
- mask 叠加只影响阅览 PNG,不修改原始 DICOM 和形变输出。
|
||||||
|
- 双端点进度条不影响普通 DICOM 阅览切片滑杆。
|
||||||
|
- 若 STL 与 DICOM 空间不匹配,前端仍显示 DICOM 切片,并以 `maskPixels=0` 表示该帧无交集。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次不需要人工二次确认,直接执行。
|
||||||
94
工程分析/测试方案-2026-05-08-02-36-12.md
Normal file
94
工程分析/测试方案-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 测试方案
|
||||||
|
|
||||||
|
开始时间:2026-05-08-02-36-12
|
||||||
|
|
||||||
|
## 本次方案路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-08-02-36-12.md`
|
||||||
|
|
||||||
|
## 测试范围
|
||||||
|
|
||||||
|
- STL 上传接口。
|
||||||
|
- ASCII STL 和 binary STL 解析。
|
||||||
|
- STL 与 DICOM 切片相交 mask 生成。
|
||||||
|
- 模型切分开启后起点帧/终点帧双图显示。
|
||||||
|
- 双端点进度条起点/终点可交叉。
|
||||||
|
- 普通 DICOM 阅览不受影响。
|
||||||
|
- 前端类型检查、构建和 Python 语法检查。
|
||||||
|
- 重新部署后访问验证。
|
||||||
|
|
||||||
|
## 测试命令
|
||||||
|
|
||||||
|
Python 语法检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile web_backend.py head_extension_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
前端类型检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebSite
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebSite
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端函数级测试建议:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
from web_backend import parse_stl_bytes
|
||||||
|
sample = b'''solid demo
|
||||||
|
facet normal 0 0 1
|
||||||
|
outer loop
|
||||||
|
vertex 0 0 0
|
||||||
|
vertex 10 0 0
|
||||||
|
vertex 0 10 0
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
endsolid demo
|
||||||
|
'''
|
||||||
|
triangles = parse_stl_bytes(sample)
|
||||||
|
print(triangles.shape)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
部署验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I --max-time 5 http://192.168.3.11:3005/
|
||||||
|
curl -s --max-time 10 "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=256&window=bone"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 手工验证点
|
||||||
|
|
||||||
|
- 打开 DICOM 阅览,普通冠状位/矢状位切片仍可查看。
|
||||||
|
- 上传 STL 后启用模型切分。
|
||||||
|
- 使用一个双端点进度条调节起点/终点帧。
|
||||||
|
- 将起点拖过终点或终点拖过起点后,界面仍能按两个端点显示切分结果。
|
||||||
|
- 起点帧和终点帧上显示真实 STL 切面 mask;无交集时不显示假 mask。
|
||||||
|
- 不再出现单独无意义的 CT MASK 图片。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- STL 上传返回模型信息。
|
||||||
|
- 带 `modelId` 的 `/api/library/reformat-preview` 可返回叠加 mask 的 PNG。
|
||||||
|
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
|
||||||
|
- `npm run lint` 通过。
|
||||||
|
- `npm run build` 通过。
|
||||||
|
- Gitea commit/push 完成。
|
||||||
|
- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`。
|
||||||
|
|
||||||
|
## 残余风险
|
||||||
|
|
||||||
|
- mask 是否精确覆盖目标结构取决于 STL 与 DICOM 是否同坐标系、同单位、同方向。如果 STL 未与 DICOM patient 坐标配准,需要额外提供配准矩阵或模型平移/缩放/旋转控制。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次不需要人工二次确认,直接执行。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -73,3 +73,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
涉及整套 DICOM 体数据读取的接口必须考虑缓存、并发和请求节流,不能把滑杆这类高频 UI 操作直接绑定到重型后端计算。自动下载类逻辑必须记录已处理 job,避免同一个完成状态在后续渲染或轮询中重复触发。
|
涉及整套 DICOM 体数据读取的接口必须考虑缓存、并发和请求节流,不能把滑杆这类高频 UI 操作直接绑定到重型后端计算。自动下载类逻辑必须记录已处理 job,避免同一个完成状态在后续渲染或轮询中重复触发。
|
||||||
|
|
||||||
|
## 2026-05-08-02-36-12 实现 STL 模型切分 mask
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户要求模型切分不能再用象征性画圈或无意义 CT MASK 图,而要根据 STL 模型真实穿透 DICOM 的位置生成 mask,并在 DICOM 起点帧、终点帧上显示。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
当前仓库主线没有 STL 模型切分实现,只有 DICOM 冠状/矢状阅览。若用前端绘制圆圈或固定区域替代,会与 STL 几何和 DICOM 切片没有真实关系,无法表达语义分割切片形态。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
后端新增 STL 上传、ASCII/Binary STL 解析、DICOM patient 坐标到体素坐标转换、STL 三角面与冠状/矢状切片平面求交、交线栅格化和填充形成 mask,并叠加到 DICOM 阅览 PNG。前端新增模型切分开关、STL 上传、单条双端点进度条,以及起点帧/终点帧双图 mask 展示。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
涉及医学影像与模型叠加时,mask 必须来自真实几何或分割数据,不能用装饰性形状替代。若 STL 与 DICOM 坐标系不一致,应优先补充配准矩阵或平移/旋转/缩放参数,而不是在图像上手工假标。
|
||||||
|
|||||||
53
工程分析/需求分析-2026-05-08-02-36-12.md
Normal file
53
工程分析/需求分析-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 需求分析
|
||||||
|
|
||||||
|
开始时间:2026-05-08-02-36-12
|
||||||
|
|
||||||
|
## 原始需求
|
||||||
|
|
||||||
|
用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改都不需要人工二次确认。
|
||||||
|
|
||||||
|
本次具体需求:
|
||||||
|
|
||||||
|
1. 起点、终点合并到一个进度条里,进度条有两个端点,起点和终点可以调整顺序。
|
||||||
|
2. 模型切分启用后,不能只是象征性画圈,而要真正判断 STL 模型穿透 DICOM 的位置,并用颜色标出形成 mask。
|
||||||
|
3. 模型切分下方的帧进度栏没有实际意义。模型切分开启后,不需要在最后一张显示 CT,也不需要当前无意义的 CT MASK 图片;应像之前一样用 DICOM 起点帧、终点帧对模型切两刀,并在这两个特定帧上显示 mask。这个 mask 代表重建 STL 模型原始语义分割的大致切片形态。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 在 DICOM 阅览中新增真实 STL 模型切分能力。
|
||||||
|
- 上传 STL 后,后端解析 STL 三角面片,按当前 DICOM 平面和起点/终点帧计算切片相交区域。
|
||||||
|
- 模型切分开启时,前端显示起点帧和终点帧两张 DICOM 切片,并叠加真实 STL 切面 mask。
|
||||||
|
- 用一个双端点进度条控制起点和终点,允许两个端点交叉,交叉后按数值顺序用于切分。
|
||||||
|
- 删除/不再显示无意义的 CT MASK 图片或伪 mask。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `web_backend.py`
|
||||||
|
- STL 上传接口。
|
||||||
|
- STL 解析、缓存。
|
||||||
|
- DICOM 阅览切片接口叠加真实 STL 切面 mask。
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- DICOM 阅览弹层新增 STL 上传、模型切分开关、双端点进度条和双帧 mask 展示。
|
||||||
|
- 移除模型切分状态下无意义的单帧 CT/MASK 展示。
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 当前定位
|
||||||
|
|
||||||
|
当前仓库主线中没有已有 `STL/模型切分/MASK` 代码,只有 DICOM 阅览和冠状/矢状重建预览。因此本次不是修补已有“画圈”代码,而是在现有 DICOM 阅览里补上真实 STL 切片 mask 能力。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不引入大型前端 3D 库。
|
||||||
|
- 不改变真实 DICOM 形变算法。
|
||||||
|
- 不提交 STL、DICOM、mask 缓存图片、ZIP 或构建产物。
|
||||||
|
- STL 与 DICOM 的空间配准优先按 DICOM `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing` 转换;若元数据不足,则降级假设 STL 坐标已在体素坐标系中。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- STL 与 DICOM 是否同一坐标系直接决定 mask 是否对齐;若输入 STL 未配准到 DICOM patient 坐标,mask 位置仍会偏。
|
||||||
|
- 不同 STL 拓扑可能导致切面轮廓不闭合,mask 填充可能只显示轮廓或局部区域。
|
||||||
|
- 大 STL 模型解析和多切片 mask 计算可能耗时,需要缓存解析后的三角面。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
用户已明确本次不需要二次人工确认,因此文档写完后直接执行。
|
||||||
Reference in New Issue
Block a user