clean and format codebase

Former-commit-id: 5d0497ac67199a7ea475849a6ec3f28df46371cb
This commit is contained in:
2025-07-07 18:52:50 +08:00
parent e0df489aee
commit e0e1c649eb
11 changed files with 335 additions and 1327 deletions

View File

View File

@ -1,223 +0,0 @@
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
import re
from datetime import datetime
import main as _main
import argparse
class RateVolumeAnalyzer:
def __init__(self):
self.mat = _main.MAT() # 创建MAT实例以使用predictor方法
def parse_log_file(self, log_path):
"""解析日志文件,提取体积和时间信息"""
volumes = []
timestamps = []
with open(log_path, 'r', encoding='utf-8') as f:
for line in f:
# 匹配体积信息的行
if '当前体积:' in line:
# 提取时间戳
time_match = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+', line)
# 提取体积
volume_match = re.search(r'当前体积: ([\d.]+) ml', line)
if time_match and volume_match:
timestamp = datetime.strptime(time_match.group(1), '%Y-%m-%d %H:%M:%S')
volume = float(volume_match.group(1))
timestamps.append(timestamp)
volumes.append(volume)
return timestamps, volumes
def extract_frames_from_video(self, video_path, timestamps, start_time):
"""从视频中提取对应时间点的帧"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print(f"无法打开视频文件: {video_path}")
return []
fps = cap.get(cv2.CAP_PROP_FPS)
frames = []
for timestamp in timestamps:
# 计算相对于实验开始的秒数
seconds_from_start = (timestamp - start_time).total_seconds()
frame_number = int(seconds_from_start * fps)
# 设置视频位置到指定帧
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = cap.read()
if ret:
frames.append(frame)
else:
frames.append(None) # 如果无法读取帧添加None
cap.release()
return frames
def calculate_rates(self, frames):
"""使用predictor方法计算每帧的rate"""
rates = []
states = []
for frame in frames:
if frame is not None:
try:
state, rate = self.mat.predictor(frame)
rates.append(rate)
states.append(state)
except Exception as e:
print(f"计算rate时发生错误: {e}")
rates.append(None)
states.append(None)
else:
rates.append(None)
states.append(None)
return rates, states
def plot_rate_volume_curve(self, volumes, rates, states, log_filename):
"""绘制rate-体积曲线"""
# 过滤掉None值
valid_data = [(v, r, s) for v, r, s in zip(volumes, rates, states)
if r is not None and s is not None]
if not valid_data:
print("没有有效的数据点可以绘制")
return
volumes_valid, rates_valid, states_valid = zip(*valid_data)
plt.figure(figsize=(12, 8))
# 根据状态用不同颜色绘制点
colors = {'transport': 'blue', 'middle': 'orange', 'about': 'purple', 'colored': 'red'}
for state in colors:
state_volumes = [v for v, s in zip(volumes_valid, states_valid) if s == state]
state_rates = [r for r, s in zip(rates_valid, states_valid) if s == state]
if state_volumes:
plt.scatter(state_volumes, state_rates,
c=colors[state], label=state, alpha=0.7, s=50)
# 绘制连接线
plt.plot(volumes_valid, rates_valid, 'k-', alpha=0.3, linewidth=1)
plt.xlabel('体积 (ml)', fontsize=12)
plt.ylabel('Rate', fontsize=12)
plt.title(f'Rate-体积曲线 ({log_filename})', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
# 保存图片
output_filename = f"rate_volume_curve_{log_filename.replace('.log', '.png')}"
plt.savefig(output_filename, dpi=300, bbox_inches='tight')
plt.show()
print(f"图片已保存为: {output_filename}")
# 打印统计信息
print(f"\n统计信息:")
print(f"总数据点: {len(valid_data)}")
for state in colors:
count = sum(1 for s in states_valid if s == state)
if count > 0:
print(f"{state}: {count} 个点")
def analyze_experiment(self, timestamp_str=None):
"""分析指定时间戳的实验,如果不指定则分析最新的实验"""
logs_dir = "logs"
videos_dir = "Videos"
if timestamp_str:
log_file = f"titration_{timestamp_str}.log"
video_file = f"{timestamp_str}.mp4"
else:
# 找到最新的日志文件
log_files = [f for f in os.listdir(logs_dir) if f.endswith('.log')]
if not log_files:
print("没有找到日志文件")
return
log_files.sort()
log_file = log_files[-1]
# 提取时间戳来找对应的视频文件
timestamp_match = re.search(r'titration_(\d{8}_\d{6})\.log', log_file)
if timestamp_match:
video_file = f"{timestamp_match.group(1)}.mp4"
else:
print("无法从日志文件名提取时间戳")
return
log_path = os.path.join(logs_dir, "titration_20250529_191634.log")
video_path = os.path.join(videos_dir, "tmp.mp4")
if not os.path.exists(log_path):
print(f"日志文件不存在: {log_path}")
return
if not os.path.exists(video_path):
print(f"视频文件不存在: {video_path}")
return
print(f"分析实验: {log_file}")
print(f"对应视频: {video_file}")
# 解析日志文件
timestamps, volumes = self.parse_log_file(log_path)
if not timestamps:
print("日志文件中没有找到体积数据")
return
print(f"找到 {len(timestamps)} 个数据点")
# 获取实验开始时间
start_time = timestamps[0]
# 从视频中提取帧
print("正在从视频中提取帧...")
frames = self.extract_frames_from_video(video_path, timestamps, start_time)
# 计算rate
print("正在计算rate值...")
rates, states = self.calculate_rates(frames)
# 绘制曲线
print("正在绘制rate-体积曲线...")
self.plot_rate_volume_curve(volumes, rates, states, log_file)
def main():
parser = argparse.ArgumentParser(description='分析滴定实验的rate-体积曲线')
parser.add_argument('--timestamp', type=str, help='指定实验时间戳 (格式: YYYYMMDD_HHMMSS)')
parser.add_argument('--list', action='store_true', help='列出所有可用的实验')
args = parser.parse_args()
analyzer = RateVolumeAnalyzer()
if args.list:
# 列出所有可用的实验
logs_dir = "logs"
if os.path.exists(logs_dir):
log_files = [f for f in os.listdir(logs_dir) if f.endswith('.log')]
log_files.sort()
print("可用的实验:")
for log_file in log_files:
timestamp_match = re.search(r'titration_(\d{8}_\d{6})\.log', log_file)
if timestamp_match:
timestamp = timestamp_match.group(1)
print(f" {timestamp}")
return
analyzer.analyze_experiment(args.timestamp)
if __name__ == "__main__":
main()

BIN
colored.zip Normal file

Binary file not shown.

View File

@ -1,611 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
滴定分析Demo程序
从Videos目录获取最新视频解析logs或直接处理视频生成rate随时间变化的可视化图像
"""
import os
import re
import cv2
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Tuple, Dict, Optional
import logging
from dataclasses import dataclass
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False
@dataclass
class DataPoint:
"""数据点结构"""
timestamp: float
relative_time: float # 相对于开始时间的秒数
state: str
rate: float
volume: float
mode: str = "FAST" # FAST, SLOW, ABOUT, CRAZY
class TitrationDemo:
def __init__(self, base_dir: str = "c:/expiriment/ai-titration-main"):
"""初始化Demo类"""
self.base_dir = Path(base_dir)
self.videos_dir = self.base_dir / "Videos"
self.logs_dir = self.base_dir / "logs"
# 模式对应的速度映射 (ml/次)
self.mode_speeds = {
'FAST': 0.45, # 快速模式速度
'SLOW': 0.05, # 慢速模式速度
'ABOUT': 0.02, # 精密模式速度
'CRAZY': 1.0, # 疯狂模式速度
'END': 0.0 # 结束模式无速度
}
# 模式颜色映射
self.mode_colors = {
'FAST': '#FF6B6B', # 红色
'SLOW': '#4ECDC4', # 青色
'ABOUT': '#45B7D1', # 蓝色
'CRAZY': '#96CEB4', # 绿色
'END': '#FFEAA7' # 黄色
}
# 状态颜色映射
self.state_colors = {
'transport': '#95A5A6', # 灰色
'middle': '#F39C12', # 橙色
'about': '#E74C3C', # 深红色
'colored': '#8E44AD' # 紫色
}
# 设置日志
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
def get_latest_video(self) -> Optional[Path]:
"""获取最新的视频文件"""
video_files = []
for ext in ['*.mp4', '*.mkv', '*.avi']:
video_files.extend(self.videos_dir.glob(ext))
if not video_files:
self.logger.error("未找到任何视频文件")
return None
# 按文件名中的时间戳排序,获取最新的
def extract_timestamp(filename: str) -> datetime:
# 提取形如 20250606_200940 的时间戳
match = re.search(r'(\d{8}_\d{6})', filename)
if match:
return datetime.strptime(match.group(1), '%Y%m%d_%H%M%S')
return datetime.min
latest_video = max(video_files, key=lambda x: extract_timestamp(x.name))
self.logger.info(f"选择最新视频: {latest_video.name}")
return latest_video
def get_corresponding_log(self, video_path: Path) -> Optional[Path]:
"""获取对应的日志文件"""
# 从视频文件名提取时间戳
timestamp_match = re.search(r'(\d{8}_\d{6})', video_path.name)
if not timestamp_match:
return None
timestamp = timestamp_match.group(1)
log_file = self.logs_dir / f"titration_{timestamp}.log"
if log_file.exists():
self.logger.info(f"找到对应日志文件: {log_file.name}")
return log_file
else:
self.logger.warning(f"未找到对应日志文件: {log_file.name}")
return None
def parse_log_data(self, log_path: Path) -> List[DataPoint]:
"""解析日志文件,提取数据点"""
data_points = []
start_time = None
current_mode = "FAST"
with open(log_path, 'r', encoding='utf-8') as f:
for line in f:
# 解析时间戳
timestamp_match = re.match(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})', line)
if not timestamp_match:
continue
timestamp_str = timestamp_match.group(1)
timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S,%f')
timestamp_float = timestamp.timestamp()
if start_time is None:
start_time = timestamp_float
relative_time = timestamp_float - start_time
# 解析模式变化
if "进入slow模式" in line or "检测到middle" in line:
current_mode = "SLOW"
elif "进入about模式" in line or "检测到about" in line:
current_mode = "ABOUT"
elif "返回fast模式" in line or "退出middle检查" in line:
current_mode = "FAST"
elif "检测到colored" in line:
current_mode = "END"
# 解析体积信息
volume_match = re.search(r'当前体积:\s*([\d.]+)\s*ml', line)
if volume_match:
volume = float(volume_match.group(1))
# 这里rate需要通过视频处理获得暂时设置为0
data_points.append(DataPoint(
timestamp=timestamp_float,
relative_time=relative_time,
state="transport", # 默认状态
rate=0.0, # 需要通过视频处理获得
volume=volume,
mode=current_mode
))
return data_points
def interpolate_volume_data(self, data_points: List[DataPoint],
interpolation_interval: float = 0.1) -> List[DataPoint]:
"""
通过mode求speed反推log两个体积数据点之间的数据
Args:
data_points: 原始数据点列表
interpolation_interval: 插值间隔(秒)
Returns:
插值后的数据点列表
"""
if len(data_points) < 2:
return data_points
interpolated_points = []
for i in range(len(data_points)):
# 添加原始数据点
interpolated_points.append(data_points[i])
# 如果不是最后一个点,进行插值
if i < len(data_points) - 1:
current_point = data_points[i]
next_point = data_points[i + 1]
# 计算时间差和体积差
time_diff = next_point.relative_time - current_point.relative_time
volume_diff = next_point.volume - current_point.volume
# 如果时间差大于插值间隔,进行插值
if time_diff > interpolation_interval:
# 根据当前模式获取速度
current_speed = self.mode_speeds.get(current_point.mode, 0.0)
# 计算需要插值的点数
num_interpolations = int(time_diff / interpolation_interval)
for j in range(1, num_interpolations + 1):
# 计算插值时间
interp_time = current_point.relative_time + j * interpolation_interval
# 如果插值时间超过下一个点的时间,跳出
if interp_time >= next_point.relative_time:
break
# 根据模式和时间计算体积
# 假设每次推进的时间间隔内,体积按照模式速度增长
elapsed_time = interp_time - current_point.relative_time
# 估算体积增长(基于推进频率和速度)
# 假设推进频率FAST=1次/秒SLOW=0.5次/秒ABOUT=0.2次/秒
push_frequency = {
'FAST': 1.0,
'SLOW': 0.5,
'ABOUT': 0.2,
'CRAZY': 2.0,
'END': 0.0
}
freq = push_frequency.get(current_point.mode, 1.0)
estimated_volume = current_point.volume + elapsed_time * freq * current_speed
# 确保体积不超过下一个点的体积
if estimated_volume > next_point.volume:
estimated_volume = current_point.volume + (elapsed_time / time_diff) * volume_diff
# 创建插值数据点
interp_point = DataPoint(
timestamp=current_point.timestamp + elapsed_time,
relative_time=interp_time,
state=current_point.state, # 继承当前状态
rate=0.0, # 插值点的rate需要通过视频获得
volume=estimated_volume,
mode=current_point.mode # 继承当前模式
)
interpolated_points.append(interp_point)
self.logger.info(f"原始数据点: {len(data_points)}, 插值后数据点: {len(interpolated_points)}")
return interpolated_points
def process_video_with_predictor(self, video_path: Path, sample_interval: int = 5) -> List[DataPoint]:
"""使用predictor处理视频提取rate数据"""
# 导入main模块中的predictor方法
import sys
sys.path.append(str(self.base_dir))
try:
from main import MAT
from utils import History
except ImportError:
self.logger.error("无法导入MAT类或History类")
return []
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
self.logger.error(f"无法打开视频文件: {video_path}")
return []
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.logger.info(f"视频信息: FPS={fps}, 总帧数={total_frames}")
# 创建MAT实例用于predictor简化初始化
try:
mat = MAT(videoSourceIndex=0, bounce_time=4, end_bounce_time=1)
# 创建简化的历史记录管理器
mat.history = History(max_window_size=100.0, base_time=5.0)
except Exception as e:
self.logger.error(f"MAT初始化失败: {e}")
return []
data_points = []
frame_count = 0
base_calculated = False
rates_for_base = []
# 用于模式推断的变量
current_mode = "FAST"
mode_change_frame = 0
while True:
ret, frame = cap.read()
if not ret:
break
# 更密集的采样
if frame_count % sample_interval == 0:
try:
# 调用predictor获取状态和rate
state, rate = mat.predictor(frame)
relative_time = frame_count / fps
# 收集前50帧的rate值来计算base
if not base_calculated and len(rates_for_base) < 50:
if state == "transport": # 只用transport状态的rate计算base
rates_for_base.append(rate)
if len(rates_for_base) >= 50:
base_rate = float(np.mean(rates_for_base))
if mat.history:
mat.history.base = base_rate
base_calculated = True
self.logger.info(f"计算得到基准rate: {base_rate:.4f}")
# 基于rate和状态推断模式
if base_calculated and mat.history and mat.history.base:
base = mat.history.base
thresholds = (base * 5, base * 13, base * 20)
# 基于predictor返回的状态推断模式
if state == "transport":
if current_mode != "FAST" and frame_count - mode_change_frame > fps * 2: # 2秒稳定期
current_mode = "FAST"
mode_change_frame = frame_count
elif state == "middle":
if current_mode == "FAST":
current_mode = "SLOW"
mode_change_frame = frame_count
elif state == "about":
if current_mode in ["FAST", "SLOW"]:
current_mode = "ABOUT"
mode_change_frame = frame_count
elif state == "colored":
current_mode = "END"
mode_change_frame = frame_count
data_points.append(DataPoint(
timestamp=time.time(),
relative_time=relative_time,
state=state,
rate=rate,
volume=0.0, # 视频中无法直接获得体积信息
mode=current_mode
))
except Exception as e:
self.logger.warning(f"处理帧 {frame_count} 时出错: {e}")
continue
frame_count += 1
# 更频繁的进度显示
if frame_count % (sample_interval * 50) == 0:
progress = (frame_count / total_frames) * 100
self.logger.info(f"处理进度: {progress:.1f}% - 已处理 {len(data_points)} 个数据点")
cap.release()
cv2.destroyAllWindows()
self.logger.info(f"视频处理完成,共获得 {len(data_points)} 个数据点")
return data_points
def _smooth_mode_changes(self, data_points: List[DataPoint]) -> List[DataPoint]:
"""平滑模式变化,避免频繁跳转"""
if len(data_points) < 3:
return data_points
smoothed_points = data_points.copy()
window_size = 5 # 平滑窗口大小
for i in range(window_size, len(data_points) - window_size):
# 获取窗口内的模式
window_modes = [p.mode for p in data_points[i-window_size:i+window_size+1]]
# 使用众数作为当前点的模式
mode_counts = {}
for mode in window_modes:
mode_counts[mode] = mode_counts.get(mode, 0) + 1
most_common_mode = max(mode_counts.items(), key=lambda x: x[1])[0]
smoothed_points[i].mode = most_common_mode
return smoothed_points
def merge_log_and_video_data(self, log_data: List[DataPoint],
video_data: List[DataPoint]) -> List[DataPoint]:
"""合并日志和视频数据"""
if not log_data:
return video_data
if not video_data:
return log_data
# 以日志数据为基础补充视频中的rate信息
merged_data = []
for log_point in log_data:
# 找到时间最接近的视频数据点
closest_video_point = min(video_data,
key=lambda x: abs(x.relative_time - log_point.relative_time))
# 如果时间差在合理范围内使用视频的rate数据
if abs(closest_video_point.relative_time - log_point.relative_time) < 5.0:
log_point.rate = closest_video_point.rate
log_point.state = closest_video_point.state
merged_data.append(log_point)
return merged_data
def create_visualization(self, data_points: List[DataPoint],
save_path: Optional[str] = None) -> None:
"""创建可视化图表"""
if not data_points:
self.logger.error("没有数据点可供可视化")
return
# 提取数据
times = [point.relative_time for point in data_points]
rates = [point.rate for point in data_points]
volumes = [point.volume for point in data_points]
modes = [point.mode for point in data_points]
states = [point.state for point in data_points]
# 创建图表
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
# 上图Rate随时间变化按Mode着色
self._plot_rate_by_mode(ax1, times, rates, modes)
# 下图Volume随时间变化按State着色
self._plot_volume_by_state(ax2, times, volumes, states)
# 调整布局
plt.tight_layout()
# 保存或显示
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
self.logger.info(f"图表已保存至: {save_path}")
else:
plt.show()
def _plot_rate_by_mode(self, ax, times: List[float], rates: List[float],
modes: List[str]) -> None:
"""绘制Rate随时间变化图按Mode着色"""
# 按模式分组绘制
current_mode = None
current_times = []
current_rates = []
for i, (time, rate, mode) in enumerate(zip(times, rates, modes)):
if mode != current_mode:
# 绘制前一段
if current_times and current_mode:
ax.plot(current_times, current_rates,
color=self.mode_colors.get(current_mode, '#000000'),
linewidth=2, label=f'{current_mode} Mode' if current_mode not in ax.get_legend_handles_labels()[1] else "")
# 开始新的一段
current_mode = mode
current_times = [time]
current_rates = [rate]
else:
current_times.append(time)
current_rates.append(rate)
# 绘制最后一段
if current_times and current_mode:
ax.plot(current_times, current_rates,
color=self.mode_colors.get(current_mode, '#000000'),
linewidth=2, label=f'{current_mode} Mode' if current_mode not in ax.get_legend_handles_labels()[1] else "")
ax.set_xlabel('时间 (秒)')
ax.set_ylabel('Rate')
ax.set_title('Rate随时间变化 (按操作模式着色)')
ax.legend(loc='upper right')
ax.grid(True, alpha=0.3)
def _plot_volume_by_state(self, ax, times: List[float], volumes: List[float],
states: List[str]) -> None:
"""绘制Volume随时间变化图按State着色"""
# 过滤掉volume为0的点来自视频数据
filtered_data = [(t, v, s) for t, v, s in zip(times, volumes, states) if v > 0]
if not filtered_data:
ax.text(0.5, 0.5, '无体积数据', ha='center', va='center', transform=ax.transAxes)
ax.set_xlabel('时间 (秒)')
ax.set_ylabel('体积 (ml)')
ax.set_title('体积随时间变化 (按检测状态着色)')
return
f_times, f_volumes, f_states = zip(*filtered_data)
# 按状态分组绘制
current_state = None
current_times = []
current_volumes = []
for time, volume, state in zip(f_times, f_volumes, f_states):
if state != current_state:
# 绘制前一段
if current_times and current_state:
ax.plot(current_times, current_volumes,
color=self.state_colors.get(current_state, '#000000'),
linewidth=2, marker='o', markersize=3,
label=f'{current_state.title()} State' if current_state not in ax.get_legend_handles_labels()[1] else "")
# 开始新的一段
current_state = state
current_times = [time]
current_volumes = [volume]
else:
current_times.append(time)
current_volumes.append(volume)
# 绘制最后一段
if current_times and current_state:
ax.plot(current_times, current_volumes,
color=self.state_colors.get(current_state, '#000000'),
linewidth=2, marker='o', markersize=3,
label=f'{current_state.title()} State' if current_state not in ax.get_legend_handles_labels()[1] else "")
ax.set_xlabel('时间 (秒)')
ax.set_ylabel('体积 (ml)')
ax.set_title('体积随时间变化 (按检测状态着色)')
ax.legend(loc='lower right')
ax.grid(True, alpha=0.3)
def run_demo(self, use_video: bool = True, sample_interval: int = 5) -> None:
"""运行demo程序"""
self.logger.info("开始运行滴定分析Demo...")
# 1. 获取最新视频
latest_video = self.get_latest_video()
if not latest_video:
return
# 2. 获取对应日志
log_file = self.get_corresponding_log(latest_video)
# 3. 解析数据
log_data = []
if log_file:
self.logger.info("解析日志数据...")
log_data = self.parse_log_data(log_file)
video_data = []
if use_video:
self.logger.info("处理视频数据...")
video_data = self.process_video_with_predictor(latest_video, sample_interval)
# 4. 合并数据
if log_data and video_data:
self.logger.info("合并日志和视频数据...")
final_data = self.merge_log_and_video_data(log_data, video_data)
elif log_data:
final_data = log_data
elif video_data:
final_data = video_data
else:
self.logger.error("没有可用的数据")
return
# 5. 创建可视化
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
save_path = self.base_dir / f"rate_analysis_{timestamp}.png"
self.logger.info("创建可视化图表...")
self.create_visualization(final_data, str(save_path))
self.logger.info("Demo运行完成!")
# 打印统计信息
self._print_statistics(final_data)
def _print_statistics(self, data_points: List[DataPoint]) -> None:
"""打印数据统计信息"""
if not data_points:
return
print("\n=== 数据统计信息 ===")
print(f"总数据点数: {len(data_points)}")
print(f"时间范围: {data_points[0].relative_time:.1f}s - {data_points[-1].relative_time:.1f}s")
# 模式统计
mode_counts = {}
for point in data_points:
mode_counts[point.mode] = mode_counts.get(point.mode, 0) + 1
print("\n模式分布:")
for mode, count in mode_counts.items():
percentage = (count / len(data_points)) * 100
print(f" {mode}: {count} 次 ({percentage:.1f}%)")
# Rate统计
rates = [point.rate for point in data_points if point.rate > 0]
if rates:
print(f"\nRate统计:")
print(f" 最小值: {min(rates):.4f}")
print(f" 最大值: {max(rates):.4f}")
print(f" 平均值: {np.mean(rates):.4f}")
# 体积统计
volumes = [point.volume for point in data_points if point.volume > 0]
if volumes:
print(f"\n体积统计:")
print(f" 最小值: {min(volumes):.2f} ml")
print(f" 最大值: {max(volumes):.2f} ml")
def main():
"""主函数"""
demo = TitrationDemo()
# 运行demo
# use_video=True: 同时处理视频和日志数据
# use_video=False: 仅使用日志数据
# sample_interval: 视频采样间隔每N帧处理一次- 设置为更小的值获得更密集的数据点
demo.run_demo(use_video=True, sample_interval=1) # 每3帧采样一次大幅增加数据点
if __name__ == "__main__":
main()

