v1.1
支持本地视频测试
This commit is contained in:
Generated
+1
-3
@@ -3,7 +3,5 @@
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="fish_monter" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="fish_monter" project-jdk-type="Python SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="fish_monitor" project-jdk-type="Python SDK" />
|
||||
</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
|
||||
import streamlit as st
|
||||
import cv2
|
||||
from datetime import datetime
|
||||
from fish_tracker import FishTracker
|
||||
from history_manager import FishHistoryManager
|
||||
|
||||
|
||||
def main():
|
||||
@@ -11,12 +13,16 @@ def main():
|
||||
# 初始化模型(只加载一次)
|
||||
if 'tracker' not in st.session_state:
|
||||
st.session_state.tracker = FishTracker()
|
||||
if 'history' not in st.session_state:
|
||||
st.session_state.history = FishHistoryManager()
|
||||
|
||||
tracker = st.session_state.tracker
|
||||
history = st.session_state.history
|
||||
|
||||
# 视频显示区域
|
||||
frame_placeholder = st.empty()
|
||||
alert_placeholder = st.empty()
|
||||
report_placeholder = st.empty()
|
||||
|
||||
# 打开摄像头
|
||||
cap = cv2.VideoCapture(0)
|
||||
@@ -35,7 +41,11 @@ def main():
|
||||
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_frame = cv2.cvtColor(output_frame, cv2.COLOR_BGR2RGB)
|
||||
@@ -48,6 +58,10 @@ def main():
|
||||
else:
|
||||
alert_placeholder.empty()
|
||||
|
||||
# 显示报告生成信息
|
||||
if report_path:
|
||||
report_placeholder.success(f"✅ 今日报告已生成:{report_path}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
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 = []
|
||||
|
||||
def process_frame(self, frame):
|
||||
"""返回处理后帧、告警列表、每条鱼的统计信息"""
|
||||
self.alerts.clear()
|
||||
h, w = frame.shape[:2]
|
||||
|
||||
@@ -31,6 +32,7 @@ class FishTracker:
|
||||
|
||||
# 3. 更新轨迹 & 绘制
|
||||
output_frame = frame.copy()
|
||||
track_stats = []
|
||||
for track in tracks:
|
||||
if not track.is_confirmed():
|
||||
continue
|
||||
@@ -44,6 +46,7 @@ class FishTracker:
|
||||
|
||||
# 计算速度(像素/秒)
|
||||
traj = self.trajectories[track_id]
|
||||
step_dist = 0
|
||||
if len(traj) >= 2:
|
||||
total_dist = 0
|
||||
total_time = 0
|
||||
@@ -51,6 +54,8 @@ class FishTracker:
|
||||
dx = traj[i][0] - traj[i-1][0]
|
||||
dy = traj[i][1] - traj[i-1][1]
|
||||
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()
|
||||
total_dist += dist
|
||||
total_time += dt
|
||||
@@ -62,12 +67,21 @@ class FishTracker:
|
||||
is_bottom = cy > h * 0.8
|
||||
|
||||
# 异常规则
|
||||
is_slow = speed < 15
|
||||
if speed < 15 and is_bottom:
|
||||
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
|
||||
cv2.rectangle(output_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||
cv2.putText(output_frame, f"Fish-{track_id}", (x1, y1 - 10),
|
||||
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