
В предыдущей статье я подробно рассказывал про свой "аниме завод" — пайплайн, который автоматически превращает эпизоды в готовые Shorts. Но внутри этой системы есть один особенно важный узел, который заслуживает отдельного разбора: виртуальная камера для автоматического кадрирования.
В этой статье я разберу не просто "функцию автокропа", а полноценный алгоритм виртуальной камеры для вертикального видео. Это тот случай, когда задача на первый взгляд кажется простой: есть горизонтальный ролик, нужно сделать 9:16, удержать человека в кадре и не превратить результат в дёрганый автофокус из начала 2010-х.
Но как только начинаешь делать это не для демо, а для реального пайплайна, сразу всплывают инженерные проблемы:
детектор лиц шумит;
лицо периодически теряется;
цель движется неравномерно;
одного "следовать за центром бокса" недостаточно;
идеально точная камера выглядит неестественно и даже хуже, чем немного "человеческая".
В итоге мне понадобилась система, которая ведёт себя не как бездушный кроппер, а как оператор: плавно, с инерцией, с предсказанием движения, с композиционными поправками и с нормальным fallback-режимом, когда лиц в кадре нет вообще.
В статье разберём весь алгоритм целиком:
трёхуровневый face detection fallback: MediaPipe → YuNet → Haar Cascade;
простой, но практичный face tracking между кадрами;
anti-jerk и low-pass фильтрацию;
виртуальную камеру как демпфированный осциллятор;
композиционные правила: rule-of-thirds, side bias, eye-level lift, face margin;
Ken Burns fallback, когда лицо потеряно или отсутствует;
интерполяцию пути камеры и применение виртуального кропа к видео.
1. Почему задача сделать вертикальное видео сложнее, чем кажется
Допустим, у нас есть обычное горизонтальное видео 16:9. Мы хотим получить 9:16 для YouTube Shorts, TikTok или Reels.
Наивный путь выглядит так:
взяли центр кадра → вырезали вертикальное окно → готово
Формально да. Практически — нет.
Если человек сместился влево, камера его обрежет. Если в кадре два человека, композиция развалится. Если лицо двигается, кроп начнёт либо отставать, либо прыгать. Если лиц нет, видео станет либо статичным, либо вообще бессмысленным.
Поэтому здесь нужна не обрезка, а именно виртуальная камера — сущность, у которой есть:
цель наблюдения;
инерция;
ограничение скорости и ускорения;
задержка реакции;
композиционные правила;
fallback-поведение.
Именно это превращает "автокроп" в систему, которая выглядит как работа живого оператора.
2. Архитектура решения целиком
Если убрать детали, весь пайплайн выглядит так:

Ключевая идея: алгоритм не принимает решение "по одному кадру". Он живёт во времени. И качество здесь рождается не столько из точности детектора, сколько из того, как система обращается с неидеальными данными.
3. Детекция лиц: трёхуровневый fallback
Первая часть системы — это не один детектор, а каскад из трёх бэкендов.
3.1 Почему одного детектора недостаточно
Любой face detector иногда ошибается:
теряет лицо на повороте головы;
хуже работает на сложном освещении;
ломается на нефотореалистичных лицах;
может быть банально не установлен в окружении.
Поэтому практичнее строить не "идеальный детектор", а устойчивую систему деградации.
3.2 Схема fallback-цепочки

Это простой, но очень практичный паттерн: сначала используем лучший вариант, затем резервный, затем "последнюю надежду".
3.3 MediaPipe как основной детектор
MediaPipe здесь — базовый рабочий вариант.
Плюсы:
быстро работает на CPU;
даёт confidence;
обычно хорошо ловит лицо даже под углом и при неидеальном свете;
возвращает удобный bounding box в нормализованных координатах.
Пример инициализации:
mp_face_detection = mp.solutions.face_detection.FaceDetection( model_selection=1, min_detection_confidence=0.75 ) results = mp_face_detection.process(frame_bgr) if results.detections: for detection in results.detections: box = detection.location_data.relative_bounding_box confidence = float(detection.score[0])
Для production-пайплайна важно, что на выходе мы приводим всё к единому виду: центр лица, размер, confidence, bounding box.
3.4 YuNet как резервная артиллерия
YuNet нужен не потому, что MediaPipe плохой, а потому что production любит сценарии "что-то пошло не так".
YuNet полезен, когда:
MediaPipe недоступен в окружении;
MediaPipe не нашёл лицо, но оно очевидно есть;
нужен альтернативный ONNX-бэкенд через OpenCV.
Пример:

