Своими рукамипрактическая инструкция

Учим модель на своих фотографиях: от первого набора до Raspberry Pi

Редакция Фермозавра·25 июля 2026 г.·13 мин чтения

Соберём снимки из хозяйства, договоримся о подписях и обучим небольшой классификатор. Самая интересная часть — открыть ошибки и понять, что модель заметила на фото.

Учим модель на своих фотографиях: от первого набора до Raspberry Pi

Готовая модель увидела на вашем листе что-то странное? Откройте снимок, сверьте формат программы и найдите свою культуру в её классах. После первого запуска на Raspberry Pi можно попробовать обучение на фотографиях хозяйства. Пройдём путь от понятных подписей до своей модели и разберём её ответы.

Здесь разберём полный учебный цикл: собрать фотографии, проверить подписи, разделить растения между выборками, обучить классификатор и перенести его на Pi. Основы архитектур — в сравнении CNN, RNN и LLM; организация съёмки — в статье об ESP32-CAM для наблюдений. Это лабораторная классификация одного изображения; причины заболеваний и решения об обработке проверяются отдельно.

Выбираем одну культуру и понятные признаки

Начните с признака, который можно уверенно подписать. Если виден белый налёт, описывайте именно его, пока причина не подтверждена. Названия заболеваний сохраняйте точными: фитофтороз и бурая пятнистость требуют разных определений. Такая ясность поможет и разметчику, и проверке модели.

Сделайте короткую карточку каждого класса: что в него входит, кто проверяет примеры и куда откладывать сомнения. «Без видимых симптомов» хорошо описывает наблюдение. Неясные кадры сохраняйте отдельно до проверки; способность отвергать незнакомые изображения потребуется оценивать специально.

Собираем разные растения, места и условия

Считайте независимые растения и серии вместе с числом файлов. Несколько кадров одного листа удобны для опыта, но разнообразие появится с другими растениями и условиями. Первую подборку используйте для проверки программы и понятности подписей.

Посмотрите, каких сортов, стадий и условий света не хватает. Добавляйте реальные примеры там, где проверка показывает пробел. Для редких состояний ведите сбор по мере их появления и сохраняйте происхождение каждого кадра.

Организуем съёмку, которую можно повторить

  1. Определите будущий способ использования: отдельный лист крупным планом или кадр стационарной камеры. Обучающие изображения должны соответствовать этой задаче.
  2. Выбирайте расстояние по фокусировке камеры и видимости признака. Универсальной дистанции для всех объективов нет.
  3. Снимайте разные растения при характерном для работы освещении. Для разных классов используйте сопоставимые фоны и условия.
  4. Сохраняйте исходные фотографии; отметки и подписи ведите отдельно, чтобы модель не училась распознавать маркер или табличку.
  5. Записывайте идентификатор растения, дату, участок, камеру и основание подписи. Пример имени файла: gh2_plant014_20260927_001.jpg; это условная схема именования.
  6. Смазанные, слишком тёмные и неразличимые кадры отправляйте на повторную съёмку или в отдельный архив качества.

Разделяем растения до начала обучения

Создайте dataset/manifest.csv: путь к файлу, класс, группа и выборка. Снимки можно держать в удобных подпапках, а таблица поможет проследить родственные серии. Замените условное symptom_a своим однозначно описанным признаком.

path,label,group,split
photos/a01.jpg,no_visible_symptoms,plant_a,train
photos/a02.jpg,symptom_a,plant_b,train
photos/b01.jpg,no_visible_symptoms,plant_c,val
photos/b02.jpg,symptom_a,plant_d,val
photos/c01.jpg,no_visible_symptoms,plant_e,test
photos/c02.jpg,symptom_a,plant_f,test

Шесть строк ниже показывают формат таблицы. В train модель учится, val помогает выбрать настройки и остановку, test ждёт итоговой оценки. Все фотографии одного растения и их производные поместите в одну группу и выборку. Если связана целая сессия или соседние растения, расширьте группу до этого уровня.

Группы распределяют до обучения. В этом примере каждый класс должен быть представлен во всех трёх выборках; при нехватке независимых групп скрипт останавливается. Он также обнаруживает одинаковые файлы в разных выборках, но не распознаёт визуально похожие кадры с другими именами: это остаётся задачей подготовки данных. Принцип разделения связанных наблюдений описан в документации scikit-learn.

Берём готовую основу и учим новый выходной слой

