From 49830482680ef5e6a6f9363f32022f2d42e45add Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Wed, 8 Jul 2026 14:39:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20CAD=20=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E8=AF=86=E5=88=AB=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9F=B1=E5=AD=90/=E6=96=87=E5=AD=97/=E5=A2=99?= =?UTF-8?q?=E4=BD=93=E5=80=99=E9=80=89=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 element_detector.py,包含 ColumnDetector、TextExtractor、WallCandidateDetector - ColumnDetector:通过 INSERT 块名关键词识别柱子候选 - TextExtractor:提取 TEXT/MTEXT 内容、坐标、图层 - WallCandidateDetector:按长度阈值筛选 LINE/LWPOLYLINE,排除标注图层 - 采用 OOP 设计,所有规则可配置,保留原始 handle 追溯 - 输出 element_detection.json 和 element_report.txt - 更新 main.py 集成元素识别步骤 --- docs/tasks/task_detail_2026_07_08.md | 13 + experiments/dxf-parser/element_detector.py | 445 +++++++++++++++++++++ experiments/dxf-parser/main.py | 23 ++ 3 files changed, 481 insertions(+) create mode 100644 experiments/dxf-parser/element_detector.py diff --git a/docs/tasks/task_detail_2026_07_08.md b/docs/tasks/task_detail_2026_07_08.md index 8d83b6f..e9f270b 100644 --- a/docs/tasks/task_detail_2026_07_08.md +++ b/docs/tasks/task_detail_2026_07_08.md @@ -26,3 +26,16 @@ 4. 创建 README.md 使用说明文档。 5. 运行验证:成功解析洗浴中心C-48.dxf(719个实体、12个图层、11种块、71条文本),JSON 输出格式正确。 - **执行结果**: 模块化拆分完成,代码结构清晰,JSON 输出符合 Task1 规范,为后续墙体识别等功能扩展奠定基础。 + +## 会话 ID: 3 +- [2026-07-08 14:39] +- **执行原因**: 实现 Task2 —— CAD 基础构件候选识别 Demo,从 DXF 数据中识别柱子、文字和墙体候选。 +- **执行过程**: + 1. 创建 element_detector.py,采用 OOP 设计,包含三个独立识别器: + - ColumnDetector:通过 INSERT 块名关键词(柱/column/COL)识别柱子候选 + - TextExtractor:提取所有 TEXT/MTEXT 的内容、坐标、图层 + - WallCandidateDetector:分析 LINE/LWPOLYLINE,按长度阈值(>=500)筛选,排除标注图层(DIM/TEXT/DEFPOINTS) + 2. ElementDetector 作为编排器统一调度,所有规则可配置。 + 3. 更新 main.py 集成元素识别步骤,输出 element_detection.json 和 element_report.txt。 + 4. 修复 ezdxf Vec3 切片兼容问题。 +- **执行结果**: 成功识别 6 个柱子候选(柱子01)、71 条文字、231 条墙体候选(179 LINE + 52 LWPOLYLINE),输出文件格式符合 Task2 规范。 diff --git a/experiments/dxf-parser/element_detector.py b/experiments/dxf-parser/element_detector.py new file mode 100644 index 0000000..867c5c7 --- /dev/null +++ b/experiments/dxf-parser/element_detector.py @@ -0,0 +1,445 @@ +"""CAD 基础构件候选识别模块 —— 从 DXF 数据中识别柱子、文字和墙体候选。 + +设计原则: +- 面向对象,每个识别模块独立 +- 所有规则可配置 +- 保留原始实体 handle 方便追溯 +- 不假设图层命名规范,仅做启发式匹配 +""" + +import math +import json +from pathlib import Path +from typing import Optional + +import ezdxf + + +# ============================================================ +# 配置常量(可修改) +# ============================================================ + +# 柱子识别:INSERT 块名中包含以下关键词之一 +COLUMN_KEYWORDS = ["柱", "column", "COL"] + +# 墙体候选:最小线段长度(图纸单位,通常为毫米) +WALL_MIN_LENGTH = 500 + +# 墙体候选:排除的图层关键词(标注、文字等非建筑图层) +WALL_EXCLUDE_LAYERS = ["DIM", "TEXT", "DEFPOINTS", "标注", "NOTE", "PUB_DIM", "AXIS"] + +# 墙体候选:优先包含的图层关键词(空列表 = 不限制) +WALL_INCLUDE_LAYERS = [] + + +# ============================================================ +# 柱子识别器 +# ============================================================ + +class ColumnDetector: + """从 INSERT 实体中识别候选柱子。 + + 规则:块名包含柱/column/COL 等关键词。 + """ + + def __init__(self, keywords: Optional[list[str]] = None): + """ + Args: + keywords: 块名匹配关键词列表,默认使用 COLUMN_KEYWORDS + """ + self.keywords = keywords or COLUMN_KEYWORDS + + def detect(self, msp) -> list[dict]: + """遍历模型空间,检测柱子候选。 + + Args: + msp: ezdxf 模型空间对象 + + Returns: + list[dict]: 柱子候选列表 + """ + columns = [] + for entity in msp: + if entity.dxftype() != "INSERT": + continue + block_name = entity.dxf.name + if self._match(block_name): + insert = entity.dxf.insert + columns.append({ + "name": block_name, + "position": { + "x": round(insert[0], 4), + "y": round(insert[1], 4), + }, + "source": "INSERT", + "handle": entity.dxf.handle, + "layer": entity.dxf.layer, + "rotation": round(entity.dxf.rotation, 2), + "scale": [ + round(entity.dxf.xscale, 2), + round(entity.dxf.yscale, 2), + round(entity.dxf.zscale, 2), + ], + }) + return columns + + def _match(self, block_name: str) -> bool: + """检查块名是否匹配柱子关键词。""" + upper = block_name.upper() + for kw in self.keywords: + if kw.upper() in upper or kw in block_name: + return True + return False + + +# ============================================================ +# 文字提取器 +# ============================================================ + +class TextExtractor: + """提取所有 TEXT / MTEXT 实体的文本内容及其坐标。""" + + def extract(self, msp) -> list[dict]: + """提取模型空间中所有文字实体。 + + Args: + msp: ezdxf 模型空间对象 + + Returns: + list[dict]: 文字信息列表 + """ + texts = [] + for entity in msp: + dxftype = entity.dxftype() + if dxftype not in ("TEXT", "MTEXT"): + continue + + content = entity.plain_text() if dxftype == "MTEXT" else entity.dxf.text + insert = entity.dxf.insert if hasattr(entity.dxf, "insert") else None + + item = { + "content": content, + "type": dxftype, + "handle": entity.dxf.handle, + "layer": entity.dxf.layer, + } + if insert is not None: + item["x"] = round(insert[0], 4) + item["y"] = round(insert[1], 4) + if hasattr(entity.dxf, "height"): + item["height"] = round(entity.dxf.height, 2) + + texts.append(item) + return texts + + +# ============================================================ +# 墙体候选识别器 +# ============================================================ + +class WallCandidateDetector: + """从 LINE / LWPOLYLINE 中识别墙体候选线段。 + + 规则: + 1. 线段长度 >= min_length + 2. 所在图层不是标注类图层 + 3. 可选:限制在主要建筑图层 + """ + + def __init__( + self, + min_length: float = WALL_MIN_LENGTH, + exclude_layers: Optional[list[str]] = None, + include_layers: Optional[list[str]] = None, + ): + """ + Args: + min_length: 最小线段长度阈值 + exclude_layers: 要排除的图层关键词列表 + include_layers: 仅包含的图层关键词列表(空 = 不限制) + """ + self.min_length = min_length + self.exclude_layers = exclude_layers or WALL_EXCLUDE_LAYERS + self.include_layers = include_layers or WALL_INCLUDE_LAYERS + + def detect(self, msp) -> list[dict]: + """检测墙体候选线段。""" + candidates = [] + for entity in msp: + dxftype = entity.dxftype() + layer = entity.dxf.layer + + if self._is_excluded_layer(layer): + continue + + if dxftype == "LINE": + start = entity.dxf.start + end = entity.dxf.end + length = math.dist([start[0], start[1]], [end[0], end[1]]) + if length >= self.min_length: + candidates.append({ + "entity_type": "LINE", + "handle": entity.dxf.handle, + "layer": layer, + "length": round(length, 2), + "start": [round(start[0], 4), round(start[1], 4)], + "end": [round(end[0], 4), round(end[1], 4)], + }) + + elif dxftype == "LWPOLYLINE": + try: + pts = entity.get_points() + except Exception: + continue + if len(pts) < 2: + continue + # 计算总长度和最长段 + total_length = 0.0 + max_seg_length = 0.0 + segments = [] + for i in range(len(pts) - 1): + seg_len = math.dist([pts[i][0], pts[i][1]], [pts[i + 1][0], pts[i + 1][1]]) + total_length += seg_len + if seg_len > max_seg_length: + max_seg_length = seg_len + segments.append({ + "start": [round(pts[i][0], 4), round(pts[i][1], 4)], + "end": [round(pts[i + 1][0], 4), round(pts[i + 1][1], 4)], + "length": round(seg_len, 2), + }) + + # 至少最长段满足阈值才算候选 + if max_seg_length >= self.min_length: + candidates.append({ + "entity_type": "LWPOLYLINE", + "handle": entity.dxf.handle, + "layer": layer, + "vertex_count": len(pts), + "closed": entity.closed, + "total_length": round(total_length, 2), + "max_segment_length": round(max_seg_length, 2), + "segments": [s for s in segments if s["length"] >= self.min_length], + "points": [[round(p[0], 4), round(p[1], 4)] for p in pts], + }) + + return candidates + + def _is_excluded_layer(self, layer_name: str) -> bool: + """检查图层是否属于排除范围(标注类图层)。""" + upper = layer_name.upper() + for kw in self.exclude_layers: + if kw.upper() in upper or kw in layer_name: + return True + # 如果配置了 include 列表,则仅包含匹配的图层 + if self.include_layers: + for kw in self.include_layers: + if kw.upper() in upper or kw in layer_name: + return False + return True # 不在 include 列表中则排除 + return False + + +# ============================================================ +# 主检测器(编排) +# ============================================================ + +class ElementDetector: + """编排各识别器,输出统一的检测结果。""" + + def __init__( + self, + column_keywords: Optional[list[str]] = None, + wall_min_length: float = WALL_MIN_LENGTH, + wall_exclude_layers: Optional[list[str]] = None, + wall_include_layers: Optional[list[str]] = None, + ): + self.column_detector = ColumnDetector(keywords=column_keywords) + self.text_extractor = TextExtractor() + self.wall_detector = WallCandidateDetector( + min_length=wall_min_length, + exclude_layers=wall_exclude_layers, + include_layers=wall_include_layers, + ) + + def detect_all(self, doc) -> dict: + """执行全部识别,返回结构化结果。 + + Args: + doc: ezdxf Drawing 对象 + + Returns: + dict: {"columns": [...], "texts": [...], "wall_candidates": [...]} + """ + msp = doc.modelspace() + return { + "columns": self.column_detector.detect(msp), + "texts": self.text_extractor.extract(msp), + "wall_candidates": self.wall_detector.detect(msp), + } + + +# ============================================================ +# 报告生成 +# ============================================================ + +def generate_report(result: dict) -> str: + """根据检测结果生成纯文本统计报告。 + + Args: + result: detect_all() 的返回值 + + Returns: + str: 格式化报告文本 + """ + lines = [] + lines.append("=" * 50) + lines.append(" CAD 元素分析报告") + lines.append("=" * 50) + lines.append("") + + # 柱子 + columns = result["columns"] + lines.append(f"【柱子候选】") + lines.append(f" 数量: {len(columns)}") + if columns: + # 按块名分组统计 + block_counts = {} + for col in columns: + name = col["name"] + block_counts[name] = block_counts.get(name, 0) + 1 + for name, count in sorted(block_counts.items()): + lines.append(f" - {name}: {count} 个") + lines.append("") + + # 文字 + texts = result["texts"] + lines.append(f"【文字】") + lines.append(f" 数量: {len(texts)}") + if texts: + # 按图层分组统计 + layer_counts = {} + for t in texts: + ly = t["layer"] + layer_counts[ly] = layer_counts.get(ly, 0) + 1 + for ly, count in sorted(layer_counts.items()): + lines.append(f" - 图层 {ly}: {count} 条") + lines.append("") + + # 墙体候选 + walls = result["wall_candidates"] + lines.append(f"【墙体候选】") + lines.append(f" 数量: {len(walls)}") + if walls: + # 按类型统计 + type_counts = {} + layer_counts = {} + lengths = [] + for w in walls: + etype = w["entity_type"] + type_counts[etype] = type_counts.get(etype, 0) + 1 + ly = w["layer"] + layer_counts[ly] = layer_counts.get(ly, 0) + 1 + # 获取长度 + if etype == "LINE": + lengths.append(w["length"]) + elif etype == "LWPOLYLINE": + lengths.append(w["max_segment_length"]) + + lines.append(" 按类型:") + for etype, count in sorted(type_counts.items()): + lines.append(f" - {etype}: {count} 条") + + lines.append(" 按图层:") + for ly, count in sorted(layer_counts.items(), key=lambda x: -x[1]): + lines.append(f" - 图层 {ly}: {count} 条") + + if lengths: + lines.append(f" 最长段: {max(lengths):.2f}") + lines.append(f" 最短段: {min(lengths):.2f}") + lines.append(f" 平均长: {sum(lengths) / len(lengths):.2f}") + + lines.append("") + lines.append("=" * 50) + lines.append(" 报告结束") + lines.append("=" * 50) + return "\n".join(lines) + + +# ============================================================ +# 入口 +# ============================================================ + +def run_detection( + dxf_path: str, + output_dir: str, + column_keywords: Optional[list[str]] = None, + wall_min_length: float = WALL_MIN_LENGTH, + wall_exclude_layers: Optional[list[str]] = None, + wall_include_layers: Optional[list[str]] = None, +) -> dict: + """执行元素检测并输出 JSON 和报告文件。 + + Args: + dxf_path: DXF 文件路径 + output_dir: 输出目录路径 + column_keywords: 柱子识别关键词 + wall_min_length: 墙体最小长度阈值 + wall_exclude_layers: 墙体排除图层关键词 + wall_include_layers: 墙体限定图层关键词 + + Returns: + dict: 检测结果 + """ + doc = ezdxf.readfile(dxf_path) + filename = Path(dxf_path).stem + + detector = ElementDetector( + column_keywords=column_keywords, + wall_min_length=wall_min_length, + wall_exclude_layers=wall_exclude_layers, + wall_include_layers=wall_include_layers, + ) + result = detector.detect_all(doc) + + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + # 输出 JSON + json_path = out_dir / f"{filename}_element_detection.json" + json_path.write_text( + json.dumps(result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"检测结果已保存到: {json_path}") + + # 输出报告 + report_path = out_dir / f"{filename}_element_report.txt" + report = generate_report(result) + report_path.write_text(report, encoding="utf-8") + print(f"分析报告已保存到: {report_path}") + + # 打印摘要 + print(f"\n 柱子候选 : {len(result['columns'])} 个") + print(f" 文字 : {len(result['texts'])} 条") + print(f" 墙体候选 : {len(result['wall_candidates'])} 条") + print(report) + + return result + + +# ============================================================ +# 独立运行入口 +# ============================================================ + +if __name__ == "__main__": + import sys + BASE_DIR = Path(__file__).parent.parent.parent + SAMPLE_DIR = BASE_DIR / "samples" + OUTPUT_DIR = Path(__file__).parent / "output" + + dxf_file = SAMPLE_DIR / "洗浴中心C-48.dxf" + if not dxf_file.exists(): + print(f"错误:文件不存在 -> {dxf_file}") + sys.exit(1) + + run_detection(str(dxf_file), str(OUTPUT_DIR)) diff --git a/experiments/dxf-parser/main.py b/experiments/dxf-parser/main.py index 679e286..315d7a5 100644 --- a/experiments/dxf-parser/main.py +++ b/experiments/dxf-parser/main.py @@ -7,6 +7,7 @@ import ezdxf from layer_analysis import analyze_layers from entity_analysis import analyze_entities +from element_detector import ElementDetector # 路径配置 BASE_DIR = Path(__file__).parent.parent.parent @@ -143,6 +144,28 @@ def main(): ) print(f"JSON 已保存到: {output_json}") + # ========== 第二阶段:元素识别 ========== + print(f"\n{'─' * 56}") + print(f" 开始元素识别...") + detector = ElementDetector() + detection_result = detector.detect_all(doc) + + # 输出元素检测 JSON + detection_json = OUTPUT_DIR / f"{DXF_FILE.stem}_element_detection.json" + detection_json.write_text( + json.dumps(detection_result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"元素检测结果已保存到: {detection_json}") + + # 输出统计报告 + from element_detector import generate_report + report = generate_report(detection_result) + report_path = OUTPUT_DIR / f"{DXF_FILE.stem}_element_report.txt" + report_path.write_text(report, encoding="utf-8") + print(f"分析报告已保存到: {report_path}") + print(report) + if __name__ == "__main__": main()