YuNet медленнее, зато даёт хорошую вторую линию обороны.
3.5 Haar Cascade как "лишь бы не ослепнуть полностью"
Haar Cascade — не лучший детектор по качеству. Но он:
почти везде доступен;
не требует тяжёлых зависимостей;
иногда спасает, когда всё остальное отвалилось.
Пример:
cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) faces = cascade.detectMultiScale(gray, 1.1, 5, minSize=(50, 50))
С инженерной точки зрения ценность Haar не в точности, а в том, что система даже в деградированном режиме не становится полностью слепой.
3.6 Единый интерфейс детектора
Снаружи вся цепочка прячется за одним интерфейсом:
backend = _DetectorBackend(min_confidence=0.75) detections = backend.detect(frame_rgb, min_size=50)
На выходе у нас список детекций с унифицированной структурой:
center = (cx, cy)
size = (w, h)
score
box
Это важный архитектурный момент: остальная часть алгоритма ничего не знает о том, кто именно нашёл лицо.
4. От детекции к трекингу: сопровождение лица между кадрами
Детектор отвечает на вопрос: "что есть на этом кадре?"
Но виртуальной камере нужен другой ответ: "какой объект мы ведём во времени?"
Если этого слоя нет, то при двух лицах или при скачках confidence камера будет бесконечно перескакивать между объектами.
4.1 Простой nearest-neighbor tracking
В данном случае не нужен тяжёлый multi-object tracker. Достаточно простого правила:
берём центр лица на прошлом кадре;
считаем расстояние до всех текущих детекций;
выбираем ближайшую;
если расстояние в пределах допуска — считаем, что это тот же объект.
Код:
mp_face_detection = mp.solutions.face_detection.FaceDetection( model_selection=1, # 1 = более точная модель, чуть медленнее min_detection_confidence=0.75 # порог доверия ) results = mp_face_detection.process(frame_bgr) if results.detections: for detection in results.detections: # detection.location_data содержит bounding box (x, y, w, h) # detection.score[0] содержит confidence ∈ [0, 1] box = detection.location_data.relative_bounding_box confidence = float(detection.score[0])
Почему это работает? Потому что для большинства talking-head и похожих сценариев движение лица между соседними анализируемыми кадрами ограничено. Значит, ближайшая корректная детекция почти всегда и есть продолжение текущего трека.
4.2 Обработка потерь: инертный трек
Одна из самых неприятных проблем в face tracking — кратковременные пропуски:
человек отвернулся;
свет дал блик;
рука перекрыла часть лица;
детектор просто моргнул.
Если в этот момент резко переключиться на fallback, камера дёрнется. Поэтому нужен grace period: короткое время мы продолжаем верить последней известной позиции.
if new_center is None and tracked_face is not None and (t - last_seen_time) < max_miss_time: new_center = tracked_face.center
Это сильно улучшает субъективное качество. На коротких провалах камера выглядит устойчивой, а не нервной.
4.3 Почему здесь не нужен "умный AI tracker"
Можно добавить optical flow, Kalman filter, appearance embeddings, полноценный MOT. Но если задача — вертикальный автокроп для роликов с ограниченным числом лиц, то простой distance-based tracking даёт достаточное качество при минимальной сложности и высокой воспроизводимости.
Иногда лучший алгоритм — не тот, который выглядит умнее на бумаге, а тот, который проще тюнить и чинить в проде.
5. Стабилизация входного сигнала: anti-jerk и low-pass filter
Даже если детектор нашёл правильное лицо, координаты всё равно шумят. Это фундаментальное свойство системы.
Типичные проблемы:
центр бокса слегка дрожит от кадра к кадру;
размер лица прыгает;
иногда прилетает ложная, но "уверенная" детекция далеко от предыдущей точки.
Если отдать это напрямую в камеру, получится дрожание.
5.1 Anti-jerk: жёсткое ограничение скачка
Сначала нужно отрезать совсем неадекватные прыжки.
if filtered_face_center is not None: delta_face = new_center - filtered_face_center max_face_step = 0.04 dist = float(np.linalg.norm(delta_face)) if dist > max_face_step: new_center = filtered_face_center + delta_face * (max_face_step / dist)
Формула:

Смысл простой: лицо не может телепортироваться через полэкрана между соседними кадрами анализа. Если "детектор так говорит", значит, это шум или ложное срабатывание.
5.2 Low-pass filter: сглаживаем остаточный шум
После hard clamp остаются нормальные, но всё ещё шумные колебания. Для них подходит экспоненциальное сглаживание:
yunet = cv2.FaceDetectorYN.create( model=path_to_onnx, config="", input_size=(320, 320), score_threshold=0.75, nms_threshold=0.3, top_k=5000 ) _, faces = yunet.detect(frame_bgr) # faces — массив [x, y, w, h, conf]
В коде:
if filtered_face_center is None: filtered_face_center = new_center.astype(np.float32) else: filtered_face_center = ( filtered_face_center * face_filter + new_center.astype(np.float32) * (1.0 - face_filter) ) new_center = filtered_face_center
Если упростить мысль: мы не доверяем одному измерению полностью. Мы аккуратно смешиваем новую оценку с уже стабилизированным прошлым.
5.3 Почему недостаточно только фильтра
Это важный момент.
Только anti-jerk — грубый инструмент. Он режет крупные выбросы, но не убирает мелкую дрожь.
Только low-pass — тоже недостаточно. Он сгладит шум, но при большом выбросе всё равно утянет сигнал в сторону.
Поэтому оба шага нужны вместе:
детекция → hard clamp → low-pass → камера
Именно такая композиция делает входной сигнал пригодным для дальнейшей физической модели.
6. Виртуальная камера как физическая система
Вот здесь начинается часть, которая реально отличает живой результат от "умного кропа".
Если камера мгновенно ставится в целевую точку, она выглядит как робот. Настоящий оператор так не работает. У него есть инерция, ограничения на ускорение и естественное демпфирование.
Поэтому камеру удобно моделировать как демпфированный осциллятор.
6.1 Математическая модель
Берём классическую spring-damper систему:

Где:
m — условная масса;
k — жёсткость пружины;
c — демпфирование;
x — текущая позиция камеры;
x_target — целевая позиция;
v — скорость камеры.
Интуитивно:
пружина тянет камеру к цели;
демпфирование мешает ей раскачиваться бесконечно;
ограничения на скорость и ускорение делают движение правдоподобным.
6.2 Численная интеграция по кадрам
На каждом шаге анализа:
error = target_center - prev_center accel = error * follow_stiffness - velocity * follow_damping acc_norm = np.linalg.norm(accel) if acc_norm > max_center_accel: accel = accel * (max_center_accel / acc_norm) velocity = velocity + accel * dt velocity *= velocity_soften velocity *= (1.0 - velocity_decay) speed = np.linalg.norm(velocity) if speed > max_center_speed: velocity = velocity * (max_center_speed / speed) new_pos = prev_center + velocity * dt
Эта схема проста, но даёт очень хороший контроль над поведением камеры.
6.3 Смысл ключевых параметров
Чтобы не тюнить вслепую, важно понимать, что делают параметры.
Параметр | Смысл | Эффект при увеличении |
|---|---|---|
| насколько сильно камера тянется к цели | реакция быстрее, но больше риск overshoot |
| сопротивление движению | меньше раскачки, более консервативная камера |
| лимит ускорения | камера не может резко рвануть |
| лимит скорости | камера не полетит быстрее допустимого темпа |
| дополнительное смягчение скорости | меньше высокочастотных колебаний |
| экспоненциальное затухание | камера быстрее успокаивается |
Камера — это не координата, а динамическая система. Именно это делает её визуально правдоподобной.
6.4 Predictive lead: оператор смотрит не туда, где лицо сейчас
Если человек быстро движется, камера не должна просто догонять. Иначе она всегда будет немного отставать.
Поэтому полезно добавить лёгкое предсказание:
cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) faces = cascade.detectMultiScale(gray, 1.1, 5, minSize=(50, 50)) # faces — массив прямоугольников [x, y, w, h]
Это маленькая экстраполяция по текущему движению. Визуально она делает следование более "человеческим".
6.5 Human lag: парадоксально, но камеру иногда надо замедлить
Идеально отзывчивая система часто выглядит хуже живой.
Человек-оператор не телепортируется в новую точку мгновенно. У него есть микрозадержка реакции. Поэтому небольшой лаг иногда улучшает восприятие:
backend = _DetectorBackend(min_confidence=0.75) detections = backend.detect(frame_rgb, min_size=50) # detections — список FaceDetection с (center, size, score, box)
Это тонкий, но важный момент: реализм — не всегда максимум точности.
7. Композиция: камера должна не только следить, но и красиво кадрировать
Даже идеально стабильная камера может давать плохую картинку, если композиция примитивная.
Если постоянно ставить лицо строго в центр, кадр быстро начинает выглядеть плоским и "машинным". Поэтому к физике нужно добавить композиционные эвристики.
7.1 Rule-of-thirds и side bias
Когда лиц несколько или сцена не требует жёсткого центрирования, полезно смещать объект к линиям третей.
if side_bias > 0.0 and not single_face_active: biased_target = 0.5 - side_bias if cx < 0.5 else 0.5 + side_bias edge_proximity = abs(cx - 0.5) * 2 adaptive_bias = side_bias_strength * (1 - edge_proximity) cx = cx * (1.0 - adaptive_bias) + biased_target * adaptive_bias
Здесь важно, что смещение адаптивное. Если лицо уже близко к краю, bias ослабевает, иначе можно сделать кадр хуже, а не лучше.
7.2 Single-face mode
Если в ролике стабильно одно лицо, поведение камеры должно быть другим:
меньше композиционных экспериментов;
больше стабилизации;
более консервативная скорость;
лучше удержание talking-head кадра.
Пример адаптации параметров:
if single_face_active and stabilization_strength > 0.0: effective_face_filter = min(0.995, face_filter + stabilization_strength * 0.1) effective_smoothing = min(0.985, smoothing + stabilization_strength * 0.08) effective_center_dead_zone = min(0.35, center_dead_zone + stabilization_strength * 0.08) effective_max_center_speed = max(0.05, max_center_speed * (1.0 - 0.25 * stabilization_strength))
Именно такие conditional tuning-правила обычно и отличают "рабочую систему" от абстрактного алгоритма.
7.3 Eye-level lift: целимся в глаза, а не в геометрический центр
Bounding box лица — это ещё не композиция. Если целиться строго в центр бокса, мы часто получаем кадр, где камера смотрит в нос.
Гораздо лучше — чуть поднять точку внимания к уровню глаз:
cy = np.clip(cy - tracked_face.size[1] * eye_level_lift, 0.0, 1.0)
Это маленькая поправка, но она сильно влияет на субъективное качество кадра.
7.4 Dead zone: игнорируем микродвижения
Если человек слегка шевельнулся или детектор дал микрошум, камера не должна реагировать на каждую мелочь.
delta = target_center - prev_center dist = np.linalg.norm(delta) if dist < effective_center_dead_zone: target_center = prev_center
Dead zone — один из самых недооценённых параметров. Без него камера выглядит нервной даже при хорошей детекции.
7.5 Face margin: лицо нельзя прижимать к краю
Даже если математика разрешает двигать центр куда угодно, в реальной композиции нужен защитный зазор.
if face_margin > 0.0: half_crop_w = min(0.5, (target_width / sw) / (2.0 * z)) half_crop_h = min(0.5, (target_height / sh) / (2.0 * z)) guard_x = max(face_margin, half_crop_w) guard_y = max(face_margin, half_crop_h) cx = np.clip(cx, guard_x, 1.0 - guard_x) cy = np.clip(cy, guard_y, 1.0 - guard_y)
Это защищает лицо от неприятного подрезания ушей, волос, жестов и вообще делает кадр более "воздушным".
8. Что делать, когда лиц нет: Ken Burns fallback
Система, которая умеет работать только в режиме "вижу лицо", ломается на любом более сложном видео.
Нужен fallback-режим, когда:
лицо потеряно;
лица нет вовсе;
в кадре статичная сцена;
детектор не справился.
Здесь помогает классический Ken Burns effect — мягкое панорамирование и зум.
8.1 Модель движения Ken Burns
Простейшая версия строится на синусах:
if tracked_face is not None: prev_center = tracked_face.center # где было лицо раньше # найти ближайшую детекцию dists = [np.linalg.norm(d.center - prev_center) for d in dets] j = int(np.argmin(dists)) # индекс ближайшей # если расстояние приемлемо — обновляем трек if dists[j] < match_tolerance: # обычно 0.15-0.20 tracked_face = dets[j] new_center = dets[j].center last_seen_time = t # иначе трек потеряется, и будет Ken Burns
Это даёт контролируемое, плавное движение без случайного дёрганья.
8.2 Почему Ken Burns лучше статичного центра
Статичный кроп, когда лиц нет, выглядит как баг. Ken Burns создаёт впечатление, что система всё ещё "держит сцену", а не просто замерла.
Для многих роликов этого уже достаточно, чтобы fallback не воспринимался как деградация.
8.3 Мягкий переход из face tracking в fallback
Переход тоже должен быть плавным.
else: kc, kz = _ken_burns_motion(...) camera_states.append(CameraState(t, kc, kz, False)) velocity *= 0.5
Мы не телепортируемся в новую логику, а постепенно гасим накопленную скорость камеры.
Это тот тип деталей, которые не всегда видны в коде сразу, но очень заметны в результате.
9. Из дискретных состояний в непрерывный путь камеры
На этапе анализа мы считаем camera states с частотой, например, 8 FPS. Но финальное видео может быть 30 FPS или 60 FPS.
Если применять путь камеры как есть, движение будет ступенчатым. Поэтому нужен непрерывный path через интерполяцию.
9.1 Интерполяция состояний
Базовый вариант — линейная интерполяция между соседними точками:
if new_center is None and tracked_face is not None and (t - last_seen_time) < max_miss_time: new_center = tracked_face.center # используем старое значение
Этого достаточно, потому что сама физическая модель уже делает траекторию достаточно гладкой.
9.2 Применение виртуальной камеры
Когда есть функция path(t), дальше всё довольно прямолинейно:
получаем исходный кадр;
берём центр и zoom из
path(t);считаем ROI;
вырезаем область;
масштабируем её под итоговый размер 9:16.