Используем MobileNetV2 с весами ImageNet и новым выходным слоем под свои классы. Основную сеть оставим замороженной: обучается только классификатор поверх извлечённых признаков. Такой этап переноса обучения описан в официальном руководстве TensorFlow. Частичное размораживание основы — отдельный эксперимент после этого этапа, с меньшим шагом обучения и повторным выбором настроек по val; приведённый скрипт его не выполняет.

Для опыта выбраны кадр 224×224, alpha=0.35, пакет из восьми изображений, до десяти эпох и Adam с шагом 1e-3. EarlyStopping наблюдает за потерей на val: после трёх эпох без улучшения останавливается и возвращает лучшие веса. Посмотрите на этот ход обучения прежде, чем менять параметры.

MobileNetV2 ожидает значения от −1 до 1. Поэтому преобразование x / 127.5 − 1 встроено в модель слоем Rescaling. Обе программы ниже передают в неё RGB в диапазоне 0–255, учитывают ориентацию EXIF и одинаково меняют размер через Pillow BILINEAR. Дополнительно делить пиксели на 255 нельзя.

Испытанное окружение — Linux ARM64, Python 3.12, TensorFlow 2.21.0, Keras 3.15.1, NumPy 2.5.3, Pillow 12.3.0 и ai-edge-litert 2.2.0. Команды выполняются на компьютере для обучения в отдельном каталоге. Установка пакетов и первая загрузка весов требуют Интернета; сами фотографии программа не отправляет наружу. Совместимость этих команд с другими ОС и версиями Python здесь не проверена.

python3.12 -m venv .venv
. .venv/bin/activate
python -m pip install --only-binary=:all: tensorflow==2.21.0 keras==3.15.1 numpy==2.5.3 pillow==12.3.0 ai-edge-litert==2.2.0
python -m pip check

Сохраните следующий файл как train.py рядом с папкой dataset. Скрипт намеренно использует CPU и ограничивает число потоков. Он принимает только одиночные JPEG/PNG; ограничения 20 MiB и 16 миллионов пикселей выбраны автором для этого примера. Исходники большего размера заранее подготовьте одинаковым способом для всех выборок, сохранив оригиналы отдельно.

import os
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
import argparse, csv, hashlib, json, re
from pathlib import Path
import numpy as np
from PIL import Image, ImageOps
import tensorflow as tf
from ai_edge_litert.interpreter import Interpreter

tf.config.threading.set_intra_op_parallelism_threads(2)
tf.config.threading.set_inter_op_parallelism_threads(1)
SIZE = 224

def pixels(path):
    if path.stat().st_size > 20 * 1024 * 1024:
        raise ValueError('Файл больше 20 MiB')
    with Image.open(path) as im:
        if im.format not in ('JPEG', 'PNG') or getattr(im, 'n_frames', 1) != 1:
            raise ValueError('Нужен одиночный JPEG или PNG')
        if im.width * im.height > 16_000_000:
            raise ValueError('Изображение больше 16 млн пикселей')
        im = ImageOps.exif_transpose(im).convert('RGB')
        return np.asarray(im.resize((SIZE, SIZE), Image.Resampling.BILINEAR), dtype=np.float32)

