2025, Nov 07 12:02
Надежное распознавание грани кубика без наклеек: YOLOv11 и простая постобработка
Надежное распознавание грани кубика без наклеек: откажитесь от контуров Canny. Используйте сегментацию YOLOv11 и простую проверку формы и цвета в OpenCV.
Надежно распознавать грани кубика Рубика без наклеек классическими методами обработки изображений сложнее, чем кажется. Границы между соседними кубиками одного цвета слабые или отсутствуют, а простые цветовые эвристики легко сбиваются из‑за фона. Если вы пробовали конвейеры контуров на базе Canny и уперлись в стену, этот материал предлагает практичный путь, который работает: переходите от краев к семантической сегментации и добавьте легкие геометрические и цветовые проверки.
Постановка задачи: почему классический подход дает сбои
Методы, основанные на выделении границ, хорошо работают с контрастными наклейками. На кубиках без наклеек соседние элементы могут иметь одинаковый оттенок, поэтому границы едва заметны или их вовсе нет. Фон нередко «вмешивается» с похожей насыщенностью и яркостью, что приводит либо к слипшимся контурам, либо к пропускам. Даже, казалось бы, очевидные трюки разваливаются: белые элементы не несут насыщенности, а насыщенность деревянного пола может соперничать с кубиком, поэтому простое маскирование по H/S не позволяет чисто изолировать грань.
Базовый подход, который буксует на практике
Ниже приведен конвейер OpenCV: он удаляет шум, размывает, извлекает границы, расширяет их и пытается найти контуры, похожие на квадраты. Такой код работает для граней с наклейками на темном фоне, но ненадежен для кубиков без наклеек.
import cv2 as cv
import numpy as np
from google.colab.patches import cv2_imshow
img_src = cv.imread('cube.png')
gray_img = cv.cvtColor(img_src, cv.COLOR_BGR2GRAY)
denoised_img = cv.fastNlMeansDenoising(gray_img, None, 20, 7, 7)
smeared_img = cv.blur(denoised_img, (3, 3))
edges_img = cv.Canny(smeared_img, 30, 60, 3)
thick_edges = cv.dilate(edges_img, cv.getStructuringElement(cv.MORPH_RECT, (9, 9)))
cnts, _ = cv.findContours(thick_edges, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)
boxes = []
for c in cnts:
approx_poly = cv.approxPolyDP(c, 0.1 * cv.arcLength(c, True), True)
if len(approx_poly) == 4 or True:
x, y, w, h = cv.boundingRect(approx_poly)
aspect = float(w) / h
area = cv.contourArea(approx_poly)
if aspect >= 0.8 and aspect <= 1.2 and w >= 30 and w <= 80 and area >= 900:
boxes.append({"x": x, "y": y, "w": w, "h": h})
vis = img_src.copy()
for b in boxes:
x, y, w, h = b["x"], b["y"], b["w"], b["h"]
cv.rectangle(vis, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imshow(vis)
Во многих реальных сценах это не разделяет элементы: соседние одинаково окрашенные кубики часто сливаются в один контур.
Коренные причины
Суть проблемы в том, что решающий признак — не в границах, а в семантике. Вы пытаетесь распознать конкретный объект и раскладку его грани при разном освещении и на разных фонах. Градиенты, пороги по уровню серого или эвристики по одному каналу — нестабильные сигналы для этой задачи. Как показывает практика, белые элементы не имеют насыщенности, а фон может давать сопоставимые насыщенность и яркость, поэтому чистая сегментация исключительно по цветовым каналам нереалистична. Подход должен интерпретировать формы и области целиком.
бросьте в это ИИ. он в этом хорош. не чат‑боты, а модели семантической сегментации.
Рабочее решение: сегментация YOLOv11 с легкой постобработкой
Обучите модель сегментации YOLOv11 на гранях кубика, затем выполните простые проверки на согласованность формы и цвета. Такой подход избегает хрупких краев и опирается на обученные маски. Подготовьте датасет в формате YOLOv11 Instance Segmentation и создайте data.yaml:
train: ../train/images
val: ../valid/images
test: ../test/images
nc: 6
names: ['Cube']
Установите ultralytics и запустите обучение:
!pip install ultralytics
from ultralytics import YOLO
seg_net = YOLO('best.pt')
seg_net.train(data='./data/data.yaml', epochs=100, batch=64, device='cuda')
После инференса проверьте кандидатную область. Ниже приведены проверки: область должна быть примерно квадратной, в основном заполненной, а каждый элемент — относительно однородным по целевому цветовому диапазону. Тест однородности выполняется в HSV с использованием заранее заданных color_ranges.
import cv2
import numpy as np
from ultralytics import YOLO
def looks_square(tile, tol=0.2):
h, w = tile.shape[:2]
r1, r2 = h / w, w / h
if r1 < 1 - tol or r1 > 1 + tol:
return False
if r2 < 1 - tol or r2 > 1 + tol:
return False
return True
def mostly_filled(tile, fill_thresh=0.85):
h, w, c = tile.shape
total = h * w * c
filled = np.sum(tile > 0)
return filled / total > fill_thresh
def passes_color_uniformity(tile, color, min_ratio):
if color not in color_ranges:
return False
hh, ww = tile.shape[:2]
hsv = cv2.cvtColor(tile, cv2.COLOR_BGR2HSV)
lower, upper = color_ranges[color]
mask = cv2.inRange(hsv, np.array(lower), np.array(upper))
return (np.count_nonzero(mask) / (hh * ww)) > min_ratio
def run_seg_inference(net: YOLO, frame):
return net(frame, verbose=False)
def extract_face_grid(outputs, n, homogenity_thres=0.6):
for _, pred in enumerate(outputs):
orig = pred.orig_img
H, W, _ = orig.shape
if pred.masks is not None:
for idx, mk in enumerate(pred.masks.data):
mask_np = (mk.cpu().numpy() * 255).astype(np.uint8)
if mask_np.shape[0] != orig.shape[0] or mask_np.shape[1] != orig.shape[1]:
mask_np = cv2.resize(mask_np, (W, H), interpolation=cv2.INTER_NEAREST)
mask_np, rect = simplify_mask(mask_np, eps=0.005)
masked = cv2.bitwise_and(orig, orig, mask=mask_np)
x1, y1, ww, hh = rect
x2, y2 = x1 + ww, y1 + hh
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(orig.shape[1], x2)
y2 = min(orig.shape[0], y2)
crop = masked[y1:y2, x1:x2]
if not looks_square(crop):
continue
if not mostly_filled(crop):
continue
tags, uniform = infer_colors_grid(crop, n, color_detection_model)
if sum([sum(row) for row in uniform]) < homogenity_thres * len(uniform) * len(uniform[0]):
continue
return tags, crop, mask_np, rect
return None, None, None, None
def infer_colors_grid(patch, n, color_detection_model):
h, w, _ = patch.shape
hh, ww = h // n, w // n
tag_grid = [['' for _ in range(n)] for __ in range(n)]
uniform_grid = [[False for _ in range(n)] for __ in range(n)]
for i in range(n):
for j in range(n):
cell = patch[i * hh:(i + 1) * hh, j * ww:(j + 1) * ww]
tag_grid[i][j] = find_best_matching_color_legacy(
get_median_color(cell), tpe='bgr')
uniform_grid[i][j] = passes_color_uniformity(cell, tag_grid[i][j], 0.5)
return tag_grid, uniform_grid
Запустите это на кадре и получите сетку грани, соответствующую вырезку и маску:
results_out = run_seg_inference(seg_net, current_frame)
face_grid, face_crop, face_mask, face_rect = extract_face_grid(results_out, n=grid_n, homogenity_thres=0.6)
Здесь модель сегментации формирует чистую маску объекта, а затем выполняются строгие проверки, соответствующие геометрии и цветовому устройству грани кубика. Там, где выделение границ сливает элементы или вовсе пропускает грань, обученная сегментация берет на себя основную работу. Благодаря этому вся последующая логика сводится к прямолинейной валидации и выборке по сетке.
Почему это важно
Грани без наклеек не гарантируют ни сильных контуров, ни простых порогов. Модель сегментации, обученная на ваших сценах, устойчивее к вариативности фона и освещения и при этом экономична в работе. Дополнительные проверки формы и однородности цвета делают конвейер детерминированным и понятным — можно доверять тому, что передается дальше в логику чтения цветов.
Выводы
Если контуры по краям не справляются с гранями без наклеек, перестаньте «бороться» с картинкой. Используйте модель семантической сегментации, чтобы надежно изолировать кубик, затем подтвердите детекцию простыми геометрическими ограничениями и проверками однородности по каждой ячейке. Снимайте цвет по сетке медианой и сопоставляйте его вашим текущим классификатором цветов. Эта комбинация устойчива при слабых границах, похожих фонах и разных условиях съемки, а конечный код остается ясным и поддерживаемым.
Статья основана на вопросе на StackOverflow от Tripaloski и ответе от Tripaloski.