feat: add alarm workflow and packaging support

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
sladro 2025-11-13 11:38:45 +08:00
parent bdcfc708cb
commit 6fba8a981a
4 changed files with 255 additions and 9 deletions

42
main.py
View File

@ -15,7 +15,11 @@ os.environ['OPENCV_LOG_LEVEL'] = 'ERROR'
os.environ['OPENCV_VIDEOIO_DEBUG'] = '0' os.environ['OPENCV_VIDEOIO_DEBUG'] = '0'
# 添加项目根目录到Python路径 # 添加项目根目录到Python路径
if getattr(sys, 'frozen', False):
project_root = Path(getattr(sys, '_MEIPASS'))
else:
project_root = Path(__file__).parent project_root = Path(__file__).parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
from src.camera.opencv_camera import OpenCVCamera from src.camera.opencv_camera import OpenCVCamera
@ -23,6 +27,7 @@ from src.preprocessing.image_enhancer import ImageEnhancer
from src.roi_detection.led_detector import LEDDetector from src.roi_detection.led_detector import LEDDetector
from src.output.result_formatter import ResultFormatter from src.output.result_formatter import ResultFormatter
from src.output.logger import LEDLogger from src.output.logger import LEDLogger
from src.output.alarm_manager import AlarmManager
from src.ui.tech_ui import TechUI from src.ui.tech_ui import TechUI
@ -47,6 +52,7 @@ class YantaiVisionXSystem:
self.led_detector = None self.led_detector = None
self.formatter = ResultFormatter() self.formatter = ResultFormatter()
self.logger = LEDLogger() self.logger = LEDLogger()
self.alarm_manager = AlarmManager(self.logger)
self.tech_ui = TechUI() self.tech_ui = TechUI()
# 状态变量 # 状态变量
@ -80,6 +86,11 @@ class YantaiVisionXSystem:
) )
self.logger.log_info("LED检测器初始化完成") self.logger.log_info("LED检测器初始化完成")
if self.led_detector and hasattr(self.led_detector, 'algorithm_config'):
self.alarm_manager.update_config(
self.led_detector.algorithm_config.get('alarm')
)
# 3. 初始化摄像头 # 3. 初始化摄像头
if camera_config is None: if camera_config is None:
camera_config = { camera_config = {
@ -140,8 +151,22 @@ class YantaiVisionXSystem:
# 记录结果 # 记录结果
self.logger.log_detection_result(detection_result) self.logger.log_detection_result(detection_result)
alarm_result = self.alarm_manager.process_detection(detection_result)
for event in alarm_result.events:
self.logger.log_alarm_event(event)
for roi_name in alarm_result.recoveries:
self.logger.log_recovery_event(
roi_name,
detection_result.frame_count,
detection_result.timestamp
)
# 保存结果 # 保存结果
if save_results and detection_result.frame_count % 30 == 0: # 每30帧保存一次 if (
save_results
and self.logger.save_results_enabled
and detection_result.frame_count % self.logger.save_results_interval == 0
):
self.logger.save_result_to_file(detection_result) self.logger.save_result_to_file(detection_result)
# 显示结果 # 显示结果
@ -190,7 +215,7 @@ class YantaiVisionXSystem:
output_path = Path(output_dir) output_path = Path(output_dir)
output_path.mkdir(exist_ok=True) output_path.mkdir(exist_ok=True)
results = [] results = [] if self.logger.save_results_enabled else None
self.logger.log_info(f"开始处理视频: {video_path}") self.logger.log_info(f"开始处理视频: {video_path}")
@ -207,12 +232,23 @@ class YantaiVisionXSystem:
# LED检测 # LED检测
detection_result = self.led_detector.detect_leds(enhanced_frame) detection_result = self.led_detector.detect_leds(enhanced_frame)
if results is not None:
results.append(detection_result) results.append(detection_result)
# 记录结果 # 记录结果
if detection_result.frame_count % 100 == 0: if detection_result.frame_count % 100 == 0:
self.logger.log_detection_result(detection_result) self.logger.log_detection_result(detection_result)
alarm_result = self.alarm_manager.process_detection(detection_result)
for event in alarm_result.events:
self.logger.log_alarm_event(event)
for roi_name in alarm_result.recoveries:
self.logger.log_recovery_event(
roi_name,
detection_result.frame_count,
detection_result.timestamp
)
# 显示结果 # 显示结果
if self.display_enabled: if self.display_enabled:
self._display_results(frame, detection_result) self._display_results(frame, detection_result)
@ -350,4 +386,4 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
exit(main()) raise SystemExit(main())

127
src/output/alarm_manager.py Normal file
View File

@ -0,0 +1,127 @@
"""报警管理模块
负责处理LED检测结果中的异常灯状态触发报警并记录事件信息
"""
from __future__ import annotations
import sys
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from ..roi_detection.threshold_detector import LEDState
from ..roi_detection.led_detector import LEDDetectionResult
@dataclass
class AlarmEvent:
"""报警事件信息"""
timestamp: float
frame_count: int
roi_names: List[str]
stability: Dict[str, float]
confidence: Dict[str, float]
@dataclass
class AlarmProcessingResult:
"""报警处理后的输出结果"""
events: List[AlarmEvent] = field(default_factory=list)
recoveries: List[str] = field(default_factory=list)
class AlarmManager:
"""管理LED报警事件"""
def __init__(self, logger=None, config: Optional[Dict[str, float]] = None):
self.logger = logger
self.config = self._load_config(config)
self._active_alerts: Dict[str, float] = {}
self._last_notification_time: float = 0.0
def _load_config(self, config: Optional[Dict[str, float]]) -> Dict[str, float]:
defaults = {
"enabled": True,
"stability_threshold": 0.6,
"confidence_threshold": 0.5,
"cooldown_seconds": 30.0,
"sound_enabled": True,
}
if not config:
return defaults
merged = defaults.copy()
merged.update({k: v for k, v in config.items() if v is not None})
return merged
def update_config(self, config: Optional[Dict[str, float]]) -> None:
if config:
self.config.update({k: v for k, v in config.items() if v is not None})
def process_detection(self, result: LEDDetectionResult) -> AlarmProcessingResult:
if not self.config.get("enabled", True):
return AlarmProcessingResult()
events: List[AlarmEvent] = []
recoveries: List[str] = []
stability_threshold = self.config.get("stability_threshold", 0.6)
confidence_threshold = self.config.get("confidence_threshold", 0.5)
new_alerts: Dict[str, Tuple[float, float]] = {}
for roi_name, stable_result in result.stable_states.items():
led_state = stable_result.led_state
if (
led_state == LEDState.OFF
and stable_result.stability >= stability_threshold
and stable_result.confidence >= confidence_threshold
):
new_alerts[roi_name] = (stable_result.stability, stable_result.confidence)
else:
if roi_name in self._active_alerts and led_state == LEDState.ON:
recoveries.append(roi_name)
new_roi_names = [roi for roi in new_alerts.keys() if roi not in self._active_alerts]
for roi_name in recoveries:
self._active_alerts.pop(roi_name, None)
if new_roi_names:
event = AlarmEvent(
timestamp=result.timestamp,
frame_count=result.frame_count,
roi_names=new_roi_names,
stability={roi: new_alerts[roi][0] for roi in new_roi_names},
confidence={roi: new_alerts[roi][1] for roi in new_roi_names},
)
events.append(event)
for roi in new_roi_names:
self._active_alerts[roi] = result.timestamp
self._trigger_notification()
return AlarmProcessingResult(events=events, recoveries=recoveries)
def _trigger_notification(self) -> None:
if not self.config.get("sound_enabled", True):
return
cooldown = self.config.get("cooldown_seconds", 30.0)
current_time = time.time()
if current_time - self._last_notification_time < cooldown:
return
try:
if sys.platform.startswith("win"):
import winsound
winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
else:
print("\a", end="", flush=True)
except Exception as exc: # pragma: no cover - 平台相关分支
if self.logger:
self.logger.log_warning(f"报警提示触发失败: {exc}")
self._last_notification_time = current_time

View File

@ -7,12 +7,15 @@ import os
import logging import logging
import json import json
from datetime import datetime from datetime import datetime
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, TYPE_CHECKING
from pathlib import Path from pathlib import Path
from ..roi_detection.led_detector import LEDDetectionResult from ..roi_detection.led_detector import LEDDetectionResult
from .result_formatter import ResultFormatter from .result_formatter import ResultFormatter
if TYPE_CHECKING:
from .alarm_manager import AlarmEvent
class LEDLogger: class LEDLogger:
""" """
@ -34,8 +37,17 @@ class LEDLogger:
self.log_level = logging_config.get('log_level', 'INFO') self.log_level = logging_config.get('log_level', 'INFO')
self.log_file = logging_config.get('log_file', 'logs/led_detection.log') self.log_file = logging_config.get('log_file', 'logs/led_detection.log')
self.alarm_log_file = logging_config.get('alarm_log_file', 'logs/alarm_events.log')
self.save_debug_images = logging_config.get('save_debug_images', False) self.save_debug_images = logging_config.get('save_debug_images', False)
self.debug_image_path = logging_config.get('debug_image_path', 'debug/') self.debug_image_path = logging_config.get('debug_image_path', 'debug/')
self.log_frame_details = logging_config.get('log_frame_details', False)
self.frame_log_interval = self._to_positive_int(
logging_config.get('frame_log_interval'), default=60
)
self.save_results_enabled = logging_config.get('save_results_enabled', False)
self.save_results_interval = self._to_positive_int(
logging_config.get('save_results_interval'), default=120
)
# 创建目录 # 创建目录
self._create_directories() self._create_directories()
@ -58,8 +70,13 @@ class LEDLogger:
'logging': { 'logging': {
'log_level': 'INFO', 'log_level': 'INFO',
'log_file': 'logs/led_detection.log', 'log_file': 'logs/led_detection.log',
'alarm_log_file': 'logs/alarm_events.log',
'save_debug_images': False, 'save_debug_images': False,
'debug_image_path': 'debug/' 'debug_image_path': 'debug/',
'log_frame_details': False,
'frame_log_interval': 60,
'save_results_enabled': False,
'save_results_interval': 120
} }
} }
@ -71,6 +88,10 @@ class LEDLogger:
log_dir = Path(self.log_file).parent log_dir = Path(self.log_file).parent
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
# 创建报警日志目录
alarm_dir = Path(self.alarm_log_file).parent
alarm_dir.mkdir(parents=True, exist_ok=True)
# 创建调试图像目录 # 创建调试图像目录
if self.save_debug_images: if self.save_debug_images:
debug_dir = Path(self.debug_image_path) debug_dir = Path(self.debug_image_path)
@ -118,6 +139,12 @@ class LEDLogger:
""" """
self.total_frames += 1 self.total_frames += 1
if not self.log_frame_details:
return
if self.total_frames % self.frame_log_interval != 0:
return
# 基本信息 # 基本信息
self.logger.info(f"帧#{result.frame_count} 处理完成 - " self.logger.info(f"帧#{result.frame_count} 处理完成 - "
f"用时: {result.processing_time*1000:.1f}ms") f"用时: {result.processing_time*1000:.1f}ms")
@ -176,6 +203,58 @@ class LEDLogger:
""" """
self.logger.debug(debug_msg) self.logger.debug(debug_msg)
@staticmethod
def _to_positive_int(value: Optional[Any], default: int) -> int:
try:
result = int(value)
return result if result > 0 else default
except (TypeError, ValueError):
return default
def log_alarm_event(self, event: 'AlarmEvent') -> None:
"""记录报警事件"""
timestamp_text = datetime.fromtimestamp(event.timestamp).isoformat()
message = (f"报警触发 - 帧#{event.frame_count} 受影响LED: "
f"{', '.join(event.roi_names)}")
self.logger.warning(message)
record = {
'timestamp': timestamp_text,
'frame': event.frame_count,
'rois': event.roi_names,
'stability': event.stability,
'confidence': event.confidence
}
try:
with open(self.alarm_log_file, 'a', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False)
f.write('\n')
except Exception as exc:
self.logger.error(f"写入报警日志失败: {exc}")
def log_recovery_event(self, roi_name: str, frame_count: int,
timestamp: Optional[float] = None) -> None:
"""记录恢复事件"""
ts = timestamp if timestamp is not None else datetime.now().timestamp()
timestamp_text = datetime.fromtimestamp(ts).isoformat()
self.logger.info(f"LED恢复正常 - 帧#{frame_count} ROI: {roi_name}")
record = {
'timestamp': timestamp_text,
'frame': frame_count,
'roi': roi_name,
'event': 'recovery'
}
try:
with open(self.alarm_log_file, 'a', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False)
f.write('\n')
except Exception as exc:
self.logger.error(f"写入恢复日志失败: {exc}")
def save_result_to_file(self, result: LEDDetectionResult, def save_result_to_file(self, result: LEDDetectionResult,
format_type: str = 'json', format_type: str = 'json',
filename: Optional[str] = None) -> str: filename: Optional[str] = None) -> str:

View File

@ -10,12 +10,16 @@ import numpy as np
import yaml import yaml
import sys import sys
import os import os
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
# 添加项目根目录到路径 # 添加项目根目录到路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) if getattr(sys, 'frozen', False):
base_dir = Path(getattr(sys, '_MEIPASS'))
else:
base_dir = Path(__file__).resolve().parent.parent
from src.roi_detection.roi_manager import ROIManager, ROIRegion sys.path.insert(0, str(base_dir))
class ROICalibrationTool: class ROICalibrationTool: