initial commit
This commit is contained in:
commit
0bafbee31f
54 files changed
+6317
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
models/*
|
||||||
|
!models/.gitkeep
|
||||||
|
runs/
|
||||||
|
input/*
|
||||||
|
!input/.gitkeep
|
||||||
|
|
||||||
|
font_tmp/
|
||||||
|
fonts-noto-cjk_*.deb
|
||||||
|
README?.md
|
||||||
Submodule
+1
Submodule .git_backup_20260821 added at f07b49e0bb.
@@ -0,0 +1 @@
|
|||||||
|
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
runs/
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# OpenCV 运行时依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgl1 \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
COPY assets/fonts /app/assets/fonts
|
||||||
|
COPY run_batch.sh /app/run_batch.sh
|
||||||
|
RUN mkdir -p /app/models /app/input /app/runs \
|
||||||
|
&& chmod +x /app/run_batch.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["bash", "/app/run_batch.sh"]
|
||||||
|
CMD ["--input", "/app/input", "--output", "/app/runs"]
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Bird Detect Cascade Batch
|
||||||
|
|
||||||
|
这是一个 **YOLOv5 + ConvNeXt + 贝叶斯密度估计** 级联鸟类识别批处理程序。它通过 bash 命令处理图片、视频或文件夹,并输出标注结果、JSON、XML。
|
||||||
|
|
||||||
|
## 1. 最常用运行方式
|
||||||
|
|
||||||
|
处理单张图片、单个视频或一个文件夹:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input
|
||||||
|
```
|
||||||
|
|
||||||
|
指定输出目录,并关闭密度估计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input --output /path/to/runs --disable-density
|
||||||
|
```
|
||||||
|
|
||||||
|
输入路径可以是图片、视频,也可以是包含图片/视频的文件夹。默认会递归处理子文件夹。
|
||||||
|
|
||||||
|
三个模型都可以单独关闭,例如只运行密度估计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input \
|
||||||
|
--disable-detection \
|
||||||
|
--disable-classification
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 哪些参数可以直接写在 bash 指令里
|
||||||
|
|
||||||
|
下面这些参数都可以直接写在 `bash run_batch.sh ...` 后面,不需要改 Python 代码。
|
||||||
|
|
||||||
|
| bash 参数 | 默认值 | 含义 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `--input PATH` | 无,必填 | 输入路径,可以是图片、视频或文件夹 |
|
||||||
|
| `--output PATH` | `./runs` | 输出根目录 |
|
||||||
|
| `--sample-interval INT` | `2` | 视频抽帧间隔,`2` 表示每 2 帧处理 1 帧 |
|
||||||
|
| `--recursive` | 开启 | 输入是文件夹时递归处理子目录 |
|
||||||
|
| `--no-recursive` | 关闭递归 | 只处理输入文件夹第一层文件 |
|
||||||
|
| `--enable-detection` | 开启 | 启用 YOLO 目标检测 |
|
||||||
|
| `--disable-detection` | 关闭目标检测 | 不加载、不运行 YOLO;检测结果为空 |
|
||||||
|
| `--enable-classification` | 开启 | 启用 ConvNeXt 细粒度识别 |
|
||||||
|
| `--disable-classification` | 关闭细粒度识别 | 不加载、不运行 ConvNeXt;保留 YOLO 检测框 |
|
||||||
|
| `--enable-density` | 开启 | 启用贝叶斯密度估计 |
|
||||||
|
| `--disable-density` | 关闭密度估计 | 不运行密度估计,只运行 YOLO + ConvNeXt |
|
||||||
|
| `--det-conf-thres FLOAT` | `0.25` | YOLO 第一层检测阈值,低于该值的框直接丢弃 |
|
||||||
|
| `--det-iou-thres FLOAT` | `0.45` | NMS IoU 阈值;越低,重叠框去得越狠 |
|
||||||
|
| `--det-second-conf-thres FLOAT` | `0.35` | YOLO 第二层过滤阈值,低于该值不会裁剪,也不会进入 ConvNeXt |
|
||||||
|
| `--min-box-area-ratio FLOAT` | `0.00002` | 过滤面积占比过小的噪声框 |
|
||||||
|
| `--max-box-area-ratio FLOAT` | `1.0` | 最大框面积占比;默认不按面积过滤大目标 |
|
||||||
|
| `--max-box-aspect-ratio FLOAT` | `6.0` | 过滤长宽比过大的细长框 |
|
||||||
|
| `--cls-show-conf-thres FLOAT` | `0.50` | 普通目标分类置信度低于该值时,不在结果图显示物种名 |
|
||||||
|
| `--large-box-area-ratio FLOAT` | `0.002` | 框面积占比超过该值时,认为是近景大目标 |
|
||||||
|
| `--large-box-cls-show-conf-thres FLOAT` | `0.25` | 大目标显示物种名的较低分类阈值 |
|
||||||
|
| `--show-large-box-name-always BOOL` | `true` | 大目标是否只要分类名非空就显示 |
|
||||||
|
| `--dense-scene-count-thres INT` | `20` | 密度估计数量达到该值时,认为是密集场景 |
|
||||||
|
| `--hide-cls-name-in-dense-scene BOOL` | `true` | 密集场景下是否隐藏所有物种名 |
|
||||||
|
| `--crop-padding-ratio FLOAT` | `0.08` | 送入 ConvNeXt 前,检测框向外扩的比例 |
|
||||||
|
| `--max-detections INT` | `50` | 单张图或单帧最多保留的检测框数量 |
|
||||||
|
| `--uniform-species-color BOOL` | `false` | `true` 时所有物种的检测框和标签统一使用同一种颜色 |
|
||||||
|
|
||||||
|
示例:降低检测阈值、提高进入分类前的二次过滤阈值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh \
|
||||||
|
--input /path/to/input \
|
||||||
|
--det-conf-thres 0.15 \
|
||||||
|
--det-second-conf-thres 0.45
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:密集场景仍然显示物种名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh \
|
||||||
|
--input /path/to/input \
|
||||||
|
--hide-cls-name-in-dense-scene false
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:让所有物种的检测框和标签统一使用绿色:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh \
|
||||||
|
--input /path/to/input \
|
||||||
|
--uniform-species-color true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 阈值怎么调
|
||||||
|
|
||||||
|
最关键的是这两个参数:
|
||||||
|
|
||||||
|
- `--det-conf-thres`: YOLO 第一层阈值。低于这个值的框会在 YOLO 后处理阶段直接丢弃。
|
||||||
|
- `--det-second-conf-thres`: YOLO 第二层阈值。低于这个值的框不会裁剪,也不会传给 ConvNeXt 分类。
|
||||||
|
|
||||||
|
常见调法:
|
||||||
|
|
||||||
|
- 漏检多:降低 `--det-conf-thres`,例如 `0.25 -> 0.15`。
|
||||||
|
- 误检多:提高 `--det-second-conf-thres`,例如 `0.35 -> 0.45`。
|
||||||
|
- 小噪声框多:提高 `--min-box-area-ratio`。
|
||||||
|
- 异常大框多:降低 `--max-box-area-ratio`;不要为了排除异常框把它设得过低,否则近景大鸟会被误删。
|
||||||
|
- 细长误检框多:降低 `--max-box-aspect-ratio`。
|
||||||
|
- 检测到了但不显示物种名:降低 `--cls-show-conf-thres`,或检查 `--hide-cls-name-in-dense-scene`。
|
||||||
|
- 密集场景过早隐藏物种名:提高 `--dense-scene-count-thres`,或设置 `--hide-cls-name-in-dense-scene false`。
|
||||||
|
|
||||||
|
这些阈值现在都可以写在 bash 指令里,不需要去代码里调。
|
||||||
|
|
||||||
|
## 4. 三个模型的开关
|
||||||
|
|
||||||
|
三个模型默认都开启:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input
|
||||||
|
```
|
||||||
|
|
||||||
|
关闭目标检测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input --disable-detection
|
||||||
|
```
|
||||||
|
|
||||||
|
关闭细粒度识别,只输出 YOLO 检测框:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input --disable-classification
|
||||||
|
```
|
||||||
|
|
||||||
|
关闭密度估计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input --disable-density
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以任意组合,例如只运行目标检测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input \
|
||||||
|
--disable-classification \
|
||||||
|
--disable-density
|
||||||
|
```
|
||||||
|
|
||||||
|
只运行密度估计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_batch.sh --input /path/to/input \
|
||||||
|
--disable-detection \
|
||||||
|
--disable-classification
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:细粒度识别依赖目标检测提供裁剪框,因此关闭目标检测后,ConvNeXt 不会加载或运行,即使显式传入 `--enable-classification`。密度估计与另外两个模型相互独立。
|
||||||
|
|
||||||
|
关闭某个模型后:
|
||||||
|
|
||||||
|
- 对应 ONNX 模型不会加载,也不会执行推理;
|
||||||
|
- 关闭目标检测时,`results` 为空;
|
||||||
|
- 关闭细粒度识别时,每个检测结果的 `cls_class_id` 为 `-1`、`cls_class_name` 为空、`cls_confidence` 为 `0.0`;
|
||||||
|
- 关闭密度估计时,JSON 里的 `density` 为 `null`,结果图不显示密度热力图和密度估计数量。
|
||||||
|
|
||||||
|
颜色开关不影响模型推理和 JSON/XML,只影响结果图片或视频的可视化:
|
||||||
|
|
||||||
|
- `--uniform-species-color false`:默认行为,每个物种使用各自的稳定颜色;
|
||||||
|
- `--uniform-species-color true`:所有物种的检测框和标签统一使用绿色。
|
||||||
|
|
||||||
|
## 5. 哪些配置用环境变量,不直接写 bash 参数
|
||||||
|
|
||||||
|
模型路径、输入尺寸、设备这类参数通常不频繁改,所以保留为环境变量。它们可以写在 bash 命令前面,也不需要改代码。
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 含义 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `ENABLE_DETECTION` | `true` | 是否启用 YOLO 目标检测 |
|
||||||
|
| `ENABLE_CLASSIFICATION` | `true` | 是否启用 ConvNeXt 细粒度识别 |
|
||||||
|
| `ENABLE_DENSITY` | `true` | 是否启用密度估计 |
|
||||||
|
| `DET_MODEL_PATH` | `models/YNDC_20251010_yolov5_optimize.onnx` | YOLOv5 检测模型 |
|
||||||
|
| `DET_CLASSES_PATH` | `models/YNDC_20251010_yolov5_optimize.txt` | YOLO 类别文件 |
|
||||||
|
| `CLS_MODEL_PATH` | `models/HHK_20260525_mmcls_optimize.onnx` | ConvNeXt 分类模型 |
|
||||||
|
| `CLS_CLASSES_PATH` | `models/HHK_20260525_mmcls_optimize.txt` | ConvNeXt 类别文件 |
|
||||||
|
| `DENSITY_MODEL_PATH` | `models/bird_density.onnx` | 密度估计模型 |
|
||||||
|
| `DET_IMG_SIZE` | `640` | YOLO 输入尺寸 |
|
||||||
|
| `CLS_IMG_SIZE` | `224` | ConvNeXt 输入尺寸 |
|
||||||
|
| `DENSITY_IMG_SIZE` | `0` | 密度模型输入尺寸;`0` 表示自动读取模型输入 |
|
||||||
|
| `TARGET_DET_CLASS_IDS` | `0` | 只保留哪些 YOLO 检测类别 |
|
||||||
|
| `DEVICE` | `cpu` | ONNX Runtime 运行设备,支持 `cpu` 或 `cuda`;CUDA 不可用时直接报错,不会静默回退 CPU |
|
||||||
|
| `UNIFORM_SPECIES_COLOR` | `false` | 是否让所有物种的检测框和标签使用同一种颜色 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVICE=cpu TARGET_DET_CLASS_IDS=0 bash run_batch.sh --input /path/to/input
|
||||||
|
```
|
||||||
|
|
||||||
|
指定 GPU 0 运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CUDA_VISIBLE_DEVICES=0 DEVICE=cuda bash run_batch.sh --input /path/to/input
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 CUDA 前必须安装与服务器 CUDA/cuDNN 匹配的 `onnxruntime-gpu`。启动时程序会分别打印 YOLO、ConvNeXt、密度模型的实际 Provider;使用 GPU 时应看到 `CUDAExecutionProvider`。
|
||||||
|
|
||||||
|
## 6. 哪些情况才需要改代码
|
||||||
|
|
||||||
|
一般调阈值、开关三个模型、切换统一颜色、改模型路径都不需要改代码。
|
||||||
|
|
||||||
|
只有下面这些情况通常需要改代码:
|
||||||
|
|
||||||
|
- YOLO ONNX 输出格式不是 `[1, num_boxes, 5 + num_classes]` 或 `[num_boxes, 6]`,需要改 `app/inference/yolo_onnx.py`。
|
||||||
|
- ConvNeXt 训练时没有使用 ImageNet mean/std,需要改 `app/inference/convnext_onnx.py` 的归一化参数。
|
||||||
|
- 密度估计模型的前处理或输出含义和当前实现不一致,需要改 `app/inference/density_onnx.py`。
|
||||||
|
- 想改变结果 JSON/XML 字段结构,需要改 `app/batch_runner.py` 或 `app/utils.py`。
|
||||||
|
|
||||||
|
## 7. 输出结果
|
||||||
|
|
||||||
|
每次运行会生成一个任务目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runs/<时间戳>_<输入名>/
|
||||||
|
├── result/ # 标注后的图片或视频
|
||||||
|
├── json/ # 结构化结果
|
||||||
|
├── xml/ # XML 标注结果
|
||||||
|
└── index.json # 本次批处理汇总
|
||||||
|
```
|
||||||
|
|
||||||
|
图片输出:
|
||||||
|
|
||||||
|
- `result/<原文件名>.jpg`
|
||||||
|
- `json/<原文件名>.json`
|
||||||
|
- `xml/<原文件名>.xml`
|
||||||
|
|
||||||
|
视频输出:
|
||||||
|
|
||||||
|
- `result/<原文件名>.mp4`
|
||||||
|
- `json/<原文件名>.json`
|
||||||
|
- `xml/<原文件名>.xml`
|
||||||
|
|
||||||
|
JSON 中每个目标包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bbox_xyxy": [100.0, 120.0, 240.0, 310.0],
|
||||||
|
"det_confidence": 0.91,
|
||||||
|
"det_class_id": 0,
|
||||||
|
"det_class_name": "bird",
|
||||||
|
"cls_class_id": 12,
|
||||||
|
"cls_class_name": "绿头鸭",
|
||||||
|
"cls_confidence": 0.87,
|
||||||
|
"box_area_ratio": 0.0032,
|
||||||
|
"is_large_box": true,
|
||||||
|
"is_dense_scene": false,
|
||||||
|
"show_cls_name": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Docker 备选运行方式
|
||||||
|
|
||||||
|
如果不想在宿主机安装 Python 依赖,可以用 Docker。Docker 只是备选,默认仍然执行同一个 `run_batch.sh`。
|
||||||
|
|
||||||
|
构建镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t bird-cascade-batch:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Compose 处理项目根目录的 `input/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm bird-cascade-batch
|
||||||
|
```
|
||||||
|
|
||||||
|
临时覆盖参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm bird-cascade-batch \
|
||||||
|
--input /app/input \
|
||||||
|
--output /app/runs \
|
||||||
|
--sample-interval 5 \
|
||||||
|
--disable-density
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` 默认挂载:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./models:/app/models:ro
|
||||||
|
- ./input:/app/input:ro
|
||||||
|
- ./runs:/app/runs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 程序结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── batch_runner.py # 批处理入口
|
||||||
|
├── config.py # 环境变量配置
|
||||||
|
├── pipeline.py # 主级联流程
|
||||||
|
├── utils.py # 图像、JSON、XML 工具
|
||||||
|
├── video_pipeline.py # 视频平滑工具
|
||||||
|
└── inference/
|
||||||
|
├── yolo_onnx.py # YOLOv5 ONNX 推理和后处理
|
||||||
|
├── convnext_onnx.py # ConvNeXt ONNX 推理
|
||||||
|
└── density_onnx.py # 密度估计 ONNX 推理
|
||||||
|
```
|
||||||
|
|
||||||
|
历史文件 `pipeline_raw.py`、`pipeline_add_density.py`、`config_raw.py`、`batch_runner_raw.py` 不参与默认 bash 批处理主流程。
|
||||||
+455
@@ -0,0 +1,455 @@
|
|||||||
|
# Bird Detect Cascade Service:YOLOv5 + ConvNeXt 鸟类级联识别服务
|
||||||
|
|
||||||
|
本项目用于将 **YOLOv5 目标检测模型** 和 **ConvNeXt 鸟类细粒度分类模型** 级联起来:
|
||||||
|
|
||||||
|
1. 接收图片或视频;
|
||||||
|
2. 使用 YOLOv5 检测鸟类目标位置;
|
||||||
|
3. 对每个检测框进行目标裁剪;
|
||||||
|
4. 将裁剪图送入 ConvNeXt 分类模型;
|
||||||
|
5. 返回每个目标的位置、检测置信度、分类类别、分类置信度;
|
||||||
|
6. 可选返回画框后的图片或视频。
|
||||||
|
|
||||||
|
项目默认使用 **ONNX Runtime** 推理,适合用 Docker 部署成 HTTP API 服务,方便网站后端调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
bird_detect_cascade_service/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI 接口入口
|
||||||
|
│ ├── config.py # 配置读取
|
||||||
|
│ ├── schemas.py # 返回结构
|
||||||
|
│ ├── pipeline.py # YOLO + 裁剪 + ConvNeXt 级联流程
|
||||||
|
│ ├── utils.py # 图像工具函数
|
||||||
|
│ └── inference/
|
||||||
|
│ ├── yolo_onnx.py # YOLOv5 ONNX 推理与后处理
|
||||||
|
│ └── convnext_onnx.py # ConvNeXt ONNX 推理
|
||||||
|
├── models/
|
||||||
|
│ ├── yolov5.onnx # 你的 YOLOv5 ONNX 模型,需自行放入
|
||||||
|
│ ├── convnext.onnx # 你的 ConvNeXt ONNX 模型,需自行放入
|
||||||
|
│ ├── det_classes.txt # YOLO 类别名
|
||||||
|
│ └── cls_classes.txt # ConvNeXt 类别名
|
||||||
|
├── test_client.py # 本地接口测试脚本
|
||||||
|
├── requirements.txt
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 模型准备
|
||||||
|
|
||||||
|
### 2.1 YOLOv5 导出 ONNX
|
||||||
|
|
||||||
|
如果你的 YOLOv5 是 `.pt` 权重,一般可在 YOLOv5 项目目录下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python export.py \
|
||||||
|
--weights /path/to/best.pt \
|
||||||
|
--img 960 960 \
|
||||||
|
--batch 1 \
|
||||||
|
--device cpu \
|
||||||
|
--include onnx \
|
||||||
|
--simplify
|
||||||
|
```
|
||||||
|
|
||||||
|
导出后把 ONNX 文件复制为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
models/yolov5.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 ConvNeXt 导出 ONNX
|
||||||
|
|
||||||
|
如果你已有 ConvNeXt 的 `.onnx`,直接复制为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
models/convnext.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你只有 PyTorch 权重,需要用你训练时的模型定义导出,输入通常为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 × 3 × 224 × 224
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你的 ConvNeXt 输入尺寸不是 224,需要修改 `.env` 或 `docker-compose.yml` 中的:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLS_IMG_SIZE=224
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 类别文件格式
|
||||||
|
|
||||||
|
### 3.1 YOLO 检测类别
|
||||||
|
|
||||||
|
`models/det_classes.txt`
|
||||||
|
|
||||||
|
如果 YOLO 只检测鸟,可以写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bird
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 YOLO 有多个检测类别,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bird
|
||||||
|
person
|
||||||
|
cat
|
||||||
|
dog
|
||||||
|
```
|
||||||
|
|
||||||
|
每一行对应一个类别,行号从 0 开始。
|
||||||
|
|
||||||
|
### 3.2 ConvNeXt 分类类别
|
||||||
|
|
||||||
|
`models/cls_classes.txt`
|
||||||
|
|
||||||
|
每一行一个鸟类类别,顺序必须和训练时类别索引一致,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Aix_galericulata
|
||||||
|
Anas_crecca
|
||||||
|
Anser_cygnoides
|
||||||
|
Cygnus_columbianus
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 本地直接运行
|
||||||
|
|
||||||
|
安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
访问接口文档:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Docker 构建与运行
|
||||||
|
|
||||||
|
### 5.1 构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t bird-detect-cascade-service:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 启动容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name bird-detect-cascade-service \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v $(pwd)/models:/app/models \
|
||||||
|
bird-detect-cascade-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 使用 docker compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f bird-detect-cascade-service
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 说明
|
||||||
|
|
||||||
|
### 6.1 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"det_model_loaded": true,
|
||||||
|
"cls_model_loaded": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 图片识别接口
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /predict
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| file | file | 必填 | 上传图片 |
|
||||||
|
| return_image | bool | false | 是否返回画框后的 base64 图片 |
|
||||||
|
| det_conf | float | 配置文件默认值 | YOLO 检测置信度阈值 |
|
||||||
|
| iou_thres | float | 配置文件默认值 | NMS 的 IoU 阈值 |
|
||||||
|
|
||||||
|
curl 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8000/predict?return_image=true" \
|
||||||
|
-F "file=@/path/to/test.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"image_width": 1920,
|
||||||
|
"image_height": 1080,
|
||||||
|
"num_objects": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"bbox_xyxy": [500.2, 130.7, 820.9, 600.4],
|
||||||
|
"det_confidence": 0.91,
|
||||||
|
"det_class_id": 0,
|
||||||
|
"det_class_name": "bird",
|
||||||
|
"cls_class_id": 12,
|
||||||
|
"cls_class_name": "Aix_galericulata",
|
||||||
|
"cls_confidence": 0.87
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotated_image_base64": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Python 测试调用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_client.py \
|
||||||
|
--url http://127.0.0.1:8000/predict \
|
||||||
|
--image /path/to/test.jpg \
|
||||||
|
--save result.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关键环境变量
|
||||||
|
|
||||||
|
这些配置可以在 `docker-compose.yml` 中修改。
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| DET_MODEL_PATH | /app/models/yolov5.onnx | YOLOv5 ONNX 路径 |
|
||||||
|
| CLS_MODEL_PATH | /app/models/convnext.onnx | ConvNeXt ONNX 路径 |
|
||||||
|
| DET_CLASSES_PATH | /app/models/det_classes.txt | 检测类别文件 |
|
||||||
|
| CLS_CLASSES_PATH | /app/models/cls_classes.txt | 分类类别文件 |
|
||||||
|
| DET_IMG_SIZE | 960 | YOLO 输入尺寸 |
|
||||||
|
| CLS_IMG_SIZE | 224 | ConvNeXt 输入尺寸 |
|
||||||
|
| DET_CONF_THRES | 0.25 | YOLO 默认置信度阈值 |
|
||||||
|
| DET_IOU_THRES | 0.45 | NMS 默认 IoU 阈值 |
|
||||||
|
| CROP_PADDING_RATIO | 0.08 | 裁剪框外扩比例 |
|
||||||
|
| MAX_DETECTIONS | 50 | 单张图片最多返回目标数 |
|
||||||
|
| DEVICE | cpu | onnxruntime 设备,默认 cpu |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 给网站同事的集成方式
|
||||||
|
|
||||||
|
网站后端只需要以 `multipart/form-data` 方式上传图片到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST http://服务器IP:8000/predict
|
||||||
|
```
|
||||||
|
|
||||||
|
前端或后端拿到 JSON 后,根据 `results` 字段展示识别结果即可。
|
||||||
|
|
||||||
|
如果网站只需要结果,不需要画框图:
|
||||||
|
|
||||||
|
```text
|
||||||
|
return_image=false
|
||||||
|
```
|
||||||
|
|
||||||
|
如果网站需要展示画框图:
|
||||||
|
|
||||||
|
```text
|
||||||
|
return_image=true
|
||||||
|
```
|
||||||
|
|
||||||
|
返回的 `annotated_image_base64` 是 JPEG 的 base64 字符串,前端可拼接为:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const imgSrc = "data:image/jpeg;base64," + annotated_image_base64;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 注意事项
|
||||||
|
|
||||||
|
1. YOLO 的输入尺寸必须和导出 ONNX 时一致,例如导出时是 `--img 960 960`,配置里也建议用 `DET_IMG_SIZE=960`。
|
||||||
|
2. ConvNeXt 的类别文件顺序必须和训练时完全一致。
|
||||||
|
3. 如果 YOLO 输出不是标准 YOLOv5 ONNX 格式 `[1, num_boxes, 5 + num_classes]`,需要根据实际输出改 `app/inference/yolo_onnx.py`。
|
||||||
|
4. 如果 ConvNeXt 训练时使用了非 ImageNet 的 mean/std,需要修改 `app/inference/convnext_onnx.py` 中的归一化参数。
|
||||||
|
5. 如果部署机器没有 GPU,默认 CPU 推理即可;如果需要 GPU 版本,需要替换 `onnxruntime` 为 `onnxruntime-gpu`,并使用支持 CUDA 的基础镜像。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 常见问题
|
||||||
|
|
||||||
|
### 11.1 为什么要用 HTTP API?
|
||||||
|
|
||||||
|
因为最终要集成到网站中,HTTP API 是最直接的对接方式。网站后端不需要关心模型细节,只需要上传图片并接收 JSON。
|
||||||
|
|
||||||
|
### 11.2 为什么先检测再分类?
|
||||||
|
|
||||||
|
YOLOv5 负责解决“鸟在哪里”,ConvNeXt 负责解决“这只鸟是什么种类”。先裁剪目标区域可以减少背景干扰,更适合细粒度分类。
|
||||||
|
|
||||||
|
### 11.3 多只鸟怎么处理?
|
||||||
|
|
||||||
|
YOLO 检测到几个目标,就裁剪几个目标,并分别送入 ConvNeXt 分类,最终返回一个结果列表。
|
||||||
|
|
||||||
|
### 11.4 没检测到鸟怎么办?
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"num_objects": 0,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 视频识别接口
|
||||||
|
|
||||||
|
当前版本新增视频接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /predict_video
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.1 后台测试视频上传
|
||||||
|
|
||||||
|
启动服务后,在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash backend_test_video.sh /path/to/test.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
等价于:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8000/predict_video?sample_interval=25&save_annotated_video=true" \
|
||||||
|
-F "file=@/path/to/test.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `sample_interval=25` 表示每 25 帧处理 1 帧;
|
||||||
|
- `save_annotated_video=true` 表示保存画框后视频;
|
||||||
|
- 如果视频很长,可以把 `sample_interval` 调大,例如 50、100,减少计算量。
|
||||||
|
|
||||||
|
### 12.2 视频运行结果保存位置
|
||||||
|
|
||||||
|
如果使用 `docker compose up -d --build` 启动,`docker-compose.yml` 已经挂载:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./models:/app/models
|
||||||
|
- ./runs:/app/runs
|
||||||
|
```
|
||||||
|
|
||||||
|
所以结果会同时出现在:
|
||||||
|
|
||||||
|
容器内:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/app/runs/video_时间_随机ID/
|
||||||
|
```
|
||||||
|
|
||||||
|
宿主机项目目录下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
./runs/video_时间_随机ID/
|
||||||
|
```
|
||||||
|
|
||||||
|
每次处理一个视频,会生成一个新的任务目录,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runs/
|
||||||
|
└── video_20260518_123000_a1b2c3d4/
|
||||||
|
├── source_video.mp4 # 上传的原始视频副本
|
||||||
|
├── annotated.mp4 # 画框后的结果视频
|
||||||
|
├── summary.json # 本次视频任务摘要
|
||||||
|
├── frame_results.json # 每个抽帧的详细识别结果
|
||||||
|
└── frames/
|
||||||
|
├── frame_00000000.jpg # 有识别结果的关键帧截图
|
||||||
|
├── frame_00000025.jpg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 返回结果示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"task_dir": "/app/runs/video_20260518_123000_a1b2c3d4",
|
||||||
|
"source_video": "/app/runs/video_20260518_123000_a1b2c3d4/source_video.mp4",
|
||||||
|
"annotated_video": "/app/runs/video_20260518_123000_a1b2c3d4/annotated.mp4",
|
||||||
|
"frames_dir": "/app/runs/video_20260518_123000_a1b2c3d4/frames",
|
||||||
|
"frame_results_json": "/app/runs/video_20260518_123000_a1b2c3d4/frame_results.json",
|
||||||
|
"summary_json": "/app/runs/video_20260518_123000_a1b2c3d4/summary.json",
|
||||||
|
"sample_interval": 25,
|
||||||
|
"processed_frames": 120,
|
||||||
|
"detected_frames": 87,
|
||||||
|
"elapsed_sec": 35.6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 网站集成建议
|
||||||
|
|
||||||
|
网站后端上传视频到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST http://服务器IP:8000/predict_video
|
||||||
|
```
|
||||||
|
|
||||||
|
如果网站需要展示结果,可以让网站后端读取返回的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
annotated_video
|
||||||
|
frame_results_json
|
||||||
|
summary_json
|
||||||
|
```
|
||||||
|
|
||||||
|
正式部署时更推荐把 `runs/` 目录映射到网站后端可访问的静态文件目录,或者在本服务里再增加 `/files/...` 静态下载接口。
|
||||||
Whitespace-only changes.
@@ -0,0 +1,924 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Sequence
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.pipeline import BirdCascadePipeline
|
||||||
|
from app.utils import (
|
||||||
|
decode_image_from_bytes,
|
||||||
|
safe_stem,
|
||||||
|
save_json,
|
||||||
|
write_image_xml,
|
||||||
|
write_video_xml,
|
||||||
|
)
|
||||||
|
from app.video_pipeline import VideoResultSmoother
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
|
||||||
|
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".m4v"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool(value: str | bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "y", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "n", "off"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"布尔值必须是 true/false、yes/no、1/0 之一,当前为: {value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now_timestamp_minute() -> str:
|
||||||
|
return time.strftime("%Y%m%d%H%M")
|
||||||
|
|
||||||
|
|
||||||
|
def is_image(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in IMAGE_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def is_video(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in VIDEO_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def collect_inputs(input_path: Path, recursive: bool = False) -> List[Path]:
|
||||||
|
if input_path.is_file():
|
||||||
|
if is_image(input_path) or is_video(input_path):
|
||||||
|
return [input_path]
|
||||||
|
raise ValueError(f"不支持的文件类型: {input_path}")
|
||||||
|
|
||||||
|
if not input_path.is_dir():
|
||||||
|
raise FileNotFoundError(f"输入路径不存在: {input_path}")
|
||||||
|
|
||||||
|
iterator = input_path.rglob("*") if recursive else input_path.glob("*")
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for p in iterator:
|
||||||
|
if p.is_file() and (is_image(p) or is_video(p)):
|
||||||
|
files.append(p)
|
||||||
|
|
||||||
|
files.sort()
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def make_output_dirs(input_path: Path, output_base: Path) -> Dict[str, Path]:
|
||||||
|
timestamp = now_timestamp_minute()
|
||||||
|
input_name = safe_stem(input_path.name)
|
||||||
|
|
||||||
|
output_root = output_base / f"{timestamp}_{input_name}"
|
||||||
|
|
||||||
|
if output_root.exists():
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate = output_base / f"{timestamp}_{input_name}_{idx:02d}"
|
||||||
|
if not candidate.exists():
|
||||||
|
output_root = candidate
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
result_dir = output_root / "result"
|
||||||
|
xml_dir = output_root / "xml"
|
||||||
|
json_dir = output_root / "json"
|
||||||
|
|
||||||
|
result_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xml_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
json_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root": output_root,
|
||||||
|
"result": result_dir,
|
||||||
|
"xml": xml_dir,
|
||||||
|
"json": json_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unique_path(path: Path) -> Path:
|
||||||
|
if not path.exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
stem = path.stem
|
||||||
|
suffix = path.suffix
|
||||||
|
parent = path.parent
|
||||||
|
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate = parent / f"{stem}_{idx:02d}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(
|
||||||
|
image_path: Path,
|
||||||
|
pipeline: BirdCascadePipeline,
|
||||||
|
dirs: Dict[str, Path],
|
||||||
|
) -> Dict:
|
||||||
|
img_bgr = decode_image_from_bytes(image_path.read_bytes())
|
||||||
|
|
||||||
|
output = pipeline.predict(img_bgr, return_image=False)
|
||||||
|
results = output["results"]
|
||||||
|
density_json = output.get("density")
|
||||||
|
density_result = output.get("density_result")
|
||||||
|
|
||||||
|
result_img_path = unique_path(dirs["result"] / f"{image_path.stem}.jpg")
|
||||||
|
json_path = unique_path(dirs["json"] / f"{image_path.stem}.json")
|
||||||
|
xml_path = unique_path(dirs["xml"] / f"{image_path.stem}.xml")
|
||||||
|
|
||||||
|
annotated = pipeline.draw_full_result(
|
||||||
|
img_bgr=img_bgr,
|
||||||
|
results=results,
|
||||||
|
density_result=density_result,
|
||||||
|
)
|
||||||
|
cv2.imwrite(str(result_img_path), annotated)
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"success": True,
|
||||||
|
"type": "image",
|
||||||
|
"source_path": str(image_path),
|
||||||
|
"result_path": str(result_img_path),
|
||||||
|
"xml_path": str(xml_path),
|
||||||
|
"json_path": str(json_path),
|
||||||
|
"image_width": output["image_width"],
|
||||||
|
"image_height": output["image_height"],
|
||||||
|
"num_objects": output["num_objects"],
|
||||||
|
"density": density_json,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_image_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
image_filename=result_img_path.name,
|
||||||
|
image_path=str(result_img_path),
|
||||||
|
width=int(output["image_width"]),
|
||||||
|
height=int(output["image_height"]),
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json_data
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(
|
||||||
|
video_path: Path,
|
||||||
|
pipeline: BirdCascadePipeline,
|
||||||
|
dirs: Dict[str, Path],
|
||||||
|
sample_interval: int,
|
||||||
|
) -> Dict:
|
||||||
|
result_video_path = unique_path(dirs["result"] / f"{video_path.stem}.mp4")
|
||||||
|
json_path = unique_path(dirs["json"] / f"{video_path.stem}.json")
|
||||||
|
xml_path = unique_path(dirs["xml"] / f"{video_path.stem}.xml")
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(str(video_path))
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise ValueError(f"视频打开失败: {video_path}")
|
||||||
|
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
if fps is None or fps <= 0:
|
||||||
|
fps = 25.0
|
||||||
|
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||||
|
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
writer = cv2.VideoWriter(str(result_video_path), fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
smoother = VideoResultSmoother(
|
||||||
|
hold_frames=settings.smooth_hold_frames,
|
||||||
|
alpha=settings.box_smooth_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
frame_results = []
|
||||||
|
processed_frames = 0
|
||||||
|
detected_frames = 0
|
||||||
|
frame_index = 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
last_density_result = None
|
||||||
|
last_density_json = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
should_process = frame_index % sample_interval == 0
|
||||||
|
|
||||||
|
if should_process:
|
||||||
|
output = pipeline.predict(frame, return_image=False)
|
||||||
|
|
||||||
|
raw_results = output["results"]
|
||||||
|
density_result = output.get("density_result")
|
||||||
|
density_json = output.get("density")
|
||||||
|
|
||||||
|
last_density_result = density_result
|
||||||
|
last_density_json = density_json
|
||||||
|
|
||||||
|
processed_frames += 1
|
||||||
|
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.update(raw_results, frame_index)
|
||||||
|
else:
|
||||||
|
results = raw_results
|
||||||
|
|
||||||
|
if len(results) > 0:
|
||||||
|
detected_frames += 1
|
||||||
|
|
||||||
|
frame_results.append(
|
||||||
|
{
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"time_sec": round(frame_index / fps, 3),
|
||||||
|
"num_objects": len(results),
|
||||||
|
"density": density_json,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.get_for_skipped_frame(frame_index)
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
density_result = last_density_result
|
||||||
|
density_json = last_density_json
|
||||||
|
|
||||||
|
annotated_frame = pipeline.draw_full_result(
|
||||||
|
img_bgr=frame,
|
||||||
|
results=results,
|
||||||
|
density_result=density_result,
|
||||||
|
)
|
||||||
|
writer.write(annotated_frame)
|
||||||
|
|
||||||
|
frame_index += 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 3)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"success": True,
|
||||||
|
"type": "video",
|
||||||
|
"source_path": str(video_path),
|
||||||
|
"result_path": str(result_video_path),
|
||||||
|
"xml_path": str(xml_path),
|
||||||
|
"json_path": str(json_path),
|
||||||
|
"video_info": {
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
"total_frames": total_frames,
|
||||||
|
},
|
||||||
|
"sample_interval": sample_interval,
|
||||||
|
"smooth_video_boxes": settings.smooth_video_boxes,
|
||||||
|
"smooth_hold_frames": settings.smooth_hold_frames,
|
||||||
|
"box_smooth_alpha": settings.box_smooth_alpha,
|
||||||
|
"processed_frames": processed_frames,
|
||||||
|
"detected_frames": detected_frames,
|
||||||
|
"elapsed_sec": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"summary": summary,
|
||||||
|
"frames": frame_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_video_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
video_filename=result_video_path.name,
|
||||||
|
video_path=str(result_video_path),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
fps=fps,
|
||||||
|
total_frames=total_frames,
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
frame_results=frame_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def run_batch(
|
||||||
|
input_path: str,
|
||||||
|
output_base: str,
|
||||||
|
recursive: bool,
|
||||||
|
sample_interval: int,
|
||||||
|
) -> Dict:
|
||||||
|
input_path_obj = Path(input_path).resolve()
|
||||||
|
output_base_obj = Path(output_base).resolve()
|
||||||
|
output_base_obj.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dirs = make_output_dirs(input_path_obj, output_base_obj)
|
||||||
|
files = collect_inputs(input_path_obj, recursive=recursive)
|
||||||
|
|
||||||
|
if len(files) == 0:
|
||||||
|
raise ValueError(f"没有找到图片或视频文件: {input_path_obj}")
|
||||||
|
|
||||||
|
pipeline = BirdCascadePipeline()
|
||||||
|
|
||||||
|
success_results = []
|
||||||
|
failed_results = []
|
||||||
|
|
||||||
|
for idx, file_path in enumerate(files, start=1):
|
||||||
|
print(f"[{idx}/{len(files)}] processing: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_image(file_path):
|
||||||
|
result = process_image(
|
||||||
|
image_path=file_path,
|
||||||
|
pipeline=pipeline,
|
||||||
|
dirs=dirs,
|
||||||
|
)
|
||||||
|
elif is_video(file_path):
|
||||||
|
result = process_video(
|
||||||
|
video_path=file_path,
|
||||||
|
pipeline=pipeline,
|
||||||
|
dirs=dirs,
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
success_results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed = {
|
||||||
|
"source_path": str(file_path),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
failed_results.append(failed)
|
||||||
|
print(f"[ERROR] {file_path}: {e}")
|
||||||
|
|
||||||
|
index_json = {
|
||||||
|
"success": len(failed_results) == 0,
|
||||||
|
"input_path": str(input_path_obj),
|
||||||
|
"output_root": str(dirs["root"]),
|
||||||
|
"result_dir": str(dirs["result"]),
|
||||||
|
"xml_dir": str(dirs["xml"]),
|
||||||
|
"json_dir": str(dirs["json"]),
|
||||||
|
"total_files": len(files),
|
||||||
|
"success_files": len(success_results),
|
||||||
|
"failed_files": len(failed_results),
|
||||||
|
"failed": failed_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
index_path = dirs["root"] / "index.json"
|
||||||
|
index_path.write_text(
|
||||||
|
json.dumps(index_json, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nDone.")
|
||||||
|
print(f"Output root: {dirs['root']}")
|
||||||
|
print(f"Result dir : {dirs['result']}")
|
||||||
|
print(f"XML dir : {dirs['xml']}")
|
||||||
|
print(f"JSON dir : {dirs['json']}")
|
||||||
|
print(f"Index JSON : {index_path}")
|
||||||
|
|
||||||
|
return index_json
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Bird cascade batch inference")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
required=True,
|
||||||
|
help="输入路径,可以是单张图片、单个视频,或包含图片/视频的文件夹",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=settings.output_dir,
|
||||||
|
help="输出根目录,默认输出到项目 runs 下",
|
||||||
|
)
|
||||||
|
|
||||||
|
recursive_group = parser.add_mutually_exclusive_group()
|
||||||
|
recursive_group.add_argument(
|
||||||
|
"--recursive",
|
||||||
|
dest="recursive",
|
||||||
|
action="store_true",
|
||||||
|
help="输入为文件夹时递归处理子文件夹(默认)",
|
||||||
|
)
|
||||||
|
recursive_group.add_argument(
|
||||||
|
"--no-recursive",
|
||||||
|
dest="recursive",
|
||||||
|
action="store_false",
|
||||||
|
help="输入为文件夹时只处理第一层文件",
|
||||||
|
)
|
||||||
|
parser.set_defaults(recursive=True)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--sample-interval",
|
||||||
|
type=int,
|
||||||
|
default=settings.video_sample_interval,
|
||||||
|
help="视频抽帧间隔",
|
||||||
|
)
|
||||||
|
|
||||||
|
detection_group = parser.add_mutually_exclusive_group()
|
||||||
|
detection_group.add_argument(
|
||||||
|
"--enable-detection",
|
||||||
|
dest="enable_detection",
|
||||||
|
action="store_true",
|
||||||
|
help="启用 YOLO 目标检测(默认)",
|
||||||
|
)
|
||||||
|
detection_group.add_argument(
|
||||||
|
"--disable-detection",
|
||||||
|
dest="enable_detection",
|
||||||
|
action="store_false",
|
||||||
|
help="关闭 YOLO 目标检测",
|
||||||
|
)
|
||||||
|
parser.set_defaults(enable_detection=settings.enable_detection)
|
||||||
|
|
||||||
|
classification_group = parser.add_mutually_exclusive_group()
|
||||||
|
classification_group.add_argument(
|
||||||
|
"--enable-classification",
|
||||||
|
dest="enable_classification",
|
||||||
|
action="store_true",
|
||||||
|
help="启用 ConvNeXt 细粒度识别(默认)",
|
||||||
|
)
|
||||||
|
classification_group.add_argument(
|
||||||
|
"--disable-classification",
|
||||||
|
dest="enable_classification",
|
||||||
|
action="store_false",
|
||||||
|
help="关闭 ConvNeXt 细粒度识别,仅保留 YOLO 检测框",
|
||||||
|
)
|
||||||
|
parser.set_defaults(enable_classification=settings.enable_classification)
|
||||||
|
|
||||||
|
density_group = parser.add_mutually_exclusive_group()
|
||||||
|
density_group.add_argument(
|
||||||
|
"--enable-density",
|
||||||
|
dest="enable_density",
|
||||||
|
action="store_true",
|
||||||
|
help="启用贝叶斯密度估计(默认)",
|
||||||
|
)
|
||||||
|
density_group.add_argument(
|
||||||
|
"--disable-density",
|
||||||
|
dest="enable_density",
|
||||||
|
action="store_false",
|
||||||
|
help="关闭贝叶斯密度估计",
|
||||||
|
)
|
||||||
|
parser.set_defaults(enable_density=settings.enable_density)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--det-conf-thres",
|
||||||
|
type=float,
|
||||||
|
default=settings.det_conf_thres,
|
||||||
|
help="YOLO 第一层检测置信度阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--det-iou-thres",
|
||||||
|
type=float,
|
||||||
|
default=settings.det_iou_thres,
|
||||||
|
help="NMS IoU 阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--det-second-conf-thres",
|
||||||
|
type=float,
|
||||||
|
default=settings.det_second_conf_thres,
|
||||||
|
help="进入 ConvNeXt 前的第二层检测置信度阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-box-area-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.min_box_area_ratio,
|
||||||
|
help="检测框最小面积占比",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-box-area-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.max_box_area_ratio,
|
||||||
|
help="检测框最大面积占比",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-box-aspect-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.max_box_aspect_ratio,
|
||||||
|
help="检测框最大长宽比",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--detection-roi-y-min-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.detection_roi_y_min_ratio,
|
||||||
|
help="只保留中心点位于该纵向比例以下区域之外的框,默认 0",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--detection-roi-y-max-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.detection_roi_y_max_ratio,
|
||||||
|
help="只保留中心点位于该纵向比例以上区域之前的框,默认 1",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cls-show-conf-thres",
|
||||||
|
type=float,
|
||||||
|
default=settings.cls_show_conf_thres,
|
||||||
|
help="普通目标显示物种名的分类置信度阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--large-box-area-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.large_box_area_ratio,
|
||||||
|
help="近景大目标的框面积占比阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--large-box-cls-show-conf-thres",
|
||||||
|
type=float,
|
||||||
|
default=settings.large_box_cls_show_conf_thres,
|
||||||
|
help="大目标显示物种名的分类置信度阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--show-large-box-name-always",
|
||||||
|
type=parse_bool,
|
||||||
|
default=settings.show_large_box_name_always,
|
||||||
|
metavar="BOOL",
|
||||||
|
help="大目标是否始终显示非空物种名",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dense-scene-count-thres",
|
||||||
|
type=int,
|
||||||
|
default=settings.dense_scene_count_thres,
|
||||||
|
help="判定密集场景的密度估计数量阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hide-cls-name-in-dense-scene",
|
||||||
|
type=parse_bool,
|
||||||
|
default=settings.hide_cls_name_in_dense_scene,
|
||||||
|
metavar="BOOL",
|
||||||
|
help="密集场景下是否隐藏物种名",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crop-padding-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.crop_padding_ratio,
|
||||||
|
help="送入 ConvNeXt 前检测框向外扩的比例",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-detections",
|
||||||
|
type=int,
|
||||||
|
default=settings.max_detections,
|
||||||
|
help="单张图或单帧最多保留的检测框数量",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--uniform-species-color",
|
||||||
|
type=parse_bool,
|
||||||
|
default=settings.uniform_species_color,
|
||||||
|
metavar="BOOL",
|
||||||
|
help="所有物种的检测框和标签是否统一使用同一种颜色",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-species-name",
|
||||||
|
dest="forced_species_name",
|
||||||
|
default=settings.forced_species_name,
|
||||||
|
help="人工确认整段视频为单一物种时,强制覆盖所有细粒度标签;留空不覆盖",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-species-class-id",
|
||||||
|
dest="forced_species_class_id",
|
||||||
|
type=int,
|
||||||
|
default=settings.forced_species_class_id,
|
||||||
|
help="强制物种对应的类别 ID;默认 -1 时按标签文件自动查找",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-species-min-box-area-ratio",
|
||||||
|
dest="forced_species_min_box_area_ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.forced_species_min_box_area_ratio,
|
||||||
|
help="只覆盖面积占比达到该值的目标;默认 0 表示覆盖全部检测目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--species-name-remap",
|
||||||
|
default=settings.species_name_remap,
|
||||||
|
help="按人工复核纠正指定标签,格式:东方白鹳=白鹤,白鹭=白鹤",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--species-name-remap-min-confidence",
|
||||||
|
type=float,
|
||||||
|
default=settings.species_name_remap_min_confidence,
|
||||||
|
help="只有原始分类置信度达到该值时才应用物种标签映射",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allowed-species-names",
|
||||||
|
default=settings.allowed_species_names,
|
||||||
|
help="只允许这些物种显示标签,英文逗号分隔;留空表示不限制",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--binary-dark-species-name",
|
||||||
|
default=settings.binary_dark_species_name,
|
||||||
|
help="双物种明暗规则中的深色物种名称;留空表示关闭",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--binary-light-species-name",
|
||||||
|
default=settings.binary_light_species_name,
|
||||||
|
help="双物种明暗规则中的浅色物种名称;留空表示关闭",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--binary-dark-pixel-threshold",
|
||||||
|
type=int,
|
||||||
|
default=settings.binary_dark_pixel_threshold,
|
||||||
|
help="灰度低于该值的像素视为暗色像素,范围 0~255",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--binary-dark-ratio-threshold",
|
||||||
|
type=float,
|
||||||
|
default=settings.binary_dark_ratio_threshold,
|
||||||
|
help="暗色像素比例达到该值时使用深色物种标签",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-species-name",
|
||||||
|
default=settings.dark_override_species_name,
|
||||||
|
help="暗色目标人工覆盖标签;留空表示关闭",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-species-class-id",
|
||||||
|
type=int,
|
||||||
|
default=settings.dark_override_species_class_id,
|
||||||
|
help="暗色目标人工覆盖标签的 class id;默认 -1 时按类别文件查找",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-pixel-threshold",
|
||||||
|
type=int,
|
||||||
|
default=settings.dark_override_pixel_threshold,
|
||||||
|
help="灰度低于该值的像素视为暗色像素,范围 0~255",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-ratio-threshold",
|
||||||
|
type=float,
|
||||||
|
default=settings.dark_override_ratio_threshold,
|
||||||
|
help="暗色像素比例达到该值时覆盖为指定标签",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-min-box-area-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.dark_override_min_box_area_ratio,
|
||||||
|
help="只覆盖面积占比达到该值的暗色目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-max-box-area-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.dark_override_max_box_area_ratio,
|
||||||
|
help="只覆盖面积占比不超过该值的暗色目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-roi-y-min-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.dark_override_roi_y_min_ratio,
|
||||||
|
help="暗色覆盖只作用于中心点纵向比例不小于该值的目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dark-override-roi-y-max-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.dark_override_roi_y_max_ratio,
|
||||||
|
help="暗色覆盖只作用于中心点纵向比例不大于该值的目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-override-species-name",
|
||||||
|
default=settings.region_override_species_name,
|
||||||
|
help="区域人工覆盖标签;留空表示关闭",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-override-species-class-id",
|
||||||
|
type=int,
|
||||||
|
default=settings.region_override_species_class_id,
|
||||||
|
help="区域人工覆盖标签的 class id;默认 -1 时按类别文件查找",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-override-source-species-names",
|
||||||
|
default=settings.region_override_source_species_names,
|
||||||
|
help="只覆盖这些原标签,英文逗号分隔;留空表示覆盖区域内全部非空标签",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-override-roi-y-min-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.region_override_roi_y_min_ratio,
|
||||||
|
help="区域覆盖只作用于中心点纵向比例不小于该值的目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-override-roi-y-max-ratio",
|
||||||
|
type=float,
|
||||||
|
default=settings.region_override_roi_y_max_ratio,
|
||||||
|
help="区域覆盖只作用于中心点纵向比例不大于该值的目标",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enable-crane-appearance-correction",
|
||||||
|
action="store_true",
|
||||||
|
default=settings.enable_crane_appearance_correction,
|
||||||
|
help="启用白鹤/白头鹤/白枕鹤外观二次纠正",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crane-white-ratio-threshold",
|
||||||
|
type=float,
|
||||||
|
default=settings.crane_white_ratio_threshold,
|
||||||
|
help="鹤类纠正中判定白鹤的亮白像素比例阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crane-dark-ratio-threshold",
|
||||||
|
type=float,
|
||||||
|
default=settings.crane_dark_ratio_threshold,
|
||||||
|
help="鹤类纠正中判定白枕鹤/白头鹤的暗色像素比例阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crane-tail-white-ratio-threshold",
|
||||||
|
type=float,
|
||||||
|
default=settings.crane_tail_white_ratio_threshold,
|
||||||
|
help="鹤类纠正中判定白枕鹤白尾/白斑的局部亮白比例阈值",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--crane-uncertain-hide-label",
|
||||||
|
type=parse_bool,
|
||||||
|
default=settings.crane_uncertain_hide_label,
|
||||||
|
metavar="BOOL",
|
||||||
|
help="鹤类纠正无法确认时是否隐藏标签",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def validate_args(
|
||||||
|
parser: argparse.ArgumentParser,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> argparse.Namespace:
|
||||||
|
if args.sample_interval < 1:
|
||||||
|
parser.error("--sample-interval 必须大于等于 1")
|
||||||
|
|
||||||
|
probability_fields = {
|
||||||
|
"--det-conf-thres": args.det_conf_thres,
|
||||||
|
"--det-iou-thres": args.det_iou_thres,
|
||||||
|
"--det-second-conf-thres": args.det_second_conf_thres,
|
||||||
|
"--cls-show-conf-thres": args.cls_show_conf_thres,
|
||||||
|
"--large-box-cls-show-conf-thres": args.large_box_cls_show_conf_thres,
|
||||||
|
"--species-name-remap-min-confidence": (
|
||||||
|
args.species_name_remap_min_confidence
|
||||||
|
),
|
||||||
|
"--detection-roi-y-min-ratio": args.detection_roi_y_min_ratio,
|
||||||
|
"--detection-roi-y-max-ratio": args.detection_roi_y_max_ratio,
|
||||||
|
"--binary-dark-ratio-threshold": args.binary_dark_ratio_threshold,
|
||||||
|
"--dark-override-ratio-threshold": args.dark_override_ratio_threshold,
|
||||||
|
"--dark-override-roi-y-min-ratio": args.dark_override_roi_y_min_ratio,
|
||||||
|
"--dark-override-roi-y-max-ratio": args.dark_override_roi_y_max_ratio,
|
||||||
|
"--region-override-roi-y-min-ratio": (
|
||||||
|
args.region_override_roi_y_min_ratio
|
||||||
|
),
|
||||||
|
"--region-override-roi-y-max-ratio": (
|
||||||
|
args.region_override_roi_y_max_ratio
|
||||||
|
),
|
||||||
|
"--crane-white-ratio-threshold": args.crane_white_ratio_threshold,
|
||||||
|
"--crane-dark-ratio-threshold": args.crane_dark_ratio_threshold,
|
||||||
|
"--crane-tail-white-ratio-threshold": (
|
||||||
|
args.crane_tail_white_ratio_threshold
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for option, value in probability_fields.items():
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
parser.error(f"{option} 必须在 0 到 1 之间")
|
||||||
|
|
||||||
|
ratio_fields = {
|
||||||
|
"--min-box-area-ratio": args.min_box_area_ratio,
|
||||||
|
"--max-box-area-ratio": args.max_box_area_ratio,
|
||||||
|
"--large-box-area-ratio": args.large_box_area_ratio,
|
||||||
|
"--crop-padding-ratio": args.crop_padding_ratio,
|
||||||
|
"--force-species-min-box-area-ratio": (
|
||||||
|
args.forced_species_min_box_area_ratio
|
||||||
|
),
|
||||||
|
"--dark-override-min-box-area-ratio": (
|
||||||
|
args.dark_override_min_box_area_ratio
|
||||||
|
),
|
||||||
|
"--dark-override-max-box-area-ratio": (
|
||||||
|
args.dark_override_max_box_area_ratio
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for option, value in ratio_fields.items():
|
||||||
|
if value < 0.0:
|
||||||
|
parser.error(f"{option} 不能小于 0")
|
||||||
|
|
||||||
|
if args.min_box_area_ratio > args.max_box_area_ratio:
|
||||||
|
parser.error("--min-box-area-ratio 不能大于 --max-box-area-ratio")
|
||||||
|
if args.detection_roi_y_min_ratio > args.detection_roi_y_max_ratio:
|
||||||
|
parser.error(
|
||||||
|
"--detection-roi-y-min-ratio 不能大于 "
|
||||||
|
"--detection-roi-y-max-ratio"
|
||||||
|
)
|
||||||
|
if args.max_box_aspect_ratio <= 0.0:
|
||||||
|
parser.error("--max-box-aspect-ratio 必须大于 0")
|
||||||
|
if args.dense_scene_count_thres < 0:
|
||||||
|
parser.error("--dense-scene-count-thres 不能小于 0")
|
||||||
|
if args.max_detections < 1:
|
||||||
|
parser.error("--max-detections 必须大于等于 1")
|
||||||
|
if not 0 <= args.binary_dark_pixel_threshold <= 255:
|
||||||
|
parser.error("--binary-dark-pixel-threshold 必须在 0 到 255 之间")
|
||||||
|
if not 0 <= args.dark_override_pixel_threshold <= 255:
|
||||||
|
parser.error("--dark-override-pixel-threshold 必须在 0 到 255 之间")
|
||||||
|
if args.dark_override_min_box_area_ratio > args.dark_override_max_box_area_ratio:
|
||||||
|
parser.error(
|
||||||
|
"--dark-override-min-box-area-ratio 不能大于 "
|
||||||
|
"--dark-override-max-box-area-ratio"
|
||||||
|
)
|
||||||
|
if args.dark_override_roi_y_min_ratio > args.dark_override_roi_y_max_ratio:
|
||||||
|
parser.error(
|
||||||
|
"--dark-override-roi-y-min-ratio 不能大于 "
|
||||||
|
"--dark-override-roi-y-max-ratio"
|
||||||
|
)
|
||||||
|
if args.region_override_roi_y_min_ratio > args.region_override_roi_y_max_ratio:
|
||||||
|
parser.error(
|
||||||
|
"--region-override-roi-y-min-ratio 不能大于 "
|
||||||
|
"--region-override-roi-y-max-ratio"
|
||||||
|
)
|
||||||
|
if bool(args.binary_dark_species_name) != bool(
|
||||||
|
args.binary_light_species_name
|
||||||
|
):
|
||||||
|
parser.error(
|
||||||
|
"--binary-dark-species-name 和 "
|
||||||
|
"--binary-light-species-name 必须同时设置"
|
||||||
|
)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = build_parser()
|
||||||
|
return validate_args(parser, parser.parse_args(argv))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cli_settings(args: argparse.Namespace) -> None:
|
||||||
|
setting_names = (
|
||||||
|
"enable_detection",
|
||||||
|
"enable_classification",
|
||||||
|
"enable_density",
|
||||||
|
"det_conf_thres",
|
||||||
|
"det_iou_thres",
|
||||||
|
"det_second_conf_thres",
|
||||||
|
"min_box_area_ratio",
|
||||||
|
"max_box_area_ratio",
|
||||||
|
"max_box_aspect_ratio",
|
||||||
|
"detection_roi_y_min_ratio",
|
||||||
|
"detection_roi_y_max_ratio",
|
||||||
|
"cls_show_conf_thres",
|
||||||
|
"large_box_area_ratio",
|
||||||
|
"large_box_cls_show_conf_thres",
|
||||||
|
"show_large_box_name_always",
|
||||||
|
"dense_scene_count_thres",
|
||||||
|
"hide_cls_name_in_dense_scene",
|
||||||
|
"crop_padding_ratio",
|
||||||
|
"max_detections",
|
||||||
|
"uniform_species_color",
|
||||||
|
"forced_species_name",
|
||||||
|
"forced_species_class_id",
|
||||||
|
"forced_species_min_box_area_ratio",
|
||||||
|
"species_name_remap",
|
||||||
|
"species_name_remap_min_confidence",
|
||||||
|
"allowed_species_names",
|
||||||
|
"binary_dark_species_name",
|
||||||
|
"binary_light_species_name",
|
||||||
|
"binary_dark_pixel_threshold",
|
||||||
|
"binary_dark_ratio_threshold",
|
||||||
|
"dark_override_species_name",
|
||||||
|
"dark_override_species_class_id",
|
||||||
|
"dark_override_pixel_threshold",
|
||||||
|
"dark_override_ratio_threshold",
|
||||||
|
"dark_override_min_box_area_ratio",
|
||||||
|
"dark_override_max_box_area_ratio",
|
||||||
|
"dark_override_roi_y_min_ratio",
|
||||||
|
"dark_override_roi_y_max_ratio",
|
||||||
|
"region_override_species_name",
|
||||||
|
"region_override_species_class_id",
|
||||||
|
"region_override_source_species_names",
|
||||||
|
"region_override_roi_y_min_ratio",
|
||||||
|
"region_override_roi_y_max_ratio",
|
||||||
|
"enable_crane_appearance_correction",
|
||||||
|
"crane_white_ratio_threshold",
|
||||||
|
"crane_dark_ratio_threshold",
|
||||||
|
"crane_tail_white_ratio_threshold",
|
||||||
|
"crane_uncertain_hide_label",
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in setting_names:
|
||||||
|
object.__setattr__(settings, name, getattr(args, name))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
apply_cli_settings(args)
|
||||||
|
|
||||||
|
return run_batch(
|
||||||
|
input_path=args.input,
|
||||||
|
output_base=args.output,
|
||||||
|
recursive=args.recursive,
|
||||||
|
sample_interval=args.sample_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.pipeline import BirdCascadePipeline
|
||||||
|
from app.utils import (
|
||||||
|
decode_image_from_bytes,
|
||||||
|
draw_results,
|
||||||
|
safe_stem,
|
||||||
|
save_json,
|
||||||
|
write_image_xml,
|
||||||
|
write_video_xml,
|
||||||
|
)
|
||||||
|
from app.video_pipeline import VideoResultSmoother
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
|
||||||
|
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".m4v"}
|
||||||
|
|
||||||
|
|
||||||
|
def now_timestamp_minute() -> str:
|
||||||
|
return time.strftime("%Y%m%d%H%M")
|
||||||
|
|
||||||
|
|
||||||
|
def is_image(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in IMAGE_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def is_video(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() in VIDEO_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def collect_inputs(input_path: Path, recursive: bool = False) -> List[Path]:
|
||||||
|
if input_path.is_file():
|
||||||
|
if is_image(input_path) or is_video(input_path):
|
||||||
|
return [input_path]
|
||||||
|
raise ValueError(f"不支持的文件类型: {input_path}")
|
||||||
|
|
||||||
|
if not input_path.is_dir():
|
||||||
|
raise FileNotFoundError(f"输入路径不存在: {input_path}")
|
||||||
|
|
||||||
|
iterator = input_path.rglob("*") if recursive else input_path.glob("*")
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for p in iterator:
|
||||||
|
if p.is_file() and (is_image(p) or is_video(p)):
|
||||||
|
files.append(p)
|
||||||
|
|
||||||
|
files.sort()
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def make_output_dirs(input_path: Path, output_base: Path) -> Dict[str, Path]:
|
||||||
|
timestamp = now_timestamp_minute()
|
||||||
|
input_name = safe_stem(input_path.name)
|
||||||
|
|
||||||
|
output_root = output_base / f"{timestamp}_{input_name}"
|
||||||
|
|
||||||
|
if output_root.exists():
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate = output_base / f"{timestamp}_{input_name}_{idx:02d}"
|
||||||
|
if not candidate.exists():
|
||||||
|
output_root = candidate
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
result_dir = output_root / "result"
|
||||||
|
xml_dir = output_root / "xml"
|
||||||
|
json_dir = output_root / "json"
|
||||||
|
|
||||||
|
result_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xml_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
json_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root": output_root,
|
||||||
|
"result": result_dir,
|
||||||
|
"xml": xml_dir,
|
||||||
|
"json": json_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unique_path(path: Path) -> Path:
|
||||||
|
"""
|
||||||
|
如果不同子目录里存在同名文件,避免覆盖。
|
||||||
|
例如 result/1.jpg 已存在,则自动变成 result/1_01.jpg。
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
stem = path.stem
|
||||||
|
suffix = path.suffix
|
||||||
|
parent = path.parent
|
||||||
|
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate = parent / f"{stem}_{idx:02d}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(
|
||||||
|
image_path: Path,
|
||||||
|
pipeline: BirdCascadePipeline,
|
||||||
|
dirs: Dict[str, Path],
|
||||||
|
) -> Dict:
|
||||||
|
img_bgr = decode_image_from_bytes(image_path.read_bytes())
|
||||||
|
|
||||||
|
output = pipeline.predict(img_bgr, return_image=False)
|
||||||
|
results = output["results"]
|
||||||
|
|
||||||
|
result_img_path = unique_path(dirs["result"] / f"{image_path.stem}.jpg")
|
||||||
|
json_path = unique_path(dirs["json"] / f"{image_path.stem}.json")
|
||||||
|
xml_path = unique_path(dirs["xml"] / f"{image_path.stem}.xml")
|
||||||
|
|
||||||
|
annotated = draw_results(img_bgr, results)
|
||||||
|
cv2.imwrite(str(result_img_path), annotated)
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"success": True,
|
||||||
|
"type": "image",
|
||||||
|
"source_path": str(image_path),
|
||||||
|
"result_path": str(result_img_path),
|
||||||
|
"xml_path": str(xml_path),
|
||||||
|
"json_path": str(json_path),
|
||||||
|
"image_width": output["image_width"],
|
||||||
|
"image_height": output["image_height"],
|
||||||
|
"num_objects": output["num_objects"],
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_image_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
image_filename=result_img_path.name,
|
||||||
|
image_path=str(result_img_path),
|
||||||
|
width=int(output["image_width"]),
|
||||||
|
height=int(output["image_height"]),
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json_data
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(
|
||||||
|
video_path: Path,
|
||||||
|
pipeline: BirdCascadePipeline,
|
||||||
|
dirs: Dict[str, Path],
|
||||||
|
sample_interval: int,
|
||||||
|
) -> Dict:
|
||||||
|
result_video_path = unique_path(dirs["result"] / f"{video_path.stem}.mp4")
|
||||||
|
json_path = unique_path(dirs["json"] / f"{video_path.stem}.json")
|
||||||
|
xml_path = unique_path(dirs["xml"] / f"{video_path.stem}.xml")
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(str(video_path))
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise ValueError(f"视频打开失败: {video_path}")
|
||||||
|
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
if fps is None or fps <= 0:
|
||||||
|
fps = 25.0
|
||||||
|
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||||
|
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
writer = cv2.VideoWriter(str(result_video_path), fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
smoother = VideoResultSmoother(
|
||||||
|
hold_frames=settings.smooth_hold_frames,
|
||||||
|
alpha=settings.box_smooth_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
frame_results = []
|
||||||
|
processed_frames = 0
|
||||||
|
detected_frames = 0
|
||||||
|
frame_index = 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
should_process = frame_index % sample_interval == 0
|
||||||
|
|
||||||
|
if should_process:
|
||||||
|
output = pipeline.predict(frame, return_image=False)
|
||||||
|
raw_results = output["results"]
|
||||||
|
processed_frames += 1
|
||||||
|
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.update(raw_results, frame_index)
|
||||||
|
else:
|
||||||
|
results = raw_results
|
||||||
|
|
||||||
|
if len(results) > 0:
|
||||||
|
detected_frames += 1
|
||||||
|
|
||||||
|
frame_results.append(
|
||||||
|
{
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"time_sec": round(frame_index / fps, 3),
|
||||||
|
"num_objects": len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.get_for_skipped_frame(frame_index)
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
annotated_frame = draw_results(frame, results)
|
||||||
|
writer.write(annotated_frame)
|
||||||
|
|
||||||
|
frame_index += 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 3)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"success": True,
|
||||||
|
"type": "video",
|
||||||
|
"source_path": str(video_path),
|
||||||
|
"result_path": str(result_video_path),
|
||||||
|
"xml_path": str(xml_path),
|
||||||
|
"json_path": str(json_path),
|
||||||
|
"video_info": {
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
"total_frames": total_frames,
|
||||||
|
},
|
||||||
|
"sample_interval": sample_interval,
|
||||||
|
"smooth_video_boxes": settings.smooth_video_boxes,
|
||||||
|
"smooth_hold_frames": settings.smooth_hold_frames,
|
||||||
|
"box_smooth_alpha": settings.box_smooth_alpha,
|
||||||
|
"processed_frames": processed_frames,
|
||||||
|
"detected_frames": detected_frames,
|
||||||
|
"elapsed_sec": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"summary": summary,
|
||||||
|
"frames": frame_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_video_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
video_filename=result_video_path.name,
|
||||||
|
video_path=str(result_video_path),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
fps=fps,
|
||||||
|
total_frames=total_frames,
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
frame_results=frame_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def run_batch(
|
||||||
|
input_path: str,
|
||||||
|
output_base: str,
|
||||||
|
recursive: bool,
|
||||||
|
sample_interval: int,
|
||||||
|
) -> Dict:
|
||||||
|
input_path_obj = Path(input_path).resolve()
|
||||||
|
output_base_obj = Path(output_base).resolve()
|
||||||
|
output_base_obj.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dirs = make_output_dirs(input_path_obj, output_base_obj)
|
||||||
|
files = collect_inputs(input_path_obj, recursive=recursive)
|
||||||
|
|
||||||
|
if len(files) == 0:
|
||||||
|
raise ValueError(f"没有找到图片或视频文件: {input_path_obj}")
|
||||||
|
|
||||||
|
pipeline = BirdCascadePipeline()
|
||||||
|
|
||||||
|
success_results = []
|
||||||
|
failed_results = []
|
||||||
|
|
||||||
|
for idx, file_path in enumerate(files, start=1):
|
||||||
|
print(f"[{idx}/{len(files)}] processing: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_image(file_path):
|
||||||
|
result = process_image(
|
||||||
|
image_path=file_path,
|
||||||
|
pipeline=pipeline,
|
||||||
|
dirs=dirs,
|
||||||
|
)
|
||||||
|
elif is_video(file_path):
|
||||||
|
result = process_video(
|
||||||
|
video_path=file_path,
|
||||||
|
pipeline=pipeline,
|
||||||
|
dirs=dirs,
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
success_results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed = {
|
||||||
|
"source_path": str(file_path),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
failed_results.append(failed)
|
||||||
|
print(f"[ERROR] {file_path}: {e}")
|
||||||
|
|
||||||
|
index_json = {
|
||||||
|
"success": len(failed_results) == 0,
|
||||||
|
"input_path": str(input_path_obj),
|
||||||
|
"output_root": str(dirs["root"]),
|
||||||
|
"result_dir": str(dirs["result"]),
|
||||||
|
"xml_dir": str(dirs["xml"]),
|
||||||
|
"json_dir": str(dirs["json"]),
|
||||||
|
"total_files": len(files),
|
||||||
|
"success_files": len(success_results),
|
||||||
|
"failed_files": len(failed_results),
|
||||||
|
"failed": failed_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
index_path = dirs["root"] / "index.json"
|
||||||
|
index_path.write_text(
|
||||||
|
json.dumps(index_json, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nDone.")
|
||||||
|
print(f"Output root: {dirs['root']}")
|
||||||
|
print(f"Result dir : {dirs['result']}")
|
||||||
|
print(f"XML dir : {dirs['xml']}")
|
||||||
|
print(f"JSON dir : {dirs['json']}")
|
||||||
|
print(f"Index JSON : {index_path}")
|
||||||
|
|
||||||
|
return index_json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Bird cascade batch inference")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
required=True,
|
||||||
|
help="输入路径,可以是单张图片、单个视频,或包含图片/视频的文件夹",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="/data2/zhangw_data/Bird_detect_cascade_service/runs",
|
||||||
|
help="输出根目录,默认直接输出到项目 runs 下",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--recursive",
|
||||||
|
action="store_true",
|
||||||
|
help="输入为文件夹时,是否递归处理子文件夹",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sample-interval",
|
||||||
|
type=int,
|
||||||
|
default=settings.video_sample_interval,
|
||||||
|
help="视频抽帧间隔",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
run_batch(
|
||||||
|
input_path=args.input,
|
||||||
|
output_base=args.output,
|
||||||
|
recursive=args.recursive,
|
||||||
|
sample_interval=args.sample_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+494
@@ -0,0 +1,494 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 项目根目录
|
||||||
|
# =========================
|
||||||
|
# 例如:
|
||||||
|
# 宿主机:
|
||||||
|
# /data2/zhangw_data/Bird_detect_cascade_service
|
||||||
|
#
|
||||||
|
# Docker:
|
||||||
|
# /Bird_detect_cascade_service
|
||||||
|
#
|
||||||
|
# 会自动适配,不需要写死绝对路径
|
||||||
|
# =========================
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
MODELS_DIR = os.path.join(BASE_DIR, "models")
|
||||||
|
RUNS_DIR = os.path.join(BASE_DIR, "runs")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_str(name: str, default: str) -> str:
|
||||||
|
return os.getenv(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(name: str, default: int) -> int:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_float(name: str, default: float) -> float:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool(name: str, default: bool) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.lower() in ["1", "true", "yes", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int_list(name: str, default: str) -> list[int]:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
|
||||||
|
if value is None or value.strip() == "":
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
int(x.strip())
|
||||||
|
for x in value.split(",")
|
||||||
|
if x.strip() != ""
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 模型开关
|
||||||
|
# =========================
|
||||||
|
enable_detection: bool = _get_bool(
|
||||||
|
"ENABLE_DETECTION",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
enable_classification: bool = _get_bool(
|
||||||
|
"ENABLE_CLASSIFICATION",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# YOLOv5 模型
|
||||||
|
# =========================
|
||||||
|
det_model_path: str = _get_str(
|
||||||
|
"DET_MODEL_PATH",
|
||||||
|
os.path.join(
|
||||||
|
MODELS_DIR,
|
||||||
|
"YNDC_20251010_yolov5_optimize.onnx",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
det_classes_path: str = _get_str(
|
||||||
|
"DET_CLASSES_PATH",
|
||||||
|
os.path.join(
|
||||||
|
MODELS_DIR,
|
||||||
|
"YNDC_20251010_yolov5_optimize.txt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# ConvNeXt 分类模型
|
||||||
|
# =========================
|
||||||
|
cls_model_path: str = _get_str(
|
||||||
|
"CLS_MODEL_PATH",
|
||||||
|
os.path.join(
|
||||||
|
MODELS_DIR,
|
||||||
|
"JXPYH_20260204_mmcls_optimize.onnx",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_classes_path: str = _get_str(
|
||||||
|
"CLS_CLASSES_PATH",
|
||||||
|
os.path.join(
|
||||||
|
MODELS_DIR,
|
||||||
|
"JXPYH_20260204_mmcls_optimize.txt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 密度估计模型
|
||||||
|
# =========================
|
||||||
|
enable_density: bool = _get_bool(
|
||||||
|
"ENABLE_DENSITY",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
density_model_path: str = _get_str(
|
||||||
|
"DENSITY_MODEL_PATH",
|
||||||
|
os.path.join(
|
||||||
|
MODELS_DIR,
|
||||||
|
"bird_density.onnx",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果模型输入固定,例如 400×400
|
||||||
|
# 可以设置 DENSITY_IMG_SIZE=400
|
||||||
|
# 默认 0 表示自动读取 ONNX 输入尺寸
|
||||||
|
density_img_size: int = _get_int(
|
||||||
|
"DENSITY_IMG_SIZE",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 输入尺寸
|
||||||
|
# =========================
|
||||||
|
det_img_size: int = _get_int(
|
||||||
|
"DET_IMG_SIZE",
|
||||||
|
640,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_img_size: int = _get_int(
|
||||||
|
"CLS_IMG_SIZE",
|
||||||
|
224,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# YOLO 阈值
|
||||||
|
# =========================
|
||||||
|
det_conf_thres: float = _get_float(
|
||||||
|
"DET_CONF_THRES",
|
||||||
|
0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
det_iou_thres: float = _get_float(
|
||||||
|
"DET_IOU_THRES",
|
||||||
|
0.45,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 二次过滤 YOLO 检测框,避免把明显错误的框传给 ConvNeXt
|
||||||
|
det_second_conf_thres: float = _get_float(
|
||||||
|
"DET_SECOND_CONF_THRES",
|
||||||
|
0.35,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 最小框面积占整图比例,太小的噪声框过滤
|
||||||
|
min_box_area_ratio: float = _get_float(
|
||||||
|
"MIN_BOX_AREA_RATIO",
|
||||||
|
0.00002,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 最大框面积占整图比例,太大的误检框过滤
|
||||||
|
max_box_area_ratio: float = _get_float(
|
||||||
|
"MAX_BOX_AREA_RATIO",
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 最大长宽比,过细过长的框过滤
|
||||||
|
max_box_aspect_ratio: float = _get_float(
|
||||||
|
"MAX_BOX_ASPECT_RATIO",
|
||||||
|
6.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 可选检测区域:按检测框中心点的纵向位置过滤。
|
||||||
|
# 默认 0~1 表示整幅画面;可排除固定字幕、时间戳或岸边噪声区域。
|
||||||
|
detection_roi_y_min_ratio: float = _get_float(
|
||||||
|
"DETECTION_ROI_Y_MIN_RATIO",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
detection_roi_y_max_ratio: float = _get_float(
|
||||||
|
"DETECTION_ROI_Y_MAX_RATIO",
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分类置信度低于该值时,不显示物种名称
|
||||||
|
cls_show_conf_thres: float = _get_float(
|
||||||
|
"CLS_SHOW_CONF_THRES",
|
||||||
|
0.50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 大目标判断阈值:框面积占整图比例超过该值,认为是近景大目标
|
||||||
|
large_box_area_ratio: float = _get_float(
|
||||||
|
"LARGE_BOX_AREA_RATIO",
|
||||||
|
0.002,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 大目标分类显示阈值,近景大目标即使分类置信度稍低,也显示物种名
|
||||||
|
large_box_cls_show_conf_thres: float = _get_float(
|
||||||
|
"LARGE_BOX_CLS_SHOW_CONF_THRES",
|
||||||
|
0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数量达到该阈值时,认为是密集场景,不显示物种名
|
||||||
|
dense_scene_count_thres: int = _get_int(
|
||||||
|
"DENSE_SCENE_COUNT_THRES",
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 大目标强制显示物种名,只要分类结果非空即可
|
||||||
|
show_large_box_name_always: bool = _get_bool(
|
||||||
|
"SHOW_LARGE_BOX_NAME_ALWAYS",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 是否在密集场景隐藏所有物种名
|
||||||
|
hide_cls_name_in_dense_scene: bool = _get_bool(
|
||||||
|
"HIDE_CLS_NAME_IN_DENSE_SCENE",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 所有物种是否统一使用同一种标签和检测框颜色
|
||||||
|
uniform_species_color: bool = _get_bool(
|
||||||
|
"UNIFORM_SPECIES_COLOR",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 可选:整段素材已人工确认只有一个物种时,覆盖细粒度模型标签。
|
||||||
|
# 留空表示使用模型输出。
|
||||||
|
forced_species_name: str = _get_str(
|
||||||
|
"FORCED_SPECIES_NAME",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
forced_species_class_id: int = _get_int(
|
||||||
|
"FORCED_SPECIES_CLASS_ID",
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 只对达到该框面积占比的目标应用人工物种覆盖。
|
||||||
|
# 可用来统一主要大型鸟类,同时保留画面中的小型伴生鸟类。
|
||||||
|
forced_species_min_box_area_ratio: float = _get_float(
|
||||||
|
"FORCED_SPECIES_MIN_BOX_AREA_RATIO",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 视频级人工复核映射,格式示例:
|
||||||
|
# 东方白鹳=白鹤,白鹭=白鹤
|
||||||
|
species_name_remap: str = _get_str(
|
||||||
|
"SPECIES_NAME_REMAP",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# 只有原始细粒度分类置信度达到该阈值时才应用标签映射。
|
||||||
|
# 默认 0 保持原有行为;混合物种场景可提高该值以避免低置信度误映射。
|
||||||
|
species_name_remap_min_confidence: float = _get_float(
|
||||||
|
"SPECIES_NAME_REMAP_MIN_CONFIDENCE",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 留空表示允许全部;非空时,只有白名单中的物种显示名称和物种色。
|
||||||
|
allowed_species_names: str = _get_str(
|
||||||
|
"ALLOWED_SPECIES_NAMES",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# 可选双物种明暗规则。两个名称都非空时,不运行细粒度分类模型,
|
||||||
|
# 而是根据检测框内暗色像素比例区分两个外观差异明显的物种。
|
||||||
|
binary_dark_species_name: str = _get_str(
|
||||||
|
"BINARY_DARK_SPECIES_NAME",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
binary_light_species_name: str = _get_str(
|
||||||
|
"BINARY_LIGHT_SPECIES_NAME",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
binary_dark_pixel_threshold: int = _get_int(
|
||||||
|
"BINARY_DARK_PIXEL_THRESHOLD",
|
||||||
|
85,
|
||||||
|
)
|
||||||
|
|
||||||
|
binary_dark_ratio_threshold: float = _get_float(
|
||||||
|
"BINARY_DARK_RATIO_THRESHOLD",
|
||||||
|
0.12,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 可选:对画面中的深色目标做人工标签覆盖。
|
||||||
|
# 适合模型把黑色雁鸭类误判成白骨顶等细粒度类别的场景。
|
||||||
|
dark_override_species_name: str = _get_str(
|
||||||
|
"DARK_OVERRIDE_SPECIES_NAME",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
dark_override_species_class_id: int = _get_int(
|
||||||
|
"DARK_OVERRIDE_SPECIES_CLASS_ID",
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_pixel_threshold: int = _get_int(
|
||||||
|
"DARK_OVERRIDE_PIXEL_THRESHOLD",
|
||||||
|
80,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_ratio_threshold: float = _get_float(
|
||||||
|
"DARK_OVERRIDE_RATIO_THRESHOLD",
|
||||||
|
0.35,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_min_box_area_ratio: float = _get_float(
|
||||||
|
"DARK_OVERRIDE_MIN_BOX_AREA_RATIO",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_max_box_area_ratio: float = _get_float(
|
||||||
|
"DARK_OVERRIDE_MAX_BOX_AREA_RATIO",
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_roi_y_min_ratio: float = _get_float(
|
||||||
|
"DARK_OVERRIDE_ROI_Y_MIN_RATIO",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_override_roi_y_max_ratio: float = _get_float(
|
||||||
|
"DARK_OVERRIDE_ROI_Y_MAX_RATIO",
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 可选:按画面纵向区域做人工标签覆盖。
|
||||||
|
# 用于同一视频中上/中/下区域物种分布稳定的场景。
|
||||||
|
region_override_species_name: str = _get_str(
|
||||||
|
"REGION_OVERRIDE_SPECIES_NAME",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
region_override_species_class_id: int = _get_int(
|
||||||
|
"REGION_OVERRIDE_SPECIES_CLASS_ID",
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
region_override_source_species_names: str = _get_str(
|
||||||
|
"REGION_OVERRIDE_SOURCE_SPECIES_NAMES",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
region_override_roi_y_min_ratio: float = _get_float(
|
||||||
|
"REGION_OVERRIDE_ROI_Y_MIN_RATIO",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
region_override_roi_y_max_ratio: float = _get_float(
|
||||||
|
"REGION_OVERRIDE_ROI_Y_MAX_RATIO",
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 可选:鹤类混淆纠正。针对白鹤/白头鹤/白枕鹤相互误判,
|
||||||
|
# 根据检测框内亮白像素和暗色像素比例做保守二次校验。
|
||||||
|
enable_crane_appearance_correction: bool = _get_bool(
|
||||||
|
"ENABLE_CRANE_APPEARANCE_CORRECTION",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
crane_white_ratio_threshold: float = _get_float(
|
||||||
|
"CRANE_WHITE_RATIO_THRESHOLD",
|
||||||
|
0.30,
|
||||||
|
)
|
||||||
|
|
||||||
|
crane_dark_ratio_threshold: float = _get_float(
|
||||||
|
"CRANE_DARK_RATIO_THRESHOLD",
|
||||||
|
0.28,
|
||||||
|
)
|
||||||
|
|
||||||
|
crane_tail_white_ratio_threshold: float = _get_float(
|
||||||
|
"CRANE_TAIL_WHITE_RATIO_THRESHOLD",
|
||||||
|
0.16,
|
||||||
|
)
|
||||||
|
|
||||||
|
crane_uncertain_hide_label: bool = _get_bool(
|
||||||
|
"CRANE_UNCERTAIN_HIDE_LABEL",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 裁剪外扩比例
|
||||||
|
# =========================
|
||||||
|
crop_padding_ratio: float = _get_float(
|
||||||
|
"CROP_PADDING_RATIO",
|
||||||
|
0.08,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 最多检测目标数
|
||||||
|
# =========================
|
||||||
|
max_detections: int = _get_int(
|
||||||
|
"MAX_DETECTIONS",
|
||||||
|
50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 推理设备
|
||||||
|
# cpu / cuda
|
||||||
|
# =========================
|
||||||
|
device: str = _get_str(
|
||||||
|
"DEVICE",
|
||||||
|
"cpu",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 输出目录
|
||||||
|
# =========================
|
||||||
|
output_dir: str = _get_str(
|
||||||
|
"OUTPUT_DIR",
|
||||||
|
RUNS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
image_output_subdir: str = _get_str(
|
||||||
|
"IMAGE_OUTPUT_SUBDIR",
|
||||||
|
"images",
|
||||||
|
)
|
||||||
|
|
||||||
|
video_output_subdir: str = _get_str(
|
||||||
|
"VIDEO_OUTPUT_SUBDIR",
|
||||||
|
"video",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 视频抽帧间隔
|
||||||
|
# =========================
|
||||||
|
video_sample_interval: int = _get_int(
|
||||||
|
"VIDEO_SAMPLE_INTERVAL",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 是否保存画框视频
|
||||||
|
# =========================
|
||||||
|
save_annotated_video: bool = _get_bool(
|
||||||
|
"SAVE_ANNOTATED_VIDEO",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 只保留 YOLO 类别
|
||||||
|
# 默认只保留 bird
|
||||||
|
# =========================
|
||||||
|
target_det_class_ids: list[int] = None
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 视频框平滑
|
||||||
|
# =========================
|
||||||
|
smooth_video_boxes: bool = _get_bool(
|
||||||
|
"SMOOTH_VIDEO_BOXES",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
smooth_hold_frames: int = _get_int(
|
||||||
|
"SMOOTH_HOLD_FRAMES",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
box_smooth_alpha: float = _get_float(
|
||||||
|
"BOX_SMOOTH_ALPHA",
|
||||||
|
0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"target_det_class_ids",
|
||||||
|
_get_int_list(
|
||||||
|
"TARGET_DET_CLASS_IDS",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_str(name: str, default: str) -> str:
|
||||||
|
return os.getenv(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(name: str, default: int) -> int:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_float(name: str, default: float) -> float:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool(name: str, default: bool) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.lower() in ["1", "true", "yes", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int_list(name: str, default: str) -> list[int]:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
if value is None or value.strip() == "":
|
||||||
|
return []
|
||||||
|
return [int(x.strip()) for x in value.split(",") if x.strip() != ""]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
# Docker 内默认模型路径
|
||||||
|
det_model_path: str = _get_str(
|
||||||
|
"DET_MODEL_PATH",
|
||||||
|
"/app/models/YNDC_20251010_yolov5_optimize.onnx",
|
||||||
|
)
|
||||||
|
cls_model_path: str = _get_str(
|
||||||
|
"CLS_MODEL_PATH",
|
||||||
|
"/app/models/HHK_20260525_mmcls_optimize.onnx",
|
||||||
|
)
|
||||||
|
det_classes_path: str = _get_str(
|
||||||
|
"DET_CLASSES_PATH",
|
||||||
|
"/app/models/YNDC_20251010_yolov5_optimize.txt",
|
||||||
|
)
|
||||||
|
cls_classes_path: str = _get_str(
|
||||||
|
"CLS_CLASSES_PATH",
|
||||||
|
"/app/models/HHK_20260525_mmcls_optimize.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 输入尺寸
|
||||||
|
det_img_size: int = _get_int("DET_IMG_SIZE", 640)
|
||||||
|
cls_img_size: int = _get_int("CLS_IMG_SIZE", 224)
|
||||||
|
|
||||||
|
# YOLO 检测阈值
|
||||||
|
det_conf_thres: float = _get_float("DET_CONF_THRES", 0.25)
|
||||||
|
det_iou_thres: float = _get_float("DET_IOU_THRES", 0.45)
|
||||||
|
|
||||||
|
# 目标裁剪外扩比例
|
||||||
|
crop_padding_ratio: float = _get_float("CROP_PADDING_RATIO", 0.08)
|
||||||
|
|
||||||
|
# 最多保留目标数量
|
||||||
|
max_detections: int = _get_int("MAX_DETECTIONS", 50)
|
||||||
|
|
||||||
|
# 当前环境没有 onnxruntime-gpu 时,建议用 cpu
|
||||||
|
device: str = _get_str("DEVICE", "cpu")
|
||||||
|
|
||||||
|
# 输出目录
|
||||||
|
output_dir: str = _get_str("OUTPUT_DIR", "/app/runs")
|
||||||
|
image_output_subdir: str = _get_str("IMAGE_OUTPUT_SUBDIR", "images")
|
||||||
|
video_output_subdir: str = _get_str("VIDEO_OUTPUT_SUBDIR", "video")
|
||||||
|
|
||||||
|
# 视频抽帧间隔
|
||||||
|
video_sample_interval: int = _get_int("VIDEO_SAMPLE_INTERVAL", 2)
|
||||||
|
|
||||||
|
# 是否保存画框后视频
|
||||||
|
save_annotated_video: bool = _get_bool("SAVE_ANNOTATED_VIDEO", True)
|
||||||
|
|
||||||
|
# 只保留 YOLO 类别 0,也就是“鸟”
|
||||||
|
target_det_class_ids: list[int] = None
|
||||||
|
|
||||||
|
# 视频平滑参数
|
||||||
|
smooth_video_boxes: bool = _get_bool("SMOOTH_VIDEO_BOXES", True)
|
||||||
|
|
||||||
|
# 未处理帧或短暂漏检时,最多复用多少帧上一结果
|
||||||
|
smooth_hold_frames: int = _get_int("SMOOTH_HOLD_FRAMES", 3)
|
||||||
|
|
||||||
|
# 框坐标平滑系数,越大越稳定,但响应越慢
|
||||||
|
box_smooth_alpha: float = _get_float("BOX_SMOOTH_ALPHA", 0.25)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"target_det_class_ids",
|
||||||
|
_get_int_list("TARGET_DET_CLASS_IDS", "0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.utils import (
|
||||||
|
draw_results,
|
||||||
|
now_timestamp_minute,
|
||||||
|
safe_stem,
|
||||||
|
save_json,
|
||||||
|
write_image_xml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_unique_result_dir(parent_dir: Path, timestamp: str, original_filename: str) -> tuple[Path, str, str]:
|
||||||
|
"""
|
||||||
|
创建单次图片识别结果目录。
|
||||||
|
|
||||||
|
目录名:
|
||||||
|
时间戳_原文件名stem
|
||||||
|
|
||||||
|
例如:
|
||||||
|
runs/images/202605181431_DJI_20250820151801_0006_Z/
|
||||||
|
|
||||||
|
返回:
|
||||||
|
result_dir, result_base_name, original_stem
|
||||||
|
"""
|
||||||
|
original_stem = safe_stem(original_filename)
|
||||||
|
base_name = f"{timestamp}_{original_stem}"
|
||||||
|
|
||||||
|
result_dir = parent_dir / base_name
|
||||||
|
|
||||||
|
if not result_dir.exists():
|
||||||
|
result_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return result_dir, base_name, original_stem
|
||||||
|
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate_base = f"{base_name}_{idx:02d}"
|
||||||
|
candidate_dir = parent_dir / candidate_base
|
||||||
|
if not candidate_dir.exists():
|
||||||
|
candidate_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return candidate_dir, candidate_base, original_stem
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_outputs(
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
original_filename: str,
|
||||||
|
predict_output: Dict,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
图片结果自动保存到:
|
||||||
|
runs/images/时间戳_原图片名/
|
||||||
|
|
||||||
|
目录内部生成:
|
||||||
|
原图片名.jpg
|
||||||
|
时间戳_原图片名.json
|
||||||
|
时间戳_原图片名.xml
|
||||||
|
|
||||||
|
示例:
|
||||||
|
runs/images/202605181431_DJI_20250820151801_0006_Z/
|
||||||
|
├── DJI_20250820151801_0006_Z.jpg
|
||||||
|
├── 202605181431_DJI_20250820151801_0006_Z.json
|
||||||
|
└── 202605181431_DJI_20250820151801_0006_Z.xml
|
||||||
|
"""
|
||||||
|
images_root = Path(settings.output_dir) / settings.image_output_subdir
|
||||||
|
images_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = now_timestamp_minute()
|
||||||
|
result_dir, result_base_name, original_stem = make_unique_result_dir(
|
||||||
|
parent_dir=images_root,
|
||||||
|
timestamp=timestamp,
|
||||||
|
original_filename=original_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 图片文件名不带时间戳,按你的要求保留原图片名 stem
|
||||||
|
image_path = result_dir / f"{original_stem}.jpg"
|
||||||
|
|
||||||
|
# JSON 和 XML 带时间戳
|
||||||
|
json_path = result_dir / f"{result_base_name}.json"
|
||||||
|
xml_path = result_dir / f"{result_base_name}.xml"
|
||||||
|
|
||||||
|
results = predict_output.get("results", [])
|
||||||
|
|
||||||
|
annotated = draw_results(img_bgr, results)
|
||||||
|
cv2.imwrite(str(image_path), annotated)
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"success": True,
|
||||||
|
"type": "image",
|
||||||
|
"original_filename": original_filename,
|
||||||
|
"result_dir": str(result_dir),
|
||||||
|
"saved_image": str(image_path),
|
||||||
|
"saved_json": str(json_path),
|
||||||
|
"saved_xml": str(xml_path),
|
||||||
|
"image_width": predict_output.get("image_width"),
|
||||||
|
"image_height": predict_output.get("image_height"),
|
||||||
|
"num_objects": predict_output.get("num_objects"),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_image_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
image_filename=image_path.name,
|
||||||
|
image_path=str(image_path),
|
||||||
|
width=int(predict_output.get("image_width", img_bgr.shape[1])),
|
||||||
|
height=int(predict_output.get("image_height", img_bgr.shape[0])),
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result_dir": str(result_dir),
|
||||||
|
"image": str(image_path),
|
||||||
|
"json": str(json_path),
|
||||||
|
"xml": str(xml_path),
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,101 @@
|
|||||||
|
from typing import List, Dict
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
|
||||||
|
def softmax(x: np.ndarray) -> np.ndarray:
|
||||||
|
x = x.astype(np.float32)
|
||||||
|
x = x - np.max(x)
|
||||||
|
exp = np.exp(x)
|
||||||
|
return exp / (np.sum(exp) + 1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def to_probabilities(output: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
兼容两种常见 ONNX 导出:
|
||||||
|
1. 模型直接输出 logits,需要 softmax;
|
||||||
|
2. 模型图中已经包含 softmax,直接输出概率。
|
||||||
|
|
||||||
|
对概率再次做 softmax 会把原本很高的置信度压到接近均匀分布。
|
||||||
|
"""
|
||||||
|
values = np.asarray(output, dtype=np.float32).reshape(-1)
|
||||||
|
total = float(np.sum(values))
|
||||||
|
is_probability_output = (
|
||||||
|
values.size > 0
|
||||||
|
and np.all(np.isfinite(values))
|
||||||
|
and float(np.min(values)) >= -1e-5
|
||||||
|
and float(np.max(values)) <= 1.0 + 1e-5
|
||||||
|
and abs(total - 1.0) <= 1e-3
|
||||||
|
)
|
||||||
|
if is_probability_output:
|
||||||
|
return values / max(total, 1e-12)
|
||||||
|
return softmax(values)
|
||||||
|
|
||||||
|
|
||||||
|
class ConvNeXtONNXClassifier:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_path: str,
|
||||||
|
class_names: List[str],
|
||||||
|
img_size: int = 224,
|
||||||
|
device: str = "cpu",
|
||||||
|
):
|
||||||
|
self.model_path = model_path
|
||||||
|
self.class_names = class_names
|
||||||
|
self.img_size = img_size
|
||||||
|
|
||||||
|
providers = ["CPUExecutionProvider"]
|
||||||
|
if device.lower() in ["cuda", "gpu"]:
|
||||||
|
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
|
||||||
|
self.session = ort.InferenceSession(model_path, providers=providers)
|
||||||
|
active_providers = self.session.get_providers()
|
||||||
|
print("[ConvNeXt] providers:", active_providers)
|
||||||
|
if (
|
||||||
|
device.lower() in ["cuda", "gpu"]
|
||||||
|
and "CUDAExecutionProvider" not in active_providers
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"请求使用 CUDA 推理,但 ConvNeXt 会话未启用 "
|
||||||
|
"CUDAExecutionProvider。请安装匹配 CUDA/cuDNN 的 "
|
||||||
|
"onnxruntime-gpu。"
|
||||||
|
)
|
||||||
|
self.input_name = self.session.get_inputs()[0].name
|
||||||
|
self.output_names = [o.name for o in self.session.get_outputs()]
|
||||||
|
|
||||||
|
# 常见 ImageNet 归一化参数。若训练 ConvNeXt 时使用了其他 mean/std,需要保持一致。
|
||||||
|
self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||||
|
self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||||
|
|
||||||
|
def preprocess(self, img_bgr: np.ndarray) -> np.ndarray:
|
||||||
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
img_rgb = cv2.resize(img_rgb, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR)
|
||||||
|
img = img_rgb.astype(np.float32) / 255.0
|
||||||
|
img = (img - self.mean) / self.std
|
||||||
|
img = img.transpose(2, 0, 1)
|
||||||
|
img = np.expand_dims(img, axis=0).astype(np.float32)
|
||||||
|
return img
|
||||||
|
|
||||||
|
def infer(self, img_bgr: np.ndarray) -> Dict:
|
||||||
|
input_tensor = self.preprocess(img_bgr)
|
||||||
|
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
|
||||||
|
logits = np.asarray(outputs[0])
|
||||||
|
|
||||||
|
if logits.ndim == 2:
|
||||||
|
logits = logits[0]
|
||||||
|
elif logits.ndim == 1:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logits = logits.reshape(-1)
|
||||||
|
|
||||||
|
probs = to_probabilities(logits)
|
||||||
|
class_id = int(np.argmax(probs))
|
||||||
|
confidence = float(probs[class_id])
|
||||||
|
class_name = self.class_names[class_id] if 0 <= class_id < len(self.class_names) else str(class_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cls_class_id": class_id,
|
||||||
|
"cls_class_name": class_name,
|
||||||
|
"cls_confidence": confidence,
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
|
||||||
|
class DensityEstimatorONNX:
|
||||||
|
def __init__(self, model_path, input_size=None, device="cpu", providers=None):
|
||||||
|
self.model_path = model_path
|
||||||
|
self.input_size = input_size
|
||||||
|
|
||||||
|
if providers is None:
|
||||||
|
providers = ["CPUExecutionProvider"]
|
||||||
|
if device.lower() in ["cuda", "gpu"]:
|
||||||
|
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
|
||||||
|
self.session = ort.InferenceSession(model_path, providers=providers)
|
||||||
|
active_providers = self.session.get_providers()
|
||||||
|
print("[Density] providers:", active_providers)
|
||||||
|
if (
|
||||||
|
device.lower() in ["cuda", "gpu"]
|
||||||
|
and "CUDAExecutionProvider" not in active_providers
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"请求使用 CUDA 推理,但密度模型会话未启用 "
|
||||||
|
"CUDAExecutionProvider。请安装匹配 CUDA/cuDNN 的 "
|
||||||
|
"onnxruntime-gpu。"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.input_name = self.session.get_inputs()[0].name
|
||||||
|
self.input_shape = self.session.get_inputs()[0].shape
|
||||||
|
self.output_names = [o.name for o in self.session.get_outputs()]
|
||||||
|
|
||||||
|
print("[Density] input_name:", self.input_name)
|
||||||
|
print("[Density] input_shape:", self.input_shape)
|
||||||
|
print("[Density] output_names:", self.output_names)
|
||||||
|
|
||||||
|
if self.input_size is None:
|
||||||
|
h = self.input_shape[2]
|
||||||
|
w = self.input_shape[3]
|
||||||
|
if isinstance(h, int) and isinstance(w, int):
|
||||||
|
self.input_size = (w, h)
|
||||||
|
else:
|
||||||
|
self.input_size = (400, 400)
|
||||||
|
|
||||||
|
def preprocess(self, image_bgr):
|
||||||
|
original_h, original_w = image_bgr.shape[:2]
|
||||||
|
|
||||||
|
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
resized = cv2.resize(image_rgb, self.input_size)
|
||||||
|
|
||||||
|
img = resized.astype(np.float32) / 255.0
|
||||||
|
|
||||||
|
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||||
|
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||||
|
img = (img - mean) / std
|
||||||
|
|
||||||
|
img = img.transpose(2, 0, 1)
|
||||||
|
img = np.expand_dims(img, axis=0).astype(np.float32)
|
||||||
|
|
||||||
|
return img, original_w, original_h
|
||||||
|
|
||||||
|
def infer(self, image_bgr):
|
||||||
|
input_tensor, original_w, original_h = self.preprocess(image_bgr)
|
||||||
|
|
||||||
|
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
|
||||||
|
density = outputs[0]
|
||||||
|
|
||||||
|
density = np.squeeze(density).astype(np.float32)
|
||||||
|
|
||||||
|
if density.ndim != 2:
|
||||||
|
density = density.reshape(density.shape[-2], density.shape[-1])
|
||||||
|
|
||||||
|
count = float(density.sum())
|
||||||
|
|
||||||
|
density_map = cv2.resize(density, (original_w, original_h))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"density_map": density_map
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def colorize_density_map(density_map):
|
||||||
|
d = density_map.copy()
|
||||||
|
d = d - d.min()
|
||||||
|
if d.max() > 0:
|
||||||
|
d = d / d.max()
|
||||||
|
d = (d * 255).astype(np.uint8)
|
||||||
|
heatmap = cv2.applyColorMap(d, cv2.COLORMAP_JET)
|
||||||
|
return heatmap
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
from typing import List, Dict
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
|
||||||
|
def letterbox(
|
||||||
|
img: np.ndarray,
|
||||||
|
new_shape=(960, 960),
|
||||||
|
color=(114, 114, 114),
|
||||||
|
auto=False,
|
||||||
|
scale_fill=False,
|
||||||
|
scale_up=True,
|
||||||
|
stride=32,
|
||||||
|
):
|
||||||
|
shape = img.shape[:2] # h, w
|
||||||
|
|
||||||
|
if isinstance(new_shape, int):
|
||||||
|
new_shape = (new_shape, new_shape)
|
||||||
|
|
||||||
|
r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
|
||||||
|
if not scale_up:
|
||||||
|
r = min(r, 1.0)
|
||||||
|
|
||||||
|
ratio = (r, r)
|
||||||
|
new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r)))
|
||||||
|
|
||||||
|
dw = new_shape[1] - new_unpad[0]
|
||||||
|
dh = new_shape[0] - new_unpad[1]
|
||||||
|
|
||||||
|
if auto:
|
||||||
|
dw, dh = np.mod(dw, stride), np.mod(dh, stride)
|
||||||
|
elif scale_fill:
|
||||||
|
dw, dh = 0.0, 0.0
|
||||||
|
new_unpad = (new_shape[1], new_shape[0])
|
||||||
|
ratio = (new_shape[1] / shape[1], new_shape[0] / shape[0])
|
||||||
|
|
||||||
|
dw /= 2
|
||||||
|
dh /= 2
|
||||||
|
|
||||||
|
if shape[::-1] != new_unpad:
|
||||||
|
img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
|
||||||
|
|
||||||
|
top = int(round(dh - 0.1))
|
||||||
|
bottom = int(round(dh + 0.1))
|
||||||
|
left = int(round(dw - 0.1))
|
||||||
|
right = int(round(dw + 0.1))
|
||||||
|
|
||||||
|
img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)
|
||||||
|
return img, ratio, (dw, dh)
|
||||||
|
|
||||||
|
|
||||||
|
def xywh2xyxy(x: np.ndarray) -> np.ndarray:
|
||||||
|
y = np.copy(x)
|
||||||
|
y[:, 0] = x[:, 0] - x[:, 2] / 2
|
||||||
|
y[:, 1] = x[:, 1] - x[:, 3] / 2
|
||||||
|
y[:, 2] = x[:, 0] + x[:, 2] / 2
|
||||||
|
y[:, 3] = x[:, 1] + x[:, 3] / 2
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
def box_iou(box: np.ndarray, boxes: np.ndarray) -> np.ndarray:
|
||||||
|
x1 = np.maximum(box[0], boxes[:, 0])
|
||||||
|
y1 = np.maximum(box[1], boxes[:, 1])
|
||||||
|
x2 = np.minimum(box[2], boxes[:, 2])
|
||||||
|
y2 = np.minimum(box[3], boxes[:, 3])
|
||||||
|
|
||||||
|
inter = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
|
||||||
|
area1 = max(0, box[2] - box[0]) * max(0, box[3] - box[1])
|
||||||
|
area2 = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(0, boxes[:, 3] - boxes[:, 1])
|
||||||
|
union = area1 + area2 - inter + 1e-7
|
||||||
|
return inter / union
|
||||||
|
|
||||||
|
|
||||||
|
def nms_numpy(boxes: np.ndarray, scores: np.ndarray, iou_thres: float) -> List[int]:
|
||||||
|
if len(boxes) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
order = scores.argsort()[::-1]
|
||||||
|
keep = []
|
||||||
|
|
||||||
|
while order.size > 0:
|
||||||
|
i = order[0]
|
||||||
|
keep.append(int(i))
|
||||||
|
if order.size == 1:
|
||||||
|
break
|
||||||
|
ious = box_iou(boxes[i], boxes[order[1:]])
|
||||||
|
inds = np.where(ious <= iou_thres)[0]
|
||||||
|
order = order[inds + 1]
|
||||||
|
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def scale_boxes_from_letterbox(boxes: np.ndarray, ratio, pad, original_shape) -> np.ndarray:
|
||||||
|
# boxes: xyxy in letterboxed image
|
||||||
|
gain = ratio[0]
|
||||||
|
pad_x, pad_y = pad
|
||||||
|
|
||||||
|
boxes[:, [0, 2]] -= pad_x
|
||||||
|
boxes[:, [1, 3]] -= pad_y
|
||||||
|
boxes[:, :4] /= gain
|
||||||
|
|
||||||
|
h, w = original_shape[:2]
|
||||||
|
boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w - 1)
|
||||||
|
boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h - 1)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
class YOLOv5ONNXDetector:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_path: str,
|
||||||
|
class_names: List[str],
|
||||||
|
img_size: int = 960,
|
||||||
|
device: str = "cpu",
|
||||||
|
):
|
||||||
|
self.model_path = model_path
|
||||||
|
self.class_names = class_names
|
||||||
|
self.img_size = img_size
|
||||||
|
|
||||||
|
providers = ["CPUExecutionProvider"]
|
||||||
|
if device.lower() in ["cuda", "gpu"]:
|
||||||
|
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
|
||||||
|
self.session = ort.InferenceSession(model_path, providers=providers)
|
||||||
|
active_providers = self.session.get_providers()
|
||||||
|
print("[YOLO] providers:", active_providers)
|
||||||
|
if (
|
||||||
|
device.lower() in ["cuda", "gpu"]
|
||||||
|
and "CUDAExecutionProvider" not in active_providers
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"请求使用 CUDA 推理,但 YOLO 会话未启用 "
|
||||||
|
"CUDAExecutionProvider。请安装匹配 CUDA/cuDNN 的 "
|
||||||
|
"onnxruntime-gpu。"
|
||||||
|
)
|
||||||
|
self.input_name = self.session.get_inputs()[0].name
|
||||||
|
self.output_names = [o.name for o in self.session.get_outputs()]
|
||||||
|
|
||||||
|
def preprocess(self, img_bgr: np.ndarray):
|
||||||
|
img_lb, ratio, pad = letterbox(img_bgr, new_shape=(self.img_size, self.img_size), auto=False)
|
||||||
|
img_rgb = cv2.cvtColor(img_lb, cv2.COLOR_BGR2RGB)
|
||||||
|
img = img_rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
|
||||||
|
img = np.expand_dims(img, axis=0)
|
||||||
|
return img, ratio, pad
|
||||||
|
|
||||||
|
def infer(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
conf_thres: float = 0.25,
|
||||||
|
iou_thres: float = 0.45,
|
||||||
|
max_det: int = 50,
|
||||||
|
) -> List[Dict]:
|
||||||
|
input_tensor, ratio, pad = self.preprocess(img_bgr)
|
||||||
|
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
|
||||||
|
|
||||||
|
pred = outputs[0]
|
||||||
|
|
||||||
|
# 常见 YOLOv5 ONNX 输出: [1, num_boxes, 5 + num_classes]
|
||||||
|
if isinstance(pred, list):
|
||||||
|
pred = pred[0]
|
||||||
|
pred = np.asarray(pred)
|
||||||
|
|
||||||
|
if pred.ndim == 3:
|
||||||
|
pred = pred[0]
|
||||||
|
elif pred.ndim == 2:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 YOLO 输出维度: {pred.shape}")
|
||||||
|
|
||||||
|
if pred.shape[1] < 6:
|
||||||
|
raise ValueError(f"YOLO 输出最后一维应至少为 6,当前为: {pred.shape}")
|
||||||
|
|
||||||
|
boxes_xywh = pred[:, :4]
|
||||||
|
obj_conf = pred[:, 4]
|
||||||
|
|
||||||
|
if pred.shape[1] == 6:
|
||||||
|
# 某些导出格式可能已经是 x,y,w,h,conf,class_id
|
||||||
|
cls_ids = pred[:, 5].astype(np.int32)
|
||||||
|
scores = obj_conf
|
||||||
|
else:
|
||||||
|
cls_scores = pred[:, 5:]
|
||||||
|
cls_ids = np.argmax(cls_scores, axis=1)
|
||||||
|
cls_conf = cls_scores[np.arange(len(cls_scores)), cls_ids]
|
||||||
|
scores = obj_conf * cls_conf
|
||||||
|
|
||||||
|
mask = scores >= conf_thres
|
||||||
|
if not np.any(mask):
|
||||||
|
return []
|
||||||
|
|
||||||
|
boxes_xywh = boxes_xywh[mask]
|
||||||
|
scores = scores[mask]
|
||||||
|
cls_ids = cls_ids[mask]
|
||||||
|
|
||||||
|
boxes = xywh2xyxy(boxes_xywh)
|
||||||
|
boxes = scale_boxes_from_letterbox(boxes, ratio, pad, img_bgr.shape)
|
||||||
|
|
||||||
|
final_results = []
|
||||||
|
|
||||||
|
for class_id in np.unique(cls_ids):
|
||||||
|
idxs = np.where(cls_ids == class_id)[0]
|
||||||
|
keep = nms_numpy(boxes[idxs], scores[idxs], iou_thres)
|
||||||
|
|
||||||
|
for k in keep:
|
||||||
|
original_idx = idxs[k]
|
||||||
|
cid = int(class_id)
|
||||||
|
cname = self.class_names[cid] if 0 <= cid < len(self.class_names) else str(cid)
|
||||||
|
final_results.append({
|
||||||
|
"bbox_xyxy": boxes[original_idx].astype(float).tolist(),
|
||||||
|
"det_confidence": float(scores[original_idx]),
|
||||||
|
"det_class_id": cid,
|
||||||
|
"det_class_name": cname,
|
||||||
|
})
|
||||||
|
|
||||||
|
final_results.sort(key=lambda x: x["det_confidence"], reverse=True)
|
||||||
|
return final_results[:max_det]
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from fastapi import FastAPI, UploadFile, File, Query, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.schemas import PredictResponse
|
||||||
|
from app.utils import decode_image_from_bytes
|
||||||
|
from app.pipeline import BirdCascadePipeline
|
||||||
|
from app.image_pipeline import save_image_outputs
|
||||||
|
from app.video_pipeline import process_video_file
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Bird Cascade Service",
|
||||||
|
description="YOLOv5 detection + target crop + ConvNeXt fine-grained bird classification",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline: Optional[BirdCascadePipeline] = None
|
||||||
|
startup_error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def load_models():
|
||||||
|
global pipeline, startup_error
|
||||||
|
try:
|
||||||
|
pipeline = BirdCascadePipeline()
|
||||||
|
startup_error = None
|
||||||
|
except Exception as e:
|
||||||
|
pipeline = None
|
||||||
|
startup_error = str(e)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok" if pipeline is not None else "error",
|
||||||
|
"det_model_loaded": pipeline is not None,
|
||||||
|
"cls_model_loaded": pipeline is not None,
|
||||||
|
"error": startup_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/predict", response_model=PredictResponse)
|
||||||
|
async def predict(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
return_image: bool = Query(False, description="是否在返回 JSON 中包含 base64 图片"),
|
||||||
|
det_conf: Optional[float] = Query(None, ge=0.0, le=1.0, description="YOLO 检测置信度阈值"),
|
||||||
|
iou_thres: Optional[float] = Query(None, ge=0.0, le=1.0, description="NMS IoU 阈值"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
图片识别接口。
|
||||||
|
|
||||||
|
无论 return_image 是否为 true,都会自动保存:
|
||||||
|
1. 打框图片;
|
||||||
|
2. JSON;
|
||||||
|
3. XML。
|
||||||
|
|
||||||
|
保存目录:
|
||||||
|
runs/images/
|
||||||
|
"""
|
||||||
|
if pipeline is None:
|
||||||
|
raise HTTPException(status_code=500, detail=f"模型未加载成功: {startup_error}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await file.read()
|
||||||
|
img_bgr = decode_image_from_bytes(data)
|
||||||
|
|
||||||
|
output = pipeline.predict(
|
||||||
|
img_bgr,
|
||||||
|
return_image=return_image,
|
||||||
|
det_conf=det_conf,
|
||||||
|
iou_thres=iou_thres,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_outputs = save_image_outputs(
|
||||||
|
img_bgr=img_bgr,
|
||||||
|
original_filename=file.filename or "input.jpg",
|
||||||
|
predict_output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
output["saved_outputs"] = saved_outputs
|
||||||
|
return output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/predict_video")
|
||||||
|
async def predict_video(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
sample_interval: Optional[int] = Query(None, ge=1, description="视频抽帧间隔,例如 2 表示每 2 帧处理 1 帧"),
|
||||||
|
save_annotated_video: bool = Query(True, description="是否保存画框后视频"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
视频识别接口。
|
||||||
|
|
||||||
|
自动保存:
|
||||||
|
1. 打框视频;
|
||||||
|
2. JSON;
|
||||||
|
3. XML;
|
||||||
|
4. 关键帧截图;
|
||||||
|
5. 原始视频副本。
|
||||||
|
|
||||||
|
保存目录:
|
||||||
|
runs/video/
|
||||||
|
"""
|
||||||
|
if pipeline is None:
|
||||||
|
raise HTTPException(status_code=500, detail=f"模型未加载成功: {startup_error}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
suffix = Path(file.filename or "input.mp4").suffix or ".mp4"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(await file.read())
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
summary = process_video_file(
|
||||||
|
video_path=tmp_path,
|
||||||
|
pipeline=pipeline,
|
||||||
|
original_filename=file.filename or "input.mp4",
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
save_annotated_video=save_annotated_video,
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
+794
@@ -0,0 +1,794 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from app.config import settings, BASE_DIR
|
||||||
|
from app.utils import read_classes, crop_with_padding, encode_image_to_base64
|
||||||
|
from app.inference.yolo_onnx import YOLOv5ONNXDetector
|
||||||
|
from app.inference.convnext_onnx import ConvNeXtONNXClassifier
|
||||||
|
from app.inference.density_onnx import DensityEstimatorONNX
|
||||||
|
|
||||||
|
|
||||||
|
class BirdCascadePipeline:
|
||||||
|
def __init__(self):
|
||||||
|
self.det_classes = []
|
||||||
|
self.cls_classes = []
|
||||||
|
self.detector = None
|
||||||
|
self.classifier = None
|
||||||
|
self.binary_dark_species_name = getattr(
|
||||||
|
settings,
|
||||||
|
"binary_dark_species_name",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
self.binary_light_species_name = getattr(
|
||||||
|
settings,
|
||||||
|
"binary_light_species_name",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
self.use_binary_species_rule = bool(
|
||||||
|
self.binary_dark_species_name
|
||||||
|
and self.binary_light_species_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.enable_detection:
|
||||||
|
self.det_classes = read_classes(
|
||||||
|
settings.det_classes_path,
|
||||||
|
keep_empty=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(settings.det_model_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"未找到 YOLOv5 模型: {settings.det_model_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.detector = YOLOv5ONNXDetector(
|
||||||
|
model_path=settings.det_model_path,
|
||||||
|
class_names=self.det_classes,
|
||||||
|
img_size=settings.det_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.enable_classification or self.use_binary_species_rule:
|
||||||
|
self.cls_classes = read_classes(
|
||||||
|
settings.cls_classes_path,
|
||||||
|
keep_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.enable_classification and not self.use_binary_species_rule:
|
||||||
|
if not os.path.exists(settings.cls_model_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"未找到 ConvNeXt 模型: {settings.cls_model_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.classifier = ConvNeXtONNXClassifier(
|
||||||
|
model_path=settings.cls_model_path,
|
||||||
|
class_names=self.cls_classes,
|
||||||
|
img_size=settings.cls_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.density_estimator = None
|
||||||
|
if settings.enable_density:
|
||||||
|
if not os.path.exists(settings.density_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到密度估计模型: {settings.density_model_path}")
|
||||||
|
|
||||||
|
density_input_size = None
|
||||||
|
if settings.density_img_size and settings.density_img_size > 0:
|
||||||
|
density_input_size = (settings.density_img_size, settings.density_img_size)
|
||||||
|
|
||||||
|
self.density_estimator = DensityEstimatorONNX(
|
||||||
|
model_path=settings.density_model_path,
|
||||||
|
input_size=density_input_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target_det_class_ids = settings.target_det_class_ids
|
||||||
|
self.species_name_remap = {}
|
||||||
|
remap_text = getattr(settings, "species_name_remap", "").replace(",", ",")
|
||||||
|
for rule in remap_text.split(","):
|
||||||
|
rule = rule.strip()
|
||||||
|
if not rule:
|
||||||
|
continue
|
||||||
|
if "=" not in rule:
|
||||||
|
raise ValueError(
|
||||||
|
"SPECIES_NAME_REMAP/--species-name-remap 格式错误: "
|
||||||
|
f"{rule!r},应为 原标签=新标签"
|
||||||
|
)
|
||||||
|
source_name, target_name = [
|
||||||
|
value.strip()
|
||||||
|
for value in rule.split("=", 1)
|
||||||
|
]
|
||||||
|
if not source_name or not target_name:
|
||||||
|
raise ValueError(
|
||||||
|
"物种标签映射的原标签和新标签都不能为空"
|
||||||
|
)
|
||||||
|
self.species_name_remap[source_name] = target_name
|
||||||
|
allowed_text = getattr(
|
||||||
|
settings,
|
||||||
|
"allowed_species_names",
|
||||||
|
"",
|
||||||
|
).replace(",", ",")
|
||||||
|
self.allowed_species_names = {
|
||||||
|
name.strip()
|
||||||
|
for name in allowed_text.split(",")
|
||||||
|
if name.strip()
|
||||||
|
}
|
||||||
|
self.font_path = self._find_chinese_font()
|
||||||
|
|
||||||
|
def _find_chinese_font(self) -> str:
|
||||||
|
candidates = [
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "NotoSansCJK-Regular.ttc"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "NotoSansSC-Regular.otf"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "SimHei.ttf"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "msyh.ttc"),
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"C:/Windows/Fonts/msyh.ttc",
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in candidates:
|
||||||
|
if os.path.exists(p):
|
||||||
|
print(f"[Font] 使用中文字体: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"未找到中文字体,请将 NotoSansCJK-Regular.ttc 放到 "
|
||||||
|
f"{os.path.join(BASE_DIR, 'assets', 'fonts')} 下。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_font_by_target_height(self, target_height: int) -> ImageFont.FreeTypeFont:
|
||||||
|
lo, hi = 8, 180
|
||||||
|
best_size = 28
|
||||||
|
sample_text = "绿头鸭"
|
||||||
|
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
font = ImageFont.truetype(self.font_path, mid)
|
||||||
|
bbox = font.getbbox(sample_text)
|
||||||
|
text_h = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
if text_h <= target_height:
|
||||||
|
best_size = mid
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
|
||||||
|
return ImageFont.truetype(self.font_path, best_size)
|
||||||
|
|
||||||
|
def _get_color_by_class_id(self, class_id: int) -> tuple[int, int, int]:
|
||||||
|
if settings.uniform_species_color:
|
||||||
|
return (0, 190, 0)
|
||||||
|
|
||||||
|
palette = [
|
||||||
|
(0, 190, 0),
|
||||||
|
(220, 60, 60),
|
||||||
|
(70, 130, 255),
|
||||||
|
(255, 160, 0),
|
||||||
|
(180, 80, 220),
|
||||||
|
(0, 180, 180),
|
||||||
|
(255, 80, 180),
|
||||||
|
(120, 180, 40),
|
||||||
|
(160, 120, 60),
|
||||||
|
(80, 80, 220),
|
||||||
|
(255, 210, 0),
|
||||||
|
(0, 120, 255),
|
||||||
|
(255, 90, 90),
|
||||||
|
(90, 220, 160),
|
||||||
|
(200, 80, 140),
|
||||||
|
]
|
||||||
|
|
||||||
|
if class_id < 0:
|
||||||
|
return (155, 155, 155)
|
||||||
|
|
||||||
|
return palette[class_id % len(palette)]
|
||||||
|
|
||||||
|
def _filter_target_detections(self, detections: List[Dict], img_w: int, img_h: int) -> List[Dict]:
|
||||||
|
filtered = []
|
||||||
|
img_area = float(img_w * img_h)
|
||||||
|
|
||||||
|
det_second_conf_thres = getattr(settings, "det_second_conf_thres", settings.det_conf_thres)
|
||||||
|
min_box_area_ratio = getattr(settings, "min_box_area_ratio", 0.00002)
|
||||||
|
max_box_area_ratio = getattr(settings, "max_box_area_ratio", 1.0)
|
||||||
|
max_box_aspect_ratio = getattr(settings, "max_box_aspect_ratio", 6.0)
|
||||||
|
roi_y_min = float(
|
||||||
|
getattr(settings, "detection_roi_y_min_ratio", 0.0)
|
||||||
|
)
|
||||||
|
roi_y_max = float(
|
||||||
|
getattr(settings, "detection_roi_y_max_ratio", 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
det_class_id = int(det.get("det_class_id", -1))
|
||||||
|
det_conf = float(det.get("det_confidence", 0.0))
|
||||||
|
|
||||||
|
if self.target_det_class_ids and det_class_id not in self.target_det_class_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if det_conf < det_second_conf_thres:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = [float(v) for v in det["bbox_xyxy"]]
|
||||||
|
box_w = max(0.0, x2 - x1)
|
||||||
|
box_h = max(0.0, y2 - y1)
|
||||||
|
|
||||||
|
if box_w <= 1 or box_h <= 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
area_ratio = (box_w * box_h) / img_area
|
||||||
|
|
||||||
|
if area_ratio < min_box_area_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if area_ratio > max_box_area_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
box_ratio = max(box_w / box_h, box_h / box_w)
|
||||||
|
if box_ratio > max_box_aspect_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
center_y_ratio = ((y1 + y2) * 0.5) / float(img_h)
|
||||||
|
if center_y_ratio < roi_y_min or center_y_ratio > roi_y_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(det)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def _draw_count_text(self, img_bgr: np.ndarray, count: int) -> np.ndarray:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
short_side = min(h, w)
|
||||||
|
|
||||||
|
target_text_height = int(short_side * 0.035)
|
||||||
|
target_text_height = max(30, min(target_text_height, 48))
|
||||||
|
|
||||||
|
font = self._get_font_by_target_height(target_text_height)
|
||||||
|
text = f"数量:{int(count)}"
|
||||||
|
|
||||||
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
pil_img = Image.fromarray(img_rgb)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=2)
|
||||||
|
text_h = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
margin_x = max(18, min(int(short_side * 0.018), 36))
|
||||||
|
margin_y = max(18, min(int(short_side * 0.018), 36))
|
||||||
|
|
||||||
|
x = margin_x
|
||||||
|
y = h - margin_y - text_h
|
||||||
|
|
||||||
|
draw.text(
|
||||||
|
(x, y),
|
||||||
|
text,
|
||||||
|
font=font,
|
||||||
|
fill=(255, 255, 255),
|
||||||
|
stroke_width=2,
|
||||||
|
stroke_fill=(0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def _draw_detection_results(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
results: List[Dict],
|
||||||
|
is_dense_scene: bool = False,
|
||||||
|
) -> np.ndarray:
|
||||||
|
out = img_bgr.copy()
|
||||||
|
h, w = out.shape[:2]
|
||||||
|
short_side = min(h, w)
|
||||||
|
|
||||||
|
box_thick = max(2, int(short_side * 0.0022))
|
||||||
|
|
||||||
|
# 密集场景中每只鸟仍显示自己的标签,因此字号保持紧凑,
|
||||||
|
# 避免标签底色遮住相邻目标。
|
||||||
|
label_target_height = int(short_side * 0.014)
|
||||||
|
label_target_height = max(14, min(label_target_height, 24))
|
||||||
|
label_font = self._get_font_by_target_height(label_target_height)
|
||||||
|
|
||||||
|
img_rgb = cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
|
||||||
|
pil_img = Image.fromarray(img_rgb)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
|
||||||
|
hide_in_dense = getattr(settings, "hide_cls_name_in_dense_scene", True)
|
||||||
|
for item in results:
|
||||||
|
x1, y1, x2, y2 = [int(round(v)) for v in item["bbox_xyxy"]]
|
||||||
|
|
||||||
|
x1 = max(0, min(x1, w - 1))
|
||||||
|
y1 = max(0, min(y1, h - 1))
|
||||||
|
x2 = max(0, min(x2, w - 1))
|
||||||
|
y2 = max(0, min(y2, h - 1))
|
||||||
|
|
||||||
|
cls_id = int(item.get("cls_class_id", -1))
|
||||||
|
cls_name = str(item.get("cls_class_name", "") or "").strip()
|
||||||
|
|
||||||
|
color = self._get_color_by_class_id(cls_id)
|
||||||
|
|
||||||
|
draw.rectangle(
|
||||||
|
[(x1, y1), (x2, y2)],
|
||||||
|
outline=color,
|
||||||
|
width=box_thick,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_dense_scene and hide_in_dense:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cls_name == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bool(item.get("show_cls_name", False)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = cls_name
|
||||||
|
|
||||||
|
text_bbox = draw.textbbox((0, 0), text, font=label_font, stroke_width=0)
|
||||||
|
text_w = text_bbox[2] - text_bbox[0]
|
||||||
|
text_h = text_bbox[3] - text_bbox[1]
|
||||||
|
|
||||||
|
pad_x = max(3, int(label_target_height * 0.18))
|
||||||
|
pad_y = max(2, int(label_target_height * 0.10))
|
||||||
|
|
||||||
|
label_width = text_w + 2 * pad_x
|
||||||
|
label_height = text_h + 2 * pad_y
|
||||||
|
|
||||||
|
tx = x1
|
||||||
|
ty = y1 - label_height
|
||||||
|
|
||||||
|
if ty < 0:
|
||||||
|
ty = y1
|
||||||
|
|
||||||
|
if tx + label_width > w:
|
||||||
|
tx = max(0, w - label_width)
|
||||||
|
|
||||||
|
draw.rectangle(
|
||||||
|
[
|
||||||
|
(tx, ty),
|
||||||
|
(tx + label_width, ty + label_height),
|
||||||
|
],
|
||||||
|
fill=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_x = tx + (label_width - text_w) // 2 - text_bbox[0]
|
||||||
|
text_y = ty + (label_height - text_h) // 2 - text_bbox[1]
|
||||||
|
|
||||||
|
draw.text(
|
||||||
|
(text_x, text_y),
|
||||||
|
text,
|
||||||
|
font=label_font,
|
||||||
|
fill=(0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def _draw_density_panel(self, img_bgr: np.ndarray, density_result: Dict | None) -> np.ndarray:
|
||||||
|
if density_result is None or "density_map" not in density_result:
|
||||||
|
return img_bgr
|
||||||
|
|
||||||
|
out = img_bgr.copy()
|
||||||
|
h, w = out.shape[:2]
|
||||||
|
short_side = min(h, w)
|
||||||
|
|
||||||
|
panel_w = int(w * 0.28)
|
||||||
|
panel_h = int(h * 0.24)
|
||||||
|
|
||||||
|
panel_w = max(300, min(panel_w, int(w * 0.32)))
|
||||||
|
panel_h = max(180, min(panel_h, int(h * 0.28)))
|
||||||
|
|
||||||
|
panel_w = min(panel_w, w - 40)
|
||||||
|
panel_h = min(panel_h, h - 40)
|
||||||
|
|
||||||
|
margin = max(18, min(int(short_side * 0.018), 36))
|
||||||
|
|
||||||
|
x1 = w - panel_w - margin
|
||||||
|
y1 = h - panel_h - margin
|
||||||
|
x2 = w - margin
|
||||||
|
y2 = h - margin
|
||||||
|
|
||||||
|
density_map = density_result["density_map"]
|
||||||
|
heatmap = DensityEstimatorONNX.colorize_density_map(density_map)
|
||||||
|
heatmap_small = cv2.resize(heatmap, (panel_w, panel_h))
|
||||||
|
|
||||||
|
roi = out[y1:y2, x1:x2]
|
||||||
|
blended = cv2.addWeighted(roi, 0.25, heatmap_small, 0.75, 0)
|
||||||
|
out[y1:y2, x1:x2] = blended
|
||||||
|
|
||||||
|
border_thick = max(2, int(short_side * 0.002))
|
||||||
|
cv2.rectangle(out, (x1, y1), (x2, y2), (255, 255, 255), border_thick)
|
||||||
|
|
||||||
|
count = int(round(float(density_result.get("count", 0.0))))
|
||||||
|
out = self._draw_count_text(out, count)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def draw_full_result(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
results: List[Dict],
|
||||||
|
density_result: Dict | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
count = 0
|
||||||
|
if density_result is not None:
|
||||||
|
count = int(round(float(density_result.get("count", 0.0))))
|
||||||
|
|
||||||
|
dense_scene_count_thres = getattr(settings, "dense_scene_count_thres", 50)
|
||||||
|
is_dense_scene = count >= dense_scene_count_thres
|
||||||
|
|
||||||
|
annotated = self._draw_detection_results(
|
||||||
|
img_bgr,
|
||||||
|
results,
|
||||||
|
is_dense_scene=is_dense_scene,
|
||||||
|
)
|
||||||
|
|
||||||
|
annotated = self._draw_density_panel(annotated, density_result)
|
||||||
|
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
def predict(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
return_image: bool = False,
|
||||||
|
det_conf: float | None = None,
|
||||||
|
iou_thres: float | None = None,
|
||||||
|
) -> Dict:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
|
||||||
|
det_conf = settings.det_conf_thres if det_conf is None else det_conf
|
||||||
|
iou_thres = settings.det_iou_thres if iou_thres is None else iou_thres
|
||||||
|
|
||||||
|
density_result = None
|
||||||
|
density_count = 0
|
||||||
|
|
||||||
|
if self.density_estimator is not None:
|
||||||
|
density_result = self.density_estimator.infer(img_bgr)
|
||||||
|
density_count = int(round(float(density_result.get("count", 0.0))))
|
||||||
|
|
||||||
|
dense_scene_count_thres = getattr(settings, "dense_scene_count_thres", 50)
|
||||||
|
is_dense_scene = density_count >= dense_scene_count_thres
|
||||||
|
|
||||||
|
detections = []
|
||||||
|
if self.detector is not None:
|
||||||
|
detections = self.detector.infer(
|
||||||
|
img_bgr,
|
||||||
|
conf_thres=det_conf,
|
||||||
|
iou_thres=iou_thres,
|
||||||
|
max_det=settings.max_detections,
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = self._filter_target_detections(
|
||||||
|
detections,
|
||||||
|
img_w=w,
|
||||||
|
img_h=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
results: List[Dict] = []
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
cls_id = -1
|
||||||
|
cls_name = ""
|
||||||
|
cls_conf = 0.0
|
||||||
|
|
||||||
|
if self.use_binary_species_rule:
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=0.0,
|
||||||
|
)
|
||||||
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||||
|
dark_pixel_threshold = int(
|
||||||
|
getattr(settings, "binary_dark_pixel_threshold", 85)
|
||||||
|
)
|
||||||
|
dark_ratio_threshold = float(
|
||||||
|
getattr(settings, "binary_dark_ratio_threshold", 0.12)
|
||||||
|
)
|
||||||
|
dark_ratio = float(
|
||||||
|
np.mean(gray < dark_pixel_threshold)
|
||||||
|
)
|
||||||
|
if dark_ratio >= dark_ratio_threshold:
|
||||||
|
cls_name = self.binary_dark_species_name
|
||||||
|
else:
|
||||||
|
cls_name = self.binary_light_species_name
|
||||||
|
if cls_name in self.cls_classes:
|
||||||
|
cls_id = self.cls_classes.index(cls_name)
|
||||||
|
cls_conf = 1.0
|
||||||
|
elif self.classifier is not None:
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=settings.crop_padding_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_result = self.classifier.infer(crop)
|
||||||
|
|
||||||
|
cls_id = int(cls_result["cls_class_id"])
|
||||||
|
cls_name = str(cls_result["cls_class_name"] or "").strip()
|
||||||
|
cls_conf = float(cls_result["cls_confidence"])
|
||||||
|
|
||||||
|
remap_min_confidence = float(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"species_name_remap_min_confidence",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
cls_name in self.species_name_remap
|
||||||
|
and cls_conf >= remap_min_confidence
|
||||||
|
):
|
||||||
|
cls_name = self.species_name_remap[cls_name]
|
||||||
|
if cls_name in self.cls_classes:
|
||||||
|
cls_id = self.cls_classes.index(cls_name)
|
||||||
|
cls_conf = 1.0
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.allowed_species_names
|
||||||
|
and cls_name not in self.allowed_species_names
|
||||||
|
):
|
||||||
|
cls_id = -1
|
||||||
|
cls_name = ""
|
||||||
|
cls_conf = 0.0
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = [float(v) for v in det["bbox_xyxy"]]
|
||||||
|
box_area_ratio = ((x2 - x1) * (y2 - y1)) / float(w * h)
|
||||||
|
|
||||||
|
dark_override_species_name = getattr(
|
||||||
|
settings,
|
||||||
|
"dark_override_species_name",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
if dark_override_species_name:
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=0.0,
|
||||||
|
)
|
||||||
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||||
|
dark_pixel_threshold = int(
|
||||||
|
getattr(settings, "dark_override_pixel_threshold", 80)
|
||||||
|
)
|
||||||
|
dark_ratio_threshold = float(
|
||||||
|
getattr(settings, "dark_override_ratio_threshold", 0.35)
|
||||||
|
)
|
||||||
|
min_dark_area = float(
|
||||||
|
getattr(settings, "dark_override_min_box_area_ratio", 0.0)
|
||||||
|
)
|
||||||
|
max_dark_area = float(
|
||||||
|
getattr(settings, "dark_override_max_box_area_ratio", 1.0)
|
||||||
|
)
|
||||||
|
dark_roi_y_min = float(
|
||||||
|
getattr(settings, "dark_override_roi_y_min_ratio", 0.0)
|
||||||
|
)
|
||||||
|
dark_roi_y_max = float(
|
||||||
|
getattr(settings, "dark_override_roi_y_max_ratio", 1.0)
|
||||||
|
)
|
||||||
|
center_y_ratio = ((y1 + y2) * 0.5) / float(h)
|
||||||
|
dark_ratio = float(np.mean(gray < dark_pixel_threshold))
|
||||||
|
if (
|
||||||
|
dark_ratio >= dark_ratio_threshold
|
||||||
|
and box_area_ratio >= min_dark_area
|
||||||
|
and box_area_ratio <= max_dark_area
|
||||||
|
and center_y_ratio >= dark_roi_y_min
|
||||||
|
and center_y_ratio <= dark_roi_y_max
|
||||||
|
):
|
||||||
|
dark_class_id = int(
|
||||||
|
getattr(settings, "dark_override_species_class_id", -1)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
dark_class_id < 0
|
||||||
|
and dark_override_species_name in self.cls_classes
|
||||||
|
):
|
||||||
|
dark_class_id = self.cls_classes.index(
|
||||||
|
dark_override_species_name
|
||||||
|
)
|
||||||
|
cls_id = dark_class_id
|
||||||
|
cls_name = dark_override_species_name
|
||||||
|
cls_conf = 1.0
|
||||||
|
|
||||||
|
forced_species_name = getattr(
|
||||||
|
settings,
|
||||||
|
"forced_species_name",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
forced_min_area = float(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"forced_species_min_box_area_ratio",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
forced_species_name
|
||||||
|
and box_area_ratio >= forced_min_area
|
||||||
|
):
|
||||||
|
forced_class_id = int(
|
||||||
|
getattr(settings, "forced_species_class_id", -1)
|
||||||
|
)
|
||||||
|
if forced_class_id < 0 and forced_species_name in self.cls_classes:
|
||||||
|
forced_class_id = self.cls_classes.index(forced_species_name)
|
||||||
|
cls_id = forced_class_id
|
||||||
|
cls_name = forced_species_name
|
||||||
|
cls_conf = 1.0
|
||||||
|
|
||||||
|
region_override_species_name = getattr(
|
||||||
|
settings,
|
||||||
|
"region_override_species_name",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
if region_override_species_name and cls_name:
|
||||||
|
region_y_min = float(
|
||||||
|
getattr(settings, "region_override_roi_y_min_ratio", 0.0)
|
||||||
|
)
|
||||||
|
region_y_max = float(
|
||||||
|
getattr(settings, "region_override_roi_y_max_ratio", 1.0)
|
||||||
|
)
|
||||||
|
center_y_ratio = ((y1 + y2) * 0.5) / float(h)
|
||||||
|
source_text = getattr(
|
||||||
|
settings,
|
||||||
|
"region_override_source_species_names",
|
||||||
|
"",
|
||||||
|
).replace(",", ",")
|
||||||
|
source_names = {
|
||||||
|
value.strip()
|
||||||
|
for value in source_text.split(",")
|
||||||
|
if value.strip()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
center_y_ratio >= region_y_min
|
||||||
|
and center_y_ratio <= region_y_max
|
||||||
|
and (not source_names or cls_name in source_names)
|
||||||
|
):
|
||||||
|
region_class_id = int(
|
||||||
|
getattr(settings, "region_override_species_class_id", -1)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
region_class_id < 0
|
||||||
|
and region_override_species_name in self.cls_classes
|
||||||
|
):
|
||||||
|
region_class_id = self.cls_classes.index(
|
||||||
|
region_override_species_name
|
||||||
|
)
|
||||||
|
cls_id = region_class_id
|
||||||
|
cls_name = region_override_species_name
|
||||||
|
cls_conf = 1.0
|
||||||
|
|
||||||
|
crane_names = {"白鹤", "白头鹤", "白枕鹤"}
|
||||||
|
if (
|
||||||
|
getattr(settings, "enable_crane_appearance_correction", False)
|
||||||
|
and cls_name in crane_names
|
||||||
|
):
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=0.0,
|
||||||
|
)
|
||||||
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||||
|
white_ratio = float(np.mean(gray > 168))
|
||||||
|
dark_ratio = float(np.mean(gray < 78))
|
||||||
|
crop_h, crop_w = gray.shape[:2]
|
||||||
|
lower_white_ratio = float(
|
||||||
|
np.mean(gray[crop_h // 2 :, :] > 168)
|
||||||
|
)
|
||||||
|
left_white_ratio = float(
|
||||||
|
np.mean(gray[:, : max(1, crop_w // 3)] > 168)
|
||||||
|
)
|
||||||
|
right_white_ratio = float(
|
||||||
|
np.mean(gray[:, max(0, crop_w * 2 // 3) :] > 168)
|
||||||
|
)
|
||||||
|
tail_white_ratio = max(
|
||||||
|
lower_white_ratio,
|
||||||
|
left_white_ratio,
|
||||||
|
right_white_ratio,
|
||||||
|
)
|
||||||
|
white_thres = float(
|
||||||
|
getattr(settings, "crane_white_ratio_threshold", 0.30)
|
||||||
|
)
|
||||||
|
dark_thres = float(
|
||||||
|
getattr(settings, "crane_dark_ratio_threshold", 0.28)
|
||||||
|
)
|
||||||
|
tail_white_thres = float(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"crane_tail_white_ratio_threshold",
|
||||||
|
0.16,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
corrected_name = ""
|
||||||
|
if white_ratio >= white_thres and dark_ratio < dark_thres:
|
||||||
|
corrected_name = "白鹤"
|
||||||
|
elif (
|
||||||
|
white_ratio < white_thres
|
||||||
|
and tail_white_ratio >= tail_white_thres
|
||||||
|
):
|
||||||
|
corrected_name = "白枕鹤"
|
||||||
|
elif dark_ratio >= dark_thres and white_ratio < white_thres:
|
||||||
|
corrected_name = "白头鹤"
|
||||||
|
elif not getattr(settings, "crane_uncertain_hide_label", True):
|
||||||
|
corrected_name = cls_name
|
||||||
|
|
||||||
|
if corrected_name:
|
||||||
|
cls_name = corrected_name
|
||||||
|
if cls_name in self.cls_classes:
|
||||||
|
cls_id = self.cls_classes.index(cls_name)
|
||||||
|
cls_conf = max(cls_conf, 0.90)
|
||||||
|
else:
|
||||||
|
cls_id = -1
|
||||||
|
cls_name = ""
|
||||||
|
cls_conf = 0.0
|
||||||
|
|
||||||
|
normal_show_thres = getattr(settings, "cls_show_conf_thres", 0.60)
|
||||||
|
large_box_area_ratio = getattr(settings, "large_box_area_ratio", 0.002)
|
||||||
|
large_box_show_thres = getattr(settings, "large_box_cls_show_conf_thres", 0.25)
|
||||||
|
show_large_always = getattr(settings, "show_large_box_name_always", True)
|
||||||
|
hide_in_dense = getattr(settings, "hide_cls_name_in_dense_scene", True)
|
||||||
|
generic_class_names = {
|
||||||
|
"鸟",
|
||||||
|
"bird",
|
||||||
|
"unknown",
|
||||||
|
"未知",
|
||||||
|
"未知鸟类",
|
||||||
|
}
|
||||||
|
|
||||||
|
is_large_box = box_area_ratio >= large_box_area_ratio
|
||||||
|
|
||||||
|
if is_dense_scene and hide_in_dense:
|
||||||
|
show_cls_name = False
|
||||||
|
elif cls_name == "" or cls_name.lower() in generic_class_names:
|
||||||
|
show_cls_name = False
|
||||||
|
elif is_large_box and show_large_always:
|
||||||
|
show_cls_name = True
|
||||||
|
else:
|
||||||
|
show_cls_name = (
|
||||||
|
cls_conf >= normal_show_thres
|
||||||
|
or
|
||||||
|
(is_large_box and cls_conf >= large_box_show_thres)
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
"bbox_xyxy": [round(float(v), 2) for v in det["bbox_xyxy"]],
|
||||||
|
"det_confidence": round(float(det["det_confidence"]), 6),
|
||||||
|
"det_class_id": int(det["det_class_id"]),
|
||||||
|
"det_class_name": str(det["det_class_name"]),
|
||||||
|
"cls_class_id": cls_id,
|
||||||
|
"cls_class_name": cls_name,
|
||||||
|
"cls_confidence": round(cls_conf, 6),
|
||||||
|
"box_area_ratio": round(float(box_area_ratio), 8),
|
||||||
|
"is_large_box": bool(is_large_box),
|
||||||
|
"is_dense_scene": bool(is_dense_scene),
|
||||||
|
"show_cls_name": bool(show_cls_name),
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append(merged)
|
||||||
|
|
||||||
|
density_json = None
|
||||||
|
|
||||||
|
if density_result is not None:
|
||||||
|
density_json = {
|
||||||
|
"count": int(round(float(density_result.get("count", 0.0)))),
|
||||||
|
"is_dense_scene": bool(is_dense_scene),
|
||||||
|
}
|
||||||
|
|
||||||
|
annotated_image_base64 = None
|
||||||
|
|
||||||
|
if return_image:
|
||||||
|
annotated = self.draw_full_result(
|
||||||
|
img_bgr,
|
||||||
|
results,
|
||||||
|
density_result,
|
||||||
|
)
|
||||||
|
annotated_image_base64 = encode_image_to_base64(annotated)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"image_width": w,
|
||||||
|
"image_height": h,
|
||||||
|
"num_objects": len(results),
|
||||||
|
"target_det_class_ids": self.target_det_class_ids,
|
||||||
|
"density": density_json,
|
||||||
|
"density_result": density_result,
|
||||||
|
"results": results,
|
||||||
|
"annotated_image_base64": annotated_image_base64,
|
||||||
|
"message": "ok",
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from app.config import settings, BASE_DIR
|
||||||
|
from app.utils import read_classes, crop_with_padding, draw_results, encode_image_to_base64
|
||||||
|
from app.inference.yolo_onnx import YOLOv5ONNXDetector
|
||||||
|
from app.inference.convnext_onnx import ConvNeXtONNXClassifier
|
||||||
|
from app.inference.density_onnx import DensityEstimatorONNX
|
||||||
|
|
||||||
|
|
||||||
|
class BirdCascadePipeline:
|
||||||
|
def __init__(self):
|
||||||
|
self.det_classes = read_classes(settings.det_classes_path, keep_empty=False)
|
||||||
|
self.cls_classes = read_classes(settings.cls_classes_path, keep_empty=True)
|
||||||
|
|
||||||
|
if not os.path.exists(settings.det_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到 YOLOv5 模型: {settings.det_model_path}")
|
||||||
|
|
||||||
|
if not os.path.exists(settings.cls_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到 ConvNeXt 模型: {settings.cls_model_path}")
|
||||||
|
|
||||||
|
self.detector = YOLOv5ONNXDetector(
|
||||||
|
model_path=settings.det_model_path,
|
||||||
|
class_names=self.det_classes,
|
||||||
|
img_size=settings.det_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.classifier = ConvNeXtONNXClassifier(
|
||||||
|
model_path=settings.cls_model_path,
|
||||||
|
class_names=self.cls_classes,
|
||||||
|
img_size=settings.cls_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.density_estimator = None
|
||||||
|
if settings.enable_density:
|
||||||
|
if not os.path.exists(settings.density_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到密度估计模型: {settings.density_model_path}")
|
||||||
|
|
||||||
|
density_input_size = None
|
||||||
|
if settings.density_img_size and settings.density_img_size > 0:
|
||||||
|
density_input_size = (settings.density_img_size, settings.density_img_size)
|
||||||
|
|
||||||
|
self.density_estimator = DensityEstimatorONNX(
|
||||||
|
model_path=settings.density_model_path,
|
||||||
|
input_size=density_input_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target_det_class_ids = settings.target_det_class_ids
|
||||||
|
self.font_path = self._find_chinese_font()
|
||||||
|
|
||||||
|
def _find_chinese_font(self) -> str:
|
||||||
|
candidates = [
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "NotoSansCJK-Regular.ttc"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "NotoSansSC-Regular.otf"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "SimHei.ttf"),
|
||||||
|
os.path.join(BASE_DIR, "assets", "fonts", "msyh.ttc"),
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
"C:/Windows/Fonts/msyh.ttc",
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in candidates:
|
||||||
|
if os.path.exists(p):
|
||||||
|
print(f"[Font] 使用中文字体: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"未找到中文字体。请将 NotoSansCJK-Regular.ttc 放到 "
|
||||||
|
f"{os.path.join(BASE_DIR, 'assets', 'fonts')} 下。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_font_by_target_height(self, target_height: int) -> ImageFont.FreeTypeFont:
|
||||||
|
"""
|
||||||
|
用目标像素高度反推字号,避免不同图片字体视觉大小不一致。
|
||||||
|
"""
|
||||||
|
lo, hi = 8, 160
|
||||||
|
best_size = 28
|
||||||
|
sample_text = "数量:888"
|
||||||
|
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
font = ImageFont.truetype(self.font_path, mid)
|
||||||
|
bbox = font.getbbox(sample_text)
|
||||||
|
text_h = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
if text_h <= target_height:
|
||||||
|
best_size = mid
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
|
||||||
|
return ImageFont.truetype(self.font_path, best_size)
|
||||||
|
|
||||||
|
def _draw_count_text(self, img_bgr: np.ndarray, count: int) -> np.ndarray:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
short_side = min(h, w)
|
||||||
|
|
||||||
|
# 关键:不要直接用 font_size,而是控制最终“字高”
|
||||||
|
# 0.035 比 0.06 稳定很多;同时设置上下限,避免极端图过大或过小。
|
||||||
|
target_text_height = int(short_side * 0.035)
|
||||||
|
target_text_height = max(30, min(target_text_height, 48))
|
||||||
|
|
||||||
|
font = self._get_font_by_target_height(target_text_height)
|
||||||
|
|
||||||
|
text = f"数量:{int(count)}"
|
||||||
|
|
||||||
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
pil_img = Image.fromarray(img_rgb)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=2)
|
||||||
|
text_h = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
margin_x = int(short_side * 0.018)
|
||||||
|
margin_y = int(short_side * 0.018)
|
||||||
|
|
||||||
|
margin_x = max(18, min(margin_x, 36))
|
||||||
|
margin_y = max(18, min(margin_y, 36))
|
||||||
|
|
||||||
|
x = margin_x
|
||||||
|
y = h - margin_y - text_h
|
||||||
|
|
||||||
|
draw.text(
|
||||||
|
(x, y),
|
||||||
|
text,
|
||||||
|
font=font,
|
||||||
|
fill=(255, 255, 255),
|
||||||
|
stroke_width=2,
|
||||||
|
stroke_fill=(0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def _filter_target_detections(self, detections: List[Dict], img_w: int, img_h: int) -> List[Dict]:
|
||||||
|
filtered = []
|
||||||
|
img_area = float(img_w * img_h)
|
||||||
|
|
||||||
|
det_second_conf_thres = getattr(settings, "det_second_conf_thres", settings.det_conf_thres)
|
||||||
|
min_box_area_ratio = getattr(settings, "min_box_area_ratio", 0.00002)
|
||||||
|
max_box_area_ratio = getattr(settings, "max_box_area_ratio", 0.08)
|
||||||
|
max_box_aspect_ratio = getattr(settings, "max_box_aspect_ratio", 6.0)
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
det_class_id = int(det.get("det_class_id", -1))
|
||||||
|
det_conf = float(det.get("det_confidence", 0.0))
|
||||||
|
|
||||||
|
if self.target_det_class_ids and det_class_id not in self.target_det_class_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if det_conf < det_second_conf_thres:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = [float(v) for v in det["bbox_xyxy"]]
|
||||||
|
box_w = max(0.0, x2 - x1)
|
||||||
|
box_h = max(0.0, y2 - y1)
|
||||||
|
|
||||||
|
if box_w <= 1 or box_h <= 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
box_area = box_w * box_h
|
||||||
|
area_ratio = box_area / img_area
|
||||||
|
|
||||||
|
if area_ratio < min_box_area_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if area_ratio > max_box_area_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
box_ratio = max(box_w / box_h, box_h / box_w)
|
||||||
|
if box_ratio > max_box_aspect_ratio:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(det)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def _draw_density_panel(self, img_bgr: np.ndarray, density_result: Dict | None) -> np.ndarray:
|
||||||
|
if density_result is None or "density_map" not in density_result:
|
||||||
|
return img_bgr
|
||||||
|
|
||||||
|
out = img_bgr.copy()
|
||||||
|
h, w = out.shape[:2]
|
||||||
|
short_side = min(h, w)
|
||||||
|
|
||||||
|
panel_w = int(w * 0.28)
|
||||||
|
panel_h = int(h * 0.24)
|
||||||
|
|
||||||
|
panel_w = max(300, min(panel_w, int(w * 0.32)))
|
||||||
|
panel_h = max(180, min(panel_h, int(h * 0.28)))
|
||||||
|
|
||||||
|
panel_w = min(panel_w, w - 40)
|
||||||
|
panel_h = min(panel_h, h - 40)
|
||||||
|
|
||||||
|
margin = int(short_side * 0.018)
|
||||||
|
margin = max(18, min(margin, 36))
|
||||||
|
|
||||||
|
x1 = w - panel_w - margin
|
||||||
|
y1 = h - panel_h - margin
|
||||||
|
x2 = w - margin
|
||||||
|
y2 = h - margin
|
||||||
|
|
||||||
|
density_map = density_result["density_map"]
|
||||||
|
heatmap = DensityEstimatorONNX.colorize_density_map(density_map)
|
||||||
|
heatmap_small = cv2.resize(heatmap, (panel_w, panel_h))
|
||||||
|
|
||||||
|
roi = out[y1:y2, x1:x2]
|
||||||
|
blended = cv2.addWeighted(roi, 0.25, heatmap_small, 0.75, 0)
|
||||||
|
out[y1:y2, x1:x2] = blended
|
||||||
|
|
||||||
|
border_thick = max(2, int(short_side * 0.002))
|
||||||
|
cv2.rectangle(out, (x1, y1), (x2, y2), (255, 255, 255), border_thick)
|
||||||
|
|
||||||
|
count = int(round(float(density_result.get("count", 0.0))))
|
||||||
|
out = self._draw_count_text(out, count)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def draw_full_result(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
results: List[Dict],
|
||||||
|
density_result: Dict | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
annotated = draw_results(img_bgr, results)
|
||||||
|
annotated = self._draw_density_panel(annotated, density_result)
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
def predict(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
return_image: bool = False,
|
||||||
|
det_conf: float | None = None,
|
||||||
|
iou_thres: float | None = None,
|
||||||
|
) -> Dict:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
|
||||||
|
det_conf = settings.det_conf_thres if det_conf is None else det_conf
|
||||||
|
iou_thres = settings.det_iou_thres if iou_thres is None else iou_thres
|
||||||
|
|
||||||
|
detections = self.detector.infer(
|
||||||
|
img_bgr,
|
||||||
|
conf_thres=det_conf,
|
||||||
|
iou_thres=iou_thres,
|
||||||
|
max_det=settings.max_detections,
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = self._filter_target_detections(detections, img_w=w, img_h=h)
|
||||||
|
|
||||||
|
results: List[Dict] = []
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=settings.crop_padding_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_result = self.classifier.infer(crop)
|
||||||
|
|
||||||
|
cls_id = int(cls_result["cls_class_id"])
|
||||||
|
cls_name = str(cls_result["cls_class_name"] or "")
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
"bbox_xyxy": [round(float(v), 2) for v in det["bbox_xyxy"]],
|
||||||
|
"det_confidence": round(float(det["det_confidence"]), 6),
|
||||||
|
"det_class_id": int(det["det_class_id"]),
|
||||||
|
"det_class_name": str(det["det_class_name"]),
|
||||||
|
"cls_class_id": cls_id,
|
||||||
|
"cls_class_name": cls_name,
|
||||||
|
"cls_confidence": round(float(cls_result["cls_confidence"]), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append(merged)
|
||||||
|
|
||||||
|
density_result = None
|
||||||
|
if self.density_estimator is not None:
|
||||||
|
density_result = self.density_estimator.infer(img_bgr)
|
||||||
|
|
||||||
|
density_json = None
|
||||||
|
if density_result is not None:
|
||||||
|
density_json = {
|
||||||
|
"count": int(round(float(density_result.get("count", 0.0)))),
|
||||||
|
}
|
||||||
|
|
||||||
|
annotated_image_base64 = None
|
||||||
|
if return_image:
|
||||||
|
annotated = self.draw_full_result(img_bgr, results, density_result)
|
||||||
|
annotated_image_base64 = encode_image_to_base64(annotated)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"image_width": w,
|
||||||
|
"image_height": h,
|
||||||
|
"num_objects": len(results),
|
||||||
|
"target_det_class_ids": self.target_det_class_ids,
|
||||||
|
"density": density_json,
|
||||||
|
"density_result": density_result,
|
||||||
|
"results": results,
|
||||||
|
"annotated_image_base64": annotated_image_base64,
|
||||||
|
"message": "ok",
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.utils import read_classes, crop_with_padding, draw_results, encode_image_to_base64
|
||||||
|
from app.inference.yolo_onnx import YOLOv5ONNXDetector
|
||||||
|
from app.inference.convnext_onnx import ConvNeXtONNXClassifier
|
||||||
|
|
||||||
|
|
||||||
|
class BirdCascadePipeline:
|
||||||
|
def __init__(self):
|
||||||
|
self.det_classes = read_classes(settings.det_classes_path, keep_empty=False)
|
||||||
|
|
||||||
|
# 分类类别文件必须保留空项,因为 ;; 表示该类别不显示
|
||||||
|
self.cls_classes = read_classes(settings.cls_classes_path, keep_empty=True)
|
||||||
|
|
||||||
|
if not os.path.exists(settings.det_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到 YOLOv5 模型: {settings.det_model_path}")
|
||||||
|
|
||||||
|
if not os.path.exists(settings.cls_model_path):
|
||||||
|
raise FileNotFoundError(f"未找到 ConvNeXt 模型: {settings.cls_model_path}")
|
||||||
|
|
||||||
|
self.detector = YOLOv5ONNXDetector(
|
||||||
|
model_path=settings.det_model_path,
|
||||||
|
class_names=self.det_classes,
|
||||||
|
img_size=settings.det_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.classifier = ConvNeXtONNXClassifier(
|
||||||
|
model_path=settings.cls_model_path,
|
||||||
|
class_names=self.cls_classes,
|
||||||
|
img_size=settings.cls_img_size,
|
||||||
|
device=settings.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target_det_class_ids = settings.target_det_class_ids
|
||||||
|
|
||||||
|
def _filter_target_detections(self, detections: List[Dict]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
只保留 YOLO 检测类别为 0 的目标,也就是“鸟”。
|
||||||
|
"""
|
||||||
|
if self.target_det_class_ids is None or len(self.target_det_class_ids) == 0:
|
||||||
|
return detections
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for det in detections:
|
||||||
|
det_class_id = int(det.get("det_class_id", -1))
|
||||||
|
if det_class_id in self.target_det_class_ids:
|
||||||
|
filtered.append(det)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def predict(
|
||||||
|
self,
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
return_image: bool = False,
|
||||||
|
det_conf: float | None = None,
|
||||||
|
iou_thres: float | None = None,
|
||||||
|
) -> Dict:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
|
||||||
|
det_conf = settings.det_conf_thres if det_conf is None else det_conf
|
||||||
|
iou_thres = settings.det_iou_thres if iou_thres is None else iou_thres
|
||||||
|
|
||||||
|
detections = self.detector.infer(
|
||||||
|
img_bgr,
|
||||||
|
conf_thres=det_conf,
|
||||||
|
iou_thres=iou_thres,
|
||||||
|
max_det=settings.max_detections,
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = self._filter_target_detections(detections)
|
||||||
|
|
||||||
|
results: List[Dict] = []
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
crop = crop_with_padding(
|
||||||
|
img_bgr,
|
||||||
|
det["bbox_xyxy"],
|
||||||
|
padding_ratio=settings.crop_padding_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_result = self.classifier.infer(crop)
|
||||||
|
|
||||||
|
cls_id = int(cls_result["cls_class_id"])
|
||||||
|
cls_name = str(cls_result["cls_class_name"] or "")
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
"bbox_xyxy": [round(float(v), 2) for v in det["bbox_xyxy"]],
|
||||||
|
"det_confidence": round(float(det["det_confidence"]), 6),
|
||||||
|
"det_class_id": int(det["det_class_id"]),
|
||||||
|
"det_class_name": str(det["det_class_name"]),
|
||||||
|
"cls_class_id": cls_id,
|
||||||
|
"cls_class_name": cls_name,
|
||||||
|
"cls_confidence": round(float(cls_result["cls_confidence"]), 6),
|
||||||
|
}
|
||||||
|
results.append(merged)
|
||||||
|
|
||||||
|
annotated_image_base64 = None
|
||||||
|
if return_image:
|
||||||
|
annotated = draw_results(img_bgr, results)
|
||||||
|
annotated_image_base64 = encode_image_to_base64(annotated)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"image_width": w,
|
||||||
|
"image_height": h,
|
||||||
|
"num_objects": len(results),
|
||||||
|
"target_det_class_ids": self.target_det_class_ids,
|
||||||
|
"results": results,
|
||||||
|
"annotated_image_base64": annotated_image_base64,
|
||||||
|
"message": "ok",
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectResult(BaseModel):
|
||||||
|
bbox_xyxy: List[float]
|
||||||
|
det_confidence: float
|
||||||
|
det_class_id: int
|
||||||
|
det_class_name: str
|
||||||
|
cls_class_id: int
|
||||||
|
cls_class_name: str
|
||||||
|
cls_confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
class PredictResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
image_width: int
|
||||||
|
image_height: int
|
||||||
|
num_objects: int
|
||||||
|
results: List[ObjectResult]
|
||||||
|
annotated_image_base64: Optional[str] = None
|
||||||
|
saved_outputs: Optional[Dict[str, Any]] = None
|
||||||
|
message: str = "ok"
|
||||||
+451
@@ -0,0 +1,451 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def read_classes(path: str, keep_empty: bool = False) -> List[str]:
|
||||||
|
"""
|
||||||
|
支持两种类别文件格式:
|
||||||
|
1. 每行一个类别;
|
||||||
|
2. 用英文分号 ; 分隔类别。
|
||||||
|
|
||||||
|
keep_empty=True 时,会保留空类别。
|
||||||
|
你的分类文件里有 ;;,表示该类别不显示,所以分类类别必须保留空项。
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
text = p.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
if ";" in text:
|
||||||
|
parts = text.split(";")
|
||||||
|
if keep_empty:
|
||||||
|
return [x.strip() for x in parts]
|
||||||
|
return [x.strip() for x in parts if x.strip()]
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
if keep_empty:
|
||||||
|
return [x.strip() for x in lines]
|
||||||
|
return [x.strip() for x in lines if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_image_from_bytes(data: bytes) -> np.ndarray:
|
||||||
|
arr = np.frombuffer(data, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("图片解码失败,请确认上传的是有效图片文件")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def encode_image_to_base64(img_bgr: np.ndarray) -> str:
|
||||||
|
ok, buf = cv2.imencode(".jpg", img_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
|
||||||
|
if not ok:
|
||||||
|
raise ValueError("图片编码失败")
|
||||||
|
return base64.b64encode(buf.tobytes()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def clip_box(box, width: int, height: int):
|
||||||
|
x1, y1, x2, y2 = box
|
||||||
|
x1 = max(0, min(float(x1), width - 1))
|
||||||
|
y1 = max(0, min(float(y1), height - 1))
|
||||||
|
x2 = max(0, min(float(x2), width - 1))
|
||||||
|
y2 = max(0, min(float(y2), height - 1))
|
||||||
|
return [x1, y1, x2, y2]
|
||||||
|
|
||||||
|
|
||||||
|
def crop_with_padding(img_bgr: np.ndarray, box, padding_ratio: float = 0.08) -> np.ndarray:
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
x1, y1, x2, y2 = clip_box(box, w, h)
|
||||||
|
|
||||||
|
bw = x2 - x1
|
||||||
|
bh = y2 - y1
|
||||||
|
pad_x = bw * padding_ratio
|
||||||
|
pad_y = bh * padding_ratio
|
||||||
|
|
||||||
|
nx1 = int(max(0, x1 - pad_x))
|
||||||
|
ny1 = int(max(0, y1 - pad_y))
|
||||||
|
nx2 = int(min(w, x2 + pad_x))
|
||||||
|
ny2 = int(min(h, y2 + pad_y))
|
||||||
|
|
||||||
|
if nx2 <= nx1 or ny2 <= ny1:
|
||||||
|
return img_bgr.copy()
|
||||||
|
|
||||||
|
return img_bgr[ny1:ny2, nx1:nx2].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def stable_color_for_class(class_id: int, class_name: str = "") -> tuple[int, int, int]:
|
||||||
|
"""
|
||||||
|
根据类别 ID 和类别名生成稳定颜色。
|
||||||
|
同一物种每次运行颜色一致。
|
||||||
|
OpenCV 使用 BGR 顺序。
|
||||||
|
"""
|
||||||
|
key = f"{class_id}_{class_name}".encode("utf-8")
|
||||||
|
digest = hashlib.md5(key).hexdigest()
|
||||||
|
|
||||||
|
b = int(digest[0:2], 16)
|
||||||
|
g = int(digest[2:4], 16)
|
||||||
|
r = int(digest[4:6], 16)
|
||||||
|
|
||||||
|
# 避免颜色太暗
|
||||||
|
b = 80 + b % 176
|
||||||
|
g = 80 + g % 176
|
||||||
|
r = 80 + r % 176
|
||||||
|
|
||||||
|
return b, g, r
|
||||||
|
|
||||||
|
|
||||||
|
def bgr_to_rgb(color_bgr: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||||
|
b, g, r = color_bgr
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_label(item: Dict) -> str:
|
||||||
|
"""
|
||||||
|
可视化时只显示物种中文名。
|
||||||
|
如果分类文件中该类别是空字符串,则不显示文字。
|
||||||
|
"""
|
||||||
|
name = str(item.get("cls_class_name", "") or "").strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def find_chinese_font() -> str | None:
|
||||||
|
"""
|
||||||
|
自动寻找中文字体。
|
||||||
|
可优先通过环境变量 FONT_PATH 指定。
|
||||||
|
"""
|
||||||
|
env_font = os.getenv("FONT_PATH", "").strip()
|
||||||
|
if env_font and Path(env_font).exists():
|
||||||
|
return env_font
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
"/data2/zhangw_data/Bird_detect_cascade_service/fonts/NotoSansCJK-Regular.ttc",
|
||||||
|
"/data2/zhangw_data/Bird_detect_cascade_service/fonts/NotoSansCJK-Regular.otf",
|
||||||
|
"/data2/zhangw_data/Bird_detect_cascade_service/fonts/SourceHanSansSC-Regular.otf",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.otf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/arphic/uming.ttc",
|
||||||
|
"/usr/share/fonts/truetype/arphic/ukai.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in candidates:
|
||||||
|
if Path(p).exists():
|
||||||
|
return p
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_FONT_CACHE: dict[int, ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_font(font_size: int = 26):
|
||||||
|
"""
|
||||||
|
获取支持中文的字体。
|
||||||
|
如果找不到中文字体,会退回默认字体,但默认字体仍可能无法显示中文。
|
||||||
|
"""
|
||||||
|
if font_size in _FONT_CACHE:
|
||||||
|
return _FONT_CACHE[font_size]
|
||||||
|
|
||||||
|
font_path = find_chinese_font()
|
||||||
|
|
||||||
|
if font_path:
|
||||||
|
font = ImageFont.truetype(font_path, font_size)
|
||||||
|
else:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
_FONT_CACHE[font_size] = font
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def draw_chinese_text_with_background(
|
||||||
|
img_bgr: np.ndarray,
|
||||||
|
text: str,
|
||||||
|
left: int,
|
||||||
|
top: int,
|
||||||
|
color_bgr: tuple[int, int, int],
|
||||||
|
font_size: int = 26,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
使用 PIL 绘制中文标签。
|
||||||
|
输入输出都是 OpenCV BGR 图像。
|
||||||
|
|
||||||
|
这个版本解决中文文字在色块中偏下的问题:
|
||||||
|
1. 先计算文字真实边界;
|
||||||
|
2. 再根据背景框中心点反推文字绘制位置;
|
||||||
|
3. 保证中文视觉上垂直居中。
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return img_bgr
|
||||||
|
|
||||||
|
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
pil_img = Image.fromarray(img_rgb)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
|
||||||
|
font = get_font(font_size)
|
||||||
|
color_rgb = bgr_to_rgb(color_bgr)
|
||||||
|
|
||||||
|
# 计算文字真实边界。注意:中文字体 bbox 的 y 方向通常不是从 0 开始。
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_left = bbox[0]
|
||||||
|
text_top = bbox[1]
|
||||||
|
text_right = bbox[2]
|
||||||
|
text_bottom = bbox[3]
|
||||||
|
|
||||||
|
text_w = text_right - text_left
|
||||||
|
text_h = text_bottom - text_top
|
||||||
|
|
||||||
|
pad_x = 6
|
||||||
|
pad_y = 4
|
||||||
|
|
||||||
|
img_w, img_h = pil_img.size
|
||||||
|
|
||||||
|
x1 = max(0, min(int(left), img_w - 1))
|
||||||
|
y1 = max(0, min(int(top), img_h - 1))
|
||||||
|
|
||||||
|
bg_w = text_w + 2 * pad_x
|
||||||
|
bg_h = text_h + 2 * pad_y
|
||||||
|
|
||||||
|
# 默认把标签放在检测框上方;如果上方空间不够,就放在框内部顶端。
|
||||||
|
bg_left = x1
|
||||||
|
if y1 - bg_h >= 0:
|
||||||
|
bg_top = y1 - bg_h
|
||||||
|
else:
|
||||||
|
bg_top = y1
|
||||||
|
|
||||||
|
bg_right = min(img_w - 1, bg_left + bg_w)
|
||||||
|
bg_bottom = min(img_h - 1, bg_top + bg_h)
|
||||||
|
|
||||||
|
# 画背景色块
|
||||||
|
draw.rectangle(
|
||||||
|
[bg_left, bg_top, bg_right, bg_bottom],
|
||||||
|
fill=color_rgb,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关键修正:
|
||||||
|
# 让文字真实 bbox 的中心对齐背景框中心,而不是简单 bg_top + pad_y。
|
||||||
|
bg_center_x = (bg_left + bg_right) / 2
|
||||||
|
bg_center_y = (bg_top + bg_bottom) / 2
|
||||||
|
|
||||||
|
text_draw_x = bg_center_x - text_w / 2 - text_left
|
||||||
|
text_draw_y = bg_center_y - text_h / 2 - text_top
|
||||||
|
|
||||||
|
draw.text(
|
||||||
|
(text_draw_x, text_draw_y),
|
||||||
|
text,
|
||||||
|
font=font,
|
||||||
|
fill=(0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_rgb = np.array(pil_img)
|
||||||
|
out_bgr = cv2.cvtColor(out_rgb, cv2.COLOR_RGB2BGR)
|
||||||
|
return out_bgr
|
||||||
|
|
||||||
|
def draw_results(img_bgr: np.ndarray, results: List[dict]) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
只画类别名,不画置信度。
|
||||||
|
中文文字使用 PIL 绘制,避免 OpenCV 中文乱码。
|
||||||
|
空类别名只画框,不显示文字。
|
||||||
|
"""
|
||||||
|
out = img_bgr.copy()
|
||||||
|
|
||||||
|
for item in results:
|
||||||
|
x1, y1, x2, y2 = [int(round(v)) for v in item["bbox_xyxy"]]
|
||||||
|
|
||||||
|
cls_id = int(item.get("cls_class_id", -1))
|
||||||
|
cls_name = str(item.get("cls_class_name", "") or "")
|
||||||
|
label = get_display_label(item)
|
||||||
|
|
||||||
|
if settings.uniform_species_color:
|
||||||
|
color = (0, 190, 0)
|
||||||
|
else:
|
||||||
|
color = stable_color_for_class(cls_id, cls_name)
|
||||||
|
|
||||||
|
# 先用 OpenCV 画框
|
||||||
|
cv2.rectangle(out, (x1, y1), (x2, y2), color, 2)
|
||||||
|
|
||||||
|
# 再用 PIL 画中文
|
||||||
|
if label:
|
||||||
|
# 根据图像大小自适应字体大小,避免无人机大图上文字太小
|
||||||
|
h, w = out.shape[:2]
|
||||||
|
font_size = max(22, int(min(w, h) * 0.018))
|
||||||
|
font_size = min(font_size, 42)
|
||||||
|
|
||||||
|
out = draw_chinese_text_with_background(
|
||||||
|
img_bgr=out,
|
||||||
|
text=label,
|
||||||
|
left=x1,
|
||||||
|
top=y1,
|
||||||
|
color_bgr=color,
|
||||||
|
font_size=font_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def now_timestamp_minute() -> str:
|
||||||
|
return time.strftime("%Y%m%d%H%M")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_stem(filename: str) -> str:
|
||||||
|
name = Path(filename).stem
|
||||||
|
name = name.replace(" ", "_")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def make_unique_base(output_dir: Path, timestamp: str, original_filename: str) -> str:
|
||||||
|
"""
|
||||||
|
生成 时间戳_原文件名。
|
||||||
|
如果同一分钟内重复处理同一个文件,则自动加 _01、_02。
|
||||||
|
"""
|
||||||
|
stem = safe_stem(original_filename)
|
||||||
|
base = f"{timestamp}_{stem}"
|
||||||
|
|
||||||
|
candidate = base
|
||||||
|
idx = 1
|
||||||
|
while (
|
||||||
|
(output_dir / f"{candidate}.json").exists()
|
||||||
|
or (output_dir / f"{candidate}.xml").exists()
|
||||||
|
or (output_dir / f"{candidate}.jpg").exists()
|
||||||
|
or (output_dir / f"{candidate}.mp4").exists()
|
||||||
|
):
|
||||||
|
candidate = f"{base}_{idx:02d}"
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, data: Dict):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def indent_xml(elem, level: int = 0):
|
||||||
|
i = "\n" + level * " "
|
||||||
|
if len(elem):
|
||||||
|
if not elem.text or not elem.text.strip():
|
||||||
|
elem.text = i + " "
|
||||||
|
for child in elem:
|
||||||
|
indent_xml(child, level + 1)
|
||||||
|
if not child.tail or not child.tail.strip():
|
||||||
|
child.tail = i
|
||||||
|
if level and (not elem.tail or not elem.tail.strip()):
|
||||||
|
elem.tail = i
|
||||||
|
|
||||||
|
|
||||||
|
def write_image_xml(
|
||||||
|
xml_path: Path,
|
||||||
|
image_filename: str,
|
||||||
|
image_path: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
results: List[Dict],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
图片 XML,接近 Pascal VOC 格式。
|
||||||
|
空类别名为了避免 XML name 为空,会写成 class_<id>。
|
||||||
|
"""
|
||||||
|
root = ET.Element("annotation")
|
||||||
|
|
||||||
|
ET.SubElement(root, "filename").text = image_filename
|
||||||
|
ET.SubElement(root, "path").text = image_path
|
||||||
|
|
||||||
|
size = ET.SubElement(root, "size")
|
||||||
|
ET.SubElement(size, "width").text = str(width)
|
||||||
|
ET.SubElement(size, "height").text = str(height)
|
||||||
|
ET.SubElement(size, "depth").text = "3"
|
||||||
|
|
||||||
|
for item in results:
|
||||||
|
obj = ET.SubElement(root, "object")
|
||||||
|
|
||||||
|
cls_id = int(item.get("cls_class_id", -1))
|
||||||
|
cls_name = str(item.get("cls_class_name", "") or "").strip()
|
||||||
|
xml_name = cls_name if cls_name else f"class_{cls_id}"
|
||||||
|
|
||||||
|
ET.SubElement(obj, "name").text = xml_name
|
||||||
|
ET.SubElement(obj, "class_id").text = str(cls_id)
|
||||||
|
|
||||||
|
box = item["bbox_xyxy"]
|
||||||
|
bndbox = ET.SubElement(obj, "bndbox")
|
||||||
|
ET.SubElement(bndbox, "xmin").text = str(int(round(box[0])))
|
||||||
|
ET.SubElement(bndbox, "ymin").text = str(int(round(box[1])))
|
||||||
|
ET.SubElement(bndbox, "xmax").text = str(int(round(box[2])))
|
||||||
|
ET.SubElement(bndbox, "ymax").text = str(int(round(box[3])))
|
||||||
|
|
||||||
|
indent_xml(root)
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_video_xml(
|
||||||
|
xml_path: Path,
|
||||||
|
video_filename: str,
|
||||||
|
video_path: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
fps: float,
|
||||||
|
total_frames: int,
|
||||||
|
sample_interval: int,
|
||||||
|
frame_results: List[Dict],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
视频 XML:记录每个处理帧上的目标框。
|
||||||
|
"""
|
||||||
|
root = ET.Element("video_annotation")
|
||||||
|
|
||||||
|
ET.SubElement(root, "filename").text = video_filename
|
||||||
|
ET.SubElement(root, "path").text = video_path
|
||||||
|
|
||||||
|
info = ET.SubElement(root, "video_info")
|
||||||
|
ET.SubElement(info, "width").text = str(width)
|
||||||
|
ET.SubElement(info, "height").text = str(height)
|
||||||
|
ET.SubElement(info, "fps").text = str(fps)
|
||||||
|
ET.SubElement(info, "total_frames").text = str(total_frames)
|
||||||
|
ET.SubElement(info, "sample_interval").text = str(sample_interval)
|
||||||
|
|
||||||
|
frames_node = ET.SubElement(root, "frames")
|
||||||
|
|
||||||
|
for frame_item in frame_results:
|
||||||
|
frame_node = ET.SubElement(frames_node, "frame")
|
||||||
|
ET.SubElement(frame_node, "frame_index").text = str(frame_item["frame_index"])
|
||||||
|
ET.SubElement(frame_node, "time_sec").text = str(frame_item["time_sec"])
|
||||||
|
|
||||||
|
objects_node = ET.SubElement(frame_node, "objects")
|
||||||
|
|
||||||
|
for item in frame_item.get("results", []):
|
||||||
|
obj = ET.SubElement(objects_node, "object")
|
||||||
|
|
||||||
|
cls_id = int(item.get("cls_class_id", -1))
|
||||||
|
cls_name = str(item.get("cls_class_name", "") or "").strip()
|
||||||
|
xml_name = cls_name if cls_name else f"class_{cls_id}"
|
||||||
|
|
||||||
|
ET.SubElement(obj, "name").text = xml_name
|
||||||
|
ET.SubElement(obj, "class_id").text = str(cls_id)
|
||||||
|
|
||||||
|
box = item["bbox_xyxy"]
|
||||||
|
bndbox = ET.SubElement(obj, "bndbox")
|
||||||
|
ET.SubElement(bndbox, "xmin").text = str(int(round(box[0])))
|
||||||
|
ET.SubElement(bndbox, "ymin").text = str(int(round(box[1])))
|
||||||
|
ET.SubElement(bndbox, "xmax").text = str(int(round(box[2])))
|
||||||
|
ET.SubElement(bndbox, "ymax").text = str(int(round(box[3])))
|
||||||
|
|
||||||
|
indent_xml(root)
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.pipeline import BirdCascadePipeline
|
||||||
|
from app.utils import (
|
||||||
|
draw_results,
|
||||||
|
now_timestamp_minute,
|
||||||
|
safe_stem,
|
||||||
|
save_json,
|
||||||
|
write_video_xml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_unique_result_dir(parent_dir: Path, timestamp: str, original_filename: str) -> tuple[Path, str, str]:
|
||||||
|
"""
|
||||||
|
创建单次视频识别结果目录。
|
||||||
|
|
||||||
|
目录名:
|
||||||
|
时间戳_原文件名stem
|
||||||
|
|
||||||
|
例如:
|
||||||
|
runs/video/202605181431_DJI_20260423155914_0006_Z/
|
||||||
|
|
||||||
|
返回:
|
||||||
|
result_dir, result_base_name, original_stem
|
||||||
|
"""
|
||||||
|
original_stem = safe_stem(original_filename)
|
||||||
|
base_name = f"{timestamp}_{original_stem}"
|
||||||
|
|
||||||
|
result_dir = parent_dir / base_name
|
||||||
|
|
||||||
|
if not result_dir.exists():
|
||||||
|
result_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return result_dir, base_name, original_stem
|
||||||
|
|
||||||
|
idx = 1
|
||||||
|
while True:
|
||||||
|
candidate_base = f"{base_name}_{idx:02d}"
|
||||||
|
candidate_dir = parent_dir / candidate_base
|
||||||
|
if not candidate_dir.exists():
|
||||||
|
candidate_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return candidate_dir, candidate_base, original_stem
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def iou_xyxy(a: List[float], b: List[float]) -> float:
|
||||||
|
ax1, ay1, ax2, ay2 = a
|
||||||
|
bx1, by1, bx2, by2 = b
|
||||||
|
|
||||||
|
x1 = max(ax1, bx1)
|
||||||
|
y1 = max(ay1, by1)
|
||||||
|
x2 = min(ax2, bx2)
|
||||||
|
y2 = min(ay2, by2)
|
||||||
|
|
||||||
|
inter = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
||||||
|
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
|
||||||
|
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
|
||||||
|
|
||||||
|
return inter / (area_a + area_b - inter + 1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResultSmoother:
|
||||||
|
"""
|
||||||
|
降低视频框闪烁:
|
||||||
|
1. 非推理帧复用上一推理结果;
|
||||||
|
2. 短暂漏检时保留上一结果若干帧;
|
||||||
|
3. 对匹配目标的坐标做指数平滑;
|
||||||
|
4. 对同一目标累计物种置信度,避免标签和颜色逐帧跳变。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hold_frames: int = 8, alpha: float = 0.65):
|
||||||
|
self.hold_frames = hold_frames
|
||||||
|
self.alpha = alpha
|
||||||
|
self.last_results: List[Dict] = []
|
||||||
|
self.last_update_frame: int = -10**9
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_results(results: List[Dict]) -> List[Dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in item.items()
|
||||||
|
if not key.startswith("_smoother_")
|
||||||
|
}
|
||||||
|
for item in results
|
||||||
|
]
|
||||||
|
|
||||||
|
def update(self, current_results: List[Dict], frame_index: int) -> List[Dict]:
|
||||||
|
if len(current_results) == 0:
|
||||||
|
if frame_index - self.last_update_frame <= self.hold_frames:
|
||||||
|
return self._public_results(self.last_results)
|
||||||
|
self.last_results = []
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(self.last_results) == 0:
|
||||||
|
self.last_results = []
|
||||||
|
for current in current_results:
|
||||||
|
item = dict(current)
|
||||||
|
class_key = (
|
||||||
|
int(item.get("cls_class_id", -1)),
|
||||||
|
str(item.get("cls_class_name", "") or ""),
|
||||||
|
)
|
||||||
|
confidence = max(float(item.get("cls_confidence", 0.0)), 0.01)
|
||||||
|
item["_smoother_class_votes"] = {class_key: confidence}
|
||||||
|
self.last_results.append(item)
|
||||||
|
self.last_update_frame = frame_index
|
||||||
|
return self._public_results(self.last_results)
|
||||||
|
|
||||||
|
smoothed_results = []
|
||||||
|
used_last = set()
|
||||||
|
|
||||||
|
for cur in current_results:
|
||||||
|
cur_box = cur["bbox_xyxy"]
|
||||||
|
|
||||||
|
best_idx = -1
|
||||||
|
best_iou = 0.0
|
||||||
|
|
||||||
|
for idx, prev in enumerate(self.last_results):
|
||||||
|
if idx in used_last:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = iou_xyxy(cur_box, prev["bbox_xyxy"])
|
||||||
|
if score > best_iou:
|
||||||
|
best_iou = score
|
||||||
|
best_idx = idx
|
||||||
|
|
||||||
|
new_item = dict(cur)
|
||||||
|
|
||||||
|
if best_idx >= 0 and best_iou >= 0.1:
|
||||||
|
prev = self.last_results[best_idx]
|
||||||
|
prev_box = prev["bbox_xyxy"]
|
||||||
|
|
||||||
|
smooth_box = []
|
||||||
|
for p, c in zip(prev_box, cur_box):
|
||||||
|
smooth_box.append(round(self.alpha * float(p) + (1 - self.alpha) * float(c), 2))
|
||||||
|
|
||||||
|
new_item["bbox_xyxy"] = smooth_box
|
||||||
|
|
||||||
|
votes = {
|
||||||
|
key: float(value) * 0.90
|
||||||
|
for key, value in prev.get(
|
||||||
|
"_smoother_class_votes",
|
||||||
|
{},
|
||||||
|
).items()
|
||||||
|
}
|
||||||
|
current_key = (
|
||||||
|
int(cur.get("cls_class_id", -1)),
|
||||||
|
str(cur.get("cls_class_name", "") or ""),
|
||||||
|
)
|
||||||
|
current_confidence = max(
|
||||||
|
float(cur.get("cls_confidence", 0.0)),
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
votes[current_key] = votes.get(current_key, 0.0) + current_confidence
|
||||||
|
chosen_key = max(votes, key=votes.get)
|
||||||
|
new_item["_smoother_class_votes"] = votes
|
||||||
|
new_item["cls_class_id"] = chosen_key[0]
|
||||||
|
new_item["cls_class_name"] = chosen_key[1]
|
||||||
|
if chosen_key != current_key:
|
||||||
|
new_item["show_cls_name"] = bool(
|
||||||
|
prev.get("show_cls_name", False)
|
||||||
|
)
|
||||||
|
used_last.add(best_idx)
|
||||||
|
else:
|
||||||
|
class_key = (
|
||||||
|
int(cur.get("cls_class_id", -1)),
|
||||||
|
str(cur.get("cls_class_name", "") or ""),
|
||||||
|
)
|
||||||
|
confidence = max(float(cur.get("cls_confidence", 0.0)), 0.01)
|
||||||
|
new_item["_smoother_class_votes"] = {class_key: confidence}
|
||||||
|
|
||||||
|
smoothed_results.append(new_item)
|
||||||
|
|
||||||
|
self.last_results = smoothed_results
|
||||||
|
self.last_update_frame = frame_index
|
||||||
|
|
||||||
|
return self._public_results(smoothed_results)
|
||||||
|
|
||||||
|
def get_for_skipped_frame(self, frame_index: int) -> List[Dict]:
|
||||||
|
if frame_index - self.last_update_frame <= self.hold_frames:
|
||||||
|
return self._public_results(self.last_results)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def process_video_file(
|
||||||
|
video_path: str,
|
||||||
|
pipeline: BirdCascadePipeline,
|
||||||
|
original_filename: str,
|
||||||
|
sample_interval: Optional[int] = None,
|
||||||
|
save_annotated_video: Optional[bool] = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
视频结果自动保存到:
|
||||||
|
runs/video/时间戳_原视频名/
|
||||||
|
|
||||||
|
目录内部生成:
|
||||||
|
原视频名.mp4
|
||||||
|
时间戳_原视频名.json
|
||||||
|
时间戳_原视频名.xml
|
||||||
|
原视频名_source.原后缀
|
||||||
|
frames/
|
||||||
|
|
||||||
|
示例:
|
||||||
|
runs/video/202605181431_DJI_20260423155914_0006_Z/
|
||||||
|
├── DJI_20260423155914_0006_Z.mp4
|
||||||
|
├── 202605181431_DJI_20260423155914_0006_Z.json
|
||||||
|
├── 202605181431_DJI_20260423155914_0006_Z.xml
|
||||||
|
├── DJI_20260423155914_0006_Z_source.MP4
|
||||||
|
└── frames/
|
||||||
|
"""
|
||||||
|
sample_interval = settings.video_sample_interval if sample_interval is None else int(sample_interval)
|
||||||
|
save_annotated_video = settings.save_annotated_video if save_annotated_video is None else bool(save_annotated_video)
|
||||||
|
|
||||||
|
video_root = Path(settings.output_dir) / settings.video_output_subdir
|
||||||
|
video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = now_timestamp_minute()
|
||||||
|
result_dir, result_base_name, original_stem = make_unique_result_dir(
|
||||||
|
parent_dir=video_root,
|
||||||
|
timestamp=timestamp,
|
||||||
|
original_filename=original_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
original_suffix = Path(original_filename).suffix or ".mp4"
|
||||||
|
|
||||||
|
source_video_path = result_dir / f"{original_stem}_source{original_suffix}"
|
||||||
|
|
||||||
|
# 输出视频固定为 mp4,文件名不带时间戳
|
||||||
|
annotated_video_path = result_dir / f"{original_stem}.mp4"
|
||||||
|
|
||||||
|
# JSON 和 XML 带时间戳
|
||||||
|
json_path = result_dir / f"{result_base_name}.json"
|
||||||
|
xml_path = result_dir / f"{result_base_name}.xml"
|
||||||
|
|
||||||
|
frames_dir = result_dir / "frames"
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
shutil.copy2(video_path, source_video_path)
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(str(source_video_path))
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise ValueError(f"视频打开失败: {source_video_path}")
|
||||||
|
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
if fps is None or fps <= 0:
|
||||||
|
fps = 25.0
|
||||||
|
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||||
|
|
||||||
|
writer = None
|
||||||
|
if save_annotated_video:
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
|
writer = cv2.VideoWriter(str(annotated_video_path), fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
smoother = VideoResultSmoother(
|
||||||
|
hold_frames=settings.smooth_hold_frames,
|
||||||
|
alpha=settings.box_smooth_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
frame_results = []
|
||||||
|
processed_frames = 0
|
||||||
|
detected_frames = 0
|
||||||
|
frame_index = 0
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
should_process = frame_index % sample_interval == 0
|
||||||
|
|
||||||
|
if should_process:
|
||||||
|
output = pipeline.predict(frame, return_image=False)
|
||||||
|
raw_results = output["results"]
|
||||||
|
processed_frames += 1
|
||||||
|
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.update(raw_results, frame_index)
|
||||||
|
else:
|
||||||
|
results = raw_results
|
||||||
|
|
||||||
|
if len(results) > 0:
|
||||||
|
detected_frames += 1
|
||||||
|
|
||||||
|
frame_results.append(
|
||||||
|
{
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"time_sec": round(frame_index / fps, 3),
|
||||||
|
"num_objects": len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if settings.smooth_video_boxes:
|
||||||
|
results = smoother.get_for_skipped_frame(frame_index)
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
annotated_frame = draw_results(frame, results)
|
||||||
|
|
||||||
|
if should_process and len(results) > 0:
|
||||||
|
frame_img_path = frames_dir / f"frame_{frame_index:08d}.jpg"
|
||||||
|
cv2.imwrite(str(frame_img_path), annotated_frame)
|
||||||
|
|
||||||
|
if writer is not None:
|
||||||
|
writer.write(annotated_frame)
|
||||||
|
|
||||||
|
frame_index += 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
if writer is not None:
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 3)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"success": True,
|
||||||
|
"type": "video",
|
||||||
|
"original_filename": original_filename,
|
||||||
|
"result_dir": str(result_dir),
|
||||||
|
"source_video": str(source_video_path),
|
||||||
|
"annotated_video": str(annotated_video_path) if annotated_video_path.exists() else None,
|
||||||
|
"json": str(json_path),
|
||||||
|
"xml": str(xml_path),
|
||||||
|
"frames_dir": str(frames_dir),
|
||||||
|
"video_info": {
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
"total_frames": total_frames,
|
||||||
|
},
|
||||||
|
"sample_interval": sample_interval,
|
||||||
|
"smooth_video_boxes": settings.smooth_video_boxes,
|
||||||
|
"smooth_hold_frames": settings.smooth_hold_frames,
|
||||||
|
"box_smooth_alpha": settings.box_smooth_alpha,
|
||||||
|
"processed_frames": processed_frames,
|
||||||
|
"detected_frames": detected_frames,
|
||||||
|
"elapsed_sec": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"summary": summary,
|
||||||
|
"frames": frame_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json(json_path, json_data)
|
||||||
|
|
||||||
|
write_video_xml(
|
||||||
|
xml_path=xml_path,
|
||||||
|
video_filename=annotated_video_path.name,
|
||||||
|
video_path=str(annotated_video_path),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
fps=fps,
|
||||||
|
total_frames=total_frames,
|
||||||
|
sample_interval=sample_interval,
|
||||||
|
frame_results=frame_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
bird-cascade-batch:
|
||||||
|
build: .
|
||||||
|
image: bird-cascade-batch:latest
|
||||||
|
volumes:
|
||||||
|
- ./models:/app/models:ro
|
||||||
|
- ./input:/app/input:ro
|
||||||
|
- ./runs:/app/runs
|
||||||
|
environment:
|
||||||
|
ENABLE_DETECTION: "true"
|
||||||
|
ENABLE_CLASSIFICATION: "true"
|
||||||
|
ENABLE_DENSITY: "true"
|
||||||
|
DET_MODEL_PATH: /app/models/YNDC_20251010_yolov5_optimize.onnx
|
||||||
|
CLS_MODEL_PATH: /app/models/HHK_20260525_mmcls_optimize.onnx
|
||||||
|
DET_CLASSES_PATH: /app/models/YNDC_20251010_yolov5_optimize.txt
|
||||||
|
CLS_CLASSES_PATH: /app/models/HHK_20260525_mmcls_optimize.txt
|
||||||
|
DENSITY_MODEL_PATH: /app/models/bird_density.onnx
|
||||||
|
DET_IMG_SIZE: "640"
|
||||||
|
CLS_IMG_SIZE: "224"
|
||||||
|
DENSITY_IMG_SIZE: "0"
|
||||||
|
OUTPUT_DIR: /app/runs
|
||||||
|
TARGET_DET_CLASS_IDS: "0"
|
||||||
|
VIDEO_SAMPLE_INTERVAL: "2"
|
||||||
|
DEVICE: cpu
|
||||||
|
UNIFORM_SPECIES_COLOR: "false"
|
||||||
|
SMOOTH_VIDEO_BOXES: "true"
|
||||||
|
SMOOTH_HOLD_FRAMES: "3"
|
||||||
|
BOX_SMOOTH_ALPHA: "0.25"
|
||||||
|
FONT_PATH: /app/assets/fonts/NotoSansCJK-Regular.ttc
|
||||||
|
command:
|
||||||
|
- --input
|
||||||
|
- /app/input
|
||||||
|
- --output
|
||||||
|
- /app/runs
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/usr/share/fontconfig/conf.avail/70-fonts-noto-cjk.conf
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,186 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: noto-cjk
|
||||||
|
Upstream-Contact: https://github.com/googlei18n/noto-cjk/issues
|
||||||
|
Source: https://github.com/googlei18n/noto-cjk
|
||||||
|
Files-Excluded:
|
||||||
|
NotoSansCJKjp-Black.otf
|
||||||
|
NotoSansCJKjp-Bold.otf
|
||||||
|
NotoSansCJKjp-DemiLight.otf
|
||||||
|
NotoSansCJKjp-Light.otf
|
||||||
|
NotoSansCJKjp-Medium.otf
|
||||||
|
NotoSansCJKjp-Regular.otf
|
||||||
|
NotoSansCJKjp-Thin.otf
|
||||||
|
NotoSansCJKkr-Black.otf
|
||||||
|
NotoSansCJKkr-Bold.otf
|
||||||
|
NotoSansCJKkr-DemiLight.otf
|
||||||
|
NotoSansCJKkr-Light.otf
|
||||||
|
NotoSansCJKkr-Medium.otf
|
||||||
|
NotoSansCJKkr-Regular.otf
|
||||||
|
NotoSansCJKkr-Thin.otf
|
||||||
|
NotoSansCJKsc-Black.otf
|
||||||
|
NotoSansCJKsc-Bold.otf
|
||||||
|
NotoSansCJKsc-DemiLight.otf
|
||||||
|
NotoSansCJKsc-Light.otf
|
||||||
|
NotoSansCJKsc-Medium.otf
|
||||||
|
NotoSansCJKsc-Regular.otf
|
||||||
|
NotoSansCJKsc-Thin.otf
|
||||||
|
NotoSansCJKtc-Black.otf
|
||||||
|
NotoSansCJKtc-Bold.otf
|
||||||
|
NotoSansCJKtc-DemiLight.otf
|
||||||
|
NotoSansCJKtc-Light.otf
|
||||||
|
NotoSansCJKtc-Medium.otf
|
||||||
|
NotoSansCJKtc-Regular.otf
|
||||||
|
NotoSansCJKtc-Thin.otf
|
||||||
|
NotoSansCJK.ttc.zip
|
||||||
|
NotoSansJP-Black.otf
|
||||||
|
NotoSansJP-Bold.otf
|
||||||
|
NotoSansJP-DemiLight.otf
|
||||||
|
NotoSansJP-Light.otf
|
||||||
|
NotoSansJP-Medium.otf
|
||||||
|
NotoSansJP-Regular.otf
|
||||||
|
NotoSansJP-Thin.otf
|
||||||
|
NotoSansKR-Black.otf
|
||||||
|
NotoSansKR-Bold.otf
|
||||||
|
NotoSansKR-DemiLight.otf
|
||||||
|
NotoSansKR-Light.otf
|
||||||
|
NotoSansKR-Medium.otf
|
||||||
|
NotoSansKR-Regular.otf
|
||||||
|
NotoSansKR-Thin.otf
|
||||||
|
NotoSansMonoCJKjp-Bold.otf
|
||||||
|
NotoSansMonoCJKjp-Regular.otf
|
||||||
|
NotoSansMonoCJKkr-Bold.otf
|
||||||
|
NotoSansMonoCJKkr-Regular.otf
|
||||||
|
NotoSansMonoCJKsc-Bold.otf
|
||||||
|
NotoSansMonoCJKsc-Regular.otf
|
||||||
|
NotoSansMonoCJKtc-Bold.otf
|
||||||
|
NotoSansMonoCJKtc-Regular.otf
|
||||||
|
NotoSansSC-Black.otf
|
||||||
|
NotoSansSC-Bold.otf
|
||||||
|
NotoSansSC-DemiLight.otf
|
||||||
|
NotoSansSC-Light.otf
|
||||||
|
NotoSansSC-Medium.otf
|
||||||
|
NotoSansSC-Regular.otf
|
||||||
|
NotoSansSC-Thin.otf
|
||||||
|
NotoSansTC-Black.otf
|
||||||
|
NotoSansTC-Bold.otf
|
||||||
|
NotoSansTC-DemiLight.otf
|
||||||
|
NotoSansTC-Light.otf
|
||||||
|
NotoSansTC-Medium.otf
|
||||||
|
NotoSansTC-Regular.otf
|
||||||
|
NotoSansTC-Thin.otf
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2010-2015, Google Corporation
|
||||||
|
License: SIL-1.1
|
||||||
|
|
||||||
|
Files: debian/*
|
||||||
|
Copyright: 2013, Vasudev Kamath <kamathvasudev@gmail.com>
|
||||||
|
2013-2015, Jonas Smedegaard <dr@jones.dk>
|
||||||
|
2015, ChangZhuo Chen (陳昌倬) <czchen@debian.org>
|
||||||
|
License: GPL-3+
|
||||||
|
|
||||||
|
License: SIL-1.1
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
.
|
||||||
|
PREAMBLE
|
||||||
|
.
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to provide
|
||||||
|
a free and open framework in which fonts may be shared and improved in
|
||||||
|
partnership with others.
|
||||||
|
.
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
.
|
||||||
|
DEFINITIONS
|
||||||
|
.
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
.
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
.
|
||||||
|
"Original Version" refers to the collection of Font Software components
|
||||||
|
as distributed by the Copyright Holder(s).
|
||||||
|
.
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
.
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical writer
|
||||||
|
or other person who contributed to the Font Software.
|
||||||
|
.
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
.
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
.
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
.
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
.
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
.
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
.
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole, must
|
||||||
|
be distributed entirely under this license, and must not be distributed
|
||||||
|
under any other license. The requirement for fonts to remain under this
|
||||||
|
license does not apply to any document created using the Font Software.
|
||||||
|
.
|
||||||
|
TERMINATION
|
||||||
|
.
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
.
|
||||||
|
DISCLAIMER
|
||||||
|
.
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
License: GPL-3+
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
.
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
.
|
||||||
|
On Debian systems, the complete text of the GNU General
|
||||||
|
Public License version 3 can be found in "/usr/share/common-licenses/GPL-3".
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
||||||
|
<fontconfig>
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ja</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Serif CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ko</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Serif CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Serif CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Serif CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Serif CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ja</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Sans CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ko</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Sans CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ja</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Sans Mono CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>ko</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend">
|
||||||
|
<string>Noto Sans Mono CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="prepend" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
</fontconfig>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
普通翠鸟;针尾鸭;绿翅鸭;绿头鸭;斑嘴鸭;灰雁;鸿雁;豆雁;苍鹭;红头潜鸭;斑嘴鸭;斑背潜鸭;白眼潜鸭;;鹊鸭;环颈鸻;金眶鸻;棕头鸥;红嘴鸥;东方白鹳;小天鹅;大天鹅;疣鼻天鹅;白鹭;普通鸬鹚;扇尾沙锥;黑水鸡;灰鹤;丹顶鹤;蛎鹬;黑翅长脚鹬;黑尾鸥;黑鹳;罗纹鸭;赤颈鸭;赤膀鸭;普通秋沙鸭;普通秋沙鸭;夜鹭;卷羽鹈鹕;;普通鸬鹚;雉鸡;罗纹鸭;白琵鹭;凤头䴙䴘;反嘴鹬;琵嘴鸭;白眉鸭;斑嘴鸭;小䴙䴘;赤麻鸭;鹤鹬;林鹬;泽鹬;红脚鹬;灰头麦鸡;斑嘴鸭
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
八哥;绿头鸭;绿头鸭;斑嘴鸭;白额雁;灰雁;鸿雁;豆雁;白枕鹤;苍鹭;草鹭;池鹭;鸟;;环颈鸻;金眶鸻;须浮鸥;白翅浮鸥;东方白鹳;黑鹳;小天鹅;白鹭;白骨顶;扇尾沙锥;黑水鸡;普通燕鸻;黑领椋鸟;灰鹤;白头鹤;白鹤;罗纹鸭;赤颈鸭;夜鹭;普通鸬鹚;雉鸡;白琵鹭;凤头䴙䴘;反嘴鹬;珠颈斑鸠;丝光椋鸟;小䴙䴘;鹤鹬;青脚鹬;灰头麦鸡;凤头麦鸡
|
||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
鸟
|
||||||
|
人
|
||||||
|
船
|
||||||
|
牛
|
||||||
|
其他
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
八哥;普通翠鸟;绿翅鸭;绿头鸭;绿头鸭;斑嘴鸭;白额雁;灰雁;鸿雁;苍鹭;池鹭;红头潜鸭;凤头潜鸭;斑背潜鸭;;;斑鱼狗;须浮鸥;小天鹅;白鹭;白骨顶;黑水鸡;黑翅长脚鹬;水雉;棕背伯劳;罗纹鸭;赤颈鸭;白鹡鸰;夜鹭;;鹗;大山雀;;普通鸬鹚;喜鹊;白琵鹭;凤头䴙䴘;黑颈䴙䴘;反嘴鹬;琵嘴鸭;白眉鸭;珠颈斑鸠;丝光椋鸟;山斑鸠;火斑鸠;小䴙䴘;赤麻鸭;青脚鹬;斑鸫;灰头麦鸡;
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
numpy==1.26.4
|
||||||
|
opencv-python-headless==4.10.0.84
|
||||||
|
onnxruntime==1.20.1
|
||||||
|
Pillow==11.0.0
|
||||||
|
requests==2.32.3
|
||||||
|
pydantic==2.10.4
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python}"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
exec "$PYTHON_BIN" -m app.batch_runner "$@"
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app import batch_runner
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRunnerCliTests(unittest.TestCase):
|
||||||
|
def test_readme_options_are_parsed(self):
|
||||||
|
args = batch_runner.parse_args(
|
||||||
|
[
|
||||||
|
"--input",
|
||||||
|
"/tmp/input",
|
||||||
|
"--output",
|
||||||
|
"/tmp/output",
|
||||||
|
"--sample-interval",
|
||||||
|
"5",
|
||||||
|
"--no-recursive",
|
||||||
|
"--disable-detection",
|
||||||
|
"--disable-classification",
|
||||||
|
"--disable-density",
|
||||||
|
"--det-conf-thres",
|
||||||
|
"0.15",
|
||||||
|
"--det-iou-thres",
|
||||||
|
"0.40",
|
||||||
|
"--det-second-conf-thres",
|
||||||
|
"0.45",
|
||||||
|
"--min-box-area-ratio",
|
||||||
|
"0.0001",
|
||||||
|
"--max-box-area-ratio",
|
||||||
|
"0.10",
|
||||||
|
"--max-box-aspect-ratio",
|
||||||
|
"4.0",
|
||||||
|
"--detection-roi-y-min-ratio",
|
||||||
|
"0.08",
|
||||||
|
"--detection-roi-y-max-ratio",
|
||||||
|
"0.82",
|
||||||
|
"--cls-show-conf-thres",
|
||||||
|
"0.60",
|
||||||
|
"--large-box-area-ratio",
|
||||||
|
"0.003",
|
||||||
|
"--large-box-cls-show-conf-thres",
|
||||||
|
"0.30",
|
||||||
|
"--show-large-box-name-always",
|
||||||
|
"false",
|
||||||
|
"--dense-scene-count-thres",
|
||||||
|
"30",
|
||||||
|
"--hide-cls-name-in-dense-scene",
|
||||||
|
"false",
|
||||||
|
"--crop-padding-ratio",
|
||||||
|
"0.10",
|
||||||
|
"--max-detections",
|
||||||
|
"75",
|
||||||
|
"--uniform-species-color",
|
||||||
|
"true",
|
||||||
|
"--species-name-remap-min-confidence",
|
||||||
|
"0.80",
|
||||||
|
"--binary-dark-species-name",
|
||||||
|
"东方白鹳",
|
||||||
|
"--binary-light-species-name",
|
||||||
|
"白琵鹭",
|
||||||
|
"--binary-dark-pixel-threshold",
|
||||||
|
"85",
|
||||||
|
"--binary-dark-ratio-threshold",
|
||||||
|
"0.12",
|
||||||
|
"--dark-override-species-name",
|
||||||
|
"雁鸭类",
|
||||||
|
"--dark-override-species-class-id",
|
||||||
|
"60",
|
||||||
|
"--dark-override-pixel-threshold",
|
||||||
|
"75",
|
||||||
|
"--dark-override-ratio-threshold",
|
||||||
|
"0.30",
|
||||||
|
"--dark-override-min-box-area-ratio",
|
||||||
|
"0.00001",
|
||||||
|
"--dark-override-max-box-area-ratio",
|
||||||
|
"0.002",
|
||||||
|
"--dark-override-roi-y-min-ratio",
|
||||||
|
"0.60",
|
||||||
|
"--dark-override-roi-y-max-ratio",
|
||||||
|
"0.95",
|
||||||
|
"--region-override-species-name",
|
||||||
|
"白头鹤",
|
||||||
|
"--region-override-species-class-id",
|
||||||
|
"28",
|
||||||
|
"--region-override-source-species-names",
|
||||||
|
"白鹤,白头鹤,白枕鹤",
|
||||||
|
"--region-override-roi-y-min-ratio",
|
||||||
|
"0.24",
|
||||||
|
"--region-override-roi-y-max-ratio",
|
||||||
|
"0.45",
|
||||||
|
"--enable-crane-appearance-correction",
|
||||||
|
"--crane-white-ratio-threshold",
|
||||||
|
"0.33",
|
||||||
|
"--crane-dark-ratio-threshold",
|
||||||
|
"0.29",
|
||||||
|
"--crane-tail-white-ratio-threshold",
|
||||||
|
"0.14",
|
||||||
|
"--crane-uncertain-hide-label",
|
||||||
|
"false",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(args.input, "/tmp/input")
|
||||||
|
self.assertEqual(args.output, "/tmp/output")
|
||||||
|
self.assertEqual(args.sample_interval, 5)
|
||||||
|
self.assertFalse(args.recursive)
|
||||||
|
self.assertFalse(args.enable_detection)
|
||||||
|
self.assertFalse(args.enable_classification)
|
||||||
|
self.assertFalse(args.enable_density)
|
||||||
|
self.assertEqual(args.det_conf_thres, 0.15)
|
||||||
|
self.assertEqual(args.det_iou_thres, 0.40)
|
||||||
|
self.assertEqual(args.det_second_conf_thres, 0.45)
|
||||||
|
self.assertEqual(args.min_box_area_ratio, 0.0001)
|
||||||
|
self.assertEqual(args.max_box_area_ratio, 0.10)
|
||||||
|
self.assertEqual(args.max_box_aspect_ratio, 4.0)
|
||||||
|
self.assertEqual(args.detection_roi_y_min_ratio, 0.08)
|
||||||
|
self.assertEqual(args.detection_roi_y_max_ratio, 0.82)
|
||||||
|
self.assertEqual(args.cls_show_conf_thres, 0.60)
|
||||||
|
self.assertEqual(args.large_box_area_ratio, 0.003)
|
||||||
|
self.assertEqual(args.large_box_cls_show_conf_thres, 0.30)
|
||||||
|
self.assertFalse(args.show_large_box_name_always)
|
||||||
|
self.assertEqual(args.dense_scene_count_thres, 30)
|
||||||
|
self.assertFalse(args.hide_cls_name_in_dense_scene)
|
||||||
|
self.assertEqual(args.crop_padding_ratio, 0.10)
|
||||||
|
self.assertEqual(args.max_detections, 75)
|
||||||
|
self.assertTrue(args.uniform_species_color)
|
||||||
|
self.assertEqual(args.species_name_remap_min_confidence, 0.80)
|
||||||
|
self.assertEqual(args.binary_dark_species_name, "东方白鹳")
|
||||||
|
self.assertEqual(args.binary_light_species_name, "白琵鹭")
|
||||||
|
self.assertEqual(args.binary_dark_pixel_threshold, 85)
|
||||||
|
self.assertEqual(args.binary_dark_ratio_threshold, 0.12)
|
||||||
|
self.assertEqual(args.dark_override_species_name, "雁鸭类")
|
||||||
|
self.assertEqual(args.dark_override_species_class_id, 60)
|
||||||
|
self.assertEqual(args.dark_override_pixel_threshold, 75)
|
||||||
|
self.assertEqual(args.dark_override_ratio_threshold, 0.30)
|
||||||
|
self.assertEqual(args.dark_override_min_box_area_ratio, 0.00001)
|
||||||
|
self.assertEqual(args.dark_override_max_box_area_ratio, 0.002)
|
||||||
|
self.assertEqual(args.dark_override_roi_y_min_ratio, 0.60)
|
||||||
|
self.assertEqual(args.dark_override_roi_y_max_ratio, 0.95)
|
||||||
|
self.assertEqual(args.region_override_species_name, "白头鹤")
|
||||||
|
self.assertEqual(args.region_override_species_class_id, 28)
|
||||||
|
self.assertEqual(
|
||||||
|
args.region_override_source_species_names,
|
||||||
|
"白鹤,白头鹤,白枕鹤",
|
||||||
|
)
|
||||||
|
self.assertEqual(args.region_override_roi_y_min_ratio, 0.24)
|
||||||
|
self.assertEqual(args.region_override_roi_y_max_ratio, 0.45)
|
||||||
|
self.assertTrue(args.enable_crane_appearance_correction)
|
||||||
|
self.assertEqual(args.crane_white_ratio_threshold, 0.33)
|
||||||
|
self.assertEqual(args.crane_dark_ratio_threshold, 0.29)
|
||||||
|
self.assertEqual(args.crane_tail_white_ratio_threshold, 0.14)
|
||||||
|
self.assertFalse(args.crane_uncertain_hide_label)
|
||||||
|
|
||||||
|
def test_readme_defaults_enable_recursive_and_all_models(self):
|
||||||
|
args = batch_runner.parse_args(["--input", "/tmp/input"])
|
||||||
|
|
||||||
|
self.assertTrue(args.recursive)
|
||||||
|
self.assertEqual(args.enable_detection, settings.enable_detection)
|
||||||
|
self.assertEqual(
|
||||||
|
args.enable_classification,
|
||||||
|
settings.enable_classification,
|
||||||
|
)
|
||||||
|
self.assertEqual(args.enable_density, settings.enable_density)
|
||||||
|
self.assertEqual(args.sample_interval, settings.video_sample_interval)
|
||||||
|
self.assertEqual(args.max_box_area_ratio, 1.0)
|
||||||
|
self.assertEqual(
|
||||||
|
args.uniform_species_color,
|
||||||
|
settings.uniform_species_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_applies_cli_values_before_running_batch(self):
|
||||||
|
setting_names = (
|
||||||
|
"enable_detection",
|
||||||
|
"enable_classification",
|
||||||
|
"enable_density",
|
||||||
|
"det_conf_thres",
|
||||||
|
"hide_cls_name_in_dense_scene",
|
||||||
|
"max_detections",
|
||||||
|
"uniform_species_color",
|
||||||
|
)
|
||||||
|
original_values = {
|
||||||
|
name: getattr(settings, name)
|
||||||
|
for name in setting_names
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(
|
||||||
|
batch_runner,
|
||||||
|
"run_batch",
|
||||||
|
return_value={"success": True},
|
||||||
|
) as run_batch_mock:
|
||||||
|
result = batch_runner.main(
|
||||||
|
[
|
||||||
|
"--input",
|
||||||
|
"/tmp/input",
|
||||||
|
"--output",
|
||||||
|
"/tmp/output",
|
||||||
|
"--disable-detection",
|
||||||
|
"--disable-classification",
|
||||||
|
"--disable-density",
|
||||||
|
"--det-conf-thres",
|
||||||
|
"0.15",
|
||||||
|
"--hide-cls-name-in-dense-scene",
|
||||||
|
"false",
|
||||||
|
"--max-detections",
|
||||||
|
"75",
|
||||||
|
"--uniform-species-color",
|
||||||
|
"true",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"success": True})
|
||||||
|
self.assertFalse(settings.enable_detection)
|
||||||
|
self.assertFalse(settings.enable_classification)
|
||||||
|
self.assertFalse(settings.enable_density)
|
||||||
|
self.assertEqual(settings.det_conf_thres, 0.15)
|
||||||
|
self.assertFalse(settings.hide_cls_name_in_dense_scene)
|
||||||
|
self.assertEqual(settings.max_detections, 75)
|
||||||
|
self.assertTrue(settings.uniform_species_color)
|
||||||
|
run_batch_mock.assert_called_once_with(
|
||||||
|
input_path="/tmp/input",
|
||||||
|
output_base="/tmp/output",
|
||||||
|
recursive=True,
|
||||||
|
sample_interval=2,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for name, value in original_values.items():
|
||||||
|
object.__setattr__(settings, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.inference.convnext_onnx import softmax, to_probabilities
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifierProbabilityTests(unittest.TestCase):
|
||||||
|
def test_existing_probabilities_are_not_softmaxed_again(self):
|
||||||
|
output = np.array([0.01, 0.09, 0.90], dtype=np.float32)
|
||||||
|
probabilities = to_probabilities(output)
|
||||||
|
np.testing.assert_allclose(probabilities, output, atol=1e-6)
|
||||||
|
|
||||||
|
def test_logits_are_converted_with_softmax(self):
|
||||||
|
logits = np.array([-1.0, 0.0, 3.0], dtype=np.float32)
|
||||||
|
np.testing.assert_allclose(
|
||||||
|
to_probabilities(logits),
|
||||||
|
softmax(logits),
|
||||||
|
atol=1e-6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.pipeline import BirdCascadePipeline
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineSwitchTests(unittest.TestCase):
|
||||||
|
setting_names = (
|
||||||
|
"enable_detection",
|
||||||
|
"enable_classification",
|
||||||
|
"enable_density",
|
||||||
|
"uniform_species_color",
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.original_values = {
|
||||||
|
name: getattr(settings, name)
|
||||||
|
for name in self.setting_names
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for name, value in self.original_values.items():
|
||||||
|
object.__setattr__(settings, name, value)
|
||||||
|
|
||||||
|
def test_all_models_can_be_disabled_without_loading_onnx(self):
|
||||||
|
object.__setattr__(settings, "enable_detection", False)
|
||||||
|
object.__setattr__(settings, "enable_classification", False)
|
||||||
|
object.__setattr__(settings, "enable_density", False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.pipeline.YOLOv5ONNXDetector") as detector_mock,
|
||||||
|
patch("app.pipeline.ConvNeXtONNXClassifier") as classifier_mock,
|
||||||
|
patch("app.pipeline.DensityEstimatorONNX") as density_mock,
|
||||||
|
):
|
||||||
|
pipeline = BirdCascadePipeline()
|
||||||
|
|
||||||
|
self.assertIsNone(pipeline.detector)
|
||||||
|
self.assertIsNone(pipeline.classifier)
|
||||||
|
self.assertIsNone(pipeline.density_estimator)
|
||||||
|
detector_mock.assert_not_called()
|
||||||
|
classifier_mock.assert_not_called()
|
||||||
|
density_mock.assert_not_called()
|
||||||
|
|
||||||
|
output = pipeline.predict(
|
||||||
|
np.zeros((32, 32, 3), dtype=np.uint8),
|
||||||
|
)
|
||||||
|
self.assertEqual(output["results"], [])
|
||||||
|
self.assertIsNone(output["density"])
|
||||||
|
|
||||||
|
def test_detection_without_classification_keeps_detection_fields(self):
|
||||||
|
object.__setattr__(settings, "enable_detection", True)
|
||||||
|
object.__setattr__(settings, "enable_classification", False)
|
||||||
|
object.__setattr__(settings, "enable_density", False)
|
||||||
|
|
||||||
|
fake_detector_result = [
|
||||||
|
{
|
||||||
|
"bbox_xyxy": [4.0, 4.0, 20.0, 20.0],
|
||||||
|
"det_confidence": 0.9,
|
||||||
|
"det_class_id": 0,
|
||||||
|
"det_class_name": "bird",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.pipeline.read_classes",
|
||||||
|
return_value=["bird"],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.pipeline.os.path.exists",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch("app.pipeline.YOLOv5ONNXDetector") as detector_class,
|
||||||
|
patch("app.pipeline.ConvNeXtONNXClassifier") as classifier_class,
|
||||||
|
):
|
||||||
|
detector_class.return_value.infer.return_value = fake_detector_result
|
||||||
|
pipeline = BirdCascadePipeline()
|
||||||
|
output = pipeline.predict(
|
||||||
|
np.zeros((32, 32, 3), dtype=np.uint8),
|
||||||
|
)
|
||||||
|
|
||||||
|
classifier_class.assert_not_called()
|
||||||
|
self.assertEqual(len(output["results"]), 1)
|
||||||
|
result = output["results"][0]
|
||||||
|
self.assertEqual(result["det_class_name"], "bird")
|
||||||
|
self.assertEqual(result["cls_class_id"], -1)
|
||||||
|
self.assertEqual(result["cls_class_name"], "")
|
||||||
|
self.assertEqual(result["cls_confidence"], 0.0)
|
||||||
|
self.assertFalse(result["show_cls_name"])
|
||||||
|
|
||||||
|
def test_uniform_species_color_uses_one_color_for_every_class(self):
|
||||||
|
pipeline = object.__new__(BirdCascadePipeline)
|
||||||
|
|
||||||
|
object.__setattr__(settings, "uniform_species_color", False)
|
||||||
|
first_color = pipeline._get_color_by_class_id(1)
|
||||||
|
second_color = pipeline._get_color_by_class_id(2)
|
||||||
|
self.assertNotEqual(first_color, second_color)
|
||||||
|
|
||||||
|
object.__setattr__(settings, "uniform_species_color", True)
|
||||||
|
self.assertEqual(
|
||||||
|
pipeline._get_color_by_class_id(1),
|
||||||
|
pipeline._get_color_by_class_id(2),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
pipeline._get_color_by_class_id(999),
|
||||||
|
(0, 190, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.video_pipeline import VideoResultSmoother
|
||||||
|
|
||||||
|
|
||||||
|
def result(class_id, class_name, confidence, box):
|
||||||
|
return {
|
||||||
|
"bbox_xyxy": box,
|
||||||
|
"cls_class_id": class_id,
|
||||||
|
"cls_class_name": class_name,
|
||||||
|
"cls_confidence": confidence,
|
||||||
|
"show_cls_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResultSmootherTests(unittest.TestCase):
|
||||||
|
def test_one_low_confidence_label_does_not_flip_track(self):
|
||||||
|
smoother = VideoResultSmoother(hold_frames=4, alpha=0.5)
|
||||||
|
smoother.update([result(1, "白鹤", 0.95, [0, 0, 20, 20])], 0)
|
||||||
|
second = smoother.update(
|
||||||
|
[result(2, "白枕鹤", 0.20, [1, 1, 21, 21])],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(second[0]["cls_class_name"], "白鹤")
|
||||||
|
self.assertNotIn("_smoother_class_votes", second[0])
|
||||||
|
|
||||||
|
def test_matching_does_not_require_same_predicted_class(self):
|
||||||
|
smoother = VideoResultSmoother(hold_frames=4, alpha=0.5)
|
||||||
|
smoother.update([result(1, "白鹤", 0.9, [0, 0, 20, 20])], 0)
|
||||||
|
second = smoother.update(
|
||||||
|
[result(2, "白枕鹤", 0.9, [2, 2, 22, 22])],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(second[0]["bbox_xyxy"], [1.0, 1.0, 21.0, 21.0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user