← Все статьи

Урок 4. Синтетический генератор и all.jsonl — Обучаем CNN и CRNN с нуля

Серия «Обучаем CNN и CRNN с нуля»: генератор листов со стилем, повторами и трудными пустыми клетками, адаптер источника и all.jsonl.

Стопка полупрозрачных бланков поднимается с тёмной платы — синтетика рождает кропы
Содержание

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

Если все вырезки клеток похожи друг на друга, а «пустая» всегда выглядит как чистая бумага без пятен и линий, три следующих шага курса теряют смысл. Сплит по листам (урок 5) сравнивает обучение и проверку, которые выглядят одинаково: утечку стиля листа не видно. Детектор пустых (уроки 7–9) отличает белый квадрат от цифр — задача слишком лёгкая, хватает правила «есть ли чернила», сеть почти ничему не учится. Каскад (урок 18) нечему проверять: нет «грязных» пустых клеток, на которых сторож и читатель должны спорить.

Поэтому у урока две цели. Первая — получить датасет без ручной разметки. Вторая, важнее — заложить в синтетику свойства реальных бланков, на которых потом ловятся ошибки. В уроке 3 мы зафиксировали алфавит и формат. Сегодня серия «Обучаем CNN и CRNN с нуля» собирает данные и сразу слой-адаптер, чтобы остальной курс не знал, откуда взялись кропы.

Три свойства: стиль листа, повторы значений, трудные пустые клетки

Файлы урока

  • src/make_synthetic_crops.py — генератор: листы со своим стилем, повторы значений, трудные пустые клетки.
  • src/patterns.py — класс «паттерна» метки (int, dec1, dec2, lead0, …): для статистики сейчас и для стратификации в уроке 5.
  • src/data_sources/base.py — контракт адаптера и проверка записей.
  • src/data_sources/synthetic.py — первая реализация адаптера (читает папку генератора).
  • src/data_sources/__init__.py — делает папку пакетом.
  • src/prepare_dataset.py — источник → all.jsonl + manifest.json.
  • experiments/show_samples_grid.py — сетка образцов из разных листов.
  • tests/test_data_source_contract.py и tests/test_synthetic_generator.py — тесты.

Эталон для сверки, если генерация не сошлась: lesson-04. Клонировать его не нужно: файлы урока вы дописываете в свой репозиторий и коммитите.

Предскажи

Раздел «Предскажи» — не оценка. Вы фиксируете ожидание до запуска, потом сверяете с манифестом и отчётом контракта.

Перед запуском запишите ответы:

  1. Сколько кропов получится при --sheets 250 --per-sheet 20 и какую долю составят пустые?
  2. При --repeat-share 0.3 какая доля value-кропов будет повторять значение, уже встречавшееся на том же листе: меньше 30 %, около 30 % или больше?
  3. Что должно произойти, если источник отдаст кроп с меткой 7.3 (с точкой)?

Как устроен генератор

Генератор — самый длинный файл курса (около 280 строк). Шесть идей ниже задают устройство; полный код — в свёрнутом блоке раздела «Напиши сам». Если команды не дают вывод из блока «Ожидаемое», сверьте файлы с эталоном lesson-04 в браузере.

1. Лист = стиль. Каждый лист получает свой набор параметров (они пишутся в sheets.json), и все кропы листа рисуются в этом стиле.

Если перемешать листы, модель выучит стиль бумаги, а не цифры

Параметр Что меняет
cell_w, cell_h размер клетки (≈ 110–200 × 40–64 px)
font_ratio размер цифр относительно высоты клетки
stroke толщина пера (0, 1 или 2 px обводки)
slant наклон всей строки
jitter_dy, jitter_rot насколько «пляшут» отдельные символы по вертикали и по углу
ink_gray, paper_gray цвет чернил и бумаги
paper_noise, shade, blur шум бумаги, перепад освещения, размытие
border, border_gray есть ли линия-рамка клетки

Зачем это нужно: в реальных сканах кропы одного листа похожи друг на друга (одно перо, одна бумага, один сканер). Если сплит перемешает листы, модель выучит стиль листа, а не цифры. В уроке 5 мы это увидим своими глазами.

2. Символы рисуются по одному. Каждый символ получает свой небольшой сдвиг и поворот, потом вся строка наклоняется. Так цифры выглядят «от руки», а не как набранный шрифт. Внутри мы пользуемся единственным встроенным шрифтом Pillow, а разнообразие даёт геометрия и перо.

Base → jitter → наклон строки

3. Значения повторяются внутри листа. С вероятностью --repeat-share (по умолчанию 0.3) кроп повторяет значение, которое на этом листе уже встречалось. В реальных бланках так и бывает. Именно это делает утечку из урока 5 заметной: если соседние клетки одного листа окажутся и в train, и в val, модель просто вспомнит ответ.

4. Пустые клетки бывают трудными. Чистая бумага — это лёгкий случай. Реальные пустые клетки зачёркнуты, подчёркнуты, запачканы или поцарапаны. Генератор делает шесть видов:

Эвристика «сумма чернил» ломается на hatch и specks

Вид Что нарисовано Доля среди пустых
clean только бумага и шум ≈ 30 %
line подчёркивание или вертикальная черта ≈ 20 %
stain размытое пятно ≈ 15 %
hatch штриховка (клетка зачёркнута) ≈ 15 %
specks точки ≈ 10 %
scratch царапина ≈ 9 %

Представьте наивное правило без нейросети: посчитать, сколько на кропе «тёмного» (чернил), и если мало — клетка пустая, если много — там цифры. На чистой бумаге это почти всегда срабатывает. Но зачёркнутая или запачканная пустая клетка тоже даёт много чернил — правило решит, что там значение, хотя цифр нет. Такие примеры называют трудными отрицательными (hard negatives): они похожи на заполненные по количеству штрихов, но метка у них «пусто». Без них детектор из уроков 7–9 учился бы отличать белый квадрат от цифр и на реальных бланках ошибался бы на грязи.

5. Каждый лист живёт в своём потоке случайности. Лист с номером s рисуется генератором default_rng([seed, s]). Поэтому лист №3 получится одинаковым и при --sheets 5, и при --sheets 250. Это проверяет тест test_sheets_are_independent_of_total_count.

