From 26dce2921780e10b1aff719a29baebf2ea73f09e Mon Sep 17 00:00:00 2001 From: zhengsl Date: Sun, 6 Sep 2026 22:26:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E8=BD=A6=E9=97=B4?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/middleware.py | 2 + framework/settings.py | 3 +- framework/urls.py | 1 + language-zh.json | 1 + templates/app/base.html | 3 + templates/workshop_monitor/index.html | 94 +++++ tests/test_workshop_algorithms.py | 76 ++++ workshop_monitor/README.md | 31 ++ workshop_monitor/__init__.py | 1 + workshop_monitor/apps.py | 7 + workshop_monitor/calibration.py | 126 +++++++ workshop_monitor/fusion.py | 188 ++++++++++ workshop_monitor/migrations/0001_initial.py | 101 +++++ workshop_monitor/migrations/__init__.py | 1 + workshop_monitor/models.py | 134 +++++++ workshop_monitor/runtime.py | 263 +++++++++++++ workshop_monitor/tests.py | 80 ++++ workshop_monitor/urls.py | 18 + workshop_monitor/views.py | 389 ++++++++++++++++++++ 19 files changed, 1518 insertions(+), 1 deletion(-) create mode 100644 templates/workshop_monitor/index.html create mode 100644 tests/test_workshop_algorithms.py create mode 100644 workshop_monitor/README.md create mode 100644 workshop_monitor/__init__.py create mode 100644 workshop_monitor/apps.py create mode 100644 workshop_monitor/calibration.py create mode 100644 workshop_monitor/fusion.py create mode 100644 workshop_monitor/migrations/0001_initial.py create mode 100644 workshop_monitor/migrations/__init__.py create mode 100644 workshop_monitor/models.py create mode 100644 workshop_monitor/runtime.py create mode 100644 workshop_monitor/tests.py create mode 100644 workshop_monitor/urls.py create mode 100644 workshop_monitor/views.py diff --git a/app/middleware.py b/app/middleware.py index 23ea7c3..40f8849 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -21,6 +21,8 @@ def _required_role(path, method): return ROLE_ALGORITHM_ADMIN if method != "GET" and path.startswith(("/stream/", "/nvr/", "/control/", "/zone/", "/alarm/")): return ROLE_OPERATOR + if method != "GET" and path.startswith("/workshop/"): + return ROLE_OPERATOR if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")): return ROLE_OPERATOR if path.startswith(("/analysis/openPreviewStart", "/analysis/openPreviewStop")): diff --git a/framework/settings.py b/framework/settings.py index 20d5026..02c839c 100644 --- a/framework/settings.py +++ b/framework/settings.py @@ -62,7 +62,8 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'app' + 'app', + 'workshop_monitor', ] MIDDLEWARE = [ diff --git a/framework/urls.py b/framework/urls.py index f5a6f99..ad9791d 100644 --- a/framework/urls.py +++ b/framework/urls.py @@ -24,6 +24,7 @@ import os urlpatterns = [ # path('admin/', admin.site.urls), # path(r'app/', include('app.urls')), + path(r'workshop/', include('workshop_monitor.urls')), path(r'', include('app.urls')), ] diff --git a/language-zh.json b/language-zh.json index e779b43..4ad04fe 100644 --- a/language-zh.json +++ b/language-zh.json @@ -734,6 +734,7 @@ "llm_no_image_provided": "未提供图像文件", "nav_zones": "布控管理", "nav_control": "布控管理", + "nav_workshop": "车间监控", "zone_all_cameras": "全部摄像头", "zone_camera": "摄像头", "zone_name": "布控名称", diff --git a/templates/app/base.html b/templates/app/base.html index dba0c4a..f7ec593 100644 --- a/templates/app/base.html +++ b/templates/app/base.html @@ -58,6 +58,9 @@ {{ T.nav_control|default:'布控管理' }} + + {{ T.nav_workshop|default:'车间监控' }} + diff --git a/templates/workshop_monitor/index.html b/templates/workshop_monitor/index.html new file mode 100644 index 0000000..3bbfec7 --- /dev/null +++ b/templates/workshop_monitor/index.html @@ -0,0 +1,94 @@ +{% extends "app/base.html" %} +{% load static %} +{% block title %}车间监控{% endblock %} +{% block nav_workshop %}active{% endblock %} +{% block page_title %}车间监控{% endblock %} +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
车间多摄像头实时定位
统一坐标系 · 左上角 (0,0) · X 向右 / Y 向下 · 单位:米
+
+ + + 未启动 +
+
+
主车间 · 130 × 50m目标坐标为算法估算值
+
四路实时视频检测框底边中点映射到地面
+
+
+
实时目标0 个
暂无目标
+
摄像头状态
+
定位说明
每台摄像头必须分别标定。控制点可以不同,但世界坐标必须使用同一个厂房原点。摄像头移动、转动或变焦后需重新标定。
+
+
+ +
车间与摄像头配置
+
+
+
+
+

摄像头安装位置

XYZ 为现场测量值;Yaw:0°向右、90°向下、180°向左、270°向上。
+
+ +
现场控制点标定
+
+
请先选择摄像头并抓取画面

点击画面添加像素点,然后在右侧选择对应的现场控制点;至少 4 个拟合点和 3 个独立验证点。

