Merge pull request 'feat(asset-catalog): 简化分析工具+新增汇总和AI推理脚本' (#24) from feature/asset-catalog-simplify into main
Reviewed-on: http://micecola.top:8418/starmen1265/ChildHood/pulls/24
This commit was merged in pull request #24.
This commit is contained in:
@@ -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 列表
|
||||
- 材质槽位
|
||||
- 包围盒 6 面候选
|
||||
- 射线检测细节包围盒(spawn + Line Trace)
|
||||
- AI 占位字段(key_surfaces, style_tags, scene_tags 等)
|
||||
|
||||
输出: output/meshes/<SubfolderName>.json,每个子目录一个文件
|
||||
@@ -18,7 +16,6 @@
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
@@ -28,7 +25,7 @@ if str(_SCRIPT_DIR) not in sys.path:
|
||||
from _common import (
|
||||
HAS_UNREAL, get_scan_root, get_output_dir, get_steps,
|
||||
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]"
|
||||
@@ -263,12 +260,9 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
||||
"mesh_complexity": {},
|
||||
"sockets": [],
|
||||
"material_slots": [],
|
||||
"detail_boxes": [],
|
||||
"bbox_faces": [],
|
||||
"analysis_notes": [],
|
||||
# AI Pass 2 占位
|
||||
"key_surfaces": [],
|
||||
"detail_boxes_annotated": [],
|
||||
"ground_sink_estimate": None,
|
||||
"style_tags": [],
|
||||
"scene_tags": [],
|
||||
@@ -301,39 +295,20 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
||||
# ---- 2. 锚点 ----
|
||||
result["pivot"] = classify_pivot(bmin, bmax)
|
||||
|
||||
# ---- 3. 包围盒面 ----
|
||||
result["bbox_faces"] = compute_bbox_faces(bmin, bmax)
|
||||
|
||||
except Exception as e:
|
||||
result["analysis_notes"].append(f"包围盒提取失败: {e}")
|
||||
return result
|
||||
|
||||
# ---- 4. 网格复杂度 ----
|
||||
# ---- 3. 网格复杂度 ----
|
||||
result["mesh_complexity"] = _try_get_complexity(mesh)
|
||||
|
||||
# ---- 5. Sockets ----
|
||||
# ---- 4. Sockets ----
|
||||
result["sockets"] = _try_get_sockets(mesh)
|
||||
|
||||
# ---- 6. 材质槽位 ----
|
||||
# ---- 5. 材质槽位 ----
|
||||
result["material_slots"] = _try_get_material_slots(mesh)
|
||||
|
||||
# ---- 7. 细节包围盒 ----
|
||||
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 ----
|
||||
# ---- 6. 自动 Tags ----
|
||||
style_tags, scene_tags = _generate_mesh_tags(name, subfolder)
|
||||
result["style_tags"] = style_tags
|
||||
result["scene_tags"] = scene_tags
|
||||
@@ -341,198 +316,6 @@ def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
||||
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