Spaces:
Running
Running
Download processor/app/workflow_instantiate.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/workflow_instantiate.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/workflow_instantiate.py
-
curl -L -o workflow_instantiate.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/workflow_instantiate.py
21.3 kB
| """Template workflow instantiation — 上传数据自动补齐输入卡片并连线。 | |
| 模板工作流 = graph 中只含处理节点(mediapipe_hand/annotation/human_review/ | |
| lerobot_export)和它们之间的边,输入卡片被删掉。约定式判定:存在"无 | |
| incoming 边"的 process/review/export 输入 handle 即视为模板。 | |
| 上传数据时(session.py 钩子)识别输入源(相机名 + info.json sensors), | |
| 调用 ``complete_graph`` 补齐输入卡片并自动连线,生成完整工作流实例 | |
| (可编辑、非预设,``template_id`` 指向模板)绑定项目;后续上传出现新 | |
| 输入源 → ``increment_instance`` 增量补全(只新增,不改既有节点/边)。 | |
| 连线规则: | |
| - 视频类输入(rgb/fisheye/stereo)→ 每路视频连一个 mediapipe_hand 的 | |
| ``video`` 输入(mediapipe_hand.run 只处理第一个 video incoming): | |
| 先复用模板中悬空的 mediapipe_hand,不够则深拷贝原型。 | |
| - 手套传感器(sensor_data)→ 绝不连 video 端口;第一个手套连到模板中 | |
| 悬空的 ``data`` 输入(如 annotation),后续手套/无 data 输入时照建 | |
| 不连线(worker 中 glove_sensor.run 自行 find_parquet)。 | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| from datetime import datetime, timezone | |
| from uuid import uuid4 | |
| from app.localstore import get_workflow, upsert_project, upsert_workflow | |
| from app.glove_sources import ( | |
| group_glove_source_keys, | |
| is_paired_glove_source, | |
| merge_paired_glove_nodes, | |
| ) | |
| from app.processing.batch import _keys_of | |
| from app.processing.catalog import get_descriptor | |
| from app.workflow_types import HAND_PROCESS_TYPES, migrate_graph_types | |
| # ``mono_camera`` is the canonical input module for newly detected single-view | |
| # cameras. Keep the historical aliases so old graphs still participate in | |
| # increment-instance de-duplication. | |
| INPUT_MODULES = { | |
| "mono_camera", "rgbd_camera", "rgb_camera", "fisheye_camera", | |
| "stereo_camera", "stereo_rgbd_camera", "glove_sensor", "gripper_device", | |
| } | |
| VIDEO_PROCESS_TYPES = { | |
| "mediapipe_hand", *HAND_PROCESS_TYPES, | |
| } | |
| MULTI_VIEW_VIDEO_TYPES = set(HAND_PROCESS_TYPES) | |
| EDGE_STYLE = {"stroke": "#475569", "strokeWidth": 2} | |
| def _utcnow_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| # ── 模板判定 ────────────────────────────────────────── | |
| def _incoming_handles(edges: list[dict]) -> dict[str, list[str]]: | |
| """node_id → 已被喂入的 targetHandle key 列表(含缺省 "data")。""" | |
| incoming: dict[str, list[str]] = {} | |
| for e in edges: | |
| target = e.get("target") | |
| if target: | |
| incoming.setdefault(str(target), []).append(e.get("targetHandle") or "data") | |
| return incoming | |
| def _dangling_inputs(nodes: list[dict], edges: list[dict]) -> dict[str, list[str]]: | |
| """每个节点的悬空输入 handle key 列表(仅 process/review/export)。 | |
| input 类节点无 inputs,天然排除;已被入边喂满的输入端口不算悬空。 | |
| """ | |
| incoming = _incoming_handles(edges) | |
| out: dict[str, list[str]] = {} | |
| for n in nodes: | |
| data = n.get("data") or {} | |
| if data.get("category") == "input": | |
| continue | |
| fed = {h.lower() for h in incoming.get(str(n.get("id")), [])} | |
| dangling = [ | |
| i.get("key") for i in (data.get("inputs") or []) | |
| if i.get("key") and i.get("key").lower() not in fed | |
| ] | |
| if dangling: | |
| out[str(n["id"])] = dangling | |
| return out | |
| def is_template_workflow(workflow: dict) -> bool: | |
| """约定式模板判定:graph 中存在无 incoming 边的 process 输入 handle。""" | |
| graph = workflow.get("graph") or {} | |
| return bool(_dangling_inputs(graph.get("nodes") or [], graph.get("edges") or [])) | |
| # ── 基础构造工具 ────────────────────────────────────── | |
| def _alloc_node_id(used: set[str]) -> str: | |
| """``node_<max+1>`` 递增分配,保证与现有节点不冲突。""" | |
| max_n = 0 | |
| for nid in used: | |
| if isinstance(nid, str) and nid.startswith("node_"): | |
| try: | |
| max_n = max(max_n, int(nid[5:])) | |
| except ValueError: | |
| pass | |
| nid = f"node_{max_n + 1}" | |
| used.add(nid) | |
| return nid | |
| def _edge_id(source: str, source_handle: str, target: str, target_handle: str) -> str: | |
| return f"xy-edge__{source}{source_handle}-{target}{target_handle}" | |
| def _camera_module(camera: str) -> str: | |
| """相机名 → 输入模块映射(在剔除 _aux 之后调用)。""" | |
| c = str(camera).lower() | |
| if "stereo" in c and "depth" in c: | |
| return "stereo_rgbd_camera" | |
| if "stereo" in c: | |
| return "stereo_camera" | |
| if ("rgbd" in c or ("depth" in c and any( | |
| token in c for token in ("rgb", "color")))): | |
| return "rgbd_camera" | |
| # New instances use one generic single-view input. Keep rgb_camera and | |
| # fisheye_camera in INPUT_MODULES so historical graphs remain valid, but | |
| # do not create new model/lens-specific cards. | |
| return "mono_camera" | |
| def _paired_camera_key(camera: str, available: set[str]) -> str | None: | |
| """Find the matching left/right stream without relying on a model name.""" | |
| value = str(camera) | |
| replacements = ( | |
| ("_left_", "_right_"), ("_right_", "_left_"), | |
| ("_left", "_right"), ("_right", "_left"), | |
| ) | |
| for old, new in replacements: | |
| if old not in value.lower(): | |
| continue | |
| candidate = value.lower().replace(old, new) | |
| exact = next((item for item in available if item.lower() == candidate), None) | |
| if exact: | |
| return exact | |
| return None | |
| def _camera_groups(cameras: list[str]) -> list[list[str]]: | |
| """Group generic left/right stream names into physical stereo cameras. | |
| This handles ``stereo_left/right`` as well as future names such as | |
| ``head_left_rgb/head_right_rgb``. Unpaired streams remain mono inputs. | |
| """ | |
| values = sorted({str(camera).strip() for camera in cameras if str(camera).strip()}) | |
| remaining = set(values) | |
| groups: list[list[str]] = [] | |
| for camera in values: | |
| if camera not in remaining: | |
| continue | |
| remaining.remove(camera) | |
| pair = _paired_camera_key(camera, remaining) | |
| if pair: | |
| remaining.remove(pair) | |
| groups.append([camera, pair]) | |
| else: | |
| groups.append([camera]) | |
| return groups | |
| def _input_node(node_type: str, source_keys: list[str], x: float, y: float, | |
| used: set[str]) -> dict: | |
| """构造完整 React Flow 输入节点(与现网手动搭建的节点同构)。 | |
| config 取自模块 default_config,并把 source_key/source_keys 覆写为 | |
| 实际检测到的名字 —— worker 只读 node.data.config,这是唯一生效位。 | |
| """ | |
| desc = get_descriptor(node_type) | |
| if desc is None: | |
| raise ValueError(f"Unknown input module: {node_type}") | |
| d = desc.to_dict() | |
| config = dict(d.get("default_config") or {}) | |
| if len(source_keys) > 1: | |
| config["source_keys"] = ",".join(source_keys) | |
| config["source_key"] = source_keys[0] | |
| else: | |
| config["source_key"] = source_keys[0] | |
| config.pop("source_keys", None) | |
| data = { | |
| "label": d.get("label"), | |
| "category": "input", | |
| "icon": d.get("icon"), | |
| "color": d.get("color"), | |
| "config": config, | |
| "nodeType": node_type, | |
| "inputs": list(d.get("inputs") or []), | |
| "outputs": list(d.get("outputs") or []), | |
| "configSchema": list(d.get("config_schema") or []), | |
| "executionTarget": d.get("execution_target") or "server", | |
| } | |
| return { | |
| "id": _alloc_node_id(used), | |
| "type": "workflowNode", | |
| "position": {"x": x, "y": y}, | |
| "data": data, | |
| "measured": {"width": 280, "height": 86}, | |
| "selected": False, | |
| "dragging": False, | |
| } | |
| def _copy_video_node(proto: dict, copy_k: int, used: set[str]) -> dict: | |
| """深拷贝视频处理节点原型(新 id、原型正下方偏移)。 | |
| 拷贝不带任何下游边 —— 处理节点本身只返回临时结果,完成阶段合并到 | |
| 对应 episode parquet; | |
| 双目新模块可通过同一 video handle 的重复边接收 video#1。 | |
| """ | |
| node = copy.deepcopy(proto) | |
| node["id"] = _alloc_node_id(used) | |
| pos = node.get("position") or {} | |
| node["position"] = { | |
| "x": pos.get("x", 0), | |
| "y": pos.get("y", 0) + (copy_k + 1) * 220, | |
| } | |
| node["selected"] = False | |
| node["dragging"] = False | |
| return node | |
| def _make_edge(source: str, source_handle: str, target: str, target_handle: str) -> dict: | |
| return { | |
| "style": dict(EDGE_STYLE), | |
| "source": source, | |
| "sourceHandle": source_handle, | |
| "target": target, | |
| "targetHandle": target_handle, | |
| "id": _edge_id(source, source_handle, target, target_handle), | |
| } | |
| # ── 核心算法 ────────────────────────────────────────── | |
| def complete_graph(graph: dict, camera_names: list[str], sensors: list[str]) -> tuple[list[dict], list[dict], dict]: | |
| """在任意 graph 上补齐输入子图(创建与增量共用)。 | |
| 返回 ``(nodes, edges, added)`` —— 普通输入只追加内容;左右手套 | |
| 传感器会合并到一个节点,兼容并修正历史重复节点。 | |
| """ | |
| migrated, _ = migrate_graph_types(graph) | |
| nodes = migrated.get("nodes") or [] | |
| edges = migrated.get("edges") or [] | |
| normalized_graph = {"nodes": nodes, "edges": edges} | |
| merge_paired_glove_nodes(normalized_graph) | |
| nodes = normalized_graph["nodes"] | |
| edges = normalized_graph["edges"] | |
| used = {str(n.get("id")) for n in nodes} | |
| existing_edge_ids = {e.get("id") for e in edges} | |
| added = {"video_nodes": 0, "mp_copies": 0, | |
| "processor_copies": 0, "glove_nodes": 0} | |
| # ── 1. 已有输入覆盖集(增量去重:nodeType + source_key)── | |
| covered: set[tuple[str, str]] = set() | |
| for n in nodes: | |
| data = n.get("data") or {} | |
| if data.get("nodeType") not in INPUT_MODULES: | |
| continue | |
| for k in _keys_of(data.get("config") or {}): | |
| covered.add((data["nodeType"], str(k).lower())) | |
| # ── 2. 期望输入源(相机剔除 _aux;传感器来自 info.json)── | |
| mains = [c for c in (camera_names or []) if not str(c).lower().endswith("_aux")] | |
| video_groups: list[tuple[str, list[str]]] = [] | |
| existing_stereo_keys = { | |
| key for module, key in covered | |
| if module in {"stereo_camera", "stereo_rgbd_camera"} | |
| } | |
| # UMI publishes a primary RGB stream and internal left/right streams. They | |
| # belong to one physical source node, not three camera inputs. | |
| gripper_mains = [ | |
| c for c in mains | |
| if "gripper" in str(c).lower() or "umi" in str(c).lower() | |
| ] | |
| camera_mains = [c for c in mains if c not in gripper_mains] | |
| covered_gripper = any( | |
| module == "gripper_device" and key in { | |
| value.lower() for value in gripper_mains | |
| } | |
| for module, key in covered | |
| ) | |
| if gripper_mains and not covered_gripper: | |
| video_groups.append(("gripper_device", gripper_mains)) | |
| for group in _camera_groups(camera_mains): | |
| mod = "stereo_camera" if len(group) > 1 else _camera_module(group[0]) | |
| if mod in {"stereo_camera", "stereo_rgbd_camera"}: | |
| # A complete existing pair is already covered. An unpaired | |
| # historical stereo node is allowed to receive a later pair in a | |
| # future migration rather than suppressing every new camera. | |
| if all(key.lower() in existing_stereo_keys for key in group): | |
| continue | |
| video_groups.append((mod, group)) | |
| else: | |
| c = group[0] | |
| if (mod, c.lower()) in covered: | |
| continue | |
| video_groups.append((mod, group)) | |
| # 存量工作流可能已有一个 left_glove 节点。发现成对传感器时直接把 | |
| # 右手 source key 补到该节点,不再新建第二个 SenseGlove 节点。 | |
| for glove_group in group_glove_source_keys(sensors): | |
| if not is_paired_glove_source(glove_group): | |
| continue | |
| group_lower = [str(key).lower() for key in glove_group] | |
| for node in nodes: | |
| data = node.get("data") or {} | |
| if data.get("nodeType") != "glove_sensor": | |
| continue | |
| config = data.setdefault("config", {}) | |
| existing = _keys_of(config) | |
| if not existing or not any( | |
| old.lower() in new or new in old.lower() | |
| for old in existing for new in group_lower): | |
| continue | |
| config["source_key"] = glove_group[0] | |
| config["source_keys"] = ",".join(glove_group) | |
| config.pop("position", None) | |
| break | |
| desired_glove = [ | |
| keys for keys in group_glove_source_keys(sensors) | |
| if not any(("glove_sensor", str(key).lower()) in covered for key in keys) | |
| ] | |
| # ── 3. 视频处理节点(模板复用池)+ 原型 ── | |
| # MediaPipe 保持旧的一路一节点语义;新的 Hand3D/黑手套模块可在 | |
| # 同一个节点中接收 stereo_camera 的两路视频。 | |
| video_fed = {str(e.get("target")) for e in edges | |
| if (e.get("targetHandle") or "data") == "video"} | |
| video_processors: dict[str, list[dict]] = {} | |
| for n in nodes: | |
| node_type = (n.get("data") or {}).get("nodeType") | |
| if node_type in VIDEO_PROCESS_TYPES: | |
| video_processors.setdefault(node_type, []).append(n) | |
| dangling_processors = { | |
| node_type: [n for n in items | |
| if str(n.get("id")) not in video_fed] | |
| for node_type, items in video_processors.items() | |
| } | |
| # ── 4. 布局:输入列在最左,复制的处理节点在原型正下方 ── | |
| proc_nodes = [n for n in nodes if (n.get("data") or {}).get("category") != "input"] | |
| min_x = min((n.get("position") or {}).get("x", 0) for n in proc_nodes) if proc_nodes else 0 | |
| min_y = min((n.get("position") or {}).get("y", 0) for n in proc_nodes) if proc_nodes else 0 | |
| input_x = min_x - 340 | |
| row = 0 | |
| copy_k = 0 | |
| # ── 5. 视频输入节点:连接模板中的视频处理节点 ── | |
| for mod, keys in video_groups: | |
| if mod == "gripper_device": | |
| handles = ["rgb_video"] | |
| elif mod in {"stereo_camera", "stereo_rgbd_camera"}: | |
| handles = ["video_left"] if len(keys) < 2 else ["video_left", "video_right"] | |
| else: | |
| handles = ["video"] | |
| # 没有视频处理节点原型→ 不建孤立输入节点 | |
| if not video_processors: | |
| continue | |
| node = _input_node(mod, keys, input_x, min_y + row * 180, used) | |
| nodes.append(node) | |
| added["video_nodes"] += 1 | |
| row += 1 | |
| for processor_type, prototypes in video_processors.items(): | |
| prototype = prototypes[0] | |
| reusable = dangling_processors.get(processor_type) or [] | |
| # Multi-view modules consume both refs in one run. Legacy | |
| # MediaPipe consumes only the first ref, so it gets one node/view. | |
| groups = [handles] if ( | |
| mod in {"stereo_camera", "stereo_rgbd_camera"} | |
| and len(handles) > 1 | |
| and processor_type in MULTI_VIEW_VIDEO_TYPES | |
| ) else [[h] for h in handles] | |
| for group in groups: | |
| if reusable: | |
| processor = reusable.pop(0) | |
| else: | |
| processor = _copy_video_node(prototype, copy_k, used) | |
| nodes.append(processor) | |
| added["mp_copies"] += 1 | |
| added["processor_copies"] += 1 | |
| copy_k += 1 | |
| for h in group: | |
| e = _make_edge(node["id"], h, processor["id"], "video") | |
| if e["id"] not in existing_edge_ids: | |
| edges.append(e) | |
| existing_edge_ids.add(e["id"]) | |
| # RGB-D processing has a typed depth input. The input card | |
| # publishes the paired depth directory; keep the historical | |
| # worker-side auto-pair fallback for old uploads that lack a | |
| # usable sidecar, but make the normal graph connection explicit. | |
| if (mod in {"rgbd_camera", "stereo_rgbd_camera"} | |
| and processor_type in { | |
| "rgbd_to_3d_bare_hand", "rgbd_to_3d_black_glove", | |
| }): | |
| e = _make_edge(node["id"], "depth", processor["id"], "depth") | |
| if e["id"] not in existing_edge_ids: | |
| edges.append(e) | |
| existing_edge_ids.add(e["id"]) | |
| # ── 6. 手套传感器:绝不连 video 端口 ── | |
| dangling_by_key: dict[str, str] = {} | |
| for nid, keys in _dangling_inputs(nodes, edges).items(): | |
| for k in keys: | |
| dangling_by_key.setdefault(k, nid) # 每个 handle key 只取第一个目标 | |
| data_target = dangling_by_key.get("data") | |
| glove_wired = False | |
| for source_keys in desired_glove: | |
| g = _input_node("glove_sensor", [str(key) for key in source_keys], | |
| input_x, min_y + row * 180, used) | |
| nodes.append(g) | |
| added["glove_nodes"] += 1 | |
| row += 1 | |
| if data_target and not glove_wired: | |
| e = _make_edge(g["id"], "sensor_data", data_target, "data") | |
| if e["id"] not in existing_edge_ids: | |
| edges.append(e) | |
| existing_edge_ids.add(e["id"]) | |
| glove_wired = True | |
| return nodes, edges, added | |
| # ── 实例生命周期 ────────────────────────────────────── | |
| def build_instance(template: dict, camera_names: list[str], sensors: list[str]) -> dict: | |
| """由模板 + 输入源生成完整工作流实例(非预设、可编辑)。""" | |
| nodes, edges, _added = complete_graph( | |
| template.get("graph") or {}, camera_names, sensors) | |
| return { | |
| "id": str(uuid4()), | |
| "name": f"{template.get('name') or 'Workflow'} (auto)", | |
| "description": template.get("description"), | |
| "graph": {"nodes": nodes, "edges": edges}, | |
| "node_configs": template.get("node_configs") or {}, | |
| "status": "active", | |
| "is_preset": False, | |
| "template_id": template["id"], | |
| "created_at": _utcnow_iso(), | |
| "updated_at": _utcnow_iso(), | |
| } | |
| def increment_instance(instance: dict, camera_names: list[str], sensors: list[str]) -> bool: | |
| """增量补全:有新输入源 → 只新增节点/边,写回;无变化返回 False。""" | |
| nodes, edges, added = complete_graph( | |
| instance.get("graph") or {}, camera_names, sensors) | |
| if not any(added.values()): | |
| return False | |
| instance["graph"] = {"nodes": nodes, "edges": edges} | |
| instance["updated_at"] = _utcnow_iso() | |
| upsert_workflow(instance) | |
| return True | |
| def _find_instance(project: dict, template_id: str) -> dict | None: | |
| """项目绑定中 ``template_id`` 匹配、非预设的最新实例。""" | |
| if not project: | |
| return None | |
| wf_ids = project.get("workflow_ids") or [] | |
| if not isinstance(wf_ids, list): | |
| wf_ids = [wf_ids] if wf_ids else [] | |
| candidates = [ | |
| w for w in (get_workflow(i) for i in wf_ids) | |
| if w and w.get("template_id") == template_id and not w.get("is_preset") | |
| ] | |
| if not candidates: | |
| return None | |
| return max(candidates, key=lambda w: w.get("updated_at") or "") | |
| def _bind_to_project(project: dict, workflow_id: str) -> None: | |
| """实例 id 去重追加到项目 workflow_ids 并写回。""" | |
| wf_ids = project.get("workflow_ids") or [] | |
| if not isinstance(wf_ids, list): | |
| wf_ids = [wf_ids] if wf_ids else [] | |
| if workflow_id not in wf_ids: | |
| wf_ids.append(workflow_id) | |
| project["workflow_ids"] = wf_ids | |
| upsert_project(project) | |
| def batch_covers_instance(instance: dict, camera_names: list[str]) -> bool: | |
| """入队前判定:实例的 video 输入键被本批次全覆盖才允许入队。 | |
| 手套传感器不参与判定(glove_sensor.run 自行 find_parquet); | |
| stereo 部分覆盖放行(模块退化单路,安全);缺主目视频 → False。 | |
| """ | |
| graph = instance.get("graph") or {} | |
| have = { | |
| str(c).lower() for c in (camera_names or []) | |
| if not str(c).lower().endswith("_aux") | |
| } | |
| for n in graph.get("nodes", []): | |
| data = n.get("data") or {} | |
| if data.get("category") != "input" or data.get("nodeType") in {"glove_sensor", "gripper_device"}: | |
| continue | |
| keys = [str(k).lower() for k in _keys_of(data.get("config") or {})] | |
| if not keys: | |
| continue | |
| if data.get("nodeType") in {"stereo_camera", "stereo_rgbd_camera"}: | |
| if not any(k in have for k in keys): | |
| return False | |
| else: | |
| if not all(k in have for k in keys): | |
| return False | |
| return True | |