324
main.py
View File

@ -1,19 +1,20 @@
import cv2
import atexit
import logging
import time
from datetime import datetime
import numpy as np
import ch340
import atexit
import utils
from utils import State, History,login_to_platform,send_data_to_platform
from scipy.signal import find_peaks
from typing import Optional
import logging
import cv2
import numpy as np
from scipy.signal import find_peaks
import ch340
import utils
from utils import History, State, login_to_platform, send_data_to_platform
class MAT:
def __init__(self, videoSourceIndex=0, bounce_time=2,sensity=30):
def __init__(self, videoSourceIndex=0, bounce_time=2, sensity=30):
# 初始化logging
utils.setup_logging()
self.system_logger = logging.getLogger("System")
@ -21,15 +22,15 @@ class MAT:
self.endpoint_logger = logging.getLogger("Endpoint")
self.volume_logger = logging.getLogger("Volume")
self.system_logger.info('正在初始化MAT系统...')
self.system_logger.info("正在初始化MAT系统...")
self.videoSourceIndex = videoSourceIndex
self.cap = cv2.VideoCapture(videoSourceIndex, cv2.CAP_DSHOW)
self.ch340 = ch340.CH340()
self.total_volume = 0
self.sensity = sensity
self.state = State(bounce_time)
atexit.register(self.ch340.stop)
self.history = History(bounce_time)
self.colored_volume = None
@ -41,37 +42,41 @@ class MAT:
self.control_logger.info("开始抽取12ml")
self.ch340.max_speed()
self.ch340.pull(vol=12)
self.control_logger.info('完成抽取')
self.control_logger.info("完成抽取")
def ch340_init(self):
"初始化电机位置,防止过头导致抽取溶液不准。"
# self.ch340.push(speed=1,t=1)
self.ch340.pull(speed=1.2,vol=1.8)
self.control_logger.info('电极位置已初始化')
self.ch340.pull(speed=1.2, vol=1.8)
self.control_logger.info("电极位置已初始化")
def ch340_push(self, speed:int|float=0.1):
def ch340_push(self, speed: int | float = 0.1):
"常规推送保证推送时间为1s推送体积=speed"
self.ch340.push_async(speed=speed, t=1)
def process_left(self, now: float, value_only:Optional[bool]=False) -> Optional[float]:
'''
def process_left(
self, now: float, value_only: Optional[bool] = False
) -> Optional[float]:
"""
计算当前时刻剩余体积并更新到self.total_volume。
@param now: 当前时间戳
@param value_only: 如果为True则只返回剩余体积不更新状态
@return: 当前剩余体积仅当value_only为True时返回
'''
"""
if self.state.mode == State.Mode.ABOUT:
return self.total_volume
st = self.ch340.start
if not value_only:
self.ch340.stop()
if not abs(self.ch340._get_time()-1) < 1e-3:
self.control_logger.warning(f"CH340 timing issue detected: {self.ch340._get_time()}")
if not value_only:
self.ch340.stop()
if not abs(self.ch340._get_time() - 1) < 1e-3:
self.control_logger.warning(
f"CH340 timing issue detected: {self.ch340._get_time()}"
)
return self.total_volume
r = now - st
ret = self.total_volume - (1-r) * self.speeds[self.state.mode.value]
ret = self.total_volume - (1 - r) * self.speeds[self.state.mode.value]
if value_only:
return ret
else:
@ -83,109 +88,119 @@ class MAT:
if not suc:
self.control_logger.error("无法从摄像头捕获帧")
return None
# 录制当前帧到视频文件
if hasattr(self, 'capFile') and self.capFile.isOpened():
if hasattr(self, "capFile") and self.capFile.isOpened():
self.capFile.write(im)
ret, rate = self.predictor(im)
if ret is None:
return None
now = time.time()
val = self.process_left(now, value_only=True)
# 确保val不为None
if val is None:
self.control_logger.warning("体积计算返回None跳过本次记录")
return ret
# 更新滑动窗口历史记录 - 维护最近end_bounce_time内的状态
self.history.add_record(now, ret, rate, val, im)
if self.history.is_empty():
self.control_logger.error("未预期的没有可用的历史记录")
return ret
# === 状态进入逻辑 ===
if now - self._start_time<10:
if now - self._start_time < 10:
self._display_status(im, ret, rate, val)
return ret
if not self.edited and self.history.base is not None and rate>self.history.base * 2:
if (
not self.edited
and self.history.base is not None
and rate > self.history.base * 2
):
self.edited = True
self.speeds[0] /= 2
# 1. middle: predictor返回middle立即进入slow状态
if ret == "middle":
if self.state.is_fast_mode():
self.process_left(now)
self.state.enter_middle_state(now)
self.control_logger.info(f"检测到middle立即进入slow模式当前体积: {val:.2f} ml")
self.control_logger.info(
f"检测到middle立即进入slow模式当前体积: {val:.2f} ml"
)
# 2. about: 返回about且处于middle则进入about状态
elif ret == "about":
if self.state.is_slow_mode():
self.process_left(now)
self.state.enter_about_state(now)
self.control_logger.info(f"检测到about进入about模式当前体积: {val:.2f} ml")
self.control_logger.info(
f"检测到about进入about模式当前体积: {val:.2f} ml"
)
# middle检查: 进入middle的bounce_time后在最近bounce_time内middle比例<70%返回fast状态
if self.state.should_check_middle_exit(now) and not self.history.last_end:
# 计算最近bounce_time内的middle比例
recent_records = self.history.get_recent_records(self.state.bounce_time, now)
recent_records = self.history.get_recent_records(
self.state.bounce_time, now
)
if recent_records:
trans_ratio = self.history.get_state_ratio("transport", recent_records)
if trans_ratio > 0.4:
self.process_left(now)
self.state.exit_middle_check()
self.control_logger.info(f"middle比例{trans_ratio:.2%}<60%退出middle检查返回fast模式")
self.control_logger.info(
f"middle比例{trans_ratio:.2%}<60%退出middle检查返回fast模式"
)
if self.state.mode == State.Mode.ABOUT and self.state.about_check:
h = self.history.get_recent_records(self.about_time/3,now)
h = self.history.get_recent_records(self.about_time / 3, now)
ratio = self.history.get_state_ratio("transport", h)
self.history.about_history.append(ratio == 1)
while len(self.history.about_history) > 5:
self.history.about_history.pop(0)
if len(self.history.about_history) == 5 and self.history.last_end < 2:
if len(self.history.about_history) == 5 and self.history.last_end < 2:
rate = sum(self.history.about_history) / len(self.history.about_history)
if rate > 0.8:
self.state.exit_about()
self.control_logger.info(f"about比例{ratio:.2%}<80%退出about检查返回middle模式")
self.control_logger.info(
f"about比例{ratio:.2%}<80%退出about检查返回middle模式"
)
ret = self.end_check()
if ret == "colored":
return ret
if not self.history.last_end:
self.history.last_end = max(self.history.last_end,ret)
self.history.last_end = max(self.history.last_end, ret)
self.state.about_check = False
# 显示状态信息
self._display_status(im, ret, rate, val)
return ret
def end_check(self,dep=0):
'''终点检测,通过对图像直方图峰值分析,确认是否全局变色。'''
def end_check(self, dep=0):
"""终点检测,通过对图像直方图峰值分析,确认是否全局变色。"""
if not self.running:
return "colored"
if dep == 1:
suc,im = self.cap.read()
suc, im = self.cap.read()
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
# 降低饱和度通道 (S通道是索引1)
hsv[:, :, 1] = hsv[:, :, 1] * 0.8
# 转换回BGR颜色空间
result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
name = f"colored_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
cv2.imwrite(name,result)
cv2.imwrite(name, result)
self.colored_im = name
# 维护一个递归检测重复4次后认定为终点。
if dep > 3:
self.colored_volume = self.total_volume
@ -193,7 +208,7 @@ class MAT:
self.running = False
self.ch340.stop()
return "colored"
suc,im = self.cap.read()
suc, im = self.cap.read()
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 0]
s = s[s > 0]
@ -201,80 +216,105 @@ class MAT:
hist = hist.flatten() # 转换为一维数组
# 峰值检测 - 找到直方图中的峰值
peaks, properties = find_peaks(hist,
height=np.max(hist) * 0.2, # 峰值高度至少是最大值的10%
distance=10, # 峰值之间的最小距离
prominence=np.max(hist) * 0.01) # 峰值的突出度
if np.any(peaks>130):
peaks, properties = find_peaks(
hist,
height=np.max(hist) * 0.2, # 峰值高度至少是最大值的10%
distance=10, # 峰值之间的最小距离
prominence=np.max(hist) * 0.01,
) # 峰值的突出度
if np.any(peaks > 130):
if dep == 0:
self.process_left(time.time())
self.history.end_history.append(True)
self.control_logger.info(f"检测到colored状态end_check at {dep}")
bgn = time.time()
while time.time()-bgn < 2:
suc,im = self.cap.read()
if hasattr(self, 'capFile') and self.capFile.isOpened():
while time.time() - bgn < 2:
suc, im = self.cap.read()
if hasattr(self, "capFile") and self.capFile.isOpened():
self.capFile.write(im)
cv2.putText(im, "ENDCHK", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.putText(
im,
"ENDCHK",
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 255, 0),
2,
)
cv2.imshow("Frame", im)
return self.end_check(dep+1)
return self.end_check(dep + 1)
# time.sleep(2)
else:
self.colored_im = None
return dep
def _display_status(self, im, detection_result, rate, volume):
"""显示状态信息到图像上"""
mode_color = {
State.Mode.FAST: (255, 255, 255),
State.Mode.SLOW: (10, 215, 255),
State.Mode.ABOUT: (228,116,167),
State.Mode.ABOUT: (228, 116, 167),
}
status_text = f"Stat: {detection_result}, rate: {round(rate,2)}, Vol: {volume:.2f} ml"
status_text = (
f"Stat: {detection_result}, rate: {round(rate,2)}, Vol: {volume:.2f} ml"
)
status_text += self.state.get_status_text()
cv2.putText(im, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7,
mode_color[self.state.mode], 2)
cv2.putText(
im,
status_text,
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
mode_color[self.state.mode],
2,
)
cv2.imshow("Frame", im)
cv2.waitKey(1)
def predictor(self,im):
'''主预测函数分析图像并返回当前状态。当前colored不使用这个函数。'''
hsv = cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
s = hsv[:,:,1]
mask = s>self.sensity
cv2.imshow('mask',im*mask[:,:,np.newaxis])
tot = mask.shape[0]*mask.shape[1]
def predictor(self, im):
"""主预测函数分析图像并返回当前状态。当前colored不使用这个函数。"""
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1]
mask = s > self.sensity
cv2.imshow("mask", im * mask[:, :, np.newaxis])
tot = mask.shape[0] * mask.shape[1]
val = np.sum(mask)
rate = val/tot
rate = val / tot
if self.history.base is not None:
base = self.history.base
thr = (min(0.05,base*5), min(0.2,base*13), 0.5)
thr = (min(0.05, base * 5), min(0.2, base * 13), 0.5)
# thr = (base*5, base*13, 0.5)
if rate < thr[0]:
return "transport",rate
elif rate <thr[1]:
return "middle",rate
return "transport", rate
elif rate < thr[1]:
return "middle", rate
elif rate < thr[2]:
return "about",rate
return "about", rate
else:
return "colored",rate
return "colored", rate
else:
return "transport",rate
return "transport", rate
def __del__(self):
self.cap.release()
if hasattr(self, 'capFile') and self.capFile.isOpened():
if hasattr(self, "capFile") and self.capFile.isOpened():
self.capFile.release()
cv2.destroyAllWindows()
def format_date_time(self, timestamp):
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def run(self, quick_speed=0.2, slow_speed=0.05, end_speed=0.02, mid_time=0.5, about_time=1, cap_dir="Videos"):
def run(
self,
quick_speed=0.2,
slow_speed=0.05,
end_speed=0.02,
mid_time=0.5,
about_time=1,
cap_dir="Videos",
):
self.running = True
self.start_time = time.time()
self.speeds: list[float] = [quick_speed, slow_speed, end_speed]
@ -282,19 +322,19 @@ class MAT:
self.edited = False
self.volume_list = []
self.color_list = []
if cap_dir is not None:
vid_name = f"{cap_dir}/{datetime.now().strftime('%Y%m%d_%H%M%S')}.mkv"
else:
vid_name = "Disabled"
# 记录实验开始
experiment_params = {
"视频源索引 ": self.videoSourceIndex,
"防抖时间 ": self.state.bounce_time,
"快速模式速度": f"{quick_speed} ml/次",
"慢速模式速度": f"{slow_speed} ml/次",
"录制视频 ": vid_name
"录制视频 ": vid_name,
}
self.system_logger.info("实验参数:")
for key, value in experiment_params.items():
@ -302,50 +342,56 @@ class MAT:
fps = int(self.cap.get(cv2.CAP_PROP_FPS)) or 30
width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# 初始化视频录制器
if vid_name != "Disabled":
fourcc = cv2.VideoWriter.fourcc(*'x264') # 使用更兼容的编码器
fourcc = cv2.VideoWriter.fourcc(*"x264") # 使用更兼容的编码器
self.capFile = cv2.VideoWriter(vid_name, fourcc, fps, (width, height))
if not self.capFile.isOpened():
self.system_logger.error(f"无法打开视频录制器: {vid_name}")
del self.capFile
else:
self.system_logger.info(f"视频录制已初始化")
self.ch340_init()
cnt = 0
self._start_time = time.time()
self.about_time=about_time
self.about_time = about_time
while self.running:
if not self.ch340.running:
if 12 * cnt - self.total_volume < 0.5:
self.ch340_pull()
cnt += 1
should_push = False
if self.state.is_fast_mode():
should_push = True
elif self.state.is_slow_mode() and \
time.time() - self.ch340.start > mid_time:
elif (
self.state.is_slow_mode()
and time.time() - self.ch340.start > mid_time
):
should_push = True
elif self.state.is_about_mode() and \
time.time() - self.ch340.start > about_time:
elif (
self.state.is_about_mode()
and time.time() - self.ch340.start > about_time
):
if not self.state.about_first_flag:
self.state.about_check = True
else:
self.state.about_first_flag = False
should_push = True
if should_push:
speed = self.speeds[self.state.mode.value]
self.volume_logger.info(f"当前体积: {self.total_volume:.2f} ml, 加入速度: {speed:.2f} ml/次")
self.volume_logger.info(
f"当前体积: {self.total_volume:.2f} ml, 加入速度: {speed:.2f} ml/次"
)
self.ch340_push(speed)
self.total_volume += speed
self.volume_list.append(round(self.total_volume,2))
self.volume_list.append(round(self.total_volume, 2))
try:
self.color_list.append(self.state.mode.name)
except Exception as e:
@ -354,12 +400,12 @@ class MAT:
if self._pred() is None:
self.control_logger.error("预测失败,跳过当前帧")
continue
# 释放视频录制器
if hasattr(self, 'capFile') and self.capFile.isOpened():
if hasattr(self, "capFile") and self.capFile.isOpened():
self.capFile.release()
self.system_logger.info(f"视频录制完成: {vid_name}")
# 实验结束,记录结果
experiment_results = {
"总体积 ": f"{self.total_volume:.2f} ml",
@ -369,12 +415,12 @@ class MAT:
"滴定速率": f"{self.total_volume / (time.time() - self.start_time):.2f} mL/s",
}
upload_data = {
"start_time":self.format_date_time(self.start_time),
"end_time":self.format_date_time(time.time()),
"volume_record": f'{self.volume_list}',
'voltage_record': f'{[]}',
'color_record': f'{self.color_list}',
'final_volume': f'{self.colored_volume}',
"start_time": self.format_date_time(self.start_time),
"end_time": self.format_date_time(time.time()),
"volume_record": f"{self.volume_list}",
"voltage_record": f"{[]}",
"color_record": f"{self.color_list}",
"final_volume": f"{self.colored_volume}",
}
self.system_logger.info("实验结果:")
for key, value in experiment_results.items():
@ -382,27 +428,23 @@ class MAT:
return upload_data, self.colored_im
if __name__ == "__main__":
token = login_to_platform("13504022184","password")
token = login_to_platform("13504022184", "password")
# 创建MAT类的实例并运行
mat = MAT(
videoSourceIndex = 1,
bounce_time=2,
sensity = 32
)
mat = MAT(videoSourceIndex=1, bounce_time=2, sensity=32)
mat.state.mode = State.Mode.FAST
final_data, finish_picture = mat.run(
slow_speed = 0.05,
quick_speed = 1.0,
slow_speed=0.05,
quick_speed=1.0,
about_time=3,
# cap_dir=None
)
with open("log.json","w") as f:
with open("log.json", "w") as f:
import json
json.dump(final_data, f, indent=4)
send_data_to_platform(token, final_data, finish_picture)

68
test.py
View File

@ -1,68 +0,0 @@
import cv2
import numpy as np
from matplotlib import pyplot as plt
from scipy.signal import find_peaks
cap = cv2.VideoCapture(2) # 使用摄像头0通常更稳定
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) # 降低分辨率提高处理速度
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
# 预先创建图形窗口,避免重复创建
fig, ax = plt.subplots(figsize=(10, 4))
plt.ion()
ax.set_title('Saturation Channel Histogram')
ax.set_xlabel('Saturation Value')
ax.set_ylabel('Pixel Count')
ax.set_xlim(0, 255)
while True:
ret, frame = cap.read()
if not ret:
print("Failed to grab frame")
break
cv2.imshow("Camera Feed", frame)
# 直接提取饱和度通道避免完整HSV转换
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 0]
s = s[s > 0] # 只保留非零饱和度值,减少噪声
# 使用更高效的直方图计算
hist = cv2.calcHist([s], [0], None, [256], [0, 256])
hist = hist.flatten() # 转换为一维数组
# 峰值检测 - 找到直方图中的峰值
peaks, properties = find_peaks(hist,
height=np.max(hist) * 0.5, # 峰值高度至少是最大值的10%
distance=10, # 峰值之间的最小距离
prominence=np.max(hist) * 0.2) # 峰值的突出度
# 清除旧数据并绘制新直方图
ax.clear()
ax.plot(hist, 'b-', linewidth=1)
# 标注峰值
if len(peaks) > 0:
print(peaks)
ax.text(0.5, 1.05, f'Found {len(peaks)} peaks')
ax.plot(peaks, hist[peaks], 'ro', markersize=8, label=f'Peaks ({len(peaks)})')
# 在峰值处添加文字标注
for i, peak in enumerate(peaks):
ax.annotate(f'Peak {i+1}\n({peak}, {int(hist[peak])})',
xy=(peak, hist[peak]),
xytext=(peak, hist[peak] + np.max(hist) * 0.1),
ha='center', va='bottom',
bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7),
arrowprops=dict(arrowstyle='->', color='red'))
plt.draw()
plt.pause(0.1) # 确保图形更新
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
plt.close('all')

View File

@ -1,11 +0,0 @@
import cv2
import numpy as np
cap = cv2.VideoCapture(2) # 使用摄像头0通常更稳定
im = cap.read()[1]
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(hsv)
pink_mask = (((h >= 300/360) | (h <= 20/360)) & (s >= 0.15) & (v >= 0.2))
cv2.imshow("Pink Mask", im * pink_mask[:,:,np.newaxis]) # 显示掩码
cv2.waitKey(0) # 等待按键
cap.release()

23
tmp.py
View File

@ -1,23 +0,0 @@
from datetime import datetime
import json
import requests
import time
from utils import login_to_platform,send_data_to_platform
token = login_to_platform("13504022184","password")
def format_date_time(timestamp):
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
print(token)
final_data ={
'start_time': '2025-06-17 14:59:33',
'end_time': '2025-06-17 15:01:18',
'volume_record': '[0.8, 1.6, 2.4000000000000004, 3.2, 4.0, 4.8, 5.6, 6.3999999999999995, 7.199999999999999, 7.999999999999999, 8.799999999999999, 9.6, 10.4, 11.200000000000001, 12.000000000000002, 12.800000000000002, 13.200000000000003, 13.600000000000003, 14.000000000000004, 14.400000000000004, 14.800000000000004, 15.200000000000005, 15.600000000000005, 16.000000000000004, 16.400000000000002, 16.8, 17.2, 17.599999999999998, 17.999999999999996, 18.399999999999995, 18.799999999999994, 19.199999999999992, 19.59999999999999, 19.99999999999999, 20.399999999999988, 20.799999999999986, 21.199999999999985, 21.599999999999984, 21.999999999999982, 22.39999999999998, 22.79999999999998, 23.199999999999978, 23.599999999999977, 23.999999999999975, 24.399999999999974, 24.799999999999972, 25.19999999999997, 25.59999999999997, 25.999999999999968, 26.399999999999967, 26.799999999999965, 27.199999999999964, 27.599999999999962, 27.99999999999996, 28.39999999999996, 28.447450351715048, 28.49745035171505, 28.54745035171505, 28.59745035171505, 28.98371173143383, 28.781944286823233, 28.761131591796836]',
'voltage_record': "[]",
'color_record': "['transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'transport', 'middle', 'transport', 'transport', 'transport', 'transport', 'middle', 'colored']",
'final_volume': "28.761131591796836"
}
# print(json.dumps(final_data))
send_data_to_platform(token, final_data, "im.jpg")

144
tmp2.py
View File

@ -1,144 +0,0 @@
import numpy as np
import cv2
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from collections import deque
import threading
import time
class DistributionChangeDetector:
def __init__(self, baseline_windows: list[np.ndarray]):
"""
参数 baseline_windows: List of arrays代表初始稳定期的多个窗口
"""
self.baseline = self._compute_baseline(baseline_windows)
def _compute_stats(self, window: np.ndarray) -> tuple[float, float, float]:
"""返回 (P_under30, std, mode)"""
p_under30 = np.mean(window < 30)
std = np.std(window, ddof=1)
# 快速估计众数:最大 bin 的中心
hist, bin_edges = np.histogram(window, bins=50)
max_bin_index = np.argmax(hist)
mode_est = (bin_edges[max_bin_index] + bin_edges[max_bin_index + 1]) / 2
return p_under30, std, mode_est
def _compute_baseline(self, windows: list[np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
"""
返回 baseline 向量 (P0, σ0, mode0) 和对应标准差(用于归一化)
"""
stats = np.array([self._compute_stats(w) for w in windows])
mean = stats.mean(axis=0)
std = stats.std(axis=0) + 1e-6 # 防止除0
return mean, std
def update(self, window: np.ndarray) -> float:
"""
输入:当前窗口数据(长度 = 窗口大小)
输出:变化分数(越大表示分布越偏离基准)
"""
x = np.array(self._compute_stats(window))
mean, std = self.baseline
norm_diff = (x - mean) / std
change_score = np.linalg.norm(norm_diff)
return float(change_score)
def hsv_score(s:np.ndarray):
mask = s>30
tot = len(mask)
val = np.sum(mask)
rate = val/tot
return rate
class RealTimePlotter:
def __init__(self, max_points=200):
self.max_points = max_points
self.scores = deque(maxlen=max_points)
self.scores2 = deque(maxlen=max_points)
self.times = deque(maxlen=max_points)
self.start_time = time.time()
# 设置图形
plt.ion() # 打开交互模式
self.fig, (self.ax,self.ax2) = plt.subplots(1,2,figsize=(10, 6))
self.line, = self.ax.plot([], [], 'b-', linewidth=2)
self.line2, = self.ax2.plot([], [], 'b-', linewidth=2)
self.ax.set_xlabel('Time (s)')
self.ax.set_ylabel('Change Score')
self.ax.set_title('Real-time Distribution Change Detection')
self.ax.grid(True)
self.ax2.grid(True)
def update_plot(self, score,s_score):
current_time = time.time() - self.start_time
self.scores.append(score)
self.scores2.append(s_score)
self.times.append(current_time)
# 更新数据
self.line.set_data(list(self.times), list(self.scores))
self.line2.set_data(list(self.times), list(self.scores2))
# 自动调整坐标轴
if len(self.times) > 1:
self.ax.set_xlim(min(self.times), max(self.times))
self.ax2.set_xlim(min(self.times), max(self.times))
self.ax.set_ylim(0,100)
# self.ax.set_ylim(min(self.scores) * 0.95, max(self.scores) * 1.05)
self.ax2.set_ylim(0,1)
# 刷新图形
self.fig.canvas.draw()
self.fig.canvas.flush_events()
def gen_data():
cap = cv2.VideoCapture(1)
while True:
ret, frame = cap.read()
if not ret:
break
cv2.imshow("Camera Feed", frame)
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1] # 直接提取饱和度通道
s = s[s > 0] # 只保留非零饱和度值,减少噪声
yield s
if cv2.waitKey(1) & 0xFF == ord('a'):
break
cap.release()
cv2.destroyAllWindows()
def main():
# 初始化数据生成器
gen = gen_data()
# 获取基线数据
print("收集基线数据...")
baseline_data = [next(gen) for _ in range(30*5)]
# 初始化检测器和绘图器
det = DistributionChangeDetector(baseline_data)
plotter = RealTimePlotter()
print("开始实时检测和绘图...")
try:
for x in gen:
score = det.update(x)
score2 = hsv_score(x)
plotter.update_plot(score,score2)
# 小延时以控制更新频率
time.sleep(0.01)
except KeyboardInterrupt:
print("停止检测")
finally:
plt.ioff() # 关闭交互模式
plt.show() # 保持最终图形显示
if __name__ == "__main__":
main()

188
utils.py
View File

@ -1,20 +1,23 @@
import base64
import json
import logging
import os
import time
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from dataclasses import dataclass
from typing import List, Optional,Literal
from typing import List, Literal, Optional
import numpy as np
import time
import requests
import json
import base64
@dataclass
class HistoryRecord:
"""历史记录的单个条目"""
timestamp: float
state: Literal["transport","middle","about","colored"]
state: Literal["transport", "middle", "about", "colored"]
rate: float
volume: float
image: np.ndarray
@ -22,18 +25,18 @@ class HistoryRecord:
class History:
"""滑动窗口历史记录管理类"""
def __init__(self, max_window_size: float = 5.0, base_time = 5.0):
def __init__(self, max_window_size: float = 5.0, base_time=5.0):
"""
初始化历史记录管理器
Args:
max_window_size: 最大窗口时间长度(秒)
base_time: 基准时间长度(秒)
display: 是否开启实时可视化显示
"""
if base_time > max_window_size:
max_window_size = base_time+0.2
max_window_size = base_time + 0.2
# raise ValueError("Base time must be less than or equal to max window size.")
self.records: List[HistoryRecord] = []
self.max_window_size = max_window_size
@ -42,22 +45,32 @@ class History:
self.base = None
self._base_time = base_time
self._base_cnt = 0
self.end_history:list[bool] = []
self.end_history: list[bool] = []
self.last_end = 0
def add_record(self, timestamp: float, state: str, rate: float, volume: float, image: np.ndarray):
def add_record(
self,
timestamp: float,
state: str,
rate: float,
volume: float,
image: np.ndarray,
):
"""添加新的历史记录"""
record = HistoryRecord(timestamp, state, rate, volume, image)
self.records.append(record)
self._cleanup_old_records(timestamp)
if self.base is None and (timestamp-self._base_time)>= self.records[0].timestamp:
if (
self.base is None
and (timestamp - self._base_time) >= self.records[0].timestamp
):
if self._base_cnt < 30:
self._base_cnt+=1
self._base_cnt += 1
return
base_records = self.get_recent_records(self._base_time, timestamp)
self.base = sum([rec.rate for rec in base_records]) / len(base_records)
get_endpoint_logger().info("Base rate calculated: %.2f", self.base)
def _cleanup_old_records(self, current_time: float):
"""清理过期的历史记录"""
cutoff_time = current_time - self.max_window_size
@ -66,112 +79,131 @@ class History:
while self.records and self.records[0].timestamp < cutoff_time:
self.records.pop(0)
return True
def get_records_in_timespan(self, start_time: float, end_time: Optional[float] = None) -> List[HistoryRecord]:
def get_records_in_timespan(
self, start_time: float, end_time: Optional[float] = None
) -> List[HistoryRecord]:
"""获取指定时间段内的记录"""
if end_time is None:
return [record for record in self.records if record.timestamp >= start_time]
else:
return [record for record in self.records
if start_time <= record.timestamp <= end_time]
def get_recent_records(self, duration: float, current_time: float) -> List[HistoryRecord]:
return [
record
for record in self.records
if start_time <= record.timestamp <= end_time
]
def get_recent_records(
self, duration: float, current_time: float
) -> List[HistoryRecord]:
"""获取最近指定时间长度内的记录"""
start_time = current_time - duration
return self.get_records_in_timespan(start_time, current_time)
def get_state_ratio(self, target_state: str, records: Optional[List[HistoryRecord]] = None) -> float:
def get_state_ratio(
self, target_state: str, records: Optional[List[HistoryRecord]] = None
) -> float:
"""计算指定状态在记录中的比例"""
if records is None:
records = self.records
if not records:
return 0.0
target_count = sum(1 for record in records if record.state == target_state)
return target_count / len(records)
def get_states_by_type(self, target_state: str) -> List[float]:
"""获取所有指定状态的时间戳"""
return [record.timestamp for record in self.records if record.state == target_state]
def find_record_by_timestamp(self, target_timestamp: float) -> Optional[HistoryRecord]:
return [
record.timestamp for record in self.records if record.state == target_state
]
def find_record_by_timestamp(
self, target_timestamp: float
) -> Optional[HistoryRecord]:
"""根据时间戳查找记录"""
for record in self.records:
if record.timestamp == target_timestamp:
return record
return None
def is_empty(self) -> bool:
"""检查历史记录是否为空"""
return len(self.records) == 0
class State:
"""滴定状态管理类"""
class Mode(Enum):
FAST = 0 # 快速模式
SLOW = 1 # 慢速模式 (middle)
ABOUT = 2 # 接近终点模式
FAST = 0 # 快速模式
SLOW = 1 # 慢速模式 (middle)
ABOUT = 2 # 接近终点模式
def __init__(self, bounce_time=1):
self.mode = self.Mode.FAST
self.bounce_time = bounce_time
# 状态检查标志
self.in_middle_check = False
self.in_end_check = False
self.about_check = False
self.about_first_flag = False
# 时间记录
self.middle_detected_time:Optional[float] = None
self.middle_detected_time: Optional[float] = None
def is_fast_mode(self):
return self.mode == self.Mode.FAST
def is_slow_mode(self):
return self.mode == self.Mode.SLOW
def is_about_mode(self):
return self.mode == self.Mode.ABOUT
def enter_middle_state(self, current_time):
"""进入middle状态 - 立即切换到slow模式并开始检查"""
self.mode = self.Mode.SLOW
self.in_middle_check = True
self.middle_detected_time = current_time
def enter_about_state(self, current_time):
"""从middle状态进入about状态"""
if self.mode == self.Mode.SLOW:
self.mode = self.Mode.ABOUT
def exit_middle_check(self):
"""退出middle检查状态返回fast模式"""
self.in_middle_check = False
self.middle_detected_time = None
self.mode = self.Mode.FAST
def exit_about(self):
"""about状态退出"""
self.about_check = False
self.about_first_flag = True
if self.mode == self.Mode.ABOUT:
self.mode = self.Mode.SLOW
def should_check_middle_exit(self, current_time):
"""检查是否应该进行middle退出检查"""
return (self.in_middle_check and
self.middle_detected_time is not None and
current_time - self.middle_detected_time > self.bounce_time and
(self.mode == self.Mode.SLOW))
return (
self.in_middle_check
and self.middle_detected_time is not None
and current_time - self.middle_detected_time > self.bounce_time
and (self.mode == self.Mode.SLOW)
)
def get_status_text(self):
"""获取状态显示文本"""
status = []
current_time = time.time()
if self.in_middle_check and current_time - self.middle_detected_time > self.bounce_time:
if (
self.in_middle_check
and current_time - self.middle_detected_time > self.bounce_time
):
status.append("MIDCHK")
return ", " + ", ".join(status) if status else ""
@ -179,41 +211,42 @@ class State:
def login_to_platform(username, password):
"""登录到平台获取token"""
try:
wwwF = {'userName': username, 'password': password}
url = 'https://jingsai.mools.net/api/login'
wwwF = {"userName": username, "password": password}
url = "https://jingsai.mools.net/api/login"
response = requests.post(url, wwwF,timeout=2)
response = requests.post(url, wwwF, timeout=2)
if response is None:
print("错误",'网络连接失败 ')
print("错误", "网络连接失败 ")
return None
request = json.loads(response.text)
if request['code'] == 1:
if request["code"] == 1:
# 登陆成功
# print("登陆成功",'登陆成功')
# 从服务器获取到的数据中找到token
token = request['token']
token = request["token"]
print("成功", "登录成功!")
return token
elif request['code'] == 2:
print("错误",'用户名或密码错误!')
elif request["code"] == 2:
print("错误", "用户名或密码错误!")
return None
else:
print("错误",'登陆失败 ')
print("错误", "登陆失败 ")
return None
except Exception as e:
print(e)
print("错误", f"发送数据时出错:{e}")
return None
def send_data_to_platform(token, data, picture):
"""将数据发送到平台"""
try:
# if 1:
# if 1:
# 打开图片文件并转换为 Base64 编码
with open(picture, "rb") as picture_file:
picture_data = picture_file.read()
base64_encoded_picture = base64.b64encode(picture_data).decode("utf-8")
# print(base64_encoded_picture)
# 更新数据字典,添加 Base64 编码的图片
@ -223,7 +256,7 @@ def send_data_to_platform(token, data, picture):
# 设置请求头
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
"Content-Type": "application/json",
}
url = "https://jingsai.mools.net/api/upload-record"
@ -234,11 +267,14 @@ def send_data_to_platform(token, data, picture):
request = json.loads(response.text)
# print(request['code'])
# 检查响应
if request['code'] == 1:
if request["code"] == 1:
print("提交成功", "提交成功")
else:
print("错误", f"网络请求失败,状态码:{response.status_code}\n错误信息:{response.text}")
print(
"错误",
f"网络请求失败,状态码:{response.status_code}\n错误信息:{response.text}",
)
except Exception as e:
raise e
@ -248,7 +284,7 @@ def send_data_to_platform(token, data, picture):
def setup_logging(log_level=logging.INFO, log_dir="logs"):
"""
设置logging配置创建不同模块的logger
Args:
log_level: 日志级别默认INFO
log_dir: 日志文件存储目录,默认"logs"
@ -256,19 +292,19 @@ def setup_logging(log_level=logging.INFO, log_dir="logs"):
# 创建日志目录
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# 获取当前时间作为日志文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = os.path.join(log_dir, f"titration_{timestamp}.log")
# 配置根logger
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)8s - %(levelname)7s - %(message)s',
format="%(asctime)s - %(name)8s - %(levelname)7s - %(message)s",
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler() # 同时输出到控制台
]
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler(), # 同时输出到控制台
],
)
return log_file

