Commit d4d8d3a0 by yhb

1

parent 6c828f54
--- ---
alwaysApply: true alwaysApply: true
--- ---
满:液位高度为容器高度,空:液位高度为0
一、空间逻辑判断,一个通道多个检测区域(大于等于俩个)才触发空间逻辑判断:
情况1.下方检测区域模型分割掩码结果只有air,上方检测区域液位高度为0,函数输出001
情况2.上方检测区域模型分割掩码结果有liquid或者foam,下方检测区域液位高度为容器高度,函数输出002
二、时间逻辑,从检测开始到结束一直使用,每个检测区域的逻辑是独立的。
1.本次液位高度为0(空),理论上下一次液位高度数据不会是容器高度,若下次液位高度数据是容器高度(满),数据为假且zero_full计数变量加1,液位高度数据依旧采用上次数据,当zero_full值为3时判断此次为真,使用本次数据为最终高度数据。
2.本次液位高度为容器高度(满),理论上下一次的数据不会是0(空),若下次液位高度数据是0(空),full_zero计数变量加一
3.前后俩次的数据变化范围不会超过8mm,前后俩次分割的结果面积不会差太多
4.data_count数据点技术
\ No newline at end of file
...@@ -18,13 +18,13 @@ channels: ...@@ -18,13 +18,13 @@ channels:
channel2: channel2:
general: general:
task_id: '1' task_id: '1'
task_name: test task_name: 海信现场测试
save_liquid_data_path: d:\restructure\liquid_level_line_detection_system\database\mission_result\1_test save_liquid_data_path: D:\restructure\liquid_level_line_detection_system\database\mission_result\1_海信现场测试
channel3: channel3:
general: general:
task_id: '1' task_id: '1'
task_name: test task_name: 海信现场测试
save_liquid_data_path: d:\restructure\liquid_level_line_detection_system\database\mission_result\1_test save_liquid_data_path: D:\restructure\liquid_level_line_detection_system\database\mission_result\1_海信现场测试
channel4: channel4:
general: general:
task_id: '1' task_id: '1'
...@@ -33,7 +33,7 @@ channel4: ...@@ -33,7 +33,7 @@ channel4:
channel1: channel1:
general: general:
task_id: '1' task_id: '1'
task_name: 历史回放功能展示 task_name: 曲线
area_count: 0 area_count: 0
safe_low: 2.0mm safe_low: 2.0mm
safe_high: 10.0mm safe_high: 10.0mm
...@@ -41,7 +41,7 @@ channel1: ...@@ -41,7 +41,7 @@ channel1:
video_format: AVI video_format: AVI
push_address: '' push_address: ''
video_path: '' video_path: ''
save_liquid_data_path: d:\restructure\liquid_level_line_detection_system\database\mission_result\1_历史回放功能展示 save_liquid_data_path: D:\restructure\liquid_level_line_detection_system\database\mission_result\1_曲线
areas: areas:
area_1: 通道1_区域1 area_1: 通道1_区域1
area_heights: area_heights:
......
...@@ -223,7 +223,10 @@ class CurvePanelHandler: ...@@ -223,7 +223,10 @@ class CurvePanelHandler:
# 业务数据管理(从Widget层移动到Handler层) # 业务数据管理(从Widget层移动到Handler层)
self.channel_data = {} # {channel_id: {'channel_name': str, 'window_name': str, 'time': [], 'value': []}} self.channel_data = {} # {channel_id: {'channel_name': str, 'window_name': str, 'time': [], 'value': []}}
self.channel_start_times = {} # 每个通道的起始时间 self.channel_start_times = {} # 每个通道的起始时间
self.max_points = 3000 # 每条曲线最大显示点数(3000点 ≈ 2分钟@25Hz) # 每条曲线历史模式下单次绘制的最大点数(根据X轴显示范围做采样)
# 注意:原始数据全部保存在 channel_data 中,仅在送往UI前做抽样
# self.max_points = 3000 # 旧字段,保留以兼容旧逻辑(实时模式用)
self.max_points_per_view = 800 # 新字段:单视图最大显示点数,用于采样
# 🔥 曲线加载模式:'realtime' 或 'history' # 🔥 曲线加载模式:'realtime' 或 'history'
# - 'realtime':实时检测模式,限制数据点为3000个(滚动窗口) # - 'realtime':实时检测模式,限制数据点为3000个(滚动窗口)
...@@ -248,6 +251,9 @@ class CurvePanelHandler: ...@@ -248,6 +251,9 @@ class CurvePanelHandler:
# 连接信号 # 连接信号
curve_panel.missionFolderChanged.connect(self._handleMissionFolderChanged) curve_panel.missionFolderChanged.connect(self._handleMissionFolderChanged)
curve_panel.refreshMissionListRequested.connect(self.loadMissionFolders) curve_panel.refreshMissionListRequested.connect(self.loadMissionFolders)
# 时间轴范围变化信号:用于缩放/拖动时根据当前范围重新采样
if hasattr(curve_panel, "timeAxisRangeChanged"):
curve_panel.timeAxisRangeChanged.connect(self._onTimeAxisRangeChanged)
# 连接安全限值变化信号 # 连接安全限值变化信号
curve_panel.spn_upper_limit.valueChanged.connect(self._handleSafetyLimitsChanged) curve_panel.spn_upper_limit.valueChanged.connect(self._handleSafetyLimitsChanged)
...@@ -345,7 +351,7 @@ class CurvePanelHandler: ...@@ -345,7 +351,7 @@ class CurvePanelHandler:
before_count = len(channel['time']) before_count = len(channel['time'])
print(f" - 更新前数据点数: {before_count}") print(f" - 更新前数据点数: {before_count}")
# 批量添加数据 # 批量添加数据(原始数据全部保留在 channel_data 中)
added_count = 0 added_count = 0
for point in data_points: for point in data_points:
timestamp = point['timestamp'] timestamp = point['timestamp']
...@@ -364,24 +370,8 @@ class CurvePanelHandler: ...@@ -364,24 +370,8 @@ class CurvePanelHandler:
print(f" - 实际添加数据点数: {added_count}") print(f" - 实际添加数据点数: {added_count}")
print(f" - 更新后数据点数: {after_add_count}") print(f" - 更新后数据点数: {after_add_count}")
# 🔥 数据点数量限制(已禁用) # ===== 根据当前X轴显示范围进行采样(显示层抽点,不影响原始数据) =====
# - 原逻辑:'realtime' 同步布局模式限制为3000个点(滚动窗口) processed_time, processed_value = self._getSampledDataForChannel(channel_id)
# - 已禁用:不再限制数据点数量,所有模式都显示完整数据
# if self.curve_load_mode == 'realtime':
# # 同步布局模式:保留最新3000个点
# if len(channel['time']) > self.max_points:
# before_limit = len(channel['time'])
# channel['time'] = channel['time'][-self.max_points:]
# channel['value'] = channel['value'][-self.max_points:]
# print(f" - 限制数据点: {before_limit} -> {len(channel['time'])}")
# 🔥 处理时间间隔断点:超过2分钟的数据点之间插入NaN断开连接
processed_time, processed_value = self._processTimeGaps(
channel['time'],
channel['value'],
max_gap_seconds=120 # 2分钟 = 120秒
)
print(f" - 处理后数据点数: {len(processed_time)}") print(f" - 处理后数据点数: {len(processed_time)}")
# 🔥 数据验证:确保有数据才更新UI # 🔥 数据验证:确保有数据才更新UI
...@@ -498,6 +488,109 @@ class CurvePanelHandler: ...@@ -498,6 +488,109 @@ class CurvePanelHandler:
return processed_time, processed_value return processed_time, processed_value
def _getSampledDataForChannel(self, channel_id, starttime=None, endtime=None):
"""
根据给定的时间范围对指定通道的数据进行抽样,并处理时间间隔断点。
Args:
channel_id (str): 通道ID
starttime (float|None): 可见范围起始时间戳;None 时自动取通道最小时间
endtime (float|None): 可见范围结束时间戳;None 时自动取通道最大时间
Returns:
tuple: (processed_time, processed_value)
"""
if channel_id not in self.channel_data:
return [], []
channel = self.channel_data[channel_id]
full_time = channel.get("time", [])
full_value = channel.get("value", [])
if not full_time or not full_value:
return [], []
# 如果外部没有指定范围,则使用当前CurvePanel中的范围
if starttime is None or endtime is None:
try:
starttime, endtime = self.getTimeAxisRange()
except Exception:
starttime, endtime = None, None
# 如果仍然没有有效范围,则使用该通道的整体时间范围
if starttime is None or endtime is None or not np.isfinite(starttime) or not np.isfinite(endtime):
starttime = min(full_time)
endtime = max(full_time)
# 选出当前显示范围内的数据点
visible_indices = [i for i, t in enumerate(full_time) if starttime <= t <= endtime]
if visible_indices:
visible_time = [full_time[i] for i in visible_indices]
visible_value = [full_value[i] for i in visible_indices]
else:
# 如果当前显示范围内没有点,则退回到全量数据
visible_time = full_time
visible_value = full_value
visible_count = len(visible_time)
max_points_per_view = getattr(self, "max_points_per_view", 5000)
if visible_count > max_points_per_view:
# 计算采样步长,保证单次绘制点数不超过 max_points_per_view
step = int(np.ceil(visible_count / max_points_per_view))
print(
f" - 采样可见数据[{channel_id}]: 原始={visible_count} 点, 目标最大={max_points_per_view} 点, 步长={step}"
)
sampled_time = visible_time[::step]
sampled_value = visible_value[::step]
else:
sampled_time = visible_time
sampled_value = visible_value
# 处理时间间隔断点:超过2分钟的数据点之间插入NaN断开连接
processed_time, processed_value = self._processTimeGaps(
sampled_time, sampled_value, max_gap_seconds=120 # 2分钟 = 120秒
)
return processed_time, processed_value
def _onTimeAxisRangeChanged(self, starttime, endtime):
"""
曲线面板时间轴范围变化回调(由CurvePanel触发)
每次缩放/拖动X轴时,按照当前显示范围对所有通道重新采样并刷新UI。
"""
if not self.curve_panel:
return
print(
f"🔁 [时间轴变化重采样] starttime={starttime}, endtime={endtime}, 通道数量={len(self.channel_data)}"
)
for channel_id, channel in self.channel_data.items():
processed_time, processed_value = self._getSampledDataForChannel(
channel_id, starttime=starttime, endtime=endtime
)
# 数据验证:与 updateCurveData 中保持一致
if not processed_time or not processed_value:
continue
if len(processed_time) != len(processed_value):
continue
has_valid_data = any(
np.isfinite(v) and np.isfinite(t)
for t, v in zip(processed_time, processed_value)
)
if not has_valid_data:
continue
self.curve_panel.updateCurveDisplay(
channel_id,
processed_time,
processed_value
)
def _exportChannelDataToCSV(self, channel_id, file_path=None): def _exportChannelDataToCSV(self, channel_id, file_path=None):
""" """
导出通道数据到CSV文件(手动导出功能) 导出通道数据到CSV文件(手动导出功能)
......
...@@ -422,16 +422,16 @@ class LiquidDetectionEngine: ...@@ -422,16 +422,16 @@ class LiquidDetectionEngine:
continue continue
# 执行检测(传入top坐标和配置用于坐标转换) # 执行检测(传入top坐标和配置用于坐标转换)
liquid_height_mm = self._detect_single_target( liquid_height_raw_mm = self._detect_single_target(
cropped, idx, top, cropped, idx, top,
fixed_bottoms[idx] if idx < len(fixed_bottoms) else None, fixed_bottoms[idx] if idx < len(fixed_bottoms) else None,
fixed_tops[idx] if idx < len(fixed_tops) else None, fixed_tops[idx] if idx < len(fixed_tops) else None,
actual_heights[idx] if idx < len(actual_heights) else 20.0 actual_heights[idx] if idx < len(actual_heights) else 20.0
) )
# 如果没有检测到液位,使用高度0 # 如果没有检测到液位,使用高度0(原始观测)
if liquid_height_mm is None: if liquid_height_raw_mm is None:
liquid_height_mm = 0.0 liquid_height_raw_mm = 0.0
# 计算液位线位置 # 计算液位线位置
# 注意:container_bottom_y 和 container_top_y 已经是原图中的绝对坐标 # 注意:container_bottom_y 和 container_top_y 已经是原图中的绝对坐标
...@@ -440,6 +440,19 @@ class LiquidDetectionEngine: ...@@ -440,6 +440,19 @@ class LiquidDetectionEngine:
container_height_mm = actual_heights[idx] if idx < len(actual_heights) else 20.0 container_height_mm = actual_heights[idx] if idx < len(actual_heights) else 20.0
container_pixel_height = container_bottom_y - container_top_y container_pixel_height = container_bottom_y - container_top_y
# ==================== 多帧稳健逻辑:卡尔曼滤波和平滑 ====================
# 使用卡尔曼滤波对单帧观测值进行平滑,得到最终的液位高度(毫米)
if idx < len(self.kalman_filters):
liquid_height_mm = self._apply_kalman_filter(
observation=liquid_height_raw_mm,
idx=idx,
container_height_mm=container_height_mm
)
else:
# 保险起见,如果卡尔曼未初始化,则直接使用原始高度
liquid_height_mm = max(0.0, min(liquid_height_raw_mm, container_height_mm))
pixel_per_mm = container_pixel_height / container_height_mm pixel_per_mm = container_pixel_height / container_height_mm
height_px = int(liquid_height_mm * pixel_per_mm) height_px = int(liquid_height_mm * pixel_per_mm)
...@@ -533,9 +546,7 @@ class LiquidDetectionEngine: ...@@ -533,9 +546,7 @@ class LiquidDetectionEngine:
# else: # else:
# print(f" - ⚠️ 未检测到任何mask!") # print(f" - ⚠️ 未检测到任何mask!")
liquid_height = None # 解析YOLO推理结果,得到当前帧的所有实例信息
# 解析YOLO推理结果
all_masks_info = parse_yolo_result( all_masks_info = parse_yolo_result(
mission_result=mission_result, mission_result=mission_result,
model_names=self.model.names, model_names=self.model.names,
...@@ -543,20 +554,32 @@ class LiquidDetectionEngine: ...@@ -543,20 +554,32 @@ class LiquidDetectionEngine:
confidence_threshold=0.5 confidence_threshold=0.5
) )
if len(all_masks_info) == 0: # ==================== 多帧稳健逻辑:更新计数器 ====================
return None # 如果检测到 liquid 实例,则认为本帧有有效观测,清零“未检测计数”和帧计数;
# 否则累加,用于后续 foam/air 备选策略和“保持上一帧高度”。
# ️ 关键修复:将原图坐标转换为裁剪图像坐标 has_liquid = any(class_name == 'liquid' for _, class_name, _ in all_masks_info)
# container_bottom_offset 是原图绝对坐标,需要转换为裁剪图像中的相对坐标
container_bottom_in_crop = container_bottom_offset - crop_top_y # 确保索引安全
container_top_in_crop = container_top_offset - crop_top_y if idx < len(self.no_liquid_count):
if has_liquid:
# 本帧检测到 liquid:重置计数器
self.no_liquid_count[idx] = 0
if idx < len(self.frame_counters):
self.frame_counters[idx] = 0
else:
# 本帧未检测到 liquid:累加计数器
self.no_liquid_count[idx] += 1
if idx < len(self.frame_counters):
self.frame_counters[idx] += 1
# print(f" [坐标转换-目标{idx}]:") # 周期性清零,避免计数无限增长(与历史逻辑类似,每3帧清一次)
# print(f" - 裁剪区域top: {crop_top_y}px (原图坐标)") if self.frame_counters[idx] > 0 and self.frame_counters[idx] % 3 == 0:
# print(f" - 原图容器底部: {container_bottom_offset}px → 裁剪图像中: {container_bottom_in_crop}px") self.no_liquid_count[idx] = 0
# print(f" - 原图容器顶部: {container_top_offset}px → 裁剪图像中: {container_top_in_crop}px")
# print(f" - 裁剪图像中容器高度: {container_bottom_in_crop - container_top_in_crop}px(应等于{container_pixel_height}px)")
# 如果完全没有任何 mask,直接走后面的“保持上一帧高度 / 返回 None”逻辑
if len(all_masks_info) == 0:
liquid_height = None
else:
# 分析mask获取液位高度(使用裁剪图像坐标) # 分析mask获取液位高度(使用裁剪图像坐标)
liquid_height = calculate_liquid_height( liquid_height = calculate_liquid_height(
all_masks_info=all_masks_info, all_masks_info=all_masks_info,
...@@ -566,6 +589,18 @@ class LiquidDetectionEngine: ...@@ -566,6 +589,18 @@ class LiquidDetectionEngine:
no_liquid_count=self.no_liquid_count[idx] if idx < len(self.no_liquid_count) else 0 no_liquid_count=self.no_liquid_count[idx] if idx < len(self.no_liquid_count) else 0
) )
# ==================== 多帧稳健逻辑:保持上一帧高度 ====================
# 如果连续未检测到 liquid 但还没达到 foam/air 备选触发阈值,
# 并且之前有有效高度,则直接保持上一帧的液位高度。
if liquid_height is None and idx < len(self.no_liquid_count):
if self.no_liquid_count[idx] < 3 and idx < len(self.last_liquid_heights):
if self.last_liquid_heights[idx] is not None:
liquid_height = self.last_liquid_heights[idx]
# 如果本帧最终得到有效高度,更新 last_liquid_heights
if liquid_height is not None and idx < len(self.last_liquid_heights):
self.last_liquid_heights[idx] = liquid_height
return liquid_height return liquid_height
except Exception as e: except Exception as e:
......
# -*- coding: utf-8 -*-
"""
时间逻辑判断模块
实现检测区域的时间逻辑判断,用于过滤异常数据并输出最终液位高度
使用示例:
from handlers.videopage.temporal_logic import TemporalLogicFilter
# 为每个检测区域创建一个过滤器实例
filter = TemporalLogicFilter(container_height_mm=100.0)
# 每次检测后调用
final_height = filter.process(current_height_mm=50.0, segmentation_area=1000.0)
"""
from typing import Optional
import numpy as np
class TemporalLogicFilter:
"""
时间逻辑过滤器
每个检测区域独立使用一个实例,维护该区域的时间逻辑状态
"""
def __init__(self, container_height_mm: float, area_change_threshold: float = 0.3):
"""
初始化时间逻辑过滤器
Args:
container_height_mm: 容器高度(毫米),用于判断"满"状态
area_change_threshold: 分割面积变化阈值(0-1),默认0.3表示面积变化不超过30%
"""
self.container_height_mm = container_height_mm
# 状态变量
self.last_height_mm: Optional[float] = None # 上次液位高度
self.last_segmentation_area: Optional[float] = None # 上次分割面积
self.zero_full_count: int = 0 # 从空到满的异常计数
self.full_zero_count: int = 0 # 从满到空的异常计数
self.data_count: int = 0 # 数据点计数
# 参数
self.max_height_change_mm = 8.0 # 最大高度变化(毫米)
self.area_change_threshold = area_change_threshold # 面积变化阈值
# 判断"空"和"满"的容差(毫米)
self.empty_tolerance_mm = 1.0 # 高度小于此值视为空
self.full_tolerance_mm = 1.0 # 高度大于(容器高度-此值)视为满
def _is_empty(self, height_mm: float) -> bool:
"""判断液位是否为空"""
return height_mm <= self.empty_tolerance_mm
def _is_full(self, height_mm: float) -> bool:
"""判断液位是否为满"""
return height_mm >= (self.container_height_mm - self.full_tolerance_mm)
def _is_height_change_valid(self, current_height: float, last_height: float) -> bool:
"""
判断高度变化是否有效(规则3:前后两次数据变化范围不会超过8mm)
Args:
current_height: 当前高度(毫米)
last_height: 上次高度(毫米)
Returns:
bool: 变化是否有效
"""
if last_height is None:
return True # 第一次检测,总是有效
height_change = abs(current_height - last_height)
return height_change <= self.max_height_change_mm
def _is_area_change_valid(self, current_area: Optional[float], last_area: Optional[float]) -> bool:
"""
判断分割面积变化是否有效(规则3:前后两次分割的结果面积不会差太多)
Args:
current_area: 当前分割面积
last_area: 上次分割面积
Returns:
bool: 面积变化是否有效
"""
if last_area is None or current_area is None:
return True # 没有面积数据,跳过检查
if last_area == 0:
return True # 上次面积为0,跳过检查
# 计算面积变化比例
area_change_ratio = abs(current_area - last_area) / last_area
return area_change_ratio <= self.area_change_threshold
def process(
self,
current_height_mm: float,
segmentation_area: Optional[float] = None
) -> float:
"""
处理当前检测到的液位高度,应用时间逻辑判断
Args:
current_height_mm: 当前检测到的液位高度(毫米)
segmentation_area: 当前分割结果面积(可选,用于规则3的面积检查)
Returns:
float: 经过时间逻辑判断后的最终液位高度(毫米)
"""
# 数据点计数加1
self.data_count += 1
# 第一次检测,直接使用当前数据
if self.last_height_mm is None:
self.last_height_mm = current_height_mm
self.last_segmentation_area = segmentation_area
return current_height_mm
# 规则1:本次液位高度为0(空),理论上下一次液位高度数据不会是容器高度
if self._is_empty(self.last_height_mm):
if self._is_full(current_height_mm):
# 从空到满的异常情况
self.zero_full_count += 1
if self.zero_full_count >= 3:
# 连续3次从空到满,判断此次为真,使用本次数据
self.last_height_mm = current_height_mm
self.last_segmentation_area = segmentation_area
self.zero_full_count = 0 # 重置计数器
return current_height_mm
else:
# 数据为假,使用上次数据
return self.last_height_mm
else:
# 正常情况,重置计数器
self.zero_full_count = 0
# 规则2:本次液位高度为容器高度(满),理论上下一次的数据不会是0(空)
if self._is_full(self.last_height_mm):
if self._is_empty(current_height_mm):
# 从满到空的异常情况
self.full_zero_count += 1
# 注意:规则2只要求计数,没有说明如何处理,这里使用上次数据
return self.last_height_mm
else:
# 正常情况,重置计数器
self.full_zero_count = 0
# 规则3:前后两次的数据变化范围不会超过8mm
if not self._is_height_change_valid(current_height_mm, self.last_height_mm):
# 高度变化过大,使用上次数据
return self.last_height_mm
# 规则3:前后两次分割的结果面积不会差太多
if not self._is_area_change_valid(segmentation_area, self.last_segmentation_area):
# 面积变化过大,使用上次数据
return self.last_height_mm
# 所有检查通过,使用当前数据
self.last_height_mm = current_height_mm
self.last_segmentation_area = segmentation_area
self.zero_full_count = 0 # 重置计数器
self.full_zero_count = 0 # 重置计数器
return current_height_mm
def reset(self):
"""重置过滤器状态"""
self.last_height_mm = None
self.last_segmentation_area = None
self.zero_full_count = 0
self.full_zero_count = 0
self.data_count = 0
def get_state(self) -> dict:
"""
获取当前状态信息(用于调试)
Returns:
dict: 状态字典
"""
return {
'last_height_mm': self.last_height_mm,
'zero_full_count': self.zero_full_count,
'full_zero_count': self.full_zero_count,
'data_count': self.data_count,
'container_height_mm': self.container_height_mm
}
def create_temporal_filter(container_height_mm: float, area_change_threshold: float = 0.3) -> TemporalLogicFilter:
"""
创建时间逻辑过滤器的便捷函数
Args:
container_height_mm: 容器高度(毫米)
area_change_threshold: 分割面积变化阈值(0-1),默认0.3
Returns:
TemporalLogicFilter: 过滤器实例
"""
return TemporalLogicFilter(container_height_mm, area_change_threshold)
"""
检测引擎模块
"""
\ No newline at end of file
"""
液面标注引擎 - 从123.py提取的标注功能
"""
import cv2
import numpy as np
import wx
import json
import os
# 🔥 修改:从配置文件获取RTSP地址
def get_rtsp_url(channel_name):
"""根据通道名称获取RTSP地址或视频文件路径,自动根据后缀判断类型"""
try:
url = None
# 首先尝试从配置文件读取
config_path = os.path.join("resources", "rtsp_config.json")
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
channels = config.get("channels", {})
# 通道名称到channel key的映射
channel_mapping = {
"通道1": "channel1",
"通道2": "channel2",
"通道3": "channel3",
"通道4": "channel4",
"通道5": "channel5",
"通道6": "channel6"
}
# 获取对应的channel key
channel_key = channel_mapping.get(channel_name)
if channel_key and channel_key in channels:
url = channels[channel_key].get("rtsp_url")
# 如果配置文件中没有找到,使用硬编码的备用地址
if not url:
fallback_mapping = {
"通道1": None,
"通道2": "rtsp://admin:@192.168.1.188:554/stream1",
"通道3": None, # 预留位置,后续接入
"通道4": None, # 预留位置,后续接入
"通道5": None, # 预留位置,后续接入
"通道6": None # 预留位置,后续接入
}
url = fallback_mapping.get(channel_name)
if url is None:
print(f"⚠️ {channel_name}暂未配置地址")
return None
# 🔥 新增:根据文件扩展名自动判断类型
video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.m4v', '.3gp', '.webm']
url_lower = url.lower()
# 检查是否是视频文件
is_video_file = any(url_lower.endswith(ext) for ext in video_extensions)
if is_video_file:
print(f"✅ {channel_name}使用视频文件: {url}")
else:
print(f"✅ {channel_name}使用RTSP流: {url}")
return url
except Exception as e:
print(f"❌ 读取配置失败: {e}")
print(f"⚠️ {channel_name}暂未配置地址")
return None
class LiquidAnnotationEngine:
"""液面标注引擎"""
def __init__(self, channel_name="通道1"):
self.drawing_box = False
self.box_start = (0, 0)
self.boxes = []
self.bottom_points = []
self.top_points = [] # 🔥 新增:顶部点列表
self.step = 0 # 0: 画框,1: 点底部,2: 点顶部
self.current_img = None
self.selected_frame = None
# 🔥 新增:通道信息
self.channel_name = channel_name
self.rtsp_url = get_rtsp_url(channel_name)
def annotate_from_camera(self, camera_index=0):
"""从RTSP摄像头获取图像并进行标注"""
print(f"🎥 {self.channel_name}正在连接RTSP: {self.rtsp_url}")
cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not cap.isOpened():
raise RuntimeError(f"❌ 无法连接{self.channel_name}的RTSP摄像头 {self.rtsp_url}")
# 设置缓冲区大小以减少延迟
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
# 读取几帧让摄像头稳定
for _ in range(10):
ret, frame = cap.read()
if not ret:
cap.release()
raise RuntimeError("❌ 无法读取RTSP摄像头帧")
# 获取用于标注的帧
ret, self.selected_frame = cap.read()
cap.release()
if not ret or self.selected_frame is None:
raise RuntimeError("❌ 无法获取标注用帧")
return self.start_annotation()
def annotate_from_image(self, image_path):
"""从图像文件进行标注"""
self.selected_frame = cv2.imread(image_path)
if self.selected_frame is None:
raise RuntimeError("❌ 无法读取图像文件")
return self.start_annotation()
def start_annotation(self):
"""开始标注过程"""
self.current_img = self.selected_frame.copy()
self.boxes = []
self.bottom_points = []
self.top_points = [] # 🔥 新增:重置顶部点
self.step = 0
cv2.namedWindow('Draw Targets', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Draw Targets', 800, 600)
cv2.setMouseCallback('Draw Targets', self.mouse_callback)
cv2.imshow('Draw Targets', self.current_img)
print("操作步骤:")
print("1. 鼠标拖动画正方形框选择检测区域")
print("2. 鼠标点击表示该目标的底部液位点")
print("3. 鼠标点击表示该目标的顶部液位点") # 🔥 新增步骤
print("重复以上三步;按 'q' 键结束标注")
while True:
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
break
cv2.destroyAllWindows()
# 🔥 修改:计算固定底部和顶部偏移
fixed_bottoms = []
fixed_tops = []
for i in range(len(self.boxes)):
if i < len(self.bottom_points) and i < len(self.top_points):
cx, cy, size = self.boxes[i]
_, bottom_y = self.bottom_points[i]
_, top_y = self.top_points[i]
frame_top = cy - size // 2
bottom_offset = bottom_y - frame_top
top_offset = top_y - frame_top
fixed_bottoms.append(bottom_offset)
fixed_tops.append(top_offset)
return self.boxes, fixed_bottoms, fixed_tops # 🔥 修改:返回三个值
def mouse_callback(self, event, x, y, flags, param):
"""鼠标回调函数"""
img = self.current_img.copy()
if self.step == 0: # 画框模式
if event == cv2.EVENT_LBUTTONDOWN:
self.drawing_box = True
self.box_start = (x, y)
elif event == cv2.EVENT_MOUSEMOVE and self.drawing_box:
dx = x - self.box_start[0]
dy = y - self.box_start[1]
length = max(abs(dx), abs(dy))
length = ((length + 31) // 32) * 32 # 保证是32的倍数
x2 = self.box_start[0] + (length if dx >= 0 else -length)
y2 = self.box_start[1] + (length if dy >= 0 else -length)
cv2.rectangle(img, self.box_start, (x2, y2), (255, 255, 0), 2)
elif event == cv2.EVENT_LBUTTONUP and self.drawing_box:
self.drawing_box = False
dx = x - self.box_start[0]
dy = y - self.box_start[1]
length = max(abs(dx), abs(dy))
length = ((length + 31) // 32) * 32 # 保证是32的倍数
x2 = self.box_start[0] + (length if dx >= 0 else -length)
y2 = self.box_start[1] + (length if dy >= 0 else -length)
cx = (self.box_start[0] + x2) // 2
cy = (self.box_start[1] + y2) // 2
size = length
self.boxes.append((cx, cy, size))
self.step = 1
print(f"✅ 正方形框完成:中心=({cx}, {cy}), 尺寸={size}px,请点击底部点")
elif self.step == 1: # 点击底部模式
if event == cv2.EVENT_LBUTTONDOWN:
self.bottom_points.append((x, y))
print(f"✅ 底部点完成:坐标=({x}, {y}),请点击顶部点")
self.step = 2 # 🔥 修改:转到顶部点模式
elif self.step == 2: # 🔥 新增:点击顶部模式
if event == cv2.EVENT_LBUTTONDOWN:
self.top_points.append((x, y))
print(f"✅ 顶部点完成:坐标=({x}, {y}),可继续画下一个目标框\n")
self.step = 0
# 可视化已标注的内容
for i, (cx, cy, size) in enumerate(self.boxes):
half = size // 2
top = cy - half
bottom = cy + half
left = cx - half
right = cx + half
cv2.rectangle(img, (left, top), (right, bottom), (255, 255, 0), 2)
cv2.putText(img, f"T{i+1}", (left + 5, top + 20), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
# 🔥 修改:绘制底部点(绿色)和顶部点(红色)
for pt in self.bottom_points:
cv2.circle(img, pt, 5, (0, 255, 0), -1) # 绿色圆点表示底部
for pt in self.top_points:
cv2.circle(img, pt, 5, (0, 0, 255), -1) # 红色圆点表示顶部
cv2.imshow('Draw Targets', img)
class EmbeddedAnnotationEngine:
"""内嵌式标注引擎 - 用于wxPython面板"""
def __init__(self, channel_name="通道1"):
self.drawing_box = False
self.box_start = (0, 0)
self.boxes = []
self.bottom_points = []
self.top_points = [] # 🔥 新增:顶部点列表
self.step = 0 # 0: 画框,1: 点底部,2: 点顶部
self.current_frame = None
self.scale_factor = 1.0
self.offset_x = 0
self.offset_y = 0
# 🔥 新增:通道信息
self.channel_name = channel_name
self.rtsp_url = get_rtsp_url(channel_name)
print(f"🔧 EmbeddedAnnotationEngine初始化,通道: {channel_name}, RTSP: {self.rtsp_url}")
def set_channel_info(self, channel_name):
"""设置通道信息"""
self.channel_name = channel_name
self.rtsp_url = get_rtsp_url(channel_name)
print(f"📹 标注引擎更新为{channel_name}, RTSP: {self.rtsp_url}")
def load_frame_from_camera(self):
"""从RTSP摄像头或视频文件获取图像"""
print(f"🎥 {self.channel_name}正在连接源: {self.rtsp_url}")
# 🔥 新增:判断是视频文件还是RTSP
if self.channel_name == "通道1" and not self.rtsp_url.startswith("rtsp://"):
# 从视频文件读取
if not os.path.exists(self.rtsp_url):
raise RuntimeError(f"❌ 视频文件不存在: {self.rtsp_url}")
cap = cv2.VideoCapture(self.rtsp_url)
if not cap.isOpened():
raise RuntimeError(f"❌ 无法打开视频文件: {self.rtsp_url}")
# 获取视频信息
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
print(f"📹 视频文件信息: 总帧数={total_frames}, 帧率={fps:.2f}")
# 读取中间帧作为标注图像
middle_frame = total_frames // 2
cap.set(cv2.CAP_PROP_POS_FRAMES, middle_frame)
ret, self.current_frame = cap.read()
cap.release()
if not ret or self.current_frame is None:
raise RuntimeError(f"❌ 无法从视频文件获取标注图像")
print(f"✅ 成功从视频文件获取标注图像 (第{middle_frame}帧)")
return True
else:
# 原有的RTSP摄像头逻辑
cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not cap.isOpened():
raise RuntimeError(f"❌ 无法连接{self.channel_name}的RTSP摄像头 {self.rtsp_url}")
# 设置缓冲区大小以减少延迟
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
# 读取几帧让摄像头稳定
for _ in range(10):
ret, frame = cap.read()
if not ret:
cap.release()
raise RuntimeError(f"❌ 无法读取{self.channel_name}RTSP摄像头帧")
# 获取用于标注的帧
ret, self.current_frame = cap.read()
cap.release()
if not ret or self.current_frame is None:
raise RuntimeError(f"❌ 无法获取{self.channel_name}标注用帧")
print(f"✅ 成功从{self.channel_name}获取标注图像")
return True
def load_frame_from_image(self, image_path):
"""从图像文件加载"""
self.current_frame = cv2.imread(image_path)
if self.current_frame is None:
raise RuntimeError("❌ 无法读取图像文件")
return True
def reset_annotation(self):
"""重置标注状态"""
self.boxes = []
self.bottom_points = []
self.top_points = [] # 🔥 新增:重置顶部点
self.step = 0
self.drawing_box = False
def calculate_display_params(self, panel_width, panel_height):
"""计算显示参数(缩放和偏移)"""
if self.current_frame is None:
return
frame_height, frame_width = self.current_frame.shape[:2]
# 计算缩放比例,保持宽高比
scale_x = panel_width / frame_width
scale_y = panel_height / frame_height
self.scale_factor = min(scale_x, scale_y)
# 计算居中偏移
scaled_width = int(frame_width * self.scale_factor)
scaled_height = int(frame_height * self.scale_factor)
self.offset_x = (panel_width - scaled_width) // 2
self.offset_y = (panel_height - scaled_height) // 2
def panel_to_image_coords(self, panel_x, panel_y):
"""将面板坐标转换为图像坐标"""
image_x = int((panel_x - self.offset_x) / self.scale_factor)
image_y = int((panel_y - self.offset_y) / self.scale_factor)
return image_x, image_y
def image_to_panel_coords(self, image_x, image_y):
"""将图像坐标转换为面板坐标"""
panel_x = int(image_x * self.scale_factor + self.offset_x)
panel_y = int(image_y * self.scale_factor + self.offset_y)
return panel_x, panel_y
def handle_mouse_down(self, panel_x, panel_y):
"""处理鼠标按下事件"""
if self.current_frame is None:
return False
image_x, image_y = self.panel_to_image_coords(panel_x, panel_y)
if self.step == 0: # 画框模式
self.drawing_box = True
self.box_start = (image_x, image_y)
return True
elif self.step == 1: # 点击底部模式
self.bottom_points.append((image_x, image_y))
self.step = 2 # 🔥 修改:转到顶部点模式
return True
elif self.step == 2: # 🔥 新增:点击顶部模式
self.top_points.append((image_x, image_y))
self.step = 0
return True
return False
def handle_mouse_move(self, panel_x, panel_y):
"""处理鼠标移动事件"""
if self.current_frame is None or not self.drawing_box:
return False
return True
def handle_mouse_up(self, panel_x, panel_y):
"""处理鼠标释放事件"""
if self.current_frame is None or not self.drawing_box:
return False
self.drawing_box = False
image_x, image_y = self.panel_to_image_coords(panel_x, panel_y)
dx = image_x - self.box_start[0]
dy = image_y - self.box_start[1]
length = max(abs(dx), abs(dy))
length = ((length + 31) // 32) * 32 # 保证是32的倍数
if length > 0:
x2 = self.box_start[0] + (length if dx >= 0 else -length)
y2 = self.box_start[1] + (length if dy >= 0 else -length)
cx = (self.box_start[0] + x2) // 2
cy = (self.box_start[1] + y2) // 2
size = length
self.boxes.append((cx, cy, size))
self.step = 1 # 转到底部点模式
return True
def get_display_image(self, panel_width, panel_height, current_mouse_pos=None):
"""获取用于显示的图像"""
if self.current_frame is None:
return None
# 重新计算显示参数
self.calculate_display_params(panel_width, panel_height)
# 创建显示图像
img = self.current_frame.copy()
# 如果正在画框,显示临时框
if self.drawing_box and current_mouse_pos:
panel_x, panel_y = current_mouse_pos
image_x, image_y = self.panel_to_image_coords(panel_x, panel_y)
dx = image_x - self.box_start[0]
dy = image_y - self.box_start[1]
length = max(abs(dx), abs(dy))
length = ((length + 31) // 32) * 32
x2 = self.box_start[0] + (length if dx >= 0 else -length)
y2 = self.box_start[1] + (length if dy >= 0 else -length)
cv2.rectangle(img, self.box_start, (x2, y2), (255, 255, 0), 2)
# 绘制已完成的框
for i, (cx, cy, size) in enumerate(self.boxes):
half = size // 2
top = cy - half
bottom = cy + half
left = cx - half
right = cx + half
cv2.rectangle(img, (left, top), (right, bottom), (255, 255, 0), 2)
cv2.putText(img, f"T{i+1}", (left + 5, top + 20), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
# 🔥 修改:绘制底部点(绿色)和顶部点(红色)
for pt in self.bottom_points:
cv2.circle(img, pt, 5, (0, 255, 0), -1) # 绿色圆点表示底部
for pt in self.top_points:
cv2.circle(img, pt, 5, (0, 0, 255), -1) # 红色圆点表示顶部
# 缩放图像
scaled_height = int(img.shape[0] * self.scale_factor)
scaled_width = int(img.shape[1] * self.scale_factor)
img = cv2.resize(img, (scaled_width, scaled_height))
return img
def get_annotation_results(self):
"""获取标注结果 - 返回三个值"""
# 要求三个都存在才返回结果(框、底部点、顶部点)
if not self.boxes or not self.bottom_points or not self.top_points:
return None, None, None
# 检查数量是否匹配
if len(self.boxes) != len(self.bottom_points) or len(self.boxes) != len(self.top_points):
print(f"❌ 标注数据数量不匹配: 框{len(self.boxes)}个, 底部{len(self.bottom_points)}个, 顶部{len(self.top_points)}个")
return None, None, None
# 计算固定底部和顶部偏移
fixed_bottoms = []
fixed_tops = []
for i in range(len(self.boxes)):
cx, cy, size = self.boxes[i]
_, bottom_y = self.bottom_points[i]
_, top_y = self.top_points[i]
frame_top = cy - size // 2
bottom_offset = bottom_y - frame_top
top_offset = top_y - frame_top
fixed_bottoms.append(bottom_offset)
fixed_tops.append(top_offset)
# 🔥 修改:返回三个值
return self.boxes, fixed_bottoms, fixed_tops
def get_status_text(self):
"""获取当前状态文本"""
if self.step == 0:
return f"已标注 {len(self.boxes)} 个区域 - 请画检测框"
elif self.step == 1:
return f"已标注 {len(self.boxes)} 个区域 - 请点击底部"
elif self.step == 2: # 🔥 新增:顶部状态文本
return f"已标注 {len(self.boxes)} 个区域 - 请点击顶部"
def load_annotation_data(self, targets, fixed_bottoms, fixed_tops=None):
"""加载历史标注数据"""
try:
print(f"📖 开始加载历史标注数据...")
print(f" - 目标数据: {targets}")
print(f" - 底部偏移: {fixed_bottoms}")
print(f" - 顶部偏移: {fixed_tops}")
# 重置当前标注状态
self.reset_annotation()
# 加载目标框数据
if targets and isinstance(targets, list):
self.boxes = []
for target in targets:
if isinstance(target, list) and len(target) >= 3:
cx, cy, size = target[:3]
self.boxes.append((int(cx), int(cy), int(size)))
print(f" ✅ 加载目标框: 中心({cx}, {cy}), 尺寸{size}")
# 🔥 修复:正确处理底部偏移数据
if fixed_bottoms and isinstance(fixed_bottoms, list) and self.boxes:
self.bottom_points = []
for i, bottom in enumerate(fixed_bottoms):
if i < len(self.boxes):
cx, cy, size = self.boxes[i]
if isinstance(bottom, (int, float)):
# 🔥 修复:如果是偏移值,计算实际底部坐标
frame_top = cy - size // 2
bottom_y = frame_top + int(bottom) # frame_top + offset = bottom
self.bottom_points.append((int(cx), int(bottom_y)))
print(f" ✅ 加载底部点: ({cx}, {bottom_y}) [偏移{bottom}]")
elif isinstance(bottom, list) and len(bottom) >= 2:
# 如果是完整的坐标对
x, y = bottom[:2]
self.bottom_points.append((int(x), int(y)))
print(f" ✅ 加载底部点: ({x}, {y})")
# 🔥 新增:处理顶部偏移数据
if fixed_tops and isinstance(fixed_tops, list) and self.boxes:
self.top_points = []
for i, top in enumerate(fixed_tops):
if i < len(self.boxes):
cx, cy, size = self.boxes[i]
if isinstance(top, (int, float)):
# 如果是偏移值,计算实际顶部坐标
frame_top = cy - size // 2
top_y = frame_top + int(top) # frame_top + offset = top
self.top_points.append((int(cx), int(top_y)))
print(f" ✅ 加载顶部点: ({cx}, {top_y}) [偏移{top}]")
elif isinstance(top, list) and len(top) >= 2:
# 如果是完整的坐标对
x, y = top[:2]
self.top_points.append((int(x), int(y)))
print(f" ✅ 加载顶部点: ({x}, {y})")
# 🔥 修改:设置标注步骤为完成状态
if len(self.boxes) == len(self.bottom_points) == len(self.top_points):
self.step = 0 # 全部完成
elif len(self.boxes) == len(self.bottom_points):
self.step = 2 # 需要点击顶部
elif len(self.boxes) > len(self.bottom_points):
self.step = 1 # 需要点击底部
else:
self.step = 0 # 需要画框
print(f"✅ 历史标注数据加载完成:")
print(f" - 目标框数量: {len(self.boxes)}")
print(f" - 底部点数量: {len(self.bottom_points)}")
print(f" - 顶部点数量: {len(self.top_points)}")
return True
except Exception as e:
print(f"❌ 加载历史标注数据失败: {e}")
import traceback
traceback.print_exc()
return False
\ No newline at end of file
"""
液面检测引擎 - 专注于检测功能
"""
import cv2
import numpy as np
import threading
import time
import os
import json
from pathlib import Path
from core.model_loader import ModelLoader
from ultralytics import YOLO
from core.constants import (
SMOOTH_WINDOW,
FULL_THRESHOLD,
MAX_DATA_POINTS,
get_channel_model,
is_channel_model_selected
)
import csv
import os
from datetime import datetime
# 🔥 修改:从配置文件获取RTSP地址
def get_rtsp_url(channel_name):
"""根据通道名称获取RTSP地址 - 从配置文件读取"""
try:
config_path = os.path.join("resources", "rtsp_config.json")
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
channels = config.get("channels", {})
# 通道名称到channel key的映射
channel_mapping = {
"通道1": "channel1",
"通道2": "channel2",
"通道3": "channel3",
"通道4": "channel4",
"通道5": "channel5",
"通道6": "channel6"
}
# 获取对应的channel key
channel_key = channel_mapping.get(channel_name)
if channel_key and channel_key in channels:
rtsp_url = channels[channel_key].get("rtsp_url")
if rtsp_url:
return rtsp_url
# 如果配置文件中没有找到,使用硬编码的备用地址
fallback_mapping = {
"通道1": "rtsp://admin:@192.168.1.220:554/stream1",
"通道2": "rtsp://admin:@192.168.1.188:554/stream1",
"通道3": None, # 预留位置,后续接入
"通道4": None, # 预留位置,后续接入
"通道5": None, # 预留位置,后续接入
"通道6": None # 预留位置,后续接入
}
rtsp_url = fallback_mapping.get(channel_name, None)
if rtsp_url is None:
print(f"⚠️ {channel_name}暂未配置RTSP地址")
return None
return rtsp_url
except Exception as e:
print(f"❌ 读取RTSP配置失败: {e}")
print(f"⚠️ {channel_name}暂未配置RTSP地址")
return None
# 在类定义之前添加这些辅助函数(移出类外部)
def get_class_color(class_name):
"""为不同类别分配不同的颜色"""
color_map = {
'liquid': (0, 255, 0), # 绿色 - 液体
'foam': (255, 0, 0), # 蓝色 - 泡沫
'air': (0, 0, 255), # 红色 - 空气
}
return color_map.get(class_name, (128, 128, 128)) # 默认灰色
def overlay_multiple_masks_on_image(image, masks_info, alpha=0.5):
"""将多个不同类别的mask叠加到图像上"""
overlay = image.copy()
for mask, class_name, confidence in masks_info:
color = get_class_color(class_name)
mask_colored = np.zeros_like(image)
mask_colored[mask > 0] = color
# 叠加mask到原图
cv2.addWeighted(mask_colored, alpha, overlay, 1 - alpha, 0, overlay)
# 添加mask轮廓
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(overlay, contours, -1, color, 2)
# 在mask上添加类别标签和置信度
if len(contours) > 0:
largest_contour = max(contours, key=cv2.contourArea)
M = cv2.moments(largest_contour)
if M["m00"] != 0:
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
label = f"{class_name}: {confidence:.2f}"
font_scale = 0.8
thickness = 2
# 添加文字背景
(text_width, text_height), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
cv2.rectangle(overlay, (cx-15, cy-text_height-5), (cx+text_width+15, cy+5), (0, 0, 0), -1)
# 绘制文字
cv2.putText(overlay, label, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, thickness)
return overlay
def calculate_foam_boundary_lines(mask, class_name):
"""计算foam mask的顶部和底部边界线"""
if np.sum(mask) == 0:
return None, None
y_coords, x_coords = np.where(mask)
if len(y_coords) == 0:
return None, None
top_y = np.min(y_coords)
bottom_y = np.max(y_coords)
# 计算顶部边界线的平均位置
top_region_height = max(1, int((bottom_y - top_y) * 0.1))
top_region_mask = mask[top_y:top_y + top_region_height, :]
if np.sum(top_region_mask) > 0:
top_y_coords = np.where(top_region_mask)[0] + top_y
top_line_y = np.mean(top_y_coords)
else:
top_line_y = top_y
# 计算底部边界线的平均位置
bottom_region_height = max(1, int((bottom_y - top_y) * 0.1))
bottom_region_mask = mask[bottom_y - bottom_region_height:bottom_y + 1, :]
if np.sum(bottom_region_mask) > 0:
bottom_y_coords = np.where(bottom_region_mask)[0] + (bottom_y - bottom_region_height)
bottom_line_y = np.mean(bottom_y_coords)
else:
bottom_line_y = bottom_y
return top_line_y, bottom_line_y
def analyze_multiple_foams(foam_masks, cropped_shape):
"""分析多个foam,找到可能的液位边界"""
if len(foam_masks) < 2:
return None, None, 0, "需要至少2个foam才能分析边界"
foam_boundaries = []
# 计算每个foam的边界信息
for i, mask in enumerate(foam_masks):
top_y, bottom_y = calculate_foam_boundary_lines(mask, "foam")
if top_y is not None and bottom_y is not None:
center_y = (top_y + bottom_y) / 2
foam_boundaries.append({
'index': i,
'top_y': top_y,
'bottom_y': bottom_y,
'center_y': center_y
})
if len(foam_boundaries) < 2:
return None, None, 0, "有效foam边界少于2个"
# 按垂直位置排序
foam_boundaries.sort(key=lambda x: x['center_y'])
container_height = cropped_shape[0]
error_threshold_px = container_height * 0.1
analysis_info = f"\n 容器高度: {container_height}px, 10%误差阈值: {error_threshold_px:.1f}px"
# 检查相邻foam之间的边界
for i in range(len(foam_boundaries) - 1):
upper_foam = foam_boundaries[i]
lower_foam = foam_boundaries[i + 1]
upper_bottom = upper_foam['bottom_y']
lower_top = lower_foam['top_y']
boundary_distance = abs(upper_bottom - lower_top)
analysis_info += f"\n Foam{upper_foam['index']+1}底部(y={upper_bottom:.1f}) vs Foam{lower_foam['index']+1}顶部(y={lower_top:.1f})"
analysis_info += f"\n 边界距离: {boundary_distance:.1f}px"
if boundary_distance <= error_threshold_px:
liquid_level_y = (upper_bottom + lower_top) / 2
analysis_info += f"\n ✅ 距离 {boundary_distance:.1f}px ≤ {error_threshold_px:.1f}px,确定为液位边界: y={liquid_level_y:.1f}"
return liquid_level_y, 1.0, len(foam_boundaries), analysis_info
else:
analysis_info += f"\n ❌ 距离 {boundary_distance:.1f}px > {error_threshold_px:.1f}px,不是液位边界"
analysis_info += f"\n ❌ 未找到符合条件的液位边界"
return None, 0, len(foam_boundaries), analysis_info
def enhanced_liquid_detection_with_foam_analysis(all_masks_info, cropped, fixed_container_bottom,
container_pixel_height, container_height_cm,
target_idx, no_liquid_count, last_liquid_heights, frame_counters):
"""
🆕 增强的液位检测,结合连续帧逻辑和foam分析
@param target_idx 目标索引
@param no_liquid_count 每个目标连续未检测到liquid的帧数
@param last_liquid_heights 每个目标最后一次检测到的液位高度
@param frame_counters 每个目标的帧计数器(用于每4帧清零)
"""
liquid_height = None
detection_method = "无检测"
analysis_details = ""
# 🆕 计算像素到厘米的转换比例
pixel_per_cm = container_pixel_height / container_height_cm
# 分离不同类别的mask
liquid_masks = []
foam_masks = []
air_masks = []
for mask, class_name, confidence in all_masks_info:
if class_name == 'liquid':
liquid_masks.append(mask)
elif class_name == 'foam':
foam_masks.append(mask)
elif class_name == 'air':
air_masks.append(mask)
# 🎯 方法1:直接liquid检测 - 优先且主要方法
if liquid_masks:
best_liquid_mask = None
topmost_y = float('inf')
for mask in liquid_masks:
y_indices = np.where(mask)[0]
if len(y_indices) > 0:
mask_top_y = np.min(y_indices)
if mask_top_y < topmost_y:
topmost_y = mask_top_y
best_liquid_mask = mask
if best_liquid_mask is not None:
liquid_mask = best_liquid_mask
y_indices = np.where(liquid_mask)[0]
line_y = np.min(y_indices) # 液面顶部
liquid_height_px = fixed_container_bottom - line_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = "直接liquid检测(最上层)"
analysis_details = f"检测到{len(liquid_masks)}个liquid mask,选择最上层液体,液面位置: y={line_y}, 像素高度: {liquid_height_px}px"
# 🆕 重置所有计数器并记录最新液位
no_liquid_count[target_idx] = 0
frame_counters[target_idx] = 0
last_liquid_heights[target_idx] = liquid_height
print(f" ✅ Target{target_idx+1}: 检测到liquid(最上层),重置所有计数器,液位: {liquid_height:.3f}cm")
# 🆕 如果没有检测到liquid,处理计数器逻辑
else:
no_liquid_count[target_idx] += 1
frame_counters[target_idx] += 1
# print(f" 📊 Target{target_idx+1}: 连续{no_liquid_count[target_idx]}帧未检测到liquid,帧计数器: {frame_counters[target_idx]}")
# 🎯 连续3帧未检测到liquid时,启用备选方法
if no_liquid_count[target_idx] >= 3:
# print(f" 🚨 Target{target_idx+1}: 连续{no_liquid_count[target_idx]}帧未检测到liquid,启用备选检测方法")
# 方法2:多foam边界分析 - 第一备选方法
if len(foam_masks) >= 2:
foam_liquid_level, foam_confidence, foam_count, foam_analysis = analyze_multiple_foams(
foam_masks, cropped.shape
)
analysis_details += f"\n🔍 Foam边界分析 (检测到{foam_count}个foam):{foam_analysis}"
if foam_liquid_level is not None:
foam_liquid_height_px = fixed_container_bottom - foam_liquid_level
liquid_height = foam_liquid_height_px / pixel_per_cm
detection_method = "多foam边界分析(备选)"
analysis_details += f"\n✅ 使用foam边界分析结果: 像素高度 {foam_liquid_height_px}px = {liquid_height:.1f}cm"
print(f" 🌊 Target{target_idx+1}: 使用foam边界分析,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 方法2.5:单个foam下边界分析 - 第二备选方法
elif len(foam_masks) == 1:
foam_mask = foam_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(foam_mask, "foam")
if bottom_y is not None:
liquid_height_px = fixed_container_bottom - bottom_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = f"单foam底部检测(备选)"
analysis_details += f"\n🔍 单foam分析: 顶部y={top_y:.1f}, 底部y={bottom_y:.1f}, 像素高度: {liquid_height_px}px = {liquid_height:.3f}cm"
print(f" 🫧 Target{target_idx+1}: 使用单foam下边界检测,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 方法3:单个air的分析 - 第三备选方法
elif len(air_masks) == 1:
air_mask = air_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(air_mask, "air")
if bottom_y is not None:
liquid_height_px = fixed_container_bottom - bottom_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = f"单air底部检测(备选)"
analysis_details += f"\n🔍 单air分析: 顶部y={top_y:.1f}, 底部y={bottom_y:.1f}, 像素高度: {liquid_height_px}px = {liquid_height:.3f}cm"
print(f" 🌬️ Target{target_idx+1}: 使用单air检测,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 如果备选方法也没有结果,使用最后一次的检测结果
if liquid_height is None and last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = "使用最后液位(保持)"
analysis_details += f"\n🔒 备选方法无结果,保持最后一次检测结果: {liquid_height:.3f}cm"
print(f" 📌 Target{target_idx+1}: 保持最后液位: {liquid_height:.3f}cm")
else:
# 连续未检测到liquid但少于3帧,使用最后一次的检测结果
if last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = f"保持液位({no_liquid_count[target_idx]}/3)"
# 🔄 每3帧清零一次连续未检测计数器
if frame_counters[target_idx] % 3 == 0:
print(f" 🔄 Target{target_idx+1}: 达到3帧周期,清零连续未检测计数器 ({no_liquid_count[target_idx]} → 0)")
no_liquid_count[target_idx] = 0
return liquid_height, detection_method, analysis_details
class LiquidDetectionEngine:
"""液面检测引擎 - 专注于检测功能,不包含捕获逻辑"""
def __init__(self, channel_name="通道1"):
self.model = None
self.targets = []
self.fixed_container_bottoms = []
self.fixed_container_tops = []
self.actual_heights = []
self.kalman_filters = []
self.recent_observations = []
self.zero_count_list = []
self.full_count_list = []
self.is_running = False
# 🔥 移除:self.cap = None # 不再维护摄像头连接
self.SMOOTH_WINDOW = SMOOTH_WINDOW
self.FULL_THRESHOLD = FULL_THRESHOLD
# 数据收集相关
self.all_heights = []
self.frame_count = 0
self.max_data_points = MAX_DATA_POINTS
# 数据管理相关配置
self.data_cleanup_interval = 300
self.last_cleanup_time = 0
# 数据统计
self.total_frames_processed = 0
self.data_points_generated = 0
# CSV文件相关属性
self.channel_name = channel_name
curve_storage_path = self.get_curve_storage_path_from_config()
if curve_storage_path:
self.csv_save_dir = curve_storage_path
print(f"📁 使用配置文件中的曲线保存路径: {self.csv_save_dir}")
else:
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
self.csv_save_dir = os.path.join(project_root, 'resources', 'curve_data')
print(f"⚠️ 未找到curve_storage_path配置,使用默认路径: {self.csv_save_dir}")
self.task_csv_dir = None
self.csv_files = {}
self.last_save_time = {}
# 报警系统相关属性
self.alarm_records = []
self.last_alarm_times = {}
self.alarm_cooldown = 30
self.alarm_frame_enabled = True
self.alarm_frame_callback = None
self.current_frame = None
# 帧缓存机制
self.frame_buffer = []
self.frame_buffer_size = 20
self.pending_alarm_saves = []
self.alarm_video_sessions = []
self.alarm_merge_timeout = 10.0
self.video_fps = 10
# 🔥 移除:self.rtsp_url = get_rtsp_url(channel_name) # 不再管理RTSP连接
# 确保基础目录存在
try:
os.makedirs(self.csv_save_dir, exist_ok=True)
print(f"📁 数据将保存到: {self.csv_save_dir}")
except Exception as e:
print(f"❌ 创建基础保存目录失败: {e}")
def save_alarm_frames_sequence(self, alarm_record):
"""保存报警帧序列并生成AVI视频:前2帧+当前帧+后2帧"""
try:
import time
# 获取当前帧索引(帧缓存中的最后一帧)
current_frame_idx = len(self.frame_buffer) - 1
alarm_timestamp = alarm_record['alarm_timestamp']
# 🔥 收集帧序列
frames_to_save = []
# 前2帧
for i in range(max(0, current_frame_idx - 2), current_frame_idx):
if i < len(self.frame_buffer):
frames_to_save.append({
'frame': self.frame_buffer[i]['frame'],
'frame_type': f'前{current_frame_idx - i}帧',
'timestamp': self.frame_buffer[i]['timestamp']
})
# 当前帧(报警帧)
if current_frame_idx >= 0 and current_frame_idx < len(self.frame_buffer):
frames_to_save.append({
'frame': self.frame_buffer[current_frame_idx]['frame'],
'frame_type': '报警帧',
'timestamp': self.frame_buffer[current_frame_idx]['timestamp']
})
# 🔥 创建报警视频会话
alarm_session = {
'alarm_record': alarm_record,
'start_timestamp': alarm_timestamp,
'frames': frames_to_save.copy(),
'completed': False,
'pending_frames': 2, # 还需要等待的后续帧数
'channel_name': self.channel_name # 🔥 确保包含channel_name
}
# 添加到待处理队列(用于后续帧)
self.pending_alarm_saves.append(alarm_session)
print(f"📹 {self.channel_name} 开始收集报警帧序列,已收集{len(frames_to_save)}帧,等待后续2帧...")
except Exception as e:
print(f"❌ 保存报警帧序列失败: {e}")
def cleanup_timeout_sessions(self):
"""清理超时的视频会话"""
try:
import time
current_time = time.time()
completed_sessions = []
for i, session in enumerate(self.alarm_video_sessions):
if not session['completed']:
time_diff = current_time - session['start_timestamp']
if time_diff > self.alarm_merge_timeout * 2: # 超时清理
session['completed'] = True
completed_sessions.append(i)
print(f"⏰ {self.channel_name} 报警视频会话超时,强制完成")
self.generate_alarm_video(session)
# 移除已完成的会话
for idx in reversed(completed_sessions):
self.alarm_video_sessions.pop(idx)
except Exception as e:
print(f"❌ 清理报警视频会话失败: {e}")
def generate_alarm_video(self, session):
"""生成报警视频"""
try:
if not session['frames'] or not self.alarm_frame_callback:
return
# 🔥 调用视频生成回调
video_data = {
'alarm_record': session['alarm_record'],
'frames': session['frames'],
'channel_name': self.channel_name
}
# 调用视频面板的视频生成方法
if hasattr(self.alarm_frame_callback, '__self__'):
video_panel = self.alarm_frame_callback.__self__
if hasattr(video_panel, 'generate_alarm_video'):
video_panel.generate_alarm_video(video_data)
print(f"🎬 {self.channel_name} 报警视频生成完成,包含{len(session['frames'])}帧")
except Exception as e:
print(f"❌ 生成报警视频失败: {e}")
def set_current_frame(self, frame):
"""设置当前处理的帧并更新帧缓存"""
self.current_frame = frame
# 🔥 新增:维护帧缓存
if frame is not None:
# 添加时间戳
import time
frame_with_timestamp = {
'frame': frame.copy(),
'timestamp': time.time()
}
# 添加到缓存队列
self.frame_buffer.append(frame_with_timestamp)
# 限制缓存大小
if len(self.frame_buffer) > self.frame_buffer_size:
self.frame_buffer.pop(0)
# 🔥 处理待保存的报警记录(等待后续帧)
self.process_pending_alarm_saves()
def set_alarm_frame_callback(self, callback):
"""设置报警帧回调函数"""
self.alarm_frame_callback = callback
def check_alarm_conditions(self, area_idx, height_cm):
"""检查报警条件并生成报警记录"""
try:
import time
from datetime import datetime
# 获取安全上下限
safe_low, safe_high = self.get_safety_limits()
# 获取当前时间
current_time = datetime.now()
current_timestamp = time.time()
# 检查是否在冷却期内
area_key = f"{self.channel_name}_area_{area_idx}"
if area_key in self.last_alarm_times:
time_since_last_alarm = current_timestamp - self.last_alarm_times[area_key]
if time_since_last_alarm < self.alarm_cooldown:
return # 在冷却期内,不生成新的报警
# 检查报警条件
alarm_type = None
if height_cm < safe_low:
alarm_type = "低于下限"
elif height_cm > safe_high:
alarm_type = "高于上限"
if alarm_type:
# 生成报警记录
area_name = self.get_area_name(area_idx)
alarm_record = {
'time': current_time.strftime("%Y-%m-%d %H:%M:%S"),
'channel': self.channel_name,
'area_idx': area_idx,
'area_name': area_name,
'alarm_type': alarm_type,
'height': height_cm,
'safe_low': safe_low,
'safe_high': safe_high,
'message': f"{current_time.strftime('%H:%M:%S')} {area_name} {alarm_type}",
'alarm_timestamp': current_timestamp # 🔥 新增:报警时间戳
}
# 添加到报警记录
self.alarm_records.append(alarm_record)
# 更新最后报警时间
self.last_alarm_times[area_key] = current_timestamp
# 打印报警信息
print(f"🚨 {self.channel_name} 报警: {alarm_record['message']} - 当前值: {height_cm:.1f}cm")
# 🔥 新增:触发报警帧保存和视频生成
if self.alarm_frame_callback and self.alarm_frame_enabled:
print(f"🎬 {self.channel_name} 触发报警帧保存,回调函数: {self.alarm_frame_callback}")
print(f"🎬 当前帧是否存在: {self.current_frame is not None}")
self.save_alarm_frames_sequence(alarm_record)
else:
print(f"⚠️ {self.channel_name} 报警帧保存未启用或回调为空")
print(f" - alarm_frame_enabled: {self.alarm_frame_enabled}")
print(f" - alarm_frame_callback: {self.alarm_frame_callback}")
except Exception as e:
print(f"❌ 检查报警条件失败: {e}")
def process_pending_alarm_saves(self):
"""处理待保存的报警记录(添加后续帧并生成AVI视频)"""
try:
if not self.pending_alarm_saves or len(self.frame_buffer) == 0:
return
current_frame = self.frame_buffer[-1]
completed_sessions = []
for i, session in enumerate(self.pending_alarm_saves):
if session['pending_frames'] > 0:
# 添加后续帧
frame_type = f"后{3 - session['pending_frames']}帧"
session['frames'].append({
'frame': current_frame['frame'].copy(),
'frame_type': frame_type,
'timestamp': current_frame['timestamp']
})
session['pending_frames'] -= 1
# 检查是否完成
if session['pending_frames'] <= 0:
session['completed'] = True
completed_sessions.append(i)
# 🔥 生成AVI报警视频
self.generate_alarm_avi_video(session)
# 移除已完成的会话
for idx in reversed(completed_sessions):
self.pending_alarm_saves.pop(idx)
except Exception as e:
print(f"❌ 处理报警视频会话失败: {e}")
# 第674行应该改为:
def generate_alarm_avi_video(self, session):
"""生成AVI格式的报警视频"""
try:
if not session['frames'] or not self.alarm_frame_callback:
return
# 🔥 调用视频生成回调(传递给VideoPanel处理)
video_data = {
'alarm_record': session['alarm_record'],
'frames': session['frames'],
'channel_name': session['channel_name'], # 确保包含channel_name
'format': 'avi' # 指定AVI格式
}
# 修改第663-669行
# 调用视频面板的AVI视频生成方法
try:
# 🔥 直接调用回调函数,让它处理视频生成
self.alarm_frame_callback(video_data, 'generate_video')
print(f"✅ {self.channel_name} 报警AVI视频回调已调用")
except Exception as callback_error:
print(f"❌ {self.channel_name} 视频生成回调失败: {callback_error}")
except Exception as e:
print(f"❌ 生成报警AVI视频失败: {e}")
def get_safety_limits(self):
"""从配置文件读取安全上下限值"""
try:
import json
import os
import re
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
general = config.get('general', {})
safe_low_str = general.get('safe_low', '2.0cm')
safe_high_str = general.get('safe_high', '10.0cm')
# 提取数值部分(去掉"cm"单位)
safe_low_match = re.search(r'([\d.]+)', safe_low_str)
safe_high_match = re.search(r'([\d.]+)', safe_high_str)
safe_low = float(safe_low_match.group(1)) if safe_low_match else 2.0
safe_high = float(safe_high_match.group(1)) if safe_high_match else 10.0
return safe_low, safe_high
else:
return 2.0, 10.0
except Exception as e:
print(f"❌ 读取{self.channel_name}安全预警值失败: {e}")
return 2.0, 10.0
def get_alarm_records(self):
"""获取报警记录"""
return self.alarm_records.copy()
def load_model(self, model_path):
"""加载YOLO模型"""
try:
print(f"🔄 {self.channel_name}正在加载模型: {model_path}")
# 检查文件是否存在
import os
if not os.path.exists(model_path):
print(f"❌ {self.channel_name}模型文件不存在: {model_path}")
return False
# 检查文件大小
file_size = os.path.getsize(model_path)
if file_size == 0:
print(f"❌ {self.channel_name}模型文件为空: {model_path}")
return False
print(f"📁 {self.channel_name}模型文件大小: {file_size / (1024*1024):.1f}MB")
# 尝试加载模型
self.model = YOLO(model_path)
print(f"✅ {self.channel_name}模型加载成功: {os.path.basename(model_path)}")
return True
except Exception as e:
print(f"❌ {self.channel_name}模型加载失败: {e}")
import traceback
traceback.print_exc()
self.model = None
return False
def setup_detection(self, targets, fixed_container_bottoms, fixed_container_tops=None, actual_heights=None, camera_index=0):
"""设置检测参数 - 不再初始化摄像头,兼容旧接口"""
try:
# 获取当前通道的模型路径
if not is_channel_model_selected(self.channel_name):
raise ValueError(f"{self.channel_name}未选择模型")
model_path = get_channel_model(self.channel_name)
if not model_path:
raise ValueError(f"{self.channel_name}模型路径无效")
print(f"📂 {self.channel_name}使用模型: {model_path}")
# 如果是dat文件,先解码
if model_path.endswith('.dat'):
model_loader = ModelLoader()
original_data = model_loader._decode_dat_file(model_path)
temp_dir = Path("temp_models")
temp_dir.mkdir(exist_ok=True)
temp_model_path = temp_dir / f"temp_{Path(model_path).stem}.pt"
with open(temp_model_path, 'wb') as f:
f.write(original_data)
model_path = str(temp_model_path)
print(f"✅ {self.channel_name}模型已解码: {model_path}")
# 加载模型
if not self.load_model(model_path):
raise ValueError(f"{self.channel_name}模型加载失败")
# 设置参数
self.targets = targets
self.fixed_container_bottoms = fixed_container_bottoms
self.fixed_container_tops = fixed_container_tops if fixed_container_tops else []
self.actual_heights = actual_heights if actual_heights else []
print(f"🔧 检测参数设置完成:")
print(f" - 目标数量: {len(targets)}")
print(f" - 底部数据: {len(fixed_container_bottoms)}")
print(f" - 顶部数据: {len(self.fixed_container_tops)}")
print(f" - 实际高度: {self.actual_heights}")
print(f" - 相机索引: {camera_index} (新架构中忽略)")
# 初始化其他列表
self.recent_observations = [[] for _ in range(len(targets))]
self.zero_count_list = [0] * len(targets)
self.full_count_list = [0] * len(targets)
# 使用默认值初始化卡尔曼滤波器
self.init_kalman_filters_with_defaults()
print(f"✅ {self.channel_name}检测引擎设置成功")
return True
except Exception as e:
print(f"❌ {self.channel_name}检测引擎设置失败: {e}")
import traceback
traceback.print_exc()
return False
def init_kalman_filters_with_defaults(self):
"""使用默认值初始化卡尔曼滤波器 - 不需要摄像头"""
print("⏳ 使用默认值初始化卡尔曼滤波器...")
# 使用默认的初始高度(比如容器高度的50%)
init_means = []
for idx in range(len(self.targets)):
if idx < len(self.actual_heights):
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
# 默认假设初始液位为容器高度的50%
default_height = actual_height_cm * 0.5
init_means.append(default_height)
else:
init_means.append(5.0) # 默认5cm
self.kalman_filters = self.init_kalman_filters_list(len(self.targets), init_means)
print(f"✅ 卡尔曼滤波器初始化完成,默认起始高度:{init_means}")
def init_kalman_filters(self):
"""初始化卡尔曼滤波器"""
print("⏳ 正在初始化卡尔曼滤波器...")
initial_heights = [[] for _ in self.targets]
frame_count = 0
init_frame_count = 25 # 初始化帧数
while frame_count < init_frame_count:
ret, frame = self.cap.read()
if not ret:
print(f"⚠️ 读取RTSP帧失败,帧数: {frame_count}")
time.sleep(0.1) # 等待一下再重试
continue
for idx, (cx, cy, size) in enumerate(self.targets):
crop = frame[max(0, cy - size // 2):min(cy + size // 2, frame.shape[0]),
max(0, cx - size // 2):min(cx + size // 2, frame.shape[1])]
if self.model is None:
continue
results = self.model.predict(source=crop, imgsz=640, save=False, conf=0.5, iou=0.5)
result = results[0]
if result.masks is not None:
masks = result.masks.data.cpu().numpy() > 0.5
classes = result.boxes.cls.cpu().numpy().astype(int)
for i, mask in enumerate(masks):
if self.model.names[classes[i]] != 'liquid':
continue
resized_mask = cv2.resize(mask.astype(np.uint8), (crop.shape[1], crop.shape[0])) > 0.5
y_indices = np.where(resized_mask)[0]
if len(y_indices) > 0:
# 🔥 修复:使用与正常运行时相同的计算公式
liquid_top_y = np.min(y_indices) # 液体顶部
# 🔥 使用您的公式计算初始高度
if idx < len(self.fixed_container_bottoms) and idx < len(self.fixed_container_tops) and idx < len(self.actual_heights):
container_bottom_y = self.fixed_container_bottoms[idx]
container_top_y = self.fixed_container_tops[idx]
# 边界检查
if liquid_top_y > container_bottom_y:
liquid_cm = 0.0
elif liquid_top_y < container_top_y:
liquid_top_y = container_top_y
liquid_cm = float(self.actual_heights[idx].replace('cm', ''))
else:
# 🔥 使用相同的公式:液体高度(cm) = (容器底部像素 - 液位顶部像素) / (容器底部像素 - 容器顶部像素) × 实际容器高度(cm)
numerator = container_bottom_y - liquid_top_y
denominator = container_bottom_y - container_top_y
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
if denominator != 0:
liquid_cm = (numerator / denominator) * actual_height_cm
if liquid_cm > actual_height_cm:
liquid_cm = actual_height_cm
liquid_cm = max(0, round(liquid_cm, 1))
else:
liquid_cm = 0.0
else:
liquid_cm = 0.0
initial_heights[idx].append(liquid_cm)
frame_count += 1
# 计算初始高度
init_means = [self.stable_median(hs) for hs in initial_heights]
self.kalman_filters = self.init_kalman_filters_list(len(self.targets), init_means)
print(f"✅ 初始化完成,起始高度:{init_means}")
# RTSP流不需要重置位置
print("✅ RTSP摄像头初始化完成")
def stable_median(self, data, max_std=1.0):
"""稳健地计算中位数"""
if len(data) == 0:
return 0
data = np.array(data)
q1, q3 = np.percentile(data, [25, 75])
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
data = data[(data >= lower) & (data <= upper)]
if len(data) >= 2 and np.std(data) > max_std:
median_val = np.median(data)
data = data[np.abs(data - median_val) <= max_std]
return float(np.median(data)) if len(data) > 0 else 0
def init_kalman_filters_list(self, num_targets, init_means):
"""初始化卡尔曼滤波器列表"""
kalman_list = []
for i in range(num_targets):
kf = cv2.KalmanFilter(2, 1)
kf.measurementMatrix = np.array([[1, 0]], np.float32)
kf.transitionMatrix = np.array([[1, 0.2], [0, 0.3]], np.float32)
kf.processNoiseCov = np.diag([1e-2, 1e-2]).astype(np.float32)
kf.measurementNoiseCov = np.array([[1]], dtype=np.float32)
kf.statePost = np.array([[init_means[i]], [0]], dtype=np.float32)
kalman_list.append(kf)
return kalman_list
def get_height_data(self):
"""获取液面高度历史数据"""
if not self.all_heights:
return []
# 返回每个目标的高度数据
result = []
for i in range(len(self.targets)):
if i < len(self.all_heights):
result.append(self.all_heights[i].copy())
else:
result.append([])
return result
def ensure_save_directory(self):
"""确保保存目录存在 - 创建任务级别的子目录"""
try:
# 创建基础目录
os.makedirs(self.csv_save_dir, exist_ok=True)
# 🔥 新增:创建任务级别的子目录
task_folder = self.get_task_folder_name()
self.task_csv_dir = os.path.join(self.csv_save_dir, task_folder)
os.makedirs(self.task_csv_dir, exist_ok=True)
print(f"✅ 创建任务数据保存目录: {self.task_csv_dir}")
# 创建readme文件说明数据格式
readme_path = os.path.join(self.csv_save_dir, 'README.txt')
if not os.path.exists(readme_path):
with open(readme_path, 'w', encoding='utf-8') as f:
f.write("曲线数据说明:\n")
f.write("1. 目录命名格式:任务ID_任务名称_curve_YYYYMMDD_HHMMSS\n")
f.write("2. 文件命名格式:通道名_区域X_区域名称_YYYYMMDD.csv\n")
f.write("3. 数据格式:\n")
f.write(" - 时间:YYYY-MM-DD-HH:MM:SS.mmm\n")
f.write(" - 液位高度:以厘米(cm)为单位,保留一位小数\n")
f.write(" - 分隔符:空格\n")
except Exception as e:
print(f"❌ 创建保存目录失败: {e}")
# 新增方法:获取任务文件夹名称
def get_task_folder_name(self):
"""生成任务文件夹名称 - 格式:任务ID_任务名称_curve_日期_时间"""
try:
# 获取任务信息
task_id, task_name = self.get_task_info()
# 生成时间戳
current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
# 清理任务名称
clean_task_name = self.clean_filename(task_name)
# 生成文件夹名称
folder_name = f"{task_id}_{clean_task_name}_curve_{current_time}"
return folder_name
except Exception as e:
print(f"❌ 生成任务文件夹名失败: {e}")
current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"UNKNOWN_未命名任务_curve_{current_time}"
# 新增方法:获取任务信息
def get_task_info(self):
"""从配置文件获取任务ID和任务名称"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
print(f"🔍 查找配置文件: {config_file}")
if os.path.exists(config_file):
print(f"✅ 找到配置文件: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
config_data = json.load(f)
print(f"📖 配置文件内容: {config_data.keys()}")
# 从general中读取任务信息
if 'general' in config_data:
general = config_data['general']
task_id = general.get('task_id', 'UNKNOWN')
task_name = general.get('task_name', '未命名任务')
print(f"✅ 读取到任务信息: task_id={task_id}, task_name={task_name}")
return task_id, task_name
else:
print(f"⚠️ 配置文件中没有general字段")
else:
print(f"❌ 配置文件不存在: {config_file}")
# 如果没有找到配置,返回默认值
print(f"⚠️ 使用默认任务信息")
return 'UNKNOWN', '未命名任务'
except Exception as e:
print(f"❌ 获取任务信息失败: {e}")
import traceback
traceback.print_exc()
return 'UNKNOWN', '未命名任务'
# 修改 get_csv_filename 方法 - 使用任务子目录
def get_csv_filename(self, area_idx):
"""生成CSV文件名 - 保存到任务子目录"""
try:
current_date = datetime.now().strftime("%Y%m%d")
# 获取区域名称
area_name = self.get_area_name(area_idx)
# 生成文件名:通道2_区域1_区域名称_20250806.csv
filename = f"{self.channel_name}_区域{area_idx+1}_{area_name}_{current_date}.csv"
# 🔥 修改:使用任务子目录
if hasattr(self, 'task_csv_dir'):
return os.path.join(self.task_csv_dir, filename)
else:
return os.path.join(self.csv_save_dir, filename)
except Exception as e:
print(f"❌ 生成CSV文件名失败: {e}")
current_date = datetime.now().strftime("%Y%m%d")
return os.path.join(self.csv_save_dir,
f"{self.channel_name}_区域{area_idx+1}_{current_date}.csv")
def get_curve_storage_path_from_config(self):
"""从通道配置文件读取curve_storage_path"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
print(f"🔍 读取{self.channel_name}的curve_storage_path: {config_file}")
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# 检查general配置中的curve_storage_path
if 'general' in config and 'curve_storage_path' in config['general']:
curve_path = config['general']['curve_storage_path']
if curve_path and curve_path.strip():
print(f"✅ 找到curve_storage_path: {curve_path}")
return curve_path.strip()
print(f"⚠️ {self.channel_name}配置文件中未找到curve_storage_path字段")
else:
print(f"⚠️ {self.channel_name}配置文件不存在: {config_file}")
return None
except Exception as e:
print(f"❌ 读取{self.channel_name}的curve_storage_path失败: {e}")
return None
# 新增方法:获取区域名称
# 新增方法:获取区域名称
def get_area_name(self, area_idx):
"""获取区域名称"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config_data = json.load(f)
# 从general.areas中读取区域名称
if 'general' in config_data and 'areas' in config_data['general']:
areas = config_data['general']['areas']
area_key = f'area_{area_idx + 1}'
if area_key in areas:
area_name = areas[area_key]
# 清理区域名称中的特殊字符
area_name = self.clean_filename(area_name)
return area_name
# 如果没有找到配置,返回默认名称
return f"检测区域{area_idx+1}"
except Exception as e:
print(f"❌ 获取区域名称失败: {e}")
return f"检测区域{area_idx+1}"
# 新增方法:清理文件名
def clean_filename(self, name):
"""清理文件名中的特殊字符"""
try:
import re
# 移除或替换不允许的字符
forbidden_chars = r'[\\/:*?"<>|]'
clean_name = re.sub(forbidden_chars, '_', name)
# 移除首尾空格
clean_name = clean_name.strip()
# 如果名称为空,使用默认名称
if not clean_name:
clean_name = '未命名区域'
return clean_name
except Exception as e:
print(f"❌ 清理文件名失败: {e}")
return '未命名区域'
# 修改 init_csv_file 方法,不使用csv.writer
def init_csv_file(self, area_idx):
"""初始化CSV文件"""
try:
filename = self.get_csv_filename(area_idx)
file_exists = os.path.exists(filename)
# 打开文件,使用追加模式,不使用csv模块
f = open(filename, 'a', encoding='utf-8')
# 不写入表头,直接创建文件
if not file_exists:
print(f"✅ 创建新的CSV文件: {filename}")
return f, None # 不返回writer
except Exception as e:
print(f"❌ 初始化CSV文件失败: {e}")
return None, None
# 修改 save_data_to_csv 方法,在第一次保存时创建任务目录
def save_data_to_csv(self, area_idx, height_cm):
"""保存数据到CSV文件"""
try:
# 🔥 新增:如果任务目录还没创建,先创建
if self.task_csv_dir is None:
self.create_task_directory()
# 获取当前时间
current_time = datetime.now()
# 检查是否需要创建新的日期文件
if (area_idx not in self.csv_files or
current_time.date() != datetime.fromtimestamp(self.last_save_time.get(area_idx, 0)).date()):
# 关闭旧文件(如果存在)
if area_idx in self.csv_files:
self.csv_files[area_idx][0].close()
# 创建新文件
f, _ = self.init_csv_file(area_idx)
if f:
self.csv_files[area_idx] = (f, None)
# 获取文件对象并写入数据
if area_idx in self.csv_files:
f, _ = self.csv_files[area_idx]
# 写入数据格式:日期-时间(毫秒) 液位高度(三位小数),用空格分隔
time_str = current_time.strftime("%Y-%m-%d-%H:%M:%S.%f")[:-3] # 保留毫秒
height_str = f"{height_cm:.3f}" # 保留三位小数
# 直接写入文件,用空格分隔
f.write(f"{time_str} {height_str}\n")
# 立即写入磁盘
f.flush()
# 更新最后保存时间
self.last_save_time[area_idx] = current_time.timestamp()
except Exception as e:
print(f"❌ 保存数据到CSV失败: {e}")
import traceback
traceback.print_exc()
# 新增方法:创建任务目录
def create_task_directory(self):
"""创建任务目录"""
try:
task_folder = self.get_task_folder_name()
self.task_csv_dir = os.path.join(self.csv_save_dir, task_folder)
os.makedirs(self.task_csv_dir, exist_ok=True)
print(f"✅ 创建任务数据保存目录: {self.task_csv_dir}")
except Exception as e:
print(f"❌ 创建任务目录失败: {e}")
# 回退到基础目录
self.task_csv_dir = self.csv_save_dir
def process_frame(self, frame, threshold=10, error_percentage=30):
"""处理单帧图像 - 这是检测引擎的核心功能"""
if self.model is None or not self.targets:
return frame
# 🔥 新增:设置当前帧(用于报警系统)
self.set_current_frame(frame)
h, w = frame.shape[:2]
processed_img = frame.copy()
# 初始化高度数据结构
if not self.all_heights:
self.all_heights = [[] for _ in self.targets]
# 初始化连续检测失败计数器
if not hasattr(self, 'detection_fail_counts'):
self.detection_fail_counts = [0] * len(self.targets)
if not hasattr(self, 'full_count_list'):
self.full_count_list = [0] * len(self.targets)
if not hasattr(self, 'consecutive_rejects'):
self.consecutive_rejects = [0] * len(self.targets)
# 🆕 新增:连续帧检测相关变量
if not hasattr(self, 'no_liquid_count'):
self.no_liquid_count = [0] * len(self.targets)
if not hasattr(self, 'last_liquid_heights'):
self.last_liquid_heights = [None] * len(self.targets)
if not hasattr(self, 'frame_counters'):
self.frame_counters = [0] * len(self.targets)
for idx, (center_x, center_y, crop_size) in enumerate(self.targets):
half_size = crop_size // 2
top = max(center_y - half_size, 0)
bottom = min(center_y + half_size, h)
left = max(center_x - half_size, 0)
right = min(center_x + half_size, w)
cropped = processed_img[top:bottom, left:right]
if cropped.size == 0:
continue
liquid_height = None
all_masks_info = []
try:
results = self.model.predict(source=cropped, imgsz=640, conf=0.5, iou=0.5, save=False, verbose=False)
result = results[0]
# print(f"\n=== Target {idx+1} 分割结果分析 ===")
# print(f"裁剪区域大小: {cropped.shape[1]}x{cropped.shape[0]} = {cropped.shape[1] * cropped.shape[0]} 像素")
# 获取容器信息
container_top_offset = self.fixed_container_tops[idx] if idx < len(self.fixed_container_tops) else 0
container_bottom_offset = self.fixed_container_bottoms[idx] if idx < len(self.fixed_container_bottoms) else 100
container_pixel_height = container_bottom_offset - container_top_offset
container_height_cm = float(self.actual_heights[idx].replace('cm', '')) if idx < len(self.actual_heights) else 15.0
# 满液阈值 = 容器实际高度的90%
full_threshold_cm = container_height_cm * 0.9
if result.masks is not None:
masks = result.masks.data.cpu().numpy() > 0.5
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
names = self.model.names
# 按置信度排序
sorted_indices = np.argsort(confidences)[::-1]
for i in sorted_indices:
class_name = names[classes[i]]
confidence = confidences[i]
# 只处理置信度大于0.5的检测
if confidence < 0.5:
continue
resized_mask = cv2.resize(masks[i].astype(np.uint8), (cropped.shape[1], cropped.shape[0])) > 0.5
# 存储mask信息
all_masks_info.append((resized_mask, class_name, confidence))
# # 检测统计
# detection_count = len(all_masks_info)
# if detection_count > 0:
# class_counts = {}
# for _, class_name, _ in all_masks_info:
# class_counts[class_name] = class_counts.get(class_name, 0) + 1
# print(f" 📊 检测汇总: 共{detection_count}个有效检测")
# for class_name, count in class_counts.items():
# print(f" {class_name}: {count}个")
# 🆕 使用增强的液位检测方法(包含新的连续帧逻辑)
liquid_height, detection_method, analysis_details = enhanced_liquid_detection_with_foam_analysis(
all_masks_info, cropped, container_bottom_offset,
container_pixel_height, container_height_cm,
idx, self.no_liquid_count, self.last_liquid_heights, self.frame_counters
)
if analysis_details:
print(f" 分析详情: {analysis_details}")
else:
print(f" ❌ 未检测到任何分割结果")
except Exception as e:
print(f"❌ 检测过程出错: {e}")
liquid_height = None
# 卡尔曼滤波预测步骤
predicted = self.kalman_filters[idx].predict()
predicted_height = predicted[0][0]
# 获取当前观测值
current_observation = liquid_height if liquid_height is not None else 0
# 误差计算逻辑
prediction_error_percent = abs(current_observation - predicted_height) / container_height_cm * 100
# print(f"\n🎯 Target {idx+1} 卡尔曼滤波决策:")
# print(f" 预测值: {predicted_height:.3f}cm")
# print(f" 观测值: {current_observation:.3f}cm")
# print(f" 预测误差: {prediction_error_percent:.1f}%")
# print(f" 连续拒绝次数: {self.consecutive_rejects[idx]}")
# 误差控制逻辑
if prediction_error_percent > error_percentage:
# 误差过大,增加拒绝计数
self.consecutive_rejects[idx] += 1
# 检查是否连续6次拒绝
if self.consecutive_rejects[idx] >= 3:
# 连续6次误差过大,强制使用观测值更新
self.kalman_filters[idx].statePost = np.array([[current_observation], [0]], dtype=np.float32)
self.kalman_filters[idx].statePre = np.array([[current_observation], [0]], dtype=np.float32)
final_height = current_observation
self.consecutive_rejects[idx] = 0 # 重置计数器
# print(f" 🔄 连续3次误差过大,强制使用观测值更新: {observation:.3f}cm → 滤波后: {final_height:.3f}cm")
else:
# 使用预测值
final_height = predicted_height
# print(f" ❌ 误差 {prediction_error_percent:.1f}% > {error_percentage}%,使用预测值: {predicted_height:.3f}cm (连续拒绝: {self.consecutive_rejects[idx]}/6)")
else:
# 误差可接受,正常更新
self.kalman_filters[idx].correct(np.array([[current_observation]], dtype=np.float32))
final_height = self.kalman_filters[idx].statePost[0][0]
self.consecutive_rejects[idx] = 0 # 重置计数器
# print(f" ✅ 误差 {prediction_error_percent:.1f}% <= {error_percentage}%,使用观测值: {current_observation:.3f}cm → 滤波后: {final_height:.3f}cm")
# 更新满液状态判断
if final_height == 0:
self.detection_fail_counts[idx] += 1
self.full_count_list[idx] = 0
elif final_height >= full_threshold_cm:
self.full_count_list[idx] += 1
self.detection_fail_counts[idx] = 0
# print(f" 🌊 液位 {final_height:.3f}cm >= {full_threshold_cm:.3f}cm,判定为满液状态")
else:
self.detection_fail_counts[idx] = 0
self.full_count_list[idx] = 0
# 添加到滑动窗口
if idx >= len(self.recent_observations):
self.recent_observations.extend([[] for _ in range(idx + 1 - len(self.recent_observations))])
self.recent_observations[idx].append(final_height)
if len(self.recent_observations[idx]) > self.SMOOTH_WINDOW:
self.recent_observations[idx].pop(0)
# 使用最终确定的高度
height_cm = np.clip(final_height, 0, container_height_cm)
# 收集高度数据
if len(self.all_heights) > idx:
self.all_heights[idx].append(height_cm)
if len(self.all_heights[idx]) > self.max_data_points:
self.all_heights[idx].pop(0)
# 保存数据到CSV和检查报警条件
self.save_data_to_csv(idx, height_cm)
self.check_alarm_conditions(idx, height_cm)
# 绘制检测框
cv2.rectangle(processed_img, (left, top), (right, bottom), (0, 255, 0), 2)
# 计算并绘制液位线
pixel_per_cm = container_pixel_height / container_height_cm
height_px = int(height_cm * pixel_per_cm)
# 计算液位线在裁剪区域内的位置
container_bottom_in_crop = container_bottom_offset
liquid_line_y_in_crop = container_bottom_in_crop - height_px
liquid_line_y_absolute = top + liquid_line_y_in_crop
# print(f" 📏 绘制信息:")
# print(f" 液位高度: {height_cm:.3f}cm = {height_px}px")
# print(f" 容器底部(裁剪内): {container_bottom_in_crop}px")
# print(f" 液位线(裁剪内): {liquid_line_y_in_crop}px")
# print(f" 液位线(原图): {liquid_line_y_absolute}px")
# 检查液位线是否在有效范围内
if 0 <= liquid_line_y_in_crop < cropped.shape[0]:
# 绘制液位线
cv2.line(processed_img, (left, liquid_line_y_absolute), (right, liquid_line_y_absolute), (0, 0, 255), 3)
# print(f" ✅ 液位线绘制成功")
else:
print(f" ❌ 液位线超出裁剪区域范围: {liquid_line_y_in_crop} (应在0-{cropped.shape[0]})")
# 显示带单位的实际高度
text = f"{height_cm:.3f}cm"
cv2.putText(processed_img, text,
(left + 5, top - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.8, (0, 255, 0), 2)
self.frame_count += 1
# 定期清理和统计
import time
current_time = time.time()
if current_time - self.last_cleanup_time > self.data_cleanup_interval:
self.cleanup_data_statistics()
self.last_cleanup_time = current_time
self.total_frames_processed += 1
return processed_img
def start_detection(self):
"""开始检测 - 不再管理摄像头"""
try:
# 🔥 移除:摄像头相关的初始化逻辑
self.is_running = True
print(f"✅ {self.channel_name}检测引擎已启动(纯检测模式)")
except Exception as e:
print(f"❌ {self.channel_name}检测启动失败: {e}")
self.is_running = False
def stop_detection(self):
"""停止检测 - 不再管理摄像头"""
self.is_running = False
# 🔥 移除:不再释放摄像头连接
print(f"✅ {self.channel_name}检测引擎已停止")
def notify_information_panel_update(self):
"""通知信息面板更新模型显示"""
try:
import wx
# 获取主窗口
app = wx.GetApp()
if not app:
return
main_frame = app.GetTopWindow()
if not main_frame:
return
# 查找信息面板
information_panel = None
# 方法1:通过function_panel查找
if hasattr(main_frame, 'function_panel'):
function_panel = main_frame.function_panel
# 检查function_panel是否有notebook
if hasattr(function_panel, 'notebook'):
notebook = function_panel.notebook
page_count = notebook.GetPageCount()
for i in range(page_count):
page = notebook.GetPage(i)
if hasattr(page, '__class__') and 'Information' in page.__class__.__name__:
information_panel = page
break
# 方法2:直接查找information_panel属性
if not information_panel and hasattr(function_panel, 'information_panel'):
information_panel = function_panel.information_panel
# 方法3:遍历所有子窗口查找
if not information_panel:
information_panel = self.find_information_panel_recursive(main_frame)
# 如果找到信息面板,调用刷新方法
if information_panel and hasattr(information_panel, 'refresh_model_data'):
wx.CallAfter(information_panel.refresh_model_data)
print("✅ 已通知信息面板更新模型显示")
else:
print("⚠️ 未找到信息面板或刷新方法")
except Exception as e:
print(f"❌ 通知信息面板更新失败: {e}")
def find_information_panel_recursive(self, parent):
"""递归查找信息面板"""
try:
# 检查当前窗口
if hasattr(parent, '__class__') and 'Information' in parent.__class__.__name__:
return parent
# 检查子窗口
if hasattr(parent, 'GetChildren'):
for child in parent.GetChildren():
result = self.find_information_panel_recursive(child)
if result:
return result
return None
except:
return None
def cleanup(self):
"""清理资源"""
try:
# 关闭所有CSV文件
for f, _ in self.csv_files.values():
try:
f.close()
except:
pass
self.csv_files.clear()
# 原有的清理代码
temp_dir = Path("temp_models")
if temp_dir.exists():
for temp_file in temp_dir.glob("temp_*.pt"):
try:
temp_file.unlink()
except:
pass
except:
pass
def calculate_liquid_height(self, liquid_top_y, idx):
"""计算液体高度 - 使用比例换算方法"""
container_bottom_y = self.fixed_container_bottoms[idx]
container_top_y = self.fixed_container_tops[idx]
# 🔥 边界检查和修正
if liquid_top_y > container_bottom_y:
# 液位线在容器底部以下,说明无液体
return 0.0
elif liquid_top_y < container_top_y:
# 液位线在容器顶部以上,说明满液位
liquid_top_y = container_top_y
# 按照你的公式计算
numerator = container_bottom_y - liquid_top_y # 容器底部-液位线位置
denominator = container_bottom_y - container_top_y # 容器底部-容器顶部
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
liquid_height = (numerator / denominator) * actual_height_cm
# 🔥 新增:限制液体高度不超过容器实际高度
if liquid_height > actual_height_cm:
print(f"⚠️ {self.channel_name}区域{idx+1}: 计算高度{liquid_height:.1f}cm超过容器实际高度{actual_height_cm}cm,修正为{actual_height_cm}cm")
liquid_height = actual_height_cm
return max(0, round(liquid_height, 3))
def cleanup_data_statistics(self):
"""定期清理数据并打印统计信息"""
try:
total_data_points = sum(len(heights) for heights in self.all_heights)
print(f"📊 {self.channel_name}数据统计:")
print(f" - 处理帧数: {self.total_frames_processed}")
print(f" - 当前数据点: {total_data_points}")
print(f" - 最大数据点: {self.max_data_points}")
print(f" - 内存使用率: {total_data_points/self.max_data_points*100:.1f}%")
# 如果数据点接近上限,提前警告
if total_data_points > self.max_data_points * 0.9:
print(f"⚠️ {self.channel_name}数据点接近上限,即将开始覆盖最旧数据")
except Exception as e:
print(f"❌ {self.channel_name}数据统计失败: {e}")
def get_data_statistics(self):
"""获取详细的数据统计信息"""
try:
stats = {
'channel_name': self.channel_name,
'max_data_points': self.max_data_points,
'total_frames_processed': self.total_frames_processed,
'is_running': self.is_running,
'targets_count': len(self.targets),
'areas_data': []
}
for i, heights in enumerate(self.all_heights):
area_stats = {
'area_index': i,
'data_points': len(heights),
'usage_percent': len(heights) / self.max_data_points * 100,
'latest_value': heights[-1] if heights else 0
}
stats['areas_data'].append(area_stats)
return stats
except Exception as e:
print(f"❌ 获取{self.channel_name}统计信息失败: {e}")
return {}
def get_safety_limits(self):
"""从配置文件读取安全上下限值"""
try:
import json
import os
import re
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
general = config.get('general', {})
safe_low_str = general.get('safe_low', '2.0cm')
safe_high_str = general.get('safe_high', '10.0cm')
# 提取数值部分(去掉"cm"单位)
safe_low_match = re.search(r'([\d.]+)', safe_low_str)
safe_high_match = re.search(r'([\d.]+)', safe_high_str)
safe_low = float(safe_low_match.group(1)) if safe_low_match else 2.0
safe_high = float(safe_high_match.group(1)) if safe_high_match else 10.0
return safe_low, safe_high
else:
return 2.0, 10.0
except Exception as e:
print(f"❌ 读取{self.channel_name}安全预警值失败: {e}")
return 2.0, 10.0
def get_alarm_records(self):
"""获取报警记录"""
return self.alarm_records.copy()
\ No newline at end of file
"""
液面检测引擎 - 专注于检测功能
"""
import cv2
import numpy as np
import threading
import time
import os
import json
from pathlib import Path
from core.model_loader import ModelLoader
from ultralytics import YOLO
from core.constants import (
SMOOTH_WINDOW,
FULL_THRESHOLD,
MAX_DATA_POINTS,
get_channel_model,
is_channel_model_selected
)
import csv
import os
from datetime import datetime
# 🔥 修改:从配置文件获取RTSP地址
def get_rtsp_url(channel_name):
"""根据通道名称获取RTSP地址 - 从配置文件读取"""
try:
config_path = os.path.join("resources", "rtsp_config.json")
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
channels = config.get("channels", {})
# 通道名称到channel key的映射
channel_mapping = {
"通道1": "channel1",
"通道2": "channel2",
"通道3": "channel3",
"通道4": "channel4",
"通道5": "channel5",
"通道6": "channel6"
}
# 获取对应的channel key
channel_key = channel_mapping.get(channel_name)
if channel_key and channel_key in channels:
rtsp_url = channels[channel_key].get("rtsp_url")
if rtsp_url:
return rtsp_url
# 如果配置文件中没有找到,使用硬编码的备用地址
fallback_mapping = {
"通道1": "rtsp://admin:@192.168.1.220:554/stream1",
"通道2": "rtsp://admin:@192.168.1.188:554/stream1",
"通道3": None, # 预留位置,后续接入
"通道4": None, # 预留位置,后续接入
"通道5": None, # 预留位置,后续接入
"通道6": None # 预留位置,后续接入
}
rtsp_url = fallback_mapping.get(channel_name, None)
if rtsp_url is None:
print(f"⚠️ {channel_name}暂未配置RTSP地址")
return None
return rtsp_url
except Exception as e:
print(f"❌ 读取RTSP配置失败: {e}")
print(f"⚠️ {channel_name}暂未配置RTSP地址")
return None
# 在类定义之前添加这些辅助函数(移出类外部)
def get_class_color(class_name):
"""为不同类别分配不同的颜色"""
color_map = {
'liquid': (0, 255, 0), # 绿色 - 液体
'foam': (255, 0, 0), # 蓝色 - 泡沫
'air': (0, 0, 255), # 红色 - 空气
}
return color_map.get(class_name, (128, 128, 128)) # 默认灰色
def overlay_multiple_masks_on_image(image, masks_info, alpha=0.5):
"""将多个不同类别的mask叠加到图像上"""
overlay = image.copy()
for mask, class_name, confidence in masks_info:
color = get_class_color(class_name)
mask_colored = np.zeros_like(image)
mask_colored[mask > 0] = color
# 叠加mask到原图
cv2.addWeighted(mask_colored, alpha, overlay, 1 - alpha, 0, overlay)
# 添加mask轮廓
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(overlay, contours, -1, color, 2)
# 在mask上添加类别标签和置信度
if len(contours) > 0:
largest_contour = max(contours, key=cv2.contourArea)
M = cv2.moments(largest_contour)
if M["m00"] != 0:
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
label = f"{class_name}: {confidence:.2f}"
font_scale = 0.8
thickness = 2
# 添加文字背景
(text_width, text_height), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
cv2.rectangle(overlay, (cx-15, cy-text_height-5), (cx+text_width+15, cy+5), (0, 0, 0), -1)
# 绘制文字
cv2.putText(overlay, label, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, thickness)
return overlay
def calculate_foam_boundary_lines(mask, class_name):
"""计算foam mask的顶部和底部边界线"""
if np.sum(mask) == 0:
return None, None
y_coords, x_coords = np.where(mask)
if len(y_coords) == 0:
return None, None
top_y = np.min(y_coords)
bottom_y = np.max(y_coords)
# 计算顶部边界线的平均位置
top_region_height = max(1, int((bottom_y - top_y) * 0.1))
top_region_mask = mask[top_y:top_y + top_region_height, :]
if np.sum(top_region_mask) > 0:
top_y_coords = np.where(top_region_mask)[0] + top_y
top_line_y = np.mean(top_y_coords)
else:
top_line_y = top_y
# 计算底部边界线的平均位置
bottom_region_height = max(1, int((bottom_y - top_y) * 0.1))
bottom_region_mask = mask[bottom_y - bottom_region_height:bottom_y + 1, :]
if np.sum(bottom_region_mask) > 0:
bottom_y_coords = np.where(bottom_region_mask)[0] + (bottom_y - bottom_region_height)
bottom_line_y = np.mean(bottom_y_coords)
else:
bottom_line_y = bottom_y
return top_line_y, bottom_line_y
def analyze_multiple_foams(foam_masks, cropped_shape):
"""分析多个foam,找到可能的液位边界"""
if len(foam_masks) < 2:
return None, None, 0, "需要至少2个foam才能分析边界"
foam_boundaries = []
# 计算每个foam的边界信息
for i, mask in enumerate(foam_masks):
top_y, bottom_y = calculate_foam_boundary_lines(mask, "foam")
if top_y is not None and bottom_y is not None:
center_y = (top_y + bottom_y) / 2
foam_boundaries.append({
'index': i,
'top_y': top_y,
'bottom_y': bottom_y,
'center_y': center_y
})
if len(foam_boundaries) < 2:
return None, None, 0, "有效foam边界少于2个"
# 按垂直位置排序
foam_boundaries.sort(key=lambda x: x['center_y'])
container_height = cropped_shape[0]
error_threshold_px = container_height * 0.1
analysis_info = f"\n 容器高度: {container_height}px, 10%误差阈值: {error_threshold_px:.1f}px"
# 检查相邻foam之间的边界
for i in range(len(foam_boundaries) - 1):
upper_foam = foam_boundaries[i]
lower_foam = foam_boundaries[i + 1]
upper_bottom = upper_foam['bottom_y']
lower_top = lower_foam['top_y']
boundary_distance = abs(upper_bottom - lower_top)
analysis_info += f"\n Foam{upper_foam['index']+1}底部(y={upper_bottom:.1f}) vs Foam{lower_foam['index']+1}顶部(y={lower_top:.1f})"
analysis_info += f"\n 边界距离: {boundary_distance:.1f}px"
if boundary_distance <= error_threshold_px:
liquid_level_y = (upper_bottom + lower_top) / 2
analysis_info += f"\n ✅ 距离 {boundary_distance:.1f}px ≤ {error_threshold_px:.1f}px,确定为液位边界: y={liquid_level_y:.1f}"
return liquid_level_y, 1.0, len(foam_boundaries), analysis_info
else:
analysis_info += f"\n ❌ 距离 {boundary_distance:.1f}px > {error_threshold_px:.1f}px,不是液位边界"
analysis_info += f"\n ❌ 未找到符合条件的液位边界"
return None, 0, len(foam_boundaries), analysis_info
def enhanced_liquid_detection_with_foam_analysis(all_masks_info, cropped, fixed_container_bottom,
container_pixel_height, container_height_cm,
target_idx, no_liquid_count, last_liquid_heights, frame_counters):
"""
🆕 增强的液位检测,结合连续帧逻辑和foam分析
@param target_idx 目标索引
@param no_liquid_count 每个目标连续未检测到liquid的帧数
@param last_liquid_heights 每个目标最后一次检测到的液位高度
@param frame_counters 每个目标的帧计数器(用于每4帧清零)
"""
liquid_height = None
detection_method = "无检测"
analysis_details = ""
# 🆕 计算像素到厘米的转换比例
pixel_per_cm = container_pixel_height / container_height_cm
# 分离不同类别的mask
liquid_masks = []
foam_masks = []
air_masks = []
for mask, class_name, confidence in all_masks_info:
if class_name == 'liquid':
liquid_masks.append(mask)
elif class_name == 'foam':
foam_masks.append(mask)
elif class_name == 'air':
air_masks.append(mask)
# 🎯 方法1:直接liquid检测 - 优先且主要方法
if liquid_masks:
best_liquid_mask = None
topmost_y = float('inf')
for mask in liquid_masks:
y_indices = np.where(mask)[0]
if len(y_indices) > 0:
mask_top_y = np.min(y_indices)
if mask_top_y < topmost_y:
topmost_y = mask_top_y
best_liquid_mask = mask
if best_liquid_mask is not None:
liquid_mask = best_liquid_mask
y_indices = np.where(liquid_mask)[0]
line_y = np.min(y_indices) # 液面顶部
liquid_height_px = fixed_container_bottom - line_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = "直接liquid检测(最上层)"
analysis_details = f"检测到{len(liquid_masks)}个liquid mask,选择最上层液体,液面位置: y={line_y}, 像素高度: {liquid_height_px}px"
# 🆕 重置所有计数器并记录最新液位
no_liquid_count[target_idx] = 0
frame_counters[target_idx] = 0
last_liquid_heights[target_idx] = liquid_height
print(f" ✅ Target{target_idx+1}: 检测到liquid(最上层),重置所有计数器,液位: {liquid_height:.3f}cm")
# 🆕 如果没有检测到liquid,处理计数器逻辑
else:
no_liquid_count[target_idx] += 1
frame_counters[target_idx] += 1
# print(f" 📊 Target{target_idx+1}: 连续{no_liquid_count[target_idx]}帧未检测到liquid,帧计数器: {frame_counters[target_idx]}")
# 🎯 连续3帧未检测到liquid时,启用备选方法
if no_liquid_count[target_idx] >= 3:
# print(f" 🚨 Target{target_idx+1}: 连续{no_liquid_count[target_idx]}帧未检测到liquid,启用备选检测方法")
# 方法2:多foam边界分析 - 第一备选方法
if len(foam_masks) >= 2:
foam_liquid_level, foam_confidence, foam_count, foam_analysis = analyze_multiple_foams(
foam_masks, cropped.shape
)
analysis_details += f"\n🔍 Foam边界分析 (检测到{foam_count}个foam):{foam_analysis}"
if foam_liquid_level is not None:
foam_liquid_height_px = fixed_container_bottom - foam_liquid_level
liquid_height = foam_liquid_height_px / pixel_per_cm
detection_method = "多foam边界分析(备选)"
analysis_details += f"\n✅ 使用foam边界分析结果: 像素高度 {foam_liquid_height_px}px = {liquid_height:.1f}cm"
print(f" 🌊 Target{target_idx+1}: 使用foam边界分析,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 方法2.5:单个foam下边界分析 - 第二备选方法
elif len(foam_masks) == 1:
foam_mask = foam_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(foam_mask, "foam")
if bottom_y is not None:
liquid_height_px = fixed_container_bottom - bottom_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = f"单foam底部检测(备选)"
analysis_details += f"\n🔍 单foam分析: 顶部y={top_y:.1f}, 底部y={bottom_y:.1f}, 像素高度: {liquid_height_px}px = {liquid_height:.3f}cm"
print(f" 🫧 Target{target_idx+1}: 使用单foam下边界检测,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 方法3:单个air的分析 - 第三备选方法
elif len(air_masks) == 1:
air_mask = air_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(air_mask, "air")
if bottom_y is not None:
liquid_height_px = fixed_container_bottom - bottom_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = f"单air底部检测(备选)"
analysis_details += f"\n🔍 单air分析: 顶部y={top_y:.1f}, 底部y={bottom_y:.1f}, 像素高度: {liquid_height_px}px = {liquid_height:.3f}cm"
print(f" 🌬️ Target{target_idx+1}: 使用单air检测,液位: {liquid_height:.3f}cm")
# 🆕 更新最后液位记录
last_liquid_heights[target_idx] = liquid_height
# 如果备选方法也没有结果,使用最后一次的检测结果
if liquid_height is None and last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = "使用最后液位(保持)"
analysis_details += f"\n🔒 备选方法无结果,保持最后一次检测结果: {liquid_height:.3f}cm"
print(f" 📌 Target{target_idx+1}: 保持最后液位: {liquid_height:.3f}cm")
else:
# 连续未检测到liquid但少于3帧,使用最后一次的检测结果
if last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = f"保持液位({no_liquid_count[target_idx]}/3)"
# 🔄 每3帧清零一次连续未检测计数器
if frame_counters[target_idx] % 3 == 0:
print(f" 🔄 Target{target_idx+1}: 达到3帧周期,清零连续未检测计数器 ({no_liquid_count[target_idx]} → 0)")
no_liquid_count[target_idx] = 0
return liquid_height, detection_method, analysis_details
class LiquidDetectionEngine:
"""液面检测引擎 - 专注于检测功能,不包含捕获逻辑"""
def __init__(self, channel_name="通道1"):
self.model = None
self.targets = []
self.fixed_container_bottoms = []
self.fixed_container_tops = []
self.actual_heights = []
self.kalman_filters = []
self.recent_observations = []
self.zero_count_list = []
self.full_count_list = []
self.is_running = False
# 🔥 移除:self.cap = None # 不再维护摄像头连接
self.SMOOTH_WINDOW = SMOOTH_WINDOW
self.FULL_THRESHOLD = FULL_THRESHOLD
# 数据收集相关
self.all_heights = []
self.frame_count = 0
self.max_data_points = MAX_DATA_POINTS
# 数据管理相关配置
self.data_cleanup_interval = 300
self.last_cleanup_time = 0
# 数据统计
self.total_frames_processed = 0
self.data_points_generated = 0
# CSV文件相关属性
self.channel_name = channel_name
curve_storage_path = self.get_curve_storage_path_from_config()
if curve_storage_path:
self.csv_save_dir = curve_storage_path
print(f"📁 使用配置文件中的曲线保存路径: {self.csv_save_dir}")
else:
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
self.csv_save_dir = os.path.join(project_root, 'resources', 'curve_data')
print(f"⚠️ 未找到curve_storage_path配置,使用默认路径: {self.csv_save_dir}")
self.task_csv_dir = None
self.csv_files = {}
self.last_save_time = {}
# 报警系统相关属性
self.alarm_records = []
self.last_alarm_times = {}
self.alarm_cooldown = 30
self.alarm_frame_enabled = True
self.alarm_frame_callback = None
self.current_frame = None
# 帧缓存机制
self.frame_buffer = []
self.frame_buffer_size = 20
self.pending_alarm_saves = []
self.alarm_video_sessions = []
self.alarm_merge_timeout = 10.0
self.video_fps = 10
# 🔥 移除:self.rtsp_url = get_rtsp_url(channel_name) # 不再管理RTSP连接
# 确保基础目录存在
try:
os.makedirs(self.csv_save_dir, exist_ok=True)
print(f"📁 数据将保存到: {self.csv_save_dir}")
except Exception as e:
print(f"❌ 创建基础保存目录失败: {e}")
def save_alarm_frames_sequence(self, alarm_record):
"""保存报警帧序列并生成AVI视频:前2帧+当前帧+后2帧"""
try:
import time
# 获取当前帧索引(帧缓存中的最后一帧)
current_frame_idx = len(self.frame_buffer) - 1
alarm_timestamp = alarm_record['alarm_timestamp']
# 🔥 收集帧序列
frames_to_save = []
# 前2帧
for i in range(max(0, current_frame_idx - 2), current_frame_idx):
if i < len(self.frame_buffer):
frames_to_save.append({
'frame': self.frame_buffer[i]['frame'],
'frame_type': f'前{current_frame_idx - i}帧',
'timestamp': self.frame_buffer[i]['timestamp']
})
# 当前帧(报警帧)
if current_frame_idx >= 0 and current_frame_idx < len(self.frame_buffer):
frames_to_save.append({
'frame': self.frame_buffer[current_frame_idx]['frame'],
'frame_type': '报警帧',
'timestamp': self.frame_buffer[current_frame_idx]['timestamp']
})
# 🔥 创建报警视频会话
alarm_session = {
'alarm_record': alarm_record,
'start_timestamp': alarm_timestamp,
'frames': frames_to_save.copy(),
'completed': False,
'pending_frames': 2, # 还需要等待的后续帧数
'channel_name': self.channel_name # 🔥 确保包含channel_name
}
# 添加到待处理队列(用于后续帧)
self.pending_alarm_saves.append(alarm_session)
print(f"📹 {self.channel_name} 开始收集报警帧序列,已收集{len(frames_to_save)}帧,等待后续2帧...")
except Exception as e:
print(f"❌ 保存报警帧序列失败: {e}")
def cleanup_timeout_sessions(self):
"""清理超时的视频会话"""
try:
import time
current_time = time.time()
completed_sessions = []
for i, session in enumerate(self.alarm_video_sessions):
if not session['completed']:
time_diff = current_time - session['start_timestamp']
if time_diff > self.alarm_merge_timeout * 2: # 超时清理
session['completed'] = True
completed_sessions.append(i)
print(f"⏰ {self.channel_name} 报警视频会话超时,强制完成")
self.generate_alarm_video(session)
# 移除已完成的会话
for idx in reversed(completed_sessions):
self.alarm_video_sessions.pop(idx)
except Exception as e:
print(f"❌ 清理报警视频会话失败: {e}")
def generate_alarm_video(self, session):
"""生成报警视频"""
try:
if not session['frames'] or not self.alarm_frame_callback:
return
# 🔥 调用视频生成回调
video_data = {
'alarm_record': session['alarm_record'],
'frames': session['frames'],
'channel_name': self.channel_name
}
# 调用视频面板的视频生成方法
if hasattr(self.alarm_frame_callback, '__self__'):
video_panel = self.alarm_frame_callback.__self__
if hasattr(video_panel, 'generate_alarm_video'):
video_panel.generate_alarm_video(video_data)
print(f"🎬 {self.channel_name} 报警视频生成完成,包含{len(session['frames'])}帧")
except Exception as e:
print(f"❌ 生成报警视频失败: {e}")
def set_current_frame(self, frame):
"""设置当前处理的帧并更新帧缓存"""
self.current_frame = frame
# 🔥 新增:维护帧缓存
if frame is not None:
# 添加时间戳
import time
frame_with_timestamp = {
'frame': frame.copy(),
'timestamp': time.time()
}
# 添加到缓存队列
self.frame_buffer.append(frame_with_timestamp)
# 限制缓存大小
if len(self.frame_buffer) > self.frame_buffer_size:
self.frame_buffer.pop(0)
# 🔥 处理待保存的报警记录(等待后续帧)
self.process_pending_alarm_saves()
def set_alarm_frame_callback(self, callback):
"""设置报警帧回调函数"""
self.alarm_frame_callback = callback
def check_alarm_conditions(self, area_idx, height_cm):
"""检查报警条件并生成报警记录"""
try:
import time
from datetime import datetime
# 获取安全上下限
safe_low, safe_high = self.get_safety_limits()
# 获取当前时间
current_time = datetime.now()
current_timestamp = time.time()
# 检查是否在冷却期内
area_key = f"{self.channel_name}_area_{area_idx}"
if area_key in self.last_alarm_times:
time_since_last_alarm = current_timestamp - self.last_alarm_times[area_key]
if time_since_last_alarm < self.alarm_cooldown:
return # 在冷却期内,不生成新的报警
# 检查报警条件
alarm_type = None
if height_cm < safe_low:
alarm_type = "低于下限"
elif height_cm > safe_high:
alarm_type = "高于上限"
if alarm_type:
# 生成报警记录
area_name = self.get_area_name(area_idx)
alarm_record = {
'time': current_time.strftime("%Y-%m-%d %H:%M:%S"),
'channel': self.channel_name,
'area_idx': area_idx,
'area_name': area_name,
'alarm_type': alarm_type,
'height': height_cm,
'safe_low': safe_low,
'safe_high': safe_high,
'message': f"{current_time.strftime('%H:%M:%S')} {area_name} {alarm_type}",
'alarm_timestamp': current_timestamp # 🔥 新增:报警时间戳
}
# 添加到报警记录
self.alarm_records.append(alarm_record)
# 更新最后报警时间
self.last_alarm_times[area_key] = current_timestamp
# 打印报警信息
print(f"🚨 {self.channel_name} 报警: {alarm_record['message']} - 当前值: {height_cm:.1f}cm")
# 🔥 新增:触发报警帧保存和视频生成
if self.alarm_frame_callback and self.alarm_frame_enabled:
print(f"🎬 {self.channel_name} 触发报警帧保存,回调函数: {self.alarm_frame_callback}")
print(f"🎬 当前帧是否存在: {self.current_frame is not None}")
self.save_alarm_frames_sequence(alarm_record)
else:
print(f"⚠️ {self.channel_name} 报警帧保存未启用或回调为空")
print(f" - alarm_frame_enabled: {self.alarm_frame_enabled}")
print(f" - alarm_frame_callback: {self.alarm_frame_callback}")
except Exception as e:
print(f"❌ 检查报警条件失败: {e}")
def process_pending_alarm_saves(self):
"""处理待保存的报警记录(添加后续帧并生成AVI视频)"""
try:
if not self.pending_alarm_saves or len(self.frame_buffer) == 0:
return
current_frame = self.frame_buffer[-1]
completed_sessions = []
for i, session in enumerate(self.pending_alarm_saves):
if session['pending_frames'] > 0:
# 添加后续帧
frame_type = f"后{3 - session['pending_frames']}帧"
session['frames'].append({
'frame': current_frame['frame'].copy(),
'frame_type': frame_type,
'timestamp': current_frame['timestamp']
})
session['pending_frames'] -= 1
# 检查是否完成
if session['pending_frames'] <= 0:
session['completed'] = True
completed_sessions.append(i)
# 🔥 生成AVI报警视频
self.generate_alarm_avi_video(session)
# 移除已完成的会话
for idx in reversed(completed_sessions):
self.pending_alarm_saves.pop(idx)
except Exception as e:
print(f"❌ 处理报警视频会话失败: {e}")
# 第674行应该改为:
def generate_alarm_avi_video(self, session):
"""生成AVI格式的报警视频"""
try:
if not session['frames'] or not self.alarm_frame_callback:
return
# 🔥 调用视频生成回调(传递给VideoPanel处理)
video_data = {
'alarm_record': session['alarm_record'],
'frames': session['frames'],
'channel_name': session['channel_name'], # 确保包含channel_name
'format': 'avi' # 指定AVI格式
}
# 修改第663-669行
# 调用视频面板的AVI视频生成方法
try:
# 🔥 直接调用回调函数,让它处理视频生成
self.alarm_frame_callback(video_data, 'generate_video')
print(f"✅ {self.channel_name} 报警AVI视频回调已调用")
except Exception as callback_error:
print(f"❌ {self.channel_name} 视频生成回调失败: {callback_error}")
except Exception as e:
print(f"❌ 生成报警AVI视频失败: {e}")
def get_safety_limits(self):
"""从配置文件读取安全上下限值"""
try:
import json
import os
import re
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
general = config.get('general', {})
safe_low_str = general.get('safe_low', '2.0cm')
safe_high_str = general.get('safe_high', '10.0cm')
# 提取数值部分(去掉"cm"单位)
safe_low_match = re.search(r'([\d.]+)', safe_low_str)
safe_high_match = re.search(r'([\d.]+)', safe_high_str)
safe_low = float(safe_low_match.group(1)) if safe_low_match else 2.0
safe_high = float(safe_high_match.group(1)) if safe_high_match else 10.0
return safe_low, safe_high
else:
return 2.0, 10.0
except Exception as e:
print(f"❌ 读取{self.channel_name}安全预警值失败: {e}")
return 2.0, 10.0
def get_alarm_records(self):
"""获取报警记录"""
return self.alarm_records.copy()
def load_model(self, model_path):
"""加载YOLO模型"""
try:
print(f"🔄 {self.channel_name}正在加载模型: {model_path}")
# 检查文件是否存在
import os
if not os.path.exists(model_path):
print(f"❌ {self.channel_name}模型文件不存在: {model_path}")
return False
# 检查文件大小
file_size = os.path.getsize(model_path)
if file_size == 0:
print(f"❌ {self.channel_name}模型文件为空: {model_path}")
return False
print(f"📁 {self.channel_name}模型文件大小: {file_size / (1024*1024):.1f}MB")
# 尝试加载模型
self.model = YOLO(model_path)
print(f"✅ {self.channel_name}模型加载成功: {os.path.basename(model_path)}")
return True
except Exception as e:
print(f"❌ {self.channel_name}模型加载失败: {e}")
import traceback
traceback.print_exc()
self.model = None
return False
def setup_detection(self, targets, fixed_container_bottoms, fixed_container_tops=None, actual_heights=None, camera_index=0):
"""设置检测参数 - 不再初始化摄像头,兼容旧接口"""
try:
# 获取当前通道的模型路径
if not is_channel_model_selected(self.channel_name):
raise ValueError(f"{self.channel_name}未选择模型")
model_path = get_channel_model(self.channel_name)
if not model_path:
raise ValueError(f"{self.channel_name}模型路径无效")
print(f"📂 {self.channel_name}使用模型: {model_path}")
# 如果是dat文件,先解码
if model_path.endswith('.dat'):
model_loader = ModelLoader()
original_data = model_loader._decode_dat_file(model_path)
temp_dir = Path("temp_models")
temp_dir.mkdir(exist_ok=True)
temp_model_path = temp_dir / f"temp_{Path(model_path).stem}.pt"
with open(temp_model_path, 'wb') as f:
f.write(original_data)
model_path = str(temp_model_path)
print(f"✅ {self.channel_name}模型已解码: {model_path}")
# 加载模型
if not self.load_model(model_path):
raise ValueError(f"{self.channel_name}模型加载失败")
# 设置参数
self.targets = targets
self.fixed_container_bottoms = fixed_container_bottoms
self.fixed_container_tops = fixed_container_tops if fixed_container_tops else []
self.actual_heights = actual_heights if actual_heights else []
print(f"🔧 检测参数设置完成:")
print(f" - 目标数量: {len(targets)}")
print(f" - 底部数据: {len(fixed_container_bottoms)}")
print(f" - 顶部数据: {len(self.fixed_container_tops)}")
print(f" - 实际高度: {self.actual_heights}")
print(f" - 相机索引: {camera_index} (新架构中忽略)")
# 初始化其他列表
self.recent_observations = [[] for _ in range(len(targets))]
self.zero_count_list = [0] * len(targets)
self.full_count_list = [0] * len(targets)
# 使用默认值初始化卡尔曼滤波器
self.init_kalman_filters_with_defaults()
print(f"✅ {self.channel_name}检测引擎设置成功")
return True
except Exception as e:
print(f"❌ {self.channel_name}检测引擎设置失败: {e}")
import traceback
traceback.print_exc()
return False
def init_kalman_filters_with_defaults(self):
"""使用默认值初始化卡尔曼滤波器 - 不需要摄像头"""
print("⏳ 使用默认值初始化卡尔曼滤波器...")
# 使用默认的初始高度(比如容器高度的50%)
init_means = []
for idx in range(len(self.targets)):
if idx < len(self.actual_heights):
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
# 默认假设初始液位为容器高度的50%
default_height = actual_height_cm * 0.5
init_means.append(default_height)
else:
init_means.append(5.0) # 默认5cm
self.kalman_filters = self.init_kalman_filters_list(len(self.targets), init_means)
print(f"✅ 卡尔曼滤波器初始化完成,默认起始高度:{init_means}")
def init_kalman_filters(self):
"""初始化卡尔曼滤波器"""
print("⏳ 正在初始化卡尔曼滤波器...")
initial_heights = [[] for _ in self.targets]
frame_count = 0
init_frame_count = 25 # 初始化帧数
while frame_count < init_frame_count:
ret, frame = self.cap.read()
if not ret:
print(f"⚠️ 读取RTSP帧失败,帧数: {frame_count}")
time.sleep(0.1) # 等待一下再重试
continue
for idx, (cx, cy, size) in enumerate(self.targets):
crop = frame[max(0, cy - size // 2):min(cy + size // 2, frame.shape[0]),
max(0, cx - size // 2):min(cx + size // 2, frame.shape[1])]
if self.model is None:
continue
results = self.model.predict(source=crop, imgsz=640, save=False, conf=0.5, iou=0.5)
result = results[0]
if result.masks is not None:
masks = result.masks.data.cpu().numpy() > 0.5
classes = result.boxes.cls.cpu().numpy().astype(int)
for i, mask in enumerate(masks):
if self.model.names[classes[i]] != 'liquid':
continue
resized_mask = cv2.resize(mask.astype(np.uint8), (crop.shape[1], crop.shape[0])) > 0.5
y_indices = np.where(resized_mask)[0]
if len(y_indices) > 0:
# 🔥 修复:使用与正常运行时相同的计算公式
liquid_top_y = np.min(y_indices) # 液体顶部
# 🔥 使用您的公式计算初始高度
if idx < len(self.fixed_container_bottoms) and idx < len(self.fixed_container_tops) and idx < len(self.actual_heights):
container_bottom_y = self.fixed_container_bottoms[idx]
container_top_y = self.fixed_container_tops[idx]
# 边界检查
if liquid_top_y > container_bottom_y:
liquid_cm = 0.0
elif liquid_top_y < container_top_y:
liquid_top_y = container_top_y
liquid_cm = float(self.actual_heights[idx].replace('cm', ''))
else:
# 🔥 使用相同的公式:液体高度(cm) = (容器底部像素 - 液位顶部像素) / (容器底部像素 - 容器顶部像素) × 实际容器高度(cm)
numerator = container_bottom_y - liquid_top_y
denominator = container_bottom_y - container_top_y
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
if denominator != 0:
liquid_cm = (numerator / denominator) * actual_height_cm
if liquid_cm > actual_height_cm:
liquid_cm = actual_height_cm
liquid_cm = max(0, round(liquid_cm, 1))
else:
liquid_cm = 0.0
else:
liquid_cm = 0.0
initial_heights[idx].append(liquid_cm)
frame_count += 1
# 计算初始高度
init_means = [self.stable_median(hs) for hs in initial_heights]
self.kalman_filters = self.init_kalman_filters_list(len(self.targets), init_means)
print(f"✅ 初始化完成,起始高度:{init_means}")
# RTSP流不需要重置位置
print("✅ RTSP摄像头初始化完成")
def stable_median(self, data, max_std=1.0):
"""稳健地计算中位数"""
if len(data) == 0:
return 0
data = np.array(data)
q1, q3 = np.percentile(data, [25, 75])
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
data = data[(data >= lower) & (data <= upper)]
if len(data) >= 2 and np.std(data) > max_std:
median_val = np.median(data)
data = data[np.abs(data - median_val) <= max_std]
return float(np.median(data)) if len(data) > 0 else 0
def init_kalman_filters_list(self, num_targets, init_means):
"""初始化卡尔曼滤波器列表"""
kalman_list = []
for i in range(num_targets):
kf = cv2.KalmanFilter(2, 1)
kf.measurementMatrix = np.array([[1, 0]], np.float32)
kf.transitionMatrix = np.array([[1, 0.2], [0, 0.3]], np.float32)
kf.processNoiseCov = np.diag([1e-2, 1e-2]).astype(np.float32)
kf.measurementNoiseCov = np.array([[1]], dtype=np.float32)
kf.statePost = np.array([[init_means[i]], [0]], dtype=np.float32)
kalman_list.append(kf)
return kalman_list
def get_height_data(self):
"""获取液面高度历史数据"""
if not self.all_heights:
return []
# 返回每个目标的高度数据
result = []
for i in range(len(self.targets)):
if i < len(self.all_heights):
result.append(self.all_heights[i].copy())
else:
result.append([])
return result
def ensure_save_directory(self):
"""确保保存目录存在 - 创建任务级别的子目录"""
try:
# 创建基础目录
os.makedirs(self.csv_save_dir, exist_ok=True)
# 🔥 新增:创建任务级别的子目录
task_folder = self.get_task_folder_name()
self.task_csv_dir = os.path.join(self.csv_save_dir, task_folder)
os.makedirs(self.task_csv_dir, exist_ok=True)
print(f"✅ 创建任务数据保存目录: {self.task_csv_dir}")
# 创建readme文件说明数据格式
readme_path = os.path.join(self.csv_save_dir, 'README.txt')
if not os.path.exists(readme_path):
with open(readme_path, 'w', encoding='utf-8') as f:
f.write("曲线数据说明:\n")
f.write("1. 目录命名格式:任务ID_任务名称_curve_YYYYMMDD_HHMMSS\n")
f.write("2. 文件命名格式:通道名_区域X_区域名称_YYYYMMDD.csv\n")
f.write("3. 数据格式:\n")
f.write(" - 时间:YYYY-MM-DD-HH:MM:SS.mmm\n")
f.write(" - 液位高度:以厘米(cm)为单位,保留一位小数\n")
f.write(" - 分隔符:空格\n")
except Exception as e:
print(f"❌ 创建保存目录失败: {e}")
# 新增方法:获取任务文件夹名称
def get_task_folder_name(self):
"""生成任务文件夹名称 - 格式:任务ID_任务名称_curve_日期_时间"""
try:
# 获取任务信息
task_id, task_name = self.get_task_info()
# 生成时间戳
current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
# 清理任务名称
clean_task_name = self.clean_filename(task_name)
# 生成文件夹名称
folder_name = f"{task_id}_{clean_task_name}_curve_{current_time}"
return folder_name
except Exception as e:
print(f"❌ 生成任务文件夹名失败: {e}")
current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"UNKNOWN_未命名任务_curve_{current_time}"
# 新增方法:获取任务信息
def get_task_info(self):
"""从配置文件获取任务ID和任务名称"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
print(f"🔍 查找配置文件: {config_file}")
if os.path.exists(config_file):
print(f"✅ 找到配置文件: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
config_data = json.load(f)
print(f"📖 配置文件内容: {config_data.keys()}")
# 从general中读取任务信息
if 'general' in config_data:
general = config_data['general']
task_id = general.get('task_id', 'UNKNOWN')
task_name = general.get('task_name', '未命名任务')
print(f"✅ 读取到任务信息: task_id={task_id}, task_name={task_name}")
return task_id, task_name
else:
print(f"⚠️ 配置文件中没有general字段")
else:
print(f"❌ 配置文件不存在: {config_file}")
# 如果没有找到配置,返回默认值
print(f"⚠️ 使用默认任务信息")
return 'UNKNOWN', '未命名任务'
except Exception as e:
print(f"❌ 获取任务信息失败: {e}")
import traceback
traceback.print_exc()
return 'UNKNOWN', '未命名任务'
# 修改 get_csv_filename 方法 - 使用任务子目录
def get_csv_filename(self, area_idx):
"""生成CSV文件名 - 保存到任务子目录"""
try:
current_date = datetime.now().strftime("%Y%m%d")
# 获取区域名称
area_name = self.get_area_name(area_idx)
# 生成文件名:通道2_区域1_区域名称_20250806.csv
filename = f"{self.channel_name}_区域{area_idx+1}_{area_name}_{current_date}.csv"
# 🔥 修改:使用任务子目录
if hasattr(self, 'task_csv_dir'):
return os.path.join(self.task_csv_dir, filename)
else:
return os.path.join(self.csv_save_dir, filename)
except Exception as e:
print(f"❌ 生成CSV文件名失败: {e}")
current_date = datetime.now().strftime("%Y%m%d")
return os.path.join(self.csv_save_dir,
f"{self.channel_name}_区域{area_idx+1}_{current_date}.csv")
def get_curve_storage_path_from_config(self):
"""从通道配置文件读取curve_storage_path"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
print(f"🔍 读取{self.channel_name}的curve_storage_path: {config_file}")
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# 检查general配置中的curve_storage_path
if 'general' in config and 'curve_storage_path' in config['general']:
curve_path = config['general']['curve_storage_path']
if curve_path and curve_path.strip():
print(f"✅ 找到curve_storage_path: {curve_path}")
return curve_path.strip()
print(f"⚠️ {self.channel_name}配置文件中未找到curve_storage_path字段")
else:
print(f"⚠️ {self.channel_name}配置文件不存在: {config_file}")
return None
except Exception as e:
print(f"❌ 读取{self.channel_name}的curve_storage_path失败: {e}")
return None
# 新增方法:获取区域名称
# 新增方法:获取区域名称
def get_area_name(self, area_idx):
"""获取区域名称"""
try:
import json
import os
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config_data = json.load(f)
# 从general.areas中读取区域名称
if 'general' in config_data and 'areas' in config_data['general']:
areas = config_data['general']['areas']
area_key = f'area_{area_idx + 1}'
if area_key in areas:
area_name = areas[area_key]
# 清理区域名称中的特殊字符
area_name = self.clean_filename(area_name)
return area_name
# 如果没有找到配置,返回默认名称
return f"检测区域{area_idx+1}"
except Exception as e:
print(f"❌ 获取区域名称失败: {e}")
return f"检测区域{area_idx+1}"
# 新增方法:清理文件名
def clean_filename(self, name):
"""清理文件名中的特殊字符"""
try:
import re
# 移除或替换不允许的字符
forbidden_chars = r'[\\/:*?"<>|]'
clean_name = re.sub(forbidden_chars, '_', name)
# 移除首尾空格
clean_name = clean_name.strip()
# 如果名称为空,使用默认名称
if not clean_name:
clean_name = '未命名区域'
return clean_name
except Exception as e:
print(f"❌ 清理文件名失败: {e}")
return '未命名区域'
# 修改 init_csv_file 方法,不使用csv.writer
def init_csv_file(self, area_idx):
"""初始化CSV文件"""
try:
filename = self.get_csv_filename(area_idx)
file_exists = os.path.exists(filename)
# 打开文件,使用追加模式,不使用csv模块
f = open(filename, 'a', encoding='utf-8')
# 不写入表头,直接创建文件
if not file_exists:
print(f"✅ 创建新的CSV文件: {filename}")
return f, None # 不返回writer
except Exception as e:
print(f"❌ 初始化CSV文件失败: {e}")
return None, None
# 修改 save_data_to_csv 方法,在第一次保存时创建任务目录
def save_data_to_csv(self, area_idx, height_cm):
"""保存数据到CSV文件"""
try:
# 🔥 新增:如果任务目录还没创建,先创建
if self.task_csv_dir is None:
self.create_task_directory()
# 获取当前时间
current_time = datetime.now()
# 检查是否需要创建新的日期文件
if (area_idx not in self.csv_files or
current_time.date() != datetime.fromtimestamp(self.last_save_time.get(area_idx, 0)).date()):
# 关闭旧文件(如果存在)
if area_idx in self.csv_files:
self.csv_files[area_idx][0].close()
# 创建新文件
f, _ = self.init_csv_file(area_idx)
if f:
self.csv_files[area_idx] = (f, None)
# 获取文件对象并写入数据
if area_idx in self.csv_files:
f, _ = self.csv_files[area_idx]
# 写入数据格式:日期-时间(毫秒) 液位高度(三位小数),用空格分隔
time_str = current_time.strftime("%Y-%m-%d-%H:%M:%S.%f")[:-3] # 保留毫秒
height_str = f"{height_cm:.3f}" # 保留三位小数
# 直接写入文件,用空格分隔
f.write(f"{time_str} {height_str}\n")
# 立即写入磁盘
f.flush()
# 更新最后保存时间
self.last_save_time[area_idx] = current_time.timestamp()
except Exception as e:
print(f"❌ 保存数据到CSV失败: {e}")
import traceback
traceback.print_exc()
# 新增方法:创建任务目录
def create_task_directory(self):
"""创建任务目录"""
try:
task_folder = self.get_task_folder_name()
self.task_csv_dir = os.path.join(self.csv_save_dir, task_folder)
os.makedirs(self.task_csv_dir, exist_ok=True)
print(f"✅ 创建任务数据保存目录: {self.task_csv_dir}")
except Exception as e:
print(f"❌ 创建任务目录失败: {e}")
# 回退到基础目录
self.task_csv_dir = self.csv_save_dir
def process_frame(self, frame, threshold=10, error_percentage=30):
"""处理单帧图像 - 这是检测引擎的核心功能"""
if self.model is None or not self.targets:
return frame
# 🔥 新增:设置当前帧(用于报警系统)
self.set_current_frame(frame)
h, w = frame.shape[:2]
processed_img = frame.copy()
# 初始化高度数据结构
if not self.all_heights:
self.all_heights = [[] for _ in self.targets]
# 初始化连续检测失败计数器
if not hasattr(self, 'detection_fail_counts'):
self.detection_fail_counts = [0] * len(self.targets)
if not hasattr(self, 'full_count_list'):
self.full_count_list = [0] * len(self.targets)
if not hasattr(self, 'consecutive_rejects'):
self.consecutive_rejects = [0] * len(self.targets)
# 🆕 新增:连续帧检测相关变量
if not hasattr(self, 'no_liquid_count'):
self.no_liquid_count = [0] * len(self.targets)
if not hasattr(self, 'last_liquid_heights'):
self.last_liquid_heights = [None] * len(self.targets)
if not hasattr(self, 'frame_counters'):
self.frame_counters = [0] * len(self.targets)
for idx, (center_x, center_y, crop_size) in enumerate(self.targets):
half_size = crop_size // 2
top = max(center_y - half_size, 0)
bottom = min(center_y + half_size, h)
left = max(center_x - half_size, 0)
right = min(center_x + half_size, w)
cropped = processed_img[top:bottom, left:right]
if cropped.size == 0:
continue
liquid_height = None
all_masks_info = []
try:
results = self.model.predict(source=cropped, imgsz=640, conf=0.5, iou=0.5, save=False, verbose=False)
result = results[0]
# print(f"\n=== Target {idx+1} 分割结果分析 ===")
# print(f"裁剪区域大小: {cropped.shape[1]}x{cropped.shape[0]} = {cropped.shape[1] * cropped.shape[0]} 像素")
# 获取容器信息
container_top_offset = self.fixed_container_tops[idx] if idx < len(self.fixed_container_tops) else 0
container_bottom_offset = self.fixed_container_bottoms[idx] if idx < len(self.fixed_container_bottoms) else 100
container_pixel_height = container_bottom_offset - container_top_offset
container_height_cm = float(self.actual_heights[idx].replace('cm', '')) if idx < len(self.actual_heights) else 15.0
# 满液阈值 = 容器实际高度的90%
full_threshold_cm = container_height_cm * 0.9
if result.masks is not None:
masks = result.masks.data.cpu().numpy() > 0.5
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
names = self.model.names
# 按置信度排序
sorted_indices = np.argsort(confidences)[::-1]
for i in sorted_indices:
class_name = names[classes[i]]
confidence = confidences[i]
# 只处理置信度大于0.5的检测
if confidence < 0.5:
continue
resized_mask = cv2.resize(masks[i].astype(np.uint8), (cropped.shape[1], cropped.shape[0])) > 0.5
# 存储mask信息
all_masks_info.append((resized_mask, class_name, confidence))
# # 检测统计
# detection_count = len(all_masks_info)
# if detection_count > 0:
# class_counts = {}
# for _, class_name, _ in all_masks_info:
# class_counts[class_name] = class_counts.get(class_name, 0) + 1
# print(f" 📊 检测汇总: 共{detection_count}个有效检测")
# for class_name, count in class_counts.items():
# print(f" {class_name}: {count}个")
# 🆕 使用增强的液位检测方法(包含新的连续帧逻辑)
liquid_height, detection_method, analysis_details = enhanced_liquid_detection_with_foam_analysis(
all_masks_info, cropped, container_bottom_offset,
container_pixel_height, container_height_cm,
idx, self.no_liquid_count, self.last_liquid_heights, self.frame_counters
)
if analysis_details:
print(f" 分析详情: {analysis_details}")
else:
print(f" ❌ 未检测到任何分割结果")
except Exception as e:
print(f"❌ 检测过程出错: {e}")
liquid_height = None
# 卡尔曼滤波预测步骤
predicted = self.kalman_filters[idx].predict()
predicted_height = predicted[0][0]
# 获取当前观测值
current_observation = liquid_height if liquid_height is not None else 0
# 误差计算逻辑
prediction_error_percent = abs(current_observation - predicted_height) / container_height_cm * 100
# print(f"\n🎯 Target {idx+1} 卡尔曼滤波决策:")
# print(f" 预测值: {predicted_height:.3f}cm")
# print(f" 观测值: {current_observation:.3f}cm")
# print(f" 预测误差: {prediction_error_percent:.1f}%")
# print(f" 连续拒绝次数: {self.consecutive_rejects[idx]}")
# 误差控制逻辑
if prediction_error_percent > error_percentage:
# 误差过大,增加拒绝计数
self.consecutive_rejects[idx] += 1
# 检查是否连续6次拒绝
if self.consecutive_rejects[idx] >= 3:
# 连续6次误差过大,强制使用观测值更新
self.kalman_filters[idx].statePost = np.array([[current_observation], [0]], dtype=np.float32)
self.kalman_filters[idx].statePre = np.array([[current_observation], [0]], dtype=np.float32)
final_height = current_observation
self.consecutive_rejects[idx] = 0 # 重置计数器
# print(f" 🔄 连续3次误差过大,强制使用观测值更新: {observation:.3f}cm → 滤波后: {final_height:.3f}cm")
else:
# 使用预测值
final_height = predicted_height
# print(f" ❌ 误差 {prediction_error_percent:.1f}% > {error_percentage}%,使用预测值: {predicted_height:.3f}cm (连续拒绝: {self.consecutive_rejects[idx]}/6)")
else:
# 误差可接受,正常更新
self.kalman_filters[idx].correct(np.array([[current_observation]], dtype=np.float32))
final_height = self.kalman_filters[idx].statePost[0][0]
self.consecutive_rejects[idx] = 0 # 重置计数器
# print(f" ✅ 误差 {prediction_error_percent:.1f}% <= {error_percentage}%,使用观测值: {current_observation:.3f}cm → 滤波后: {final_height:.3f}cm")
# 更新满液状态判断
if final_height == 0:
self.detection_fail_counts[idx] += 1
self.full_count_list[idx] = 0
elif final_height >= full_threshold_cm:
self.full_count_list[idx] += 1
self.detection_fail_counts[idx] = 0
# print(f" 🌊 液位 {final_height:.3f}cm >= {full_threshold_cm:.3f}cm,判定为满液状态")
else:
self.detection_fail_counts[idx] = 0
self.full_count_list[idx] = 0
# 添加到滑动窗口
if idx >= len(self.recent_observations):
self.recent_observations.extend([[] for _ in range(idx + 1 - len(self.recent_observations))])
self.recent_observations[idx].append(final_height)
if len(self.recent_observations[idx]) > self.SMOOTH_WINDOW:
self.recent_observations[idx].pop(0)
# 使用最终确定的高度
height_cm = np.clip(final_height, 0, container_height_cm)
# 收集高度数据
if len(self.all_heights) > idx:
self.all_heights[idx].append(height_cm)
if len(self.all_heights[idx]) > self.max_data_points:
self.all_heights[idx].pop(0)
# 保存数据到CSV和检查报警条件
self.save_data_to_csv(idx, height_cm)
self.check_alarm_conditions(idx, height_cm)
# 绘制检测框
cv2.rectangle(processed_img, (left, top), (right, bottom), (0, 255, 0), 2)
# 计算并绘制液位线
pixel_per_cm = container_pixel_height / container_height_cm
height_px = int(height_cm * pixel_per_cm)
# 计算液位线在裁剪区域内的位置
container_bottom_in_crop = container_bottom_offset
liquid_line_y_in_crop = container_bottom_in_crop - height_px
liquid_line_y_absolute = top + liquid_line_y_in_crop
# print(f" 📏 绘制信息:")
# print(f" 液位高度: {height_cm:.3f}cm = {height_px}px")
# print(f" 容器底部(裁剪内): {container_bottom_in_crop}px")
# print(f" 液位线(裁剪内): {liquid_line_y_in_crop}px")
# print(f" 液位线(原图): {liquid_line_y_absolute}px")
# 检查液位线是否在有效范围内
if 0 <= liquid_line_y_in_crop < cropped.shape[0]:
# 绘制液位线
cv2.line(processed_img, (left, liquid_line_y_absolute), (right, liquid_line_y_absolute), (0, 0, 255), 3)
# print(f" ✅ 液位线绘制成功")
else:
print(f" ❌ 液位线超出裁剪区域范围: {liquid_line_y_in_crop} (应在0-{cropped.shape[0]})")
# 显示带单位的实际高度
text = f"{height_cm:.3f}cm"
cv2.putText(processed_img, text,
(left + 5, top - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.8, (0, 255, 0), 2)
self.frame_count += 1
# 定期清理和统计
import time
current_time = time.time()
if current_time - self.last_cleanup_time > self.data_cleanup_interval:
self.cleanup_data_statistics()
self.last_cleanup_time = current_time
self.total_frames_processed += 1
return processed_img
def start_detection(self):
"""开始检测 - 不再管理摄像头"""
try:
# 🔥 移除:摄像头相关的初始化逻辑
self.is_running = True
print(f"✅ {self.channel_name}检测引擎已启动(纯检测模式)")
except Exception as e:
print(f"❌ {self.channel_name}检测启动失败: {e}")
self.is_running = False
def stop_detection(self):
"""停止检测 - 不再管理摄像头"""
self.is_running = False
# 🔥 移除:不再释放摄像头连接
print(f"✅ {self.channel_name}检测引擎已停止")
def notify_information_panel_update(self):
"""通知信息面板更新模型显示"""
try:
import wx
# 获取主窗口
app = wx.GetApp()
if not app:
return
main_frame = app.GetTopWindow()
if not main_frame:
return
# 查找信息面板
information_panel = None
# 方法1:通过function_panel查找
if hasattr(main_frame, 'function_panel'):
function_panel = main_frame.function_panel
# 检查function_panel是否有notebook
if hasattr(function_panel, 'notebook'):
notebook = function_panel.notebook
page_count = notebook.GetPageCount()
for i in range(page_count):
page = notebook.GetPage(i)
if hasattr(page, '__class__') and 'Information' in page.__class__.__name__:
information_panel = page
break
# 方法2:直接查找information_panel属性
if not information_panel and hasattr(function_panel, 'information_panel'):
information_panel = function_panel.information_panel
# 方法3:遍历所有子窗口查找
if not information_panel:
information_panel = self.find_information_panel_recursive(main_frame)
# 如果找到信息面板,调用刷新方法
if information_panel and hasattr(information_panel, 'refresh_model_data'):
wx.CallAfter(information_panel.refresh_model_data)
print("✅ 已通知信息面板更新模型显示")
else:
print("⚠️ 未找到信息面板或刷新方法")
except Exception as e:
print(f"❌ 通知信息面板更新失败: {e}")
def find_information_panel_recursive(self, parent):
"""递归查找信息面板"""
try:
# 检查当前窗口
if hasattr(parent, '__class__') and 'Information' in parent.__class__.__name__:
return parent
# 检查子窗口
if hasattr(parent, 'GetChildren'):
for child in parent.GetChildren():
result = self.find_information_panel_recursive(child)
if result:
return result
return None
except:
return None
def cleanup(self):
"""清理资源"""
try:
# 关闭所有CSV文件
for f, _ in self.csv_files.values():
try:
f.close()
except:
pass
self.csv_files.clear()
# 原有的清理代码
temp_dir = Path("temp_models")
if temp_dir.exists():
for temp_file in temp_dir.glob("temp_*.pt"):
try:
temp_file.unlink()
except:
pass
except:
pass
def calculate_liquid_height(self, liquid_top_y, idx):
"""计算液体高度 - 使用比例换算方法"""
container_bottom_y = self.fixed_container_bottoms[idx]
container_top_y = self.fixed_container_tops[idx]
# 🔥 边界检查和修正
if liquid_top_y > container_bottom_y:
# 液位线在容器底部以下,说明无液体
return 0.0
elif liquid_top_y < container_top_y:
# 液位线在容器顶部以上,说明满液位
liquid_top_y = container_top_y
# 按照你的公式计算
numerator = container_bottom_y - liquid_top_y # 容器底部-液位线位置
denominator = container_bottom_y - container_top_y # 容器底部-容器顶部
actual_height_cm = float(self.actual_heights[idx].replace('cm', ''))
liquid_height = (numerator / denominator) * actual_height_cm
# 🔥 新增:限制液体高度不超过容器实际高度
if liquid_height > actual_height_cm:
print(f"⚠️ {self.channel_name}区域{idx+1}: 计算高度{liquid_height:.1f}cm超过容器实际高度{actual_height_cm}cm,修正为{actual_height_cm}cm")
liquid_height = actual_height_cm
return max(0, round(liquid_height, 3))
def cleanup_data_statistics(self):
"""定期清理数据并打印统计信息"""
try:
total_data_points = sum(len(heights) for heights in self.all_heights)
print(f"📊 {self.channel_name}数据统计:")
print(f" - 处理帧数: {self.total_frames_processed}")
print(f" - 当前数据点: {total_data_points}")
print(f" - 最大数据点: {self.max_data_points}")
print(f" - 内存使用率: {total_data_points/self.max_data_points*100:.1f}%")
# 如果数据点接近上限,提前警告
if total_data_points > self.max_data_points * 0.9:
print(f"⚠️ {self.channel_name}数据点接近上限,即将开始覆盖最旧数据")
except Exception as e:
print(f"❌ {self.channel_name}数据统计失败: {e}")
def get_data_statistics(self):
"""获取详细的数据统计信息"""
try:
stats = {
'channel_name': self.channel_name,
'max_data_points': self.max_data_points,
'total_frames_processed': self.total_frames_processed,
'is_running': self.is_running,
'targets_count': len(self.targets),
'areas_data': []
}
for i, heights in enumerate(self.all_heights):
area_stats = {
'area_index': i,
'data_points': len(heights),
'usage_percent': len(heights) / self.max_data_points * 100,
'latest_value': heights[-1] if heights else 0
}
stats['areas_data'].append(area_stats)
return stats
except Exception as e:
print(f"❌ 获取{self.channel_name}统计信息失败: {e}")
return {}
def get_safety_limits(self):
"""从配置文件读取安全上下限值"""
try:
import json
import os
import re
# 构建通道配置文件路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
config_file = os.path.join(project_root, 'resources', 'channel_configs', f'{self.channel_name}_config.json')
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
general = config.get('general', {})
safe_low_str = general.get('safe_low', '2.0cm')
safe_high_str = general.get('safe_high', '10.0cm')
# 提取数值部分(去掉"cm"单位)
safe_low_match = re.search(r'([\d.]+)', safe_low_str)
safe_high_match = re.search(r'([\d.]+)', safe_high_str)
safe_low = float(safe_low_match.group(1)) if safe_low_match else 2.0
safe_high = float(safe_high_match.group(1)) if safe_high_match else 10.0
return safe_low, safe_high
else:
return 2.0, 10.0
except Exception as e:
print(f"❌ 读取{self.channel_name}安全预警值失败: {e}")
return 2.0, 10.0
def get_alarm_records(self):
"""获取报警记录"""
return self.alarm_records.copy()
\ No newline at end of file
"""
卡尔曼滤波引擎模块
包含卡尔曼滤波相关的功能
"""
import cv2
import numpy as np
def stable_median(data, max_std=1.0):
"""稳健地计算中位数"""
if len(data) == 0:
return 0
data = np.array(data)
q1, q3 = np.percentile(data, [25, 75])
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
data = data[(data >= lower) & (data <= upper)]
if len(data) >= 2 and np.std(data) > max_std:
median_val = np.median(data)
data = data[np.abs(data - median_val) <= max_std]
return float(np.median(data)) if len(data) > 0 else 0
def init_kalman_filters_list(num_targets, init_means):
"""初始化卡尔曼滤波器列表"""
kalman_list = []
for i in range(num_targets):
kf = cv2.KalmanFilter(2, 1)
kf.measurementMatrix = np.array([[1, 0]], np.float32)
kf.transitionMatrix = np.array([[1, 0.9], [0, 0.9]], np.float32)
kf.processNoiseCov = np.diag([1e-4, 1e-3]).astype(np.float32)
kf.measurementNoiseCov = np.array([[10]], dtype=np.float32)
kf.statePost = np.array([[init_means[i]], [0]], dtype=np.float32)
kalman_list.append(kf)
return kalman_list
class KalmanFilterEngine:
"""卡尔曼滤波引擎"""
def __init__(self, num_targets=0):
"""初始化卡尔曼滤波引擎"""
self.num_targets = num_targets
self.kalman_filters = []
self.consecutive_rejects = [0] * num_targets
self.recent_observations = [[] for _ in range(num_targets)]
self.last_observations = [None] * num_targets
self.smooth_window = 5
def initialize(self, initial_heights):
"""初始化卡尔曼滤波器"""
init_means = [stable_median(heights) for heights in initial_heights]
self.kalman_filters = init_kalman_filters_list(self.num_targets, init_means)
print(f"✅ 卡尔曼滤波器初始化完成,起始高度:{init_means}")
return init_means
def update(self, target_idx, observation, container_height_cm, error_percentage=30):
"""更新指定目标的卡尔曼滤波器"""
if not self.kalman_filters or target_idx >= len(self.kalman_filters):
raise RuntimeError("卡尔曼滤波器未初始化或目标索引超出范围")
# 预测步骤
predicted = self.kalman_filters[target_idx].predict()
predicted_height = predicted[0][0]
# 更新滤波器
final_height, self.consecutive_rejects[target_idx] = self._update_kalman_filter(
self.kalman_filters[target_idx],
observation,
predicted_height,
container_height_cm,
error_percentage,
self.consecutive_rejects[target_idx],
self.last_observations[target_idx]
)
# 更新上次观测值记录
self.last_observations[target_idx] = observation
# 添加到滑动窗口
self.recent_observations[target_idx].append(final_height)
if len(self.recent_observations[target_idx]) > self.smooth_window:
self.recent_observations[target_idx].pop(0)
return final_height, predicted_height
def _update_kalman_filter(self, kalman_filter, observation, predicted_height, container_height_cm,
error_percentage=30, consecutive_rejects=0, last_observation=None):
"""更新卡尔曼滤波器"""
# 计算预测误差(相对于容器高度的百分比)
prediction_error_percent = abs(observation - predicted_height) / container_height_cm * 100
# 检测是否是重复的观测值(保持的液位数据)
is_repeated_observation = (last_observation is not None and
observation == last_observation)
# 误差控制逻辑
if prediction_error_percent > error_percentage:
# 误差过大,增加拒绝计数
consecutive_rejects += 1
# 检查是否连续6次拒绝
if consecutive_rejects >= 6:
# 连续6次误差过大,强制使用观测值更新
kalman_filter.correct(np.array([[observation]], dtype=np.float32))
final_height = kalman_filter.statePost[0][0]
consecutive_rejects = 0 # 重置计数器
print(f" 连续6次误差过大,强制使用观测值更新: {observation:.3f}cm → 滤波后: {final_height:.3f}cm")
else:
# 使用预测值
final_height = predicted_height
print(f" ❌ 误差 {prediction_error_percent:.1f}% > {error_percentage}%,使用预测值: {predicted_height:.3f}cm (连续拒绝: {consecutive_rejects}/6)")
else:
# 误差可接受,正常更新
kalman_filter.correct(np.array([[observation]], dtype=np.float32))
final_height = kalman_filter.statePost[0][0]
consecutive_rejects = 0 # 重置计数器
print(f" ✅ 误差 {prediction_error_percent:.1f}% <= {error_percentage}%,使用观测值: {observation:.3f}cm → 滤波后: {final_height:.3f}cm")
return final_height, consecutive_rejects
def get_smooth_height(self, target_idx):
"""获取平滑后的高度(中位数)"""
if not self.recent_observations[target_idx]:
return 0
return np.median(self.recent_observations[target_idx])
def reset_target(self, target_idx):
"""重置指定目标的滤波器状态"""
if target_idx < len(self.consecutive_rejects):
self.consecutive_rejects[target_idx] = 0
if target_idx < len(self.last_observations):
self.last_observations[target_idx] = None
if target_idx < len(self.recent_observations):
self.recent_observations[target_idx] = []
print(f" 重置目标{target_idx+1}的滤波器状态")
\ No newline at end of file
"""
液位检测引擎模块
包含液位检测相关的核心功能
"""
import cv2
import numpy as np
from ultralytics import YOLO
def get_class_color(class_name):
"""为不同类别分配不同的颜色"""
color_map = {
'liquid': (0, 255, 0), # 绿色 - 液体
'foam': (255, 0, 0), # 蓝色 - 泡沫
'air': (0, 0, 255), # 红色 - 空气
}
return color_map.get(class_name, (128, 128, 128)) # 默认灰色
def overlay_multiple_masks_on_image(image, masks_info, alpha=0.5):
"""将多个不同类别的mask叠加到图像上"""
overlay = image.copy()
for mask, class_name, confidence in masks_info:
color = get_class_color(class_name)
mask_colored = np.zeros_like(image)
mask_colored[mask > 0] = color
# 叠加mask到原图
cv2.addWeighted(mask_colored, alpha, overlay, 1 - alpha, 0, overlay)
# 添加mask轮廓
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(overlay, contours, -1, color, 2)
# 在mask上添加类别标签和置信度
if len(contours) > 0:
largest_contour = max(contours, key=cv2.contourArea)
M = cv2.moments(largest_contour)
if M["m00"] != 0:
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
label = f"{class_name}: {confidence:.2f}"
font_scale = 0.8
thickness = 2
# 添加文字背景
(text_width, text_height), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
cv2.rectangle(overlay, (cx-15, cy-text_height-5), (cx+text_width+15, cy+5), (0, 0, 0), -1)
# 绘制文字
cv2.putText(overlay, label, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, thickness)
return overlay
def calculate_foam_boundary_lines(mask, class_name):
"""计算foam mask的顶部和底部边界线"""
if np.sum(mask) == 0:
return None, None
y_coords, x_coords = np.where(mask)
if len(y_coords) == 0:
return None, None
top_y = np.min(y_coords)
bottom_y = np.max(y_coords)
# 计算顶部边界线的平均位置
top_region_height = max(1, int((bottom_y - top_y) * 0.1))
top_region_mask = mask[top_y:top_y + top_region_height, :]
if np.sum(top_region_mask) > 0:
top_y_coords = np.where(top_region_mask)[0] + top_y
top_line_y = np.mean(top_y_coords)
else:
top_line_y = top_y
# 计算底部边界线的平均位置
bottom_region_height = max(1, int((bottom_y - top_y) * 0.1))
bottom_region_mask = mask[bottom_y - bottom_region_height:bottom_y + 1, :]
if np.sum(bottom_region_mask) > 0:
bottom_y_coords = np.where(bottom_region_mask)[0] + (bottom_y - bottom_region_height)
bottom_line_y = np.mean(bottom_y_coords)
else:
bottom_line_y = bottom_y
return top_line_y, bottom_line_y
def analyze_multiple_foams(foam_masks, cropped_shape):
"""分析多个foam,找到可能的液位边界"""
if len(foam_masks) < 2:
return None, None, 0, "需要至少2个foam才能分析边界"
foam_boundaries = []
# 计算每个foam的边界信息
for i, mask in enumerate(foam_masks):
top_y, bottom_y = calculate_foam_boundary_lines(mask, "foam")
if top_y is not None and bottom_y is not None:
center_y = (top_y + bottom_y) / 2
foam_boundaries.append({
'index': i,
'top_y': top_y,
'bottom_y': bottom_y,
'center_y': center_y
})
if len(foam_boundaries) < 2:
return None, None, 0, "有效foam边界少于2个"
# 按垂直位置排序
foam_boundaries.sort(key=lambda x: x['center_y'])
container_height = cropped_shape[0]
error_threshold_px = container_height * 0.1
analysis_info = f"\n 容器高度: {container_height}px, 10%误差阈值: {error_threshold_px:.1f}px"
# 检查相邻foam之间的边界
for i in range(len(foam_boundaries) - 1):
upper_foam = foam_boundaries[i]
lower_foam = foam_boundaries[i + 1]
upper_bottom = upper_foam['bottom_y']
lower_top = lower_foam['top_y']
boundary_distance = abs(upper_bottom - lower_top)
analysis_info += f"\n Foam{upper_foam['index']+1}底部(y={upper_bottom:.1f}) vs Foam{lower_foam['index']+1}顶部(y={lower_top:.1f})"
analysis_info += f"\n 边界距离: {boundary_distance:.1f}px"
if boundary_distance <= error_threshold_px:
liquid_level_y = (upper_bottom + lower_top) / 2
analysis_info += f"\n ✅ 距离 {boundary_distance:.1f}px ≤ {error_threshold_px:.1f}px,确定为液位边界: y={liquid_level_y:.1f}"
return liquid_level_y, 1.0, len(foam_boundaries), analysis_info
else:
analysis_info += f"\n ❌ 距离 {boundary_distance:.1f}px > {error_threshold_px:.1f}px,不是液位边界"
analysis_info += f"\n ❌ 未找到符合条件的液位边界"
return None, 0, len(foam_boundaries), analysis_info
def enhanced_liquid_detection_with_foam_analysis(all_masks_info, cropped, fixed_container_bottom, container_pixel_height, container_height_cm):
"""增强的液位检测,结合foam分析"""
liquid_height = None
detection_method = "无检测"
analysis_details = ""
# 计算像素到厘米的转换比例
pixel_per_cm = container_pixel_height / container_height_cm
# 分离不同类别的mask
liquid_masks = []
foam_masks = []
air_masks = []
for mask, class_name, confidence in all_masks_info:
if class_name == 'liquid':
liquid_masks.append(mask)
elif class_name == 'foam':
foam_masks.append(mask)
elif class_name == 'air':
air_masks.append(mask)
# 方法1:直接liquid检测 - 优先方法
if liquid_masks:
liquid_mask = liquid_masks[0]
y_indices = np.where(liquid_mask)[0]
if len(y_indices) > 0:
line_y = np.min(y_indices)
liquid_height_px = fixed_container_bottom - line_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = "直接liquid检测"
analysis_details = f"检测到{len(liquid_masks)}个liquid mask,液面位置: y={line_y}, 像素高度: {liquid_height_px}px"
# 方法2:多foam边界分析
if liquid_height is None and len(liquid_masks) == 0 and len(foam_masks) >= 2:
foam_liquid_level, foam_confidence, foam_count, foam_analysis = analyze_multiple_foams(
foam_masks, cropped.shape
)
analysis_details += f"\n Foam边界分析 (检测到{foam_count}个foam):{foam_analysis}"
if foam_liquid_level is not None:
foam_liquid_height_px = fixed_container_bottom - foam_liquid_level
liquid_height = foam_liquid_height_px / pixel_per_cm
detection_method = "多foam边界分析"
analysis_details += f"\n✅ 使用foam边界分析结果: 像素高度 {foam_liquid_height_px}px = {liquid_height:.1f}cm"
# 方法3:单个air分析
elif liquid_height is None and len(liquid_masks) == 0 and len(foam_masks) == 0 and len(air_masks) == 1:
air_mask = air_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(air_mask, "air")
if bottom_y is not None:
liquid_height_px = fixed_container_bottom - bottom_y
liquid_height = liquid_height_px / pixel_per_cm
detection_method = f"单air底部检测"
analysis_details += f"\n 单air分析: 顶部y={top_y:.1f}, 底部y={bottom_y:.1f}, 像素高度: {liquid_height_px}px = {liquid_height:.3f}cm"
return liquid_height, detection_method, analysis_details
class LiquidDetectionEngine:
"""液位检测引擎"""
def __init__(self, model_path=None):
"""初始化检测模型"""
self.model = None
if model_path:
self.load_model(model_path)
def load_model(self, model_path):
"""加载YOLO模型"""
try:
print(f"🔄 正在加载模型: {model_path}")
# 检查文件是否存在
import os
if not os.path.exists(model_path):
print(f"❌ 模型文件不存在: {model_path}")
return False
# 检查文件大小
file_size = os.path.getsize(model_path)
if file_size == 0:
print(f"❌ 模型文件为空: {model_path}")
return False
print(f" 模型文件大小: {file_size / (1024*1024):.1f}MB")
# 尝试加载模型
self.model = YOLO(model_path)
print(f"✅ 模型加载成功: {os.path.basename(model_path)}")
return True
except Exception as e:
print(f"❌ 模型加载失败: {e}")
import traceback
traceback.print_exc()
self.model = None
return False
def process_single_target(self, cropped_img, target_idx, fixed_container_bottom,
fixed_container_top, container_height_cm):
"""处理单个目标的液位检测"""
if self.model is None:
return None, "模型未加载", "", []
results = self.model.predict(source=cropped_img, imgsz=640, conf=0.5, save=False, verbose=False)
result = results[0]
all_masks_info = []
if result.masks is not None:
masks = result.masks.data.cpu().numpy() > 0.5
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
names = self.model.names
# 按置信度排序,优先处理高置信度的检测
sorted_indices = np.argsort(confidences)[::-1]
for i in sorted_indices:
class_name = names[classes[i]]
confidence = confidences[i]
# 只处理置信度大于0.5的检测
if confidence < 0.5:
continue
resized_mask = cv2.resize(masks[i].astype(np.uint8),
(cropped_img.shape[1], cropped_img.shape[0])) > 0.5
# 存储mask信息
all_masks_info.append((resized_mask, class_name, confidence))
# 计算容器像素高度
container_pixel_height = fixed_container_bottom - fixed_container_top
# 使用增强的液位检测方法
liquid_height, detection_method, analysis_details = enhanced_liquid_detection_with_foam_analysis(
all_masks_info, cropped_img, fixed_container_bottom,
container_pixel_height, container_height_cm
)
return liquid_height, detection_method, analysis_details, all_masks_info
"""
Real Time Streaming Capture
用于处理RTSP/RTMP等实时流,避免花屏问题
"""
import threading
import cv2
class RTSCapture(cv2.VideoCapture):
"""Real Time Streaming Capture.
这个类必须使用 RTSCapture.create 方法创建,请不要直接实例化
"""
_cur_frame = None
_reading = False
schemes = ["rtsp://", "rtmp://"] # 用于识别实时流
@staticmethod
def create(url, *schemes):
"""实例化&初始化
rtscap = RTSCapture.create("rtsp://example.com/live/1")
or
rtscap = RTSCapture.create("http://example.com/live/1.m3u8", "http://")
"""
rtscap = RTSCapture(url)
rtscap.frame_receiver = threading.Thread(target=rtscap.recv_frame, daemon=True)
rtscap.schemes.extend(schemes)
if isinstance(url, str) and url.startswith(tuple(rtscap.schemes)):
rtscap._reading = True
elif isinstance(url, int):
# 这里可能是本机设备
pass
return rtscap
def isStarted(self):
"""替代 VideoCapture.isOpened() """
ok = self.isOpened()
if ok and self._reading:
ok = self.frame_receiver.is_alive()
return ok
def recv_frame(self):
"""子线程读取最新视频帧方法"""
while self._reading and self.isOpened():
ok, frame = self.read()
if not ok:
break
self._cur_frame = frame
self._reading = False
def read2(self):
"""读取最新视频帧
返回结果格式与 VideoCapture.read() 一样
"""
frame = self._cur_frame
self._cur_frame = None
return frame is not None, frame
def start_read(self):
"""启动子线程读取视频帧"""
self.frame_receiver.start()
self.read_latest_frame = self.read2 if self._reading else self.read
def stop_read(self):
"""退出子线程方法"""
self._reading = False
if self.frame_receiver.is_alive():
self.frame_receiver.join()
def release(self):
"""释放资源"""
self.stop_read()
super().release()
# 测试代码
if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print("Usage: python rtscapture.py <rtsp_url>")
sys.exit(1)
rtscap = RTSCapture.create(sys.argv[1])
rtscap.start_read() # 启动子线程并改变 read_latest_frame 的指向
while rtscap.isStarted():
ok, frame = rtscap.read_latest_frame() # read_latest_frame() 替代 read()
if not ok:
if cv2.waitKey(100) & 0xFF == ord('q'):
break
continue
# 帧处理代码写这里
cv2.imshow("cam", frame)
if cv2.waitKey(100) & 0xFF == ord('q'):
break
rtscap.stop_read()
rtscap.release()
cv2.destroyAllWindows()
\ No newline at end of file
从yolo分割结果到液位高度数据总体分两个步骤
从yolo分割结果到液位高度数据总体分两个步骤
1.通过 yolo,分割结果得到检测的液位线 2.根据卡尔曼滤波的结果是否选择更新
通过 yolo 结果,一共3层,有检测液体,没有检测到液体但是有检测到泡沫或者空气,什么都没有检测到
->如果有检测到液体->取液体的最上面作为液位高度
->没有检测到液体,用其他检测到
->有泡沫->取泡沫下方作为液位高度
->有多个泡沫,可能是液体混着泡沫的情况,取两个泡沫中间
->没泡沫有空气取空气下方作为液位高度
->如果没有检测到,仍然保留上次结果
2.得到1的液位结果,进行卡尔曼滤波判断
卡尔曼滤波会自己单独得到一个预测的液位高度值和我们通过1得到的yolo结果进行比较
->卡尔曼滤波的液位高度 和 我们实际得到的差不多,使用卡尔曼滤波的值
->卡尔曼滤波的液位高度 和 我们实际得到的差距大,这个情况可能存在跳变
->连续三次都差距大,那么就取新的ylg结果作为高度值
->不是连续三次,说明可能存在误检,仍然保留上次的结果,不进行更新。
\ No newline at end of file
时间轴变量starttime,endtime 时间轴变量starttime,endtime
...@@ -3,3 +3,17 @@ ...@@ -3,3 +3,17 @@
@curvepanel_handler.py#L371-378 修改self._video_layout_mode = 1同步布局,绘制曲线的数据范围 @curvepanel_handler.py#L371-378 修改self._video_layout_mode = 1同步布局,绘制曲线的数据范围
实验最长时间三天,strattime到endtime,缩放限制 实验最长时间三天,strattime到endtime,缩放限制
曲线面板历史模式下,, 曲线面板历史模式下,,
**简短回答**
`self.max_points_per_view = 5000` 用来限制**当前 X 轴可见范围内,单次真正传给 PyQtGraph 去画的点数上限**,防止一次画太多点导致卡顿。
### 具体含义
- **原始数据**:全部保存在 `channel['time']` / `channel['value']` 里,不受这个值影响。
- **显示数据**:在 `updateCurveData()` 里,会先根据当前时间轴范围 `(starttime, endtime)` 取出“当前可见时间段”的点数 `visible_count`
- 如果 `visible_count <= max_points_per_view`:不抽样,全部画出来。
- 如果 `visible_count > max_points_per_view`:按步长 `step = ceil(visible_count / max_points_per_view)` 抽样,只画一部分代表点。
- 所以:
- **值越大**:曲线越细致,但大时间跨度时更容易卡。
- **值越小**:性能好,但放大到很长时间范围时曲线会更“稀疏”。
如果你想“更平滑、更细腻”,可以把 5000 提高一些;如果性能吃紧,可以改小,比如 2000。
\ No newline at end of file
实例分割与多策略液面推断:对每个裁剪区域运行 YOLO 分割,整理 all_masks_info 后调用 enhanced_liquid_detection_with_foam_analysis()。该函数优先寻找最高的 liquid mask;若连续 3 帧未检测到 liquid,则依次尝试“多 foam 边界”“单 foam 底边”“单 air 底边”以及回退上一帧结果,实现多源冗余推断。
实例分割与多策略液面推断:对每个裁剪区域运行 YOLO 分割,整理 all_masks_info 后调用 enhanced_liquid_detection_with_foam_analysis()。该函数优先寻找最高的 liquid mask;若连续 3 帧未检测到 liquid,则依次尝试“多 foam 边界”“单 foam 底边”“单 air 底边”以及回退上一帧结果,实现多源冗余推断。
### 缺失的关键步骤对比
- **卡尔曼滤波与误差控制**
历史版本在 `process_frame()` 中对每个目标执行预测、误差阈值判断与强制校正,最终输出平滑的 `final_height`(参见 `history/detection_engine.py` `1342:1381`)。现有 `handlers/videopage/detection.py` 仅返回一次性观测值,完全没有滤波、拒绝逻辑或历史观测窗口,因此无法抑制抖动和偶发误检。
- **高度数据累积与 CSV 持久化**
旧流程把 `final_height` 推入 `self.all_heights` 的滑动窗口,并调用 `save_data_to_csv()` 将时间戳+高度写入任务曲线文件(`history/detection_engine.py` `1394:1413``1168:1205`)。现版只把高度放进返回字典,没有任何历史缓存、上限控制或落盘,外部无法直接复用曲线数据。
- **报警阈值判断与帧序列记录**
历史引擎在保存数据后立即执行 `check_alarm_conditions()`,依据通道配置的 `safe_low/safe_high` 触发报警记录、帧缓存、AVI 生成等(`history/detection_engine.py` `538:664``414:635`)。现在的 `detection.py` 不再读取安全线、更没有报警策略或帧回放机制,无法联动告警面板。
- **帧级状态维护与统计**
过去的 `LiquidDetectionEngine` 管理 `frame_buffer``pending_alarm_saves``total_frames_processed` 等运行指标,并定期清理(`history/detection_engine.py` `398:485`, `1450:1613`)。新流程是无状态函数式调用,缺乏帧缓存、周期统计或内存占用监测。
- **可视化与界面联动**
旧代码在 `process_frame()` 中直接绘制检测框、液位线、文本并通知信息面板刷新(`history/detection_engine.py` `1415:1545`)。当前引擎仅返回数值,所有可视化/刷新逻辑需要额外组件处理,否则界面不会自动反映结果。
综上,现代 `handlers/videopage` 版本保留了“mask→液位高度”的核心数学流程,但缺失了历史版本中围绕数据落库、告警、滤波、统计与 UI 联动的一系列工程化步骤。如果需要达到旧版的完整功能,这些模块需要在新架构下重新整合。
总体分两个步骤
1.通过 yolo,分割结果得到检测的液位线 2.根据卡尔曼滤波的结果是否选择更新
通过 yolo 结果,一共3层,有检测液体,没有检测到液体但是有检测到泡沫或者空气,什么都没有检测到
->如果有检测到液体->取液体的最上面作为液位高度
->没有检测到液体,用其他检测到
->有泡沫->取泡沫下方作为液位高度
->有多个泡沫,可能是液体混着泡沫的情况,取两个泡沫中间
->没泡沫有空气取空气下方作为液位高度
->如果没有检测到,仍然保留上次结果
2.得到1的液位结果,进行卡尔曼滤波判断
卡尔曼滤波会自己单独得到一个预测的液位高度值和我们通过1得到的yolo结果进行比较
->卡尔曼滤波的液位高度 和 我们实际得到的差不多,使用卡尔曼滤波的值
->卡尔曼滤波的液位高度 和 我们实际得到的差距大,这个情况可能存在跳变
->连续三次都差距大,那么就取新的ylg结果作为高度值
->不是连续三次,说明可能存在误检,仍然保留上次的结果,不进行更新。
### 总体回答
**相较于 `history/detection_engine.py`,现在的 `detection_logic.py + detection.py` 在“YOLO 分割结果 → 液位高度数据”这条链路里,主要少了两大类步骤:多帧时序稳健逻辑 和 卡尔曼/滑动窗口平滑逻辑。**
---
### 1. 少了“多帧稳健逻辑”:不再真正利用历史高度与帧计数
在历史版里,从 YOLO 分割结果到液位高度,`enhanced_liquid_detection_with_foam_analysis()` 会结合多帧状态来“保高度”和触发 foam/air 备选策略:
- 维护每个目标的:
- `no_liquid_count`(连续未检测到 liquid 的帧数)
- `last_liquid_heights`(最后一次有效液位)
- `frame_counters`(帧计数器,用于周期性清零)
- 关键行为:
- 连续 < 3 帧没 liquid:直接“保持上一帧液位”;
- 连续 ≥ 3 帧没 liquid:才启用 foam/air 备选,并在备选失败时继续“保持最后一次液位”。
```252:327:history/detection_engine.py
if liquid_masks:
...
last_liquid_heights[target_idx] = liquid_height
...
else:
no_liquid_count[target_idx] += 1
frame_counters[target_idx] += 1
...
if no_liquid_count[target_idx] >= 3:
# foam / air 备选 + 失败后保持 last_liquid_heights
...
if liquid_height is None and last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = "使用最后液位(保持)"
...
else:
# <3帧,用最后一次的检测结果
if last_liquid_heights[target_idx] is not None:
liquid_height = last_liquid_heights[target_idx]
detection_method = f"保持液位({no_liquid_count[target_idx]}/3)"
```
而在当前新逻辑中:
- `calculate_liquid_height()` 只接受一个 **外部传入的** `no_liquid_count` 数值,用于判断是否启用 foam/air 备选;
- **没有** `last_liquid_heights``frame_counters` 概念,也**不做“保持上一帧高度”**的处理。
```202:283:handlers/videopage/detection_logic.py
def calculate_liquid_height(..., no_liquid_count: int = 0):
...
# 方法1:直接 liquid
if liquid_masks:
...
return max(0, min(liquid_height_mm, container_height_mm))
# 方法2:foam / air 备选,仅在 no_liquid_count >= 3 时启用
if no_liquid_count >= 3:
...
return None
```
更关键的是,在 `detection.py` 里:
- `_detect_single_target()` 仅把当前 `self.no_liquid_count[idx]` 值传入,但**从未在任意位置更新它(++ 或重置)**,所以:
- `no_liquid_count` 永远是初始化的 0;
- foam/air 备选逻辑实际上**永远不会被触发**
- 也就没有“连续多帧缺失后再启用备选”的时序机制。
```561:567:handlers/videopage/detection.py
liquid_height = calculate_liquid_height(
all_masks_info=all_masks_info,
container_bottom=container_bottom_in_crop,
container_pixel_height=container_pixel_height,
container_height_mm=container_height_mm,
no_liquid_count=self.no_liquid_count[idx] if idx < len(self.no_liquid_count) else 0
)
```
**总结这一点:**
现在的链路里,从 YOLO 结果到液位高度是“单帧决策”,缺少历史版中那套“多帧计数 + 保持上一帧 + 达到阈值才启用 foam/air 备选”的稳健时序逻辑。
---
### 2. 少了“卡尔曼 + 滑动窗口平滑”的高度过滤步骤
历史版中,在得到 `liquid_height` 之后,还有一层 **卡尔曼滤波 + 误差控制 + 滑动窗口平滑**,才得到最终用于保存/画线的 `height_cm`
```1342:1381:history/detection_engine.py
predicted = self.kalman_filters[idx].predict()
current_observation = liquid_height if liquid_height is not None else 0
prediction_error_percent = abs(current_observation - predicted_height) / container_height_cm * 100
if prediction_error_percent > error_percentage:
...
if self.consecutive_rejects[idx] >= 3:
# 强制使用观测值
self.kalman_filters[idx].statePost = ...
final_height = current_observation
else:
# 用预测值
final_height = predicted_height
else:
# 正常更新
self.kalman_filters[idx].correct(...)
final_height = self.kalman_filters[idx].statePost[0][0]
...
self.recent_observations[idx].append(final_height)
if len(self.recent_observations[idx]) > self.SMOOTH_WINDOW:
self.recent_observations[idx].pop(0)
height_cm = np.clip(final_height, 0, container_height_cm)
```
而在当前新架构里:
- `detection.py` 中虽然也定义了 `_apply_kalman_filter()``recent_observations``smooth_window` 等:
```576:630:handlers/videopage/detection.py
def _apply_kalman_filter(self, observation, idx, container_height_mm):
predicted = self.kalman_filters[idx].predict()
...
if prediction_error_percent > self.error_percentage:
...
else:
self.kalman_filters[idx].correct(...)
final_height = self.kalman_filters[idx].statePost[0][0]
...
self.recent_observations[idx].append(final_height)
...
final_height = max(0, min(final_height, container_height_mm))
return final_height
```
- **但 `detect()` / `_detect_single_target()` 完全没有调用 `_apply_kalman_filter()`**,即:
- `calculate_liquid_height()` 得到的 `liquid_height_mm` 直接用来算像素高度;
- 没有任何时序滤波或滑动窗口平滑。
```424:452:handlers/videopage/detection.py
liquid_height_mm = self._detect_single_target(...)
if liquid_height_mm is None:
liquid_height_mm = 0.0
...
height_px = int(liquid_height_mm * pixel_per_mm)
liquid_line_y_absolute = container_bottom_y - height_px
```
**总结这一点:**
历史链路里“YOLO → 原始观测 → 卡尔曼+误差控制 → 平滑高度 → 最终液位数据”,
而现在是“YOLO → 单帧观测 → 直接输出”,**缺了“滤波 + 平滑”这一层。**
---
### 3. 范围内不算缺口的:CSV 存储 / 报警逻辑
你问的是“从 YOLO 分割结果到液位高度数据”的流程,对应主要是“如何算出一个高度值”。
历史版在这之后还有:
- `save_data_to_csv()` 存曲线数据;
- `check_alarm_conditions()` 结合配置触发报警/录像。
这些在当前 `handlers/videopage` 层面是由其他模块和 handler 负责,不一定需要放在 `detection_logic.py` / `detection.py` 里,所以这里不算“缺少”,而是职责拆分。
---
### 最后一句归纳
**对比历史实现,目前从 YOLO 分割结果到液位高度数据,缺的关键是:① 多帧的 `no_liquid_count`/`last_liquid_heights` 时序稳健逻辑(含“保持上一帧高度”和 foam/air 启动条件),② 得到原始高度后的卡尔曼滤波 + 滑动窗口平滑步骤;这两步现在在新代码中要么完全没实现,要么实现了但未被调用。**
\ No newline at end of file
...@@ -91,6 +91,8 @@ class CurvePanel(QtWidgets.QWidget): ...@@ -91,6 +91,8 @@ class CurvePanel(QtWidgets.QWidget):
backClicked = QtCore.Signal() # 返回按钮点击信号 backClicked = QtCore.Signal() # 返回按钮点击信号
missionFolderChanged = QtCore.Signal(str) # 任务文件夹选择变化信号(传递完整路径) missionFolderChanged = QtCore.Signal(str) # 任务文件夹选择变化信号(传递完整路径)
refreshMissionListRequested = QtCore.Signal() # 刷新任务列表请求信号 refreshMissionListRequested = QtCore.Signal() # 刷新任务列表请求信号
# 时间轴范围变化信号:(starttime, endtime) - Unix时间戳
timeAxisRangeChanged = QtCore.Signal(float, float)
def __init__(self, parent=None): def __init__(self, parent=None):
super(CurvePanel, self).__init__(parent) super(CurvePanel, self).__init__(parent)
...@@ -564,7 +566,7 @@ class CurvePanel(QtWidgets.QWidget): ...@@ -564,7 +566,7 @@ class CurvePanel(QtWidgets.QWidget):
# 创建曲线UI对象(圆润样式) # 创建曲线UI对象(圆润样式)
pen = pg.mkPen( pen = pg.mkPen(
color=color, color=color,
width=3, width=1,
style=Qt.SolidLine, style=Qt.SolidLine,
capStyle=Qt.RoundCap, # 圆形端点 capStyle=Qt.RoundCap, # 圆形端点
joinStyle=Qt.RoundJoin # 圆角连接 joinStyle=Qt.RoundJoin # 圆角连接
...@@ -912,6 +914,13 @@ class CurvePanel(QtWidgets.QWidget): ...@@ -912,6 +914,13 @@ class CurvePanel(QtWidgets.QWidget):
print(f"🔄 [时间轴范围] starttime={x_min:.3f} ({start_str}) -> endtime={x_max:.3f} ({end_str})") print(f"🔄 [时间轴范围] starttime={x_min:.3f} ({start_str}) -> endtime={x_max:.3f} ({end_str})")
# 发送时间轴范围变化信号,通知Handler根据新的范围重新采样
try:
self.timeAxisRangeChanged.emit(float(self.starttime), float(self.endtime))
except Exception:
# 静默处理,避免因为范围无效导致崩溃
pass
# 确保Y轴范围在0-23之间 # 确保Y轴范围在0-23之间
if y_min < 0 or y_max > 23: if y_min < 0 or y_max > 23:
# 调整到合法范围 # 调整到合法范围
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment