v1.1
支持本地视频测试
This commit is contained in:
Generated
+1
-3
@@ -3,7 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="fish_monter" />
|
<option name="sdkName" value="fish_monter" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="fish_monter" project-jdk-type="Python SDK">
|
<component name="ProjectRootManager" version="2" project-jdk-name="fish_monitor" project-jdk-type="Python SDK" />
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
</project>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 观赏鱼健康监控系统
|
||||||
|
|
||||||
|
基于 YOLOv8 + DeepSORT 的实时观赏鱼检测与健康异常告警 Demo(笔记本摄像头版),通过 Streamlit 提供可视化界面。
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
- 实时调用摄像头检测 COCO 数据集中的 `fish` 类别(ID=17),并进行多目标跟踪。
|
||||||
|
- 记录每条鱼最近 30 帧的运动轨迹,计算速度(像素/秒)。
|
||||||
|
- 简单异常规则:鱼体速度低且位于画面底部 20% 区域时发出“低速 + 沉底”告警。
|
||||||
|
- 在页面同时展示视频画面与告警信息。
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
- `app.py`:Streamlit 前端入口,负责摄像头采集、显示和告警呈现。
|
||||||
|
- `fish_tracker.py`:封装检测、跟踪、轨迹维护与异常规则。
|
||||||
|
- `requirements.txt`:依赖列表。
|
||||||
|
- `yolov8n.pt`:YOLOv8n 预训练权重(COCO)。
|
||||||
|
|
||||||
|
## 环境依赖
|
||||||
|
### Python 包
|
||||||
|
`requirements.txt` 已列出核心依赖:
|
||||||
|
- ultralytics==8.3.241
|
||||||
|
- deep_sort_realtime==1.3.2
|
||||||
|
- streamlit==1.52.2
|
||||||
|
- numpy==2.4.0
|
||||||
|
|
||||||
|
建议使用 Python 3.9+。安装:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 额外系统依赖
|
||||||
|
- OpenCV 访问摄像头需要正确的驱动与权限。
|
||||||
|
- 若使用 GPU,加装对应版本的 PyTorch 与 CUDA(`ultralytics` 会依赖 torch,确保版本匹配)。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
1) 安装依赖(见上)。
|
||||||
|
2) 连接摄像头,确保权限正常。
|
||||||
|
3) 运行 Streamlit:
|
||||||
|
```bash
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
4) 在浏览器中打开提示的本地地址,实时查看画面、告警,并自动积累鱼类活动档案;每日 20:00 自动生成当日报告(`reports/fish_report_YYYYMMDD.txt`)。
|
||||||
|
|
||||||
|
## 核心处理流程
|
||||||
|
- `FishTracker.process_frame(frame)`:
|
||||||
|
1. 使用 YOLOv8 推理,过滤出 `fish` 类别且置信度高于阈值(默认 0.5)。
|
||||||
|
2. 送入 DeepSORT 进行多目标跟踪,获取每条鱼的 ID 与位置。
|
||||||
|
3. 维护每个 ID 近 30 帧的轨迹,计算平均速度(像素/秒),并输出 `track_stats`(速度、是否在底部、步进位移等)。
|
||||||
|
4. 规则判断:速度 < 15 且中心点落在图像底部 20% 区域则告警。
|
||||||
|
5. 在帧上绘制边框与 ID,并返回帧、告警列表和 `track_stats`。
|
||||||
|
- `history_manager.py`:累计各鱼 ID 的当日指标(帧数、位移、底部占比、慢速占比、平均速度),20:00 生成日度报告并写入 `reports/`。
|
||||||
|
- `app.py` 循环读取摄像头帧,调用检测与历史积累,时间到达 20:00 时自动生成报告并提示。
|
||||||
|
|
||||||
|
## 配置与自定义
|
||||||
|
- 置信度阈值、规则阈值可在 `FishTracker.__init__` 或 `process_frame` 中调整。
|
||||||
|
- 若要替换模型权重,可将 `model_path` 指向新 `.pt` 文件。
|
||||||
|
- 需要支持更多异常模式(如停留时间过长、群体异常等)可在规则部分扩展。
|
||||||
|
|
||||||
|
## 已知局限
|
||||||
|
- 依赖 COCO 中的 `fish` 类别,针对特定观赏鱼场景准确率有限;建议用自定义数据微调权重。
|
||||||
|
- 规则简单,仅基于速度与位置,可能产生漏报/误报。
|
||||||
|
- 未包含录像/截图/日志持久化,需自行扩展。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
- **无法打开摄像头**:检查设备权限和驱动;在 Windows 上确保未被其他应用占用。
|
||||||
|
- **模型加载慢/显存占用高**:可换更小模型或在 CPU 上运行(速度会下降)。
|
||||||
|
- **DeepSORT 报错或缺少依赖**:确认安装了 `deep_sort_realtime`,并确保 OpenCV 版本兼容。
|
||||||
|
|
||||||
|
## 下一步可以做什么
|
||||||
|
- 引入自定义训练的鱼类检测模型,提升精度。
|
||||||
|
- 丰富健康判定规则(体表异常、呼吸频率、群体行为),将日度报告改为可视化仪表盘。
|
||||||
|
- 增加数据记录与回放,支持告警/报告历史查询。
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# app.py
|
# app.py
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import cv2
|
import cv2
|
||||||
|
from datetime import datetime
|
||||||
from fish_tracker import FishTracker
|
from fish_tracker import FishTracker
|
||||||
|
from history_manager import FishHistoryManager
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -11,12 +13,16 @@ def main():
|
|||||||
# 初始化模型(只加载一次)
|
# 初始化模型(只加载一次)
|
||||||
if 'tracker' not in st.session_state:
|
if 'tracker' not in st.session_state:
|
||||||
st.session_state.tracker = FishTracker()
|
st.session_state.tracker = FishTracker()
|
||||||
|
if 'history' not in st.session_state:
|
||||||
|
st.session_state.history = FishHistoryManager()
|
||||||
|
|
||||||
tracker = st.session_state.tracker
|
tracker = st.session_state.tracker
|
||||||
|
history = st.session_state.history
|
||||||
|
|
||||||
# 视频显示区域
|
# 视频显示区域
|
||||||
frame_placeholder = st.empty()
|
frame_placeholder = st.empty()
|
||||||
alert_placeholder = st.empty()
|
alert_placeholder = st.empty()
|
||||||
|
report_placeholder = st.empty()
|
||||||
|
|
||||||
# 打开摄像头
|
# 打开摄像头
|
||||||
cap = cv2.VideoCapture(0)
|
cap = cv2.VideoCapture(0)
|
||||||
@@ -35,7 +41,11 @@ def main():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# 处理帧
|
# 处理帧
|
||||||
output_frame, alerts = tracker.process_frame(frame)
|
output_frame, alerts, track_stats = tracker.process_frame(frame)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
history.update(track_stats, now)
|
||||||
|
report_path = history.maybe_generate_report(now)
|
||||||
|
|
||||||
# 转为 RGB 显示(Streamlit 要求)
|
# 转为 RGB 显示(Streamlit 要求)
|
||||||
rgb_frame = cv2.cvtColor(output_frame, cv2.COLOR_BGR2RGB)
|
rgb_frame = cv2.cvtColor(output_frame, cv2.COLOR_BGR2RGB)
|
||||||
@@ -48,6 +58,10 @@ def main():
|
|||||||
else:
|
else:
|
||||||
alert_placeholder.empty()
|
alert_placeholder.empty()
|
||||||
|
|
||||||
|
# 显示报告生成信息
|
||||||
|
if report_path:
|
||||||
|
report_placeholder.success(f"✅ 今日报告已生成:{report_path}")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="GENERAL_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="jdk" jdkName="fish_monter" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
+15
-1
@@ -14,6 +14,7 @@ class FishTracker:
|
|||||||
self.alerts = []
|
self.alerts = []
|
||||||
|
|
||||||
def process_frame(self, frame):
|
def process_frame(self, frame):
|
||||||
|
"""返回处理后帧、告警列表、每条鱼的统计信息"""
|
||||||
self.alerts.clear()
|
self.alerts.clear()
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class FishTracker:
|
|||||||
|
|
||||||
# 3. 更新轨迹 & 绘制
|
# 3. 更新轨迹 & 绘制
|
||||||
output_frame = frame.copy()
|
output_frame = frame.copy()
|
||||||
|
track_stats = []
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
if not track.is_confirmed():
|
if not track.is_confirmed():
|
||||||
continue
|
continue
|
||||||
@@ -44,6 +46,7 @@ class FishTracker:
|
|||||||
|
|
||||||
# 计算速度(像素/秒)
|
# 计算速度(像素/秒)
|
||||||
traj = self.trajectories[track_id]
|
traj = self.trajectories[track_id]
|
||||||
|
step_dist = 0
|
||||||
if len(traj) >= 2:
|
if len(traj) >= 2:
|
||||||
total_dist = 0
|
total_dist = 0
|
||||||
total_time = 0
|
total_time = 0
|
||||||
@@ -51,6 +54,8 @@ class FishTracker:
|
|||||||
dx = traj[i][0] - traj[i-1][0]
|
dx = traj[i][0] - traj[i-1][0]
|
||||||
dy = traj[i][1] - traj[i-1][1]
|
dy = traj[i][1] - traj[i-1][1]
|
||||||
dist = np.sqrt(dx*dx + dy*dy)
|
dist = np.sqrt(dx*dx + dy*dy)
|
||||||
|
if i == len(traj) - 1:
|
||||||
|
step_dist = dist
|
||||||
dt = (traj[i][2] - traj[i-1][2]) / cv2.getTickFrequency()
|
dt = (traj[i][2] - traj[i-1][2]) / cv2.getTickFrequency()
|
||||||
total_dist += dist
|
total_dist += dist
|
||||||
total_time += dt
|
total_time += dt
|
||||||
@@ -62,12 +67,21 @@ class FishTracker:
|
|||||||
is_bottom = cy > h * 0.8
|
is_bottom = cy > h * 0.8
|
||||||
|
|
||||||
# 异常规则
|
# 异常规则
|
||||||
|
is_slow = speed < 15
|
||||||
if speed < 15 and is_bottom:
|
if speed < 15 and is_bottom:
|
||||||
self.alerts.append(f"⚠️ 鱼 {track_id}: 低速({speed:.1f}px/s) + 沉底")
|
self.alerts.append(f"⚠️ 鱼 {track_id}: 低速({speed:.1f}px/s) + 沉底")
|
||||||
|
|
||||||
|
track_stats.append({
|
||||||
|
"id": track_id,
|
||||||
|
"speed": speed,
|
||||||
|
"is_bottom": is_bottom,
|
||||||
|
"is_slow": is_slow,
|
||||||
|
"distance_delta": step_dist,
|
||||||
|
})
|
||||||
|
|
||||||
# 绘制框和ID
|
# 绘制框和ID
|
||||||
cv2.rectangle(output_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
cv2.rectangle(output_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||||
cv2.putText(output_frame, f"Fish-{track_id}", (x1, y1 - 10),
|
cv2.putText(output_frame, f"Fish-{track_id}", (x1, y1 - 10),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
||||||
|
|
||||||
return output_frame, self.alerts
|
return output_frame, self.alerts, track_stats
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import mean
|
||||||
|
|
||||||
|
|
||||||
|
class FishHistoryManager:
|
||||||
|
"""维护每日鱼类活动档案,并在指定时间生成报告。"""
|
||||||
|
|
||||||
|
def __init__(self, report_dir: str = "reports", report_hour: int = 20):
|
||||||
|
self.report_dir = Path(report_dir)
|
||||||
|
self.report_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.report_hour = report_hour
|
||||||
|
self.records = {} # fish_id -> metrics
|
||||||
|
self.last_report_date: date | None = None
|
||||||
|
|
||||||
|
def update(self, track_stats, ts: datetime):
|
||||||
|
"""用当前帧的跟踪统计更新档案。"""
|
||||||
|
for stats in track_stats:
|
||||||
|
fid = stats["id"]
|
||||||
|
rec = self.records.setdefault(fid, {
|
||||||
|
"frames": 0,
|
||||||
|
"distance": 0.0,
|
||||||
|
"bottom_frames": 0,
|
||||||
|
"slow_frames": 0,
|
||||||
|
"speed_samples": [],
|
||||||
|
"first_seen": ts,
|
||||||
|
"last_seen": ts,
|
||||||
|
})
|
||||||
|
rec["frames"] += 1
|
||||||
|
rec["distance"] += float(stats.get("distance_delta", 0) or 0)
|
||||||
|
if stats.get("is_bottom"):
|
||||||
|
rec["bottom_frames"] += 1
|
||||||
|
if stats.get("is_slow"):
|
||||||
|
rec["slow_frames"] += 1
|
||||||
|
speed = stats.get("speed")
|
||||||
|
if speed is not None:
|
||||||
|
rec["speed_samples"].append(float(speed))
|
||||||
|
rec["last_seen"] = ts
|
||||||
|
|
||||||
|
def _analyze_record(self, fid, rec):
|
||||||
|
frames = rec.get("frames", 1)
|
||||||
|
bottom_ratio = rec.get("bottom_frames", 0) / frames
|
||||||
|
slow_ratio = rec.get("slow_frames", 0) / frames
|
||||||
|
avg_speed = mean(rec["speed_samples"]) if rec["speed_samples"] else 0
|
||||||
|
distance = rec.get("distance", 0)
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
if avg_speed < 10 or slow_ratio > 0.5:
|
||||||
|
issues.append("活动偏低")
|
||||||
|
if bottom_ratio > 0.4:
|
||||||
|
issues.append("长时间停留底部")
|
||||||
|
|
||||||
|
health = "疑似异常" if issues else "正常"
|
||||||
|
return {
|
||||||
|
"fish_id": fid,
|
||||||
|
"frames": frames,
|
||||||
|
"distance": distance,
|
||||||
|
"avg_speed": avg_speed,
|
||||||
|
"bottom_ratio": bottom_ratio,
|
||||||
|
"slow_ratio": slow_ratio,
|
||||||
|
"health": health,
|
||||||
|
"issues": issues,
|
||||||
|
"active_period": f"{rec['first_seen'].strftime('%H:%M:%S')} - {rec['last_seen'].strftime('%H:%M:%S')}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_report(self, report_date: date | None = None):
|
||||||
|
"""生成报告文本并写入文件,返回文件路径。"""
|
||||||
|
report_date = report_date or date.today()
|
||||||
|
analyses = [self._analyze_record(fid, rec) for fid, rec in self.records.items()]
|
||||||
|
analyses.sort(key=lambda x: x["fish_id"])
|
||||||
|
|
||||||
|
lines = [f"鱼类日度报告 - {report_date.strftime('%Y-%m-%d')}"]
|
||||||
|
if not analyses:
|
||||||
|
lines.append("今日无检测数据。")
|
||||||
|
else:
|
||||||
|
for item in analyses:
|
||||||
|
issues_text = ";".join(item["issues"]) if item["issues"] else "无"
|
||||||
|
lines.append(
|
||||||
|
f"- 鱼 {item['fish_id']}: 状态={item['health']},平均速度={item['avg_speed']:.1f}px/s,"
|
||||||
|
f"底部占比={item['bottom_ratio']:.2f},慢速占比={item['slow_ratio']:.2f},"
|
||||||
|
f"累计位移={item['distance']:.1f}px,时间段={item['active_period']},问题={issues_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
report_path = self.report_dir / f"fish_report_{report_date.strftime('%Y%m%d')}.txt"
|
||||||
|
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
self.last_report_date = report_date
|
||||||
|
# 每日报告后可选择清空记录,当前继续保留,次日将覆盖分析。
|
||||||
|
return report_path
|
||||||
|
|
||||||
|
def maybe_generate_report(self, now: datetime):
|
||||||
|
"""在到达指定时间且今日未生成报告时生成报告。"""
|
||||||
|
if now.hour >= self.report_hour:
|
||||||
|
today = now.date()
|
||||||
|
if self.last_report_date != today:
|
||||||
|
return self.generate_report(today)
|
||||||
|
return None
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
deep_sort_realtime==1.3.2
|
||||||
|
numpy==2.4.0
|
||||||
|
streamlit==1.52.2
|
||||||
|
ultralytics==8.3.241
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线视频检测与告警验证脚本
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
python test_video.py --video sample.mp4
|
||||||
|
python test_video.py --video sample.mp4 --output result.mp4
|
||||||
|
|
||||||
|
按下 `q` 可中途退出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import cv2
|
||||||
|
from datetime import datetime
|
||||||
|
from fish_tracker import FishTracker
|
||||||
|
from history_manager import FishHistoryManager
|
||||||
|
|
||||||
|
|
||||||
|
def run(video_path: str, output_path: str | None, report_path: str | None):
|
||||||
|
tracker = FishTracker()
|
||||||
|
history = FishHistoryManager()
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise RuntimeError(f"无法打开视频文件:{video_path}")
|
||||||
|
|
||||||
|
writer = None
|
||||||
|
if output_path:
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 25
|
||||||
|
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
writer = cv2.VideoWriter(output_path, fourcc, fps, (w, h))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ret, frame = cap.read()
|
||||||
|
# 读取失败:可能是到结尾,也可能是坏帧,先尝试继续几次
|
||||||
|
if not ret or frame is None:
|
||||||
|
# 到达文件末尾:直接退出
|
||||||
|
if not ret:
|
||||||
|
break
|
||||||
|
# 否则跳过本帧
|
||||||
|
print("警告:读取到空帧,跳过。")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 防御性检查:帧尺寸异常也跳过
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
if h == 0 or w == 0:
|
||||||
|
print("警告:帧尺寸异常,跳过。")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 个别帧解码失败时,YOLO/DeepSORT 可能抛异常,这里捕获并跳过
|
||||||
|
try:
|
||||||
|
output_frame, alerts, track_stats = tracker.process_frame(frame)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"警告:处理当前帧时出错,已跳过。错误信息:{e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
history.update(track_stats, now)
|
||||||
|
|
||||||
|
# 显示结果
|
||||||
|
if alerts:
|
||||||
|
print("; ".join(alerts))
|
||||||
|
cv2.imshow("fish monitor (press q to exit)", output_frame)
|
||||||
|
|
||||||
|
if writer:
|
||||||
|
writer.write(output_frame)
|
||||||
|
|
||||||
|
if cv2.waitKey(1) & 0xFF == ord("q"):
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
if writer:
|
||||||
|
writer.release()
|
||||||
|
if report_path:
|
||||||
|
# 结束后生成一次离线报告
|
||||||
|
out = history.generate_report()
|
||||||
|
print(f"报告已生成:{out}")
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="离线视频检测验证脚本")
|
||||||
|
parser.add_argument("--video", required=True, help="待检测的视频文件路径")
|
||||||
|
parser.add_argument("--output", help="可选,保存标注结果的视频路径")
|
||||||
|
parser.add_argument("--report", action="store_true", help="视频结束后生成报告(reports/ 下)")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
report_path = "reports/fish_report_offline.txt" if args.report else None
|
||||||
|
run(args.video, args.output, report_path)
|
||||||
|
|
||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user