Files
zhangpu da90a803e4 feat(core): 实现碗尺寸识别与标定系统
- 新增碗尺寸识别主应用界面,支持图片拖拽、选择和摄像头实时检测
- 界面显示碗的尺寸类别、直径、置信度及算法方法
- 支持计算食物重量,依据秤示数和碗重差值计算
- 实现核心检测模块,采用霍夫圆变换和轮廓法两种检测方法融合
- 检测结果包含尺寸分类和置信度估计,支持绘制调试信息
- 提供交互式标定工具,支持批量加载样本图片并自动检测直径
- 标定工具计算阈值和碗重量,生成并保存配置文件config.json
- 界面友好,提供当前状态提示和结果预览功能
2026-08-26 18:19:45 +08:00

247 lines
9.6 KiB
Python
Raw Permalink 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.
"""
bowl_detector.py
碗尺寸检测核心模块
检测逻辑:
1. 霍夫圆变换(HoughCircles- 最优先,精度高
2. 轮廓法(Contour + 最大类圆轮廓)- 备用
3. 两路结果取平均(如果都成功)- 提高鲁棒性
输入:BGR 图像(numpy array 或 文件路径)
输出:BowlDetectionResult,包含 size_label / pixel_diameter / confidence / debug_image
"""
import os
import json
import math
import cv2
import numpy as np
from dataclasses import dataclass
from typing import Optional, Tuple
from enum import Enum
class BowlSize(Enum):
SMALL = "小碗"
MEDIUM = "中碗"
LARGE = "大碗"
UNKNOWN = "未知"
@dataclass
class BowlDetectionResult:
size: BowlSize = BowlSize.UNKNOWN
pixel_diameter: float = 0.0
confidence: float = 0.0
method: str = "none"
center: Tuple[int, int] = (0, 0)
debug_image: Optional[object] = None
@property
def label(self) -> str:
return self.size.value
@property
def is_valid(self) -> bool:
"""直径检测成功即视为有效(未标定时 size=UNKNOWN 但直径仍可用于标定)"""
return self.pixel_diameter > 0
@property
def is_classified(self) -> bool:
"""不仅检测到直径,且完成了尺寸分类"""
return self.size != BowlSize.UNKNOWN and self.pixel_diameter > 0
class BowlDetector:
DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or self.DEFAULT_CONFIG_PATH
self.config = self._load_config()
def detect(self, image_input, draw_debug: bool = True) -> BowlDetectionResult:
img = self._load_image(image_input)
if img is None:
return BowlDetectionResult()
h, w = img.shape[:2]
cfg = self.config.get("detection", {})
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# CLAHE 对比度增强 —— 解决浅色碗+浅色背景对比度低的问题
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
blur_k = cfg.get("blur_kernel_size", 11)
blur_k = blur_k if blur_k % 2 == 1 else blur_k + 1
blurred = cv2.GaussianBlur(enhanced, (blur_k, blur_k), 0)
hough_result = self._detect_by_hough(blurred, w, h, cfg)
contour_result = self._detect_by_contour(blurred, w, h, cfg)
# 如果两路都失败,用更宽松参数再试一次(备用策略)
if hough_result is None and contour_result is None:
relaxed_cfg = dict(cfg)
relaxed_cfg["hough_param2"] = max(10, cfg.get("hough_param2", 25) // 2)
relaxed_cfg["canny_threshold1"] = max(5, cfg.get("canny_threshold1", 20) // 2)
relaxed_cfg["canny_threshold2"] = max(20, cfg.get("canny_threshold2", 60) // 2)
relaxed_cfg["min_circularity"] = 0.4
hough_result = self._detect_by_hough(blurred, w, h, relaxed_cfg)
contour_result = self._detect_by_contour(blurred, w, h, relaxed_cfg)
merged = self._merge_results(hough_result, contour_result)
if merged["diameter"] > 0:
size, confidence = self._classify_size(merged["diameter"])
cx, cy = merged["center"]
radius = int(merged["diameter"] / 2)
detection = BowlDetectionResult(
size=size,
pixel_diameter=merged["diameter"],
confidence=confidence,
method=merged["method"],
center=(cx, cy),
)
if draw_debug:
detection.debug_image = self._draw_debug(img.copy(), cx, cy, radius, detection)
else:
detection = BowlDetectionResult()
if draw_debug:
detection.debug_image = img.copy()
return detection
def reload_config(self):
self.config = self._load_config()
def is_calibrated(self) -> bool:
return self.config.get("calibrated", False)
def get_bowl_weight(self, size: BowlSize) -> float:
key = {BowlSize.SMALL: "small", BowlSize.MEDIUM: "medium", BowlSize.LARGE: "large"}.get(size, "")
return self.config.get("bowl_weights_grams", {}).get(key, 0.0)
def _load_config(self) -> dict:
if os.path.exists(self.config_path):
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def _load_image(self, image_input) -> Optional[object]:
if isinstance(image_input, str):
return cv2.imdecode(np.fromfile(image_input, dtype=np.uint8), cv2.IMREAD_COLOR)
elif isinstance(image_input, np.ndarray):
return image_input.copy()
return None
def _detect_by_hough(self, blurred_gray, w, h, cfg) -> Optional[dict]:
min_r = int(min(w, h) * cfg.get("hough_min_radius_ratio", 0.05))
max_r = int(min(w, h) * cfg.get("hough_max_radius_ratio", 0.50))
min_dist = int(min(w, h) * cfg.get("hough_min_dist_ratio", 0.30))
circles = cv2.HoughCircles(
blurred_gray, cv2.HOUGH_GRADIENT,
dp=cfg.get("hough_dp", 1.2), minDist=min_dist,
param1=cfg.get("hough_param1", 80), param2=cfg.get("hough_param2", 35),
minRadius=min_r, maxRadius=max_r,
)
if circles is not None:
circles = np.round(circles[0, :]).astype(int)
best = max(circles, key=lambda c: c[2])
cx, cy, r = best
return {"center": (cx, cy), "diameter": float(r * 2), "method": "hough"}
return None
def _detect_by_contour(self, blurred_gray, w, h, cfg) -> Optional[dict]:
edges = cv2.Canny(blurred_gray, cfg.get("canny_threshold1", 30), cfg.get("canny_threshold2", 100))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
edges = cv2.dilate(edges, kernel, iterations=1)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
img_area = w * h
min_area = img_area * cfg.get("min_area_ratio", 0.05)
max_area = img_area * cfg.get("max_area_ratio", 0.95)
min_circ = cfg.get("min_circularity", 0.60)
best_score, best_info = -1, None
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area or area > max_area:
continue
perimeter = cv2.arcLength(cnt, True)
if perimeter == 0:
continue
circularity = 4 * math.pi * area / (perimeter ** 2)
if circularity < min_circ:
continue
(cx, cy), radius = cv2.minEnclosingCircle(cnt)
score = circularity * area
if score > best_score:
best_score = score
best_info = {"center": (int(cx), int(cy)), "diameter": float(radius * 2), "method": "contour"}
return best_info
def _merge_results(self, hough, contour) -> dict:
if hough and contour:
d = (hough["diameter"] + contour["diameter"]) / 2
cx = (hough["center"][0] + contour["center"][0]) // 2
cy = (hough["center"][1] + contour["center"][1]) // 2
return {"center": (cx, cy), "diameter": d, "method": "hough+contour"}
elif hough:
return hough
elif contour:
return contour
return {"center": (0, 0), "diameter": 0.0, "method": "none"}
def _classify_size(self, diameter: float) -> Tuple[BowlSize, float]:
thresholds = self.config.get("thresholds", {})
small_max = thresholds.get("small_max_diameter", 0)
medium_max = thresholds.get("medium_max_diameter", 0)
if not self.is_calibrated() or small_max == 0 or medium_max == 0:
return BowlSize.UNKNOWN, 0.0
if diameter <= small_max:
conf = min(1.0, 0.7 + (small_max - diameter) / small_max * 0.3)
return BowlSize.SMALL, round(conf, 3)
elif diameter <= medium_max:
margin = medium_max - small_max
conf = min(1.0, 0.7 + min(diameter - small_max, medium_max - diameter) / margin * 0.3)
return BowlSize.MEDIUM, round(conf, 3)
else:
conf = min(1.0, 0.7 + (diameter - medium_max) / medium_max * 0.3)
return BowlSize.LARGE, round(conf, 3)
def _draw_debug(self, img, cx, cy, radius, result) -> object:
color_map = {
BowlSize.SMALL: (0, 200, 100),
BowlSize.MEDIUM: (0, 165, 255),
BowlSize.LARGE: (0, 80, 255),
BowlSize.UNKNOWN: (128, 128, 128),
}
color = color_map[result.size]
cv2.circle(img, (cx, cy), radius, color, 3)
cv2.circle(img, (cx, cy), 5, color, -1)
cv2.line(img, (cx - radius, cy), (cx + radius, cy), color, 1)
lines = [
f"{result.label}",
f"Diameter: {result.pixel_diameter:.0f}px",
f"Confidence: {result.confidence:.0%}",
f"Method: {result.method}",
]
for i, line in enumerate(lines):
y = 35 + i * 30
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 0, 0), 4, cv2.LINE_AA)
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, color, 2, cv2.LINE_AA)
return img
_default_detector: Optional[BowlDetector] = None
def detect_bowl_size(image_input, config_path: Optional[str] = None) -> BowlDetectionResult:
global _default_detector
if _default_detector is None or config_path:
_default_detector = BowlDetector(config_path)
return _default_detector.detect(image_input)