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