Compare commits

...

36 Commits

Author SHA1 Message Date
fe4b90abcd 2026-05-08-03-57-51 接入DICOM SEG双切面展示 2026-05-08 04:19:24 +08:00
946c0f4ef3 2026-05-08-03-45-01 调整Mask双图展示 2026-05-08 03:49:47 +08:00
aee4466a94 2026-05-08-02-54-30 调整模型切分范围条 2026-05-08 02:57:45 +08:00
8e0e54fc3c 2026-05-08-02-36-12 实现STL模型切分mask 2026-05-08 02:45:12 +08:00
7b7c555321 2026-05-03-23-22-10 修复阅览切换和下载触发 2026-05-03 23:26:36 +08:00
bff7eead08 2026-05-03-22-36-18 增加DICOM阅览和单项下载 2026-05-03 22:46:46 +08:00
525c2c1dda 2026-05-03-22-02-15 调整预览分界线绘制 2026-05-03 22:09:19 +08:00
e431830d15 2026-05-03-18-27-21 建立项目修改工作流 2026-05-03 18:29:28 +08:00
9a67748172 polish frontend metadata 2026-05-03 16:54:34 +08:00
83a9bdcbf4 polish admin reset and branding 2026-05-03 15:23:17 +08:00
0d0a881555 refine workspace previews and library thumbnails 2026-05-03 15:00:26 +08:00
149cbc95d3 update web workflow and preview behavior 2026-05-03 05:41:22 +08:00
f4bde6460a Revert "generate videos from selected z-axis sequences"
This reverts commit f568faf6ed.
2026-05-03 03:03:16 +08:00
f568faf6ed generate videos from selected z-axis sequences 2026-05-03 02:59:00 +08:00
5ba2d48fdb choose source and arrow for generated videos 2026-05-03 02:51:27 +08:00
2fcab9c71a select contents for four-state zip package 2026-05-03 02:45:45 +08:00
49b797e7dc fix slow zip progress display rounding 2026-05-03 02:38:37 +08:00
d46f320a74 preserve cutoff line color in rotated preview 2026-05-03 02:29:17 +08:00
a937583400 toggle cutoff line only in quick preview 2026-05-03 02:11:14 +08:00
1d2d428aab align cutoff line rendering across previews 2026-05-03 02:01:00 +08:00
ea71713b0a draw deformation cutoff line in previews 2026-05-03 01:57:57 +08:00
2559522e3d further slow zip packaging progress display 2026-05-03 01:52:07 +08:00
5663e1c6d3 slow zip packaging progress display 2026-05-03 01:49:18 +08:00
022828095b show zip packaging progress on download 2026-05-03 01:43:52 +08:00
a795aa13bf zip deformation outputs on download 2026-05-03 01:33:18 +08:00
0aa9cffb97 share deformation job progress by account 2026-05-03 01:23:10 +08:00
eb03bea7d4 persist deformation job progress across refresh 2026-05-03 01:18:25 +08:00
d5a6b1c935 serve frontend on port 3005 2026-05-03 00:34:10 +08:00
c6e39984a5 avoid duplicate DICOM zip compression 2026-05-03 00:22:34 +08:00
479a39d880 add per-state DICOM zip downloads 2026-05-03 00:17:30 +08:00
18541db9d4 separate workspace data source and processing sections 2026-05-03 00:08:55 +08:00
e6f9873036 auto refresh workspace preview 2026-05-03 00:03:56 +08:00
89e6fef7f9 close user menu on outside click 2026-05-02 23:59:38 +08:00
40dbd2c839 add DICOM metadata details modal 2026-05-02 23:56:22 +08:00
bef76448f7 speed up DICOM slice previews 2026-05-02 23:49:43 +08:00
676ef25106 add DICOM slice previews to image library 2026-05-02 23:34:33 +08:00
38 changed files with 4896 additions and 249 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ WebSite/dist/
WebSite/.env.local WebSite/.env.local
# Runtime outputs and uploaded data # Runtime outputs and uploaded data
*.log
app_output/ app_output/
app_previews/ app_previews/
ppt_video/ 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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3005 --host=0.0.0.0",
"backend": "python ../web_backend.py", "backend": "python ../web_backend.py",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",

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"; @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