View File

@ -1,57 +1,67 @@
import cv2
import numpy as np
import ch340
flag = False
vidId = 1
cap = cv2.VideoCapture(vidId, cv2.CAP_DSHOW)
while not flag:
suc,im = cap.read()
cv2.imshow("image",im)
suc, im = cap.read()
cv2.imshow("image", im)
k = cv2.waitKey(0)
if k & 0xff == ord('q'):
if k & 0xFF == ord("q"):
# cap.release()
# cv2.destroyAllWindows()
flag=True
flag = True
else:
vidId+=1
vidId += 1
cap.release()
cap.open(vidId, cv2.CAP_DSHOW)
print(f"使用摄像头索引: {vidId}")
import ch340
base = 30
pump = ch340.CH340()
while True:
suc,im = cap.read()
hsv = cv2.cvtColor(im,cv2.COLOR_BGR2HSV)
s = hsv[:,:,1]
mask = s>base
cv2.imshow('mask',im*mask[:,:,np.newaxis])
tot = mask.shape[0]*mask.shape[1]
suc, im = cap.read()
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1]
mask = s > base
cv2.imshow("mask", im * mask[:, :, np.newaxis])
tot = mask.shape[0] * mask.shape[1]
val = np.sum(mask)
rate = val/tot
thr = (0.05,0.2, 0.5)
rate = val / tot
thr = (0.05, 0.2, 0.5)
ret = ""
if rate < thr[0]:
ret = "transport"
elif rate <thr[1]:
ret = "middle"
ret = "transport"
elif rate < thr[1]:
ret = "middle"
elif rate < thr[2]:
ret = "about"
ret = "about"
else:
ret = "colored"
im = cv2.putText(im, f"Rate: {rate:.2},base={base},{ret}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
cv2.imshow('image',im)
ret = "colored"
im = cv2.putText(
im,
f"Rate: {rate:.2},base={base},{ret}",
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
1,
(0, 255, 0),
2,
)
cv2.imshow("image", im)
k = cv2.waitKey(1)
if k & 0xff == ord('w'):
base+=1
elif k & 0xff == ord('s'):
base-=1
elif k & 0xff == ord('a'):
pump.push_async(speed=0.1,vol=0.1)
elif k & 0xff == ord('q'):
if k & 0xFF == ord("w"):
base += 1
elif k & 0xFF == ord("s"):
base -= 1
elif k & 0xFF == ord("a"):
pump.push_async(speed=0.1, vol=0.1)
elif k & 0xFF == ord("q"):
print("Camera index = ", vidId)
print("k =",base)
print("k =", base)
cap.release()
cv2.destroyAllWindows()
break
break