支持本地视频测试
This commit is contained in:
z66
2025-12-23 17:59:18 +08:00
parent ae9d252255
commit 1a135cdda7
9 changed files with 301 additions and 14 deletions
+1 -3
View File
@@ -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>
+72
View File
@@ -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 版本兼容。
## 下一步可以做什么
- 引入自定义训练的鱼类检测模型,提升精度。
- 丰富健康判定规则(体表异常、呼吸频率、群体行为),将日度报告改为可视化仪表盘。
- 增加数据记录与回放,支持告警/报告历史查询。
+15 -1
View File
@@ -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:
-9
View File
@@ -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
View File
@@ -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
+98
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
deep_sort_realtime==1.3.2
numpy==2.4.0
streamlit==1.52.2
ultralytics==8.3.241
+96
View File
@@ -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
View File
Binary file not shown.