6. Шум бумаги слабый намеренно. В уроке 2 мы поставили защиту: если диапазон яркости кропа меньше 30 уровней, контраст не растягивается. Эта защита грубая: она держится, пока шум слабый. Поэтому у нас paper_noise от 1 до 3. Тест test_clean_empty_crops_are_left_alone_by_contrast_guard связывает урок 4 с уроком 2 и сразу покраснеет, если вы увеличите шум.

Дополнительно у генератора есть флаг --task: measurement (по умолчанию), diameter или thickness. Сегодня используем первый. Остальные пригодятся в уроке 16.

Адаптеры источников данных

Контракт. Любой источник отдаёт записи одного вида:

Генератор / публичные / сканы → тонкий адаптер → all.jsonl

Поле Смысл
id уникальный идентификатор кропа
image путь к картинке относительно image_root
text нормализованная метка; '' для пустой клетки
group_id единица сплита (лист, виртуальный лист, официальный сплит источника)
task measurement, diameter или thickness
is_empty true для пустой клетки

Плюс словарь meta(): source, task, alphabet, license, image_root. Лицензия попадёт в манифест: когда появятся публичные данные, это будет важно.

Путь image хранится относительно папки image_root (она записана в манифесте). Так датасет можно перенести на другой компьютер, не переписывая all.jsonl.

src/data_sources/base.py

Самое ценное здесь — check_records. Она применяет к каждой записи всё, чему мы научились в уроке 3: метка должна быть нормализованной, проходить проверку алфавита и формата, а пустая метка должна совпадать с is_empty. Мусор останавливается на входе и не доходит до обучения. Полный файл — в «Напиши сам».

Подмена метки на 7.3: источник не соответствует контракту

src/data_sources/synthetic.py

Адаптер почти ничего не делает: переименовывает поля родного формата генератора (sheet → group_id, kind → is_empty) и собирает meta(). Так и должен выглядеть хороший адаптер: тонкий.

src/prepare_dataset.py

Скрипт выбирает адаптер по --source, проверяет контракт (при нарушении печатает список и завершается с кодом 1), сортирует записи и пишет all.jsonl и manifest.json. Манифест без дат и временных меток: один и тот же вход всегда даёт один и тот же манифест. Источники public и real пока сообщают, что подключатся позже (приложения A2 и A4).

Напиши сам

Сначала положите файлы в репозиторий из урока 1. Блоки ниже свёрнуты — разверните и скопируйте в указанные пути. Шесть идей генератора — в разделе выше; длинный код набираете из блоков, а не с экрана терминала.

Генератор вызывает validate_format из урока 3. Если в src/alphabet.py ещё стоят NotImplementedError (заготовка с TODO), сначала допишите алфавит или скопируйте решение из свёрнутого блока урока 3 — иначе make_synthetic_crops.py упадёт на первой же метке. Быстрая проверка: python -m pytest tests/test_alphabet.py -q → 12 passed.

Запуск команд урока 4 — после того как файлы на месте и алфавит зелёный.

src/make_synthetic_crops.py — генератор (~280 строк) (развернуть)
"""Генератор синтетических кропов.

Что моделируем (это то, на чём потом ловятся ошибки):
  * у каждого листа свой стиль: размер клетки, толщина пера, наклон, шум, фон;
  * значения внутри листа повторяются (как в реальных бланках);
  * пустые клетки бывают трудными: пятна, линии, штриховка, царапины.

Результат в папке --out:
  images/<sheet_id>/<n>.png   кропы
  index.jsonl                 одна строка на кроп (родной формат генератора)
  sheets.json                 стиль каждого листа
  generator.json              параметры запуска и версия генератора
"""
import argparse
import json
import math
from pathlib import Path

import numpy as np
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont

from alphabet import normalize_label, validate_format

GENERATOR_VERSION = "1"

MEASUREMENT_KINDS = {"dec1": 0.50, "dec2": 0.25, "int": 0.20, "lead0": 0.05}
DIAMETER_KINDS = {"single": 0.60, "fraction": 0.40}
THICKNESS_KINDS = {"single": 0.45, "range": 0.55}
EMPTY_KINDS = {"clean": 0.30, "stain": 0.15, "line": 0.20, "hatch": 0.15, "scratch": 0.10, "specks": 0.10}
TASKS = ("measurement", "diameter", "thickness")


# ----------------------------------------------------------------------------- значения

def choice_weighted(rng: np.random.Generator, weights: dict):
    keys = list(weights)
    p = np.array([weights[k] for k in keys], dtype=float)
    return keys[int(rng.choice(len(keys), p=p / p.sum()))]


def rand_int_part(rng: np.random.Generator) -> int:
    n_digits = choice_weighted(rng, {1: 0.45, 2: 0.40, 3: 0.15})
    lo = 1 if n_digits == 1 else 10 ** (n_digits - 1)
    return int(rng.integers(lo, 10 ** n_digits))


def rand_number(rng: np.random.Generator) -> str:
    """Число для thickness: 1–2 цифры, иногда с десятичной запятой."""
    base = int(rng.integers(1, 31))
    return f"{base},{int(rng.integers(1, 10))}" if rng.random() < 0.5 else str(base)


def make_value(task: str, rng: np.random.Generator) -> str:
    if task == "measurement":
        kind = choice_weighted(rng, MEASUREMENT_KINDS)
        if kind == "int":
            return str(rand_int_part(rng))
        if kind == "dec1":
            return f"{rand_int_part(rng)},{int(rng.integers(0, 10))}"
        if kind == "dec2":
            return f"{rand_int_part(rng)},{int(rng.integers(0, 100)):02d}"
        if rng.random() < 0.5:
            return f"0,{int(rng.integers(1, 10))}"
        return f"0,{int(rng.integers(1, 100)):02d}"
    if task == "diameter":
        if choice_weighted(rng, DIAMETER_KINDS) == "single":
            return str(int(rng.integers(10, 1000)))
        return f"{int(rng.integers(10, 100))}/{int(rng.integers(10, 100))}"
    if task == "thickness":
        if choice_weighted(rng, THICKNESS_KINDS) == "single":
            return rand_number(rng)
        return f"{rand_number(rng)}-{rand_number(rng)}"
    raise ValueError(f"неизвестная задача {task!r}")


