""" 尿检试纸颜色识别 - 核心算法模块 基于鸿蒙C++代码 (PaperRecognizer + ColorReader + PaperConfig) 的Python重写 Author: 小月 Date: 2026-05-03 """ import cv2 import numpy as np import math import logging from dataclasses import dataclass, field from enum import Enum from typing import List, Tuple, Optional logger = logging.getLogger("urine_recognizer") # ============================================================================= # 枚举类型 (Types.h) # ============================================================================= class BlockItems(Enum): BI_14 = 14 BI_11 = 11 BI_8 = 8 class BigLocatorType(Enum): BLT_SHORT = 0 # 10mm BLT_LONG = 1 # 15mm class LocatorColor(Enum): UNKNOWN = 0 RED = 1 GREEN = 2 BLUE = 3 class PaperSpatialType(Enum): PST_UNKNOWN = 0 PST_14_S = 1 # 14联, 10mm大定位块 PST_14_L = 2 # 14联, 15mm大定位块 PST_11_S = 3 # 11联, 10mm大定位块 PST_11_L = 4 # 11联, 15mm大定位块 PST_8_S = 5 # 8联, 10mm大定位块 PST_8_L = 6 # 8联, 15mm大定位块 class BlurLevel(Enum): BLUR_NONE = 0 # 清晰 (>300) BLUR_SLIGHT = 1 # 轻微模糊 (100-300) BLUR_SEVERE = 2 # 严重模糊 (<100) # ============================================================================= # 检测项目名称 (14/11/8项) # ============================================================================= # 尿常规14项名称(id=0靠近小定位块,依次向大定位块方向) # 来源:蔡叔叔确认的红色14联试纸实际排列 (2026-05-03) ITEM_NAMES_14 = [ "葡萄糖(GLU)", # 0 "白细胞(LEU)", # 1 "亚硝酸盐(NIT)", # 2 "尿胆原(URO)", # 3 "胆红素(BIL)", # 4 "酮体(KET)", # 5 "比重(SG)", # 6 "潜血(BLD)", # 7 "pH", # 8 "尿蛋白(PRO)", # 9 "微量白蛋白(MA)", # 10 "肌酐(CRE)", # 11 "尿钙(Ca)", # 12 "抗坏血酸(VC)", # 13 ] # 尿常规11项名称 ITEM_NAMES_11 = [ "抗坏血酸(VC)", "葡萄糖(GLU)", "胆红素(BIL)", "酮体(KET)", "比重(SG)", "潜血(BLD)", "pH", "蛋白质(PRO)", "尿胆原(URO)", "亚硝酸盐(NIT)", "白细胞(LEU)", ] # 尿常规8项名称 ITEM_NAMES_8 = [ "葡萄糖(GLU)", "胆红素(BIL)", "酮体(KET)", "比重(SG)", "潜血(BLD)", "pH", "蛋白质(PRO)", "白细胞(LEU)", ] ITEM_NAMES_MAP = { 14: ITEM_NAMES_14, 11: ITEM_NAMES_11, 8: ITEM_NAMES_8, } # ============================================================================= # 全局参数 (GlobalParams.h) # ============================================================================= COLOR_DISTANCE_THRESHOLD = 180.0 AREA_ERROR_RATIO = 0.20 DISTANCE_ERROR_RATIO = 0.20 ANGLE_ERROR_DEGREE = 15.0 BIG_LOCATOR_RATIO_SHORT = 2.0 # 10mm: 10/5 BIG_LOCATOR_RATIO_LONG = 3.0 # 15mm: 15/5 SMALL_LOCATOR_RATIO = 1.667 # 5/3 AREA_RATIO_SHORT = 3.33 # 10/3 AREA_RATIO_LONG = 5.0 # 15/3 MIN_SAMPLE_PIXELS = 25 DEFAULT_SAMPLE_RADIUS = 3 MIN_CONTOUR_AREA = 100.0 MAX_AREA_RATIO = 0.3 MIN_RECTANGULARITY = 0.70 MAX_LOCATOR_AREA = 20000.0 MIN_SMALL_LOCATOR_AREA = 100.0 MAX_SMALL_LOCATOR_AREA = 8000.0 MIN_BIG_LOCATOR_AREA = 200.0 MAX_BIG_LOCATOR_AREA = 15000.0 COLOR_COMPONENT_THRESHOLD = 0.05 # ============================================================================= # 数据结构 (Types.h) # ============================================================================= @dataclass class LocatorInfo: """定位块信息""" rotated_rect: Optional[cv2.RotatedRect] = None center: Tuple[float, float] = (0.0, 0.0) area: float = 0.0 width: float = 0.0 # 长边 height: float = 0.0 # 短边 color: LocatorColor = LocatorColor.UNKNOWN is_big: bool = False avg_color: Tuple[float, float, float] = (0.0, 0.0, 0.0) # BGR @dataclass class ColorResult: """色块颜色结果""" id: int = 0 r: float = 0.0 g: float = 0.0 b: float = 0.0 h: float = 0.0 s: float = 0.0 v: float = 0.0 l: float = 0.0 a: float = 0.0 b_val: float = 0.0 @dataclass class RecognitionResult: """识别结果""" success: bool = False error_code: int = -1 error_message: str = "" paper_type: PaperSpatialType = PaperSpatialType.PST_UNKNOWN paper_type_name: str = "" locator_color: LocatorColor = LocatorColor.UNKNOWN locator_color_name: str = "" item_count: int = 0 colors: List[ColorResult] = field(default_factory=list) blur_level: int = 0 blur_score: float = 0.0 annotated_image_b64: str = "" # ============================================================================= # 试纸配置 (PaperConfig.h/.cpp) # ============================================================================= @dataclass class PaperConfig: """单个试纸规格配置""" type: PaperSpatialType = PaperSpatialType.PST_UNKNOWN block_items: BlockItems = BlockItems.BI_14 locator_type: BigLocatorType = BigLocatorType.BLT_SHORT items_num: int = 0 slices_num: int = 0 first_pos: float = 0.0 step: float = 0.0 dist_times: float = 0.0 wh_big_ratio: float = 0.0 # 6种规格配置 PAPER_CONFIGS = { PaperSpatialType.PST_14_S: PaperConfig( type=PaperSpatialType.PST_14_S, block_items=BlockItems.BI_14, locator_type=BigLocatorType.BLT_SHORT, items_num=14, slices_num=50, first_pos=2.25, step=3.5, dist_times=21.3, wh_big_ratio=BIG_LOCATOR_RATIO_SHORT, ), PaperSpatialType.PST_14_L: PaperConfig( type=PaperSpatialType.PST_14_L, block_items=BlockItems.BI_14, locator_type=BigLocatorType.BLT_LONG, items_num=14, slices_num=50, first_pos=2.25, step=3.5, dist_times=21.8, wh_big_ratio=BIG_LOCATOR_RATIO_LONG, ), PaperSpatialType.PST_11_S: PaperConfig( type=PaperSpatialType.PST_11_S, block_items=BlockItems.BI_11, locator_type=BigLocatorType.BLT_SHORT, items_num=11, slices_num=39, first_pos=2.25, step=3.5, dist_times=17.6, wh_big_ratio=BIG_LOCATOR_RATIO_SHORT, ), PaperSpatialType.PST_11_L: PaperConfig( type=PaperSpatialType.PST_11_L, block_items=BlockItems.BI_11, locator_type=BigLocatorType.BLT_LONG, items_num=11, slices_num=39, first_pos=2.25, step=3.5, dist_times=18.1, wh_big_ratio=BIG_LOCATOR_RATIO_LONG, ), PaperSpatialType.PST_8_S: PaperConfig( type=PaperSpatialType.PST_8_S, block_items=BlockItems.BI_8, locator_type=BigLocatorType.BLT_SHORT, items_num=8, slices_num=29, first_pos=2.25, step=3.5, dist_times=12.9, wh_big_ratio=BIG_LOCATOR_RATIO_SHORT, ), PaperSpatialType.PST_8_L: PaperConfig( type=PaperSpatialType.PST_8_L, block_items=BlockItems.BI_8, locator_type=BigLocatorType.BLT_LONG, items_num=8, slices_num=29, first_pos=2.25, step=3.5, dist_times=13.4, wh_big_ratio=BIG_LOCATOR_RATIO_LONG, ), } PAPER_TYPE_NAMES = { PaperSpatialType.PST_14_S: "14联(S型/10mm)", PaperSpatialType.PST_14_L: "14联(L型/15mm)", PaperSpatialType.PST_11_S: "11联(S型/10mm)", PaperSpatialType.PST_11_L: "11联(L型/15mm)", PaperSpatialType.PST_8_S: "8联(S型/10mm)", PaperSpatialType.PST_8_L: "8联(L型/15mm)", } LOCATOR_COLOR_NAMES = { LocatorColor.RED: "红色", LocatorColor.GREEN: "绿色", LocatorColor.BLUE: "蓝色", LocatorColor.UNKNOWN: "未知", } def match_paper_config(dist_ratio: float, area_ratio: float, locator_type: BigLocatorType) -> PaperSpatialType: """ 根据距离比和面积比匹配试纸规格 直接平移 C++ PaperConfigManager::matchConfig() """ # 面积比范围 Min_AreaRadio_S = 2.6 Max_AreaRadio_S = 4.0 Min_AreaRadio_L = 4.2 Max_AreaRadio_L = 6.0 # S型 (10mm) 距离比 Min_DistRadio_14S = 19.0 Max_DistRadio_14S = 26.5 Min_DistRadio_11S = 15.5 Max_DistRadio_11S = 18.5 Min_DistRadio_8S = 11.6 Max_DistRadio_8S = 14.1 # L型 (15mm) 距离比 Min_DistRadio_14L = 19.0 Max_DistRadio_14L = 26.98 Min_DistRadio_11L = 15.5 Max_DistRadio_11L = 18.5 Min_DistRadio_8L = 12.0 Max_DistRadio_8L = 14.5 # 检查面积比是否在有效范围内 if area_ratio < Min_AreaRadio_S or area_ratio > Max_AreaRadio_L: return PaperSpatialType.PST_UNKNOWN # 空白区间 [4.0, 4.2] 不做判断 if area_ratio > Max_AreaRadio_S and area_ratio < Min_AreaRadio_L: return PaperSpatialType.PST_UNKNOWN is_small_type = area_ratio <= Max_AreaRadio_S if is_small_type: if dist_ratio > Min_DistRadio_14S and dist_ratio < Max_DistRadio_14S: return PaperSpatialType.PST_14_S elif dist_ratio > Min_DistRadio_11S and dist_ratio < Max_DistRadio_11S: return PaperSpatialType.PST_11_S elif dist_ratio > Min_DistRadio_8S and dist_ratio < Max_DistRadio_8S: return PaperSpatialType.PST_8_S else: if dist_ratio > Min_DistRadio_14L and dist_ratio < Max_DistRadio_14L: return PaperSpatialType.PST_14_L elif dist_ratio > Min_DistRadio_11L and dist_ratio < Max_DistRadio_11L: return PaperSpatialType.PST_11_L elif dist_ratio > Min_DistRadio_8L and dist_ratio < Max_DistRadio_8L: return PaperSpatialType.PST_8_L return PaperSpatialType.PST_UNKNOWN # ============================================================================= # 颜色读取器 (ColorReader.h/.cpp) # ============================================================================= class ColorReader: """颜色读取器:白平衡 + 颜色采样 + 颜色空间转换""" @staticmethod def apply_white_balance(image: np.ndarray) -> np.ndarray: """灰度世界白平衡算法""" if image is None or image.size == 0: return image # 确保BGR格式 if len(image.shape) == 2: bgr = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) elif image.shape[2] == 4: bgr = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) else: bgr = image.copy() # 计算各通道均值 avg_b, avg_g, avg_r = cv2.mean(bgr)[:3] avg_b = max(avg_b, 1.0) avg_g = max(avg_g, 1.0) avg_r = max(avg_r, 1.0) target_avg = (avg_b + avg_g + avg_r) / 3.0 if target_avg < 1: return image b, g, r = cv2.split(bgr) b = np.clip(b * (target_avg / avg_b), 0, 255).astype(np.uint8) g = np.clip(g * (target_avg / avg_g), 0, 255).astype(np.uint8) r = np.clip(r * (target_avg / avg_r), 0, 255).astype(np.uint8) result = cv2.merge([b, g, r]) return result @staticmethod def sample_color(image: np.ndarray, center: Tuple[float, float], radius: int = DEFAULT_SAMPLE_RADIUS) -> Tuple[float, float, float]: """圆形ROI采样平均颜色,返回 (B, G, R)""" if image is None or image.size == 0: return (0.0, 0.0, 0.0) cx, cy = int(round(center[0])), int(round(center[1])) h, w = image.shape[:2] x1 = max(0, cx - radius) y1 = max(0, cy - radius) x2 = min(w - 1, cx + radius) y2 = min(h - 1, cy + radius) if x2 <= x1 or y2 <= y1: return (0.0, 0.0, 0.0) roi = image[y1:y2+1, x1:x2+1] mask = np.zeros(roi.shape[:2], dtype=np.uint8) mask_center = (cx - x1, cy - y1) cv2.circle(mask, mask_center, radius, 255, -1) avg = cv2.mean(roi, mask) return (float(avg[0]), float(avg[1]), float(avg[2])) @staticmethod def convert_color_spaces(bgr: Tuple[float, float, float]) -> dict: """BGR → RGB + HSV + LAB 转换""" b, g, r = bgr # 创建1×1像素做转换 mat = np.uint8([[[b, g, r]]]) hsv = cv2.cvtColor(mat, cv2.COLOR_BGR2HSV)[0, 0] lab = cv2.cvtColor(mat, cv2.COLOR_BGR2Lab)[0, 0] return { "r": r, "g": g, "b": b, "h": float(hsv[0]), # OpenCV H: 0-180(不转换) "s": float(hsv[1]), "v": float(hsv[2]), "l": float(lab[0]), "a": float(lab[1]), # OpenCV LAB a: 0-255(原始uint8) "b_val": float(lab[2]), # OpenCV LAB b: 0-255(原始uint8) } @classmethod def read_colors(cls, image: np.ndarray, centers: List[Tuple[float, float]], small_locator_on_left: bool, apply_wb: bool = True) -> List[ColorResult]: """读取所有色块颜色""" results = [] if image is None or not centers: return results color_image = image.copy() if apply_wb: color_image = cls.apply_white_balance(color_image) for i, center in enumerate(centers): bgr = cls.sample_color(color_image, center, DEFAULT_SAMPLE_RADIUS) cs = cls.convert_color_spaces(bgr) result = ColorResult( id=i, r=cs["r"], g=cs["g"], b=cs["b"], h=cs["h"], s=cs["s"], v=cs["v"], l=cs["l"], a=cs["a"], b_val=cs["b_val"], ) results.append(result) # 调整顺序:始终从靠近小定位块的色块开始 results = cls.adjust_order(results, small_locator_on_left) return results @staticmethod def adjust_order(colors: List[ColorResult], small_locator_on_left: bool) -> List[ColorResult]: """centers 已经是 small→big 顺序,无需反转。保留此方法供未来扩展。""" # centers 在 _calculate_color_block_centers 中已保证 # centers[0] 靠近小定位块,无需额外反转 for i in range(len(colors)): colors[i].id = i return colors # ============================================================================= # 试纸识别器 (PaperRecognizer.h/.cpp) # ============================================================================= class UrineStripRecognizer: """尿检试纸识别器 - 完整平移C++ PaperRecognizer""" def __init__(self): self.original_image: Optional[np.ndarray] = None self.process_image: Optional[np.ndarray] = None self.hsv_image: Optional[np.ndarray] = None self.gray_image: Optional[np.ndarray] = None self.locator1 = LocatorInfo() self.locator2 = LocatorInfo() self.big_locator: Optional[LocatorInfo] = None self.small_locator: Optional[LocatorInfo] = None self.has_locator1 = False self.has_locator2 = False self.centers: List[Tuple[float, float]] = [] self.config: Optional[PaperConfig] = None self.result = RecognitionResult() self.candidate_count = 0 # ===== 主入口 ===== def recognize(self, image: np.ndarray) -> RecognitionResult: """主识别函数""" self.result = RecognitionResult() logger.info("========== 开始识别 ==========") logger.info(f"图像尺寸: {image.shape[1]} x {image.shape[0]}") if image is None or image.size == 0: self.result.error_code = 7 self.result.error_message = "输入图像为空" logger.error("[错误] 输入图像为空") return self.result # Step 1: 预处理 self._preprocess_image(image) # Step 2: 检测定位块 if not self._detect_locators(): logger.error(f"[失败] 定位块检测失败, errorCode={self.result.error_code}") return self.result # Step 3: 验证定位块配对 if not self._validate_locator_pair(): logger.error(f"[失败] 定位块配对验证失败, errorCode={self.result.error_code}") return self.result # Step 3.5: 清晰度评估 big_blur, big_score = self._assess_locator_blur(self.big_locator) small_blur, small_score = self._assess_locator_blur(self.small_locator) self.result.blur_level = max(big_blur.value, small_blur.value) self.result.blur_score = min(big_score, small_score) logger.info(f"[清晰度] 大={big_score:.1f}, 小={small_score:.1f}, 等级={self.result.blur_level}") # Step 3.6: 杂物检测 paper_height = self.big_locator.height if paper_height < 30.0: self.result.error_code = 9 self.result.error_message = "试纸过小,可能为杂物" logger.error(f"[杂物检测] 试纸高度 {paper_height:.1f}px < 30px") return self.result if self.candidate_count > 15: self.result.error_code = 10 self.result.error_message = "场景过于杂乱" logger.error(f"[杂物检测] 候选数量 {self.candidate_count} > 15") return self.result # Step 4: 规格匹配 if not self._match_paper_spec(): logger.error(f"[失败] 规格匹配失败, errorCode={self.result.error_code}") return self.result # Step 5: 色块位置计算 self._calculate_color_block_centers() # Step 5.5: 生成标注图(红圈标记色块中心点) annotated_b64 = self._generate_annotated_image() # Step 6: 颜色读取 # 小定位块在左边:排序后 locator1 在左,如果 locator1 不是大定位块→小定位块在左边 small_locator_on_left = not self.locator1.is_big color_reader = ColorReader() self.result.colors = color_reader.read_colors( self.original_image, self.centers, small_locator_on_left, apply_wb=False, # 不白平衡:全图含背景会导致严重偏色 ) self.result.success = True self.result.error_code = 0 self.result.item_count = len(self.centers) self.result.annotated_image_b64 = annotated_b64 # 类型名称 self.result.paper_type_name = PAPER_TYPE_NAMES.get( self.result.paper_type, "未知") self.result.locator_color_name = LOCATOR_COLOR_NAMES.get( self.result.locator_color, "未知") logger.info(f"[成功] 识别完成! 联数={self.result.item_count}, " f"规格={self.result.paper_type_name}") logger.info("========== 识别结束 ==========") return self.result # ===== Step 1: 图像预处理 ===== def _preprocess_image(self, image: np.ndarray): """图像预处理""" self.original_image = image.copy() # 统一为BGR格式 if len(image.shape) == 2: self.process_image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) elif image.shape[2] == 4: self.process_image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) else: self.process_image = image.copy() # HSV和灰度 self.hsv_image = cv2.cvtColor(self.process_image, cv2.COLOR_BGR2HSV) self.gray_image = cv2.cvtColor(self.process_image, cv2.COLOR_BGR2GRAY) # 高斯滤波 self.gray_image = cv2.GaussianBlur(self.gray_image, (5, 5), 0) # ===== Step 2: 定位块检测 ===== def _detect_locators(self) -> bool: """检测定位块""" contours = self._find_candidate_contours() logger.info(f"检测到轮廓数量: {len(contours)}") if not contours: self.result.error_code = 1 self.result.error_message = "未找到轮廓" logger.error("[定位块检测] 未找到任何轮廓") return False return self._filter_locator_candidates(contours) def _find_candidate_contours(self) -> List[np.ndarray]: """查找候选轮廓: HSV颜色分割 + Canny备用""" # 策略1: HSV颜色分割 # 红色 (双范围) red_mask1 = cv2.inRange(self.hsv_image, (0, 70, 50), (10, 255, 255)) red_mask2 = cv2.inRange(self.hsv_image, (160, 70, 50), (180, 255, 255)) red_mask = cv2.bitwise_or(red_mask1, red_mask2) # 绿色 green_mask = cv2.inRange(self.hsv_image, (35, 70, 50), (85, 255, 255)) # 蓝色 (低明度,排除亮蓝绿色反应色块) blue_mask = cv2.inRange(self.hsv_image, (90, 50, 10), (150, 255, 120)) color_mask = cv2.bitwise_or(cv2.bitwise_or(red_mask, green_mask), blue_mask) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) color_mask = cv2.morphologyEx(color_mask, cv2.MORPH_CLOSE, kernel) color_mask = cv2.morphologyEx(color_mask, cv2.MORPH_OPEN, kernel) contours, _ = cv2.findContours(color_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 策略2: Canny边缘检测 (备用) if len(contours) < 2: edges = cv2.Canny(self.gray_image, 50, 150) edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return list(contours) def _filter_locator_candidates(self, contours: List[np.ndarray]) -> bool: """筛选候选定位块 + Y坐标聚类 + 配对验证""" candidates: List[LocatorInfo] = [] image_area = self.original_image.shape[0] * self.original_image.shape[1] min_area = MIN_CONTOUR_AREA max_area = image_area * MAX_AREA_RATIO for contour in contours: area = cv2.contourArea(contour) # 过滤1: 面积 if area < min_area or area > max_area: continue # 最小外接旋转矩形 rotated_rect = cv2.minAreaRect(contour) w = max(rotated_rect[1]) h = min(rotated_rect[1]) aspect_ratio = w / max(h, 1.0) # 过滤2: 可能是定位块? if not self._is_potential_locator(rotated_rect, area): continue # 过滤3: 矩形度 rect_area = rotated_rect[1][0] * rotated_rect[1][1] rectangularity = area / rect_area if rect_area > 0 else 0 if rectangularity < MIN_RECTANGULARITY: continue # 创建候选 info = LocatorInfo() info.rotated_rect = rotated_rect info.center = (rotated_rect[0][0], rotated_rect[0][1]) info.area = area info.width = w info.height = h info.color = LocatorColor.UNKNOWN # 判断大小 tolerance = 0.35 dist_big_s = abs(aspect_ratio - BIG_LOCATOR_RATIO_SHORT) dist_big_l = abs(aspect_ratio - BIG_LOCATOR_RATIO_LONG) dist_small = abs(aspect_ratio - SMALL_LOCATOR_RATIO) info.is_big = (dist_big_s < tolerance or dist_big_l < tolerance) and \ (dist_big_s < dist_small or dist_big_l < dist_small) # 过滤4: 面积范围 if info.is_big: if info.area < MIN_BIG_LOCATOR_AREA or info.area > MAX_BIG_LOCATOR_AREA: continue else: if info.area < MIN_SMALL_LOCATOR_AREA or info.area > MAX_SMALL_LOCATOR_AREA: continue candidates.append(info) if len(candidates) < 2: self.result.error_code = 1 self.result.error_message = "候选定位块数量不足" logger.error(f"[定位块检测] 候选数量不足: {len(candidates)} < 2") return False self.candidate_count = len(candidates) logger.info(f"候选定位块数量: {self.candidate_count}") # Y坐标聚类 y_groups = self._group_candidates_by_y(candidates) # 对每组尝试配对 valid_pairs = [] for i, group in enumerate(y_groups): logger.info(f"[分组配对] 尝试第 {i+1} 组, 共 {len(group)} 个候选") pair = self._find_valid_locator_pair_in_group(group) if pair is not None: valid_pairs.append(pair) # 跨组配对(处理倾斜试纸) if not valid_pairs and len(y_groups) > 1: logger.warning("[分组配对] 所有组内配对失败,尝试跨组配对") all_candidates = [] for group in y_groups: all_candidates.extend(group) pair = self._find_valid_locator_pair_in_group(all_candidates) if pair is not None: valid_pairs.append(pair) logger.info("[跨组配对] 成功找到配对!") if not valid_pairs: self.result.error_code = 2 self.result.error_message = "未找到有效定位块配对" logger.error("[定位块检测] 未找到有效配对 (需要一大一小,颜色一致,面积比正确)") return False # 多条试纸检测 if len(valid_pairs) > 1: self.result.error_code = 6 self.result.error_message = f"检测到 {len(valid_pairs)} 条试纸,请单独拍摄" logger.warning(f"[定位块检测] 检测到 {len(valid_pairs)} 条试纸") return False # 唯一有效配对 self.locator1 = valid_pairs[0][0] # big self.locator2 = valid_pairs[0][1] # small self.big_locator = self.locator1 self.small_locator = self.locator2 self.has_locator1 = True self.has_locator2 = True self._sort_locators_by_position() logger.info(f"[定位块检测成功] 大定位块颜色={self.big_locator.color.name}, " f"小定位块颜色={self.small_locator.color.name}") return True def _group_candidates_by_y(self, candidates: List[LocatorInfo]) -> List[List[LocatorInfo]]: """Y坐标聚类""" if not candidates: return [] # 按Y排序 candidates.sort(key=lambda c: c.center[1]) avg_height = sum(c.height for c in candidates) / len(candidates) y_threshold = avg_height * 3.0 groups = [] current = [candidates[0]] for i in range(1, len(candidates)): y_diff = candidates[i].center[1] - candidates[i-1].center[1] if y_diff < y_threshold: current.append(candidates[i]) else: groups.append(current) current = [candidates[i]] if current: groups.append(current) return groups def _find_valid_locator_pair_in_group(self, group: List[LocatorInfo]) \ -> Optional[Tuple[LocatorInfo, LocatorInfo]]: """在组内找有效配对,返回 (big, small) 或 None""" if len(group) < 2: return None # 按X排序 group.sort(key=lambda c: c.center[0]) for i in range(len(group)): for j in range(i + 1, len(group)): left = group[i] right = group[j] if left.is_big == right.is_big: continue big = left if left.is_big else right small = right if left.is_big else left # 延迟计算颜色 if big.color == LocatorColor.UNKNOWN: big.avg_color = self._get_center_region_color(big.rotated_rect) big.color = self._determine_locator_color_by_center(big.rotated_rect) if small.color == LocatorColor.UNKNOWN: small.avg_color = self._get_center_region_color(small.rotated_rect) small.color = self._determine_locator_color_by_center(small.rotated_rect) # 验证1: 颜色类型一致 if big.color == LocatorColor.UNKNOWN or small.color == LocatorColor.UNKNOWN: continue if big.color != small.color: continue # 验证2: RGB距离 color_dist = self._calculate_color_distance(big.avg_color, small.avg_color) if color_dist > COLOR_DISTANCE_THRESHOLD: continue # 验证3: 面积比 area_ratio = big.area / max(small.area, 1.0) error_s = abs(area_ratio - AREA_RATIO_SHORT) / AREA_RATIO_SHORT error_l = abs(area_ratio - AREA_RATIO_LONG) / AREA_RATIO_LONG area_error = min(error_s, error_l) if area_error > AREA_ERROR_RATIO: continue # 验证4: Y坐标差距 y_diff = abs(big.center[1] - small.center[1]) avg_h = (big.height + small.height) / 2.0 if y_diff > avg_h * 4.0: continue logger.info(f"[配对检测] 成功!") return (big, small) return None # ===== Step 3: 配对验证 ===== def _validate_locator_pair(self) -> bool: """验证定位块配对(颜色一致性)""" if not self.has_locator1 or not self.has_locator2: self.result.error_code = 2 self.result.error_message = "定位块未初始化" return False if self.locator1.color == LocatorColor.UNKNOWN or \ self.locator2.color == LocatorColor.UNKNOWN: self.result.error_code = 3 self.result.error_message = "定位块颜色未知" return False if self.locator1.color != self.locator2.color: self.result.error_code = 3 self.result.error_message = "定位块颜色不一致" return False color_dist = self._calculate_color_distance( self.locator1.avg_color, self.locator2.avg_color) logger.info(f"定位块颜色距离: {color_dist:.1f} (阈值: {COLOR_DISTANCE_THRESHOLD})") if color_dist > COLOR_DISTANCE_THRESHOLD: self.result.error_code = 3 self.result.error_message = "定位块颜色距离过大" return False self.result.locator_color = self.locator1.color logger.info(f"[配对验证成功] 定位块颜色: {self.result.locator_color.name}") return True # ===== Step 4: 规格匹配 ===== def _match_paper_spec(self) -> bool: """匹配试纸规格""" dx = self.locator2.center[0] - self.locator1.center[0] dy = self.locator2.center[1] - self.locator1.center[1] dist = math.sqrt(dx * dx + dy * dy) paper_height = self.big_locator.height if paper_height < 1: self.result.error_code = 4 self.result.error_message = "试纸高度无效" return False dist_ratio = dist / paper_height area_ratio = self.big_locator.area / max(self.small_locator.area, 1.0) mid_point = (AREA_RATIO_SHORT + AREA_RATIO_LONG) / 2.0 locator_type = BigLocatorType.BLT_SHORT if area_ratio < mid_point else BigLocatorType.BLT_LONG logger.info(f"--- 规格匹配参数 ---") logger.info(f"定位块中心距离: {dist:.2f}px") logger.info(f"试纸高度: {paper_height:.2f}px") logger.info(f"距离比: {dist_ratio:.3f}") logger.info(f"面积比: {area_ratio:.3f}") logger.info(f"大定位块类型: {'S型(10mm)' if locator_type == BigLocatorType.BLT_SHORT else 'L型(15mm)'}") self.result.paper_type = match_paper_config(dist_ratio, area_ratio, locator_type) if self.result.paper_type == PaperSpatialType.PST_UNKNOWN: self.result.error_code = 4 self.result.error_message = f"试纸规格未匹配 (distRatio={dist_ratio:.2f})" logger.error(f"[规格匹配失败] distRatio={dist_ratio:.2f} 不在任何规格范围内") return False self.config = PAPER_CONFIGS.get(self.result.paper_type) if self.config is None: self.result.error_code = 4 self.result.error_message = "配置未找到" return False logger.info(f"[规格匹配成功] 类型={self.result.paper_type_name}, " f"联数={self.config.items_num}") return True # ===== Step 5: 色块位置计算 ===== def _calculate_color_block_centers(self): """计算色块中心点坐标""" self.centers = [] if self.config is None or self.big_locator is None or self.small_locator is None: return # 判断小定位块位置 small_locator_on_left = not self.locator1.is_big # 方向向量 (从小定位块指向大定位块) dx = self.big_locator.center[0] - self.small_locator.center[0] dy = self.big_locator.center[1] - self.small_locator.center[1] dist = math.sqrt(dx * dx + dy * dy) if dist < 1: return unit_x = dx / dist unit_y = dy / dist big_w = self.big_locator.width small_w = self.small_locator.width # 小定位块边缘中心 (靠近大定位块一侧) small_edge_x = self.small_locator.center[0] + small_w * 0.5 * unit_x small_edge_y = self.small_locator.center[1] + small_w * 0.5 * unit_y # 大定位块边缘中心 (靠近小定位块一侧) big_edge_x = self.big_locator.center[0] - big_w * 0.5 * unit_x big_edge_y = self.big_locator.center[1] - big_w * 0.5 * unit_y effective_dx = big_edge_x - small_edge_x effective_dy = big_edge_y - small_edge_y effective_dist = math.sqrt(effective_dx * effective_dx + effective_dy * effective_dy) if effective_dist < 1: logger.error(f"[色块计算] 有效距离太小: {effective_dist:.2f}") return avg_dist = effective_dist / self.config.slices_num first_offset = avg_dist * self.config.first_pos step_offset = avg_dist * self.config.step logger.info(f"[色块计算] effectiveDist={effective_dist:.2f}, " f"slicesNum={self.config.slices_num}, " f"firstOffset={first_offset:.2f}, stepOffset={step_offset:.2f}") for i in range(self.config.items_num): offset = first_offset + step_offset * i cx = small_edge_x + offset * unit_x cy = small_edge_y + offset * unit_y self.centers.append((cx, cy)) logger.info(f"[色块计算] 成功计算 {len(self.centers)} 个色块中心") # ===== Step 5.5: 生成标注图 ===== def _generate_annotated_image(self) -> str: """生成标注图(红圈标记色块中心点),返回 base64 编码的 JPEG""" import base64 if self.original_image is None or not self.centers: return "" # 克隆原图 annotated = self.original_image.copy() # 确保 BGR 格式 if len(annotated.shape) == 2: annotated = cv2.cvtColor(annotated, cv2.COLOR_GRAY2BGR) elif annotated.shape[2] == 4: annotated = cv2.cvtColor(annotated, cv2.COLOR_BGRA2BGR) # 绘制色块中心红圈(C++ PaperRecognizer::drawAnnotations) for cx, cy in self.centers: cv2.circle(annotated, (int(cx), int(cy)), 6, (0, 0, 255), 2) # 编码为 JPEG base64 _, buf = cv2.imencode('.jpg', annotated, [cv2.IMWRITE_JPEG_QUALITY, 85]) b64 = base64.b64encode(buf).decode('utf-8') return b64 # ===== 辅助函数 ===== def _is_potential_locator(self, rect: cv2.RotatedRect, area: float) -> bool: """检查是否是潜在的定位块""" w = max(rect[1]) h = min(rect[1]) aspect_ratio = w / max(h, 1.0) # 排除正方形 if 0.9 <= aspect_ratio <= 1.1: return False # 排除过长形状 if aspect_ratio > 4.0: return False # 检查宽高比范围 tolerance = 0.35 is_big = (abs(aspect_ratio - BIG_LOCATOR_RATIO_SHORT) < tolerance or abs(aspect_ratio - BIG_LOCATOR_RATIO_LONG) < tolerance) is_small = abs(aspect_ratio - SMALL_LOCATOR_RATIO) < tolerance return is_big or is_small def _sort_locators_by_position(self): """按X坐标排序,locator1在左,locator2在右""" if self.locator1.center[0] > self.locator2.center[0]: # 交换两个定位块 self.locator1, self.locator2 = self.locator2, self.locator1 # 重新确定大/小定位块引用 self.big_locator = self.locator1 if self.locator1.is_big else self.locator2 self.small_locator = self.locator2 if self.locator1.is_big else self.locator1 def _calculate_color_distance(self, c1: Tuple[float, float, float], c2: Tuple[float, float, float]) -> float: """RGB欧氏距离""" db = c1[0] - c2[0] dg = c1[1] - c2[1] dr = c1[2] - c2[2] return math.sqrt(dr * dr + dg * dg + db * db) def _get_center_region_color(self, rect: cv2.RotatedRect) -> Tuple[float, float, float]: """获取旋转矩形中心30%区域的平均颜色""" new_size = (rect[1][0] * 0.3, rect[1][1] * 0.3) center_rect = (rect[0], new_size, rect[2]) roi = cv2.boxPoints(center_rect).astype(np.int32) # 使用boundingRect近似 x, y, w, h = cv2.boundingRect(roi) x = max(0, x); y = max(0, y) w = min(w, self.process_image.shape[1] - x) h = min(h, self.process_image.shape[0] - y) if w <= 0 or h <= 0: return (0.0, 0.0, 0.0) roi_mat = self.process_image[y:y+h, x:x+w] avg = cv2.mean(roi_mat) return (float(avg[0]), float(avg[1]), float(avg[2])) def _determine_locator_color_by_center(self, rect: cv2.RotatedRect) -> LocatorColor: """通过中心区域颜色判断定位块颜色""" avg = self._get_center_region_color(rect) b, g, r = avg blue_margin = 15.0 other_margin = 15.0 # RGB策略 if b > g + blue_margin and b > r + blue_margin: return LocatorColor.BLUE if g > b + other_margin and g > r + other_margin: return LocatorColor.GREEN if r > g + other_margin and r > b + other_margin: return LocatorColor.RED # HSV策略 x, y, w, h = cv2.boundingRect(cv2.boxPoints(rect).astype(np.int32)) x = max(0, x); y = max(0, y) w = min(w, self.hsv_image.shape[1] - x) h = min(h, self.hsv_image.shape[0] - y) if w > 0 and h > 0: # 取中心小区域 cx, cy = x + w // 2, y + h // 2 cw, ch = int(w * 0.3), int(h * 0.3) cx1 = max(0, cx - cw // 2) cy1 = max(0, cy - ch // 2) cx2 = min(self.hsv_image.shape[1], cx1 + cw) cy2 = min(self.hsv_image.shape[0], cy1 + ch) if cx2 > cx1 and cy2 > cy1: roi_hsv = self.hsv_image[cy1:cy2, cx1:cx2] avg_hsv = cv2.mean(roi_hsv) hue, sat = avg_hsv[0], avg_hsv[1] if hue >= 95 and hue < 130 and sat > 50: return LocatorColor.BLUE if hue > 35 and hue < 85 and sat > 50: return LocatorColor.GREEN if (hue < 25 or hue > 150) and sat > 50: return LocatorColor.RED return LocatorColor.UNKNOWN def _assess_locator_blur(self, locator: LocatorInfo) -> Tuple[BlurLevel, float]: """评估定位块区域清晰度 (Laplacian方差法)""" rect = locator.rotated_rect w, h = rect[1] expand = 0.15 x = int(rect[0][0] - w / 2 - w * expand) y = int(rect[0][1] - h / 2 - h * expand) rw = int(w + 2 * w * expand) rh = int(h + 2 * h * expand) x = max(0, x); y = max(0, y) rw = min(rw, self.gray_image.shape[1] - x) rh = min(rh, self.gray_image.shape[0] - y) if rw <= 0 or rh <= 0: return BlurLevel.BLUR_SEVERE, 0.0 roi = self.gray_image[y:y+rh, x:x+rw] laplacian = cv2.Laplacian(roi, cv2.CV_64F) _, stddev = cv2.meanStdDev(laplacian) score = float(stddev[0][0] * stddev[0][0]) if score > 300: return BlurLevel.BLUR_NONE, score elif score > 100: return BlurLevel.BLUR_SLIGHT, score else: return BlurLevel.BLUR_SEVERE, score # ============================================================================= # 便捷函数 # ============================================================================= def analyze_urine_strip(image_data: bytes) -> dict: """ 完整分析流程: bytes → 识别 → JSON结果 用于API路由调用 """ # 解码图片 nparr = np.frombuffer(image_data, np.uint8) image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if image is None: return { "success": False, "error_code": 7, "error_message": "无法解码图片", } # 识别 recognizer = UrineStripRecognizer() result = recognizer.recognize(image) # 构建响应 item_names = ITEM_NAMES_MAP.get(result.item_count, []) colors_output = [] for cr in result.colors: item = { "id": cr.id, "rgb": {"r": round(cr.r, 1), "g": round(cr.g, 1), "b": round(cr.b, 1)}, "hsv": {"h": round(cr.h, 1), "s": round(cr.s, 1), "v": round(cr.v, 1)}, "lab": {"l": round(cr.l, 1), "a": round(cr.a, 1), "b": round(cr.b_val, 1)}, } if cr.id < len(item_names): item["name"] = item_names[cr.id] colors_output.append(item) return { "success": result.success, "error_code": result.error_code, "error_message": result.error_message, "paper_type": result.paper_type_name, "locator_color": result.locator_color_name, "item_count": result.item_count, "blur_level": result.blur_level, "blur_score": round(result.blur_score, 1), "annotated_image_base64": result.annotated_image_b64, "colors": colors_output, }