На этом шаге алгоритм превращается из аналитической модели в реальное видео.
10. Рабочий профиль параметров: operator mode
Самое интересное в таких системах — не только формулы, но и то, как они тюнятся в реальности.
Ниже профиль, который ориентирован на "живого оператора" для talking-head и похожих сценариев:
analysis_fps: 8 min_face_ratio: 0.06 min_face_confidence: 0.75 match_tolerance: 0.18 max_miss_time: 2.7 smoothing: 0.93 stabilization_strength: 0.26 follow_stiffness: 8.4 follow_damping: 2.35 max_center_accel: 0.85 predictive_lead: 0.065 human_lag: 0.02 velocity_soften: 0.86 velocity_decay: 0.10 face_filter: 0.80 face_margin: 0.085 side_bias: 0.22 side_bias_strength: 0.37 center_dead_zone: 0.052 max_center_speed: 0.40 eye_level_lift: 0.10 zoom_smoothing: 0.75 ken_burns_period: 12 ken_burns_pan_amplitude: 4.0 ken_burns_tilt_amplitude: 2.0 ken_burns_zoom_amplitude: 0.0
Эта конфигурация не претендует на универсальность, но хорошо показывает важную мысль: качество здесь рождается из баланса параметров, а не из одной магической нейросети.
10.1 Профили под разные стили
Стиль | Stiffness | Damping | Max speed | Идея |
|---|---|---|---|---|
Static / robotic | 2.0 | 5.0 | 0.05 | почти не двигается |
Operator mode | 8.4 | 2.35 | 0.40 | живое, но контролируемое движение |
Action | 15.0 | 1.2 | 0.80 | быстрая реакция |
Cinematic | 4.0 | 4.0 | 0.15 | медленно и мягко |
Это удобный способ думать не отдельными числами, а профилями поведения камеры.
11. Практические edge cases
Любая статья про алгоритм выглядит неполной, если не сказать, где он ломается и как это чинить.
11.1 Лицо под углом или профиль
Если человек поворачивается в профиль, детектор может терять трек. Что помогает:
увеличить
max_miss_time;ослабить
match_tolerance;подключить YuNet как дополнительный детектор;
снизить
min_face_confidence, если качество входа нестабильное.
11.2 Аниме и нефотореалистичные лица
Если модель обучена на фотографиях, а вход — аниме, проблема не в "плохом коде", а в domain mismatch.
Практические варианты:
ослабить пороги;
уменьшить
min_face_ratio;использовать альтернативный бэкенд;
при необходимости перейти на специализированный детектор.
11.3 Несколько лиц в кадре
Когда в кадре два человека, слишком агрессивный single-face режим только навредит.
Тогда лучше:
if filtered_face_center is not None: delta_face = new_center - filtered_face_center max_face_step = 0.04 # макс движение за кадр (доля экрана) dist = float(np.linalg.norm(delta_face)) if dist > max_face_step: # скачок слишком большой → обрезаем его new_center = filtered_face_center + delta_face * (max_face_step / dist)
И позволить композиции удерживать двух говорящих более естественно.
11.4 Быстрое движение
Для резких движений полезен adaptive boost:

