Compare commits
11 Commits
149cbc95d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4b90abcd | |||
| 946c0f4ef3 | |||
| aee4466a94 | |||
| 8e0e54fc3c | |||
| 7b7c555321 | |||
| bff7eead08 | |||
| 525c2c1dda | |||
| e431830d15 | |||
| 9a67748172 | |||
| 83a9bdcbf4 | |||
| 0d0a881555 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
62
AGENTS.md
Normal 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`。
|
||||
@@ -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>
|
||||
|
||||
|
||||
BIN
WebSite/public/logo_square.png
Normal file
BIN
WebSite/public/logo_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
787
web_backend.py
787
web_backend.py
@@ -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,6 +1325,17 @@ 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
|
||||
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),
|
||||
@@ -662,11 +1348,64 @@ def make_preview(
|
||||
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()
|
||||
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}")
|
||||
|
||||
45
工程分析/实现方案-2026-05-03-18-27-21.md
Normal file
45
工程分析/实现方案-2026-05-03-18-27-21.md
Normal 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 密码不写入仓库,不写入提交信息。
|
||||
- 重新部署前先执行前端检查,确保基础构建链路可用。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次为建立工作流本身,按用户明确指令直接执行。后续业务代码修改必须等待用户对实现方案进行二次人工审核确认。
|
||||
52
工程分析/实现方案-2026-05-03-22-02-15.md
Normal file
52
工程分析/实现方案-2026-05-03-22-02-15.md
Normal 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 形变字段生成逻辑。
|
||||
- 不修改前端交互状态和接口参数,降低联动风险。
|
||||
- 测试时覆盖快速预览接口、四状态过程图、前端类型检查和构建。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||
78
工程分析/实现方案-2026-05-03-22-36-18.md
Normal file
78
工程分析/实现方案-2026-05-03-22-36-18.md
Normal 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. 提交并推送 Gitea,commit 信息使用 `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` 下,删除影像库条目时继续随缓存目录清理。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||
55
工程分析/实现方案-2026-05-03-23-22-10.md
Normal file
55
工程分析/实现方案-2026-05-03-23-22-10.md
Normal 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. 提交并推送 Gitea,commit 信息使用 `2026-05-03-23-22-10 修复阅览切换和下载触发`。
|
||||
9. 重新部署到 `http://192.168.3.11:3005/`。
|
||||
|
||||
## 回滚思路
|
||||
|
||||
若缓存或 blob 下载出现问题,可回滚本次 `web_backend.py` 和 `WebSite/src/App.tsx` 修改,恢复直接读取体数据和直接 URL 下载方式。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 体数据缓存只影响阅览预览接口,不影响真实形变输出。
|
||||
- 缓存使用目录签名,影像库数据变化后会重新读取。
|
||||
- 删除影像库时同步清除 DICOM 文件缓存和体数据缓存。
|
||||
- 前端保留下载回退路径,避免 blob 下载失败后无法下载。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
65
工程分析/实现方案-2026-05-08-02-36-12.md
Normal file
65
工程分析/实现方案-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 实现方案
|
||||
|
||||
开始时间:2026-05-08-02-36-12
|
||||
|
||||
## 本次方案路径
|
||||
|
||||
`工程分析/实现方案-2026-05-08-02-36-12.md`
|
||||
|
||||
## 实现目标
|
||||
|
||||
在 DICOM 阅览中实现真实 STL 模型切分 mask,并用单个双端点进度条控制起点/终点帧。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web_backend.py`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 后端新增 STL 模型目录和缓存:
|
||||
- `MODEL_DIR = web_library/_stl_models`
|
||||
- `STL_MODEL_CACHE`
|
||||
- 支持 ASCII STL 和 binary STL 解析为三角面数组。
|
||||
2. 后端新增 STL 上传接口:
|
||||
- `POST /api/model/upload`
|
||||
- 使用请求体保存 STL 文件。
|
||||
- 返回 `modelId`、`name`、`triangleCount`。
|
||||
3. 后端新增 DICOM 几何元数据提取:
|
||||
- 基于排序后的 DICOM 文件读取 `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing`。
|
||||
- 构造 patient 坐标到体素坐标的转换。
|
||||
- 元数据不足时降级为 STL 已是体素坐标。
|
||||
4. 后端增强 `/api/library/reformat-preview`:
|
||||
- 支持 `modelId` 参数。
|
||||
- 当传入模型时,根据 `plane` 和 `index` 计算 STL 三角面与当前 DICOM 切片平面的交线。
|
||||
- 将交线栅格化并填充形成 mask,叠加到 DICOM PNG 上。
|
||||
- 返回 `maskPixels`,便于前端知道该帧是否有模型穿透。
|
||||
5. 前端 DICOM 阅览新增模型切分控制:
|
||||
- STL 文件上传按钮。
|
||||
- 模型切分开关。
|
||||
- 一个双端点进度条控制起点/终点,两个端点允许交叉。
|
||||
- 起点/终点数值按当前切片总数约束。
|
||||
6. 前端模型切分展示:
|
||||
- 模型切分关闭:保留当前单帧 DICOM 阅览。
|
||||
- 模型切分开启:显示起点帧、终点帧两张 DICOM 图,并请求后端叠加真实 STL mask。
|
||||
- 不再显示无意义的 CT MASK 图片或伪造圆圈 mask。
|
||||
7. 执行测试方案。
|
||||
8. 更新 `工程分析/经验记录.md`。
|
||||
9. 提交并推送 Gitea,commit 信息使用 `2026-05-08-02-36-12 实现STL模型切分mask`。
|
||||
10. 重新部署到 `http://192.168.3.11:3005/`。
|
||||
|
||||
## 回滚思路
|
||||
|
||||
若 STL mask 功能不符合预期,可回滚 `web_backend.py` 中 STL 上传/解析/mask 叠加逻辑,以及 `WebSite/src/App.tsx` 中模型切分 UI,恢复纯 DICOM 阅览。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- STL 解析结果和 DICOM 体数据都使用缓存,避免高频重复解析。
|
||||
- mask 叠加只影响阅览 PNG,不修改原始 DICOM 和形变输出。
|
||||
- 双端点进度条不影响普通 DICOM 阅览切片滑杆。
|
||||
- 若 STL 与 DICOM 空间不匹配,前端仍显示 DICOM 切片,并以 `maskPixels=0` 表示该帧无交集。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
47
工程分析/实现方案-2026-05-08-02-54-30.md
Normal file
47
工程分析/实现方案-2026-05-08-02-54-30.md
Normal 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. 提交并推送 Gitea,commit 信息使用 `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` 计算。
|
||||
- 构建和类型检查通过后再部署。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
43
工程分析/实现方案-2026-05-08-03-45-01.md
Normal file
43
工程分析/实现方案-2026-05-08-03-45-01.md
Normal 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. 更新经验记录,提交并推送 Gitea,commit 信息使用 `2026-05-08-03-45-01 调整Mask双图展示`。
|
||||
|
||||
## 回滚思路
|
||||
|
||||
若展示逻辑异常,可回滚 `WebSite/src/App.tsx` 中右侧预览区域的 JSX 调整,恢复此前模型切分条件渲染逻辑。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 不改 STL 解析和 DICOM mask 计算核心算法,降低医学影像处理链路风险。
|
||||
- 保留普通 DICOM 阅览路径,避免影响原有冠状位/矢状位查看。
|
||||
- 使用现有 `modelStartPreview`、`modelEndPreview` 状态,避免新增并发请求链路。
|
||||
58
工程分析/实现方案-2026-05-08-03-57-51.md
Normal file
58
工程分析/实现方案-2026-05-08-03-57-51.md
Normal 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 数据,请提供其所在目录或文件格式说明。
|
||||
70
工程分析/工程整体分析.md
Normal file
70
工程分析/工程整体分析.md
Normal 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`。后续每次项目修改需求都应先生成需求分析、实现方案、测试方案,并在实现方案与测试方案通过用户二次审核后再修改业务代码。
|
||||
55
工程分析/测试方案-2026-05-03-18-27-21.md
Normal file
55
工程分析/测试方案-2026-05-03-18-27-21.md
Normal 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 推送依赖远程服务和凭据可用性,如远程不可达需记录失败原因并提醒用户。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次为建立工作流本身,按用户明确指令直接执行。后续业务代码修改必须等待用户对测试方案进行二次人工审核确认。
|
||||
77
工程分析/测试方案-2026-05-03-22-02-15.md
Normal file
77
工程分析/测试方案-2026-05-03-22-02-15.md
Normal 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 形变边界不应被理解为像素级临床标尺。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||
87
工程分析/测试方案-2026-05-03-22-36-18.md
Normal file
87
工程分析/测试方案-2026-05-03-22-36-18.md
Normal 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 功能。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||
78
工程分析/测试方案-2026-05-03-23-22-10.md
Normal file
78
工程分析/测试方案-2026-05-03-23-22-10.md
Normal 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 部署。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
94
工程分析/测试方案-2026-05-08-02-36-12.md
Normal file
94
工程分析/测试方案-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 测试方案
|
||||
|
||||
开始时间:2026-05-08-02-36-12
|
||||
|
||||
## 本次方案路径
|
||||
|
||||
`工程分析/测试方案-2026-05-08-02-36-12.md`
|
||||
|
||||
## 测试范围
|
||||
|
||||
- STL 上传接口。
|
||||
- ASCII STL 和 binary STL 解析。
|
||||
- STL 与 DICOM 切片相交 mask 生成。
|
||||
- 模型切分开启后起点帧/终点帧双图显示。
|
||||
- 双端点进度条起点/终点可交叉。
|
||||
- 普通 DICOM 阅览不受影响。
|
||||
- 前端类型检查、构建和 Python 语法检查。
|
||||
- 重新部署后访问验证。
|
||||
|
||||
## 测试命令
|
||||
|
||||
Python 语法检查:
|
||||
|
||||
```bash
|
||||
python -m py_compile web_backend.py head_extension_app.py
|
||||
```
|
||||
|
||||
前端类型检查:
|
||||
|
||||
```bash
|
||||
cd WebSite
|
||||
npm run lint
|
||||
```
|
||||
|
||||
前端构建:
|
||||
|
||||
```bash
|
||||
cd WebSite
|
||||
npm run build
|
||||
```
|
||||
|
||||
后端函数级测试建议:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
from web_backend import parse_stl_bytes
|
||||
sample = b'''solid demo
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 0 0
|
||||
vertex 0 10 0
|
||||
endloop
|
||||
endfacet
|
||||
endsolid demo
|
||||
'''
|
||||
triangles = parse_stl_bytes(sample)
|
||||
print(triangles.shape)
|
||||
PY
|
||||
```
|
||||
|
||||
部署验证:
|
||||
|
||||
```bash
|
||||
curl -I --max-time 5 http://192.168.3.11:3005/
|
||||
curl -s --max-time 10 "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=256&window=bone"
|
||||
```
|
||||
|
||||
## 手工验证点
|
||||
|
||||
- 打开 DICOM 阅览,普通冠状位/矢状位切片仍可查看。
|
||||
- 上传 STL 后启用模型切分。
|
||||
- 使用一个双端点进度条调节起点/终点帧。
|
||||
- 将起点拖过终点或终点拖过起点后,界面仍能按两个端点显示切分结果。
|
||||
- 起点帧和终点帧上显示真实 STL 切面 mask;无交集时不显示假 mask。
|
||||
- 不再出现单独无意义的 CT MASK 图片。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- STL 上传返回模型信息。
|
||||
- 带 `modelId` 的 `/api/library/reformat-preview` 可返回叠加 mask 的 PNG。
|
||||
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
|
||||
- `npm run lint` 通过。
|
||||
- `npm run build` 通过。
|
||||
- Gitea commit/push 完成。
|
||||
- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`。
|
||||
|
||||
## 残余风险
|
||||
|
||||
- mask 是否精确覆盖目标结构取决于 STL 与 DICOM 是否同坐标系、同单位、同方向。如果 STL 未与 DICOM patient 坐标配准,需要额外提供配准矩阵或模型平移/缩放/旋转控制。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
61
工程分析/测试方案-2026-05-08-02-54-30.md
Normal file
61
工程分析/测试方案-2026-05-08-02-54-30.md
Normal 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 手柄在不同浏览器的细节样式可能略有差异,但应保持单条范围条的视觉结构。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
50
工程分析/测试方案-2026-05-08-03-45-01.md
Normal file
50
工程分析/测试方案-2026-05-08-03-45-01.md
Normal 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 数据,只能通过类型检查、构建和接口逻辑验证保证基本正确性。
|
||||
55
工程分析/测试方案-2026-05-08-03-57-51.md
Normal file
55
工程分析/测试方案-2026-05-08-03-57-51.md
Normal 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,可能需要针对实际格式补充解析和配准逻辑。
|
||||
147
工程分析/经验记录.md
Normal file
147
工程分析/经验记录.md
Normal 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。
|
||||
43
工程分析/需求分析-2026-05-03-18-27-21.md
Normal file
43
工程分析/需求分析-2026-05-03-18-27-21.md
Normal 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`,可能绕过工作流。
|
||||
- 若把凭据写入文档或提交信息,会造成安全风险。
|
||||
- 若部署方式变化但未更新工程整体分析,后续重新部署可能失败。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
本次按用户指令直接建立工作流;后续任何业务代码修改都需要先等待用户确认实现方案和测试方案。
|
||||
40
工程分析/需求分析-2026-05-03-22-02-15.md
Normal file
40
工程分析/需求分析-2026-05-03-22-02-15.md
Normal 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 图,再在形变结果上叠加单条黄色斜向分界线;原始图仍显示水平线,软过渡维持当前单线效果或使用同一叠加方式保持一致。需用户确认后执行代码修改。
|
||||
55
工程分析/需求分析-2026-05-03-22-36-18.md
Normal file
55
工程分析/需求分析-2026-05-03-22-36-18.md
Normal 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 冲突。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
建议实现为:四状态卡片标题旁增加图标下载按钮;影像库卡片按钮区从“调阅/信息/删除”调整为“变换/阅览/信息/删除”,阅览弹层支持冠状位、矢状位,以及默认/骨窗/软组织/脑窗/肺窗等显示模式。待用户确认后执行。
|
||||
55
工程分析/需求分析-2026-05-03-23-22-10.md
Normal file
55
工程分析/需求分析-2026-05-03-23-22-10.md
Normal 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 部署。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
用户已明确本次不需要二次人工确认,因此本次文档写完后直接执行实现和测试。
|
||||
53
工程分析/需求分析-2026-05-08-02-36-12.md
Normal file
53
工程分析/需求分析-2026-05-08-02-36-12.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 需求分析
|
||||
|
||||
开始时间:2026-05-08-02-36-12
|
||||
|
||||
## 原始需求
|
||||
|
||||
用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改都不需要人工二次确认。
|
||||
|
||||
本次具体需求:
|
||||
|
||||
1. 起点、终点合并到一个进度条里,进度条有两个端点,起点和终点可以调整顺序。
|
||||
2. 模型切分启用后,不能只是象征性画圈,而要真正判断 STL 模型穿透 DICOM 的位置,并用颜色标出形成 mask。
|
||||
3. 模型切分下方的帧进度栏没有实际意义。模型切分开启后,不需要在最后一张显示 CT,也不需要当前无意义的 CT MASK 图片;应像之前一样用 DICOM 起点帧、终点帧对模型切两刀,并在这两个特定帧上显示 mask。这个 mask 代表重建 STL 模型原始语义分割的大致切片形态。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在 DICOM 阅览中新增真实 STL 模型切分能力。
|
||||
- 上传 STL 后,后端解析 STL 三角面片,按当前 DICOM 平面和起点/终点帧计算切片相交区域。
|
||||
- 模型切分开启时,前端显示起点帧和终点帧两张 DICOM 切片,并叠加真实 STL 切面 mask。
|
||||
- 用一个双端点进度条控制起点和终点,允许两个端点交叉,交叉后按数值顺序用于切分。
|
||||
- 删除/不再显示无意义的 CT MASK 图片或伪 mask。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `web_backend.py`
|
||||
- STL 上传接口。
|
||||
- STL 解析、缓存。
|
||||
- DICOM 阅览切片接口叠加真实 STL 切面 mask。
|
||||
- `WebSite/src/App.tsx`
|
||||
- DICOM 阅览弹层新增 STL 上传、模型切分开关、双端点进度条和双帧 mask 展示。
|
||||
- 移除模型切分状态下无意义的单帧 CT/MASK 展示。
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 当前定位
|
||||
|
||||
当前仓库主线中没有已有 `STL/模型切分/MASK` 代码,只有 DICOM 阅览和冠状/矢状重建预览。因此本次不是修补已有“画圈”代码,而是在现有 DICOM 阅览里补上真实 STL 切片 mask 能力。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不引入大型前端 3D 库。
|
||||
- 不改变真实 DICOM 形变算法。
|
||||
- 不提交 STL、DICOM、mask 缓存图片、ZIP 或构建产物。
|
||||
- STL 与 DICOM 的空间配准优先按 DICOM `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing` 转换;若元数据不足,则降级假设 STL 坐标已在体素坐标系中。
|
||||
|
||||
## 风险点
|
||||
|
||||
- STL 与 DICOM 是否同一坐标系直接决定 mask 是否对齐;若输入 STL 未配准到 DICOM patient 坐标,mask 位置仍会偏。
|
||||
- 不同 STL 拓扑可能导致切面轮廓不闭合,mask 填充可能只显示轮廓或局部区域。
|
||||
- 大 STL 模型解析和多切片 mask 计算可能耗时,需要缓存解析后的三角面。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
用户已明确本次不需要二次人工确认,因此文档写完后直接执行。
|
||||
45
工程分析/需求分析-2026-05-08-02-54-30.md
Normal file
45
工程分析/需求分析-2026-05-08-02-54-30.md
Normal 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。
|
||||
- 两个端点重叠时,需要保证都能拖动。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
用户已明确本次不需要人工二次确认,直接执行。
|
||||
38
工程分析/需求分析-2026-05-08-03-45-01.md
Normal file
38
工程分析/需求分析-2026-05-08-03-45-01.md
Normal 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 可能为空;前端需给出清晰状态。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
- 无需等待二次确认;按用户此前授权直接执行。
|
||||
52
工程分析/需求分析-2026-05-08-03-57-51.md
Normal file
52
工程分析/需求分析-2026-05-08-03-57-51.md
Normal 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 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。
|
||||
Reference in New Issue
Block a user