365 lines
15 KiB
Python
365 lines
15 KiB
Python
#!/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()
|