Всем привет.
Сегодня я расскажу, как решал задачу по определению качества крупы гречневой через Computer Vision.
Задача состояла в том, что по фото с фотосепаратора (такая штука которая отделяет хорошие зерна от плохих) необходимо определить качество крупы: сколько доброкачественных ядер уходит в некондицию и сколько плохих остается. Размечал и обучал модель я на своем портале picva.ru который доступен всем желающим бесплатно.
Зачем это производству
На производственной линии крупа идет сплошным потоком. Фотосепаратор удаляет зерна, которые считает некондицией (плохими).
Если настройки задрать, в некондицию полетит много хорошей крупы, и предприятие несет потерю продукта. Если ослабить, черные и уже порченые крупинки останутся в продукте. Поэтому в лаборатории периодически фотографируют чистый продукт и некондицию и смотрят реальные проценты. По ним решают, что изменять в работе фотосепаратора.
Что было на входе
Заказчик прислал 24 кадра, по 8 на группу: хорошие, плохие и серые. Снимает линейная камера. Кадр — узкая полоска 2716 на 512, не обычное фото.
На кадре примерно 200–800 зерен. Типичная рамка около 28 на 12 пикселей.
Сверху фото: хорошая партия крупы, дальше плохая, дальше серая.



Нужно было посчитать три класса:
хорошие;
плохие, черные;
серые, пограничные.
Какую крупу считать серой, решала лаборатория. Модель только повторяет ее правило.
Сколько чего насчитали руками
Порция |
Крупинок |
Хорошие |
Серые |
Черные |
|---|---|---|---|---|
хорошая |
2322 |
86.3% |
13.7% |
0.0% |
плохая |
1632 |
3.7% |
22.9% |
73.4% |
серая |
5130 |
20.8% |
70.4% |
8.7% |
Хорошая крупа в некондиции — прямые потери. Тут его 3.7%. Плохое зерно, крупа в хорошей порции — неправильная настройка фотосепаратора. Черных там не нашлось, серых 13.7%.
Кусок серого кадра после ручной разметки. Желтые — серые, зеленые — хорошие, красные — черные.

Разметка
На picva.ru разметил 5 кадров и сразу обучил модель, YOLOv11. Обучение идет на GPU и пока бесплатно.
Остальные фото размечал уже с помощью этой модели. Сам портал так пока не умеет, поэтому доразметку делал через Cursor. Потом все равно пришлось много править руками: разметил еще 10 кадров, собрал новую модель и так дошел до всех 24.
Иногда думают, что ChatGPT или Claude размечают такое из коробки. На этих кадрах так не вышло: путают хорошее с серым. Я и сам на повторной разметке одних и тех же зерен совпадал с собой примерно на 83%. Это шум разметки, не жесткий потолок модели, но чудес на этой границе ждать не стоит.
Аугментация
24 фото для модели мало, поэтому размножил набор до 500 синтетических кадров. В picva такого пока нет.
Обычный поворот и яркость всего кадра тут не подходят. Съемка на просвет: фон подсвечен сзади и сильно гуляет, а сама крупа почти нет. Если осветлить кадр целиком, модель начнет путать класс с подсветкой.
Оставил такой набор:
не менять яркость всего кадра;
вырезать зерна и сажать их на новый фон;
без поворота, пиксель камеры не квадратный, зерно вытянуто примерно в 2.3 раза;
отражения можно;
цвет зерна почти не менять;
у YOLO выключить цветовой джиттер и mosaic, масштаб оставить маленьким, 0.15.
Модель
Финальную модель учил на этих 500 изображениях, снова через picva.ru. Пробовал v8 и v11, остановился на YOLO26 small, yolo26s, 60 эпох.
Почему вход 1536, а не 640
Кадр шириной 2716, а зерно по высоте около 12 пикселей. На входе 640 зерно сожмется до пары пикселей, и сеть его не увидит. Поставил 1536. Даже так на входе сети зерно остается примерно 15 на 6 пикселей.
Нарезать полоску на куски тоже пробовал: зерно становится крупнее, но обучение от этого портилось. Вернулся к целому кадру. Растягивать полоску по вертикали не стал, вход и так уже большой.
Лимит рамок
У YOLO по умолчанию максимум 300 найденных объектов на кадр, а у меня бывает почти 800 зерен. Поднял потолок. У YOLO26 этот потолок зашивается в ONNX при экспорте. Первый HTML на каждом кадре отдавал ровно 300 зерен и в плотной серой партии терял примерно половину. Пришлось переэкспортировать модель с большим лимитом.
Качество
Val: mAP50 77.6%, mAP50-95 46.9%, precision 71.5%, recall 74.0%
Test: mAP50 78.3%, mAP50-95 48.1%, precision 71.2%, recall 75.6%
mAP50-95 ниже, потому что зерно мелкое и рамка часто сидит не идеально. Для процентов по классам важнее, что зерно нашлось и класс не перепутан.
Как ошибается модель, лучше видно на картинке. Слева зерно, которое модель не нашла. В середине лишняя рамка там, где зерна слиплись. Справа по разметке серое, а модель сказала «хорошее».

