video_monitor/workshop_monitor/fusion.py
2026-09-06 22:26:35 +08:00

189 lines
8.4 KiB
Python

"""世界坐标上的轻量跨摄像头全局轨迹融合。"""
from functools import lru_cache
import math
import time
def _distance(a, b):
return math.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1]))
def _embedding_similarity(a, b):
if a is None or b is None:
return None
try:
import numpy as np
av, bv = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
denom = float(np.linalg.norm(av) * np.linalg.norm(bv))
return float(np.dot(av, bv) / denom) if denom > 1e-12 else None
except Exception:
return None
def minimum_cost_pairs(costs, max_cost):
"""小规模精确最小代价分配;目标较多时退化为确定性的全局贪心。"""
rows = len(costs)
cols = len(costs[0]) if rows else 0
if not rows or not cols:
return []
if rows > 12 or cols > 12:
candidates = sorted((float(costs[r][c]), r, c) for r in range(rows) for c in range(cols)
if float(costs[r][c]) <= max_cost)
used_r, used_c, result = set(), set(), []
for cost, r, c in candidates:
if r not in used_r and c not in used_c:
used_r.add(r); used_c.add(c); result.append((r, c, cost))
return result
unmatched = float(max_cost) + 0.001
@lru_cache(None)
def solve(row, used_mask):
if row >= rows:
return 0.0, ()
best_cost, best_pairs = solve(row + 1, used_mask)
best_cost += unmatched
for col in range(cols):
cost = float(costs[row][col])
if used_mask & (1 << col) or cost > max_cost:
continue
tail_cost, tail_pairs = solve(row + 1, used_mask | (1 << col))
total = cost + tail_cost
if total < best_cost:
best_cost = total
best_pairs = ((row, col, cost),) + tail_pairs
return best_cost, best_pairs
return list(solve(0, 0)[1])
def cluster_observations(observations, radius_m, time_window_sec):
clusters = []
ordered = sorted(observations or [], key=lambda x: (-float(x.get("score", 0)), int(x.get("camera_id", 0))))
for obs in ordered:
best = None
for cluster in clusters:
if any(int(x["camera_id"]) == int(obs["camera_id"]) for x in cluster):
continue
if max(abs(float(x["timestamp"]) - float(obs["timestamp"])) for x in cluster) > time_window_sec:
continue
cx = sum(float(x["x"]) for x in cluster) / len(cluster)
cy = sum(float(x["y"]) for x in cluster) / len(cluster)
dist = _distance((cx, cy), (obs["x"], obs["y"]))
if dist > radius_m:
continue
similarities = [_embedding_similarity(x.get("embedding"), obs.get("embedding")) for x in cluster]
known = [x for x in similarities if x is not None]
if known and max(known) < 0.45:
continue
if best is None or dist < best[0]:
best = (dist, cluster)
if best:
best[1].append(obs)
else:
clusters.append([obs])
return clusters
class GlobalFusionTracker:
def __init__(self, radius_m=1.5, time_window_sec=1.0, max_speed_mps=3.0,
lost_ttl_sec=30.0, trail_sec=30.0):
self.radius_m = float(radius_m)
self.time_window_sec = float(time_window_sec)
self.max_speed_mps = float(max_speed_mps)
self.lost_ttl_sec = float(lost_ttl_sec)
self.trail_sec = float(trail_sec)
self._tracks = {}
self._next_id = 1
@staticmethod
def _aggregate(cluster):
weights = []
for obs in cluster:
calibration_error = max(0.25, float(obs.get("calibration_error_m") or 1.0))
weights.append(max(0.05, float(obs.get("score", 0.5))) / (calibration_error ** 2))
total = sum(weights) or 1.0
x = sum(float(o["x"]) * w for o, w in zip(cluster, weights)) / total
y = sum(float(o["y"]) * w for o, w in zip(cluster, weights)) / total
latest = max(float(o["timestamp"]) for o in cluster)
embeddings = [o.get("embedding") for o in cluster if o.get("embedding") is not None]
embedding = None
if embeddings:
try:
import numpy as np
embedding = np.mean(np.asarray(embeddings, dtype=float), axis=0)
norm = np.linalg.norm(embedding)
if norm > 1e-12:
embedding = embedding / norm
except Exception:
embedding = None
return {
"class": cluster[0].get("class", "person"), "x": x, "y": y,
"timestamp": latest, "confidence": sum(float(o.get("score", 0)) for o in cluster) / len(cluster),
"source_camera_ids": sorted({int(o["camera_id"]) for o in cluster}),
"observations": cluster, "embedding": embedding,
}
def update(self, observations, now=None):
now = float(now if now is not None else time.time())
fresh = [o for o in observations or [] if now - float(o.get("timestamp", 0)) <= self.time_window_sec]
candidates = [self._aggregate(c) for c in cluster_observations(
fresh, self.radius_m, self.time_window_sec)]
existing = list(self._tracks.values())
costs = []
for candidate in candidates:
row = []
for track in existing:
dt = max(0.0, candidate["timestamp"] - track["timestamp"])
predicted = (track["x"] + track["vx"] * dt, track["y"] + track["vy"] * dt)
dist = _distance(predicted, (candidate["x"], candidate["y"]))
reachable = self.radius_m + self.max_speed_mps * dt
if candidate["class"] != track["class"] or dt < -self.time_window_sec or dist > reachable:
row.append(1e9); continue
sim = _embedding_similarity(candidate.get("embedding"), track.get("embedding"))
if sim is not None and sim < 0.35:
row.append(1e9); continue
row.append(dist + (0.0 if sim is None else (1.0 - sim) * self.radius_m * 0.5))
costs.append(row)
matched_candidates = set()
for ci, ti, _cost in minimum_cost_pairs(costs, self.radius_m * 2.0):
candidate, track = candidates[ci], existing[ti]
dt = max(0.05, candidate["timestamp"] - track["timestamp"])
vx = (candidate["x"] - track["x"]) / dt
vy = (candidate["y"] - track["y"]) / dt
track["vx"] = track["vx"] * 0.5 + vx * 0.5
track["vy"] = track["vy"] * 0.5 + vy * 0.5
track.update({k: candidate[k] for k in ("x", "y", "timestamp", "confidence", "source_camera_ids", "observations")})
if candidate.get("embedding") is not None:
track["embedding"] = candidate["embedding"]
track["trail"].append([candidate["timestamp"], candidate["x"], candidate["y"]])
matched_candidates.add(ci)
for ci, candidate in enumerate(candidates):
if ci in matched_candidates:
continue
gid = "G%06d" % self._next_id
self._next_id += 1
self._tracks[gid] = {
"global_id": gid, **candidate, "vx": 0.0, "vy": 0.0,
"trail": [[candidate["timestamp"], candidate["x"], candidate["y"]]],
}
for gid, track in list(self._tracks.items()):
cutoff = now - self.trail_sec
track["trail"] = [p for p in track["trail"] if p[0] >= cutoff]
if now - track["timestamp"] > self.lost_ttl_sec:
del self._tracks[gid]
return self.snapshot(now)
def snapshot(self, now=None):
now = float(now if now is not None else time.time())
result = []
for track in self._tracks.values():
age = max(0.0, now - float(track["timestamp"]))
item = {k: track[k] for k in ("global_id", "class", "x", "y", "confidence", "timestamp", "source_camera_ids")}
item["state"] = "active" if age <= self.time_window_sec else "lost"
item["fusion_confidence"] = "high" if len(track["source_camera_ids"]) > 1 else ("medium" if age <= self.time_window_sec else "low")
item["trail"] = [[round(p[1], 3), round(p[2], 3)] for p in track["trail"]]
item["age_sec"] = age
result.append(item)
return sorted(result, key=lambda x: x["global_id"])