""" ODD 人工标注平台 v2 - 地点模式:静态字段(道路类型/几何/设施)只标一次,动态字段(天气/交通)逐图标注 - 数据来源:sessions_index.json → day_summaries(只标代表图) - 展示每张图和每个地点的视频时长(duration) - 新增地点:支持上传图片或输入路径,手动录入标签 运行:conda activate nds && streamlit run label_platform.py """ import json import re import sqlite3 import shutil from datetime import datetime from pathlib import Path import pandas as pd import streamlit as st # ─── 常量 ───────────────────────────────────────────────────────────── INDEX_FILE = Path("/home/stu1/Projects/LabelWork/sessions_index.json") DB_FILE = "/home/stu1/Projects/LabelWork/label_platform.db" UPLOAD_DIR = Path("/home/stu1/Projects/LabelWork/uploaded_images") CITY_MAP = { "ShenZhen": "深圳", "Changchun": "长春", "Hongkong": "香港", "Beijing": "北京", "Shanghai": "上海", "Guangzhou": "广州", "Haerbin": "哈尔滨", "Hangzhou": "杭州", "Wuhan": "武汉", "Chengdu": "成都", } BLUE = ["#0D47A1","#1565C0","#1976D2","#1E88E5","#42A5F5", "#64B5F6","#90CAF9","#29B6F6","#0288D1","#4FC3F7"] # ─── 标签体系 ────────────────────────────────────────────────────────── TOP_LEVEL_CONFIG = { "区域": ["封闭园区","交通管制区域","开放道路"], "城市道路": ["快速路","主干路","次干路","支路","街巷"], "公路": ["高速公路","一级公路","二级公路","三级公路","四级公路"], "乡村道路": ["村道","其他乡村内部道路"], "其他道路": ["厂矿","林区","港口","专用道路"], "停车区域": ["室内停车场","室外停车场","路侧停车位"], "自动驾驶场景": ["封闭场景","半封闭场景","开放场景"], } SECONDARY_MULTI = { "1.3 道路几何": ["曲率"], "1.4 包含车道特征": ["车道类型","车道宽度"], "1.6 道路交叉": ["交叉类型"], "2.1 交通控制": ["地面标签"], "2.2 路侧与周边环境": ["设施"], "3.1 机动车": ["类型"], "3.2 VRU": ["类型"], "3.4 障碍物": ["类型"], } # 静态字段(道路本身特征,整个地点一致) STATIC_SCHEMA = { "一、道路静态环境": { "1.2 道路表面": { "表面类型": ["沥青","混凝土","土路","碎石","冰雪路面","金属板"], }, "1.3 道路几何": { "坡度": ["平路","上坡","下坡","起伏路"], "曲率": ["直线","弯道 (曲率<0.01)","弯道 (0.01<曲率<0.05)","弯道 (曲率>0.05)"], "横坡": ["正常排水坡度","反超高","无横坡"], }, "1.4 包含车道特征": { "最宽车道数量": ["单车道","双车道","三车道","四车道及以上"], "车道类型": ["普通车道","公交专用道","HOV车道","潮汐车道","应急车道", "非机动车道","人行道","汇入匝道","汇出匝道"], "车道宽度": ["标准","狭窄","超宽"], }, "1.5 道路边缘": { "边缘类型": ["路缘石","护栏 (金属)","护栏 (混凝土)","草地/泥土","无物理隔离"], }, "1.6 道路交叉": { "交叉类型": ["路段 (无交叉)","平面交叉 (十字)","平面交叉 (丁字)", "平面交叉 (畸形)","大型环岛 (出入口数 > 4)","小型环岛","立体交叉"], }, }, "二、交通设施": { "2.1 交通控制": { "信号灯": ["有","无"], "标志牌": ["限速","禁止","指示","警告","施工","无"], "地面标签": ["实线","虚线","双黄线","导流线","斑马线","标线磨损"], }, "2.2 路侧与周边环境": { "设施": ["无","路灯","电线杆","隔音墙","路边树木","路边停车位", "地面停车场出入口","隧道出入口","居民楼","商场","学校", "医院","公园","绿化带"], }, "2.3 特殊设施": {"类型": ["收费站","检查站","施工区域围挡","减速带","无"]}, }, } # 动态字段(每张图独立标注) DYNAMIC_SCHEMA = { "三、动态目标 (路面状况)": { "1.2 道路表面": { "表面状态": ["干燥","潮湿","积水","积雪","结冰","泥泞"], }, "3.1 机动车": {"类型": ["轿车","客车/巴士","卡车/货车","特种车辆 (警)","特种车辆(消)","特种车辆(救)","工程车辆"]}, "3.2 VRU": {"类型": ["自行车","电动车","三轮车","行人","无"]}, "3.3 动物": {"类型": ["有","无"]}, "3.4 障碍物": {"类型": ["落石","遗洒物","倒伏树木","锥桶","无"]}, "3.5 事故车辆": {"类型": ["有","无"]}, }, "四、大气环境": { "4.1 天气": {"类型": ["晴","多云","阴","雨 (小/中/大)","雪","雾","冰雹"]}, "4.2 颗粒物": {"类型": ["无","雾霾","沙尘","烟尘"]}, "4.3 光照": { "强度": ["正常","强光/逆光","弱光/昏暗","黑暗"], }, }, } FULL_SCHEMA = {**STATIC_SCHEMA, **DYNAMIC_SCHEMA} TAG_PATHS = [ ("一、道路静态环境","1.2 道路表面","表面类型"), ("一、道路静态环境","1.3 道路几何","坡度"), ("一、道路静态环境","1.3 道路几何","曲率"), ("一、道路静态环境","1.4 包含车道特征","最宽车道数量"), ("一、道路静态环境","1.4 包含车道特征","车道类型"), ("一、道路静态环境","1.5 道路边缘","边缘类型"), ("一、道路静态环境","1.6 道路交叉","交叉类型"), ("二、交通设施","2.1 交通控制","信号灯"), ("二、交通设施","2.1 交通控制","地面标签"), ("二、交通设施","2.2 路侧与周边环境","设施"), ("二、交通设施","2.3 特殊设施","类型"), ("三、动态目标 (路面状况)","1.2 道路表面","表面状态"), ("三、动态目标 (路面状况)","3.1 机动车","类型"), ("三、动态目标 (路面状况)","3.2 VRU","类型"), ("三、动态目标 (路面状况)","3.3 动物","类型"), ("三、动态目标 (路面状况)","3.4 障碍物","类型"), ("三、动态目标 (路面状况)","3.5 事故车辆","类型"), ("四、大气环境","4.1 天气","类型"), ("四、大气环境","4.2 颗粒物","类型"), ("四、大气环境","4.3 光照","强度"), ] # ─── 工具函数 ────────────────────────────────────────────────────────── def extract_city(l1: str) -> str: m = re.search(r"\d{4}-([A-Za-z]+)-AerialVideo", l1) return CITY_MAP.get(m.group(1), m.group(1)) if m else l1 def parse_time(name: str) -> str: m = re.search(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})", name) if m: y, mo, d, h, mi = m.groups() return f"{y}年{int(mo)}月{int(d)}日 {int(h):02d}:{int(mi):02d}" return "" def parse_hour(ct: str) -> int: m = re.search(r"\s(\d{1,2}):", ct or "") return int(m.group(1)) if m else -1 def time_period(h: int) -> str: if 7 <= h < 9: return "早高峰 (07-09)" if 17 <= h < 19: return "晚高峰 (17-19)" if h >= 19: return "夜间 (19+)" if h >= 0: return "日常规" return "日常规" def fmt_dur(minutes: float) -> str: if minutes >= 60: return f"{minutes/60:.1f}h" return f"{minutes:.0f}min" def extract_tag(d: dict, sec: str, sub: str, attr: str): try: v = d[sec][sub][attr] return v if isinstance(v, list) else ([v] if v else None) except (KeyError, TypeError): return None # ─── 索引加载 ────────────────────────────────────────────────────────── @st.cache_data(ttl=600) def load_location_groups() -> dict: """ 读取 sessions_index.json → day_summaries,按 l2 分组。 返回 dict: l2 → {city, l1, total_duration, days, all_reps} """ if not INDEX_FILE.exists(): return {} with open(INDEX_FILE, encoding="utf-8") as f: idx = json.load(f) day_summaries = idx.get("day_summaries", []) groups: dict = {} for ds in day_summaries: l2 = ds["l2"] l1 = ds.get("l1", "") if l2 not in groups: groups[l2] = { "l2": l2, "l1": l1, "city": extract_city(l1), "total_duration": 0.0, "days": [], "all_reps": [], } groups[l2]["total_duration"] += ds.get("total_duration", 0) reps = ds.get("representatives", []) groups[l2]["days"].append({ "date": ds.get("date", ""), "date_display": ds.get("date_display", ""), "total_duration": ds.get("total_duration", 0), "session_count": ds.get("session_count", 0), "reps": reps, }) groups[l2]["all_reps"].extend(reps) # 按日期排序每个地点的 days for loc in groups.values(): loc["days"].sort(key=lambda d: d["date"]) return groups # ─── 数据库 ──────────────────────────────────────────────────────────── def init_db(): with sqlite3.connect(DB_FILE) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS dataset ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_name TEXT NOT NULL, folder_path TEXT NOT NULL UNIQUE, location_name TEXT, label_time TIMESTAMP, collection_time TEXT, top_road_category TEXT, top_road_subcategory TEXT, secondary_tags_json TEXT, has_dynamic_override INTEGER DEFAULT 0, has_atmosphere_override INTEGER DEFAULT 0, has_road_surface_override INTEGER DEFAULT 0, duration REAL DEFAULT 0, quality_tags TEXT DEFAULT '', comments TEXT DEFAULT '', image_path TEXT DEFAULT '' ) """) @st.cache_data(ttl=30) def load_labeled_fps() -> set: try: with sqlite3.connect(DB_FILE) as conn: return {r[0] for r in conn.execute("SELECT folder_path FROM dataset").fetchall()} except Exception: return set() def get_location_static(l2: str) -> dict: """从 DB 中读取该地点任意一行的静态标签,用于预填充""" try: with sqlite3.connect(DB_FILE) as conn: row = conn.execute( "SELECT top_road_category, top_road_subcategory, secondary_tags_json " "FROM dataset WHERE location_name=? LIMIT 1", (l2,) ).fetchone() if row: tags = json.loads(row[2]) if row[2] else {} static_sec = {k: tags[k] for k in STATIC_SCHEMA if k in tags} return {"top_cat": row[0] or "", "top_sub": row[1] or "", "sections": static_sec} except Exception: pass return {} def get_rep_dynamic(folder_path: str) -> dict: """读取某张代表图已有的动态标签""" try: with sqlite3.connect(DB_FILE) as conn: row = conn.execute( "SELECT secondary_tags_json FROM dataset WHERE folder_path=?", (folder_path,) ).fetchone() if row and row[0]: tags = json.loads(row[0]) return {k: tags[k] for k in DYNAMIC_SCHEMA if k in tags} except Exception: pass return {} def _upsert_rep(conn, rep: dict, l2: str, top_cat: str, top_sub: str, merged_tags: dict, comments: str = ""): conn.execute(""" INSERT OR REPLACE INTO dataset (video_name, folder_path, location_name, label_time, collection_time, top_road_category, top_road_subcategory, secondary_tags_json, has_dynamic_override, has_atmosphere_override, has_road_surface_override, duration, quality_tags, comments, image_path) VALUES (?,?,?,?,?,?,?,?,0,0,0,?,'',?,?) """, ( Path(rep["image_path"]).name if rep.get("image_path") else "", rep["folder_path"], l2, datetime.now().isoformat(), rep.get("collection_time", ""), top_cat, top_sub, json.dumps(merged_tags, ensure_ascii=False), rep.get("duration", 0), comments, rep.get("image_path", ""), )) def save_static_to_location(l2: str, loc: dict, top_cat: str, top_sub: str, static_sections: dict): """将静态标签保存到该地点所有代表图(保留已有动态标签)""" with sqlite3.connect(DB_FILE) as conn: for rep in loc["all_reps"]: # 保留现有动态标签 existing_dyn = get_rep_dynamic(rep["folder_path"]) merged = {**static_sections, **existing_dyn} _upsert_rep(conn, rep, l2, top_cat, top_sub, merged) def save_dynamic_for_rep(rep: dict, l2: str, loc_static: dict, dynamic_sections: dict, comments: str = ""): """保存单张代表图的动态标签,合并地点静态标签""" top_cat = loc_static.get("top_cat", "") top_sub = loc_static.get("top_sub", "") static_sec = loc_static.get("sections", {}) merged = {**static_sec, **dynamic_sections} with sqlite3.connect(DB_FILE) as conn: _upsert_rep(conn, rep, l2, top_cat, top_sub, merged, comments) def save_manual_location(folder_path: str, video_name: str, location_name: str, collection_time: str, top_cat: str, top_sub: str, all_tags: dict, duration: float, image_path: str, comments: str): with sqlite3.connect(DB_FILE) as conn: conn.execute(""" INSERT OR REPLACE INTO dataset (video_name, folder_path, location_name, label_time, collection_time, top_road_category, top_road_subcategory, secondary_tags_json, has_dynamic_override, has_atmosphere_override, has_road_surface_override, duration, quality_tags, comments, image_path) VALUES (?,?,?,?,?,?,?,?,0,0,0,?,'',?,?) """, ( video_name, folder_path, location_name, datetime.now().isoformat(), collection_time, top_cat, top_sub, json.dumps(all_tags, ensure_ascii=False), duration, comments, image_path, )) @st.cache_data(ttl=60) def load_df() -> pd.DataFrame: try: with sqlite3.connect(DB_FILE) as conn: df = pd.read_sql( "SELECT id,video_name,folder_path,location_name,collection_time," "top_road_category,top_road_subcategory,secondary_tags_json,duration,image_path " "FROM dataset", conn ) except Exception: return pd.DataFrame() df["city"] = df["folder_path"].apply(lambda p: extract_city(p.split("/")[0]) if p and "/" in p else "手动录入") df["year"] = df["folder_path"].apply(lambda p: p[:4] if p and p[:4].isdigit() else "") df["_tags"] = df["secondary_tags_json"].apply( lambda s: json.loads(s) if pd.notna(s) and s else {} ) return df # ─── 表单渲染 ────────────────────────────────────────────────────────── def _field(schema_dict: dict, sec_key: str, sub_key: str, attr: str, options: list, existing: dict, form_key: str): """渲染单个字段(单选或多选)""" is_multi = attr in SECONDARY_MULTI.get(sub_key, []) ex_val = existing.get(sec_key, {}).get(sub_key, {}).get(attr) label = f"{sub_key} · {attr}" if is_multi: if isinstance(ex_val, str): ex_val = [ex_val] if ex_val else [] elif not isinstance(ex_val, list): ex_val = [] return st.multiselect(label, options, default=[v for v in ex_val if v in options], key=form_key) else: if isinstance(ex_val, list): ex_val = ex_val[0] if ex_val else "" return st.selectbox(label, options, index=options.index(ex_val) if ex_val in options else 0, key=form_key) def render_schema_section(schema: dict, existing: dict, key_prefix: str) -> dict: """渲染 schema 所有字段(3列布局),返回收集到的标签 dict""" result = {} for sec, sub_dict in schema.items(): st.markdown(f"
", unsafe_allow_html=True) result[sec] = {} cols = st.columns(3) ci = 0 for sub_key, attrs in sub_dict.items(): result[sec][sub_key] = {} for attr, options in attrs.items(): fk = f"{key_prefix}_{sec}_{sub_key}_{attr}" with cols[ci % 3]: result[sec][sub_key][attr] = _field( schema, sec, sub_key, attr, options, existing.get(sec, {}), fk ) ci += 1 return result def render_schema_flat(schema: dict, existing: dict, key_prefix: str) -> dict: """渲染 schema 所有字段(垂直单列,适合窄列),返回收集到的标签 dict""" result = {} for sec, sub_dict in schema.items(): st.markdown(f"", unsafe_allow_html=True) result[sec] = {} for sub_key, attrs in sub_dict.items(): result[sec][sub_key] = {} for attr, options in attrs.items(): fk = f"{key_prefix}_{sec}_{sub_key}_{attr}" result[sec][sub_key][attr] = _field( schema, sec, sub_key, attr, options, existing.get(sec, {}), fk ) return result # ─── 页面1:地点标注 ─────────────────────────────────────────────────── def page_label(): loc_groups = load_location_groups() if not loc_groups: st.error(f"未找到索引文件:{INDEX_FILE}") return labeled_fps = load_labeled_fps() # 侧边栏:地点列表 with st.sidebar: st.markdown("### 选择地点") all_cities = sorted({v["city"] for v in loc_groups.values()}) sel_city = st.multiselect("城市过滤", all_cities, default=all_cities, key="lbl_city") st.markdown("---") filtered_locs = {l2: loc for l2, loc in sorted(loc_groups.items()) if loc["city"] in sel_city} total_reps = sum(len(loc["all_reps"]) for loc in filtered_locs.values()) labeled_reps = sum(1 for loc in filtered_locs.values() for rep in loc["all_reps"] if rep["folder_path"] in labeled_fps) st.markdown(f"**进度:{labeled_reps} / {total_reps} 张**") st.progress(labeled_reps / total_reps if total_reps else 0) st.markdown("---") for l2, loc in filtered_locs.items(): n_rep = len(loc["all_reps"]) n_done = sum(1 for r in loc["all_reps"] if r["folder_path"] in labeled_fps) if n_done == 0: dot, clr = "⬜", "#9E9E9E" elif n_done < n_rep: dot, clr = "🔶", "#F57C00" else: dot, clr = "✅", "#2E7D32" if st.button( f"{dot} {loc['city']} · {l2} ({n_done}/{n_rep})", key=f"loc_{l2}", use_container_width=True ): st.session_state.sel_loc = l2 if "sel_loc" not in st.session_state: st.info("← 从左侧选择一个地点开始标注") return l2 = st.session_state.sel_loc loc = loc_groups.get(l2) if not loc: st.warning("地点数据不存在"); return # 地点头部 n_rep = len(loc["all_reps"]) n_done = sum(1 for r in loc["all_reps"] if r["folder_path"] in labeled_fps) st.markdown( f"