Как отдать заказчику
Инференс можно гонять прямо на сайте. Лаборатории это не подошло: проверять нужно нечасто, но без интернета и без установки Python. Отдельная программа тоже слабый вариант. Она зависит от операционной системы, и на производстве на установщик смотрят как на подозрительный файл.
Поэтому один HTML-файл. Его скачивают, открывают двойным щелчком в Chrome или Edge и загружают пачку фото. Снимки никуда не уходят, считает компьютер в лаборатории.
Так выглядит страница после прогона. Открыт серый кадр, сверху ползунок уверенности. На странице цвета другие, чем на разметке выше: черные синие, хорошие зеленые, серые оранжевые.

Как модель оказалась внутри файла
После обучения модель лежит в ONNX. В браузере ее считает onnxruntime-web 1.20.1, это WebAssembly. Сам wasm-файл движка около 11 МБ.
И модель, и движок вшиты в HTML текстом, base64. Отдельного файла модели рядом нет. Страница, открытая с диска, по правилам браузера обычно не читает соседние файлы. Если оставить модель отдельно, страница либо полезет в интернет, либо просто промолчит.
Движок из коробки хочет скачать свой wasm, обычно с CDN. Я отдаю wasm уже готовыми байтами, у onnxruntime это параметр wasmBinary. Качать больше нечего.
Размер и время
Файл получается 67.4 МБ. Сама модель весит 37 МБ, base64 раздувает объем примерно на треть, плюс движок.
Мерил на ноутбуке Intel i7-13700H, браузер Chromium:
страница открывается примерно за 3 секунды, браузер на это время может подвиснуть;
один кадр считается около 3.7–3.9 секунды;
пачка из 24 кадров — около полутора минут.
Поток один. На странице, открытой как файл, многопоточный WebAssembly браузер не разрешает. Для лаборатории это нормально: им нужна периодическая проверка, а не конвейер.
Ползунок уверенности модель заново не гоняет. Прогон один, с порогом 0.05, а ползунок только прячет слабые рамки. По умолчанию показываю от 0.25. Справа сразу число объектов и доли по классам, и то же самое суммой по всей пачке.
Готовый html-файл с моделью можно также скачать с портала picva.ru (такой функционал уже есть)
Что вышло
Заказчик получил файл, который открывается без интернета и без установки программ. По пачке фото сразу видно доли хороших, серых и черных зерен. Ему этого хватило.
Picva я делаю сам: picva.ru. Там разметка, обучение YOLO на GPU и выгрузка такого HTML.
picva.ru - бесплатный портал для разметки и своих моделей. По смыслу это что-то вроде Roboflow, по коду это форк Label Studio, достаточно сильно измененный и с добавлением возможности обучения моделей. Есть обучение YOLO на GPU, проверка модели на своих фото. Модель можно выгрузить в том числе в onnx формате, запустить прямо на портале или выгрузить одним HTML-файлом как описано выше, который работает без интернета и без Python.
Если есть свой набор фото с дефектами, могу прогнать его через портал и посмотреть, обучается ли модель. Напишите на vopros@picva.ru.
В планах ИИ-помощник, аугментации и помощь с дообучением.
Большое спасибо если дочитали до конца. Буду рад обратной связи.
Комментарии (8)

