feat(asset-catalog): 简化分析工具+新增汇总和AI推理脚本 #24
@@ -0,0 +1,366 @@
|
|||||||
|
"""GLM API 批量推理脚本 — 资产语义增强。
|
||||||
|
|
||||||
|
读取 asset_database.json,按类别分批调用 GLM API(OpenAI 兼容接口),
|
||||||
|
推断每个资产的朝向、功能、交互属性等语义信息。
|
||||||
|
|
||||||
|
此脚本不依赖 UE5,可离线运行。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
GLM_API_KEY — API Key(必需)
|
||||||
|
GLM_API_BASE — API Base URL(默认 https://open.bigmodel.cn/api/paas/v4)
|
||||||
|
GLM_MODEL — 模型名(默认 glm-4-flash)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python ai_inference.py [--input FILE] [--output FILE] [--batch-size N] [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_SCRIPT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||||
|
|
||||||
|
from _common import read_json, write_json, iso_now
|
||||||
|
|
||||||
|
TAG = "[AIInference]"
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
DEFAULT_API_BASE = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
DEFAULT_MODEL = "glm-4-flash"
|
||||||
|
DEFAULT_BATCH_SIZE = 20
|
||||||
|
|
||||||
|
# ---- Prompt 模板 ----
|
||||||
|
SYSTEM_PROMPT = """你是一个 UE5 游戏开发助手,专注于为 3D 模型资产提取语义信息。
|
||||||
|
|
||||||
|
你会收到一批 StaticMesh 资产的几何数据(名称、尺寸、锚点、材质等),
|
||||||
|
需要推断每个资产的以下语义字段:
|
||||||
|
|
||||||
|
1. facing_direction: 主朝向方向
|
||||||
|
- 格式: "+X", "-X", "+Y", "-Y", null
|
||||||
|
- 判断依据: 根据资产类型和包围盒尺寸推断"正面"方向
|
||||||
|
- 对称物体(桌子、球、圆柱等)设为 null
|
||||||
|
- 电视/显示器: 屏幕面方向; 门: 开门方向; 椅子: 坐面方向
|
||||||
|
|
||||||
|
2. surface_height_cm: 可用水平表面高度 (cm)
|
||||||
|
- 如桌面高度、台面高度、床面高度
|
||||||
|
- 对于无水平表面的物体(墙体、栏杆)设为 null
|
||||||
|
- 使用锚点类型判断: CENTER_BOTTOM 时直接取 max.z
|
||||||
|
|
||||||
|
3. function_description: 一句话功能描述 (中文, ≤50字)
|
||||||
|
|
||||||
|
4. placement_notes: 放置注意事项 (中文, ≤80字)
|
||||||
|
- 锚点位置提醒、旋转建议、缩放注意等
|
||||||
|
|
||||||
|
5. interaction_hints: 交互提示标签数组
|
||||||
|
- 可选值: can_sit, can_place_items, can_open, can_interact_screen,
|
||||||
|
climbable, electronics, lighting, container, decoration,
|
||||||
|
pass_through, driveable, weapon, vegetation
|
||||||
|
- 可多选
|
||||||
|
|
||||||
|
6. plausible_substitutes: BSP 基础体降级方案 (中文, 或 null)
|
||||||
|
- 当原始资产不可用时,用什么 Cube/Cylinder + 缩放 + 材质替代
|
||||||
|
|
||||||
|
你必须返回严格的 JSON 数组,每个元素对应输入中的一个资产,顺序一致。
|
||||||
|
不要输出任何 JSON 之外的内容。"""
|
||||||
|
|
||||||
|
BATCH_PROMPT_TEMPLATE = """以下是 {category} 类别的 {count} 个 StaticMesh 资产数据:
|
||||||
|
|
||||||
|
{assets_json}
|
||||||
|
|
||||||
|
请为每个资产推断语义信息,返回 JSON 数组。每个元素包含:
|
||||||
|
facing_direction, surface_height_cm, function_description,
|
||||||
|
placement_notes, interaction_hints, plausible_substitutes"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_config():
|
||||||
|
"""从环境变量读取 API 配置。"""
|
||||||
|
api_key = os.environ.get("GLM_API_KEY", "")
|
||||||
|
api_base = os.environ.get("GLM_API_BASE", DEFAULT_API_BASE)
|
||||||
|
model = os.environ.get("GLM_MODEL", DEFAULT_MODEL)
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
print(f"{TAG} 错误: 未设置 GLM_API_KEY 环境变量")
|
||||||
|
print(f"{TAG} 设置方法: set GLM_API_KEY=your_key_here")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_key": api_key,
|
||||||
|
"api_base": api_base,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_batch_category(tags: dict) -> str:
|
||||||
|
"""根据 tags 推断批次类别名。"""
|
||||||
|
style = tags.get("style", [])
|
||||||
|
if not style:
|
||||||
|
return "未分类"
|
||||||
|
# 取第一个非空 style tag 作为类别
|
||||||
|
return style[0] if style[0] != "unknown" else "未分类"
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_batches(assets: dict, batch_size: int) -> list:
|
||||||
|
"""将资产按类别分批。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of (category_name, batch_index, [asset_keys])
|
||||||
|
"""
|
||||||
|
# 按类别分组
|
||||||
|
by_category = {}
|
||||||
|
for name, data in assets.items():
|
||||||
|
cat = _classify_batch_category(data.get("tags", {}))
|
||||||
|
by_category.setdefault(cat, []).append(name)
|
||||||
|
|
||||||
|
# 每个类别内按 batch_size 切分
|
||||||
|
batches = []
|
||||||
|
for cat, keys in sorted(by_category.items()):
|
||||||
|
for i in range(0, len(keys), batch_size):
|
||||||
|
batch_keys = keys[i:i + batch_size]
|
||||||
|
batches.append((cat, i // batch_size + 1, batch_keys))
|
||||||
|
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def _format_asset_for_prompt(name: str, data: dict) -> dict:
|
||||||
|
"""提取 prompt 中需要的精简字段。"""
|
||||||
|
bb = data.get("bounding_box", {})
|
||||||
|
pivot = data.get("pivot", {})
|
||||||
|
tags = data.get("tags", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"size_cm": bb.get("size"),
|
||||||
|
"pivot_type": pivot.get("type"),
|
||||||
|
"pivot_offset_xy": pivot.get("offset_xy"),
|
||||||
|
"material_count": len(data.get("material_slots", [])),
|
||||||
|
"socket_count": len(data.get("sockets", [])),
|
||||||
|
"tags_style": tags.get("style", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_glm_api(config: dict, category: str, assets_json: str) -> list:
|
||||||
|
"""调用 GLM API 进行推理。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
推理结果列表,失败时返回空列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError:
|
||||||
|
print(f"{TAG} 错误: 需要安装 openai 包")
|
||||||
|
print(f"{TAG} pip install openai")
|
||||||
|
return []
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=config["api_key"],
|
||||||
|
base_url=config["api_base"],
|
||||||
|
)
|
||||||
|
|
||||||
|
user_prompt = BATCH_PROMPT_TEMPLATE.format(
|
||||||
|
category=category,
|
||||||
|
count=assets_json.count('"name"'),
|
||||||
|
assets_json=assets_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=config["model"],
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{TAG} API 调用失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = response.choices[0].message.content.strip()
|
||||||
|
if not content:
|
||||||
|
print(f"{TAG} API 返回空内容")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 解析 JSON
|
||||||
|
try:
|
||||||
|
result = json.loads(content)
|
||||||
|
# 如果返回的是 {"results": [...]} 形式,提取数组
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for key in ("results", "data", "assets", "items"):
|
||||||
|
if key in result and isinstance(result[key], list):
|
||||||
|
return result[key]
|
||||||
|
# 如果找不到数组,可能是单条结果被包在 dict 里
|
||||||
|
print(f"{TAG} 警告: 返回 JSON 不是数组,尝试整体作为单条: {list(result.keys())}")
|
||||||
|
return [result]
|
||||||
|
if isinstance(result, list):
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"{TAG} JSON 解析失败: {e}")
|
||||||
|
print(f"{TAG} 原始响应前 200 字: {content[:200]}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def run_inference(
|
||||||
|
input_file: Path,
|
||||||
|
output_file: Path,
|
||||||
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""执行批量 AI 推理。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: asset_database.json 路径
|
||||||
|
output_file: 输出路径(通常与 input 相同,就地更新)
|
||||||
|
batch_size: 每批资产数量
|
||||||
|
dry_run: 只打印批次信息,不调用 API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计信息 dict
|
||||||
|
"""
|
||||||
|
# 读取数据库
|
||||||
|
if not input_file.exists():
|
||||||
|
print(f"{TAG} 错误: {input_file} 不存在,请先运行 build_database")
|
||||||
|
return {"status": "error", "reason": "no input file"}
|
||||||
|
|
||||||
|
db = read_json(input_file)
|
||||||
|
assets = db.get("assets", {})
|
||||||
|
if not assets:
|
||||||
|
print(f"{TAG} 错误: 数据库中无资产")
|
||||||
|
return {"status": "error", "reason": "empty assets"}
|
||||||
|
|
||||||
|
# 过滤: 跳过已推理的
|
||||||
|
pending = {}
|
||||||
|
for name, data in assets.items():
|
||||||
|
if data.get("ai_inference") is not None:
|
||||||
|
continue
|
||||||
|
pending[name] = data
|
||||||
|
|
||||||
|
print(f"{TAG} 数据库共 {len(assets)} 个资产,待推理 {len(pending)} 个")
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
print(f"{TAG} 全部已推理,无需处理")
|
||||||
|
return {"status": "ok", "total": len(assets), "inferred": 0, "skipped": len(assets)}
|
||||||
|
|
||||||
|
# 分批
|
||||||
|
batches = _prepare_batches(pending, batch_size)
|
||||||
|
print(f"{TAG} 分为 {len(batches)} 批")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for cat, idx, keys in batches:
|
||||||
|
print(f"{TAG} 批次 [{cat}] #{idx}: {len(keys)} 个资产")
|
||||||
|
for k in keys[:3]:
|
||||||
|
print(f"{TAG} - {k}")
|
||||||
|
if len(keys) > 3:
|
||||||
|
print(f"{TAG} ... 还有 {len(keys) - 3} 个")
|
||||||
|
return {"status": "ok", "dry_run": True, "batches": len(batches)}
|
||||||
|
|
||||||
|
# 获取 API 配置
|
||||||
|
config = _get_api_config()
|
||||||
|
if not config:
|
||||||
|
return {"status": "error", "reason": "no api key"}
|
||||||
|
|
||||||
|
print(f"{TAG} API: {config['api_base']} 模型: {config['model']}")
|
||||||
|
|
||||||
|
# 逐批推理
|
||||||
|
total_inferred = 0
|
||||||
|
total_failed = 0
|
||||||
|
|
||||||
|
for batch_idx, (cat, batch_num, keys) in enumerate(batches):
|
||||||
|
print(f"{TAG} 批次 [{batch_idx + 1}/{len(batches)}] [{cat}] #{batch_num}: {len(keys)} 个资产...")
|
||||||
|
|
||||||
|
# 构造 prompt 数据
|
||||||
|
prompt_assets = []
|
||||||
|
for k in keys:
|
||||||
|
prompt_assets.append(_format_asset_for_prompt(k, pending[k]))
|
||||||
|
|
||||||
|
assets_json = json.dumps(prompt_assets, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 调用 API
|
||||||
|
results = _call_glm_api(config, cat, assets_json)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
print(f"{TAG} ⚠️ 推理失败,跳过此批")
|
||||||
|
total_failed += len(keys)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 合并结果
|
||||||
|
matched = 0
|
||||||
|
for i, k in enumerate(keys):
|
||||||
|
if i < len(results):
|
||||||
|
assets[k]["ai_inference"] = results[i]
|
||||||
|
matched += 1
|
||||||
|
total_inferred += 1
|
||||||
|
else:
|
||||||
|
print(f"{TAG} ⚠️ {k}: API 返回结果不足")
|
||||||
|
|
||||||
|
print(f"{TAG} ✅ 匹配 {matched}/{len(keys)}")
|
||||||
|
|
||||||
|
# 每批都保存一次(断点续跑)
|
||||||
|
db["assets"] = assets
|
||||||
|
# 更新 metadata
|
||||||
|
if "ai_inference_metadata" not in db["metadata"]:
|
||||||
|
db["metadata"]["ai_inference_metadata"] = {
|
||||||
|
"model": config["model"],
|
||||||
|
"api_base": config["api_base"],
|
||||||
|
"started_at": iso_now(),
|
||||||
|
}
|
||||||
|
db["metadata"]["ai_inference_metadata"]["last_batch"] = batch_idx + 1
|
||||||
|
db["metadata"]["ai_inference_metadata"]["total_batches"] = len(batches)
|
||||||
|
db["metadata"]["ai_inference_metadata"]["inferred"] = total_inferred
|
||||||
|
db["metadata"]["ai_inference_metadata"]["failed"] = total_failed
|
||||||
|
write_json(db, output_file)
|
||||||
|
|
||||||
|
# 限速: 避免触发 API 限流
|
||||||
|
if batch_idx < len(batches) - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
db["metadata"]["ai_inference_metadata"]["completed_at"] = iso_now()
|
||||||
|
write_json(db, output_file)
|
||||||
|
|
||||||
|
print(f"{TAG} 推理完成: {total_inferred} 成功, {total_failed} 失败")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"total": len(assets),
|
||||||
|
"inferred": total_inferred,
|
||||||
|
"failed": total_failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="GLM API 批量推理 — 资产语义增强")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input", type=str,
|
||||||
|
default=str(_SCRIPT_DIR.parent.parent / "output" / "asset-catalog" / "asset_database.json"),
|
||||||
|
help="输入 asset_database.json 路径",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=str, default=None,
|
||||||
|
help="输出路径 (默认与 input 相同,就地更新)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
|
||||||
|
help=f"每批资产数量 (默认 {DEFAULT_BATCH_SIZE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="只打印批次信息,不调用 API",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
input_file = Path(args.input)
|
||||||
|
output_file = Path(args.output) if args.output else input_file
|
||||||
|
|
||||||
|
result = run_inference(input_file, output_file, args.batch_size, args.dry_run)
|
||||||
|
if result.get("status") != "ok" and not result.get("dry_run"):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
- 网格复杂度(顶点数、三角形数)
|
- 网格复杂度(顶点数、三角形数)
|
||||||
- Socket 列表
|
- Socket 列表
|
||||||
- 材质槽位
|
- 材质槽位
|
||||||
- 包围盒 6 面候选
|
|
||||||
- 射线检测细节包围盒(spawn + Line Trace)
|
|
||||||
- AI 占位字段(key_surfaces, style_tags, scene_tags 等)
|
- AI 占位字段(key_surfaces, style_tags, scene_tags 等)
|
||||||
|
|
||||||
输出: output/meshes/<SubfolderName>.json,每个子目录一个文件
|
输出: output/meshes/<SubfolderName>.json,每个子目录一个文件
|
||||||
@@ -18,7 +16,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import math
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
@@ -28,7 +25,7 @@ if str(_SCRIPT_DIR) not in sys.path:
|
|||||||
from _common import (
|
from _common import (
|
||||||
HAS_UNREAL, get_scan_root, get_output_dir, get_steps,
|
HAS_UNREAL, get_scan_root, get_output_dir, get_steps,
|
||||||
write_json, read_json, wait_for_editor, progress_log,
|
write_json, read_json, wait_for_editor, progress_log,
|
||||||
iso_now, safe_subfolder_name, classify_pivot, compute_bbox_faces,
|
iso_now, safe_subfolder_name, classify_pivot,
|
||||||
)
|
)
|
||||||
|
|
||||||
TAG = "[MeshAnalysis]"
|
TAG = "[MeshAnalysis]"
|
||||||
@@ -263,12 +260,9 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
|||||||
"mesh_complexity": {},
|
"mesh_complexity": {},
|
||||||
"sockets": [],
|
"sockets": [],
|
||||||
"material_slots": [],
|
"material_slots": [],
|
||||||
"detail_boxes": [],
|
|
||||||
"bbox_faces": [],
|
|
||||||
"analysis_notes": [],
|
"analysis_notes": [],
|
||||||
# AI Pass 2 占位
|
# AI Pass 2 占位
|
||||||
"key_surfaces": [],
|
"key_surfaces": [],
|
||||||
"detail_boxes_annotated": [],
|
|
||||||
"ground_sink_estimate": None,
|
"ground_sink_estimate": None,
|
||||||
"style_tags": [],
|
"style_tags": [],
|
||||||
"scene_tags": [],
|
"scene_tags": [],
|
||||||
@@ -301,39 +295,20 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
|||||||
# ---- 2. 锚点 ----
|
# ---- 2. 锚点 ----
|
||||||
result["pivot"] = classify_pivot(bmin, bmax)
|
result["pivot"] = classify_pivot(bmin, bmax)
|
||||||
|
|
||||||
# ---- 3. 包围盒面 ----
|
|
||||||
result["bbox_faces"] = compute_bbox_faces(bmin, bmax)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["analysis_notes"].append(f"包围盒提取失败: {e}")
|
result["analysis_notes"].append(f"包围盒提取失败: {e}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ---- 4. 网格复杂度 ----
|
# ---- 3. 网格复杂度 ----
|
||||||
result["mesh_complexity"] = _try_get_complexity(mesh)
|
result["mesh_complexity"] = _try_get_complexity(mesh)
|
||||||
|
|
||||||
# ---- 5. Sockets ----
|
# ---- 4. Sockets ----
|
||||||
result["sockets"] = _try_get_sockets(mesh)
|
result["sockets"] = _try_get_sockets(mesh)
|
||||||
|
|
||||||
# ---- 6. 材质槽位 ----
|
# ---- 5. 材质槽位 ----
|
||||||
result["material_slots"] = _try_get_material_slots(mesh)
|
result["material_slots"] = _try_get_material_slots(mesh)
|
||||||
|
|
||||||
# ---- 7. 细节包围盒 ----
|
# ---- 6. 自动 Tags ----
|
||||||
detail_boxes, trace_debug = _try_trace_detail_boxes(mesh, bmin, bmax)
|
|
||||||
if trace_debug:
|
|
||||||
result["analysis_notes"].extend(trace_debug)
|
|
||||||
if detail_boxes:
|
|
||||||
result["detail_boxes"] = detail_boxes
|
|
||||||
else:
|
|
||||||
# 降级: 仅整体包围盒
|
|
||||||
result["detail_boxes"] = [{
|
|
||||||
"label": "overall",
|
|
||||||
"min": _round3(list(bmin)),
|
|
||||||
"max": _round3(list(bmax)),
|
|
||||||
"size": _round3(list(size)),
|
|
||||||
"source": "overall",
|
|
||||||
}]
|
|
||||||
|
|
||||||
# ---- 8. 自动 Tags ----
|
|
||||||
style_tags, scene_tags = _generate_mesh_tags(name, subfolder)
|
style_tags, scene_tags = _generate_mesh_tags(name, subfolder)
|
||||||
result["style_tags"] = style_tags
|
result["style_tags"] = style_tags
|
||||||
result["scene_tags"] = scene_tags
|
result["scene_tags"] = scene_tags
|
||||||
@@ -341,198 +316,6 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Line Trace 细节包围盒
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# UE5.7 Python correct enum names (discovered via probe)
|
|
||||||
_TRACE_CHANNEL = None
|
|
||||||
_DRAW_DEBUG = None
|
|
||||||
|
|
||||||
def _get_trace_enums():
|
|
||||||
"""Lazy-init: get the correct UE5.7 trace enums."""
|
|
||||||
global _TRACE_CHANNEL, _DRAW_DEBUG
|
|
||||||
if _TRACE_CHANNEL is not None:
|
|
||||||
return _TRACE_CHANNEL is not None
|
|
||||||
import unreal
|
|
||||||
try:
|
|
||||||
_TRACE_CHANNEL = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1
|
|
||||||
_DRAW_DEBUG = unreal.DrawDebugTrace.NONE
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _try_trace_detail_boxes(mesh, bmin: tuple, bmax: tuple) -> tuple:
|
|
||||||
"""通过 Line Trace 获取细节包围盒。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(detail_boxes, debug_notes)
|
|
||||||
"""
|
|
||||||
import unreal
|
|
||||||
|
|
||||||
detail_boxes = []
|
|
||||||
debug = []
|
|
||||||
|
|
||||||
size = (bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2])
|
|
||||||
if max(size) < 10:
|
|
||||||
return [], ["skip_trace: model too small"]
|
|
||||||
|
|
||||||
# Check trace API availability
|
|
||||||
if not _get_trace_enums():
|
|
||||||
return [], ["trace_unavailable: enums not found"]
|
|
||||||
|
|
||||||
# ---- Spawn temporary Actor ----
|
|
||||||
try:
|
|
||||||
editor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
||||||
if not editor_subsystem:
|
|
||||||
return [], ["trace_fail: no subsystem"]
|
|
||||||
except Exception as e:
|
|
||||||
return [], [f"trace_fail: {e}"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
actor = editor_subsystem.spawn_actor_from_object(mesh, unreal.Vector(0, 0, 0))
|
|
||||||
except Exception as e:
|
|
||||||
return [], [f"trace_fail: spawn {e}"]
|
|
||||||
|
|
||||||
if not actor:
|
|
||||||
return [], ["trace_fail: spawn None"]
|
|
||||||
|
|
||||||
# Set collision to BlockAll so traces can hit this mesh
|
|
||||||
smc = actor.get_component_by_class(unreal.StaticMeshComponent)
|
|
||||||
if smc:
|
|
||||||
try:
|
|
||||||
smc.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
|
||||||
smc.set_collision_profile_name("BlockAll")
|
|
||||||
except Exception as e:
|
|
||||||
debug.append(f"collision_set_warn: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
hit_points = _perform_line_traces(actor, bmin, bmax)
|
|
||||||
debug.append(f"trace_hits: {len(hit_points)}")
|
|
||||||
|
|
||||||
if len(hit_points) >= 4:
|
|
||||||
detail_boxes = _cluster_hit_points(hit_points, bmin, bmax)
|
|
||||||
if detail_boxes:
|
|
||||||
debug.append(f"trace_boxes: {len(detail_boxes)}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return detail_boxes, debug
|
|
||||||
|
|
||||||
|
|
||||||
def _perform_line_traces(actor, bmin: tuple, bmax: tuple) -> list:
|
|
||||||
"""多方向 Line Trace 收集命中点。"""
|
|
||||||
import unreal
|
|
||||||
|
|
||||||
hit_points = []
|
|
||||||
mn_x, mn_y, mn_z = bmin
|
|
||||||
mx_x, mx_y, mx_z = bmax
|
|
||||||
sx, sy, sz = mx_x - mn_x, mx_y - mn_y, mx_z - mn_z
|
|
||||||
|
|
||||||
step = min(50, max(10, min(sx, sy) / 5))
|
|
||||||
margin = max(20, max(sx, sy, sz) * 0.1)
|
|
||||||
z_step = min(50, max(15, sz / 4))
|
|
||||||
|
|
||||||
trace_ch = _TRACE_CHANNEL
|
|
||||||
draw_dbg = _DRAW_DEBUG
|
|
||||||
|
|
||||||
def _trace(start_v, end_v):
|
|
||||||
try:
|
|
||||||
hit = unreal.SystemLibrary.line_trace_single(
|
|
||||||
actor, start_v, end_v,
|
|
||||||
trace_ch, False, [], draw_dbg,
|
|
||||||
)
|
|
||||||
if hit and hasattr(hit, 'impact_point'):
|
|
||||||
pt = hit.impact_point
|
|
||||||
hit_points.append((pt.x, pt.y, pt.z))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Top-down (Z axis)
|
|
||||||
z = mn_z
|
|
||||||
while z <= mx_z + 0.1:
|
|
||||||
x = mn_x + step / 2
|
|
||||||
while x <= mx_x + 0.1:
|
|
||||||
y = mn_y + step / 2
|
|
||||||
while y <= mx_y + 0.1:
|
|
||||||
_trace(unreal.Vector(x, y, mx_z + margin),
|
|
||||||
unreal.Vector(x, y, mn_z - margin))
|
|
||||||
y += step
|
|
||||||
x += step
|
|
||||||
z += z_step
|
|
||||||
|
|
||||||
# Front (Y+)
|
|
||||||
x = mn_x + step / 2
|
|
||||||
while x <= mx_x + 0.1:
|
|
||||||
z = mn_z + step / 2
|
|
||||||
while z <= mx_z + 0.1:
|
|
||||||
_trace(unreal.Vector(x, mx_y + margin, z),
|
|
||||||
unreal.Vector(x, mn_y - margin, z))
|
|
||||||
z += step
|
|
||||||
x += step
|
|
||||||
|
|
||||||
# Right (X+)
|
|
||||||
y = mn_y + step / 2
|
|
||||||
while y <= mx_y + 0.1:
|
|
||||||
z = mn_z + step / 2
|
|
||||||
while z <= mx_z + 0.1:
|
|
||||||
_trace(unreal.Vector(mx_x + margin, y, z),
|
|
||||||
unreal.Vector(mn_x - margin, y, z))
|
|
||||||
z += step
|
|
||||||
y += step
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return hit_points
|
|
||||||
|
|
||||||
|
|
||||||
def _cluster_hit_points(hit_points: list, bmin: tuple, bmax: tuple) -> list:
|
|
||||||
"""将命中点按 Z 轴聚类为细节包围盒。"""
|
|
||||||
if not hit_points:
|
|
||||||
return []
|
|
||||||
|
|
||||||
sorted_pts = sorted(hit_points, key=lambda p: p[2])
|
|
||||||
z_slices = []
|
|
||||||
current_slice = [sorted_pts[0]]
|
|
||||||
z_gap = 10.0
|
|
||||||
|
|
||||||
for i in range(1, len(sorted_pts)):
|
|
||||||
if sorted_pts[i][2] - current_slice[-1][2] > z_gap:
|
|
||||||
z_slices.append(current_slice)
|
|
||||||
current_slice = [sorted_pts[i]]
|
|
||||||
else:
|
|
||||||
current_slice.append(sorted_pts[i])
|
|
||||||
z_slices.append(current_slice)
|
|
||||||
|
|
||||||
boxes = []
|
|
||||||
for idx, pts in enumerate(z_slices):
|
|
||||||
if len(pts) < 3:
|
|
||||||
continue
|
|
||||||
xs = [p[0] for p in pts]
|
|
||||||
ys = [p[1] for p in pts]
|
|
||||||
zs = [p[2] for p in pts]
|
|
||||||
bmn = [min(xs), min(ys), min(zs)]
|
|
||||||
bmx = [max(xs), max(ys), max(zs)]
|
|
||||||
bsz = [bmx[0]-bmn[0], bmx[1]-bmn[1], bmx[2]-bmn[2]]
|
|
||||||
if bsz[0] < 2 and bsz[1] < 2:
|
|
||||||
continue
|
|
||||||
boxes.append({
|
|
||||||
"label": f"part_{idx}",
|
|
||||||
"min": _round3(bmn), "max": _round3(bmx), "size": _round3(bsz),
|
|
||||||
"source": "trace", "hit_count": len(pts),
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(boxes) > 8:
|
|
||||||
boxes.sort(key=lambda b: b["size"][0]*b["size"][1]*b["size"][2], reverse=True)
|
|
||||||
boxes = boxes[:8]
|
|
||||||
return boxes
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 主流程
|
# 主流程
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""资产数据库汇总脚本。
|
||||||
|
|
||||||
|
读取 asset-catalog 的扫描结果(meshes/*.json),汇总为统一的单文件
|
||||||
|
asset_database.json,以资产名为 key 扁平化存储。
|
||||||
|
|
||||||
|
此脚本不依赖 UE5,可离线运行。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python build_database.py [--input DIR] [--output FILE]
|
||||||
|
|
||||||
|
--input 扫描输出目录,默认 output/asset-catalog
|
||||||
|
--output 输出文件路径,默认 <input>/asset_database.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_SCRIPT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||||
|
|
||||||
|
from _common import read_json, write_json, iso_now
|
||||||
|
|
||||||
|
TAG = "[BuildDB]"
|
||||||
|
|
||||||
|
|
||||||
|
def build_database(input_dir: Path, output_file: Path) -> dict:
|
||||||
|
"""汇总 meshes/*.json 为统一数据库。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_dir: 包含 meshes/ 子目录的扫描输出根目录
|
||||||
|
output_file: 输出的 asset_database.json 路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计信息 dict
|
||||||
|
"""
|
||||||
|
meshes_dir = input_dir / "meshes"
|
||||||
|
if not meshes_dir.exists():
|
||||||
|
print(f"{TAG} 错误: {meshes_dir} 不存在,请先运行 analyze_meshes")
|
||||||
|
return {"status": "error", "reason": "no meshes dir"}
|
||||||
|
|
||||||
|
# 收集所有子目录的扫描文件
|
||||||
|
mesh_files = sorted(meshes_dir.glob("*.json"))
|
||||||
|
if not mesh_files:
|
||||||
|
print(f"{TAG} 错误: {meshes_dir} 下无 JSON 文件")
|
||||||
|
return {"status": "error", "reason": "no mesh files"}
|
||||||
|
|
||||||
|
print(f"{TAG} 发现 {len(mesh_files)} 个扫描文件")
|
||||||
|
|
||||||
|
# 扁平化合并
|
||||||
|
assets = {}
|
||||||
|
total = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for mf in mesh_files:
|
||||||
|
try:
|
||||||
|
data = read_json(mf)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{TAG} 警告: 读取 {mf.name} 失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
subfolder = data.get("subfolder", mf.stem)
|
||||||
|
meshes = data.get("meshes", {})
|
||||||
|
if not meshes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for name, mesh_data in meshes.items():
|
||||||
|
total += 1
|
||||||
|
|
||||||
|
# 跳过无包围盒的(加载失败的)
|
||||||
|
if mesh_data.get("bounding_box") is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 构建统一条目
|
||||||
|
entry = {
|
||||||
|
"ue_path": mesh_data.get("ue_path", ""),
|
||||||
|
"subfolder": subfolder,
|
||||||
|
"bounding_box": mesh_data.get("bounding_box"),
|
||||||
|
"pivot": mesh_data.get("pivot"),
|
||||||
|
"mesh_complexity": mesh_data.get("mesh_complexity", {}),
|
||||||
|
"material_slots": mesh_data.get("material_slots", []),
|
||||||
|
"sockets": mesh_data.get("sockets", []),
|
||||||
|
"tags": {
|
||||||
|
"style": mesh_data.get("style_tags", []),
|
||||||
|
"scene": mesh_data.get("scene_tags", []),
|
||||||
|
},
|
||||||
|
"ai_inference": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果资产名重复(不同子目录下同名),加后缀
|
||||||
|
key = name
|
||||||
|
if key in assets:
|
||||||
|
safe_sub = subfolder.replace("/", "_").replace("\\", "_")
|
||||||
|
key = f"{name}_{safe_sub}"
|
||||||
|
print(f"{TAG} 注意: 资产名重复 {name},使用 {key}")
|
||||||
|
|
||||||
|
assets[key] = entry
|
||||||
|
|
||||||
|
# 写入数据库
|
||||||
|
db = {
|
||||||
|
"metadata": {
|
||||||
|
"generated_at": iso_now(),
|
||||||
|
"source": "asset_catalog_v1",
|
||||||
|
"total_assets": len(assets),
|
||||||
|
"skipped_no_bbox": skipped,
|
||||||
|
"input_files": [mf.name for mf in mesh_files],
|
||||||
|
},
|
||||||
|
"assets": assets,
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(db, output_file)
|
||||||
|
print(f"{TAG} ✅ 汇总完成: {len(assets)} 个资产 → {output_file}")
|
||||||
|
print(f"{TAG} 跳过 {skipped} 个无包围盒资产 / 总计 {total}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"total": total,
|
||||||
|
"assets": len(assets),
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="汇总资产扫描数据为统一 JSON 数据库")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input", type=str,
|
||||||
|
default=str(_SCRIPT_DIR.parent.parent / "output" / "asset-catalog"),
|
||||||
|
help="扫描输出目录 (默认: output/asset-catalog)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=str, default=None,
|
||||||
|
help="输出文件路径 (默认: <input>/asset_database.json)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
input_dir = Path(args.input)
|
||||||
|
output_file = Path(args.output) if args.output else input_dir / "asset_database.json"
|
||||||
|
|
||||||
|
result = build_database(input_dir, output_file)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user