third_party_pusher.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. """
  2. 第三方平台推送模块
  3. 将批次信息推送到第三方平台接口
  4. """
  5. import os
  6. import time
  7. import json
  8. import logging
  9. import threading
  10. import queue
  11. import cv2
  12. import requests
  13. from typing import Optional, Dict, Any, List, Callable
  14. from dataclasses import dataclass
  15. from datetime import datetime
  16. from pathlib import Path
  17. logger = logging.getLogger(__name__)
  18. def _normalize_timestamp(ts: float) -> float:
  19. """统一时间戳为秒。CaptureUploader 使用毫秒,其他可能使用秒。"""
  20. if ts > 1e12:
  21. return ts / 1000.0
  22. return ts
  23. def _convert_to_legacy_batch_info(new_info: Dict[str, Any]) -> Dict[str, Any]:
  24. """
  25. 把新版 CaptureUploader 生成的 batch_info 转成业务平台要求的 PairedImageSaver 老格式。
  26. 参考字段:
  27. - batch_id / device_id / project_id / timestamp / datetime
  28. - total_persons / ptz_images_count
  29. - panorama: local_path / local_path_original / oss_url / oss_url_original
  30. - persons: person_index / position(x,y 归一化) / bbox / confidence /
  31. ptz_position / ptz_bbox / ptz_image_saved /
  32. ptz_image_path / ptz_image_original_path /
  33. ptz_oss_url / ptz_oss_url_original
  34. - upload_status
  35. """
  36. normalized_ts = _normalize_timestamp(new_info.get("timestamp", time.time()))
  37. urls = new_info.get("image_urls") or {}
  38. image_paths = new_info.get("image_paths") or []
  39. camera_type = new_info.get("camera_type", "panorama")
  40. is_ptz = camera_type == "ptz"
  41. # image_paths 约定:[original, marked]
  42. original_path = image_paths[0] if len(image_paths) > 0 else None
  43. marked_path = image_paths[1] if len(image_paths) > 1 else original_path
  44. oss_url_original = urls.get("original") or None
  45. oss_url_marked = urls.get("marked") or oss_url_original
  46. # 读取原图尺寸,用于把人体中心坐标归一化到 0~1
  47. img_w, img_h = 0, 0
  48. if original_path and os.path.exists(original_path):
  49. try:
  50. img = cv2.imread(original_path)
  51. if img is not None:
  52. img_h, img_w = img.shape[:2]
  53. except Exception:
  54. pass
  55. ptz_position = new_info.get("ptz_position") or {}
  56. persons = []
  57. for i, det in enumerate(new_info.get("detections") or []):
  58. bbox = det.get("bbox", [0, 0, 0, 0])
  59. x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
  60. cx = (x1 + x2) / 2.0
  61. cy = (y1 + y2) / 2.0
  62. position = {
  63. "x": round(cx / img_w, 4) if img_w else round(cx, 4),
  64. "y": round(cy / img_h, 4) if img_h else round(cy, 4),
  65. }
  66. person = {
  67. "person_index": i,
  68. "position": position,
  69. "bbox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
  70. "confidence": float(det.get("confidence", 0.0)),
  71. }
  72. # PTZ 流检测时,把同一张 PTZ 图作为每个人的特写图复用
  73. # 始终包含 ptz_position 字段,第三方平台要求必须有 pan/tilt/zoom 数值
  74. if is_ptz:
  75. # 使用实际检测时的 PTZ 位置(若无实际位置,用默认值 0/0/1)
  76. ptz_pan = ptz_position.get("pan") if isinstance(ptz_position, dict) else 0
  77. ptz_tilt = ptz_position.get("tilt") if isinstance(ptz_position, dict) else 0
  78. ptz_zoom = ptz_position.get("zoom") if isinstance(ptz_position, dict) else 1
  79. person["ptz_position"] = {
  80. "pan": ptz_pan if ptz_pan is not None else 0,
  81. "tilt": ptz_tilt if ptz_tilt is not None else 0,
  82. "zoom": ptz_zoom if ptz_zoom is not None else 1,
  83. }
  84. person["ptz_bbox"] = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
  85. person["ptz_image_saved"] = bool(marked_path and os.path.exists(marked_path))
  86. person["ptz_image_path"] = marked_path
  87. person["ptz_image_original_path"] = original_path
  88. person["ptz_oss_url"] = oss_url_marked
  89. person["ptz_oss_url_original"] = oss_url_original
  90. else:
  91. # 全景相机检测时,复用当前检测框作为 ptz_bbox
  92. person["ptz_position"] = {
  93. "pan": 0, "tilt": 0, "zoom": 1,
  94. }
  95. person["ptz_bbox"] = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
  96. person["ptz_image_saved"] = bool(marked_path and os.path.exists(marked_path))
  97. person["ptz_image_path"] = marked_path
  98. person["ptz_image_original_path"] = original_path
  99. person["ptz_oss_url"] = oss_url_marked
  100. person["ptz_oss_url_original"] = oss_url_original
  101. persons.append(person)
  102. ptz_uploaded = is_ptz and bool(oss_url_marked)
  103. legacy = {
  104. "batch_id": new_info.get("batch_id", ""),
  105. "device_id": new_info.get("device_id", ""),
  106. "project_id": new_info.get("project_id", ""),
  107. "timestamp": normalized_ts,
  108. "datetime": datetime.fromtimestamp(normalized_ts).isoformat(),
  109. "install_address": new_info.get("install_address", ""),
  110. "total_persons": len(persons),
  111. "ptz_images_count": len(persons),
  112. "panorama": {
  113. "local_path": marked_path,
  114. "local_path_original": original_path,
  115. "oss_url": oss_url_marked,
  116. "oss_url_original": oss_url_original,
  117. },
  118. "persons": persons,
  119. "upload_status": {
  120. "panorama_uploaded": bool(oss_url_marked or oss_url_original),
  121. "panorama_original_uploaded": bool(oss_url_original),
  122. "all_ptz_uploaded": ptz_uploaded,
  123. },
  124. }
  125. return legacy
  126. @dataclass
  127. class BatchReport:
  128. """批次上报数据"""
  129. batch_id: str
  130. device_id: str
  131. project_id: str
  132. timestamp: float
  133. batch_info: Dict[str, Any] # batch_info.json 的完整内容
  134. local_path: Optional[str] = None # batch_info.json 本地路径
  135. class ThirdPartyPusher:
  136. """
  137. 第三方平台推送器
  138. 负责将批次信息推送到配置的第三方平台接口
  139. """
  140. def __init__(self, config: Dict[str, Any] = None):
  141. """
  142. 初始化第三方平台推送器
  143. Args:
  144. config: 第三方平台配置字典
  145. """
  146. from config import THIRD_PARTY_CONFIG, DEVICE_CONFIG
  147. self.config = config or THIRD_PARTY_CONFIG
  148. self.device_config = DEVICE_CONFIG
  149. # 功能开关
  150. self.enabled = self.config.get('enabled', False)
  151. # 平台配置
  152. self.platform_type = self.config.get('platform_type', 'custom')
  153. self.base_url = self.config.get('base_url', '')
  154. self.api_version = self.config.get('api_version', 'v1')
  155. # 认证配置
  156. self.auth_type = self.config.get('auth_type', 'none')
  157. self.api_key = self.config.get('api_key', '')
  158. self.api_secret = self.config.get('api_secret', '')
  159. self.oauth2_config = self.config.get('oauth2', {})
  160. # 接口路径
  161. self.endpoints = self.config.get('endpoints', {})
  162. self.batch_report_url = self.endpoints.get('batch_report', '/api/batch/report')
  163. self.heartbeat_url = self.endpoints.get('heartbeat', '/api/device/heartbeat')
  164. # 推送控制
  165. self.push_interval = self.config.get('push_interval', 1.0)
  166. self.retry_count = self.config.get('retry_count', 3)
  167. self.retry_delay = self.config.get('retry_delay', 2.0)
  168. self.timeout = self.config.get('timeout', 10)
  169. self.data_format = self.config.get('data_format', 'json')
  170. self.include_images = self.config.get('include_images', False)
  171. # OAuth2 Token
  172. self._access_token = None
  173. self._token_expires_at = 0
  174. # 上报队列
  175. self.report_queue = queue.Queue()
  176. # 工作线程
  177. self.running = False
  178. self.worker_thread = None
  179. # 统计
  180. self.stats = {
  181. 'total_reports': 0,
  182. 'success_reports': 0,
  183. 'failed_reports': 0,
  184. }
  185. self.stats_lock = threading.Lock()
  186. # 回调
  187. self.on_report_success: Optional[Callable] = None
  188. self.on_report_failed: Optional[Callable] = None
  189. # 最后上报时间
  190. self.last_report_time = 0
  191. if self.enabled:
  192. logger.info(f"[第三方平台] 推送器初始化完成: {self.base_url}")
  193. def start(self):
  194. """启动推送器"""
  195. if not self.enabled:
  196. logger.info("[第三方平台] 推送器未启用")
  197. return
  198. if self.running:
  199. return
  200. self.running = True
  201. self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
  202. self.worker_thread.start()
  203. logger.info("[第三方平台] 推送器已启动")
  204. def stop(self):
  205. """停止推送器"""
  206. self.running = False
  207. if self.worker_thread:
  208. self.worker_thread.join(timeout=5)
  209. logger.info("[第三方平台] 推送器已停止")
  210. def _worker_loop(self):
  211. """工作线程循环"""
  212. while self.running:
  213. try:
  214. report = self.report_queue.get(timeout=1.0)
  215. self._process_report(report)
  216. except queue.Empty:
  217. continue
  218. except Exception as e:
  219. logger.error(f"[第三方平台] 处理上报错误: {e}")
  220. def _get_auth_headers(self) -> Dict[str, str]:
  221. """获取认证请求头(当前第三方接口不需要自定义 header,返回空避免 422)"""
  222. return {}
  223. def _get_oauth2_token(self) -> Optional[str]:
  224. """获取 OAuth2 Token"""
  225. # 检查现有 token 是否有效
  226. if self._access_token and time.time() < self._token_expires_at - 60:
  227. return self._access_token
  228. # 重新获取 token
  229. token_url = self.oauth2_config.get('token_url', '')
  230. client_id = self.oauth2_config.get('client_id', '')
  231. client_secret = self.oauth2_config.get('client_secret', '')
  232. scope = self.oauth2_config.get('scope', '')
  233. if not all([token_url, client_id, client_secret]):
  234. logger.error("[第三方平台] OAuth2 配置不完整")
  235. return None
  236. try:
  237. data = {
  238. 'grant_type': 'client_credentials',
  239. 'client_id': client_id,
  240. 'client_secret': client_secret,
  241. }
  242. if scope:
  243. data['scope'] = scope
  244. response = requests.post(token_url, data=data, timeout=self.timeout)
  245. if response.status_code == 200:
  246. result = response.json()
  247. self._access_token = result.get('access_token')
  248. expires_in = result.get('expires_in', 3600)
  249. self._token_expires_at = time.time() + expires_in
  250. logger.info("[第三方平台] OAuth2 Token 获取成功")
  251. return self._access_token
  252. else:
  253. logger.error(f"[第三方平台] OAuth2 Token 获取失败: {response.status_code}")
  254. return None
  255. except Exception as e:
  256. logger.error(f"[第三方平台] OAuth2 Token 请求异常: {e}")
  257. return None
  258. def _process_report(self, report: BatchReport):
  259. """处理单个上报任务"""
  260. # 检查推送间隔
  261. current_time = time.time()
  262. time_since_last = current_time - self.last_report_time
  263. if time_since_last < self.push_interval:
  264. time.sleep(self.push_interval - time_since_last)
  265. success = self._send_batch_report(report)
  266. with self.stats_lock:
  267. self.stats['total_reports'] += 1
  268. if success:
  269. self.stats['success_reports'] += 1
  270. else:
  271. self.stats['failed_reports'] += 1
  272. self.last_report_time = time.time()
  273. # 触发回调
  274. if success and self.on_report_success:
  275. try:
  276. self.on_report_success(report)
  277. except Exception as e:
  278. logger.error(f"[第三方平台] 成功回调执行错误: {e}")
  279. elif not success and self.on_report_failed:
  280. try:
  281. self.on_report_failed(report)
  282. except Exception as e:
  283. logger.error(f"[第三方平台] 失败回调执行错误: {e}")
  284. def _send_batch_report(self, report: BatchReport) -> bool:
  285. """
  286. 发送批次上报请求
  287. Args:
  288. report: 批次上报数据
  289. Returns:
  290. bool: 是否成功
  291. """
  292. if not self.base_url:
  293. logger.error("[第三方平台] 未配置 base_url")
  294. return False
  295. url = f"{self.base_url}{self.batch_report_url}"
  296. # 构建请求数据
  297. payload = self._build_payload(report)
  298. try:
  299. logger.info("[第三方平台] 上报 payload: %s", json.dumps(payload, ensure_ascii=False))
  300. except Exception:
  301. logger.info("[第三方平台] 上报 payload: %s", payload)
  302. headers = self._get_auth_headers()
  303. for attempt in range(self.retry_count):
  304. try:
  305. if self.data_format == 'json':
  306. response = requests.post(
  307. url,
  308. json=payload,
  309. headers=headers,
  310. timeout=self.timeout,
  311. verify=False
  312. )
  313. else:
  314. response = requests.post(
  315. url,
  316. data=payload,
  317. headers=headers,
  318. timeout=self.timeout,
  319. verify=False
  320. )
  321. if response.status_code == 200:
  322. result = response.json()
  323. status = result.get('status', '')
  324. message = result.get('message', '')
  325. if (result.get('code') == 200 or
  326. result.get('success') == True or
  327. status in ('pending', 'success', 'accepted') or
  328. '请求已接收' in message or
  329. message == 'accepted'):
  330. logger.info(f"[第三方平台] 批次上报成功: {report.batch_id}, task_id={result.get('task_id')}")
  331. return True
  332. else:
  333. logger.warning(f"[第三方平台] 批次上报失败: {result.get('msg', '未知错误')}")
  334. try:
  335. logger.warning(f"[第三方平台] 响应内容: {str(result)[:500]}")
  336. except Exception:
  337. pass
  338. else:
  339. logger.warning(f"[第三方平台] 批次上报失败: HTTP {response.status_code}")
  340. try:
  341. logger.warning(f"[第三方平台] 响应内容: {response.text[:500]}")
  342. except Exception:
  343. pass
  344. if attempt < self.retry_count - 1:
  345. time.sleep(self.retry_delay)
  346. except requests.exceptions.Timeout:
  347. logger.warning(f"[第三方平台] 请求超时 (尝试 {attempt + 1}/{self.retry_count})")
  348. if attempt < self.retry_count - 1:
  349. time.sleep(self.retry_delay)
  350. except Exception as e:
  351. logger.error(f"[第三方平台] 请求异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
  352. if attempt < self.retry_count - 1:
  353. time.sleep(self.retry_delay)
  354. logger.error(f"[第三方平台] 批次上报最终失败: {report.batch_id}")
  355. return False
  356. def _build_payload(self, report: BatchReport) -> Dict[str, Any]:
  357. """
  358. 构建上报请求体
  359. Args:
  360. report: 批次上报数据
  361. Returns:
  362. Dict: 请求体字典
  363. """
  364. batch_info = report.batch_info
  365. # 根据平台类型调整格式
  366. normalized_ts = _normalize_timestamp(report.timestamp)
  367. if self.platform_type == 'jtjai':
  368. # 优先取 OSS URL,否则用本地路径
  369. urls = batch_info.get('image_urls') or {}
  370. image_url = urls.get('original') or urls.get('marked') or (batch_info.get('image_paths') or [None])[0]
  371. payload = {
  372. 'createTime': datetime.fromtimestamp(normalized_ts).strftime("%Y-%m-%d %H:%M:%S"),
  373. 'addr': f"设备{report.device_id}批次上报",
  374. 'installAddress': batch_info.get('install_address', ''),
  375. 'ext1': json.dumps([image_url]),
  376. 'ext2': json.dumps({
  377. 'batchId': report.batch_id,
  378. 'deviceId': report.device_id,
  379. 'projectId': report.project_id,
  380. 'installAddress': batch_info.get('install_address', ''),
  381. 'totalPersons': len(batch_info.get('detections', [])),
  382. 'ptzImagesCount': 1 if batch_info.get('camera_type') == 'ptz' else 0,
  383. 'persons': batch_info.get('detections', []),
  384. 'imageUrls': urls,
  385. })
  386. }
  387. else:
  388. # custom / 其他平台:把新版 batch_info 转回老字段名后上报,
  389. # 兼容原人体分析平台对 panorama / total_persons / persons 的解析。
  390. payload = _convert_to_legacy_batch_info(batch_info)
  391. # 统一时间戳单位为秒,避免第三方解析错误
  392. payload['timestamp'] = normalized_ts
  393. return payload
  394. def report_batch(self, batch_info: Dict[str, Any], local_path: Optional[str] = None):
  395. """
  396. 上报批次信息
  397. Args:
  398. batch_info: batch_info.json 的字典内容
  399. local_path: batch_info.json 的本地文件路径(可选)
  400. """
  401. if not self.enabled:
  402. return
  403. # 接受所有相机类型的检测上报(panorama 或 ptz)
  404. # 业务流程:检测到人 → 上传 OSS → 推送第三方平台
  405. report = BatchReport(
  406. batch_id=batch_info.get('batch_id', ''),
  407. device_id=batch_info.get('device_id', ''),
  408. project_id=batch_info.get('project_id', ''),
  409. timestamp=batch_info.get('timestamp', time.time()),
  410. batch_info=batch_info,
  411. local_path=local_path
  412. )
  413. self.report_queue.put(report)
  414. def report_batch_sync(self, batch_info: Dict[str, Any],
  415. local_path: Optional[str] = None) -> bool:
  416. """
  417. 同步上报批次信息
  418. Args:
  419. batch_info: batch_info.json 的字典内容
  420. local_path: batch_info.json 的本地文件路径(可选)
  421. Returns:
  422. bool: 是否成功
  423. """
  424. if not self.enabled:
  425. return False
  426. report = BatchReport(
  427. batch_id=batch_info.get('batch_id', ''),
  428. device_id=batch_info.get('device_id', ''),
  429. project_id=batch_info.get('project_id', ''),
  430. timestamp=batch_info.get('timestamp', time.time()),
  431. batch_info=batch_info,
  432. local_path=local_path
  433. )
  434. return self._send_batch_report(report)
  435. def send_heartbeat(self) -> bool:
  436. """
  437. 发送心跳
  438. Returns:
  439. bool: 是否成功
  440. """
  441. if not self.enabled or not self.heartbeat_url:
  442. return False
  443. url = f"{self.base_url}{self.heartbeat_url}"
  444. payload = {
  445. 'deviceId': self.device_config.get('device_id', ''),
  446. 'projectId': self.device_config.get('project_id', ''),
  447. 'timestamp': time.time(),
  448. 'status': 'online',
  449. }
  450. headers = self._get_auth_headers()
  451. try:
  452. response = requests.post(
  453. url,
  454. json=payload,
  455. headers=headers,
  456. timeout=self.timeout,
  457. verify=False
  458. )
  459. if response.status_code == 200:
  460. logger.debug("[第三方平台] 心跳发送成功")
  461. return True
  462. else:
  463. logger.warning(f"[第三方平台] 心跳发送失败: HTTP {response.status_code}")
  464. return False
  465. except Exception as e:
  466. logger.error(f"[第三方平台] 心跳发送异常: {e}")
  467. return False
  468. def set_callbacks(self, on_success: Callable = None, on_failed: Callable = None):
  469. """
  470. 设置回调函数
  471. Args:
  472. on_success: 上报成功回调
  473. on_failed: 上报失败回调
  474. """
  475. self.on_report_success = on_success
  476. self.on_report_failed = on_failed
  477. def get_stats(self) -> Dict[str, int]:
  478. """获取统计信息"""
  479. with self.stats_lock:
  480. return self.stats.copy()
  481. def is_enabled(self) -> bool:
  482. """检查是否启用"""
  483. return self.enabled
  484. # 全局单例
  485. _third_party_pusher_instance: Optional[ThirdPartyPusher] = None
  486. _third_party_pusher_lock = threading.Lock()
  487. def get_third_party_pusher(config: Dict[str, Any] = None) -> ThirdPartyPusher:
  488. """
  489. 获取第三方平台推送器实例(单例模式,线程安全)
  490. Args:
  491. config: 第三方平台配置
  492. Returns:
  493. ThirdPartyPusher 实例
  494. """
  495. global _third_party_pusher_instance
  496. if _third_party_pusher_instance is None:
  497. with _third_party_pusher_lock:
  498. if _third_party_pusher_instance is None:
  499. _third_party_pusher_instance = ThirdPartyPusher(config)
  500. return _third_party_pusher_instance
  501. def reset_third_party_pusher():
  502. """重置第三方平台推送器实例"""
  503. global _third_party_pusher_instance
  504. with _third_party_pusher_lock:
  505. if _third_party_pusher_instance is not None:
  506. _third_party_pusher_instance.stop()
  507. _third_party_pusher_instance = None