Compare commits
36 Commits
664fec7485
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4b90abcd | |||
| 946c0f4ef3 | |||
| aee4466a94 | |||
| 8e0e54fc3c | |||
| 7b7c555321 | |||
| bff7eead08 | |||
| 525c2c1dda | |||
| e431830d15 | |||
| 9a67748172 | |||
| 83a9bdcbf4 | |||
| 0d0a881555 | |||
| 149cbc95d3 | |||
| f4bde6460a | |||
| f568faf6ed | |||
| 5ba2d48fdb | |||
| 2fcab9c71a | |||
| 49b797e7dc | |||
| d46f320a74 | |||
| a937583400 | |||
| 1d2d428aab | |||
| ea71713b0a | |||
| 2559522e3d | |||
| 5663e1c6d3 | |||
| 022828095b | |||
| a795aa13bf | |||
| 0aa9cffb97 | |||
| eb03bea7d4 | |||
| d5a6b1c935 | |||
| c6e39984a5 | |||
| 479a39d880 | |||
| 18541db9d4 | |||
| e6f9873036 | |||
| 89e6fef7f9 | |||
| 40dbd2c839 | |||
| bef76448f7 | |||
| 676ef25106 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 |
1612
WebSite/src/App.tsx
1612
WebSite/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
1434
web_backend.py
1434
web_backend.py
File diff suppressed because it is too large
Load Diff
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