Compare commits

...

11 Commits

34 changed files with 3360 additions and 104 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ WebSite/dist/
WebSite/.env.local
# Runtime outputs and uploaded data
*.log
app_output/
app_previews/
ppt_video/

62
AGENTS.md Normal file
View File

@@ -0,0 +1,62 @@
# 项目修改工作流
本仓库后续所有项目修改相关需求,都必须遵循以下流程。若用户临时提出的要求与本文件冲突,应先向用户说明冲突点并等待确认。
## 强制时间戳
- 每次开始处理项目修改需求前,先记录开始时间,格式为 `YYYY-MM-DD-HH-MM-SS`
- 同一轮需求内创建的 `需求分析``实现方案``测试方案` 文件必须使用同一个开始时间戳。
## 工程分析目录
- 每次执行前先阅读或创建 `工程分析/`
- 优先阅读 `工程分析/工程整体分析.md`,了解项目结构、运行方式和风险边界。
- 在最终执行修改前,必须阅读 `工程分析/经验记录.md`,避免重复犯错。
## 需求分析
- 每次用户提出项目修改需求后,先整理需求并写入:
`工程分析/需求分析-YYYY-MM-DD-HH-MM-SS.md`
- 文档至少包含:原始需求、目标、影响范围、约束、风险点、待确认事项。
## 实现方案
- 每次进入代码修改前,先将实现方案写入:
`工程分析/实现方案-YYYY-MM-DD-HH-MM-SS.md`
- 文档至少包含:方案路径、涉及文件、执行步骤、回滚思路、风险控制。
- 实现方案写完后必须等待用户二次人工审核确认;未经确认不得修改业务代码。
## 测试方案
- 每次执行代码修改前,先将测试方案写入:
`工程分析/测试方案-YYYY-MM-DD-HH-MM-SS.md`
- 文档至少包含:测试范围、测试命令、手工验证点、验收标准、无法测试的风险。
- 测试方案写完后必须等待用户二次人工审核确认;未经确认不得修改业务代码。
## 执行与经验记录
- 用户确认实现方案和测试方案后,方可执行完整修改。
- 执行过程中遇到的关键问题及解决方案,必须追加到 `工程分析/经验记录.md`
- 经验记录统一使用四段式:
- A. 具体问题
- B. 产生问题原因
- C. 解决问题方案
- D. 后续如何避免问题
## Gitea 备份
- 每次最终执行后,必须将本次文档变更提交到 Gitea。
- commit 信息格式:
`YYYY-MM-DD-HH-MM-SS 简要描述`
- 远程仓库:
`http://admin@192.168.31.5:5002/admin/Head_CT_Morph.git`
- 不得把 Gitea 密码、令牌或其他凭据写入仓库文件、提交信息、日志文档或可追踪脚本。
- 完成 commit 后,必须提醒用户文档备份 commit 已完成。
## 重新部署
- 每次最终执行项目修改后,重新部署或重启本项目。
- 当前项目通常需要:
- Python 后端:`cd WebSite && npm run backend`
- 前端开发服务:`cd WebSite && npm run dev`
- 若部署方式发生变化,应同步更新 `工程分析/工程整体分析.md`

View File

@@ -3,11 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
<link rel="icon" type="image/png" href="/logo_square.png" />
<link rel="apple-touch-icon" href="/logo_square.png" />
<title>头部CT变形平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,56 @@
@import "tailwindcss";
.dual-range-input {
-webkit-appearance: none;
appearance: none;
pointer-events: none;
}
.dual-range-input::-webkit-slider-runnable-track {
height: 0;
background: transparent;
border: 0;
}
.dual-range-input::-moz-range-track {
height: 0;
background: transparent;
border: 0;
}
.dual-range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
pointer-events: auto;
width: 20px;
height: 20px;
margin-top: -9px;
border-radius: 9999px;
border: 3px solid #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.22);
}
.dual-range-input::-moz-range-thumb {
pointer-events: auto;
width: 20px;
height: 20px;
border-radius: 9999px;
border: 3px solid #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.22);
}
.dual-range-input-start::-webkit-slider-thumb {
background: #2563eb;
}
.dual-range-input-start::-moz-range-thumb {
background: #2563eb;
}
.dual-range-input-end::-webkit-slider-thumb {
background: #f97316;
}
.dual-range-input-end::-moz-range-thumb {
background: #f97316;
}

View File

