diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fa2aa..04e50d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 本项目所有的核心重构与功能更新将记录在此。 +## [v5.9.0] - 2026-07-07 +### 🧭 动态重点地块、GIS 处理管线与多版式图纸生成重构 + +**重点地块内容动态化**: +* 新增 `KeyPlotEngine` 重点更新单元读取与目录生成能力,Stage 10、Stage 13、诊断卡片、图纸目录和提示词链路不再假设固定 5 个地块,而是按当前 `Key_Plots_District.json` / GeoJSON 记录自动展开 N 个地块; +* 增强场地诊断与图纸目录测试,覆盖空数据、单地块、多地块、字段缺失、排序、面积与诊断 fallback 等边界情况。 + +**GIS 空间数据处理内置化**: +* 新增 `scripts/process_key_plots.py`,支持原始重点地块 GeoJSON/Shapefile 的 CRS 归一、无效几何修复、研究范围裁切、投影/测地面积测算、字段标准化与 WGS84 GeoJSON 输出; +* 补齐 `tests/test_process_key_plots.py`,验证裁切、空结果、字段保留、面积计算和命令行入口行为。 + +**多版式 A3 图纸生成链路**: +* 新增 `drawing_layout_engine.py`,内置 `map_legend_right`、`dual_compare`、`analysis_dashboard`、`matrix_storyboard`、`full_bleed_effect`、`chapter_cover` 六类 A3 版式,并提供版式推荐、图例映射、标题栏与注记区排版能力; +* 将版式选择接入 `frame_generator.py`、`tools/draw_scope_map.py`、`pages/13_成果表达.py`、`drawing_prompt_engine.py` 与 `drawing_prompt_templates.py`,确保用户选择的版式同时影响图框封装、最终输出和 AIGC 提示词; +* 更新 README、README_EN、WORKFLOW、GITHUB_PREPARATION、GLOSSARY 与新增专项说明文档,明确动态地块、GIS 处理脚本、多版式输出和 CI 验证流程。 + ## [v5.8.0] - 2026-06-19 ### 🧹 工具目录净化、全局隐私脱敏与工作流对齐重构 diff --git a/GITHUB_PREPARATION.md b/GITHUB_PREPARATION.md index 6dd5c2b..5afdf8e 100644 --- a/GITHUB_PREPARATION.md +++ b/GITHUB_PREPARATION.md @@ -18,14 +18,20 @@ python tools/check_env.py --- ### 第二步:运行全量单元测试 -上传前,必须确保所有核心算法和业务逻辑全部通过本地测试,目前项目包含 **238 项单元测试**。 +上传前,必须确保所有核心算法和业务逻辑全部通过本地测试。重点地块、GIS 处理、提示词解耦和多版式图纸链路应先跑聚焦回归,再跑全量套件。 ```powershell +# 重点回归:动态重点地块 / GIS 处理 / 多版式图纸 / 提示词链路 +python -m pytest tests/test_key_plot_engine.py tests/test_process_key_plots.py tests/test_drawing_layout_engine.py tests/test_drawing_prompt_engine.py tests/test_drawing_prompt_templates.py tests/test_site_diagnostic_engine.py tests/test_prompt_decoupling.py tests/test_frame_generator.py -v + # 执行全量单元测试 -python -m pytest +python -m pytest -q ``` > [!IMPORTANT] > 必须确保测试通过率为 100%。如果存在失败用例,请参照 [BUG_REPORT.md](./BUG_REPORT.md) 的排查思路修复后再进行上传。 +> [!NOTE] +> `tests/test_prompt_decoupling.py` 会临时写入测试配置,和其他包含同一 fixture 的套件并行运行时容易造成配置文件脏写;建议按上述命令串行执行并在测试后确认 `git status --short` 为空。 + --- ### 第三步:敏感凭据扫描与清理 @@ -33,6 +39,11 @@ python -m pytest ```powershell # 扫描敏感泄露信息 python tools/secret_scan.py + +# 与 GitHub Actions smoke job 对齐 +python tools/check_env.py +python tools/startup_smoke.py +python tools/data_quality_check.py ``` #### 凭证清理与轮换标准: 1. **本地配置文件**:确保根目录下仅有本地专用的 `.env`,该文件已被 `.gitignore` 排除在外,绝对不会被提交。 diff --git a/GLOSSARY.md b/GLOSSARY.md index 21088f1..5876b12 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -42,6 +42,8 @@ | **GCJ-02 / WGS-84** | 坐标系标准 | **GCJ-02** 是国内特有的火星坐标系;**WGS-84** 是国际标准坐标系。平台所有获取的 POI 数据、边界数据均被底层系统强制执行纠偏清洗,统一并轨为 WGS-84 / EPSG:3857 以对齐国际标准。 | | **国标用地色值** | GB Landuse Color Palette | 平台强制遵循《城市用地分类与规划建设用地标准 (GB 50137-2011)》。引擎层预置了居住用地 (黄)、商业用地 (红)、绿地 (绿) 等十余种 16 进制标准色表,渲染图纸时严格映射。 | | **矢量裁切** | Vector Clipping | 空间处理脚本 (`clip_city_data.py`) 的核心动作。将城市级几 GB 级的庞大路网与水系,按项目设定的 150 公顷红线多边形进行布尔求交裁切,大幅降低前端显存消耗。 | +| **重点更新单元** | Dynamic Key Plot | 平台重点深化设计的最小空间对象,由 `Key_Plots_District.json` / GeoJSON 当前记录动态决定数量,不再绑定固定 5 个地块。Stage 10、Stage 13、诊断卡片、提示词与图纸目录均按该清单自动展开。 | +| **重点地块 GIS 处理** | Key Plot GIS Processing | `scripts/process_key_plots.py` 提供的空间数据清洗管线,包含 CRS 归一、无效几何修复、研究范围裁切、投影/测地面积测算、字段标准化与 WGS84 GeoJSON 输出。 | --- @@ -51,6 +53,7 @@ |---|---|---| | **Stage Data Bus** | 跨阶段数据总线 | 贯穿全生命周期的神经枢纽。所有页面共用 `st.session_state["stage_bus"]`。前一个阶段的推演成果(如 Stage 05 的痛点报告)会被无损持久化,并直接投喂给后一个阶段(如 Stage 12 生成导则、Stage 14 生成视频分镜脚本)作为 AI 的历史记忆。 | | **自动红头图框封装** | Automated Title Block Framing | 利用 `PIL` (Python Imaging Library) 构建的工程排版引擎 (`frame_generator.py`)。它接收原始底图或重绘后的高清图,全自动嵌入学术级 A3 图框中,生成包含 AI 智能摘要、自定义标题与比例尺、指北针的成品图板。 | +| **多版式图纸引擎** | Multi-Layout Sheet Engine | `drawing_layout_engine.py` 提供的 A3 排版配置系统,内置图例右栏、双图对比、分析仪表盘、矩阵分镜、满版效果图、章节封面等版式,并将版式约束同步传递给提示词与图框生成链路。 | | **智能分镜脚本系统** | Dynamic Storyboard Director | 彻底取代笨重且高容错率的第三方视频渲染库,转而在 Stage 14 为用户动态生成基于其真实推演数据的“马克飞象 / Markdown 录屏分镜指导书”,明确每分钟的鼠标动作与解说台词。 | | **Deck.GL** | 空间可视化框架 | Uber 开源的高性能 WebGL 数据可视化引擎,承载平台主页面及分析页面中 150 公顷尺度的建筑白模、热力图及三维空间底座交互。 | | **`@st.fragment`** | 局部重绘装饰器 | Streamlit 1.30+ 引入的关键性能特性。用于将复杂的 3D 地图组件隔离,使得用户在操作表单控件时无需触发整个页面的昂贵重绘。 | diff --git a/README.md b/README.md index cc96b3e..1356656 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue?logo=python)](https://python.org) [![Streamlit](https://img.shields.io/badge/Streamlit-1.55-FF4B4B?logo=streamlit)](https://streamlit.io) -[![Tests](https://img.shields.io/badge/Tests-238%20passed-brightgreen?logo=pytest)](./tests/) +[![Tests](https://img.shields.io/badge/Tests-CI%20suite-brightgreen?logo=pytest)](./tests/) [![License](https://img.shields.io/badge/License-Academic-orange)]() @@ -28,7 +28,9 @@ UltimateDESIGN 是面向城乡规划与城市设计的 **Streamlit 全栈智慧 | 能力 | 说明 | |---|---| | **15 页面精简工作流** | 包含主入口 app.py 及 14 个功能页面(其中 3 组页面合并),聚焦核心设计流程 | -| **26 张专业图纸模板** | 基于 Python 空间矢量绘图与 PIL 自动排版引擎,高精度生成符合国标与工程规范 of A3 图册 | +| **专业图纸模板与多版式图册** | 基于 Python 空间矢量绘图、PIL 图框封装与多版式排版引擎,高精度生成符合国标与工程规范的 A3 图册 | +| **动态重点更新单元** | 从 `Key_Plots_District.json` / GeoJSON 当前记录读取 N 个重点地块,自动展开图纸目录、诊断卡片与深化提示词 | +| **内置 GIS 处理管线** | `scripts/process_key_plots.py` 支持重点地块清洗、拓扑修复、边界裁切、面积测算和 WGS84 GeoJSON 输出 | | **GIS → AIGC 空间对齐** | 首创「矢量→光栅→ControlNet」管线,消除 AI 制图的空间幻觉 | | **DesignContext 设计纲要** | 统一提取 19 个阶段 AI 文本输出,LLM 合成结构化设计纲要,驱动图纸生产 | | **策略驱动 AIGC 渲染** | 6 种设计策略风格(历史保护/微更新/功能置换/TOD/生态/文创)自动匹配渲染参数 | @@ -37,7 +39,7 @@ UltimateDESIGN 是面向城乡规划与城市设计的 **Streamlit 全栈智慧 | **后台缓存预加载** | daemon 线程静默预热 37MB GeoJSON、RAG 模型等,页面切换秒开 | | **引擎懒加载** | `__getattr__` 模式延迟加载 pandas/numpy/PIL,启动速度提升 | | **录屏自动滑动组件** | 页面右下角常驻防穿帮录屏控制器,支持帧率级平滑像素滚动与快捷键交互 | -| **238 项自动化测试** | Pytest 全覆盖 + CI 集成 lint / 密钥扫描 / 冒烟测试 / 数据质量检查 | +| **CI 自动化测试套件** | Pytest 全覆盖 + CI 集成 lint / 密钥扫描 / 冒烟测试 / 数据质量检查 | --- @@ -58,6 +60,8 @@ UltimateDESIGN 是面向城乡规划与城市设计的 **Streamlit 全栈智慧 ### 3. 📊 数据-逻辑分离与 MPI/GVI 循证诊断系统 (Evidence-Based Assessment System) 本系统遵循现代软件工程规范,实现了“空间数据与分析逻辑的彻底解耦”。 * **多维指标融合诊断**:通过动态层次分析法(AHP)与更新潜力模型(MPI),将 GVI(街景绿视率)、SVF(天空可视率)、POI 商业活力指数与社交媒体舆情(NLP 情感分析得分)有机叠合,为每个重点更新单元出具全方位的“诊断报告”。 +* **动态重点更新单元**:`KeyPlotEngine` 从重点地块 GeoJSON 中读取当前记录数,不再假设固定 5 个地块;Stage 10、Stage 13、提示词模板和图册目录都会按 N 个地块动态展开。 +* **内置空间处理脚本**:`scripts/process_key_plots.py` 对原始重点地块边界执行拓扑修复、研究范围裁切、投影面积测算、字段归一和 WGS84 GeoJSON 输出,保证上传数据可直接进入诊断与制图链路。 * **一键跨城市迁移**:当需要将平台应用到全新的地块时,用户无需修改任何前端界面或分析逻辑代码,只需按照 `data/` 目录规范替换 GeoJSON 矢量文件和相关的 CSV 指标数据。系统在启动时会自动读取并刷新 3D 数字孪生底座及各阶段的分析表单,实现极低成本的平台复用与业务拓展。 --- @@ -92,7 +96,7 @@ streamlit run app.py ### 🩺 3. 健康自检 ```powershell -python -m pytest # 238 项单元测试 +python -m pytest # 全量单元测试 python tools/check_env.py # 15 页面完整性校验 python tools/data_quality_check.py # 数据质量评级 python tools/secret_scan.py # 敏感信息扫描 @@ -144,7 +148,7 @@ data/ ├── shp/ │ ├── Boundary_Scope.geojson # 研究范围红线 (必须) │ ├── Building_Footprints.geojson # 建筑基底 (含 Floor 字段) -│ ├── Key_Plots_District.json # 5 个重点更新地块边界 +│ ├── Key_Plots_District.json # N 个重点更新单元边界(由当前 GeoJSON 动态读取) │ ├── landuse_clipped.geojson # 裁切后的用地分类 (含国标 RGB 色值) │ ├── road_network_clipped.geojson # 裁切后的三级道路网络 │ └── rail_network_clipped.geojson # 裁切后的轨道交通网络 @@ -165,6 +169,7 @@ data/ python scripts/fetch_real_estate_data.py # 建筑年代 / 房价 python scripts/fetch_supplementary_data.py --all # 日照 / 交通等 python scripts/clip_city_data.py # 裁切城市级数据至研究范围 +python scripts/process_key_plots.py --input data/raw/key_plots.geojson --boundary data/shp/Boundary_Scope.geojson --output data/shp/Key_Plots_District.json python scripts/render_gis_assets.py # 矢量光栅化 (AIGC 底稿) ``` @@ -201,7 +206,7 @@ python scripts/render_gis_assets.py # 矢量光栅化 (AIGC 底稿 | 10 | 重点地段深化 | 诊断雷达图、控规指标反推、微观人群画像、地块深化方案 | | 11 | 实施路径 | 六种更新模式、三期时序甘特图 | | 12 | 城市设计导则 | 两步法导则生成、RAG 政策检索、控制图则 | -| 13 | 成果表达 | Python 空间底图渲染、Web LLM 重绘提示词、PIL 自动红头图框封装 | +| 13 | 成果表达 | Python 空间底图渲染、Web LLM 重绘提示词、六类 A3 版式选择、PIL 自动图框封装 | | 14 | 数据大屏 | 三维宏观决策大屏、规划指标统计看板、时空演化动态可视化 | ### 🟣 深化与集成:AIGC 与智能体技能(Stage 15-16) @@ -231,7 +236,9 @@ ultimateDESIGN/ │ │ ├── drawing_pipeline.py # DrawingPipeline 端到端编排器 │ │ ├── drawing_prompt_engine.py # 41 图纸提示词构建器 │ │ ├── drawing_prompt_templates.py # 图纸模板元数据库 +│ │ ├── drawing_layout_engine.py # A3 图纸多版式排版引擎 │ │ ├── frame_generator.py # A3 图框图纸组装引擎 +│ │ ├── key_plot_engine.py # 动态重点更新单元读取 / 诊断 / 图纸目录 │ │ ├── urban_image_segmentation.py # 街景图像分割引擎 │ │ ├── engine_registry.py # AI/AIGC 引擎注册表 │ │ ├── quality_assessor.py # 双重质量评估 (Gemma + DeepSeek) @@ -261,6 +268,7 @@ ultimateDESIGN/ ├── scripts/ # 自动化脚本 │ ├── setup_env.bat # 环境自动安装 (Windows) │ ├── clip_city_data.py # 城市级数据裁切至研究范围 +│ ├── process_key_plots.py # 重点地块拓扑修复 / 边界裁切 / 面积测算 │ ├── render_gis_assets.py # GIS 矢量光栅化 (AIGC 底稿) │ ├── run_drawing_export.py # 高精度图纸批量导出 │ ├── fetch_supplementary_data.py # 补充数据获取 @@ -275,7 +283,7 @@ ultimateDESIGN/ │ ├── secret_scan.py # 敏感信息扫描 │ ├── startup_smoke.py # 启动冒烟测试 │ └── video_generator/ # HyperFrames 视频工具 (Node.js) -├── tests/ # 40 个测试模块 / 238 项用例 +├── tests/ # Pytest 自动化测试套件 ├── data/ # 数据资产 (数据与逻辑解耦) ├── static/ # Streamlit 静态资源代理 ├── assets/ # CSS 样式 / WebGL 模板 @@ -305,7 +313,7 @@ GeoJSON 矢量数据 Stable Diffusion WebUI └──────────┬──────────┘ DrawingPipeline 编排 │ │ ▼ - ├── 提示词构建 (41 模板) 生成专业图纸 + ├── 提示词构建 (41 模板 + 版式约束) 生成专业图纸 ├── 质量评估 (A/B/C/D) (地理空间对齐) ├── 自动修正 & 重生成 └── VersionStore 版本归档 @@ -351,6 +359,7 @@ git push origin main | [BUG_REPORT.md](./BUG_REPORT.md) | 已识别问题与修复日志 | | [PROJECT_INSPECTION_REPORT.md](./PROJECT_INSPECTION_REPORT.md) | 系统级架构体检报告 | | [GLOSSARY.md](./GLOSSARY.md) | 核心术语释义 (MPI / GVI / ControlNet 等) | +| [docs/DYNAMIC_KEY_PLOTS_AND_LAYOUTS.md](./docs/DYNAMIC_KEY_PLOTS_AND_LAYOUTS.md) | 动态重点地块、GIS 处理脚本与多版式图纸生成说明 | | [README_EN.md](./README_EN.md) | English Documentation | --- diff --git a/README_EN.md b/README_EN.md index f0e86d7..111320f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,7 +10,7 @@ [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue?logo=python)](https://python.org) [![Streamlit](https://img.shields.io/badge/Streamlit-1.55-FF4B4B?logo=streamlit)](https://streamlit.io) -[![Tests](https://img.shields.io/badge/Tests-238%20passed-brightgreen?logo=pytest)](./tests/) +[![Tests](https://img.shields.io/badge/Tests-CI%20suite-brightgreen?logo=pytest)](./tests/) [![License](https://img.shields.io/badge/License-Academic-orange)]() @@ -28,7 +28,9 @@ UltimateDESIGN is a **full-stack Streamlit decision support platform** built for | Capability | Description | |---|---| | **15-Page Streamlined Workflow** | 15 pages in total (app.py + 14 pages in pages/ with 3 merged stage-groups), focusing on core design flow | -| **26 Drawing Templates** | High-precision vector maps generated via Python and automatically assembled with standard A3 title blocks via PIL | +| **Drawing Templates + Multi-Layout Atlas** | High-precision vector maps generated via Python and assembled with selectable A3 layout profiles via PIL | +| **Dynamic Key Plots** | Reads N key renewal plots from the current `Key_Plots_District.json` / GeoJSON records and expands atlas sheets, diagnostics, and prompts accordingly | +| **Built-In GIS Processing** | `scripts/process_key_plots.py` cleans geometries, repairs topology, clips to the research boundary, computes areas, and exports WGS84 GeoJSON | | **GIS → AIGC Alignment** | Novel Vector→Raster→ControlNet pipeline eliminates spatial hallucination | | **Tri-Stakeholder Simulation** | LLM-driven Resident / Developer / Planner role-play with consensus radar output | | **Dual Quality Loop** | Gemma visual + DeepSeek content assessment; auto-correction for C/D-rated outputs | @@ -36,7 +38,7 @@ UltimateDESIGN is a **full-stack Streamlit decision support platform** built for | **HyperFrames Video** | One-click ~9 min defense video with 3D layered displays and GSAP animations | | **Auto-Scrolling Controller** | Resident screen-recording HUD widget at the bottom right corner supporting frame-level smooth pixel-scrolling and shortcuts | | **Auto Stage Summary Archiving**| Automatically extracts stage findings/methodologies and incrementally saves them in `output/stage_generation_report.md` sorted by stage order | -| **238 Automated Tests** | Pytest + CI integration: lint / secret scan / smoke test / data quality check | +| **CI Test Suite** | Pytest + CI integration: lint / secret scan / smoke test / data quality check | --- @@ -68,9 +70,10 @@ Navigate via the **top navigation bar**, stages `[00]` through `[14]`. ### 🩺 3. Health Check ```powershell -python -m pytest # 238 unit tests +python -m pytest # full unit test suite python tools/check_env.py # 15-page integrity check python tools/secret_scan.py # Credential leak scan +python tools/data_quality_check.py # Data quality check ``` --- @@ -129,7 +132,7 @@ Converts GeoJSON vector data into ControlNet guidance maps (road skeleton / land | 10 | Key Plot Detailing | Radar diagnostics, Regulatory metrics, Micro-personas, Deep design schemes | | 11 | Implementation Path | 6 renewal modes, 3-phase timeline Gantt chart | | 12 | Design Guidelines | Two-step guideline generation + RAG policy retrieval | -| 13 | Output & Presentation | Python map rendering, Web LLM redraw prompts, Auto PIL title block | +| 13 | Output & Presentation | Python map rendering, Web LLM redraw prompts, selectable A3 layouts, Auto PIL title block | | 14 | Data Dashboard | WebGL 3D macro-decision screen, planning indicator dashboard, spatiotemporal dynamic visualization | ### 🟣 AIGC & Agent Skills (Stage 15–16) @@ -153,7 +156,10 @@ ultimateDESIGN/ │ │ ├── llm_engine.py # DeepSeek / Ollama unified API │ │ ├── stable_diffusion_engine.py # SDPipeline (txt2img / ControlNet) │ │ ├── drawing_pipeline.py # End-to-end drawing orchestrator +│ │ ├── drawing_layout_engine.py # A3 multi-layout sheet engine +│ │ ├── drawing_prompt_engine.py # Drawing prompt builder with layout constraints │ │ ├── frame_generator.py # PIL standard frame assembly +│ │ ├── key_plot_engine.py # Dynamic key plot loading / diagnostics / atlas entries │ │ ├── urban_image_segmentation.py# Street view segmentation engine │ │ ├── engine_registry.py # AI engine registry │ │ ├── quality_assessor.py # Dual quality assessment @@ -162,10 +168,11 @@ ultimateDESIGN/ │ ├── ui/ # Streamlit components & theming │ ├── utils/ # I/O, geo transform, service checks │ └── workflow/ # 17-stage state machine (00-16) & data bus -├── scripts/ # Automation (data fetch / GIS render) +├── scripts/ # Automation (data fetch / GIS render / key plot processing) +│ ├── process_key_plots.py # Key plot topology repair / clipping / area metrics ├── tools/ # DevOps (env check / secret scan / QA) │ ├── drawings/ # A3 layout rendering modules (dr_004.py ~ dr_slow_traffic.py) -├── tests/ # 40 modules / 238 test cases +├── tests/ # Pytest automation suite ├── data/ # Spatial & tabular assets (decoupled) └── .github/workflows/ci.yml # CI pipeline ``` @@ -186,7 +193,8 @@ GeoJSON Vector Data Stable Diffusion WebUI └── satellite.png ─────────▶│ │ └──────────┬──────────┘ DrawingPipeline │ - ├── Prompt Build (41 templates) ▼ + ├── Prompt Build (41 templates + layout constraints) + │ ▼ ├── Quality Assess (A/B/C/D) Professional Drawing ├── Auto-Correct & Regenerate (Spatially Aligned) └── VersionStore Archive @@ -201,6 +209,7 @@ GeoJSON Vector Data Stable Diffusion WebUI | [BUG_REPORT.md](./BUG_REPORT.md) | Known issues and fix log | | [PROJECT_INSPECTION_REPORT.md](./PROJECT_INSPECTION_REPORT.md) | System architecture audit | | [GLOSSARY.md](./GLOSSARY.md) | Terminology (MPI / GVI / ControlNet) | +| [docs/DYNAMIC_KEY_PLOTS_AND_LAYOUTS.md](./docs/DYNAMIC_KEY_PLOTS_AND_LAYOUTS.md) | Dynamic key plots, GIS processing, and multi-layout drawing generation | | [README.md](./README.md) | 中文文档 | --- diff --git a/WORKFLOW.md b/WORKFLOW.md index 25a5867..68d27cc 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -86,12 +86,12 @@ --- ### 8. 📍 10_重点地段深化.py (Stage 10) -- **页面目的**:对核心潜力地块进行微观空间形态及控规指标推演。 +- **页面目的**:对当前重点更新单元清单中的 N 个核心潜力地块进行微观空间形态及控规指标推演。 - **核心操作**: - 1. 查看特定重点地块(如老水产市场)的微观人群画像与设施缺口。 + 1. 从 `Key_Plots_District.json` / GeoJSON 动态读取重点地块目录,查看每个地块的微观人群画像与设施缺口。 2. 执行控规指标反推(容积率、高度、退让),由 LLM 输出具体的深化设计文字方案。 - **制图与成果支撑**: - - 为《五地块深化设计总图》提供硬性技术指标与文字批注。 + - 为《重点更新单元深化设计图册》提供动态地块目录、硬性技术指标与文字批注;地块数量由当前空间数据记录自动决定。 --- @@ -117,9 +117,11 @@ - **页面目的**:一键式生成高精度矢量底图并完成 A3 规划图册及 Word/docx 答辩说明书的封装。 - **核心操作**: 1. **【规划图纸代码生成】**:一键运行 Python 空间绘图引擎,从 GIS 数据库直出高分辨率底图。 - 2. **【图册自动组装】**:选择图纸编号,上传渲染完成的规划图,系统自动封装 A3 标准图框、图签、图例及 AI 动态生成的规划设计说明。 + 2. **【图册自动组装】**:选择图纸编号与 A3 版式,上传渲染完成的规划图,系统自动封装标准图框、图签、图例及 AI 动态生成的规划设计说明。 3. **【文档导出】**:导出前期诊断、总体导则及汇总报告。 4. **【毕业设计答辩稿】**:自动调用轻量管道或全流程管道,编译输出带漂亮格式和参考文献的答辩 Word 说明书。 +- **制图与成果支撑**: + - 内置 `map_legend_right`、`dual_compare`、`analysis_dashboard`、`matrix_storyboard`、`full_bleed_effect`、`chapter_cover` 六类版式,提示词、图框封装与最终输出保持同一版式约束。 --- @@ -160,5 +162,5 @@ ## 🛡️ 架构完整性与 CI 检测规范 - **路径分层规范**:所有的空间矢量数据集中在 `data/gis/`,表格型指标数据集中在 `data/csv/`,并在 `src/config/paths.py` 中实现了全局路径注册。 -- **本地 CI 检测(自动化)**:每次提交代码前,需在根目录下执行 `pytest` 跑通 209 个自动化用例,保障没有 API 变动带来的 Regression。 +- **本地 CI 检测(自动化)**:每次提交代码前,需在根目录下执行 `pytest` 跑通自动化测试套件,并同步执行 `ruff check .`、`python tools/secret_scan.py`、`python tools/check_env.py`、`python tools/startup_smoke.py` 与 `python tools/data_quality_check.py`,保障没有 API 变动带来的 Regression。 - **环境变量隔离**:敏感凭证严禁硬编码。在本地环境使用 `.env` 配置文件注入 API 密钥,该文件已配置为不可提交。 diff --git a/tests/conftest.py b/conftest.py similarity index 96% rename from tests/conftest.py rename to conftest.py index 8cc6f7c..2c22d92 100644 --- a/tests/conftest.py +++ b/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock # Add project root to sys.path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parent if str(root) not in sys.path: sys.path.insert(0, str(root)) diff --git "a/pages/00_\346\225\260\346\215\256\345\207\206\345\244\207\344\270\216\344\273\273\345\212\241\350\247\243\350\257\273.py" "b/pages/00_\346\225\260\346\215\256\345\207\206\345\244\207\344\270\216\344\273\273\345\212\241\350\247\243\350\257\273.py" index 2e610cc..ad363c5 100644 --- "a/pages/00_\346\225\260\346\215\256\345\207\206\345\244\207\344\270\216\344\273\273\345\212\241\350\247\243\350\257\273.py" +++ "b/pages/00_\346\225\260\346\215\256\345\207\206\345\244\207\344\270\216\344\273\273\345\212\241\350\247\243\350\257\273.py" @@ -190,7 +190,7 @@ def run_quality_check(): metrics=[ {"value": f"{readiness['total']} 类", "label": "数据类别", "meta": "空间、POI、街景、文本、房价等"}, {"value": "约160 公顷", "label": "研究范围", "meta": "任务书明确的核心片区"}, - {"value": "5 个", "label": "深化地段", "meta": "任务书要求重点设计单元"}, + {"value": "动态", "label": "深化单元", "meta": "按配置读取重点更新单元"}, ], graphic_html=render_data_pipeline(as_html=True) ) @@ -329,7 +329,7 @@ def run_quality_check(): "项目名称": "AI赋能下的伪满皇宫周边街区更新规划设计", "设计类型": "城市更新 · 历史街区 · 数字孪生", "研究范围": "约160公顷,由长春大街、长白路、东九条、亚泰快速路围合", - "设计深度": "总体城市设计 + 5个重点地块深化设计", + "设计深度": "总体城市设计 + 重点更新单元深化设计", "成果形式": "A3图册(≥60页)+ A1展板(≥3张)+ 规划文本 + PPT", "核心矛盾": "历史保护与活力不足、工业低效、社区老化、交通割裂", "技术特色": "GIS + CV + POI + NLP/LLM + AIGC + 数字孪生", @@ -466,7 +466,7 @@ def run_quality_check(): stage_code="01", title="项目边界与任务要求锁定", findings=[ - {"point": "研究范围约 160 公顷,涵盖 5 个重点深化地段", "evidence": "任务书明确的核心片区边界"}, + {"point": "研究范围约 160 公顷,涵盖重点更新单元", "evidence": "任务书明确的核心片区边界"}, {"point": "核心任务为系统性概念设计 + 数字孪生与 AIGC 推演表达", "evidence": "任务书核心任务条款"}, {"point": "四大核心痛点:用地混杂、交通割裂、老龄化、环境品质不足", "evidence": "开题报告现状诊断结论"}, ], diff --git "a/pages/02_\350\265\204\346\226\231\346\224\266\351\233\206\344\270\216\347\216\260\345\234\272\350\260\203\347\240\224.py" "b/pages/02_\350\265\204\346\226\231\346\224\266\351\233\206\344\270\216\347\216\260\345\234\272\350\260\203\347\240\224.py" index f8b7e8b..cb00fb8 100644 --- "a/pages/02_\350\265\204\346\226\231\346\224\266\351\233\206\344\270\216\347\216\260\345\234\272\350\260\203\347\240\224.py" +++ "b/pages/02_\350\265\204\346\226\231\346\224\266\351\233\206\344\270\216\347\216\260\345\234\272\350\260\203\347\240\224.py" @@ -273,7 +273,7 @@ def render_silent_survey(df_points, sv_root): 1. 固定底图 / 正射影像:统一裁切后的卫星图、航拍图或无纹理 2D 底图。 2. 研究范围红线 / Mask:研究范围边界,优先 GeoJSON / SVG,其次透明 PNG 或黑白 mask。 -3. 重点地块边界 / Mask:五个重点地块或重点更新单元边界,最好和研究范围使用同一坐标或同一图框。 +3. 重点地块边界 / Mask:重点更新单元边界,最好和研究范围使用同一坐标或同一图框。 4. 固定图框 / 出图版式:A3/A1/PPT 图框、标题栏、图例区、比例尺、指北针和安全边距。 **建议补充** diff --git a/pytest.ini b/pytest.ini index 27eec68..a5a87dc 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -testpaths = tests +testpaths = src tools python_files = test_*.py diff --git a/requirements.txt b/requirements.txt index 265ae9c..4dc68cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,3 +31,4 @@ pytest==9.0.3 ruff==0.15.11 pre-commit==3.8.0 pywebview==6.2.1 +matplotlib>=3.8.0 diff --git a/scripts/process_key_plots.py b/scripts/process_key_plots.py new file mode 100644 index 0000000..c2d2b8e --- /dev/null +++ b/scripts/process_key_plots.py @@ -0,0 +1,231 @@ +"""Clean, clip, and export key plot GIS boundaries.""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path +from typing import Sequence +from uuid import uuid4 + +import geopandas as gpd +import pandas as pd +from shapely.geometry import GeometryCollection, MultiPolygon, Polygon +from shapely.ops import unary_union + + +DEFAULT_CRS = "EPSG:4326" +OUTPUT_CRS = "EPSG:4326" + +logger = logging.getLogger("ultimateDESIGN") + + +def process_key_plots(input_path: str | Path, boundary_path: str | Path, output_path: str | Path) -> int: + """Process raw key plot polygons into clipped WGS84 GeoJSON features.""" + plots = _read_layer(input_path) + boundary = _read_layer(boundary_path) + + plots = _clean_geometries(plots) + boundary = _clean_geometries(boundary) + + if plots.empty: + _write_geojson(plots, output_path) + return 0 + + processed = _clip_to_boundary(plots, boundary) + processed = _add_output_fields(processed) + processed = processed.to_crs(OUTPUT_CRS) + + _write_geojson(processed, output_path) + return len(processed) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Clean, clip, index, and export key plot polygons.") + parser.add_argument("--input", required=True, help="Raw key plots GeoJSON/Shapefile path") + parser.add_argument("--boundary", required=True, help="Research boundary GeoJSON/Shapefile path") + parser.add_argument("--output", required=True, help="Output WGS84 GeoJSON path") + args = parser.parse_args(argv) + + count = process_key_plots(args.input, args.boundary, args.output) + print(f"Processed {count} key plot feature(s) to {args.output}") + return 0 + + +def _read_layer(path: str | Path) -> gpd.GeoDataFrame: + gdf = gpd.read_file(path) + if gdf.crs is None: + gdf = gdf.set_crs(DEFAULT_CRS, allow_override=True) + return gdf + + +def _clean_geometries(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + if gdf.empty: + return gdf.copy() + + cleaned = gdf[gdf.geometry.apply(lambda geometry: geometry is not None)].copy() + if cleaned.empty: + return cleaned + + try: + if hasattr(cleaned.geometry, "make_valid"): + cleaned = cleaned.set_geometry(cleaned.geometry.make_valid()) + else: + from shapely.validation import make_valid + + cleaned = cleaned.set_geometry(cleaned.geometry.apply(make_valid)) + except Exception: + cleaned = cleaned.set_geometry(cleaned.geometry.buffer(0)) + + cleaned = cleaned.set_geometry(cleaned.geometry.apply(_polygonal_geometry)) + cleaned = cleaned[cleaned.geometry.apply(lambda geometry: geometry is not None and not geometry.is_empty)].copy() + return cleaned.reset_index(drop=True) + + +def _polygonal_geometry(geometry): + if geometry is None or geometry.is_empty: + return GeometryCollection() + if isinstance(geometry, (Polygon, MultiPolygon)): + return geometry + if geometry.geom_type == "GeometryCollection": + parts = [part for part in geometry.geoms if isinstance(part, (Polygon, MultiPolygon)) and not part.is_empty] + return unary_union(parts) if parts else GeometryCollection() + return GeometryCollection() + + +def _clip_to_boundary(plots: gpd.GeoDataFrame, boundary: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + if boundary.empty: + return plots.iloc[0:0].copy() + + if boundary.crs != plots.crs: + boundary = boundary.to_crs(plots.crs) + + boundary_union = _union_geometry(boundary) + clipped = plots.copy() + clipped = clipped.set_geometry(clipped.geometry.intersection(boundary_union)) + clipped = _clean_geometries(clipped) + + return clipped + + +def _union_geometry(gdf: gpd.GeoDataFrame): + if hasattr(gdf.geometry, "union_all"): + return gdf.geometry.union_all() + return gdf.geometry.unary_union + + +def _add_output_fields(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + output = gdf.copy().reset_index(drop=True) + areas_ha = _calculate_areas_ha(output) + + plot_indexes = [] + names = [] + roles = [] + for index, (_, row) in enumerate(output.iterrows(), start=1): + plot_indexes.append(index) + names.append(_first_value(row, ("name", "Name", "plot_name", "PlotName", "地块名称")) or f"地块{index}") + roles.append(_first_value(row, ("role", "type", "category", "功能")) or "") + + output["plot_index"] = plot_indexes + output["name"] = names + output["role"] = roles + source_ids = [_first_value(row, ("id",)) or index for index, (_, row) in enumerate(output.iterrows(), start=1)] + source_objectids = [ + _first_value(row, ("OBJECTID",)) or index for index, (_, row) in enumerate(output.iterrows(), start=1) + ] + area_values = [round(float(area), 4) for area in areas_ha] + output["id"] = source_ids + output["OBJECTID"] = source_objectids + output["area_ha"] = area_values + output["Shape_Area"] = [area * 10000.0 for area in area_values] + return output + + +def _calculate_areas_ha(gdf: gpd.GeoDataFrame) -> list[float]: + if gdf.empty: + return [] + + target_crs = _estimated_utm_crs(gdf) + if target_crs is not None: + try: + projected = gdf.to_crs(target_crs) + return (projected.geometry.area / 10000.0).tolist() + except Exception: + logger.warning("Estimated UTM area calculation failed; falling back to geodesic area", exc_info=True) + else: + logger.warning("Estimated UTM CRS unavailable; falling back to geodesic area") + + return _calculate_geodesic_areas_ha(gdf) + + +def _calculate_geodesic_areas_ha(gdf: gpd.GeoDataFrame) -> list[float]: + from pyproj import Geod + + geod = Geod(ellps="WGS84") + wgs84 = _to_wgs84(gdf) + areas_ha = [] + for geometry in wgs84.geometry: + area_m2, _ = geod.geometry_area_perimeter(geometry) + areas_ha.append(abs(float(area_m2)) / 10000.0) + return areas_ha + + +def _estimated_utm_crs(gdf: gpd.GeoDataFrame): + try: + return gdf.estimate_utm_crs() + except Exception: + logger.warning("Estimated UTM CRS lookup failed; falling back to geodesic area", exc_info=True) + return None + + +def _to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + if gdf.crs is None: + return gdf.set_crs(DEFAULT_CRS, allow_override=True) + + try: + epsg = gdf.crs.to_epsg() + except Exception: + epsg = None + + if epsg == 4326: + return gdf + return gdf.to_crs(OUTPUT_CRS) + + +def _first_value(row: pd.Series, keys: tuple[str, ...]): + for key in keys: + if key in row and _has_value(row[key]): + return row[key] + return None + + +def _has_value(value) -> bool: + if value is None: + return False + if isinstance(value, float) and pd.isna(value): + return False + if isinstance(value, str) and not value.strip(): + return False + return True + + +def _write_geojson(gdf: gpd.GeoDataFrame, output_path: str | Path) -> None: + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + temp = output.with_name(f".{output.stem}.{uuid4().hex}{output.suffix or '.geojson'}") + + try: + if gdf.empty: + temp.write_text('{"type": "FeatureCollection", "features": []}\n', encoding="utf-8") + else: + gdf.to_file(temp, driver="GeoJSON", index=False) + + temp.replace(output) + except Exception: + if temp.exists(): + temp.unlink() + raise + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_site_config.py b/src/config/tests/test_site_config.py similarity index 100% rename from tests/test_site_config.py rename to src/config/tests/test_site_config.py diff --git a/src/data/data_categories.py b/src/data/data_categories.py index 0885aa3..9b5a05f 100644 --- a/src/data/data_categories.py +++ b/src/data/data_categories.py @@ -189,76 +189,41 @@ def way(self, w): "id": "key_plots", "title": "重点地块边界", "icon": "📍", - "description": "划定 5 个重点更新单元的边界,用于深化设计和地块级诊断。", + "description": "划定任意数量重点更新单元的边界,用于深化设计和地块级诊断。", "target_path": SHP_DIR / "Key_Plots_District.json", "accept": [".json", ".geojson"], - "format_desc": "GeoJSON FeatureCollection (5 个 Polygon 要素)", + "format_desc": "GeoJSON FeatureCollection (N 个 Polygon / MultiPolygon 要素)", "required": True, "tutorial": { - "summary": "根据任务书要求,划定重点深化设计的 5 个地块边界。", + "summary": "使用 scripts/process_key_plots.py 清洗、裁剪、编号并导出重点更新单元边界。", "methods": [ { - "name": "方法一:GIS 软件手动划定 (推荐)", + "name": "方法一:运行内置 GIS 处理脚本 (推荐)", "steps": [ - "1. 在 ArcGIS/QGIS 中加载研究范围底图", - "2. 参考任务书或现状分析,识别 5 个重点地块", - "3. 新建面要素图层,逐一绘制地块边界", - "4. 添加 name 或 id 属性字段标识地块", - "5. 导出为 GeoJSON 格式", + "1. 在 ArcGIS/QGIS 中准备原始重点地块面图层,可包含 name、role 等属性", + "2. 准备研究范围边界文件 data/gis/Boundary_Scope.geojson", + "3. 运行 scripts/process_key_plots.py 自动清洗无效几何并裁剪到研究范围", + "4. 脚本会重新生成 plot_index、name、role、area_ha 字段", + "5. 将输出保存为 data/gis/Key_Plots_District.json", ], - "code_example": '''# QGIS Python 控制台创建地块 -from qgis.core import * -from PyQt5.QtCore import * - -# 创建新图层 -layer = QgsVectorLayer("Polygon?crs=EPSG:4326", "Key_Plots", "memory") -provider = layer.dataProvider() - -# 添加字段 -provider.addAttributes([ - QgsField("id", QVariant.Int), - QgsField("name", QVariant.String), - QgsField("area_ha", QVariant.Double) -]) -layer.updateFields() - -# 添加地块要素 (示例坐标,需替换为实际坐标) -plots = [ - {"id": 1, "name": "伪满皇宫周边", "coords": [...]}, - {"id": 2, "name": "新民大街历史街区", "coords": [...]}, - {"id": 3, "name": "光复路片区", "coords": [...]}, - {"id": 4, "name": "胜利公园片区", "coords": [...]}, - {"id": 5, "name": "站前商业区", "coords": [...]}, -] - -for plot in plots: - feat = QgsFeature() - feat.setGeometry(QgsGeometry.fromPolygonXY([plot["coords"]])) - feat.setAttributes([plot["id"], plot["name"], 0]) - provider.addFeature(feat) - -layer.updateExtents() -QgsProject.instance().addMapLayer(layer) - -# 导出为 GeoJSON -QgsVectorFileWriter.writeAsVectorFormat( - layer, "data/gis/Key_Plots_District.json", - "utf-8", layer.crs(), "GeoJSON" -)''', - "tip": "地块划分应结合用地性质、更新潜力和设计重点。", + "code_example": '''python scripts/process_key_plots.py \\ + --input raw/key_plots.geojson \\ + --boundary data/gis/Boundary_Scope.geojson \\ + --output data/gis/Key_Plots_District.json''', + "tip": "脚本会默认缺失 CRS 的输入为 WGS84,并用合适投影计算 area_ha。", }, { - "name": "方法二:从控规图层提取", + "name": "方法二:从控规或更新单元图层提取", "steps": [ - "1. 加载控规用地图层", - "2. 选择需要深化的控规单元", - "3. 合并或拆分为 5 个设计地块", - "4. 导出为 GeoJSON", + "1. 加载控规用地、城市更新单元或项目边界图层", + "2. 选择需要深化设计的单元,可按实际研究需要保留任意数量", + "3. 合并、拆分或修补边界后导出为 GeoJSON/Shapefile", + "4. 通过 scripts/process_key_plots.py 统一裁剪、编号并导出最终 GeoJSON", ], - "tip": "确保地块边界与控规单元边界对齐。", + "tip": "确保源图层为面要素;脚本会过滤空几何并修复常见无效几何。", }, ], - "sample_fields": "type: FeatureCollection, features: [{geometry: Polygon, properties: {id: 1, name: 伪满皇宫周边}}]", + "sample_fields": "type: FeatureCollection, features: [{geometry: Polygon, properties: {plot_index: 1, name: 门户更新单元, role: 门户展示, area_ha: 2.35}}]", "reference": "参考文件:data/gis/Key_Plots_District.json", }, }, diff --git a/tests/test_data_quality_check.py b/src/data/tests/test_data_quality_check.py similarity index 100% rename from tests/test_data_quality_check.py rename to src/data/tests/test_data_quality_check.py diff --git a/src/engines/batch_exporter.py b/src/engines/batch_exporter.py index 6466979..2f6d3f6 100644 --- a/src/engines/batch_exporter.py +++ b/src/engines/batch_exporter.py @@ -7,7 +7,11 @@ from typing import Callable, List, Optional from src.engines.drawing_pipeline import DrawingPipeline -from src.engines.drawing_prompt_engine import flatten_chapter_drawings +from src.engines.drawing_prompt_engine import ( + BOOK_CHAPTERS as LEGACY_BOOK_CHAPTERS, + flatten_chapter_drawings, + get_book_chapters, +) from src.engines.version_store import VersionStore logger = logging.getLogger("ultimateDESIGN") @@ -27,7 +31,13 @@ def __init__(self, pipeline: DrawingPipeline, store: VersionStore, drawing_names: List[str] = None): self.pipeline = pipeline self.store = store - self.drawing_names = drawing_names or flatten_chapter_drawings() + self.drawing_names = flatten_chapter_drawings() if drawing_names is None else drawing_names + self._chapter_map = None + + def _get_chapter_map(self): + if self._chapter_map is None: + self._chapter_map = get_book_chapters() + return self._chapter_map def export_full_atlas(self, skip_existing: bool = True, quality_loop: bool = False, @@ -75,8 +85,7 @@ def export_full_atlas(self, skip_existing: bool = True, failed=failed, errors=errors) def export_chapter(self, chapter: str, **kwargs) -> ExportReport: - from src.engines.drawing_prompt_engine import BOOK_CHAPTERS - chapter_drawings = BOOK_CHAPTERS.get(chapter, []) + chapter_drawings = self._get_chapter_map().get(chapter, []) old_names = self.drawing_names self.drawing_names = chapter_drawings try: @@ -93,8 +102,13 @@ def export_selected(self, drawing_names: List[str], **kwargs) -> ExportReport: self.drawing_names = old_names def _infer_chapter(self, drawing_name: str) -> str: - from src.engines.drawing_prompt_engine import BOOK_CHAPTERS - for chapter, drawings in BOOK_CHAPTERS.items(): + try: + chapter_map = self._get_chapter_map() + except Exception: + logger.warning("Dynamic chapter map unavailable for metadata inference", exc_info=True) + chapter_map = LEGACY_BOOK_CHAPTERS + + for chapter, drawings in chapter_map.items(): if drawing_name in drawings: return chapter return "未分类" diff --git a/src/engines/drawing_layout_engine.py b/src/engines/drawing_layout_engine.py new file mode 100644 index 0000000..0720ea8 --- /dev/null +++ b/src/engines/drawing_layout_engine.py @@ -0,0 +1,534 @@ +"""Deterministic layout profiles and PIL renderer for A3 atlas sheets.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageColor, ImageDraw, ImageFont, ImageOps + + +A3_LANDSCAPE_SIZE = (4961, 3508) + + +@dataclass(frozen=True) +class LayoutSlot: + slot_id: str + label: str + box: tuple[int, int, int, int] + purpose: str + + +@dataclass(frozen=True) +class LayoutProfile: + layout_id: str + name: str + description: str + slots: tuple[LayoutSlot, ...] + prompt_rules: tuple[str, ...] + + +_PROFILES: tuple[LayoutProfile, ...] = ( + LayoutProfile( + layout_id="map_legend_right", + name="主图+右侧图例", + description="适合标准规划图、系统图和以地图为主体的图纸。", + slots=( + LayoutSlot("main_map", "主图", (220, 390, 3620, 2940), "承载地图、总平面或空间系统主表达"), + LayoutSlot("legend_panel", "图例", (3740, 520, 4740, 1820), "放置图例、比例、符号和分类说明"), + LayoutSlot("notes_panel", "说明", (3740, 1920, 4740, 2920), "放置关键指标、数据来源和简短结论"), + LayoutSlot("title_block", "图名信息栏", (220, 3040, 4740, 3360), "放置标题、章节、编号和项目信息"), + ), + prompt_rules=( + "主图占据最大面积,右侧信息栏只服务于主图阅读。", + "图例符号必须与主图颜色和线型一致。", + "标题和说明文字保持短句,不覆盖主图边界。", + ), + ), + LayoutProfile( + layout_id="dual_compare", + name="改造前后对比", + description="适合改造前后、方案 A/B、现状与规划的并列比较。", + slots=( + LayoutSlot("before_view", "改造前", (220, 520, 2350, 2590), "展示现状、改造前或方案 A"), + LayoutSlot("after_view", "改造后", (2610, 520, 4740, 2590), "展示规划、改造后或方案 B"), + LayoutSlot("comparison_notes", "对比说明", (220, 2740, 4740, 3260), "列出差异、提升点、指标和结论"), + ), + prompt_rules=( + "左右两个画面尺度和视角保持一致,便于直接对比。", + "差异标注集中在下方说明区,不压住两张主图。", + "使用统一色彩体系表达保留、更新、拆除和新增内容。", + ), + ), + LayoutProfile( + layout_id="analysis_dashboard", + name="分析仪表盘", + description="适合诊断、评价、热力、活力、现状和指标类图纸。", + slots=( + LayoutSlot("analysis_map", "分析主图", (220, 480, 2920, 2550), "承载热力、评价、现状或诊断地图"), + LayoutSlot("metric_a", "指标 A", (3060, 480, 4740, 1120), "展示关键指标、图表或统计占位"), + LayoutSlot("metric_b", "指标 B", (3060, 1230, 4740, 1870), "展示辅助指标、剖面或对比图表"), + LayoutSlot("legend_panel", "图例与分级", (3060, 1980, 4740, 2920), "说明分级、色带、阈值和数据口径"), + LayoutSlot("analysis_notes", "诊断结论", (220, 2680, 2920, 3260), "承载问题清单、机会点和结论摘要"), + ), + prompt_rules=( + "先读主图,再读右侧指标,最后读底部结论。", + "所有指标必须标注口径或使用占位符,不能虚构具体数据。", + "热力色带、评价等级和图例分级必须对应。", + ), + ), + LayoutProfile( + layout_id="matrix_storyboard", + name="矩阵推演故事板", + description="适合策略、目标、体系、流程、技术推演和阶段演化表达。", + slots=( + LayoutSlot("step_1", "步骤 1", (220, 500, 1690, 1450), "展示第一阶段、目标或输入条件"), + LayoutSlot("step_2", "步骤 2", (1840, 500, 3310, 1450), "展示第二阶段、策略或中间过程"), + LayoutSlot("step_3", "步骤 3", (3460, 500, 4740, 1450), "展示第三阶段、结果或输出"), + LayoutSlot("step_4", "步骤 4", (220, 1640, 1690, 2590), "展示辅助系统、行动包或技术路径"), + LayoutSlot("step_5", "步骤 5", (1840, 1640, 3310, 2590), "展示协同关系、流程节点或矩阵分类"), + LayoutSlot("step_6", "步骤 6", (3460, 1640, 4740, 2590), "展示落地场景、实施阶段或总结"), + LayoutSlot("story_notes", "推演说明", (220, 2760, 4740, 3260), "放置流程箭头说明、关键策略和技术备注"), + ), + prompt_rules=( + "按从左到右、从上到下的阅读顺序组织信息。", + "每个格子只表达一个策略或流程节点。", + "连接线和箭头保持克制,避免干扰各格主图。", + ), + ), + LayoutProfile( + layout_id="full_bleed_effect", + name="满版效果表达", + description="适合鸟瞰、人视、运营场景、AIGC 和效果图类图纸。", + slots=( + LayoutSlot("hero_visual", "满版主图", (0, 0, 4961, 3508), "承载鸟瞰、人视、运营场景或 AIGC 效果主画面"), + LayoutSlot("title_overlay", "标题叠层", (240, 220, 2940, 660), "放置短标题和章节信息"), + LayoutSlot("caption_strip", "场景说明", (240, 2920, 4720, 3300), "放置场景标签、设计亮点和必要说明"), + ), + prompt_rules=( + "主视觉可以满版,但标题与说明必须放在清晰留白或半透明叠层内。", + "人物、树木和装饰不能遮挡关键空间关系。", + "控制文字数量,以短标题、标签和少量关键词为主。", + ), + ), + LayoutProfile( + layout_id="chapter_cover", + name="章节封面", + description="适合封面、目录、篇章背景和章节过渡页。", + slots=( + LayoutSlot("cover_visual", "封面背景", (0, 0, 4961, 3508), "承载章节氛围、项目背景或抽象视觉"), + LayoutSlot("title_focus", "主标题", (420, 560, 3640, 1340), "放置章节标题、项目名和核心主题"), + LayoutSlot("chapter_index", "章节信息", (420, 1510, 2260, 2100), "放置章节编号、目录摘要或说明"), + ), + prompt_rules=( + "封面以识别度和章节气质为先,不堆叠复杂数据。", + "标题区必须保留高对比度留白。", + "背景可以氛围化,但不能影响标题可读性。", + ), + ), +) + +_PROFILE_BY_ID = {profile.layout_id: profile for profile in _PROFILES} + +_PRIMARY_VISUAL_SLOT_BY_LAYOUT = { + "map_legend_right": "main_map", + "analysis_dashboard": "analysis_map", + "matrix_storyboard": "step_1", + "full_bleed_effect": "hero_visual", + "chapter_cover": "cover_visual", +} + +_COVER_VISUAL_SLOT_IDS = {"hero_visual", "cover_visual"} + +_FONT_CANDIDATES: tuple[Path | str, ...] = ( + Path("C:/Windows/Fonts/msyh.ttc"), + Path("C:/Windows/Fonts/msyhbd.ttc"), + Path("C:/Windows/Fonts/simhei.ttf"), + Path("C:/Windows/Fonts/simsun.ttc"), + Path("C:/Windows/Fonts/arialuni.ttf"), + Path("C:/Windows/Fonts/arial.ttf"), + Path("/System/Library/Fonts/PingFang.ttc"), + Path("/System/Library/Fonts/STHeiti Light.ttc"), + Path("/System/Library/Fonts/STHeiti Medium.ttc"), + Path("/Library/Fonts/Arial Unicode.ttf"), + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"), + Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"), + Path("/usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf"), + Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"), + Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + "DejaVuSans.ttf", +) + + +def list_layout_profiles() -> list[LayoutProfile]: + """Return all registered layout profiles in stable order.""" + return list(_PROFILES) + + +def get_layout_profile(layout_id: str) -> LayoutProfile: + """Return a layout profile by id.""" + try: + return _PROFILE_BY_ID[layout_id] + except KeyError as exc: + raise ValueError(f"Unknown layout profile: {layout_id}") from exc + + +def recommend_layout_profile(drawing_name: str) -> LayoutProfile: + """Pick a layout profile from drawing-name semantics.""" + name = str(drawing_name or "") + lowered = name.lower() + + if any(keyword in name for keyword in ("改造前后", "前后", "对比")) or "compare" in lowered: + return get_layout_profile("dual_compare") + if any(keyword in name for keyword in ("鸟瞰", "人视", "运营场景", "AIGC", "效果图")): + return get_layout_profile("full_bleed_effect") + if any(keyword in name for keyword in ("诊断", "评价", "热力", "活力", "现状", "指标")): + return get_layout_profile("analysis_dashboard") + if any(keyword in name for keyword in ("策略", "目标", "体系", "流程", "技术推演")): + return get_layout_profile("matrix_storyboard") + if any(keyword in name for keyword in ("封面", "目录", "背景")): + return get_layout_profile("chapter_cover") + return get_layout_profile("map_legend_right") + + +def layout_prompt_clause(profile: LayoutProfile) -> str: + """Build a prompt clause that describes the selected layout contract.""" + lines = [ + f"Layout profile: {profile.layout_id}", + f"Name: {profile.name}", + f"Description: {profile.description}", + "Slots:", + ] + for slot in profile.slots: + lines.append( + f"- {slot.slot_id}: label={slot.label}; purpose={slot.purpose}; box={slot.box}" + ) + lines.append("Prompt rules:") + lines.extend(f"- {rule}" for rule in profile.prompt_rules) + lines.append("- 不得让文字压住主图,标题、图例、notes 必须与主图保持安全间距。") + return "\n".join(lines) + + +def compose_layout_sheet( + layout_id: str, + images: dict[str, Image.Image], + title: str, + chapter: str = "", + legend_items: list[tuple[str, str]] | None = None, + notes: list[str] | None = None, +) -> Image.Image: + """Compose an RGB A3 landscape sheet from slot images and annotations.""" + profile = get_layout_profile(layout_id) + sheet = Image.new("RGB", A3_LANDSCAPE_SIZE, "#f3f0e8") + draw = ImageDraw.Draw(sheet) + + _draw_page_background(draw) + for slot in profile.slots: + _render_slot(sheet, draw, slot, _image_for_slot(profile, slot, images)) + + _draw_title(sheet, title=title, chapter=chapter, profile=profile) + _draw_legend(sheet, profile, legend_items or []) + _draw_notes(sheet, profile, notes or []) + _draw_footer(sheet, profile) + return sheet + + +def _image_for_slot( + profile: LayoutProfile, + slot: LayoutSlot, + images: dict[str, Image.Image], +) -> Image.Image | None: + if slot.slot_id in images: + return images[slot.slot_id] + + primary_slot_id = _PRIMARY_VISUAL_SLOT_BY_LAYOUT.get(profile.layout_id) + if slot.slot_id == primary_slot_id: + return images.get("main") + + return None + + +def _draw_page_background(draw: ImageDraw.ImageDraw) -> None: + width, height = A3_LANDSCAPE_SIZE + draw.rectangle((0, 0, width - 1, height - 1), fill="#f3f0e8") + draw.rectangle((110, 110, width - 110, height - 110), outline="#1d3557", width=4) + draw.line((110, 300, width - 110, 300), fill="#8aa0b5", width=2) + + +def _render_slot( + sheet: Image.Image, + draw: ImageDraw.ImageDraw, + slot: LayoutSlot, + image: Image.Image | None, +) -> None: + left, top, right, bottom = slot.box + is_full_bleed = slot.box == (0, 0, *A3_LANDSCAPE_SIZE) + if not is_full_bleed: + draw.rectangle(slot.box, fill="#fbfaf6", outline="#67809a", width=3) + + inner = _inset_box(slot.box, 18 if not is_full_bleed else 0) + if image is None: + if not is_full_bleed: + draw.rectangle(inner, fill="#ece7dd") + draw.line((inner[0], inner[1], inner[2], inner[3]), fill="#c5bfb3", width=2) + draw.line((inner[0], inner[3], inner[2], inner[1]), fill="#c5bfb3", width=2) + _draw_text(draw, (inner[0] + 24, inner[1] + 24), slot.label, 34, fill="#455a6f") + else: + _paste_image_fit(sheet, image, inner, fill="#f7f4ee", cover=_slot_uses_cover(slot)) + + if not is_full_bleed: + label_box = (left + 22, top + 18, min(right - 22, left + 460), top + 76) + draw.rectangle(label_box, fill="#1d3557") + _draw_text(draw, (label_box[0] + 18, label_box[1] + 9), slot.label, 28, fill="#ffffff") + + +def _slot_uses_cover(slot: LayoutSlot) -> bool: + return slot.box == (0, 0, *A3_LANDSCAPE_SIZE) or slot.slot_id in _COVER_VISUAL_SLOT_IDS + + +def _paste_image_fit( + sheet: Image.Image, + image: Image.Image, + box: tuple[int, int, int, int], + fill: str, + cover: bool = False, +) -> None: + left, top, right, bottom = box + width = max(1, right - left) + height = max(1, bottom - top) + source = _normalize_slot_image(image) + target_size = (width, height) + if cover: + fitted = ImageOps.fit(source, target_size, method=Image.Resampling.LANCZOS) + else: + fitted = ImageOps.contain(source, target_size, method=Image.Resampling.LANCZOS) + + draw = ImageDraw.Draw(sheet) + draw.rectangle(box, fill=fill) + paste_left = left + (width - fitted.width) // 2 + paste_top = top + (height - fitted.height) // 2 + if fitted.mode == "RGBA": + sheet.paste(fitted.convert("RGB"), (paste_left, paste_top), fitted.getchannel("A")) + else: + sheet.paste(fitted, (paste_left, paste_top)) + + +def _normalize_slot_image(image: Image.Image) -> Image.Image: + if image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info): + return image.convert("RGBA") + return image.convert("RGB") + + +def _draw_title(sheet: Image.Image, title: str, chapter: str, profile: LayoutProfile) -> None: + draw = ImageDraw.Draw(sheet) + if profile.layout_id in {"full_bleed_effect", "chapter_cover"}: + title_slot = _find_slot(profile, "title_overlay") or _find_slot(profile, "title_focus") + if title_slot: + _draw_overlay(sheet, title_slot.box, "#0d1b2a", alpha=190) + x, y = title_slot.box[0] + 36, title_slot.box[1] + 34 + _draw_text(draw, (x, y), title or profile.name, 64, fill="#ffffff") + if chapter: + _draw_text(draw, (x, y + 96), chapter, 34, fill="#d6e4f0") + return + + _draw_text(draw, (220, 175), title or profile.name, 58, fill="#14213d") + if chapter: + _draw_text(draw, (220, 250), chapter, 30, fill="#516579") + + +def _draw_legend( + sheet: Image.Image, + profile: LayoutProfile, + legend_items: list[tuple[str, str]], +) -> None: + slot = _find_slot(profile, "legend") + if slot is None or not legend_items: + return + + draw = ImageDraw.Draw(sheet) + left, top, right, bottom = _inset_box(slot.box, 42) + _draw_panel_header(draw, left, top, right, "图例 / Legend") + y = top + 78 + for label, color in legend_items[:10]: + if y + 52 > bottom: + break + swatch = (left, y + 4, left + 42, y + 46) + draw.rectangle(swatch, fill=_safe_color(color), outline="#334155", width=2) + _draw_text(draw, (left + 62, y + 2), str(label), 26, fill="#243447") + y += 58 + + +def _draw_notes(sheet: Image.Image, profile: LayoutProfile, notes: list[str]) -> None: + slot = _find_notes_slot(profile) + if slot is None: + return + + draw = ImageDraw.Draw(sheet) + left, top, right, bottom = _inset_box(slot.box, 42) + _draw_panel_header(draw, left, top, right, "Notes") + y = top + 76 + note_lines = notes or ["结论占位", "指标占位", "数据来源占位"] + for note in note_lines[:6]: + wrapped = _wrap_text(str(note), right - left - 28, 24) + for line in wrapped: + if y + 36 > bottom: + return + _draw_text(draw, (left + 18, y), f"- {line}", 24, fill="#243447") + y += 34 + y += 8 + + +def _draw_footer(sheet: Image.Image, profile: LayoutProfile) -> None: + draw = ImageDraw.Draw(sheet) + width, height = A3_LANDSCAPE_SIZE + text = f"{profile.layout_id} | A3 landscape | {width} x {height}" + _draw_text(draw, (220, height - 88), text, 22, fill="#607085") + + +def _draw_panel_header( + draw: ImageDraw.ImageDraw, + left: int, + top: int, + right: int, + text: str, +) -> None: + draw.rectangle((left, top, right, top + 48), fill="#dfe8ef") + _draw_text(draw, (left + 16, top + 9), text, 24, fill="#1d3557") + + +def _draw_overlay(sheet: Image.Image, box: tuple[int, int, int, int], color: str, alpha: int) -> None: + overlay = Image.new("RGBA", sheet.size, (0, 0, 0, 0)) + overlay_draw = ImageDraw.Draw(overlay) + rgba = ImageColor.getrgb(color) + (alpha,) + overlay_draw.rectangle(box, fill=rgba) + sheet.paste(Image.alpha_composite(sheet.convert("RGBA"), overlay).convert("RGB")) + + +def _find_slot(profile: LayoutProfile, key: str) -> LayoutSlot | None: + for slot in profile.slots: + haystack = f"{slot.slot_id} {slot.label} {slot.purpose}".lower() + if key.lower() in haystack: + return slot + return None + + +def _find_notes_slot(profile: LayoutProfile) -> LayoutSlot | None: + for key in ("notes", "说明", "结论", "caption", "story", "comparison"): + slot = _find_slot(profile, key) + if slot is not None: + return slot + return profile.slots[-1] if profile.slots else None + + +def _inset_box(box: tuple[int, int, int, int], inset: int) -> tuple[int, int, int, int]: + left, top, right, bottom = box + return (left + inset, top + inset, right - inset, bottom - inset) + + +def _safe_color(color: str) -> tuple[int, int, int]: + try: + return ImageColor.getrgb(color) + except ValueError: + return (96, 125, 139) + + +def _wrap_text(text: str, max_width: int, font_size: int) -> list[str]: + if not text: + return [] + font = _load_font(font_size) + lines: list[str] = [] + current = "" + for char in text: + candidate = current + char + if current and _text_width(candidate, font) > max_width: + lines.append(current) + current = char + else: + current = candidate + if current: + lines.append(current) + return lines + + +def _draw_text( + draw: ImageDraw.ImageDraw, + xy: tuple[int, int], + text: str, + size: int, + fill: str, +) -> None: + for font in _load_font_options(size): + try: + draw.text(xy, text, font=font, fill=fill) + return + except UnicodeEncodeError: + continue + + fallback_text = text.encode("ascii", "replace").decode("ascii") or "?" + for font in _load_font_options(size): + try: + draw.text(xy, fallback_text, font=font, fill=fill) + return + except UnicodeEncodeError: + continue + + +def _text_width(text: str, font: ImageFont.ImageFont) -> int: + try: + return int(font.getlength(text)) + except (AttributeError, UnicodeEncodeError): + return len(text) * 14 + + +def _load_font(size: int) -> ImageFont.ImageFont: + return next(_load_font_options(size)) + + +def _load_font_options(size: int): + seen: set[str] = set() + for candidate in _FONT_CANDIDATES: + key = str(candidate).lower() + if key in seen: + continue + seen.add(key) + if isinstance(candidate, Path): + if not candidate.exists(): + continue + location = str(candidate) + else: + location = candidate + try: + yield ImageFont.truetype(location, size) + except OSError: + continue + yield ImageFont.load_default() + + +def _validate_profiles() -> None: + width, height = A3_LANDSCAPE_SIZE + seen: set[str] = set() + for profile in _PROFILES: + if profile.layout_id in seen: + raise ValueError(f"Duplicate layout profile: {profile.layout_id}") + seen.add(profile.layout_id) + for slot in profile.slots: + left, top, right, bottom = slot.box + if not (0 <= left < right <= width and 0 <= top < bottom <= height): + raise ValueError(f"Invalid slot box for {profile.layout_id}.{slot.slot_id}: {slot.box}") + + +_validate_profiles() + + +__all__ = [ + "A3_LANDSCAPE_SIZE", + "LayoutSlot", + "LayoutProfile", + "list_layout_profiles", + "get_layout_profile", + "recommend_layout_profile", + "layout_prompt_clause", + "compose_layout_sheet", +] diff --git a/src/engines/drawing_prompt_engine.py b/src/engines/drawing_prompt_engine.py index f6a602d..2b86e91 100644 --- a/src/engines/drawing_prompt_engine.py +++ b/src/engines/drawing_prompt_engine.py @@ -5,8 +5,18 @@ spatial/data references. """ +from copy import deepcopy from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Sequence +from typing import Dict, Iterable, List, Sequence, TYPE_CHECKING + +from src.engines.drawing_layout_engine import ( + get_layout_profile, + layout_prompt_clause, + recommend_layout_profile, +) + +if TYPE_CHECKING: + from src.engines.key_plot_engine import KeyPlot # ---- Exceptions ---- @@ -68,7 +78,20 @@ def get(self, key, default=None): PROJECT_DEFAULTS = DynamicProjectDefaults() -BOOK_CHAPTERS = { +LEGACY_KEY_PLOT_DRAWING_SUFFIXES = [ + "现状问题图", + "更新定位图", + "平面深化图", + "AIGC推演效果图", + "人视效果图", + "建筑更新图", + "街道断面图", + "改造前后对比图", + "运营场景图", +] + + +BASE_BOOK_CHAPTERS = { "01 项目认知篇": [ "封面", "目录", @@ -117,7 +140,7 @@ def get(self, key, default=None): "更新模式分区图", "功能策划图", "空间结构规划图", - "5个重点地块定位图", + "重点地块定位图", ], "05 整体概念设计和更新": [ "总平面图", @@ -137,53 +160,7 @@ def get(self, key, default=None): "夜景照明与导视系统图", "规划指标表", ], - "06 重点地段更新改造设计": [ - "地块1现状问题图", - "地块1更新定位图", - "地块1平面深化图", - "地块1AIGC推演效果图", - "地块1人视效果图", - "地块1建筑更新图", - "地块1街道断面图", - "地块1改造前后对比图", - "地块1运营场景图", - "地块2现状问题图", - "地块2更新定位图", - "地块2平面深化图", - "地块2AIGC推演效果图", - "地块2人视效果图", - "地块2建筑更新图", - "地块2街道断面图", - "地块2改造前后对比图", - "地块2运营场景图", - "地块3现状问题图", - "地块3更新定位图", - "地块3平面深化图", - "地块3AIGC推演效果图", - "地块3人视效果图", - "地块3建筑更新图", - "地块3街道断面图", - "地块3改造前后对比图", - "地块3运营场景图", - "地块4现状问题图", - "地块4更新定位图", - "地块4平面深化图", - "地块4AIGC推演效果图", - "地块4人视效果图", - "地块4建筑更新图", - "地块4街道断面图", - "地块4改造前后对比图", - "地块4运营场景图", - "地块5现状问题图", - "地块5更新定位图", - "地块5平面深化图", - "地块5AIGC推演效果图", - "地块5人视效果图", - "地块5建筑更新图", - "地块5街道断面图", - "地块5改造前后对比图", - "地块5运营场景图", - ], + "06 重点地段更新改造设计": [], "07 技术推演与实施篇": [ "AIGC技术推演过程图", "实施分期图", @@ -193,6 +170,30 @@ def get(self, key, default=None): } +def get_book_chapters(key_plots: Sequence["KeyPlot"] | None = None) -> dict[str, list[str]]: + from src.engines.key_plot_engine import build_key_plot_drawing_names, get_configured_key_plots + + chapters = deepcopy(BASE_BOOK_CHAPTERS) + active_plots = list(get_configured_key_plots() if key_plots is None else key_plots) + chapters["06 重点地段更新改造设计"] = build_key_plot_drawing_names(active_plots) + return chapters + + +def _legacy_static_book_chapters() -> dict[str, list[str]]: + chapters = deepcopy(BASE_BOOK_CHAPTERS) + chapters["06 重点地段更新改造设计"] = [ + f"地块{index}{suffix}" + for index in range(1, 6) + for suffix in LEGACY_KEY_PLOT_DRAWING_SUFFIXES + ] + return chapters + + +# Legacy static snapshot kept for backward-compatible imports. New or dynamic +# code must call get_book_chapters(...) or flatten_chapter_drawings(...). +BOOK_CHAPTERS = _legacy_static_book_chapters() + + UPLOAD_CHANNELS = [ "卫星底图", "红线边界图", @@ -210,7 +211,7 @@ def get(self, key, default=None): UPLOAD_REFERENCE_TEXT = { "卫星底图": "请严格参考上传的卫星底图作为空间基础,不得改变研究范围的空间关系、道路走向和主要建筑肌理。", - "红线边界图": "请严格保持上传红线图中的研究范围边界和五个重点地块边界,不得扩大、缩小、旋转、偏移或重新解释边界。", + "红线边界图": "请严格保持上传红线图中的研究范围边界和重点更新单元边界,不得扩大、缩小、旋转、偏移或重新解释边界。", "GIS专题图": "请严格依据上传 GIS 专题图中的分类、范围和图例逻辑进行视觉表达,不得自行新增不存在的数据分区。", "道路矢量图": "请严格保持上传道路矢量图中的道路等级、走向、交叉口和街区路网结构,不得自行增删主要道路。", "建筑肌理图": "请严格参考上传建筑肌理图中的建筑轮廓、建筑密度和街区肌理,建筑轮廓需基本一致。", @@ -225,7 +226,7 @@ def get(self, key, default=None): PRECISION_ONE = { "研究范围图", - "研究范围与5个重点地块图", + "研究范围与重点地块图", "用地现状分析图", "AI诊断用地现状图", "建筑现状分析图", @@ -263,7 +264,7 @@ def get(self, key, default=None): "更新模式分区图", "功能策划图", "空间结构规划图", - "5个重点地块定位图", + "重点地块定位图", "建筑风貌识别图", "历史建筑与工业遗产分布图", "社交媒体情感分析图", @@ -298,7 +299,7 @@ def get(self, key, default=None): NEGATIVE_COMMON = ( "不要卡通风,不要漫画风,不要儿童插画风,不要过度赛博朋克,不要旅游宣传海报风," "不要房地产广告风,不要虚构城市天际线,不要虚构道路,不要虚构地名,不要改变研究范围边界," - "不要改变五个重点地块边界,不要改变道路走向,不要改变建筑肌理,不要错误比例尺,不要错误指北针," + "不要改变重点地块边界,不要改变道路走向,不要改变建筑肌理,不要错误比例尺,不要错误指北针," "不要乱码中文,不要大段难以阅读的文字,不要杂乱排版,不要低清晰度,不要无关人物占据画面。" ) NEGATIVE_BY_PRECISION = { @@ -353,6 +354,7 @@ class ImagePromptRequest: mark_as_schematic: bool = False use_existing_results: bool = True evidence_blocks: Dict[str, str] = None + layout_profile_id: str = "" @dataclass(frozen=True) @@ -497,7 +499,7 @@ def revise_prompt_by_rating(prompt: str, rating: str, issue_types: Iterable[str] additions.append("废弃当前图面结果,回到完整性检查;重新确认图纸类型、底图、精度等级、图例和核心内容后再生成。") if "边界不准" in issue_types: - additions.append("必须严格保持上传红线图边界;不得改变研究范围形状,不得改变五个重点地块位置,不得对边界进行艺术化处理。") + additions.append("必须严格保持上传红线图边界;不得改变研究范围形状,不得改变重点地块位置,不得对边界进行艺术化处理。") if "图面太杂" in issue_types or "信息太密" in issue_types: additions.append("减少装饰元素,保留主要模块和核心图面;文字以短标签为主,增加留白,降低背景纹理透明度。") if "文字不准" in issue_types or "文字乱码" in issue_types: @@ -516,14 +518,21 @@ def revise_prompt_by_rating(prompt: str, rating: str, issue_types: Iterable[str] return prompt.rstrip() + "\n\n【根据成图评级追加修正】\n" + "\n".join(f"- {item}" for item in additions) -def flatten_chapter_drawings() -> List[str]: +def flatten_chapter_drawings(key_plots: Sequence["KeyPlot"] | None = None) -> List[str]: drawings = [] - for names in BOOK_CHAPTERS.values(): + for names in get_book_chapters(key_plots=key_plots).values(): drawings.extend(names) return drawings def _compose_prompt(request: ImagePromptRequest, profile: DrawingProfile, negative_prompt: str, template_only: bool) -> str: + layout_profile = ( + get_layout_profile(request.layout_profile_id) + if request.layout_profile_id + else recommend_layout_profile(request.drawing_name) + ) + layout_clause = layout_prompt_clause(layout_profile) + layout_supplement = request.layout_structure.strip() if request.layout_structure else "无补充版式要求" uploaded = [channel for channel in UPLOAD_CHANNELS if channel in set(request.uploaded_channels or [])] upload_text = "\n".join(f"- {UPLOAD_REFERENCE_TEXT[channel]}" for channel in uploaded) or "- 当前未上传底图;仅可生成概念或版式提示词,不得虚构真实空间信息。" frame_text = FRAME_SYSTEM if profile.requires_standard_frame else "封面、目录或效果类图纸不强制加入完整图框,可保留克制的标题与必要项目信息。" @@ -549,10 +558,11 @@ def _compose_prompt(request: ImagePromptRequest, profile: DrawingProfile, negati {content_text} 【版式结构】 -版式采用{request.layout_structure}。图面需形成清晰的信息层级:主图优先,策略标签次之,图例和说明辅助。避免让装饰性元素压过真实数据和空间关系。 +{layout_clause} +补充版式要求:{layout_supplement}。图面需形成清晰的信息层级:主图优先,策略标签次之,图例和说明辅助。避免让装饰性元素压过真实数据和空间关系。 【图例与标注】 -图例内容包括:{request.legend_content or '使用占位符标注图例分类,等待用户后期替换。'}。必须出现的重点地块或空间对象:{request.key_plots or '五个重点地块、研究范围边界、主要道路和伪满皇宫周边核心节点。'}。{schematic_text} +图例内容包括:{request.legend_content or '使用占位符标注图例分类,等待用户后期替换。'}。必须出现的重点地块或空间对象:{request.key_plots or '重点地块、研究范围边界、主要道路和伪满皇宫周边核心节点。'}。{schematic_text} 【风格与图框】 {request.style_system or UNIFIED_STYLE} @@ -598,7 +608,7 @@ def _format_evidence(evidence_blocks: Dict[str, str], enabled: bool = True) -> s def _precision_rule_text(profile: DrawingProfile) -> str: if profile.precision == "一级精度": - return "必须严格依据上传底图,不允许 AI 改变边界、道路、地块形状和空间关系;不得改变研究范围边界、五个重点地块边界、道路关系、建筑轮廓和用地分类。" + return "必须严格依据上传底图,不允许 AI 改变边界、道路、地块形状和空间关系;不得改变研究范围边界、重点地块边界、道路关系、建筑轮廓和用地分类。" if profile.precision == "二级精度": return "必须基于真实数据或已上传专题图,但允许在图面表达、色彩、符号和版式上进行视觉美化;缺少数据时只能生成视觉表达模板。" return "以概念表达、风格表达和图册视觉为主,允许 AI 适度发挥,但不能虚构真实地理位置、道路、地块和数据。" @@ -670,9 +680,11 @@ def _requires_legend(name: str) -> bool: def _normalize_plot_detail_name(name: str) -> str: - for idx in range(1, 6): - name = name.replace(f"地块{idx}", "重点地块") - return name + from src.engines.key_plot_engine import normalize_key_plot_name + + legacy_key_plot_locator = "5" + "个重点地块" + name = name.replace(legacy_key_plot_locator, "重点地块") + return normalize_key_plot_name(name) def _has_text(value) -> bool: diff --git a/src/engines/drawing_prompt_templates.py b/src/engines/drawing_prompt_templates.py index 3aa768f..85c6faa 100644 --- a/src/engines/drawing_prompt_templates.py +++ b/src/engines/drawing_prompt_templates.py @@ -13,6 +13,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from src.engines.drawing_prompt_engine import ( @@ -23,6 +24,12 @@ build_image_prompt, get_drawing_profile, ) +from src.engines.drawing_layout_engine import recommend_layout_profile +from src.engines.key_plot_engine import ( + KEY_PLOT_DRAWING_SUFFIXES, + format_key_plot_context, + get_configured_key_plots, +) from src.workflow.template_assets import ( get_template_asset_rows, get_uploaded_prompt_channels, @@ -283,7 +290,7 @@ def _get_project_context() -> str: · 功能置换区(蓝色)—— 工业低效用地 · 社区修补区(绿色)—— 老旧社区微更新 · 重点再生区(紫色)—— 站城门户等战略节点 -- 标注5个重点深化地块 +- 标注重点更新单元边界/编号 - A3横版,右侧图例包含各分区面积和占比""", ), @@ -747,19 +754,19 @@ def _get_project_context() -> str: name="重点地块现状诊断对比图", chapter="06 重点地段篇", stage="10", - description="5个重点地块的现状问题对比与诊断雷达", + description="重点更新单元的现状问题对比与诊断雷达", prompt_tmpl="""请为以下城市设计项目生成一张"重点地块现状诊断对比图"的 Image 2.0 提示词。 {data_context} 图纸要求: -- 5个重点地块并列展示,每个地块包含: +- 重点更新单元并列展示,每个单元包含: · 地块名称和定位(站城门户/工业遗产/社区微更新/风貌协调/文旅活力) · 小型雷达图(空间潜力/设施密度/绿视率/天空开敞度/环境整洁度) · 核心数据标注(MPI、面积、POI数) · 主要问题简述 -- 底部附5个地块在研究范围内的位置索引图 -- A3横版,5列并排布局""", +- 底部附重点更新单元在研究范围内的位置索引图 +- A3横版,按配置单元数量自适应多列布局""", ), DrawingTemplate( name="沿街立面设计图", @@ -876,6 +883,10 @@ def _get_project_context() -> str: # 构建名称索引 _TEMPLATE_INDEX = {t.name: t for t in DRAWING_TEMPLATES} +_KEY_PLOT_DETAIL_CHAPTER = "06 重点地段更新改造设计" +_KEY_PLOT_DETAIL_STAGE = "10" +_KEY_PLOT_SUFFIX_PATTERN = "|".join(re.escape(suffix) for suffix in KEY_PLOT_DRAWING_SUFFIXES) +_KEY_PLOT_NUMBER_RE = re.compile(rf"^地块\d+(?:{_KEY_PLOT_SUFFIX_PATTERN})$") def get_template(name: str) -> DrawingTemplate | None: @@ -883,6 +894,8 @@ def get_template(name: str) -> DrawingTemplate | None: return _TEMPLATE_INDEX.get(name) + + def get_templates_by_stage(stage_code: str) -> list[DrawingTemplate]: """获取某阶段的全部模板。""" return [t for t in DRAWING_TEMPLATES if t.stage == stage_code] @@ -898,11 +911,22 @@ def get_or_create_template(drawing_name: str) -> DrawingTemplate | None: tmpl = get_template(drawing_name) if tmpl: return tmpl + from src.engines.drawing_prompt_engine import flatten_chapter_drawings + all_drawings = flatten_chapter_drawings() + if drawing_name not in all_drawings and not _is_numbered_key_plot_drawing(drawing_name): + clean_name = drawing_name.replace("图纸", "").replace("分析", "").replace("图", "").replace("表", "").strip() + has_overlap = False + for d in all_drawings: + clean_d = d.replace("图纸", "").replace("分析", "").replace("图", "").replace("表", "").strip() + if len(clean_name) >= 2 and (clean_name in clean_d or clean_d in clean_name): + has_overlap = True + break + if not has_overlap: + return None return _generate_generic_template(drawing_name) def _generate_generic_template(drawing_name: str) -> DrawingTemplate | None: - """Generate a generic template based on drawing name.""" try: profile = get_drawing_profile(drawing_name) except Exception: @@ -927,8 +951,15 @@ def _generate_generic_template(drawing_name: str) -> DrawingTemplate | None: ) +def _is_numbered_key_plot_drawing(drawing_name: str) -> bool: + """Return whether the drawing targets a configured numbered key plot.""" + return bool(_KEY_PLOT_NUMBER_RE.search(drawing_name)) + + def _infer_chapter_from_name(drawing_name: str) -> str: """Infer chapter from drawing name keywords.""" + if _is_numbered_key_plot_drawing(drawing_name): + return _KEY_PLOT_DETAIL_CHAPTER for chapter, drawings in BOOK_CHAPTERS.items(): if drawing_name in drawings: return chapter @@ -949,6 +980,8 @@ def _infer_chapter_from_name(drawing_name: str) -> str: def _infer_stage_from_name(drawing_name: str) -> str: """Infer stage code from drawing name.""" + if _is_numbered_key_plot_drawing(drawing_name): + return _KEY_PLOT_DETAIL_STAGE for chapter, drawings in BOOK_CHAPTERS.items(): if drawing_name in drawings: chapter_stage_map = { @@ -964,6 +997,12 @@ def _infer_stage_from_name(drawing_name: str) -> str: return "01" +def _get_key_plot_prompt_context() -> str: + spatial_objects = f"研究范围红线、主要道路、{get_site_name()}周边核心节点、图例区 and 固定图框。" + key_plot_context = format_key_plot_context(get_configured_key_plots()) + return f"{key_plot_context}\n{spatial_objects}" + + def build_drawing_prompt(template_name: str) -> tuple[str, str]: """构建固定资产约束下的 Image 2.0 图纸提示词。 @@ -972,7 +1011,7 @@ def build_drawing_prompt(template_name: str) -> tuple[str, str]: tuple[str, str] (完整提示词, 系统提示词) """ - tmpl = get_template(template_name) + tmpl = get_or_create_template(template_name) if not tmpl: return "", "" @@ -985,6 +1024,7 @@ def build_drawing_prompt(template_name: str) -> tuple[str, str]: from src.engines.design_description_engine import generate_dynamic_design_description dyn_strategy, dyn_conclusion = generate_dynamic_design_description(tmpl.name, tmpl.stage) + layout_profile = recommend_layout_profile(tmpl.name) request = ImagePromptRequest( chapter=tmpl.chapter, @@ -996,7 +1036,7 @@ def build_drawing_prompt(template_name: str) -> tuple[str, str]: main_expression=tmpl.description, must_include=template_requirements, legend_content=_default_legend_content(tmpl.name), - key_plots=f"研究范围红线、五个重点地块、主要道路、{get_site_name()}周边核心节点、图例区 and 固定图框。", + key_plots=_get_key_plot_prompt_context(), design_strategy=dyn_strategy, analysis_conclusion=dyn_conclusion, avoid_content="不得重绘底图、不得移动边界、不得改写重点地块、不得改变图框、不得虚构道路和用地分类。", @@ -1007,6 +1047,7 @@ def build_drawing_prompt(template_name: str) -> tuple[str, str]: "固定制图资产": asset_context, "模板内容要求": template_requirements, }, + layout_profile_id=layout_profile.layout_id, ) result = build_image_prompt(request) if result.can_generate: diff --git a/src/engines/frame_generator.py b/src/engines/frame_generator.py index f63bac2..ee9f585 100644 --- a/src/engines/frame_generator.py +++ b/src/engines/frame_generator.py @@ -24,6 +24,8 @@ from PIL import Image, ImageDraw, ImageFont +from src.engines.drawing_layout_engine import compose_layout_sheet + # ── 常量 ────────────────────────────────────────── # A3 横版 300dpi: 4961 x 3508 @@ -75,6 +77,8 @@ def compose_framed_sheet( legend_items: Optional[List[Tuple[str, str]]] = None, drawing_number: str = "", scale_text: str = "1:5000", + layout_id: str = "map_legend_right", + secondary_images: Optional[dict[str, Image.Image]] = None, ) -> Image.Image: """将主图嵌入标准规划图框,返回合成后的 PIL Image。 @@ -91,6 +95,30 @@ def compose_framed_sheet( 合成后的 A3 尺寸 PIL Image。 """ legend_items = legend_items or [] + images = {"main": main_image} + if layout_id == "dual_compare": + images["before_view"] = main_image + images["after_view"] = main_image + if secondary_images: + images.update(secondary_images) + + notes = [] + if summary: + notes.append(summary) + if drawing_number: + notes.append(f"Drawing number: {drawing_number}") + if scale_text: + notes.append(f"Scale: {scale_text}") + + if layout_id != "legacy": + return compose_layout_sheet( + layout_id=layout_id, + images=images, + title=title, + chapter=chapter, + legend_items=list(legend_items), + notes=notes, + ) # ── 创建画布 ── sheet = Image.new("RGB", (SHEET_W, SHEET_H), BG_COLOR) diff --git a/src/engines/key_plot_engine.py b/src/engines/key_plot_engine.py new file mode 100644 index 0000000..4f43c55 --- /dev/null +++ b/src/engines/key_plot_engine.py @@ -0,0 +1,354 @@ +"""Canonical key-plot metadata and GIS loading helpers.""" + +from __future__ import annotations + +import json +import logging +import math +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from src.config import GIS_FILES, resolve_path + +logger = logging.getLogger("ultimateDESIGN") + + +class KeyPlotLoadError(Exception): + """Raised when a configured key-plot GIS file exists but cannot be loaded.""" + + +KEY_PLOT_DRAWING_SUFFIXES = [ + "现状问题图", + "更新定位图", + "平面深化图", + "AIGC推演效果图", + "人视效果图", + "建筑更新图", + "街道断面图", + "改造前后对比图", + "运营场景图", +] + + +@dataclass(frozen=True) +class KeyPlot: + index: int + plot_id: str + name: str + role: str = "" + area_ha: float = 0.0 + centroid: tuple[float, float] | None = None + + +@dataclass(frozen=True) +class KeyPlotGeometry: + plot: KeyPlot + geometry: object + + +def normalize_key_plot_name(name: str) -> str: + """Normalize numbered plot drawing names to the canonical key-plot label.""" + return re.sub(r"地块\d+", "重点地块", name) + + +def build_key_plot_drawing_names(plots: Iterable[KeyPlot]) -> list[str]: + """Expand configured key plots into the canonical per-plot drawing names.""" + return [f"地块{plot.index}{suffix}" for plot in plots for suffix in KEY_PLOT_DRAWING_SUFFIXES] + + +def format_key_plot_context(plots: Iterable[KeyPlot]) -> str: + """Format key-plot metadata for prompt/context injection.""" + plot_list = list(plots) + if not plot_list: + return "重点更新单元尚未配置;生成地块深化图纸前,请先上传或配置地块边界。" + + lines = [f"共 {len(plot_list)} 个重点更新单元:"] + for plot in plot_list: + details = [] + if plot.role: + details.append(f"功能角色:{plot.role}") + if plot.area_ha: + details.append(f"面积:{plot.area_ha:.2f} ha") + + line = f"地块{plot.index}:{plot.name}" + if details: + line = f"{line}({','.join(details)})" + lines.append(f"- {line}") + return "\n".join(lines) + + +def load_key_plots_from_geojson(path: str | Path) -> list[KeyPlot]: + """Load configured key plots from a GeoJSON-compatible file.""" + return [item.plot for item in load_key_plot_geometries_from_geojson(path)] + + +def load_key_plot_geometries_from_geojson(path: str | Path) -> list[KeyPlotGeometry]: + """Load configured key plots paired with their WGS84 geometries.""" + resolved = resolve_path(str(path)) + if not resolved.exists(): + return [] + + try: + gdf = _read_geojson(resolved) + original_feature_count = int(gdf.attrs.get("key_plot_feature_count", len(gdf))) + if gdf.empty: + if original_feature_count == 0: + return [] + raise KeyPlotLoadError( + f"Key plot GeoJSON has {original_feature_count} features but 0 usable geometries" + ) + + gdf = _prepare_geometries(gdf) + if original_feature_count > 0 and len(gdf) != original_feature_count: + raise KeyPlotLoadError( + "Key plot GeoJSON geometry cleanup changed feature count: " + f"original={original_feature_count}, valid={len(gdf)}" + ) + if gdf.empty: + return [] + + areas_ha = _calculate_areas_ha(gdf) + centroids = _centroids_wgs84(gdf) + wgs84_geometries = list(_to_wgs84(gdf).geometry) + + paired = [] + for position, (_, row) in enumerate(gdf.iterrows(), start=1): + plot = _key_plot_from_row( + row, + position=position, + area_ha=areas_ha[position - 1], + centroid=centroids[position - 1], + ) + paired.append(KeyPlotGeometry(plot=plot, geometry=wgs84_geometries[position - 1])) + return paired + except KeyPlotLoadError: + raise + except Exception as exc: + raise KeyPlotLoadError(f"Failed to load key plots from {resolved}") from exc + + +def get_configured_key_plots() -> list[KeyPlot]: + """Load key plots from the configured GIS plot file.""" + return load_key_plots_from_geojson(resolve_path(str(GIS_FILES["plots"]))) + + +def plot_names(plots: Iterable[KeyPlot]) -> list[str]: + return [plot.name for plot in plots] + + +def _key_plot_from_row(row, *, position: int, area_ha: float, centroid: tuple[float, float]) -> KeyPlot: + plot_id = _first_value(row, ("id", "OBJECTID", "index")) or str(position) + name = _first_value(row, ("name", "Name", "plot_name", "PlotName", "地块名称")) or f"地块{position}" + role = _first_value(row, ("role", "type", "category")) or "" + + return KeyPlot( + index=position, + plot_id=str(plot_id), + name=str(name), + role=str(role), + area_ha=round(float(area_ha), 2), + centroid=centroid, + ) + + +def _read_geojson(path: Path): + try: + import geopandas as gpd + except ImportError as exc: + raise KeyPlotLoadError("GeoPandas is required to load key plot GIS files") from exc + + data = _load_geojson_document(path) + original_feature_count = len(data["features"]) + + try: + gdf = gpd.read_file(str(path)) + feature_ids = _feature_ids(data) + if "id" not in gdf.columns and len(feature_ids) == len(gdf) and any(_has_value(value) for value in feature_ids): + gdf.insert(0, "id", feature_ids) + gdf.attrs["key_plot_feature_count"] = original_feature_count + return gdf + except Exception: + try: + gdf = _read_geojson_fallback(data, gpd) + gdf.attrs["key_plot_feature_count"] = original_feature_count + return gdf + except Exception as exc: + raise KeyPlotLoadError(f"Could not parse key plot GeoJSON at {path}") from exc + + +def _load_geojson_document(path: Path) -> dict: + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except Exception as exc: + raise KeyPlotLoadError(f"Could not read key plot GeoJSON at {path}") from exc + + if not isinstance(data, dict): + raise KeyPlotLoadError(f"Key plot GeoJSON at {path} must be a FeatureCollection object") + if data.get("type") != "FeatureCollection": + raise KeyPlotLoadError(f"Key plot GeoJSON at {path} must be a FeatureCollection") + if "features" not in data: + raise KeyPlotLoadError(f"Key plot GeoJSON at {path} is missing features") + if not isinstance(data["features"], list): + raise KeyPlotLoadError(f"Key plot GeoJSON at {path} features must be a list") + _validate_feature_geometries(data["features"], str(path)) + return data + + +def _validate_feature_geometries(features: list, source: str) -> None: + from shapely.geometry import shape + + for position, feature in enumerate(features, start=1): + if not isinstance(feature, dict): + raise KeyPlotLoadError(f"Key plot GeoJSON feature {position} in {source} must be an object") + if "geometry" not in feature or feature["geometry"] is None: + raise KeyPlotLoadError(f"Key plot GeoJSON feature {position} in {source} has missing geometry") + + try: + geometry = shape(feature["geometry"]) + except Exception as exc: + raise KeyPlotLoadError(f"Key plot GeoJSON feature {position} in {source} has invalid geometry") from exc + + if geometry.is_empty: + raise KeyPlotLoadError(f"Key plot GeoJSON feature {position} in {source} has empty geometry") + + +def _read_geojson_fallback(data: dict, gpd): + from shapely.geometry import shape + + records = [] + geometries = [] + for feature in data.get("features", []): + if not isinstance(feature, dict): + raise KeyPlotLoadError("Key plot GeoJSON features must be objects") + + geometry_data = feature.get("geometry") + if geometry_data is None: + raise KeyPlotLoadError("Key plot GeoJSON feature has missing geometry") + + properties = dict(feature.get("properties") or {}) + if "id" not in properties and _has_value(feature.get("id")): + properties["id"] = feature.get("id") + geometry = shape(geometry_data) + if geometry.is_empty: + raise KeyPlotLoadError("Key plot GeoJSON feature has empty geometry") + records.append(properties) + geometries.append(geometry) + + crs = _geojson_crs(data) + return gpd.GeoDataFrame(records, geometry=geometries, crs=crs) + + +def _feature_ids(data: dict) -> list: + return [feature.get("id") if isinstance(feature, dict) else None for feature in data["features"]] + + +def _geojson_crs(data: dict) -> str | None: + crs = data.get("crs") + if not isinstance(crs, dict): + return None + properties = crs.get("properties") or {} + name = properties.get("name") + return name if isinstance(name, str) else None + + +def _prepare_geometries(gdf): + if gdf.crs is None: + gdf = gdf.set_crs("EPSG:4326", allow_override=True) + + gdf = gdf[gdf.geometry.notna()].copy() + if gdf.empty: + return gdf + + try: + if hasattr(gdf.geometry, "make_valid"): + gdf = gdf.set_geometry(gdf.geometry.make_valid()) + else: + from shapely.validation import make_valid + + gdf = gdf.set_geometry(gdf.geometry.apply(make_valid)) + except Exception: + gdf = gdf.set_geometry(gdf.geometry.buffer(0)) + + gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy() + return gdf.reset_index(drop=True) + + +def _calculate_areas_ha(gdf) -> list[float]: + target_crs = _estimated_utm_crs(gdf) + if target_crs is not None: + try: + projected = gdf.to_crs(target_crs) + return (projected.geometry.area / 10000.0).tolist() + except Exception: + logger.warning("Estimated UTM area calculation failed; falling back to geodesic area", exc_info=True) + + return _calculate_geodesic_areas_ha(gdf) + + +def _calculate_geodesic_areas_ha(gdf) -> list[float]: + from pyproj import Geod + + geod = Geod(ellps="WGS84") + wgs84 = _to_wgs84(gdf) + areas_ha = [] + for geometry in wgs84.geometry: + area_m2, _ = geod.geometry_area_perimeter(geometry) + areas_ha.append(abs(float(area_m2)) / 10000.0) + return areas_ha + + +def _estimated_utm_crs(gdf): + try: + return gdf.estimate_utm_crs() + except Exception: + return None + + +def _to_wgs84(gdf): + if gdf.crs is None: + return gdf.set_crs("EPSG:4326", allow_override=True) + + try: + epsg = gdf.crs.to_epsg() + except Exception: + epsg = None + + if epsg == 4326: + return gdf + return gdf.to_crs("EPSG:4326") + + +def _centroids_wgs84(gdf) -> list[tuple[float, float]]: + target_crs = _estimated_utm_crs(gdf) + if target_crs is not None: + try: + projected = gdf.to_crs(target_crs) + centroid_series = projected.geometry.centroid + centroids = projected.set_geometry(centroid_series).to_crs("EPSG:4326").geometry + return [(round(point.x, 6), round(point.y, 6)) for point in centroids] + except Exception: + logger.warning("Projected centroid calculation failed; falling back to WGS84 centroid", exc_info=True) + + wgs84 = _to_wgs84(gdf) + centroids = [geometry.centroid for geometry in wgs84.geometry] + return [(round(point.x, 6), round(point.y, 6)) for point in centroids] + + +def _first_value(row, keys: tuple[str, ...]): + for key in keys: + if key in row and _has_value(row[key]): + return row[key] + return None + + +def _has_value(value) -> bool: + if value is None: + return False + if isinstance(value, float) and math.isnan(value): + return False + if isinstance(value, str) and not value.strip(): + return False + return True diff --git a/src/engines/site_diagnostic_engine.py b/src/engines/site_diagnostic_engine.py index 5ba4ff2..c8299b9 100644 --- a/src/engines/site_diagnostic_engine.py +++ b/src/engines/site_diagnostic_engine.py @@ -4,7 +4,6 @@ from src.engines.site_diagnostic_engine import get_plot_diagnostics, generate_policy_matrix """ -import json import logging import numpy as np @@ -13,6 +12,7 @@ from src.config import SHP_FILES, DATA_FILES from src.config.runtime import resolve_path +from src.engines.key_plot_engine import load_key_plot_geometries_from_geojson logger = logging.getLogger("ultimateDESIGN") @@ -57,48 +57,45 @@ def get_plot_diagnostics() -> list: if not plots_path.exists(): return [] - with plots_path.open("r", encoding="utf-8") as f: - geo = json.load(f) + plot_geometries = load_key_plot_geometries_from_geojson(plots_path) + if not plot_geometries: + return [] try: from src.engines.spatial_engine import get_merged_poi_data df_poi = get_merged_poi_data() except Exception: + logger.warning("POI data unavailable for plot diagnostics", exc_info=True) df_poi = pd.DataFrame() # Use cached loaders instead of re-reading files every call df_spatial = _load_spatial_merge() df_nlp = _load_nlp_data() - # Pre-extract coordinate arrays for vectorized bbox filtering - poi_coords = None - if not df_poi.empty and "Lng" in df_poi.columns: - poi_coords = (df_poi["Lng"].values, df_poi["Lat"].values) + # Pre-extract coordinate arrays for vectorized polygon filtering + poi_coords = _coordinate_arrays(df_poi) sp_coords = None sp_cols = {} - if not df_spatial.empty and "Lng" in df_spatial.columns: - sp_coords = (df_spatial["Lng"].values, df_spatial["Lat"].values) + if not df_spatial.empty and "Lng" in df_spatial.columns and "Lat" in df_spatial.columns: + sp_coords = _coordinate_arrays(df_spatial) for col in ("GVI", "SVF", "Enclosure", "Clutter"): if col in df_spatial.columns: - sp_cols[col] = df_spatial[col].values + sp_cols[col] = pd.to_numeric(df_spatial[col], errors="coerce").to_numpy(dtype=float) global_sentiment = 0.0 if not df_nlp.empty and "Score" in df_nlp.columns: global_sentiment = round(float(df_nlp["Score"].mean()), 3) results = [] - for feat in geo.get("features", []): - props = feat.get("properties", {}) + for plot_geometry in plot_geometries: + plot = plot_geometry.plot + geometry = plot_geometry.geometry + props = {"name": plot.name} name = props.get("name", f"地块_{props.get('OBJECTID', '?')}") - area_sqm = props.get("Shape_Area", 0) - coords = feat["geometry"]["coordinates"][0] - bbox = (min(c[0] for c in coords), max(c[0] for c in coords), - min(c[1] for c in coords), max(c[1] for c in coords)) - - # Vectorized bbox filtering - poi_count = _count_in_bbox_vec(poi_coords, bbox) - gvi_mean, svf_mean, enc_mean, clu_mean = _spatial_means_in_bbox_vec(sp_coords, sp_cols, bbox) + area_sqm = float(plot.area_ha) * 10000.0 + poi_count = _count_in_geometry_vec(poi_coords, geometry) + gvi_mean, svf_mean, enc_mean, clu_mean = _spatial_means_in_geometry_vec(sp_coords, sp_cols, geometry) s_i = min(1.0, area_sqm / 150000) d_i = min(1.0, poi_count / 20) if poi_count > 0 else 0.3 @@ -120,30 +117,68 @@ def get_plot_diagnostics() -> list: return results -def _count_in_bbox_vec(coords_tuple, bbox): - """Vectorized point-in-bbox count using pre-extracted numpy arrays.""" - if coords_tuple is None: - return 0 - lngs, lats = coords_tuple - mask = (lngs >= bbox[0]) & (lngs <= bbox[1]) & (lats >= bbox[2]) & (lats <= bbox[3]) +def _coordinate_arrays(df): + """Return numeric longitude/latitude arrays, or None when unavailable.""" + if df.empty or "Lng" not in df.columns or "Lat" not in df.columns: + return None + lngs = pd.to_numeric(df["Lng"], errors="coerce").to_numpy(dtype=float) + lats = pd.to_numeric(df["Lat"], errors="coerce").to_numpy(dtype=float) + return lngs, lats + + +def _count_in_geometry_vec(coords_tuple, geometry): + """Count coordinate pairs covered by a polygon geometry.""" + mask = _point_mask_in_geometry(coords_tuple, geometry) return int(np.count_nonzero(mask)) -def _spatial_means_in_bbox_vec(coords_tuple, col_arrays, bbox): - """Vectorized spatial metric means using pre-extracted numpy arrays.""" - if coords_tuple is None: - return 0.0, 0.0, 0.0, 0.0 - lngs, lats = coords_tuple - mask = (lngs >= bbox[0]) & (lngs <= bbox[1]) & (lats >= bbox[2]) & (lats <= bbox[3]) +def _spatial_means_in_geometry_vec(coords_tuple, col_arrays, geometry): + """Mean spatial metrics for coordinate pairs covered by a polygon geometry.""" + mask = _point_mask_in_geometry(coords_tuple, geometry) if not np.any(mask): return 0.0, 0.0, 0.0, 0.0 - gvi = round(float(col_arrays["GVI"][mask].mean()), 2) if "GVI" in col_arrays else 0.0 - svf = round(float(col_arrays["SVF"][mask].mean()), 2) if "SVF" in col_arrays else 0.0 - enc = round(float(col_arrays["Enclosure"][mask].mean()), 2) if "Enclosure" in col_arrays else 0.0 - clu = round(float(col_arrays["Clutter"][mask].mean()), 2) if "Clutter" in col_arrays else 0.0 + gvi = _masked_mean(col_arrays.get("GVI"), mask) + svf = _masked_mean(col_arrays.get("SVF"), mask) + enc = _masked_mean(col_arrays.get("Enclosure"), mask) + clu = _masked_mean(col_arrays.get("Clutter"), mask) return gvi, svf, enc, clu +def _point_mask_in_geometry(coords_tuple, geometry): + """Return a bool mask for points covered by geometry, with bbox prefiltering.""" + if coords_tuple is None or geometry is None or geometry.is_empty: + return np.array([], dtype=bool) + + from shapely.geometry import Point + + lngs, lats = coords_tuple + if len(lngs) != len(lats): + return np.array([], dtype=bool) + + minx, miny, maxx, maxy = geometry.bounds + finite = np.isfinite(lngs) & np.isfinite(lats) + bbox_mask = finite & (lngs >= minx) & (lngs <= maxx) & (lats >= miny) & (lats <= maxy) + mask = np.zeros(len(lngs), dtype=bool) + for idx in np.nonzero(bbox_mask)[0]: + mask[idx] = bool(geometry.covers(Point(float(lngs[idx]), float(lats[idx])))) + return mask + + +def _masked_mean(values, mask): + """Return a rounded mean for masked numeric values, or 0 for missing data.""" + if values is None: + return 0.0 + values = np.asarray(values, dtype=float) + mask = np.asarray(mask, dtype=bool) + if len(values) != len(mask): + return 0.0 + selected = values[mask] + selected = selected[np.isfinite(selected)] + if selected.size == 0: + return 0.0 + return round(float(selected.mean()), 2) + + # ═══════════════════════════════════════════ # Policy compliance matrix # ═══════════════════════════════════════════ diff --git a/src/engines/spatial_data_injector.py b/src/engines/spatial_data_injector.py index 96ad381..821395d 100644 --- a/src/engines/spatial_data_injector.py +++ b/src/engines/spatial_data_injector.py @@ -162,26 +162,12 @@ def get_gvi_summary() -> str: @st.cache_data(ttl=3600, max_entries=20) def get_key_plots_summary() -> str: - """读取 Key_Plots_District.json,返回地块名称和面积列表。""" - path = resolve_path(str(GIS_FILES["plots"])) - if not path.exists(): - return "重点地块数据暂不可用。" - - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - features = data.get("features", []) - if not features: - return "重点地块数据为空。" - - lines = [f"研究范围共 {len(features)} 个重点更新单元:"] - for feat in features: - props = feat.get("properties", {}) - name = props.get("name", f"地块_{props.get('OBJECTID', '?')}") - area = props.get("Shape_Area", 0) - lines.append(f" - {name}: 面积 {area/10000:.2f} ha") - - return "\n".join(lines) + try: + from src.engines.key_plot_engine import format_key_plot_context, get_configured_key_plots + return format_key_plot_context(get_configured_key_plots()) + except Exception: + logger.warning("Key plot summary unavailable", exc_info=True) + return "重点更新单元数据暂不可用。" # ═══════════════════════════════════════════ diff --git a/src/engines/tests/test_drawing_layout_engine.py b/src/engines/tests/test_drawing_layout_engine.py new file mode 100644 index 0000000..74b3e22 --- /dev/null +++ b/src/engines/tests/test_drawing_layout_engine.py @@ -0,0 +1,287 @@ +from PIL import Image, ImageChops, ImageDraw +import pytest + +import src.engines.drawing_layout_engine as layout_engine +from src.engines.drawing_layout_engine import ( + A3_LANDSCAPE_SIZE, + LayoutProfile, + compose_layout_sheet, + get_layout_profile, + layout_prompt_clause, + list_layout_profiles, + recommend_layout_profile, +) + + +EXPECTED_LAYOUT_IDS = { + "map_legend_right", + "dual_compare", + "analysis_dashboard", + "matrix_storyboard", + "full_bleed_effect", + "chapter_cover", +} + + +def _sample_image(seed: int) -> Image.Image: + width = 640 + seed * 31 + height = 420 + seed * 17 + color = ( + (70 + seed * 37) % 255, + (120 + seed * 53) % 255, + (180 + seed * 29) % 255, + ) + return Image.new("RGB", (width, height), color) + + +def _assert_not_blank(image: Image.Image) -> None: + baseline = Image.new("RGB", image.size, image.getpixel((0, 0))) + diff = ImageChops.difference(image, baseline) + assert diff.getbbox() is not None + + +def _assert_color_close(pixel, expected, tolerance: int = 4) -> None: + assert all(abs(pixel[index] - expected[index]) <= tolerance for index in range(3)) + + +def _spy_drawn_text(monkeypatch): + drawn_texts: list[str] = [] + original_draw_text = layout_engine._draw_text + + def spy(draw, xy, text, size, fill): + drawn_texts.append(str(text)) + return original_draw_text(draw, xy, text, size, fill) + + monkeypatch.setattr(layout_engine, "_draw_text", spy) + return drawn_texts + + +def test_a3_landscape_size_matches_print_target(): + assert A3_LANDSCAPE_SIZE == (4961, 3508) + + +def test_registry_contains_expected_unique_layout_profiles(): + profiles = list_layout_profiles() + layout_ids = [profile.layout_id for profile in profiles] + + assert EXPECTED_LAYOUT_IDS.issubset(layout_ids) + assert len(layout_ids) == len(set(layout_ids)) + assert len(layout_ids) >= 6 + assert all(isinstance(profile, LayoutProfile) for profile in profiles) + assert all(isinstance(profile.slots, tuple) for profile in profiles) + assert all(isinstance(profile.prompt_rules, tuple) for profile in profiles) + + +def test_layout_slot_boxes_are_valid_and_inside_a3_canvas(): + canvas_width, canvas_height = A3_LANDSCAPE_SIZE + + for profile in list_layout_profiles(): + assert profile.slots + for slot in profile.slots: + left, top, right, bottom = slot.box + + assert 0 <= left < right <= canvas_width + assert 0 <= top < bottom <= canvas_height + assert slot.slot_id + assert slot.label + assert slot.purpose + + +def test_get_layout_profile_rejects_unknown_id(): + with pytest.raises(ValueError, match="unknown_layout"): + get_layout_profile("unknown_layout") + + +@pytest.mark.parametrize( + ("drawing_name", "expected_layout_id"), + [ + ("地块改造前后对比图", "dual_compare"), + ("AIGC鸟瞰效果图", "full_bleed_effect"), + ("人视运营场景效果图", "full_bleed_effect"), + ("用地现状活力热力评价图", "analysis_dashboard"), + ("更新策略目标体系流程技术推演图", "matrix_storyboard"), + ("项目封面与目录背景图", "chapter_cover"), + ("道路交通系统规划图", "map_legend_right"), + ], +) +def test_recommend_layout_profile_matches_drawing_semantics(drawing_name, expected_layout_id): + profile = recommend_layout_profile(drawing_name) + + assert profile.layout_id == expected_layout_id + + +def test_layout_prompt_clause_includes_slots_rules_and_text_safety_rule(): + profile = get_layout_profile("analysis_dashboard") + + clause = layout_prompt_clause(profile) + + assert profile.layout_id in clause + assert "不得让文字压住主图" in clause + for slot in profile.slots: + assert slot.label in clause + assert slot.purpose in clause + for rule in profile.prompt_rules: + assert rule in clause + + +@pytest.mark.parametrize("layout_id", sorted(EXPECTED_LAYOUT_IDS)) +def test_compose_layout_sheet_outputs_non_blank_rgb_a3_sheet_for_every_profile(layout_id): + profile = get_layout_profile(layout_id) + images = { + slot.slot_id: _sample_image(index + 1) + for index, slot in enumerate(profile.slots) + } + + sheet = compose_layout_sheet( + layout_id, + images, + title="重点地段更新图纸", + chapter="06 重点地段更新改造设计", + legend_items=[("历史建筑", "#f2c94c"), ("慢行系统", "#2d9cdb")], + notes=["指标占位:建筑高度、开发强度", "结论占位:公共空间连续性提升"], + ) + + assert sheet.size == A3_LANDSCAPE_SIZE + assert sheet.mode == "RGB" + _assert_not_blank(sheet) + + +def test_slot_image_is_composited_into_its_declared_slot(): + red = (225, 32, 32) + sheet = compose_layout_sheet( + "map_legend_right", + {"main_map": Image.new("RGB", (1200, 900), red)}, + title="主图落槽测试", + ) + + main_map = next(slot for slot in get_layout_profile("map_legend_right").slots if slot.slot_id == "main_map") + center = ((main_map.box[0] + main_map.box[2]) // 2, (main_map.box[1] + main_map.box[3]) // 2) + _assert_color_close(sheet.getpixel(center), red) + + +def test_legend_items_use_label_color_order(monkeypatch): + drawn_texts = _spy_drawn_text(monkeypatch) + legend_color = (242, 201, 76) + + sheet = compose_layout_sheet( + "map_legend_right", + {"main_map": Image.new("RGB", (1200, 900), (230, 230, 230))}, + title="图例顺序测试", + legend_items=[("历史建筑", "#f2c94c")], + ) + + legend_slot = next( + slot for slot in get_layout_profile("map_legend_right").slots if slot.slot_id == "legend_panel" + ) + left, top, _, _ = legend_slot.box + swatch_center = (left + 42 + 21, top + 42 + 78 + 25) + + _assert_color_close(sheet.getpixel(swatch_center), legend_color) + assert "历史建筑" in drawn_texts + assert "#f2c94c" not in drawn_texts + + +@pytest.mark.parametrize( + ("layout_id", "slot_id"), + [ + ("full_bleed_effect", "hero_visual"), + ("chapter_cover", "cover_visual"), + ], +) +def test_full_bleed_primary_visual_uses_cover_crop_for_extreme_aspect_ratio(layout_id, slot_id): + visual_color = (16, 125, 201) + sheet = compose_layout_sheet( + layout_id, + {slot_id: Image.new("RGB", (5000, 200), visual_color)}, + title="", + ) + width, height = A3_LANDSCAPE_SIZE + + sample_points = [ + (30, 30), + (width - 31, 30), + (30, height - 31), + (width - 31, height - 31), + (width // 2, 30), + (width // 2, height - 31), + ] + + for point in sample_points: + _assert_color_close(sheet.getpixel(point), visual_color, tolerance=3) + + +def test_rgba_slot_image_alpha_composites_transparent_pixels_over_slot_fill(): + transparent_source = Image.new("RGBA", (800, 600), (0, 0, 0, 0)) + red = (230, 20, 30) + ImageDraw.Draw(transparent_source).rectangle((330, 250, 470, 350), fill=red + (255,)) + + sheet = compose_layout_sheet( + "map_legend_right", + {"main_map": transparent_source}, + title="", + ) + + slot = next(slot for slot in get_layout_profile("map_legend_right").slots if slot.slot_id == "main_map") + inner_left, inner_top = slot.box[0] + 18, slot.box[1] + 18 + transparent_probe = (inner_left + 140, inner_top + 140) + red_probe = ((slot.box[0] + slot.box[2]) // 2, (slot.box[1] + slot.box[3]) // 2) + + _assert_color_close(sheet.getpixel(transparent_probe), (247, 244, 238), tolerance=8) + _assert_color_close(sheet.getpixel(red_probe), red, tolerance=10) + + +@pytest.mark.parametrize( + ("layout_id", "primary_slot_id"), + [ + ("map_legend_right", "main_map"), + ("analysis_dashboard", "analysis_map"), + ("matrix_storyboard", "step_1"), + ("full_bleed_effect", "hero_visual"), + ("chapter_cover", "cover_visual"), + ], +) +def test_main_image_alias_populates_primary_visual_slot(layout_id, primary_slot_id): + main_color = (203, 40, 90) + sheet = compose_layout_sheet( + layout_id, + {"main": Image.new("RGB", (1200, 900), main_color)}, + title="", + ) + + slot = next(slot for slot in get_layout_profile(layout_id).slots if slot.slot_id == primary_slot_id) + center = ((slot.box[0] + slot.box[2]) // 2, (slot.box[1] + slot.box[3]) // 2) + + _assert_color_close(sheet.getpixel(center), main_color, tolerance=4) + + +def test_draw_text_replaces_unsupported_unicode_instead_of_dropping_it(): + class UnicodeRejectingDraw: + def __init__(self): + self.calls: list[str] = [] + + def text(self, xy, text, font, fill): + rendered = str(text) + self.calls.append(rendered) + if any(ord(character) > 127 for character in rendered): + raise UnicodeEncodeError("latin-1", rendered, 0, len(rendered), "unsupported") + + draw = UnicodeRejectingDraw() + + layout_engine._draw_text(draw, (0, 0), "\u4e2d\u6587", 24, fill="#000000") + + assert draw.calls[0] == "\u4e2d\u6587" + assert draw.calls[-1] + + +def test_empty_notes_render_placeholder_text(monkeypatch): + drawn_texts = _spy_drawn_text(monkeypatch) + + sheet = compose_layout_sheet( + "map_legend_right", + {"main_map": _sample_image(3)}, + title="空 notes 占位测试", + notes=[], + ) + + assert sheet.size == A3_LANDSCAPE_SIZE + assert any("占位" in text for text in drawn_texts) diff --git a/tests/test_drawing_pipeline.py b/src/engines/tests/test_drawing_pipeline.py similarity index 99% rename from tests/test_drawing_pipeline.py rename to src/engines/tests/test_drawing_pipeline.py index 51c6abe..7f6ce25 100644 --- a/tests/test_drawing_pipeline.py +++ b/src/engines/tests/test_drawing_pipeline.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) diff --git a/src/engines/tests/test_drawing_prompt_engine.py b/src/engines/tests/test_drawing_prompt_engine.py new file mode 100644 index 0000000..c55ba23 --- /dev/null +++ b/src/engines/tests/test_drawing_prompt_engine.py @@ -0,0 +1,205 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +from src.engines.drawing_prompt_engine import ( + BOOK_CHAPTERS, + ImagePromptRequest, + build_image_prompt, + flatten_chapter_drawings, + get_book_chapters, + get_drawing_profile, + revise_prompt_by_rating, +) +from src.engines.key_plot_engine import KeyPlot + + +def _base_request(**overrides): + data = { + "chapter": "04 策略生成篇", + "drawing_name": "总体策略图", + "drawing_type": "策略生成类", + "aspect_ratio": "A3横版", + "output_scene": "A3横版图册", + "uploaded_channels": [], + "main_expression": "表达问题-策略-空间响应关系", + "legend_content": "问题类型、策略类型、空间响应类型", + "evidence_blocks": {"阶段四博弈共识": "保护历史风貌,同时补足公共空间。"}, + } + data.update(overrides) + return ImagePromptRequest(**data) + + +def _plot(index: int) -> KeyPlot: + return KeyPlot(index=index, plot_id=str(index), name=f"测试地块{index}") + + +def test_book_chapters_expand_dynamic_key_plots(): + plots = [_plot(1), _plot(2), _plot(3)] + + chapters = get_book_chapters(key_plots=plots) + detail = chapters.get("06 重点地段更新改造设计", []) + + assert len(detail) == 27 + assert detail[0] == "地块1现状问题图" + assert detail[-1] == "地块3运营场景图" + assert "地块4现状问题图" not in detail + + +def test_flatten_chapter_drawings_accepts_dynamic_key_plots(): + names = flatten_chapter_drawings(key_plots=[_plot(1)]) + + assert "地块1街道断面图" in names + assert "地块2街道断面图" not in names + + +def test_legacy_book_chapters_snapshot_stays_static_while_flatten_is_dynamic(): + static_detail = BOOK_CHAPTERS["06 重点地段更新改造设计"] + dynamic_names = flatten_chapter_drawings(key_plots=[_plot(1)]) + + assert len(static_detail) == 45 + assert "地块5街道断面图" in static_detail + assert "地块2街道断面图" not in dynamic_names + + +def test_arbitrary_plot_index_profile_is_level_one(): + profile = get_drawing_profile("地块12平面深化图") + + assert profile.precision == "一级精度" + assert "红线边界图" in profile.required_uploads + + +def test_static_strategy_chapter_uses_dynamic_key_plot_label(): + assert "重点地块定位图" in BOOK_CHAPTERS["04 策略生成篇"] + assert "5个重点地块定位图" not in BOOK_CHAPTERS["04 策略生成篇"] + + +def test_legacy_key_plot_locator_name_matches_current_profile(): + legacy = get_drawing_profile("5个重点地块定位图") + current = get_drawing_profile("重点地块定位图") + + assert legacy.precision == current.precision + assert legacy.drawing_type == current.drawing_type + assert legacy.required_uploads == current.required_uploads + + +def test_module_import_does_not_load_key_plot_engine_at_top_level(): + code = ( + "import sys; " + "import src.engines.drawing_prompt_engine; " + "print('src.engines.key_plot_engine' in sys.modules)" + ) + + completed = subprocess.run( + [sys.executable, "-c", code], + cwd=Path(__file__).resolve().parents[3], + capture_output=True, + text=True, + check=True, + ) + + assert completed.stdout.strip() == "False" + + +def test_level_one_research_scope_requires_redline(): + request = _base_request( + chapter="01 项目认知篇", + drawing_name="研究范围图", + uploaded_channels=["卫星底图"], + main_expression="表达研究范围和四至边界", + legend_content="研究范围边界、主要道路、核心地标", + ) + + result = build_image_prompt(request) + + assert result.can_generate + assert "红线边界图" in result.missing_items + + +def test_level_two_without_data_generates_template_only_prompt(): + request = _base_request( + chapter="03 价值评估篇", + drawing_name="遗产价值评估热力图", + uploaded_channels=[], + main_expression="表达遗产价值评价等级和保护优先级", + legend_content="高价值、中价值、低价值、保护冲突点", + ) + + result = build_image_prompt(request) + + assert result.can_generate + assert result.template_only + assert "视觉表达模板提示词" in result.prompt + assert "不要虚构热力数据" in result.negative_prompt + + +def test_level_one_complete_prompt_includes_reference_constraints(): + request = _base_request( + chapter="05 整体概念设计和更新", + drawing_name="道路交通系统规划图", + uploaded_channels=["卫星底图", "红线边界图", "道路矢量图", "图例参考图", "固定图框模板"], + main_expression="表达道路等级、交通组织和慢行衔接", + legend_content="主干路、次干路、支路、慢行路径、换乘节点", + ) + + result = build_image_prompt(request) + + assert result.can_generate + assert "不得改变研究范围边界" in result.prompt + assert "请严格保持上传道路矢量图" in result.prompt + assert "请严格套用上传的固定图框" in result.prompt + assert "不要改变道路结构" in result.negative_prompt + + +def test_plot_detail_drawing_is_level_one(): + profile = get_drawing_profile("地块3平面深化图") + + assert profile.precision == "一级精度" + assert "红线边界图" in profile.required_uploads + + +def test_rating_revision_strengthens_boundary_and_text_rules(): + prompt = "生成一张总体策略图。" + + revised = revise_prompt_by_rating(prompt, "B级:需要轻微后期修改", ["边界不准", "文字乱码"]) + + assert "必须严格保持上传红线图边界" in revised + assert "只生成一级标题和少量关键词" in revised + + +def test_prompt_includes_recommended_layout_clause(): + request = _base_request( + drawing_name="地块2改造前后对比图", + drawing_type="重点地块深化类", + main_expression="对比更新前后的街区空间品质和功能提升", + layout_structure="保留改造前后对比的补充说明", + ) + + result = build_image_prompt(request) + + assert result.can_generate + assert "Layout profile: dual_compare" in result.prompt + assert "保留改造前后对比的补充说明" in result.prompt + assert "Times New Roman" in result.prompt + + +def test_explicit_layout_profile_id_overrides_recommendation(): + request = _base_request( + drawing_name="地块2改造前后对比图", + drawing_type="重点地块深化类", + layout_profile_id="analysis_dashboard", + ) + + result = build_image_prompt(request) + + assert "Layout profile: analysis_dashboard" in result.prompt + assert "Layout profile: dual_compare" not in result.prompt + + +def test_unknown_layout_profile_id_raises(): + request = _base_request(layout_profile_id="unknown_layout_profile") + + with pytest.raises(ValueError, match="unknown_layout_profile"): + build_image_prompt(request) diff --git a/src/engines/tests/test_drawing_prompt_templates.py b/src/engines/tests/test_drawing_prompt_templates.py new file mode 100644 index 0000000..221f82f --- /dev/null +++ b/src/engines/tests/test_drawing_prompt_templates.py @@ -0,0 +1,170 @@ +from src.engines import drawing_prompt_templates as templates +from src.engines.key_plot_engine import KeyPlot + + +def test_build_drawing_prompt_passes_recommended_layout_profile(monkeypatch): + from src.engines.drawing_layout_engine import get_layout_profile + + _stub_dynamic_description(monkeypatch) + monkeypatch.setattr(templates, "load_template_asset_manifest", _complete_manifest) + monkeypatch.setattr( + templates, + "recommend_layout_profile", + lambda _name: get_layout_profile("analysis_dashboard"), + ) + + prompt, _ = templates.build_drawing_prompt("地块7街道断面图") + + assert "Layout profile: analysis_dashboard" in prompt + + +def _stub_dynamic_description(monkeypatch): + from src.engines import design_description_engine + + monkeypatch.setattr( + design_description_engine, + "generate_dynamic_design_description", + lambda _name, _stage: ("测试制图策略", "测试分析结论"), + ) + + +def _manifest_for(asset_ids): + asset_to_channel = { + "fixed_base_map": "卫星底图", + "research_scope": "红线边界图", + "key_plots": "红线边界图", + "fixed_frame": "固定图框模板", + "road_network": "道路矢量图", + "legend_reference": "图例参考图", + "gis_theme": "GIS专题图", + "building_texture": "建筑肌理图", + } + return { + "version": 1, + "assets": { + asset_id: { + "asset_id": asset_id, + "label": asset_id, + "prompt_channel": asset_to_channel[asset_id], + "original_name": f"{asset_id}.png", + "filename": f"{asset_id}.png", + "size_bytes": 12, + } + for asset_id in asset_ids + }, + } + + +def _complete_manifest(): + return _manifest_for( + [ + "fixed_base_map", + "research_scope", + "key_plots", + "fixed_frame", + "road_network", + "legend_reference", + "gis_theme", + "building_texture", + ] + ) + + +def test_build_drawing_prompt_blocks_level_one_when_assets_missing(monkeypatch): + monkeypatch.setattr(templates, "load_template_asset_manifest", lambda: {"version": 1, "assets": {}}) + + prompt, system_prompt = templates.build_drawing_prompt("道路交通系统规划图") + + pass + + +def test_build_drawing_prompt_uses_locked_asset_compiler(monkeypatch): + monkeypatch.setattr( + templates, + "load_template_asset_manifest", + lambda: _manifest_for( + [ + "fixed_base_map", + "research_scope", + "key_plots", + "fixed_frame", + "road_network", + "legend_reference", + ] + ), + ) + + prompt, _ = templates.build_drawing_prompt("道路交通系统规划图") + + assert "完整提示词" in prompt + assert "请严格套用上传的固定图框" in prompt + assert "只生成规划分析覆盖层" in prompt + assert "最终合成顺序" in prompt + assert "请为以下城市设计项目生成" not in prompt + + +def test_dynamic_plot_template_is_generated(monkeypatch): + _stub_dynamic_description(monkeypatch) + monkeypatch.setattr(templates, "load_template_asset_manifest", _complete_manifest) + + prompt, system_prompt = templates.build_drawing_prompt("地块7街道断面图") + + assert prompt + assert "完整提示词" in prompt + assert "地块7街道断面图" in prompt + assert system_prompt + + +def test_dynamic_plot_inference_prefers_plot_detail_over_status_keywords(): + drawing_name = "地块6现状问题图" + + assert templates._infer_chapter_from_name(drawing_name) == "06 重点地段更新改造设计" + assert templates._infer_stage_from_name(drawing_name) == "10" + + +def test_dynamic_plot_inference_runs_before_legacy_book_chapters(monkeypatch): + drawing_name = "地块6现状问题图" + monkeypatch.setattr(templates, "BOOK_CHAPTERS", {"02 数据诊断篇": [drawing_name]}) + + assert templates._infer_chapter_from_name(drawing_name) == "06 重点地段更新改造设计" + assert templates._infer_stage_from_name(drawing_name) == "10" + + +def test_dynamic_plot_inference_requires_canonical_plot_detail_name(): + drawing_name = "用地现状图地块6" + + assert templates._infer_chapter_from_name(drawing_name) == "02 数据诊断篇" + assert templates._infer_stage_from_name(drawing_name) == "01" + + +def test_build_drawing_prompt_uses_dynamic_key_plot_context(monkeypatch): + _stub_dynamic_description(monkeypatch) + monkeypatch.setattr(templates, "load_template_asset_manifest", _complete_manifest) + monkeypatch.setattr( + templates, + "get_configured_key_plots", + lambda: [ + KeyPlot(index=1, plot_id="1", name="站前门户单元"), + KeyPlot(index=7, plot_id="7", name="滨水修补单元", role="滨水公共空间修补"), + ], + ) + + prompt, _ = templates.build_drawing_prompt("道路交通系统规划图") + + assert "共 2 个重点更新单元" in prompt + assert "地块7:滨水修补单元" in prompt + assert "五个重点地块" not in prompt + + +def test_key_plot_prompt_context_does_not_suppress_loader_errors(monkeypatch): + def raise_corrupt_config(): + raise RuntimeError("corrupted key plot config") + + monkeypatch.setattr(templates, "get_configured_key_plots", raise_corrupt_config) + + try: + templates._get_key_plot_prompt_context() + except RuntimeError as exc: + assert "corrupted key plot config" in str(exc) + else: + raise AssertionError("Expected key plot loader errors to propagate") diff --git a/tests/test_engine_registry.py b/src/engines/tests/test_engine_registry.py similarity index 98% rename from tests/test_engine_registry.py rename to src/engines/tests/test_engine_registry.py index e979d42..4dc35ee 100644 --- a/tests/test_engine_registry.py +++ b/src/engines/tests/test_engine_registry.py @@ -2,7 +2,7 @@ from pathlib import Path # Add project root to sys.path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) diff --git a/src/engines/tests/test_frame_generator.py b/src/engines/tests/test_frame_generator.py new file mode 100644 index 0000000..838273b --- /dev/null +++ b/src/engines/tests/test_frame_generator.py @@ -0,0 +1,107 @@ +from pathlib import Path +from types import SimpleNamespace + +from PIL import Image + +from src.engines.drawing_layout_engine import A3_LANDSCAPE_SIZE +from src.engines.frame_generator import compose_framed_sheet, sheet_to_bytes + + +def _sample_image(color: str = "#94a3b8") -> Image.Image: + return Image.new("RGB", (640, 360), color) + + +def test_compose_framed_sheet_legacy_call_returns_a3_landscape_size(): + sheet = compose_framed_sheet( + main_image=_sample_image(), + title="用地现状分析图", + chapter="02 数据诊断篇", + summary="保留旧调用方式的说明文本。", + legend_items=[("居住用地", "#facc15")], + drawing_number="DR-011", + scale_text="1:5000", + ) + + assert sheet.size == A3_LANDSCAPE_SIZE + assert sheet.mode == "RGB" + assert sheet_to_bytes(sheet).startswith(b"\x89PNG") + + +def test_compose_framed_sheet_dual_compare_accepts_secondary_images_and_legend_items(): + sheet = compose_framed_sheet( + main_image=_sample_image("#d1d5db"), + title="地块2改造前后对比图", + layout_id="dual_compare", + secondary_images={ + "before_view": _sample_image("#64748b"), + "after_view": _sample_image("#86efac"), + }, + legend_items=[("保留", "#3b82f6"), ("新增", "#22c55e")], + ) + + assert sheet.size == A3_LANDSCAPE_SIZE + assert sheet.mode == "RGB" + + +def test_results_page_wires_selected_layout_id_to_atlas_generation(): + page_source = ( + Path(__file__).resolve().parents[3] / "src" / "stages" / "stage13_outputs" / "page.py" + ).read_text(encoding="utf-8") + + assert "list_layout_profiles" in page_source + assert "recommend_layout_profile" in page_source + assert "图纸版式" in page_source + assert "selected_layout_id" in page_source + assert "layout_id=selected_layout_id" in page_source + + +def test_process_a3_layout_passes_legacy_metadata_to_selected_layout(tmp_path, monkeypatch): + from src.engines import frame_generator + from tools import draw_scope_map + + map_path = tmp_path / "map.png" + output_path = tmp_path / "sheet.png" + Image.new("RGB", (40, 20), "#f8fafc").save(map_path) + + drawing_module = SimpleNamespace( + legend_items=[ + ("Boundary", "rect_red_border"), + ("Blue link", "line_blue"), + ("Fallback", "style_not_mapped"), + ] + ) + default_module = SimpleNamespace(legend_items=[("Default", "rect_water")]) + + def fake_get_drawing_module(drawing_type): + if drawing_type == "custom metadata drawing": + return drawing_module + return default_module + + captured = {} + + def fake_compose_framed_sheet(**kwargs): + captured.update(kwargs) + return Image.new("RGB", A3_LANDSCAPE_SIZE, "#ffffff") + + monkeypatch.setattr(draw_scope_map, "get_drawing_module", fake_get_drawing_module) + monkeypatch.setattr(frame_generator, "compose_framed_sheet", fake_compose_framed_sheet) + + draw_scope_map.process_a3_layout( + map_path, + output_path, + view_w=959.04, + drawing_type="custom metadata drawing", + title="Custom Metadata", + description_lines=["diagnostic note"], + drawing_number="DR-TST", + layout_id="analysis_dashboard", + ) + + assert captured["layout_id"] == "analysis_dashboard" + assert captured["legend_items"] == [ + ("Boundary", "#ff3b30"), + ("Blue link", "#3b82f6"), + ("Fallback", "#607d8b"), + ] + assert captured["scale_text"] == "1:3000" + assert output_path.exists() diff --git a/tests/test_guideline_prompt.py b/src/engines/tests/test_guideline_prompt.py similarity index 97% rename from tests/test_guideline_prompt.py rename to src/engines/tests/test_guideline_prompt.py index 95d32dc..1dfc1e5 100644 --- a/tests/test_guideline_prompt.py +++ b/src/engines/tests/test_guideline_prompt.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) diff --git a/src/engines/tests/test_key_plot_engine.py b/src/engines/tests/test_key_plot_engine.py new file mode 100644 index 0000000..c9e2a01 --- /dev/null +++ b/src/engines/tests/test_key_plot_engine.py @@ -0,0 +1,421 @@ +import json +from importlib import import_module +from pathlib import Path +from typing import get_type_hints + +import pytest + + +def _engine(): + try: + return import_module("src.engines.key_plot_engine") + except ModuleNotFoundError as exc: + pytest.fail(f"key plot engine module is missing: {exc}") + + +def _write_three_plot_geojson(path): + features = [ + { + "type": "Feature", + "id": "plot-a", + "properties": {"name": "门户更新单元", "role": "门户展示"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.3400, 43.9000], + [125.3410, 43.9000], + [125.3410, 43.9010], + [125.3400, 43.9010], + [125.3400, 43.9000], + ] + ], + }, + }, + { + "type": "Feature", + "properties": {"OBJECTID": 102, "Name": "滨水活力单元", "type": "产业服务"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.3420, 43.9000], + [125.3432, 43.9000], + [125.3432, 43.9012], + [125.3420, 43.9012], + [125.3420, 43.9000], + ] + ], + }, + }, + { + "type": "Feature", + "properties": {"plot_name": "街区织补单元", "category": "社区生活"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.3440, 43.9000], + [125.3454, 43.9000], + [125.3454, 43.9014], + [125.3440, 43.9014], + [125.3440, 43.9000], + ] + ], + }, + }, + ] + path.write_text( + json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False), + encoding="utf-8", + ) + + +def _valid_polygon_feature(name="valid plot"): + return { + "type": "Feature", + "properties": {"name": name}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.3400, 43.9000], + [125.3410, 43.9000], + [125.3410, 43.9010], + [125.3400, 43.9010], + [125.3400, 43.9000], + ] + ], + }, + } + + +def _write_feature_collection(path, features): + path.write_text( + json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False), + encoding="utf-8", + ) + + +def test_suffixes_are_canonical_and_ordered(): + engine = _engine() + + assert engine.KEY_PLOT_DRAWING_SUFFIXES == [ + "现状问题图", + "更新定位图", + "平面深化图", + "AIGC推演效果图", + "人视效果图", + "建筑更新图", + "街道断面图", + "改造前后对比图", + "运营场景图", + ] + + +def test_load_key_plots_from_geojson_returns_ordered_plot_metadata(tmp_path): + engine = _engine() + geojson_path = tmp_path / "plots.geojson" + _write_three_plot_geojson(geojson_path) + + plots = engine.load_key_plots_from_geojson(geojson_path) + + assert [plot.index for plot in plots] == [1, 2, 3] + assert [plot.name for plot in plots] == ["门户更新单元", "滨水活力单元", "街区织补单元"] + assert [plot.role for plot in plots] == ["门户展示", "产业服务", "社区生活"] + assert all(plot.area_ha > 0 for plot in plots) + assert all(plot.centroid is not None for plot in plots) + + +def test_load_key_plot_geometries_from_geojson_returns_paired_metadata_and_wgs84_geometry(tmp_path): + engine = _engine() + from shapely.geometry import Point + + geojson_path = tmp_path / "paired-plots.geojson" + features = [ + { + "type": "Feature", + "properties": {"id": "west", "name": "West Plot"}, + "geometry": { + "type": "Polygon", + "coordinates": [[[0.0, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01], [0.0, 0.0]]], + }, + }, + { + "type": "Feature", + "properties": {"id": "east", "name": "East Plot"}, + "geometry": { + "type": "Polygon", + "coordinates": [[[0.02, 0.0], [0.03, 0.0], [0.03, 0.01], [0.02, 0.01], [0.02, 0.0]]], + }, + }, + ] + _write_feature_collection(geojson_path, features) + + paired = engine.load_key_plot_geometries_from_geojson(geojson_path) + + assert [item.plot.plot_id for item in paired] == ["west", "east"] + assert [item.plot.name for item in paired] == ["West Plot", "East Plot"] + assert paired[0].geometry.covers(Point(0.005, 0.005)) + assert not paired[0].geometry.covers(Point(0.025, 0.005)) + assert paired[1].geometry.covers(Point(0.025, 0.005)) + + +def test_normalize_key_plot_name_replaces_numbered_plot_prefixes(): + engine = _engine() + + assert engine.normalize_key_plot_name("地块7街道断面图") == "重点地块街道断面图" + assert engine.normalize_key_plot_name("地块12AIGC推演效果图") == "重点地块AIGC推演效果图" + + +def test_build_key_plot_drawing_names_expands_each_plot_to_nine_names(tmp_path): + engine = _engine() + geojson_path = tmp_path / "plots.geojson" + _write_three_plot_geojson(geojson_path) + plots = engine.load_key_plots_from_geojson(geojson_path) + + names = engine.build_key_plot_drawing_names(plots) + + assert len(names) == 27 + assert "地块1现状问题图" in names + assert "地块3运营场景图" in names + assert "地块4现状问题图" not in names + + +def test_format_key_plot_context_includes_count_names_roles_and_area(): + engine = _engine() + plots = [ + engine.KeyPlot(index=1, plot_id="plot-a", name="门户更新单元", role="门户展示", area_ha=2.5), + engine.KeyPlot(index=2, plot_id="plot-b", name="滨水活力单元", role="产业服务", area_ha=3.25), + ] + + context = engine.format_key_plot_context(plots) + + assert "共 2 个重点更新单元" in context + assert "地块1:门户更新单元" in context + assert "门户展示" in context + assert "2.50 ha" in context + assert "地块2:滨水活力单元" in context + + +def test_format_key_plot_context_explains_missing_plot_boundaries(): + engine = _engine() + + context = engine.format_key_plot_context([]) + + assert "重点更新单元尚未配置" in context + assert "上传" in context + assert "地块边界" in context + + +def test_load_key_plots_from_geojson_missing_file_returns_empty_list(tmp_path): + engine = _engine() + + assert engine.load_key_plots_from_geojson(tmp_path / "missing.geojson") == [] + + +def test_load_key_plots_from_geojson_uses_index_property_for_plot_id(tmp_path): + engine = _engine() + geojson_path = tmp_path / "plot-with-index.geojson" + feature = { + "type": "Feature", + "properties": {"index": "plot-index-value", "name": "索引地块"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.3400, 43.9000], + [125.3410, 43.9000], + [125.3410, 43.9010], + [125.3400, 43.9010], + [125.3400, 43.9000], + ] + ], + }, + } + geojson_path.write_text( + json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False), + encoding="utf-8", + ) + + loaded = engine.load_key_plots_from_geojson(geojson_path) + + assert loaded[0].plot_id == "plot-index-value" + + +def test_load_key_plots_from_geojson_corrupt_file_raises_typed_error(tmp_path): + engine = _engine() + corrupt_path = tmp_path / "corrupt.geojson" + corrupt_path.write_text("{not valid geojson", encoding="utf-8") + + assert hasattr(engine, "KeyPlotLoadError") + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(corrupt_path) + + +def test_load_key_plots_from_geojson_object_without_feature_collection_raises_typed_error(tmp_path): + engine = _engine() + malformed_path = tmp_path / "object.geojson" + malformed_path.write_text("{}", encoding="utf-8") + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(malformed_path) + + +def test_load_key_plots_from_geojson_feature_collection_without_features_raises_typed_error(tmp_path): + engine = _engine() + malformed_path = tmp_path / "missing-features.geojson" + malformed_path.write_text(json.dumps({"type": "FeatureCollection"}), encoding="utf-8") + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(malformed_path) + + +def test_load_key_plots_from_geojson_feature_collection_with_non_list_features_raises_typed_error(tmp_path): + engine = _engine() + malformed_path = tmp_path / "non-list-features.geojson" + malformed_path.write_text( + json.dumps({"type": "FeatureCollection", "features": {}}), + encoding="utf-8", + ) + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(malformed_path) + + +def test_load_key_plots_from_geojson_empty_feature_collection_returns_empty_list(tmp_path): + engine = _engine() + empty_path = tmp_path / "empty.geojson" + empty_path.write_text( + json.dumps({"type": "FeatureCollection", "features": []}), + encoding="utf-8", + ) + + assert engine.load_key_plots_from_geojson(empty_path) == [] + + +def test_load_key_plots_from_geojson_feature_missing_geometry_raises_typed_error(tmp_path): + engine = _engine() + missing_geometry_path = tmp_path / "missing-geometry.geojson" + feature = {"type": "Feature", "properties": {"name": "missing geometry"}} + _write_feature_collection(missing_geometry_path, [feature]) + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(missing_geometry_path) + + +def test_load_key_plots_from_geojson_feature_null_geometry_raises_typed_error(tmp_path): + engine = _engine() + null_geometry_path = tmp_path / "null-geometry.geojson" + feature = {"type": "Feature", "properties": {"name": "null geometry"}, "geometry": None} + _write_feature_collection(null_geometry_path, [feature]) + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(null_geometry_path) + + +def test_load_key_plots_from_geojson_feature_empty_polygon_raises_typed_error(tmp_path): + engine = _engine() + empty_polygon_path = tmp_path / "empty-polygon.geojson" + feature = { + "type": "Feature", + "properties": {"name": "empty polygon"}, + "geometry": {"type": "Polygon", "coordinates": []}, + } + _write_feature_collection(empty_polygon_path, [feature]) + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(empty_polygon_path) + + +def test_load_key_plots_from_geojson_mixed_valid_and_invalid_geometry_raises_typed_error(tmp_path): + engine = _engine() + mixed_path = tmp_path / "mixed-invalid.geojson" + features = [ + _valid_polygon_feature("valid plot"), + {"type": "Feature", "properties": {"name": "missing geometry"}}, + {"type": "Feature", "properties": {"name": "null geometry"}, "geometry": None}, + { + "type": "Feature", + "properties": {"name": "empty polygon"}, + "geometry": {"type": "Polygon", "coordinates": []}, + }, + ] + _write_feature_collection(mixed_path, features) + + with pytest.raises(engine.KeyPlotLoadError): + engine.load_key_plots_from_geojson(mixed_path) + + +def test_load_key_plots_from_geojson_uses_geodesic_area_fallback_when_utm_unavailable(tmp_path, monkeypatch): + engine = _engine() + geojson_path = tmp_path / "plots.geojson" + _write_three_plot_geojson(geojson_path) + + import geopandas as gpd + + original_to_crs = gpd.GeoDataFrame.to_crs + + def no_estimated_utm(self, *args, **kwargs): + return None + + def reject_web_mercator(self, crs=None, epsg=None, *args, **kwargs): + target = f"EPSG:{epsg}" if epsg is not None else str(crs) + if target.upper() == "EPSG:3857": + raise AssertionError("EPSG:3857 must not be used for area fallback") + return original_to_crs(self, crs=crs, epsg=epsg, *args, **kwargs) + + monkeypatch.setattr(gpd.GeoDataFrame, "estimate_utm_crs", no_estimated_utm) + monkeypatch.setattr(gpd.GeoDataFrame, "to_crs", reject_web_mercator) + + loaded = engine.load_key_plots_from_geojson(geojson_path) + + assert loaded + assert all(plot.area_ha > 0 for plot in loaded) + + +def test_load_key_plots_from_geojson_geodesic_area_fallback_matches_known_small_polygon(tmp_path, monkeypatch): + engine = _engine() + geojson_path = tmp_path / "equator.geojson" + feature = { + "type": "Feature", + "properties": {"name": "equator plot"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0.0, 0.0], + [0.01, 0.0], + [0.01, 0.01], + [0.0, 0.01], + [0.0, 0.0], + ] + ], + }, + } + _write_feature_collection(geojson_path, [feature]) + + import geopandas as gpd + + monkeypatch.setattr(gpd.GeoDataFrame, "estimate_utm_crs", lambda self, *args, **kwargs: None) + + loaded = engine.load_key_plots_from_geojson(geojson_path) + + assert 120 <= loaded[0].area_ha <= 126 + + +def test_load_key_plots_from_geojson_accepts_str_or_path_annotation(): + engine = _engine() + + hints = get_type_hints(engine.load_key_plots_from_geojson) + + assert hints["path"] == str | Path + + +def test_plot_names_returns_loaded_names(tmp_path): + engine = _engine() + geojson_path = tmp_path / "plots.geojson" + _write_three_plot_geojson(geojson_path) + plots = engine.load_key_plots_from_geojson(geojson_path) + + assert engine.plot_names(plots) == ["门户更新单元", "滨水活力单元", "街区织补单元"] diff --git a/tests/test_llm_engine.py b/src/engines/tests/test_llm_engine.py similarity index 100% rename from tests/test_llm_engine.py rename to src/engines/tests/test_llm_engine.py diff --git a/tests/test_local_draft_loader.py b/src/engines/tests/test_local_draft_loader.py similarity index 100% rename from tests/test_local_draft_loader.py rename to src/engines/tests/test_local_draft_loader.py diff --git a/tests/test_nlp_engine.py b/src/engines/tests/test_nlp_engine.py similarity index 100% rename from tests/test_nlp_engine.py rename to src/engines/tests/test_nlp_engine.py diff --git a/src/engines/tests/test_process_key_plots.py b/src/engines/tests/test_process_key_plots.py new file mode 100644 index 0000000..faec8eb --- /dev/null +++ b/src/engines/tests/test_process_key_plots.py @@ -0,0 +1,239 @@ +import json +from importlib import import_module + +import geopandas as gpd +import pytest +from shapely.geometry import box + +from scripts.process_key_plots import main, process_key_plots +from src.data.data_categories import DATA_CATEGORIES +from src.engines.key_plot_engine import load_key_plots_from_geojson + + +def _write_gdf(path, geometries, records=None, crs="EPSG:4326"): + records = records or [{} for _ in geometries] + gdf = gpd.GeoDataFrame(records, geometry=geometries, crs=crs) + gdf.to_file(path, driver="GeoJSON") + return gdf + + +def _read_geojson(path): + with path.open("r", encoding="utf-8") as file: + return json.load(file) + + +def _key_plots_category(): + return next(category for category in DATA_CATEGORIES if category["id"] == "key_plots") + + +def _category_tutorial_text(category): + tutorial = category["tutorial"] + methods = tutorial["methods"] + pieces = [category["description"], category["format_desc"], tutorial["summary"], tutorial["sample_fields"]] + for method in methods: + pieces.extend([method["name"], method.get("code_example", ""), method.get("tip", "")]) + pieces.extend(method["steps"]) + return "\n".join(pieces) + + +def test_process_key_plots_clips_indexes_and_writes_geojson(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "nested" / "Key_Plots_District.json" + + _write_gdf( + input_path, + [box(0.0, 0.0, 0.01, 0.01), box(0.02, 0.0, 0.03, 0.01)], + [{"name": "门户更新单元", "role": "门户展示"}, {"role": "社区生活"}], + ) + _write_gdf(boundary_path, [box(-0.01, -0.01, 0.04, 0.02)]) + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 2 + data = _read_geojson(output_path) + assert data["type"] == "FeatureCollection" + assert len(data["features"]) == 2 + assert [feature["properties"]["plot_index"] for feature in data["features"]] == [1, 2] + assert [feature["properties"]["name"] for feature in data["features"]] == ["门户更新单元", "地块2"] + assert data["features"][0]["properties"]["role"] == "门户展示" + assert all(feature["properties"]["area_ha"] > 0 for feature in data["features"]) + + loaded = load_key_plots_from_geojson(output_path) + assert [plot.index for plot in loaded] == [1, 2] + assert [plot.name for plot in loaded] == ["门户更新单元", "地块2"] + assert all(plot.area_ha > 0 for plot in loaded) + + +def test_process_key_plots_preserves_source_fields_and_adds_compatibility_fields(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + _write_gdf( + input_path, + [box(0.0, 0.0, 0.01, 0.01)], + [{"name": "compat plot", "role": "legacy", "source_code": "SRC-001"}], + ) + _write_gdf(boundary_path, [box(-0.01, -0.01, 0.02, 0.02)]) + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 1 + properties = _read_geojson(output_path)["features"][0]["properties"] + assert properties["source_code"] == "SRC-001" + assert properties["plot_index"] == 1 + assert properties["id"] == 1 + assert properties["OBJECTID"] == 1 + assert properties["Shape_Area"] > 0 + assert properties["Shape_Area"] == pytest.approx(properties["area_ha"] * 10000) + + +def test_process_key_plots_clips_partial_polygon_to_boundary(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + source_gdf = _write_gdf(input_path, [box(0.0, 0.0, 2.0, 2.0)], [{"name": "partial"}]) + _write_gdf(boundary_path, [box(0.5, 0.5, 1.5, 1.5)]) + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 1 + output_gdf = gpd.read_file(output_path) + assert tuple(round(value, 6) for value in output_gdf.total_bounds) == (0.5, 0.5, 1.5, 1.5) + assert output_gdf.geometry.iloc[0].area < source_gdf.geometry.iloc[0].area + + +def test_process_key_plots_writes_empty_geojson_when_boundary_is_disjoint(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + _write_gdf(input_path, [box(0.0, 0.0, 1.0, 1.0)], [{"name": "outside"}]) + _write_gdf(boundary_path, [box(10.0, 10.0, 11.0, 11.0)]) + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 0 + assert output_path.exists() + assert _read_geojson(output_path)["features"] == [] + + +def test_process_key_plots_writes_empty_geojson_when_boundary_has_no_features(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "empty_boundary.geojson" + output_path = tmp_path / "out.geojson" + + _write_gdf(input_path, [box(0.0, 0.0, 1.0, 1.0)], [{"name": "unclipped"}]) + boundary_path.write_text('{"type": "FeatureCollection", "features": []}', encoding="utf-8") + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 0 + assert output_path.exists() + assert _read_geojson(output_path)["features"] == [] + + +def test_process_key_plots_reprojects_boundary_to_plot_crs(tmp_path): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + plot_wgs84 = gpd.GeoDataFrame( + [{"name": "projected clip"}], + geometry=[box(0.0, 0.0, 0.02, 0.02)], + crs="EPSG:4326", + ) + plot_projected = plot_wgs84.to_crs("EPSG:3857") + plot_projected.to_file(input_path, driver="GeoJSON") + _write_gdf(boundary_path, [box(0.005, 0.005, 0.015, 0.015)], crs="EPSG:4326") + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 1 + output_gdf = gpd.read_file(output_path) + assert output_gdf.crs.to_epsg() == 4326 + minx, miny, maxx, maxy = output_gdf.total_bounds + assert 0.0049 <= minx <= 0.0051 + assert 0.0049 <= miny <= 0.0051 + assert 0.0149 <= maxx <= 0.0151 + assert 0.0149 <= maxy <= 0.0151 + + +def test_process_key_plots_uses_geodesic_area_fallback_without_web_mercator(tmp_path, monkeypatch): + module = import_module("scripts.process_key_plots") + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + _write_gdf(input_path, [box(0.0, 0.0, 0.01, 0.01)], [{"name": "geodesic fallback"}]) + _write_gdf(boundary_path, [box(-0.01, -0.01, 0.02, 0.02)]) + monkeypatch.setattr(module, "_estimated_utm_crs", lambda _: None) + + original_to_crs = gpd.GeoDataFrame.to_crs + + def reject_web_mercator(self, crs=None, epsg=None, *args, **kwargs): + target = f"EPSG:{epsg}" if epsg is not None else str(crs) + if target.upper() == "EPSG:3857": + raise AssertionError("EPSG:3857 must not be used for area fallback") + return original_to_crs(self, crs=crs, epsg=epsg, *args, **kwargs) + + monkeypatch.setattr(gpd.GeoDataFrame, "to_crs", reject_web_mercator) + + count = process_key_plots(input_path, boundary_path, output_path) + + assert count == 1 + assert _read_geojson(output_path)["features"][0]["properties"]["area_ha"] > 0 + + +def test_write_geojson_preserves_existing_output_when_non_empty_write_fails(tmp_path, monkeypatch): + module = import_module("scripts.process_key_plots") + output_path = tmp_path / "out.geojson" + old_content = '{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"old": true}}]}' + output_path.write_text(old_content, encoding="utf-8") + gdf = gpd.GeoDataFrame([{"name": "new"}], geometry=[box(0.0, 0.0, 0.01, 0.01)], crs="EPSG:4326") + + def fail_to_file(self, *args, **kwargs): + raise RuntimeError("simulated write failure") + + monkeypatch.setattr(gpd.GeoDataFrame, "to_file", fail_to_file) + + with pytest.raises(RuntimeError, match="simulated write failure"): + module._write_geojson(gdf, output_path) + + assert output_path.read_text(encoding="utf-8") == old_content + + +def test_process_key_plots_main_prints_processed_message(tmp_path, capsys): + input_path = tmp_path / "raw_plots.geojson" + boundary_path = tmp_path / "boundary.geojson" + output_path = tmp_path / "out.geojson" + + _write_gdf(input_path, [box(0.0, 0.0, 0.01, 0.01)], [{"name": "cli plot"}]) + _write_gdf(boundary_path, [box(-0.01, -0.01, 0.02, 0.02)]) + + exit_code = main(["--input", str(input_path), "--boundary", str(boundary_path), "--output", str(output_path)]) + + assert exit_code == 0 + assert output_path.exists() + assert "Processed 1 key plot feature" in capsys.readouterr().out + + +def test_key_plots_category_describes_dynamic_processing_script(): + category = _key_plots_category() + category_text = _category_tutorial_text(category) + + assert "5 个 Polygon" not in category_text + assert "5 个重点" not in category_text + assert "scripts/process_key_plots.py" in category_text + assert "GeoJSON FeatureCollection (N 个 Polygon / MultiPolygon 要素)" in category["format_desc"] + assert "plot_index" in category["tutorial"]["sample_fields"] + assert "name" in category["tutorial"]["sample_fields"] + assert "role" in category["tutorial"]["sample_fields"] + + +def test_process_key_plots_module_exports_required_function(): + module = import_module("scripts.process_key_plots") + + assert module.process_key_plots is process_key_plots diff --git a/src/engines/tests/test_prompt_decoupling.py b/src/engines/tests/test_prompt_decoupling.py new file mode 100644 index 0000000..963b25e --- /dev/null +++ b/src/engines/tests/test_prompt_decoupling.py @@ -0,0 +1,311 @@ +import logging +import re +import sys +import yaml +import pytest +from pathlib import Path +from types import SimpleNamespace + +root = Path(__file__).resolve().parents[3] +if str(root) not in sys.path: + sys.path.append(str(root)) + +# Mock streamlit before imports +sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) + +from src.config.runtime import resolve_path +from src.config.loader import load_global_config + + +FIXED_KEY_PLOT_COUNT_PATTERNS = ( + re.compile(r"(? 0 for plot in diagnostics] == [True, True] + + +def test_plot_diagnostics_filters_points_by_polygon_not_bbox(tmp_path, monkeypatch): + engine = _engine() + plots_path = tmp_path / "l-shape.geojson" + _write_feature_collection( + plots_path, + [ + _polygon_feature( + "L形更新单元", + [ + [0.0, 0.0], + [0.02, 0.0], + [0.02, 0.01], + [0.01, 0.01], + [0.01, 0.02], + [0.0, 0.02], + [0.0, 0.0], + ], + plot_id="l-shape", + ) + ], + ) + poi = pd.DataFrame( + [ + {"Name": "inside", "Lng": 0.005, "Lat": 0.005}, + {"Name": "bbox-only", "Lng": 0.015, "Lat": 0.015}, + ] + ) + spatial = pd.DataFrame( + [ + {"Lng": 0.005, "Lat": 0.005, "GVI": 10, "SVF": 20, "Enclosure": 30, "Clutter": 40}, + {"Lng": 0.015, "Lat": 0.015, "GVI": 90, "SVF": 80, "Enclosure": 70, "Clutter": 60}, + ] + ) + _patch_diagnostic_sources(monkeypatch, engine, plots_path, poi=poi, spatial=spatial) + + diagnostics = engine.get_plot_diagnostics() + + assert diagnostics[0]["poi_count"] == 1 + assert diagnostics[0]["gvi_mean"] == 10 + assert diagnostics[0]["svf_mean"] == 20 + assert diagnostics[0]["enclosure_mean"] == 30 + assert diagnostics[0]["clutter_mean"] == 40 + + +def test_plot_diagnostics_missing_plots_path_returns_empty_list(tmp_path, monkeypatch): + engine = _engine() + _patch_diagnostic_sources(monkeypatch, engine, tmp_path / "missing.geojson") + + assert engine.get_plot_diagnostics() == [] + + +def test_plot_diagnostics_uses_paired_plot_geometry_loader(tmp_path, monkeypatch): + engine = _engine() + from shapely.geometry import box + from src.engines import spatial_engine + from src.engines.key_plot_engine import KeyPlot, KeyPlotGeometry + + plots_path = tmp_path / "plots.geojson" + plots_path.write_text("{}", encoding="utf-8") + monkeypatch.setitem(engine.SHP_FILES, "plots", plots_path) + monkeypatch.setattr( + engine, + "load_key_plot_geometries_from_geojson", + lambda _: [KeyPlotGeometry(KeyPlot(index=1, plot_id="first", name="first", area_ha=1.0), box(0, 0, 1, 1))], + raising=False, + ) + monkeypatch.setattr( + engine, + "load_key_plots_from_geojson", + lambda _: (_ for _ in ()).throw(AssertionError("old metadata loader called")), + raising=False, + ) + monkeypatch.setattr( + engine, + "_load_plot_geometries_wgs84", + lambda _: (_ for _ in ()).throw(AssertionError("old geometry loader called")), + raising=False, + ) + monkeypatch.setattr(spatial_engine, "get_merged_poi_data", lambda: pd.DataFrame()) + monkeypatch.setattr(engine, "_load_spatial_merge", lambda: pd.DataFrame()) + monkeypatch.setattr(engine, "_load_nlp_data", lambda: pd.DataFrame()) + + diagnostics = engine.get_plot_diagnostics() + + assert [plot["name"] for plot in diagnostics] == ["first"] + + +def test_plot_diagnostics_logs_poi_source_errors(tmp_path, monkeypatch, caplog): + engine = _engine() + from src.engines import spatial_engine + + plots_path = tmp_path / "plots.geojson" + _write_feature_collection( + plots_path, + [ + _polygon_feature( + "first", + [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], + plot_id="first", + ) + ], + ) + monkeypatch.setitem(engine.SHP_FILES, "plots", plots_path) + + def raise_poi_error(): + raise RuntimeError("poi offline") + + monkeypatch.setattr(spatial_engine, "get_merged_poi_data", raise_poi_error) + monkeypatch.setattr(engine, "_load_spatial_merge", lambda: pd.DataFrame()) + monkeypatch.setattr(engine, "_load_nlp_data", lambda: pd.DataFrame()) + + with caplog.at_level("WARNING", logger="ultimateDESIGN"): + diagnostics = engine.get_plot_diagnostics() + + assert diagnostics[0]["poi_count"] == 0 + assert "POI data unavailable for plot diagnostics" in caplog.text + + +def test_masked_mean_handles_mask_length_mismatch(): + engine = _engine() + + assert engine._masked_mean(np.array([1.0, 2.0]), np.array([True], dtype=bool)) == 0.0 + + +def test_point_mask_handles_coordinate_length_mismatch(): + engine = _engine() + from shapely.geometry import box + + mask = engine._point_mask_in_geometry((np.array([0.5, 0.75]), np.array([0.5])), box(0, 0, 1, 1)) + + assert mask.dtype == bool + assert not mask.any() diff --git a/tests/test_spatial_engine.py b/src/engines/tests/test_spatial_engine.py similarity index 100% rename from tests/test_spatial_engine.py rename to src/engines/tests/test_spatial_engine.py diff --git a/tests/test_stage04_diagnosis_config.py b/src/engines/tests/test_stage04_diagnosis_config.py similarity index 100% rename from tests/test_stage04_diagnosis_config.py rename to src/engines/tests/test_stage04_diagnosis_config.py diff --git a/tests/test_stage07_strategy_config.py b/src/engines/tests/test_stage07_strategy_config.py similarity index 100% rename from tests/test_stage07_strategy_config.py rename to src/engines/tests/test_stage07_strategy_config.py diff --git a/tests/test_stage08_master_plan_config.py b/src/engines/tests/test_stage08_master_plan_config.py similarity index 100% rename from tests/test_stage08_master_plan_config.py rename to src/engines/tests/test_stage08_master_plan_config.py diff --git a/tests/test_stage09_systems_config.py b/src/engines/tests/test_stage09_systems_config.py similarity index 100% rename from tests/test_stage09_systems_config.py rename to src/engines/tests/test_stage09_systems_config.py diff --git a/tests/test_stage10_detail_design_config.py b/src/engines/tests/test_stage10_detail_design_config.py similarity index 100% rename from tests/test_stage10_detail_design_config.py rename to src/engines/tests/test_stage10_detail_design_config.py diff --git a/tests/test_stage11_implementation_config.py b/src/engines/tests/test_stage11_implementation_config.py similarity index 100% rename from tests/test_stage11_implementation_config.py rename to src/engines/tests/test_stage11_implementation_config.py diff --git a/tests/test_stage12_guideline_config.py b/src/engines/tests/test_stage12_guideline_config.py similarity index 100% rename from tests/test_stage12_guideline_config.py rename to src/engines/tests/test_stage12_guideline_config.py diff --git a/tests/test_stage13_outputs_config.py b/src/engines/tests/test_stage13_outputs_config.py similarity index 100% rename from tests/test_stage13_outputs_config.py rename to src/engines/tests/test_stage13_outputs_config.py diff --git a/src/engines/tests/test_thesis_pipeline.py b/src/engines/tests/test_thesis_pipeline.py new file mode 100644 index 0000000..f814659 --- /dev/null +++ b/src/engines/tests/test_thesis_pipeline.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import MagicMock +from src.engines.key_plot_engine import KeyPlot +from src.engines.thesis_pipeline import _get_plot_names, run_full_pipeline + + +def test_get_plot_names_uses_key_plot_engine(monkeypatch): + mock_plots = [ + KeyPlot(index=1, plot_id="1", name="地块甲", role="", area_ha=1.0, centroid=(0, 0)), + KeyPlot(index=2, plot_id="2", name="地块乙", role="", area_ha=1.0, centroid=(0, 0)), + ] + monkeypatch.setattr( + "src.engines.key_plot_engine.get_configured_key_plots", + lambda: mock_plots + ) + + names = _get_plot_names() + assert names == ["地块甲", "地块乙"] + + +def test_get_plot_names_fallback_on_exception(monkeypatch): + def raise_err(): + raise RuntimeError("GIS file error") + + monkeypatch.setattr( + "src.engines.key_plot_engine.get_configured_key_plots", + raise_err + ) + + names = _get_plot_names() + # Check that fallback names are returned + assert len(names) == 5 + assert "老水产批发市场" in names + + +@pytest.mark.parametrize("num_plots", [1, 3, 6, 8]) +def test_run_full_pipeline_dynamic_steps(monkeypatch, num_plots): + # Mock configured key plots with varying count + mock_plots = [ + KeyPlot(index=i, plot_id=str(i), name=f"地块-{i}", role="", area_ha=1.0, centroid=(0,0)) + for i in range(1, num_plots + 1) + ] + monkeypatch.setattr( + "src.engines.key_plot_engine.get_configured_key_plots", + lambda: mock_plots + ) + + # Mock all internal generator functions of thesis_pipeline to avoid actual run/LLM calls + pipeline_module = "src.engines.thesis_pipeline" + mocked_gens = [ + "_gen_diagnosis_report", "_gen_mpi_ranking", "_gen_case_benchmark", + "_gen_design_concept", "_gen_strategy_matrix", "_gen_design_brief", + "_gen_spatial_structure", "_gen_landuse_sandbox", "_gen_traffic_system", + "_gen_public_space", "_gen_building_form", "_gen_landscape_style", + "_gen_plot_metrics", "_gen_plot_personas", "_gen_plot_design", + "_gen_region_phasing", "_gen_design_guideline", "_gen_supplementary" + ] + + for gen in mocked_gens: + monkeypatch.setattr(f"{pipeline_module}.{gen}", MagicMock()) + + monkeypatch.setattr(f"{pipeline_module}.build_thesis_context", MagicMock()) + monkeypatch.setattr(f"{pipeline_module}.generate_single_section", MagicMock(return_value="mock section content")) + + mock_buf = MagicMock() + mock_buf.getvalue.return_value = b"docx content" + monkeypatch.setattr(f"{pipeline_module}.assemble_thesis_docx", lambda **kwargs: mock_buf) + + progress_calls = [] + def progress_callback(step, total, msg): + progress_calls.append((step, total, msg)) + + log_calls = [] + def log_callback(msg): + log_calls.append(msg) + + run_full_pipeline(progress_callback=progress_callback, log_callback=log_callback, enable_deai=False, model="mock-model") + + # Verify that the reported total steps matches the calculation + # total_steps calculation: + # 2 (diagnosis + mpi) + 2 (case + concept) + 2 (strategy + brief) + 2 (structure + sand) + + # 4 (traffic + public + form + landscape) + num_plots * 3 + 1 (phasing) + 1 (guideline) + + # 8 (extra) + 27 (chapters) + 0 (deai) + 1 (docx) = 50 + num_plots * 3 + expected_total_steps = 50 + num_plots * 3 + + assert len(progress_calls) > 0 + # Every callback should report the same total_steps + assert all(call[1] == expected_total_steps for call in progress_calls) + # The last callback should have step == expected_total_steps + assert progress_calls[-1][0] == expected_total_steps diff --git a/tests/test_version_store.py b/src/engines/tests/test_version_store.py similarity index 98% rename from tests/test_version_store.py rename to src/engines/tests/test_version_store.py index 83b21bd..cb6ad77 100644 --- a/tests/test_version_store.py +++ b/src/engines/tests/test_version_store.py @@ -2,7 +2,7 @@ from pathlib import Path from unittest.mock import MagicMock -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) diff --git a/src/engines/thesis_pipeline.py b/src/engines/thesis_pipeline.py index 5f195c1..f9d9164 100644 --- a/src/engines/thesis_pipeline.py +++ b/src/engines/thesis_pipeline.py @@ -454,13 +454,11 @@ def _gen_landscape_style(pc, lc, model="deepseek-v4-pro"): def _get_plot_names() -> list: """获取重点地块名称列表""" try: - from src.engines.spatial_data_injector import get_key_plots_summary - summary = get_key_plots_summary() - # 从摘要中提取地块名 - import re - names = re.findall(r'[「【](.+?)[」】]', summary) + from src.engines.key_plot_engine import get_configured_key_plots, plot_names + plots = get_configured_key_plots() + names = plot_names(plots) if names: - return names[:5] + return names except Exception: pass # 默认地块列表 @@ -823,7 +821,7 @@ def lc(msg): # 计算总步数 plot_names = _get_plot_names() - n_plots = min(len(plot_names), 5) + n_plots = len(plot_names) total_steps = ( 2 + # Stage 05: 诊断报告 + MPI 2 + # Stage 06: 案例 + 概念 @@ -869,7 +867,7 @@ def lc(msg): # ── Stage 10 ── lc(f"\n🏗️ Stage 10: 重点地块深化 ({n_plots} 个地块)") - for pname in plot_names[:n_plots]: + for pname in plot_names: lc(f" 地块: {pname}") _gen_plot_metrics(pc, lc, pname, model=model); step += 1; pc(step, total_steps, f"{pname} 指标") _gen_plot_personas(pc, lc, pname, model=model); step += 1; pc(step, total_steps, f"{pname} 人群") diff --git a/src/stages/stage13_outputs/page.py b/src/stages/stage13_outputs/page.py index 7c7192c..a4111c5 100644 --- a/src/stages/stage13_outputs/page.py +++ b/src/stages/stage13_outputs/page.py @@ -15,6 +15,7 @@ from src.stages.common.workspace import render_stage_workspace from src.stages.stage13_outputs.config import STAGE13_WORKSPACE +from src.engines.drawing_layout_engine import list_layout_profiles, recommend_layout_profile PROJECT_ROOT = Path(__file__).resolve().parents[3] @@ -129,6 +130,28 @@ def render_page() -> None: drawing_title = st.text_input("图纸标题", value=default_title) drawing_num = st.text_input("图纸编号", value=default_code) + layout_profiles = list_layout_profiles() + recommended_layout = recommend_layout_profile(drawing_title or drawing_type) + layout_options = [profile.layout_id for profile in layout_profiles] + layout_labels = { + profile.layout_id: f"{profile.name} ({profile.layout_id})" + for profile in layout_profiles + } + default_layout_index = next( + ( + index + for index, profile in enumerate(layout_profiles) + if profile.layout_id == recommended_layout.layout_id + ), + 0, + ) + selected_layout_id = st.selectbox( + "图纸版式", + layout_options, + index=default_layout_index, + format_func=lambda layout_id: layout_labels.get(layout_id, layout_id), + ) + st.markdown("### 2. 设计说明与规划指标") # Default descriptions helper @@ -316,7 +339,8 @@ def render_page() -> None: drawing_number=drawing_num, author=author, author_id=author_id, - organization=organization + organization=organization, + layout_id=selected_layout_id ) # 记录文件以供预览 diff --git a/src/ui/app_shell.py b/src/ui/app_shell.py index 4bbd8f6..0cf34cb 100644 --- a/src/ui/app_shell.py +++ b/src/ui/app_shell.py @@ -233,7 +233,7 @@ def render_auto_tour(): {url:"/专项系统设计", label:"🔧 09 专项系统设计", duration:35, desc:"交通/TOD/生活圈/天际线/风貌景观四大专项"}, {url:"/重点地段深化", label:"🔍 10 重点地段深化", duration:45, - desc:"5个重点地块:诊断雷达、控规反推、人群画像、Before/After推演"}, + desc:"重点更新单元:诊断雷达、控规反推、人群画像、Before/After推演"}, {url:"/实施路径", label:"📅 11 实施路径", duration:25, desc:"六种更新模式、三期时序甘特图、留改拆总图"}, {url:"/城市设计导则", label:"📐 12 城市设计导则", duration:25, diff --git a/tests/test_critical_images.py b/src/ui/tests/test_critical_images.py similarity index 100% rename from tests/test_critical_images.py rename to src/ui/tests/test_critical_images.py diff --git a/tests/test_deployment_assets.py b/src/ui/tests/test_deployment_assets.py similarity index 100% rename from tests/test_deployment_assets.py rename to src/ui/tests/test_deployment_assets.py diff --git a/tests/test_homepage_runtime_cleanup.py b/src/ui/tests/test_homepage_runtime_cleanup.py similarity index 100% rename from tests/test_homepage_runtime_cleanup.py rename to src/ui/tests/test_homepage_runtime_cleanup.py diff --git a/tests/test_mobile_detection.py b/src/ui/tests/test_mobile_detection.py similarity index 100% rename from tests/test_mobile_detection.py rename to src/ui/tests/test_mobile_detection.py diff --git a/tests/test_navigation_routes.py b/src/ui/tests/test_navigation_routes.py similarity index 100% rename from tests/test_navigation_routes.py rename to src/ui/tests/test_navigation_routes.py diff --git a/tests/test_output_flow_panel.py b/src/ui/tests/test_output_flow_panel.py similarity index 100% rename from tests/test_output_flow_panel.py rename to src/ui/tests/test_output_flow_panel.py diff --git a/tests/test_streamlit_compat.py b/src/ui/tests/test_streamlit_compat.py similarity index 100% rename from tests/test_streamlit_compat.py rename to src/ui/tests/test_streamlit_compat.py diff --git a/tests/test_exceptions.py b/src/utils/tests/test_exceptions.py similarity index 100% rename from tests/test_exceptions.py rename to src/utils/tests/test_exceptions.py diff --git a/tests/test_geo_transform.py b/src/utils/tests/test_geo_transform.py similarity index 92% rename from tests/test_geo_transform.py rename to src/utils/tests/test_geo_transform.py index d7665be..dbac005 100644 --- a/tests/test_geo_transform.py +++ b/src/utils/tests/test_geo_transform.py @@ -2,7 +2,7 @@ from pathlib import Path # Add project root to sys.path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) diff --git a/tests/test_preloader_modes.py b/src/utils/tests/test_preloader_modes.py similarity index 100% rename from tests/test_preloader_modes.py rename to src/utils/tests/test_preloader_modes.py diff --git a/tests/test_service_check.py b/src/utils/tests/test_service_check.py similarity index 100% rename from tests/test_service_check.py rename to src/utils/tests/test_service_check.py diff --git a/src/workflow/template_assets.py b/src/workflow/template_assets.py index 7e7be39..9f501e1 100644 --- a/src/workflow/template_assets.py +++ b/src/workflow/template_assets.py @@ -52,7 +52,7 @@ class TemplateAssetSpec: prompt_channel="红线边界图", required=True, accepted_types=("geojson", "json", "svg", "png", "jpg", "jpeg", "pdf", "zip"), - description="五个重点地块或重点更新单元的边界/编号/mask,用于定位深化设计对象。", + description="重点更新单元的边界/编号/mask,用于定位深化设计对象。", generation_rule="重点地块位置、形状、编号和边界必须固定,最终成图阶段应重新叠加清晰边界。", ), TemplateAssetSpec( diff --git a/tests/test_approval_state.py b/src/workflow/tests/test_approval_state.py similarity index 100% rename from tests/test_approval_state.py rename to src/workflow/tests/test_approval_state.py diff --git a/tests/test_artifact_registry.py b/src/workflow/tests/test_artifact_registry.py similarity index 100% rename from tests/test_artifact_registry.py rename to src/workflow/tests/test_artifact_registry.py diff --git a/tests/test_stage_data_bus.py b/src/workflow/tests/test_stage_data_bus.py similarity index 98% rename from tests/test_stage_data_bus.py rename to src/workflow/tests/test_stage_data_bus.py index b413834..edb1390 100644 --- a/tests/test_stage_data_bus.py +++ b/src/workflow/tests/test_stage_data_bus.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -root = Path(__file__).resolve().parents[1] +root = Path(__file__).resolve().parents[3] if str(root) not in sys.path: sys.path.append(str(root)) sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) diff --git a/tests/test_stage_workspace.py b/src/workflow/tests/test_stage_workspace.py similarity index 100% rename from tests/test_stage_workspace.py rename to src/workflow/tests/test_stage_workspace.py diff --git a/tests/test_template_assets.py b/src/workflow/tests/test_template_assets.py similarity index 100% rename from tests/test_template_assets.py rename to src/workflow/tests/test_template_assets.py diff --git a/tests/test_drawing_prompt_engine.py b/tests/test_drawing_prompt_engine.py deleted file mode 100644 index 6a1e504..0000000 --- a/tests/test_drawing_prompt_engine.py +++ /dev/null @@ -1,88 +0,0 @@ -from src.engines.drawing_prompt_engine import ( - ImagePromptRequest, - build_image_prompt, - get_drawing_profile, - revise_prompt_by_rating, -) - - -def _base_request(**overrides): - data = { - "chapter": "04 策略生成篇", - "drawing_name": "总体策略图", - "drawing_type": "策略生成类", - "aspect_ratio": "A3横版", - "output_scene": "A3横版图册", - "uploaded_channels": [], - "main_expression": "表达问题-策略-空间响应关系", - "legend_content": "问题类型、策略类型、空间响应类型", - "evidence_blocks": {"阶段四博弈共识": "保护历史风貌,同时补足公共空间。"}, - } - data.update(overrides) - return ImagePromptRequest(**data) - - -def test_level_one_research_scope_requires_redline(): - request = _base_request( - chapter="01 项目认知篇", - drawing_name="研究范围图", - uploaded_channels=["卫星底图"], - main_expression="表达研究范围和四至边界", - legend_content="研究范围边界、主要道路、核心地标", - ) - - result = build_image_prompt(request) - - assert result.can_generate - assert "红线边界图" in result.missing_items - - -def test_level_two_without_data_generates_template_only_prompt(): - request = _base_request( - chapter="03 价值评估篇", - drawing_name="遗产价值评估热力图", - uploaded_channels=[], - main_expression="表达遗产价值评价等级和保护优先级", - legend_content="高价值、中价值、低价值、保护冲突点", - ) - - result = build_image_prompt(request) - - assert result.can_generate - assert result.template_only - assert "视觉表达模板提示词" in result.prompt - assert "不要虚构热力数据" in result.negative_prompt - - -def test_level_one_complete_prompt_includes_reference_constraints(): - request = _base_request( - chapter="05 整体概念设计和更新", - drawing_name="道路交通系统规划图", - uploaded_channels=["卫星底图", "红线边界图", "道路矢量图", "图例参考图", "固定图框模板"], - main_expression="表达道路等级、交通组织和慢行衔接", - legend_content="主干路、次干路、支路、慢行路径、换乘节点", - ) - - result = build_image_prompt(request) - - assert result.can_generate - assert "不得改变研究范围边界" in result.prompt - assert "请严格保持上传道路矢量图" in result.prompt - assert "请严格套用上传的固定图框" in result.prompt - assert "不要改变道路结构" in result.negative_prompt - - -def test_plot_detail_drawing_is_level_one(): - profile = get_drawing_profile("地块3平面深化图") - - assert profile.precision == "一级精度" - assert "红线边界图" in profile.required_uploads - - -def test_rating_revision_strengthens_boundary_and_text_rules(): - prompt = "生成一张总体策略图。" - - revised = revise_prompt_by_rating(prompt, "B级:需要轻微后期修改", ["边界不准", "文字乱码"]) - - assert "必须严格保持上传红线图边界" in revised - assert "只生成一级标题和少量关键词" in revised diff --git a/tests/test_drawing_prompt_templates.py b/tests/test_drawing_prompt_templates.py deleted file mode 100644 index e316664..0000000 --- a/tests/test_drawing_prompt_templates.py +++ /dev/null @@ -1,61 +0,0 @@ -from src.engines import drawing_prompt_templates as templates - - -def _manifest_for(asset_ids): - asset_to_channel = { - "fixed_base_map": "卫星底图", - "research_scope": "红线边界图", - "key_plots": "红线边界图", - "fixed_frame": "固定图框模板", - "road_network": "道路矢量图", - "legend_reference": "图例参考图", - "gis_theme": "GIS专题图", - "building_texture": "建筑肌理图", - } - return { - "version": 1, - "assets": { - asset_id: { - "asset_id": asset_id, - "label": asset_id, - "prompt_channel": asset_to_channel[asset_id], - "original_name": f"{asset_id}.png", - "filename": f"{asset_id}.png", - "size_bytes": 12, - } - for asset_id in asset_ids - }, - } - - -def test_build_drawing_prompt_blocks_level_one_when_assets_missing(monkeypatch): - monkeypatch.setattr(templates, "load_template_asset_manifest", lambda: {"version": 1, "assets": {}}) - - prompt, system_prompt = templates.build_drawing_prompt("道路交通系统规划图") - - pass - - -def test_build_drawing_prompt_uses_locked_asset_compiler(monkeypatch): - monkeypatch.setattr( - templates, - "load_template_asset_manifest", - lambda: _manifest_for( - [ - "fixed_base_map", - "research_scope", - "key_plots", - "fixed_frame", - "road_network", - "legend_reference", - ] - ), - ) - - prompt, _ = templates.build_drawing_prompt("道路交通系统规划图") - - assert "完整提示词" in prompt - assert "请严格套用上传的固定图框" in prompt - assert "只生成规划分析覆盖层" in prompt - assert "最终合成顺序" in prompt - assert "请为以下城市设计项目生成" not in prompt diff --git a/tests/test_prompt_decoupling.py b/tests/test_prompt_decoupling.py deleted file mode 100644 index 233fe4d..0000000 --- a/tests/test_prompt_decoupling.py +++ /dev/null @@ -1,76 +0,0 @@ -import sys -import yaml -import pytest -from pathlib import Path - -root = Path(__file__).resolve().parents[1] -if str(root) not in sys.path: - sys.path.append(str(root)) - -# Mock streamlit before imports -sys.modules.setdefault("streamlit", type(sys)("streamlit_mock")) - -from src.config.runtime import resolve_path -from src.config.loader import load_global_config - - -@pytest.fixture -def mock_config(): - config_path = resolve_path("config/config.yaml") - assert config_path.exists() - - # Backup original config - with open(config_path, "r", encoding="utf-8") as f: - orig_content = f.read() - orig_data = yaml.safe_load(orig_content) - - # Write mock site configuration using correct config.yaml keys - mock_data = orig_data.copy() - mock_data["site"] = { - "city_name": "\u6d4b\u8bd5\u5e02", # 测试市 - "district_name": "\u6d4b\u8bd5\u533a", # 测试区 - "site_name": "\u6d4b\u8bd5\u5730\u5757", # 测试地块 - "description": "\u6d4b\u8bd5\u5730\u5757\u7ea699\u516c\u9877", # 测试地块约99公顷 - "adjacent_features": "\u6d4b\u8bd5\u5468\u8fb9", # 测试周边 - "policies": ["\u6d4b\u8bd5\u653f\u7b56\u4e00", "\u6d4b\u8bd5\u653f\u7b56\u4e8c"] # 测试政策一, 测试政策二 - } - - with open(config_path, "w", encoding="utf-8") as f: - yaml.safe_dump(mock_data, f, allow_unicode=True) - - # Clear cached resource loaders - if hasattr(load_global_config, "clear"): - load_global_config.clear() - - yield mock_data - - # Restore original config - with open(config_path, "w", encoding="utf-8") as f: - f.write(orig_content) - if hasattr(load_global_config, "clear"): - load_global_config.clear() - - -def test_decoupled_guideline_prompt(mock_config): - from src.engines.guideline_prompt import build_guideline_prompt - prompt = build_guideline_prompt() - assert "\u6d4b\u8bd5\u5e02" in prompt - assert "\u6d4b\u8bd5\u533a" in prompt - assert "\u6d4b\u8bd5\u5730\u5757" in prompt - assert "99" in prompt - - -def test_decoupled_drawing_prompt(mock_config): - from src.engines.drawing_prompt_templates import build_drawing_prompt - prompt, sys_prompt = build_drawing_prompt("\u5468\u8fb9\u5173\u7cfb\u56fe") # 周边关系图 - # Even if missing uploaded assets, it should print out template demands containing new site name or city - assert "\u6d4b\u8bd5" in prompt or "\u6d4b\u8bd5" in sys_prompt - - -def test_decoupled_thesis_prompt(mock_config): - from src.engines.thesis_composer import get_thesis_system_prompt - prompt = get_thesis_system_prompt() - assert "\u6d4b\u8bd5\u5e02" in prompt - assert "\u6d4b\u8bd5\u533a" in prompt - assert "\u6d4b\u8bd5\u5730\u5757" in prompt - assert "\u6d4b\u8bd5\u5730\u5757\u7ea699\u516c\u9877" in prompt diff --git a/tools/README.md b/tools/README.md index f87e9fd..3962176 100644 --- a/tools/README.md +++ b/tools/README.md @@ -7,10 +7,10 @@ --- ## 📂 目录结构与子文件夹 -- [**`drawings/`**](file:///e:/AI-based-project/urban-platform/tools/drawings): 包含具体图纸编号(如 `dr_004.py`, `dr_017.py` 等)的程序化绘制与数据映射渲染规则。 -- [**`realesrgan-ncnn-vulkan/`**](file:///e:/AI-based-project/urban-platform/tools/realesrgan-ncnn-vulkan): 基于 Vulkan 接口的 Real-ESRGAN 超分辨率重建本地引擎,用于将低像素图纸超分放大 4 倍。 -- [**`video_generator/`**](file:///e:/AI-based-project/urban-platform/tools/video_generator): 包含生成大屏幕演示与三维场景展示视频的录制/自动渲染工具。 -- [**`archive/`**](file:///e:/AI-based-project/urban-platform/tools/archive): 存放已经废弃或陈旧的临时性、过渡性脚本。 +- [**`drawings/`**](drawings): 包含具体图纸编号(如 `dr_004.py`, `dr_017.py` 等)的程序化绘制与数据映射渲染规则。 +- [**`realesrgan-ncnn-vulkan/`**](realesrgan-ncnn-vulkan): 基于 Vulkan 接口的 Real-ESRGAN 超分辨率重建本地引擎,用于将低像素图纸超分放大 4 倍。 +- [**`video_generator/`**](video_generator): 包含生成大屏幕演示与三维场景展示视频的录制/自动渲染工具。 +- [**`archive/`**](archive): 存放已经废弃或陈旧的临时性、过渡性脚本。 --- @@ -21,16 +21,16 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`process_site_data.py`**](file:///e:/AI-based-project/urban-platform/tools/process_site_data.py) | **一键式核心数据管道**:自动归一化表格列名(中英文大小写映射),按 GeoJSON 范围边界缓冲裁剪要素,利用 Centroid 空间相交(`sjoin`)将建筑轮廓与用地现状关联并绑定 `prop_style` 控制样式(内置 `.buffer(0)` 拓扑修复),自动截断 6 位精度压缩导出并触发质量体检。 | -| [**`data_quality_check.py`**](file:///e:/AI-based-project/urban-platform/tools/data_quality_check.py) | 数据质量自检工具,在数据清洗后运行,对各图层完整性、稳定标识字段、必需属性缺失进行体检,输出诊断报告。 | -| [**`clip_buildings.py`**](file:///e:/AI-based-project/urban-platform/tools/clip_buildings.py) | 依研究范围边界裁剪建筑轮廓矢量 GeoJSON。 | -| [**`crop_scope.py`**](file:///e:/AI-based-project/urban-platform/tools/crop_scope.py) | 自动读取并裁剪研究范围 GeoJSON 的地理边界包络线(bbox)。 | -| [**`fill_missing_landuse.py`**](file:///e:/AI-based-project/urban-platform/tools/fill_missing_landuse.py) | 针对用地现状数据中空白缝隙、未标记字段及重叠部分进行拓扑修复与自动过渡填充。 | -| [**`prepare_landuse.py`**](file:///e:/AI-based-project/urban-platform/tools/prepare_landuse.py) | 规划用地现状的前期清洗与格式规范化配准。 | -| [**`sync_building_landuse.py`**](file:///e:/AI-based-project/urban-platform/tools/sync_building_landuse.py) | 同步校准建筑底面与底层用地利用类型的空间重叠属性。 | -| [**`compress_geojson.py`**](file:///e:/AI-based-project/urban-platform/tools/compress_geojson.py) | 剔除 GeoJSON 中的冗余元数据属性,并将坐标精度截断至小数点后 6 位,极限轻量化以保证 Deck.GL 三维渲染高帧率。 | -| [**`generate_building_shadows.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_building_shadows.py) | 根据建筑层高/绝对高度和设定的太阳入射角,计算并生成现状建筑群的投影阴影多边形。 | -| [**`get_streetview.py`**](file:///e:/AI-based-project/urban-platform/tools/get_streetview.py) | 街景采样点照片抓取脚本,基于道路线点位自动调用接口获取现状街区全景图。 | +| [**`process_site_data.py`**](process_site_data.py) | **一键式核心数据管道**:自动归一化表格列名(中英文大小写映射),按 GeoJSON 范围边界缓冲裁剪要素,利用 Centroid 空间相交(`sjoin`)将建筑轮廓与用地现状关联并绑定 `prop_style` 控制样式(内置 `.buffer(0)` 拓扑修复),自动截断 6 位精度压缩导出并触发质量体检。 | +| [**`data_quality_check.py`**](data_quality_check.py) | 数据质量自检工具,在数据清洗后运行,对各图层完整性、稳定标识字段、必需属性缺失进行体检,输出诊断报告。 | +| [**`clip_buildings.py`**](clip_buildings.py) | 依研究范围边界裁剪建筑轮廓矢量 GeoJSON。 | +| [**`crop_scope.py`**](crop_scope.py) | 自动读取并裁剪研究范围 GeoJSON 的地理边界包络线(bbox)。 | +| [**`fill_missing_landuse.py`**](fill_missing_landuse.py) | 针对用地现状数据中空白缝隙、未标记字段及重叠部分进行拓扑修复与自动过渡填充。 | +| [**`prepare_landuse.py`**](prepare_landuse.py) | 规划用地现状的前期清洗与格式规范化配准。 | +| [**`sync_building_landuse.py`**](sync_building_landuse.py) | 同步校准建筑底面与底层用地利用类型的空间重叠属性。 | +| [**`compress_geojson.py`**](compress_geojson.py) | 剔除 GeoJSON 中的冗余元数据属性,并将坐标精度截断至小数点后 6 位,极限轻量化以保证 Deck.GL 三维渲染高帧率。 | +| [**`generate_building_shadows.py`**](generate_building_shadows.py) | 根据建筑层高/绝对高度和设定的太阳入射角,计算并生成现状建筑群的投影阴影多边形。 | +| [**`get_streetview.py`**](get_streetview.py) | 街景采样点照片抓取脚本,基于道路线点位自动调用接口获取现状街区全景图。 | --- @@ -39,11 +39,11 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`draw_scope_map.py`**](file:///e:/AI-based-project/urban-platform/tools/draw_scope_map.py) | 核心 2D 地图绘图驱动。读取 site 配置,自适应标注关键 Landmark 坐标,并内建多系统(Windows/Linux/macOS)中文字体 Fallback 链保护,彻底防止绘图时出现文字“豆腐块”乱码或崩溃。 | -| [**`add_wind_rose.py`**](file:///e:/AI-based-project/urban-platform/tools/add_wind_rose.py) | 程序化绘制通用的 8 方向精美矢量风玫瑰(指北针),并批量叠加至目标图纸右上角。 | -| [**`add_real_wind_rose.py`**](file:///e:/AI-based-project/urban-platform/tools/add_real_wind_rose.py) | 读取本地实测风玫瑰图片并拼贴至全套图纸的指定地图版面中。 | -| [**`redraw_dr054_atlas_layout.py`**](file:///e:/AI-based-project/urban-platform/tools/redraw_dr054_atlas_layout.py) | 专项重绘排水与竖向网络分析图,修复图层相互遮挡与标签字号重叠。 | -| [**`render_analysis_single_sheets.py`**](file:///e:/AI-based-project/urban-platform/tools/render_analysis_single_sheets.py) | 自动读取渲染成果图,拼装符合图则图签规范的单页 A3 纸张。 | +| [**`draw_scope_map.py`**](draw_scope_map.py) | 核心 2D 地图绘图驱动。读取 site 配置,自适应标注关键 Landmark 坐标,并内建多系统(Windows/Linux/macOS)中文字体 Fallback 链保护,彻底防止绘图时出现文字“豆腐块”乱码或崩溃。 | +| [**`add_wind_rose.py`**](add_wind_rose.py) | 程序化绘制通用的 8 方向精美矢量风玫瑰(指北针),并批量叠加至目标图纸右上角。 | +| [**`add_real_wind_rose.py`**](add_real_wind_rose.py) | 读取本地实测风玫瑰图片并拼贴至全套图纸的指定地图版面中。 | +| [**`redraw_dr054_atlas_layout.py`**](redraw_dr054_atlas_layout.py) | 专项重绘排水与竖向网络分析图,修复图层相互遮挡与标签字号重叠。 | +| [**`render_analysis_single_sheets.py`**](render_analysis_single_sheets.py) | 自动读取渲染成果图,拼装符合图则图签规范的单页 A3 纸张。 | --- @@ -52,13 +52,13 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`generate_all_diagrams.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_all_diagrams.py) | 一键生成并导出全套技术图表(博弈沙盘决策流、RAG 法规审查流、SD 局部推演架构等)。 | -| [**`generate_architecture_diagrams.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_architecture_diagrams.py) | 自动绘制并导出本平台基于多 Agent 协同(协商代理、评估代理、规划代理)的底层运行架构图。 | -| [**`generate_technical_route.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_technical_route.py) | 绘制导出本系统完整的技术实现路线与数据处理链条大图。 | -| [**`generate_technology_parameters_graph.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_technology_parameters_graph.py) | 自动生成包含本系统各功能模块 AI 模型(DeepSeek/ControlNet 等)输入输出参数映射关系的知识图谱。 | -| [**`generate_workflow_flowchart.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_workflow_flowchart.py) | 绘制导出微更新设计全阶段决策工作流图。 | -| [**`draw_libs_chart.py`**](file:///e:/AI-based-project/urban-platform/tools/draw_libs_chart.py) | 绘制并导出系统所用第三方核心技术栈(Matplotlib, Shapely, Streamlit 等)的技术树对比图表。 | -| [**`codegraph_tool.py`**](file:///e:/AI-based-project/urban-platform/tools/codegraph_tool.py) | 项目代码依赖解析树生成工具,自动扫描 Python 代码中的类与函数并输出相互调用关系拓扑。 | +| [**`generate_all_diagrams.py`**](generate_all_diagrams.py) | 一键生成并导出全套技术图表(博弈沙盘决策流、RAG 法规审查流、SD 局部推演架构等)。 | +| [**`generate_architecture_diagrams.py`**](generate_architecture_diagrams.py) | 自动绘制并导出本平台基于多 Agent 协同(协商代理、评估代理、规划代理)的底层运行架构图。 | +| [**`generate_technical_route.py`**](generate_technical_route.py) | 绘制导出本系统完整的技术实现路线与数据处理链条大图。 | +| [**`generate_technology_parameters_graph.py`**](generate_technology_parameters_graph.py) | 自动生成包含本系统各功能模块 AI 模型(DeepSeek/ControlNet 等)输入输出参数映射关系的知识图谱。 | +| [**`generate_workflow_flowchart.py`**](generate_workflow_flowchart.py) | 绘制导出微更新设计全阶段决策工作流图。 | +| [**`draw_libs_chart.py`**](draw_libs_chart.py) | 绘制并导出系统所用第三方核心技术栈(Matplotlib, Shapely, Streamlit 等)的技术树对比图表。 | +| [**`codegraph_tool.py`**](codegraph_tool.py) | 项目代码依赖解析树生成工具,自动扫描 Python 代码中的类与函数并输出相互调用关系拓扑。 | --- @@ -67,15 +67,15 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`generate_indicator_images.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_indicator_images.py) | 读取各重点地段指标控制大表,计算并生成可视化多维分析雷达图与柱状对比图。 | -| [**`generate_design_creed_sheets.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_design_creed_sheets.py) | 自动生成高对比度、排版精美的“设计原则与理念”中英文字排海报。 | -| [**`generate_list_diagrams.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_list_diagrams.py) | 生成一系列带序号标签、渐变底色和 Apple 设计规范的列表说明看板插图。 | -| [**`generate_exhibition_board_tiles.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_exhibition_board_tiles.py) | 自动拼装生成规划答辩墙大板(展板分块拼贴效果图)。 | -| [**`generate_urban_analysis_board.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_urban_analysis_board.py) | 绘制生成区域宏观区位分析大板。 | -| [**`generate_urban_rural_planning.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_urban_rural_planning.py) | 绘制生成中国城乡规划规范合规检测对比表。 | -| [**`generate_unified_landscape.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_unified_landscape.py) | 绘制生成多尺度协同景观设计空间导图。 | -| [**`render_exhibition_board_previews.py`**](file:///e:/AI-based-project/urban-platform/tools/render_exhibition_board_previews.py) | 批量拼装、缩放与输出答辩展板的高清效果预览图。 | -| [**`super_resolve_creeds.py`**](file:///e:/AI-based-project/urban-platform/tools/super_resolve_creeds.py) | 调用本地 Real-ESRGAN 可执行文件对 AI 绘制的低分辨率概念总图进行 4 倍高清重建。 | +| [**`generate_indicator_images.py`**](generate_indicator_images.py) | 读取各重点地段指标控制大表,计算并生成可视化多维分析雷达图与柱状对比图。 | +| [**`generate_design_creed_sheets.py`**](generate_design_creed_sheets.py) | 自动生成高对比度、排版精美的“设计原则与理念”中英文字排海报。 | +| [**`generate_list_diagrams.py`**](generate_list_diagrams.py) | 生成一系列带序号标签、渐变底色和 Apple 设计规范的列表说明看板插图。 | +| [**`generate_exhibition_board_tiles.py`**](generate_exhibition_board_tiles.py) | 自动拼装生成规划答辩墙大板(展板分块拼贴效果图)。 | +| [**`generate_urban_analysis_board.py`**](generate_urban_analysis_board.py) | 绘制生成区域宏观区位分析大板。 | +| [**`generate_urban_rural_planning.py`**](generate_urban_rural_planning.py) | 绘制生成中国城乡规划规范合规检测对比表。 | +| [**`generate_unified_landscape.py`**](generate_unified_landscape.py) | 绘制生成多尺度协同景观设计空间导图。 | +| [**`render_exhibition_board_previews.py`**](render_exhibition_board_previews.py) | 批量拼装、缩放与输出答辩展板的高清效果预览图。 | +| [**`super_resolve_creeds.py`**](super_resolve_creeds.py) | 调用本地 Real-ESRGAN 可执行文件对 AI 绘制的低分辨率概念总图进行 4 倍高清重建。 | --- @@ -84,14 +84,14 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`generate_pptx_slides.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_pptx_slides.py) | **幻灯片自动编译**:读取大屏数据与图表,以统一的主题和转场动画一键生成答辩汇报 `.pptx` 幻灯片文件。 | -| [**`generate_atlas_crops.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_atlas_crops.py) | 生成多比例尺图册的局部细节对比图。 | -| [**`generate_atlas_sheets.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_atlas_sheets.py) | 批量生成 A3 规范成果图册。 | -| [**`generate_atlas_ppt.py`**](file:///e:/AI-based-project/urban-platform/tools/generate_atlas_ppt.py) | 将全套 A3 图册拼贴并生成演示 PPT 脑图结构。 | -| [**`rearrange_tables_landscape.py`**](file:///e:/AI-based-project/urban-platform/tools/rearrange_tables_landscape.py) | 将超宽的规划控制指标大表(Word 导出时)自动调整为竖排自适应换行,防止内容溢出边界。 | -| [**`replace_weekly_figures_v4.py`**](file:///e:/AI-based-project/urban-platform/tools/replace_weekly_figures_v4.py) | 毕设周志插图替换神器,自动通过 Word 接口将图纸按双图并排自适应插入目标位置并清理冗余行。 | -| [**`restore_atlas_images.py`**](file:///e:/AI-based-project/urban-platform/tools/restore_atlas_images.py) | 一键式批量找回丢失或损毁的原始图册缓存图纸并安全防误覆盖。 | -| [**`restore_static_sheets.py`**](file:///e:/AI-based-project/urban-platform/tools/restore_static_sheets.py) | 针对不需程序化生成的总体鸟瞰效果图图纸进行静态恢复替换。 | +| [**`generate_pptx_slides.py`**](generate_pptx_slides.py) | **幻灯片自动编译**:读取大屏数据与图表,以统一的主题和转场动画一键生成答辩汇报 `.pptx` 幻灯片文件。 | +| [**`generate_atlas_crops.py`**](generate_atlas_crops.py) | 生成多比例尺图册的局部细节对比图。 | +| [**`generate_atlas_sheets.py`**](generate_atlas_sheets.py) | 批量生成 A3 规范成果图册。 | +| [**`generate_atlas_ppt.py`**](generate_atlas_ppt.py) | 将全套 A3 图册拼贴并生成演示 PPT 脑图结构。 | +| [**`rearrange_tables_landscape.py`**](rearrange_tables_landscape.py) | 将超宽的规划控制指标大表(Word 导出时)自动调整为竖排自适应换行,防止内容溢出边界。 | +| [**`replace_weekly_figures_v4.py`**](replace_weekly_figures_v4.py) | 毕设周志插图替换神器,自动通过 Word 接口将图纸按双图并排自适应插入目标位置并清理冗余行。 | +| [**`restore_atlas_images.py`**](restore_atlas_images.py) | 一键式批量找回丢失或损毁的原始图册缓存图纸并安全防误覆盖。 | +| [**`restore_static_sheets.py`**](restore_static_sheets.py) | 针对不需程序化生成的总体鸟瞰效果图图纸进行静态恢复替换。 | --- @@ -100,8 +100,8 @@ | 脚本名称 | 功能描述 | | :--- | :--- | -| [**`rebuild_rag.py`**](file:///e:/AI-based-project/urban-platform/tools/rebuild_rag.py) | 政策法规 RAG 数据库一键式重新构建、文本分块以及矢量检索索引的本地更新生成。 | -| [**`enhance_reference_exact_analysis.py`**](file:///e:/AI-based-project/urban-platform/tools/enhance_reference_exact_analysis.py) | 优化本地 RAG 检索的权重和关联召回匹配精度。 | -| [**`check_env.py`**](file:///e:/AI-based-project/urban-platform/tools/check_env.py) | 检测本地的 Python 版本、GDAL/Fiona/GeoPandas 依赖冲突以及硬件环境。 | -| [**`secret_scan.py`**](file:///e:/AI-based-project/urban-platform/tools/secret_scan.py) | 安全防泄漏扫描工具,确保代码和配置文件中没有硬编码泄露 API Key、Token 密码等敏感词。 | -| [**`startup_smoke.py`**](file:///e:/AI-based-project/urban-platform/tools/startup_smoke.py) | Streamlit 启动冒烟测试校验,确保各页面基本导入、服务响应正常。 | +| [**`rebuild_rag.py`**](rebuild_rag.py) | 政策法规 RAG 数据库一键式重新构建、文本分块以及矢量检索索引的本地更新生成。 | +| [**`enhance_reference_exact_analysis.py`**](enhance_reference_exact_analysis.py) | 优化本地 RAG 检索的权重和关联召回匹配精度。 | +| [**`check_env.py`**](check_env.py) | 检测本地的 Python 版本、GDAL/Fiona/GeoPandas 依赖冲突以及硬件环境。 | +| [**`secret_scan.py`**](secret_scan.py) | 安全防泄漏扫描工具,确保代码和配置文件中没有硬编码泄露 API Key、Token 密码等敏感词。 | +| [**`startup_smoke.py`**](startup_smoke.py) | Streamlit 启动冒烟测试校验,确保各页面基本导入、服务响应正常。 | diff --git a/tools/codegraph_tool.py b/tools/codegraph_tool.py index 9c104cf..30c1e89 100644 --- a/tools/codegraph_tool.py +++ b/tools/codegraph_tool.py @@ -5,7 +5,26 @@ import argparse from pathlib import Path -DB_PATH = Path("e:/AI-based-project/urban-platform/.codegraph/codegraph.db") +def resolve_db_path(): + # Resolve the repository root directory dynamically (relative to tools/codegraph_tool.py) + root = Path(__file__).resolve().parents[1] + + # 1. Check local workspace root + db = root / ".codegraph" / "codegraph.db" + if db.exists(): + return db + + # 2. Check parent folder's parent (in case we are in a Git worktree, e.g. .worktrees/) + if len(root.parents) >= 2: + wt_db = root.parents[1] / ".codegraph" / "codegraph.db" + if wt_db.exists(): + return wt_db + + # 3. Fallback to the original hardcoded path + return Path("e:/AI-based-project/urban-platform/.codegraph/codegraph.db") + +DB_PATH = resolve_db_path() + def get_connection(): if not DB_PATH.exists(): @@ -177,9 +196,11 @@ def cmd_visualize(args): cursor = conn.cursor() # Let's find files and see imports or containment relation between key modules. - # Group nodes by top-level files - cursor.execute(""" - SELECT path FROM files WHERE path LIKE 'src/%' OR path LIKE 'tools/%' OR path = 'app.py'; + # Group nodes by top-level files, excluding test files + cursor.execute(r""" + SELECT path FROM files + WHERE (path LIKE 'src/%' OR path LIKE 'tools/%' OR path = 'app.py') + AND path NOT LIKE '%/tests/%' AND path NOT LIKE '%\tests\%'; """) files = [r[0] for r in cursor.fetchall()] @@ -191,9 +212,8 @@ def cmd_visualize(args): print(" classDef tool fill:#fffbeb,stroke:#fbbf24,stroke-width:2px;") print(" classDef main fill:#faf5ff,stroke:#a855f7,stroke-width:2px;") - # Get dependencies between files via edges - # We trace imports or calls across files - cursor.execute(""" + # Get dependencies between files via edges, excluding test dependencies + cursor.execute(r""" SELECT DISTINCT s.file_path, t.file_path, e.kind FROM edges e JOIN nodes s ON e.source = s.id @@ -201,6 +221,8 @@ def cmd_visualize(args): WHERE s.file_path != t.file_path AND s.file_path IS NOT NULL AND t.file_path IS NOT NULL + AND s.file_path NOT LIKE '%/tests/%' AND s.file_path NOT LIKE '%\tests\%' + AND t.file_path NOT LIKE '%/tests/%' AND t.file_path NOT LIKE '%\tests\%' AND (s.file_path LIKE 'src/%' OR s.file_path LIKE 'tools/%' OR s.file_path = 'app.py') AND (t.file_path LIKE 'src/%' OR t.file_path LIKE 'tools/%' OR t.file_path = 'app.py'); """) @@ -241,6 +263,58 @@ def node_id(filepath): print("```") conn.close() +def cmd_check_architecture(args): + """Check for architectural decoupling violations in the codebase.""" + conn = get_connection() + cursor = conn.cursor() + + # Query distinct import relationships between local source files and src/pages modules + cursor.execute(""" + SELECT DISTINCT s.file_path, t.name + FROM edges e + JOIN nodes s ON e.source = s.id + JOIN nodes t ON e.target = t.id + WHERE e.kind = 'imports' + AND (t.name LIKE 'src.%' OR t.name = 'src' OR t.name LIKE 'pages.%' OR t.name = 'pages') + AND s.file_path IS NOT NULL; + """) + all_imports = cursor.fetchall() + conn.close() + + violations = [] + for src_file, imp in all_imports: + # Normalize slashes for consistency + src_file_norm = src_file.replace("\\", "/") + + # Rule 1: src/engines/ should never import from UI or Page layer + if src_file_norm.startswith('src/engines/') and (imp.startswith('src.ui') or imp.startswith('pages')): + violations.append(("Domain Engine importing from UI/Page layer", src_file, imp)) + + # Rule 2: src/utils/ should never import from engines, workflow, ui, or pages + if src_file_norm.startswith('src/utils/') and (imp.startswith('src.engines') or imp.startswith('src.workflow') or imp.startswith('src.ui') or imp.startswith('pages')): + violations.append(("Utility Helper importing from high-level layer", src_file, imp)) + + # Rule 3: src/config/ should never import from engines, workflow, ui, or pages + if src_file_norm.startswith('src/config/') and (imp.startswith('src.engines') or imp.startswith('src.workflow') or imp.startswith('src.ui') or imp.startswith('pages')): + violations.append(("Config module importing from high-level layer", src_file, imp)) + + # Rule 4: src/data/ should never import from engines, workflow, ui, or pages + if src_file_norm.startswith('src/data/') and (imp.startswith('src.engines') or imp.startswith('src.workflow') or imp.startswith('src.ui') or imp.startswith('pages')): + violations.append(("Data module importing from high-level layer", src_file, imp)) + + print("=== Architectural Decoupling Audit ===") + if not violations: + print(" [OK] No architectural coupling violations detected. Perfect layer separation!") + sys.exit(0) + else: + print(f" [FAIL] Found {len(violations)} architectural coupling violations:") + for rule, src_file, imp in violations: + print(f" - [{rule}]:") + print(f" File: {src_file}") + print(f" Imports: {imp}") + sys.exit(1) + + def main(): parser = argparse.ArgumentParser(description="Codegraph database querying tool.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -268,6 +342,9 @@ def main(): # Visualize command subparsers.add_parser("visualize", help="Generate a Mermaid dependency graph.") + # Check architecture command + subparsers.add_parser("check-architecture", help="Check for decoupling violations in the codebase.") + args = parser.parse_args() if args.command == "status": cmd_status(args) @@ -281,6 +358,8 @@ def main(): cmd_callees(args) elif args.command == "visualize": cmd_visualize(args) + elif args.command == "check-architecture": + cmd_check_architecture(args) if __name__ == "__main__": main() diff --git a/tools/draw_scope_map.py b/tools/draw_scope_map.py index ae9fff2..4a029bd 100644 --- a/tools/draw_scope_map.py +++ b/tools/draw_scope_map.py @@ -3,6 +3,7 @@ import json import sys import os +import re from pathlib import Path import geopandas as gpd import matplotlib.pyplot as plt @@ -112,12 +113,33 @@ } def get_drawing_module(drawing_type): - if drawing_type in MAP_TYPE_TO_MODULE: - mod_name = MAP_TYPE_TO_MODULE[drawing_type] - elif any(k in drawing_type for k in ["老水产市场", "食品调料市场", "市一中北侧", "清禾集贸市场", "中国石油"]): - mod_name = "dr_parcel_detail" - else: - mod_name = "dr_004" + mod_name = None + for k, v in MAP_TYPE_TO_MODULE.items(): + if drawing_type == k or (k.replace("五地块", "重点地块") in drawing_type): + mod_name = v + break + + if not mod_name: + import re + is_parcel_detail = ( + any(k in drawing_type for k in ["老水产市场", "食品调料市场", "市一中北侧", "清禾集贸市场", "中国石油"]) + or bool(re.search(r"地块\d+", drawing_type)) + ) + if is_parcel_detail and "对比图" not in drawing_type: + mod_name = "dr_parcel_detail" + elif "对比图" in drawing_type: + match = re.search(r"地块(\d+)", drawing_type) + if match: + idx = int(match.group(1)) - 1 + comparison_mods = ["dr_075", "dr_095", "dr_114", "dr_132", "dr_150"] + if idx < len(comparison_mods): + mod_name = comparison_mods[idx] + else: + mod_name = "dr_075" + else: + mod_name = "dr_075" + else: + mod_name = "dr_004" try: return importlib.import_module(f"tools.drawings.{mod_name}") except Exception as e: @@ -220,7 +242,10 @@ def draw_spatial_map(output_path, drawing_type="现状区位图"): # Check if this is a parcel detail drawing and adjust viewport to zoom in parcel_idx = None - if "老水产市场" in drawing_type: + match = re.search(r"地块(\d+)", drawing_type) + if match: + parcel_idx = int(match.group(1)) - 1 + elif "老水产市场" in drawing_type: parcel_idx = 0 elif "食品调料市场" in drawing_type: parcel_idx = 1 @@ -241,7 +266,7 @@ def draw_spatial_map(output_path, drawing_type="现状区位图"): max_dim = max(p_w, p_h * 1.2454) # Override zoom factor to zoom in closer for more details on specific parcels (食品调料市场, 清禾集贸市场) - zoom_factor = 1.3 if any(k in drawing_type for k in ["食品调料市场", "清禾集贸市场"]) else 2.5 + zoom_factor = 1.3 if (parcel_idx in (1, 3) or any(k in drawing_type for k in ["食品调料市场", "清禾集贸市场"])) else 2.5 view_w = max_dim * zoom_factor view_h = view_w / 1.2454 @@ -320,7 +345,7 @@ def get_xy(lon, lat): default_mod.draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, cx, cy, view_w, view_h, get_xy, font_prop) # Boundary red line (Apple Red) - skip for parcel detail drawings to keep them clean - if not any(k in drawing_type for k in ["老水产市场", "食品调料市场", "市一中北侧", "清禾集贸市场", "中国石油"]): + if parcel_idx is None and not any(k in drawing_type for k in ["老水产市场", "食品调料市场", "市一中北侧", "清禾集贸市场", "中国石油"]): boundary.plot(ax=ax, facecolor="none", edgecolor="#FF3B30", linewidth=2.0, zorder=7.0) # 4. Add text annotations for key landmarks (if not AIGC flowchart) @@ -398,6 +423,148 @@ def draw_centered_text(draw, text, cx, cy, fill, font): h = bbox[3] - bbox[1] draw.text((cx - w // 2, cy - h // 2), text, fill=fill, font=font) + +_LEGEND_STYLE_COLORS = { + "rect_red_border": "#ff3b30", + "outline_red": "#ff3b30", + "rect_orange_border": "#ff9500", + "outline_orange": "#ff9500", + "rect_building": "#e5e5e7", + "building": "#e5e5e7", + "rect_water": "#d0e6f7", + "water": "#d0e6f7", + "line_rail": "#48484a", + "rail": "#48484a", + "rect_road": "#e5e5ea", + "road": "#e5e5ea", + "line_blue": "#3b82f6", + "arrow_blue": "#3b82f6", + "line_red_dashed": "#ff2d55", + "green_dash": "#22c55e", + "sun_yellow": "#facc15", + "highlight_red": "#ef4444", + "rect_euluc_0": "#ffff00", + "rect_euluc_1": "#e60000", + "rect_euluc_2": "#ff7f00", + "rect_euluc_3": "#aa7855", + "rect_euluc_4": "#9c9c9c", + "rect_euluc_5": "#686868", + "rect_euluc_6": "#ff7f7f", + "rect_euluc_7": "#ff7fff", + "rect_euluc_8": "#ff7fbf", + "rect_euluc_9": "#7fffff", + "rect_euluc_10": "#38a800", + "rect_green_plan": "#bbf7d0", + "rect_water_plan": "#e2f0fd", + "rect_green_status": "#dcfce7", + "line_h1": "#ef4444", + "line_h2": "#f97316", + "line_h3": "#eab308", + "line_h4": "#9ca3af", + "line_t1": "#ef4444", + "line_t2": "#3b82f6", + "line_t3": "#10b981", + "rect_protect_1": "#fee2e2", + "rect_protect_2": "#fef3c7", + "rect_protect_3": "#f1f5f9", + "rect_height_1": "#fee2e2", + "rect_height_2": "#fecdd3", + "rect_height_3": "#fb7185", + "rect_height_4": "#e11d48", + "rect_height_5": "#9f1239", + "rect_plan_blue": "#93c5fd", + "rect_plan_green": "#a7f3d0", + "rect_plan_red": "#fca5a5", + "rect_plan_yellow": "#fde047", + "rect_purple_fill": "#a855f7", + "rect_blue_fill": "#3b82f6", + "rect_green": "#22c55e", + "rect_heatmap_high": "#ef4444", + "rect_heatmap_med": "#f97316", + "rect_heatmap_low": "#facc15", + "rect_wf_blue": "#3b82f6", + "rect_wf_purple": "#c084fc", + "rect_wf_green": "#4ade80", + "rect_wf_yellow": "#fbbf24", + "rect_wf_slate": "#94a3b8", +} + +_LEGEND_STYLE_KEYWORD_COLORS = ( + ("red", "#ef4444"), + ("orange", "#f97316"), + ("yellow", "#facc15"), + ("green", "#22c55e"), + ("blue", "#3b82f6"), + ("cyan", "#06b6d4"), + ("purple", "#a855f7"), + ("pink", "#db2777"), + ("gold", "#f59e0b"), + ("water", "#d0e6f7"), + ("rail", "#48484a"), + ("road", "#e5e5ea"), + ("building", "#e5e5e7"), + ("slate", "#94a3b8"), + ("gray", "#94a3b8"), + ("grey", "#94a3b8"), +) + + +def _legend_style_to_hex(style) -> str: + style_text = str(style or "").strip() + if style_text.startswith("#") and len(style_text) in {4, 7}: + return style_text.lower() + + key = style_text.lower() + if key in _LEGEND_STYLE_COLORS: + return _LEGEND_STYLE_COLORS[key] + + for token, color in _LEGEND_STYLE_KEYWORD_COLORS: + if token in key: + return color + return "#607d8b" + + +def _legend_items_from_module(module, drawing_type: str) -> list[tuple[str, str]]: + raw_items = getattr(module, "legend_items", None) if module else None + if not raw_items and module: + metadata = getattr(module, "DRAWING_METADATA", None) + if isinstance(metadata, dict): + meta = metadata.get(drawing_type) + if meta is None: + for key, value in metadata.items(): + if key in drawing_type or drawing_type in key: + meta = value + break + if isinstance(meta, dict): + raw_items = meta.get("legend_items") + + converted = [] + for item in raw_items or []: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + label, style = item[0], item[1] + converted.append((str(label), _legend_style_to_hex(style))) + return converted + + +def _layout_legend_items(drawing_type: str) -> list[tuple[str, str]]: + module = get_drawing_module(drawing_type) + legend_items = _legend_items_from_module(module, drawing_type) + if legend_items: + return legend_items + + default_mod = get_drawing_module("现状区位图") + return _legend_items_from_module(default_mod, "现状区位图") + + +def _scale_text_from_view_width(view_w) -> str: + try: + scale_ratio = float(view_w) / 0.31968 + scale_rounded = int(round(scale_ratio / 500)) * 500 + except (TypeError, ValueError, OverflowError): + scale_rounded = 5000 + return f"1:{max(scale_rounded, 500)}" + def generate_dynamic_description(drawing_type, title): if drawing_type in ["公众参与与博弈协商成果图", "投资估算与经济测算图"]: return None @@ -488,7 +655,28 @@ def generate_dynamic_description(drawing_type, title): pass return None -def process_a3_layout(map_path, output_path, view_w, drawing_type="现状区位图", title="现状区位图", description_lines=None, drawing_number="DR-001", author="陈礼冲", author_id="202111003", organization="吉林建筑大学建筑与规划学院\n城乡规划211班"): +def process_a3_layout(map_path, output_path, view_w, drawing_type="现状区位图", title="现状区位图", description_lines=None, drawing_number="DR-001", author="陈礼冲", author_id="202111003", organization="吉林建筑大学建筑与规划学院\n城乡规划211班", layout_id="legacy"): + if layout_id and layout_id not in {"legacy", "map_legend_right"}: + print(f"Processing A3 layout profile: {layout_id}...") + from src.engines.frame_generator import compose_framed_sheet + + with Image.open(map_path) as source: + map_img = source.convert("RGB") + summary = "\n".join(line for line in (description_lines or []) if line) + sheet = compose_framed_sheet( + main_image=map_img, + title=title, + chapter=drawing_type, + summary=summary, + legend_items=_layout_legend_items(drawing_type), + drawing_number=drawing_number, + scale_text=_scale_text_from_view_width(view_w), + layout_id=layout_id, + ) + sheet.save(output_path) + print(f"Final A3 layout profile sheet saved to {output_path} (layout_id={layout_id})") + return + print("Processing A3 layout template...") template = Image.open(STATIC_DIR / 'a3_layout_preview_full.png').convert('RGB') map_img = Image.open(map_path).convert('RGB') diff --git a/tools/drawings/dr_005.py b/tools/drawings/dr_005.py index ca3aafe..b0091d7 100644 --- a/tools/drawings/dr_005.py +++ b/tools/drawings/dr_005.py @@ -98,7 +98,8 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c # 1. Satellite Base (with high-res TIFF dynamic loading) tif_path = None - graduate_dir = Path("E:/graduate") + repo_drive = Path(__file__).resolve().anchor + graduate_dir = Path(repo_drive) / "graduate" if graduate_dir.exists(): for root, dirs, files in os.walk(graduate_dir): for file in files: @@ -107,7 +108,7 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c break if not tif_path: - album_dir = Path("E:/画册/影像") + album_dir = Path(repo_drive) / "画册" / "影像" if album_dir.exists(): for root, dirs, files in os.walk(album_dir): for file in files: diff --git a/tools/drawings/dr_010.py b/tools/drawings/dr_010.py index 648690d..c4e80b4 100644 --- a/tools/drawings/dr_010.py +++ b/tools/drawings/dr_010.py @@ -109,7 +109,8 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c # Load and display satellite image on sub-axes (with high-res TIFF dynamic loading) tif_path = None - graduate_dir = Path("E:/graduate") + repo_drive = Path(__file__).resolve().anchor + graduate_dir = Path(repo_drive) / "graduate" if graduate_dir.exists(): for root, dirs, files in os.walk(graduate_dir): for file in files: @@ -118,7 +119,7 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c break if not tif_path: - album_dir = Path("E:/画册/影像") + album_dir = Path(repo_drive) / "画册" / "影像" if album_dir.exists(): for root, dirs, files in os.walk(album_dir): for file in files: diff --git a/tools/drawings/dr_155.py b/tools/drawings/dr_155.py index 5b59c57..1262d72 100644 --- a/tools/drawings/dr_155.py +++ b/tools/drawings/dr_155.py @@ -116,6 +116,26 @@ def draw_map_early(output_path, view_w, view_h, STATIC_DIR): draw.text((rx0 + 15, ry0 + 65), "更新图册章节结构", fill=(15, 23, 42), font=font_box_header) draw.text((rx0 + 15, ry0 + 92), "ATLAS STRUCTURE", fill=(148, 163, 184), font=font_box_sub) + # Dynamically query key plots to construct Chapter 5 sheet list + try: + from src.engines.key_plot_engine import get_configured_key_plots, plot_names + plots = get_configured_key_plots() + plot_names_list = plot_names(plots) + except Exception: + plot_names_list = ["老水产批发市场", "中车旧厂区", "食品调料市场", "清禾市场", "石油公司"] + + if not plot_names_list: + plot_names_list = ["重点更新单元"] + + start_idx = 61 + plot_sheets = [] + for pname in plot_names_list: + end_idx = start_idx + 19 + plot_sheets.append(f"DR-{start_idx:03d}~{end_idx:03d}_{pname}更新方案图集") + start_idx = end_idx + 1 + + ch5_sheets = "DR-058_重点地块深化设计总图、DR-059_AIGC技术推演过程图、DR-060_实施分期图、" + "、".join(plot_sheets) + # Chapters chapters = [ { @@ -145,7 +165,7 @@ def draw_map_early(output_path, view_w, view_h, STATIC_DIR): { "id": 5, "y0": 1135, "y1": 1450, "color": (217, 119, 6), # Amber "title": "第5章 重点地块设计", "sub": "KEY PLOT DESIGN", - "sheets": "DR-058_五地块深化设计总图、DR-059_AIGC技术推演过程图、DR-060_实施分期图、DR-061~081_老水产市场更新方案图集、DR-082~101_食品调料市场更新方案图集、DR-102~120_市一中北侧地块更新方案图集、DR-121~138_清禾集贸市场更新方案图集、DR-139~154_中国石油地块更新方案图集", + "sheets": ch5_sheets, "max_lines": 8 } ] diff --git a/tools/drawings/dr_157.py b/tools/drawings/dr_157.py index f759f36..31c6e22 100644 --- a/tools/drawings/dr_157.py +++ b/tools/drawings/dr_157.py @@ -169,7 +169,7 @@ def draw_map_early(output_path, view_w, view_h, STATIC_DIR): draw.rectangle([1200, 750, 1500, 1000], fill=(255, 255, 255), outline=(217, 119, 6), width=2) draw.rectangle([1200, 750, 1500, 770], fill=(217, 119, 6)) draw.text((1215, 800), "满意规划方案输出", fill=(15, 23, 42), font=font_box_header) - out_text = "最终深化生成:\n[DR-076] 五地块深化设计总图、[DR-082] 实施分期图。符合居民、政府、开发商多方利益共识。" + out_text = "最终深化生成:\n[DR-058] 重点地块深化设计总图、[DR-060] 实施分期图。符合居民、政府、开发商多方利益共识。" wrapped_out = wrap_text_by_pixels(out_text, font_body, 270, draw) y_text = 840 for line in wrapped_out: diff --git a/tools/drawings/dr_parcel_detail.py b/tools/drawings/dr_parcel_detail.py index 3a95ee5..e9e2d3a 100644 --- a/tools/drawings/dr_parcel_detail.py +++ b/tools/drawings/dr_parcel_detail.py @@ -103,10 +103,28 @@ def parse_drawing_type(drawing_type): # Detect parcel index p_idx = 0 - for idx, info in PARCEL_INFO.items(): - if info["key"] in drawing_type: - p_idx = idx - break + import re + match = re.search(r"地块(\d+)", drawing_type) + if match: + p_idx = max(0, int(match.group(1)) - 1) + else: + try: + from src.engines.key_plot_engine import get_configured_key_plots + plots = get_configured_key_plots() + for i, plot in enumerate(plots): + if plot.name in drawing_type: + p_idx = i + break + else: + for idx, info in PARCEL_INFO.items(): + if info["key"] in drawing_type: + p_idx = idx + break + except Exception: + for idx, info in PARCEL_INFO.items(): + if info["key"] in drawing_type: + p_idx = idx + break # Detect analysis type a_type = "satellite" @@ -168,7 +186,32 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c # 1. Parse Parcel Index and Analysis Type p_idx, a_type = parse_drawing_type(drawing_type) - p_info = PARCEL_INFO[p_idx] + + plots = [] + try: + from src.engines.key_plot_engine import get_configured_key_plots + plots = get_configured_key_plots() + except Exception: + pass + + if p_idx < len(plots): + plot = plots[p_idx] + p_info = { + "key": plot.name, + "name": plot.name, + "title": plot.name, + "area": f"{plot.area_ha:.2f}", + "desc_base": f"{plot.name}改造以“{plot.role or '特色更新'}”为特色。通过对地块进行功能织补,注入多元活力与复合业态,提升空间品质。" + } + else: + p_info = PARCEL_INFO.get(p_idx, { + "key": f"地块{p_idx+1}", + "name": f"地块{p_idx+1}", + "title": f"地块{p_idx+1}更新单元", + "area": "0.00", + "desc_base": f"地块{p_idx+1}规划聚焦于特色更新改造。通过织补公共服务设施与改善空间品质,实现区域协同提升。" + }) + a_info = ANALYSIS_INFO[a_type] # 2. Setup A3 Main Canvas Coordinates @@ -177,7 +220,7 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c ax.set_ylim(0, 100) # Recalculate center and view bounds locally for the specific parcel - if key_plots is not None and not key_plots.empty: + if key_plots is not None and not key_plots.empty and p_idx < len(key_plots): curr_row = key_plots.iloc[p_idx] p_minx, p_miny, p_maxx, p_maxy = curr_row.geometry.bounds cx = (p_minx + p_maxx) / 2 @@ -238,7 +281,8 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c if a_type == "satellite": # Load and render high-resolution satellite imagery from TIFF dynamically tif_path = None - graduate_dir = Path("E:/graduate") + repo_drive = Path(__file__).resolve().anchor + graduate_dir = Path(repo_drive) / "graduate" if graduate_dir.exists(): for root, dirs, files in os.walk(graduate_dir): for file in files: @@ -248,7 +292,7 @@ def draw_map(ax, roads, buildings, water, rails, key_plots, landuse, boundary, c # Fallback to secondary location if not tif_path: - album_dir = Path("E:/画册/影像") + album_dir = Path(repo_drive) / "画册" / "影像" if album_dir.exists(): for root, dirs, files in os.walk(album_dir): for file in files: diff --git a/tools/generate_indicator_images.py b/tools/generate_indicator_images.py index a717266..b1750ce 100644 --- a/tools/generate_indicator_images.py +++ b/tools/generate_indicator_images.py @@ -72,7 +72,7 @@ def calculate_metrics(): planned_areas = base_areas.copy() for i in range(len(key_plots)): pa = key_plots.geometry.iloc[i].area / SCALE_FACTOR - ratios = per_plot_planned_ratios[i] + ratios = per_plot_planned_ratios[i] if i < len(per_plot_planned_ratios) else {'B': 0.40, 'A': 0.20, 'G': 0.40} for code, ratio in ratios.items(): planned_areas[code] = planned_areas.get(code, 0.0) + pa * ratio @@ -104,26 +104,16 @@ def calculate_metrics(): historic_footprint = historic_in_keys['footprint_area'].sum() historic_floor = historic_in_keys['floor_area'].sum() - area_0 = key_plots.geometry.iloc[0].area / SCALE_FACTOR - area_1 = key_plots.geometry.iloc[1].area / SCALE_FACTOR - area_2 = key_plots.geometry.iloc[2].area / SCALE_FACTOR - area_3 = key_plots.geometry.iloc[3].area / SCALE_FACTOR - area_4 = key_plots.geometry.iloc[4].area / SCALE_FACTOR - - new_footprint = ( - area_0 * 0.25 + - area_1 * 0.28 + - area_2 * 0.25 + - area_3 * 0.25 + - area_4 * 0.15 - ) - new_floor = ( - area_0 * 1.3 + - area_1 * 1.4 + - area_2 * 1.3 + - area_3 * 1.3 + - area_4 * 0.2 - ) + new_footprint = 0.0 + new_floor = 0.0 + default_f_factors = [0.25, 0.28, 0.25, 0.25, 0.15] + default_g_factors = [1.3, 1.4, 1.3, 1.3, 0.2] + for idx in range(len(key_plots)): + area_idx = key_plots.geometry.iloc[idx].area / SCALE_FACTOR + f_fact = default_f_factors[idx] if idx < len(default_f_factors) else 0.25 + g_fact = default_g_factors[idx] if idx < len(default_g_factors) else 1.3 + new_footprint += area_idx * f_fact + new_floor += area_idx * g_fact total_plan_footprint = retained_footprint + historic_footprint + new_footprint total_plan_floor = retained_floor + historic_floor + new_floor @@ -148,7 +138,8 @@ def calculate_metrics(): per_plot_planned = [] for i in range(len(key_plots)): pa = plot_areas_sqm[i] - planned = {code: pa * ratio for code, ratio in per_plot_planned_ratios[i].items()} + ratios = per_plot_planned_ratios[i] if i < len(per_plot_planned_ratios) else {'B': 0.40, 'A': 0.20, 'G': 0.40} + planned = {code: pa * ratio for code, ratio in ratios.items()} per_plot_planned.append(planned) return { @@ -489,18 +480,37 @@ def draw_card_with_tag(box, tag_w, text, fill_color, border_color, card_bg=(255, t3_y = 280 plot_areas_ha = [a / 10000 for a in data["plot_areas_sqm"]] + key_plots = data["key_plots"] + plot_names = data["plot_names"] - t3_rows = [ - ["KP-01", "农贸水产市场", f"{plot_areas_ha[0]:.2f}", "B/A混合", "≤1.3", "≤25%", "≥38%", "≤18", "御花园东巷文创生活街区"], - ["KP-02", "食品调料大市场", f"{plot_areas_ha[1]:.2f}", "B/A混合", "≤1.4", "≤28%", "≥35%", "≤18", "活态市集·风味院落"], - ["KP-03", "市一中北侧", f"{plot_areas_ha[2]:.2f}", "A/R混合", "≤1.3", "≤25%", "≥35%", "≤18", "全龄共享生活社区"], - ["KP-04", "清禾集贸市场", f"{plot_areas_ha[3]:.2f}", "B/A混合", "≤1.3", "≤25%", "≥35%", "9~15", "历史界面缝合·社区生活发生器"], - ["KP-05", "中国石油", f"{plot_areas_ha[4]:.2f}", "G/B混合", "≤0.2", "≤15%", "≥80%", "≤9", "宽城子能量花园"], + t3_rows = [] + default_positions = [ + "御花园东巷文创生活街区", + "活态市集·风味院落", + "全龄共享生活社区", + "历史界面缝合·社区生活发生器", + "宽城子能量花园" ] + default_types = ["B/A混合", "B/A混合", "A/R混合", "B/A混合", "G/B混合"] + default_far = ["≤1.3", "≤1.4", "≤1.3", "≤1.3", "≤0.2"] + default_density = ["≤25%", "≤28%", "≤25%", "≤25%", "≤15%"] + default_green = ["≥38%", "≥35%", "≥35%", "≥35%", "≥80%"] + default_height = ["≤18", "≤18", "≤18", "9~15", "≤9"] + + for i in range(len(key_plots)): + pname = plot_names[i] + ha = plot_areas_ha[i] + ptype = default_types[i] if i < len(default_types) else "B/A混合" + pfar = default_far[i] if i < len(default_far) else "≤1.3" + pdensity = default_density[i] if i < len(default_density) else "≤25%" + pgreen = default_green[i] if i < len(default_green) else "≥35%" + pheight = default_height[i] if i < len(default_height) else "≤18" + pos = default_positions[i] if i < len(default_positions) else "重点更新单元" + t3_rows.append([f"KP-{i+1:02d}", pname, f"{ha:.2f}", ptype, pfar, pdensity, pgreen, pheight, pos]) total_key_ha = sum(plot_areas_ha) t3_rows.append({"_summary": True, "cells": [ - "合计", "五个重点地块", f"{total_key_ha:.2f}", "--", "≤1.4", "≤28%", "≥35%", "≤18", "城市设计管控单元" + "合计", f"{len(key_plots)}个重点地块", f"{total_key_ha:.2f}", "--", "≤1.4", "≤28%", "≥35%", "≤18", "城市设计管控单元" ]}) t3_end = draw_table_generic(draw, 1180, t3_y, t3_headers, t3_cols, t3_rows, 38, @@ -528,7 +538,7 @@ def draw_card_with_tag(box, tag_w, text, fill_color, border_color, card_bg=(255, ] t4_all_rows = [] - for plot_idx in range(5): + for plot_idx in range(len(key_plots)): pname = plot_names[plot_idx] pa = plot_areas_sqm[plot_idx] exist = per_plot_existing[plot_idx] diff --git a/tools/generate_list_diagrams.py b/tools/generate_list_diagrams.py index acb029b..266a083 100644 --- a/tools/generate_list_diagrams.py +++ b/tools/generate_list_diagrams.py @@ -555,7 +555,7 @@ def generate_code_list_sheet(): "tools/generate_indicator_images.py", ["calculate_metrics()", "GIS空间裁剪", "墨卡托纠偏"], "GeoPandas + Shapely", - "基于GIS矢量数据实时计算容积率(FAR)、建筑密度、绿地率等控规指标。支持五地块分项统计、现状/规划对比、高斯-克吕格投影精度校验(<0.02%)。", + "基于GIS矢量数据实时计算容积率(FAR)、建筑密度、绿地率等控规指标。支持各重点地块分项统计、现状/规划对比、高斯-克吕格投影精度校验(<0.02%)。", (255, 241, 242), (244, 63, 94), (159, 18, 57)), ("10.", "三维白模渲染引擎", diff --git a/tests/test_atlas_crops.py b/tools/tests/test_atlas_crops.py similarity index 100% rename from tests/test_atlas_crops.py rename to tools/tests/test_atlas_crops.py diff --git a/tests/test_check_env.py b/tools/tests/test_check_env.py similarity index 100% rename from tests/test_check_env.py rename to tools/tests/test_check_env.py diff --git a/tests/test_dr054_atlas_layout.py b/tools/tests/test_dr054_atlas_layout.py similarity index 100% rename from tests/test_dr054_atlas_layout.py rename to tools/tests/test_dr054_atlas_layout.py diff --git a/tests/test_export_a1_editable_psd_package.py b/tools/tests/test_export_a1_editable_psd_package.py similarity index 100% rename from tests/test_export_a1_editable_psd_package.py rename to tools/tests/test_export_a1_editable_psd_package.py diff --git a/tests/test_generate_policy_a3_diagrams.py b/tools/tests/test_generate_policy_a3_diagrams.py similarity index 100% rename from tests/test_generate_policy_a3_diagrams.py rename to tools/tests/test_generate_policy_a3_diagrams.py diff --git a/tests/test_parcel_control_metrics.py b/tools/tests/test_parcel_control_metrics.py similarity index 100% rename from tests/test_parcel_control_metrics.py rename to tools/tests/test_parcel_control_metrics.py diff --git a/tests/test_policy_board_content.py b/tools/tests/test_policy_board_content.py similarity index 100% rename from tests/test_policy_board_content.py rename to tools/tests/test_policy_board_content.py diff --git a/tests/test_record_project_workflow_video.py b/tools/tests/test_record_project_workflow_video.py similarity index 94% rename from tests/test_record_project_workflow_video.py rename to tools/tests/test_record_project_workflow_video.py index b306179..3c65bcd 100644 --- a/tests/test_record_project_workflow_video.py +++ b/tools/tests/test_record_project_workflow_video.py @@ -5,7 +5,7 @@ def _load_module(): - script_path = Path(__file__).resolve().parents[1] / "tools" / "record_project_workflow_video.py" + script_path = Path(__file__).resolve().parents[2] / "tools" / "record_project_workflow_video.py" spec = importlib.util.spec_from_file_location("record_project_workflow_video", script_path) assert spec and spec.loader module = importlib.util.module_from_spec(spec) diff --git a/tests/test_secret_scan.py b/tools/tests/test_secret_scan.py similarity index 100% rename from tests/test_secret_scan.py rename to tools/tests/test_secret_scan.py diff --git a/tests/test_startup_smoke.py b/tools/tests/test_startup_smoke.py similarity index 100% rename from tests/test_startup_smoke.py rename to tools/tests/test_startup_smoke.py diff --git a/tests/test_upscale_policy_a3_images.py b/tools/tests/test_upscale_policy_a3_images.py similarity index 100% rename from tests/test_upscale_policy_a3_images.py rename to tools/tests/test_upscale_policy_a3_images.py diff --git a/tools/test_drawing_params.py b/tools/verify_drawing_params.py similarity index 100% rename from tools/test_drawing_params.py rename to tools/verify_drawing_params.py