capture_uploader.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. """检测到人后的保存 + 上传."""
  2. import logging
  3. import os
  4. import threading
  5. import time
  6. import uuid
  7. from typing import Any, Callable, Dict, List, Optional
  8. import cv2
  9. import numpy as np
  10. from config.device import DEVICE_CONFIG
  11. from core.oss_uploader import OSSUploader
  12. logger = logging.getLogger(__name__)
  13. class CaptureUploader:
  14. def __init__(
  15. self,
  16. group_id: str,
  17. save_dir: str = "data/captures",
  18. upload_callback: Optional[Callable[[Dict], None]] = None,
  19. oss_uploader: Optional[OSSUploader] = None,
  20. dedup_seconds: float = 5.0,
  21. ):
  22. self.group_id = group_id
  23. self.save_dir = os.path.join(save_dir, group_id)
  24. os.makedirs(self.save_dir, exist_ok=True)
  25. self.upload_callback = upload_callback
  26. self.oss_uploader = oss_uploader
  27. self.dedup_seconds = dedup_seconds
  28. self._last_uploads: List[Dict[str, Any]] = []
  29. self._lock = threading.Lock()
  30. self._counter = 0
  31. def _should_upload(self, camera_type: str, bbox: List[float]) -> bool:
  32. cx = (bbox[0] + bbox[2]) / 2
  33. cy = (bbox[1] + bbox[3]) / 2
  34. for u in self._last_uploads:
  35. if u["camera_type"] != camera_type:
  36. continue
  37. dx = abs(u["cx"] - cx)
  38. dy = abs(u["cy"] - cy)
  39. if dx < 50 and dy < 50:
  40. return False
  41. return True
  42. def _validate_inputs(
  43. self,
  44. camera_type: str,
  45. frame: np.ndarray,
  46. detections: List[Dict],
  47. ) -> None:
  48. if camera_type not in ("panorama", "ptz"):
  49. raise ValueError("camera_type must be 'panorama' or 'ptz'")
  50. if not isinstance(frame, np.ndarray):
  51. raise ValueError("frame must be a numpy ndarray")
  52. if frame.ndim != 3 or frame.shape[2] != 3:
  53. raise ValueError("frame must have shape (H, W, 3)")
  54. if frame.dtype != np.uint8:
  55. raise ValueError("frame must have dtype uint8")
  56. for i, det in enumerate(detections):
  57. if not isinstance(det, dict):
  58. raise ValueError(f"detection {i} must be a dict")
  59. if "bbox" not in det:
  60. raise ValueError(f"detection {i} missing bbox")
  61. bbox = det["bbox"]
  62. if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
  63. raise ValueError(f"detection {i} bbox must be a list/tuple of 4 numbers")
  64. try:
  65. [float(v) for v in bbox]
  66. except (TypeError, ValueError):
  67. raise ValueError(f"detection {i} bbox must contain numbers")
  68. if "confidence" not in det:
  69. raise ValueError(f"detection {i} missing confidence")
  70. try:
  71. float(det["confidence"])
  72. except (TypeError, ValueError):
  73. raise ValueError(f"detection {i} confidence must be a number")
  74. def handle_detection(
  75. self,
  76. camera_type: str, # 'panorama' or 'ptz'
  77. frame: np.ndarray,
  78. detections: List[Dict],
  79. ptz_position: Optional[Dict] = None,
  80. ) -> List[Dict]:
  81. self._validate_inputs(camera_type, frame, detections)
  82. if not detections:
  83. return []
  84. with self._lock:
  85. now = time.monotonic()
  86. self._last_uploads = [
  87. u for u in self._last_uploads
  88. if now - u["time"] < self.dedup_seconds
  89. ]
  90. upload_decisions = [
  91. (det, self._should_upload(camera_type, det["bbox"]))
  92. for det in detections
  93. ]
  94. to_upload = [det for det, should in upload_decisions if should]
  95. if not to_upload:
  96. logger.debug("All detections deduplicated; skipping file writes for %s", camera_type)
  97. return []
  98. self._counter += 1
  99. counter = self._counter
  100. ts = int(time.time() * 1000)
  101. original_path = os.path.join(
  102. self.save_dir, f"{camera_type}_{ts}_{counter}_original.jpg"
  103. )
  104. marked_path = os.path.join(
  105. self.save_dir, f"{camera_type}_{ts}_{counter}_marked.jpg"
  106. )
  107. # Reserve dedup slots and generate paths under lock.
  108. for det in to_upload:
  109. self._last_uploads.append({
  110. "camera_type": camera_type,
  111. "cx": (det["bbox"][0] + det["bbox"][2]) / 2,
  112. "cy": (det["bbox"][1] + det["bbox"][3]) / 2,
  113. "time": time.monotonic(),
  114. })
  115. # File I/O and user callback run outside the lock.
  116. logger.info("Saving original image to %s", original_path)
  117. if not cv2.imwrite(original_path, frame):
  118. raise RuntimeError(f"Failed to write {original_path}")
  119. marked = frame.copy()
  120. for det in to_upload:
  121. x1, y1, x2, y2 = map(int, det["bbox"])
  122. cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
  123. cv2.putText(marked, f"{det['confidence']:.2f}", (x1, y1 - 5),
  124. cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
  125. logger.info("Saving marked image to %s", marked_path)
  126. if not cv2.imwrite(marked_path, marked):
  127. raise RuntimeError(f"Failed to write {marked_path}")
  128. results: List[Dict] = []
  129. for det in to_upload:
  130. x1, y1, x2, y2 = map(int, det["bbox"])
  131. payload = {
  132. "group_id": self.group_id,
  133. "camera_type": camera_type,
  134. "timestamp": ts,
  135. "original": original_path,
  136. "marked": marked_path,
  137. "bbox": [x1, y1, x2, y2],
  138. "confidence": det["confidence"],
  139. "ptz_position": ptz_position,
  140. }
  141. results.append(payload)
  142. # 上传图片到 OSS(如启用)
  143. image_urls = {}
  144. if self.oss_uploader is not None and self.oss_uploader.enabled:
  145. image_urls = self.oss_uploader.upload_pair(original_path, marked_path)
  146. logger.info("OSS URLs for %s: %s", camera_type, image_urls)
  147. if self.upload_callback and results:
  148. batch_info = {
  149. "batch_id": str(uuid.uuid4()),
  150. "device_id": DEVICE_CONFIG.get("device_id", "unknown"),
  151. "project_id": DEVICE_CONFIG.get("project_id", ""),
  152. "timestamp": ts,
  153. "camera_type": camera_type,
  154. "image_paths": [original_path, marked_path],
  155. "image_urls": image_urls,
  156. "detections": [
  157. {"bbox": det["bbox"], "confidence": det["confidence"], "camera_type": camera_type}
  158. for det in to_upload
  159. ],
  160. "ptz_position": ptz_position,
  161. }
  162. logger.info("Uploading batch info for %s", camera_type)
  163. try:
  164. self.upload_callback(batch_info)
  165. except Exception as exc: # noqa: BLE001
  166. logger.warning("Upload callback failed: %s", exc)
  167. return results