feat: 新增 CAD 元素识别模块,支持柱子/文字/墙体候选检测
- 新增 element_detector.py,包含 ColumnDetector、TextExtractor、WallCandidateDetector - ColumnDetector:通过 INSERT 块名关键词识别柱子候选 - TextExtractor:提取 TEXT/MTEXT 内容、坐标、图层 - WallCandidateDetector:按长度阈值筛选 LINE/LWPOLYLINE,排除标注图层 - 采用 OOP 设计,所有规则可配置,保留原始 handle 追溯 - 输出 element_detection.json 和 element_report.txt - 更新 main.py 集成元素识别步骤
This commit is contained in:
@@ -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 规范。
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user