+
本次像素观测
尚未点选
+
厂房控制点
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/tests/test_workshop_algorithms.py b/tests/test_workshop_algorithms.py new file mode 100644 index 0000000..8c40fca --- /dev/null +++ b/tests/test_workshop_algorithms.py @@ -0,0 +1,76 @@ +import unittest + +from workshop_monitor.calibration import CalibrationError, foot_point_world, solve_planar_calibration +from workshop_monitor.fusion import GlobalFusionTracker, minimum_cost_pairs + + +class WorkshopCalibrationTests(unittest.TestCase): + def observations(self): + return [ + {"u": 0, "v": 0, "x": 0, "y": 0, "role": "fit"}, + {"u": 1300, "v": 0, "x": 130, "y": 0, "role": "fit"}, + {"u": 1300, "v": 500, "x": 130, "y": 50, "role": "fit"}, + {"u": 0, "v": 500, "x": 0, "y": 50, "role": "fit"}, + {"u": 260, "v": 100, "x": 26, "y": 10, "role": "verify"}, + {"u": 650, "v": 250, "x": 65, "y": 25, "role": "verify"}, + {"u": 1040, "v": 400, "x": 104, "y": 40, "role": "verify"}, + ] + + def test_homography_uses_independent_validation_points(self): + result = solve_planar_calibration(self.observations(), 1300, 500) + self.assertTrue(result["is_valid"]) + self.assertLess(result["validation_mean_m"], 1e-5) + self.assertEqual(len(result["observations"]), 7) + + def test_collinear_points_are_rejected(self): + rows = [{"u": i * 10, "v": i * 10, "x": i, "y": i, "role": "fit"} for i in range(4)] + rows += [{"u": i, "v": i + 1, "x": i, "y": i + 1, "role": "verify"} for i in range(3)] + with self.assertRaises(CalibrationError): + solve_planar_calibration(rows, 100, 100) + + def test_person_foot_point_and_boundary(self): + h = [[0.1, 0, 0], [0, 0.1, 0], [0, 0, 1]] + self.assertEqual(foot_point_world([100, 100, 200, 300], h, 130, 50), (15.0, 30.0)) + self.assertIsNone(foot_point_world([2000, 100, 2200, 300], h, 130, 50)) + + +class WorkshopFusionTests(unittest.TestCase): + def obs(self, camera, local_id, x, y, timestamp, embedding=None): + return {"camera_id": camera, "stream_id": camera, "local_track_id": local_id, + "class": "person", "score": .9, "x": x, "y": y, + "timestamp": timestamp, "calibration_error_m": .5, + "embedding": embedding} + + def test_two_cameras_merge_same_person(self): + fusion = GlobalFusionTracker(radius_m=1.5, time_window_sec=1, max_speed_mps=3) + targets = fusion.update([self.obs(1, 1, 10, 10, 100), self.obs(2, 4, 10.4, 10.1, 100.1)], 100.1) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0]["source_camera_ids"], [1, 2]) + + def test_same_camera_people_never_merge(self): + fusion = GlobalFusionTracker(radius_m=2, time_window_sec=1, max_speed_mps=3) + targets = fusion.update([self.obs(1, 1, 10, 10, 100), self.obs(1, 2, 10.2, 10.1, 100)], 100) + self.assertEqual(len(targets), 2) + + def test_unreachable_jump_creates_new_global_id(self): + fusion = GlobalFusionTracker(radius_m=1, time_window_sec=1, max_speed_mps=2) + first = fusion.update([self.obs(1, 1, 1, 1, 100)], 100)[0]["global_id"] + targets = fusion.update([self.obs(2, 9, 20, 20, 101)], 101) + self.assertEqual(len(targets), 2) + self.assertNotEqual(first, min(targets, key=lambda t: abs(t["x"] - 20))["global_id"]) + + def test_reid_rejects_conflicting_appearance(self): + fusion = GlobalFusionTracker(radius_m=2, time_window_sec=1) + targets = fusion.update([ + self.obs(1, 1, 10, 10, 100, [1, 0]), + self.obs(2, 2, 10.1, 10.1, 100, [-1, 0]), + ], 100) + self.assertEqual(len(targets), 2) + + def test_exact_assignment(self): + pairs = minimum_cost_pairs([[1, 2], [1.1, 100]], 10) + self.assertEqual({(r, c) for r, c, _ in pairs}, {(0, 1), (1, 0)}) + + +if __name__ == "__main__": + unittest.main() diff --git a/workshop_monitor/README.md b/workshop_monitor/README.md new file mode 100644 index 0000000..a26767a --- /dev/null +++ b/workshop_monitor/README.md @@ -0,0 +1,31 @@ +# 车间监控现场标定指南 + +## 坐标约定 + +- 厂房平面尺寸为 130 米 × 50 米,左上角为 `(0, 0)`。 +- X 轴向右,范围 `0–130`;Y 轴向下,范围 `0–50`;地面为 `Z=0`。 +- 四台摄像头必须使用同一套世界坐标。摄像头安装位置 XYZ、朝向或焦距发生变化后,应重新标定该摄像头。 + +## 现场准备 + +1. 在现场选择清晰、固定且位于地面的特征点,例如地砖角、立柱脚、设备底座角或临时贴在地面的标记。 +2. 使用卷尺或激光测距仪测量每个点相对于厂房左上角原点的 X/Y 坐标。 +3. 建议准备 8–12 个用于拟合的点和至少 3 个独立验证点。控制点应覆盖画面的近、中、远区域及左右两侧,不能集中在一条直线上。 +4. 单独测量每台摄像头镜头中心的 X/Y/Z 和大致朝向。位置仅用于平面图展示和辅助诊断,不替代该相机自身的像素到地面标定。 + +## 系统操作 + +1. 进入“车间监控 → 车间配置”,确认四个视频流分别绑定到 C1–C4,并录入摄像头安装位置。 +2. 打开“控制点与标定”,先建立厂房控制点名称及测得的 X/Y 坐标。公共地面点只需建立一次,可供不同相机重复使用。 +3. 选择一台摄像头并抓取当前画面,在画面中依次点击特征点,再为每个像素点选择对应的厂房控制点。 +4. 将至少 4 个点设为“拟合”,至少 3 个未参与拟合的点设为“验证”。建议实际使用 8–12 个分布均匀的拟合点。 +5. 点击“计算并激活”。验证平均误差不超过 1 米才会激活;不合格结果会保留为草稿,可调整点位后重新计算。 +6. 对 C1、C2、C3、C4 分别重复第 3–5 步。每台相机看到的控制点可以不同,但所有点的世界坐标必须来自同一厂房坐标系。 + +## 验收建议 + +- 先在验证点位置站人,核对页面显示坐标与实测坐标的误差。 +- 再进行单摄移动、重叠区移动和跨视野移动,观察全局 ID 是否保持一致。 +- 两人交叉测试时,如未配置 ReID 模型,重点检查页面的低融合置信度提示。 +- 单路断流后确认其余三路、平面图和目标列表仍持续更新。 + diff --git a/workshop_monitor/__init__.py b/workshop_monitor/__init__.py new file mode 100644 index 0000000..ff69ba5 --- /dev/null +++ b/workshop_monitor/__init__.py @@ -0,0 +1 @@ +"""车间多摄像头定位模块。""" diff --git a/workshop_monitor/apps.py b/workshop_monitor/apps.py new file mode 100644 index 0000000..5d07859 --- /dev/null +++ b/workshop_monitor/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WorkshopMonitorConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "workshop_monitor" + verbose_name = "车间监控" diff --git a/workshop_monitor/calibration.py b/workshop_monitor/calibration.py new file mode 100644 index 0000000..089c76d --- /dev/null +++ b/workshop_monitor/calibration.py @@ -0,0 +1,126 @@ +"""平面控制点标定和像素/世界坐标转换。""" +import math + + +class CalibrationError(ValueError): + pass + + +def _cv(): + try: + import cv2 + import numpy as np + return cv2, np + except Exception as exc: + raise CalibrationError("OpenCV/Numpy 不可用,无法执行标定") from exc + + +def map_point(homography, u, v): + """使用 3x3 Homography 将像素点映射到世界平面。""" + _cv2, np = _cv() + matrix = np.asarray(homography, dtype=np.float64) + if matrix.shape != (3, 3) or not np.isfinite(matrix).all(): + raise CalibrationError("Homography 格式无效") + p = matrix.dot(np.asarray([float(u), float(v), 1.0], dtype=np.float64)) + if abs(float(p[2])) < 1e-10: + raise CalibrationError("像素点无法投影到地面") + return float(p[0] / p[2]), float(p[1] / p[2]) + + +def _area_ratio(points, frame_width, frame_height): + cv2, np = _cv() + hull = cv2.convexHull(np.asarray(points, dtype=np.float32)) + denom = max(1.0, float(frame_width) * float(frame_height)) + return float(cv2.contourArea(hull)) / denom + + +def solve_planar_calibration(observations, frame_width, frame_height, + max_validation_mean_m=1.0): + """根据拟合点求 H,并使用完全独立的验证点评价世界坐标误差。""" + cv2, np = _cv() + try: + fw, fh = int(frame_width), int(frame_height) + except Exception as exc: + raise CalibrationError("图像尺寸无效") from exc + if fw <= 0 or fh <= 0: + raise CalibrationError("图像尺寸必须大于 0") + + fit = [p for p in observations if p.get("role", "fit") == "fit"] + verify = [p for p in observations if p.get("role") == "verify"] + if len(fit) < 4: + raise CalibrationError("至少需要 4 个拟合点") + if len(verify) < 3: + raise CalibrationError("至少需要 3 个独立验证点") + + def pixels(rows): + return np.asarray([[float(p["u"]), float(p["v"])] for p in rows], dtype=np.float64) + + def worlds(rows): + return np.asarray([[float(p["x"]), float(p["y"])] for p in rows], dtype=np.float64) + + src, dst = pixels(fit), worlds(fit) + if not np.isfinite(src).all() or not np.isfinite(dst).all(): + raise CalibrationError("控制点包含非有限数值") + if _area_ratio(src, fw, fh) < 0.01: + raise CalibrationError("拟合点共线或过度集中,请扩大点位分布") + if float(cv2.contourArea(cv2.convexHull(dst.astype(np.float32)))) < 0.01: + raise CalibrationError("世界坐标点共线或过度集中") + + matrix, mask = cv2.findHomography(src, dst, cv2.RANSAC, 0.75) + if matrix is None or not np.isfinite(matrix).all() or abs(float(np.linalg.det(matrix))) < 1e-12: + raise CalibrationError("无法计算稳定的 Homography") + + fit_projection = cv2.perspectiveTransform(src.reshape(-1, 1, 2), matrix).reshape(-1, 2) + fit_errors = np.linalg.norm(fit_projection - dst, axis=1) + verify_src, verify_dst = pixels(verify), worlds(verify) + verify_projection = cv2.perspectiveTransform(verify_src.reshape(-1, 1, 2), matrix).reshape(-1, 2) + verify_errors = np.linalg.norm(verify_projection - verify_dst, axis=1) + + fit_rmse = math.sqrt(float(np.mean(np.square(fit_errors)))) + validation_mean = float(np.mean(verify_errors)) + validation_max = float(np.max(verify_errors)) + warnings = [] + if len(fit) < 8: + warnings.append("拟合点少于建议的 8 个") + coverage = _area_ratio(src, fw, fh) + if coverage < 0.15: + warnings.append("拟合点仅覆盖画面 %.1f%%,建议增加边缘和远端点" % (coverage * 100.0)) + inliers = int(mask.sum()) if mask is not None else len(fit) + if inliers < len(fit): + warnings.append("RANSAC 排除了 %d 个异常拟合点" % (len(fit) - inliers)) + + detail = [] + fit_index = verify_index = 0 + for p in observations: + row = dict(p) + if p.get("role", "fit") == "fit": + row["error_m"] = float(fit_errors[fit_index]) + fit_index += 1 + else: + row["error_m"] = float(verify_errors[verify_index]) + verify_index += 1 + detail.append(row) + + return { + "homography": matrix.tolist(), + "fit_rmse_m": fit_rmse, + "validation_mean_m": validation_mean, + "validation_max_m": validation_max, + "coverage_ratio": coverage, + "inlier_count": inliers, + "is_valid": validation_mean <= float(max_validation_mean_m), + "warnings": warnings, + "observations": detail, + } + + +def foot_point_world(box, homography, width_m, height_m, boundary_margin_m=1.0): + if not box or len(box) != 4: + return None + u = (float(box[0]) + float(box[2])) * 0.5 + v = float(box[3]) + x, y = map_point(homography, u, v) + margin = max(0.0, float(boundary_margin_m)) + if x < -margin or y < -margin or x > float(width_m) + margin or y > float(height_m) + margin: + return None + return max(0.0, min(float(width_m), x)), max(0.0, min(float(height_m), y)) diff --git a/workshop_monitor/fusion.py b/workshop_monitor/fusion.py new file mode 100644 index 0000000..d674249 --- /dev/null +++ b/workshop_monitor/fusion.py @@ -0,0 +1,188 @@ +"""世界坐标上的轻量跨摄像头全局轨迹融合。""" +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"]) diff --git a/workshop_monitor/migrations/0001_initial.py b/workshop_monitor/migrations/0001_initial.py new file mode 100644 index 0000000..e4bdc1c --- /dev/null +++ b/workshop_monitor/migrations/0001_initial.py @@ -0,0 +1,101 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + dependencies = [("app", "0002_zonemodel_alarm_repeat_sec")] + operations = [ + migrations.CreateModel( + name="WorkshopSite", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(default="主车间", max_length=100)), + ("width_m", models.FloatField(default=130.0)), + ("height_m", models.FloatField(default=50.0)), + ("target_labels", models.JSONField(default=list)), + ("analysis_fps", models.FloatField(default=2.0)), + ("fusion_radius_m", models.FloatField(default=1.5)), + ("observation_window_sec", models.FloatField(default=1.0)), + ("max_speed_mps", models.FloatField(default=3.0)), + ("lost_ttl_sec", models.FloatField(default=30.0)), + ("trail_sec", models.FloatField(default=30.0)), + ("create_time", models.DateTimeField(auto_now_add=True)), + ("last_update_time", models.DateTimeField(auto_now=True)), + ("detector", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_detector_sites", to="app.algorithmmodel")), + ("reid_model", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_reid_sites", to="app.algorithmmodel")), + ], + options={"db_table": "wm_site"}, + ), + migrations.CreateModel( + name="WorkshopCamera", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slot", models.PositiveIntegerField(default=1)), + ("display_name", models.CharField(default="", max_length=100)), + ("enabled", models.BooleanField(default=True)), + ("install_x", models.FloatField(default=0.0)), + ("install_y", models.FloatField(default=0.0)), + ("install_z", models.FloatField(default=0.0)), + ("yaw_deg", models.FloatField(default=0.0)), + ("pitch_deg", models.FloatField(default=0.0)), + ("create_time", models.DateTimeField(auto_now_add=True)), + ("last_update_time", models.DateTimeField(auto_now=True)), + ("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="cameras", to="workshop_monitor.workshopsite")), + ("stream", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_bindings", to="app.streammodel")), + ], + options={"db_table": "wm_camera", "ordering": ("slot", "id")}, + ), + migrations.CreateModel( + name="GroundControlPoint", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("x", models.FloatField()), + ("y", models.FloatField()), + ("description", models.CharField(blank=True, default="", max_length=300)), + ("create_time", models.DateTimeField(auto_now_add=True)), + ("last_update_time", models.DateTimeField(auto_now=True)), + ("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="control_points", to="workshop_monitor.workshopsite")), + ], + options={"db_table": "wm_ground_control_point", "ordering": ("name", "id")}, + ), + migrations.CreateModel( + name="CameraCalibration", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("snapshot_path", models.CharField(blank=True, default="", max_length=500)), + ("frame_width", models.PositiveIntegerField(default=0)), + ("frame_height", models.PositiveIntegerField(default=0)), + ("homography", models.JSONField(default=list)), + ("fit_rmse_m", models.FloatField(blank=True, null=True)), + ("validation_mean_m", models.FloatField(blank=True, null=True)), + ("validation_max_m", models.FloatField(blank=True, null=True)), + ("status", models.CharField(choices=[("draft", "草稿"), ("valid", "合格"), ("invalid", "不合格")], default="draft", max_length=16)), + ("is_active", models.BooleanField(default=False)), + ("warnings", models.JSONField(default=list)), + ("created_by", models.IntegerField(default=0)), + ("create_time", models.DateTimeField(auto_now_add=True)), + ("last_update_time", models.DateTimeField(auto_now=True)), + ("camera", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calibrations", to="workshop_monitor.workshopcamera")), + ], + options={"db_table": "wm_camera_calibration", "ordering": ("-id",)}, + ), + migrations.CreateModel( + name="CalibrationObservation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("pixel_u", models.FloatField()), + ("pixel_v", models.FloatField()), + ("world_x", models.FloatField()), + ("world_y", models.FloatField()), + ("role", models.CharField(choices=[("fit", "拟合"), ("verify", "验证")], default="fit", max_length=10)), + ("error_m", models.FloatField(blank=True, null=True)), + ("calibration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="observations", to="workshop_monitor.cameracalibration")), + ("control_point", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="camera_observations", to="workshop_monitor.groundcontrolpoint")), + ], + options={"db_table": "wm_calibration_observation", "ordering": ("id",)}, + ), + migrations.AddConstraint(model_name="workshopcamera", constraint=models.UniqueConstraint(fields=("site", "slot"), name="wm_camera_site_slot_uniq")), + migrations.AddConstraint(model_name="groundcontrolpoint", constraint=models.UniqueConstraint(fields=("site", "name"), name="wm_gcp_site_name_uniq")), + ] diff --git a/workshop_monitor/migrations/__init__.py b/workshop_monitor/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/workshop_monitor/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/workshop_monitor/models.py b/workshop_monitor/models.py new file mode 100644 index 0000000..2503ed4 --- /dev/null +++ b/workshop_monitor/models.py @@ -0,0 +1,134 @@ +from django.db import models + + +class WorkshopSite(models.Model): + name = models.CharField(max_length=100, default="主车间") + width_m = models.FloatField(default=130.0) + height_m = models.FloatField(default=50.0) + detector = models.ForeignKey( + "app.AlgorithmModel", null=True, blank=True, on_delete=models.SET_NULL, + related_name="workshop_detector_sites", + ) + reid_model = models.ForeignKey( + "app.AlgorithmModel", null=True, blank=True, on_delete=models.SET_NULL, + related_name="workshop_reid_sites", + ) + target_labels = models.JSONField(default=list) + analysis_fps = models.FloatField(default=2.0) + fusion_radius_m = models.FloatField(default=1.5) + observation_window_sec = models.FloatField(default=1.0) + max_speed_mps = models.FloatField(default=3.0) + lost_ttl_sec = models.FloatField(default=30.0) + trail_sec = models.FloatField(default=30.0) + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "wm_site" + + def __str__(self): + return self.name + + +class WorkshopCamera(models.Model): + site = models.ForeignKey(WorkshopSite, on_delete=models.CASCADE, related_name="cameras") + stream = models.ForeignKey( + "app.StreamModel", null=True, blank=True, on_delete=models.SET_NULL, + related_name="workshop_bindings", + ) + slot = models.PositiveIntegerField(default=1) + display_name = models.CharField(max_length=100, default="") + enabled = models.BooleanField(default=True) + install_x = models.FloatField(default=0.0) + install_y = models.FloatField(default=0.0) + install_z = models.FloatField(default=0.0) + yaw_deg = models.FloatField(default=0.0) + pitch_deg = models.FloatField(default=0.0) + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "wm_camera" + ordering = ("slot", "id") + constraints = [ + models.UniqueConstraint(fields=("site", "slot"), name="wm_camera_site_slot_uniq"), + ] + + def __str__(self): + return self.display_name or (self.stream.nickname if self.stream else "摄像头 %s" % self.slot) + + @property + def active_calibration(self): + return self.calibrations.filter(is_active=True).order_by("-id").first() + + +class GroundControlPoint(models.Model): + site = models.ForeignKey(WorkshopSite, on_delete=models.CASCADE, related_name="control_points") + name = models.CharField(max_length=100) + x = models.FloatField() + y = models.FloatField() + description = models.CharField(max_length=300, default="", blank=True) + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "wm_ground_control_point" + ordering = ("name", "id") + constraints = [ + models.UniqueConstraint(fields=("site", "name"), name="wm_gcp_site_name_uniq"), + ] + + def __str__(self): + return "%s (%.2f, %.2f)" % (self.name, self.x, self.y) + + +class CameraCalibration(models.Model): + STATUS_DRAFT = "draft" + STATUS_VALID = "valid" + STATUS_INVALID = "invalid" + STATUS_CHOICES = ( + (STATUS_DRAFT, "草稿"), + (STATUS_VALID, "合格"), + (STATUS_INVALID, "不合格"), + ) + + camera = models.ForeignKey(WorkshopCamera, on_delete=models.CASCADE, related_name="calibrations") + snapshot_path = models.CharField(max_length=500, default="", blank=True) + frame_width = models.PositiveIntegerField(default=0) + frame_height = models.PositiveIntegerField(default=0) + homography = models.JSONField(default=list) + fit_rmse_m = models.FloatField(null=True, blank=True) + validation_mean_m = models.FloatField(null=True, blank=True) + validation_max_m = models.FloatField(null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT) + is_active = models.BooleanField(default=False) + warnings = models.JSONField(default=list) + created_by = models.IntegerField(default=0) + create_time = models.DateTimeField(auto_now_add=True) + last_update_time = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "wm_camera_calibration" + ordering = ("-id",) + + +class CalibrationObservation(models.Model): + ROLE_FIT = "fit" + ROLE_VERIFY = "verify" + ROLE_CHOICES = ((ROLE_FIT, "拟合"), (ROLE_VERIFY, "验证")) + + calibration = models.ForeignKey(CameraCalibration, on_delete=models.CASCADE, related_name="observations") + control_point = models.ForeignKey( + GroundControlPoint, null=True, blank=True, on_delete=models.SET_NULL, + related_name="camera_observations", + ) + pixel_u = models.FloatField() + pixel_v = models.FloatField() + world_x = models.FloatField() + world_y = models.FloatField() + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default=ROLE_FIT) + error_m = models.FloatField(null=True, blank=True) + + class Meta: + db_table = "wm_calibration_observation" + ordering = ("id",) diff --git a/workshop_monitor/runtime.py b/workshop_monitor/runtime.py new file mode 100644 index 0000000..b2a8c16 --- /dev/null +++ b/workshop_monitor/runtime.py @@ -0,0 +1,263 @@ +"""独立于布控分析流水线的车间实时定位进程。""" +import multiprocessing as mp +import queue +import threading +import time + +from .calibration import foot_point_world +from .fusion import GlobalFusionTracker + + +class _CameraReader(threading.Thread): + def __init__(self, config, target_fps): + super().__init__(name="workshop-camera-%s" % config["camera_id"], daemon=True) + self.config = config + self.target_fps = target_fps + self._lock = threading.Lock() + self._running = True + self._frame = None + self._timestamp = 0.0 + self._sequence = 0 + self._health = {"stream_health": "connecting", "stalled_sec": 0.0} + + def run(self): + from app.analysis.frames import FrameSource + source = FrameSource(self.config["rtsp_url"], target_fps=max(2, int(self.target_fps * 2))) + try: + while self._running: + ok, frame = source.read() + with self._lock: + self._health = source.health_snapshot() + if ok and frame is not None: + self._frame = frame + self._timestamp = time.time() + self._sequence += 1 + if not ok: + time.sleep(0.1) + finally: + source.close() + + def latest(self): + with self._lock: + return self._sequence, self._timestamp, self._frame, dict(self._health) + + def close(self): + self._running = False + + +def _build_engine(spec): + from app.analysis.engines.factory import EngineFactory + engine = EngineFactory.create( + spec["inference_engine"], model_file=spec["model_file"], labels=spec.get("labels") or [], + input_size=tuple(spec.get("input_size") or (640, 640)), + conf_threshold=float(spec.get("conf_threshold", 0.4)), + iou_threshold=float(spec.get("iou_threshold", 0.5)), + algorithm_type=spec.get("algorithm_type", "yolo"), + task_type=spec.get("task_type", "detect"), device=spec.get("device", "cpu"), + target_labels=spec.get("target_labels") or [], + ) + if not engine.load(): + raise RuntimeError("模型加载失败: %s" % spec.get("name", spec.get("model_file", ""))) + return engine + + +def workshop_worker_main(config, state_queue, command_queue): + readers = [] + try: + detector = _build_engine(config["detector"]) + reid = _build_engine(config["reid_model"]) if config.get("reid_model") else None + tracker_cls = __import__("app.analysis.tracker", fromlist=["IoUTracker"]).IoUTracker + trackers = {int(c["camera_id"]): tracker_cls() for c in config["cameras"]} + readers = [_CameraReader(c, config["analysis_fps"]) for c in config["cameras"]] + for reader in readers: + reader.start() + fusion = GlobalFusionTracker( + config["fusion_radius_m"], config["observation_window_sec"], + config["max_speed_mps"], config["lost_ttl_sec"], config["trail_sec"], + ) + last_seq = {int(c["camera_id"]): 0 for c in config["cameras"]} + last_process = {int(c["camera_id"]): 0.0 for c in config["cameras"]} + latest_observations = {int(c["camera_id"]): [] for c in config["cameras"]} + frame_index = {int(c["camera_id"]): 0 for c in config["cameras"]} + state_sequence = 0 + period = 1.0 / max(0.1, float(config["analysis_fps"])) + running = True + while running: + try: + while True: + command = command_queue.get_nowait() + if command.get("cmd") == "stop": + running = False + except queue.Empty: + pass + if not running: + break + changed = False + camera_states = [] + now = time.time() + for camera, reader in zip(config["cameras"], readers): + cid = int(camera["camera_id"]) + sequence, captured_at, frame, health = reader.latest() + if frame is not None and sequence != last_seq[cid] and now - last_process[cid] >= period: + last_seq[cid] = sequence + last_process[cid] = now + frame_index[cid] += 1 + try: + detections = [d for d in detector.detect(frame) + if d.get("label") in config["target_labels"]] + active, _ended, _new, _idx = trackers[cid].update( + detections, frame_index[cid], timestamp=captured_at) + confirmed = [t for t in active if t.get("confirmed") and t.get("observed")] + embeddings = {} + if reid and confirmed: + valid, values = reid.extract_embeddings(frame, [t["box"] for t in confirmed]) + for output_index, track_index in enumerate(valid): + embeddings[confirmed[track_index]["track_id"]] = values[output_index] + h, w = frame.shape[:2] + observations = [] + calibration = camera["calibration"] + for track in confirmed: + world = foot_point_world(track["box"], calibration["homography"], + config["width_m"], config["height_m"]) + if world is None: + continue + box = [float(track["box"][0]) / w, float(track["box"][1]) / h, + float(track["box"][2]) / w, float(track["box"][3]) / h] + observations.append({ + "camera_id": cid, "stream_id": camera["stream_id"], + "local_track_id": int(track["track_id"]), "class": track.get("label", "person"), + "score": float(track.get("score", 0)), "bbox": box, + "x": world[0], "y": world[1], "timestamp": captured_at, + "calibration_error_m": calibration.get("validation_mean_m") or 1.0, + "embedding": embeddings.get(track["track_id"]), + }) + latest_observations[cid] = observations + health["analysis_health"] = "running" + health["analysis_error"] = "" + changed = True + except Exception as exc: + health["analysis_health"] = "error" + health["analysis_error"] = str(exc) + fresh = [o for o in latest_observations[cid] + if now - float(o.get("timestamp", 0)) <= config["observation_window_sec"]] + camera_states.append({ + "camera_id": cid, "stream_id": camera["stream_id"], "slot": camera["slot"], + "display_name": camera["display_name"], **health, "observations": fresh, + }) + if changed or state_sequence == 0: + all_observations = [o for rows in latest_observations.values() for o in rows] + targets = fusion.update(all_observations, now) + for obs in all_observations: + choices = [t for t in targets if t["class"] == obs["class"] and + obs["camera_id"] in t["source_camera_ids"]] + if choices: + obs["global_id"] = min(choices, key=lambda t: (t["x"] - obs["x"]) ** 2 + + (t["y"] - obs["y"]) ** 2)["global_id"] + obs.pop("embedding", None) + state_sequence += 1 + payload = {"kind": "state", "sequence": state_sequence, "timestamp": now, + "running": True, "cameras": camera_states, "targets": targets} + try: + while True: + state_queue.get_nowait() + except queue.Empty: + pass + state_queue.put(payload) + time.sleep(0.02) + except Exception as exc: + state_queue.put({"kind": "error", "running": False, "error": str(exc), "timestamp": time.time()}) + finally: + for reader in readers: + reader.close() + for reader in readers: + reader.join(timeout=2.0) + state_queue.put({"kind": "stopped", "running": False, "timestamp": time.time()}) + + +class WorkshopRuntimeManager: + _instance = None + _instance_lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self._lock = threading.RLock() + self._process = None + self._state_queue = None + self._command_queue = None + self._listener = None + self._state = {"running": False, "sequence": 0, "cameras": [], "targets": []} + + def _listen(self): + while self._process is not None: + try: + message = self._state_queue.get(timeout=0.5) + with self._lock: + self._state = message + except queue.Empty: + if self._process is not None and not self._process.is_alive(): + with self._lock: + self._state = {**self._state, "running": False} + if self._state.get("kind") != "stopped" and not self._state.get("error"): + self._state["error"] = "车间定位进程已退出" + break + + def start(self, config): + with self._lock: + if self._process is not None and self._process.is_alive(): + return True, "already running" + context = mp.get_context("spawn") + self._state_queue = context.Queue(maxsize=4) + self._command_queue = context.Queue(maxsize=8) + self._process = context.Process( + target=workshop_worker_main, args=(config, self._state_queue, self._command_queue), + name="workshop-monitor", daemon=True, + ) + self._state = {"running": True, "sequence": 0, "cameras": [], "targets": [], + "timestamp": time.time()} + self._process.start() + self._listener = threading.Thread(target=self._listen, name="workshop-state-listener", daemon=True) + self._listener.start() + return True, "started" + + def stop(self): + with self._lock: + process = self._process + if process is None or not process.is_alive(): + self._state = {**self._state, "running": False} + return True, "already stopped" + try: + self._command_queue.put({"cmd": "stop"}, timeout=1.0) + except Exception: + pass + process.join(timeout=8.0) + if process.is_alive(): + process.terminate() + process.join(timeout=3.0) + with self._lock: + self._process = None + self._state = {**self._state, "running": False} + return True, "stopped" + + def snapshot(self, since=None): + with self._lock: + state = dict(self._state) + sequence = int(state.get("sequence") or 0) + if since is not None and sequence <= int(since or 0): + return {"changed": False, "sequence": sequence, "running": bool(state.get("running")), + "timestamp": state.get("timestamp"), "error": state.get("error", "")} + state["changed"] = True + return state + + +def get_runtime_manager(): + return WorkshopRuntimeManager() diff --git a/workshop_monitor/tests.py b/workshop_monitor/tests.py new file mode 100644 index 0000000..67cd9d5 --- /dev/null +++ b/workshop_monitor/tests.py @@ -0,0 +1,80 @@ +import json + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from app.models import AlgorithmModel, StreamModel +from .models import CameraCalibration, WorkshopSite + + +def make_stream(code, index): + return StreamModel.objects.create( + user_id=1, sort=index, code=code, app="live", name=code, + pull_stream_url="", pull_stream_type=21, pull_stream_transfer_mode=0, + pull_stream_ip="127.0.0.1", pull_stream_port=0, + pull_stream_username="", pull_stream_password="", nickname="Camera %d" % index, + remark="", forward_state=0, is_audio=0, snap_filepath="", camera_sum_num=1, + camera_name="Camera %d" % index, camera_manufacturer="test", camera_owner="", + camera_model="test", camera_device_id=code, camera_parent_id="", camera_civilcode="", + state=0, + ) + + +class WorkshopApiTests(TestCase): + def setUp(self): + self.admin = get_user_model().objects.create_superuser("wm-admin", "wm@example.com", "pass") + self.client.force_login(self.admin) + self.detector = AlgorithmModel.objects.create( + name="detector", algorithm_type="yolo11", task_type="detect", + inference_engine="onnxruntime", device="cpu", model_file="missing.onnx", + labels='["person"]', state=1, is_default=1, + ) + for i, code in enumerate(( + "34020000001320000001", "34020000001320000002", + "34020000001320000003", "34020000001320000004", + ), start=1): + make_stream(code, i) + + def test_config_initializes_existing_four_streams(self): + response = self.client.get("/workshop/openConfig") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["code"], 1000) + self.assertEqual(len(payload["data"]["cameras"]), 4) + self.assertTrue(all(c["stream_id"] for c in payload["data"]["cameras"])) + self.assertEqual(payload["data"]["site"]["width_m"], 130.0) + + def test_calibration_activation_requires_validation_under_one_meter(self): + config = self.client.get("/workshop/openConfig").json()["data"] + camera_id = config["cameras"][0]["id"] + rows = [ + {"u": 0, "v": 0, "x": 0, "y": 0, "role": "fit"}, + {"u": 1300, "v": 0, "x": 130, "y": 0, "role": "fit"}, + {"u": 1300, "v": 500, "x": 130, "y": 50, "role": "fit"}, + {"u": 0, "v": 500, "x": 0, "y": 50, "role": "fit"}, + {"u": 200, "v": 100, "x": 20, "y": 10, "role": "verify"}, + {"u": 650, "v": 250, "x": 65, "y": 25, "role": "verify"}, + {"u": 1100, "v": 400, "x": 110, "y": 40, "role": "verify"}, + ] + response = self.client.post("/workshop/openCalibrate", data=json.dumps({ + "camera_id": camera_id, "frame_width": 1300, "frame_height": 500, + "observations": rows, "activate": True, + }), content_type="application/json") + self.assertEqual(response.json()["code"], 1000) + calibration = CameraCalibration.objects.get() + self.assertTrue(calibration.is_active) + self.assertEqual(calibration.status, CameraCalibration.STATUS_VALID) + + def test_viewer_cannot_change_configuration(self): + viewer = get_user_model().objects.create_user("wm-viewer", password="pass") + self.client.force_login(viewer) + response = self.client.post("/workshop/openSaveConfig", data="{}", content_type="application/json") + self.assertEqual(response.status_code, 403) + + def test_workshop_tables_do_not_create_control_or_alarm_rows(self): + self.client.get("/workshop/openConfig") + site = WorkshopSite.objects.get() + self.assertEqual(site.cameras.count(), 4) + from app.models import AlarmModel, ZoneModel + self.assertEqual(AlarmModel.objects.count(), 0) + self.assertEqual(ZoneModel.objects.count(), 0) diff --git a/workshop_monitor/urls.py b/workshop_monitor/urls.py new file mode 100644 index 0000000..72e0cc1 --- /dev/null +++ b/workshop_monitor/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("index", views.index), + path("openConfig", views.open_config), + path("openSaveConfig", views.open_save_config), + path("openControlPointSave", views.open_control_point_save), + path("openControlPointDelete", views.open_control_point_delete), + path("openCapture", views.open_capture), + path("openCalibrate", views.open_calibrate), + path("openActivateCalibration", views.open_activate_calibration), + path("openStart", views.open_start), + path("openStop", views.open_stop), + path("openState", views.open_state), +] diff --git a/workshop_monitor/views.py b/workshop_monitor/views.py new file mode 100644 index 0000000..5ecc8db --- /dev/null +++ b/workshop_monitor/views.py @@ -0,0 +1,389 @@ +import base64 +import json +from pathlib import Path +import time +import uuid + +from django.db import transaction +from django.http import JsonResponse +from django.shortcuts import render + +from app.models import AlgorithmModel, StreamModel +from monitor_runtime.paths import RESOURCE_ROOT + +from .calibration import CalibrationError, solve_planar_calibration +from .models import ( + CalibrationObservation, CameraCalibration, GroundControlPoint, + WorkshopCamera, WorkshopSite, +) +from .runtime import get_runtime_manager + + +DEFAULT_STREAM_CODES = ( + "34020000001320000001", "34020000001320000002", + "34020000001320000003", "34020000001320000004", +) +DEFAULT_CAMERA_POSES = ( + (0.0, 0.0, 5.0, 45.0), (130.0, 0.0, 5.0, 135.0), + (130.0, 50.0, 5.0, 225.0), (0.0, 50.0, 5.0, 315.0), +) + + +def _reply(ok, data=None, msg="成功", status=200): + return JsonResponse({"code": 1000 if ok else 0, "msg": msg, "data": data or {}}, status=status) + + +def f_checkRequestSafe(request): + user = getattr(request, "user", None) + return (True, "成功") if user is not None and user.is_authenticated else (False, "未登录") + + +def f_parsePostParams(request): + if request.POST: + return {key: request.POST.get(key) for key in request.POST} + try: + return json.loads(request.body.decode("utf-8")) if request.body else {} + except Exception: + return {} + + +def _site(): + site = WorkshopSite.objects.order_by("id").first() + if site is None: + detector = AlgorithmModel.objects.filter(is_default=1, state=1).first() + site = WorkshopSite.objects.create(detector=detector, target_labels=["person"]) + existing_slots = set(site.cameras.values_list("slot", flat=True)) + streams = {s.code: s for s in StreamModel.objects.filter(code__in=DEFAULT_STREAM_CODES)} + for slot, (code, pose) in enumerate(zip(DEFAULT_STREAM_CODES, DEFAULT_CAMERA_POSES), start=1): + if slot not in existing_slots: + stream = streams.get(code) + WorkshopCamera.objects.create( + site=site, slot=slot, stream=stream, + display_name=(stream.nickname if stream else "摄像头 %d" % slot), + install_x=pose[0], install_y=pose[1], install_z=pose[2], yaw_deg=pose[3], + ) + return site + + +def _camera_dict(camera): + stream = camera.stream + calibration = camera.active_calibration + return { + "id": camera.id, "slot": camera.slot, "display_name": camera.display_name, + "enabled": camera.enabled, "stream_id": camera.stream_id, + "stream_code": stream.code if stream else "", "stream_app": stream.app if stream else "", + "stream_name": stream.name if stream else "", "stream_nickname": stream.nickname if stream else "", + "install_x": camera.install_x, "install_y": camera.install_y, "install_z": camera.install_z, + "yaw_deg": camera.yaw_deg, "pitch_deg": camera.pitch_deg, + "calibration": ({ + "id": calibration.id, "status": calibration.status, + "fit_rmse_m": calibration.fit_rmse_m, + "validation_mean_m": calibration.validation_mean_m, + "validation_max_m": calibration.validation_max_m, + "frame_width": calibration.frame_width, "frame_height": calibration.frame_height, + "create_time": calibration.create_time, + } if calibration else None), + } + + +def _config_data(site): + streams = [{"id": s.id, "code": s.code, "nickname": s.nickname, "app": s.app, "name": s.name, + "pull_stream_ip": s.pull_stream_ip, "forward_state": s.forward_state} + for s in StreamModel.objects.order_by("id")] + detectors = [{"id": a.id, "name": a.name, "task_type": a.task_type, + "device": a.device, "engine": a.inference_engine} + for a in AlgorithmModel.objects.filter(state=1).order_by("id")] + points = [{"id": p.id, "name": p.name, "x": p.x, "y": p.y, + "description": p.description} for p in site.control_points.all()] + return { + "site": {"id": site.id, "name": site.name, "width_m": site.width_m, + "height_m": site.height_m, "detector_id": site.detector_id, + "reid_model_id": site.reid_model_id, "target_labels": site.target_labels or ["person"], + "analysis_fps": site.analysis_fps, "fusion_radius_m": site.fusion_radius_m, + "observation_window_sec": site.observation_window_sec, + "max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec, + "trail_sec": site.trail_sec}, + "cameras": [_camera_dict(c) for c in site.cameras.select_related("stream")], + "control_points": points, "streams": streams, "algorithms": detectors, + "runtime": get_runtime_manager().snapshot(), + } + + +def index(request): + return render(request, "workshop_monitor/index.html", {}) + + +def open_config(request): + return _reply(True, _config_data(_site())) + + +def _number(params, name, minimum=None, maximum=None): + try: + value = float(params[name]) + except Exception as exc: + raise ValueError("%s 数值无效" % name) from exc + if minimum is not None and value < minimum: + raise ValueError("%s 不能小于 %s" % (name, minimum)) + if maximum is not None and value > maximum: + raise ValueError("%s 不能大于 %s" % (name, maximum)) + return value + + +def open_save_config(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + params = f_parsePostParams(request) + try: + with transaction.atomic(): + site = _site() + site.name = str(params.get("name") or "主车间")[:100] + site.width_m = _number(params, "width_m", 1, 10000) + site.height_m = _number(params, "height_m", 1, 10000) + site.analysis_fps = _number(params, "analysis_fps", 0.1, 30) + site.fusion_radius_m = _number(params, "fusion_radius_m", 0.1, 20) + site.observation_window_sec = _number(params, "observation_window_sec", 0.1, 10) + site.max_speed_mps = _number(params, "max_speed_mps", 0.1, 50) + site.lost_ttl_sec = _number(params, "lost_ttl_sec", 1, 600) + site.trail_sec = _number(params, "trail_sec", 1, 600) + labels = params.get("target_labels") or ["person"] + if isinstance(labels, str): + labels = [x.strip() for x in labels.split(",") if x.strip()] + site.target_labels = labels or ["person"] + site.detector = AlgorithmModel.objects.filter(id=int(params.get("detector_id") or 0), state=1).first() + rid = int(params.get("reid_model_id") or 0) + site.reid_model = AlgorithmModel.objects.filter(id=rid, state=1, task_type="reid").first() if rid else None + if site.detector and site.detector.task_type == "reid": + raise ValueError("检测模型不能是 ReID 模型") + site.save() + for row in params.get("cameras") or []: + camera = site.cameras.get(id=int(row["id"])) + camera.stream = StreamModel.objects.filter(id=int(row.get("stream_id") or 0)).first() + camera.display_name = str(row.get("display_name") or "摄像头 %d" % camera.slot)[:100] + camera.enabled = bool(row.get("enabled", True)) + camera.install_x = float(row.get("install_x", 0)) + camera.install_y = float(row.get("install_y", 0)) + camera.install_z = float(row.get("install_z", 0)) + camera.yaw_deg = float(row.get("yaw_deg", 0)) % 360.0 + camera.pitch_deg = max(-90.0, min(90.0, float(row.get("pitch_deg", 0)))) + if not (0 <= camera.install_x <= site.width_m and 0 <= camera.install_y <= site.height_m): + raise ValueError("摄像头 %d 的安装 XY 超出厂房边界" % camera.slot) + camera.save() + return _reply(True, _config_data(site)) + except Exception as exc: + return _reply(False, msg=str(exc)) + + +def open_control_point_save(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + params = f_parsePostParams(request) + try: + site = _site() + x, y = _number(params, "x", 0, site.width_m), _number(params, "y", 0, site.height_m) + pid = int(params.get("id") or 0) + point = site.control_points.filter(id=pid).first() if pid else GroundControlPoint(site=site) + if point is None: + raise ValueError("控制点不存在") + point.name = str(params.get("name") or "").strip()[:100] + if not point.name: + raise ValueError("控制点名称不能为空") + point.x, point.y = x, y + point.description = str(params.get("description") or "")[:300] + point.save() + return _reply(True, {"id": point.id}) + except Exception as exc: + return _reply(False, msg=str(exc)) + + +def open_control_point_delete(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + try: + point = _site().control_points.get(id=int(f_parsePostParams(request).get("id") or 0)) + point.delete() + return _reply(True) + except Exception as exc: + return _reply(False, msg=str(exc)) + + +def open_capture(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + cap = None + try: + import cv2 + camera = _site().cameras.select_related("stream").get(id=int(f_parsePostParams(request).get("camera_id") or 0)) + if not camera.stream: + raise ValueError("摄像头尚未绑定视频流") + from app.analysis.manager import AnalysisManager + url = AnalysisManager.build_rtsp_url(camera.stream) + if not url: + raise ValueError("无法生成摄像头 RTSP 地址") + cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) + frame = None + deadline = time.monotonic() + 8.0 + while time.monotonic() < deadline: + ret, current = cap.read() + if ret and current is not None: + frame = current + break + if frame is None: + raise ValueError("抓帧失败,请确认 GB28181 视频流在线") + token = uuid.uuid4().hex + directory = Path(RESOURCE_ROOT) / "static" / "storage" / "workshop" / "calibration" + directory.mkdir(parents=True, exist_ok=True) + path = directory / (token + ".jpg") + if not cv2.imwrite(str(path), frame, [int(cv2.IMWRITE_JPEG_QUALITY), 92]): + raise ValueError("标定截图保存失败") + h, w = frame.shape[:2] + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return _reply(True, {"token": token, "width": w, "height": h, + "image": "data:image/jpeg;base64," + encoded}) + except Exception as exc: + return _reply(False, msg=str(exc)) + finally: + if cap is not None: + cap.release() + + +def open_calibrate(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + params = f_parsePostParams(request) + try: + site = _site() + camera = site.cameras.get(id=int(params.get("camera_id") or 0)) + observations = params.get("observations") or [] + result = solve_planar_calibration(observations, params.get("frame_width"), params.get("frame_height"), 1.0) + token = str(params.get("snapshot_token") or "") + rel_path = "workshop/calibration/%s.jpg" % token if token else "" + with transaction.atomic(): + calibration = CameraCalibration.objects.create( + camera=camera, snapshot_path=rel_path, + frame_width=int(params.get("frame_width")), frame_height=int(params.get("frame_height")), + homography=result["homography"], fit_rmse_m=result["fit_rmse_m"], + validation_mean_m=result["validation_mean_m"], validation_max_m=result["validation_max_m"], + status=CameraCalibration.STATUS_VALID if result["is_valid"] else CameraCalibration.STATUS_INVALID, + is_active=False, warnings=result["warnings"], + created_by=getattr(request.user, "id", 0) or 0, + ) + point_map = {p.id: p for p in site.control_points.all()} + for row in result["observations"]: + pid = int(row.get("control_point_id") or 0) + CalibrationObservation.objects.create( + calibration=calibration, control_point=point_map.get(pid), + pixel_u=float(row["u"]), pixel_v=float(row["v"]), + world_x=float(row["x"]), world_y=float(row["y"]), + role=row.get("role", "fit"), error_m=float(row["error_m"]), + ) + if result["is_valid"] and bool(params.get("activate", True)): + camera.calibrations.filter(is_active=True).update(is_active=False) + calibration.is_active = True + calibration.save(update_fields=("is_active", "last_update_time")) + return _reply(True, {"calibration_id": calibration.id, **result}) + except (CalibrationError, ValueError, KeyError, TypeError) as exc: + return _reply(False, msg=str(exc)) + + +def open_activate_calibration(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + try: + calibration = CameraCalibration.objects.select_related("camera").get( + id=int(f_parsePostParams(request).get("calibration_id") or 0)) + if calibration.status != CameraCalibration.STATUS_VALID or calibration.validation_mean_m is None or calibration.validation_mean_m > 1.0: + raise ValueError("只有验证平均误差不超过 1 米的标定才能激活") + with transaction.atomic(): + calibration.camera.calibrations.filter(is_active=True).update(is_active=False) + calibration.is_active = True + calibration.save(update_fields=("is_active", "last_update_time")) + return _reply(True) + except Exception as exc: + return _reply(False, msg=str(exc)) + + +def _algorithm_spec(algorithm, targets=None): + labels = algorithm.labels + if isinstance(labels, str): + try: + labels = json.loads(labels) + except Exception: + labels = [] + model_path = Path(algorithm.model_file) + if not model_path.is_absolute(): + model_path = Path(RESOURCE_ROOT) / "static" / "upload" / "weight" / model_path + if not model_path.is_file(): + raise ValueError("模型文件不存在: %s" % algorithm.model_file) + return { + "id": algorithm.id, "name": algorithm.name, "model_file": str(model_path), + "labels": labels, "input_size": [algorithm.input_width, algorithm.input_height], + "conf_threshold": algorithm.conf_threshold, "iou_threshold": algorithm.iou_threshold, + "algorithm_type": algorithm.algorithm_type, "task_type": algorithm.task_type, + "inference_engine": algorithm.inference_engine, "device": algorithm.device, + "target_labels": targets or [], + } + + +def _runtime_config(site): + if not site.detector or site.detector.state != 1: + raise ValueError("请先选择可用的人员检测模型") + cameras = [] + from app.analysis.manager import AnalysisManager + for camera in site.cameras.filter(enabled=True).select_related("stream"): + calibration = camera.active_calibration + if not camera.stream or not calibration: + continue + url = AnalysisManager.build_rtsp_url(camera.stream) + if not url: + continue + cameras.append({ + "camera_id": camera.id, "stream_id": camera.stream_id, "slot": camera.slot, + "display_name": camera.display_name or str(camera), "rtsp_url": url, + "calibration": {"homography": calibration.homography, + "validation_mean_m": calibration.validation_mean_m}, + }) + if len(cameras) < 3: + raise ValueError("至少需要 3 台已绑定且完成合格标定的摄像头") + return { + "width_m": site.width_m, "height_m": site.height_m, + "analysis_fps": site.analysis_fps, "fusion_radius_m": site.fusion_radius_m, + "observation_window_sec": site.observation_window_sec, + "max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec, + "trail_sec": site.trail_sec, "target_labels": site.target_labels or ["person"], + "detector": _algorithm_spec(site.detector, site.target_labels or ["person"]), + "reid_model": _algorithm_spec(site.reid_model) if site.reid_model else None, + "cameras": cameras, + } + + +def open_start(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + try: + ok, info = get_runtime_manager().start(_runtime_config(_site())) + return _reply(ok, get_runtime_manager().snapshot(), info) + except Exception as exc: + return _reply(False, msg=str(exc)) + + +def open_stop(request): + ok, msg = f_checkRequestSafe(request) + if request.method != "POST" or not ok: + return _reply(False, msg=msg if not ok else "仅支持 POST") + ok, info = get_runtime_manager().stop() + return _reply(ok, get_runtime_manager().snapshot(), info) + + +def open_state(request): + try: + since = request.GET.get("since") + return _reply(True, get_runtime_manager().snapshot(since)) + except Exception as exc: + return _reply(False, msg=str(exc))