Видеоаналитика в СИБУРе — это сложный и многогранный продукт, который внедряется на разных производствах. Несмотря на то, что это один продукт, его конфигурация может сильно отличаться: используются различные камеры, детекторы и параметры, а также интеграции с разнообразными сторонними системами.
В таких условиях инженеру не всегда понятно, что именно надо дописать, а валидация происходит только после окончания редактирования файла и перезапуска сервиса.
Логичное решение — предоставить инженерам удобный интерфейс, где они смогут заполнять форму и сразу видеть ошибки.
Меня зовут Владимир Кирилкин, я техлид в Цифровом СИБУРе, в команде Индустрии 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 в видеоаналитике на множестве площадок.