@@ -16,6 +16,9 @@ export default defineConfig(({mode}) => {
}, },
}, },
server: { server: {
host: '0.0.0.0',
port: 3005,
strictPort: true,
// HMR is disabled in AI Studio via DISABLE_HMR env var. // HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits. // Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import imageio.v2 as imageio import imageio.v2 as imageio
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import gaussian_filter
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
from head_extension_app import ( from head_extension_app import (
@@ -20,9 +21,41 @@ OUTPUT_DIR = Path("ppt_video")
FPS = 30 FPS = 30
DURATION_SECONDS = 6 DURATION_SECONDS = 6
END_HOLD_SECONDS = 1 END_HOLD_SECONDS = 1
VIDEO_MODES = {"hard_boundary", "gaussian_smooth", "soft_transition"}
MODE_LABELS = {
"hard_boundary": "Hard boundary",
"gaussian_smooth": "Gaussian smooth",
"soft_transition": "Soft transition",
}
def video_soft_bend_2d(image, angle_degrees): def normalize_mode(mode):
return mode if mode in VIDEO_MODES else "soft_transition"
def video_motion_weight(height, width, mode):
mode = normalize_mode(mode)
yy, xx = np.mgrid[0:height, 0:width]
full_motion_y = height * 0.50
fixed_y = height * 0.92
boundary_y = (full_motion_y + fixed_y) * 0.5
if mode == "hard_boundary":
return (yy <= boundary_y).astype(np.float32)
if mode == "gaussian_smooth":
hard = (yy <= boundary_y).astype(np.float32)
return np.clip(gaussian_filter(hard, sigma=height * 0.025), 0, 1).astype(np.float32)
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32)
def video_soft_bend_2d(image, angle_degrees, mode="soft_transition"):
"""Video-only 2D deformation with a broad neck transition. """Video-only 2D deformation with a broad neck transition.
The app's fast preview uses a compact transition, which is useful for The app's fast preview uses a compact transition, which is useful for
@@ -37,19 +70,7 @@ def video_soft_bend_2d(image, angle_degrees):
pivot_x = int(width * 0.55) pivot_x = int(width * 0.55)
pivot_y = int(height * 0.62) pivot_y = int(height * 0.62)
# Broad transition: nearly full motion for head/upper C-spine, then a long weight = video_motion_weight(height, width, mode)
# smooth blend through the lower C-spine to avoid splitting bone structures.
full_motion_y = height * 0.50
fixed_y = height * 0.92
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
# A small x-dependent term keeps the posterior contour from looking like a
# straight sliced plane while remaining deterministic and smooth.
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1)
theta = np.deg2rad(angle_degrees) * weight theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta) cos_t = np.cos(theta)
sin_t = np.sin(theta) sin_t = np.sin(theta)
@@ -74,8 +95,9 @@ def smoothstep(t):
return t * t * (3 - 2 * t) return t * t * (3 - 2 * t)
def make_frame(before_image, angle, max_angle): def make_frame(before_image, angle, max_angle, show_arrow=True, mode="soft_transition"):
after_image = video_soft_bend_2d(before_image, angle) mode = normalize_mode(mode)
after_image = video_soft_bend_2d(before_image, angle, mode)
frame = Image.new("RGB", (1920, 1080), (0, 0, 0)) frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
draw = ImageDraw.Draw(frame) draw = ImageDraw.Draw(frame)
@@ -90,33 +112,17 @@ def make_frame(before_image, angle, max_angle):
draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255)) draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255))
draw.text( draw.text(
(1055, 120), (1055, 120),
f"Head extension: {angle:04.1f} deg", f"{MODE_LABELS[mode]}: {angle:04.1f} deg",
font=title_font, font=title_font,
fill=(255, 255, 255), fill=(255, 255, 255),
) )
# Yellow direction arrow on the animated side.
arrow = (255, 210, 60) arrow = (255, 210, 60)
if show_arrow:
x0, y0, x1, y1 = 1390, 405, 1515, 335 x0, y0, x1, y1 = 1390, 405, 1515, 335
draw.line((x0, y0, x1, y1), fill=arrow, width=8) draw.line((x0, y0, x1, y1), fill=arrow, width=8)
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow) draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow)
# Minimal progress bar.
bar_x, bar_y, bar_w, bar_h = 1040, 980, 760, 12
draw.rounded_rectangle(
(bar_x, bar_y, bar_x + bar_w, bar_y + bar_h),
radius=6,
fill=(70, 70, 70),
)
fill_w = int(bar_w * angle / max_angle) if max_angle else 0
draw.rounded_rectangle(
(bar_x, bar_y, bar_x + fill_w, bar_y + bar_h),
radius=6,
fill=arrow,
)
draw.text((1040, 1000), "0 deg", font=get_font(24), fill=(210, 210, 210))
draw.text((1745, 1000), f"{max_angle:g} deg", font=get_font(24), fill=(210, 210, 210))
# Invisible-looking spacer: keeps the font imported above alive for some PIL builds. # Invisible-looking spacer: keeps the font imported above alive for some PIL builds.
_ = angle_font _ = angle_font
return frame return frame
@@ -148,10 +154,22 @@ def parse_args():
default=DURATION_SECONDS, default=DURATION_SECONDS,
help="Animation duration in seconds before final hold. Default: 6", help="Animation duration in seconds before final hold. Default: 6",
) )
parser.add_argument(
"--no-arrow",
action="store_true",
help="Hide the yellow direction arrow.",
)
parser.add_argument(
"--mode",
default="soft_transition",
choices=["hard_boundary", "gaussian_smooth", "soft_transition"],
help="2D video deformation mode. Default: soft_transition",
)
return parser.parse_args() return parser.parse_args()
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0): def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True, mode="soft_transition"):
mode = normalize_mode(mode)
output_file = Path(output_path) output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True) output_file.parent.mkdir(parents=True, exist_ok=True)
@@ -172,17 +190,17 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0)
for index in range(moving_frames): for index in range(moving_frames):
t = index / (moving_frames - 1) t = index / (moving_frames - 1)
angle = max_angle * smoothstep(t) angle = max_angle * smoothstep(t)
writer.append_data(np.asarray(make_frame(before_image, angle, max_angle))) writer.append_data(np.asarray(make_frame(before_image, angle, max_angle, show_arrow, mode)))
for _ in range(hold_frames): for _ in range(hold_frames):
writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle))) writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow, mode)))
return output_file.resolve() return output_file.resolve()
def main(): def main():
args = parse_args() args = parse_args()
output_file = generate_video(args.input, args.output, args.max_angle, args.duration) output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow, args.mode)
print(output_file) print(output_file)