def read_manifest(root):
    root = root.resolve()
    with (root / 'manifest.csv').open(newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        if reader.fieldnames != ['path', 'label', 'group', 'split']:
            raise ValueError('Заголовок: path,label,group,split')
        rows = list(reader)
    if not rows:
        raise ValueError('Пустая разметка')
    groups, hashes, paths = {}, {}, set()
    for r in rows:
        if None in r or any(not v for v in r.values()):
            raise ValueError('Пустая или лишняя ячейка')
        if r['split'] not in ('train', 'val', 'test') or not re.fullmatch(r'[a-z][a-z0-9_]*', r['label']):
            raise ValueError('Неверный split или label')
        p = (root / r['path']).resolve()
        if not p.is_relative_to(root) or not p.is_file() or p in paths:
            raise ValueError('Неверный или повторный путь')
        paths.add(p)
        pixels(p)
        digest = hashlib.sha256(p.read_bytes()).hexdigest()
        for seen, key in ((groups, r['group']), (hashes, digest)):
            if key in seen and seen[key] != r['split']:
                raise ValueError('Утечка: группа или одинаковый файл в разных выборках')
            seen[key] = r['split']
        r['file'] = p
    labels = sorted({r['label'] for r in rows})
    if len(labels) < 2:
        raise ValueError('Нужно минимум два класса')
    for split in ('train', 'val', 'test'):
        if {r['label'] for r in rows if r['split'] == split} != set(labels):
            raise ValueError('Каждый класс нужен в train, val и test')
    return rows, labels

def dataset(rows, labels, split, batch):
    selected = [r for r in rows if r['split'] == split]
    def generate():
        for r in selected:
            yield pixels(r['file']), np.int32(labels.index(r['label']))
    ds = tf.data.Dataset.from_generator(generate, output_signature=(
        tf.TensorSpec((SIZE, SIZE, 3), tf.float32), tf.TensorSpec((), tf.int32)))
    if split == 'train':
        ds = ds.shuffle(len(selected), seed=42)
    options = tf.data.Options()
    options.threading.private_threadpool_size = 1
    return ds.batch(batch).with_options(options), selected

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('dataset', type=Path)
    parser.add_argument('output', type=Path)
    parser.add_argument('--epochs', type=int, default=10)
    parser.add_argument('--batch', type=int, default=8)
    args = parser.parse_args()
    if args.epochs < 1 or args.batch < 1:
        raise ValueError('epochs и batch должны быть положительными')
    rows, labels = read_manifest(args.dataset)
    args.output.mkdir(parents=True, exist_ok=False)
    tf.keras.utils.set_random_seed(42)
    train, _ = dataset(rows, labels, 'train', args.batch)
    val, _ = dataset(rows, labels, 'val', args.batch)
    test, test_rows = dataset(rows, labels, 'test', args.batch)
    base = tf.keras.applications.MobileNetV2(
        input_shape=(SIZE, SIZE, 3), alpha=0.35, include_top=False, weights='imagenet')
    base.trainable = False
    inputs = tf.keras.Input(shape=(SIZE, SIZE, 3))
    x = tf.keras.layers.Rescaling(1 / 127.5, offset=-1)(inputs)
    x = base(x, training=False)
    x = tf.keras.layers.GlobalAveragePooling2D()(x)
    outputs = tf.keras.layers.Dense(len(labels), activation='softmax')(x)
    model = tf.keras.Model(inputs, outputs)
    model.compile(optimizer=tf.keras.optimizers.Adam(1e-3),
                  loss='sparse_categorical_crossentropy', metrics=['accuracy'])
    history = model.fit(train, validation_data=val, epochs=args.epochs, verbose=2,
        callbacks=[tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=3,
                                                   restore_best_weights=True)])
    model.save(args.output / 'model.keras')
    converter = tf.lite.TFLiteConverter.from_keras_model(model)
    data = converter.convert()
    (args.output / 'model.tflite').write_bytes(data)
    runtime = Interpreter(model_content=data, num_threads=1)
    runtime.allocate_tensors()
    inp, out = runtime.get_input_details()[0], runtime.get_output_details()[0]
    if list(inp['shape']) != [1, SIZE, SIZE, 3] or inp['dtype'] != np.float32:
        raise ValueError('Неожиданный вход TFLite')
    matrix = np.zeros((len(labels), len(labels)), dtype=np.int64)
    max_error = 0.0
    for r in test_rows:
        image = pixels(r['file'])[None]
        expected = model(image, training=False).numpy()[0]
        runtime.set_tensor(inp['index'], image)
        runtime.invoke()
        scores = runtime.get_tensor(out['index'])[0]
        if not np.allclose(scores, expected, atol=1e-4, rtol=1e-4):
            raise ValueError('Выход TFLite отличается от Keras')
        max_error = max(max_error, float(np.max(np.abs(scores - expected))))
        matrix[labels.index(r['label']), int(np.argmax(scores))] += 1
    recall = np.diag(matrix) / matrix.sum(axis=1)
    precision = [float(matrix[i, i] / matrix[:, i].sum()) if matrix[:, i].sum() else None
                 for i in range(len(labels))]
    manifest = {'labels': labels, 'input_shape': [1, SIZE, SIZE, 3],
        'input_dtype': 'float32', 'input_range': 'RGB 0..255',
        'resize': 'Pillow BILINEAR, EXIF transpose',
        'model_sha256': hashlib.sha256(data).hexdigest()}
    report = {'labels': labels, 'confusion_rows_true_columns_predicted': matrix.tolist(),
        'test_count': len(test_rows), 'recall': recall.tolist(), 'precision': precision,
        'tflite_max_absolute_error': max_error, 'history': history.history,
        'versions': {'tensorflow': tf.__version__, 'numpy': np.__version__}}
    (args.output / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
    (args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
    (args.output / 'dataset.csv').write_bytes((args.dataset / 'manifest.csv').read_bytes())
    print('Готово:', args.output, '; это проверка программы, не допуск к применению.')

if __name__ == '__main__':
    main()
python train.py dataset run-01 --epochs 10 --batch 8
python -m pip freeze > run-01/requirements.txt

Каждый опыт сохраняйте в новый каталог. После ошибки оставьте журнал, исправьте причину и выберите другое имя. На выходе получите Keras, TFLite, описание входа и классов, таблицу данных и отчёт. Исходные фотографии храните как отдельную неизменяемую версию набора.

Открываем ошибки и смотрим на исходные фотографии

В report.json найдите матрицу ошибок: строки — настоящие классы, столбцы — ответы, порядок задан в labels. Recall показывает, какую долю класса нашли, precision — какая доля ответов этого класса верна. При отсутствии таких ответов precision будет null. Выберите несколько ошибочных ячеек и вернитесь к снимкам.

Рядом с метриками посмотрите число групп и примеров каждого класса. Сравните с простым базовым вариантом и разберите редкие состояния отдельно. Настройки выбирайте по train и val, сохраняя test для итогового сравнения.

Значения softmax не являются автоматически вероятностью болезни; проблема калибровки описана, например, в работе Guo и соавторов. Произвольный порог 60–70% не обеспечивает безопасного отказа. Ненужный объект, темнота или новый сорт могут получить высокий балл одного из известных классов. Порог и отдельные правила качества кадра проверяют на подходящих независимых данных, а спорный результат передают человеку.

Переносим свой комплект на Raspberry Pi

Скрипт уже экспортирует TFLite и пропускает каждый тестовый кадр через обе версии. Сравнение с atol=1e-4 и rtol=1e-4 проверяет совпадение вычислений после экспорта. Качество классификации оцениваем по отдельным метрикам и фотографиям.

Перенесите вместе model.tflite и manifest.json в отдельную папку на Pi. В описании записаны порядок классов, формат входа и SHA256 модели. Хеш обнаружит случайное несовпадение файла с описанием, но не является цифровой подписью: используйте собственный проверенный комплект. Отдельный labels.txt здесь не нужен.

Скрипт из статьи о готовой модели нельзя использовать заменой одного имени файла: там фиксированы другая модель, размер и классы. Здесь нужен predict_farm.py ниже. Для него достаточно окружения с ai-edge-litert, NumPy и Pillow из инструкции подготовки Raspberry Pi; TensorFlow на Pi для распознавания не требуется.

import argparse, hashlib, json
from pathlib import Path
import numpy as np
from PIL import Image, ImageOps
from ai_edge_litert.interpreter import Interpreter

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('bundle', type=Path)
    parser.add_argument('image', type=Path)
    args = parser.parse_args()
    meta = json.loads((args.bundle / 'manifest.json').read_text(encoding='utf-8'))
    data = (args.bundle / 'model.tflite').read_bytes()
    if hashlib.sha256(data).hexdigest() != meta['model_sha256']:
        raise ValueError('Модель не совпадает с manifest.json')
    labels = meta['labels']
    if not isinstance(labels, list) or len(labels) < 2 or any(not isinstance(x, str) or not x for x in labels):
        raise ValueError('Неверные метки классов')
    if len(set(labels)) != len(labels):
        raise ValueError('Повторные метки классов')
    if (meta['input_shape'] != [1, 224, 224, 3] or meta['input_dtype'] != 'float32'
            or meta['input_range'] != 'RGB 0..255' or meta['resize'] != 'Pillow BILINEAR, EXIF transpose'):
        raise ValueError('Несовместимая подготовка изображения')
    if args.image.stat().st_size > 20 * 1024 * 1024:
        raise ValueError('Файл больше 20 MiB')
    with Image.open(args.image) as im:
        if im.format not in ('JPEG', 'PNG') or getattr(im, 'n_frames', 1) != 1:
            raise ValueError('Нужен одиночный JPEG или PNG')
        if im.width * im.height > 16_000_000:
            raise ValueError('Изображение больше 16 млн пикселей')
        im = ImageOps.exif_transpose(im).convert('RGB')
        image = np.asarray(im.resize((224, 224), Image.Resampling.BILINEAR), dtype=np.float32)[None]
    runtime = Interpreter(model_content=data, num_threads=1)
    runtime.allocate_tensors()
    inputs, outputs = runtime.get_input_details(), runtime.get_output_details()
    if len(inputs) != 1 or len(outputs) != 1:
        raise ValueError('Нужны один вход и один выход')
    inp, out = inputs[0], outputs[0]
    if list(inp['shape']) != [1, 224, 224, 3] or inp['dtype'] != np.float32:
        raise ValueError('Неверный вход модели')
    if list(out['shape']) != [1, len(labels)] or out['dtype'] != np.float32:
        raise ValueError('Неверный выход модели')
    runtime.set_tensor(inp['index'], image)
    runtime.invoke()
    scores = runtime.get_tensor(out['index'])[0]
    if (not np.isfinite(scores).all() or np.any(scores < 0) or np.any(scores > 1)
            or not np.isclose(scores.sum(), 1, atol=1e-3)):
        raise ValueError('Некорректные оценки модели')
    for i in np.argsort(-scores, kind='stable')[:3]:
        print(f'{labels[i]}: {scores[i]:.5f}')
    print('Оценки классов не являются диагнозом или вероятностью болезни.')

if __name__ == '__main__':
    main()
# Включите окружение с ai-edge-litert, NumPy и Pillow.
# Например, если оно создано по предыдущей статье:
. ~/plant-ai/.venv/bin/activate
python predict_farm.py run-01 /полный/путь/leaf.jpg

Запустите predict_farm.py на снимке: появятся до трёх классов с оценками. Несовпадение хеша, повреждённый файл или неверный формат дадут ошибку. Сохраните прежний комплект отдельно. Для сравнения моделей выберите общую задачу и подходящие контрольные снимки.

Проверяем, что могло исказить оценку

  • Разные причины объединены в один класс без ясного определения, либо один и тот же признак размечен по-разному.
  • Фон, подпись на кадре или способ съёмки совпадает с классом и становится подсказкой для модели.
  • Серия одного растения разошлась между выборками; переименование и небольшая обрезка не делают снимки независимыми.
  • Обучение показывает хороший результат, а проверка заметно хуже. Возможные причины — переобучение, отличия данных или разметки; одного процента недостаточно для вывода.
  • Изменились камера, обрезка или нормализация при переносе. Проверяйте весь путь от файла до входного массива.
  • Ошибки из тестового набора добавили в обучение и продолжили сообщать прежнюю тестовую точность. После такой доработки нужен новый независимый контроль.

Планируем следующий запуск по измеренному времени

Разделите работу на сбор, подписи, выборки, вычисления и проверку в будущих условиях. Этот пример работает на CPU. Засеките свой запуск и посмотрите расход памяти: так появится основание планировать следующий опыт.

В расходы включите съёмку и хранение, время разметчика и специалиста, сопровождение и повторные проверки. Небольшой первый запуск поможет освоить весь процесс и понять, где уходит больше усилий.

Договариваемся, кто подтверждает подпись

Назначьте ответственного за определения классов и разбор разногласий. Если класс назван заболеванием, основание такой подписи должно соответствовать задаче диагностики и быть подтверждено компетентным специалистом; знакомство с теплицей само по себе этого не заменяет. Для спорных фотографий предусмотрите статус «не определено» вне обучающих классов до проверки.

Дайте разметчикам согласованные примеры и правила. Периодически сравнивайте подписи и сохраняйте историю исправлений. Ошибки модели используйте для следующего набора, оставляя действующий test неизменным.

Что собрать, если редкого состояния пока мало

Сохраняйте реальные примеры с происхождением и подтверждением. Если удобнее объединить классы в «есть видимое отклонение», сначала переопределите задачу и оценку. Тогда будет ясно, что именно модель учится различать.

Пока подтверждённых примеров мало, продолжайте ручной просмотр и сбор. Модель на обычных листьях можно изучать как эксперимент, отдельно проверяя реакцию на неизвестные состояния. По мере накопления данных вернитесь к выбранной задаче.

Пробуем фотографии стационарной камеры

Перед схемой ESP32-CAM → Pi → уведомление соберите кадры именно будущей камеры. Сравните их с ручными снимками. Для первых запусков сохраняйте кадр, время, версию и оценку — так ответ легко проверить.

Сделайте заметными отсутствие свежего кадра и ошибку обработки. Затем выберите частоту уведомлений и порядок осмотра по результатам проверки. Решения об обработке оставьте за ответственным специалистом.

Собираем результаты первого опыта

Положите рядом набор снимков, его таблицу, модель, описание и отчёт. Повторите запуск на сохранённом файле и найдите его результат. Такой комплект позволит вернуться к опыту через месяц и понять, какие данные и настройки дали этот ответ.

Теперь сравните ответы на независимых снимках хозяйства и выберите одну конкретную доработку для следующего опыта. Следующие темы серии — прогнозы по данным датчиков и камера с уведомлениями. Общая последовательность материалов собрана в путеводителе по DIY.

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи