From 525c2c1dda1c2231ce44978d89e68bf6e86323e8 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 3 May 2026 22:09:19 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-03-22-02-15=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=88=86=E7=95=8C=E7=BA=BF=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- head_extension_app.py | 90 +++++++++++++++++++++--- web_backend.py | 40 ++++++++--- 工程分析/实现方案-2026-05-03-22-02-15.md | 52 ++++++++++++++ 工程分析/测试方案-2026-05-03-22-02-15.md | 77 ++++++++++++++++++++ 工程分析/经验记录.md | 18 +++++ 工程分析/需求分析-2026-05-03-22-02-15.md | 40 +++++++++++ 6 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 工程分析/实现方案-2026-05-03-22-02-15.md create mode 100644 工程分析/测试方案-2026-05-03-22-02-15.md create mode 100644 工程分析/需求分析-2026-05-03-22-02-15.md diff --git a/head_extension_app.py b/head_extension_app.py index a15b3ec..25683ef 100644 --- a/head_extension_app.py +++ b/head_extension_app.py @@ -123,14 +123,21 @@ def cutoff_center_z(coordinates_cutoff): return float(np.mean([point[0] for point in coordinates_cutoff])) -def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF): - panel = panel.copy() +def cutoff_line_y(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF): crop_height = int(image_depth * 0.72) if crop_height <= 0: - return panel + return None line_y = int(round((image_depth - 1 - cutoff_center_z(coordinates_cutoff)) * panel.height / crop_height)) if line_y < 0 or line_y >= panel.height: + return None + return line_y + + +def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF): + panel = panel.copy() + line_y = cutoff_line_y(panel, image_depth, coordinates_cutoff) + if line_y is None: return panel draw = ImageDraw.Draw(panel) @@ -141,6 +148,57 @@ def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_ return panel +def draw_deformed_cutoff_line(panel, image_depth, angle_degrees, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF): + panel = panel.copy() + line_y = cutoff_line_y(panel, image_depth, coordinates_cutoff) + if line_y is None: + return panel + + theta = np.deg2rad(float(angle_degrees)) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + pivot_x = int(panel.width * 0.55) + pivot_y = int(panel.height * 0.62) + + points = [] + for x in (-panel.width * 0.08, panel.width * 1.08): + dx = x - pivot_x + dy = line_y - pivot_y + points.append(( + pivot_x + cos_t * dx - sin_t * dy, + pivot_y + sin_t * dx + cos_t * dy, + )) + + draw = ImageDraw.Draw(panel) + shadow = (0, 0, 0) + line_color = (255, 215, 60) + draw.line(points, fill=shadow, width=6) + draw.line(points, fill=line_color, width=3) + return panel + + +def preview_deform_with_cutoff_line( + image, + image_depth, + angle_degrees, + mode="soft_transition", + transition_width=90, + gaussian_sigma=3, + show_cutoff_line=True, + coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF, +): + preview = preview_deform_2d( + image, + angle_degrees, + mode, + transition_width=transition_width, + gaussian_sigma=gaussian_sigma, + ) + if not show_cutoff_line: + return preview + return draw_deformed_cutoff_line(preview, image_depth, angle_degrees, coordinates_cutoff) + + def fit_image(image, width, height): scale = min(width / image.width, height / image.height) resized = image.resize( @@ -464,18 +522,34 @@ def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates screenshot_dir = output_dir / "process_screenshots" reset_folder(screenshot_dir) - original_panel = sitk_sagittal_panel(state_images["original"], coordinates_cutoff) + image_depth = state_images["original"].GetSize()[2] + original_panel = sitk_sagittal_panel(state_images["original"]) + original_display = ( + draw_cutoff_line(original_panel, image_depth, coordinates_cutoff) + if coordinates_cutoff is not None + else original_panel + ) preview_panels = { - "original": original_panel, - "hard_boundary": preview_deform_2d(original_panel, angle_degrees, "hard_boundary"), - "gaussian_smooth": preview_deform_2d( + "original": original_display, + "hard_boundary": preview_deform_with_cutoff_line( original_panel, + image_depth, + angle_degrees, + "hard_boundary", + show_cutoff_line=coordinates_cutoff is not None, + coordinates_cutoff=coordinates_cutoff or DEFAULT_COORDINATES_CUTOFF, + ), + "gaussian_smooth": preview_deform_with_cutoff_line( + original_panel, + image_depth, angle_degrees, "gaussian_smooth", transition_width, + show_cutoff_line=coordinates_cutoff is not None, + coordinates_cutoff=coordinates_cutoff or DEFAULT_COORDINATES_CUTOFF, ), "soft_transition": preview_deform_2d( - original_panel, + original_display, angle_degrees, "soft_transition", transition_width, diff --git a/web_backend.py b/web_backend.py index d2a3020..865b200 100644 --- a/web_backend.py +++ b/web_backend.py @@ -28,6 +28,7 @@ from head_extension_app import ( draw_cutoff_line, fit_image, load_dicom_volume, + preview_deform_with_cutoff_line, preview_deform_2d, run_deformation, sagittal_mip, @@ -650,13 +651,24 @@ def make_preview( volume = load_dicom_volume(input_dir) before = crop_head_neck(sagittal_mip(volume)) before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before - after = preview_deform_2d( - before_display, - float(angle_degrees), - mode, - transition_width=float(transition_width), - gaussian_sigma=float(gaussian_sigma), - ) + if mode in {"hard_boundary", "gaussian_smooth"}: + after = preview_deform_with_cutoff_line( + before, + volume.shape[0], + float(angle_degrees), + mode, + transition_width=float(transition_width), + gaussian_sigma=float(gaussian_sigma), + show_cutoff_line=show_cutoff_line, + ) + else: + after = preview_deform_2d( + before_display, + float(angle_degrees), + mode, + transition_width=float(transition_width), + gaussian_sigma=float(gaussian_sigma), + ) canvas_image = Image.new("RGB", (1440, 520), (0, 0, 0)) canvas_image.paste(fit_image(before_display, 700, 520), (0, 0)) @@ -666,15 +678,23 @@ def make_preview( ("Original", before_display), ( "Hard boundary", - preview_deform_2d(before_display, float(angle_degrees), "hard_boundary"), + preview_deform_with_cutoff_line( + before, + volume.shape[0], + float(angle_degrees), + "hard_boundary", + show_cutoff_line=show_cutoff_line, + ), ), ( "Gaussian smooth", - preview_deform_2d( - before_display, + preview_deform_with_cutoff_line( + before, + volume.shape[0], float(angle_degrees), "gaussian_smooth", gaussian_sigma=float(gaussian_sigma), + show_cutoff_line=show_cutoff_line, ), ), ( diff --git a/工程分析/实现方案-2026-05-03-22-02-15.md b/工程分析/实现方案-2026-05-03-22-02-15.md new file mode 100644 index 0000000..2cd54cc --- /dev/null +++ b/工程分析/实现方案-2026-05-03-22-02-15.md @@ -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 形变字段生成逻辑。 +- 不修改前端交互状态和接口参数,降低联动风险。 +- 测试时覆盖快速预览接口、四状态过程图、前端类型检查和构建。 + +## 人工审核状态 + +待用户二次人工审核确认。未经确认不得修改业务代码。 diff --git a/工程分析/测试方案-2026-05-03-22-02-15.md b/工程分析/测试方案-2026-05-03-22-02-15.md new file mode 100644 index 0000000..cd1ee63 --- /dev/null +++ b/工程分析/测试方案-2026-05-03-22-02-15.md @@ -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 形变边界不应被理解为像素级临床标尺。 + +## 人工审核状态 + +待用户二次人工审核确认。未经确认不得修改业务代码。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 0fe23f5..0075dfb 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -19,3 +19,21 @@ C. 解决问题方案 D. 后续如何避免问题 后续每次项目修改开始时先阅读 `AGENTS.md`、`工程分析/工程整体分析.md` 和 `工程分析/经验记录.md`,严格使用同一开始时间戳创建三类分析文档,并在用户确认实现方案和测试方案后再执行代码改动。 + +## 2026-05-03-22-02-15 调整硬边界与高斯平滑预览分界线 + +A. 具体问题 + +硬边界和高斯平滑预览图中的黄色分界线出现 Y 字形:一部分随头颈部形变变成斜线,另一部分仍横向延长,视觉上像多出一条不自然的横线。 + +B. 产生问题原因 + +旧逻辑先把黄色分界线画到原始 CT 预览图上,再把整张图传入 `preview_deform_2d` 做快速 2D 形变。硬边界和高斯平滑在分界附近存在不连续或快速变化的运动权重,导致同一条标注线被分裂成不同运动状态。 + +C. 解决问题方案 + +将硬边界和高斯平滑预览改为先对不含黄色线的 CT 图执行形变,再在形变结果上叠加单条黄色分界线。保留原始图的水平分界线,并尽量不改变软过渡原有视觉效果。 + +D. 后续如何避免问题 + +预览标注层、参考线、箭头等非 CT 内容应优先在形变结果上单独叠加,不要和医学影像像素一起参与形变采样。若必须参与形变,应先确认该标注能承受硬边界或快速变化权重造成的撕裂效果。 diff --git a/工程分析/需求分析-2026-05-03-22-02-15.md b/工程分析/需求分析-2026-05-03-22-02-15.md new file mode 100644 index 0000000..667482a --- /dev/null +++ b/工程分析/需求分析-2026-05-03-22-02-15.md @@ -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 图,再在形变结果上叠加单条黄色斜向分界线;原始图仍显示水平线,软过渡维持当前单线效果或使用同一叠加方式保持一致。需用户确认后执行代码修改。