File size: 21,303 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af470e1
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af470e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af470e1
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af470e1
d646dd7
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
"""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