Видеоаналитика в СИБУРе — это сложный и многогранный продукт, который внедряется на разных производствах. Несмотря на то, что это один продукт, его конфигурация может сильно отличаться: используются различные камеры, детекторы и параметры, а также интеграции с разнообразными сторонними системами.
В таких условиях инженеру не всегда понятно, что именно надо дописать, а валидация происходит только после окончания редактирования файла и перезапуска сервиса.
Логичное решение — предоставить инженерам удобный интерфейс, где они смогут заполнять форму и сразу видеть ошибки.
Меня зовут Владимир Кирилкин, я техлид в Цифровом СИБУРе, в команде Индустрии 4.0. Мы разрабатываем продукт «Видеоаналитика на производстве», и о наших задачах уже писали на Хабре.
Мы подошли к задаче нестандартно: вместо заранее заданных форм на фронте реализовали их автоматическую генерацию с использованием JSON-схем и немного ✨магии✨.
Наши сервисы построены на Python и React, но предложенный подход можно адаптировать и для других технологий — правда, с чуть меньшим количеством ✨магии✨.
Итак, решение — использовать Pydantic, который генерирует JSON-схему для конфигов → фронт рендерит формы по json схеме → пользователь заполняет форму и видит ошибки валидации → применение конфига на беке.
Этот подход имеет дополнительное преимущество: при изменении бэкенда нет необходимости тревожить фронтенд-разработчика.
С чем работаем
Проектов в работе много, приведу пару примеров.
Контроль использования страховочной привязи
Детекция людей, поиск привязи на людях.
Интеграция с системами технологического видеонаблюдения (СТВН).
Оповещения по email в ОТиПБ.
Отслеживание работ на станках
Детекция движений в зонах.
Отправка данных в BI систему для дашборда план/факта работ.
Сливо-наливные эстакады
Классификация этапа слива-налива по видеокамере.
Получение данных из MES (Система управления производственными процессами).
Отправка данных в ЭКОНС (внутренняя система визуализации, о ней тоже писали тут).

Поиск решения

У Pydantic есть интересная функция: мы можем получить JSON-схему для описанной модели EmailConfig1.model_json_schema()
{ "properties": { "host": { "title": "Host", "type": "string" }, "port": { "title": "Port", "type": "integer" }, "username": { "title": "Username", "type": "string" }, "sender": { "title": "Sender", "type": "string" }, "timeout": { "default": 60, "title": "Timeout", "type": "integer" } }, "required": [ "host", "port", "username", "sender" ], "title": "EmailConfig1", "type": "object" }
Но что мы можем с ней сделать?
Для фронта (здесь и далее под фронтом подразумевается web приложение на React) существует библиотека react-jsonschema-form, которая умеет рендерить формы по JSON-схеме.
Что такое JSON схема
JSON Schema — это спецификация для описания структуры JSON-документов. Она позволяет указать формат данных (числа, строки, объекты, массивы), допустимые значения (минимумы, максимумы), обязательные и необязательные поля.
Учитывая, что JSON легко конвертируется и в другие структурированные форматы (yaml, toml, …), то json схемы применимы и к ним.
Можно заодно упомянуть сайт schemastore.org, на котором есть схемы для множества файлов. Сам я постоянно пользуюсь этими схемами для gitlab-ci.yml и pyproject.toml.
Так же json схемы можно встретить в openAPI. Именно с помощью него описываются параметры и результаты запросов
Вернемся к спецификации. У неё есть разные версии.
На текущий момент
Pydantic поддерживает 2020-12
RJSF — draft-07, на имплементацию новых версий открыт issue
AJV (библиотека, которую использует RJSF для валидации форм) - все версии
Краткий список изменений новых версий
2019-09
Версионирование
Зависимости схем
Новые форматы даты/времени
Дополнительная валидация
2020-12
Динамические ссылки
Формализация ключевых слов
Дополнительная кастомизация
В своей работе мы не встречали ситуации, в которой pydantic генерировал схему, вызывающую проблемы на фронте.
Реализация

Схема
{ "properties": { "host": { "title": "Host", "type": "string" }, "port": { "title": "Port", "type": "integer" }, "username": { "title": "Username", "type": "string" }, "sender": { "title": "Sender", "type": "string" }, "timeout": { "default": 60, "title": "Timeout", "type": "integer" } }, "required": [ "host", "port", "username", "sender" ], "title": "EmailConfig1", "type": "object" }
Pydantic позволяет добавлять метаданные. Для бекэнд разработчиков они не приносят большой пользы, но сильно упрощают понимание формы на фронте.
class EmailConfig2(BaseModel): """Конфигурация почтового сервера для отправки оповещений""" host: IPvAnyAddress = Field(..., title="Хост почтового сервера") port: int = Field( default=25, title="Порт почтового сервера", description="Порт сервера отправки почты", le=65535, ge=1, ) username: str | None = Field(None, title="Имя пользователя", min_length=3) sender: str = Field( default="Оповещения bscreen", title="Имя отправителя", min_length=3, description="Имя отправителя, которое будет отображаться в письме", ) timeout: int = Field(60, title="Таймаут", description="Таймаут соединения", ge=1) model_config = ConfigDict(title="Конфигурация почтового сервера")
Все эти дополнения отражаются в json схеме
Схема
{ "description": "Конфигурация почтового сервера для отправки оповещений", "properties": { "host": { "format": "ipvanyaddress", "title": "Хост почтового сервера", "type": "string" }, "port": { "default": 25, "description": "Порт сервера отправки почты", "maximum": 65535, "minimum": 1, "title": "Порт почтового сервера", "type": "integer" }, "username": { "anyOf": [ { "minLength": 3, "type": "string" }, { "type": "null" } ], "default": null, "title": "Имя пользователя" }, "sender": { "default": "Оповещения bscreen", "description": "Имя отправителя, которое будет отображаться в письме", "minLength": 3, "title": "Имя отправителя", "type": "string" }, "timeout": { "default": 60, "description": "Таймаут соединения", "minimum": 1, "title": "Таймаут", "type": "integer" } }, "required": [ "host" ], "title": "Конфигурация почтового сервера", "type": "object" }
А благодаря добавлению в модель ограничений, мы можем выполнять валидацию на клиенте без взаимодействия с беком.

В итоге мы можем рассчитывать на три уровня валидации:
HTML5
AJV
Pydantic — валидация на стороне бекэнда
Усложняем схему
Добавляем вложенные схемы и свои валидаторы.
Конечно, написанные нами валидаторы model_validator и field_validator никак не попадут на фронт. Задействовать их мы сможем только при попытке применить к схеме новые данные:
class TLSConfig(BaseModel): key_file: Path | None = None cert_file: Path | None = None @model_validator(mode="after") @classmethod def check_tls(cls, v): if bool(v.cert_file) ^ bool(v.key_file): raise ValueError("Оба параметра key_file и cert_file должны быть установлены или не установлены") return v @field_validator("key_file", "cert_file") @classmethod def check_paths(cls, v: Path | None): if v and not v.exists(): raise ValueError(f"Файл {v} не существует") return v class EmailConfig3(BaseModel): """Конфигурация почтового сервера для отправки оповещений""" tls: TLSConfig = Field(default_factory=TLSConfig, title="Настройки безопасности")
Схема
{ "$defs": { "TLSConfig": { "properties": { "key_file": { "anyOf": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "title": "Key File" }, "cert_file": { "anyOf": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "title": "Cert File" } }, "title": "TLSConfig", "type": "object" } }, "description": "Конфигурация почтового сервера для отправки оповещений", "properties": { "tls": { "$ref": "#/$defs/TLSConfig", "title": "Настройки безопасности" } }, "required": [ "host" ], "title": "Конфигурация почтового сервера", "type": "object" }

