Commit 0fcf27b8 by yhb

1

parent 66a7639e
# Python缓存文件
__pycache__/
# 忽略所有文件
/*
# 只上传指定的文件夹
!/handlers/
!/widgets/
!/rules/
!/hooks/
!/icons/
!/labelme/
!/utils/
# 只上传指定的文件
!/app.py
!/__main__.py
!/__init__.py
!/.gitignore
# 忽略这些文件夹中的特定内容
**/__pycache__/
*.py[cod]
*$py.class
*.so
# IDE文件
.vscode/
.idea/
*.swp
*.swo
# 日志文件
*.log
# 临时文件
*.tmp
*.temp
~*
# 系统文件
.DS_Store
Thumbs.db
desktop.ini
# Python虚拟环境
.env
.venv
env/
venv/
# 打包文件
dist/
build/
*.egg-info/
# 数据库文件(如果不需要追踪)
# *.db
# *.sqlite
# *.sqlite3
# 敏感配置文件
config/secrets.yaml
config/private.yaml
\ No newline at end of file
*.backup
*.backup_*
**/*.md
**/build/
**/SdkLog_Python/
**/*.csv
*_report.txt
......@@ -103,7 +103,7 @@ binaries = []
try:
binaries += collect_dynamic_libs('torch')
print("✓ 已收集 torch 二进制文件")
excPyInstaller 1.specept Exception as e:
except Exception as e:
print(f"⚠ 收集 torch 二进制文件失败: {e}")
pass
......
......@@ -87,7 +87,6 @@ class ModelSignalHandler:
if hasattr(self, 'modelSetPage') and hasattr(self, 'trainingPage'):
if hasattr(self.modelSetPage, 'modelListChanged') and hasattr(self.trainingPage, 'connectModelListChangeSignal'):
self.trainingPage.connectModelListChangeSignal(self.modelSetPage)
print("[模型信号处理] 已连接模型列表变化信号到训练页面")
except Exception as e:
import traceback
......
from ast import main
123131441
fsafasa
main
\ No newline at end of file
......@@ -48,17 +48,27 @@ except ImportError:
class AmplifyWindowHandler:
"""全屏放大窗口业务逻辑处理器"""
def __init__(self, amplify_window, device_config=None):
# 来源常量
SOURCE_AMPLIFY = 'amplifysource' # 点击放大显示按钮进入
SOURCE_ANNOTATION = 'annotationsource' # 点击开始标注按钮进入
def __init__(self, amplify_window, device_config=None, source=None):
"""
初始化处理器
Args:
amplify_window: AmplifyWindow实例
device_config: 设备配置字典,包含IP、端口、用户名、密码等
source: 来源标记,可选值:
- 'amplifysource': 点击放大显示按钮进入
- 'annotationsource': 点击开始标注按钮进入
"""
self.amplify_window = amplify_window
self.channel_id = amplify_window._channel_id
# 来源标记(用于区分不同入口,显示不同控件)
self.source = source or self.SOURCE_AMPLIFY
# 物理变焦控制器
self.physical_zoom_controller = None
self.physical_zoom_enabled = False
......@@ -81,7 +91,6 @@ class AmplifyWindowHandler:
# 初始化物理变焦控制器
self._initPhysicalZoomController()
print(f"[AMPLIFY_HANDLER] 处理器已创建: {self.channel_id}, 物理变焦: {'启用' if self.physical_zoom_enabled else '不可用'}")
def _loadDeviceConfigFromFile(self):
"""从配置文件的RTSP地址解析设备配置"""
......@@ -521,9 +530,8 @@ class AmplifyWindowHandler:
if self.physical_zoom_controller:
self.physical_zoom_controller.disconnect_device()
self.physical_zoom_controller = None
print(f"[AMPLIFY_HANDLER] 通道{self.channel_id}: 物理变焦控制器已清理")
except Exception as e:
print(f"[AMPLIFY_HANDLER] 清理失败: {e}")
except Exception:
pass
def get_zoom_status(self):
"""
......@@ -545,6 +553,44 @@ class AmplifyWindowHandler:
status['physical_status'] = physical_status
return status
# ========== 来源判断辅助方法 ==========
def isAmplifySource(self):
"""
判断是否从放大显示按钮进入
Returns:
bool: 是则返回True
"""
return self.source == self.SOURCE_AMPLIFY
def isAnnotationSource(self):
"""
判断是否从开始标注按钮进入
Returns:
bool: 是则返回True
"""
return self.source == self.SOURCE_ANNOTATION
def getSource(self):
"""
获取当前来源标记
Returns:
str: 来源标记 ('amplifysource' 或 'annotationsource')
"""
return self.source
def setSource(self, source):
"""
设置来源标记
Args:
source: 来源标记
"""
self.source = source
# ============================================================================
......
# -*- coding: utf-8 -*-
"""
自动标功能模块
自动标功能模块
通过YOLO分割掩码自动检测容器的顶部和底部位置
"""
......@@ -19,7 +19,7 @@ from database.config import get_temp_models_dir
class AutoDotDetector:
"""
自动标检测器
自动标检测器
功能:
1. 输入图片和检测框
......@@ -29,7 +29,7 @@ class AutoDotDetector:
def __init__(self, model_path: str = None, device: str = 'cuda'):
"""
初始化自动标检测器
初始化自动标检测器
Args:
model_path: YOLO模型路径(.pt 或 .dat 文件)
......@@ -194,12 +194,35 @@ class AutoDotDetector:
print(f"🔍 开始检测容器边界")
print(f" 图像尺寸: {image.shape[1]}x{image.shape[0]}")
# 🔥 根据图像尺寸动态计算 imgsz
# 对于大图像,使用更大的 imgsz 以保证小目标检测精度
img_height, img_width = image.shape[:2]
max_dim = max(img_height, img_width)
# 动态计算 imgsz:
# - 小图像(<=640): 使用 640
# - 中等图像(640-1280): 使用 1280
# - 大图像(>1280): 使用图像较长边(上限1920)
if max_dim <= 640:
imgsz = 640
elif max_dim <= 1280:
imgsz = 1280
else:
imgsz = min(max_dim, 1920)
# 确保 imgsz 是32的倍数(YOLO要求)
imgsz = (imgsz // 32) * 32
if imgsz < 640:
imgsz = 640
print(f" 📐 动态推理尺寸: imgsz={imgsz} (原图较长边={max_dim})")
# 执行YOLO推理(直接使用输入图像,不再裁剪)
print(f" 🔄 执行YOLO推理...")
try:
mission_results = self.model.predict(
source=image,
imgsz=640,
imgsz=imgsz,
conf=conf_threshold,
iou=0.5,
device=self.device,
......@@ -296,7 +319,7 @@ class AutoDotDetector:
)
print(f"\n{'='*60}")
print(f"✅ 自动标完成")
print(f"✅ 自动标完成")
return {
'success': True,
......@@ -666,12 +689,12 @@ class AutoDotDetector:
# ==================== 独立调试功能 ====================
def test_auto_dot():
def test_auto_annotation():
"""独立调试函数"""
import os
print("="*80)
print("🧪 自动标功能测试")
print("🧪 自动标功能测试")
print("="*80)
# 配置参数
......@@ -702,11 +725,11 @@ def test_auto_dot():
print(f" 图片尺寸: {image.shape[1]}x{image.shape[0]}")
# 创建检测器
print(f"\n🔧 初始化自动标检测器...")
print(f"\n🔧 初始化自动标检测器...")
detector = AutoDotDetector(model_path=model_path, device='cuda')
# 执行检测(直接传入整张图片)
print(f"\n🚀 开始自动标检测...")
print(f"\n🚀 开始自动标检测...")
result = detector.detect_container_boundaries(
image=image,
conf_threshold=0.5
......@@ -728,7 +751,7 @@ def test_auto_dot():
print()
# 保存标注图片
output_path = os.path.join(output_dir, "auto_dot_result.jpg")
output_path = os.path.join(output_dir, "auto_annotation_result.jpg")
cv2.imwrite(output_path, result['annotated_image'])
print(f"💾 标注图片已保存: {output_path}")
......@@ -753,27 +776,51 @@ def test_auto_dot():
class AutoAnnotationDetector:
"""
自动标注检测器(整合标框和标点功能)
自动标注检测器
功能:
1. 输入图像
2. 使用YOLO分割模型检测液体、空气、泡沫区域
3. 可输出box位置数据(标框)或点位置数据(标点)
4. 根据位置信息绘制框或点
输入:图像 (np.ndarray)
输出(纯数据,不含绘制):
- 检测区域框: List[Dict] - {'x1', 'y1', 'x2', 'y2', 'classes', 'area'}
- 容器顶部底部位置: List[Dict] - {'top', 'bottom', 'top_x', 'bottom_x', 'height', 'method'}
使用方法:
detector = AutoAnnotationDetector(model_path)
result = detector.detect(image) # 执行检测
boxes = detector.get_boxes(result) # 获取区域框数据
points = detector.get_points(result) # 获取顶部底部位置数据
绘制方法(可选,由调用方决定是否使用):
annotated = detector.draw_boxes(image, boxes)
annotated = detector.draw_points(image, points)
annotated = detector.draw_all(image, boxes, points)
"""
def __init__(self, model_path: str = None, device: str = 'cuda'):
# 默认模型路径(基于项目根目录)
DEFAULT_MODEL_PATH = str(project_root / "database" / "model" / "detection_model" / "detect" / "best.dat")
def __init__(self, model_path: str = None, device: str = 'cuda', model=None):
"""
初始化自动标注检测器
Args:
model_path: YOLO模型路径(.pt 或 .dat 文件)
model_path: YOLO模型路径(.pt 或 .dat 文件),默认使用项目内置模型
device: 计算设备 ('cuda' 或 'cpu')
model: 已加载的YOLO模型实例(可选,传入则跳过加载)
"""
self.model = None
self.model_path = model_path
self.model = model # 🔥 支持直接传入已加载的模型
self.device = self._validate_device(device)
# 如果已传入模型,跳过加载
if self.model is not None:
print(f"✅ [AutoAnnotationDetector] 使用已传入的模型实例")
return
# 使用默认模型路径或指定路径
if model_path is None:
model_path = self.DEFAULT_MODEL_PATH
self.model_path = model_path
if model_path:
self.load_model(model_path)
......@@ -929,12 +976,64 @@ class AutoAnnotationDetector:
print(f"🔍 开始自动检测")
print(f" 图像尺寸: {image.shape[1]}x{image.shape[0]}")
# 🔥 模型预热:确保模型已正确初始化
if not hasattr(self, '_warmed_up') or not self._warmed_up:
print(f" � [预热] 执行模型预热...")
try:
# 创建一个小的测试图像进行预热
warmup_img = np.zeros((640, 640, 3), dtype=np.uint8)
_ = self.model.predict(
source=warmup_img,
imgsz=640,
conf=0.5,
device=self.device,
save=False,
verbose=False,
stream=False
)
self._warmed_up = True
print(f" ✅ [预热] 模型预热完成")
except Exception as e:
print(f" ⚠️ [预热] 预热失败: {e}")
# 🔥 调试:检查模型信息
print(f" 🔧 [调试] 模型类型: {type(self.model)}")
if hasattr(self.model, 'task'):
print(f" 🔧 [调试] 模型task: {self.model.task}")
if hasattr(self.model, 'names'):
print(f" 🔧 [调试] 类别名称: {self.model.names}")
# 🔥 根据图像尺寸动态计算 imgsz
# 对于大图像,使用更大的 imgsz 以保证小目标检测精度
img_height, img_width = image.shape[:2]
max_dim = max(img_height, img_width)
# 动态计算 imgsz:
# - 小图像(<=640): 使用 640
# - 中等图像(640-1280): 使用 1280
# - 大图像(>1280): 使用图像较长边(上限1920)
if max_dim <= 640:
imgsz = 640
elif max_dim <= 1280:
imgsz = 1280
else:
imgsz = min(max_dim, 1920)
# 确保 imgsz 是32的倍数(YOLO要求)
imgsz = (imgsz // 32) * 32
if imgsz < 640:
imgsz = 640
print(f" 📐 动态推理尺寸: imgsz={imgsz} (原图较长边={max_dim})")
print(f" 📐 置信度阈值: conf={conf_threshold}")
print(f" 📐 设备: device={self.device}")
# 执行YOLO推理
print(f" 🔄 执行YOLO推理...")
try:
mission_results = self.model.predict(
source=image,
imgsz=640,
imgsz=imgsz,
conf=conf_threshold,
iou=0.5,
device=self.device,
......@@ -944,8 +1043,23 @@ class AutoAnnotationDetector:
stream=False
)
mission_result = mission_results[0]
# 🔥 调试:检查推理结果
print(f" 🔧 [调试] 推理结果类型: {type(mission_result)}")
print(f" 🔧 [调试] boxes: {mission_result.boxes}")
if mission_result.boxes is not None:
print(f" 🔧 [调试] boxes数量: {len(mission_result.boxes)}")
if len(mission_result.boxes) > 0:
print(f" 🔧 [调试] boxes.cls: {mission_result.boxes.cls}")
print(f" 🔧 [调试] boxes.conf: {mission_result.boxes.conf}")
print(f" 🔧 [调试] masks: {mission_result.masks}")
if mission_result.masks is not None:
print(f" 🔧 [调试] masks.data.shape: {mission_result.masks.data.shape}")
except Exception as e:
print(f" ❌ YOLO推理失败: {e}")
import traceback
traceback.print_exc()
return {
'success': False,
'boxes': [],
......@@ -956,6 +1070,8 @@ class AutoAnnotationDetector:
# 处理检测结果
if mission_result.masks is None:
print(f" ⚠️ 未检测到任何掩码")
print(f" 🔧 [调试] 注意: boxes存在但masks为None,可能模型不是分割模型!")
print(f" 🔧 [调试] 请检查模型是否为 YOLOv8-seg 分割模型")
return {
'success': False,
'boxes': [],
......@@ -1171,6 +1287,206 @@ class AutoAnnotationDetector:
else:
return []
def get_system_format(
self,
detection_result: Dict,
padding: int = 10,
distance_threshold: int = 200
) -> Dict:
"""
直接输出系统格式的标注数据(SimpleAnnotationEngine格式)
根据位置距离将掩码聚类到不同区域,每个区域一个框
Args:
detection_result: detect()方法的返回结果
padding: box边界扩展像素数
distance_threshold: 区域聚类距离阈值(像素),超过此距离的掩码归为不同区域
Returns:
Dict: {
'boxes': List[Tuple(cx, cy, size)], # 框中心和边长
'bottom_points': List[Tuple(x, y)], # 底部点坐标
'top_points': List[Tuple(x, y)] # 顶部点坐标
}
"""
if not detection_result.get('success'):
return {'boxes': [], 'bottom_points': [], 'top_points': []}
valid_masks = detection_result['masks']
class_names = detection_result['class_names']
confidences = detection_result.get('confidences', [1.0] * len(valid_masks))
height, width = detection_result['image_shape']
print(f"\n🔥 [区域聚类] 开始根据位置距离分类区域...")
print(f" 距离阈值: {distance_threshold}px")
print(f" 掩码数量: {len(valid_masks)}")
# 1. 计算每个mask的中心点和边界
mask_info = []
for i, (mask, class_name) in enumerate(zip(valid_masks, class_names)):
y_coords, x_coords = np.where(mask)
if len(y_coords) == 0:
continue
cx = int(np.mean(x_coords))
cy = int(np.mean(y_coords))
x1 = int(np.min(x_coords))
y1 = int(np.min(y_coords))
x2 = int(np.max(x_coords))
y2 = int(np.max(y_coords))
conf = confidences[i] if i < len(confidences) else 1.0
mask_info.append({
'index': i,
'mask': mask,
'class_name': class_name,
'cx': cx, 'cy': cy,
'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2,
'confidence': conf,
'area': np.sum(mask)
})
if not mask_info:
return {'boxes': [], 'bottom_points': [], 'top_points': []}
# 2. 基于距离的区域聚类(简单聚类算法)
regions = [] # 每个区域是一个mask_info列表
assigned = [False] * len(mask_info)
for i, info in enumerate(mask_info):
if assigned[i]:
continue
# 创建新区域
region = [info]
assigned[i] = True
# 查找所有距离在阈值内的掩码
for j, other_info in enumerate(mask_info):
if assigned[j]:
continue
# 计算与区域中所有掩码的最小距离
min_dist = float('inf')
for region_mask in region:
dist = np.sqrt((info['cx'] - other_info['cx'])**2 + (info['cy'] - other_info['cy'])**2)
min_dist = min(min_dist, dist)
if min_dist <= distance_threshold:
region.append(other_info)
assigned[j] = True
regions.append(region)
print(f" 聚类结果: {len(regions)} 个区域")
# 3. 每个区域内,每种类别只保留一个掩码(保留置信度最高的)
filtered_regions = []
for region_idx, region in enumerate(regions):
class_best = {} # class_name -> best mask_info
for mask_info_item in region:
class_name = mask_info_item['class_name']
if class_name not in class_best:
class_best[class_name] = mask_info_item
else:
# 保留置信度更高的,如果置信度相同则保留面积更大的
current = class_best[class_name]
if (mask_info_item['confidence'] > current['confidence'] or
(mask_info_item['confidence'] == current['confidence'] and
mask_info_item['area'] > current['area'])):
class_best[class_name] = mask_info_item
filtered_region = list(class_best.values())
filtered_regions.append(filtered_region)
classes_in_region = [m['class_name'] for m in filtered_region]
print(f" 区域{region_idx+1}: {len(filtered_region)} 个掩码, 类别: {classes_in_region}")
# 4. 为每个区域生成框和点位
boxes = []
bottom_points = []
top_points = []
for region_idx, region in enumerate(filtered_regions):
if not region:
continue
# 合并区域内所有掩码计算边界框
all_x1 = min(m['x1'] for m in region)
all_y1 = min(m['y1'] for m in region)
all_x2 = max(m['x2'] for m in region)
all_y2 = max(m['y2'] for m in region)
# 添加padding
all_x1 = max(0, all_x1 - padding)
all_y1 = max(0, all_y1 - padding)
all_x2 = min(width, all_x2 + padding)
all_y2 = min(height, all_y2 + padding)
# 转换为 (cx, cy, size) 格式
cx = (all_x1 + all_x2) // 2
cy = (all_y1 + all_y2) // 2
box_width = all_x2 - all_x1
box_height = all_y2 - all_y1
size = max(box_width, box_height)
boxes.append((cx, cy, size))
# 计算点位(基于区域内的掩码)
# 收集不同类别的坐标
liquid_y_coords = []
air_y_coords = []
foam_y_coords = []
all_x_coords = []
for m in region:
y_coords, x_coords = np.where(m['mask'])
all_x_coords.extend(x_coords.tolist())
if m['class_name'] == 'liquid':
liquid_y_coords.extend(y_coords.tolist())
elif m['class_name'] == 'air':
air_y_coords.extend(y_coords.tolist())
elif m['class_name'] == 'foam':
foam_y_coords.extend(y_coords.tolist())
# 计算顶部点和底部点
center_x = int(np.median(all_x_coords)) if all_x_coords else cx
# 底部点:优先使用liquid的最下边,其次foam,最后air
if liquid_y_coords:
bottom_y = int(np.max(liquid_y_coords))
elif foam_y_coords:
bottom_y = int(np.max(foam_y_coords))
elif air_y_coords:
bottom_y = int(np.max(air_y_coords))
else:
bottom_y = all_y2 - padding
# 顶部点:优先使用air的最上边,其次foam,最后liquid
if air_y_coords:
top_y = int(np.min(air_y_coords))
elif foam_y_coords:
top_y = int(np.min(foam_y_coords))
elif liquid_y_coords:
top_y = int(np.min(liquid_y_coords))
else:
top_y = all_y1 + padding
bottom_points.append((center_x, bottom_y))
top_points.append((center_x, top_y))
print(f" 📦 区域{region_idx+1} box: ({cx}, {cy}, {size}), top: ({center_x}, {top_y}), bottom: ({center_x}, {bottom_y})")
print(f"✅ [区域聚类] 完成,生成 {len(boxes)} 个区域框")
return {
'boxes': boxes,
'bottom_points': bottom_points,
'top_points': top_points
}
def draw_boxes(
self,
image: np.ndarray,
......@@ -1586,23 +1902,18 @@ AutoboxDetector = AutoAnnotationDetector
def test_auto_annotation():
"""测试自动标注功能(整合标框和标)"""
"""测试自动标注功能(整合标框和标)"""
import os
print("="*80)
print("🧪 自动标注功能测试")
print("="*80)
# 配置参数
model_path = r"D:\restructure\liquid_level_line_detection_system\database\model\detection_model\detect\best.dat"
test_image_path = r"D:\restructure\liquid_level_line_detection_system\test_image\4.jpg"
output_dir = r"D:\restructure\liquid_level_line_detection_system\test_output"
# 检查文件
if not os.path.exists(model_path):
print(f"❌ 模型文件不存在: {model_path}")
return
# 配置参数(使用项目根目录动态路径)
test_image_path = str(project_root / "test_image" / "4.jpg")
output_dir = str(project_root / "test_output")
# 检查文件(模型使用默认路径,在类内部验证)
if not os.path.exists(test_image_path):
print(f"❌ 测试图片不存在: {test_image_path}")
print(f"💡 请将测试图片放置到: {test_image_path}")
......@@ -1620,9 +1931,10 @@ def test_auto_annotation():
print(f" 图片尺寸: {image.shape[1]}x{image.shape[0]}")
# 创建检测器
# 创建检测器(使用默认模型路径)
print(f"\n🔧 初始化自动标注检测器...")
detector = AutoAnnotationDetector(model_path=model_path, device='cuda')
print(f" 默认模型路径: {AutoAnnotationDetector.DEFAULT_MODEL_PATH}")
detector = AutoAnnotationDetector(device='cuda')
# 执行核心检测
print(f"\n🚀 开始执行检测...")
......@@ -1695,9 +2007,395 @@ def test_auto_annotation():
print(f"{'='*80}")
# ==================== 自动标注GUI界面 ====================
class AutoDotSettingDialog:
"""
自动标注设置对话框
界面布局:
- 顶部:图像预览区域(显示标线结果)
- 底部:图片集路径选择 + 模型路径选择
"""
def __init__(self, parent=None):
"""初始化对话框"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QFileDialog, QFrame
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap, QImage
self.dialog = QDialog(parent)
self.dialog.setWindowTitle("自动标注设置")
self.dialog.setMinimumSize(800, 600)
# 保存引用
self.QPixmap = QPixmap
self.QImage = QImage
self.QFileDialog = QFileDialog
self.Qt = Qt
# 检测器实例
self.detector = None
self.current_image = None
self.result_image = None
# 主布局
main_layout = QVBoxLayout(self.dialog)
main_layout.setSpacing(10)
main_layout.setContentsMargins(15, 15, 15, 15)
# ========== 图像预览区域 ==========
preview_frame = QFrame()
preview_frame.setStyleSheet("""
QFrame {
border: 2px solid #E74C3C;
border-radius: 5px;
background-color: #FAFAFA;
}
""")
preview_frame.setMinimumHeight(300)
preview_layout = QVBoxLayout(preview_frame)
preview_layout.setContentsMargins(5, 5, 5, 5)
self.preview_label = QLabel("标线结果")
self.preview_label.setAlignment(Qt.AlignCenter)
self.preview_label.setStyleSheet("""
QLabel {
color: #E74C3C;
font-size: 16px;
font-weight: bold;
border: none;
}
""")
preview_layout.addWidget(self.preview_label)
main_layout.addWidget(preview_frame, stretch=1)
# ========== 图片集路径 ==========
images_layout = QHBoxLayout()
images_layout.setSpacing(10)
images_label = QLabel("图片集")
images_label.setStyleSheet("color: #E74C3C; font-weight: bold;")
images_label.setFixedWidth(60)
self.images_path_edit = QLineEdit()
self.images_path_edit.setText(r"C:\Users\123\Desktop\yewei\picture") # 默认图片集路径
self.images_path_edit.setPlaceholderText("选择图片集文件夹...")
self.images_path_edit.setStyleSheet("""
QLineEdit {
border: 2px solid #E74C3C;
border-radius: 3px;
padding: 5px;
}
""")
images_browse_btn = QPushButton("浏览")
images_browse_btn.setStyleSheet("""
QPushButton {
color: #E74C3C;
border: none;
font-weight: bold;
padding: 5px 15px;
}
QPushButton:hover {
color: #C0392B;
}
""")
images_browse_btn.clicked.connect(self._browse_images_folder)
images_layout.addWidget(images_label)
images_layout.addWidget(self.images_path_edit, stretch=1)
images_layout.addWidget(images_browse_btn)
main_layout.addLayout(images_layout)
# ========== 模型路径 ==========
model_layout = QHBoxLayout()
model_layout.setSpacing(10)
model_label = QLabel("模型路径")
model_label.setStyleSheet("color: #E74C3C; font-weight: bold;")
model_label.setFixedWidth(60)
self.model_path_edit = QLineEdit()
self.model_path_edit.setText(AutoAnnotationDetector.DEFAULT_MODEL_PATH) # 设置默认模型路径
self.model_path_edit.setPlaceholderText("选择模型文件 (.pt 或 .dat)...")
self.model_path_edit.setStyleSheet("""
QLineEdit {
border: 2px solid #E74C3C;
border-radius: 3px;
padding: 5px;
}
""")
model_browse_btn = QPushButton("浏览")
model_browse_btn.setStyleSheet("""
QPushButton {
color: #E74C3C;
border: none;
font-weight: bold;
padding: 5px 15px;
}
QPushButton:hover {
color: #C0392B;
}
""")
model_browse_btn.clicked.connect(self._browse_model_file)
model_layout.addWidget(model_label)
model_layout.addWidget(self.model_path_edit, stretch=1)
model_layout.addWidget(model_browse_btn)
main_layout.addLayout(model_layout)
# ========== 操作按钮 ==========
button_layout = QHBoxLayout()
button_layout.setSpacing(20)
# 上一张按钮
self.prev_btn = QPushButton("上一张")
self.prev_btn.setStyleSheet("""
QPushButton {
background-color: #E74C3C;
color: white;
border: none;
border-radius: 5px;
padding: 10px 30px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover {
background-color: #C0392B;
}
QPushButton:disabled {
background-color: #BDC3C7;
}
""")
self.prev_btn.clicked.connect(self._on_prev_clicked)
# 开始按钮
self.start_btn = QPushButton("开始")
self.start_btn.setStyleSheet("""
QPushButton {
background-color: #27AE60;
color: white;
border: none;
border-radius: 5px;
padding: 10px 40px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover {
background-color: #1E8449;
}
QPushButton:disabled {
background-color: #BDC3C7;
}
""")
self.start_btn.clicked.connect(self._on_start_clicked)
# 下一张按钮
self.next_btn = QPushButton("下一张")
self.next_btn.setStyleSheet("""
QPushButton {
background-color: #E74C3C;
color: white;
border: none;
border-radius: 5px;
padding: 10px 30px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover {
background-color: #C0392B;
}
QPushButton:disabled {
background-color: #BDC3C7;
}
""")
self.next_btn.clicked.connect(self._on_next_clicked)
button_layout.addStretch()
button_layout.addWidget(self.prev_btn)
button_layout.addWidget(self.start_btn)
button_layout.addWidget(self.next_btn)
button_layout.addStretch()
main_layout.addLayout(button_layout)
# ========== 状态变量 ==========
self.image_list = [] # 图片文件列表
self.current_index = 0 # 当前图片索引
self.detector = None # 检测器实例
def _load_image_list(self):
"""加载图片集文件夹中的所有图片"""
import os
images_path = self.images_path_edit.text()
if not images_path or not os.path.isdir(images_path):
return False
# 支持的图片格式
extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif')
self.image_list = [
os.path.join(images_path, f)
for f in sorted(os.listdir(images_path))
if f.lower().endswith(extensions)
]
self.current_index = 0
return len(self.image_list) > 0
def _process_current_image(self):
"""处理当前图片并显示结果"""
if not self.image_list or self.current_index >= len(self.image_list):
return
# 加载当前图片
image_path = self.image_list[self.current_index]
image = cv2.imread(image_path)
if image is None:
print(f"❌ 无法读取图片: {image_path}")
return
# 确保检测器已加载
if self.detector is None:
model_path = self.model_path_edit.text()
self.detector = AutoAnnotationDetector(model_path=model_path, device='cuda')
# 执行检测
result = self.detector.detect(image)
if result.get('success'):
boxes = self.detector.get_boxes(result)
points = self.detector.get_points(result)
annotated = self.detector.draw_all(image, boxes, points)
self.set_result_image(annotated)
else:
self.set_result_image(image)
# 更新窗口标题显示当前进度
self.dialog.setWindowTitle(f"自动标注设置 - [{self.current_index + 1}/{len(self.image_list)}] {Path(image_path).name}")
def _on_start_clicked(self):
"""开始按钮点击事件"""
if not self._load_image_list():
print("❌ 请先选择有效的图片集文件夹")
return
self._process_current_image()
def _on_prev_clicked(self):
"""上一张按钮点击事件"""
if not self.image_list:
if not self._load_image_list():
return
if self.current_index > 0:
self.current_index -= 1
self._process_current_image()
def _on_next_clicked(self):
"""下一张按钮点击事件"""
if not self.image_list:
if not self._load_image_list():
return
if self.current_index < len(self.image_list) - 1:
self.current_index += 1
self._process_current_image()
def _browse_images_folder(self):
"""浏览图片集文件夹"""
folder = self.QFileDialog.getExistingDirectory(
self.dialog,
"选择图片集文件夹",
"",
self.QFileDialog.ShowDirsOnly
)
if folder:
self.images_path_edit.setText(folder)
def _browse_model_file(self):
"""浏览模型文件"""
file_path, _ = self.QFileDialog.getOpenFileName(
self.dialog,
"选择模型文件",
"",
"模型文件 (*.pt *.dat);;所有文件 (*.*)"
)
if file_path:
self.model_path_edit.setText(file_path)
def set_result_image(self, image: np.ndarray):
"""设置结果图像到预览区域"""
if image is None:
return
self.result_image = image
# BGR转RGB
if len(image.shape) == 3 and image.shape[2] == 3:
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
else:
rgb_image = image
h, w = rgb_image.shape[:2]
bytes_per_line = 3 * w
q_image = self.QImage(
rgb_image.data, w, h, bytes_per_line,
self.QImage.Format_RGB888
)
# 缩放到预览区域大小
pixmap = self.QPixmap.fromImage(q_image)
scaled_pixmap = pixmap.scaled(
self.preview_label.size(),
self.Qt.KeepAspectRatio,
self.Qt.SmoothTransformation
)
self.preview_label.setPixmap(scaled_pixmap)
def get_images_path(self) -> str:
"""获取图片集路径"""
return self.images_path_edit.text()
def get_model_path(self) -> str:
"""获取模型路径"""
return self.model_path_edit.text()
def exec_(self):
"""显示对话框"""
return self.dialog.exec_()
def show(self):
"""显示对话框(非模态)"""
self.dialog.show()
def test_auto_annotation_gui():
"""测试自动标注GUI"""
import sys
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = AutoDotSettingDialog()
dialog.show()
sys.exit(app.exec_())
if __name__ == "__main__":
# 测试自动标点功能(原始版本)
# test_auto_dot()
# 测试自动标注功能(原始版本)
# test_auto_annotation()
# 测试整合的自动标注功能(标框+标注)
# test_auto_annotation()
# 测试整合的自动标注功能(标框+标点)
test_auto_annotation()
# 测试GUI界面
test_auto_annotation_gui()
......@@ -1723,8 +1723,6 @@ class ChannelPanelHandler:
# 显示窗口
amplify_window.show()
print(f"[AMPLIFY] 全屏窗口已创建: {channel_id} - {channel_name}")
# 放大窗口现在通过 _updateVideoDisplayUI 同步更新,无需单独的同步线程
# if channel_id in self._channel_captures:
# self._startAmplifyFrameSync(channel_id)
......@@ -1742,8 +1740,6 @@ class ChannelPanelHandler:
channel_id: 通道ID
"""
try:
print(f"[AMPLIFY] 全屏窗口关闭: {channel_id}")
# 从映射中移除窗口和处理器
if channel_id in self._amplify_windows:
del self._amplify_windows[channel_id]
......@@ -1772,7 +1768,6 @@ class ChannelPanelHandler:
# 检查是否已经有同步线程
if hasattr(self, '_amplify_sync_flags'):
if channel_id in self._amplify_sync_flags and self._amplify_sync_flags[channel_id]:
print(f"[AMPLIFY] 帧同步已在进行中: {channel_id}")
return
# 初始化同步标志
......@@ -1789,8 +1784,6 @@ class ChannelPanelHandler:
)
sync_thread.start()
print(f"[AMPLIFY] 帧同步线程已启动: {channel_id}")
except Exception as e:
print(f" 启动全屏帧同步失败: {e}")
......@@ -1805,7 +1798,6 @@ class ChannelPanelHandler:
if hasattr(self, '_amplify_sync_flags'):
if channel_id in self._amplify_sync_flags:
self._amplify_sync_flags[channel_id] = False
print(f"[AMPLIFY] 帧同步已停止: {channel_id}")
except Exception as e:
print(f" 停止全屏帧同步失败: {e}")
......@@ -1817,8 +1809,6 @@ class ChannelPanelHandler:
Args:
channel_id: 通道ID
"""
print(f"[AMPLIFY] 帧同步循环启动: {channel_id}")
frame_count = 0
last_sync_time = time.time()
......@@ -1860,9 +1850,6 @@ class ChannelPanelHandler:
fps = 30 / (current_time - last_sync_time)
last_sync_time = current_time
# 每100帧打印一次统计
if frame_count % 100 == 0:
print(f"[AMPLIFY] {channel_id} 已同步 {frame_count} 帧")
else:
# 没有新帧,短暂等待
time.sleep(0.01)
......@@ -1870,8 +1857,6 @@ class ChannelPanelHandler:
except Exception as e:
print(f" {channel_id} 全屏帧同步错误: {e}")
time.sleep(0.1)
print(f"[AMPLIFY] 帧同步循环停止: {channel_id},总共同步 {frame_count} 帧")
def _updateAmplifyWindows(self, channel_id, frame):
"""
......@@ -1902,7 +1887,6 @@ class ChannelPanelHandler:
"""初始化配置文件监控器"""
try:
# 临时禁用配置文件监控器以解决 QWidget 创建顺序问题
print(f"[ConfigWatcher] 配置文件监控器已禁用(避免 QWidget 创建顺序问题)")
return
# 获取配置文件路径
......@@ -2031,14 +2015,12 @@ class ChannelPanelHandler:
"""
try:
if not 1 <= channel_num <= 4:
print(f"[updateMissionLabelByVar] 无效的通道编号: {channel_num},必须在1-4之间")
return
# 获取对应的任务标签变量
mission_var_name = f'channel{channel_num}mission'
if not hasattr(self, mission_var_name):
print(f"[updateMissionLabelByVar] 未找到变量: {mission_var_name}")
return
mission_label = getattr(self, mission_var_name)
......@@ -2053,9 +2035,6 @@ class ChannelPanelHandler:
if panel and hasattr(panel, '_positionTaskLabel'):
panel._positionTaskLabel()
print(f"✅ [updateMissionLabelByVar] 已更新 {mission_var_name}: {text}")
except Exception as e:
print(f"❌ [updateMissionLabelByVar] 更新任务标签失败: {e}")
import traceback
traceback.print_exc()
......@@ -841,9 +841,9 @@ class CurvePanelHandler:
start_time, end_time = result
start_str = datetime.datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S') if start_time else 'None'
end_str = datetime.datetime.fromtimestamp(end_time).strftime('%Y-%m-%d %H:%M:%S') if end_time else 'None'
print(f"🎯 [Handler获取时间轴范围] starttime={start_time} ({start_str}) -> endtime={end_time} ({end_str})")
return result
print(f"⚠️ [Handler获取时间轴范围] curve_panel不存在,返回(None, None)")
return (None, None)
# ========== 数据管理方法 ==========
......@@ -970,12 +970,8 @@ class CurvePanelHandler:
# 构建 mission_result 目录路径
mission_result_dir = os.path.join(data_root, 'database', 'mission_result')
print(f"🔍 [任务列表] 数据根目录: {data_root}")
print(f"🔍 [任务列表] 任务目录: {mission_result_dir}")
print(f"🔍 [任务列表] 目录是否存在: {os.path.exists(mission_result_dir)}")
if not os.path.exists(mission_result_dir):
print(f"❌ [任务列表] 目录不存在: {mission_result_dir}")
# 通知UI显示空列表
if self.curve_panel:
self.curve_panel.updateMissionFolderList([])
......
......@@ -13,7 +13,7 @@
python detect_debug.py --source rtsp://192.168.1.100:554/stream
python detect_debug.py --source video.mp4
python detect_debug.py --source 0 # 摄像头
默认地址rtsp://admin:cei345678@192.168.0.127:8000/stream1
默认地址rtsp://admin:cei345678@192.168.2.126:8000/stream1
默认模型D:\\restructure\\liquid_level_line_detection_system\\database\\model\\detection_model\\7\\best.dat
"""
......@@ -64,7 +64,8 @@ class SegmentationDebugger:
device: 计算设备
"""
self.model_path = model_path
self.device = device
# 检测GPU是否可用
self.device = self._validate_device(device)
self.engine = None
# 显示参数
......@@ -91,6 +92,23 @@ class SegmentationDebugger:
'container': (255, 255, 0), # 青色
}
def _validate_device(self, device):
"""验证并选择可用的设备"""
try:
import torch
if device in ['cuda', '0'] or device.startswith('cuda:'):
if torch.cuda.is_available():
print(f"✅ GPU可用,使用设备: {device}")
return 'cuda' if device in ['cuda', '0'] else device
else:
print(f"⚠️ 未检测到GPU,自动切换到CPU模式")
return 'cpu'
return device
except Exception as e:
print(f"⚠️ 设备检测异常: {e},使用CPU模式")
return 'cpu'
def load_model(self, model_path):
"""加载检测模型"""
try:
......@@ -474,7 +492,8 @@ if QT_AVAILABLE:
super().__init__(parent)
self.source = None
self.model_path = None
self.device = 'cuda'
# 检测GPU是否可用
self.device = self._validate_device('cuda')
self.running = False
self.engine = None
self.mask_alpha = 0.6
......@@ -483,10 +502,16 @@ if QT_AVAILABLE:
self.fps_counter = 0
self.fps_start_time = time.time()
# ROI区域
self.roi = None # (x, y, w, h)
# ROI区域(支持多个)
self.roi_list = [] # [(x, y, w, h), ...]
self.use_roi = False
# 日志记录
self.csv_log_path = r"c:\Users\123\Desktop\yewei\detection_liquid_system\handlers\videopage\detect.csv"
self.frame_counter = 0
self.csv_file = None
self.csv_writer = None
# 颜色映射
self.class_colors = {
'liquid': (0, 255, 0), # 绿色
......@@ -495,6 +520,22 @@ if QT_AVAILABLE:
'container': (255, 255, 0), # 青色
}
def _validate_device(self, device):
"""验证并选择可用的设备"""
try:
import torch
if device in ['cuda', '0'] or device.startswith('cuda:'):
if torch.cuda.is_available():
return 'cuda' if device in ['cuda', '0'] else device
else:
print("[VideoThread] ⚠️ 未检测到GPU,自动切换到CPU模式")
return 'cpu'
return device
except Exception as e:
print(f"[VideoThread] ⚠️ 设备检测异常: {e},使用CPU模式")
return 'cpu'
def set_source(self, source):
"""设置视频源"""
self.source = source
......@@ -504,9 +545,18 @@ if QT_AVAILABLE:
self.model_path = model_path
def set_roi(self, roi):
"""设置检测区域"""
self.roi = roi
self.use_roi = roi is not None
"""设置检测区域(支持单个或多个)"""
if roi is None:
self.roi_list = []
self.use_roi = False
elif isinstance(roi, list):
# 多个ROI
self.roi_list = roi
self.use_roi = len(roi) > 0
else:
# 单个ROI,转换为列表
self.roi_list = [roi]
self.use_roi = True
def load_model(self):
"""加载模型"""
......@@ -528,35 +578,68 @@ if QT_AVAILABLE:
return False
def detect_and_visualize(self, frame):
"""检测并可视化分割结果"""
"""检测并可视化分割结果(支持多个ROI)"""
detection_info = {}
try:
if not self.engine or not self.engine.model:
return frame
return frame, detection_info
# 如果设置了ROI,只检测ROI区域
if self.use_roi and self.roi is not None:
x, y, w, h = self.roi
roi_frame = frame[y:y+h, x:x+w]
# 执行YOLO推理(只对ROI区域)
results = self.engine.model.predict(
source=roi_frame,
imgsz=640,
conf=0.3,
iou=0.5,
device=self.device,
save=False,
verbose=False,
half=True if self.device != 'cpu' else False
)
result = results[0]
annotated_frame = frame.copy()
# 绘制ROI边框
cv2.rectangle(annotated_frame, (x, y), (x+w, y+h), (0, 255, 255), 2)
cv2.putText(annotated_frame, "ROI", (x, y-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
annotated_frame = frame.copy()
overlay = annotated_frame.copy()
# 如果设置了ROI,逐个检测ROI区域
if self.use_roi and len(self.roi_list) > 0:
for roi_idx, roi in enumerate(self.roi_list):
x, y, w, h = roi
roi_frame = frame[y:y+h, x:x+w]
# 绘制ROI边框(黄色)
cv2.rectangle(annotated_frame, (x, y), (x+w, y+h), (0, 255, 255), 2)
cv2.putText(annotated_frame, f"ROI {roi_idx+1}", (x, y-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
# 执行YOLO推理(只对ROI区域)
results = self.engine.model.predict(
source=roi_frame,
imgsz=640,
conf=0.3,
iou=0.5,
device=self.device,
save=False,
verbose=False,
half=True if self.device != 'cpu' else False
)
result = results[0]
# 记录检测结果(CSV记录所有mask,包括低置信度的)
roi_info = {}
if result.masks is not None:
roi_info['mask_count'] = len(result.masks.data)
roi_info['classes'] = []
roi_info['confidences'] = []
roi_info['pixel_counts'] = []
for i in range(len(result.masks.data)):
class_id = int(result.boxes.cls[i].cpu().numpy())
conf = float(result.boxes.conf[i].cpu().numpy())
# CSV记录所有检测结果,不过滤置信度
class_name = self.engine.model.names[class_id]
roi_info['classes'].append(class_name)
roi_info['confidences'].append(conf)
# 计算mask像素数量
mask_resized = cv2.resize(
result.masks.data[i].cpu().numpy().astype(np.uint8),
(w, h)
) > 0.5
pixel_count = int(np.sum(mask_resized))
roi_info['pixel_counts'].append(pixel_count)
self._draw_roi_masks(annotated_frame, overlay, result, x, y, w, h)
else:
roi_info['mask_count'] = 0
roi_info['classes'] = []
roi_info['confidences'] = []
roi_info['pixel_counts'] = []
detection_info[f'ROI{roi_idx+1}'] = roi_info
else:
# 执行YOLO推理(全图)
results = self.engine.model.predict(
......@@ -571,92 +654,124 @@ if QT_AVAILABLE:
)
result = results[0]
annotated_frame = frame.copy()
# 绘制分割掩码
if result.masks is not None:
masks = result.masks.data.cpu().numpy()
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
# 创建掩码叠加层
overlay = annotated_frame.copy()
for i in range(len(masks)):
if confidences[i] < 0.3:
continue
# 获取类别信息
class_id = classes[i]
class_name = self.engine.model.names[class_id]
confidence = confidences[i]
# 根据是否使用ROI调整掩码尺寸和坐标
if self.use_roi and self.roi is not None:
x, y, w, h = self.roi
# 调整掩码尺寸到ROI大小
mask_roi = cv2.resize(
masks[i].astype(np.uint8),
(w, h)
) > 0.5
# 创建全图大小的掩码,将ROI区域的掩码放入
mask = np.zeros((frame.shape[0], frame.shape[1]), dtype=bool)
mask[y:y+h, x:x+w] = mask_roi
# 绘制掩码(只在ROI区域)
overlay[y:y+h, x:x+w][mask_roi] = self.class_colors.get(class_name, (128, 128, 128))
# 边界框坐标需要加上ROI偏移
if result.boxes is not None:
box = result.boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = map(int, box)
x1 += x
y1 += y
x2 += x
y2 += y
color = self.class_colors.get(class_name, (128, 128, 128))
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), color, 2)
label = f"{class_name}: {confidence:.2f}"
label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
cv2.rectangle(annotated_frame, (x1, y1 - label_size[1] - 10),
(x1 + label_size[0], y1), color, -1)
cv2.putText(annotated_frame, label, (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
else:
# 全图检测
mask = cv2.resize(
masks[i].astype(np.uint8),
# 记录检测结果(CSV记录所有mask,包括低置信度的)
full_info = {}
if result.masks is not None:
full_info['mask_count'] = len(result.masks.data)
full_info['classes'] = []
full_info['confidences'] = []
full_info['pixel_counts'] = []
for i in range(len(result.masks.data)):
class_id = int(result.boxes.cls[i].cpu().numpy())
conf = float(result.boxes.conf[i].cpu().numpy())
# CSV记录所有检测结果,不过滤置信度
class_name = self.engine.model.names[class_id]
full_info['classes'].append(class_name)
full_info['confidences'].append(conf)
# 计算mask像素数量
mask_resized = cv2.resize(
result.masks.data[i].cpu().numpy().astype(np.uint8),
(frame.shape[1], frame.shape[0])
) > 0.5
color = self.class_colors.get(class_name, (128, 128, 128))
overlay[mask] = color
if result.boxes is not None:
box = result.boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = map(int, box)
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), color, 2)
label = f"{class_name}: {confidence:.2f}"
label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
cv2.rectangle(annotated_frame, (x1, y1 - label_size[1] - 10),
(x1 + label_size[0], y1), color, -1)
cv2.putText(annotated_frame, label, (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
# 混合掩码和原图
annotated_frame = cv2.addWeighted(annotated_frame, 1 - self.mask_alpha,
overlay, self.mask_alpha, 0)
pixel_count = int(np.sum(mask_resized))
full_info['pixel_counts'].append(pixel_count)
self._draw_fullframe_masks(annotated_frame, overlay, result, frame.shape)
else:
full_info['mask_count'] = 0
full_info['classes'] = []
full_info['confidences'] = []
full_info['pixel_counts'] = []
detection_info['FullFrame'] = full_info
# 混合掩码和原图
annotated_frame = cv2.addWeighted(annotated_frame, 1 - self.mask_alpha,
overlay, self.mask_alpha, 0)
return annotated_frame
return annotated_frame, detection_info
except Exception as e:
print(f"检测异常: {e}")
return frame
return frame, detection_info
def _draw_roi_masks(self, annotated_frame, overlay, result, roi_x, roi_y, roi_w, roi_h):
"""绘制ROI区域的分割掩码"""
masks = result.masks.data.cpu().numpy()
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
for i in range(len(masks)):
if confidences[i] < 0.3:
continue
class_id = classes[i]
class_name = self.engine.model.names[class_id]
confidence = confidences[i]
# 调整掩码尺寸到ROI大小
mask_roi = cv2.resize(
masks[i].astype(np.uint8),
(roi_w, roi_h)
) > 0.5
# 绘制掩码
overlay[roi_y:roi_y+roi_h, roi_x:roi_x+roi_w][mask_roi] = self.class_colors.get(class_name, (128, 128, 128))
# 绘制边界框(加上ROI偏移)
if result.boxes is not None:
box = result.boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = map(int, box)
x1 += roi_x
y1 += roi_y
x2 += roi_x
y2 += roi_y
color = self.class_colors.get(class_name, (128, 128, 128))
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), color, 2)
label = f"{class_name}: {confidence:.2f}"
label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 2)[0]
cv2.rectangle(annotated_frame, (x1, y1 - label_size[1] - 8),
(x1 + label_size[0], y1), color, -1)
cv2.putText(annotated_frame, label, (x1, y1 - 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 2)
def _draw_fullframe_masks(self, annotated_frame, overlay, result, frame_shape):
"""绘制全图分割掩码"""
masks = result.masks.data.cpu().numpy()
classes = result.boxes.cls.cpu().numpy().astype(int)
confidences = result.boxes.conf.cpu().numpy()
for i in range(len(masks)):
if confidences[i] < 0.3:
continue
class_id = classes[i]
class_name = self.engine.model.names[class_id]
confidence = confidences[i]
# 调整掩码尺寸
mask = cv2.resize(
masks[i].astype(np.uint8),
(frame_shape[1], frame_shape[0])
) > 0.5
color = self.class_colors.get(class_name, (128, 128, 128))
overlay[mask] = color
# 绘制边界框
if result.boxes is not None:
box = result.boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = map(int, box)
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), color, 2)
label = f"{class_name}: {confidence:.2f}"
label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
cv2.rectangle(annotated_frame, (x1, y1 - label_size[1] - 10),
(x1 + label_size[0], y1), color, -1)
cv2.putText(annotated_frame, label, (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
def run(self):
"""线程运行函数"""
......@@ -705,6 +820,21 @@ if QT_AVAILABLE:
self.running = True
self.fps_start_time = time.time()
self.frame_counter = 0
# 初始化CSV日志文件
try:
import csv
self.csv_file = open(self.csv_log_path, 'w', newline='', encoding='utf-8-sig')
self.csv_writer = csv.writer(self.csv_file)
# 写入表头
self.csv_writer.writerow(['帧数', '检测区域', 'Mask数量', '检测类别', '置信度', '像素数量'])
self.csv_file.flush()
print(f"✅ 已创建CSV日志文件: {self.csv_log_path}")
except Exception as e:
print(f"⚠️ 无法创建CSV日志文件: {e}")
self.csv_file = None
self.csv_writer = None
while self.running:
# 读取帧
......@@ -720,7 +850,40 @@ if QT_AVAILABLE:
break
# 执行检测
annotated_frame = self.detect_and_visualize(frame)
annotated_frame, detection_info = self.detect_and_visualize(frame)
# 调试信息:每100帧输出一次检测结果
if self.frame_counter % 100 == 0 and detection_info:
for region, info in detection_info.items():
print(f"[帧{self.frame_counter}] {region}: {info.get('mask_count', 0)}个mask")
# 记录帧数
self.frame_counter += 1
# 写入CSV日志
if self.csv_writer:
try:
if detection_info:
for region_name, info in detection_info.items():
classes_str = ', '.join(info.get('classes', []))
conf_str = ', '.join([f"{c:.2f}" for c in info.get('confidences', [])])
pixel_str = ', '.join([str(p) for p in info.get('pixel_counts', [])])
self.csv_writer.writerow([
self.frame_counter,
region_name,
info.get('mask_count', 0),
classes_str if classes_str else '无',
conf_str if conf_str else '无',
pixel_str if pixel_str else '无'
])
else:
self.csv_writer.writerow([self.frame_counter, '全图', 0, '无', '无', '无'])
# 每10帧刷新一次文件
if self.frame_counter % 10 == 0:
self.csv_file.flush()
except Exception as e:
print(f"⚠️ 写入CSV失败: {e}")
# 发送帧
self.frame_ready.emit(annotated_frame)
......@@ -740,6 +903,14 @@ if QT_AVAILABLE:
# 释放资源
if cap:
cap.release()
# 关闭CSV文件
if self.csv_file:
try:
self.csv_file.close()
print(f"✅ 已保存CSV日志,共记录 {self.frame_counter} 帧")
except Exception as e:
print(f"⚠️ 关闭CSV文件失败: {e}")
def stop(self):
"""停止线程"""
......@@ -766,9 +937,9 @@ if QT_AVAILABLE:
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
# ===== RTSP输入 =====
# ===== 视频源输入 =====
rtsp_layout = QtWidgets.QHBoxLayout()
rtsp_label = QtWidgets.QLabel("rtsp")
rtsp_label = QtWidgets.QLabel("视频源")
rtsp_label.setFixedWidth(100)
rtsp_label.setStyleSheet("font-size: 14pt; color: #333;")
......@@ -789,8 +960,31 @@ if QT_AVAILABLE:
}
""")
# 添加浏览视频按钮
browse_video_btn = QtWidgets.QPushButton("浏览...")
browse_video_btn.setFixedWidth(100)
browse_video_btn.setStyleSheet("""
QPushButton {
background-color: #5f27cd;
color: white;
border: none;
border-radius: 4px;
padding: 8px;
font-size: 11pt;
font-weight: bold;
}
QPushButton:hover {
background-color: #341f97;
}
QPushButton:pressed {
background-color: #2e1a87;
}
""")
browse_video_btn.clicked.connect(self._browseVideo)
rtsp_layout.addWidget(rtsp_label)
rtsp_layout.addWidget(self.rtsp_input)
rtsp_layout.addWidget(browse_video_btn)
main_layout.addLayout(rtsp_layout)
# ===== 模型路径输入 =====
......@@ -802,7 +996,7 @@ if QT_AVAILABLE:
self.model_input = QtWidgets.QLineEdit()
self.model_input.setPlaceholderText("选择模型文件 (.pt 或 .dat)")
# 设置默认模型路径
default_model_path = r"D:\restructure\liquid_level_line_detection_system\database\model\detection_model\7\best.dat"
default_model_path = r"C:\Users\123\Desktop\yewei\detection_liquid_system\database\model\detection_model\detect\best.dat"
if os.path.exists(default_model_path):
self.model_input.setText(default_model_path)
self.model_input.setStyleSheet("""
......@@ -993,15 +1187,28 @@ if QT_AVAILABLE:
if file_path:
self.model_input.setText(file_path)
def _browseVideo(self):
"""浏览选择视频文件"""
default_video_dir = r"C:\Users\123\Desktop\yewei\video"
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
"选择视频文件",
default_video_dir,
"视频文件 (*.mp4 *.avi *.mkv *.mov *.flv *.wmv *.m4v *.webm);;所有文件 (*.*)"
)
if file_path:
self.rtsp_input.setText(file_path)
def _selectROIWithMouse(self, frame):
"""
自定义ROI选择,使用鼠标拖拽框选,按c键确认
自定义ROI选择,支持多个检测框
Args:
frame: 视频帧
Returns:
tuple: (x, y, w, h) 或 None
list: [(x, y, w, h), ...] 或 None
"""
# ROI选择状态
roi_data = {
......@@ -1012,9 +1219,11 @@ if QT_AVAILABLE:
'y': -1,
'w': 0,
'h': 0,
'confirmed': False
}
# 存储多个ROI
roi_list = []
def mouse_callback(event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN:
roi_data['drawing'] = True
......@@ -1046,6 +1255,14 @@ if QT_AVAILABLE:
# 绘制当前ROI
temp_frame = display_frame.copy()
# 绘制已添加的ROI(蓝色)
for i, roi in enumerate(roi_list):
x, y, w, h = roi
cv2.rectangle(temp_frame, (x, y), (x + w, y + h), (255, 0, 0), 2)
cv2.putText(temp_frame, f"ROI {i+1}", (x, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2)
# 绘制当前正在绘制的ROI(绿色)
if roi_data['w'] > 0 and roi_data['h'] > 0:
cv2.rectangle(temp_frame,
(roi_data['x'], roi_data['y']),
......@@ -1058,9 +1275,9 @@ if QT_AVAILABLE:
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
# 显示提示信息
cv2.putText(temp_frame, "拖动鼠标框选区域", (10, 30),
cv2.putText(temp_frame, f"拖动鼠标框选区域 (已添加{len(roi_list)}个)", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(temp_frame, "按 C 键确认 | 按 ESC 键取消", (10, 60),
cv2.putText(temp_frame, "按 C 键添加当前ROI | 按 Q 键完成 | 按 ESC 取消", (10, 60),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.imshow(window_name, temp_frame)
......@@ -1068,18 +1285,27 @@ if QT_AVAILABLE:
key = cv2.waitKey(10) & 0xFF
if key == ord('c') or key == ord('C'):
# 确认选择
roi_data['confirmed'] = True
break
# 添加当前ROI到列表
if roi_data['w'] > 0 and roi_data['h'] > 0:
roi_list.append((roi_data['x'], roi_data['y'], roi_data['w'], roi_data['h']))
print(f"✅ 已添加ROI {len(roi_list)}: ({roi_data['x']}, {roi_data['y']}, {roi_data['w']}, {roi_data['h']})")
# 重置ROI数据,准备选择下一个
roi_data.update({'x': -1, 'y': -1, 'w': 0, 'h': 0})
elif key == ord('q') or key == ord('Q'):
# 完成选择
if len(roi_list) > 0:
break
elif key == 27: # ESC键
# 取消选择
roi_list = []
break
cv2.destroyWindow(window_name)
# 返回ROI
if roi_data['confirmed'] and roi_data['w'] > 0 and roi_data['h'] > 0:
return (roi_data['x'], roi_data['y'], roi_data['w'], roi_data['h'])
# 返回ROI列表
if len(roi_list) > 0:
print(f"✅ 总共选择了 {len(roi_list)} 个检测框")
return roi_list
else:
return None
......@@ -1160,7 +1386,7 @@ if QT_AVAILABLE:
temp_cap.release()
return None # 全图检测
# 使用自定义ROI选择(c键确认
# 使用自定义ROI选择(支持多个ROI
roi = self._selectROIWithMouse(frame)
# 释放临时捕获对象
......@@ -1168,10 +1394,15 @@ if QT_AVAILABLE:
temp_cap.release()
# 检查是否选择了有效区域
if roi and roi[2] > 0 and roi[3] > 0:
return roi
else:
return None # 用户未选择,使用全图
# roi现在是一个列表: [(x, y, w, h), ...]
if roi:
# 验证所有ROI都是有效的
valid_rois = [r for r in roi if len(r) == 4 and r[2] > 0 and r[3] > 0]
if valid_rois:
return valid_rois
# 没有有效ROI,使用全图
return None
except Exception as e:
if temp_cap:
......@@ -1229,7 +1460,10 @@ if QT_AVAILABLE:
self.video_thread.start()
if roi:
self.status_label.setText(f"状态: 检测中 (ROI: {roi[2]}x{roi[3]})")
if isinstance(roi, list):
self.status_label.setText(f"状态: 检测中 ({len(roi)}个ROI区域)")
else:
self.status_label.setText(f"状态: 检测中 (ROI: {roi[2]}x{roi[3]})")
else:
self.status_label.setText("状态: 检测中 (全图)")
......@@ -1341,7 +1575,7 @@ def main():
args = parser.parse_args()
# 创建调试器(命令行模式)
print("🖥️ 使用命令行模式")
print("[CLI] 使用命令行模式")
debugger = SegmentationDebugger(model_path=args.model, device=args.device)
debugger.display_width = args.width
debugger.display_height = args.height
......@@ -1355,13 +1589,13 @@ def main():
else:
# GUI模式
if not QT_AVAILABLE:
print(" GUI模式需要Qt库支持")
print("[ERROR] GUI模式需要Qt库支持")
print("安装方法: pip install qtpy PyQt5")
print("\n或使用命令行模式:")
print(" python detect_debug.py --source <视频源> --model <模型路径>")
return 1
print("🖼️ 使用GUI模式")
print("[GUI] 使用GUI模式")
app = QtWidgets.QApplication(sys.argv)
app.setStyle("Fusion")
......
# CSV日志补丁代码
# 将以下代码整合到detect_debug.py的VideoThread类中
# ========== 在 detect_and_visualize 中添加检测信息收集 ==========
# ROI检测部分(替换第612-616行):
result = results[0]
# 记录检测结果
roi_info = {}
if result.masks is not None:
roi_info['mask_count'] = len(result.masks.data)
roi_info['classes'] = []
roi_info['confidences'] = []
for i in range(len(result.masks.data)):
class_id = int(result.boxes.cls[i].cpu().numpy())
conf = float(result.boxes.conf[i].cpu().numpy())
if conf >= 0.3:
class_name = self.engine.model.names[class_id]
roi_info['classes'].append(class_name)
roi_info['confidences'].append(conf)
self._draw_roi_masks(annotated_frame, overlay, result, x, y, w, h)
else:
roi_info['mask_count'] = 0
roi_info['classes'] = []
roi_info['confidences'] = []
detection_info[f'ROI{roi_idx+1}'] = roi_info
# 全图检测部分(替换第631-634行):
result = results[0]
# 记录检测结果
full_info = {}
if result.masks is not None:
full_info['mask_count'] = len(result.masks.data)
full_info['classes'] = []
full_info['confidences'] = []
for i in range(len(result.masks.data)):
class_id = int(result.boxes.cls[i].cpu().numpy())
conf = float(result.boxes.conf[i].cpu().numpy())
if conf >= 0.3:
class_name = self.engine.model.names[class_id]
full_info['classes'].append(class_name)
full_info['confidences'].append(conf)
self._draw_fullframe_masks(annotated_frame, overlay, result, frame.shape)
else:
full_info['mask_count'] = 0
full_info['classes'] = []
full_info['confidences'] = []
detection_info['FullFrame'] = full_info
# 返回部分(替换第640-644行):
return annotated_frame, detection_info
# ========== 在 run 方法中添加CSV日志功能 ==========
# 启动时初始化CSV(在第772行后添加):
self.running = True
self.fps_start_time = time.time()
self.frame_counter = 0
# 初始化CSV日志文件
try:
import csv
self.csv_file = open(self.csv_log_path, 'w', newline='', encoding='utf-8-sig')
self.csv_writer = csv.writer(self.csv_file)
# 写入表头
self.csv_writer.writerow(['帧数', '检测区域', 'Mask数量', '检测类别', '置信度'])
self.csv_file.flush()
print(f"✅ 已创建CSV日志文件: {self.csv_log_path}")
except Exception as e:
print(f"⚠️ 无法创建CSV日志文件: {e}")
self.csv_file = None
self.csv_writer = None
# 检测调用部分(替换第787-791行):
# 执行检测
annotated_frame, detection_info = self.detect_and_visualize(frame)
# 记录帧数
self.frame_counter += 1
# 写入CSV日志
if self.csv_writer:
try:
if detection_info:
for region_name, info in detection_info.items():
classes_str = ', '.join(info.get('classes', []))
conf_str = ', '.join([f"{c:.2f}" for c in info.get('confidences', [])])
self.csv_writer.writerow([
self.frame_counter,
region_name,
info.get('mask_count', 0),
classes_str if classes_str else '无',
conf_str if conf_str else '无'
])
else:
self.csv_writer.writerow([self.frame_counter, '全图', 0, '无', '无'])
# 每10帧刷新一次文件
if self.frame_counter % 10 == 0:
self.csv_file.flush()
except Exception as e:
print(f"⚠️ 写入CSV失败: {e}")
# 发送帧
self.frame_ready.emit(annotated_frame)
# 释放资源部分(在第807行cap.release()后添加):
# 关闭CSV文件
if self.csv_file:
try:
self.csv_file.close()
print(f"✅ 已保存CSV日志,共记录 {self.frame_counter} 帧")
except Exception as e:
print(f"⚠️ 关闭CSV文件失败: {e}")
......@@ -11,13 +11,6 @@ from pathlib import Path
# 导入动态路径获取函数
from database.config import get_temp_models_dir
# 导入检测逻辑模块
from handlers.videopage.detection_logic import (
parse_yolo_result,
calculate_liquid_height,
process_yolo_result_to_liquid_height
)
# ==================== 辅助函数 ====================
......@@ -32,6 +25,84 @@ def get_class_color(class_name):
return color_map.get(class_name, (128, 128, 128)) # 默认灰色
def calculate_foam_boundary_lines(mask):
"""计算foam mask的顶部和底部边界线"""
if np.sum(mask) == 0:
return None, None
y_coords = np.where(mask)[0]
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, container_pixel_height):
"""分析多个foam,找到可能的液位边界"""
if len(foam_masks) < 2:
return None
foam_boundaries = []
# 计算每个foam的边界信息
for i, mask in enumerate(foam_masks):
top_y, bottom_y = calculate_foam_boundary_lines(mask)
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
# 按垂直位置排序
foam_boundaries.sort(key=lambda x: x['center_y'])
error_threshold_px = container_pixel_height * 0.1
# 检查相邻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)
if boundary_distance <= error_threshold_px:
liquid_level_y = (upper_bottom + lower_top) / 2
return liquid_level_y
return None
def stable_median(data, max_std=1.0):
"""稳健地计算中位数"""
if len(data) == 0:
......@@ -50,23 +121,26 @@ def stable_median(data, max_std=1.0):
return float(np.median(data)) if len(data) > 0 else 0
# ==================== 主检测引擎 ====================
# ==================== 模型检测基类 ====================
class LiquidDetectionEngine:
class ModelDetect:
"""
液位检测引擎
模型检测基类
输入:
1. 标注数据(boxes, fixed_bottoms, fixed_tops, actual_heights)
2. 视频帧
负责:
1. 模型加载和验证
2. 设备管理
3. 配置解析
4. 状态管理
5. 资源清理
出:
液位高度数据字典
入:图像
输出:YOLO分割结果
"""
def __init__(self, model_path=None, device='cuda', batch_size=4):
"""
初始化检测引擎(支持GPU批处理加速)
初始化模型检测基类
Args:
model_path: YOLO模型路径(.pt 或 .dat 文件)
......@@ -94,15 +168,6 @@ class LiquidDetectionEngine:
self.frame_counters = []
self.consecutive_rejects = []
self.last_observations = []
# 滤波参数
self.smooth_window = 5
self.error_percentage = 30 # 误差百分比阈值
# 🔥 延迟加载模型 - 不在构造函数中加载,避免程序启动时自动下载 yolo11n.pt
# 模型将在实际需要时通过显式调用 load_model() 加载
# if model_path:
# self.load_model(model_path)
def _validate_device(self, device):
"""验证并选择可用的设备"""
......@@ -338,9 +403,9 @@ class LiquidDetectionEngine:
# 初始化状态列表
num_targets = len(self.targets)
self.recent_observations = [[] for _ in range(num_targets)]
self.no_liquid_count = [0] * num_targets
self.last_liquid_heights = [None] * num_targets
self.frame_counters = [0] * num_targets
self.no_liquid_count = [0] * num_targets # 连续未检测到liquid的帧数
self.last_liquid_heights = [None] * num_targets # 最后一次检测到的液位高度
self.frame_counters = [0] * num_targets # 帧计数器(用于周期性清零)
self.consecutive_rejects = [0] * num_targets
self.last_observations = [None] * num_targets
......@@ -350,6 +415,135 @@ class LiquidDetectionEngine:
except Exception:
pass
def _ensure_state_lists_size(self, num_targets):
"""
确保状态列表长度与目标数量匹配
用于处理动态配置场景,避免列表索引越界
"""
# 检查并扩展 no_liquid_count
while len(self.no_liquid_count) < num_targets:
self.no_liquid_count.append(0)
# 检查并扩展 last_liquid_heights
while len(self.last_liquid_heights) < num_targets:
self.last_liquid_heights.append(None)
# 检查并扩展 frame_counters
while len(self.frame_counters) < num_targets:
self.frame_counters.append(0)
# 检查并扩展 recent_observations
while len(self.recent_observations) < num_targets:
self.recent_observations.append([])
# 检查并扩展 consecutive_rejects
while len(self.consecutive_rejects) < num_targets:
self.consecutive_rejects.append(0)
# 检查并扩展 last_observations
while len(self.last_observations) < num_targets:
self.last_observations.append(None)
# 检查并扩展卡尔曼滤波器
while len(self.kalman_filters) < 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([[5.0], [0]], dtype=np.float32) # 默认初始高度5mm
self.kalman_filters.append(kf)
def predict(self, image):
"""
执行YOLO分割预测
Args:
image: 输入图像 (numpy.ndarray)
Returns:
YOLO预测结果,失败返回 None
"""
if self.model is None:
return None
try:
results = self.model.predict(
source=image,
imgsz=640,
conf=0.5,
iou=0.5,
device=self.device,
batch=self.batch_size,
save=False,
verbose=False,
half=True if self.device != 'cpu' else False,
stream=False
)
return results[0] if results else None
except Exception as e:
print(f"❌ [ModelDetect] YOLO预测异常: {e}")
return None
def cleanup(self):
"""清理资源"""
try:
# 清理临时模型文件(使用动态路径)
temp_dir = Path(get_temp_models_dir())
if temp_dir.exists():
for temp_file in temp_dir.glob("temp_*.pt"):
try:
temp_file.unlink()
except:
pass
except:
pass
# ==================== 液位检测引擎 ====================
class Logic(ModelDetect):
"""
液位检测引擎(继承自 ModelDetect)
功能:
1. 模型加载和YOLO分割推理 (继承自 ModelDetect)
2. 分割结果分析 → 液位高度
3. 多帧时序稳健逻辑
4. 卡尔曼滤波 + 滑动窗口平滑
5. 满液状态判断
6. 完整检测流程 (detect 方法)
输入:视频帧 + 标注配置
输出:液位高度数据字典
别名:LiquidDetectionEngine(向后兼容)
"""
def __init__(self, model_path=None, device='cuda', batch_size=4):
"""
初始化液位计算逻辑类
Args:
model_path: YOLO模型路径(.pt 或 .dat 文件)
device: 计算设备 ('cuda', 'cpu', '0', '1' 等)
batch_size: 批处理大小(1-8,推荐4)
"""
# 调用父类初始化
super().__init__(model_path, device, batch_size)
# 调试模式开关
self.debug = True
# 滤波参数
self.smooth_window = 5
self.error_percentage = 30 # 误差百分比阈值
# 满液状态判断参数
self.full_threshold_ratio = 0.9 # 满液阈值比例(容器高度的90%)
self.full_count = [] # 满液状态计数器
self.full_confirm_frames = 3 # 连续多少帧确认满液
def _init_kalman_filters(self, num_targets):
"""初始化卡尔曼滤波器列表"""
self.kalman_filters = []
......@@ -366,6 +560,251 @@ class LiquidDetectionEngine:
self.kalman_filters.append(kf)
def analyze_masks_to_height(self, all_masks_info, container_bottom,
container_pixel_height, container_height_mm, idx):
"""
分析分割结果,计算液位高度(多帧时序稳健逻辑)
Args:
all_masks_info: mask信息列表 [(mask, class_name, confidence), ...]
container_bottom: 容器底部y坐标
container_pixel_height: 容器像素高度
container_height_mm: 容器实际高度(毫米)
idx: 目标索引
Returns:
float: 液位高度(毫米),失败返回 None
"""
pixel_per_mm = container_pixel_height / container_height_mm
liquid_height_mm = None
detection_method = "无检测"
# 分离不同类别的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:
# 找到最上层的液体mask
topmost_y = float('inf')
for i, mask in enumerate(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
if topmost_y != float('inf'):
liquid_height_px = container_bottom - topmost_y
liquid_height_mm = liquid_height_px / pixel_per_mm
liquid_height_mm = max(0, min(liquid_height_mm, container_height_mm))
detection_method = "直接liquid检测(最上层)"
# 🆕 重置所有计数器并记录最新液位
self.no_liquid_count[idx] = 0
self.frame_counters[idx] = 0
self.last_liquid_heights[idx] = liquid_height_mm
if self.debug:
print(f" ✅ [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm,计数器已重置")
return liquid_height_mm
# 🆕 如果没有检测到liquid,处理多帧时序逻辑
else:
self.no_liquid_count[idx] += 1
self.frame_counters[idx] += 1
if self.debug:
print(f" 📊 [目标{idx}] 未检测到liquid,连续{self.no_liquid_count[idx]}帧,帧计数器: {self.frame_counters[idx]}")
# 🎯 连续3帧未检测到liquid时,启用备选方法
if self.no_liquid_count[idx] >= 3:
if self.debug:
print(f" 🚨 [目标{idx}] 连续{self.no_liquid_count[idx]}帧未检测到liquid,启用备选方法")
# 方法2:多foam边界分析(第一备选方法)
if len(foam_masks) >= 2:
liquid_y = analyze_multiple_foams(foam_masks, container_pixel_height)
if liquid_y is not None:
liquid_height_px = container_bottom - liquid_y
liquid_height_mm = liquid_height_px / pixel_per_mm
liquid_height_mm = max(0, min(liquid_height_mm, container_height_mm))
detection_method = "多foam边界分析(备选)"
self.last_liquid_heights[idx] = liquid_height_mm
if self.debug:
print(f" 🌊 [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm")
# 方法2.5:单个foam下边界分析(第二备选方法)
elif len(foam_masks) == 1:
foam_mask = foam_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(foam_mask)
if bottom_y is not None:
liquid_height_px = container_bottom - bottom_y
liquid_height_mm = liquid_height_px / pixel_per_mm
liquid_height_mm = max(0, min(liquid_height_mm, container_height_mm))
detection_method = "单foam底部检测(备选)"
self.last_liquid_heights[idx] = liquid_height_mm
if self.debug:
print(f" 🫧 [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm")
# 方法3:单个air的分析(第三备选方法)
elif len(air_masks) == 1:
air_mask = air_masks[0]
y_coords = np.where(air_mask)[0]
if len(y_coords) > 0:
bottom_y = np.max(y_coords)
liquid_height_px = container_bottom - bottom_y
liquid_height_mm = liquid_height_px / pixel_per_mm
liquid_height_mm = max(0, min(liquid_height_mm, container_height_mm))
detection_method = "单air底部检测(备选)"
self.last_liquid_heights[idx] = liquid_height_mm
if self.debug:
print(f" 🌬️ [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm")
# 如果备选方法也没有结果,使用最后一次的检测结果
if liquid_height_mm is None and self.last_liquid_heights[idx] is not None:
liquid_height_mm = self.last_liquid_heights[idx]
detection_method = "使用最后液位(保持)"
if self.debug:
print(f" 📌 [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm")
else:
# 连续未检测到liquid但少于3帧,使用最后一次的检测结果
if self.last_liquid_heights[idx] is not None:
liquid_height_mm = self.last_liquid_heights[idx]
detection_method = f"保持液位({self.no_liquid_count[idx]}/3)"
if self.debug:
print(f" 🔒 [目标{idx}] {detection_method},液位: {liquid_height_mm:.2f}mm")
# 🔄 每3帧清零一次连续未检测计数器
if self.frame_counters[idx] % 3 == 0:
if self.debug:
print(f" 🔄 [目标{idx}] 达到3帧周期,清零连续未检测计数器 ({self.no_liquid_count[idx]} → 0)")
self.no_liquid_count[idx] = 0
return liquid_height_mm
def apply_kalman_smooth(self, observation, idx, container_height_mm):
"""
应用卡尔曼滤波和滑动窗口平滑
Args:
observation: 观测值(毫米)
idx: 目标索引
container_height_mm: 容器高度(毫米)
Returns:
tuple: (平滑后的高度, 是否满液)
"""
# 预测步骤
predicted = self.kalman_filters[idx].predict()
predicted_height = predicted[0][0]
# 计算预测误差(相对于容器高度的百分比)
prediction_error_percent = abs(observation - predicted_height) / container_height_mm * 100
# 检测是否是重复的观测值(保持的液位数据)
is_repeated_observation = (self.last_observations[idx] is not None and
observation == self.last_observations[idx])
filter_action = ""
# 误差控制逻辑
if prediction_error_percent > self.error_percentage:
# 误差过大,增加拒绝计数
self.consecutive_rejects[idx] += 1
# 检查是否连续6次拒绝
if self.consecutive_rejects[idx] >= 6:
# 连续6次误差过大,强制使用观测值更新(直接赋值,更激进)
self.kalman_filters[idx].statePost = np.array([[observation], [0]], dtype=np.float32)
self.kalman_filters[idx].statePre = np.array([[observation], [0]], dtype=np.float32)
final_height = observation
self.consecutive_rejects[idx] = 0 # 重置计数器
filter_action = f"强制重置(连续6次拒绝,直接赋值{observation:.2f}mm)"
else:
# 使用预测值
final_height = predicted_height
filter_action = f"使用预测值(误差{prediction_error_percent:.1f}%>{self.error_percentage}%, 拒绝{self.consecutive_rejects[idx]}/6)"
else:
# 误差可接受,正常更新
self.kalman_filters[idx].correct(np.array([[observation]], dtype=np.float32))
final_height = self.kalman_filters[idx].statePost[0][0]
self.consecutive_rejects[idx] = 0 # 重置计数器
filter_action = f"正常更新(误差{prediction_error_percent:.1f}%<={self.error_percentage}%)"
# 更新上次观测值记录
self.last_observations[idx] = observation
# 添加到滑动窗口
self.recent_observations[idx].append(final_height)
if len(self.recent_observations[idx]) > self.smooth_window:
self.recent_observations[idx].pop(0)
# 计算滑动窗口中位数
smooth_height = self.get_smooth_height(idx)
# 限制高度范围
final_height = max(0, min(final_height, container_height_mm))
smooth_height = max(0, min(smooth_height, container_height_mm))
# 🆕 满液状态判断
full_threshold_mm = container_height_mm * self.full_threshold_ratio
is_full = smooth_height >= full_threshold_mm
# 确保 full_count 列表长度足够
while len(self.full_count) <= idx:
self.full_count.append(0)
# 更新满液计数器
if is_full:
self.full_count[idx] += 1
else:
self.full_count[idx] = 0
# 确认满液状态(连续N帧都判定为满液)
is_full_confirmed = self.full_count[idx] >= self.full_confirm_frames
# 调试信息
if self.debug:
print(f" 📈 [目标{idx}] 卡尔曼滤波: 观测={observation:.2f}mm, 预测={predicted_height:.2f}mm, 滤波后={final_height:.2f}mm")
print(f" {filter_action}")
print(f" 📉 [目标{idx}] 滑动窗口: 窗口大小={len(self.recent_observations[idx])}/{self.smooth_window}, 中位数={smooth_height:.2f}mm")
full_status = "🌊满液" if is_full_confirmed else ("接近满液" if is_full else "正常")
print(f" 💧 [目标{idx}] 满液判断: 阈值={full_threshold_mm:.2f}mm, 状态={full_status}, 计数={self.full_count[idx]}/{self.full_confirm_frames}")
# 🆕 返回滑动窗口平滑后的高度和满液状态
return smooth_height, is_full_confirmed
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] = []
if target_idx < len(self.no_liquid_count):
self.no_liquid_count[target_idx] = 0
if target_idx < len(self.frame_counters):
self.frame_counters[target_idx] = 0
def detect(self, frame, annotation_config=None):
"""
检测帧中的液位高度
......@@ -404,6 +843,9 @@ class LiquidDetectionEngine:
if not targets:
return {'liquid_line_positions': {}, 'success': False}
# 🔧 确保状态列表长度与 targets 数量匹配
self._ensure_state_lists_size(len(targets))
try:
h, w = frame.shape[:2]
liquid_line_positions = {}
......@@ -422,16 +864,36 @@ class LiquidDetectionEngine:
continue
# 执行检测(传入top坐标和配置用于坐标转换)
liquid_height_raw_mm = self._detect_single_target(
result = self._detect_single_target(
cropped, idx, top,
fixed_bottoms[idx] if idx < len(fixed_bottoms) else None,
fixed_tops[idx] if idx < len(fixed_tops) else None,
actual_heights[idx] if idx < len(actual_heights) else 20.0
)
# 如果没有检测到液位,使用高度0(原始观测)
if liquid_height_raw_mm is None:
liquid_height_raw_mm = 0.0
# 解析返回值(液位高度, 满液状态, 异常标记)
if result is not None and isinstance(result, tuple):
if len(result) == 3:
liquid_height_mm, is_full, error_flag = result
elif len(result) == 2:
liquid_height_mm, is_full = result
error_flag = None
else:
liquid_height_mm = result
is_full = False
error_flag = None
else:
liquid_height_mm = result
is_full = False
error_flag = None
# 如果没有检测到液位,使用高度0
if liquid_height_mm is None:
liquid_height_mm = 0.0
is_full = False
# 如果没有异常标记,默认设置为detect_zero
if error_flag is None:
error_flag = 'detect_zero'
# 计算液位线位置
# 注意:container_bottom_y 和 container_top_y 已经是原图中的绝对坐标
......@@ -440,32 +902,21 @@ class LiquidDetectionEngine:
container_height_mm = actual_heights[idx] if idx < len(actual_heights) else 20.0
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
height_px = int(liquid_height_mm * pixel_per_mm)
# 液位线在原图中的绝对位置(container_bottom_y 已经是绝对坐标)
liquid_line_y_absolute = container_bottom_y - height_px
# 统一使用mm单位输出
# 统一使用mm单位输出,并添加满液状态和异常标记
liquid_line_positions[idx] = {
'y': liquid_line_y_absolute,
'height_mm': liquid_height_mm, # 毫米单位
'height_px': height_px,
'left': left,
'right': right
'right': right,
'is_full': is_full, # 满液状态
'error_flag': error_flag # 异常标记: None(正常), 'detect_zero'(未检测到mask), 'detect_low'(置信度过低)
}
# # 调试信息:输出数据
......@@ -546,153 +997,85 @@ class LiquidDetectionEngine:
# else:
# print(f" - ⚠️ 未检测到任何mask!")
# 解析YOLO推理结果,得到当前帧的所有实例信息
all_masks_info = parse_yolo_result(
mission_result=mission_result,
model_names=self.model.names,
image_shape=cropped.shape[:2],
confidence_threshold=0.5
)
# ==================== 多帧稳健逻辑:更新计数器 ====================
# 如果检测到 liquid 实例,则认为本帧有有效观测,清零“未检测计数”和帧计数;
# 否则累加,用于后续 foam/air 备选策略和“保持上一帧高度”。
has_liquid = any(class_name == 'liquid' for _, class_name, _ in all_masks_info)
# 确保索引安全
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
# 周期性清零,避免计数无限增长(与历史逻辑类似,每3帧清一次)
if self.frame_counters[idx] > 0 and self.frame_counters[idx] % 3 == 0:
self.no_liquid_count[idx] = 0
# 如果完全没有任何 mask,直接走后面的“保持上一帧高度 / 返回 None”逻辑
if len(all_masks_info) == 0:
liquid_height = None
liquid_height = None
# 处理检测结果
if mission_result.masks is not None:
masks = mission_result.masks.data.cpu().numpy() > 0.5
classes = mission_result.boxes.cls.cpu().numpy().astype(int)
confidences = mission_result.boxes.conf.cpu().numpy()
else:
# 分析mask获取液位高度(使用裁剪图像坐标)
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未检测到任何mask
return None, False, 'detect_zero'
# ==================== 多帧稳健逻辑:保持上一帧高度 ====================
# 如果连续未检测到 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
# 收集所有mask信息
all_masks_info = []
total_masks = len(masks) # 记录原始检测到的mask数量
except Exception as e:
print(f"[检测-目标{idx}] ❌ 检测异常: {e}")
return None
def _apply_kalman_filter(self, observation, idx, container_height_mm):
"""
应用卡尔曼滤波平滑液位高度
Args:
observation: 观测值(毫米)
idx: 目标索引
container_height_mm: 容器高度(毫米)
Returns:
float: 滤波后的高度(毫米)
"""
# 预测步骤
predicted = self.kalman_filters[idx].predict()
predicted_height = predicted[0][0]
# 计算预测误差(相对于容器高度的百分比)
prediction_error_percent = abs(observation - predicted_height) / container_height_mm * 100
# 检测是否是重复的观测值(保持的液位数据)
is_repeated_observation = (self.last_observations[idx] is not None and
observation == self.last_observations[idx])
# 误差控制逻辑
if prediction_error_percent > self.error_percentage:
# 误差过大,增加拒绝计数
self.consecutive_rejects[idx] += 1
for i in range(len(masks)):
class_name = self.model.names[classes[i]]
conf = confidences[i]
if confidences[i] >= 0.3:
resized_mask = cv2.resize(
masks[i].astype(np.uint8),
(cropped.shape[1], cropped.shape[0])
) > 0.5
all_masks_info.append((resized_mask, class_name, confidences[i]))
# 检查是否连续6次拒绝
if self.consecutive_rejects[idx] >= 6:
# 连续6次误差过大,强制使用观测值更新
self.kalman_filters[idx].correct(np.array([[observation]], dtype=np.float32))
final_height = self.kalman_filters[idx].statePost[0][0]
self.consecutive_rejects[idx] = 0 # 重置计数器
# 如果没有有效mask信息
if len(all_masks_info) == 0:
# 判断是否因为置信度过低
if total_masks > 0:
# 有检测到mask,但置信度都低于0.3
return None, False, 'detect_low'
else:
# 完全没有检测到mask
return None, False, 'detect_zero'
# ️ 关键修复:将原图坐标转换为裁剪图像坐标
# container_bottom_offset 是原图绝对坐标,需要转换为裁剪图像中的相对坐标
container_bottom_in_crop = container_bottom_offset - crop_top_y
container_top_in_crop = container_top_offset - crop_top_y
# print(f" [坐标转换-目标{idx}]:")
# print(f" - 裁剪区域top: {crop_top_y}px (原图坐标)")
# print(f" - 原图容器底部: {container_bottom_offset}px → 裁剪图像中: {container_bottom_in_crop}px")
# 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获取液位高度(使用裁剪图像坐标)
raw_liquid_height = self.analyze_masks_to_height(
all_masks_info,
container_bottom_in_crop, # 使用裁剪图像坐标
container_pixel_height,
container_height_mm,
idx
)
# 🆕 应用卡尔曼滤波和滑动窗口平滑
if raw_liquid_height is not None:
if self.debug:
print(f" 🔬 [目标{idx}] 原始检测液位: {raw_liquid_height:.2f}mm → 进入卡尔曼滤波...")
liquid_height, is_full = self.apply_kalman_smooth(raw_liquid_height, idx, container_height_mm)
if self.debug:
full_indicator = " 🌊满液" if is_full else ""
print(f" 🎯 [目标{idx}] 最终输出液位: {liquid_height:.2f}mm{full_indicator}")
# 正常检测,无异常
return liquid_height, is_full, None
else:
# 使用预测值
final_height = predicted_height
else:
# 误差可接受,正常更新
self.kalman_filters[idx].correct(np.array([[observation]], dtype=np.float32))
final_height = self.kalman_filters[idx].statePost[0][0]
self.consecutive_rejects[idx] = 0 # 重置计数器
# 更新上次观测值记录
self.last_observations[idx] = observation
# 添加到滑动窗口
self.recent_observations[idx].append(final_height)
if len(self.recent_observations[idx]) > self.smooth_window:
self.recent_observations[idx].pop(0)
# 限制高度范围
final_height = max(0, min(final_height, container_height_mm))
return final_height
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] = []
if target_idx < len(self.no_liquid_count):
self.no_liquid_count[target_idx] = 0
if target_idx < len(self.frame_counters):
self.frame_counters[target_idx] = 0
def cleanup(self):
"""清理资源"""
try:
# 清理临时模型文件(使用动态路径)
temp_dir = Path(get_temp_models_dir())
if temp_dir.exists():
for temp_file in temp_dir.glob("temp_*.pt"):
try:
temp_file.unlink()
except:
pass
except:
pass
liquid_height = None
is_full = False
if self.debug:
print(f" ⚠️ [目标{idx}] 原始检测无结果,跳过卡尔曼滤波")
# 分析失败,返回detect_zero
return liquid_height, is_full, 'detect_zero'
except Exception as e:
print(f"[检测-目标{idx}] ❌ 检测异常: {e}")
return None, False, 'detect_zero'
# ==================== 兼容性别名 ====================
# 保持向后兼容,外部代码可以继续使用 LiquidDetectionEngine
LiquidDetectionEngine = Logic
# -*- coding: utf-8 -*-
"""
液位检测逻辑模块
从YOLO模型推理结果到输出液位高度的核心逻辑
使用示例:
# 方式1: 使用完整流程函数(推荐)
from handlers.videopage.detection_logic import process_yolo_result_to_liquid_height
liquid_height_mm = process_yolo_result_to_liquid_height(
mission_result=yolo_result,
model_names=model.names,
image_shape=(height, width),
container_bottom=container_bottom_y,
container_pixel_height=container_height_px,
container_height_mm=container_height_mm,
no_liquid_count=0
)
# 方式2: 分步处理
from handlers.videopage.detection_logic import parse_yolo_result, calculate_liquid_height
# 步骤1: 解析YOLO结果
all_masks_info = parse_yolo_result(
mission_result=yolo_result,
model_names=model.names,
image_shape=(height, width),
confidence_threshold=0.5
)
# 步骤2: 计算液位高度
liquid_height_mm = calculate_liquid_height(
all_masks_info=all_masks_info,
container_bottom=container_bottom_y,
container_pixel_height=container_height_px,
container_height_mm=container_height_mm,
no_liquid_count=0
)
"""
import cv2
import numpy as np
from typing import List, Tuple, Optional, Dict, Any
# ==================== 辅助函数 ====================
def calculate_foam_boundary_lines(mask: np.ndarray) -> Tuple[Optional[float], Optional[float]]:
"""
计算foam mask的顶部和底部边界线
Args:
mask: 二值mask数组 (H, W)
Returns:
(top_line_y, bottom_line_y): 顶部和底部边界线的y坐标,失败返回 (None, None)
"""
if np.sum(mask) == 0:
return None, None
y_coords = np.where(mask)[0]
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: List[np.ndarray], container_pixel_height: float) -> Optional[float]:
"""
分析多个foam,找到可能的液位边界
Args:
foam_masks: foam mask列表
container_pixel_height: 容器像素高度
Returns:
float: 液位y坐标,失败返回 None
"""
if len(foam_masks) < 2:
return None
foam_boundaries = []
# 计算每个foam的边界信息
for i, mask in enumerate(foam_masks):
top_y, bottom_y = calculate_foam_boundary_lines(mask)
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
# 按垂直位置排序
foam_boundaries.sort(key=lambda x: x['center_y'])
error_threshold_px = container_pixel_height * 0.1
# 检查相邻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)
if boundary_distance <= error_threshold_px:
liquid_level_y = (upper_bottom + lower_top) / 2
return liquid_level_y
return None
# ==================== YOLO结果解析 ====================
def parse_yolo_result(
mission_result: Any,
model_names: Dict[int, str],
image_shape: Tuple[int, int],
confidence_threshold: float = 0.5
) -> List[Tuple[np.ndarray, str, float]]:
"""
解析YOLO推理结果,提取mask信息
Args:
mission_result: YOLO推理结果对象(ultralytics Results对象)
model_names: 类别名称映射字典 {class_id: class_name}
image_shape: 图像尺寸 (height, width)
confidence_threshold: 置信度阈值,默认0.5
Returns:
List[Tuple[mask, class_name, confidence]]: mask信息列表
- mask: 二值mask数组 (H, W, bool)
- class_name: 类别名称 ('liquid', 'foam', 'air')
- confidence: 置信度
"""
all_masks_info = []
# 检查是否有检测结果
if mission_result.masks is None:
return all_masks_info
# 提取数据
masks = mission_result.masks.data.cpu().numpy() > 0.5
classes = mission_result.boxes.cls.cpu().numpy().astype(int)
confidences = mission_result.boxes.conf.cpu().numpy()
# 处理每个检测结果
for i in range(len(masks)):
class_id = classes[i]
confidence = confidences[i]
# 过滤低置信度结果
if confidence < confidence_threshold:
continue
# 获取类别名称
class_name = model_names.get(class_id, 'unknown')
# 调整mask尺寸到图像尺寸
resized_mask = cv2.resize(
masks[i].astype(np.uint8),
(image_shape[1], image_shape[0])
) > 0.5
all_masks_info.append((resized_mask, class_name, float(confidence)))
return all_masks_info
# ==================== 液位高度计算 ====================
def calculate_liquid_height(
all_masks_info: List[Tuple[np.ndarray, str, float]],
container_bottom: float,
container_pixel_height: float,
container_height_mm: float,
no_liquid_count: int = 0
) -> Optional[float]:
"""
从mask信息计算液位高度(核心逻辑)
Args:
all_masks_info: mask信息列表 [(mask, class_name, confidence), ...]
container_bottom: 容器底部y坐标(裁剪图像坐标系)
container_pixel_height: 容器像素高度
container_height_mm: 容器实际高度(毫米)
no_liquid_count: 连续未检测到liquid的帧数,默认0
Returns:
float: 液位高度(毫米),失败返回 None
"""
pixel_per_mm = container_pixel_height / container_height_mm
# 分离不同类别的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:
# 找到最上层的液体mask
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
if topmost_y != float('inf'):
liquid_height_px = container_bottom - topmost_y
liquid_height_mm = liquid_height_px / pixel_per_mm
return max(0, min(liquid_height_mm, container_height_mm))
# 方法2:foam边界分析(备选)- 连续3帧未检测到liquid时启用
if no_liquid_count >= 3:
if len(foam_masks) >= 2:
# 多个foam,寻找液位边界
liquid_y = analyze_multiple_foams(foam_masks, container_pixel_height)
if liquid_y is not None:
liquid_height_px = container_bottom - liquid_y
liquid_height_mm = liquid_height_px / pixel_per_mm
return max(0, min(liquid_height_mm, container_height_mm))
elif len(foam_masks) == 1:
# 单个foam,使用下边界
foam_mask = foam_masks[0]
top_y, bottom_y = calculate_foam_boundary_lines(foam_mask)
if bottom_y is not None:
liquid_height_px = container_bottom - bottom_y
liquid_height_mm = liquid_height_px / pixel_per_mm
return max(0, min(liquid_height_mm, container_height_mm))
elif len(air_masks) == 1:
# 单个air,使用下边界
air_mask = air_masks[0]
y_coords = np.where(air_mask)[0]
if len(y_coords) > 0:
bottom_y = np.max(y_coords)
liquid_height_px = container_bottom - bottom_y
liquid_height_mm = liquid_height_px / pixel_per_mm
return max(0, min(liquid_height_mm, container_height_mm))
return None
# ==================== 完整流程函数 ====================
def process_yolo_result_to_liquid_height(
mission_result: Any,
model_names: Dict[int, str],
image_shape: Tuple[int, int],
container_bottom: float,
container_pixel_height: float,
container_height_mm: float,
no_liquid_count: int = 0,
confidence_threshold: float = 0.5
) -> Optional[float]:
"""
完整的处理流程:从YOLO推理结果到液位高度
Args:
mission_result: YOLO推理结果对象
model_names: 类别名称映射字典 {class_id: class_name}
image_shape: 图像尺寸 (height, width)
container_bottom: 容器底部y坐标(裁剪图像坐标系)
container_pixel_height: 容器像素高度
container_height_mm: 容器实际高度(毫米)
no_liquid_count: 连续未检测到liquid的帧数,默认0
confidence_threshold: 置信度阈值,默认0.5
Returns:
float: 液位高度(毫米),失败返回 None
"""
# 步骤1: 解析YOLO结果
all_masks_info = parse_yolo_result(
mission_result=mission_result,
model_names=model_names,
image_shape=image_shape,
confidence_threshold=confidence_threshold
)
# 如果没有检测到任何mask,返回None
if len(all_masks_info) == 0:
return None
# 步骤2: 计算液位高度
liquid_height_mm = calculate_liquid_height(
all_masks_info=all_masks_info,
container_bottom=container_bottom,
container_pixel_height=container_pixel_height,
container_height_mm=container_height_mm,
no_liquid_count=no_liquid_count
)
return liquid_height_mm
"""
检测结果稳定器 - 基于物理规则的后处理模块
不需要训练,纯规则约束,用于消除检测跳变
"""
from collections import deque
from typing import List, Dict, Optional
import numpy as np
class DetectionStabilizer:
"""基于规则的检测结果稳定器"""
def __init__(self,
history_size=5,
pixel_change_threshold=0.20,
conf_switch_threshold=0.85,
area_tolerance=0.15):
"""
Args:
history_size: 历史帧数量
pixel_change_threshold: 像素数量变化阈值(比例)
conf_switch_threshold: 类别切换所需的置信度阈值
area_tolerance: 总面积变化容忍度
"""
self.history_size = history_size
self.pixel_change_threshold = pixel_change_threshold
self.conf_switch_threshold = conf_switch_threshold
self.area_tolerance = area_tolerance
# 历史记录
self.history = deque(maxlen=history_size)
# 统计信息
self.stats = {
'stabilized_count': 0,
'rejected_count': 0,
'total_frames': 0
}
def process(self, current_detection: Dict) -> Dict:
"""
处理当前帧的检测结果
Args:
current_detection: {
'masks': [
{'class': 'liquid', 'conf': 0.87, 'pixels': 2486, 'center_y': 50},
{'class': 'air', 'conf': 0.34, 'pixels': 2490, 'center_y': 30}
]
}
Returns:
稳定后的检测结果
"""
self.stats['total_frames'] += 1
# 第一帧,直接返回
if len(self.history) == 0:
self.history.append(current_detection)
return current_detection
# 应用规则链
stable_result = self._apply_rules(current_detection)
# 更新历史
self.history.append(stable_result)
return stable_result
def _apply_rules(self, current: Dict) -> Dict:
"""应用所有规则"""
# 规则1:空间位置检查(液体应在下方)
current = self._check_spatial_logic(current)
# 规则2:类别切换检查
current = self._check_class_transition(current)
# 规则3:像素数量平滑
current = self._smooth_pixel_counts(current)
# 规则4:总面积守恒检查
current = self._check_area_conservation(current)
# 规则5:置信度阈值动态调整
current = self._apply_confidence_filter(current)
return current
def _check_spatial_logic(self, current: Dict) -> Dict:
"""
规则1:检查空间位置逻辑
液体应该在容器下方,空气在上方
"""
masks = current.get('masks', [])
liquid_masks = [m for m in masks if m['class'] == 'liquid']
air_masks = [m for m in masks if m['class'] == 'air']
# 如果同时存在液体和空气
if liquid_masks and air_masks:
liquid_y = np.mean([m.get('center_y', 0) for m in liquid_masks])
air_y = np.mean([m.get('center_y', 0) for m in air_masks])
# 如果空气在下方,液体在上方(违反物理规律)
if air_y > liquid_y:
print(f"[WARNING] 空间逻辑异常:air在下(y={air_y:.1f}), liquid在上(y={liquid_y:.1f}),交换类别")
# 交换类别标签
for m in masks:
if m['class'] == 'liquid':
m['class'] = 'air'
m['original_class'] = 'liquid'
elif m['class'] == 'air':
m['class'] = 'liquid'
m['original_class'] = 'air'
self.stats['stabilized_count'] += 1
return current
def _check_class_transition(self, current: Dict) -> Dict:
"""
规则2:检查类别切换是否合理
禁止突然从100% air变成100% liquid
"""
if len(self.history) < 3:
return current
# 获取历史类别分布
prev_classes = self._get_class_distribution(self.history[-1])
curr_classes = self._get_class_distribution(current)
# 检测是否发生完全切换
prev_dominant = max(prev_classes, key=prev_classes.get) if prev_classes else None
curr_dominant = max(curr_classes, key=curr_classes.get) if curr_classes else None
if prev_dominant and curr_dominant and prev_dominant != curr_dominant:
prev_ratio = prev_classes[prev_dominant]
curr_ratio = curr_classes[curr_dominant]
# 如果上一帧是单一类别(>90%),当前帧切换到另一类别(>90%)
if prev_ratio > 0.9 and curr_ratio > 0.9:
print(f"[WARNING] 类别突变:{prev_dominant}(100%) -> {curr_dominant}(100%),保持历史")
# 使用历史结果
self.stats['rejected_count'] += 1
return self._copy_result(self.history[-1])
return current
def _smooth_pixel_counts(self, current: Dict) -> Dict:
"""
规则3:像素数量平滑
防止像素数突变
"""
if len(self.history) < 2:
return current
prev_masks = self.history[-1].get('masks', [])
curr_masks = current.get('masks', [])
# 按类别匹配历史mask
for curr_mask in curr_masks:
class_name = curr_mask['class']
# 查找历史中相同类别的mask
prev_mask = self._find_mask_by_class(prev_masks, class_name)
if prev_mask:
prev_pixels = prev_mask['pixels']
curr_pixels = curr_mask['pixels']
# 计算变化率
if prev_pixels > 0:
change_ratio = abs(curr_pixels - prev_pixels) / prev_pixels
# 如果变化超过阈值,进行平滑
if change_ratio > self.pixel_change_threshold:
# 限制变化幅度
max_change = prev_pixels * self.pixel_change_threshold
if curr_pixels > prev_pixels:
smoothed_pixels = prev_pixels + max_change
else:
smoothed_pixels = prev_pixels - max_change
print(f"[WARNING] 像素突变:{class_name} {prev_pixels} -> {curr_pixels},"
f"平滑为 {int(smoothed_pixels)}")
curr_mask['pixels'] = int(smoothed_pixels)
curr_mask['smoothed'] = True
self.stats['stabilized_count'] += 1
return current
def _check_area_conservation(self, current: Dict) -> Dict:
"""
规则4:总面积守恒
ROI内的总像素数应该相对稳定
"""
if len(self.history) < 2:
return current
# 计算当前总面积
curr_total = sum(m['pixels'] for m in current.get('masks', []))
# 计算历史平均总面积
hist_totals = []
for h in self.history:
total = sum(m['pixels'] for m in h.get('masks', []))
hist_totals.append(total)
hist_avg = np.mean(hist_totals)
if hist_avg > 0:
deviation = abs(curr_total - hist_avg) / hist_avg
if deviation > self.area_tolerance:
print(f"[WARNING] 总面积异常:历史均值={hist_avg:.0f}, 当前={curr_total}, "
f"偏差={deviation:.1%},使用历史数据")
self.stats['rejected_count'] += 1
return self._copy_result(self.history[-1])
return current
def _apply_confidence_filter(self, current: Dict) -> Dict:
"""
规则5:动态置信度过滤
根据历史稳定性调整置信度要求
"""
if len(self.history) < 3:
return current
# 评估历史稳定性
stability = self._calculate_stability()
# 如果历史稳定,提高切换阈值
if stability > 0.8:
threshold = self.conf_switch_threshold
else:
threshold = 0.70
# 过滤低置信度的mask
masks = current.get('masks', [])
filtered_masks = []
for mask in masks:
if mask['conf'] >= threshold:
filtered_masks.append(mask)
else:
print(f"[WARNING] 低置信度过滤:{mask['class']} conf={mask['conf']:.2f} < {threshold:.2f}")
# 如果过滤后没有mask,使用历史
if not filtered_masks and masks:
print(f"[WARNING] 所有mask被过滤,使用历史数据")
return self._copy_result(self.history[-1])
current['masks'] = filtered_masks
return current
# ===== 辅助方法 =====
def _get_class_distribution(self, detection: Dict) -> Dict[str, float]:
"""获取类别分布比例"""
masks = detection.get('masks', [])
if not masks:
return {}
total_pixels = sum(m['pixels'] for m in masks)
if total_pixels == 0:
return {}
distribution = {}
for mask in masks:
class_name = mask['class']
distribution[class_name] = distribution.get(class_name, 0) + mask['pixels']
# 转换为比例
for k in distribution:
distribution[k] /= total_pixels
return distribution
def _find_mask_by_class(self, masks: List[Dict], class_name: str) -> Optional[Dict]:
"""在mask列表中查找指定类别"""
for mask in masks:
if mask['class'] == class_name:
return mask
return None
def _calculate_stability(self) -> float:
"""计算历史稳定性(0-1)"""
if len(self.history) < 3:
return 0.5
# 检查最近N帧的主导类别是否一致
dominant_classes = []
for h in self.history:
dist = self._get_class_distribution(h)
if dist:
dominant = max(dist, key=dist.get)
dominant_classes.append(dominant)
if not dominant_classes:
return 0.5
# 计算一致性
from collections import Counter
counts = Counter(dominant_classes)
most_common_count = counts.most_common(1)[0][1]
stability = most_common_count / len(dominant_classes)
return stability
def _copy_result(self, detection: Dict) -> Dict:
"""深拷贝检测结果"""
import copy
return copy.deepcopy(detection)
def get_stats(self) -> Dict:
"""获取统计信息"""
if self.stats['total_frames'] > 0:
stabilize_rate = self.stats['stabilized_count'] / self.stats['total_frames']
reject_rate = self.stats['rejected_count'] / self.stats['total_frames']
else:
stabilize_rate = reject_rate = 0.0
return {
**self.stats,
'stabilize_rate': f"{stabilize_rate:.1%}",
'reject_rate': f"{reject_rate:.1%}"
}
def reset(self):
"""重置稳定器状态"""
self.history.clear()
self.stats = {
'stabilized_count': 0,
'rejected_count': 0,
'total_frames': 0
}
# ===== 使用示例 =====
if __name__ == "__main__":
# 创建稳定器
stabilizer = DetectionStabilizer(
history_size=5,
pixel_change_threshold=0.20, # 像素变化不超过20%
conf_switch_threshold=0.85, # 类别切换需要85%置信度
area_tolerance=0.15 # 总面积变化容忍15%
)
# 模拟检测结果
test_sequence = [
# 帧1-5:稳定的air
{'masks': [{'class': 'air', 'conf': 0.93, 'pixels': 2400, 'center_y': 30}]},
{'masks': [{'class': 'air', 'conf': 0.94, 'pixels': 2410, 'center_y': 30}]},
{'masks': [{'class': 'air', 'conf': 0.92, 'pixels': 2395, 'center_y': 30}]},
{'masks': [{'class': 'air', 'conf': 0.95, 'pixels': 2405, 'center_y': 30}]},
{'masks': [{'class': 'air', 'conf': 0.93, 'pixels': 2400, 'center_y': 30}]},
# 帧6:突然变成liquid(异常,应被拒绝)
{'masks': [{'class': 'liquid', 'conf': 0.87, 'pixels': 2486, 'center_y': 50}]},
# 帧7:恢复air
{'masks': [{'class': 'air', 'conf': 0.94, 'pixels': 2400, 'center_y': 30}]},
# 帧8-10:开始真正过渡
{'masks': [
{'class': 'air', 'conf': 0.90, 'pixels': 2300, 'center_y': 25},
{'class': 'liquid', 'conf': 0.75, 'pixels': 200, 'center_y': 55}
]},
{'masks': [
{'class': 'air', 'conf': 0.85, 'pixels': 2000, 'center_y': 20},
{'class': 'liquid', 'conf': 0.82, 'pixels': 500, 'center_y': 55}
]},
{'masks': [
{'class': 'air', 'conf': 0.78, 'pixels': 1500, 'center_y': 15},
{'class': 'liquid', 'conf': 0.88, 'pixels': 1000, 'center_y': 55}
]},
]
print("=" * 60)
print("检测结果稳定器测试")
print("=" * 60)
for i, detection in enumerate(test_sequence, 1):
print(f"\n--- 帧 {i} ---")
print(f"原始: {detection}")
stable = stabilizer.process(detection)
print(f"稳定: {stable}")
print("\n" + "=" * 60)
print("统计信息:")
for k, v in stabilizer.get_stats().items():
print(f" {k}: {v}")
......@@ -117,7 +117,6 @@ class GeneralSetPanelHandler:
widget.annotationEngineRequested.connect(self._handleAnnotationEngineRequest)
widget.frameLoadRequested.connect(self._handleFrameLoadRequest)
widget.annotationDataRequested.connect(self._handleAnnotationDataRequest)
widget.liveFrameRequested.connect(self._handleLiveFrameRequest)
def _handleRefreshModelList(self, model_widget=None):
"""处理刷新模型列表请求"""
......@@ -692,6 +691,9 @@ class GeneralSetPanelHandler:
# 2. 保存原始帧用于标注结果显示
self._annotation_source_frame = channel_frame.copy() if channel_frame is not None else None
# 2.5 🔥 调用自动标注检测器获取初始位置
self._applyAutoAnnotation(channel_frame)
# 3. 创建标注界面组件
annotation_widget = self.showAnnotationWidget(self.general_set_panel)
......@@ -1237,13 +1239,120 @@ class GeneralSetPanelHandler:
}
engine = SimpleAnnotationEngine()
pass
return engine
except Exception as e:
pass
return None
def _applyAutoAnnotation(self, frame):
"""调用自动标注检测器获取初始位置,设置到标注引擎中(阻塞等待模型加载)"""
import time
print(f"\n{'='*60}")
print(f"🔥 [自动标注] ===== 方法入口 =====")
try:
if frame is None or self.annotation_engine is None:
print(f"⚠️ [自动标注] 前置条件不满足! frame={frame is not None}, engine={self.annotation_engine is not None}")
return
print(f"🔥 [自动标注] 输入图像: shape={frame.shape}, dtype={frame.dtype}")
channel_id = self.general_set_panel.channel_id if self.general_set_panel else None
print(f"🔥 [自动标注] 通道ID: {channel_id}")
# 🔥 导入必要模块
from handlers.videopage.auto_dot import AutoAnnotationDetector
from handlers.videopage.thread_manager.threads.global_detection_thread import GlobalDetectionThread
# 🔥 阻塞等待模型加载完成
detection_engine = None
max_wait_time = 10 # 最大等待时间(秒)
check_interval = 0.3 # 检查间隔(秒)
waited_time = 0
print(f"🔥 [自动标注] 等待模型加载... (最大等待 {max_wait_time} 秒)")
while waited_time < max_wait_time:
# 尝试获取模型
try:
global_thread = GlobalDetectionThread.get_instance()
if global_thread and global_thread.model_pool_manager:
model_pool = global_thread.model_pool_manager
model_id = model_pool.channel_model_mapping.get(channel_id)
if model_id and model_id in model_pool.model_pool:
detection_engine = model_pool.model_pool[model_id]
if detection_engine and hasattr(detection_engine, 'model') and detection_engine.model:
print(f"✅ [自动标注] 模型已就绪! (等待了 {waited_time:.1f} 秒)")
print(f"✅ [自动标注] 复用模型: {model_id}")
break
except Exception as e:
pass # 静默忽略,继续等待
# 未就绪,等待后重试
if waited_time == 0:
print(f"⏳ [自动标注] 模型尚未加载,等待中...")
time.sleep(check_interval)
waited_time += check_interval
# 每2秒输出一次等待状态
if int(waited_time) > 0 and int(waited_time) % 2 == 0 and (waited_time - int(waited_time)) < check_interval:
print(f"⏳ [自动标注] 已等待 {waited_time:.1f} 秒...")
# 检查是否获取到模型
if detection_engine is None or not hasattr(detection_engine, 'model') or detection_engine.model is None:
print(f"❌ [自动标注] 模型加载超时! (等待了 {waited_time:.1f} 秒)")
print(f"💡 [自动标注] 提示: 请先启动检测后再进行自动标注")
print(f"{'='*60}\n")
return
# 🔥 创建自动标注检测器
print(f"🔥 [自动标注] 检测引擎类型: {type(detection_engine)}")
print(f"🔥 [自动标注] 模型类型: {type(detection_engine.model)}")
engine_device = getattr(detection_engine, 'device', 'cuda')
auto_detector = AutoAnnotationDetector(model=detection_engine.model, device=engine_device)
print(f"✅ [自动标注] 已复用全局模型,device={engine_device}")
# 执行检测
print(f"🔥 [自动标注] 开始执行检测...")
result = auto_detector.detect(frame, conf_threshold=0.5, min_area=50)
print(f"🔥 [自动标注] 检测结果: success={result.get('success')}")
print(f"🔥 [自动标注] 检测结果keys: {result.keys()}")
if 'error' in result:
print(f"🔥 [自动标注] 错误信息: {result.get('error')}")
if result.get('success'):
print(f"🔥 [自动标注] masks数量: {len(result.get('masks', []))}")
print(f"🔥 [自动标注] class_names: {result.get('class_names', [])}")
if not result.get('success'):
print(f"⚠️ [自动标注] 检测失败,使用手动标注模式")
print(f"{'='*60}\n")
return
# 获取系统格式数据
data = auto_detector.get_system_format(result, padding=10)
print(f"🔥 [自动标注] 检测到 {len(data['boxes'])} 个区域")
print(f"🔥 [自动标注] boxes: {data['boxes']}")
print(f"🔥 [自动标注] bottom_points: {data['bottom_points']}")
print(f"🔥 [自动标注] top_points: {data['top_points']}")
# 添加到标注引擎
for i, (box, bottom, top) in enumerate(zip(data['boxes'], data['bottom_points'], data['top_points'])):
self.annotation_engine.boxes.append(box)
self.annotation_engine.bottom_points.append(bottom)
self.annotation_engine.top_points.append(top)
print(f" 区域{i+1}: box{box}, top{top}, bottom{bottom}")
print(f"✅ [自动标注] 完成,已添加 {len(self.annotation_engine.boxes)} 个区域")
print(f"{'='*60}\n")
except Exception as e:
print(f"❌ [自动标注] 异常: {e}")
import traceback
traceback.print_exc()
print(f"{'='*60}\n")
def _handleAnnotationEngineRequest(self):
"""处理标注引擎请求"""
if self.annotation_engine and self.annotation_widget:
......@@ -1278,19 +1387,6 @@ class GeneralSetPanelHandler:
if self.annotation_widget:
self.annotation_widget.showAnnotationError(f"获取标注数据失败: {str(e)}")
def _handleLiveFrameRequest(self):
"""处理实时画面请求"""
try:
# 获取通道最新画面
if self.general_set_panel and self.general_set_panel.channel_id:
channel_frame = self.getLatestFrame(self.general_set_panel.channel_id)
# 更新标注界面的画面
if channel_frame is not None and self.annotation_widget:
self.annotation_widget.updateLiveFrame(channel_frame)
except Exception as e:
pass
def _initPhysicalZoomForAnnotation(self, annotation_widget):
"""为标注界面初始化物理变焦控制器"""
try:
......
......@@ -1386,7 +1386,6 @@ class MissionPanelHandler:
# 使用 updateMissionLabelByVar 方法更新标签
if hasattr(self, 'updateMissionLabelByVar'):
self.updateMissionLabelByVar(channel_num, task_folder_name)
print(f"✅ [多任务] 已更新 {channel_id} 的任务标签: {task_folder_name}")
# 删除状态更新逻辑,双击不改变任务状态
else:
......
"""
稳定器集成示例
展示如何在detect_debug.py中集成DetectionStabilizer
"""
from detection_stabilizer import DetectionStabilizer
# ===== 集成方式 =====
class VideoThreadWithStabilizer:
"""VideoThread集成稳定器的示例"""
def __init__(self):
# ... 原有初始化代码 ...
# 添加稳定器
self.stabilizer = DetectionStabilizer(
history_size=5, # 保持最近5帧
pixel_change_threshold=0.20, # 像素变化不超过20%
conf_switch_threshold=0.85, # 类别切换需要高置信度
area_tolerance=0.15 # 总面积变化容忍15%
)
def detect_and_visualize(self, frame):
"""修改后的检测方法"""
detection_info = {}
try:
# ... YOLO检测代码(保持不变)...
# ===== 新增:稳定器处理 =====
# 将检测结果转换为稳定器格式
stabilizer_input = self._convert_to_stabilizer_format(detection_info)
# 应用规则稳定
stable_result = self.stabilizer.process(stabilizer_input)
# 转换回原格式
detection_info = self._convert_from_stabilizer_format(stable_result)
# ===== 稳定器处理结束 =====
return annotated_frame, detection_info
except Exception as e:
print(f"检测异常: {e}")
return frame, detection_info
def _convert_to_stabilizer_format(self, detection_info):
"""
将detection_info转换为稳定器需要的格式
Input:
detection_info = {
'ROI1': {
'mask_count': 2,
'classes': ['liquid', 'air'],
'confidences': [0.87, 0.34],
'pixel_counts': [2486, 2490]
}
}
Output:
{
'masks': [
{'class': 'liquid', 'conf': 0.87, 'pixels': 2486, 'center_y': 50},
{'class': 'air', 'conf': 0.34, 'pixels': 2490, 'center_y': 30}
]
}
"""
masks = []
for region_name, info in detection_info.items():
classes = info.get('classes', [])
confidences = info.get('confidences', [])
pixel_counts = info.get('pixel_counts', [])
for i in range(len(classes)):
mask = {
'class': classes[i],
'conf': confidences[i],
'pixels': pixel_counts[i],
'center_y': 0, # TODO: 如果需要空间检查,需要计算质心
'region': region_name
}
masks.append(mask)
return {'masks': masks}
def _convert_from_stabilizer_format(self, stable_result):
"""将稳定器结果转换回detection_info格式"""
detection_info = {}
# 按区域分组
from collections import defaultdict
region_masks = defaultdict(list)
for mask in stable_result.get('masks', []):
region = mask.get('region', 'FullFrame')
region_masks[region].append(mask)
# 重建detection_info
for region, masks in region_masks.items():
detection_info[region] = {
'mask_count': len(masks),
'classes': [m['class'] for m in masks],
'confidences': [m['conf'] for m in masks],
'pixel_counts': [m['pixels'] for m in masks]
}
return detection_info
# ===== 完整集成代码片段 =====
def integration_patch():
"""
在detect_debug.py中的修改位置:
1. 在VideoThread.__init__中添加:
"""
init_code = '''
def __init__(self, parent=None):
super().__init__(parent)
# ... 原有代码 ...
# 添加检测结果稳定器
from handlers.videopage.detection_stabilizer import DetectionStabilizer
self.stabilizer = DetectionStabilizer(
history_size=5,
pixel_change_threshold=0.20,
conf_switch_threshold=0.85,
area_tolerance=0.15
)
self.use_stabilizer = True # 开关
'''
"""
2. 在detect_and_visualize方法返回前添加:
"""
detect_code = '''
def detect_and_visualize(self, frame):
# ... 原有检测代码 ...
# 混合掩码和原图
annotated_frame = cv2.addWeighted(annotated_frame, 1 - self.mask_alpha,
overlay, self.mask_alpha, 0)
# ===== 新增:应用稳定器 =====
if self.use_stabilizer and detection_info:
detection_info = self._apply_stabilizer(detection_info)
# ===== 稳定器结束 =====
return annotated_frame, detection_info
'''
"""
3. 添加稳定器辅助方法:
"""
helper_code = '''
def _apply_stabilizer(self, detection_info):
"""应用检测结果稳定器"""
try:
# 转换格式
masks = []
for region_name, info in detection_info.items():
for i in range(len(info.get('classes', []))):
masks.append({
'class': info['classes'][i],
'conf': info['confidences'][i],
'pixels': info['pixel_counts'][i],
'center_y': 0,
'region': region_name
})
# 稳定处理
stable = self.stabilizer.process({'masks': masks})
# 转换回原格式
from collections import defaultdict
result = defaultdict(lambda: {'mask_count': 0, 'classes': [], 'confidences': [], 'pixel_counts': []})
for mask in stable.get('masks', []):
region = mask.get('region', 'FullFrame')
result[region]['classes'].append(mask['class'])
result[region]['confidences'].append(mask['conf'])
result[region]['pixel_counts'].append(mask['pixels'])
result[region]['mask_count'] = len(result[region]['classes'])
return dict(result)
except Exception as e:
print(f"稳定器异常: {e}")
return detection_info
'''
return init_code, detect_code, helper_code
# ===== 测试CSV数据 =====
def test_with_csv_data():
"""使用实际CSV数据测试"""
stabilizer = DetectionStabilizer()
# 你的实际数据(从CSV提取)
csv_data = [
# 帧16-18的跳变案例
{'masks': [{'class': 'air', 'conf': 0.93, 'pixels': 2407, 'center_y': 30}]},
{'masks': [{'class': 'liquid', 'conf': 0.87, 'pixels': 2486, 'center_y': 50}]}, # 跳变
{'masks': [
{'class': 'liquid', 'conf': 0.87, 'pixels': 2493, 'center_y': 50},
{'class': 'air', 'conf': 0.34, 'pixels': 2490, 'center_y': 30}
]},
# 帧29-32的案例
{'masks': [{'class': 'air', 'conf': 0.84, 'pixels': 2509, 'center_y': 30}]},
{'masks': [{'class': 'air', 'conf': 0.91, 'pixels': 2501, 'center_y': 30}]},
{'masks': [
{'class': 'air', 'conf': 0.78, 'pixels': 2493, 'center_y': 30},
{'class': 'liquid', 'conf': 0.63, 'pixels': 2505, 'center_y': 50}
]},
{'masks': [{'class': 'liquid', 'conf': 0.89, 'pixels': 2505, 'center_y': 50}]}, # 跳变
]
print("\n" + "="*70)
print("真实CSV数据测试")
print("="*70)
for i, data in enumerate(csv_data, 16):
print(f"\n帧 {i}:")
orig_str = [f"{m['class']}({m['conf']:.2f})" for m in data['masks']]
print(f" 原始: {orig_str}")
stable = stabilizer.process(data)
stable_str = [f"{m['class']}({m['conf']:.2f})" for m in stable['masks']]
print(f" 稳定: {stable_str}")
print("\n" + "="*70)
stats = stabilizer.get_stats()
print(f"稳定率: {stats['stabilize_rate']}, 拒绝率: {stats['reject_rate']}")
if __name__ == "__main__":
print("=" * 70)
print("检测结果稳定器 - 集成示例")
print("=" * 70)
# 显示集成代码
init, detect, helper = integration_patch()
print("\n[1] 在 VideoThread.__init__ 中添加:")
print(init)
print("\n[2] 在 detect_and_visualize 方法中添加:")
print(detect)
print("\n[3] 添加辅助方法:")
print(helper)
# 运行测试
test_with_csv_data()
......@@ -194,32 +194,52 @@ class DisplayThread:
# 统一使用mm单位
height_mm = position_data.get('height_mm', 0)
valid = position_data.get('valid', True)
error_flag = position_data.get('error_flag', None) # 获取异常标记
# 只绘制有效的液位线
if not valid:
continue
# 绘制液位线(颜色根据数据新旧自动选择)
cv2.line(
display_frame,
(int(left), int(y_absolute)),
(int(right), int(y_absolute)),
line_color, # 新数据=红色,历史=黄色
2
)
# 根据error_flag选择绘制样式
if error_flag == 'detect_zero':
# YOLO未检测到mask:黄色虚线
draw_color = (0, 255, 255) # BGR: 黄色
line_type = cv2.LINE_AA
# 绘制虚线(多段短线模拟虚线)
dash_length = 10
gap_length = 5
x = int(left)
while x < int(right):
x_end = min(x + dash_length, int(right))
cv2.line(display_frame, (x, int(y_absolute)), (x_end, int(y_absolute)),
draw_color, 2, line_type)
x = x_end + gap_length
text_color = (0, 255, 255) # 黄色文字
elif error_flag == 'detect_low':
# 置信度低于0.3:黄色实线
draw_color = (0, 255, 255) # BGR: 黄色
cv2.line(display_frame, (int(left), int(y_absolute)), (int(right), int(y_absolute)),
draw_color, 2)
text_color = (0, 255, 255) # 黄色文字
else:
# 正常检测:使用原有颜色逻辑
draw_color = line_color
cv2.line(display_frame, (int(left), int(y_absolute)), (int(right), int(y_absolute)),
draw_color, 2)
# text_color已经在前面定义了
# 直接使用mm值并四舍五入
height_mm_int = int(np.round(height_mm, 0))
text = f"{height_mm_int}mm"
# 绘制高度文字(颜色与液位线匹配
# 绘制高度文字(颜色根据error_flag自动选择
cv2.putText(
display_frame,
text,
(int(left) + 5, int(y_absolute) - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
text_color, # 新数据=绿色,历史=黄
text_color, # 已经根据error_flag设置了颜
2
)
......
# -*- coding: utf-8 -*-
"""
模型加载诊断脚本
用于测试检测线程的模型配置加载是否正常
"""
import os
import sys
import yaml
# 添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_dir))))
sys.path.insert(0, project_root)
from detection_thread import DetectionThread
def test_model_config_loading():
"""测试模型配置加载"""
print("=" * 80)
print("模型配置加载测试")
print("=" * 80)
# 测试各个通道
for channel_id in ['channel1', 'channel2', 'channel3', 'channel4']:
print(f"\n{'='*80}")
print(f"测试 {channel_id}")
print('='*80)
# 加载模型配置
model_config = DetectionThread._load_model_config(channel_id)
if model_config:
print(f"[OK] [{channel_id}] 模型配置加载成功")
print(f" 配置内容:")
for key, value in model_config.items():
if key == 'model_path':
print(f" - {key}: {value}")
# 检查文件是否存在
if os.path.exists(value):
file_size = os.path.getsize(value) / (1024 * 1024) # MB
print(f" [OK] 文件存在 ({file_size:.2f} MB)")
else:
print(f" [ERROR] 文件不存在!")
else:
print(f" - {key}: {value}")
else:
print(f"[ERROR] [{channel_id}] 模型配置加载失败")
# 加载标注配置
print(f"\n尝试加载 {channel_id} 的标注配置...")
annotation_config = DetectionThread._load_annotation_config(channel_id)
if annotation_config:
print(f"[OK] [{channel_id}] 标注配置加载成功")
boxes = annotation_config.get('boxes', [])
print(f" - 检测区域数: {len(boxes)}")
print(f" - 实际高度: {annotation_config.get('actual_heights', [])}")
else:
print(f"[ERROR] [{channel_id}] 标注配置加载失败")
def test_default_config_structure():
"""测试 default_config.yaml 的结构"""
print("\n" + "=" * 80)
print("检查 default_config.yaml 配置结构")
print("=" * 80)
config_file = os.path.join(project_root, 'database', 'config', 'default_config.yaml')
if not os.path.exists(config_file):
print(f" 配置文件不存在: {config_file}")
return
print(f" 配置文件存在: {config_file}\n")
with open(config_file, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
# 检查各通道的模型路径配置
print("检查各通道模型路径配置:")
for i in range(1, 5):
channel_id = f'channel{i}'
model_path_key = f'{channel_id}_model_path'
if model_path_key in config:
model_path = config[model_path_key]
print(f" {model_path_key}: {model_path}")
# 转换为绝对路径
if not os.path.isabs(model_path):
model_path = model_path.replace('/', os.sep).replace('\\', os.sep)
full_path = os.path.join(project_root, model_path)
full_path = os.path.normpath(full_path)
else:
full_path = model_path
# 检查文件是否存在
if os.path.exists(full_path):
file_size = os.path.getsize(full_path) / (1024 * 1024) # MB
print(f" 文件存在: {full_path} ({file_size:.2f} MB)")
else:
print(f" 文件不存在: {full_path}")
else:
print(f" {model_path_key}: 未配置")
# 检查全局模型配置
print("\n检查全局模型配置:")
if 'model' in config:
model_config = config['model']
for key, value in model_config.items():
print(f" - {key}: {value}")
else:
print(" 未找到全局 model 配置")
# 检查GPU配置
print("\n检查GPU配置:")
print(f" - gpu_enabled: {config.get('gpu_enabled', '未配置')}")
print(f" - default_device: {config.get('default_device', '未配置')}")
print(f" - batch_processing_enabled: {config.get('batch_processing_enabled', '未配置')}")
print(f" - default_batch_size: {config.get('default_batch_size', '未配置')}")
if __name__ == '__main__':
print(f"项目根目录: {project_root}\n")
test_default_config_structure()
print("\n")
test_model_config_loading()
print("\n" + "=" * 80)
print("测试完成")
print("=" * 80)
"""
检测引擎模块
"""
\ 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
模块化处理class LiquidDetectionEngine代码,新增子类modeldetect,输入为图像输出为yolo分割结果的代码归档到class model中
模块化处理class LiquidDetectionEngine代码,新增子类modeldetect,输入为图像输出为yolo分割结果的代码归档到class model中
def _validate_device
def load_model
def _validate_model_file
def _decode_dat_model
def _parse_targets
def configure
def _ensure_state_lists_size
def cleanup(self)
class AutoAnnotationDetector自动标注检测器集成到系统中。用户点击开始标注后,加载模型,然后依旧进入到全屏标注界面,不过当前画面根据自动标注
AutoAnnotationDetector 输出:
boxes: {x1, y1, x2, y2}
points: {top, bottom, top_x, bottom_x}
↓ 转换
SimpleAnnotationEngine 格式:
boxes: (cx, cy, size)
bottom_points: (x, y)
top_points: (x, y)
\ 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` 不再读取安全线、更没有报警策略或帧回放机制,无法联动告警面板。
error_flag 可能的值为:
- **帧级状态维护与统计**
过去的 `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`)。当前引擎仅返回数值,所有可视化/刷新逻辑需要额外组件处理,否则界面不会自动反映结果。
None - 正常检测
'detect_zero' - YOLO未检测到任何分割mask
'detect_low' - 检测到mask但置信度都低于0.3detect() 方法接收到 None 后,在第882-884行统一将其转换为 0.0_,添加异常标记,if detect_zero,YOLO未检测到分割mask绘制黄色虚线,if detect_low,检测到的mask置信度都低于0.3绘制黄色实线
实例分割与多策略液面推断:对每个裁剪区域运行 YOLO 分割,整理 all_masks_info 后调用 enhanced_liquid_detection_with_foam_analysis()。该函数优先寻找最高的 liquid mask;若连续 3 帧未检测到 liquid,则依次尝试“多 foam 边界”“单 foam 底边”“单 air 底边”以及回退上一帧结果,实现多源冗余推断。
综上,现代 `handlers/videopage` 版本保留了“mask→液位高度”的核心数学流程,但缺失了历史版本中围绕数据落库、告警、滤波、统计与 UI 联动的一系列工程化步骤。如果需要达到旧版的完整功能,这些模块需要在新架构下重新整合。
总体分两个步骤
1.通过 yolo,分割结果得到检测的液位线 2.根据卡尔曼滤波的结果是否选择更新
通过 yolo 结果,一共3层,有检测液体,没有检测到液体但是有检测到泡沫或者空气,什么都没有检测到
......@@ -32,169 +21,4 @@
->卡尔曼滤波的液位高度 和 我们实际得到的差距大,这个情况可能存在跳变
->连续三次都差距大,那么就取新的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
\ No newline at end of file
"""
相机姿态检测模块
基于特征点匹配 + 单应矩阵的方法判断相机姿态是否发生变化
"""
import cv2
import numpy as np
from typing import Tuple, Optional, List, Dict
import logging
class CameraPositionDetector:
"""
相机姿态检测器
通过对比基准帧与当前帧的特征点,在排除液位孔区域后,
判断相机姿态是否发生改变
"""
def __init__(
self,
translation_threshold: float = 3.0, # 平移阈值(像素)
rotation_threshold: float = 0.5, # 旋转阈值(度)
scale_threshold: float = 0.02, # 尺度变化阈值(2%)
inlier_ratio_threshold: float = 0.5, # 内点比例阈值
min_match_count: int = 10, # 最少匹配点数量
voting_frames: int = 3, # 投票帧数
voting_ratio: float = 0.7 # 投票通过比例
):
"""
初始化相机姿态检测器
Args:
translation_threshold: 平移变化超过此值(像素)判定为姿态变化
rotation_threshold: 旋转变化超过此值(度)判定为姿态变化
scale_threshold: 尺度变化超过此值判定为姿态变化
inlier_ratio_threshold: RANSAC内点比例低于此值判定为姿态变化
min_match_count: 特征匹配最少点数,低于此值判定为姿态变化
voting_frames: 用于投票的历史帧数
voting_ratio: 投票通过比例
"""
self.translation_threshold = translation_threshold
self.rotation_threshold = rotation_threshold
self.scale_threshold = scale_threshold
self.inlier_ratio_threshold = inlier_ratio_threshold
self.min_match_count = min_match_count
self.voting_frames = voting_frames
self.voting_ratio = voting_ratio
# 初始化ORB特征检测器
self.orb = cv2.ORB_create(nfeatures=1000)
# 初始化特征匹配器
self.bf_matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
# 基准帧相关
self.ref_frame = None
self.ref_gray = None
self.ref_keypoints = None
self.ref_descriptors = None
# 掩膜(用于屏蔽液位孔区域)
self.static_mask = None
# 投票队列
self.vote_queue: List[bool] = []
# 日志
self.logger = logging.getLogger(__name__)
def set_reference_frame(
self,
frame: np.ndarray,
hole_bbox: Optional[Tuple[int, int, int, int]] = None,
hole_mask: Optional[np.ndarray] = None
) -> bool:
"""
设置基准帧
Args:
frame: 基准图像(BGR格式)
hole_bbox: 液位孔边界框 (x1, y1, x2, y2),用于生成掩膜
hole_mask: 液位孔掩膜(可选),如果提供则直接使用
Returns:
是否设置成功
"""
if frame is None or frame.size == 0:
self.logger.error("基准帧无效")
return False
# 转换为灰度图
self.ref_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame.copy()
self.ref_frame = frame.copy()
# 生成静止区域掩膜
h, w = self.ref_gray.shape
self.static_mask = np.ones((h, w), dtype=np.uint8) * 255
if hole_mask is not None:
# 使用提供的掩膜
self.static_mask[hole_mask > 0] = 0
elif hole_bbox is not None:
# 使用边界框生成掩膜,并适当扩展
x1, y1, x2, y2 = hole_bbox
# 扩展5%防止边缘干扰
expand_x = int((x2 - x1) * 0.05)
expand_y = int((y2 - y1) * 0.05)
x1 = max(0, x1 - expand_x)
y1 = max(0, y1 - expand_y)
x2 = min(w, x2 + expand_x)
y2 = min(h, y2 + expand_y)
self.static_mask[y1:y2, x1:x2] = 0
# 在静止区域提取特征点
self.ref_keypoints, self.ref_descriptors = self.orb.detectAndCompute(
self.ref_gray,
self.static_mask
)
if self.ref_descriptors is None or len(self.ref_keypoints) < self.min_match_count:
self.logger.warning(f"基准帧特征点不足: {len(self.ref_keypoints) if self.ref_keypoints else 0}")
return False
self.logger.info(f"基准帧设置成功,提取特征点数: {len(self.ref_keypoints)}")
# 重置投票队列
self.vote_queue.clear()
return True
def detect_position_change(
self,
current_frame: np.ndarray
) -> Dict:
"""
检测当前帧相对于基准帧的姿态变化
Args:
current_frame: 当前帧图像(BGR格式)
Returns:
检测结果字典,包含:
- changed: 是否发生姿态变化(布尔值)
- translation: 平移量 (dx, dy)
- rotation: 旋转角度(度)
- scale: 尺度变化
- inlier_ratio: 内点比例
- match_count: 匹配点数量
- confidence: 置信度
- voted_changed: 投票后的结果
"""
result = {
'changed': False,
'translation': (0.0, 0.0),
'rotation': 0.0,
'scale': 1.0,
'inlier_ratio': 0.0,
'match_count': 0,
'confidence': 0.0,
'voted_changed': False,
'error': None
}
# 检查是否已设置基准帧
if self.ref_gray is None or self.ref_descriptors is None:
result['error'] = "未设置基准帧"
return result
if current_frame is None or current_frame.size == 0:
result['error'] = "当前帧无效"
return result
# 转换为灰度图
current_gray = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY) if len(current_frame.shape) == 3 else current_frame.copy()
# 提取当前帧特征点
current_keypoints, current_descriptors = self.orb.detectAndCompute(
current_gray,
self.static_mask
)
if current_descriptors is None or len(current_keypoints) < self.min_match_count:
result['error'] = "当前帧特征点不足"
result['changed'] = True # 特征点不足也认为是异常
self._update_vote(True)
result['voted_changed'] = self._get_vote_result()
return result
result['match_count'] = len(current_keypoints)
# 特征匹配
matches = self.bf_matcher.knnMatch(self.ref_descriptors, current_descriptors, k=2)
# Lowe's ratio test
good_matches = []
for match_pair in matches:
if len(match_pair) == 2:
m, n = match_pair
if m.distance < 0.75 * n.distance:
good_matches.append(m)
if len(good_matches) < self.min_match_count:
result['error'] = f"有效匹配点不足: {len(good_matches)}"
result['changed'] = True
self._update_vote(True)
result['voted_changed'] = self._get_vote_result()
return result
# 提取匹配点坐标
ref_pts = np.float32([self.ref_keypoints[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
cur_pts = np.float32([current_keypoints[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
# RANSAC估计单应矩阵
H, mask = cv2.findHomography(ref_pts, cur_pts, cv2.RANSAC, 5.0)
if H is None:
result['error'] = "单应矩阵估计失败"
result['changed'] = True
self._update_vote(True)
result['voted_changed'] = self._get_vote_result()
return result
# 计算内点比例
inlier_count = np.sum(mask)
inlier_ratio = inlier_count / len(good_matches)
result['inlier_ratio'] = inlier_ratio
# 从单应矩阵提取姿态变化参数
h, w = self.ref_gray.shape
center = np.array([[w/2, h/2]], dtype=np.float32).reshape(-1, 1, 2)
transformed_center = cv2.perspectiveTransform(center, H)
# 平移
dx = transformed_center[0, 0, 0] - center[0, 0, 0]
dy = transformed_center[0, 0, 1] - center[0, 0, 1]
translation = np.sqrt(dx**2 + dy**2)
result['translation'] = (float(dx), float(dy))
# 旋转(近似)
rotation = np.arctan2(H[1, 0], H[0, 0]) * 180 / np.pi
result['rotation'] = float(rotation)
# 尺度变化
scale = np.sqrt(H[0, 0]**2 + H[1, 0]**2)
result['scale'] = float(scale)
scale_change = abs(scale - 1.0)
# 判断是否发生姿态变化
changed = False
reasons = []
if translation > self.translation_threshold:
changed = True
reasons.append(f"平移超限({translation:.2f}px)")
if abs(rotation) > self.rotation_threshold:
changed = True
reasons.append(f"旋转超限({rotation:.2f}°)")
if scale_change > self.scale_threshold:
changed = True
reasons.append(f"尺度变化超限({scale_change:.3f})")
if inlier_ratio < self.inlier_ratio_threshold:
changed = True
reasons.append(f"内点比例过低({inlier_ratio:.2f})")
result['changed'] = changed
result['confidence'] = inlier_ratio
if reasons:
result['error'] = "; ".join(reasons)
# 更新投票队列
self._update_vote(changed)
result['voted_changed'] = self._get_vote_result()
return result
def _update_vote(self, changed: bool):
"""更新投票队列"""
self.vote_queue.append(changed)
if len(self.vote_queue) > self.voting_frames:
self.vote_queue.pop(0)
def _get_vote_result(self) -> bool:
"""获取投票结果"""
if len(self.vote_queue) == 0:
return False
change_count = sum(self.vote_queue)
return change_count / len(self.vote_queue) >= self.voting_ratio
def reset(self):
"""重置检测器状态"""
self.vote_queue.clear()
def update_reference_frame(
self,
frame: np.ndarray,
hole_bbox: Optional[Tuple[int, int, int, int]] = None,
hole_mask: Optional[np.ndarray] = None
) -> bool:
"""
更新基准帧(当需要重新标定时)
Args:
frame: 新的基准图像
hole_bbox: 液位孔边界框
hole_mask: 液位孔掩膜
Returns:
是否更新成功
"""
self.reset()
return self.set_reference_frame(frame, hole_bbox, hole_mask)
def visualize_matches(
self,
current_frame: np.ndarray,
max_matches: int = 50
) -> Optional[np.ndarray]:
"""
可视化特征匹配结果(用于调试)
Args:
current_frame: 当前帧
max_matches: 最多显示的匹配数量
Returns:
可视化图像
"""
if self.ref_frame is None or self.ref_descriptors is None:
return None
current_gray = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY) if len(current_frame.shape) == 3 else current_frame
current_keypoints, current_descriptors = self.orb.detectAndCompute(current_gray, self.static_mask)
if current_descriptors is None:
return None
matches = self.bf_matcher.knnMatch(self.ref_descriptors, current_descriptors, k=2)
good_matches = []
for match_pair in matches:
if len(match_pair) == 2:
m, n = match_pair
if m.distance < 0.75 * n.distance:
good_matches.append(m)
# 限制显示数量
good_matches = sorted(good_matches, key=lambda x: x.distance)[:max_matches]
# 绘制匹配
img_matches = cv2.drawMatches(
self.ref_frame, self.ref_keypoints,
current_frame, current_keypoints,
good_matches, None,
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS
)
return img_matches
# 便捷函数
def create_detector(
translation_threshold: float = 3.0,
rotation_threshold: float = 0.5,
scale_threshold: float = 0.02
) -> CameraPositionDetector:
"""
创建相机姿态检测器的便捷函数
Args:
translation_threshold: 平移阈值(像素)
rotation_threshold: 旋转阈值(度)
scale_threshold: 尺度变化阈值
Returns:
CameraPositionDetector实例
"""
return CameraPositionDetector(
translation_threshold=translation_threshold,
rotation_threshold=rotation_threshold,
scale_threshold=scale_threshold
)
"""
相机姿态检测使用示例
展示如何在液位检测系统中集成相机姿态检测功能
"""
import cv2
import numpy as np
from utils.cameraposition import CameraPositionDetector, create_detector
def example_basic_usage():
"""基础使用示例"""
# 1. 创建检测器实例
detector = CameraPositionDetector(
translation_threshold=3.0, # 平移超过3像素判定为变化
rotation_threshold=0.5, # 旋转超过0.5度判定为变化
scale_threshold=0.02, # 尺度变化超过2%判定为变化
inlier_ratio_threshold=0.5, # 内点比例低于50%判定为变化
min_match_count=10, # 至少需要10个匹配点
voting_frames=3, # 使用3帧投票
voting_ratio=0.7 # 70%的帧判定为变化才触发
)
# 2. 设置基准帧(从视频或图像读取)
ref_frame = cv2.imread("path/to/reference_frame.jpg")
# 假设液位孔的检测框为 (x1, y1, x2, y2)
hole_bbox = (100, 150, 300, 400)
# 设置基准帧
success = detector.set_reference_frame(ref_frame, hole_bbox=hole_bbox)
if not success:
print("基准帧设置失败,请检查图像质量或特征点数量")
return
# 3. 对后续帧进行检测
cap = cv2.VideoCapture("path/to/video.mp4")
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
# 检测当前帧
result = detector.detect_position_change(frame)
# 查看检测结果
if result['voted_changed']:
print("警告:相机姿态发生变化!")
print(f" 平移: {result['translation']}")
print(f" 旋转: {result['rotation']:.2f}°")
print(f" 尺度: {result['scale']:.3f}")
print(f" 原因: {result['error']}")
# 可以在此处触发报警或暂停检测
# 建议用户重新固定相机或重新标定
else:
print("相机姿态正常")
# 显示当前帧
cv2.imshow("Frame", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
def example_with_yolo_detection():
"""结合YOLO检测结果的示例"""
# 创建检测器
detector = create_detector(
translation_threshold=3.0,
rotation_threshold=0.5,
scale_threshold=0.02
)
# 假设这是第一帧和YOLO检测结果
first_frame = cv2.imread("first_frame.jpg")
# YOLO检测到的液位孔框(xyxy格式)
yolo_bbox = [120, 180, 280, 420] # x1, y1, x2, y2
hole_bbox = tuple(yolo_bbox)
# 设置基准帧
detector.set_reference_frame(first_frame, hole_bbox=hole_bbox)
# 处理视频流
cap = cv2.VideoCapture(0) # 或者视频文件路径
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
# 先检测相机姿态
result = detector.detect_position_change(frame)
if result['voted_changed']:
# 相机姿态变化,暂停液位检测
cv2.putText(
frame,
"Camera Position Changed! Please recalibrate",
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 0, 255),
2
)
else:
# 相机姿态正常,继续液位检测
# 这里调用你的液位检测函数
# liquid_level = detect_liquid_level(frame)
pass
cv2.imshow("Video", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
def example_with_mask():
"""使用自定义掩膜的示例"""
detector = CameraPositionDetector()
# 读取基准帧
ref_frame = cv2.imread("reference.jpg")
h, w = ref_frame.shape[:2]
# 创建自定义掩膜(0表示液位孔区域,255表示静止区域)
hole_mask = np.zeros((h, w), dtype=np.uint8)
# 假设液位孔是一个圆形区域
center = (w // 2, h // 2)
radius = 100
cv2.circle(hole_mask, center, radius, 255, -1)
# 设置基准帧(使用掩膜)
detector.set_reference_frame(ref_frame, hole_mask=hole_mask)
# 后续检测...
def example_visualization():
"""可视化特征匹配的示例(用于调试)"""
detector = CameraPositionDetector()
# 设置基准帧
ref_frame = cv2.imread("reference.jpg")
hole_bbox = (100, 100, 300, 300)
detector.set_reference_frame(ref_frame, hole_bbox=hole_bbox)
# 读取测试帧
test_frame = cv2.imread("test_frame.jpg")
# 检测姿态变化
result = detector.detect_position_change(test_frame)
print(f"姿态变化: {result['changed']}")
print(f"平移: {result['translation']}")
print(f"旋转: {result['rotation']:.2f}°")
print(f"尺度: {result['scale']:.3f}")
print(f"内点比例: {result['inlier_ratio']:.2f}")
# 可视化匹配结果
vis_img = detector.visualize_matches(test_frame, max_matches=50)
if vis_img is not None:
cv2.imshow("Feature Matches", vis_img)
cv2.waitKey(0)
cv2.destroyAllWindows()
def example_integration_with_handler():
"""与现有handler集成的示例伪代码"""
class VideoHandler:
def __init__(self):
# 初始化相机姿态检测器
self.position_detector = CameraPositionDetector(
translation_threshold=3.0,
rotation_threshold=0.5,
voting_frames=5
)
self.position_calibrated = False
def on_calibrate_button_clicked(self, frame, hole_bbox):
"""当用户点击标定按钮时"""
success = self.position_detector.set_reference_frame(
frame,
hole_bbox=hole_bbox
)
if success:
self.position_calibrated = True
print("相机姿态标定成功")
else:
print("标定失败:特征点不足")
def process_frame(self, frame):
"""处理每一帧"""
if not self.position_calibrated:
return None, "未标定"
# 检测相机姿态
pos_result = self.position_detector.detect_position_change(frame)
if pos_result['voted_changed']:
# 相机姿态变化,返回警告
return None, f"相机姿态变化: {pos_result['error']}"
# 姿态正常,继续液位检测
# liquid_result = self.detect_liquid_level(frame)
# return liquid_result, "正常"
return None, "正常"
def on_recalibrate_request(self, frame, hole_bbox):
"""重新标定"""
success = self.position_detector.update_reference_frame(
frame,
hole_bbox=hole_bbox
)
return success
def example_parameter_tuning():
"""参数调优示例"""
# 对于高分辨率图像(如1920x1080)
high_res_detector = CameraPositionDetector(
translation_threshold=5.0, # 允许更大的平移
rotation_threshold=0.3, # 对旋转更敏感
scale_threshold=0.015, # 尺度阈值稍小
inlier_ratio_threshold=0.6, # 要求更高的内点比例
min_match_count=20, # 要求更多匹配点
voting_frames=5, # 更长的投票窗口
voting_ratio=0.8 # 更严格的投票标准
)
# 对于低分辨率图像(如640x480)
low_res_detector = CameraPositionDetector(
translation_threshold=2.0,
rotation_threshold=0.8,
scale_threshold=0.03,
inlier_ratio_threshold=0.4,
min_match_count=8,
voting_frames=3,
voting_ratio=0.6
)
# 对于振动环境(需要更宽松的阈值)
vibration_detector = CameraPositionDetector(
translation_threshold=5.0,
rotation_threshold=1.0,
scale_threshold=0.05,
voting_frames=7, # 更长的投票窗口平滑抖动
voting_ratio=0.8
)
if __name__ == "__main__":
# 运行基础示例
# example_basic_usage()
# 或运行其他示例
# example_with_yolo_detection()
# example_visualization()
print("请根据实际需求选择对应的示例函数运行")
......@@ -2290,8 +2290,6 @@ class TrainingPage(QtWidgets.QWidget):
if index >= 0:
self.test_model_combo.setCurrentIndex(index)
print(f"[模型同步] 已刷新模型列表,共 {len(models)} 个模型")
except Exception as e:
print(f"[错误] 刷新模型列表失败: {e}")
import traceback
......@@ -2307,7 +2305,6 @@ class TrainingPage(QtWidgets.QWidget):
if hasattr(modelset_page, 'modelListChanged'):
# 连接信号到刷新方法
modelset_page.modelListChanged.connect(self.refreshModelLists)
print("[模型同步] 已连接模型列表变化信号")
else:
print("[警告] ModelSetPage没有modelListChanged信号")
except Exception as e:
......
......@@ -518,6 +518,8 @@ class GeneralSetPanel(QtWidgets.QWidget):
def _onStartAnnotation(self):
"""开始标注按钮点击(发送信号给handler处理)"""
# 🔥 先启动全局检测线程(加载模型,弹出进度条)
self.detectionStartRequested.emit()
# 发送创建标注引擎请求信号给handler
self.createAnnotationEngineRequested.emit()
# 发送标注请求信号
......@@ -964,7 +966,6 @@ class AnnotationWidget(QtWidgets.QWidget):
annotationEngineRequested = QtCore.Signal() # 请求标注引擎
frameLoadRequested = QtCore.Signal() # 请求加载帧
annotationDataRequested = QtCore.Signal() # 请求标注数据
liveFrameRequested = QtCore.Signal() # 请求实时画面
def __init__(self, parent=None, annotation_engine=None):
super(AnnotationWidget, self).__init__(parent)
......@@ -993,10 +994,6 @@ class AnnotationWidget(QtWidgets.QWidget):
self.area_states = [] # 存储区域状态列表(默认、空、满)
self.channel_name = "" # 通道名称
# 实时画面预览相关
self.live_preview_enabled = False # 是否启用实时预览
self.live_timer = None # 实时画面更新定时器
# 物理变焦相关
self.physical_zoom_controller = None # 物理变焦控制器
self.physical_zoom_enabled = False # 是否启用物理变焦
......@@ -1007,6 +1004,9 @@ class AnnotationWidget(QtWidgets.QWidget):
self.zoom_center_x = 0 # 变焦中心X坐标
self.zoom_center_y = 0 # 变焦中心Y坐标
# 🔥 调试开关
self.debug = True
self._initUI()
self._connectSignals()
......@@ -1043,10 +1043,7 @@ class AnnotationWidget(QtWidgets.QWidget):
def _connectSignals(self):
"""连接信号"""
# 创建实时画面更新定时器
self.live_timer = QtCore.QTimer()
self.live_timer.timeout.connect(self._requestLiveFrame)
self.live_timer.setInterval(100) # 100ms更新一次,约10fps
pass # 保留方法结构,暂无额外信号需要连接
def _applyFullScreen(self):
"""应用全屏模式(延迟调用,确保控件已初始化)"""
......@@ -1064,25 +1061,6 @@ class AnnotationWidget(QtWidgets.QWidget):
"""设置通道名称(用于生成区域默认名称)"""
self.channel_name = channel_name
def enableLivePreview(self, enabled=True):
"""启用/禁用实时画面预览"""
self.live_preview_enabled = enabled
if enabled:
self.live_timer.start()
else:
self.live_timer.stop()
def _requestLiveFrame(self):
"""请求获取最新画面(通过信号通知handler)"""
if self.live_preview_enabled:
self.liveFrameRequested.emit()
def updateLiveFrame(self, frame):
"""更新实时画面(由handler调用)"""
if frame is not None and self.live_preview_enabled:
self.current_frame = frame.copy()
self._updateDisplay()
def setPhysicalZoomController(self, controller):
"""设置物理变焦控制器"""
self.physical_zoom_controller = controller
......@@ -1188,14 +1166,16 @@ class AnnotationWidget(QtWidgets.QWidget):
def _drawAnnotations(self, img):
"""绘制标注内容"""
print(f"[DEBUG _drawAnnotations] 方法被调用,annotation_engine={self.annotation_engine}")
if self.annotation_engine is None:
print(f"[DEBUG _drawAnnotations] annotation_engine为None,返回")
if self.debug:
print(f"[DEBUG _drawAnnotations] annotation_engine为None,返回")
return
print(f"[DEBUG _drawAnnotations] boxes数量: {len(self.annotation_engine.boxes) if hasattr(self.annotation_engine, 'boxes') else 0}")
print(f"[DEBUG _drawAnnotations] bottom_points数量: {len(self.annotation_engine.bottom_points) if hasattr(self.annotation_engine, 'bottom_points') else 0}")
print(f"[DEBUG _drawAnnotations] top_points数量: {len(self.annotation_engine.top_points) if hasattr(self.annotation_engine, 'top_points') else 0}")
if self.debug:
print(f"[DEBUG _drawAnnotations] boxes数量: {len(self.annotation_engine.boxes)}")
print(f"[DEBUG _drawAnnotations] bottom_points数量: {len(self.annotation_engine.bottom_points)}")
print(f"[DEBUG _drawAnnotations] top_points数量: {len(self.annotation_engine.top_points)}")
# 第一步:使用OpenCV绘制所有的框和点
# 绘制已完成的框
......@@ -1208,13 +1188,14 @@ class AnnotationWidget(QtWidgets.QWidget):
left = cx - half
right = cx + half
if self.debug:
print(f"[DEBUG _drawAnnotations] 绘制框{i}: cx={cx}, cy={cy}, size={size}, rect=({left},{top})->({right},{bottom})")
# 绘制检测框(黄色)
cv2.rectangle(img, (left, top), (right, bottom), (0, 255, 255), 3)
# 绘制底部点(绿色)- 绘制1px水平线条,长度与检测框宽度一致
print(f"[DEBUG _drawAnnotations] 底部点数量: {len(self.annotation_engine.bottom_points)}")
for i, pt in enumerate(self.annotation_engine.bottom_points):
print(f"[DEBUG _drawAnnotations] 绘制底部点 {i}: {pt}")
# 获取对应框的宽度作为线条长度
if i < len(self.annotation_engine.boxes):
_, _, size = self.annotation_engine.boxes[i]
......@@ -1225,13 +1206,14 @@ class AnnotationWidget(QtWidgets.QWidget):
x, y = pt
start_point = (x - half_length, y)
end_point = (x + half_length, y)
print(f"[DEBUG _drawAnnotations] 底部线条: start={start_point}, end={end_point}, color=(0,255,0), thickness=1, length={line_length}")
if self.debug:
print(f"[DEBUG _drawAnnotations] 绘制底部点{i}: pt={pt}, line=({start_point})->({end_point})")
cv2.line(img, start_point, end_point, (0, 255, 0), 1)
# 绘制顶部点(红色)- 绘制1px水平线条,长度与检测框宽度一致
print(f"[DEBUG _drawAnnotations] 顶部点数量: {len(self.annotation_engine.top_points)}")
for i, pt in enumerate(self.annotation_engine.top_points):
print(f"[DEBUG _drawAnnotations] 绘制顶部点 {i}: {pt}")
# 获取对应框的宽度作为线条长度
if i < len(self.annotation_engine.boxes):
_, _, size = self.annotation_engine.boxes[i]
......@@ -1242,7 +1224,10 @@ class AnnotationWidget(QtWidgets.QWidget):
x, y = pt
start_point = (x - half_length, y)
end_point = (x + half_length, y)
print(f"[DEBUG _drawAnnotations] 顶部线条: start={start_point}, end={end_point}, color=(0,0,255), thickness=1, length={line_length}")
if self.debug:
print(f"[DEBUG _drawAnnotations] 绘制顶部点{i}: pt={pt}, line=({start_point})->({end_point})")
cv2.line(img, start_point, end_point, (0, 0, 255), 1)
# 如果正在画框,显示临时框
......
"""
"""
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtMultimediaWidgets import QVideoWidget
from PyQt5.QtCore import QUrl, Qt
import os
# 导入图标工具和响应式布局
try:
from widgets.style_manager import newIcon
from widgets.responsive_layout import ResponsiveLayout, scale_w, scale_h
except ImportError:
try:
from style_manager import newIcon
from responsive_layout import ResponsiveLayout, scale_w, scale_h
except ImportError:
def newIcon(icon):
from PyQt5 import QtGui
return QtGui.QIcon()
ResponsiveLayout = None
scale_w = lambda x: x
scale_h = lambda x: x
class HistoryPanel(QtWidgets.QWidget):
""""""
def __init__(self, parent=None):
super().__init__(parent)
self._media_player = None
self._video_widget = None
self._is_seeking = False #
self._initUI()
def _initUI(self):
"""UI - 使用固定大小和绝对位置布局"""
# 🔥 设置面板固定大小 - 响应式布局
self.setFixedSize(scale_w(660), scale_h(380))
# 不使用布局管理器,直接使用绝对位置
self._createVideoArea()
self._createControlsArea()
self._createInfoArea()
def _createVideoArea(self):
"""创建视频显示区域 - 固定位置和大小"""
self._video_widget = QVideoWidget(self)
# 固定位置和大小:左上角(5, 5),宽度650,高度300
self._video_widget.setGeometry(5, 5, 650, 300)
self._video_widget.setStyleSheet(
"background: black; "
"border: 2px solid #dee2e6; "
"border-radius: 4px;"
)
#
self._media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface)
self._media_player.setVideoOutput(self._video_widget)
#
self._media_player.positionChanged.connect(self._onPositionChanged)
self._media_player.durationChanged.connect(self._onDurationChanged)
self._media_player.stateChanged.connect(self._onStateChanged)
self._media_player.error.connect(self._onError)
def _createControlsArea(self):
"""创建控制区域 - 固定位置和大小"""
button_style = """
QPushButton {
background-color: transparent;
border: 1px solid transparent;
border-radius: 3px;
padding: 5px;
}
QPushButton:hover {
background-color: palette(light);
border: 1px solid palette(mid);
}
QPushButton:pressed {
background-color: palette(midlight);
}
QPushButton:disabled {
opacity: 0.5;
}
"""
# 播放/暂停按钮 - 固定位置(5, 310),大小35x35
self.play_pause_button = QtWidgets.QPushButton(self)
self.play_pause_button.setGeometry(5, 310, 35, 35)
self.play_pause_button.setIcon(newIcon('开始'))
self.play_pause_button.setIconSize(QtCore.QSize(24, 24))
self.play_pause_button.setToolTip('播放')
self.play_pause_button.setStyleSheet(button_style)
# 进度条 - 固定位置(45, 318),宽度380
self.position_slider = QtWidgets.QSlider(Qt.Horizontal, self)
self.position_slider.setGeometry(45, 318, 380, 20)
self.position_slider.setRange(0, 0)
self.position_slider.setStyleSheet("""
QSlider::groove:horizontal {
border: 1px solid #999;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #2196F3;
border: 1px solid #1976D2;
width: 18px;
margin: -5px 0;
border-radius: 9px;
}
QSlider::sub-page:horizontal {
background: #2196F3;
border-radius: 4px;
}
""")
self.position_slider.sliderPressed.connect(self._onSliderPressed)
self.position_slider.sliderReleased.connect(self._onSliderReleased)
self.position_slider.sliderMoved.connect(self._onSliderMoved)
# 时间标签 - 固定位置(430, 315),宽度85
self.time_label = QtWidgets.QLabel("00:00 / 00:00", self)
self.time_label.setGeometry(430, 315, 85, 25)
self.time_label.setStyleSheet("color: #666; font-size: 8pt;")
self.time_label.setAlignment(Qt.AlignCenter)
# 音量控制器已删除,不再需要
# 设置默认音量
self._media_player.setVolume(50)
def _createInfoArea(self):
"""创建信息区域(已隐藏)"""
self.info_label = QtWidgets.QLabel("", self)
self.info_label.setGeometry(5, 350, 650, 25)
self.info_label.setStyleSheet(
"color: #666; "
"font-size: 7pt; "
"padding: 3px;"
)
self.info_label.setWordWrap(True)
self.info_label.setVisible(False) # 隐藏文件路径标签
def loadVideo(self, video_path, title=None, info_text=None):
"""
Args:
video_path:
title:
info_text:
"""
print(f"[HistoryPanel] ========== loadVideo ==========")
print(f"[HistoryPanel] : {video_path}")
print(f"[HistoryPanel] : {os.path.exists(video_path)}")
if not os.path.exists(video_path):
print(f"[HistoryPanel] : ")
QtWidgets.QMessageBox.warning(self, "", f":\n{video_path}")
return False
try:
#
if info_text:
self.info_label.setText(info_text)
print(f"[HistoryPanel] : {info_text}")
else:
self.info_label.setText(f": {video_path}")
print(f"[HistoryPanel] ")
#
abs_path = os.path.abspath(video_path)
print(f"[HistoryPanel] : {abs_path}")
video_url = QUrl.fromLocalFile(abs_path)
print(f"[HistoryPanel] URL: {video_url.toString()}")
print(f"[HistoryPanel] URL: {video_url.isValid()}")
media_content = QMediaContent(video_url)
print(f"[HistoryPanel] : {media_content}")
print(f"[HistoryPanel] : {media_content.isNull()}")
print(f"[HistoryPanel] ...")
self._media_player.setMedia(media_content)
print(f"[HistoryPanel] : {self._media_player.state()}")
print(f"[HistoryPanel] : {self._media_player.mediaStatus()}")
print(f"[HistoryPanel] : {self._media_player.error()}")
if self._media_player.error() != QMediaPlayer.NoError:
print(f"[HistoryPanel] : {self._media_player.errorString()}")
print(f"[HistoryPanel] ")
return True
except Exception as e:
print(f"[HistoryPanel] : {e}")
import traceback
traceback.print_exc()
QtWidgets.QMessageBox.warning(self, "", f":\n{str(e)}")
return False
def play(self):
""""""
self._media_player.play()
def pause(self):
""""""
self._media_player.pause()
def stop(self):
""""""
self._media_player.stop()
def _onPositionChanged(self, position):
""""""
if not self._is_seeking:
self.position_slider.setValue(position)
#
current_time = position // 1000
total_time = self._media_player.duration() // 1000
self.time_label.setText(
f"{current_time//60:02d}:{current_time%60:02d} / "
f"{total_time//60:02d}:{total_time%60:02d}"
)
def _onDurationChanged(self, duration):
""""""
self.position_slider.setRange(0, duration)
def _onStateChanged(self, state):
"""handler"""
#
pass
def _onError(self, error):
""""""
error_string = self._media_player.errorString()
print(f"[HistoryPanel] : {error_string}")
QtWidgets.QMessageBox.warning(self, "", f":\n{error_string}")
def _onSliderPressed(self):
""""""
self._is_seeking = True
def _onSliderReleased(self):
""""""
self._is_seeking = False
self._media_player.setPosition(self.position_slider.value())
def _onSliderMoved(self, position):
""""""
#
current_time = position // 1000
total_time = self._media_player.duration() // 1000
self.time_label.setText(
f"{current_time//60:02d}:{current_time%60:02d} / "
f"{total_time//60:02d}:{total_time%60:02d}"
)
def _onVolumeChanged(self, value):
""""""
self._media_player.setVolume(value)
def setStatistics(self, stats_dict):
"""
Args:
stats_dict: {
'total_frames': 100,
'detection_count': 33,
'success_count': 30,
'fail_count': 3,
'success_rate': 90.9
}
"""
#
stats_text = (
f": {stats_dict.get('total_frames', 0)} | "
f": {stats_dict.get('detection_count', 0)} | "
f": {stats_dict.get('success_count', 0)} | "
f": {stats_dict.get('fail_count', 0)} | "
f": {stats_dict.get('success_rate', 0):.1f}%"
)
#
if not hasattr(self, 'stats_label'):
#
stats_widget = QtWidgets.QWidget()
stats_widget.setStyleSheet(
"background: #f8f9fa; "
"border: 1px solid #dee2e6; "
"border-radius: 5px; "
"padding: 12px;"
)
stats_layout = QtWidgets.QVBoxLayout(stats_widget)
stats_layout.setContentsMargins(0, 0, 0, 0)
stats_title = QtWidgets.QLabel("")
stats_title.setStyleSheet(
"color: #333; "
"font-size: 10pt; "
"font-weight: bold; "
"background: transparent; "
"border: none; "
"padding: 0;"
)
stats_layout.addWidget(stats_title)
self.stats_label = QtWidgets.QLabel(stats_text)
self.stats_label.setStyleSheet(
"color: #666; "
"font-size: 8pt; "
"background: transparent; "
"border: none; "
"padding: 3px 0;"
)
stats_layout.addWidget(self.stats_label)
#
main_layout = self.layout()
main_layout.insertWidget(1, stats_widget)
else:
self.stats_label.setText(stats_text)
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