Spaces:
Running
Running
File size: 9,339 Bytes
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 | """Project-level device naming bindings — 工作流全局共享,项目按卡片覆盖设备名。
主流方案:工作流 = 处理逻辑(共享一份);项目 = 配置。项目对某工作流的
输入卡片(source_key/source_keys)做本项目覆盖(如头戴式 head_fisheye_rgb
vs 胸戴式 chest_fisheye_rgb),互不污染,改逻辑一次全项目生效。
绑定存储:projects.json 项目对象 ``workflow_bindings``:
{"<工作流id>": {"<node_id>": {"source_key": "chest_fisheye_rgb"}}}
两个纯函数:
- ``effective_input_keys(workflow, bindings)``:上传匹配用 —— 合并绑定后
的工作流期望输入键(与 _workflow_input_source_keys 同规则)。
- ``apply_bindings(graph, bindings)``:入队快照用 —— **深拷贝** graph 并把
绑定写进输入节点 data.config(worker 只读 node.data.config,不应用
node_configs;必须深拷贝,否则污染共享工作流定义)。
"""
from __future__ import annotations
import copy
def input_keys_match(want: list[str], available) -> bool:
"""工作流期望键 vs 批次相机名的**双向子串**匹配。
设备名("D435")配相机全名("D435_depth_rgb")、或工作流全名配批次
别名均命中。此前调用方用 set 精确相等判断(`w in have_primary`),
前缀型设备名永远不命中 → 上传/重跑误报 No match。
"""
avail = [str(a).lower() for a in (available or [])]
for w in want:
wl = str(w).lower()
for a in avail:
if wl in a or a in wl:
return True
return False
def matched_input_keys(want: list[str], available) -> list[tuple[str, str]]:
"""Return one-to-one ``(workflow_key, available_key)`` matches.
The boolean matcher above answers whether any input is compatible. This
helper is used when a workflow has multiple input devices so one uploaded
stream cannot silently satisfy two different requested devices.
"""
available_values = []
seen_available: set[str] = set()
for value in available or []:
original = str(value).strip()
lowered = original.lower()
if not original or lowered in seen_available:
continue
seen_available.add(lowered)
available_values.append((original, lowered))
matches: list[tuple[str, str]] = []
used_available: set[str] = set()
for value in want or []:
requested = str(value).strip()
requested_lower = requested.lower()
if not requested:
continue
candidates = [
(original, lowered) for original, lowered in available_values
if lowered not in used_available
and (requested_lower in lowered or lowered in requested_lower)
]
if not candidates:
continue
candidates.sort(key=lambda item: (
item[1] != requested_lower,
abs(len(item[1]) - len(requested_lower)),
item[1],
))
selected = candidates[0]
used_available.add(selected[1])
matches.append((requested, selected[0]))
return matches
def effective_input_keys(workflow: dict, bindings: dict | None = None) -> list[str]:
"""工作流期望的输入源键,叠加项目绑定覆盖(绑定优先于节点 config)。
``bindings``: 项目对该工作流的 ``{node_id: {"source_key"|"source_keys": ...}}``。
读取规则与 app.api.workflows._workflow_input_source_keys 一致:
source_keys(逗号拆分)→ source_key → position。
"""
graph = workflow.get("graph") or {}
overrides = workflow.get("node_configs") or {}
keys: list[str] = []
for node in graph.get("nodes", []):
node_id = node.get("id")
data = node.get("data") or {}
config = dict(data.get("config") or {})
override = overrides.get(node_id, {}) if isinstance(overrides, dict) else {}
if isinstance(override, dict):
config.update(override)
binding = (bindings or {}).get(node_id)
if isinstance(binding, dict):
_apply_binding_value(config, binding)
values = (config.get("source_keys") or config.get("source_key") or config.get("position"))
if isinstance(values, str):
values = [v.strip() for v in values.split(",") if v.strip()]
if isinstance(values, (list, tuple, set)):
keys.extend(str(value) for value in values if value)
return keys
def _apply_binding_value(config: dict, binding: dict) -> None:
"""绑定语义 = 节点输入命名:同时覆盖 source_keys(多源逗号)与
source_key(首源)。工作流匹配读取顺序是 source_keys 优先,只改
source_key 对 stereo(有 source_keys)不生效 —— 两处必须一致。
"""
bound = str(binding.get("source_keys") or binding.get("source_key") or "").strip()
if not bound:
return
config["source_keys"] = bound
config["source_key"] = bound.split(",")[0].strip()
def auto_bindings_for_batch(workflow: dict, camera_names: list[str]) -> dict | None:
"""类型兜底匹配:精确名不匹配时,按单目/双目类型自动对齐输入名。
单目工作流(mono_camera/fisheye/rgb 输入节点)+ 批次单路主目 → 输入
节点 source_key 覆盖为批次主目名;双目工作流(stereo_camera)+ 批次
多路主目 → source_keys 覆盖为批次主目名。返回绑定 dict(供
apply_bindings 注入入队快照,不改工作流定义);类型不匹配返回 None
(仍走不兼容异常)。
主目判定只数 RGB 相机:深度相机(d435_depth 等)不是视频流,不能
参与单/双目类型判断 —— 单 D435 批次(d435_rgb + d435_depth)必须
判为单目,否则单目工作流会被误判不兼容(failed)。
"""
graph = workflow.get("graph") or {}
# A depth device can legitimately publish a color video slot such as
# ``D435_depth_rgb``. Exclude only pure depth streams; do not discard a
# real RGB video merely because its parent device name contains "depth".
def is_primary_video(name: str) -> bool:
low = str(name).lower()
if low.endswith("_aux"):
return False
return not ("depth" in low and "rgb" not in low)
mains = [str(c) for c in (camera_names or []) if is_primary_video(str(c))]
if not mains:
return None
# Do not treat any two cameras as a stereo pair. Two independent mono
# cameras are valid, too; stereo requires a conventional left/right pair
# (or an explicit stereo stream name).
def has_stereo_pair(values: list[str]) -> bool:
available = {str(value).strip().lower() for value in values}
for value in available:
replacements = (
("_left_", "_right_"), ("_right_", "_left_"),
("_left", "_right"), ("_right", "_left"),
)
for old, new in replacements:
if old in value and value.replace(old, new) in available:
return True
return False
is_stereo_batch = has_stereo_pair(mains) or any(
"stereo" in str(c).lower() for c in mains
)
bindings: dict = {}
for node in graph.get("nodes", []):
data = node.get("data") or {}
node_type = data.get("nodeType", "")
if node_type not in ("mono_camera", "rgbd_camera", "fisheye_camera", "rgb_camera",
"stereo_camera", "stereo_rgbd_camera"):
continue
if node_type in ("stereo_camera", "stereo_rgbd_camera"):
if not is_stereo_batch:
return None
bindings[node["id"]] = {"source_keys": ",".join(sorted(mains))}
else:
if is_stereo_batch:
return None
bindings[node["id"]] = {"source_key": mains[0]}
return bindings or None
def apply_bindings(graph: dict | None, bindings: dict | None) -> dict:
"""深拷贝 graph,把项目绑定写进输入节点 ``data.config``。
返回新 graph(原对象不动);无绑定 → 仍深拷贝(调用方入队快照需要隔离)。
绑定值空字符串/None 视为清除(保留工作流默认值)。
"""
g = copy.deepcopy(graph or {})
nodes = g.get("nodes") or []
for node in nodes:
node_id = node.get("id")
binding = (bindings or {}).get(node_id)
if not isinstance(binding, dict):
continue
data = node.get("data")
if not isinstance(data, dict):
continue
config = dict(data.get("config") or {})
# 只有输入源卡片(source_key/source_keys/legacy position)才有绑定语义.
# Older RGB/fisheye graphs stored only ``position``; accepting it here
# lets the new type-first auto-match upgrade their run snapshot too.
if ("source_keys" not in config and "source_key" not in config
and "position" not in config):
continue
bound = str(binding.get("source_keys") or binding.get("source_key") or "").strip()
if not bound:
continue
# 与 effective_input_keys 一致:覆盖 source_keys(多源)与
# source_key(首源);source_key 变更同步旧 position 字段
_apply_binding_value(config, binding)
config["position"] = config["source_key"]
data["config"] = config
return g
|