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_。 """ 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)