Dish_Recognition/core/detect_plate_process.py

403 lines
15 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.

import os
import time
import cv2
import numpy as np
from ultralytics import YOLO
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from core.accel import resolve_yolo_weights
# ==================== 【配置区域】 ====================
_ROOT = Path(__file__).resolve().parents[1]
class Config:
# 你的Plate检测模型路径(相对项目根目录);若存在 best_openvino_model 会自动优先加载
MODEL_PATH = str(_ROOT / "best.pt")
CONF_THRESHOLD = 0.90
IOU_THRESHOLD = 0.45
# 显式 imgsz,便于 OpenVINO / CPU 控制耗时;可用环境变量 DISH_YOLO_IMGSZ 覆盖
IMGSZ = int(os.environ.get("DISH_YOLO_IMGSZ", "640"))
BOX_THICKNESS = 2
FONT_SIZE = 22
# 输出目录
OUTPUT_DIR = r"detect"
Cutting = r"cutting"
GENERATE_VISUALIZATION = True # 是否生成可视化图
# 默认类别名(加载权重后会被 model.names 覆盖)
LABEL_NAMES = {0: "Plate"}
COLORS = {'Plate': (0, 255, 0)}
# 检测到后直接丢弃,不进入裁剪/识别
FILTER_CLASS_NAMES = ("手", "空盘")
# ========================================================
def _load_filter_names(config):
names = getattr(config, "FILTER_CLASS_NAMES", None)
if names is None:
try:
from dao.settings import DISH_DETECT_FILTER_NAMES
names = DISH_DETECT_FILTER_NAMES
except Exception:
names = ("手", "空盘")
return {str(n).strip() for n in (names or []) if str(n).strip()}
def _load_edge_filter_params(config):
"""贴边/过小框过滤参数;可用 Config 覆盖,否则读 settings。"""
def _get(name, default):
if hasattr(config, name):
return getattr(config, name)
try:
from dao import settings as S
return getattr(S, name, default)
except Exception:
return default
return {
"edge_margin": int(_get("DISH_DETECT_EDGE_MARGIN_PX", 8)),
"edge_min_area_ratio": float(_get("DISH_DETECT_EDGE_MIN_AREA_RATIO", 0.02)),
"edge_min_side": int(_get("DISH_DETECT_EDGE_MIN_SIDE_PX", 48)),
"min_area_ratio": float(_get("DISH_DETECT_MIN_AREA_RATIO", 0.008)),
}
class YOLOv11PlateExtractor:
def __init__(self, config):
self.config = config
weights = resolve_yolo_weights(config.MODEL_PATH)
self.model = YOLO(weights)
self.weights_path = weights
# 以权重内类别为准
try:
model_names = getattr(self.model, "names", None) or {}
if isinstance(model_names, dict) and model_names:
self.config.LABEL_NAMES = {int(k): str(v) for k, v in model_names.items()}
except Exception:
pass
self.filter_class_names = _load_filter_names(config)
self.edge_filter = _load_edge_filter_params(config)
print(f"✅ Plate 模型加载成功: {weights}")
print(f"[OK] detect classes: {self.config.LABEL_NAMES}")
if self.filter_class_names:
print(f"[OK] detect filter drop: {sorted(self.filter_class_names)}")
ef = self.edge_filter
print(
f"[OK] edge/small box filter: margin={ef['edge_margin']}px "
f"edge_area<{ef['edge_min_area_ratio']:.3f} "
f"edge_side<{ef['edge_min_side']} "
f"min_area<{ef['min_area_ratio']:.3f}"
)
def predict(self, source):
"""source 可为图片路径或 BGR ndarray。"""
return self.model.predict(
source=source,
conf=self.config.CONF_THRESHOLD,
iou=self.config.IOU_THRESHOLD,
imgsz=getattr(self.config, "IMGSZ", 640),
verbose=False,
)[0]
def _class_name(self, results, cls_id):
cid = int(cls_id)
names = getattr(results, "names", None) or self.config.LABEL_NAMES or {}
if isinstance(names, dict):
return str(names.get(cid, names.get(str(cid), cid)))
return str(cid)
def _should_drop_class(self, class_name):
name = (class_name or "").strip()
if not name:
return False
return name in self.filter_class_names
def _should_drop_incomplete_box(self, box, img_w, img_h):
"""贴边且过小 / 面积过小 → 视为入画不完整,丢弃。"""
try:
x1, y1, x2, y2 = [float(v) for v in box[:4]]
except Exception:
return True
bw = max(0.0, x2 - x1)
bh = max(0.0, y2 - y1)
if bw <= 1 or bh <= 1 or img_w <= 0 or img_h <= 0:
return True
area_ratio = (bw * bh) / float(img_w * img_h)
ef = self.edge_filter or {}
min_area = float(ef.get("min_area_ratio") or 0)
if min_area > 0 and area_ratio < min_area:
return True
margin = max(0, int(ef.get("edge_margin") or 0))
touches = (
x1 <= margin
or y1 <= margin
or x2 >= (img_w - 1 - margin)
or y2 >= (img_h - 1 - margin)
)
if not touches:
return False
edge_area = float(ef.get("edge_min_area_ratio") or 0)
edge_side = int(ef.get("edge_min_side") or 0)
# 贴边且「面积小」或「短边过短」→ 多半是只露一角
if edge_area > 0 and area_ratio < edge_area:
return True
if edge_side > 0 and min(bw, bh) < edge_side:
return True
return False
def iter_kept_detections(self, results, image_shape=None):
"""产出保留的检测框:(box, conf, class_name)。类别/贴边不完整已过滤。"""
if results is None or results.boxes is None:
return
boxes = results.boxes.xyxy.cpu().numpy()
confs = results.boxes.conf.cpu().numpy()
if getattr(results.boxes, "cls", None) is not None:
clss = results.boxes.cls.cpu().numpy()
else:
clss = [0] * len(boxes)
img_h = img_w = None
if image_shape is not None and len(image_shape) >= 2:
img_h, img_w = int(image_shape[0]), int(image_shape[1])
dropped_cls = 0
dropped_edge = 0
for box, conf, cls_id in zip(boxes, confs, clss):
cname = self._class_name(results, cls_id)
if self._should_drop_class(cname):
dropped_cls += 1
continue
if img_w and img_h and self._should_drop_incomplete_box(box, img_w, img_h):
dropped_edge += 1
continue
yield box, float(conf), cname
if dropped_cls:
print(f"[detect] filtered {dropped_cls} box(es) by class {sorted(self.filter_class_names)}")
if dropped_edge:
print(f"[detect] filtered {dropped_edge} incomplete/edge box(es)")
def crop_plate(self, image, results):
cropped_regions = []
h, w = image.shape[:2]
for box, conf, cname in self.iter_kept_detections(results, image_shape=image.shape):
x1, y1, x2, y2 = map(int, box)
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(w, x2)
y2 = min(h, y2)
if x2 > x1 and y2 > y1:
crop = image[y1:y2, x1:x2]
if crop.size > 0:
cropped_regions.append({
"image": crop.copy(),
"conf": float(conf),
"box": [x1, y1, x2, y2],
"class_name": cname,
})
return cropped_regions
# 标注默认:大库红框 + 白字;今日菜单由 style 覆盖为绿框绿字
LABEL_BOX_COLOR = (229, 57, 53)
LABEL_CHIP_BG = (229, 57, 53, 220)
LABEL_CHIP_TEXT = (255, 255, 255, 255)
def _load_font(self, size=None):
size = size or self.config.FONT_SIZE
for font_name in ("simhei.ttf", "simsun.ttc", "msyh.ttc", "msyh.ttf"):
try:
return ImageFont.truetype(font_name, size)
except Exception:
continue
return ImageFont.load_default()
def _draw_box_and_chip(self, base_img, box, text, font, style=None):
"""描边框 + 标签芯片;style 可覆盖颜色(今日菜单绿 / 大库红 / 未知橙)。"""
style = style or {}
outline = style.get("box") or (229, 57, 53, 180)
chip_bg = style.get("chip_bg") or self.LABEL_CHIP_BG
chip_fg = style.get("chip_fg") or self.LABEL_CHIP_TEXT
thickness = int(style.get("box_width") or self.config.BOX_THICKNESS or 2)
x1, y1, x2, y2 = map(int, box)
rgba = base_img.convert("RGBA")
overlay = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
for t in range(max(1, thickness)):
odraw.rectangle(
[(x1 - t, y1 - t), (x2 + t, y2 + t)],
outline=outline,
)
try:
bbox = font.getbbox(str(text))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
except Exception:
tw, th = len(str(text)) * 10, 16
pad_x, pad_y = 8, 4
chip_h = th + pad_y * 2
chip_w = max(tw + pad_x * 2, chip_h)
chip_y = y1 - chip_h - 4 if y1 - chip_h - 4 > 0 else y1 + 4
chip_x = x1
# 圆形编号更贴近参考图
if str(text).isdigit() and len(str(text)) <= 2:
r = max(chip_h, 22) // 2 + 2
cx, cy = x1 + r + 2, max(y1 - r - 4, r + 2)
odraw.ellipse([(cx - r, cy - r), (cx + r, cy + r)], fill=chip_bg)
odraw.text((cx, cy), str(text), fill=chip_fg, font=font, anchor="mm")
else:
odraw.rectangle(
[(chip_x, chip_y), (chip_x + chip_w, chip_y + chip_h)],
fill=chip_bg,
)
odraw.text((chip_x + pad_x, chip_y + pad_y), str(text), fill=chip_fg, font=font)
return Image.alpha_composite(rgba, overlay).convert("RGB")
def visualize_results(self, image, results, save_path, labels=None):
"""
在检测框上绘制标签(已过滤手/空盘)。
labels 为 None 时画 类别+置信度;传入列表时画菜品名称(认菜不认盘展示)。
"""
img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
font = self._load_font()
kept = list(self.iter_kept_detections(results, image_shape=image.shape))
for i, (box, conf, cname) in enumerate(kept):
if labels is not None and i < len(labels):
text = str(labels[i])
else:
text = f"{cname} {conf:.2f}"
img = self._draw_box_and_chip(img, box, text, font)
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
img.save(save_path)
return save_path
def visualize_with_labels(self, image_or_path, boxes, labels, save_path, styles=None):
"""
在原图上按框绘制菜品名称(不依赖 YOLO results)。
:param image_or_path: BGR ndarray 或图片路径
:param boxes: [[x1,y1,x2,y2], ...]
:param labels: 与 boxes 对齐的菜品名称列表
:param styles: 可选,与 boxes 对齐的颜色样式 dict 列表
"""
if isinstance(image_or_path, str):
image = cv2.imread(image_or_path)
if image is None:
raise ValueError(f"无法读取图片: {image_or_path}")
else:
image = image_or_path
img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
font = self._load_font()
styles = styles or []
for i, (box, label) in enumerate(zip(boxes, labels)):
style = styles[i] if i < len(styles) else None
img = self._draw_box_and_chip(img, box, str(label), font, style=style)
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
img.save(save_path)
return save_path
def _load_bgr(self, image_or_path):
if isinstance(image_or_path, np.ndarray):
if image_or_path.size == 0:
return None
return image_or_path
if isinstance(image_or_path, (str, Path)):
image = cv2.imread(str(image_or_path))
return image
return None
def process_image(
self,
image_or_path,
stem=None,
save_crops=True,
visualize=None,
):
"""
统一检测入口:支持路径或 BGR ndarray。
:return: (visualize_save_path, crop_items, boxes)
crop_items: [{"image": ndarray, "path": str|None, "conf": float}, ...]
"""
image = self._load_bgr(image_or_path)
if image is None:
print(f"❌ 读取失败: {image_or_path}")
return None, [], []
if stem is None:
if isinstance(image_or_path, (str, Path)):
stem = Path(image_or_path).stem
else:
stem = f"frame_{int(time.time() * 1000)}"
# 内存直传,避免 YOLO 再读盘
results = self.predict(image)
cropped_regions = self.crop_plate(image, results)
do_viz = self.config.GENERATE_VISUALIZATION if visualize is None else bool(visualize)
visualize_save_path = None
if do_viz:
os.makedirs(self.config.OUTPUT_DIR, exist_ok=True)
visualize_save_path = self.visualize_results(
image, results,
os.path.join(self.config.OUTPUT_DIR, f"{stem}.jpg"),
)
crop_items = []
boxes = []
if save_crops:
os.makedirs(self.config.Cutting, exist_ok=True)
for i, region in enumerate(cropped_regions):
img_crop = region["image"]
path = None
if save_crops:
filename = f"{stem}_Plate_{i + 1}.jpeg"
path = os.path.join(self.config.Cutting, filename)
cv2.imwrite(path, img_crop)
crop_items.append({
"image": img_crop,
"path": path,
"conf": region.get("conf"),
"class_name": region.get("class_name"),
})
boxes.append(region["box"])
print(f"✅ {stem} → 裁剪完成,共 {len(cropped_regions)} 张")
return visualize_save_path, crop_items, boxes
def process_single_image(self, image_path):
"""
处理单张图片:检测餐盘、裁剪、生成初始可视化图。
:return: (visualize_save_path, cropped_paths, boxes)
boxes 与 cropped_paths 一一对应,供后续标注菜品名
"""
viz, crop_items, boxes = self.process_image(
image_path, save_crops=True, visualize=True,
)
cropped_paths = [c["path"] for c in crop_items if c.get("path")]
return viz, cropped_paths, boxes
def main():
cfg = Config()
app = YOLOv11PlateExtractor(cfg)
img_path = r"C:\Users\王含波\Desktop\文件\classfiy\20260422095806.jpg"
visualize_save_path, cropped_file_paths, boxes = app.process_single_image(img_path)
print("\n📦 裁剪后的图片路径:")
print(cropped_file_paths)
print("boxes:", boxes)
print("viz:", visualize_save_path)
if __name__ == "__main__":
main()