DataFlowing / processor /app /workflow_instantiate.py
quyu666's picture
sync processor from GitLab ac3323f: 深度格式兼容(legacy_mm10+逐批次覆盖)+ UMI Gripper 模块与测试
af470e1
Raw History Blame Contribute Delete
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