feat(ai-art): 户型图识别技能与qwen解析评测工具

This commit is contained in:
2026-06-21 23:28:59 +08:00
parent 6319ab331a
commit a37dfa505a
25 changed files with 2266 additions and 0 deletions
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ChildHood 户型图识别评测工具
通过 vision 大模型把户型图解析成结构化 JSON(房间 / 朝向 / 窗户形态),
用于「路径 B — 户型图推导」的"文字钉死"环节,替代人工逐项校验。
配套:与 image_gen.py 同一工具链(共用 ai-art-research/.env、OpenAI 兼容客户端、
生成结果/ 输出目录、.meta.json 元数据约定)。
Usage:
python floorplan_eval.py --image 户型图.png # 默认 provider + 默认模型
python floorplan_eval.py --image 户型图.png -m qwen-vl-max # 指定模型
python floorplan_eval.py --image 户型图.png --provider dashscope # 走阿里云百炼直连
python floorplan_eval.py --image 户型图.png --prompt-file 自定义.txt # 自定义提取 prompt
Requirements:
pip install openai python-dotenv
"""
import argparse
import base64
import io
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
# Windows 控制台 UTF-8 支持(用 reconfigure 幂等重配,避免被其他脚本 import 时
# 产生双层 TextIOWrapper 包装、损坏输出流)
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ---------------------------------------------------------------------------
# 路径配置
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent # ai-art-research/tools/
RESEARCH_DIR = SCRIPT_DIR.parent # ai-art-research/
DOTENV_PATH = RESEARCH_DIR / ".env"
OUTPUT_BASE = RESEARCH_DIR / "生成结果" / "户型图识别评测"
# ---------------------------------------------------------------------------
# 加载 .env
# ---------------------------------------------------------------------------
if DOTENV_PATH.exists():
from dotenv import load_dotenv
load_dotenv(DOTENV_PATH)
# provider 配置
APIYI_API_KEY = os.getenv("APIYI_API_KEY", "")
APIYI_BASE_URL = os.getenv("APIYI_BASE_URL", "https://api.apiyi.com/v1")
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# 各 provider 的默认模型
DEFAULT_MODEL = {
"apiyi": "qwen-vl-max",
"dashscope": "qwen-vl-max-latest",
}
# ---------------------------------------------------------------------------
# 统一提取 prompt(核心:直接对接路径 B 的"文字钉死")
# ---------------------------------------------------------------------------
EXTRACT_PROMPT = """\
你是一个专业的户型图解析助手。请仔细分析这张户型图,提取结构化信息。
【户型图符号约定】
- 墙体:粗实线或双线
- 门:弧线(表示开门方向)
- 窗:细的双平行线
- 阳台:半开放区域,常带栏杆符号
- 房间名:通常文字标注在房间内部(客厅/卧室/厨房/卫生间/阳台/餐厅/玄关等)
- 面积:常以 m² 数字标注在房间内或户型角落
- 尺寸标尺:墙体旁的尺寸线(细线 + 两端短斜线/箭头)+ 数字,标注墙段长度 / 房间长宽,单位多为米(m)或毫米(mm)
【严格规则】
1. 只描述图上实际可见、或可明确推断的结构。禁止用"住宅常识"脑补——
不要默认卧室有大窗、阳台是落地窗、北面是厨房。
2. 看不清或不确定的字段,一律填 "未知",绝不瞎猜。
3. **重点提取尺寸标尺**:户型图外墙周边常有尺寸线(细线 + 数字)标注各开间/进深分段。请尽量读出南向开间分段、北向开间分段、进深分段的数字(多为米),填入"尺寸标注"——这是推导立面窗户水平比例的关键;看不清的分段填"未知"。
4. 客餐厅若为一体空间(无明显实体隔断),合并为一个"客餐厅",不要拆成"客厅"+"餐厅"两个。
5. 多个阳台若朝向不同,分别标注各自朝向(如一个朝南、一个朝北)。
6. 输出必须是纯 JSON,不要任何解释文字、不要用 ```json 代码块包裹、不要前后缀。
【输出 JSON 结构】
{
"整体朝向": "主采光面朝向,如 南/北/东南;看不清填 未知",
"户型结构": "几室几厅几卫,如 三室两厅一卫",
"总面积": "图上标注的建筑面积,如 约 95 m²;无标注填 未知",
"房间": [
{
"名称": "客厅/主卧/次卧/厨房/卫生间/阳台/餐厅/玄关...",
"朝向": "该房间主要采光朝向:南/北/东/西",
"面积估算": "图上有标注则填,否则 未知",
"长宽尺寸": "图上标尺标注的房间长与宽,如 3.6m×4.2m;无标尺填 未知",
"窗户": [
{"所在立面": "南/北/东/西", "类型": "落地窗/普通窗/飘窗/无", "数量": 1, "推测大小": "大/中/小/未知"}
],
"是否带阳台": "是/否/未知",
"备注": "其他可见特征,如 是否飘窗、是否暗卫(无窗)等"
}
],
"尺寸标注": {
"说明": "图上外墙周边标尺的数字,用于推导立面窗户的水平开间比例",
"南向开间分段": ["如 3.6m", "4.2m"],
"北向开间分段": ["如 3.0m", "4.8m"],
"进深分段": ["如 5.1m", "3.2m"],
"总开间": "南立面总宽(有则填,无则未知)",
"总进深": "进深总长(有则填,无则未知)"
},
"立面对应表": {
"说明": "哪些房间的窗户落在哪个外立面,用于推导正投影立面",
"南立面": ["客厅", "主卧"],
"北立面": ["厨房", "卫生间"]
},
"识别备注": "图纸清晰度、符号可能误读之处、需要人工重点核对的项"
}
现在请解析这张户型图,只输出 JSON。"""
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def get_client(provider: str):
"""根据 provider 创建 OpenAI 兼容客户端,返回 (client, default_model)"""
from openai import OpenAI
if provider == "apiyi":
if not APIYI_API_KEY:
print("❌ 未找到 APIYI_API_KEY")
print(f" 请在 {DOTENV_PATH} 中设置: APIYI_API_KEY=sk-xxx")
sys.exit(1)
return OpenAI(api_key=APIYI_API_KEY, base_url=APIYI_BASE_URL)
elif provider == "dashscope":
if not DASHSCOPE_API_KEY:
print("❌ 未找到 DASHSCOPE_API_KEY")
print(" 阿里云百炼直连需要单独的 key:")
print(" 1. 前往 https://bailian.console.aliyun.com 申请 API Key")
print(f" 2. 在 {DOTENV_PATH} 中添加: DASHSCOPE_API_KEY=sk-xxx")
sys.exit(1)
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
else:
print(f"❌ 未知 provider: {provider}(支持: apiyi / dashscope)")
sys.exit(1)
_EXT_MIME = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".gif": "image/gif",
}
def encode_image(path: Path) -> str:
"""读图为 base64 data URL"""
mime = _EXT_MIME.get(path.suffix.lower(), "image/png")
b64 = base64.b64encode(path.read_bytes()).decode()
return f"data:{mime};base64,{b64}"
def parse_floorplan(client, model: str, image_path: Path, prompt: str) -> str:
"""调用 vision 模型,返回原始文本输出"""
data_url = encode_image(image_path)
response = client.chat.completions.create(
model=model,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}],
)
return response.choices[0].message.content
def extract_json(text: str):
"""从模型输出中尽量鲁棒地提取 JSON dict;失败返回 None"""
text = text.strip()
# 1. 直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 2. 去掉 ```json ... ``` 代码块包裹
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fenced:
try:
return json.loads(fenced.group(1))
except json.JSONDecodeError:
pass
# 3. 截取第一个 { 到最后一个 }
start, end = text.find("{"), text.rfind("}")
if start != -1 and end != -1 and end > start:
candidate = text[start:end + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
# 4. 容错:清理常见问题(中文逗号、尾随逗号)后再试
cleaned = (candidate
.replace(",", ",")
.replace(":", ":"))
cleaned = re.sub(r",(\s*[}\]])", r"\1", cleaned) # 去尾随逗号
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
return None
def save_result(result, metadata: dict, subdir: str = "") -> Path:
"""保存解析结果 JSON + .meta.json,返回结果文件路径"""
ts = datetime.now().strftime("%y%m%d-%H%M%S")
model_slug = metadata.get("model", "unknown").replace("/", "-").replace(".", "-")
provider_slug = metadata.get("provider", "unknown")
base = f"{ts}_{provider_slug}_{model_slug}"
out_dir = OUTPUT_BASE / subdir if subdir else OUTPUT_BASE
out_dir.mkdir(parents=True, exist_ok=True)
json_path = out_dir / f"{base}.json"
json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
meta_path = out_dir / f"{base}.meta.json"
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 已保存: {json_path.name}")
return json_path
def summarize(parsed: dict) -> str:
"""从解析结果里摘出要点用于控制台预览"""
if not isinstance(parsed, dict):
return "(非结构化结果)"
lines = []
if parsed.get("户型结构"):
lines.append(f" 户型: {parsed['户型结构']}")
if parsed.get("整体朝向"):
lines.append(f" 朝向: {parsed['整体朝向']}")
if parsed.get("总面积"):
lines.append(f" 面积: {parsed['总面积']}")
rooms = parsed.get("房间", [])
if isinstance(rooms, list):
lines.append(f" 房间数: {len(rooms)}")
for r in rooms:
if isinstance(r, dict):
win_n = len(r.get("窗户", [])) if isinstance(r.get("窗户"), list) else 0
lines.append(f" - {r.get('名称','?')} 朝向={r.get('朝向','?')} 窗={win_n} 阳台={r.get('是否带阳台','?')}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI 入口
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="ChildHood 户型图识别评测工具 — vision 模型解析户型图为结构化 JSON",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
示例:
python floorplan_eval.py --image 参考图/真实场景/户型图/QQ20260618-213247.png
python floorplan_eval.py --image 户型.png -m qwen-vl-max
python floorplan_eval.py --image 户型.png -m gemini-2.5-flash # baseline 对照
python floorplan_eval.py --image 户型.png --provider dashscope # 百炼直连
环境变量 (ai-art-research/.env):
APIYI_API_KEY API易 令牌 (apiyi provider 用,复用 image_gen.py)
DASHSCOPE_API_KEY 阿里云百炼 key (dashscope provider 用)
""",
)
parser.add_argument("-i", "--image", type=str, required=True, help="户型图路径")
parser.add_argument("-m", "--model", type=str, help="vision 模型名(默认随 provider)")
parser.add_argument("--provider", type=str, default="apiyi",
choices=["apiyi", "dashscope"], help="API 来源(默认 apiyi 复用现有 key)")
parser.add_argument("--prompt-file", type=str, help="自定义提取 prompt 文件(默认用内置 EXTRACT_PROMPT)")
parser.add_argument("--out", type=str, help="自定义输出 JSON 路径(默认存 生成结果/户型图识别评测/)")
parser.add_argument("--raw", action="store_true", help="额外保存模型原始输出文本(便于排查解析失败)")
parser.add_argument("--subdir", type=str, default="", help="输出子目录名")
args = parser.parse_args()
image_path = Path(args.image).resolve()
if not image_path.exists():
print(f"❌ 户型图不存在: {image_path}")
sys.exit(1)
if args.prompt_file:
pf = Path(args.prompt_file).resolve()
if not pf.exists():
print(f"❌ prompt 文件不存在: {pf}")
sys.exit(1)
prompt = pf.read_text(encoding="utf-8")
print(f"📄 从文件读取 prompt: {pf.name}")
else:
prompt = EXTRACT_PROMPT
client = get_client(args.provider)
model = args.model or DEFAULT_MODEL[args.provider]
print(f"🏠 户型图识别")
print(f" 图片: {image_path.name}")
print(f" Provider: {args.provider} 模型: {model}")
print()
raw = parse_floorplan(client, model, image_path, prompt)
parsed = extract_json(raw)
metadata = {
"provider": args.provider,
"model": model,
"image": str(image_path),
"prompt": prompt,
"parse_success": parsed is not None,
"timestamp": datetime.now().isoformat(),
}
if parsed is None:
print("⚠️ 未能从模型输出中解析出 JSON(可能模型没遵守格式约束)。")
print(" 原始输出前 500 字:")
print(" " + raw[:500].replace("\n", "\n "))
print()
print("💡 建议:换模型重试,或加 --raw 保留原文人工查看。")
# 保存
if args.out:
out_path = Path(args.out).resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
payload = parsed if parsed is not None else {"_raw": raw}
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
meta_path = out_path.with_suffix(".meta.json")
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 已保存: {out_path.name}")
result_path = out_path
else:
payload = parsed if parsed is not None else {"_raw": raw}
result_path = save_result(payload, metadata, subdir=args.subdir)
if args.raw:
raw_path = result_path.with_suffix(".raw.txt")
raw_path.write_text(raw, encoding="utf-8")
print(f"📝 原始输出: {raw_path.name}")
print()
if parsed is not None:
print("📋 解析预览:")
print(summarize(parsed))
print()
print(f"📍 完整结果: {result_path}")
print()
print("🔍 下一步:对照原图人工核对(重点看 房间是否漏识别、窗户数量/类型是否对)。")
if __name__ == "__main__":
main()