Как мы можем видеть, на фронте tls отображается в отдельном блоке.
Мы сделали собственные валидаторы, но пока что они выполнятся только на беке. Нам важно донести до пользователя, где именно возникли ошибки, что он сделал не так.
Проброс ошибок с бека
Pydantic умеет подробно рассказывать о возникших ошибках:
[ { "type": "ip_any_address", "loc": [ "host" ], "msg": "value is not a valid IPv4 or IPv6 address", "input": "34234", "url": "https://errors.pydantic.dev/2.9/v/ip_any_address" } ]
RJSF умеет рисовать на форме ошибки извне, однако ожидает он совсем другой формат:
{ "host": { "__errors": [ "value is not a valid IPv4 or IPv6 address" ] } }
Мы решили эту проблему через указатели в JSON (RFC 6901):
Преобразуем путь к ошибке из списка полей в строку
"/host/__errors/-"Формируем новый объект и добавляем к нему все полученные ошибки
jsonpointer.set(res_errors, path, error.msg)
Кастомизация фронта
Тема
RJSF предоставляет несколько официальных тем, но нам этого не хватило. Мы решили доработать тему antd под свои требования и расширить собственными компонентами.
Это выглядит следующим образом:
const customTheme = { ...AntdFormTheme, templates: { ...AntdFormTheme.templates, FieldTemplate, ArrayFieldTemplate, ArrayFieldItemTemplate, ObjectFieldTemplate, TitleFieldTemplate, ErrorListTemplate: ErrorList, WrapIfAdditionalTemplate, BaseInputTemplate, }, fields: { ...AntdFormTheme.fields, StringField }, widgets: { ...AntdFormTheme.widgets, RegionWidget, RegionWidgetV2, SelectBoxCustom, TextareaWidget, }, };
При наличии свободных рук можно сделать полностью свою тему, используя UI Kit компании, однако у нас свободных рук не нашлось)
Свои виджеты
RJSF позволяет настраивать выбор виджетов через объект ui_schema, но из-за динамичности формы мы отказались от этого варианта.
Мы пошли другим путём — немного дополнили компонент ObjectFieldTemplate, добавив возможность определять виджет в JSON схеме:
function loadWidget() { if (!schema.hasOwnProperty("web_widget")) return false; let Widget = getWidget(schema, schema.web_widget, widgets); return ( <Widget addText={addText} disabled={disabled} formContext={formContext} formData={formData} idSchema={idSchema} properties={properties} onAddClick={onAddClick(schema)} readonly={readonly} registry={registry} required={required} schema={schema} title={title} uiSchema={uiSchema} /> ); }
Использование в беке:
regions: Dict[str, Region] = Field( default_factory=dict, title="Регионы камеры", json_schema_extra={"web_widget": "RegionWidgetV2"}, )
Пример своего виджета
Одной из главной задач для нас была возможность настройки регионов. Записывать цифрами координаты точек весело, но не практично, поэтому мы сделали свой виджет редактора регионов — кнопка в форме и модальное окно с редактором.

