diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6aaa21 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# 🌊 FrothAnalysisQtPython - 浮选泡沫图像分析与监控系统 + +[![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/) +[![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://wiki.qt.io/Qt_for_Python) +[![OpenCV](https://img.shields.io/badge/CV-OpenCV-red.svg)](https://opencv.org/) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +**FrothAnalysisQtPython** 是一款专为**铅锌浮选工艺**设计的工业级桌面应用程序。系统基于 PySide6 +构建,深度集成了机器视觉算法与工业控制逻辑,能够实时监测**铅快粗、铅精一/二/三**等关键工序的泡沫状态,并结合 OPC 数据实现闭环控制。 + +--- + +## ✨ 核心功能 (Key Features) + +### 1. 👁️ 智能机器视觉分析 + +* **多工序监测**: 支持同时连接 4 路工业相机,覆盖铅快粗 (Rougher) 及铅精选 (Cleaner 1-3) 各级工序。 +* **特征提取 (Feature Extraction)**: + * **动态特征**: 基于 **SURF** 算法计算泡沫流速、速度方差及流动稳定性。 + * **纹理特征**: 基于 **GLCM (灰度共生矩阵)** 计算能量、对比度、相关性等纹理指标。 + * **统计特征**: 实时分析红灰比 (Red/Gray Ratio)、灰度直方图 (偏度/峰度)。 + +### 2. 📊 实时工艺监控 (Monitoring) + +* **KPI 仪表盘**: 实时显示 **原矿铅品位 (Feed)**、**高铅精矿品位 (Conc)** 及 **铅回收率 (Recovery)**。 +* **趋势追踪**: 内置高性能绘图组件 (PyQtGraph),以 10 分钟为周期动态展示品位变化趋势。 +* **状态指示**: 采用美化的 StatCard 组件,直观展示各指标的实时数值与健康状态。 + +### 3. 🎛️ 过程智能控制 (Control) + +* **双模式切换**: 支持 **自动 (Auto)** 与 **手动 (Manual)** 控制模式无缝切换。 +* **液位控制**: 针对 4 个浮选槽独立配置 PID 参数 ($K_p, K_i, K_d$),实现液位精准调节。 +* **精准加药**: + * 覆盖捕收剂、起泡剂、抑制剂等多种药剂类型。 + * 实时监控加药流量 (ml/min) 与设备运行状态。 +* **效能评估**: 实时计算系统的**控制效果**、**稳定性指标**及**能耗效率**。 + +### 4. 📈 历史数据与报表 (History) + +* **全参数记录**: 自动记录品位数据及详细的药剂消耗量(丁黄药、乙硫氮、石灰、2#油、DS1/DS2等)。 +* **灵活查询**: 支持按日期范围筛选,提供数据可视化统计(平均品位、最高值、运行时长)。 +* **一键导出**: 支持将查询结果导出为 CSV 格式,便于二次分析。 + +--- + +## 🛠️ 环境依赖 (Requirements) + +本项目基于 **Python 3.10+** 开发。主要依赖库如下: + +* **GUI**: `PySide6==6.10.0`, `pyqtgraph==0.13.7` +* **图像处理**: `opencv-python==4.12.0.88`, `numpy==2.2.6`, `matplotlib==3.10.7` +* **通讯与网络**: `requests==2.32.5`, `opcua==0.98.13`, `python-snap7==2.0.2` +* **数据处理**: `pandas==2.3.3` +* **其他**: `cryptography` + +--- + +## 🚀 快速开始 (Quick Start) + +### 1. 克隆仓库 + +```bash +git clone [https://github.com/YourUsername/FrothAnalysisQtPython.git](https://github.com/YourUsername/FrothAnalysisQtPython.git) +cd FrothAnalysisQtPython +``` + +### 2. 创建并激活虚拟环境 + +```bash +# Windows +python -m venv venv +venv\Scripts\activate + +# Linux/macOS +python3 -m venv venv +source venv/bin/activate +``` + +### 3. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 4. 配置文件 + +在运行前,请确保 config/ 和 resources/tags/ 目录下的配置正确: + +- OPC 标签表: 编辑 resources/tags/tagList.csv,添加需要采集的标签名称。 + +- 系统配置: 检查 config/config.json 或相关 Python 配置文件中的相机 IP 和服务器地址。 + +### 5. 启动系统 + +```bash +python main.py +``` + +## 📂 项目结构 (Project Structure) + +```text +FrothAnalysisQtPython/ +├── config/ # 配置中心 +│ ├── camera_configs.py # 相机节点与RTSP配置 +│ ├── system_settings.json# 全局系统参数 +│ └── tank_configs.py # 槽体参数配置 +├── data/ # 数据持久化 (SQLite数据库/自动备份) +├── logs/ # 系统运行日志 (按日期归档) +├── resources/ # 静态资源 +│ ├── styles/ # QSS 界面样式表 +│ └── tags/ # OPC/PLC 点位映射表 +├── src/ # 源代码 +│ ├── core/ # 核心架构 (Application, EventBus) +│ ├── services/ # 后端服务 +│ │ ├── opc_service.py # OPC/HTTP 数据采集与断线重连 +│ │ ├── data_service.py # SQLite 数据存储服务 +│ │ └── video_service.py# 视频流采集与分发 +│ ├── utils/ # 算法工具 +│ │ └── feature_extract.py # SURF, GLCM 特征提取算法 +│ └── views/ # UI 视图层 +│ ├── components/ # 复用组件 (StatCard, TankWidget) +│ ├── pages/ # 主要页面 +│ │ ├── monitoring_page.py # 实时监控 (KPI, 趋势图) +│ │ ├── control_page.py # 智能控制 (PID, 加药) +│ │ └── history_page.py # 历史报表 (查询, 导出) +│ └── main_window.py # 主窗口框架 +└── main.py # 程序入口 +``` + +## ⚙️ 核心算法说明 + +### 泡沫流速计算 (SURF) + +系统利用 cv2.xfeatures2d_SURF 检测前后两帧图像的特征点,通过 Brute-Force Matcher +进行匹配,计算特征点位移向量: $$ v = \frac{\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}}{\Delta t} $$ + +### 纹理分析 (GLCM) + +通过构建灰度共生矩阵,量化泡沫表面的物理特征: + +- Homogeneity (同质性): 反映气泡大小分布的均匀程度。 + +- Contrast (对比度): 反映气泡边缘的清晰度与沟槽深度。 + +### 闭环控制策略 + +- 液位控制: 采用增量式 PID 算法,实时调节排矿阀开度。 + +- 药剂配比: 根据实时品位反馈(Feed/Conc Grade)动态调整丁黄药与乙硫氮的添加比例。 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index eef9a0b..466546a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ -PySide6==6.10.0 -opencv-python==4.12.0.88 -pyqtgraph==0.13.7 -numpy==2.2.6 -requests==2.32.5 -python-snap7==2.0.2 -cryptography==46.0.3 -matplotlib==3.10.7 -pandas==2.3.3 -opcua==0.98.13 \ No newline at end of file +PySide6>=6.10.0 +opencv-python>=4.12.0.88 +pyqtgraph>=0.13.7 +numpy>=2.2.6 +requests>=2.32.5 +python-snap7>=2.0.2 +cryptography>=46.0.3 +matplotlib>=3.10.7 +pandas>=2.3.3 +opcua>=0.98.13 +scikit-image>=0.19.0 \ No newline at end of file diff --git a/src/utils/feature_extract.py b/src/utils/feature_extract.py index 902d976..7eefc42 100644 --- a/src/utils/feature_extract.py +++ b/src/utils/feature_extract.py @@ -1,789 +1,374 @@ import cv2 import numpy as np -from PIL import Image -# 加载图像 +import logging import os -import cv2 -import numpy as np -from PIL import Image import pandas as pd +from typing import Tuple, Dict, List, Optional from tqdm import tqdm - - -def process_images_in_folder(folder_path): - # 遍历文件夹中的所有子文件夹 - subfolders = [f.path for f in os.scandir(folder_path) if f.is_dir()] - - # 创建一个空的 DataFrame 来存储结果 - data = pd.DataFrame( - columns=['Image', 'Red/Gray Ratio', 'Mean', 'Variance', 'Skewness', 'Kurtosis']) - - # 使用 tqdm 显示处理进度 - for subfolder in tqdm(subfolders, desc='Processing Subfolders', unit='folder'): - image_files = [f.path for f in os.scandir(subfolder) if - f.is_file() and f.name.endswith(('.jpg', '.jpeg', '.png'))] - - for image_file in tqdm(image_files, desc='Processing Images', unit='image', leave=False): - # 加载图像 - image = cv2.imread(image_file) - target_size = (256, 256) - image = cv2.resize(image, target_size) - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - gray_pil = Image.fromarray(gray) - # 提取红色通道 - red_channel = image[:, :, 2] # OpenCV中颜色通道的顺序是BGR,因此红色通道对应索引2 - # 计算红色分量的均值 - red_mean = red_channel.mean() - gray_image = 0.289 * image[:, :, 2] + 0.587 * image[:, :, 1] + 0.114 * image[:, :, 0] - gray_image = gray_image.astype('uint8') - # 计算灰度图像的均值 - gray_mean = gray_image.mean() - # 计算红色分量与灰度图像均值的比值 - red_gray_ratio = red_mean / gray_mean - - # 计算灰度图像的统计特征 - width, height = gray_pil.size - pixel_counts = np.zeros(256) - total_pixels = width * height - for y in range(height): - for x in range(width): - pixel_value = gray_pil.getpixel((x, y)) - pixel_counts[pixel_value] += 1 - pixel_probility = pixel_counts / total_pixels - b = np.arange(256) - mean = np.sum(b * pixel_probility) - variance = np.sum(((b - mean) ** 2) * pixel_probility) - skewness = np.sum(((b - mean) ** 3) * pixel_probility) / variance ** 3 - kurtosis = np.sum(((b - mean) ** 4) * pixel_probility) / variance ** 4 - - # 将结果添加到 DataFrame 中 - data = pd.concat([data, pd.DataFrame({'Image': [os.path.basename(image_file)], - - 'Red/Gray Ratio': [red_gray_ratio], - 'Mean': [mean], - 'Variance': [variance], - 'Skewness': [skewness], - 'Kurtosis': [kurtosis]})]) - - return data - - -# 文件夹路径 -folder_path = "D:/JetBrains/sample/validation" - -# 处理图像并获取结果 -result = process_images_in_folder(folder_path) - -# 保存结果到 Excel 文件 -result.to_excel('image_statistics_val.xlsx', index=False) - -image = cv2.imread("test3.jpg") -target_size = (256, 256) -image = cv2.resize(image, target_size) -gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) -gray_pil = Image.fromarray(gray) -# 提取红色通道 -red_channel = image[:, :, 2] # OpenCV中颜色通道的顺序是BGR,因此红色通道对应索引2 - -# 计算红色分量的均值 -red_mean = red_channel.mean() - -gray_image = 0.289 * image[:, :, 2] + 0.587 * image[:, :, 1] + 0.114 * image[:, :, 0] -gray_image = gray_image.astype('uint8') -# 计算红色分量 -gray_mean = gray_image.mean() -Rrelative = red_mean / gray_mean - -print("红色分量比重:", Rrelative) - -width = 256 -height = 256 - -# 统计每个像素值的数量 -pixel_counts = np.zeros(256) -total_pixels = width * height - -# 遍历图像中的每个像素 -for y in range(height): - for x in range(width): - pixel_value = gray_pil.getpixel((x, y)) - pixel_counts[pixel_value] += 1 -pixel_probility = pixel_counts / total_pixels -b = np.arange(256) -mean = np.sum(b * pixel_probility) -varience = np.sum(((b - mean) ** 2) * pixel_probility) -skewness = np.sum(((b - mean) ** 3) * pixel_probility) / varience ** 3 -kurtosis = np.sum(((b - mean) ** 4) * pixel_probility) / varience ** 4 -print("均值:", mean) -print("方差:", varience) -print("偏度:", skewness) -print("峰度:", kurtosis) - -import os - - -def rename_images(folder_path): - # 遍历文件夹中的所有子文件夹 - subfolders = [f.path for f in os.scandir(folder_path) if f.is_dir()] - - for subfolder in subfolders: - # 遍历子文件夹中的所有图片文件 - for file_name in os.listdir(subfolder): - # 检查文件名是否符合要求 - if file_name.startswith("frame_") and file_name.endswith(".jpg"): - # 获取文件名中的数字部分 - number = file_name.split("_")[1].split(".")[0] - # 如果数字部分是个位数,则在前面加上一个 "0" - if len(number) == 1: - new_name = "frame_0" + number + ".jpg" - # 重命名图片文件 - os.rename(os.path.join(subfolder, file_name), os.path.join(subfolder, new_name)) - - -# 文件夹路径 -folder_path = "D:/JetBrains/sample/validation" - -# 执行重命名操作 -rename_images(folder_path) - -# GLCM代码 -# coding: utf-8 -# The code is written by Linghui - -import numpy as np -from skimage import data -from matplotlib import pyplot as plt -import get_glcm -import time -from PIL import Image -import cv2 -from pathlib import Path - - -def main(): - pass - - -if __name__ == '__main__': - - main() - - start = time.time() - - print('---------------0. Parameter Setting-----------------') - nbit = 8 # gray levels - mi, ma = 0, 255 # max gray and min gray - slide_window = 6 # sliding window - step = [1, 2, 3, 4] # step - angle = [0, np.pi / 4, np.pi / 2, np.pi * 3 / 4] # angle or direction - - print('-------------------1. Load Data---------------------') - image = r"D:\JetBrains\sample\train\0.9999\frame_22.jpg" - - img = np.array(Image.open(image)) # If the image has multi-bands, it needs to be converted to grayscale image - img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) - - print(img.shape) - img = np.uint8(255.0 * (img - np.min(img)) / (np.max(img) - np.min(img))) # normalization - h, w = img.shape - print('------------------2. Calcu GLCM---------------------') - glcm = get_glcm.calcu_glcm(img, mi, ma, nbit, slide_window, step, angle) - homogeneity = np.zeros((glcm.shape[2], glcm.shape[3], glcm.shape[4], glcm.shape[5]), dtype=np.float32) - contrast = np.zeros((glcm.shape[2], glcm.shape[3], glcm.shape[4], glcm.shape[5]), dtype=np.float32) - energy = np.zeros((glcm.shape[2], glcm.shape[3], glcm.shape[4], glcm.shape[5]), dtype=np.float32) - correlation = np.zeros((glcm.shape[2], glcm.shape[3], glcm.shape[4], glcm.shape[5]), dtype=np.float32) - - print('-----------------3. Calcu Feature-------------------') - # - for i in range(glcm.shape[2]): - for j in range(glcm.shape[3]): - glcm_cut = np.zeros((nbit, nbit, h, w), dtype=np.float32) - glcm_cut = glcm[:, :, i, j, :, :] - mean = get_glcm.calcu_glcm_mean(glcm_cut, nbit) - variance = get_glcm.calcu_glcm_variance(glcm_cut, nbit) - homogeneity[i, j, :, :] = get_glcm.calcu_glcm_homogeneity(glcm_cut, nbit) - contrast[i, j, :, :] = get_glcm.calcu_glcm_contrast(glcm_cut, nbit) - # dissimilarity = get_glcm.calcu_glcm_dissimilarity(glcm_cut, nbit) - # entropy = get_glcm.calcu_glcm_entropy(glcm_cut, nbit) - energy[i, j, :, :] = get_glcm.calcu_glcm_energy(glcm_cut, nbit) - correlation[i, j, :, :] = get_glcm.calcu_glcm_correlation(glcm_cut, nbit) - # Auto_correlation = get_glcm.calcu_glcm_Auto_correlation(glcm_cut, nbit) - mean_homogeneity = np.mean(homogeneity, axis=(0, 1)) - mean_contrast = np.mean(contrast, axis=(0, 1)) - mean_energy = np.mean(energy, axis=(0, 1)) - mean_correlation = np.mean(correlation, axis=(0, 1)) - print('---------------4. Display and Result----------------') - plt.figure(figsize=(6, 4.5)) - font = {'family': 'Times New Roman', - 'weight': 'normal', - 'size': 12, +from scipy import ndimage as ndi + +from skimage.feature import graycomatrix, graycoprops, local_binary_pattern, peak_local_max +from skimage.measure import regionprops +from skimage.segmentation import watershed + +# 配置日志 +logger = logging.getLogger(__name__) + + +class FrothFeatureExtractor: + """ + [核心算法层] 浮选泡沫图像特征提取器 + 包含颜色、纹理、形态学及动态特征提取算法。 + """ + + @staticmethod + def extract_all_static_features(image: np.ndarray) -> Dict[str, float]: + """ + 一次性提取所有静态特征(颜色 + 纹理 + 形态学)。 + """ + features = {} + # 1. 颜色特征 + features.update(FrothFeatureExtractor.extract_color_stats(image)) + # 2. 纹理特征 (GLCM & LBP) + features.update(FrothFeatureExtractor.extract_texture_glcm(image)) + features.update(FrothFeatureExtractor.extract_texture_lbp(image)) + # 3. 形态学特征 (气泡分割) + features.update(FrothFeatureExtractor.extract_morphological_features(image)) + return features + + @staticmethod + def extract_color_stats(image: np.ndarray, target_size: Tuple[int, int] = (256, 256)) -> Dict[str, float]: + """提取颜色统计特征 (RGB/HSV/红灰比)""" + if image is None: return {} + try: + if target_size and (image.shape[0] != target_size[0] or image.shape[1] != target_size[1]): + image = cv2.resize(image, target_size) + + # --- RGB 空间 (OpenCV默认为BGR) --- + b, g, r = cv2.split(image) + r_mean = np.mean(r) + g_mean = np.mean(g) + b_mean = np.mean(b) + + # 计算灰度 (加权) + gray = (0.299 * r + 0.587 * g + 0.114 * b) + gray_mean = np.mean(gray) + + # 红灰比 (Red/Gray Ratio) - 关键浮选指标 + red_gray_ratio = r_mean / gray_mean if gray_mean > 0 else 0.0 + + # 统计矩 (基于灰度) + gray_flat = gray.flatten() + gray_std = np.std(gray_flat) + gray_var = gray_std ** 2 + + # 偏度与峰度 + if gray_std > 0: + skewness = np.mean(((gray_flat - gray_mean) / gray_std) ** 3) + kurtosis = np.mean(((gray_flat - gray_mean) / gray_std) ** 4) + else: + skewness, kurtosis = 0.0, 0.0 + + # --- HSV 空间 --- + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + h_mean, s_mean, v_mean = np.mean(hsv, axis=(0, 1)) + + return { + 'color_r_mean': float(r_mean), + 'color_g_mean': float(g_mean), + 'color_b_mean': float(b_mean), + 'color_gray_mean': float(gray_mean), + 'color_red_gray_ratio': float(red_gray_ratio), + 'color_variance': float(gray_var), + 'color_skewness': float(skewness), + 'color_kurtosis': float(kurtosis), + 'color_h_mean': float(h_mean), + 'color_s_mean': float(s_mean), + 'color_v_mean': float(v_mean) } + except Exception as e: + logger.error(f"颜色特征提取错误: {e}") + return {} + + @staticmethod + def extract_texture_glcm(image: np.ndarray, nbit: int = 64) -> Dict[str, float]: + """提取 GLCM (灰度共生矩阵) 纹理特征""" + if image is None: return {} + try: + if len(image.shape) == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # 压缩灰度级 (量化) 以减少计算量 + img_digitized = (image / 256.0 * nbit).astype(np.uint8) + img_digitized = np.clip(img_digitized, 0, nbit - 1) + + # 计算 GLCM (距离=1, 多角度平均) + g_matrix = graycomatrix(img_digitized, [1], [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4], + levels=nbit, symmetric=True, normed=True) + + return { + 'glcm_contrast': float(graycoprops(g_matrix, 'contrast').mean()), + 'glcm_dissimilarity': float(graycoprops(g_matrix, 'dissimilarity').mean()), + 'glcm_homogeneity': float(graycoprops(g_matrix, 'homogeneity').mean()), + 'glcm_energy': float(graycoprops(g_matrix, 'energy').mean()), + 'glcm_correlation': float(graycoprops(g_matrix, 'correlation').mean()) + } + except Exception as e: + logger.error(f"GLCM 提取错误: {e}") + return {} + + @staticmethod + def extract_texture_lbp(image: np.ndarray) -> Dict[str, float]: + """提取 LBP (局部二值模式) 纹理特征""" + if image is None: return {} + try: + if len(image.shape) == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # LBP 计算 (半径1, 8个点) + lbp = local_binary_pattern(image, 8, 1, method='uniform') + + # 计算直方图熵 (Entropy) + # Uniform LBP 产生 10 种模式 (8+2) + hist, _ = np.histogram(lbp.ravel(), bins=np.arange(0, 11), density=True) + hist = hist + 1e-7 # 避免 log(0) + entropy = -np.sum(hist * np.log2(hist)) + + return {'lbp_entropy': float(entropy)} + except Exception as e: + logger.error(f"LBP 提取错误: {e}") + return {} + + @staticmethod + def extract_morphological_features(image: np.ndarray) -> Dict[str, float]: + """提取形态学特征 (分水岭算法分割气泡并统计尺寸)""" + if image is None: return {} + try: + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image + + # 1. 预处理 (CLAHE增强 + 高斯模糊) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) + + # 2. 阈值分割 (Otsu) + _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # 3. 距离变换与种子生成 + distance = ndi.distance_transform_edt(thresh) + # min_distance 决定了最小识别的气泡间距 + coords = peak_local_max(distance, min_distance=7, labels=thresh) + mask = np.zeros(distance.shape, dtype=bool) + mask[tuple(coords.T)] = True + markers, _ = ndi.label(mask) + + # 4. 分水岭算法 + labels = watershed(-distance, markers, mask=thresh) + + # 5. 区域属性统计 + regions = regionprops(labels) + + # 过滤极小的噪点区域 + regions = [r for r in regions if r.area > 5] + + if not regions: + return { + 'bubble_count': 0.0, 'bubble_mean_diam': 0.0, + 'bubble_d10': 0.0, 'bubble_d50': 0.0, 'bubble_d90': 0.0 + } + + # 计算等效直径 + diams = np.array([r.equivalent_diameter for r in regions]) + areas = np.array([r.area for r in regions]) + + # 计算圆度 (4*pi*Area / Perimeter^2) + circularities = [(4 * np.pi * r.area) / (r.perimeter ** 2) if r.perimeter > 0 else 0 for r in regions] + + return { + 'bubble_count': float(len(regions)), + 'bubble_mean_area': float(np.mean(areas)), + 'bubble_std_area': float(np.std(areas)), + 'bubble_mean_diam': float(np.mean(diams)), + 'bubble_d10': float(np.percentile(diams, 10)), + 'bubble_d50': float(np.percentile(diams, 50)), # 中值粒径 + 'bubble_d90': float(np.percentile(diams, 90)), + 'bubble_mean_circularity': float(np.mean(circularities)) + } + except Exception as e: + logger.error(f"形态学特征提取错误: {e}") + return {} + + @staticmethod + def extract_dynamic_features(img1: np.ndarray, img2: np.ndarray, time_interval: float = 0.15) -> Dict[str, float]: + """ + 提取动态特征 (优先 SURF -> SIFT -> ORB) + 需要两帧图像 + """ + if img1 is None or img2 is None: return {} + + # 统一转灰度 + if len(img1.shape) == 3: img1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) + if len(img2.shape) == 3: img2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY) + + detector = None + algorithm_name = "SURF" + + # 算法选择逻辑 + try: + if hasattr(cv2, 'xfeatures2d'): + detector = cv2.xfeatures2d.SURF_create(400) + else: + raise AttributeError + except: + try: + algorithm_name = "SIFT" + detector = cv2.SIFT_create() + except: + algorithm_name = "ORB" + detector = cv2.ORB_create(1000) + + try: + kp1, des1 = detector.detectAndCompute(img1, None) + kp2, des2 = detector.detectAndCompute(img2, None) + + if des1 is None or des2 is None or len(kp1) < 2 or len(kp2) < 2: + return {'speed_mean': 0.0, 'speed_variance': 0.0, 'stability': 0.0} + + # 匹配逻辑 + matcher = cv2.BFMatcher() + matches = [] + + if algorithm_name == "ORB": + # ORB 使用 Hamming 距离 + matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + matches = matcher.match(des1, des2) + else: + # SIFT/SURF 使用 KNN + 比率测试 + raw_matches = matcher.knnMatch(des1, des2, k=2) + for m, n in raw_matches: + if m.distance < 0.6 * n.distance: + matches.append(m) + + if not matches: + return {'speed_mean': 0.0, 'speed_variance': 0.0, 'stability': 0.0} + + # 提取坐标计算距离 + pts1 = np.float32([kp1[m.queryIdx].pt for m in matches]) + pts2 = np.float32([kp2[m.trainIdx].pt for m in matches]) + + # 欧氏距离 + distances = np.sqrt(np.sum((pts2 - pts1) ** 2, axis=1)) + + if time_interval <= 0: time_interval = 0.1 + speeds = distances / time_interval + + # 稳定性: 匹配点数量 / 平均特征点总数 + stability = len(matches) / ((len(kp1) + len(kp2)) / 2.0) + + return { + 'speed_mean': float(np.mean(speeds)), + 'speed_variance': float(np.var(speeds)), + 'stability': float(stability) + } + except Exception as e: + logger.error(f"动态特征提取错误 ({algorithm_name}): {e}") + return {'speed_mean': 0.0, 'speed_variance': 0.0, 'stability': 0.0} - plt.subplot(2, 3, 1) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(img, cmap='gray') - plt.title('Original', font) - - plt.subplot(2, 3, 2) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(mean_homogeneity, cmap='gray') - plt.title('Homogeneity', font) - - plt.subplot(2, 3, 3) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(mean_contrast, cmap='gray') - plt.title('Contrast', font) - - plt.subplot(2, 3, 4) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(mean_energy, cmap='gray') - plt.title('Energy', font) - - plt.subplot(2, 3, 5) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(mean_correlation, cmap='gray') - plt.title('Correlation', font) - save_dir = Path(r"C:\Users\23911\Desktop\paper\2025-07-28") - save_dir.mkdir(parents=True, exist_ok=True) - - plt.imsave(str(save_dir / "original.png"), img, cmap='gray') - plt.imsave(str(save_dir / "homogeneity.png"), mean_homogeneity, cmap='gray') - plt.imsave(str(save_dir / "contrast.png"), mean_contrast, cmap='gray') - plt.imsave(str(save_dir / "energy.png"), mean_energy, cmap='gray') - plt.imsave(str(save_dir / "correlation.png"), mean_correlation, cmap='gray') - - plt.subplot(2, 5, 7) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(entropy, cmap='gray') - plt.title('Entropy', font) - - plt.subplot(2, 5, 8) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(energy, cmap='gray') - plt.title('Energy', font) - - plt.subplot(2, 5, 9) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(correlation, cmap='gray') - plt.title('Correlation', font) - - plt.subplot(2, 5, 10) - plt.tick_params(labelbottom=False, labelleft=False) - plt.axis('off') - plt.imshow(Auto_correlation, cmap='gray') - plt.title('Auto Correlation', font) - - print(np.mean(mean_homogeneity)) - print(np.mean(mean_contrast)) - print(np.mean(mean_energy)) - print(np.mean(mean_correlation)) - plt.tight_layout(pad=0.5) - plt.savefig('E:/Study/!Blibli/3.GLCM/GLCM/GLCM_Features.png' - , format='png' - , bbox_inches='tight' - , pad_inches=0 - , dpi=300) - plt.show() - - end = time.time() -print('Code run time:', end - start) -# coding: utf-8 -# The code is written by Linghui - -import numpy as np -import matplotlib.pyplot as plt -import cv2 -import skimage -from PIL import Image -from skimage import data -from math import floor, ceil -from skimage.feature import graycomatrix, graycoprops - - -def main(): - pass - - -def image_patch(img2, slide_window, h, w): - image = img2 - window_size = slide_window - patch = np.zeros((slide_window, slide_window, h, w), dtype=np.uint8) - - for i in range(patch.shape[2]): - for j in range(patch.shape[3]): - patch[:, :, i, j] = img2[i: i + slide_window, j: j + slide_window] - - return patch +class FrothBatchProcessor: + """ + [业务处理层] 批量处理工具 + 用于处理文件夹下的图像数据集并导出 Excel + """ -def calcu_glcm(img, vmin=0, vmax=255, nbit=64, slide_window=7, step=[2], angle=[0]): - mi, ma = vmin, vmax - h, w = img.shape + @staticmethod + def process_folder(root_folder: str, output_file: str = 'static_features.xlsx'): + """ + 遍历文件夹 -> 提取静态特征 -> 保存 Excel + """ + if not os.path.exists(root_folder): + logger.error(f"路径不存在: {root_folder}") + return - # Compressed gray range:vmin: 0-->0, vmax: 256-1 -->nbit-1 - bins = np.linspace(mi, ma + 1, nbit + 1) - img1 = np.digitize(img, bins) - 1 + results = [] + # 获取所有子文件夹 + subfolders = [f.path for f in os.scandir(root_folder) if f.is_dir()] - # (512, 512) --> (slide_window, slide_window, 512, 512) - img2 = cv2.copyMakeBorder(img1, floor(slide_window / 2), floor(slide_window / 2) - , floor(slide_window / 2), floor(slide_window / 2), cv2.BORDER_REPLICATE) # 图像扩充 + # 如果根目录下直接有图片,也算一个任务 + if not subfolders: + subfolders = [root_folder] - patch = np.zeros((slide_window, slide_window, h, w), dtype=np.uint8) - patch = image_patch(img2, slide_window, h, w) + logger.info(f"开始处理目录: {root_folder}") - # Calculate GLCM (7, 7, 512, 512) --> (64, 64, 512, 512) - # greycomatrix(image, distances, angles, levels=None, symmetric=False, normed=False) - glcm = np.zeros((nbit, nbit, len(step), len(angle), h, w), dtype=np.uint8) - for i in range(patch.shape[2]): - for j in range(patch.shape[3]): - glcm[:, :, :, :, i, j] = graycomatrix(patch[:, :, i, j], step, angle, levels=nbit) + for folder in tqdm(subfolders, desc="Folders"): + folder_name = os.path.basename(folder) - return glcm + image_files = [f.path for f in os.scandir(folder) + if f.is_file() and f.name.lower().endswith(('.jpg', '.png', '.jpeg', '.bmp'))] + for img_path in tqdm(image_files, desc=f"Img in {folder_name}", leave=False): + img = cv2.imread(img_path) + if img is None: continue -def calcu_glcm_mean(glcm, nbit=64): - ''' - calc glcm mean - ''' - mean = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - mean += glcm[i, j] * i / (nbit) ** 2 + # 提取特征 + feats = FrothFeatureExtractor.extract_all_static_features(img) - return mean + # 添加元数据 + feats['folder'] = folder_name + feats['filename'] = os.path.basename(img_path) + results.append(feats) + if results: + df = pd.DataFrame(results) + # 调整列顺序: folder, filename 在最前 + cols = ['folder', 'filename'] + [c for c in df.columns if c not in ['folder', 'filename']] + df = df[cols] -def calcu_glcm_variance(glcm, nbit=64): - ''' - calc glcm variance - ''' - mean = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - mean += glcm[i, j] * i / (nbit) ** 2 - - variance = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - variance += glcm[i, j] * (i - mean) ** 2 - - return variance - - -def calcu_glcm_homogeneity(glcm, nbit=64): - ''' - calc glcm Homogeneity - ''' - Homogeneity = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - Homogeneity += glcm[i, j] / (1. + (i - j) ** 2) + if output_file.endswith('.csv'): + df.to_csv(output_file, index=False) + else: + df.to_excel(output_file, index=False) + logger.info(f"静态特征提取完成,已保存至: {output_file}") + else: + logger.info("未提取到任何数据。") - return Homogeneity + @staticmethod + def process_dynamic_folder(root_folder: str, output_file: str = 'dynamic_features.xlsx', interval: float = 0.15): + """ + 遍历文件夹 -> 提取动态特征(前后帧对比) -> 保存 Excel + """ + results = [] + subfolders = [f.path for f in os.scandir(root_folder) if f.is_dir()] + if not subfolders: subfolders = [root_folder] + logger.info(f"开始动态特征分析: {root_folder}") -def calcu_glcm_contrast(glcm, nbit=64): - ''' - calc glcm contrast - ''' - contrast = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - contrast += glcm[i, j] * (i - j) ** 2 + for folder in tqdm(subfolders, desc="Dynamic Folders"): + folder_name = os.path.basename(folder) + files = sorted([f.path for f in os.scandir(folder) + if f.is_file() and f.name.lower().endswith(('.jpg', '.png'))]) - return contrast + if len(files) < 2: continue + for i in range(len(files) - 1): + img1 = cv2.imread(files[i]) + img2 = cv2.imread(files[i + 1]) -def calcu_glcm_dissimilarity(glcm, nbit=64): - ''' - calc glcm dissimilarity - ''' - dissimilarity = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - dissimilarity += glcm[i, j] * np.abs(i - j) - - return dissimilarity + feats = FrothFeatureExtractor.extract_dynamic_features(img1, img2, time_interval=interval) + feats['folder'] = folder_name + feats['pair'] = f"{os.path.basename(files[i])}-{os.path.basename(files[i + 1])}" + results.append(feats) -def calcu_glcm_entropy(glcm, nbit=64): - ''' - calc glcm entropy - ''' - eps = 0.00001 - entropy = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - entropy -= glcm[i, j] * np.log10(glcm[i, j] + eps) - - return entropy - - -def calcu_glcm_energy(glcm, nbit=64): - ''' - calc glcm energy or second moment - ''' - energy = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - energy += glcm[i, j] ** 2 - - return energy - - -def calcu_glcm_correlation(glcm, nbit=64): - ''' - calc glcm correlation (Unverified result) - ''' - - x_mean = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - y_mean = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - x_mean += glcm[i, j] * i / (nbit) ** 2 - y_mean += glcm[i, j] * j / (nbit) ** 2 - - x_variance = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - y_variance = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - Exy = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - x_variance += glcm[i, j] * (i - x_mean) ** 2 - y_variance += glcm[i, j] * (j - y_mean) ** 2 - Exy += i * j * glcm[i, j] - x_variance += np.ones(x_variance.shape) - y_variance += np.ones(y_variance.shape) - correlation = (Exy - x_mean * y_mean) / (x_variance * y_variance) - - correlation = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - np.seterr(divide='ignore', invalid='ignore') - correlation += ((i - mean) * (j - mean) * (glcm[i, j] ** 2)) / variance - - return correlation - - -def calcu_glcm_Auto_correlation(glcm, nbit=64): - ''' - calc glcm auto correlation - ''' - Auto_correlation = np.zeros((glcm.shape[2], glcm.shape[3]), dtype=np.float32) - for i in range(nbit): - for j in range(nbit): - Auto_correlation += glcm[i, j] * i * j - - return Auto_correlation + if results: + pd.DataFrame(results).to_excel(output_file, index=False) + logger.info(f"动态特征提取完成,已保存至: {output_file}") if __name__ == '__main__': - main() - -# SURF主要核心代码: -import os -import cv2 -import numpy as np -import math -import pandas as pd -from tqdm import tqdm - -import os -import cv2 -import numpy as np -import math -import pandas as pd -from tqdm import tqdm - - -def calculate_speed_variance(origin_points, next_points, t_stamp): - # 计算每个点之间的平方差 - square_diff = np.sum((next_points - origin_points) ** 2, axis=1) - # 计算平方差的平均值 - bubble_speed = np.sqrt(square_diff) / t_stamp - # 计算平均速度 - bubble_speed_mean = bubble_speed.mean() - # 计算速度方差 - bubble_speed_variance = np.mean((bubble_speed - bubble_speed_mean) ** 2) - return bubble_speed_mean, bubble_speed_variance - - -def calculate_stability(good_matches, keypoints1, keypoints2): - stability = len(good_matches) / (0.5 * (len(keypoints1) + len(keypoints2))) - return stability - - -def process_folder(folder_path): - files = sorted(os.listdir(folder_path)) - dynamic_features = [] - for i in tqdm(range(len(files) - 1), desc=f"Processing {folder_path}"): - img1_path = os.path.join(folder_path, files[i]) - img2_path = os.path.join(folder_path, files[i + 1]) - - img1 = cv2.imread(img1_path, cv2.IMREAD_GRAYSCALE) - img2 = cv2.imread(img2_path, cv2.IMREAD_GRAYSCALE) - - # 创建SURF对象 - surf = cv2.xfeatures2d_SURF.create(500) - - # 返回关键点信息和描述符 - keypoints1, descriptors1 = surf.detectAndCompute(img1, None) - keypoints2, descriptors2 = surf.detectAndCompute(img2, None) - - # 初始化 Brute-Force 匹配器 - bf = cv2.BFMatcher() - - # 使用 Brute-Force 匹配器进行描述符的匹配 - matches = bf.knnMatch(descriptors1, descriptors2, k=2) - - # 只保留最佳匹配 - good_matche_points = [] - for m, n in matches: - if m.distance < 0.5 * n.distance: # 通过比率测试获取最佳匹配 - good_matche_points.append(m) - - # 从匹配中提取一一对应的特征点 - matched_keypoints1 = [keypoints1[m.queryIdx].pt for m in good_matche_points] - matched_keypoints2 = [keypoints2[m.trainIdx].pt for m in good_matche_points] - - origin_points = np.array(matched_keypoints1) - next_points = np.array(matched_keypoints2) - - bubble_speed_mean, bubble_speed_variance = calculate_speed_variance(origin_points, next_points, 0.2086) - stability = calculate_stability(good_matche_points, keypoints1, keypoints2) - - dynamic_features.append((bubble_speed_mean, bubble_speed_variance, stability)) - - return dynamic_features - - -# 处理文件夹中的所有子文件夹 -root_folder = r'D:\JetBrains\images' -subfolders = [os.path.join(root_folder, folder) for folder in os.listdir(root_folder) if - os.path.isdir(os.path.join(root_folder, folder))] - -all_dynamic_features = [] -for subfolder in subfolders: - dynamic_features = process_folder(subfolder) - all_dynamic_features.extend(dynamic_features) - -# 将结果写入 Excel 表格 -excel_file = 'dynamic_features_train.xlsx' -df = pd.DataFrame(all_dynamic_features, columns=["Speed Mean", "Speed Variance", "Stability"]) -df.to_excel(excel_file, index=False) - - -def calculate_dynamic_features(folder_path_A, folder_path_B): - # 获取文件夹 A 和文件夹 B 中的所有子文件夹路径 - subfolders_A = sorted([f.path for f in os.scandir(folder_path_A) if f.is_dir()]) - subfolders_B = sorted([f.path for f in os.scandir(folder_path_B) if f.is_dir()]) - - # 创建一个列表来存储每组图像对的动态特征 - dynamic_features_list = [] - - # 确保文件夹 A 和文件夹 B 中的子文件夹数目相同 - if len(subfolders_A) != len(subfolders_B): - print("Error: The number of subfolders in folder A is not equal to the number of subfolders in folder B.") - return dynamic_features_list - - # 遍历文件夹 A 和文件夹 B 中的每个子文件夹 - for subfolder_A, subfolder_B in tqdm(zip(subfolders_A, subfolders_B), desc='Processing Subfolders', - total=len(subfolders_A)): - # 获取子文件夹中的图像文件路径 - image_files_A = sorted( - [f.path for f in os.scandir(subfolder_A) if f.is_file() and f.name.endswith(('.jpg', '.jpeg', '.png'))]) - image_files_B = sorted( - [f.path for f in os.scandir(subfolder_B) if f.is_file() and f.name.endswith(('.jpg', '.jpeg', '.png'))]) - - # 确保子文件夹中的图像文件数目相同 - if len(image_files_A) != len(image_files_B): - print( - f"Error: The number of images in subfolder {subfolder_A} is not equal to the number of images in subfolder {subfolder_B}.") - continue - - # 遍历子文件夹中的每对图像文件 - for image_file_A, image_file_B in tqdm(zip(image_files_A, image_files_B), desc='Processing Images', - total=len(image_files_A), leave=False): - # 加载图像 - - img1 = cv2.imread(image_file_A, cv2.IMREAD_GRAYSCALE) - img2 = cv2.imread(image_file_B, cv2.IMREAD_GRAYSCALE) - - # 提取特征点 - surf = cv2.xfeatures2d_SURF.create(1000) # 返回关键点信息和描述符 - image1 = img1.copy() - image2 = img2.copy() - keypoints1, descriptors1 = surf.detectAndCompute(image1, None) - keypoints2, descriptors2 = surf.detectAndCompute(image2, None) - # 在图像上绘制关键点(关键点利用Hessian算法找到) - # DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS绘制特征点的时候绘制一个个带方向的圆 - - # 特征点匹配 - bf = cv2.BFMatcher() - - # 使用 Brute-Force 匹配器进行描述符的匹配 - matches = bf.knnMatch(descriptors1, descriptors2, k=2) - - # 只保留最佳匹配 - goodMatchePoints = [] - for m, n in matches: - if m.distance < 0.4 * n.distance: # 通过比率测试获取最佳匹配 - goodMatchePoints.append(m) - matchedKeypoints1 = [keypoints1[m.queryIdx] for m in goodMatchePoints] - matchedKeypoints2 = [keypoints2[m.trainIdx] for m in goodMatchePoints] - # for i in range(len(matchePoints)): - # print(matchePoints[i].queryIdx,matchePoints[i].trainIdx)#只是打印索引,无法寻找点 - # queryIdx为查询点索引,trainIdx为被查询点索引 - origin_x = np.zeros(len(goodMatchePoints)) - origin_y = np.zeros(len(goodMatchePoints)) - next_x = np.zeros(len(goodMatchePoints)) - next_y = np.zeros(len(goodMatchePoints)) - - # 计算速度和方差 - for i in range(len(goodMatchePoints)): - origin_x[i] = matchedKeypoints1[i].pt[0] - origin_y[i] = matchedKeypoints1[i].pt[1] - next_x[i] = matchedKeypoints2[i].pt[0] - next_y[i] = matchedKeypoints2[i].pt[1] - - t_stamp = 0.15 - bubble_speed = np.zeros(len(goodMatchePoints)) # 得到速度 - for i in range(len(goodMatchePoints)): - bubble_speed[i] = math.sqrt((next_x[i] - origin_x[i]) ** 2 + (next_y[i] - origin_y[i]) ** 2) / t_stamp - bubble_speed_varience = 0 - bubble_speed_mean = bubble_speed.mean() - - # 计算稳定性 - stability = 1 - len(goodMatchePoints) / (0.5 * (len(keypoints1) + len(keypoints2))) - - # 将动态特征添加到列表中 - dynamic_features_df = pd.DataFrame(dynamic_features_list) - - # 然后拼接新的 DataFrame 对象 - new_features_df = pd.DataFrame({ - 'Folder A': [os.path.basename(subfolder_A)], - 'Folder B': [os.path.basename(subfolder_B)], - 'Image A': [os.path.basename(image_file_A)], - 'Image B': [os.path.basename(image_file_B)], - 'Stability': [stability], - 'Speed Mean': [bubble_speed_mean], - - }) - - dynamic_features_df = pd.concat([dynamic_features_df, new_features_df], ignore_index=True) - - return dynamic_features_df - - -# 文件夹 A 和文件夹 B 的路径 -folder_path_A = "D:/JetBrains/sample/train" - -# 计算图像动态特征 -dynamic_features = calculate_dynamic_features(folder_path_A, folder_path_B) - -# 打印结果 -dynamic_features.to_excel('image_dynamic.xlsx', index=False) - -import cv2 -import numpy as np -import math -import logging as log - -import glob - -# img1=cv2.imread('D:/projects/video_flicker/video/result_45.jpg',cv2.IMREAD_GRAYSCALE) -# img2=cv2.imread('D:/projects/video_flicker/video/result_30.jpg',cv2.IMREAD_GRAYSCALE) -img1 = cv2.imread('test3.jpg', cv2.IMREAD_GRAYSCALE) -img2 = cv2.imread('test4.jpg', cv2.IMREAD_GRAYSCALE) -# 提取特征点 -# 创建SURF对象 -target_size = (256, 256) -img1 = cv2.resize(img1, target_size) -img2 = cv2.resize(img2, target_size) -surf = cv2.xfeatures2d_SURF.create(10000) # 返回关键点信息和描述符 -image1 = img1.copy() -image2 = img2.copy() -keypoint1, descriptor1 = surf.detectAndCompute(image1, None) -keypoint2, descriptor2 = surf.detectAndCompute(image2, None) -# 在图像上绘制关键点(关键点利用Hessian算法找到) -# DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS绘制特征点的时候绘制一个个带方向的圆 -image1 = cv2.drawKeypoints(image=image1, keypoints=keypoint1, outImage=image1, color=(255, 0, 255), - flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS) -image2 = cv2.drawKeypoints(image=image2, keypoints=keypoint2, outImage=image2, color=(255, 0, 255), - flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS) - -cv2.imshow('surf_keypoints1', image1) -cv2.imshow('surf_keypoints2', image2) - -# 特征点匹配 -matcher = cv2.FlannBasedMatcher() -matchePoints = matcher.match(descriptor1, descriptor2) -print(type(matchePoints), len(matchePoints), matchePoints[0]) - -# 提取最强匹配 -minMatch = 1 -maxMatch = 0 - -# for i in range(len(matchePoints)): -# print(matchePoints[i].queryIdx,matchePoints[i].trainIdx)#只是打印索引,无法寻找点 -# queryIdx为查询点索引,trainIdx为被查询点索引 -for i in range(len(matchePoints)): - if minMatch > matchePoints[i].distance: - minMatch = matchePoints[i].distance - if maxMatch < matchePoints[i].distance: - maxMatch = matchePoints[i].distance -print('最佳匹配值:', minMatch) -print('最差匹配值:', maxMatch) - -goodMatchePoints = [] -for i in range(len(matchePoints)): - if matchePoints[i].distance < minMatch + (maxMatch - minMatch) / 4: - goodMatchePoints.append(matchePoints[i]) - -outImg = None -outImg = cv2.drawMatches(img1, keypoint1, img2, keypoint2, goodMatchePoints, outImg, matchColor=(0, 0, 255), - flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS) -# 打印最匹配点 -origin_x = np.zeros(len(goodMatchePoints)) -origin_y = np.zeros(len(goodMatchePoints)) -next_x = np.zeros(len(goodMatchePoints)) -next_y = np.zeros(len(goodMatchePoints)) -# 计算速度和方差 -for i in range(len(goodMatchePoints)): - print('goodMatch输出', goodMatchePoints[i].queryIdx) - print('x坐标', keypoint2[goodMatchePoints[i].queryIdx].pt[0]) - print('y坐标', keypoint2[goodMatchePoints[i].queryIdx].pt[1]) - origin_x[i] = keypoint1[goodMatchePoints[i].queryIdx].pt[0] - origin_y[i] = keypoint1[goodMatchePoints[i].queryIdx].pt[1] - next_x[i] = keypoint2[goodMatchePoints[i].queryIdx].pt[0] - next_y[i] = keypoint2[goodMatchePoints[i].queryIdx].pt[1] - match = cv2.circle(image2, (int(keypoint2[goodMatchePoints[i].trainIdx].pt[0]), - int(keypoint2[goodMatchePoints[i].trainIdx].pt[1])), 4, (0, 255, 0), -1) -t_stamp = 0.15 -bubble_speed = np.zeros(len(goodMatchePoints)) # 得到速度 -for i in range(len(goodMatchePoints)): - bubble_speed[i] = math.sqrt((next_x[i] - origin_x[i]) ** 2 + (next_y[i] - origin_y[i]) ** 2) / t_stamp -bubble_speed_varience = 0 -bubble_speed_mean = bubble_speed.mean() -for i in range(len(goodMatchePoints)): - bubble_speed_varience += (bubble_speed[i] - bubble_speed_mean) ** 2 -bubble_speed_varience = bubble_speed_varience / len(goodMatchePoints) # 得到方差 - -# 计算稳定性 -stability = len(goodMatchePoints) / (0.5 * (len(keypoint1) + len(keypoint2))) - -cv2.imshow('matche', outImg) -cv2.imshow('query', image2) # 在image2上打印关键点位置 -cv2.waitKey() -cv2.destroyAllWindows() - -print(bubble_speed_mean) -print(bubble_speed_varience) -print() + # 简单的运行测试 + logger.info("FrothFeatureExtractor 模块已加载。请通过其他脚本调用类方法,或取消下方注释运行批处理。") + # FrothBatchProcessor.process_folder("D:/DataSet/Train", "train_data.xlsx") \ No newline at end of file diff --git a/src/views/components/tank_widget.py b/src/views/components/tank_widget.py index 35104d1..225cb39 100644 --- a/src/views/components/tank_widget.py +++ b/src/views/components/tank_widget.py @@ -26,20 +26,25 @@ class TankVisualizationWidget(QWidget): 0: [ # 铅快粗槽 (6种) ('qkc_dinghuangyao1', '丁黄药1'), ('qkc_dinghuangyao2', '丁黄药2'), ('qkc_yiliudan1', '乙硫氮1'), ('qkc_yiliudan2', '乙硫氮2'), - ('qkc_shihui', '石灰'), ('qkc_5_you', '2#油') + ('qkc_shihui', '石灰'), + ('qkc_5_you', '2#油') ], 1: [ # 铅精一槽 (3种) - ('qkj1_dinghuangyao', '丁黄药'), ('qkj1_yiliudan', '乙硫氮'), + ('qkj1_dinghuangyao', '丁黄药'), + ('qkj1_yiliudan', '乙硫氮'), ('qkj1_shihui', '石灰') ], 2: [ # 铅精二槽 (3种) - ('qkj2_yiliudan', '乙硫氮'), ('qkj2_shihui', '石灰'), - ('qkj2_dinghuangyao', '丁黄药') + ('qkj2_dinghuangyao', '丁黄药'), + ('qkj2_yiliudan', '乙硫氮'), + ('qkj2_shihui', '石灰') + ], 3: [ # 铅精三槽 (5种) - ('qkj3_dinghuangyao', '丁黄药'), ('qkj3_yiliudan', '乙硫氮'), - ('qkj3_ds1', 'DS1'), ('qkj3_ds2', 'DS2'), - ('qkj3_shihui', '石灰') + ('qkj3_dinghuangyao', '丁黄药'), + ('qkj3_yiliudan', '乙硫氮'), + ('qkj3_shihui', '石灰'), + ('qkj3_ds1', 'DS1'), ('qkj3_ds2', 'DS2') ] } diff --git a/src/views/pages/history_page.py b/src/views/pages/history_page.py index bf05f18..f7406fa 100644 --- a/src/views/pages/history_page.py +++ b/src/views/pages/history_page.py @@ -31,16 +31,17 @@ class HistoryPage(QWidget): ('qkj1_shihui', '铅快精一\n石灰'), # --- 铅快精二工序 (Cleaner 2) --- + ('qkj2_dinghuangyao', '铅快精二\n丁黄药'), ('qkj2_yiliudan', '铅快精二\n乙硫氮'), ('qkj2_shihui', '铅快精二\n石灰'), - ('qkj2_dinghuangyao', '铅快精二\n丁黄药'), # --- 铅快精三工序 (Cleaner 3) --- ('qkj3_dinghuangyao', '铅快精三\n丁黄药'), ('qkj3_yiliudan', '铅快精三\n乙硫氮'), + ('qkj3_shihui', '铅快精三\n石灰'), ('qkj3_ds1', '铅快精三\nDS1'), ('qkj3_ds2', '铅快精三\nDS2'), - ('qkj3_shihui', '铅快精三\n石灰'), + ] def __init__(self, parent=None): @@ -180,7 +181,7 @@ def create_stat_item(self, stat_info): return widget def populate_table(self): - """填充表格数据""" + """填充表格数据 (包含智能着色逻辑)""" if self.history_data is None or self.history_data.empty: self.history_table.setRowCount(0) return @@ -188,6 +189,14 @@ def populate_table(self): self.history_table.setRowCount(len(self.history_data)) sorted_data = self.history_data.sort_values(by='timestamp', ascending=False) + # [新增] 定义交替颜色:白色 和 淡蓝色 + color_1 = QColor(255, 255, 255) + color_2 = QColor(225, 245, 254) # 类似于淡蓝色 + + # [新增] 状态追踪:记录每一列的上一个值和当前的颜色索引 + # key: col_idx, value: {'val': float_value, 'c_idx': 0 or 1} + col_states = {} + for row, (_, record) in enumerate(sorted_data.iterrows()): # 1. 时间 ts = record['timestamp'] @@ -217,10 +226,26 @@ def populate_table(self): col_idx = 4 for db_key, _ in self.REAGENT_COLUMNS: val = record.get(db_key) # 直接使用数据库列名获取数据 + current_val = float(val) if val is not None else 0.0 + text = f"{val:.1f}" if val is not None and val != 0 else "--" item = QTableWidgetItem(text) - if val is not None and val > 0: - item.setBackground(QColor(240, 248, 255)) # 有流量显示淡蓝色背景 + + # [修改] 药剂变化交替着色逻辑 + # 初始化或检查变化 + if col_idx not in col_states: + col_states[col_idx] = {'val': current_val, 'c_idx': 0} + else: + state = col_states[col_idx] + # 如果值发生变化 (差值大于微小量),切换颜色索引 + if abs(state['val'] - current_val) > 0.001: + state['val'] = current_val + state['c_idx'] = 1 - state['c_idx'] + + # 根据当前索引设置背景色 + bg_color = color_1 if col_states[col_idx]['c_idx'] == 0 else color_2 + item.setBackground(bg_color) + self.history_table.setItem(row, col_idx, item) col_idx += 1 @@ -305,26 +330,62 @@ def on_query_clicked(self): def update_statistics(self): """更新统计面板""" if self.history_data is None or self.history_data.empty: + for name in ["平均铅品位", "最高铅品位", "平均回收率", "运行时长", "数据点数"]: + label = self.findChild(QLabel, f"stat_{name}") + if label: label.setText("--") return - # 示例统计 - avg_lead = self.history_data['conc_grade'].mean() - max_lead = self.history_data['conc_grade'].max() - avg_rec = self.history_data['recovery_rate'].mean() - count = len(self.history_data) - - # 查找并更新Label - label = self.findChild(QLabel, "stat_平均铅品位") - if label: label.setText(f"{avg_lead:.2f}") - - label = self.findChild(QLabel, "stat_最高铅品位") - if label: label.setText(f"{max_lead:.2f}") + # --- 2. 铅品位统计 (剔除 0 值) --- + conc_series = self.history_data['conc_grade'] + # 筛选出大于 0 的有效数据 (使用微小正数防止浮点误差) + valid_conc = conc_series[conc_series > 1e-6] + + if not valid_conc.empty: + avg_lead = valid_conc.mean() + max_lead = valid_conc.max() + else: + avg_lead = 0.0 + max_lead = 0.0 + + # --- 3. 回收率统计 (剔除 0 值) --- + rec_series = self.history_data['recovery_rate'] + valid_rec = rec_series[rec_series > 1e-6] + + if not valid_rec.empty: + avg_rec = valid_rec.mean() + else: + avg_rec = 0.0 + + # --- 4. 运行时长计算 (新增) --- + duration_hours = 0.0 + try: + # 确保 timestamp 列是 datetime 类型 (防止部分依然是字符串) + timestamps = pd.to_datetime(self.history_data['timestamp']) + + if not timestamps.empty: + start_time = timestamps.min() + end_time = timestamps.max() + # 计算时间差 (秒) -> 小时 + delta = end_time - start_time + duration_hours = delta.total_seconds() / 3600.0 + except Exception as e: + print(f"计算运行时长出错: {e}") + duration_hours = 0.0 - label = self.findChild(QLabel, "stat_平均回收率") - if label: label.setText(f"{avg_rec:.2f}") + # --- 5. 数据点数 --- + count = len(self.history_data) - label = self.findChild(QLabel, "stat_数据点数") - if label: label.setText(str(count)) + # --- 6. 更新 UI --- + # 辅助函数:安全更新 Label + def set_label_text(name, value): + label = self.findChild(QLabel, f"stat_{name}") + if label: label.setText(value) + + set_label_text("平均铅品位", f"{avg_lead:.2f}") + set_label_text("最高铅品位", f"{max_lead:.2f}") + set_label_text("平均回收率", f"{avg_rec:.2f}") + set_label_text("运行时长", f"{duration_hours:.1f}") # 保留1位小数 + set_label_text("数据点数", str(count)) def on_export_clicked(self): """导出数据""" diff --git a/src/views/pages/monitoring_page.py b/src/views/pages/monitoring_page.py index d8f48c6..0d25d86 100644 --- a/src/views/pages/monitoring_page.py +++ b/src/views/pages/monitoring_page.py @@ -83,7 +83,8 @@ def __init__(self, parent=None): self.chart_update_interval = 600 # 10分钟 # 数据缓冲 - self.max_points = 100 + # [修改] 调整为72个点 (12小时 * 6点/小时 = 72点) + self.max_points = 72 # [修改] 初始化时间数组 (存储 timestamp float) self.time_data = np.zeros(self.max_points) self.feed_grade_data = np.zeros(self.max_points) @@ -223,9 +224,9 @@ def setup_connections(self): def load_history(self): """[新增] 从数据库加载历史数据填充图表和表格""" try: - # 1. 计算时间范围 (过去24小时) + # 1. 计算时间范围 (过去12小时) [修改] end_time = datetime.now() - start_time = end_time - timedelta(hours=24) + start_time = end_time - timedelta(hours=12) # [修改] 24 -> 12 # 2. 查询数据 history = self.data_service.get_historical_data(start_time, end_time) diff --git a/test_analysis.py b/test_analysis.py new file mode 100644 index 0000000..be45bc7 --- /dev/null +++ b/test_analysis.py @@ -0,0 +1,32 @@ +import cv2 +import json +# 假设您的文件在 src/utils/feature_extract.py +from src.utils.feature_extract import FrothFeatureExtractor + +def analyze_one_image(image_path): + # 1. 读取图像 + img = cv2.imread(image_path) + if img is None: + print("错误:无法读取图像") + return + + print(f"正在分析图像: {image_path} ...") + + # 2. 一键提取所有静态特征 + # 包括:颜色(RGB/HSV)、纹理(GLCM/LBP)、形态学(气泡尺寸/数量/圆度) + all_features = FrothFeatureExtractor.extract_all_static_features(img) + + # 3. 打印结果 (格式化输出) + print("\n=== 分析结果 ===") + # 这里用 json.dumps 只是为了漂亮地打印字典 + print(json.dumps(all_features, indent=4, ensure_ascii=False)) + + # 4. 访问特定特征示例 + print(f"\n关键指标速览:") + print(f"- 气泡数量: {all_features.get('bubble_count', 0)}") + print(f"- 平均尺寸(D50): {all_features.get('bubble_d50', 0):.2f}") + print(f"- 载矿指示(红灰比): {all_features.get('color_red_gray_ratio', 0):.4f}") + +# 运行 +if __name__ == "__main__": + analyze_one_image("test_froth.jpg") # 替换为您的实际图片路径 \ No newline at end of file diff --git a/test_feature.py b/test_feature.py new file mode 100644 index 0000000..befd9fc --- /dev/null +++ b/test_feature.py @@ -0,0 +1,46 @@ +import cv2 +import numpy as np +# 导入我们重构后的类 +from src.utils.feature_extract import FrothFeatureExtractor + + +def analyze_single_image(image_path): + # 1. 读取图像 (OpenCV 读取默认为 BGR 格式) + img = cv2.imread(image_path) + + if img is None: + print(f"错误:无法找到或读取图像 {image_path}") + return + + print(f"正在分析图像: {image_path} (尺寸: {img.shape})") + + # 2. 提取颜色与统计特征 (传入单张图像) + # 这会计算红灰比、灰度均值、方差、偏度、峰度等 + color_stats = FrothFeatureExtractor.extract_color_stats(img) + print("\n[颜色与统计特征]:") + for k, v in color_stats.items(): + print(f" {k}: {v:.4f}") + + # 3. 提取纹理特征 (GLCM) (传入单张图像) + # 这会计算对比度、能量、相关性等 + texture_stats = FrothFeatureExtractor.extract_texture_glcm(img) + print("\n[GLCM 纹理特征]:") + for k, v in texture_stats.items(): + print(f" {k}: {v:.4f}") + + # 4. 关于动态特征的说明 + print("\n[动态特征]:") + print(" 警告:动态特征(速度/稳定性)需要两帧图像才能计算。") + print(" 如果您有连续的第二张图,可以调用: FrothFeatureExtractor.extract_dynamic_features(img1, img2)") + + +# --- 运行测试 --- +if __name__ == "__main__": + # 请替换为您实际的图片路径 + image_file = "test_froth.jpg" + + # 为了演示,我们先创建一个模拟的泡沫图像(如果您的目录下没有真实图片) + dummy_img = np.random.randint(0, 255, (256, 256, 3), dtype=np.uint8) + cv2.imwrite(image_file, dummy_img) + + analyze_single_image(image_file) \ No newline at end of file diff --git a/test_froth.jpg b/test_froth.jpg new file mode 100644 index 0000000..888c85d Binary files /dev/null and b/test_froth.jpg differ diff --git a/visualize_segmentation.py b/visualize_segmentation.py new file mode 100644 index 0000000..0551904 --- /dev/null +++ b/visualize_segmentation.py @@ -0,0 +1,126 @@ +import cv2 +import numpy as np +import matplotlib.pyplot as plt +from scipy import ndimage as ndi +from skimage.feature import peak_local_max +from skimage.segmentation import watershed +from skimage.color import label2rgb + + +def visualize_bubble_segmentation(image_path): + # --- 1. 读取图像 --- + image = cv2.imread(image_path) + if image is None: + print(f"无法读取图像: {image_path}") + return + + # 转换为 RGB 用于 matplotlib 显示 + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # --- 2. 预处理 (增强对比度 + 模糊) --- + # CLAHE (限制对比度自适应直方图均衡化) 增强气泡边缘 + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + # 高斯模糊去除噪点 + blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) + + # --- 3. 二值化 (Otsu阈值) --- + # 将图像分为前景(气泡)和背景(黑色) + _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # --- 4. 距离变换 (核心步骤) --- + # 计算每个白色像素到最近黑色像素的距离 + # 气泡中心越亮,边缘越暗,形成“山峰” + distance = ndi.distance_transform_edt(thresh) + + # --- 5. 生成种子点 (Markers) --- + # 寻找局部最大值(气泡中心)作为注水的“泉眼” + # min_distance 决定了允许的最小气泡间距,防止过度分割 + coords = peak_local_max(distance, min_distance=7, labels=thresh) + mask = np.zeros(distance.shape, dtype=bool) + mask[tuple(coords.T)] = True + markers, _ = ndi.label(mask) + + # --- 6. 分水岭算法 (Watershed) --- + # 从种子点开始注水,直到填满盆地(distance) + # -distance 表示取反,因为算法寻找的是盆地(最小值),而我们的是山峰(最大值) + labels = watershed(-distance, markers, mask=thresh) + + # --- 7. 结果可视化 --- + + # 将标签转化为彩色覆盖层 (Image overlay) + image_label_overlay = label2rgb(labels, image=image_rgb, bg_label=0, alpha=0.3) + + # 绘制轮廓 (Contours) 到原图上 + contour_img = image_rgb.copy() + # 遍历每个标签值(跳过0背景) + for label_val in np.unique(labels): + if label_val == 0: continue + # 创建单个气泡的掩码 + mask = np.zeros(gray.shape, dtype="uint8") + mask[labels == label_val] = 255 + # 查找轮廓 + cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + # 绘制绿色轮廓 + cv2.drawContours(contour_img, cnts, -1, (0, 255, 0), 1) + + # --- 8. Matplotlib 绘图 --- + fig, axes = plt.subplots(2, 3, figsize=(15, 10)) + ax = axes.ravel() + + # 图1: 原图 + ax[0].imshow(image_rgb) + ax[0].set_title("1. Original Image") + + # 图2: 预处理后 (CLAHE) + ax[1].imshow(enhanced, cmap='gray') + ax[1].set_title("2. Preprocessed (CLAHE)") + + # 图3: 二值化掩码 + ax[2].imshow(thresh, cmap='gray') + ax[2].set_title("3. Threshold (Binary)") + + # 图4: 距离变换图 (越亮代表越中心) + # 使用 'jet' 伪彩色显示距离高低 + im4 = ax[3].imshow(distance, cmap='jet') + ax[3].set_title("4. Distance Transform") + plt.colorbar(im4, ax=ax[3], fraction=0.046, pad=0.04) + + # 图5: 分水岭结果 (彩色块) + ax[4].imshow(image_label_overlay) + ax[4].set_title(f"5. Watershed Labels (Count: {len(np.unique(labels)) - 1})") + + # 图6: 最终轮廓图 + ax[5].imshow(contour_img) + ax[5].set_title("6. Final Contours") + + for a in ax: + a.axis('off') + + plt.tight_layout() + plt.show() + + +# --- 运行测试 --- +if __name__ == "__main__": + # 请替换为您的图片路径 + img_path = "test_froth.jpg" + + # 如果没有图片,生成一个简单的模拟图 + import os + + if not os.path.exists(img_path): + print("未找到图片,生成模拟泡沫图...") + dummy = np.zeros((300, 300), dtype=np.uint8) + # 画几个重叠的圆 + for i in range(15): + center = np.random.randint(50, 250, 2) + radius = np.random.randint(20, 50) + cv2.circle(dummy, tuple(center), radius, 255, -1) + # 加点噪点 + noise = np.random.randint(0, 50, (300, 300), dtype=np.uint8) + dummy = cv2.add(dummy, noise) + cv2.imwrite(img_path, dummy) + + visualize_bubble_segmentation(img_path) \ No newline at end of file