Files
nanocode/urinecheck/backend/urine_recognizer.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

1180 lines
42 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.
"""
尿检试纸颜色识别 - 核心算法模块
基于鸿蒙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,
}