Files
Bird_detect_cascade_service_V2/app/pipeline.py
T
2026-08-21 14:19:14 +08:00

795 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
}