# ----------------------------------------------------------------------------- стиль листа

def sample_style(rng: np.random.Generator) -> dict:
    return {
        "cell_w": int(rng.integers(110, 201)),
        "cell_h": int(rng.integers(40, 65)),
        "font_ratio": round(float(rng.uniform(0.50, 0.70)), 3),
        "stroke": int(rng.choice([0, 0, 1, 1, 2])),
        "slant": round(float(rng.uniform(-0.25, 0.25)), 3),
        "jitter_dy": round(float(rng.uniform(0.0, 1.8)), 2),
        "jitter_rot": round(float(rng.uniform(0.0, 5.0)), 2),
        "ink_gray": int(rng.integers(10, 91)),
        "paper_gray": int(rng.integers(215, 253)),
        "paper_noise": round(float(rng.uniform(1.0, 3.0)), 2),
        "shade": round(float(rng.uniform(0.0, 5.0)), 2),
        "blur": float(rng.choice([0.0, 0.5, 0.8])),
        "left_pad": int(rng.integers(5, 15)),
        "border": bool(rng.random() < 0.5),
        "border_gray": int(rng.integers(120, 190)),
    }


# ----------------------------------------------------------------------------- отрисовка

def get_font(size: int):
    try:
        return ImageFont.load_default(size=size)
    except Exception:  # старый Pillow или нет FreeType
        return ImageFont.load_default()


def paste_lighter(dst: Image.Image, src: Image.Image, x: int, y: int) -> None:
    """Накладывает src на dst (берём более яркий пиксель), с обрезкой по границам."""
    w, h = dst.size
    sx0, sy0 = max(0, -x), max(0, -y)
    dx0, dy0 = max(0, x), max(0, y)
    cw = min(src.width - sx0, w - dx0)
    ch = min(src.height - sy0, h - dy0)
    if cw <= 0 or ch <= 0:
        return
    region = dst.crop((dx0, dy0, dx0 + cw, dy0 + ch))
    patch = src.crop((sx0, sy0, sx0 + cw, sy0 + ch))
    dst.paste(ImageChops.lighter(region, patch), (dx0, dy0))