@@ -123,14 +123,21 @@ def cutoff_center_z(coordinates_cutoff):
return float(np.mean([point[0] for point in coordinates_cutoff]))
def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
panel = panel.copy()
def cutoff_line_y(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
crop_height = int(image_depth * 0.72)
if crop_height <= 0:
return panel
return None
line_y = int(round((image_depth - 1 - cutoff_center_z(coordinates_cutoff)) * panel.height / crop_height))
if line_y < 0 or line_y >= panel.height:
return None
return line_y
def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
panel = panel.copy()
line_y = cutoff_line_y(panel, image_depth, coordinates_cutoff)
if line_y is None:
return panel
draw = ImageDraw.Draw(panel)
@@ -141,6 +148,57 @@ def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_
return panel
def draw_deformed_cutoff_line(panel, image_depth, angle_degrees, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
panel = panel.copy()
line_y = cutoff_line_y(panel, image_depth, coordinates_cutoff)
if line_y is None:
return panel
theta = np.deg2rad(float(angle_degrees))
cos_t = np.cos(theta)
sin_t = np.sin(theta)
pivot_x = int(panel.width * 0.55)
pivot_y = int(panel.height * 0.62)
points = []
for x in (-panel.width * 0.08, panel.width * 1.08):
dx = x - pivot_x
dy = line_y - pivot_y
points.append((
pivot_x + cos_t * dx - sin_t * dy,
pivot_y + sin_t * dx + cos_t * dy,
))
draw = ImageDraw.Draw(panel)
shadow = (0, 0, 0)
line_color = (255, 215, 60)
draw.line(points, fill=shadow, width=6)
draw.line(points, fill=line_color, width=3)
return panel
def preview_deform_with_cutoff_line(
image,
image_depth,
angle_degrees,
mode="soft_transition",
transition_width=90,
gaussian_sigma=3,
show_cutoff_line=True,
coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF,
):
preview = preview_deform_2d(
image,
angle_degrees,
mode,
transition_width=transition_width,
gaussian_sigma=gaussian_sigma,
)
if not show_cutoff_line:
return preview
return draw_deformed_cutoff_line(preview, image_depth, angle_degrees, coordinates_cutoff)
def fit_image(image, width, height):
scale = min(width / image.width, height / image.height)
resized = image.resize(
@@ -464,18 +522,34 @@ def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates
screenshot_dir = output_dir / "process_screenshots"
reset_folder(screenshot_dir)
original_panel = sitk_sagittal_panel(state_images["original"], coordinates_cutoff)
image_depth = state_images["original"].GetSize()[2]
original_panel = sitk_sagittal_panel(state_images["original"])
original_display = (
draw_cutoff_line(original_panel, image_depth, coordinates_cutoff)
if coordinates_cutoff is not None
else original_panel
)
preview_panels = {
"original": original_panel,
"hard_boundary": preview_deform_2d(original_panel, angle_degrees, "hard_boundary"),
"gaussian_smooth": preview_deform_2d(
"original": original_display,
"hard_boundary": preview_deform_with_cutoff_line(
original_panel,
image_depth,
angle_degrees,
"hard_boundary",
show_cutoff_line=coordinates_cutoff is not None,
coordinates_cutoff=coordinates_cutoff or DEFAULT_COORDINATES_CUTOFF,
),
"gaussian_smooth": preview_deform_with_cutoff_line(
original_panel,
image_depth,
angle_degrees,
"gaussian_smooth",
transition_width,
show_cutoff_line=coordinates_cutoff is not None,
coordinates_cutoff=coordinates_cutoff or DEFAULT_COORDINATES_CUTOFF,
),
"soft_transition": preview_deform_2d(
original_panel,
original_display,
angle_degrees,
"soft_transition",
transition_width,

View File

@@ -2,6 +2,7 @@ import base64
import json
import os
import shutil
import struct
import threading
import time
import traceback
@@ -17,8 +18,9 @@ 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
from PIL import Image, ImageDraw
from generate_head_extension_video import generate_video
from head_extension_app import (
@@ -28,6 +30,7 @@ from head_extension_app import (
draw_cutoff_line,
fit_image,
load_dicom_volume,
preview_deform_with_cutoff_line,
preview_deform_2d,
run_deformation,
sagittal_mip,
@@ -40,12 +43,38 @@ PORT = 8787
JOBS = {}
JOBS_LOCK = threading.Lock()
DICOM_FILE_CACHE = {}
DICOM_VOLUME_CACHE = {}
DICOM_VOLUME_CACHE_LOCK = threading.Lock()
DICOM_VOLUME_CACHE_LIMIT = 2
LIBRARY_DIR = APP_DIR / "web_library"
LIBRARY_META = LIBRARY_DIR / "library.json"
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()
SEGMENTATION_CACHE = {}
SEGMENTATION_CACHE_LOCK = threading.Lock()
SEGMENTATION_CACHE_LIMIT = 4
SEGMENTATION_COLORS = [
(255, 112, 32),
(34, 211, 238),
(168, 85, 247),
(74, 222, 128),
(250, 204, 21),
(244, 114, 182),
(96, 165, 250),
(251, 146, 60),
]
VIEWER_WINDOWS = {
"default": {"label": "默认", "low": -500, "high": 1200},
"bone": {"label": "骨窗", "low": -500, "high": 1800},
"soft_tissue": {"label": "软组织", "low": -160, "high": 240},
"brain": {"label": "脑窗", "low": 0, "high": 80},
"lung": {"label": "肺窗", "low": -1000, "high": 400},
}
def json_default(value):
@@ -62,6 +91,20 @@ 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 safe_segmentation_filename(name):
name = safe_filename(name or "segmentation.dcm")
path = Path(name)
suffix = path.suffix.lower()
if suffix in {".dcm", ".dicom"}:
return f"{path.stem}{suffix}"
return f"{name}.dcm"
def normalized_username(username):
username = str(username or "").strip()
return username or "anonymous"
@@ -170,6 +213,510 @@ def sorted_dicom_files(dicom_dir):
return sorted_files
def dicom_dir_signature(dicom_dir):
dicom_dir = Path(dicom_dir).resolve()
files = list(dicom_dir.glob("*.dcm"))
return (
str(dicom_dir),
len(files),
max((file_path.stat().st_mtime for file_path in files), default=0),
)
def load_cached_dicom_volume(dicom_dir):
dicom_dir = Path(dicom_dir).resolve()
signature = dicom_dir_signature(dicom_dir)
cache_key = str(dicom_dir)
with DICOM_VOLUME_CACHE_LOCK:
cached = DICOM_VOLUME_CACHE.get(cache_key)
if cached and cached["signature"] == signature:
cached["last_access"] = time.time()
return cached["volume"]
volume = load_dicom_volume(dicom_dir)
DICOM_VOLUME_CACHE[cache_key] = {
"signature": signature,
"volume": volume,
"last_access": time.time(),
}
while len(DICOM_VOLUME_CACHE) > DICOM_VOLUME_CACHE_LIMIT:
oldest_key = min(
DICOM_VOLUME_CACHE,
key=lambda key: DICOM_VOLUME_CACHE[key].get("last_access", 0),
)
if oldest_key == cache_key:
break
DICOM_VOLUME_CACHE.pop(oldest_key, None)
return volume
def clear_dicom_caches(dicom_dir=None):
if dicom_dir is None:
DICOM_FILE_CACHE.clear()
with DICOM_VOLUME_CACHE_LOCK:
DICOM_VOLUME_CACHE.clear()
return
cache_key = str(Path(dicom_dir).resolve())
DICOM_FILE_CACHE.pop(cache_key, None)
with DICOM_VOLUME_CACHE_LOCK:
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 segmentation_root_for_item(item):
return Path(item["dicomPath"]).resolve().parent / "segmentations"
def segmentation_file_for_id(item, segmentation_id):
segmentation_id = safe_filename(segmentation_id)
if not segmentation_id:
return None
root = segmentation_root_for_item(item)
matches = sorted(root.glob(f"{segmentation_id}_*.dcm")) + sorted(root.glob(f"{segmentation_id}_*.dicom"))
return matches[0] if matches else None
def segmentation_meta_path(segmentation_path):
return segmentation_path.with_suffix(segmentation_path.suffix + ".json")
def segmentation_signature(segmentation_path):
segmentation_path = Path(segmentation_path).resolve()
return (
str(segmentation_path),
segmentation_path.stat().st_size,
segmentation_path.stat().st_mtime,
)
def segment_labels_from_dataset(ds):
labels = {}
for segment in getattr(ds, "SegmentSequence", []) or []:
try:
number = int(getattr(segment, "SegmentNumber", 0))
except Exception:
number = 0
if number > 0:
labels[number] = str(getattr(segment, "SegmentLabel", f"Segment {number}"))
return labels
def frame_segment_number(ds, frame_index):
per_frame = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
if per_frame and frame_index < len(per_frame):
segment_sequence = getattr(per_frame[frame_index], "SegmentIdentificationSequence", None)
if segment_sequence:
try:
return int(getattr(segment_sequence[0], "ReferencedSegmentNumber", 1))
except Exception:
return 1
return 1
def frame_position_patient(ds, frame_index):
per_frame = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
if per_frame and frame_index < len(per_frame):
plane_position = getattr(per_frame[frame_index], "PlanePositionSequence", None)
if plane_position and hasattr(plane_position[0], "ImagePositionPatient"):
return np.asarray(plane_position[0].ImagePositionPatient, dtype=np.float64)
if hasattr(ds, "ImagePositionPatient"):
return np.asarray(ds.ImagePositionPatient, dtype=np.float64)
return None
def normalize_segmentation_frames(pixel_array):
array = np.asarray(pixel_array)
if array.ndim == 2:
return array[np.newaxis, :, :]
if array.ndim == 3:
return array
if array.ndim == 4:
if array.shape[-1] == 1:
return array[..., 0]
return np.max(array, axis=-1)
raise RuntimeError("Segmentation Mask 像素维度不受支持。")
def resize_label_frame(frame, rows, cols):
frame = np.asarray(frame)
if frame.shape == (rows, cols):
return frame
image = Image.fromarray(frame.astype(np.uint16))
image = image.resize((cols, rows), Image.Resampling.NEAREST)
return np.asarray(image)
def load_segmentation_mask(item_id, segmentation_id):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
segmentation_path = segmentation_file_for_id(item, segmentation_id)
if not segmentation_path:
raise RuntimeError("没有找到已上传的 DICOM Segmentation Mask。")
signature = segmentation_signature(segmentation_path)
cache_key = f"{item_id}:{safe_filename(segmentation_id)}"
with SEGMENTATION_CACHE_LOCK:
cached = SEGMENTATION_CACHE.get(cache_key)
if cached and cached["signature"] == signature:
cached["last_access"] = time.time()
return cached["data"]
dicom_files = sorted_dicom_files(item["dicomPath"])
if not dicom_files:
raise RuntimeError("该影像数据没有可配准的 CT DICOM。")
first_ct = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True)
ct_rows = int(getattr(first_ct, "Rows", 0) or 0)
ct_cols = int(getattr(first_ct, "Columns", 0) or 0)
if ct_rows <= 0 or ct_cols <= 0:
raise RuntimeError("CT DICOM 缺少 Rows/Columns 信息,无法配准 Segmentation Mask。")
ds = pydicom.dcmread(str(segmentation_path), force=True)
frames = normalize_segmentation_frames(ds.pixel_array)
geometry = dicom_geometry(item["dicomPath"])
label_volume = np.zeros((len(dicom_files), ct_rows, ct_cols), dtype=np.uint16)
labels = segment_labels_from_dataset(ds)
for frame_index, frame in enumerate(frames):
frame = resize_label_frame(frame, ct_rows, ct_cols)
active = frame > 0
if not np.any(active):
continue
position = frame_position_patient(ds, frame_index)
if geometry and position is not None:
voxel = (position - geometry["origin"]) @ geometry["inverse"].T
slice_index = int(round(float(voxel[0])))
elif len(frames) == len(dicom_files):
slice_index = frame_index
else:
slice_index = min(len(dicom_files) - 1, frame_index)
slice_index = max(0, min(len(dicom_files) - 1, slice_index))
if int(frame.max()) > 1 and not labels:
label_volume[slice_index][active] = np.maximum(label_volume[slice_index][active], frame[active].astype(np.uint16))
else:
label = max(1, frame_segment_number(ds, frame_index))
label_volume[slice_index][active] = label
labels.setdefault(label, f"Segment {label}")
unique_labels = sorted(int(value) for value in np.unique(label_volume) if int(value) > 0)
if not unique_labels:
raise RuntimeError("DICOM Segmentation Mask 中没有可渲染的分割像素。")
if not labels:
labels = {value: f"Label {value}" for value in unique_labels}
data = {
"itemId": item_id,
"segId": safe_filename(segmentation_id),
"path": segmentation_path,
"name": segmentation_path.name.split("_", 1)[1] if "_" in segmentation_path.name else segmentation_path.name,
"volume": label_volume,
"frameCount": int(frames.shape[0]),
"segmentCount": len(unique_labels),
"labels": [
{"value": int(value), "label": labels.get(int(value), f"Segment {int(value)}")}
for value in unique_labels
],
}
with SEGMENTATION_CACHE_LOCK:
SEGMENTATION_CACHE[cache_key] = {
"signature": signature,
"data": data,
"last_access": time.time(),
}
while len(SEGMENTATION_CACHE) > SEGMENTATION_CACHE_LIMIT:
oldest_key = min(
SEGMENTATION_CACHE,
key=lambda key: SEGMENTATION_CACHE[key].get("last_access", 0),
)
if oldest_key == cache_key:
break
SEGMENTATION_CACHE.pop(oldest_key, None)
return data
def serialize_segmentation(segmentation):
return {
"segId": segmentation["segId"],
"name": segmentation["name"],
"frameCount": segmentation["frameCount"],
"segmentCount": segmentation["segmentCount"],
"labels": segmentation["labels"],
}
def list_segmentations(item_id):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
root = segmentation_root_for_item(item)
if not root.exists():
return []
segmentations = []
for path in sorted(list(root.glob("*.dcm")) + list(root.glob("*.dicom"))):
meta = read_json_file(segmentation_meta_path(path), None)
if meta:
segmentations.append(meta)
continue
seg_id = path.name.split("_", 1)[0]
try:
segmentations.append(serialize_segmentation(load_segmentation_mask(item_id, seg_id)))
except Exception:
segmentations.append({
"segId": seg_id,
"name": path.name.split("_", 1)[1] if "_" in path.name else path.name,
"frameCount": 0,
"segmentCount": 0,
"labels": [],
})
return segmentations
def save_uploaded_segmentation(headers, body):
if not body:
raise RuntimeError("上传的 Segmentation Mask 文件为空。")
item_id = safe_filename(unquote(headers.get("x-library-id", "")))
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到要绑定的 DICOM 数据。")
source_name = safe_segmentation_filename(unquote(headers.get("x-file-name", "segmentation.dcm")))
seg_id = uuid.uuid4().hex[:12]
root = segmentation_root_for_item(item)
safe_mkdir(root)
segmentation_path = root / f"{seg_id}_{source_name}"
segmentation_path.write_bytes(body)
try:
segmentation = load_segmentation_mask(item_id, seg_id)
meta = serialize_segmentation(segmentation)
write_json_file(segmentation_meta_path(segmentation_path), meta)
return meta
except Exception:
try:
segmentation_path.unlink()
except Exception:
pass
raise
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)
@@ -212,6 +759,134 @@ def make_library_slice_preview(item_id, index):
}
def render_mask_only_preview(mask, size):
preview = Image.new("RGB", size, (8, 13, 28))
if mask is None:
return preview
alpha = mask.resize(size, Image.Resampling.NEAREST)
overlay = Image.new("RGBA", size, (255, 112, 32, 0))
overlay.putalpha(alpha.point(lambda value: 210 if value else 0))
preview_rgba = preview.convert("RGBA")
preview_rgba.alpha_composite(overlay)
return preview_rgba.convert("RGB")
def render_segmentation_label_preview(label_slice):
labels = np.asarray(label_slice, dtype=np.uint16)
rgb = np.zeros((labels.shape[0], labels.shape[1], 3), dtype=np.uint8)
rgb[:, :] = np.asarray((8, 13, 28), dtype=np.uint8)
for value in sorted(int(item) for item in np.unique(labels) if int(item) > 0):
color = SEGMENTATION_COLORS[(value - 1) % len(SEGMENTATION_COLORS)]
rgb[labels == value] = color
return Image.fromarray(rgb, mode="RGB")
def normalize_reformat_index(raw_index, count):
if str(raw_index) == "middle":
return count // 2
try:
return int(raw_index)
except Exception:
return count // 2
def make_segmentation_reformat_preview(item_id, segmentation_id, plane, index):
segmentation = load_segmentation_mask(item_id, segmentation_id)
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
volume = segmentation["volume"]
if plane == "coronal":
count = volume.shape[1]
index = normalize_reformat_index(index, count)
index = max(0, min(index, count - 1))
label_slice = volume[:, index, :]
else:
count = volume.shape[2]
index = normalize_reformat_index(index, count)
index = max(0, min(index, count - 1))
label_slice = volume[:, :, index]
mask_pixels = int(np.count_nonzero(label_slice))
cache_dir = PREVIEW_CACHE_DIR / item_id / "segmentation"
safe_mkdir(cache_dir)
preview_path = cache_dir / f"{plane}_{index:04d}_seg_{safe_filename(segmentation_id)}.png"
if not preview_path.exists():
preview = render_segmentation_label_preview(label_slice)
preview = fit_image(preview, 960, 720)
preview.save(preview_path, format="PNG")
return {
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
"index": index,
"count": count,
"plane": plane,
"window": "segmentation",
"windowLabel": "Segmentation Mask",
"patientId": segmentation["itemId"],
"segId": segmentation["segId"],
"maskPixels": mask_pixels,
"segmentCount": segmentation["segmentCount"],
"labels": segmentation["labels"],
}
def make_library_reformat_preview(item_id, plane, index, window, model_id="", mask_only=False):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
window = window if window in VIEWER_WINDOWS else "default"
volume = load_cached_dicom_volume(item["dicomPath"])
mask = None
mask_pixels = 0
if plane == "coronal":
count = volume.shape[1]
index = normalize_reformat_index(index, count)
index = max(0, min(index, count - 1))
image = volume[:, index, :]
else:
count = volume.shape[2]
index = normalize_reformat_index(index, count)
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)
model_suffix = f"_model_{safe_filename(model_id)}" if model_id else ""
view_suffix = "_mask_only" if mask_only and model_id else ""
preview_path = cache_dir / f"{plane}_{window}_{index:04d}{model_suffix}{view_suffix}.png"
if not preview_path.exists():
preset = VIEWER_WINDOWS[window]
if mask_only and model_id:
base_size = fit_image(Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB"), 960, 720).size
preview = render_mask_only_preview(mask, base_size)
else:
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")
return {
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
"index": index,
"count": count,
"plane": plane,
"window": window,
"windowLabel": VIEWER_WINDOWS[window]["label"],
"patientId": item["patientId"],
"modelId": model_id,
"maskPixels": mask_pixels,
}
def dicom_value(ds, name, fallback="-"):
value = getattr(ds, name, fallback)
if value in [None, ""]:
@@ -441,7 +1116,7 @@ def reset_demo_environment():
return {
"ok": True,
"message": "演示环境已恢复出厂设置。",
"items": list_library(),
"items": [record],
}
@@ -650,23 +1325,87 @@ def make_preview(
volume = load_dicom_volume(input_dir)
before = crop_head_neck(sagittal_mip(volume))
before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before
after = preview_deform_2d(
before_display,
float(angle_degrees),
mode,
transition_width=float(transition_width),
gaussian_sigma=float(gaussian_sigma),
)
if mode in {"hard_boundary", "gaussian_smooth"}:
after = preview_deform_with_cutoff_line(
before,
volume.shape[0],
float(angle_degrees),
mode,
transition_width=float(transition_width),
gaussian_sigma=float(gaussian_sigma),
show_cutoff_line=show_cutoff_line,
)
else:
after = preview_deform_2d(
before_display,
float(angle_degrees),
mode,
transition_width=float(transition_width),
gaussian_sigma=float(gaussian_sigma),
)
canvas_image = Image.new("RGB", (1440, 520), (0, 0, 0))
canvas_image.paste(fit_image(before_display, 700, 520), (0, 0))
canvas_image.paste(fit_image(after, 700, 520), (740, 0))
panels = [
("Original", before_display),
(
"Hard boundary",
preview_deform_with_cutoff_line(
before,
volume.shape[0],
float(angle_degrees),
"hard_boundary",
show_cutoff_line=show_cutoff_line,
),
),
(
"Gaussian smooth",
preview_deform_with_cutoff_line(
before,
volume.shape[0],
float(angle_degrees),
"gaussian_smooth",
gaussian_sigma=float(gaussian_sigma),
show_cutoff_line=show_cutoff_line,
),
),
(
"Soft transition",
preview_deform_2d(
before_display,
float(angle_degrees),
"soft_transition",
transition_width=float(transition_width),
),
),
]
process_image = Image.new("RGB", (1440, 430), (0, 0, 0))
process_draw = ImageDraw.Draw(process_image)
panel_width = 330
panel_height = 300
margin = 35
gap = 20
for index, (label, panel) in enumerate(panels):
x = margin + index * (panel_width + gap)
process_draw.text((x, 28), label, fill=(230, 230, 230))
process_image.paste(fit_image(panel, panel_width, panel_height), (x, 70))
process_draw.text(
(margin, 390),
f"Angle {float(angle_degrees):.1f} deg",
fill=(170, 170, 170),
)
canvas = BytesIO()
canvas_image.save(canvas, format="PNG")
encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
process_canvas = BytesIO()
process_image.save(process_canvas, format="PNG")
process_encoded = base64.b64encode(process_canvas.getvalue()).decode("ascii")
return {
"image": f"data:image/png;base64,{encoded}",
"processImage": f"data:image/png;base64,{process_encoded}",
"source": str(Path(input_dir).resolve()),
"angleDegrees": float(angle_degrees),
}
@@ -816,6 +1555,32 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(make_library_slice_preview(item_id, index))
return
if parsed.path == "/api/library/reformat-preview":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
plane = params.get("plane", ["coronal"])[0]
index = params.get("index", ["0"])[0]
window = params.get("window", ["default"])[0]
model_id = params.get("modelId", [""])[0]
mask_only = params.get("maskOnly", ["0"])[0] in {"1", "true", "yes"}
self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id, mask_only))
return
if parsed.path == "/api/segmentation/list":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
self.send_json({"items": list_segmentations(item_id)})
return
if parsed.path == "/api/segmentation/preview":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
segmentation_id = params.get("segId", [""])[0]
plane = params.get("plane", ["coronal"])[0]
index = params.get("index", ["0"])[0]
self.send_json(make_segmentation_reformat_preview(item_id, segmentation_id, plane, index))
return
if parsed.path == "/api/library/info":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
@@ -883,6 +1648,16 @@ 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
if parsed.path == "/api/segmentation/upload":
body = self.read_bytes()
self.send_json(save_uploaded_segmentation(self.headers, body), status=201)
return
body = self.read_json()
if parsed.path == "/api/demo/reset":
self.send_json(reset_demo_environment())
@@ -1027,7 +1802,7 @@ class Handler(BaseHTTPRequestHandler):
write_library_meta(remaining)
upload_root = Path(target["dicomPath"]).resolve().parent
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
DICOM_FILE_CACHE.pop(str(Path(target["dicomPath"]).resolve()), None)
clear_dicom_caches(target["dicomPath"])
shutil.rmtree(upload_root)
preview_cache = PREVIEW_CACHE_DIR / item_id
if preview_cache.exists():
@@ -1059,13 +1834,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Disposition", f'attachment; filename="{file_path.name}"')
self.send_header("Content-Length", str(file_path.stat().st_size))
self.end_headers()
with file_path.open("rb") as file_handle:
shutil.copyfileobj(file_handle, self.wfile)
try:
with file_path.open("rb") as file_handle:
shutil.copyfileobj(file_handle, self.wfile)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
return
def send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Headers", "Content-Type, x-file-name, x-library-id")
def send_json(self, payload, status=200):
data = json.dumps(payload, ensure_ascii=False, default=json_default).encode("utf-8")
@@ -1082,6 +1860,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,45 @@
# 实现方案
开始时间2026-05-03-18-27-21
## 本次方案路径
`工程分析/实现方案-2026-05-03-18-27-21.md`
## 实现目标
建立长期有效的项目修改工作流,并将工作流说明放在后续维护者容易读取的位置。
## 涉及文件
- `AGENTS.md`
- `工程分析/工程整体分析.md`
- `工程分析/经验记录.md`
- `工程分析/需求分析-2026-05-03-18-27-21.md`
- `工程分析/实现方案-2026-05-03-18-27-21.md`
- `工程分析/测试方案-2026-05-03-18-27-21.md`
## 执行步骤
1. 创建 `工程分析/` 目录。
2. 编写 `AGENTS.md`,将用户指定的流程固化为仓库级工作流。
3. 编写 `工程分析/工程整体分析.md`,记录当前项目结构、运行方式、测试入口和风险边界。
4. 编写本次需求分析、实现方案和测试方案文档。
5. 初始化 `工程分析/经验记录.md`,记录建立工作流的原因、方案和后续避免方式。
6. 执行文档检查、前端类型检查和构建检查。
7. 将文档变更提交并推送到 Gitea。
8. 重新部署本项目的后端和前端开发服务。
## 回滚思路
如该工作流需要撤销,可删除本次新增的 `AGENTS.md``工程分析/` 中本次新增文档,并提交一次回滚 commit。若只需调整流程应优先修改 `AGENTS.md` 和对应工程分析文档,而不是删除知识库。
## 风险控制
- 本次不修改业务代码,降低运行行为变化风险。
- Gitea 密码不写入仓库,不写入提交信息。
- 重新部署前先执行前端检查,确保基础构建链路可用。
## 人工审核状态
本次为建立工作流本身,按用户明确指令直接执行。后续业务代码修改必须等待用户对实现方案进行二次人工审核确认。

View File

@@ -0,0 +1,52 @@
# 实现方案
开始时间2026-05-03-22-02-15
## 本次方案路径
`工程分析/实现方案-2026-05-03-22-02-15.md`
## 实现目标
将硬边界、高斯平滑预览图中的黄色分界线由形变后的 Y 字视觉改为单条线,同时不改变真实 DICOM 形变输出。
## 涉及文件
- `head_extension_app.py`
- `web_backend.py`,视调用兼容性决定是否需要小幅调整
- `工程分析/经验记录.md`
## 执行步骤
1.`head_extension_app.py` 中保留现有 `draw_cutoff_line`,用于原始图水平分界线。
2. 新增或调整一个预览线绘制辅助函数,用于在已经完成形变的预览图上叠加单条黄色线。
3. 修改快速预览生成逻辑:
- 原始图:仍按现有方式绘制水平分界线。
- 硬边界、高斯平滑:先对无黄线 CT 图执行 `preview_deform_2d`,再叠加单条斜向黄色分界线。
- 软过渡:保持现有视觉为单线;如实现上更简洁,可改为同一套“形变后叠线”的方式,但不得重新出现 Y 字。
4. 修改四状态过程预览生成逻辑,保证导出的 `process_comparison_4states.png``process_screenshots/hard_boundary.png``process_screenshots/gaussian_smooth.png` 中也是单条分界线。
5. 检查桌面预览入口是否复用同一逻辑,避免网页和桌面行为不一致。
6. 执行测试方案中的检查。
7. 修改完成后,将关键问题和解决方案追加到 `工程分析/经验记录.md`
8. 提交并推送 Gitea 备份commit 信息使用 `2026-05-03-22-02-15 调整预览分界线绘制`
9. 重新部署后端和前端服务到 `http://192.168.3.11:3005/`
## 预期实现细节
- 避免把带黄线的图片传入硬边界/高斯平滑的 `preview_deform_2d`
- 将黄线作为预览标注层绘制在形变结果上,使标注层不再被硬边界形变撕裂。
- 单条线的位置以当前分界线高度和旋转角度估算,保持和原有预览语义一致。
## 回滚思路
如效果不符合预期,可回滚本次对 `head_extension_app.py``web_backend.py` 的修改,恢复“先画线再形变”的旧逻辑;文档记录保留问题原因和回滚说明。
## 风险控制
- 不触碰 `run_deformation` 的真实 3D 形变字段生成逻辑。
- 不修改前端交互状态和接口参数,降低联动风险。
- 测试时覆盖快速预览接口、四状态过程图、前端类型检查和构建。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -0,0 +1,78 @@
# 实现方案
开始时间2026-05-03-22-36-18
## 本次方案路径
`工程分析/实现方案-2026-05-03-22-36-18.md`
## 实现目标
完成下载文案、四状态单项 DICOM 下载、影像库阅览入口和冠状/矢状 DICOM 显示模式切换。
## 涉及文件
- `WebSite/src/App.tsx`
- `web_backend.py`
- `工程分析/经验记录.md`
## 执行步骤
1. 前端文案调整:
- 将左侧工作站区域的“下载四状态ZIP”改为“下载结果”。
- 将影像库卡片中的“调阅”改为“变换”。
2. 四状态单项下载:
- 在四状态结果卡片中,为原始序列、硬边界、高斯平滑、软过渡重建标题旁增加下载图标按钮。
- 点击按钮调用已有 `handlePackageDownload(t.key)`,复用后端现有 `prepare_deformation_zip(job_id, target)`
- 按钮在形变任务未完成、对应状态 ZIP 正在打包时禁用,并显示简短状态。
3. 后端新增影像库重建阅览接口:
- 新增 `/api/library/reformat-preview`,参数包含 `id``plane``index``window`
- `plane` 支持 `coronal``sagittal`
- `window` 支持 `default``bone``soft_tissue``brain``lung` 等固定窗宽窗位模式。
- 读取影像库 DICOM 序列为体数据,根据平面切片后用窗宽窗位转 PNG缓存到 `web_library/_preview_cache/<item_id>/reformat/`
4. 前端新增影像库阅览弹层:
- 增加 `libraryViewerItem``viewerPlane``viewerWindow``viewerSliceIndex``viewerPreview` 等状态。
- 新增“阅览”按钮,打开弹层。
- 弹层提供冠状位/矢状位分段按钮、显示模式选择、切片滑杆、当前切片计数和预览图。
- 按切片、平面、显示模式请求 `/api/library/reformat-preview`
5. 保持现有“信息”“删除”“变换”行为不变,避免影响数据选择和工作站流程。
6. 执行测试方案。
7. 将关键问题和解决方案追加到 `工程分析/经验记录.md`
8. 提交并推送 Giteacommit 信息使用 `2026-05-03-22-36-18 增加DICOM阅览和单项下载`
9. 重新部署到 `http://192.168.3.11:3005/`
## 预期实现细节
- 单项下载 target 使用现有键:
- `original`
- `hard_boundary`
- `gaussian_smooth`
- `soft_transition`
- 阅览接口返回:
- `imageUrl`
- `index`
- `count`
- `plane`
- `window`
- `patientId`
- 窗宽窗位建议:
- default沿用现有 `ct_window` 默认范围。
- bone骨窗。
- soft_tissue软组织窗。
- brain脑窗。
- lung肺窗。
## 回滚思路
如阅览接口或 UI 不符合预期,可回滚 `web_backend.py` 的新接口和 `WebSite/src/App.tsx` 中的阅览弹层及按钮;四状态单项下载和文案调整可单独保留或回退。
## 风险控制
- 不引入新的前端依赖。
- 不修改真实 DICOM 形变与输出目录结构。
- 后端预览只输出 PNG不暴露额外 DICOM 文件写操作。
- 缓存文件写入现有 `_preview_cache` 下,删除影像库条目时继续随缓存目录清理。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -0,0 +1,55 @@
# 实现方案
开始时间2026-05-03-23-22-10
## 本次方案路径
`工程分析/实现方案-2026-05-03-23-22-10.md`
## 实现目标
修复 DICOM 阅览切片/显示模式切换导致后端连接重置的问题,并降低 ZIP 下载误触发和 HTTP 文件加载警告。
## 涉及文件
- `web_backend.py`
- `WebSite/src/App.tsx`
- `工程分析/经验记录.md`
## 执行步骤
1. 后端增加 DICOM 体数据缓存:
- 增加 `DICOM_VOLUME_CACHE` 和锁。
- 基于 DICOM 目录路径、文件数量、最新修改时间生成签名。
- `/api/library/reformat-preview` 复用缓存体数据,避免每次切片/窗位变化都重新读取完整 DICOM。
- 控制缓存规模,避免多个大体数据长期驻留。
2. 后端下载容错:
- `send_file` 捕获 `BrokenPipeError``ConnectionResetError``ConnectionAbortedError`,避免客户端中断下载时污染日志或影响服务。
3. 前端 DICOM 阅览请求防抖:
- 增加 `debouncedViewerSliceIndex`
- 切片滑杆变化后延迟短时间再请求后端,减少快速拖动时的请求数量。
- 显示模式或平面切换仍可即时重置到中间切片,但实际请求走防抖后的切片索引。
4. 前端 ZIP 下载防重复:
- 增加已下载 ZIP job id 记录,确保同一个 ZIP job 自动下载只触发一次。
5. 前端 ZIP 下载方式调整:
- 对后端文件路径优先使用 `fetch` 获取 blob再使用本地 `blob:` URL 触发下载。
- 如 blob 下载失败,回退到原有 `/api/file` URL 方式,并提示用户。
6. 执行测试方案。
7. 更新 `工程分析/经验记录.md`
8. 提交并推送 Giteacommit 信息使用 `2026-05-03-23-22-10 修复阅览切换和下载触发`
9. 重新部署到 `http://192.168.3.11:3005/`
## 回滚思路
若缓存或 blob 下载出现问题,可回滚本次 `web_backend.py``WebSite/src/App.tsx` 修改,恢复直接读取体数据和直接 URL 下载方式。
## 风险控制
- 体数据缓存只影响阅览预览接口,不影响真实形变输出。
- 缓存使用目录签名,影像库数据变化后会重新读取。
- 删除影像库时同步清除 DICOM 文件缓存和体数据缓存。
- 前端保留下载回退路径,避免 blob 下载失败后无法下载。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

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,47 @@
# 实现方案
开始时间2026-05-08-02-54-30
## 本次方案路径
`工程分析/实现方案-2026-05-08-02-54-30.md`
## 实现目标
把模型切分的起点/终点 UI 从两条原生滑杆改为一个单条范围控件。
## 涉及文件
- `WebSite/src/App.tsx`
- `WebSite/src/index.css`
- `工程分析/经验记录.md`
## 执行步骤
1.`App.tsx` 中调整模型切分范围控件:
- 只保留一个可见灰色轨道。
- 用一个绝对定位蓝色条显示 `min(start,end)``max(start,end)` 的范围。
- 两个透明 range input 叠加在同一轨道上,只显示手柄,不显示原生轨道。
- 起点端点使用蓝色,终点端点使用橙色。
2.`index.css` 中新增 `.dual-range-input` 样式:
- 隐藏 `::-webkit-slider-runnable-track``::-moz-range-track`
- 自定义 `::-webkit-slider-thumb``::-moz-range-thumb`
- 设置 pointer-events避免透明 input 阻挡拖拽。
3. 运行前端类型检查和构建。
4. 更新经验记录。
5. 提交并推送 Giteacommit 信息使用 `2026-05-08-02-54-30 调整模型切分范围条`
6. 重新部署到 `http://192.168.3.11:3005/`
## 回滚思路
如范围控件在浏览器中不可拖动,可回滚 `App.tsx``index.css` 中本次范围控件样式,恢复原两个 range input。
## 风险控制
- 仅调整 UI 展示和拖拽样式,不改后端 mask 生成逻辑。
- 保留原有 `modelClipStart``modelClipEnd` 状态和 `clampedModelStart``clampedModelEnd` 计算。
- 构建和类型检查通过后再部署。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -0,0 +1,43 @@
# 实现方案 - 2026-05-08-03-45-01
## 方案路径
围绕现有 DICOM 阅览弹窗调整右侧展示逻辑:普通阅览状态继续显示当前 DICOM 图;点击“模型切分”且 STL 已载入后右侧区域切换为“Mask 展示”,固定展示起点帧和终点帧两张由后端真实 STL mask 生成的图片。
## 涉及文件
- `WebSite/src/App.tsx`
- 优化模型切分启用时的右侧 Mask 展示 UI。
- 明确展示标题、起点帧/终点帧两张图片和各自帧号。
- 避免在 mask 展示中混入单张普通 DICOM 预览。
- `web_backend.py`
- 在已有重建预览接口中增加 `maskOnly` 输出模式,用同一 STL/DICOM 切面 mask 生成纯 mask 图片。
- `工程分析/经验记录.md`
- 追加本轮关键问题和解决方案。
## 执行步骤
1. 确认现有 `modelStartPreview``modelEndPreview` 已通过 `modelId` 请求后端真实 mask 图。
2. 调整后端 `/api/library/reformat-preview`
- 增加 `maskOnly=1` 参数。
- 参数存在且带有 `modelId` 时,输出 STL 切面 mask-only 图片,而不是普通 CT 叠加图。
- 缓存文件名区分 mask-only避免复用旧 CT 预览缓存。
3. 调整 `App.tsx` 中右侧预览区域:
- 模型切分启用且 STL 存在时,显示 Mask 展示头部。
- Mask 展示内容固定为起点帧、终点帧两张图片。
- 两张图片均使用当前平面、窗宽窗位和范围端点请求结果。
- 空交集或加载失败时,在对应图片区域显示状态。
4. 保持未启用模型切分时的普通 DICOM 阅览图不受影响。
5. 运行前端类型检查、构建和后端语法检查。
6. 重启后端与前端服务,验证部署地址可访问。
7. 更新经验记录,提交并推送 Giteacommit 信息使用 `2026-05-08-03-45-01 调整Mask双图展示`
## 回滚思路
若展示逻辑异常,可回滚 `WebSite/src/App.tsx` 中右侧预览区域的 JSX 调整,恢复此前模型切分条件渲染逻辑。
## 风险控制
- 不改 STL 解析和 DICOM mask 计算核心算法,降低医学影像处理链路风险。
- 保留普通 DICOM 阅览路径,避免影响原有冠状位/矢状位查看。
- 使用现有 `modelStartPreview``modelEndPreview` 状态,避免新增并发请求链路。

View File

@@ -0,0 +1,58 @@
# 实现方案 - 2026-05-08-03-57-51
## 方案路径
将右侧“Mask 展示”从 STL/3D 几何结果展示改为真实 DICOM Segmentation Mask 的二维实心切片展示。模型切分操作只决定需要展示的切分位置和上下端点;实际像素内容必须来自与当前 DICOM 数据配准的语义分割 mask 体数据。
## 涉及文件
- `web_backend.py`
- 新增 segmentation mask 数据发现、读取、缓存和切片渲染逻辑。
- 新增或扩展 mask 预览接口,返回切分上侧/下侧两张二维实心 mask 图片。
- 保留 STL 切分用于确定范围或几何参考,但不再用 STL Cap 作为最终 mask 图像数据源。
- `WebSite/src/App.tsx`
- 调整模型切分触发后的“Mask 展示”请求和渲染逻辑。
- 右侧面板固定展示两张二维实心 Segmentation Mask 图片。
- 对无 segmentation 数据、无交集、加载失败分别给出状态。
- `工程分析/经验记录.md`
- 完成后追加本轮关键问题、原因、解决方案和后续避免方式。
## 执行步骤
1. 数据源梳理:
- 检查 `web_library/` 和现有接口中是否已有 DICOM SEG、RTSTRUCT、NIfTI、NRRD、PNG mask 序列或其他语义分割文件。
- 当前未发现现成 segmentation 数据源,因此新增“上传/关联 Segmentation Mask”的后端入口和前端入口避免继续从 STL 伪造 mask。
2. 后端 mask 体数据读取:
- 优先支持 DICOM SEG / DICOM label-map 文件。
- 将 mask 读取为与 CT 体数据对齐的三维 label map。
- 使用缓存避免每次切分重复读取整套 mask。
3. 后端二维实心截面渲染:
- 根据当前模型切分范围计算上侧/下侧两张目标切片。
- 从 Segmentation Mask label map 中取对应切片。
- 生成二维实心 mask-only PNG而不是 CT 叠加图、STL Cap 或点云投影。
- 多标签情况下保留标签差异,可按固定颜色表渲染。
4. 前端“Mask 展示”调整:
- 点击“模型切分”后,右侧面板请求新的 segmentation mask 双图接口。
- 面板标题保持“Mask 展示”,内容为两张二维图片:上侧切面、下侧切面。
- 移除或隐藏任何 3D 外壳/点云/半透明模型展示路径。
5. 错误与降级处理:
- 若没有绑定 segmentation mask 数据,提示需要上传或关联 DICOM Segmentation Mask。
- 若某一切面 mask 为空,显示“该切面无分割区域”,但不生成假 mask。
6. 验证通过后更新经验记录、提交 Gitea并重新部署。
## 回滚思路
若新接口或前端展示出现问题,可回滚 `web_backend.py` 中新增 segmentation mask 读取/渲染逻辑和 `WebSite/src/App.tsx` 中“Mask 展示”的请求渲染改动,恢复当前 STL mask-only 双图逻辑。
## 风险控制
- 不将 STL Cap、封闭面或几何填充作为最终 mask 数据源。
- 对 segmentation 数据缺失做显式错误提示,不用临时绘制图形代替。
- DICOM SEG 坐标对齐需严格参考 CT 的方向、间距、原点和切片顺序。
- 若缺少测试数据,只做接口和类型验证不足以证明医学对齐正确,必须标注残余风险。
## 需要用户确认
- 用户已确认方案,允许新增 Segmentation Mask 上传/关联入口。
- “上、下两个视角”本次按当前切分范围的起点帧/终点帧实现。
- 如果已有真实 segmentation mask 数据,请提供其所在目录或文件格式说明。

View File

@@ -0,0 +1,70 @@
# 工程整体分析
更新时间2026-05-03-18-27-21
## 项目定位
Head_CT_Morph 是一个头颈部 CT 仰头形变工具与网页工作站,包含 Python DICOM 处理能力、桌面辅助入口,以及 React/Vite 网页工作站。
## 主要结构
- `web_backend.py`:本地 Python API 后端,负责影像库、预览、任务、文件下载,以及调用形变和视频生成逻辑。
- `head_extension_app.py`:四状态 DICOM 形变核心逻辑与桌面界面。
- `generate_head_extension_video.py`:仰头角度变化 MP4 生成逻辑。
- `video_generator_app.py`:视频生成桌面界面包装。
- `WebSite/`React 19 + Vite 前端工作站。
- `requirements.txt`Python 依赖,包括 DICOM、图像、医学影像处理与视频输出相关库。
- `WebSite/package.json`:前端依赖与运行脚本。
## 运行方式
后端:
```bash
cd WebSite
npm run backend
```
前端:
```bash
cd WebSite
npm run dev
```
前端默认端口为 `3005`,后端默认监听 `0.0.0.0:8787`
## 构建与检查
前端类型检查:
```bash
cd WebSite
npm run lint
```
前端构建:
```bash
cd WebSite
npm run build
```
Python 当前未发现统一测试脚本。涉及 Python 修改时,应至少执行对应模块的语法检查或针对性手工验证。
## 数据与产物
- DICOM 原始数据、上传库、运行结果、视频、ZIP、`node_modules` 等应避免作为常规修改提交。
- 网页上传数据通常写入 `web_library/`
- 处理结果通常写入 `web_results/`
## 修改风险
- 医学影像处理链路对路径、DICOM 排序、体素间距、输出格式较敏感。
- 后端接口同时承担文件读写、任务状态和下载路径暴露,修改时需关注路径安全与异常处理。
- 前端工作站面向实际操作流程,修改 UI 时需保持工作流连续性,不应引入说明性落地页替代真实工具界面。
- 形变和视频生成可能耗时较长,测试时要区分轻量类型检查、构建检查和完整样本数据验证。
## 长期工作流入口
项目修改工作流写入仓库根目录 `AGENTS.md`。后续每次项目修改需求都应先生成需求分析、实现方案、测试方案,并在实现方案与测试方案通过用户二次审核后再修改业务代码。

View File

@@ -0,0 +1,55 @@
# 测试方案
开始时间2026-05-03-18-27-21
## 本次方案路径
`工程分析/测试方案-2026-05-03-18-27-21.md`
## 测试范围
- 文档是否创建在正确路径。
- 工作流是否覆盖用户要求的 0 至 7 步。
- 前端类型检查是否通过。
- 前端构建是否通过。
- 项目是否能重新启动后端和前端开发服务。
## 测试命令
```bash
find 工程分析 -maxdepth 1 -type f | sort
```
```bash
cd WebSite
npm run lint
```
```bash
cd WebSite
npm run build
```
## 手工验证点
- `AGENTS.md` 是否明确后续项目修改需求必须先生成需求分析、实现方案和测试方案。
- `AGENTS.md` 是否明确实现方案和测试方案必须经过用户二次审核确认。
- `工程分析/经验记录.md` 是否采用 A/B/C/D 四段式。
- Gitea 凭据是否未写入仓库文件。
## 验收标准
- 所有本次计划新增文档存在。
- 文档内容覆盖用户要求。
- `npm run lint` 通过。
- `npm run build` 通过。
- 重新部署后可访问前端开发服务,后端进程正常监听。
## 无法测试或残余风险
- 本次不运行完整 DICOM 形变任务,避免产生大型运行结果;医学影像处理链路需在未来涉及相关代码修改时单独验证。
- Gitea 推送依赖远程服务和凭据可用性,如远程不可达需记录失败原因并提醒用户。
## 人工审核状态
本次为建立工作流本身,按用户明确指令直接执行。后续业务代码修改必须等待用户对测试方案进行二次人工审核确认。

View File

@@ -0,0 +1,77 @@
# 测试方案
开始时间2026-05-03-22-02-15
## 本次方案路径
`工程分析/测试方案-2026-05-03-22-02-15.md`
## 测试范围
- 快速预览接口 `/api/preview` 在硬边界、高斯平滑、软过渡三种模式下是否正常返回图片。
- 硬边界和高斯平滑预览图中黄色分界线是否为单条线。
- 四状态过程预览图中硬边界、高斯平滑截图是否不再出现 Y 字分界线。
- 前端类型检查和构建是否通过。
- 重新部署后 `http://192.168.3.11:3005/` 是否可访问。
## 测试命令
前端类型检查:
```bash
cd WebSite
npm run lint
```
前端构建:
```bash
cd WebSite
npm run build
```
Python 语法检查:
```bash
python -m py_compile head_extension_app.py web_backend.py
```
预览接口手工验证建议:
```bash
curl -s -X POST http://127.0.0.1:8787/api/preview \
-H 'Content-Type: application/json' \
-d '{"inputDir":"<有效DICOM目录>","angleDegrees":20,"showCutoffLine":true,"mode":"hard_boundary"}'
```
```bash
curl -s -X POST http://127.0.0.1:8787/api/preview \
-H 'Content-Type: application/json' \
-d '{"inputDir":"<有效DICOM目录>","angleDegrees":20,"showCutoffLine":true,"mode":"gaussian_smooth","gaussianSigma":3}'
```
## 手工验证点
- 在网页“影像变换工作站”打开预览分界线后,切换到“硬边界”,黄色线应为单条线。
- 切换到“高斯平滑”,黄色线应为单条线。
- 切换到“软过渡”,预览应仍自然、无 Y 字撕裂。
- 点击“隐藏预览分界线”后,预览图中不显示黄色线。
## 验收标准
- 硬边界、高斯平滑预览中不再出现 Y 字形黄色分界线。
- 预览接口仍正常返回图片。
- `python -m py_compile head_extension_app.py web_backend.py` 通过。
- `npm run lint` 通过。
- `npm run build` 通过。
- Gitea 文档和代码备份 commit 完成。
- 项目重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`
## 无法测试或残余风险
- 如果没有用户指定的实际业务 DICOM 数据,只能使用仓库现有样例目录 `Ori_Head_CT/` 做验证。
- 线条位置是预览标注层,和真实 3D DICOM 形变边界不应被理解为像素级临床标尺。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -0,0 +1,87 @@
# 测试方案
开始时间2026-05-03-22-36-18
## 本次方案路径
`工程分析/测试方案-2026-05-03-22-36-18.md`
## 测试范围
- 前端文案是否正确调整。
- 四状态输出区每个状态是否都有独立下载按钮。
- 单状态 DICOM ZIP 下载是否能触发、轮询并下载。
- 数据影像库“变换”“阅览”“信息”“删除”按钮是否正常布局和工作。
- 阅览弹层是否能显示冠状位、矢状位。
- 阅览弹层是否能切换 DICOM/CT 显示模式和切片。
- 前端类型检查、构建和 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
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=coronal&index=0&window=bone'
```
```bash
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=sagittal&index=0&window=soft_tissue'
```
部署验证:
```bash
curl -I --max-time 5 http://192.168.3.11:3005/
```
## 手工验证点
- 左侧按钮显示“下载结果”。
- 四状态卡片中原始序列、硬边界、高斯平滑、软过渡重建旁边均有下载按钮。
- 点击单状态下载按钮后,能下载对应 DICOM 序列 ZIP。
- 数据影像库卡片中“调阅”改为“变换”。
- 点击“变换”仍能进入影像变换工作站并选择该数据。
- 点击“阅览”打开弹层,能查看冠状位和矢状位。
- 切换显示模式后图像灰度/对比度发生变化。
- 调节切片滑杆后图像更新。
## 验收标准
- 所有用户提出的文字和功能入口均出现。
- 单状态 DICOM ZIP 下载可用。
- 阅览弹层可用,冠状位/矢状位和显示模式切换可用。
- `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`
## 无法测试或残余风险
- 若没有真实业务 DICOM 数据,使用仓库样例或当前影像库数据验证;不同扫描协议下的窗宽窗位显示效果可能需用户进一步微调。
- 冠状/矢状重建为轻量预览,不替代专业 DICOM 阅片器的诊断级 MPR 功能。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -0,0 +1,78 @@
# 测试方案
开始时间2026-05-03-23-22-10
## 本次方案路径
`工程分析/测试方案-2026-05-03-23-22-10.md`
## 测试范围
- DICOM 阅览冠状位/矢状位请求是否稳定。
- 切片滑杆快速变化是否不再导致后端被杀或连接重置。
- 显示模式切换是否正常返回图片。
- ZIP 下载 job 完成后是否只触发一次下载。
- 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
for i in 0 64 128 256 384 511; do
curl -s "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=$i&window=bone" >/dev/null
done
```
部署验证:
```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=511&window=bone"
```
## 手工验证点
- 打开数据影像库,进入 DICOM 阅览。
- 拖动切片滑杆,图像更新且控制台不再出现 `ERR_CONNECTION_RESET`
- 切换显示模式,图像更新且后端服务保持在线。
- 如有 ZIP 打包下载任务,完成后只自动下载一次。
## 验收标准
- 后端不再因为阅览切片/窗位切换被 `Killed`
- `reformat-preview` 连续请求正常返回 JSON。
- `npm run lint` 通过。
- `npm run build` 通过。
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
- Gitea commit/push 完成。
- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`
## 残余风险
- 纯 HTTP 部署下,浏览器可能仍会对下载文件给出安全提示;本次通过 blob 下载和重复触发保护尽量降低该提示出现概率。彻底消除需要 HTTPS 部署。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

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

@@ -0,0 +1,61 @@
# 测试方案
开始时间2026-05-08-02-54-30
## 本次方案路径
`工程分析/测试方案-2026-05-08-02-54-30.md`
## 测试范围
- 模型切分范围控件是否显示为一个单条范围条。
- 起点/终点端点是否在同一条轨道上。
- 起点/终点是否仍可拖动并允许交叉。
- 前端类型检查和构建。
- 重新部署访问验证。
## 测试命令
前端类型检查:
```bash
cd WebSite
npm run lint
```
前端构建:
```bash
cd WebSite
npm run build
```
部署验证:
```bash
curl -I --max-time 5 http://192.168.3.11:3005/
```
## 手工验证点
- 打开 DICOM 阅览,上传 STL启用模型切分。
- 模型切分范围只显示一条轨道。
- 蓝色范围条位于起点与终点之间。
- 拖动蓝色起点端点、橙色终点端点,两个端点仍可交叉。
- 起点帧/终点帧 mask 展示仍正常刷新。
## 验收标准
- 不再出现“起点”和“终点”两条独立进度条。
- `npm run lint` 通过。
- `npm run build` 通过。
- Gitea commit/push 完成。
- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`
## 残余风险
- 原生 range 手柄在不同浏览器的细节样式可能略有差异,但应保持单条范围条的视觉结构。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -0,0 +1,50 @@
# 测试方案 - 2026-05-08-03-45-01
## 测试范围
- 前端类型检查。
- 前端生产构建。
- 后端 Python 语法检查。
- 模型切分启用/关闭时右侧预览区域的条件渲染。
- 起点帧、终点帧两张 mask 图片路径是否仍来自后端 `modelId` 请求结果。
## 测试命令
```bash
cd WebSite
npm run lint
npm run build
```
```bash
python -m py_compile web_backend.py
```
部署检查:
```bash
cd WebSite
npm run backend
npm run dev
```
或按当前运行方式重启对应服务后访问 `http://192.168.3.11:3005`
## 手工验证点
- 打开 DICOM 阅览,未启用模型切分时,右侧仍显示普通 DICOM 预览图。
- 上传 STL 并点击“模型切分”后,右侧显示 Mask 展示。
- Mask 展示中有且只有两张图片:起点帧、终点帧。
- 调整范围端点后,两张图片对应帧号和图像刷新。
- 切换冠状位/矢状位或显示模式后,两张 mask 图片刷新。
## 验收标准
- 模型切分启用后,右侧不再表现为单张 CT mask 或交互式模型视图,而是两张静态 mask-only 图片。
- 两张图片分别对应 DICOM 起点帧和终点帧的 STL 切面 mask。
- `npm run lint``npm run build` 通过。
- 项目重新部署后页面可访问。
## 无法测试的风险
- 若当前环境缺少可用于手工验证的 STL 与 DICOM 数据,只能通过类型检查、构建和接口逻辑验证保证基本正确性。

View File

@@ -0,0 +1,55 @@
# 测试方案 - 2026-05-08-03-57-51
## 测试范围
- Segmentation Mask 数据发现或上传/关联流程。
- 后端 mask 读取、缓存、切片提取和 PNG 渲染。
- 前端模型切分触发后右侧“Mask 展示”双图渲染。
- 无 segmentation 数据、空 mask、加载失败等状态。
- 既有 DICOM 阅览、STL 上传和模型切分范围控件不发生回归。
## 测试命令
```bash
python -m py_compile web_backend.py
```
```bash
cd WebSite
npm run lint
npm run build
```
后端烟测:
```bash
python - <<'PY'
from web_backend import list_segmentations
print(list_segmentations('demo_ori_head_ct'))
PY
```
使用临时 DICOM mask 文件验证上传解析和 segmentation preview 生成,验证后删除临时文件。
## 手工验证点
- 打开逆向工作区或 DICOM 阅览,加载 CT 数据。
- 上传或关联真实 DICOM Segmentation Mask。
- 执行“模型切分”后右侧“Mask 展示”显示两张二维实心切面图片。
- 两张图分别对应切分位置上侧/下侧,或确认后的起点帧/终点帧。
- 图片像素来自 segmentation mask不显示 3D 外壳、点云、空心模型或 STL Cap。
- 没有 segmentation 数据时,界面提示需要上传/关联 mask不生成假图。
- 切换显示平面或调整切分范围后,双图刷新到对应切面。
## 验收标准
- “Mask 展示”在模型切分后只显示二维实心 Segmentation Mask 双图。
- 双图数据源为真实 DICOM 语义分割 mask而非 STL 几何封顶或点云投影。
- 无 mask 数据时不伪造结果。
- `python -m py_compile web_backend.py``npm run lint``npm run build` 均通过。
- 项目重新部署后 `http://192.168.3.11:3005` 可访问。
## 无法测试的风险
- 若当前环境没有真实 DICOM SEG/语义分割数据,只能验证缺失数据提示和代码结构,无法验证医学空间对齐准确性。
- 若 segmentation mask 来源不是标准 DICOM SEG可能需要针对实际格式补充解析和配准逻辑。

View File

@@ -0,0 +1,147 @@
# 经验记录
本文件作为项目统一知识库。每次最终执行项目修改前必须阅读;每次执行完成后,如遇到关键问题或形成可复用经验,必须按以下四段式追加记录。
## 2026-05-03-18-27-21 建立项目修改工作流
A. 具体问题
项目此前缺少统一的修改前分析、人工审核、测试确认、经验沉淀、Gitea 备份和重新部署流程,后续需求容易直接进入代码修改,导致风险不可追踪。
B. 产生问题原因
仓库中已有 README 和运行说明,但没有面向后续代码编纂工作的强制流程文件,也没有统一的 `工程分析/` 知识库目录。
C. 解决问题方案
新增仓库根目录 `AGENTS.md`,明确后续项目修改需求必须记录开始时间、生成需求分析、实现方案、测试方案,并在实现方案和测试方案通过用户二次审核后再修改业务代码。新增 `工程分析/工程整体分析.md`、本经验记录,以及本次需求、实现、测试方案文档。
D. 后续如何避免问题
后续每次项目修改开始时先阅读 `AGENTS.md``工程分析/工程整体分析.md``工程分析/经验记录.md`,严格使用同一开始时间戳创建三类分析文档,并在用户确认实现方案和测试方案后再执行代码改动。
## 2026-05-03-22-02-15 调整硬边界与高斯平滑预览分界线
A. 具体问题
硬边界和高斯平滑预览图中的黄色分界线出现 Y 字形:一部分随头颈部形变变成斜线,另一部分仍横向延长,视觉上像多出一条不自然的横线。
B. 产生问题原因
旧逻辑先把黄色分界线画到原始 CT 预览图上,再把整张图传入 `preview_deform_2d` 做快速 2D 形变。硬边界和高斯平滑在分界附近存在不连续或快速变化的运动权重,导致同一条标注线被分裂成不同运动状态。
C. 解决问题方案
将硬边界和高斯平滑预览改为先对不含黄色线的 CT 图执行形变,再在形变结果上叠加单条黄色分界线。保留原始图的水平分界线,并尽量不改变软过渡原有视觉效果。
D. 后续如何避免问题
预览标注层、参考线、箭头等非 CT 内容应优先在形变结果上单独叠加,不要和医学影像像素一起参与形变采样。若必须参与形变,应先确认该标注能承受硬边界或快速变化权重造成的撕裂效果。
## 2026-05-03-22-36-18 增加 DICOM 阅览和单项下载
A. 具体问题
四状态结果区只有总 ZIP 下载入口,用户无法直接下载单个状态的 DICOM 序列;影像库卡片只能进入变换工作站或查看信息,缺少冠状位、矢状位阅览能力和 CT 显示模式切换。
B. 产生问题原因
后端已经具备单状态 DICOM ZIP 打包能力,但前端未暴露单状态按钮。影像库已有轴位缩略图接口,但该接口只读取单张 DICOM未提供基于整套 DICOM 体数据的冠状/矢状重建预览。
C. 解决问题方案
前端在四状态卡片标题旁增加单状态下载按钮,复用现有 ZIP job 轮询和下载逻辑。后端新增影像库冠状/矢状重建预览接口,并按平面、切片和窗宽窗位缓存 PNG。前端新增影像库阅览弹层支持冠状位、矢状位、切片滑杆和显示模式切换。
D. 后续如何避免问题
已有后端能力应先通过前端入口复用不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。
## 2026-05-03-23-22-10 修复阅览切换和下载触发
A. 具体问题
DICOM 阅览切片滑杆和显示模式切换时,浏览器出现 `ERR_CONNECTION_RESET`;同时 ZIP 文件下载可能在其他 UI 操作期间再次触发,并出现 HTTP 文件加载安全提示。
B. 产生问题原因
阅览重建接口每次请求都重新读取整套 DICOM 体数据,切片滑杆快速拖动和显示模式切换会并发触发多个重建请求。前端 AbortController 只能取消浏览器等待,不能停止后端已经开始的 DICOM 读取容易造成内存压力并导致后端被系统终止。ZIP 下载通过直接访问后端 `/api/file` URL 触发,且缺少已完成 ZIP job 的一次性下载保护。
C. 解决问题方案
后端为 DICOM 体数据增加基于目录签名的内存缓存,并限制缓存数量;阅览接口复用缓存体数据生成不同平面、切片和窗宽窗位的 PNG。前端为阅览切片请求增加防抖减少滑杆拖动时的请求数量。ZIP 下载增加已下载 job id 记录,并优先使用 fetch blob 转本地 `blob:` URL 触发下载,失败时再回退到直接 URL。
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 坐标系不一致,应优先补充配准矩阵或平移/旋转/缩放参数,而不是在图像上手工假标。
## 2026-05-08-02-54-30 调整模型切分范围条
A. 具体问题
模型切分的起点和终点虽然逻辑上共用一段范围,但 UI 使用两个原生 range 叠加,浏览器仍显示两条完整蓝色轨道,看起来像两个独立进度条。
B. 产生问题原因
原生 `<input type="range">` 会绘制自己的轨道。两个 range 即使绝对定位叠在同一区域,也会各自显示一条轨道,不能自然表现为单条范围选择器。
C. 解决问题方案
将可见轨道改为自定义 div一条灰色总轨道和一段蓝色选中范围两个原生 range 仅保留透明拖拽层和手柄。通过 CSS 隐藏 WebKit/Firefox 的原生轨道,并分别给起点、终点手柄设置颜色。
D. 后续如何避免问题
实现双端范围选择器时,不要直接依赖两个原生 range 的默认轨道。应使用自定义轨道绘制范围,仅让原生 input 提供拖拽能力,或引入明确支持 range selection 的组件。
## 2026-05-08-03-45-01 调整 Mask 双图展示
A. 具体问题
用户强调逆向工作区右侧“Mask 展示”应在点击“模型切分”后显示,并且展示前后两张由 DICOM 切分 STL 模型得到的图片,而不是单张 CT mask、交互式模型视图或混杂普通 DICOM 阅览结果。
B. 产生问题原因
此前模型切分展示复用了 DICOM 重建预览图叠加 mask 的接口语义,虽然已有起点帧和终点帧两张图,但视觉上仍容易被理解为 CT 图叠加结果,没有把“模型切面 mask 图片”与普通 DICOM 阅览明确区分。
C. 解决问题方案
在后端重建预览接口增加 `maskOnly=1` 输出模式,复用真实 STL/DICOM 切面 mask 计算结果生成纯 mask 图片,并用独立缓存后缀避免复用普通 CT 预览。前端在模型切分启用且 STL 已载入时将右侧区域切换为明确的“Mask 展示”双图布局,分别展示起点帧和终点帧。
D. 后续如何避免问题
涉及 mask 或语义分割结果展示时应区分“CT 背景叠加图”和“mask-only 结果图”。如果用户要求展示模型切面或分割形态,优先提供独立 mask 图片,并在 UI 上明确区分普通阅览与切分结果。
## 2026-05-08-03-57-51 接入 DICOM SEG 双切面展示
A. 具体问题
用户进一步明确右侧“Mask 展示”不能显示切分后的三维模型外壳、点云、空心模型,也不能用 STL 截面封顶 Cap 作为结果;模型切分后应展示上、下两个二维实心切面,并且像素数据必须来自 DICOM 语义分割影像。
B. 产生问题原因
此前实现虽然把右侧改成了双图,但数据仍可来自 STL 与 DICOM 平面求交后的 mask-only 渲染。该结果属于几何投影/轮廓填充,不等价于真实 DICOM Segmentation Mask label map。
C. 解决问题方案
新增与影像库条目绑定的 DICOM SEG 上传、列表和预览接口,后端将 SEG 像素解析成与 CT 体数据对齐的三维 label map并按当前切分范围上、下端点渲染二维实心 mask 图片。前端新增 DICOM SEG 上传入口模型切分后的“Mask 展示”改为请求 segmentation 预览接口;没有绑定 SEG 时只提示缺少数据,不再回退到 STL 生成假结果。
D. 后续如何避免问题
凡是用户明确要求 DICOM 语义分割或 Segmentation Mask 时,不能用 STL、mesh cap、点云投影或图形填充替代。实现前应先确认真实 label map 数据源;若数据源缺失,界面应提示上传或关联,而不是生成看似合理的伪 mask。

View File

@@ -0,0 +1,43 @@
# 需求分析
开始时间2026-05-03-18-27-21
## 原始需求
用户要求新建一个完整的代码编纂工作流。后续只要提出项目修改相关需求,都需要按照该工作流执行:
- 每次执行前记录开始时间。
- 阅读或创建 `工程分析/` 文件夹,并维护工程整体分析。
- 每次需求写入 `工程分析/需求分析-时间戳.md`
- 每次实现方案写入 `工程分析/实现方案-时间戳.md`,写完后必须经过用户二次人工审核确认。
- 每次测试方案写入 `工程分析/测试方案-时间戳.md`,写完后必须经过用户二次人工审核确认。
- 最终执行前阅读 `工程分析/经验记录.md`,执行后以四段式记录关键问题及解决方案。
- 最终执行后使用 Gitea 备份 commit提交信息包含时间戳和简要描述并提醒用户备份完成。
- 重新部署本项目。
## 目标
建立仓库内可持续遵守的修改工作流,让后续项目修改具备需求记录、方案审查、测试审查、经验沉淀、文档备份和重新部署闭环。
## 影响范围
- 新增仓库根目录 `AGENTS.md`,作为后续 Codex/工程协作的优先工作流入口。
- 新增并维护 `工程分析/` 目录。
- 不修改本次业务代码。
- 不把 Gitea 密码写入仓库文件。
## 约束
- 文档文件使用本次开始时间戳 `2026-05-03-18-27-21`
- Gitea 凭据只能用于备份操作,不得持久化到仓库。
- 后续涉及业务代码修改时,必须等待用户审核确认实现方案和测试方案。
## 风险点
- 若未来执行者未阅读 `AGENTS.md`,可能绕过工作流。
- 若把凭据写入文档或提交信息,会造成安全风险。
- 若部署方式变化但未更新工程整体分析,后续重新部署可能失败。
## 待确认事项
本次按用户指令直接建立工作流;后续任何业务代码修改都需要先等待用户确认实现方案和测试方案。

View File

@@ -0,0 +1,40 @@
# 需求分析
开始时间2026-05-03-22-02-15
## 原始需求
用户要求将“硬边界”和“高斯平滑”预览图上的黄色预览分界线,从当前的 Y 字形改成一条线。用户反馈当前横向延长部分过多,视觉上比较奇怪。
## 目标
- 硬边界预览图中的黄色分界线显示为单条线,不再出现一条斜线叠加一条长横线的 Y 字效果。
- 高斯平滑预览图中的黄色分界线显示为单条线,不再出现楔形或 Y 字残留。
- 保留“显示/隐藏预览分界线”的现有功能。
- 不改变 DICOM 形变计算结果,只调整预览分界线的绘制方式。
## 影响范围
- 主要影响 `head_extension_app.py` 中的预览图绘制逻辑。
- `web_backend.py` 调用 `preview_deform_2d``draw_cutoff_line` 的路径会间接受影响,必要时同步调整调用方式。
- 前端 `WebSite/src/App.tsx` 预计不需要修改,因为问题发生在后端生成的预览图片中。
## 当前定位
当前预览分界线由 `draw_cutoff_line` 先画到原始 sagittal panel 上,再将带黄线的图片传给 `preview_deform_2d`。硬边界和高斯平滑模式下,分界线附近的运动权重不连续或变化很快,导致黄线的一部分随头部旋转,另一部分保持水平,从而形成 Y 字形。
## 约束
- 修改前必须先完成实现方案和测试方案文档,并等待用户二次人工审核确认。
- 本次应聚焦预览线绘制,不改动真实 3D DICOM 形变算法。
- 需要避免让原图、软过渡预览或下载结果出现新的不一致。
## 风险点
- 如果直接改变 `preview_deform_2d` 的形变算法,可能影响预览图中 CT 形变效果,超出需求范围。
- 如果只隐藏横线,可能导致用户无法识别分界位置。
- 如果新线条绘制与图片缩放顺序不一致,可能在网页预览和四状态过程图里出现线条位置偏差。
## 待确认事项
建议方案是:硬边界和高斯平滑预览先生成无黄线的形变 CT 图,再在形变结果上叠加单条黄色斜向分界线;原始图仍显示水平线,软过渡维持当前单线效果或使用同一叠加方式保持一致。需用户确认后执行代码修改。

View File

@@ -0,0 +1,55 @@
# 需求分析
开始时间2026-05-03-22-36-18
## 原始需求
用户提出三项修改:
1. 左侧“下载四状态ZIP”描述变为“下载结果”。
2. “四状态 DICOM 输出结果”中,原始序列、硬边界、高斯平滑、软过渡重建旁边都增加下载按钮,可以下载对应的 DICOM 文件。
3. 在“数据影像库”中,“调阅”描述变为“变换”;右侧增加“阅览”按钮,可以看 CT 冠状位、矢状位,同时可以调节 DCM 影像显示模式。
## 目标
- 调整已有按钮文案,使下载入口和变换入口更符合用户表达。
- 在四状态结果区为每个状态提供独立 DICOM 下载入口。
- 在影像库卡片中新增阅览入口,打开 DICOM 阅览弹层。
- 阅览弹层支持冠状位、矢状位切换,并支持常见 DICOM/CT 显示窗宽窗位模式切换。
## 影响范围
- `WebSite/src/App.tsx`
- 修改按钮文字。
- 增加四状态单项下载按钮。
- 增加影像库阅览弹层状态、UI 和请求逻辑。
- `web_backend.py`
- 复用或扩展现有 DICOM 读取、窗宽窗位、预览缓存能力。
- 增加影像库冠状位/矢状位阅览预览接口。
- `工程分析/经验记录.md`
- 完成后追加关键问题和解决方案。
## 当前定位
- 四状态单状态 ZIP 下载能力后端已存在:`prepare_deformation_zip(job_id, target)` 支持 `original``hard_boundary``gaussian_smooth``soft_transition`
- 前端现有 `handlePackageDownload(target)` 已支持传入 target并会轮询 ZIP job 完成后下载文件;当前仅全量 DICOM ZIP 按钮显式暴露。
- 影像库目前只有轴位切片缩略预览接口 `/api/library/preview`,使用单张 DICOM 像素生成 PNG。
- 影像库信息弹层已存在,可在其旁边新增独立阅览弹层,不必复用信息弹层。
## 约束
- 修改业务代码前必须先等待用户确认实现方案和测试方案。
- 不改变 DICOM 形变算法。
- 单状态下载应下载对应状态完整 DICOM 序列的 ZIP而不是仅下载截图。
- 阅览功能应尽量轻量,不引入大型医学影像前端库。
## 风险点
- 冠状位/矢状位预览需要读取整套 DICOM 体数据,可能比单张轴位预览更耗时;需要做缓存。
- DICOM 切片排序、窗宽窗位和方向显示若处理粗糙,可能导致阅览体验不稳定。
- 四状态卡片空间有限,新增下载按钮需要避免文字拥挤或遮挡。
- 单状态 ZIP job 与全量 ZIP job 共用 `zipJobs` 状态,需避免 target key 冲突。
## 待确认事项
建议实现为:四状态卡片标题旁增加图标下载按钮;影像库卡片按钮区从“调阅/信息/删除”调整为“变换/阅览/信息/删除”,阅览弹层支持冠状位、矢状位,以及默认/骨窗/软组织/脑窗/肺窗等显示模式。待用户确认后执行。

View File

@@ -0,0 +1,55 @@
# 需求分析
开始时间2026-05-03-23-22-10
## 原始需求
用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改均不需要用户二次人工确认。
需要修复两个问题:
1. 点击 DICOM 阅览中的切片滚动条时出现与 ZIP 文件相关的浏览器警告:`The file at ...head_ct_morph_selected_54ed40feeb63.zip was loaded over an insecure connection. This file should be served over HTTPS.`
2. 切换 DICOM 阅览显示模式时出现请求失败:`GET /api/library/reformat-preview ... net::ERR_CONNECTION_RESET`
## 目标
- DICOM 阅览切片滚动和显示模式切换不再导致后端连接重置。
- 降低切片滑杆快速拖动时的请求风暴和后端内存压力。
- ZIP 下载完成后只触发一次下载,避免和其他 UI 操作交织造成误触发。
- 尽量避免通过直接加载 ZIP URL 的方式触发浏览器 insecure file 警告。
## 影响范围
- `web_backend.py`
- DICOM 阅览重建预览接口。
- DICOM 体数据读取和缓存策略。
- 文件下载响应的连接中断容错。
- `WebSite/src/App.tsx`
- DICOM 阅览请求节流/防抖。
- ZIP 下载触发逻辑。
- 已完成 ZIP job 的重复下载保护。
- `工程分析/经验记录.md`
## 当前定位
- 后端日志显示 `python ../web_backend.py` 被系统 `Killed`,符合快速并发读取整套 DICOM 体数据导致内存压力过大的表现。
- 当前 `/api/library/reformat-preview` 每次请求都会调用 `load_dicom_volume(item["dicomPath"])`,快速拖动滑杆或切换窗位会并发生成多个请求。
- 前端阅览 effect 在 `viewerSliceIndex``viewerWindow` 等状态变化时立即发请求AbortController 只中断浏览器端等待,不能中止后端已经开始的体数据读取。
- ZIP 下载目前通过临时 `<a>` 直接访问后端 `/api/file?path=...zip`,浏览器可能显示 HTTP 文件下载安全警告;同时需要防止同一个 ZIP job 多次自动触发下载。
## 约束
- 不需要用户二次确认,可以直接执行修改。
- 不改变真实 DICOM 形变算法。
- 不引入大型前端 DICOM 阅片库。
- 不把运行缓存图片、ZIP、DICOM 或构建产物提交到仓库。
## 风险点
- 体数据内存缓存会占用一定内存,需要按影像库目录签名复用并限制缓存规模。
- Blob 下载大 ZIP 会占用浏览器内存;本次优先用于减少直接 URL 加载警告,仍需关注超大 ZIP 的浏览器表现。
- 浏览器对 HTTP 下载的安全提示无法在纯 HTTP 部署下彻底消除;如果必须完全消除,需要 HTTPS 部署。
## 待确认事项
用户已明确本次不需要二次人工确认,因此本次文档写完后直接执行实现和测试。

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

View File

@@ -0,0 +1,45 @@
# 需求分析
开始时间2026-05-08-02-54-30
## 原始需求
用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改都不需要人工二次确认。
具体问题:模型切分中的起点、终点应合并到一个进度条里,这个控件应表现为一个“范围”,而不是两条独立进度条。用户截图显示当前界面仍然出现“起点”和“终点”两条蓝色进度条。
## 目标
- 将模型切分起点/终点控件改成一个真正的范围条。
- 范围条只有一条灰色总轨道。
- 起点和终点之间显示一段蓝色选中范围。
- 两个端点在同一条轨道上拖动,允许交叉。
- 保留现有起点帧/终点帧 mask 逻辑。
## 影响范围
- `WebSite/src/App.tsx`
- 调整模型切分范围控件 DOM 和样式。
- `WebSite/src/index.css`
- 新增范围控件的原生 range 轨道隐藏和端点样式。
- `工程分析/经验记录.md`
## 当前定位
当前实现使用两个原生 `<input type="range">` 叠放,但原生 range 的轨道仍可见,因此浏览器绘制出两条完整蓝色轨道,视觉上不是一个范围控件。
## 约束
- 不修改后端 STL mask 算法。
- 不改变起点帧/终点帧计算逻辑。
- 不新增前端依赖。
- 本次不需要用户二次确认,可直接执行。
## 风险点
- 原生 range 在不同浏览器中伪元素样式不同,需要同时覆盖 WebKit 和 Firefox。
- 两个端点重叠时,需要保证都能拖动。
## 待确认事项
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -0,0 +1,38 @@
# 需求分析 - 2026-05-08-03-45-01
## 原始需求
用户要求逆向工作区右侧“Mask 展示”应在点击“模型切分”后显示,内容是前后两张 DICOM 切分出的模型样子,且明确应为两张图片。
## 目标
- 模型切分未启用时,不展示 STL mask 双图结果。
- 模型切分启用且存在 STL 模型时,右侧展示起点帧、终点帧两张图片。
- 两张图片应来自当前 DICOM 平面、显示模式、切片范围端点和 STL 模型的真实切面 mask 结果。
- 保持普通 DICOM 阅览在未启用模型切分时可正常使用。
## 影响范围
- `WebSite/src/App.tsx`
- DICOM 阅览弹窗右侧预览区域。
- 模型切分状态、起点帧/终点帧 mask 预览显示。
- `web_backend.py`
- 复核现有 `/api/library/reformat-preview` 是否已经支持 `modelId` 生成 STL mask 叠加图。
## 约束
- 严格使用本仓库代码编纂工作流。
- 本轮使用同一开始时间戳 `2026-05-08-03-45-01`
- 本次按用户此前说明,需求分析、实现方案、测试方案和执行修改不再等待二次人工确认。
- 不得用象征性图形替代真实 STL/DICOM 切面关系。
- 不得提交 Gitea 密码、令牌或其他凭据。
## 风险点
- 若只改前端文案但仍展示单图或普通 DICOM 图,用户会误认为 mask 展示没有按切分结果变化。
- 若后端缓存文件名未区分模型、平面、窗宽窗位、切片索引,可能显示旧图。
- STL 与 DICOM 坐标系不一致时mask 可能为空;前端需给出清晰状态。
## 待确认事项
- 无需等待二次确认;按用户此前授权直接执行。

View File

@@ -0,0 +1,52 @@
# 需求分析 - 2026-05-08-03-57-51
## 原始需求
【需求模块】:逆向工作区 -> 右侧“Mask 展示”面板
【触发条件】:用户在可视化工具栏点击执行“模型切分”操作后。
【当前表现】“Mask 展示”区目前显示的仍是切分后的三维模型外壳/点云,呈半透明或空心状态。
【期望表现】“Mask 展示”区需要切换显示为二维的实心截面图像。需要同时展示模型被切开处上、下两个视角的切面。
【数据源要求】:这两个实心切面不能仅仅是 3D 模型的截面封顶 Cap而必须直接映射并渲染对应的 DICOM 语义分割影像 Segmentation Mask。
## 目标
- 模型切分执行后右侧“Mask 展示”不再显示 3D 外壳、半透明点云、空心模型或几何封顶。
- “Mask 展示”改为二维图像区域,并同时展示切分位置上侧、下侧两张实心截面图。
- 两张截面图的数据源必须来自 DICOM 语义分割影像/Segmentation Mask而不是由 STL 外壳临时封顶生成。
- 截面图需与当前 DICOM 切分范围和显示平面保持一致。
## 影响范围
- 前端:
- 逆向工作区或 DICOM 阅览弹窗中的“Mask 展示”条件渲染。
- 模型切分执行后的右侧双图布局、加载状态和无 mask 状态展示。
- 后端:
- 当前 `web_backend.py` 已有 STL 上传、STL 与 DICOM 切片平面求交 mask 生成逻辑。
- 需要新增或接入真实 DICOM Segmentation Mask 数据读取、缓存和二维切片渲染能力。
- 数据:
- 需要确认工程中是否已有 DICOM SEG、RTSTRUCT、NIfTI/NRRD mask、PNG mask 序列或其他语义分割数据源。
## 约束
- 必须遵循仓库 `AGENTS.md` 中的项目修改工作流。
- 本轮使用统一开始时间戳 `2026-05-08-03-57-51`
- 实现方案和测试方案写完后,必须等待用户二次人工审核确认;未经确认不得修改业务代码。
- 不得用 STL Cap、几何封闭面、点云投影或装饰性填充冒充 DICOM Segmentation Mask。
- 不得提交 DICOM 原始数据、STL 模型、mask 缓存图、构建产物或凭据。
## 风险点
- 当前仓库现有 mask 逻辑主要来自 STL 三角面与 DICOM 平面的交线填充,不等同于真实 DICOM Segmentation Mask。
- 如果本项目数据目录中没有现成 segmentation mask 数据源,则仅靠 STL 无法满足“直接映射 DICOM 语义分割影像”的要求,需要先补充数据上传/关联入口。
- DICOM SEG、RTSTRUCT、NIfTI、NRRD、PNG mask 序列的数据坐标系和 CT 坐标系可能不同,必须处理 spacing、origin、orientation、slice order 和标签值映射。
- “上、下两个视角”需要在实现中落到明确的切片端点:通常对应当前切分范围的起点帧和终点帧,或当前切割平面的上下两侧相邻 mask 切片。
## 待确认事项
- 当前项目是否已有真实 DICOM Segmentation Mask 文件或目录。如果没有,应由本次新增上传/关联入口,还是由用户先提供数据路径。
- “上、下两个视角”是指切分范围的起点帧/终点帧,还是同一切割平面上下两侧相邻切片。
- 需要展示的 mask 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。