""" 从 AEB 场地计划表或 aeb_rawid.json 读取 rawid,查询每个 rawid 对应的 clip ukey 列表,并保存为可直接用于批量推理的单个 aeb_clips*.json。 默认输入: tools/pdcl_inference/G1Q3项目AEB场地计划表.xlsx 默认读取 "实际测试进展" 和 "复测case" 两个 sheet。 兼容旧流程: 也支持通过 --input 读取已生成的 aeb_rawid.json。 依赖: pip install pdcl_dss -i https://pypi.minieye.tech/ 默认输出命名: 未指定 --output 时,按 Excel 中 "CVE数据" 列的最小/最大时间自动命名, 例如 aeb_clips-20260322152509_to_20260410183944.json 输出格式: { "summary": { "total_scenarios": 123, "total_rawids": 456, "scenario_total_rawids": { "CPFA-25-6.5-20": 12 } }, "scenarios": { "CPFA-25-6.5-20": [ { "sheet": "实际测试进展", "场景": "CPFA", "偏置": "25", "目标速度": "6.5", "自车速度": "20", "CVE数据": "20260323113612", "rawid": "ADAS_...", "clips": ["clip_ukey1", ...] } ] } } """ import argparse import json import os import re import sys from pathlib import Path from typing import Dict, List, Optional, Tuple import pandas as pd FILE = Path(__file__).resolve() ROOT = FILE.parents[2] if str(ROOT) not in sys.path: sys.path.append(str(ROOT)) DEFAULT_XLSX = os.path.join(os.path.dirname(__file__), "G1Q3项目AEB场地计划表.xlsx") DEFAULT_SHEET_NAME = "实际测试进展" DEFAULT_RETEST_SHEET_NAME = "复测case" DEFAULT_SHEET_NAMES = [DEFAULT_SHEET_NAME, DEFAULT_RETEST_SHEET_NAME] DEFAULT_OUTPUT_PREFIX = "aeb_clips" os.environ.setdefault("STS_UID", "dis-uploader") os.environ.setdefault("STS_SECRET_KEY", "277310cc09724d315514a79701fecb0f") try: from dotenv import load_dotenv load_dotenv() except ImportError: pass def _to_str(val): """将单元格值转换为字符串,NaN 返回 None。""" if pd.isna(val): return None if isinstance(val, float) and val == int(val): return str(int(val)) return str(val).strip() def _build_column_index(header: List[object]) -> Dict[str, int]: """根据表头名称构建列索引映射。""" header_map = {} for idx, value in enumerate(header): name = _to_str(value) if name: header_map[name] = idx required_columns = ["场景", "偏置", "目标速度", "自车速度", "rawid", "CVE数据"] missing = [name for name in required_columns if name not in header_map] if missing: raise ValueError(f"Excel 缺少必需列: {missing};实际表头: {list(header_map.keys())}") return header_map def _normalize_cve_timestamp(val: Optional[str]) -> Optional[str]: """将 CVE 数据列清洗为可用于命名的时间串。""" if not val: return None digits = re.sub(r"\D", "", val) if len(digits) >= 14: return digits[:14] if len(digits) >= 8: return digits[:8] return None def _forward_fill_series(series: pd.Series) -> pd.Series: """仅对当前列做简单前向填充,避免 pandas 对 object 列的告警。""" filled = [] last_valid = None for value in series.tolist(): if not pd.isna(value): last_valid = value filled.append(value) else: filled.append(last_valid) return pd.Series(filled, index=series.index, dtype=object) def _build_scenario_key( scenario: Optional[str], offset: Optional[str], target_speed: Optional[str], ego_speed: Optional[str], ) -> str: """构造完整工况键:场景-偏置-目标速度-自车速度。""" parts = [ scenario or "未知工况", offset or "未知偏置", target_speed or "未知目标速度", ego_speed or "未知自车速度", ] return "-".join(parts) def _cluster_records(records: List[dict], fallback_scenario: Optional[str] = None) -> Dict[str, List[dict]]: """按完整工况键聚类记录。""" clustered: Dict[str, List[dict]] = {} for record in records: scenario = _to_str(record.get("场景")) or fallback_scenario or "未知工况" offset = _to_str(record.get("偏置")) target_speed = _to_str(record.get("目标速度")) ego_speed = _to_str(record.get("自车速度")) scenario_key = _build_scenario_key(scenario, offset, target_speed, ego_speed) normalized_record = dict(record) normalized_record["场景"] = scenario clustered.setdefault(scenario_key, []).append(normalized_record) return clustered def _merge_clustered_records(grouped_records: List[Dict[str, List[dict]]]) -> Dict[str, List[dict]]: """合并多个分组结果。""" merged: Dict[str, List[dict]] = {} for grouped in grouped_records: for scenario_key, records in grouped.items(): merged.setdefault(scenario_key, []).extend(records) return merged def parse_excel_sheet(xlsx_source, sheet_name: str = DEFAULT_SHEET_NAME) -> Dict[str, List[dict]]: """解析单个 Excel sheet,并按完整工况键聚类 rawid 记录。""" df = pd.read_excel(xlsx_source, sheet_name=sheet_name, header=None) header = list(df.iloc[0]) column_index = _build_column_index(header) data = df.iloc[1:].copy().reset_index(drop=True) for name in ["场景", "偏置", "目标速度", "自车速度"]: data.iloc[:, column_index[name]] = _forward_fill_series( data.iloc[:, column_index[name]] ) records: List[dict] = [] for _, row in data.iterrows(): rawid = _to_str(row.iloc[column_index["rawid"]]) if rawid is None: continue record = { "sheet": sheet_name, "场景": _to_str(row.iloc[column_index["场景"]]) or "未知工况", "偏置": _to_str(row.iloc[column_index["偏置"]]), "目标速度": _to_str(row.iloc[column_index["目标速度"]]), "自车速度": _to_str(row.iloc[column_index["自车速度"]]), "CVE数据": _to_str(row.iloc[column_index["CVE数据"]]), "rawid": rawid, } records.append(record) return _cluster_records(records) def parse_excel( xlsx_path: str, sheet_names: Optional[List[str]] = None, allow_missing_sheets: bool = False, ) -> Dict[str, List[dict]]: """解析一个或多个 Excel sheet,并合并为按完整工况键聚类的 rawid 记录。""" requested_sheet_names = sheet_names or DEFAULT_SHEET_NAMES excel = pd.ExcelFile(xlsx_path) available_sheet_names = set(excel.sheet_names) missing_sheet_names = [ sheet_name for sheet_name in requested_sheet_names if sheet_name not in available_sheet_names ] if missing_sheet_names and not allow_missing_sheets: raise ValueError( f"Excel 缺少 sheet: {missing_sheet_names};实际 sheet: {excel.sheet_names}" ) if missing_sheet_names: print( f"警告:Excel 缺少 sheet {missing_sheet_names},已跳过;" f"实际 sheet: {excel.sheet_names}" ) grouped_records = [] for sheet_name in requested_sheet_names: if sheet_name not in available_sheet_names: continue grouped_records.append(parse_excel_sheet(excel, sheet_name=sheet_name)) if not grouped_records: raise ValueError( f"Excel 未找到任何可解析 sheet;期望 sheet: {requested_sheet_names};" f"实际 sheet: {excel.sheet_names}" ) return _merge_clustered_records(grouped_records) def load_rawid_json(json_path: str) -> Dict[str, List[dict]]: """读取旧格式 aeb_rawid.json。""" with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): raise ValueError(f"输入 JSON 顶层必须是 dict,实际: {type(data).__name__}") if "scenarios" in data: scenario_data = data["scenarios"] if not isinstance(scenario_data, dict): raise ValueError("输入 JSON 的 scenarios 字段必须是 dict") data = scenario_data grouped_records = [] for scenario_name, records in data.items(): if not isinstance(records, list): raise ValueError( f"场景 {scenario_name} 对应的数据必须是 list,实际: {type(records).__name__}" ) grouped_records.append(_cluster_records(records, fallback_scenario=_to_str(scenario_name))) return _merge_clustered_records(grouped_records) def get_clip_ukeys_from_raw(raw_id: str) -> List[str]: """通过 raw_id 获取关联的 clip ukey 列表。""" from pdcl_dss import Raw with Raw(raw_id) as raw: return raw.list_clip_ukeys() def get_cve_time_range(rawid_data: Dict[str, List[dict]]) -> Optional[Tuple[str, str]]: """从记录中提取 CVE 数据时间范围。""" timestamps = [] for records in rawid_data.values(): for record in records: timestamp = _normalize_cve_timestamp(_to_str(record.get("CVE数据"))) if timestamp: timestamps.append(timestamp) if not timestamps: return None return min(timestamps), max(timestamps) def resolve_output_path( output_arg: Optional[str], source_path: str, rawid_data: Dict[str, List[dict]], ) -> str: """解析最终输出路径;未显式指定时按 CVE 时间范围自动命名。""" if output_arg: return output_arg cve_time_range = get_cve_time_range(rawid_data) if cve_time_range is None: filename = f"{DEFAULT_OUTPUT_PREFIX}.json" else: start, end = cve_time_range suffix = start if start == end else f"{start}_to_{end}" filename = f"{DEFAULT_OUTPUT_PREFIX}-{suffix}.json" return os.path.join(os.path.dirname(source_path), filename) def build_output_payload(scenario_records: Dict[str, List[dict]]) -> dict: """构造包含 summary 和 scenarios 的最终输出。""" total_rawids = sum(len(records) for records in scenario_records.values()) return { "summary": { "total_scenarios": len(scenario_records), "total_rawids": total_rawids, "scenario_total_rawids": { scenario: len(records) for scenario, records in scenario_records.items() }, }, "scenarios": scenario_records, } def build_clips_manifest(rawid_data: Dict[str, List[dict]]) -> Dict[str, List[dict]]: """为每条 rawid 记录补充 clips 字段。""" result: Dict[str, List[dict]] = {} clip_cache: Dict[str, List[str]] = {} total_rawids = sum( 1 for records in rawid_data.values() for record in records if record.get("rawid") ) processed = 0 for scenario, records in rawid_data.items(): result[scenario] = [] print(f"\n=== 工况: {scenario} ({len(records)} 条 rawid) ===") for rec in records: raw_id = rec.get("rawid") if not raw_id: print(" 跳过缺少 rawid 的记录") continue processed += 1 print(f" [{processed}/{total_rawids}] {raw_id} ...", end=" ", flush=True) if raw_id in clip_cache: clips = clip_cache[raw_id] print(f"复用缓存,找到 {len(clips)} 个 clip") else: try: clips = get_clip_ukeys_from_raw(raw_id) clip_cache[raw_id] = clips print(f"找到 {len(clips)} 个 clip") except Exception as e: clips = [] clip_cache[raw_id] = clips print(f"失败: {e}") entry = dict(rec) entry["clips"] = clips result[scenario].append(entry) return result def print_summary( result: Dict[str, List[dict]], output_path: str, cve_time_range: Optional[Tuple[str, str]] = None, ) -> None: total_rawids = sum(len(records) for records in result.values()) total_clips = sum( len(entry.get("clips", [])) for records in result.values() for entry in records ) print(f"\n已保存至:{output_path}") if cve_time_range: start, end = cve_time_range if start == end: print(f"CVE数据时间:{start}") else: print(f"CVE数据时间范围:{start} ~ {end}") print(f"共 {len(result)} 个工况,{total_rawids} 条 rawid,{total_clips} 个 clip") def parse_args(): parser = argparse.ArgumentParser( description="从 AEB 表格或 aeb_rawid.json 直接生成 aeb_clips.json。" ) parser.add_argument( "--xlsx", default=None, help=( "Excel 文件路径;未指定且 --input 也未指定时,默认读取脚本同目录下的 " "G1Q3项目AEB场地计划表.xlsx" ), ) parser.add_argument( "--sheet-name", default=None, help=( "Excel 单个 sheet 名称;指定后只读取该 sheet。" f"未指定时默认读取:{', '.join(DEFAULT_SHEET_NAMES)}" ), ) parser.add_argument( "--sheet-names", default=None, help=( "Excel 多个 sheet 名称,用英文逗号分隔;" f"未指定时默认读取:{', '.join(DEFAULT_SHEET_NAMES)}" ), ) parser.add_argument( "--input", default=None, help="兼容旧流程:输入 aeb_rawid.json 文件路径", ) parser.add_argument( "--output", default=None, help="输出 JSON 文件路径;未指定时自动按 CVE数据 时间范围命名", ) return parser.parse_args() def main(): args = parse_args() if args.xlsx and args.input: raise ValueError("--xlsx 和 --input 不能同时指定,请二选一。") if args.sheet_name and args.sheet_names: raise ValueError("--sheet-name 和 --sheet-names 不能同时指定,请二选一。") if args.input: print(f"读取 rawid JSON:{args.input}") rawid_data = load_rawid_json(args.input) source_path = args.input else: xlsx_path = args.xlsx or DEFAULT_XLSX print(f"读取 Excel:{xlsx_path}") if args.sheet_name: sheet_names = [args.sheet_name] allow_missing_sheets = False elif args.sheet_names: sheet_names = [ sheet_name.strip() for sheet_name in args.sheet_names.split(",") if sheet_name.strip() ] if not sheet_names: raise ValueError("--sheet-names 至少需要指定一个有效 sheet 名称") allow_missing_sheets = False else: sheet_names = DEFAULT_SHEET_NAMES allow_missing_sheets = True print(f"读取 sheet:{', '.join(sheet_names)}") rawid_data = parse_excel( xlsx_path, sheet_names=sheet_names, allow_missing_sheets=allow_missing_sheets, ) source_path = xlsx_path output_path = resolve_output_path(args.output, source_path, rawid_data) cve_time_range = get_cve_time_range(rawid_data) total_rawids = sum(len(records) for records in rawid_data.values()) print(f"待查询 {len(rawid_data)} 个工况,{total_rawids} 条 rawid 记录") result = build_clips_manifest(rawid_data) output_payload = build_output_payload(result) with open(output_path, "w", encoding="utf-8") as f: json.dump(output_payload, f, ensure_ascii=False, indent=2) print_summary(result, output_path, cve_time_range=cve_time_range) if __name__ == "__main__": main()