def draw_text_mask(mask: Image.Image, text: str, style: dict, rng: np.random.Generator) -> None:
    """Рисует текст посимвольно (у каждого символа свой сдвиг и поворот) на маске чернил."""
    w, h = mask.size
    stroke = style["stroke"]
    size = max(10, round(style["font_ratio"] * h))
    pad = style["left_pad"] + int(rng.integers(-2, 3))
    while True:
        font = get_font(size)
        advances = [font.getlength(ch) + 1 + stroke for ch in text]
        if sum(advances) <= w - pad - 4 or size <= 10:
            break
        size -= 2
    bbox = font.getbbox("0")
    y0 = (h - (bbox[3] - bbox[1])) // 2 - bbox[1]
    x = float(pad)
    for ch, adv in zip(text, advances):
        dy = float(rng.normal(0.0, style["jitter_dy"]))
        rot = float(rng.normal(0.0, style["jitter_rot"]))
        tile = Image.new("L", (int(adv) + size, size * 2), 0)
        ImageDraw.Draw(tile).text((size // 2, size // 2), ch, font=font, fill=255,
                                  stroke_width=stroke, stroke_fill=255)
        if abs(rot) > 0.01:
            tile = tile.rotate(rot, resample=Image.Resampling.BICUBIC)
        paste_lighter(mask, tile, int(round(x)) - size // 2, int(round(y0 + dy)) - size // 2)
        x += adv


def shear(mask: Image.Image, slant: float) -> Image.Image:
    if abs(slant) < 1e-3:
        return mask
    w, h = mask.size
    return mask.transform((w, h), Image.Transform.AFFINE, (1, slant, -slant * h / 2, 0, 1, 0),
                          resample=Image.Resampling.BICUBIC)


def draw_empty_marks(mask: Image.Image, kind: str, rng: np.random.Generator) -> None:
    """Трудные пустые клетки: пятна, линии, штриховка, царапины, точки (kind='clean' — ничего)."""
    w, h = mask.size
    draw = ImageDraw.Draw(mask)
    if kind == "stain":
        layer = Image.new("L", (w, h), 0)
        ld = ImageDraw.Draw(layer)
        for _ in range(int(rng.integers(1, 4))):
            cx, cy = int(rng.integers(0, w)), int(rng.integers(0, h))
            rx, ry = int(rng.integers(6, 22)), int(rng.integers(4, 14))
            ld.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=int(rng.integers(50, 130)))
        layer = layer.filter(ImageFilter.GaussianBlur(float(rng.uniform(2.5, 5.0))))
        paste_lighter(mask, layer, 0, 0)
    elif kind == "line":
        y = int(rng.integers(h // 2, h - 3))
        x1 = int(rng.integers(0, w // 3))
        x2 = int(rng.integers(2 * w // 3, w))
        draw.line([(x1, y), (x2, y)], fill=int(rng.integers(140, 230)), width=int(rng.integers(1, 3)))
        if rng.random() < 0.3:
            xv = int(rng.integers(0, w))
            draw.line([(xv, 0), (xv, h)], fill=int(rng.integers(110, 190)), width=1)
    elif kind == "hatch":
        step = int(rng.integers(7, 14))
        fill = int(rng.integers(120, 220))
        for x in range(-h, w, step):
            draw.line([(x, h), (x + h, 0)], fill=fill, width=1)
    elif kind == "scratch":
        for _ in range(int(rng.integers(1, 3))):
            pts = [(int(rng.integers(0, w)), int(rng.integers(0, h))) for _ in range(int(rng.integers(3, 7)))]
            draw.line(pts, fill=int(rng.integers(100, 200)), width=1)
    elif kind == "specks":
        for _ in range(int(rng.integers(3, 11))):
            cx, cy = int(rng.integers(0, w)), int(rng.integers(0, h))
            r = int(rng.integers(1, 3))
            draw.ellipse([cx, cy, cx + r, cy + r], fill=int(rng.integers(90, 200)))


def paper_array(style: dict, rng: np.random.Generator, w: int, h: int) -> np.ndarray:
    arr = rng.normal(float(style["paper_gray"]), style["paper_noise"], size=(h, w)).astype(np.float32)
    angle = float(rng.uniform(0, 2 * math.pi))
    xs = np.linspace(-1.0, 1.0, w, dtype=np.float32)[None, :]
    ys = np.linspace(-1.0, 1.0, h, dtype=np.float32)[:, None]
    return arr + style["shade"] * (math.cos(angle) * xs + math.sin(angle) * ys) / 2.0


def render_crop(text: str, empty_kind: str, style: dict, rng: np.random.Generator) -> Image.Image:
    w = max(60, style["cell_w"] + int(rng.integers(-4, 5)))
    h = max(30, style["cell_h"] + int(rng.integers(-3, 4)))
    mask = Image.new("L", (w, h), 0)
    if text:
        draw_text_mask(mask, text, style, rng)
        mask = shear(mask, style["slant"])
    else:
        draw_empty_marks(mask, empty_kind, rng)
    if style["blur"] > 0:
        mask = mask.filter(ImageFilter.GaussianBlur(style["blur"]))
    alpha = np.asarray(mask, dtype=np.float32) / 255.0
    img = paper_array(style, rng, w, h) * (1.0 - alpha) + style["ink_gray"] * alpha
    if style["border"]:
        img[h - 2:, :] = np.minimum(img[h - 2:, :], style["border_gray"])
    return Image.fromarray(img.clip(0, 255).astype(np.uint8), mode="L")


# ----------------------------------------------------------------------------- листы

def generate(out, sheets: int, per_sheet: int, seed: int = 42, task: str = "measurement",
             empty_share: float = 0.4, repeat_share: float = 0.3) -> dict:
    if task not in TASKS:
        raise ValueError(f"неизвестная задача {task!r}")
    out = Path(out)
    (out / "images").mkdir(parents=True, exist_ok=True)
    styles, lines = {}, []
    for s in range(sheets):
        sheet_id = f"sheet_{s:04d}"
        rng = np.random.default_rng([seed, s])      # у каждого листа свой независимый поток
        style = sample_style(rng)
        styles[sheet_id] = style
        (out / "images" / sheet_id).mkdir(exist_ok=True)
        values = []                                  # значения, уже встречавшиеся на листе
        for n in range(per_sheet):
            if rng.random() < empty_share:
                text, kind, empty_kind = "", "empty", choice_weighted(rng, EMPTY_KINDS)
            else:
                if values and rng.random() < repeat_share:
                    text = values[int(rng.integers(0, len(values)))]
                else:
                    text = make_value(task, rng)
                values.append(text)
                kind, empty_kind = "value", ""
            assert normalize_label(text) == text and validate_format(text, task)[0], text
            rel = f"images/{sheet_id}/{n:03d}.png"
            render_crop(text, empty_kind, style, rng).save(out / rel)
            lines.append({"file": rel, "text": text, "sheet": sheet_id, "task": task,
                          "kind": kind, "empty_kind": empty_kind})
    with open(out / "index.jsonl", "w", encoding="utf-8") as f:
        for rec in lines:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")
    (out / "sheets.json").write_text(json.dumps(styles, indent=1, ensure_ascii=False), encoding="utf-8")
    meta = {"version": GENERATOR_VERSION, "seed": seed, "task": task, "sheets": sheets,
            "per_sheet": per_sheet, "empty_share": empty_share, "repeat_share": repeat_share}
    (out / "generator.json").write_text(json.dumps(meta, indent=1), encoding="utf-8")
    return meta


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--out", default="data/synthetic")
    ap.add_argument("--sheets", type=int, default=250)
    ap.add_argument("--per-sheet", type=int, default=20)
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--task", choices=TASKS, default="measurement")
    ap.add_argument("--empty-share", type=float, default=0.4)
    ap.add_argument("--repeat-share", type=float, default=0.3)
    args = ap.parse_args()
    meta = generate(args.out, args.sheets, args.per_sheet, args.seed, args.task,
                    args.empty_share, args.repeat_share)
    print(f"готово: {args.sheets} листов × {args.per_sheet} кропов = {args.sheets * args.per_sheet} "
          f"в {args.out} (task={meta['task']}, seed={meta['seed']})")


if __name__ == "__main__":
    main()
src/patterns.py — паттерны меток (развернуть)
"""Класс «паттерна» метки: нужен для статистики датасета и (в уроке 5) для стратификации листов."""


def label_pattern(text: str, task: str) -> str:
    if text == "":
        return "empty"
    if task == "measurement":
        if text.startswith("0,"):
            return "lead0"
        if "," not in text:
            return "int"
        return "dec1" if len(text.split(",")[1]) == 1 else "dec2"
    if task == "diameter":
        return "fraction" if "/" in text else "single"
    if task == "thickness":
        return "range" if "-" in text else "single"
    raise ValueError(f"неизвестная задача {task!r}")
src/data_sources/__init__.py — пакет адаптеров (развернуть)
"""Адаптеры источников данных (контракт — в base.py)."""
src/data_sources/base.py — контракт Record и check_records (развернуть)
"""Контракт адаптера источника данных.

Любой источник (синтетика, публичные датасеты, реальные листы) приводится к одной схеме.
Остальной курс читает только all.jsonl и split.json и про источник ничего не знает.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Iterator, TypedDict

from alphabet import normalize_label, validate_format

REQUIRED_META = ("source", "task", "alphabet", "license", "image_root")


class Record(TypedDict):
    id: str          # уникальный идентификатор кропа
    image: str       # путь к картинке относительно image_root
    text: str        # нормализованная метка; '' для пустой клетки
    group_id: str    # единица сплита (лист, виртуальный лист, официальный сплит)
    task: str        # measurement | diameter | thickness
    is_empty: bool


class DataSource(ABC):
    @abstractmethod
    def iter_records(self) -> Iterator[Record]:
        """Отдаёт записи схемы Record."""

    @abstractmethod
    def meta(self) -> dict:
        """Словарь с ключами REQUIRED_META (+ любые дополнительные сведения об источнике)."""


def check_meta(meta: dict) -> list[str]:
    return [f"meta: нет ключа {k!r}" for k in REQUIRED_META if k not in meta]


def check_records(records, image_root, task: str, max_errors: int = 20) -> list[str]:
    """Проверяет записи на соответствие контракту. Возвращает список ошибок (пустой = всё хорошо)."""
    image_root = Path(image_root)
    errors: list[str] = []
    seen: set[str] = set()
    total_errors = 0

    def add(msg: str) -> None:
        nonlocal total_errors
        total_errors += 1
        if len(errors) < max_errors:
            errors.append(msg)

    for rec in records:
        rid = rec.get("id", "<без id>")
        missing = [k for k in ("id", "image", "text", "group_id", "task", "is_empty") if k not in rec]
        if missing:
            add(f"{rid}: нет полей {missing}")
            continue
        if rec["id"] in seen:
            add(f"{rid}: повторяющийся id")
        seen.add(rec["id"])
        if not rec["group_id"]:
            add(f"{rid}: пустой group_id")
        if rec["task"] != task:
            add(f"{rid}: task={rec['task']!r}, ожидалось {task!r}")
        if not isinstance(rec["is_empty"], bool):
            add(f"{rid}: is_empty должно быть bool")
        if rec["is_empty"] != (rec["text"] == ""):
            add(f"{rid}: is_empty не согласовано с text={rec['text']!r}")
        if rec["text"] != normalize_label(rec["text"]):
            add(f"{rid}: метка не нормализована: {rec['text']!r}")
        ok, reason = validate_format(rec["text"], rec["task"])
        if not ok:
            add(f"{rid}: {reason}: {rec['text']!r}")
        if not (image_root / rec["image"]).is_file():
            add(f"{rid}: нет файла {image_root / rec['image']}")
    if total_errors > len(errors):
        errors.append(f"... и ещё {total_errors - len(errors)} ошибок")
    return errors
src/data_sources/synthetic.py — адаптер синтетики (развернуть)
"""Адаптер синтетического источника: читает папку, созданную make_synthetic_crops.py."""
import json
from pathlib import Path
from typing import Iterator

from alphabet import get_alphabet
from data_sources.base import DataSource, Record


class SyntheticSource(DataSource):
    def __init__(self, root) -> None:
        self.root = Path(root)

    def iter_records(self) -> Iterator[Record]:
        counters: dict[str, int] = {}
        with open(self.root / "index.jsonl", encoding="utf-8") as f:
            for line in f:
                raw = json.loads(line)
                n = counters.get(raw["sheet"], 0)
                counters[raw["sheet"]] = n + 1
                yield Record(
                    id=f"{raw['sheet']}_{n:03d}",
                    image=raw["file"],
                    text=raw["text"],
                    group_id=raw["sheet"],
                    task=raw["task"],
                    is_empty=raw["kind"] == "empty",
                )

    def meta(self) -> dict:
        gen = json.loads((self.root / "generator.json").read_text(encoding="utf-8"))
        return {
            "source": "synthetic",
            "task": gen["task"],
            "alphabet": get_alphabet(gen["task"]),
            "license": "сгенерировано этим репозиторием, ограничений нет",
            "image_root": str(self.root),
            "generator": gen,
        }
src/prepare_dataset.py — источник → all.jsonl (развернуть)
"""Источник данных → data/processed/all.jsonl + manifest.json (единая схема для всех уроков)."""
import argparse
import json
import sys
from collections import Counter, defaultdict
from collections.abc import Sequence
from pathlib import Path

from alphabet import num_classes
from data_sources.base import Record, check_meta, check_records
from data_sources.synthetic import SyntheticSource
from patterns import label_pattern

SCHEMA = ["id", "image", "text", "group_id", "task", "is_empty"]


def build_source(name: str, src: str):
    if name == "synthetic":
        if not (Path(src) / "index.jsonl").is_file():
            sys.exit(f"В {src} нет index.jsonl. Сначала: python src/make_synthetic_crops.py --out {src}")
        return SyntheticSource(src)
    sys.exit(f"Источник {name!r} подключается позже: public — приложение A2, real — приложение A4.")


def repeat_share(records: Sequence[Record]) -> float:
    """Доля value-кропов, чьё значение встречается на том же листе ещё раз."""
    by_group = defaultdict(Counter)
    for r in records:
        if not r["is_empty"]:
            by_group[r["group_id"]][r["text"]] += 1
    value_total = sum(sum(c.values()) for c in by_group.values())
    repeated = sum(n for c in by_group.values() for n in c.values() if n > 1)
    return repeated / value_total if value_total else 0.0


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", choices=["synthetic", "public", "real"], required=True)
    ap.add_argument("--src", required=True, help="папка источника")
    ap.add_argument("--out", default="data/processed")
    args = ap.parse_args()

    source = build_source(args.source, args.src)
    meta = source.meta()
    problems = check_meta(meta)
    records = sorted(source.iter_records(), key=lambda r: (r["group_id"], r["id"]))
    problems += check_records(records, meta["image_root"], meta["task"])
    if problems:
        print("Источник не соответствует контракту:")
        for p in problems:
            print("  -", p)
        sys.exit(1)

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)
    with open(out / "all.jsonl", "w", encoding="utf-8") as f:
        for r in records:
            f.write(json.dumps({k: r[k] for k in SCHEMA}, ensure_ascii=False) + "\n")

    per_group = Counter(r["group_id"] for r in records)
    empty = sum(r["is_empty"] for r in records)
    manifest = {
        "schema_version": 1,
        "source": meta["source"],
        "task": meta["task"],
        "alphabet": meta["alphabet"],
        "num_classes": num_classes(meta["task"]),
        "image_root": meta["image_root"],
        "license": meta["license"],
        "fields": SCHEMA,
        "counts": {"total": len(records), "value": len(records) - empty, "empty": empty},
        "empty_share": round(empty / len(records), 4),
        "groups": len(per_group),
        "crops_per_group": {"min": min(per_group.values()), "max": max(per_group.values())},
        "patterns": dict(sorted(Counter(label_pattern(r["text"], r["task"]) for r in records).items())),
        "repeated_on_sheet_share": round(repeat_share(records), 4),
        "generator": meta.get("generator"),
    }
    (out / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"записано {len(records)} кропов ({manifest['counts']['value']} value, {empty} empty), "
          f"{len(per_group)} групп -> {out / 'all.jsonl'}")
    print(f"манифест: {out / 'manifest.json'}")


if __name__ == "__main__":
    main()
experiments/show_samples_grid.py — сетка образцов (развернуть)
"""Сетка образцов: 8 value и 8 empty из разных листов (видно, как различаются стили листов)."""
import argparse
import json
import random
import sys
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt  # noqa: E402
from PIL import Image  # noqa: E402


def pick(records: list[dict], n: int, rng: random.Random) -> list[dict]:
    """По одному кропу с n разных листов."""
    by_group: dict[str, list[dict]] = {}
    for r in records:
        by_group.setdefault(r["group_id"], []).append(r)
    groups = rng.sample(sorted(by_group), min(n, len(by_group)))
    return [rng.choice(by_group[g]) for g in groups]


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--data-dir", default="data/processed")
    ap.add_argument("--out", default="experiments/samples_grid.png")
    ap.add_argument("--seed", type=int, default=0)
    args = ap.parse_args()

    data_dir = Path(args.data_dir)
    manifest = json.loads((data_dir / "manifest.json").read_text(encoding="utf-8"))
    records = [json.loads(line) for line in open(data_dir / "all.jsonl", encoding="utf-8")]
    rng = random.Random(args.seed)
    values = pick([r for r in records if not r["is_empty"]], 8, rng)
    empties = pick([r for r in records if r["is_empty"]], 8, rng)
    if len(values) < 8 or len(empties) < 8:
        sys.exit("Мало листов для сетки 8 + 8: сгенерируйте больше листов.")

    fig, axes = plt.subplots(4, 4, figsize=(11, 6.5))
    for ax, rec, kind in zip(axes.flat, values + empties, ["value"] * 8 + ["empty"] * 8):
        ax.imshow(Image.open(Path(manifest["image_root"]) / rec["image"]), cmap="gray", vmin=0, vmax=255)
        label = rec["text"] if kind == "value" else "(пусто)"
        ax.set_title(f"{label}   {rec['group_id']}", fontsize=8)
        ax.set_xticks([])
        ax.set_yticks([])
    fig.suptitle("Верхние два ряда: value; нижние два: empty (включая трудные). Каждый кроп с другого листа.",
                 fontsize=9)
    fig.tight_layout()
    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(out, dpi=110)
    print(f"сохранено: {out}")


if __name__ == "__main__":
    main()
tests/test_data_source_contract.py — тесты контракта (развернуть)
import sys
import tempfile
from pathlib import Path

from PIL import Image

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from data_sources.base import REQUIRED_META, check_meta, check_records  # noqa: E402
from data_sources.synthetic import SyntheticSource  # noqa: E402
from make_synthetic_crops import generate  # noqa: E402


def assert_contract(source) -> None:
    """Контракт-тест: его должен проходить ЛЮБОЙ адаптер (synthetic, public, real)."""
    meta = source.meta()
    assert check_meta(meta) == [], check_meta(meta)
    records = list(source.iter_records())
    assert records, "источник не отдал ни одной записи"
    errors = check_records(records, meta["image_root"], meta["task"])
    assert errors == [], errors
    assert any(r["is_empty"] for r in records) and any(not r["is_empty"] for r in records)


def test_synthetic_source_passes_contract():
    with tempfile.TemporaryDirectory() as d:
        generate(d, sheets=4, per_sheet=10, seed=1)
        assert_contract(SyntheticSource(d))


def test_meta_has_required_keys():
    with tempfile.TemporaryDirectory() as d:
        generate(d, sheets=1, per_sheet=2, seed=1)
        meta = SyntheticSource(d).meta()
        for key in REQUIRED_META:
            assert key in meta


# --- контракт должен ловить типовые нарушения --------------------------------

def make_record(image: str, **over) -> dict:
    rec = {"id": "g1_000", "image": image, "text": "7,3", "group_id": "g1",
           "task": "measurement", "is_empty": False}
    rec.update(over)
    return rec


def with_png(fn):
    with tempfile.TemporaryDirectory() as d:
        Image.new("L", (10, 10), 255).save(Path(d) / "a.png")
        fn(d)


def test_check_records_accepts_good_record():
    def run(d):
        assert check_records([make_record("a.png")], d, "measurement") == []
    with_png(run)


def test_check_records_catches_violations():
    def run(d):
        cases = {
            "нет файла": make_record("missing.png"),
            "пустой group_id": make_record("a.png", group_id=""),
            "не нормализована": make_record("a.png", text="7.3"),
            "is_empty не согласовано": make_record("a.png", text="", is_empty=False),
            "формат": make_record("a.png", text="01,02,24"),
            "чужая задача": make_record("a.png", task="diameter"),
        }
        for name, rec in cases.items():
            assert check_records([rec], d, "measurement"), f"нарушение не поймано: {name}"
        dup = [make_record("a.png"), make_record("a.png")]
        assert check_records(dup, d, "measurement"), "повторяющийся id не пойман"
    with_png(run)
tests/test_synthetic_generator.py — тесты генератора (развернуть)
import atexit
import json
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from alphabet import validate_format  # noqa: E402
from image_to_tensor import MIN_CONTRAST_RANGE  # noqa: E402
from make_synthetic_crops import EMPTY_KINDS, generate  # noqa: E402
from patterns import label_pattern  # noqa: E402
from PIL import Image  # noqa: E402

_cache: dict = {}
_tmp: list = []


def read_index(root):
    with open(Path(root) / "index.jsonl", encoding="utf-8") as f:
        return [json.loads(line) for line in f]


def big_dataset() -> Path:
    """60 листов × 20 кропов, seed 42; создаётся один раз на все тесты."""
    if "big" not in _cache:
        d = tempfile.TemporaryDirectory()
        _tmp.append(d)
        generate(d.name, sheets=60, per_sheet=20, seed=42)
        _cache["big"] = Path(d.name)
    return _cache["big"]


atexit.register(lambda: [d.cleanup() for d in _tmp])


def test_same_seed_gives_identical_files():
    with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
        generate(a, sheets=3, per_sheet=8, seed=7)
        generate(b, sheets=3, per_sheet=8, seed=7)
        assert (Path(a) / "index.jsonl").read_bytes() == (Path(b) / "index.jsonl").read_bytes()
        for rec in read_index(a):
            assert (Path(a) / rec["file"]).read_bytes() == (Path(b) / rec["file"]).read_bytes()


def test_sheets_are_independent_of_total_count():
    # Листы строятся независимыми потоками: первые 3 листа не зависят от того, сколько листов всего.
    with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
        generate(a, sheets=3, per_sheet=8, seed=7)
        generate(b, sheets=6, per_sheet=8, seed=7)
        first_a = read_index(a)
        first_b = [r for r in read_index(b) if r["sheet"] in {r2["sheet"] for r2 in first_a}]
        assert first_a == first_b
        for rec in first_a:
            assert (Path(a) / rec["file"]).read_bytes() == (Path(b) / rec["file"]).read_bytes()


def test_labels_valid_for_all_tasks_and_patterns_present():
    expected = {"measurement": {"dec1", "dec2", "int", "lead0"},
                "diameter": {"single", "fraction"},
                "thickness": {"single", "range"}}
    for task, patterns in expected.items():
        with tempfile.TemporaryDirectory() as d:
            generate(d, sheets=10, per_sheet=20, seed=3, task=task)
            texts = [r["text"] for r in read_index(d) if r["kind"] == "value"]
            for t in texts:
                ok, reason = validate_format(t, task)
                assert ok, f"{task}: {t!r}: {reason}"
            assert {label_pattern(t, task) for t in texts} >= patterns, task


def test_empty_share_close_to_requested():
    rows = read_index(big_dataset())
    share = sum(r["kind"] == "empty" for r in rows) / len(rows)
    assert 0.33 <= share <= 0.47, share


def test_rare_patterns_are_at_least_three_percent():
    rows = read_index(big_dataset())
    values = [r["text"] for r in rows if r["kind"] == "value"]
    for pattern in ("dec1", "dec2", "int", "lead0"):
        share = sum(label_pattern(t, "measurement") == pattern for t in values) / len(values)
        assert share >= 0.03, f"{pattern}: {share:.3f}"


def test_all_hard_negative_kinds_occur():
    rows = read_index(big_dataset())
    kinds = {r["empty_kind"] for r in rows if r["kind"] == "empty"}
    assert kinds == set(EMPTY_KINDS), kinds


def repeated_share(root) -> float:
    by_sheet: dict = {}
    for r in read_index(root):
        if r["kind"] == "value":
            by_sheet.setdefault(r["sheet"], []).append(r["text"])
    total = sum(len(v) for v in by_sheet.values())
    repeated = sum(1 for v in by_sheet.values() for t in v if v.count(t) > 1)
    return repeated / total


def test_repeat_share_increases_repeats_on_sheet():
    with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
        generate(a, sheets=30, per_sheet=20, seed=5, repeat_share=0.0)
        generate(b, sheets=30, per_sheet=20, seed=5, repeat_share=0.6)
        assert repeated_share(b) > repeated_share(a) + 0.2


def test_sheet_styles_differ():
    styles = json.loads((big_dataset() / "sheets.json").read_text(encoding="utf-8"))
    assert len({s["cell_w"] for s in styles.values()}) >= 20
    assert len({s["ink_gray"] for s in styles.values()}) >= 20
    assert {s["border"] for s in styles.values()} == {True, False}


def test_clean_empty_crops_are_left_alone_by_contrast_guard():
    # Связь с уроком 2: чистая пустая клетка (без рамки) не должна растягиваться по контрасту.
    root = big_dataset()
    styles = json.loads((root / "sheets.json").read_text(encoding="utf-8"))
    checked = 0
    for r in read_index(root):
        if r["kind"] == "empty" and r["empty_kind"] == "clean" and not styles[r["sheet"]]["border"]:
            lo, hi = Image.open(root / r["file"]).getextrema()
            assert isinstance(lo, (int, float)) and isinstance(hi, (int, float))
            assert hi - lo < MIN_CONTRAST_RANGE, (r["file"], hi - lo)
            checked += 1
    assert checked >= 20

Команды

Окружение и репозиторий из урока 1. После того как файлы урока набраны:

python src/make_synthetic_crops.py --out data/synthetic --sheets 250 --per-sheet 20 --seed 42
python src/prepare_dataset.py --source synthetic --src data/synthetic --out data/processed
python experiments/show_samples_grid.py --out experiments/samples_grid.png
python -m pytest tests/test_data_source_contract.py tests/test_synthetic_generator.py -q

Smoke

Те же команды, но маленькие и в отдельные папки:

python src/make_synthetic_crops.py --out data/synthetic-smoke --sheets 20 --per-sheet 10 --seed 42
python src/prepare_dataset.py --source synthetic --src data/synthetic-smoke --out data/processed-smoke
python experiments/show_samples_grid.py --data-dir data/processed-smoke --out experiments/samples_grid_smoke.png
python -m pytest tests/test_data_source_contract.py tests/test_synthetic_generator.py -q

Ожидаемое

Вывод первых двух команд (время генерации у вас будет своё: у меня около 15–20 секунд на 5000 кропов):

готово: 250 листов × 20 кропов = 5000 в data/synthetic (task=measurement, seed=42)
записано 5000 кропов (2968 value, 2032 empty), 250 групп -> data/processed/all.jsonl
манифест: data/processed/manifest.json

Манифест (data/processed/manifest.json):

{
  "schema_version": 1,
  "source": "synthetic",
  "task": "measurement",
  "alphabet": "0123456789,",
  "num_classes": 12,
  "image_root": "data/synthetic",
  "license": "сгенерировано этим репозиторием, ограничений нет",
  "fields": [
    "id",
    "image",
    "text",
    "group_id",
    "task",
    "is_empty"
  ],
  "counts": {
    "total": 5000,
    "value": 2968,
    "empty": 2032
  },
  "empty_share": 0.4064,
  "groups": 250,
  "crops_per_group": {
    "min": 20,
    "max": 20
  },
  "patterns": {
    "dec1": 1477,
    "dec2": 783,
    "empty": 2032,
    "int": 551,
    "lead0": 157
  },
  "repeated_on_sheet_share": 0.473,
  "generator": {
    "version": "1",
    "seed": 42,
    "task": "measurement",
    "sheets": 250,
    "per_sheet": 20,
    "empty_share": 0.4,
    "repeat_share": 0.3
  }
}

Первые строки all.jsonl:

{"id": "sheet_0000_000", "image": "images/sheet_0000/000.png", "text": "59,44", "group_id": "sheet_0000", "task": "measurement", "is_empty": false}
{"id": "sheet_0000_001", "image": "images/sheet_0000/001.png", "text": "", "group_id": "sheet_0000", "task": "measurement", "is_empty": true}

Папка data/synthetic/images занимает около 30 МБ. Тесты: 13 passed (вместе с уроками 2–3: 33 passed).

Сетка образцов (experiments/samples_grid.png). Каждый кроп взят с другого листа:

Сетка образцов: 8 value и 8 empty с разных листов

Что на ней видно: листы различаются толщиной пера, цветом чернил, наклоном, размытием и рамкой. Ряды пустых клеток содержат чистую бумагу, пятно, подчёркивание, штриховку и царапины.

Сравните с предсказаниями:

  1. 5000 кропов, 40.6 % пустых. Заказано 40 %, получилось чуть больше: каждый кроп пустой независимо с вероятностью 0.4.
  2. Повторы: 47 %, а не 30 %. Поле repeated_on_sheet_share считает долю value-кропов, чьё значение встречается на листе хотя бы дважды. А в повторе участвуют обе копии: и оригинал, и повтор. Плюс случайные совпадения коротких чисел (например, 7). Так что 47 % при --repeat-share 0.3 не ошибка.
  3. Контракт сработает. Подмена одной метки на 7.3 в index.jsonl даёт на выходе prepare_dataset.py:
Источник не соответствует контракту:
  - sheet_0000_000: метка не нормализована: '7.3'
  - sheet_0000_000: символ '.' вне алфавита: '7.3'

и код завершения 1. Файлов в data/processed в этом случае не появится.

Паттерны среди value-кропов: dec1 49.8 %, dec2 26.4 %, int 18.6 %, lead0 5.3 %. Каждый редкий паттерн заметно выше 3 %.

Упражнение (эксперимент)

  1. Сгенерируйте два датасета с --repeat-share 0 и --repeat-share 0.6 (в разные папки) и сравните repeated_on_sheet_share в манифестах. Почему при нулевом значении параметра доля всё равно не нулевая?
  2. Добавьте свой вид трудной пустой клетки, например «крест» из двух диагоналей. Добавьте ключ в EMPTY_KINDS и ветку в draw_empty_marks. Тест test_all_hard_negative_kinds_occur сам проверит, что ваш вид встречается.
  3. Поднимите paper_noise в sample_style до 8 и запустите тесты. Какой тест краснеет и что это говорит об уроке 2?

Если не так

  • NotImplementedError в alphabet.validate_format (или encode_text). Это не баг генератора: в src/alphabet.py осталась заготовка урока 3. Допишите TODO или возьмите решение из урока 3; pytest tests/test_alphabet.py должен дать 12 passed.
  • «В … нет index.jsonl. Сначала: python src/make_synthetic_crops.py …». Вы запустили prepare_dataset.py раньше генератора или указали не ту папку в --src.
  • «Источник не соответствует контракту». Это не сбой, а работа проверки. Прочитайте список: он называет запись и причину.
  • AttributeError: ... getlength или очень мелкий кривой текст на кропах. Старая версия Pillow. Нужна 10.1 или новее: pip install -U "Pillow>=10.1".
  • ModuleNotFoundError: data_sources или alphabet. Запускайте скрипты из корня вашего репозитория (python src/...) и убедитесь, что есть файл src/data_sources/__init__.py.
  • Генерация идёт заметно дольше минуты. На обычном ноутбуке 5000 кропов занимают порядка десятка секунд. Для отладки можно сгенерировать меньше (--sheets 20), но для следующих уроков понадобится полный объём: размер зависит от арифметики урока 5.
  • --source public или --source real завершается сообщением «подключается позже». Так и должно быть: эти адаптеры появятся в приложениях A2 и A4.

Чек-лист

  • Файлы урока в вашем репозитории и закоммичены
  • Сгенерирован полный набор --sheets 250 --per-sheet 20 --seed 42
  • prepare_dataset.py записал all.jsonl и manifest.json
  • Сетка samples_grid.png сохранена
  • pytest урока 4 → 13 passed
  • Сверены три прогноза из «Предскажи»

FAQ

Зачем group_id, если пока нет сплита?

Сплит в уроке 5 делит листы, не отдельные кропы. group_id уже сейчас фиксирует, какие кропы принадлежат одному листу — иначе утечка стиля будет незаметна в манифесте.

Почему repeated_on_sheet_share больше, чем --repeat-share?

Параметр генератора — вероятность сделать повтор при рисовании value-кропа. Манифест считает долю value-кропов, чьё значение встречается на листе ≥ 2 раза: в счёт идут и оригинал, и копии, плюс случайные совпадения коротких чисел.

Нужно ли коммитить data/synthetic?

Нет. Папка в .gitignore. Пересоздаётся командой с --seed 42; манифест при том же seed будет тем же.

Чем родной index.jsonl отличается от all.jsonl?

index.jsonl — формат генератора (sheet, file, kind, empty_kind). all.jsonl — единая схема курса через адаптер. Поле empty_kind в схему курса не попадает: детектору достаточно is_empty.

Можно ли сразу генерировать diameter?

Да: --task diameter. Сегодня курс идёт по measurement; другие задачи понадобятся в уроке 16.

Почему адаптер тонкий — почти только переименование полей?

Толстый адаптер начинает «чинить» данные молча. Мы хотим, чтобы проверка была явной в check_records, а адаптер лишь приводил имена к схеме.

Что дальше

Все 250 листов пока лежат в одном all.jsonl. Следующий урок — Урок 5. Сплит по листам и утечка. Dataset и аугментации — урок 6.

Эталон для сверки: lesson-04. Клонировать его не нужно. Предыдущий: Урок 3. Алфавит, лейблы, формат.

Комментарии

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