View File

@@ -119,6 +119,86 @@ def crop_head_neck(image):
return image.crop((left, top, right, bottom)) return image.crop((left, top, right, bottom))
def cutoff_center_z(coordinates_cutoff):
return float(np.mean([point[0] for point in coordinates_cutoff]))
def cutoff_line_y(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
crop_height = int(image_depth * 0.72)
if crop_height <= 0:
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)
shadow = (0, 0, 0)
line_color = (255, 215, 60)
draw.line((0, line_y, panel.width, line_y), fill=shadow, width=6)
draw.line((0, line_y, panel.width, line_y), fill=line_color, width=3)
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): def fit_image(image, width, height):
scale = min(width / image.width, height / image.height) scale = min(width / image.width, height / image.height)
resized = image.resize( resized = image.resize(
@@ -130,29 +210,51 @@ def fit_image(image, width, height):
return canvas return canvas
def preview_deform_2d(image, angle_degrees): def preview_motion_weight(height, width, mode="soft_transition", transition_width=90, gaussian_sigma=3):
yy, xx = np.mgrid[0:height, 0:width]
boundary_y = height * 0.71
transition_span = height * np.clip(float(transition_width) / 90 * 0.42, 0.18, 0.56)
full_motion_y = boundary_y - transition_span * 0.5
fixed_y = boundary_y + transition_span * 0.5
if mode == "hard_boundary":
return (yy <= boundary_y).astype(np.float32)
if mode == "gaussian_smooth":
try:
from scipy.ndimage import gaussian_filter
except Exception:
gaussian_filter = None
hard = (yy <= boundary_y).astype(np.float32)
if gaussian_filter is None:
return hard
sigma = float(np.clip(float(gaussian_sigma), 1, 12))
return np.clip(gaussian_filter(hard, sigma=sigma), 0, 1).astype(np.float32)
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32)
def preview_deform_2d(image, angle_degrees, mode="soft_transition", transition_width=90, gaussian_sigma=3):
"""Fast visual preview only. The DICOM output uses the real 3D field.""" """Fast visual preview only. The DICOM output uses the real 3D field."""
try: try:
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
except Exception: except Exception:
return image return image
arr = np.asarray(image.convert("L")).astype(np.float32) arr = np.asarray(image.convert("RGB")).astype(np.float32)
h, w = arr.shape h, w, _ = arr.shape
yy, xx = np.mgrid[0:h, 0:w] yy, xx = np.mgrid[0:h, 0:w]
pivot_x = int(w * 0.55) pivot_x = int(w * 0.55)
pivot_y = int(h * 0.62) pivot_y = int(h * 0.62)
full_motion_y = h * 0.50 weight = preview_motion_weight(h, w, mode, transition_width, gaussian_sigma)
fixed_y = h * 0.92
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - w * 0.15) / (w * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1)
theta = np.deg2rad(angle_degrees) * weight theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta) cos_t = np.cos(theta)
sin_t = np.sin(theta) sin_t = np.sin(theta)
@@ -161,7 +263,11 @@ def preview_deform_2d(image, angle_degrees):
src_x = pivot_x + cos_t * dx + sin_t * dy src_x = pivot_x + cos_t * dx + sin_t * dy
src_y = pivot_y - sin_t * dx + cos_t * dy src_y = pivot_y - sin_t * dx + cos_t * dy
warped = map_coordinates(arr, [src_y, src_x], order=1, mode="constant", cval=0) warped_channels = [
map_coordinates(arr[..., channel], [src_y, src_x], order=1, mode="constant", cval=0)
for channel in range(arr.shape[2])
]
warped = np.stack(warped_channels, axis=2)
return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB") return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB")
@@ -333,7 +439,7 @@ def run_deformation(input_dir, output_dir, angle_degrees, transition_width, prog
output_paths["legacy_soft"] = legacy_soft_dir output_paths["legacy_soft"] = legacy_soft_dir
progress("正在生成四状态过程对比图...") progress("正在生成四状态过程对比图...")
preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees) preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees, transition_width=transition_width)
make_output_preview_from_images( make_output_preview_from_images(
state_images["original"], state_images["original"],
state_images["soft_transition"], state_images["soft_transition"],
@@ -375,14 +481,17 @@ def make_output_preview(original_dir, deformed_dicom_dir, output_dir, angle_degr
return preview_path return preview_path
def sitk_sagittal_panel(image): def sitk_sagittal_panel(image, coordinates_cutoff=None):
volume = sitk.GetArrayFromImage(image)[::-1] volume = sitk.GetArrayFromImage(image)[::-1]
return crop_head_neck(sagittal_mip(volume)) panel = crop_head_neck(sagittal_mip(volume))
if coordinates_cutoff is not None:
panel = draw_cutoff_line(panel, volume.shape[0], coordinates_cutoff)
return panel
def make_output_preview_from_images(original_image, deformed_image, output_dir, angle_degrees): def make_output_preview_from_images(original_image, deformed_image, output_dir, angle_degrees, coordinates_cutoff=None):
before = sitk_sagittal_panel(original_image) before = sitk_sagittal_panel(original_image, coordinates_cutoff)
after = sitk_sagittal_panel(deformed_image) after = sitk_sagittal_panel(deformed_image, coordinates_cutoff)
slide = Image.new("RGB", (2560, 1440), (0, 0, 0)) slide = Image.new("RGB", (2560, 1440), (0, 0, 0))
draw = ImageDraw.Draw(slide) draw = ImageDraw.Draw(slide)
@@ -408,14 +517,48 @@ def make_output_preview_from_images(original_image, deformed_image, output_dir,
return preview_path return preview_path
def make_four_state_preview(state_images, output_dir, angle_degrees): def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None, transition_width=90):
output_dir = Path(output_dir) output_dir = Path(output_dir)
screenshot_dir = output_dir / "process_screenshots" screenshot_dir = output_dir / "process_screenshots"
reset_folder(screenshot_dir) reset_folder(screenshot_dir)
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_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_display,
angle_degrees,
"soft_transition",
transition_width,
),
}
panels = [] panels = []
for state_key, label, _ in STATE_LABELS: for state_key, label, _ in STATE_LABELS:
panel = sitk_sagittal_panel(state_images[state_key]) panel = preview_panels[state_key]
panel_path = screenshot_dir / f"{state_key}.png" panel_path = screenshot_dir / f"{state_key}.png"
panel.save(panel_path, quality=95) panel.save(panel_path, quality=95)
panels.append((label, panel)) panels.append((label, panel))
@@ -488,7 +631,7 @@ class HeadExtensionApp:
self.angle = Scale( self.angle = Scale(
controls, controls,
from_=0, from_=0,
to=20, to=45,
orient=HORIZONTAL, orient=HORIZONTAL,
resolution=0.5, resolution=0.5,
length=420, length=420,
@@ -573,11 +716,17 @@ class HeadExtensionApp:
self.status.set("正在读取 DICOM 生成预览...") self.status.set("正在读取 DICOM 生成预览...")
self.cached_volume = load_dicom_volume(self.input_dir.get()) self.cached_volume = load_dicom_volume(self.input_dir.get())
before = crop_head_neck(sagittal_mip(self.cached_volume)) before = crop_head_neck(sagittal_mip(self.cached_volume))
after = preview_deform_2d(before, float(self.angle.get())) before_with_line = draw_cutoff_line(before, self.cached_volume.shape[0])
after = preview_deform_2d(
before_with_line,
float(self.angle.get()),
transition_width=float(self.transition.get()),
gaussian_sigma=3,
)
canvas = Image.new("RGB", (1120, 610), (0, 0, 0)) canvas = Image.new("RGB", (1120, 610), (0, 0, 0))
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
before_panel = fit_image(before, 520, 500) before_panel = fit_image(before_with_line, 520, 500)
after_panel = fit_image(after, 520, 500) after_panel = fit_image(after, 520, 500)
canvas.paste(before_panel, (30, 80)) canvas.paste(before_panel, (30, 80))
canvas.paste(after_panel, (570, 80)) canvas.paste(after_panel, (570, 80))

View File

@@ -51,7 +51,7 @@ class VideoGeneratorApp:
self.max_angle = Scale( self.max_angle = Scale(
controls, controls,
from_=5, from_=5,
to=30, to=45,
orient=HORIZONTAL, orient=HORIZONTAL,
resolution=1, resolution=1,
length=360, length=360,

File diff suppressed because it is too large Load Diff

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 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。