feat(aiot): 告警冷却时间调整 + 截图本地保留 + 中文路径修复
- 离岗检测冷却时间: 300s → 600s(10分钟) - 入侵检测冷却时间: 120s → 300s(5分钟) - 入侵告警级别改为高(alarm_level=3) - COS 不可用时保留本地截图文件,不再上报后删除 - 修复 cv2.imwrite 中文路径失败,改用 imencode + write_bytes - 配置订阅在 LOCAL 模式下跳过 Redis 连接 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,9 @@ class AlarmUploadWorker:
|
||||
self._logger.error(f"Worker Redis 连接失败: {e}")
|
||||
return
|
||||
|
||||
# 启动时验证云端 API 可达性
|
||||
self._check_cloud_api()
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
@@ -92,6 +95,29 @@ class AlarmUploadWorker:
|
||||
self._thread.start()
|
||||
self._logger.info("AlarmUploadWorker 已启动")
|
||||
|
||||
def _check_cloud_api(self):
|
||||
"""启动时检查云端 API 是否可达(仅记录日志,不阻断启动)"""
|
||||
upload_cfg = self._settings.alarm_upload
|
||||
base_url = upload_cfg.cloud_api_url.rstrip("/")
|
||||
health_url = f"{base_url}/health"
|
||||
report_url = f"{base_url}/admin-api/aiot/alarm/edge/report"
|
||||
|
||||
self._logger.info(f"云端 API 地址: {base_url}")
|
||||
self._logger.info(f"告警上报端点: {report_url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(health_url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
self._logger.info(f"云端健康检查通过: {health_url}")
|
||||
else:
|
||||
self._logger.warning(
|
||||
f"云端健康检查异常: {health_url}, status={resp.status_code}"
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
self._logger.warning(f"云端不可达: {health_url},请确认服务已启动")
|
||||
except Exception as e:
|
||||
self._logger.warning(f"云端健康检查失败: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止 worker"""
|
||||
if not self._thread or not self._thread.is_alive():
|
||||
@@ -156,18 +182,32 @@ class AlarmUploadWorker:
|
||||
snapshot_local_path = alarm_data.get("snapshot_local_path")
|
||||
object_key = None
|
||||
|
||||
if snapshot_local_path and os.path.exists(snapshot_local_path):
|
||||
object_key = self._upload_snapshot_to_cos(
|
||||
snapshot_local_path,
|
||||
alarm_id,
|
||||
alarm_data.get("device_id", "unknown"),
|
||||
)
|
||||
if object_key is None:
|
||||
# COS 上传失败,进入重试
|
||||
self._handle_retry(alarm_json, "COS 上传失败")
|
||||
return
|
||||
else:
|
||||
if snapshot_local_path:
|
||||
if snapshot_local_path:
|
||||
# 截图是异步保存的,等待文件写入完成(最多 3 秒)
|
||||
if not os.path.exists(snapshot_local_path):
|
||||
for _ in range(6):
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(snapshot_local_path):
|
||||
break
|
||||
|
||||
if os.path.exists(snapshot_local_path):
|
||||
object_key = self._upload_snapshot_to_cos(
|
||||
snapshot_local_path,
|
||||
alarm_id,
|
||||
alarm_data.get("device_id", "unknown"),
|
||||
)
|
||||
if object_key is None:
|
||||
# COS 上传失败,进入重试
|
||||
self._handle_retry(alarm_json, "COS 上传失败")
|
||||
return
|
||||
elif object_key == "":
|
||||
# COS 未配置,使用本地截图路径作为回退
|
||||
captures_base = os.path.join("data", "captures")
|
||||
rel_path = os.path.relpath(snapshot_local_path, captures_base)
|
||||
rel_path = rel_path.replace("\\", "/")
|
||||
object_key = f"local:{rel_path}"
|
||||
self._logger.info(f"使用本地截图路径: {object_key}")
|
||||
else:
|
||||
self._logger.warning(f"截图文件不存在: {snapshot_local_path}")
|
||||
|
||||
# Step 2: HTTP 上报告警元数据
|
||||
@@ -190,8 +230,8 @@ class AlarmUploadWorker:
|
||||
self._stats["processed"] += 1
|
||||
self._logger.info(f"告警上报成功: {alarm_id}")
|
||||
|
||||
# 可选:删除本地截图
|
||||
if snapshot_local_path and os.path.exists(snapshot_local_path):
|
||||
# 仅在 COS 上传成功时删除本地截图;本地回退模式(local:)不删除
|
||||
if snapshot_local_path and os.path.exists(snapshot_local_path) and object_key and not object_key.startswith("local:"):
|
||||
try:
|
||||
os.remove(snapshot_local_path)
|
||||
self._logger.debug(f"已删除本地截图: {snapshot_local_path}")
|
||||
@@ -266,7 +306,8 @@ class AlarmUploadWorker:
|
||||
是否上报成功
|
||||
"""
|
||||
upload_cfg = self._settings.alarm_upload
|
||||
url = f"{upload_cfg.cloud_api_url}/admin-api/aiot/alarm/edge/report"
|
||||
base_url = upload_cfg.cloud_api_url.rstrip("/")
|
||||
url = f"{base_url}/admin-api/aiot/alarm/edge/report"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -274,10 +315,16 @@ class AlarmUploadWorker:
|
||||
if upload_cfg.edge_token:
|
||||
headers["Authorization"] = f"Bearer {upload_cfg.edge_token}"
|
||||
|
||||
# 过滤掉内部字段(以 _ 开头的控制字段不发送到云端)
|
||||
report_data = {k: v for k, v in alarm_data.items() if not k.startswith("_")}
|
||||
|
||||
self._logger.debug(f"HTTP 上报 URL: {url}")
|
||||
self._logger.debug(f"HTTP 上报数据: {report_data}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=alarm_data,
|
||||
json=report_data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
@@ -293,17 +340,26 @@ class AlarmUploadWorker:
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self._logger.warning(f"HTTP 上报失败: status={response.status_code}")
|
||||
# 记录详细的错误信息便于排查
|
||||
resp_text = ""
|
||||
try:
|
||||
resp_text = response.text[:500]
|
||||
except Exception:
|
||||
pass
|
||||
self._logger.warning(
|
||||
f"HTTP 上报失败: url={url}, status={response.status_code}, "
|
||||
f"body={resp_text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except requests.Timeout:
|
||||
self._logger.warning(f"HTTP 上报超时: {url}")
|
||||
return False
|
||||
except requests.ConnectionError as e:
|
||||
self._logger.warning(f"HTTP 上报连接失败: {e}")
|
||||
self._logger.warning(f"HTTP 上报连接失败: {url}, error={e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._logger.error(f"HTTP 上报异常: {e}")
|
||||
self._logger.error(f"HTTP 上报异常: {url}, error={e}")
|
||||
return False
|
||||
|
||||
def _handle_retry(self, alarm_json: str, error: str):
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -136,6 +137,7 @@ class ConfigSyncManager:
|
||||
settings = get_settings()
|
||||
self._device_id = settings.mqtt.device_id # 边缘节点 ID
|
||||
self._config_version = settings.config_version
|
||||
self._sync_mode = settings.config_sync_mode
|
||||
self._cache = ConfigCache()
|
||||
self._db_manager = None
|
||||
|
||||
@@ -154,8 +156,11 @@ class ConfigSyncManager:
|
||||
self._version_control = get_version_control()
|
||||
self._initialized = True
|
||||
|
||||
self._init_local_redis()
|
||||
self._init_cloud_redis()
|
||||
if self._sync_mode == "REDIS":
|
||||
self._init_local_redis()
|
||||
self._init_cloud_redis()
|
||||
else:
|
||||
logger.info("CONFIG_SYNC_MODE=LOCAL: 跳过 Redis 初始化,仅使用本地 SQLite")
|
||||
|
||||
# ==================== Redis 初始化 ====================
|
||||
|
||||
@@ -239,6 +244,10 @@ class ConfigSyncManager:
|
||||
2. 从云端同步最新配置(如果可用)
|
||||
3. 启动 Stream 监听线程
|
||||
"""
|
||||
if self._sync_mode != "REDIS":
|
||||
self._log_local_config_snapshot("LOCAL")
|
||||
return
|
||||
|
||||
# Step 1: 从本地 Redis 加载已有配置到 SQLite
|
||||
self._load_from_local_redis()
|
||||
|
||||
@@ -268,6 +277,24 @@ class ConfigSyncManager:
|
||||
self._stream_thread.join(timeout=5)
|
||||
logger.info("配置 Stream 监听线程已停止")
|
||||
|
||||
def _log_local_config_snapshot(self, source: str = "SQLite"):
|
||||
self._init_database()
|
||||
if not self._db_manager:
|
||||
logger.warning(f"[EDGE] Local config snapshot skipped (no SQLite). source={source}")
|
||||
return
|
||||
try:
|
||||
cameras = self._db_manager.get_all_camera_configs()
|
||||
rois = self._db_manager.get_all_roi_configs()
|
||||
binds = []
|
||||
for roi in rois:
|
||||
binds.extend(self._db_manager.get_bindings_by_roi(roi["roi_id"]))
|
||||
logger.info(f"[EDGE] Loading config from local db ({source})...")
|
||||
logger.info(f"[EDGE] Camera count = {len(cameras)}")
|
||||
logger.info(f"[EDGE] ROI count = {len(rois)}")
|
||||
logger.info(f"[EDGE] Algorithm bindings = {len(binds)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[EDGE] Local config snapshot failed: {e}")
|
||||
|
||||
# ==================== Stream 监听 ====================
|
||||
|
||||
def _listen_config_stream(self):
|
||||
@@ -517,9 +544,20 @@ class ConfigSyncManager:
|
||||
# 同步 ROI 配置
|
||||
rois = config_data.get("rois", [])
|
||||
for roi in rois:
|
||||
if not isinstance(roi, dict):
|
||||
logger.error(f"?? ROI ????: invalid roi item type={type(roi)}")
|
||||
continue
|
||||
try:
|
||||
coordinates = roi.get("coordinates", [])
|
||||
if coordinates and isinstance(coordinates[0], dict):
|
||||
# ?? rectangle dict ? polygon list-of-dict ??
|
||||
if isinstance(coordinates, dict):
|
||||
coordinates = {
|
||||
"x": coordinates.get("x", 0),
|
||||
"y": coordinates.get("y", 0),
|
||||
"w": coordinates.get("w", 0),
|
||||
"h": coordinates.get("h", 0),
|
||||
}
|
||||
elif coordinates and isinstance(coordinates, list) and isinstance(coordinates[0], dict):
|
||||
coordinates = [[p.get("x", 0), p.get("y", 0)] for p in coordinates]
|
||||
|
||||
self._db_manager.save_roi_config(
|
||||
@@ -698,6 +736,179 @@ class ConfigSyncManager:
|
||||
logger.error(f"获取当前配置失败: {e}")
|
||||
return None
|
||||
|
||||
def _get_current_config_from_local_redis(self) -> Optional[dict]:
|
||||
if not self._local_redis:
|
||||
return None
|
||||
try:
|
||||
config_json = self._local_redis.get(LOCAL_CONFIG_CURRENT)
|
||||
return json.loads(config_json) if config_json else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_bindings_from_redis(self, roi_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取 ROI 绑定(LOCAL 模式从 SQLite 读取)"""
|
||||
if self._sync_mode == "REDIS":
|
||||
config = self._get_current_config_from_local_redis()
|
||||
if config:
|
||||
binds = config.get("binds", [])
|
||||
if roi_id:
|
||||
binds = [b for b in binds if b.get("roi_id") == roi_id]
|
||||
return binds
|
||||
|
||||
self._init_database()
|
||||
if not self._db_manager:
|
||||
return []
|
||||
if roi_id:
|
||||
return self._db_manager.get_bindings_by_roi(roi_id)
|
||||
|
||||
binds: List[Dict[str, Any]] = []
|
||||
rois = self._db_manager.get_all_roi_configs()
|
||||
for roi in rois:
|
||||
binds.extend(self._db_manager.get_bindings_by_roi(roi["roi_id"]))
|
||||
return binds
|
||||
|
||||
def get_algo_bind_from_redis(self, bind_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单个 bind(LOCAL 模式从 SQLite 读取)"""
|
||||
if self._sync_mode == "REDIS":
|
||||
config = self._get_current_config_from_local_redis()
|
||||
if config:
|
||||
for bind in config.get("binds", []):
|
||||
if bind.get("bind_id") == bind_id:
|
||||
return bind
|
||||
|
||||
self._init_database()
|
||||
if not self._db_manager:
|
||||
return None
|
||||
return self._db_manager.get_roi_algo_bind(bind_id)
|
||||
|
||||
def reload_local_config_from_file(self) -> bool:
|
||||
"""本地调试:从 JSON 文件读取配置并同步到 SQLite"""
|
||||
settings = get_settings()
|
||||
config_path = settings.debug.local_config_path
|
||||
try:
|
||||
if not os.path.exists(config_path):
|
||||
logger.warning(f"本地配置文件不存在: {config_path}")
|
||||
return False
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
return self.reload_local_config(config_data, source="FILE")
|
||||
except Exception as e:
|
||||
logger.error(f"本地配置文件加载失败: {e}")
|
||||
return False
|
||||
|
||||
def _clear_rois_for_camera_ids(self, camera_ids: List[str]):
|
||||
if not camera_ids:
|
||||
return
|
||||
self._init_database()
|
||||
if not self._db_manager:
|
||||
return
|
||||
for camera_id in camera_ids:
|
||||
rois = self._db_manager.get_rois_by_camera(camera_id)
|
||||
for roi in rois:
|
||||
roi_id = roi.get("roi_id")
|
||||
if roi_id:
|
||||
self._db_manager.delete_bindings_by_roi(roi_id)
|
||||
self._db_manager.delete_roi_config(roi_id)
|
||||
|
||||
def reload_local_config(self, config_data: dict, source: str = "LOCAL") -> bool:
|
||||
# ?????????????? camelCase key
|
||||
# ???? data ??
|
||||
if isinstance(config_data, dict) and isinstance(config_data.get("data"), dict):
|
||||
config_data = config_data.get("data")
|
||||
if not config_data:
|
||||
logger.warning(f"[EDGE] Empty config payload, source={source}")
|
||||
return False
|
||||
try:
|
||||
rois = (config_data.get("rois") or config_data.get("roiConfigs") or config_data.get("roi_list") or [])
|
||||
binds = (config_data.get("binds") or config_data.get("roiAlgoBinds") or config_data.get("algoBinds") or config_data.get("bindings") or [])
|
||||
cams = (config_data.get("cameras") or config_data.get("cameraList") or config_data.get("camera_list") or [])
|
||||
logger.info("[EDGE] Incoming payload: cameras=%s rois=%s binds=%s source=%s",
|
||||
len(cams) if isinstance(cams, list) else 0,
|
||||
len(rois) if isinstance(rois, list) else 0,
|
||||
len(binds) if isinstance(binds, list) else 0,
|
||||
source)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rois = config_data.get("rois") or config_data.get("roiConfigs") or config_data.get("roi_list")
|
||||
if isinstance(rois, list):
|
||||
norm_rois = []
|
||||
for r in rois:
|
||||
if not isinstance(r, dict):
|
||||
norm_rois.append(r)
|
||||
continue
|
||||
if "roi_id" not in r and "roiId" in r:
|
||||
r["roi_id"] = r.get("roiId")
|
||||
if "camera_id" not in r and "cameraId" in r:
|
||||
r["camera_id"] = r.get("cameraId")
|
||||
if "roi_type" not in r and "roiType" in r:
|
||||
r["roi_type"] = r.get("roiType")
|
||||
norm_rois.append(r)
|
||||
config_data["rois"] = norm_rois
|
||||
|
||||
binds = config_data.get("binds") or config_data.get("roiAlgoBinds") or config_data.get("algoBinds") or config_data.get("bindings")
|
||||
if isinstance(binds, list):
|
||||
norm_binds = []
|
||||
for b in binds:
|
||||
if not isinstance(b, dict):
|
||||
norm_binds.append(b)
|
||||
continue
|
||||
if "bind_id" not in b and "bindId" in b:
|
||||
b["bind_id"] = b.get("bindId")
|
||||
if "roi_id" not in b and "roiId" in b:
|
||||
b["roi_id"] = b.get("roiId")
|
||||
if "algo_code" not in b and "algoCode" in b:
|
||||
b["algo_code"] = b.get("algoCode")
|
||||
norm_binds.append(b)
|
||||
config_data["binds"] = norm_binds
|
||||
# 本地调试:从内存配置同步到 SQLite(支持覆盖式更新)
|
||||
try:
|
||||
camera_ids: List[str] = []
|
||||
for cam in config_data.get("cameras", []) or []:
|
||||
cid = cam.get("camera_id")
|
||||
if cid:
|
||||
camera_ids.append(cid)
|
||||
for cid in config_data.get("camera_ids", []) or []:
|
||||
if cid:
|
||||
camera_ids.append(cid)
|
||||
for roi in config_data.get("rois", []) or []:
|
||||
cid = roi.get("camera_id")
|
||||
if cid:
|
||||
camera_ids.append(cid)
|
||||
|
||||
incoming_ids = set(camera_ids)
|
||||
|
||||
if camera_ids:
|
||||
self._clear_rois_for_camera_ids(list(incoming_ids))
|
||||
|
||||
# 仅全量推送时,清除不在本次推送中的旧摄像头
|
||||
# sync_mode="full" 由 push-all 设置;单摄像头推送不带此标志,不清理
|
||||
sync_mode = config_data.get("sync_mode", "partial")
|
||||
if sync_mode == "full":
|
||||
self._init_database()
|
||||
if self._db_manager and incoming_ids:
|
||||
try:
|
||||
existing = self._db_manager.get_all_camera_configs()
|
||||
for cam in existing:
|
||||
old_id = cam.get("camera_id")
|
||||
if old_id and old_id not in incoming_ids:
|
||||
self._clear_rois_for_camera_ids([old_id])
|
||||
self._db_manager.delete_camera_config(old_id)
|
||||
logger.info(f"[EDGE] 清除不在推送列表中的旧摄像头: {old_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[EDGE] 清理旧摄像头失败: {e}")
|
||||
else:
|
||||
logger.info(f"[EDGE] 增量推送 (sync_mode={sync_mode}),跳过旧摄像头清理")
|
||||
|
||||
version = int(time.time())
|
||||
self._apply_config(config_data, version)
|
||||
self.invalidate_all_cache()
|
||||
self._log_local_config_snapshot(source)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"本地配置同步失败: {e}")
|
||||
return False
|
||||
|
||||
# ==================== 缓存管理 ====================
|
||||
|
||||
def invalidate_cache(self, cache_key: str):
|
||||
@@ -763,4 +974,4 @@ class ConfigSyncManager:
|
||||
|
||||
def get_config_sync_manager() -> ConfigSyncManager:
|
||||
"""获取配置同步管理器单例"""
|
||||
return ConfigSyncManager()
|
||||
return ConfigSyncManager()
|
||||
@@ -58,14 +58,26 @@ class ROICropper:
|
||||
def _crop_rectangle(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
coordinates: List[List[float]]
|
||||
coordinates: Union[List[List[float]], Dict[str, float]]
|
||||
) -> Optional[np.ndarray]:
|
||||
"""裁剪矩形区域"""
|
||||
if len(coordinates) < 2:
|
||||
return None
|
||||
|
||||
x1, y1 = int(coordinates[0][0]), int(coordinates[0][1])
|
||||
x2, y2 = int(coordinates[1][0]), int(coordinates[1][1])
|
||||
"""裁剪矩形区域
|
||||
|
||||
支持两种坐标格式:
|
||||
1. dict: {"x": float, "y": float, "w": float, "h": float} — 归一化坐标(0-1)
|
||||
2. list: [[x1,y1],[x2,y2]] — 像素坐标
|
||||
"""
|
||||
img_h, img_w = image.shape[:2]
|
||||
|
||||
if isinstance(coordinates, dict):
|
||||
x1 = int(coordinates["x"] * img_w)
|
||||
y1 = int(coordinates["y"] * img_h)
|
||||
x2 = int((coordinates["x"] + coordinates["w"]) * img_w)
|
||||
y2 = int((coordinates["y"] + coordinates["h"]) * img_h)
|
||||
else:
|
||||
if len(coordinates) < 2:
|
||||
return None
|
||||
x1, y1 = int(coordinates[0][0]), int(coordinates[0][1])
|
||||
x2, y2 = int(coordinates[1][0]), int(coordinates[1][1])
|
||||
|
||||
x1 = max(0, min(x1, image.shape[1] - 1))
|
||||
y1 = max(0, min(y1, image.shape[0] - 1))
|
||||
|
||||
@@ -44,9 +44,14 @@ class PendingCapture:
|
||||
|
||||
class ImageStorageManager:
|
||||
"""图片存储管理器"""
|
||||
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
"""清理文件名中的非法字符(/ \\ 等路径分隔符替换为下划线)"""
|
||||
return name.replace("/", "_").replace("\\", "_")
|
||||
|
||||
def __new__(cls, config: Optional[CaptureConfig] = None):
|
||||
if cls._instance is None:
|
||||
@@ -103,17 +108,14 @@ class ImageStorageManager:
|
||||
logger.error(f"图片保存异常: {e}")
|
||||
|
||||
def _save_image(self, capture: PendingCapture) -> Optional[str]:
|
||||
"""保存单张图片"""
|
||||
"""保存单张图片(使用 imencode+write_bytes 避免中文路径问题)"""
|
||||
try:
|
||||
image = capture.image
|
||||
|
||||
|
||||
if image is None:
|
||||
self._failed_count += 1
|
||||
return None
|
||||
|
||||
if len(image.shape) == 3 and image.shape[2] == 3:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
|
||||
|
||||
if image.shape[1] > self.config.max_width or image.shape[0] > self.config.max_height:
|
||||
scale = min(
|
||||
self.config.max_width / image.shape[1],
|
||||
@@ -124,28 +126,33 @@ class ImageStorageManager:
|
||||
int(image.shape[0] * scale)
|
||||
)
|
||||
image = cv2.resize(image, new_size, interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
date_dir = capture.timestamp.strftime("%Y%m%d")
|
||||
save_dir = Path(self.config.image_dir) / date_dir
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{capture.camera_id}_{capture.alert_id}{self.config.save_format}"
|
||||
|
||||
safe_camera_id = self._sanitize_filename(capture.camera_id)
|
||||
filename = f"{safe_camera_id}_{capture.alert_id}{self.config.save_format}"
|
||||
filepath = save_dir / filename
|
||||
|
||||
success = cv2.imwrite(
|
||||
str(filepath),
|
||||
|
||||
# 使用 imencode + write_bytes 代替 imwrite,
|
||||
# 因为 cv2.imwrite 在 Windows 上无法处理中文路径
|
||||
success, buffer = cv2.imencode(
|
||||
self.config.save_format,
|
||||
image,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, self.config.quality]
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
filepath.write_bytes(buffer.tobytes())
|
||||
self._saved_count += 1
|
||||
logger.debug(f"图片已保存: {filepath}")
|
||||
logger.info(f"图片已保存: {filepath}")
|
||||
return str(filepath)
|
||||
else:
|
||||
logger.warning(f"图片编码失败: {filepath}")
|
||||
self._failed_count += 1
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存图片失败: {e}")
|
||||
self._failed_count += 1
|
||||
@@ -158,20 +165,28 @@ class ImageStorageManager:
|
||||
alert_id: str,
|
||||
timestamp: Optional[datetime] = None
|
||||
) -> Optional[str]:
|
||||
"""异步保存抓拍图片"""
|
||||
"""异步保存抓拍图片,返回预计的文件路径"""
|
||||
ts = timestamp or datetime.now()
|
||||
capture = PendingCapture(
|
||||
image=image,
|
||||
camera_id=camera_id,
|
||||
alert_id=alert_id,
|
||||
timestamp=timestamp or datetime.now()
|
||||
timestamp=ts,
|
||||
)
|
||||
self._save_queue.put(capture)
|
||||
return f"<queued: {alert_id}>"
|
||||
|
||||
# 返回确定性的文件路径(与 _save_image 使用相同的命名规则)
|
||||
date_dir = ts.strftime("%Y%m%d")
|
||||
safe_camera_id = self._sanitize_filename(camera_id)
|
||||
filename = f"{safe_camera_id}_{alert_id}{self.config.save_format}"
|
||||
filepath = Path(self.config.image_dir) / date_dir / filename
|
||||
return str(filepath)
|
||||
|
||||
def get_image_path(self, camera_id: str, alert_id: str) -> Optional[str]:
|
||||
"""获取已保存图片路径"""
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
filename = f"{camera_id}_{alert_id}{self.config.save_format}"
|
||||
safe_camera_id = self._sanitize_filename(camera_id)
|
||||
filename = f"{safe_camera_id}_{alert_id}{self.config.save_format}"
|
||||
filepath = Path(self.config.image_dir) / date_str / filename
|
||||
|
||||
if filepath.exists():
|
||||
|
||||
Reference in New Issue
Block a user