RomanVelichkin
30.09.2026 00:39Насколько я помню, yolo26 имеет бесплатную лицензию только для некоммерческого использования.
ToxaBes
Ну, давайте попробуем. Вы большой молодец, что обучили модель и она распознает то что надо, но я думаю, что само CV в такой задаче немного избыточно.
В таких задачах прежде чем идти в CV, стоит посмотреть, что дает самый простой baseline, ведь для него созданы все условия: синяя подсветка (контрастный фон), зерно на нем хорошо отделяется и всего 3 класса различающиеся яркостью зерна.
Если baseline дает приемлемую точность, нейросеть не нужна. Если нет, он покажет, где именно проблема, и задаст планку, которую CV должен превзойти.
Давайте соберем baseline (буду делать на коленке т.к. поздно уже). Что мы имеем?
Фон подсвечен синим (B-канал около 252), значит там где есть зерно синий канал упадет ниже уровня фона. Яркие голубые полосы подсветки по краям кадра при этом зерном не считаются, потому что их B-канал остается высоким.
Понятно, что синий канал у нас самый шумный, поэтому в признаки его не берем. Работать будет в красном канале, а для дополнительного очищения от шума будем вычитать из него, например, 30% зеленого канала, т.е. возьмем признак яркости: R - 0.3*G.
Два порога T1, T2 будут делить пиксели зерна на три класса. Считать будем доли пикселей, а не зерен, что позволит нам не разделять слипшиеся зерна, ведь нам нужны общие доли.
Помимо этого обрежем лишнее по краям кадра и обрежем 1 пиксель с краев зерен тк это самые зашумленные места.
Для подбора T1, T2 по трем кадрам из статьи прогоним обычным полным перебором (grid search) по двум порогам, либо методом тыка возьмем T1 как ~30% от диапазона (255) и проверим окрестности вокруг него. Затем имея T1 подберем T2 любым приглянувшимся методом. У меня получилось T1 = 87, T2 = 145. Теперь пишем небольшой скрипт, который реализует описанное выше:
Скрипт на 30 строк, размером 1.3 Кб за ~25мс на CPU показал следующие результаты:
Хорошая партия: 83.3%/16.3%/0.4% насчитали руками: 86.3%/13.7%/0.0%
Плохая партия: 2.4%/24.1%/73.5% насчитали руками: 3.7%/22.9%/73.4%
Серая партия: 24.1%/63.4%/12.5% насчитали руками: 20.8%/70.4%/8.7%
Средняя абсолютная разница между baseline скриптом и тем что насчитали руками всего 2.5 п.п. При этом пачку из 24 кадров считает за 0.6 сек. Сам скрипт запускается элементарно:
grid.py image.jpg.Думаю, что пороги в скрипте можно подобрать еще качественнее, но я не тратил на это время.
U235U235
Отличный комментарий. Даже лучше чем статья, по моему мнению.
Можно даже несколько улучшить производительность, использовав cv2 вместо PIL. Еще, зная центры кластеров в rgb, можно более точно найти линейное преобразование вместо R-0.3G. Или просто использовать PCA для зерен, отфильтровав маской фон.
И для эрозии есть PIL.ImageFilter.MinFilter(size=3)
dedov_aa
Чувак в инфоцыгане метит а вы все обосрали, что за люди...
bakivh Автор
Спасибо за комментарий, все верно, задачу можно решить простым безлайном. Но вы верно отметили, что возможно потом надо будет править пороги и в целом поддерживать.
Цель платформы, что задачу разметки и построения модели сможет сделать не разработчик или датасайентист, а технолог на производстве самостоятельно. Грубо говоря - загрузил фото, разметил, нажал на кнопку, получил модель, запустил из браузера.
Аналог если интересно посмотрите - платформа roboflow
ToxaBes
Я прекрасно понимаю суть вашей платформы как аналога roboflow, мой комментарий был не о критике вашей платформы, а о том что выбранный пример не совсем удачный, но это никак не отменяет полезности того что вы делаете.
По поводу CV решения этой задачи, я думаю вы сможете улучшить результат если замените Yolo на RF-DETR, но тут скорее вопрос, а надо ли вообще более точно в этой задачи или текущего уровня достаточно.
RomanVelichkin
Отличное решение!
Не всегда нужно лезть за нейронками.