garbage-classifier / model.py
zhangruicong-ai
fix: 多物体识别异常保护
6aa11b1
Raw
History Blame Contribute Delete
53.7 kB
"""
垃圾图像分类模型 - 基于 DETR 主体检测 + CLIP 零样本图像分类
Garbage Classification Model - DETR object detection + CLIP zero-shot classification
"""
import os
import random
import logging
import time
from datetime import date as date_type
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Tuple, Optional
from knowledge_base import query_dna, get_knowledge_base
# Import transformers - will be lazy-loaded
transformers_available = False
try:
from transformers import pipeline
import torch
transformers_available = True
except ImportError:
pass
logger = logging.getLogger(__name__)
class GarbageClassifier:
"""垃圾分类分类器 - 使用 DETR 先检测主体区域,再用 CLIP 零样本分类"""
# COCO 类别中与垃圾无关的(人、动物等),检测到这些时忽略
SKIP_CATEGORIES = {
'person', 'bird', 'cat', 'dog', 'horse', 'sheep', 'cow',
'elephant', 'bear', 'zebra', 'giraffe', 'umbrella',
'snowboard', 'skis', 'surfboard', 'frisbee',
'baseball bat', 'baseball glove', 'tennis racket',
'car', 'truck', 'bus', 'motorcycle', 'bicycle',
'train', 'airplane', 'boat', 'traffic light',
'fire hydrant', 'stop sign', 'parking meter', 'bench',
}
def __init__(self, model_name: str = "openai/clip-vit-base-patch32"):
self.classifier = None
self.detector = None # DETR 目标检测模型(延迟加载)
self.ocr = None # TrOCR 文字识别模型(延迟加载)
self.model_name = model_name
self.device = self._detect_device()
self._executor = ThreadPoolExecutor(max_workers=2) # 并行推理线程池
self.max_image_pixels = 640 # 限制图片最长边(640px 足够识别,速度翻倍)
self.enable_ocr = os.getenv('GC_ENABLE_OCR', '').lower() in ('1', 'true', 'yes') # 默认关闭 OCR 加速
# 候选标签(英文 - CLIP 基于英文训练,使用详细描述提高准确率)
# 使用更具体的描述,帮助 CLIP 区分相似物品
self.candidate_labels = [
# === 常见可回收垃圾 ===
"plastic bottle for drinking", # 塑料瓶
"glass bottle for drinking", # 玻璃瓶
"aluminum can for soda or beer", # 易拉罐
"paper and cardboard for recycling", # 纸张纸板
"cardboard box for packaging", # 纸箱
"newspaper and magazines", # 报纸杂志
"clothing and textiles", # 衣物织物
"plastic bag for shopping", # 塑料袋
"metal tools and hardware", # 金属制品
"electronics and wires with circuit", # 电子产品
"wooden furniture or wood pieces", # 木材家具
"books and notebooks for school", # 书本
"glass jar with lid", # 玻璃罐
"iron and steel food cans", # 铁罐
"shoes and bags made of fabric", # 鞋包
"plastic container for food storage", # 塑料容器
"milk carton or tetra pak drink box", # 牛奶盒
"clean paper coffee cup", # 干净纸杯
"plastic bottle cap", # 瓶盖
# === 常见有害垃圾 ===
"battery cell for electronics", # 电池
"light bulb glass transparent", # 灯泡
"medicine pills and tablets", # 药品
"paint can with metal handle", # 油漆桶
"mercury thermometer glass", # 温度计
"pesticide spray bottle", # 杀虫剂
"fluorescent tube light", # 荧光灯管
"nail polish bottle small", # 化妆品
# === 常见厨余垃圾 ===
"food waste and leftovers on plate", # 食物残渣
"fruit and fruit peels organic waste", # 水果果皮
"banana peel yellow", # 香蕉皮
"apple core with seeds", # 苹果核
"vegetable leaves and green scraps", # 菜叶菜梗
"tea leaves and coffee grounds wet", # 茶叶咖啡渣
"egg shell broken pieces", # 蛋壳
"fish bones and chicken bones", # 鱼骨鸡骨
"nut shells hard brown", # 坚果壳
"leftover rice and noodles in bowl", # 剩饭剩面
"flowers and plants wilted", # 花草植物
"cooked meat and bones leftovers", # 骨头肉渣
"shrimp crab seafood shells", # 海鲜壳
# === 常见其他垃圾 ===
"styrofoam and foam packaging white", # 泡沫塑料
"disposable face mask blue", # 口罩
"ceramics and pottery plate or bowl", # 陶瓷
"tissue paper and napkins used", # 纸巾
"cigarette butt with filter", # 烟蒂
"diaper and sanitary pads", # 尿不湿
"dust and dirt pile", # 尘土
"broken glass not recyclable shards", # 碎玻璃
# === 中国常见特殊垃圾(详细描述以区分) ===
"instant noodle cup styrofoam cup with lid", # 泡面桶
"disposable wooden chopsticks pair", # 一次性筷子
"takeout food container plastic box", # 外卖盒
"bubble tea cup with plastic lid and straw", # 奶茶杯
"disposable bamboo chopsticks pair", # 竹筷
"wooden chopsticks reusable", # 木筷
"paper bowl disposable", # 纸碗
"wooden toothpick small", # 牙签
"plastic drinking straw", # 吸管
"wet wipes in package", # 湿巾
"cotton swab for ears", # 棉签
"bandage and adhesive tape medical", # 创可贴
"broken porcelain ceramic shards", # 碎瓷器
"disposable foam lunch box", # 泡沫饭盒
"plastic wrap and cling film", # 保鲜膜
]
# 标签 → 中国垃圾分类四分类映射
self.label_to_category: Dict[str, str] = {
# ♻️ 可回收垃圾 (Recyclable)
"plastic bottle for drinking": "可回收垃圾",
"glass bottle for drinking": "可回收垃圾",
"aluminum can for soda or beer": "可回收垃圾",
"paper and cardboard for recycling": "可回收垃圾",
"cardboard box for packaging": "可回收垃圾",
"newspaper and magazines": "可回收垃圾",
"clothing and textiles": "可回收垃圾",
"plastic bag for shopping": "可回收垃圾",
"metal tools and hardware": "可回收垃圾",
"electronics and wires with circuit": "可回收垃圾",
"wooden furniture or wood pieces": "可回收垃圾",
"books and notebooks for school": "可回收垃圾",
"glass jar with lid": "可回收垃圾",
"iron and steel food cans": "可回收垃圾",
"shoes and bags made of fabric": "可回收垃圾",
"plastic container for food storage": "可回收垃圾",
"milk carton or tetra pak drink box": "可回收垃圾",
"clean paper coffee cup": "可回收垃圾",
"plastic bottle cap": "可回收垃圾",
# ☣️ 有害垃圾 (Hazardous)
"battery cell for electronics": "有害垃圾",
"light bulb glass transparent": "有害垃圾",
"medicine pills and tablets": "有害垃圾",
"paint can with metal handle": "有害垃圾",
"mercury thermometer glass": "有害垃圾",
"pesticide spray bottle": "有害垃圾",
"fluorescent tube light": "有害垃圾",
"nail polish bottle small": "有害垃圾",
# 🍚 厨余垃圾 (Kitchen Waste)
"food waste and leftovers on plate": "厨余垃圾",
"fruit and fruit peels organic waste": "厨余垃圾",
"banana peel yellow": "厨余垃圾",
"apple core with seeds": "厨余垃圾",
"vegetable leaves and green scraps": "厨余垃圾",
"tea leaves and coffee grounds wet": "厨余垃圾",
"egg shell broken pieces": "厨余垃圾",
"fish bones and chicken bones": "厨余垃圾",
"nut shells hard brown": "厨余垃圾",
"leftover rice and noodles in bowl": "厨余垃圾",
"flowers and plants wilted": "厨余垃圾",
"cooked meat and bones leftovers": "厨余垃圾",
"shrimp crab seafood shells": "厨余垃圾",
# 🗑️ 其他垃圾 (Other/Residual)
"styrofoam and foam packaging white": "其他垃圾",
"disposable face mask blue": "其他垃圾",
"ceramics and pottery plate or bowl": "其他垃圾",
"tissue paper and napkins used": "其他垃圾",
"cigarette butt with filter": "其他垃圾",
"diaper and sanitary pads": "其他垃圾",
"dust and dirt pile": "其他垃圾",
"broken glass not recyclable shards": "其他垃圾",
"instant noodle cup styrofoam cup with lid": "其他垃圾",
"takeout food container plastic box": "其他垃圾",
"bubble tea cup with plastic lid and straw": "其他垃圾",
"disposable wooden chopsticks pair": "可回收垃圾",
"disposable bamboo chopsticks pair": "可回收垃圾",
"wooden chopsticks reusable": "可回收垃圾",
"paper bowl disposable": "可回收垃圾",
"wooden toothpick small": "其他垃圾",
"plastic drinking straw": "其他垃圾",
"wet wipes in package": "其他垃圾",
"cotton swab for ears": "其他垃圾",
"bandage and adhesive tape medical": "其他垃圾",
"broken porcelain ceramic shards": "其他垃圾",
"disposable foam lunch box": "其他垃圾",
"plastic wrap and cling film": "其他垃圾",
}
# === 通用物品识别标签 ===
# 用于"识物"功能 - 识别照片中的是什么物品(不限于垃圾)
self.general_object_labels = [
# 饮具
"water bottle", "glass cup", "coffee mug", "tea cup",
"aluminum can", "thermos", "wine glass", "beer bottle",
"plastic cup", "paper cup", "stainless steel bottle",
# 餐具
"plate", "bowl", "chopsticks", "fork", "spoon", "knife",
"ceramic bowl", "takeout box", "food container",
# 食物
"apple", "banana", "orange", "bread", "cake", "cookie",
"egg", "rice", "noodles", "sandwich", "pizza",
"chocolate bar", "candy wrapper", "fruit", "vegetable",
"watermelon", "grape", "strawberry", "corn",
"potato chip bag", "instant noodle", "soup",
# 电子产品
"cell phone", "laptop", "tablet", "headphones",
"charging cable", "remote control", "keyboard",
"computer mouse", "smart watch", "digital camera",
"power bank", "earphones",
# 服饰
"t-shirt", "pants", "shoe", "hat", "jacket", "sock",
"backpack", "handbag", "wallet", "belt",
"glove", "scarf", "sunglasses",
# 文具
"book", "notebook", "pen", "pencil", "scissors",
"tape dispenser", "newspaper", "magazine", "paper sheet",
"envelope", "cardboard box",
# 日用品
"key", "umbrella", "eyeglasses", "toothbrush",
"towel", "bar of soap", "shampoo bottle",
"tissue box", "trash bag", "plastic shopping bag",
"mirror", "clock", "pillow", "blanket", "cushion",
# 包装
"glass jar", "metal can", "milk carton",
"gift box", "plastic bottle", "cardboard box",
# 工具
"hammer", "screwdriver", "wrench", "pliers",
"measuring tape", "flashlight",
# 运动
"soccer ball", "basketball", "tennis ball",
"baseball", "frisbee", "jump rope",
# 特殊物品
"disposable face mask", "cigarette butt", "battery",
"light bulb", "medicine pill", "disposable diaper",
"wet wipe", "drinking straw", "toothpick",
"cotton swab", "bandage", "flower", "potted plant",
"toy", "stuffed animal",
# 个护
"lipstick", "makeup brush", "nail polish",
"comb", "hair brush", "razor",
# 清洁
"sponge", "cleaning cloth", "broom", "dustpan",
"laundry detergent bottle",
]
# 通用物品标签 → 中文名
self.general_label_zh: Dict[str, str] = {
"water bottle": "水瓶", "glass cup": "玻璃杯",
"coffee mug": "咖啡杯", "tea cup": "茶杯",
"aluminum can": "易拉罐", "thermos": "保温杯",
"wine glass": "酒杯", "beer bottle": "啤酒瓶",
"plastic cup": "塑料杯", "paper cup": "纸杯",
"stainless steel bottle": "不锈钢瓶",
"plate": "盘子", "bowl": "碗", "chopsticks": "筷子",
"fork": "叉子", "spoon": "勺子", "knife": "刀",
"ceramic bowl": "陶瓷碗", "takeout box": "外卖盒",
"food container": "食物容器",
"apple": "苹果", "banana": "香蕉", "orange": "橙子",
"bread": "面包", "cake": "蛋糕", "cookie": "饼干",
"egg": "鸡蛋", "rice": "米饭", "noodles": "面条",
"sandwich": "三明治", "pizza": "披萨",
"chocolate bar": "巧克力", "candy wrapper": "糖果包装",
"fruit": "水果", "vegetable": "蔬菜",
"watermelon": "西瓜", "grape": "葡萄",
"strawberry": "草莓", "corn": "玉米",
"potato chip bag": "薯片袋", "instant noodle": "泡面",
"soup": "汤",
"cell phone": "手机", "laptop": "笔记本电脑",
"tablet": "平板电脑", "headphones": "耳机",
"charging cable": "充电线", "remote control": "遥控器",
"keyboard": "键盘", "computer mouse": "鼠标",
"smart watch": "智能手表", "digital camera": "相机",
"power bank": "充电宝", "earphones": "耳塞",
"t-shirt": "T恤", "pants": "裤子", "shoe": "鞋子",
"hat": "帽子", "jacket": "外套", "sock": "袜子",
"backpack": "背包", "handbag": "手提包",
"wallet": "钱包", "belt": "腰带",
"glove": "手套", "scarf": "围巾", "sunglasses": "墨镜",
"book": "书本", "notebook": "笔记本", "pen": "笔",
"pencil": "铅笔", "scissors": "剪刀",
"tape dispenser": "胶带", "newspaper": "报纸",
"magazine": "杂志", "paper sheet": "纸张",
"envelope": "信封", "cardboard box": "纸箱",
"key": "钥匙", "umbrella": "雨伞",
"eyeglasses": "眼镜", "toothbrush": "牙刷",
"towel": "毛巾", "bar of soap": "肥皂",
"shampoo bottle": "洗发水瓶", "tissue box": "纸巾盒",
"trash bag": "垃圾袋", "plastic shopping bag": "塑料袋",
"mirror": "镜子", "clock": "时钟",
"pillow": "枕头", "blanket": "毯子", "cushion": "靠垫",
"glass jar": "玻璃罐", "metal can": "金属罐",
"milk carton": "牛奶盒", "gift box": "礼盒",
"plastic bottle": "塑料瓶",
"hammer": "锤子", "screwdriver": "螺丝刀",
"wrench": "扳手", "pliers": "钳子",
"measuring tape": "卷尺", "flashlight": "手电筒",
"soccer ball": "足球", "basketball": "篮球",
"tennis ball": "网球", "baseball": "棒球",
"frisbee": "飞盘", "jump rope": "跳绳",
"disposable face mask": "口罩",
"cigarette butt": "烟蒂", "battery": "电池",
"light bulb": "灯泡", "medicine pill": "药片",
"disposable diaper": "尿不湿", "wet wipe": "湿巾",
"drinking straw": "吸管", "toothpick": "牙签",
"cotton swab": "棉签", "bandage": "创可贴",
"flower": "花", "potted plant": "盆栽",
"toy": "玩具", "stuffed animal": "毛绒玩具",
"lipstick": "口红", "makeup brush": "化妆刷",
"nail polish": "指甲油", "comb": "梳子",
"hair brush": "发刷", "razor": "剃须刀",
"sponge": "海绵", "cleaning cloth": "抹布",
"broom": "扫把", "dustpan": "簸箕",
"laundry detergent bottle": "洗衣液瓶",
}
# DETR COCO 类别 → 中文名(用于展示检测结果)
self.coco_label_zh: Dict[str, str] = {
"person": "人", "bicycle": "自行车", "car": "汽车",
"motorcycle": "摩托车", "airplane": "飞机", "bus": "公交车",
"train": "火车", "truck": "卡车", "boat": "船",
"traffic light": "红绿灯", "fire hydrant": "消防栓",
"stop sign": "停车标志", "parking meter": "停车计时器",
"bench": "长椅", "bird": "鸟", "cat": "猫", "dog": "狗",
"horse": "马", "sheep": "羊", "cow": "牛", "elephant": "大象",
"bear": "熊", "zebra": "斑马", "giraffe": "长颈鹿",
"backpack": "背包", "umbrella": "雨伞",
"handbag": "手提包", "tie": "领带", "suitcase": "行李箱",
"frisbee": "飞盘", "skis": "滑雪板", "snowboard": "滑雪板",
"sports ball": "球", "kite": "风筝",
"baseball bat": "棒球棒", "baseball glove": "棒球手套",
"skateboard": "滑板", "surfboard": "冲浪板",
"tennis racket": "网球拍", "bottle": "瓶子",
"wine glass": "酒杯", "cup": "杯子", "fork": "叉子",
"knife": "刀", "spoon": "勺子", "bowl": "碗",
"banana": "香蕉", "apple": "苹果", "sandwich": "三明治",
"orange": "橙子", "broccoli": "西兰花", "carrot": "胡萝卜",
"hot dog": "热狗", "pizza": "披萨", "donut": "甜甜圈",
"cake": "蛋糕", "chair": "椅子", "couch": "沙发",
"potted plant": "盆栽", "bed": "床",
"dining table": "餐桌", "toilet": "马桶",
"tv": "电视", "laptop": "笔记本电脑", "mouse": "鼠标",
"remote": "遥控器", "keyboard": "键盘",
"cell phone": "手机", "microwave": "微波炉",
"oven": "烤箱", "toaster": "烤面包机", "sink": "水槽",
"refrigerator": "冰箱", "book": "书", "clock": "时钟",
"vase": "花瓶", "scissors": "剪刀",
"teddy bear": "泰迪熊", "hair drier": "吹风机",
"toothbrush": "牙刷",
}
# 分类详细信息
self.category_info: Dict[str, Dict] = {
"可回收垃圾": {
"bin_color": "blue",
"bin_name": "蓝色垃圾桶",
"icon": "♻️",
"description": "适宜回收利用和资源化利用的废弃物",
"examples": "废纸、塑料瓶、金属罐、玻璃、织物、电子产品",
"tip": "请保持清洁干燥,压扁后投放"
},
"有害垃圾": {
"bin_color": "red",
"bin_name": "红色垃圾桶",
"icon": "☣️",
"description": "对人体健康或自然环境造成直接或潜在危害的废弃物",
"examples": "废电池、废灯泡、过期药品、废油漆、杀虫剂",
"tip": "请小心轻放,避免破损泄漏"
},
"厨余垃圾": {
"bin_color": "green",
"bin_name": "绿色垃圾桶",
"icon": "🍚",
"description": "食品加工和消费过程中产生的剩菜剩饭等废弃物",
"examples": "剩饭剩菜、果皮果核、茶叶渣、蛋壳、骨头",
"tip": "请沥干水分,不要混入牙签、纸巾等杂物"
},
"其他垃圾": {
"bin_color": "gray",
"bin_name": "灰色/黑色垃圾桶",
"icon": "🗑️",
"description": "除可回收物、有害垃圾、厨余垃圾之外的废弃物",
"examples": "卫生纸、陶瓷碎片、尘土、烟蒂、使用过的口罩",
"tip": "尽量沥干水分,分类投放"
}
}
def _detect_device(self) -> str:
"""检测可用设备"""
if transformers_available:
try:
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch, 'mps') and torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
def _resize_if_needed(self, image: Image.Image) -> Image.Image:
"""限制图片最长边,减少计算量"""
w, h = image.size
if max(w, h) > self.max_image_pixels:
ratio = self.max_image_pixels / max(w, h)
new_w, new_h = int(w * ratio), int(h * ratio)
logger.info(f"[优化] 图片 {w}x{h} -> {new_w}x{new_h}")
return image.resize((new_w, new_h), Image.LANCZOS)
return image
def _load_model(self):
"""延迟加载 CLIP 分类模型"""
if self.classifier is not None:
return
if not transformers_available:
raise RuntimeError(
"transformers/torch 未安装。请运行: pip install transformers torch"
)
logger.info(f"[CLIP] 正在加载模型 {self.model_name}{self.device}...")
self.classifier = pipeline(
"zero-shot-image-classification",
model=self.model_name,
device=self.device if self.device != "cpu" else -1,
)
logger.info("[CLIP] 模型加载完成!")
def _load_detector(self):
"""延迟加载 DETR 目标检测模型"""
if self.detector is not None:
return
if not transformers_available:
raise RuntimeError(
"transformers/torch 未安装。请运行: pip install transformers torch"
)
logger.info("[DETR] 正在加载目标检测模型 facebook/detr-resnet-50...")
self.detector = pipeline(
"object-detection",
model="facebook/detr-resnet-50",
device=self.device if self.device != "cpu" else -1,
)
logger.info("[DETR] 目标检测模型加载完成!")
def _load_ocr(self):
"""延迟加载 TrOCR 文字识别模型"""
if self.ocr is not None:
return
if not transformers_available:
raise RuntimeError(
"transformers/torch 未安装。请运行: pip install transformers torch"
)
logger.info("[OCR] 正在加载文字识别模型 microsoft/trocr-small-printed...")
try:
from transformers import TrOCRProcessor, VisionEncoderDecoderModel
self._ocr_processor = TrOCRProcessor.from_pretrained("microsoft/trocr-small-printed")
self.ocr = VisionEncoderDecoderModel.from_pretrained("microsoft/trocr-small-printed")
self.ocr.to(self.device if self.device != "cpu" else "cpu")
self.ocr.eval()
logger.info("[OCR] 文字识别模型加载完成!")
except Exception as e:
logger.warning(f"[OCR] 模型加载失败,跳过文字识别: {e}")
self.ocr = None
def _extract_text(self, image: Image.Image) -> Optional[str]:
"""
用 TrOCR 从图片中提取文字(品牌名、标签等)
Args:
image: PIL Image
Returns:
str or None: 检测到的文字,如果没有则返回 None
"""
try:
self._load_ocr()
if self.ocr is None:
return None
import torch
pixel_values = self._ocr_processor(images=image, return_tensors="pt").pixel_values
pixel_values = pixel_values.to(self.device if self.device != "cpu" else "cpu")
with torch.no_grad():
generated_ids = self.ocr.generate(pixel_values, max_new_tokens=20)
text = self._ocr_processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
# 过滤无效结果:至少2个字符且包含字母数字
if len(text) >= 2 and any(c.isalnum() for c in text):
# 限制长度,只取前30个字符
text = text[:30]
logger.info(f"[OCR] 检测到文字: '{text}'")
return text
return None
except Exception as e:
logger.warning(f"[OCR] 文字识别异常: {e}")
return None
def _detect_and_crop(self, image: Image.Image) -> Tuple[Image.Image, Optional[Dict]]:
"""
用 DETR 检测图片中的主体物体,裁切到物体区域。
过滤掉无关类别(人、动物等),选择置信度最高的物体。
Args:
image: PIL Image
Returns:
(cropped_image, detection_info) - 裁切后的图片和检测信息
如果检测失败,返回原图和 None
"""
try:
self._load_detector()
# 保持原图尺寸,DETR 内部会处理缩放
detections = self.detector(image)
if not detections:
logger.info("[DETR] 未检测到任何物体,使用原图")
return image, None
logger.info(f"[DETR] 检测到 {len(detections)} 个物体")
for d in detections:
box = d['box']
bw = box['xmax'] - box['xmin']
bh = box['ymax'] - box['ymin']
logger.info(
f" - {d['label']}: {d['score']:.3f} "
f"[{bw}x{bh}]"
)
# 过滤掉无关类别(人、动物、交通工具等)
valid = [
d for d in detections
if d['label'] not in self.SKIP_CATEGORIES
]
# 如果没有有效检测,放宽限制(只排除 person)
if not valid:
valid = [
d for d in detections
if d['label'] not in {'person'}
]
# 仍然没有,就用全部检测结果
if not valid:
valid = detections
# 选出置信度最高的
best = max(valid, key=lambda x: x['score'])
# 置信度太低则放弃裁切
if best['score'] < 0.3:
logger.info(f"[DETR] 最高置信度 {best['score']:.3f} 过低,使用原图")
return image, None
box = best['box']
w, h = image.size
# 计算边界框尺寸
bw = box['xmax'] - box['xmin']
bh = box['ymax'] - box['ymin']
# 加 25% 边距,保留上下文
margin_x = bw * 0.25
margin_y = bh * 0.25
# 最小边距 20px,防止物体太小
margin_x = max(margin_x, 20)
margin_y = max(margin_y, 20)
x1 = max(0, int(box['xmin'] - margin_x))
y1 = max(0, int(box['ymin'] - margin_y))
x2 = min(w, int(box['xmax'] + margin_x))
y2 = min(h, int(box['ymax'] + margin_y))
cropped = image.crop((x1, y1, x2, y2))
logger.info(
f"[DETR] 主体 '{best['label']}' (conf={best['score']:.3f}) "
f"裁切区域 [{x1},{y1} -> {x2},{y2}]"
)
detection_info = {
'label': best['label'],
'score': best['score'],
'box': box,
}
return cropped, detection_info
except Exception as e:
logger.warning(f"[DETR] 目标检测异常,使用原图: {e}")
return image, None
def _identify_object(self, image: Image.Image, detection_info: Optional[Dict] = None, ocr_text: Optional[str] = None) -> Dict:
"""
识别图片中的是什么物品(识物功能)
策略:
1. OCR 文字(由调用方传入,已在并行中完成)
2. DETR 检测结果作为物体名称(高置信度时)
3. CLIP 通用物品标签作为后备
4. 组合文字 + 物品名 + 置信度生成自然描述
Args:
image: 裁切后的图片
detection_info: DETR 检测结果
ocr_text: 预提取的 OCR 文字(由调用方在并行线程中完成)
Returns:
dict: {name, name_zh, description, confidence, source, ocr_text}
"""
obj_name = None
obj_name_zh = None
obj_confidence = 0.0
obj_source = None
# 第一步: OCR 提取文字(由调用方并行完成,此处直接使用结果)
# ocr_text 已在 classify() 中通过 ThreadPoolExecutor 异步完成
# 第二步: DETR 检测结果
if detection_info and detection_info.get('score', 0) >= 0.5:
label_en = detection_info['label']
label_zh = self.coco_label_zh.get(label_en, label_en)
if label_en not in self.SKIP_CATEGORIES:
obj_name = label_en
obj_name_zh = label_zh
obj_confidence = detection_info['score']
obj_source = "detr"
# 第三步: CLIP 通用物品识别(如果 DETR 没有给出好结果)
if obj_source is None:
try:
self._load_model()
clip_results = self.classifier(
image, candidate_labels=self.general_object_labels
)
top = clip_results[0]
obj_name = top['label']
obj_name_zh = self.general_label_zh.get(obj_name, obj_name)
obj_confidence = top['score']
obj_source = "clip"
logger.info(
f"[识物] CLIP 检测: {obj_name_zh} ({obj_name}) "
f"置信度 {obj_confidence:.2%}"
)
except Exception as e:
logger.warning(f"[识物] 识别失败: {e}")
obj_name_zh = "未知物品"
# 第四步: 根据置信度和 OCR 文字生成自然描述
description = self._build_description(obj_name_zh, ocr_text, obj_confidence)
return {
"name": obj_name,
"name_zh": obj_name_zh or "未知物品",
"description": description,
"confidence": round(obj_confidence, 4),
"source": obj_source,
"ocr_text": ocr_text,
}
def _build_description(self, obj_name_zh: str, ocr_text: Optional[str], confidence: float) -> str:
"""
根据物品名、OCR 文字和置信度生成自然语言描述
Examples:
- "FoYes" + "塑料杯" + 高置信度 → "一个 FoYes 的塑料杯"
- 无文字 + "塑料杯" + 高置信度 → "塑料杯"
- 无文字 + "杯子" + 中置信度 → "一个类似杯子的物品"
- 无文字 + 低置信度 → "一个物品"
"""
if not obj_name_zh:
if ocr_text and len(ocr_text) >= 2:
return f"印有「{ocr_text}」的物品"
return "一个物品"
# 置信度足够高时,直接用物品名
if confidence >= 0.6:
if ocr_text and len(ocr_text) >= 2:
# 有文字:品牌名 + 物品名
return f"一个 {ocr_text}{obj_name_zh}"
return obj_name_zh
# 中等置信度:加不确定性措辞
if confidence >= 0.3:
if ocr_text and len(ocr_text) >= 2:
return f"印有「{ocr_text}」的{obj_name_zh}"
return f"可能是{obj_name_zh}"
# 低置信度:模糊描述
if ocr_text and len(ocr_text) >= 2:
return f"印有「{ocr_text}」的物品({obj_name_zh})"
return f"一个类似{obj_name_zh}的物品"
def classify(self, image_path: str) -> Dict:
"""
对传入的图片进行垃圾分类 + 物品识别
流程(并行优化版):
1. 限制图片尺寸以加速推理
2. 用 DETR 检测主体物体并裁切
3. 同时启动:OCR(后台线程)+ CLIP 垃圾分类(主线程)
4. DETR/CLIP 物品识别
5. 映射到中国四分类标准
Args:
image_path: 图片文件路径
Returns:
dict: 包含分类结果和物品识别结果的字典
"""
t_start = time.time()
self._load_model()
# 打开并限制图片大小
image = Image.open(image_path).convert("RGB")
image = self._resize_if_needed(image)
original_size = image.size
logger.info(f"输入图片: {original_size}")
# 第一步:DETR 主体检测 + 裁切(串行——需要图片本身)
cropped_image, detection_info = self._detect_and_crop(image)
logger.info(f"裁切后: {cropped_image.size}")
# 第二步:并行启动 OCR(后台线程)和 CLIP 垃圾分类(主线程)
ocr_text = None
if self.enable_ocr:
try:
self._load_ocr()
except Exception:
pass
ocr_future = self._executor.submit(self._extract_text, cropped_image)
else:
ocr_future = None
# 主线程:CLIP 垃圾分类
results = self.classifier(
cropped_image, candidate_labels=self.candidate_labels
)
# 等待 OCR 完成(非阻塞:超时则跳过 OCR)
if ocr_future is not None:
try:
ocr_text = ocr_future.result(timeout=15)
except Exception:
logger.warning("[OCR] 超时或失败,跳过文字识别")
# 第三步:物品识别(使用并行获取的 OCR 文本,不再额外耗时)
object_id = self._identify_object(cropped_image, detection_info, ocr_text)
# 取 top-3 结果
top3 = results[:3]
top_label = top3[0]["label"]
top_score = top3[0]["score"]
# 映射到垃圾类别
category = self.label_to_category.get(top_label)
if category is None:
category = "其他垃圾"
info = self.category_info.get(category, self.category_info["其他垃圾"])
# 构建预测详情
predictions = []
for r in top3:
cat = self.label_to_category.get(r["label"], "其他垃圾")
predictions.append({
"label": r["label"],
"label_zh": self._get_label_zh(r["label"]),
"score": round(r["score"], 4),
"category": cat
})
# 查询垃圾 DNA 知识库
dna_info = query_dna(top_label, self._get_label_zh(top_label))
elapsed = time.time() - t_start
logger.info(f"[分类] 总耗时 {elapsed:.1f}s")
return {
"success": True,
"item": top_label,
"item_zh": self._get_label_zh(top_label),
"confidence": round(top_score, 4),
"category": category,
"category_info": info,
"predictions": predictions,
"object": object_id, # 物品识别结果
"dna": dna_info, # 垃圾 DNA 信息
"detection": {
"object": detection_info['label'] if detection_info else None,
"confidence": detection_info['score'] if detection_info else None,
"cropped": detection_info is not None,
} if detection_info else None,
}
def _get_label_zh(self, label_en: str) -> str:
"""英文标签转中文"""
label_map = {
# 可回收
"plastic bottle for drinking": "塑料瓶",
"glass bottle for drinking": "玻璃瓶",
"aluminum can for soda or beer": "易拉罐",
"paper and cardboard for recycling": "纸张纸板",
"cardboard box for packaging": "纸箱",
"newspaper and magazines": "报纸杂志",
"clothing and textiles": "衣物织物",
"plastic bag for shopping": "塑料袋",
"metal tools and hardware": "金属制品",
"electronics and wires with circuit": "电子产品",
"wooden furniture or wood pieces": "木材家具",
"books and notebooks for school": "书本",
"glass jar with lid": "玻璃罐",
"iron and steel food cans": "铁罐",
"shoes and bags made of fabric": "鞋包",
"plastic container for food storage": "塑料容器",
"milk carton or tetra pak drink box": "牛奶盒",
"clean paper coffee cup": "干净纸杯",
"plastic bottle cap": "瓶盖",
# 有害
"battery cell for electronics": "电池",
"light bulb glass transparent": "灯泡",
"medicine pills and tablets": "药品",
"paint can with metal handle": "油漆桶",
"mercury thermometer glass": "温度计",
"pesticide spray bottle": "杀虫剂瓶",
"fluorescent tube light": "荧光灯管",
"nail polish bottle small": "化妆品",
# 厨余
"food waste and leftovers on plate": "食物残渣",
"fruit and fruit peels organic waste": "水果果皮",
"banana peel yellow": "香蕉皮",
"apple core with seeds": "苹果核",
"vegetable leaves and green scraps": "菜叶菜梗",
"tea leaves and coffee grounds wet": "茶叶咖啡渣",
"egg shell broken pieces": "蛋壳",
"fish bones and chicken bones": "鱼骨鸡骨",
"nut shells hard brown": "坚果壳",
"leftover rice and noodles in bowl": "剩饭剩面",
"flowers and plants wilted": "花草植物",
"cooked meat and bones leftovers": "骨头肉渣",
"shrimp crab seafood shells": "海鲜壳",
# 其他
"styrofoam and foam packaging white": "泡沫塑料",
"disposable face mask blue": "口罩",
"ceramics and pottery plate or bowl": "陶瓷",
"tissue paper and napkins used": "纸巾",
"cigarette butt with filter": "烟蒂",
"diaper and sanitary pads": "尿不湿/卫生巾",
"dust and dirt pile": "尘土",
"broken glass not recyclable shards": "碎玻璃",
"instant noodle cup styrofoam cup with lid": "泡面桶",
"takeout food container plastic box": "外卖盒",
"bubble tea cup with plastic lid and straw": "奶茶杯",
"disposable wooden chopsticks pair": "一次性筷子",
"disposable bamboo chopsticks pair": "一次性竹筷",
"wooden chopsticks reusable": "木筷",
"paper bowl disposable": "纸碗",
"wooden toothpick small": "牙签",
"plastic drinking straw": "吸管",
"wet wipes in package": "湿巾",
"cotton swab for ears": "棉签",
"bandage and adhesive tape medical": "创可贴",
"broken porcelain ceramic shards": "碎瓷器",
"disposable foam lunch box": "泡沫饭盒",
"plastic wrap and cling film": "保鲜膜",
}
return label_map.get(label_en, label_en)
def _classify_single_crop(self, cropped_image: Image.Image,
detection_info: Optional[Dict] = None,
ocr_text: Optional[str] = None) -> Dict:
"""
对单一裁剪区域进行垃圾分类(CLIP)+ 物品识别
Args:
cropped_image: 裁剪后的 PIL Image
detection_info: DETR 检测信息
ocr_text: 预提取的 OCR 文字
Returns:
dict: {label, label_zh, confidence, category, category_info,
object, dna, bbox}
"""
# CLIP 垃圾分类
results = self.classifier(
cropped_image, candidate_labels=self.candidate_labels
)
# 物品识别
object_id = self._identify_object(cropped_image, detection_info, ocr_text)
# Top-1 垃圾分类结果
top = results[0]
top_label = top["label"]
top_score = top["score"]
category = self.label_to_category.get(top_label, "其他垃圾")
info = self.category_info.get(category, self.category_info["其他垃圾"])
# 构造预测详情
predictions = []
for r in results[:3]:
cat = self.label_to_category.get(r["label"], "其他垃圾")
predictions.append({
"label": r["label"],
"label_zh": self._get_label_zh(r["label"]),
"score": round(r["score"], 4),
"category": cat,
})
# DNA 知识
dna_info = query_dna(top_label, self._get_label_zh(top_label))
bbox = detection_info.get('box') if detection_info else None
out = {
"label": top_label,
"label_zh": self._get_label_zh(top_label),
"confidence": round(top_score, 4),
"category": category,
"category_info": info,
"predictions": predictions,
"object": object_id,
"dna": dna_info,
}
if bbox:
out["bbox"] = bbox
if detection_info and detection_info.get('detr_label'):
out["detected_by"] = detection_info['detr_label']
out["detected_confidence"] = detection_info['detr_confidence']
return out
def classify_multi(self, image_path: str, max_objects: int = 4) -> Dict:
"""
多物体垃圾分类识别 - 同时检测并分类图片中的多个物体
流程:
1. DETR 检测所有物体
2. 过滤无关类别,按置信度排序取 top-N
3. 对每个物体裁剪并逐一 CLIP 分类
4. 汇总为数组返回
Args:
image_path: 图片文件路径
max_objects: 最多处理物体数(默认 4,过多会显著增加耗时)
Returns:
dict: {success, multi, total, objects: [...], overall}
"""
t_start = time.time()
self._load_model()
# 打开并限制图片大小
image = Image.open(image_path).convert("RGB")
image = self._resize_if_needed(image)
original_size = image.size
logger.info(f"[多物体] 输入图片: {original_size}")
# ---- 第一步:DETR 检测所有物体 ----
all_detections = self._detect_all_objects(image)
if not all_detections:
logger.info("[多物体] 未检测到有效物体,降级为单物体模式")
# 降级:对整图做一次分类
single = self.classify(image_path)
single["multi"] = False
single["total_objects"] = 0
single["objects"] = [single]
return single
logger.info(f"[多物体] 检测到 {len(all_detections)} 个有效物体")
# ---- 第二步:裁剪 + 并行 OCR(默认关闭) ----
objects_data = []
ocr_futures = {}
if self.enable_ocr:
try:
self._load_ocr()
except Exception as e:
logger.warning(f"[多物体] OCR 加载失败,跳过 OCR: {e}")
self.enable_ocr = False
for i, det in enumerate(all_detections[:max_objects]):
box = det['box']
w, h = image.size
bw, bh = box['xmax'] - box['xmin'], box['ymax'] - box['ymin']
margin_x, margin_y = max(bw * 0.2, 15), max(bh * 0.2, 15)
x1 = max(0, int(box['xmin'] - margin_x))
y1 = max(0, int(box['ymin'] - margin_y))
x2 = min(w, int(box['xmax'] + margin_x))
y2 = min(h, int(box['ymax'] + margin_y))
cropped = image.crop((x1, y1, x2, y2))
# 跳过过小的裁剪区域(CLIP 推理会异常)
if x2 - x1 < 10 or y2 - y1 < 10:
logger.warning(f"[多物体] 物体 {i} 裁剪区域过小 ({x2-x1}x{y2-y1}),跳过")
continue
# 对每个裁剪区域并行 OCR(仅在启用时)
if self.enable_ocr:
ocr_futures[i] = self._executor.submit(self._extract_text, cropped)
detection_info = {
'label': det['label'],
'score': det['score'],
'box': box,
'detr_label': det['label'],
'detr_confidence': det['score'],
}
objects_data.append({
'index': i,
'cropped': cropped,
'detection_info': detection_info,
'bbox': box,
})
# ---- 第三步:逐一 CLIP 分类 ----
classified_objects = []
for obj in objects_data:
i = obj['index']
# 等待该物体的 OCR 结果(最多 8s)
ocr_text = None
if i in ocr_futures:
try:
ocr_text = ocr_futures[i].result(timeout=8)
except Exception:
pass
# CLIP 分类(带异常保护,单个失败不影响其它物体)
try:
result = self._classify_single_crop(
obj['cropped'], obj['detection_info'], ocr_text
)
result['index'] = i
result['bbox'] = obj['bbox']
classified_objects.append(result)
except Exception as e:
logger.warning(f"[多物体] 物体 {i} 分类失败,跳过: {e}")
continue
# 如果所有物体都失败了,降级为单物体模式
if not classified_objects:
logger.info("[多物体] 所有物体分类失败,降级为单物体模式")
single = self.classify(image_path)
single["multi"] = False
single["total_objects"] = 0
single["objects"] = [single]
return single
# ---- 第四步:汇总 ----
# 按置信度排序,取最高置信度作为 overall 类别
classified_objects.sort(key=lambda x: x['confidence'], reverse=True)
# ...但展示时按检测到的空间顺序(左到右)排列
display_objects = sorted(classified_objects, key=lambda x: (
(x.get('bbox') or {}).get('xmin', 0) + (x.get('bbox') or {}).get('xmax', 0)
) / 2)
# 确定整体分类(统计各类别,取置信度加权最高的类别)
category_weights = {}
for obj in classified_objects:
cat = obj['category']
category_weights[cat] = category_weights.get(cat, 0) + obj['confidence']
overall_category = max(category_weights, key=category_weights.get)
elapsed = time.time() - t_start
logger.info(f"[多物体] 总耗时 {elapsed:.1f}s, 分类 {len(classified_objects)} 个物体")
return {
"success": True,
"multi": True,
"total_objects": len(classified_objects),
"objects": display_objects,
"overall": overall_category,
"overall_info": self.category_info.get(
overall_category, self.category_info["其他垃圾"]
),
}
def _detect_all_objects(self, image: Image.Image) -> List[Dict]:
"""
检测图片中所有与垃圾相关的物体
Returns:
[{label, score, box}, ...] 按置信度降序排列
"""
try:
self._load_detector()
detections = self.detector(image)
if not detections:
return []
# 过滤掉无关类别
valid = [
d for d in detections
if d['label'] not in self.SKIP_CATEGORIES
]
# 如果没有任何有效物体,放宽限制(只排除 person)
if not valid:
valid = [d for d in detections if d['label'] not in {'person'}]
if not valid:
valid = detections
# 按置信度降序排列
valid.sort(key=lambda x: x['score'], reverse=True)
# 过滤太低置信度的
valid = [d for d in valid if d['score'] >= 0.25]
logger.info(f"[DETR] 检测到 {len(detections)} 个物体,"
f"过滤后 {len(valid)} 个有效物体")
for d in valid:
box = d['box']
bw = box['xmax'] - box['xmin']
bh = box['ymax'] - box['ymin']
logger.info(f" - {d['label']}: {d['score']:.3f} [{bw}x{bh}]")
return valid
except Exception as e:
logger.warning(f"[DETR] 目标检测异常: {e}")
return []
def generate_daily_challenge(self, challenge_date: Optional[date_type] = None, count: int = 10) -> List[Dict]:
"""
生成每日挑战题目,基于日期确定性随机选取物品
策略:
1. 用日期字符串做种子,确保同日同题
2. 从 label_to_category 的垃圾标签中选取
3. 尽量覆盖 4 个类别(保证每个类别至少 1 题)
Args:
challenge_date: 挑战日期,默认为当天
count: 题目数量,默认 10
Returns:
[{name_en, name_zh, category}, ...]
"""
if challenge_date is None:
challenge_date = date_type.today()
# 用日期做确定性种子
seed_str = challenge_date.isoformat() # "2026-06-15"
seed = hash(seed_str) & 0xFFFFFFFF
rng = random.Random(seed)
# 收集所有有效标签(排除非垃圾类别)
items_by_category: Dict[str, List[Dict]] = {}
for label_en, cat in self.label_to_category.items():
label_zh = self._get_label_zh(label_en)
if cat not in items_by_category:
items_by_category[cat] = []
items_by_category[cat].append({
"name_en": label_en,
"name_zh": label_zh,
"category": cat,
})
categories = list(items_by_category.keys())
challenge = []
# 第一步:从每个类别至少选 1 题(4 个类别)
for cat in categories:
items = items_by_category[cat]
rng.shuffle(items)
challenge.append(items[0])
# 第二步:从剩余池中补全到 count 题
remaining_pool = []
for cat in categories:
items = items_by_category[cat]
already_taken = {c["name_en"] for c in challenge}
remaining_pool.extend([it for it in items if it["name_en"] not in already_taken])
rng.shuffle(remaining_pool)
needed = count - len(challenge)
challenge.extend(remaining_pool[:needed])
# 第三步:整体打乱顺序
rng.shuffle(challenge)
return challenge
# 命令行测试
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO)
if len(sys.argv) < 2:
print("用法: python model.py <图片路径>")
sys.exit(1)
clf = GarbageClassifier()
result = clf.classify(sys.argv[1])
print(f"\n{'='*50}")
print(f"检测物品: {result['item_zh']}")
print(f"置信度: {result['confidence']:.2%}")
print(f"分类: {result['category']}")
print(f"垃圾桶: {result['category_info']['bin_name']}")
print(f"提示: {result['category_info']['tip']}")
if result.get('detection') and result['detection'].get('cropped'):
print(f"\n[DETR] 检测到主体: {result['detection']['object']} "
f"(置信度 {result['detection']['confidence']:.2%})")
print(f"\nTop-3 预测:")
for p in result['predictions']:
print(f" - {p['label_zh']}: {p['score']:.2%} ({p['category']})")