925 lines
30 KiB
Python
925 lines
30 KiB
Python
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()
|