Особенности библиотеки RJSF: необходимо получать данные из formContext.formData, обновлять через колбеки onAddClick, item.onKeyChange, item.onChange и item.onKeyChange. То есть, нельзя сразу добавить полигон с координатами, необходимо сначала вызвать onAddClick, а затем отловить созданный элемент и уже на нём вызвать onChange.
Ссылки на данные внутри формы
Также мы добавили возможность ссылаться на другие части формы. Например, в сервисе нотификации нужно сначала объявить варианты отправки уведомлений, а затем, для каждого из требуемых инцидентов, указать заранее определенный вариант.
Для этого мы используем JSONPath — язык запросов для json.
class NotifierMessageConfig(BaseModel): name: str = Field( title="Плагин способа отправки", json_schema_extra={ "web_widget": "SelectBoxCustom", "web_query": "$.notifiers[*].web_dict_key", }, description="Выбрать способ отправки уведомления" )
На фронте для этого создали контекст, который задаём в ObjectFieldTemplate
<PathContext.Provider value={path + "." + element.name} key={element.name}> <Col span={spanWidth}>{element.content}</Col> </PathContext.Provider>
И затем используем в компоненте SelectBoxCustom:
function updateVariants() { let variants: string[] = jsonpath.query( props.formContext.formData, props.schema.web_query, ); setVariants(variants.map((s) => ({ value: s, label: s }))); } return ( <SelectWidget {...props} options={{ enumOptions: variants }} onFocus={updateVariants} /> );
Тут стоит отметить потенциальные проблемы этого решения. Из-за того, что в web_query используется абсолютный путь к полям, в случае переименования/изменения структуры, необходимо проконтролировать обновление и самого поля
Плагины на стороне бека
В python у библиотек можно описывать “точки входа” - entrypoint (документация).
С их помощью мы обозначаем подключаемые модули для сервиса, как детекторы и модели, устанавливаемые отдельными пакетами, так и внутри одного пакета.
В файле pyproject.toml описывается список entrypoint пакета:
[project.entry-points."bscreen_notificator.notifiers"] storage = "bscreen_notificator.notifiers.storage:StorageNotifier" email = "bscreen_notificator.notifiers.email:EmailNotifier" logger = "bscreen_notificator.notifiers.logger:LoggerNotifier" mtoir = "bscreen_notificator.notifiers.mtoir:MtoirNotifier" intellect_video = "bscreen_notificator.notifiers.intellect:IntellectVideoNotifier" siiot = "bscreen_notificator.notifiers.siiot:SiiotNotifier"
А в коде мы можем получить все entrypoint по группе:
>>> from importlib.metadata import entry_points >>> entry_points(group="bscreen_notificator.notifiers") [ EntryPoint( name="email", value="bscreen_notificator.notifiers.email:EmailNotifier", group="bscreen_notificator.notifiers", ), EntryPoint( name="intellect_video", value="bscreen_notificator.notifiers.intellect_video:IntellectVideoNotifier", group="bscreen_notificator.notifiers" ), ... ]
Погрузившись в дебри Pydantic и написав множество строк кода, мы получили возможность динамически подключать внешние модули и конфигурировать их.
Выглядит примерно так:
class NotifierPluginField( PluginClassField, entrypoint="bscreen_notificator.notifiers", bases=[AbstractNotifier], ): pas class NotifierConfig(BuildablePluginModel, args_field_name="args"): cls: NotifierPluginField = Field( title="Плагин способа отправки", description="Выбрать способ отправки уведомления (email, storage, logger..)", ) args: NotifierPluginField.get_config_type_hint() = Field( default_factory=dict, title="Параметры способа отправки", )
Получаем схему (приведена только часть):
{ ... "allOf": [ { "if": { "properties": { "cls": { "enum": [ "email" ] } } }, "then": { "properties": { "args": { "$ref": "#/$defs/bscreen_EmailNotifier__Config", "title": "EmailNotifier:Config-Input" } } } }, { "if": { "properties": { "cls": { "enum": [ "intellect_video" ] } } }, "then": { "properties": { "args": { "$ref": "#/$defs/bscreen_IntellectVideoNotifier__Config", "title": "IntellectVideoNotifier:Config-Input" } } } } ] }
Как мы можем видеть, объект в args выбирается динамически, в зависимости от cls

Что мы получили
Плюсы
📦 Всё работает «из коробки» — требуется только соединить фронт с беком.
✅ Проброс ошибок с бека требует одну функцию.
💅 Можно приделать любую тему.
✨ При должном подходе можно творить магию.
🫥 Для изменений фронта не нужен фронтенд разработчик — все изменения форм идут только с бека. В нашей команде вообще нет фронтенд разработчика, только я - фулстек.
Минусы
📉 На больших схемах падает производительность. У нас проблемы заметны в случаях, когда в списках содержатся большие объекты.
🌡️ На свои изменения фронта надо писать тесты, чтоб ничего не сломалось при переходе между версиями Pydantic или RJSF. У нас ломалось при переходе с первой версии Pydantic на вторую.
*Ссылки
RJSF playground - можно посмотреть примеры схем, попробовать свою схему
Рассказ от моих коллег о том, как устроен CD в видеоаналитике на множестве площадок.
