2026-05-08-02-36-12 实现STL模型切分mask
This commit is contained in:
@@ -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>
|
||||
|
||||
251
web_backend.py
251
web_backend.py
@@ -2,6 +2,7 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -17,6 +18,7 @@ from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
||||
|
||||
import pydicom
|
||||
import numpy as np
|
||||
from pydicom.multival import MultiValue
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
@@ -50,6 +52,9 @@ RESULT_DIR = APP_DIR / "web_results"
|
||||
JOBS_META = RESULT_DIR / "jobs.json"
|
||||
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
|
||||
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
||||
MODEL_DIR = LIBRARY_DIR / "_stl_models"
|
||||
STL_MODEL_CACHE = {}
|
||||
STL_MODEL_CACHE_LOCK = threading.Lock()
|
||||
VIEWER_WINDOWS = {
|
||||
"default": {"label": "默认", "low": -500, "high": 1200},
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
username = str(username or "").strip()
|
||||
return username or "anonymous"
|
||||
@@ -231,6 +241,222 @@ def clear_dicom_caches(dicom_dir=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):
|
||||
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)
|
||||
if not item:
|
||||
raise RuntimeError("影像库中没有找到该数据。")
|
||||
@@ -290,6 +516,8 @@ def make_library_reformat_preview(item_id, plane, index, window):
|
||||
except Exception:
|
||||
return count // 2
|
||||
|
||||
mask = None
|
||||
mask_pixels = 0
|
||||
if plane == "coronal":
|
||||
count = volume.shape[1]
|
||||
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))
|
||||
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"
|
||||
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():
|
||||
preset = VIEWER_WINDOWS[window]
|
||||
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.save(preview_path, format="PNG")
|
||||
|
||||
@@ -318,6 +554,8 @@ def make_library_reformat_preview(item_id, plane, index, window):
|
||||
"window": window,
|
||||
"windowLabel": VIEWER_WINDOWS[window]["label"],
|
||||
"patientId": item["patientId"],
|
||||
"modelId": model_id,
|
||||
"maskPixels": mask_pixels,
|
||||
}
|
||||
|
||||
|
||||
@@ -995,7 +1233,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
plane = params.get("plane", ["coronal"])[0]
|
||||
index = params.get("index", ["0"])[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
|
||||
|
||||
if parsed.path == "/api/library/info":
|
||||
@@ -1065,6 +1304,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(upload_library_item(self.headers, body), status=201)
|
||||
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()
|
||||
if parsed.path == "/api/demo/reset":
|
||||
self.send_json(reset_demo_environment())
|
||||
@@ -1267,6 +1511,7 @@ def main():
|
||||
safe_mkdir(APP_DIR / "ppt_video")
|
||||
safe_mkdir(LIBRARY_DIR)
|
||||
safe_mkdir(RESULT_DIR)
|
||||
safe_mkdir(MODEL_DIR)
|
||||
load_persisted_jobs()
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
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. 后续如何避免问题
|
||||
|
||||
涉及整套 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