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>

View File

@@ -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}")

View 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. 提交并推送 Giteacommit 信息使用 `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` 表示该帧无交集。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View 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 坐标配准,需要额外提供配准矩阵或模型平移/缩放/旋转控制。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -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 坐标系不一致,应优先补充配准矩阵或平移/旋转/缩放参数,而不是在图像上手工假标。

View 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 计算可能耗时,需要缓存解析后的三角面。
## 待确认事项
用户已明确本次不需要二次人工确认,因此文档写完后直接执行。