Это даёт камере возможность "проснуться" на энергичном движении, не делая всю систему постоянно гиперактивной.
12. Почему эта архитектура хорошо выглядит
На мой взгляд, сильная инженерная ценность этого решения не в отдельных формулах, а в общем подходе к системе.
Что здесь важно:
Не полагаться на один идеальный компонент. Вместо этого — fallback-архитектура.
Не доверять сырым данным. Детекция проходит через стабилизацию.
Моделировать динамику явно. Камера — это физическая система, а не набор if-ов.
Учитывать восприятие человека. Human lag, eye-level lift, dead zone, композиционные правила.
Проектировать деградацию. Когда лиц нет, система всё равно выдаёт вменяемый результат.
Думать не только про accuracy, но и про subjective quality.
13. Производительность и вычислительная стоимость
Для полноты картины стоит оценить и стоимость пайплайна.
Пусть:
N — число анализируемых кадров;
D — время face detection на кадр;
P — время трекинга и физики камеры.
Тогда:
filtered_center = α · filtered_center + (1 - α) · new_center
Для видео на 5 минут при analysis_fps = 8 получаем:
N = 300 × 8 = 2400
Дальше добавляется применение виртуальной камеры на каждый итоговый кадр:
T_apply ≈ M · R
Где:
M — число выходных кадров;
R — стоимость crop + resize.
В реальном пайплайне это вполне укладывается в практическое использование, особенно если анализ идёт на пониженной копии видео, а итоговый crop применяется уже к оригиналу.
13.1 Как удешевить анализ
Три самых практичных шага:
снижать разрешение на этапе detection;
уменьшать
analysis_fps, если сцена медленная;не держать все fallback-бэкенды активными, если основной стабильно работает.
Это скучные оптимизации, но именно они обычно и дают лучший ROI.
14. Что в итоге получилось
Если собрать всё вместе, алгоритм выглядит так:

На практике это даёт систему, которая:
не дёргается на шуме детектора;
не выглядит роботизированной;
умеет мягко деградировать;
держит talking-head сцены и обычные ролики заметно лучше, чем "кроп по центру";
