primocode 10.16.0 → 10.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +28 -8
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/mcp.js +105 -7
- package/lib/nuvem.js +11 -1
- package/lib/outra-pasta.js +1 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +29 -12
- package/lib/protecao.js +134 -0
- package/lib/sandbox.js +11 -4
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- package/voz/primo_voz/painel_dados.py +134 -0
|
@@ -0,0 +1,2113 @@
|
|
|
1
|
+
"""Tela verde (green screen without a green screen): the speaker cut out of the cut and placed over a
|
|
2
|
+
background, as the base layer of compose_final.py (SPEC Contract 13).
|
|
3
|
+
|
|
4
|
+
edit_data.json:
|
|
5
|
+
"edit_type": "tela_verde",
|
|
6
|
+
"tela_verde": {"posicao": "direita" | "esquerda" | "centro", "altura": 0.55,
|
|
7
|
+
"contorno": {"cor": "#FFFFFF", "largura": 8} | null, "sombra": true,
|
|
8
|
+
"fundo": [{"src": "assets/x.mp4" | "assets/y.png" | "#101010", "start": 0, "end": 12.3,
|
|
9
|
+
"ken_burns": true, "escurecer": 0.15, "desfocar": 0,
|
|
10
|
+
"encaixe": "auto" | "cobrir" | "inteiro"}],
|
|
11
|
+
"fundo_original_desfocado": false, "sem_rosto": "original" | "recorte", "espelhar": false}
|
|
12
|
+
"inserts": [{"type": "split_top" | "split_bottom", ...}] -> backgrounds too (add_insert_backgrounds)
|
|
13
|
+
|
|
14
|
+
How a frame is made (numpy + OpenCV, several frames at once in threads):
|
|
15
|
+
- background track: the items of "fundo" are the base; the split_top / split_bottom inserts of the edit become
|
|
16
|
+
background items that win during their own [start, end) (no split panel). A later item wins where two of
|
|
17
|
+
the same kind overlap; a gap keeps the item before it; before the first item, the blurred original with
|
|
18
|
+
fundo_original_desfocado, else a dark color.
|
|
19
|
+
- encaixe: "cobrir" fills the screen (cropping; a page taller than the screen keeps its top), "inteiro" shows
|
|
20
|
+
the whole picture or video, sharp, scaled to the width and placed in the stage above the person (the part of
|
|
21
|
+
the screen the person does not cover, minus a band for auto captions when there is room) over a blurred and
|
|
22
|
+
darkened cover copy of itself; "auto" is cobrir when the shape is close to the screen's (or taller), else
|
|
23
|
+
inteiro. Ken Burns is an eased 6% zoom over the item's time (at most twice that time when a gap holds it,
|
|
24
|
+
then it holds). Videos start at their own frame 0 and play untouched, except empty frames at the start or
|
|
25
|
+
end of the clip (scan_video): the background right before it dissolves into an empty start while the clip
|
|
26
|
+
builds up (with nothing before it, the first full frame is held there) and an empty end holds the last full
|
|
27
|
+
frame; only a video held past its end loops, dissolving from its last frame
|
|
28
|
+
into the new pass (no jump, no freeze). A video shown whole with a plain border (a studio, a flat color)
|
|
29
|
+
stays wide (down to QUIET_MIN of the width, to leave the captions a band above the head), its main area
|
|
30
|
+
(the rows that hold most of its detail over the whole clip) placed in the stage, and its border is carried
|
|
31
|
+
out to the screen edges, so it has no box around it; the part that does not fit above the head goes behind
|
|
32
|
+
the captions and the person.
|
|
33
|
+
- person (cut ranges where face_layout found a face, or every range with "sem_rosto": "recorte"): the frame
|
|
34
|
+
of cut.mp4 times the cached matte (helpers/matte.py; on a tripod take, without the room its RoomPlate
|
|
35
|
+
recognizes where the matte is unsure, and with that room's color out of its soft edge), scaled so the
|
|
36
|
+
person, from the top of the head to
|
|
37
|
+
the bottom of the screen, is `altura` of the screen height (never wider than 72% of the screen in a
|
|
38
|
+
corner, or than the screen in the center, before the zoom; a centered tight shot cut on both sides grows up to
|
|
39
|
+
CENTER_FILL to fill the width), flush with the bottom. In a corner the side the camera cut sits on the screen
|
|
40
|
+
edge; in the center a cut side never lands inside the screen when the frame is wide enough; in a corner, a
|
|
41
|
+
segment cut on both sides fills the screen width (up to CORNER_FILL times its corner size), a little lower
|
|
42
|
+
(up to FILL_DROP of the camera frame goes below the screen), so no cut lands inside the screen. A segment whose
|
|
43
|
+
face is cut by a side of the camera goes to that side (a face is never sliced in the middle of the screen). A
|
|
44
|
+
cut edge that still falls inside the screen gets a fade only on the rows the body really touches (SIDE_FADE
|
|
45
|
+
on a side, so an arm or shoulder dissolves instead of ending in a straight line; a short one on the top; the
|
|
46
|
+
person, its outline and its shadow fade together, so the other rows stay solid and the outline never shows
|
|
47
|
+
through).
|
|
48
|
+
The outline follows the outer body only: a gap it encloses (between an arm and the body, counting the
|
|
49
|
+
bottom and the cut sides as closed) shows the background with no outline around it.
|
|
50
|
+
zoom_cuts and push_in scale the person (anchored on that edge, or on the head for centro). "espelhar" flips
|
|
51
|
+
the person segments (not the ones shown as recorded). Outline and shadow are drawn at half size and blended
|
|
52
|
+
with cv2.blendLinear.
|
|
53
|
+
- ranges without a face ("sem_rosto": "original", the default) show the cut frame itself, full screen,
|
|
54
|
+
with the zoom of the camera section (screen recordings, product close-ups).
|
|
55
|
+
The result is one H.264 clip (renders/<full|preview>/telaverde/tv_<key>.mp4, cached by everything that goes
|
|
56
|
+
into it) that compose_final.py uses in place of cut.mp4.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import json
|
|
63
|
+
import math
|
|
64
|
+
import os
|
|
65
|
+
import subprocess
|
|
66
|
+
import time
|
|
67
|
+
from collections import deque
|
|
68
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
69
|
+
from dataclasses import dataclass, field
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
from typing import Callable
|
|
72
|
+
|
|
73
|
+
import numpy as np
|
|
74
|
+
|
|
75
|
+
import matte as M
|
|
76
|
+
|
|
77
|
+
TV_VERSION = "tv5" # bump to redo cached composites
|
|
78
|
+
POSITIONS = ("direita", "esquerda", "centro")
|
|
79
|
+
POSITION_ALIASES = {"direita": "direita", "right": "direita", "canto direito": "direita", "canto_direito": "direita",
|
|
80
|
+
"esquerda": "esquerda", "left": "esquerda", "canto esquerdo": "esquerda",
|
|
81
|
+
"canto_esquerdo": "esquerda", "centro": "centro", "center": "centro", "meio": "centro"}
|
|
82
|
+
DEFAULT_HEIGHT = {"direita": 0.55, "esquerda": 0.55, "centro": 0.75}
|
|
83
|
+
HEIGHT_RANGE = (0.3, 1.0)
|
|
84
|
+
CORNER_MAX_WIDTH = 0.72 # a corner person is never wider than this share of the screen (before the zoom)
|
|
85
|
+
CENTER_MAX_WIDTH = 1.0 # a centered person is never wider than the screen (a tight shot is not enlarged)
|
|
86
|
+
CENTER_FILL = 1.15 # ...except a shot cut on both sides, grown up to this much to fill the width
|
|
87
|
+
CORNER_FILL = 1.6 # a corner person cut on both sides grows up to this much to fill the width
|
|
88
|
+
FILL_DROP = 0.2 # ...and loses up to this share of the bottom of the camera frame, so the head
|
|
89
|
+
# stays where the corner size puts it (the images above keep their room)
|
|
90
|
+
FACE_EDGE = 0.015 # a face box this close to a side of the camera frame is cut by it
|
|
91
|
+
INNER_CUT_WARN = 0.4 # share of the person time with a body cut inside the screen that gets advice
|
|
92
|
+
MAX_SCALE = 2.0 # never enlarge the camera frame more than this
|
|
93
|
+
SIDE_MARGIN = 40 # px (1080 wide) between a person that is not cut by the camera and the edge
|
|
94
|
+
EDGE_FADE = 28 # px (1080 wide) of fade where the top of the camera frame cuts the body
|
|
95
|
+
SIDE_FADE = 90 # px (1080 wide) of fade where a side of the camera frame cuts the body inside
|
|
96
|
+
# the screen (the arm or shoulder dissolves instead of ending in a straight line)
|
|
97
|
+
TOUCH_FLUSH = 0.2 # share of frames touching a side for that side to sit on the screen edge
|
|
98
|
+
OUTLINE_DEFAULT = {"cor": "#FFFFFF", "largura": 8}
|
|
99
|
+
OUTLINE_MAX = 40
|
|
100
|
+
SHADOW = {"alpha": 0.5, "blur": 10.0, "dx": 6.0, "dy": 12.0} # px on 1080 wide
|
|
101
|
+
DARK_DEFAULT = "#101010"
|
|
102
|
+
DARKEN_DEFAULT = 0.15
|
|
103
|
+
DARKEN_MAX = 0.9
|
|
104
|
+
BLUR_MAX = 80.0
|
|
105
|
+
ORIGINAL_BLUR = {"sigma": 40.0, "darken": 0.35}
|
|
106
|
+
BACKDROP = {"sigma": 70.0, "darken": 0.55, "saturation": 0.55, "feather": 36.0} # behind a picture shown whole
|
|
107
|
+
KB_DRIFT = 0.06
|
|
108
|
+
KB_HOLD_AFTER = 2.0 # a held gap stretches the Ken Burns to at most this many times the item's time
|
|
109
|
+
FIT_RATIO = 1.25 # auto: a shape this close to the screen's (or taller) fills it, else it is whole
|
|
110
|
+
FITS = ("auto", "cobrir", "inteiro")
|
|
111
|
+
FIT_ALIASES = {"auto": "auto", "automatico": "auto", "automático": "auto", "cobrir": "cobrir", "cobre": "cobrir",
|
|
112
|
+
"cover": "cobrir", "preencher": "cobrir", "encher": "cobrir", "fill": "cobrir", "inteiro": "inteiro",
|
|
113
|
+
"inteira": "inteiro", "contain": "inteiro", "whole": "inteiro", "fit": "inteiro"}
|
|
114
|
+
STAGE_TOP = 120 / 1920 # the stage for a whole picture starts this far down (the app bar sits above it)
|
|
115
|
+
STAGE_GAP = 0.012 # share of the height between a whole picture and the head (and the captions)
|
|
116
|
+
WHOLE_MIN = 0.5 # a whole picture shrinks to fit the stage down to this share of the width
|
|
117
|
+
RESERVE_KEEP = 0.75 # the stage leaves room for captions when the picture keeps this share of its size
|
|
118
|
+
LOOP_FADE = 0.4 # s of crossfade where a background video loops
|
|
119
|
+
SCAN_VERSION = "scan3" # bump when scan_video changes (its cache is redone)
|
|
120
|
+
SCAN_SIDE = 160 # px of the long side the videos are scanned at
|
|
121
|
+
SCAN_DETAIL = 24 # 0-255: a pixel this far from its blurred surroundings is detail
|
|
122
|
+
SCAN_EMPTY = (0.003, 0.15, 0.5) # a frame with less detail than max(abs, share of the clip's median) is empty;
|
|
123
|
+
# next to an empty first or last frame, the ones under the last share still
|
|
124
|
+
# build up or fade out (they count as empty too)
|
|
125
|
+
SCAN_FADE = (0.02, 0.65) # a clip that ends empty after a steady fall (noise allowed: this share of the
|
|
126
|
+
# median) holds the last frame that still has this share of the fall's peak
|
|
127
|
+
SCAN_ROW = 0.01 # share of detail (over the clip, and 10% of the busiest) a row or column
|
|
128
|
+
# needs to be main area
|
|
129
|
+
SCAN_MASS = 0.85 # ...and the main rows are the band that holds this share of the detail (a thin
|
|
130
|
+
# cable or a glow at the edge of the clip is not its main area)
|
|
131
|
+
QUIET_MIN = 0.75 # a plain-border video shrinks down to this share of the width (its border is
|
|
132
|
+
# carried out, so it has no box) to leave the captions a band above the head
|
|
133
|
+
QUIET_RING = (0.06, 0.04) # top and bottom bands (share of the height) with less detail: a plain border
|
|
134
|
+
EXTEND = {"blur": 48.0, "fall": 320.0, "darken": 0.3} # px on 1080 wide: the plain border carried to the screen
|
|
135
|
+
PLATE_VERSION = "placa1" # bump when the room plate changes (its cache is redone)
|
|
136
|
+
PLATE_ENV = "EDITOR_VIDEO_TELA_VERDE_PLACA" # =0 turns the room plate off (to compare)
|
|
137
|
+
PLATE = {
|
|
138
|
+
"step": 2, # every other frame of a take feeds its plate
|
|
139
|
+
"bg": 8, # matte (0-255) under this, `fringe` px away from the body, is the room
|
|
140
|
+
"fringe": 4,
|
|
141
|
+
"n": [2.0, 8.0], # frames that saw the room there: none trusted .. fully trusted
|
|
142
|
+
"std": [6.0, 12.0], # spread of the room color (noise, a 1 px drift, a screen): trusted .. not
|
|
143
|
+
"static": 6.0, # median spread above this: the camera moved, no plate
|
|
144
|
+
"known": 0.08, # share of the frame the plate must know
|
|
145
|
+
"blur": 0.7, "win": 5, # px (plate size): blur of the frame and the plate, window of the comparison
|
|
146
|
+
"tol": 4.0, "width": 8.0, # color distance (0-255) that still is the room (plus the spread there)
|
|
147
|
+
"unsure": [0.8, 0.98], # matte values the model was not sure of: only there the room goes
|
|
148
|
+
"near": 8.0, "unlike": [18.0, 32.0], # the body color nearby (px), how far a dark room color must be from it
|
|
149
|
+
"bright": [45.0, 70.0], # a room brighter than this needs no such distance
|
|
150
|
+
"halo": [30.0, 50.0], # a room this much brighter than the body next to it is a halo (per pixel)
|
|
151
|
+
"on": 0.5, # evidence that counts
|
|
152
|
+
"unsure_px": 7, "round_px": 5, "soft": 1.5, # narrowest piece (px), rounding of the pieces, blur of their
|
|
153
|
+
# edge
|
|
154
|
+
"edge": [8, 247], "edge_floor": 0.35, # soft edge pixels (0-255) whose color is cleaned of the room
|
|
155
|
+
}
|
|
156
|
+
FIELDS = ("posicao", "altura", "contorno", "sombra", "fundo", "fundo_original_desfocado", "sem_rosto", "espelhar")
|
|
157
|
+
BG_FIELDS = ("src", "start", "end", "ken_burns", "escurecer", "desfocar", "encaixe")
|
|
158
|
+
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
|
159
|
+
VIDEO_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".gif", ".mts"}
|
|
160
|
+
EXAMPLE = '"tela_verde": {"posicao": "direita", "fundo": [{"src": "assets/pagina.png"}]}'
|
|
161
|
+
ALIASES = {"position": "posicao", "posição": "posicao", "height": "altura", "outline": "contorno", "shadow": "sombra",
|
|
162
|
+
"background": "fundo", "backgrounds": "fundo", "fundos": "fundo", "blur": "desfocar", "desfoque": "desfocar",
|
|
163
|
+
"darken": "escurecer", "dim": "escurecer", "inicio": "start", "início": "start", "fim": "end",
|
|
164
|
+
"kenburns": "ken_burns", "arquivo": "src", "file": "src", "cor": "src", "color": "src",
|
|
165
|
+
"fit": "encaixe", "encaixar": "encaixe", "ajuste": "encaixe", "modo": "encaixe",
|
|
166
|
+
"mirror": "espelhar", "flip": "espelhar", "espelho": "espelhar", "espelhado": "espelhar",
|
|
167
|
+
"inverter": "espelhar"}
|
|
168
|
+
SPLIT_LABEL = {"split_top": "imagem em cima", "split_bottom": "imagem embaixo"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hint(name: str, fields) -> str:
|
|
172
|
+
near = ALIASES.get(str(name).strip().lower())
|
|
173
|
+
if near not in fields:
|
|
174
|
+
import difflib # noqa: PLC0415
|
|
175
|
+
close = difflib.get_close_matches(str(name), list(fields), n=1, cutoff=0.6)
|
|
176
|
+
near = close[0] if close else None
|
|
177
|
+
return f" Você quis dizer \"{near}\"?" if near else ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TelaVerdeError(Exception):
|
|
181
|
+
def __init__(self, message: str, code: int = 2):
|
|
182
|
+
super().__init__(message)
|
|
183
|
+
self.code = code
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cv2():
|
|
187
|
+
return M._cv2()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def hex_rgb(value) -> tuple[int, int, int] | None:
|
|
191
|
+
if not isinstance(value, str):
|
|
192
|
+
return None
|
|
193
|
+
h = value.strip().lstrip("#")
|
|
194
|
+
if len(h) == 3:
|
|
195
|
+
h = "".join(c * 2 for c in h)
|
|
196
|
+
if len(h) != 6 or not value.strip().startswith("#"):
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
200
|
+
except ValueError:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _num(value) -> float | None:
|
|
205
|
+
if isinstance(value, bool):
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
v = float(value)
|
|
209
|
+
except (TypeError, ValueError):
|
|
210
|
+
return None
|
|
211
|
+
return v if math.isfinite(v) else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _json(value) -> str:
|
|
215
|
+
try:
|
|
216
|
+
return json.dumps(value, ensure_ascii=False)
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
return repr(value)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ease(p: float) -> float:
|
|
222
|
+
p = min(1.0, max(0.0, p))
|
|
223
|
+
return 0.5 - 0.5 * math.cos(math.pi * p)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ================================================================== config
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class Background:
|
|
230
|
+
idx: int # 1-based position in "fundo" (or in "inserts" when insert is set)
|
|
231
|
+
kind: str # color | image | video
|
|
232
|
+
src: Path | None
|
|
233
|
+
color: tuple[int, int, int] | None
|
|
234
|
+
f0: int
|
|
235
|
+
f1: int # as asked (a gap after it is still this item, see runs)
|
|
236
|
+
ken_burns: bool
|
|
237
|
+
darken: float
|
|
238
|
+
blur: float
|
|
239
|
+
asked: str = ""
|
|
240
|
+
fit_asked: str = "auto"
|
|
241
|
+
fit: str = "cobrir" # cobrir | inteiro (resolved)
|
|
242
|
+
size: tuple[int, int] | None = None # the source's width and height (upright)
|
|
243
|
+
insert: int | None = None # 1-based insert number: a split_top / split_bottom insert shown as background
|
|
244
|
+
insert_type: str | None = None
|
|
245
|
+
content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) # main area of the picture (fractions)
|
|
246
|
+
place: tuple[int, int, int, int] | None = None # inteiro: x, y, w, h of the sharp copy (render px)
|
|
247
|
+
stage: tuple[float, float] | None = None # inteiro: the band it was fitted into (render px)
|
|
248
|
+
quiet: bool = False # video with a plain border (a studio, a flat color): it melts into the screen
|
|
249
|
+
lead: int = 0 # video: empty frames at the start of a pass (the first full one shows instead)
|
|
250
|
+
tail: int = 0 # video: empty frames at the end of a pass (the last full one shows instead)
|
|
251
|
+
pass_frames: int = 0 # video: frames of one pass at the edit fps (0 = not scanned)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def name(self) -> str:
|
|
255
|
+
return f"insert {self.insert}" if self.insert else f"fundo {self.idx}"
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def where(self) -> str:
|
|
259
|
+
return f"inserts[{self.idx - 1}]" if self.insert else f"tela_verde.fundo[{self.idx - 1}]"
|
|
260
|
+
|
|
261
|
+
def content_rect(self, W: int, H: int) -> tuple[float, float, float, float] | None:
|
|
262
|
+
"""Where the main area of a whole picture lands (render px), grown by the Ken Burns zoom."""
|
|
263
|
+
if self.fit != "inteiro" or self.place is None:
|
|
264
|
+
return None
|
|
265
|
+
x, y, w, h = self.place
|
|
266
|
+
c = self.content
|
|
267
|
+
x0, y0, x1, y1 = x + c[0] * w, y + c[1] * h, x + c[2] * w, y + c[3] * h
|
|
268
|
+
if self.ken_burns:
|
|
269
|
+
g = KB_DRIFT / 2.0
|
|
270
|
+
dx, dy = (x1 - x0) * g, (y1 - y0) * g
|
|
271
|
+
x0, y0, x1, y1 = x0 - dx, y0 - dy, x1 + dx, y1 + dy
|
|
272
|
+
return (max(0.0, x0), max(0.0, y0), min(float(W), x1), min(float(H), y1))
|
|
273
|
+
|
|
274
|
+
def signature(self) -> list:
|
|
275
|
+
sig = None
|
|
276
|
+
if self.src is not None:
|
|
277
|
+
st = self.src.stat()
|
|
278
|
+
sig = [str(self.src), st.st_size, st.st_mtime_ns]
|
|
279
|
+
return [self.idx, self.insert, self.kind, sig, self.color, self.f0, self.f1, self.ken_burns,
|
|
280
|
+
round(self.darken, 4), round(self.blur, 3), self.fit, self.size,
|
|
281
|
+
[round(v, 4) for v in self.content], self.place, self.quiet, self.lead, self.tail,
|
|
282
|
+
self.pass_frames]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def media_size(path: Path | None, kind: str) -> tuple[int, int] | None:
|
|
286
|
+
"""Upright width and height of an image or video (None when it cannot be read)."""
|
|
287
|
+
if path is None or kind == "color":
|
|
288
|
+
return None
|
|
289
|
+
try:
|
|
290
|
+
if kind == "image":
|
|
291
|
+
from PIL import Image # noqa: PLC0415
|
|
292
|
+
with Image.open(path) as im:
|
|
293
|
+
w, h = im.size
|
|
294
|
+
try:
|
|
295
|
+
orient = im.getexif().get(274, 1)
|
|
296
|
+
except Exception: # noqa: BLE001
|
|
297
|
+
orient = 1
|
|
298
|
+
return (h, w) if orient in (5, 6, 7, 8) else (w, h)
|
|
299
|
+
proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
300
|
+
"stream=width,height:stream_side_data=rotation", "-of", "json", str(path)],
|
|
301
|
+
capture_output=True, text=True)
|
|
302
|
+
st = (json.loads(proc.stdout or "{}").get("streams") or [{}])[0]
|
|
303
|
+
w, h = int(st.get("width") or 0), int(st.get("height") or 0)
|
|
304
|
+
rot = 0
|
|
305
|
+
for sd in st.get("side_data_list") or []:
|
|
306
|
+
if "rotation" in sd:
|
|
307
|
+
rot = abs(int(round(float(sd["rotation"])))) % 180
|
|
308
|
+
if w <= 0 or h <= 0:
|
|
309
|
+
return None
|
|
310
|
+
return (h, w) if rot == 90 else (w, h)
|
|
311
|
+
except (OSError, ValueError, TypeError, IndexError):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def choose_fit(asked: str, kind: str, size: tuple[int, int] | None, screen: tuple[int, int]) -> str:
|
|
316
|
+
"""encaixe "auto": cobrir when the shape is close to the screen's (a page taller than a vertical screen too),
|
|
317
|
+
inteiro otherwise (a wide print, a square clip)."""
|
|
318
|
+
if kind == "color":
|
|
319
|
+
return "cobrir"
|
|
320
|
+
if asked in ("cobrir", "inteiro"):
|
|
321
|
+
return asked
|
|
322
|
+
if not size:
|
|
323
|
+
return "cobrir"
|
|
324
|
+
ar, car = size[0] / max(1, size[1]), screen[0] / max(1, screen[1])
|
|
325
|
+
if car / FIT_RATIO <= ar <= car * FIT_RATIO:
|
|
326
|
+
return "cobrir"
|
|
327
|
+
if ar < car / FIT_RATIO and car < 1.0:
|
|
328
|
+
return "cobrir" # a long page on a vertical screen: fill it and keep the top
|
|
329
|
+
return "inteiro"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _fit_value(value, where: str) -> str:
|
|
333
|
+
if value is None:
|
|
334
|
+
return "auto"
|
|
335
|
+
fit = FIT_ALIASES.get(value.strip().lower()) if isinstance(value, str) else None
|
|
336
|
+
if fit is None:
|
|
337
|
+
raise TelaVerdeError(f"{where}.encaixe precisa ser \"auto\", \"cobrir\" (enche a tela, cortando) ou \"inteiro\" "
|
|
338
|
+
f"(aparece inteiro, acima de você) (veio {_json(value)})")
|
|
339
|
+
return fit
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _look_values(item: dict, where: str, kind: str, fit: str, warnings: list[str]) -> tuple[float, float]:
|
|
343
|
+
"""escurecer and desfocar of a background item (the default darkening is 0 for a whole picture and a color)."""
|
|
344
|
+
default = 0.0 if (kind == "color" or fit == "inteiro") else DARKEN_DEFAULT
|
|
345
|
+
darken = item.get("escurecer", default)
|
|
346
|
+
dk = _num(darken)
|
|
347
|
+
if dk is None or dk < 0 or dk > 1:
|
|
348
|
+
raise TelaVerdeError(f"{where}.escurecer precisa ser um número de 0 a {DARKEN_MAX} (0 = como está; veio "
|
|
349
|
+
f"{_json(darken)})")
|
|
350
|
+
if dk > DARKEN_MAX:
|
|
351
|
+
warnings.append(f"{where}.escurecer {dk:g} ficou em {DARKEN_MAX}")
|
|
352
|
+
dk = DARKEN_MAX
|
|
353
|
+
blur = _num(item.get("desfocar", 0))
|
|
354
|
+
if blur is None or blur < 0:
|
|
355
|
+
raise TelaVerdeError(f"{where}.desfocar precisa ser um número de 0 a {BLUR_MAX:g} (pixels de desfoque; veio "
|
|
356
|
+
f"{_json(item.get('desfocar'))})")
|
|
357
|
+
return dk, min(BLUR_MAX, blur)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass
|
|
361
|
+
class Config:
|
|
362
|
+
position: str
|
|
363
|
+
height: float
|
|
364
|
+
height_asked: bool
|
|
365
|
+
outline: tuple[tuple[int, int, int], float] | None # (rgb, width px on 1080 wide)
|
|
366
|
+
shadow: bool
|
|
367
|
+
backgrounds: list[Background]
|
|
368
|
+
blurred_original: bool
|
|
369
|
+
no_face: str # original | recorte
|
|
370
|
+
mirror: bool = False # espelhar: the person segments flipped left-right
|
|
371
|
+
runs: list[tuple[int, int, int | None]] = field(default_factory=list) # (f0, f1, background index or None)
|
|
372
|
+
warnings: list[str] = field(default_factory=list)
|
|
373
|
+
run_notes: list[str] = field(default_factory=list) # the warnings about the runs (also in warnings)
|
|
374
|
+
screen: tuple[int, int] = (1080, 1920)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def from_inserts(self) -> list[Background]:
|
|
378
|
+
return [b for b in self.backgrounds if b.insert]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def parse_config(section, fps: int, nf: int, resolve: Callable[[str, str], tuple[Path, str]],
|
|
382
|
+
fr: Callable[[float], int], screen: tuple[int, int] = (1080, 1920),
|
|
383
|
+
probe: Callable[[Path | None, str], tuple[int, int] | None] = media_size) -> Config:
|
|
384
|
+
"""Check "tela_verde" (exit 2 on a mistake, before anything renders)."""
|
|
385
|
+
if section is None:
|
|
386
|
+
section = {}
|
|
387
|
+
if not isinstance(section, dict):
|
|
388
|
+
raise TelaVerdeError(f"tela_verde precisa ser um objeto (ex: {EXAMPLE})")
|
|
389
|
+
unknown = [k for k in section if k not in FIELDS]
|
|
390
|
+
if unknown:
|
|
391
|
+
raise TelaVerdeError(f"tela_verde.{unknown[0]} não existe.{_hint(unknown[0], FIELDS)} Campos: {', '.join(FIELDS)}")
|
|
392
|
+
warnings: list[str] = []
|
|
393
|
+
raw_pos = section.get("posicao", "direita")
|
|
394
|
+
key = raw_pos.strip().lower() if isinstance(raw_pos, str) else None
|
|
395
|
+
position = POSITION_ALIASES.get(key or "")
|
|
396
|
+
if position is None:
|
|
397
|
+
raise TelaVerdeError(f"tela_verde.posicao precisa ser \"direita\", \"esquerda\" ou \"centro\" (veio {_json(raw_pos)})")
|
|
398
|
+
height_asked = section.get("altura") is not None
|
|
399
|
+
height = DEFAULT_HEIGHT[position]
|
|
400
|
+
if height_asked:
|
|
401
|
+
h = _num(section.get("altura"))
|
|
402
|
+
if h is None or h <= 0:
|
|
403
|
+
raise TelaVerdeError(f"tela_verde.altura precisa ser a fração da altura da tela que a pessoa ocupa, de "
|
|
404
|
+
f"{HEIGHT_RANGE[0]} a {HEIGHT_RANGE[1]} (ex: 0.55; veio {_json(section.get('altura'))})")
|
|
405
|
+
if h > 1.0 and h <= 100:
|
|
406
|
+
h = h / 100.0 if h > 1.5 else 1.0 # "55" read as percent
|
|
407
|
+
if not HEIGHT_RANGE[0] <= h <= HEIGHT_RANGE[1]:
|
|
408
|
+
clipped = min(HEIGHT_RANGE[1], max(HEIGHT_RANGE[0], h))
|
|
409
|
+
warnings.append(f"tela_verde.altura {h:g} ficou em {clipped:g} (use de {HEIGHT_RANGE[0]} a {HEIGHT_RANGE[1]})")
|
|
410
|
+
h = clipped
|
|
411
|
+
height = h
|
|
412
|
+
outline_raw = section.get("contorno", OUTLINE_DEFAULT)
|
|
413
|
+
outline = None
|
|
414
|
+
if outline_raw is True:
|
|
415
|
+
outline_raw = OUTLINE_DEFAULT
|
|
416
|
+
if outline_raw not in (None, False):
|
|
417
|
+
if not isinstance(outline_raw, dict) or set(outline_raw) - {"cor", "largura"}:
|
|
418
|
+
raise TelaVerdeError(f"tela_verde.contorno precisa ser {{\"cor\": \"#FFFFFF\", \"largura\": 8}} ou null "
|
|
419
|
+
f"(veio {_json(outline_raw)})")
|
|
420
|
+
color = hex_rgb(outline_raw.get("cor", OUTLINE_DEFAULT["cor"]))
|
|
421
|
+
if color is None:
|
|
422
|
+
raise TelaVerdeError(f"tela_verde.contorno.cor precisa ser uma cor #RRGGBB (veio {_json(outline_raw.get('cor'))})")
|
|
423
|
+
width = _num(outline_raw.get("largura", OUTLINE_DEFAULT["largura"]))
|
|
424
|
+
if width is None or width < 0:
|
|
425
|
+
raise TelaVerdeError(f"tela_verde.contorno.largura precisa ser um número de pixels, de 0 a {OUTLINE_MAX} "
|
|
426
|
+
f"(veio {_json(outline_raw.get('largura'))})")
|
|
427
|
+
if width > OUTLINE_MAX:
|
|
428
|
+
warnings.append(f"tela_verde.contorno.largura {width:g} ficou em {OUTLINE_MAX}")
|
|
429
|
+
width = float(OUTLINE_MAX)
|
|
430
|
+
outline = (color, float(width)) if width > 0 else None
|
|
431
|
+
shadow = section.get("sombra", True)
|
|
432
|
+
if not isinstance(shadow, bool):
|
|
433
|
+
raise TelaVerdeError(f"tela_verde.sombra precisa ser true ou false (veio {_json(shadow)})")
|
|
434
|
+
blurred = section.get("fundo_original_desfocado", False)
|
|
435
|
+
if not isinstance(blurred, bool):
|
|
436
|
+
raise TelaVerdeError(f"tela_verde.fundo_original_desfocado precisa ser true ou false (veio {_json(blurred)})")
|
|
437
|
+
no_face = section.get("sem_rosto", "original")
|
|
438
|
+
if no_face not in ("original", "recorte"):
|
|
439
|
+
raise TelaVerdeError(f"tela_verde.sem_rosto precisa ser \"original\" (o trecho sem rosto aparece como foi "
|
|
440
|
+
f"gravado) ou \"recorte\" (veio {_json(no_face)})")
|
|
441
|
+
mirror = section.get("espelhar", False)
|
|
442
|
+
if not isinstance(mirror, bool):
|
|
443
|
+
raise TelaVerdeError(f"tela_verde.espelhar precisa ser true ou false (true vira você de lado, como num "
|
|
444
|
+
f"espelho; veio {_json(mirror)})")
|
|
445
|
+
fundo = section.get("fundo")
|
|
446
|
+
if fundo is None:
|
|
447
|
+
fundo = []
|
|
448
|
+
if isinstance(fundo, (str, dict)):
|
|
449
|
+
fundo = [fundo]
|
|
450
|
+
if not isinstance(fundo, list):
|
|
451
|
+
raise TelaVerdeError("tela_verde.fundo precisa ser uma lista (ex: [{\"src\": \"assets/pagina.png\", \"start\": 0, "
|
|
452
|
+
"\"end\": 8}])")
|
|
453
|
+
items: list[Background] = []
|
|
454
|
+
prev_end = 0.0
|
|
455
|
+
for i, item in enumerate(fundo):
|
|
456
|
+
where = f"tela_verde.fundo[{i}]"
|
|
457
|
+
if isinstance(item, str):
|
|
458
|
+
item = {"src": item}
|
|
459
|
+
if not isinstance(item, dict):
|
|
460
|
+
raise TelaVerdeError(f"{where} precisa ser um objeto (ex: {{\"src\": \"assets/pagina.png\", \"start\": 0}})")
|
|
461
|
+
bad = [k for k in item if k not in BG_FIELDS]
|
|
462
|
+
if bad:
|
|
463
|
+
raise TelaVerdeError(f"{where}.{bad[0]} não existe.{_hint(bad[0], BG_FIELDS)} Campos: {', '.join(BG_FIELDS)}")
|
|
464
|
+
src = item.get("src")
|
|
465
|
+
if not isinstance(src, str) or not src.strip():
|
|
466
|
+
raise TelaVerdeError(f"{where} precisa de \"src\": um arquivo (\"assets/foto.png\", \"assets/clipe.mp4\") "
|
|
467
|
+
f"ou uma cor (\"#101010\")")
|
|
468
|
+
color = hex_rgb(src) if src.strip().startswith("#") else None
|
|
469
|
+
if src.strip().startswith("#") and color is None:
|
|
470
|
+
raise TelaVerdeError(f"{where}.src: cor inválida {_json(src)} (use #RRGGBB)")
|
|
471
|
+
path, kind = (None, "color") if color else resolve(src, f"{where}.src")
|
|
472
|
+
start = item.get("start")
|
|
473
|
+
if start is None:
|
|
474
|
+
t0 = prev_end
|
|
475
|
+
else:
|
|
476
|
+
t0 = _num(start)
|
|
477
|
+
if t0 is None or t0 < 0:
|
|
478
|
+
raise TelaVerdeError(f"{where}.start precisa ser um número de segundos, 0 ou mais (veio {_json(start)})")
|
|
479
|
+
end = item.get("end")
|
|
480
|
+
t1 = None
|
|
481
|
+
if end is not None:
|
|
482
|
+
t1 = _num(end)
|
|
483
|
+
if t1 is None or t1 <= t0:
|
|
484
|
+
raise TelaVerdeError(f"{where}.end precisa ser um número de segundos maior que o start ({t0:g}) "
|
|
485
|
+
f"(veio {_json(end)})")
|
|
486
|
+
kb = item.get("ken_burns", kind == "image")
|
|
487
|
+
if not isinstance(kb, bool):
|
|
488
|
+
raise TelaVerdeError(f"{where}.ken_burns precisa ser true ou false (veio {_json(kb)})")
|
|
489
|
+
fit_asked = _fit_value(item.get("encaixe"), where)
|
|
490
|
+
size = probe(path, kind) if kind != "color" else None
|
|
491
|
+
fit = choose_fit(fit_asked, kind, size, screen)
|
|
492
|
+
dk, blur = _look_values(item, where, kind, fit, warnings)
|
|
493
|
+
f0 = fr(t0)
|
|
494
|
+
f1 = fr(t1) if t1 is not None else None
|
|
495
|
+
if f0 >= nf:
|
|
496
|
+
warnings.append(f"{where} começa depois do fim do vídeo e foi ignorado")
|
|
497
|
+
prev_end = t1 if t1 is not None else t0
|
|
498
|
+
continue
|
|
499
|
+
items.append(Background(idx=i + 1, kind=kind, src=path, color=color, f0=f0, f1=f1 if f1 is not None else -1,
|
|
500
|
+
ken_burns=kb and kind != "color", darken=dk, blur=blur, asked=src.strip(),
|
|
501
|
+
fit_asked=fit_asked, fit=fit, size=size))
|
|
502
|
+
prev_end = t1 if t1 is not None else t0
|
|
503
|
+
# an item without "end" lasts until the next item starts (or the end of the video)
|
|
504
|
+
for k, b in enumerate(items):
|
|
505
|
+
if b.f1 < 0:
|
|
506
|
+
later = [o.f0 for o in items[k + 1:] if o.f0 > b.f0]
|
|
507
|
+
b.f1 = min(later) if later else nf
|
|
508
|
+
b.f1 = min(nf, b.f1)
|
|
509
|
+
if b.f1 - b.f0 < 1:
|
|
510
|
+
warnings.append(f"tela_verde.fundo[{b.idx - 1}] dura menos de 1 quadro e foi ignorado")
|
|
511
|
+
items = [b for b in items if b.f1 > b.f0]
|
|
512
|
+
for k, a in enumerate(items):
|
|
513
|
+
for b in items[k + 1:]:
|
|
514
|
+
if b.f0 < a.f1 and a.f0 < b.f1:
|
|
515
|
+
warnings.append(f"os fundos {a.idx} e {b.idx} se sobrepõem: o {b.idx}, que vem depois na lista, fica por cima")
|
|
516
|
+
cfg = Config(position=position, height=height, height_asked=height_asked, outline=outline, shadow=shadow,
|
|
517
|
+
backgrounds=items, blurred_original=blurred, no_face=no_face, mirror=mirror, warnings=warnings,
|
|
518
|
+
screen=screen)
|
|
519
|
+
set_runs(cfg, nf, fps)
|
|
520
|
+
return cfg
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def set_runs(cfg: Config, nf: int, fps: int) -> None:
|
|
524
|
+
"""(Re)compute the runs and their warnings (after the split inserts joined the track)."""
|
|
525
|
+
for note in cfg.run_notes:
|
|
526
|
+
if note in cfg.warnings:
|
|
527
|
+
cfg.warnings.remove(note)
|
|
528
|
+
notes: list[str] = []
|
|
529
|
+
items = cfg.backgrounds
|
|
530
|
+
cfg.runs = background_runs(items, nf)
|
|
531
|
+
if not items and not cfg.blurred_original:
|
|
532
|
+
notes.append("tela_verde sem fundo: usei um fundo escuro. Planeje as imagens e vídeos como inserts "
|
|
533
|
+
"(split_top), que eles vão para o fundo, ou use tela_verde.fundo (ou "
|
|
534
|
+
"\"fundo_original_desfocado\": true)")
|
|
535
|
+
elif items and cfg.runs and cfg.runs[0][2] is None:
|
|
536
|
+
what = "o vídeo original desfocado" if cfg.blurred_original else "um fundo escuro"
|
|
537
|
+
notes.append(f"o primeiro fundo começa em {cfg.runs[0][1] / fps:.2f}s: antes dele entra {what}")
|
|
538
|
+
cfg.run_notes = notes
|
|
539
|
+
cfg.warnings.extend(notes)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def add_insert_backgrounds(cfg: Config, inserts: list[dict], nf: int, fps: int,
|
|
543
|
+
probe: Callable[[Path | None, str], tuple[int, int] | None] = media_size) -> list[Background]:
|
|
544
|
+
"""split_top / split_bottom inserts -> background items that win over tela_verde.fundo during their own frames.
|
|
545
|
+
|
|
546
|
+
inserts: [{"insert": 1-based number, "type", "src" (Path), "kind", "f0", "f1", "ken_burns", "item" (the raw
|
|
547
|
+
dict, for escurecer / desfocar / encaixe)}]."""
|
|
548
|
+
added = []
|
|
549
|
+
for it in inserts:
|
|
550
|
+
where = f"inserts[{it['insert'] - 1}]"
|
|
551
|
+
raw = it.get("item") or {}
|
|
552
|
+
fit_asked = _fit_value(raw.get("encaixe"), where)
|
|
553
|
+
size = probe(it["src"], it["kind"])
|
|
554
|
+
fit = choose_fit(fit_asked, it["kind"], size, cfg.screen)
|
|
555
|
+
dk, blur = _look_values(raw, where, it["kind"], fit, cfg.warnings)
|
|
556
|
+
f0, f1 = int(it["f0"]), min(nf, int(it["f1"]))
|
|
557
|
+
if f1 <= f0:
|
|
558
|
+
continue
|
|
559
|
+
b = Background(idx=it["insert"], kind=it["kind"], src=it["src"], color=None, f0=f0, f1=f1,
|
|
560
|
+
ken_burns=bool(it["ken_burns"]), darken=dk, blur=blur, asked=str(raw.get("src") or it["src"]),
|
|
561
|
+
fit_asked=fit_asked, fit=fit, size=size, insert=it["insert"], insert_type=it["type"])
|
|
562
|
+
added.append(b)
|
|
563
|
+
# the fundo items first: an insert (drawn later in the list) wins during its own frames
|
|
564
|
+
cfg.backgrounds = [b for b in cfg.backgrounds if not b.insert] + added
|
|
565
|
+
set_runs(cfg, nf, fps)
|
|
566
|
+
return added
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def background_runs(items: list[Background], nf: int) -> list[tuple[int, int, int | None]]:
|
|
570
|
+
"""(f0, f1, index in items or None): a later item wins; a gap keeps the item before it."""
|
|
571
|
+
owner = np.full(nf, -1, np.int32)
|
|
572
|
+
for k, b in enumerate(items):
|
|
573
|
+
owner[b.f0:b.f1] = k
|
|
574
|
+
last = -1
|
|
575
|
+
for f in range(nf): # gaps: hold the previous background
|
|
576
|
+
if owner[f] < 0:
|
|
577
|
+
owner[f] = last
|
|
578
|
+
else:
|
|
579
|
+
last = owner[f]
|
|
580
|
+
runs: list[tuple[int, int, int | None]] = []
|
|
581
|
+
start = 0
|
|
582
|
+
for f in range(1, nf + 1):
|
|
583
|
+
if f == nf or owner[f] != owner[start]:
|
|
584
|
+
runs.append((start, f, int(owner[start]) if owner[start] >= 0 else None))
|
|
585
|
+
start = f
|
|
586
|
+
return runs
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ================================================================== placement
|
|
590
|
+
|
|
591
|
+
@dataclass
|
|
592
|
+
class Segment:
|
|
593
|
+
f0: int
|
|
594
|
+
f1: int
|
|
595
|
+
mode: str # pessoa | original
|
|
596
|
+
zoom: float = 1.0
|
|
597
|
+
zoom_max: float = 1.0
|
|
598
|
+
push: float = 0.0 # push_in amount (0 = none)
|
|
599
|
+
focus_y: float = 0.4 # eye line of the cut frame (zoom anchor of an original segment)
|
|
600
|
+
stats: dict | None = None
|
|
601
|
+
scale: float = 1.0 # camera frame -> screen, before the zoom
|
|
602
|
+
flush_left: bool = False
|
|
603
|
+
flush_right: bool = False
|
|
604
|
+
face: dict | None = None # face_layout face / zone / core (fractions of the cut frame)
|
|
605
|
+
side: str | None = None # esquerda | direita: this segment sits on that side (its face is cut there)
|
|
606
|
+
fill: bool = False # corner, cut on both sides: the camera frame fills the screen width
|
|
607
|
+
drop: float = 0.0 # fill: share of the camera frame below the bottom of the screen
|
|
608
|
+
note: str = ""
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def frames(self) -> int:
|
|
612
|
+
return self.f1 - self.f0
|
|
613
|
+
|
|
614
|
+
def zoom_at(self, f: int) -> float:
|
|
615
|
+
if self.push <= 0 or self.frames <= 1:
|
|
616
|
+
return self.zoom
|
|
617
|
+
return self.zoom * (1.0 + self.push * ease((f - self.f0) / (self.frames - 1)))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def base_scale(cfg: Config, st: dict) -> float:
|
|
621
|
+
top = float(st.get("top", 0.0))
|
|
622
|
+
s = cfg.height / max(0.05, 1.0 - top)
|
|
623
|
+
width = max(0.05, float(st.get("right", 1.0)) - float(st.get("left", 0.0)))
|
|
624
|
+
s = min(s, (CENTER_MAX_WIDTH if cfg.position == "centro" else CORNER_MAX_WIDTH) / width)
|
|
625
|
+
if (cfg.position == "centro" and float(st.get("touch_left", 0)) >= TOUCH_FLUSH
|
|
626
|
+
and float(st.get("touch_right", 0)) >= TOUCH_FLUSH and s < 1.0 and s * CENTER_FILL >= 1.0):
|
|
627
|
+
s = 1.0 # cut on both sides: fill the width instead of showing two cut edges
|
|
628
|
+
return min(MAX_SCALE, max(0.05, s))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def mirror_stats(st: dict | None) -> dict | None:
|
|
632
|
+
"""Placement numbers of a flipped camera frame."""
|
|
633
|
+
if not st or not st.get("person_frames"):
|
|
634
|
+
return st
|
|
635
|
+
out = dict(st)
|
|
636
|
+
out["left"] = round(1.0 - float(st.get("right", 1.0)), 5)
|
|
637
|
+
out["right"] = round(1.0 - float(st.get("left", 0.0)), 5)
|
|
638
|
+
out["head_cx"] = round(1.0 - float(st.get("head_cx", 0.5)), 5)
|
|
639
|
+
out["touch_left"], out["touch_right"] = st.get("touch_right", 0), st.get("touch_left", 0)
|
|
640
|
+
return out
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def mirror_face(face: dict | None) -> dict | None:
|
|
644
|
+
"""face_layout segment (face / zone / core boxes) of a flipped camera frame."""
|
|
645
|
+
if not isinstance(face, dict):
|
|
646
|
+
return face
|
|
647
|
+
out = dict(face)
|
|
648
|
+
for key in ("face", "zone", "core"):
|
|
649
|
+
r = face.get(key)
|
|
650
|
+
if isinstance(r, dict) and "x0" in r and "x1" in r:
|
|
651
|
+
out[key] = {**r, "x0": round(1.0 - float(r["x1"]), 5), "x1": round(1.0 - float(r["x0"]), 5)}
|
|
652
|
+
return out
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def face_cut_sides(seg: "Segment") -> tuple[bool, bool]:
|
|
656
|
+
"""(left, right): the detected face touches that side of the camera frame."""
|
|
657
|
+
f = seg.face.get("face") if isinstance(seg.face, dict) else None
|
|
658
|
+
if not isinstance(f, dict):
|
|
659
|
+
return False, False
|
|
660
|
+
try:
|
|
661
|
+
return float(f.get("x0", 0.5)) <= FACE_EDGE, float(f.get("x1", 0.5)) >= 1.0 - FACE_EDGE
|
|
662
|
+
except (TypeError, ValueError):
|
|
663
|
+
return False, False
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def plan_segments(cfg: Config, segs: list[Segment]) -> None:
|
|
667
|
+
"""Scale and flush sides per person segment; segments close to the typical size share it (no jump at a cut
|
|
668
|
+
of the same take)."""
|
|
669
|
+
people = [s for s in segs if s.mode == "pessoa" and s.stats and s.stats.get("person_frames")]
|
|
670
|
+
for s in people:
|
|
671
|
+
st = s.stats
|
|
672
|
+
s.scale = base_scale(cfg, st)
|
|
673
|
+
cut_l, cut_r = face_cut_sides(s)
|
|
674
|
+
s.flush_left = float(st.get("touch_left", 0)) >= TOUCH_FLUSH or cut_l
|
|
675
|
+
s.flush_right = float(st.get("touch_right", 0)) >= TOUCH_FLUSH or cut_r
|
|
676
|
+
s.side = None
|
|
677
|
+
if cut_l != cut_r:
|
|
678
|
+
side = "esquerda" if cut_l else "direita"
|
|
679
|
+
if side != cfg.position:
|
|
680
|
+
s.side = side
|
|
681
|
+
s.note = (f"o rosto sai da câmera pela {side}: aqui você fica encostado na {side} "
|
|
682
|
+
f"da tela, para o rosto não aparecer cortado no meio")
|
|
683
|
+
if people:
|
|
684
|
+
weights = sorted((s.scale, s.frames) for s in people)
|
|
685
|
+
half, acc, med = sum(w for _, w in weights) / 2.0, 0.0, weights[-1][0]
|
|
686
|
+
for v, w in weights:
|
|
687
|
+
acc += w
|
|
688
|
+
if acc >= half:
|
|
689
|
+
med = v
|
|
690
|
+
break
|
|
691
|
+
for s in people:
|
|
692
|
+
if abs(s.scale - med) <= 0.08 * med:
|
|
693
|
+
s.scale = med
|
|
694
|
+
if cfg.position != "centro":
|
|
695
|
+
# cut on both sides: in a corner one of the cuts would land in the middle of the screen (an arm or a
|
|
696
|
+
# shoulder ending on a vertical line), so the person fills the width instead, a little lower
|
|
697
|
+
for s in people:
|
|
698
|
+
st = s.stats
|
|
699
|
+
if min(float(st.get("touch_left", 0)), float(st.get("touch_right", 0))) < TOUCH_FLUSH:
|
|
700
|
+
continue
|
|
701
|
+
corner = s.scale
|
|
702
|
+
if corner >= 1.0 or 1.0 / corner > CORNER_FILL:
|
|
703
|
+
continue
|
|
704
|
+
top = float(st.get("top", 0.0))
|
|
705
|
+
s.fill, s.scale = True, 1.0
|
|
706
|
+
s.flush_left = s.flush_right = True
|
|
707
|
+
s.drop = round(min(FILL_DROP, max(0.0, (1.0 - top) * (1.0 - corner))), 5)
|
|
708
|
+
fallback = people[0] if people else None
|
|
709
|
+
for s in segs:
|
|
710
|
+
if s.mode == "pessoa" and s not in people:
|
|
711
|
+
s.note = "não achei a pessoa neste trecho: só o fundo aparece"
|
|
712
|
+
if fallback is not None:
|
|
713
|
+
s.scale = fallback.scale
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def layer_rect(cfg: Config, seg: Segment, z: float, W: int, H: int) -> tuple[float, float, float]:
|
|
717
|
+
"""(scale, x0, y0) of the camera frame on the screen for zoom z (float px)."""
|
|
718
|
+
st = seg.stats or {}
|
|
719
|
+
s = seg.scale * z
|
|
720
|
+
lw, lh = W * s, H * s
|
|
721
|
+
y0 = H - lh
|
|
722
|
+
margin = SIDE_MARGIN * W / 1080.0
|
|
723
|
+
pos = seg.side or cfg.position
|
|
724
|
+
if seg.fill:
|
|
725
|
+
# both cut edges on or past the screen edges; the extra width goes to the side away from the corner
|
|
726
|
+
return s, (0.0 if pos == "direita" else W - lw), H - lh + seg.drop * lh
|
|
727
|
+
if pos == "direita":
|
|
728
|
+
x0 = W - lw if seg.flush_right else W - margin - float(st.get("right", 1.0)) * lw
|
|
729
|
+
elif pos == "esquerda":
|
|
730
|
+
x0 = 0.0 if seg.flush_left else margin - float(st.get("left", 0.0)) * lw
|
|
731
|
+
else:
|
|
732
|
+
x0 = W / 2.0 - float(st.get("head_cx", 0.5)) * lw
|
|
733
|
+
# a side the camera cut stays on (or past) the screen edge when the frame allows it
|
|
734
|
+
if seg.flush_left and seg.flush_right:
|
|
735
|
+
if lw >= W:
|
|
736
|
+
x0 = min(0.0, max(W - lw, x0))
|
|
737
|
+
elif seg.flush_left:
|
|
738
|
+
x0 = min(x0, 0.0)
|
|
739
|
+
elif seg.flush_right:
|
|
740
|
+
x0 = max(x0, W - lw)
|
|
741
|
+
return s, x0, y0
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def inner_cut(cfg: Config, segs: list[Segment], W: int, H: int) -> dict | None:
|
|
745
|
+
"""Corner positions: how much of the person time shows the body cut by the camera inside the screen, and
|
|
746
|
+
what would look more natural. None when it is little (or centro)."""
|
|
747
|
+
if cfg.position == "centro":
|
|
748
|
+
return None
|
|
749
|
+
total = inner = both = 0.0
|
|
750
|
+
for s in segs:
|
|
751
|
+
if s.mode != "pessoa" or not s.stats or not s.stats.get("person_frames"):
|
|
752
|
+
continue
|
|
753
|
+
st = s.stats
|
|
754
|
+
sc, x0, _ = layer_rect(cfg, s, s.zoom, W, H)
|
|
755
|
+
lw = W * sc
|
|
756
|
+
pos = s.side or cfg.position
|
|
757
|
+
if pos == "direita":
|
|
758
|
+
t_in = float(st.get("touch_left", 0)) if x0 > 0.5 else 0.0
|
|
759
|
+
t_out = float(st.get("touch_right", 0))
|
|
760
|
+
else:
|
|
761
|
+
t_in = float(st.get("touch_right", 0)) if x0 + lw < W - 0.5 else 0.0
|
|
762
|
+
t_out = float(st.get("touch_left", 0))
|
|
763
|
+
total += s.frames
|
|
764
|
+
inner += s.frames * t_in
|
|
765
|
+
both += s.frames * min(t_in, t_out)
|
|
766
|
+
if total <= 0 or inner / total < INNER_CUT_WARN:
|
|
767
|
+
return None
|
|
768
|
+
other = "esquerda" if cfg.position == "direita" else "direita"
|
|
769
|
+
corner = "canto direito" if cfg.position == "direita" else "canto esquerdo"
|
|
770
|
+
if both / total >= INNER_CUT_WARN:
|
|
771
|
+
tip = "\"posicao\": \"centro\" (a câmera corta os dois lados do corpo)"
|
|
772
|
+
better = "centro"
|
|
773
|
+
else:
|
|
774
|
+
tip = (f"\"posicao\": \"{other}\" ou com \"espelhar\": true (se não tiver texto na roupa ou nos objetos "
|
|
775
|
+
f"que aparecem)")
|
|
776
|
+
better = other
|
|
777
|
+
return {"lado": other, "parte": round(inner / total, 3), "melhor": better, "espelhar": better == other,
|
|
778
|
+
"aviso": (f"a câmera corta seu corpo pela {other} em {inner / total * 100:.0f}% do tempo: no {corner} esse "
|
|
779
|
+
f"corte aparece no meio da tela (braço ou ombro somem num degradê). Fica mais natural com {tip}")}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _map_rect(rect: dict, s: float, x0: float, y0: float, W: int, H: int) -> tuple[float, float, float, float]:
|
|
783
|
+
return (x0 + rect["x0"] * W * s, y0 + rect["y0"] * H * s, x0 + rect["x1"] * W * s, y0 + rect["y1"] * H * s)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _union(rects: list[tuple]) -> tuple:
|
|
787
|
+
return (min(r[0] for r in rects), min(r[1] for r in rects), max(r[2] for r in rects), max(r[3] for r in rects))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _frac(rect: tuple, W: int, H: int) -> dict:
|
|
791
|
+
return {"x0": round(max(0.0, rect[0] / W), 5), "y0": round(max(0.0, rect[1] / H), 5),
|
|
792
|
+
"x1": round(min(1.0, rect[2] / W), 5), "y1": round(min(1.0, rect[3] / H), 5)}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def screen_zones(cfg: Config, seg: Segment, W: int, H: int) -> dict:
|
|
796
|
+
"""Where the face and the body end up on the screen (fractions), over the zoom range of the segment.
|
|
797
|
+
|
|
798
|
+
-> {"face": {zone, core, eyes_y} | None, "person": rect | None}"""
|
|
799
|
+
if seg.mode != "pessoa" or not seg.stats or not seg.stats.get("person_frames"):
|
|
800
|
+
return {"face": None, "person": None}
|
|
801
|
+
st = seg.stats
|
|
802
|
+
zooms = {seg.zoom, seg.zoom * (1 + seg.push)}
|
|
803
|
+
places = [layer_rect(cfg, seg, z, W, H) for z in zooms]
|
|
804
|
+
body = {"x0": float(st.get("left", 0.0)), "y0": float(st.get("top", 0.0)),
|
|
805
|
+
"x1": float(st.get("right", 1.0)), "y1": 1.0}
|
|
806
|
+
person = _union([_map_rect(body, s, x, y, W, H) for s, x, y in places])
|
|
807
|
+
face = None
|
|
808
|
+
src = seg.face
|
|
809
|
+
if src and src.get("zone"):
|
|
810
|
+
zone = _union([_map_rect(src["zone"], s, x, y, W, H) for s, x, y in places])
|
|
811
|
+
core = _union([_map_rect(src.get("core") or src["zone"], s, x, y, W, H) for s, x, y in places])
|
|
812
|
+
eyes = src.get("face", {}).get("eyes_y") if isinstance(src.get("face"), dict) else None
|
|
813
|
+
eyes = eyes if eyes is not None else (src["zone"]["y0"] + src["zone"]["y1"]) / 2.0
|
|
814
|
+
s, _, y = places[0]
|
|
815
|
+
face = {"zone": _frac(zone, W, H), "core": _frac(core, W, H), "eyes_y": round((y + eyes * H * s) / H, 5)}
|
|
816
|
+
else:
|
|
817
|
+
# no face found by the detector: protect the top of the body (head) anyway
|
|
818
|
+
head_h = 0.28 * (1.0 - float(st.get("top", 0.0)))
|
|
819
|
+
cx = float(st.get("head_cx", 0.5))
|
|
820
|
+
head = {"x0": max(0.0, cx - 0.16), "y0": float(st.get("top", 0.0)), "x1": min(1.0, cx + 0.16),
|
|
821
|
+
"y1": float(st.get("top", 0.0)) + head_h}
|
|
822
|
+
zone = _union([_map_rect(head, s, x, y, W, H) for s, x, y in places])
|
|
823
|
+
s, _, y = places[0]
|
|
824
|
+
face = {"zone": _frac(zone, W, H), "core": _frac(zone, W, H),
|
|
825
|
+
"eyes_y": round((y + (head["y0"] + head_h * 0.45) * H * s) / H, 5)}
|
|
826
|
+
return {"face": face, "person": _frac(person, W, H)}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ================================================================== backgrounds
|
|
830
|
+
|
|
831
|
+
def _darken(img: np.ndarray, amount: float) -> np.ndarray:
|
|
832
|
+
if amount <= 0:
|
|
833
|
+
return img
|
|
834
|
+
cv2 = _cv2()
|
|
835
|
+
return cv2.convertScaleAbs(img, alpha=1.0 - amount, beta=0)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _blur(img: np.ndarray, sigma: float, W: int) -> np.ndarray:
|
|
839
|
+
"""Gaussian blur of `sigma` px (1080 wide), done at a smaller size (fast and the same look)."""
|
|
840
|
+
if sigma <= 0.3:
|
|
841
|
+
return img
|
|
842
|
+
cv2 = _cv2()
|
|
843
|
+
s = sigma * W / 1080.0
|
|
844
|
+
h, w = img.shape[:2]
|
|
845
|
+
k = max(1, min(8, int(s // 3)))
|
|
846
|
+
small = cv2.resize(img, (max(2, w // k), max(2, h // k)), interpolation=cv2.INTER_AREA)
|
|
847
|
+
small = cv2.GaussianBlur(small, (0, 0), s / k)
|
|
848
|
+
return cv2.resize(small, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _cover(img: np.ndarray, W: int, H: int, top: bool = False) -> np.ndarray:
|
|
852
|
+
cv2 = _cv2()
|
|
853
|
+
ih, iw = img.shape[:2]
|
|
854
|
+
sc = max(W / iw, H / ih)
|
|
855
|
+
nw, nh = max(W, int(math.ceil(iw * sc))), max(H, int(math.ceil(ih * sc)))
|
|
856
|
+
big = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA if sc < 1 else cv2.INTER_CUBIC)
|
|
857
|
+
x0 = (nw - W) // 2
|
|
858
|
+
y0 = 0 if top else (nh - H) // 2
|
|
859
|
+
return np.ascontiguousarray(big[y0:y0 + H, x0:x0 + W])
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def content_box(img: np.ndarray) -> tuple[float, float, float, float]:
|
|
863
|
+
"""The main area of a picture (fractions): where it differs from its border color, so the empty margin of a
|
|
864
|
+
print may go behind the captions or the person. A busy border (a photo) means the whole picture matters."""
|
|
865
|
+
cv2 = _cv2()
|
|
866
|
+
ih, iw = img.shape[:2]
|
|
867
|
+
k = max(1, int(round(max(iw, ih) / 320)))
|
|
868
|
+
small = cv2.resize(img, (max(8, iw // k), max(8, ih // k)), interpolation=cv2.INTER_AREA).astype(np.int16)
|
|
869
|
+
sh, sw = small.shape[:2]
|
|
870
|
+
ring = max(1, int(round(min(sw, sh) * 0.03)))
|
|
871
|
+
border = np.concatenate([small[:ring].reshape(-1, 3), small[-ring:].reshape(-1, 3),
|
|
872
|
+
small[:, :ring].reshape(-1, 3), small[:, -ring:].reshape(-1, 3)])
|
|
873
|
+
ref = np.median(border, axis=0)
|
|
874
|
+
spread = float(np.percentile(np.abs(border - ref).max(axis=1), 90))
|
|
875
|
+
whole = (0.0, 0.0, 1.0, 1.0)
|
|
876
|
+
if spread > 24:
|
|
877
|
+
return whole
|
|
878
|
+
diff = np.abs(small - ref).max(axis=2) > max(28.0, spread + 16.0)
|
|
879
|
+
rows = np.flatnonzero(diff.mean(axis=1) > 0.015)
|
|
880
|
+
cols = np.flatnonzero(diff.mean(axis=0) > 0.015)
|
|
881
|
+
if rows.size == 0 or cols.size == 0:
|
|
882
|
+
return whole
|
|
883
|
+
pad = 0.02
|
|
884
|
+
box = (max(0.0, cols[0] / sw - pad), max(0.0, rows[0] / sh - pad),
|
|
885
|
+
min(1.0, (cols[-1] + 1) / sw + pad), min(1.0, (rows[-1] + 1) / sh + pad))
|
|
886
|
+
if box[2] - box[0] > 0.9 and box[3] - box[1] > 0.9:
|
|
887
|
+
return whole
|
|
888
|
+
return tuple(round(v, 4) for v in box)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def whole_place(size: tuple[int, int], content: tuple[float, float, float, float], stage: tuple[float, float],
|
|
892
|
+
W: int, H: int, min_share: float = WHOLE_MIN) -> tuple[int, int, int, int]:
|
|
893
|
+
"""x, y, w, h of a picture shown whole: as wide as the screen, smaller (down to min_share of the width) only
|
|
894
|
+
when its main area does not fit the stage, main area centered in the stage (or on its top when it still does
|
|
895
|
+
not fit)."""
|
|
896
|
+
iw, ih = size
|
|
897
|
+
top, bottom = stage
|
|
898
|
+
room = max(1.0, bottom - top)
|
|
899
|
+
s = W / iw
|
|
900
|
+
main = max(1e-6, (content[3] - content[1]) * ih)
|
|
901
|
+
if main * s > room:
|
|
902
|
+
s = max(room / main, min_share * W / iw)
|
|
903
|
+
w = min(W, max(2, int(round(iw * s))))
|
|
904
|
+
h = max(2, int(round(ih * s)))
|
|
905
|
+
x = (W - w) // 2
|
|
906
|
+
c0, c1 = content[1] * h, content[3] * h
|
|
907
|
+
y = (top + bottom) / 2.0 - (c0 + c1) / 2.0 if c1 - c0 <= room + 0.5 else top - c0
|
|
908
|
+
return (x, int(round(y)), w, h)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def backdrop(img: np.ndarray, W: int, H: int) -> np.ndarray:
|
|
912
|
+
"""A blurred, desaturated and darkened cover copy of img, made at 1/8 size (behind a picture shown whole)."""
|
|
913
|
+
cv2 = _cv2()
|
|
914
|
+
sw, sh = max(2, int(round(W / 8))), max(2, int(round(H / 8)))
|
|
915
|
+
ih, iw = img.shape[:2]
|
|
916
|
+
sc = max(sw / iw, sh / ih)
|
|
917
|
+
nw, nh = max(sw, int(math.ceil(iw * sc))), max(sh, int(math.ceil(ih * sc)))
|
|
918
|
+
small = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
|
919
|
+
x0, y0 = (nw - sw) // 2, (nh - sh) // 2
|
|
920
|
+
small = cv2.GaussianBlur(np.ascontiguousarray(small[y0:y0 + sh, x0:x0 + sw]), (0, 0), BACKDROP["sigma"] * sw / 1080.0)
|
|
921
|
+
gray = cv2.cvtColor(cv2.cvtColor(small, cv2.COLOR_RGB2GRAY), cv2.COLOR_GRAY2RGB)
|
|
922
|
+
small = cv2.addWeighted(small, BACKDROP["saturation"], gray, 1.0 - BACKDROP["saturation"], 0)
|
|
923
|
+
small = cv2.convertScaleAbs(small, alpha=1.0 - BACKDROP["darken"], beta=0)
|
|
924
|
+
return cv2.resize(small, (W, H), interpolation=cv2.INTER_LINEAR)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def detail_map(img: np.ndarray) -> np.ndarray:
|
|
928
|
+
"""0/1 per pixel of a small frame: detail (an edge, an object) against its blurred surroundings."""
|
|
929
|
+
cv2 = _cv2()
|
|
930
|
+
f = img.astype(np.float32)
|
|
931
|
+
sigma = 6.0 * max(img.shape[:2]) / SCAN_SIDE
|
|
932
|
+
return (np.abs(f - cv2.GaussianBlur(f, (0, 0), sigma)).max(axis=2) > SCAN_DETAIL).astype(np.float32)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def quiet_border(acc: np.ndarray) -> bool:
|
|
936
|
+
"""acc: share of detail per pixel. A plain top and bottom border (a studio wall and floor, a flat color) has
|
|
937
|
+
almost none; those are the borders carried out to the screen (the sides of a full-width video are the
|
|
938
|
+
screen's)."""
|
|
939
|
+
h, w = acc.shape
|
|
940
|
+
r = max(1, int(round(min(h, w) * QUIET_RING[0])))
|
|
941
|
+
return max(float(acc[:r].mean()), float(acc[-r:].mean())) < QUIET_RING[1]
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def scan_video(path: Path, fps: int, cache_dir: Path | None = None) -> dict:
|
|
945
|
+
"""What a background video has where: {frames (one pass at fps), lead and tail (empty frames at its start and
|
|
946
|
+
end, at most a third of it each; a tail also covers a clip shrinking away after its last full frame),
|
|
947
|
+
content (main area, fractions, over the whole clip), quiet (plain border)}. Cached by the file signature."""
|
|
948
|
+
cv2 = _cv2()
|
|
949
|
+
st = path.stat()
|
|
950
|
+
key = hashlib.sha1(json.dumps([SCAN_VERSION, str(path), st.st_size, st.st_mtime_ns, fps, SCAN_SIDE, SCAN_DETAIL,
|
|
951
|
+
SCAN_EMPTY, SCAN_FADE, SCAN_ROW, SCAN_MASS,
|
|
952
|
+
QUIET_RING]).encode("utf-8")).hexdigest()[:16]
|
|
953
|
+
cached = cache_dir / f"scan_{key}.json" if cache_dir else None
|
|
954
|
+
if cached and cached.exists():
|
|
955
|
+
data = M.load_json(cached) or {}
|
|
956
|
+
if data.get("key") == key:
|
|
957
|
+
return data
|
|
958
|
+
size = media_size(path, "video") or (SCAN_SIDE, SCAN_SIDE)
|
|
959
|
+
k = SCAN_SIDE / max(size)
|
|
960
|
+
sw, sh = max(8, int(round(size[0] * k / 2)) * 2), max(8, int(round(size[1] * k / 2)) * 2)
|
|
961
|
+
cmd = M._ff() + ["-i", str(path), "-map", "0:v:0", "-an", "-vf",
|
|
962
|
+
f"fps={fps},scale={sw}:{sh}:flags=area,setsar=1,format=rgb24", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
|
|
963
|
+
reader = M.FrameReader(cmd, (sw, sh), 3, "ler o vídeo do fundo", queue_len=8)
|
|
964
|
+
energy: list[float] = []
|
|
965
|
+
acc = np.zeros((sh, sw), np.float32)
|
|
966
|
+
try:
|
|
967
|
+
while True:
|
|
968
|
+
fr = reader.read()
|
|
969
|
+
if fr is None:
|
|
970
|
+
break
|
|
971
|
+
d = detail_map(fr)
|
|
972
|
+
energy.append(float(d.mean()))
|
|
973
|
+
acc += d
|
|
974
|
+
finally:
|
|
975
|
+
reader.close()
|
|
976
|
+
n = len(energy)
|
|
977
|
+
out = {"key": key, "frames": n, "lead": 0, "tail": 0, "content": [0.0, 0.0, 1.0, 1.0], "quiet": False}
|
|
978
|
+
if n:
|
|
979
|
+
acc /= n
|
|
980
|
+
e = np.array(energy)
|
|
981
|
+
med = float(np.median(e))
|
|
982
|
+
empty = max(SCAN_EMPTY[0], SCAN_EMPTY[1] * med)
|
|
983
|
+
thin = e < max(empty, SCAN_EMPTY[2] * med)
|
|
984
|
+
|
|
985
|
+
def run(flags: np.ndarray, edge: float) -> int:
|
|
986
|
+
"""Frames from that end that are empty or still building up (0 unless the end frame is empty; 0 in a
|
|
987
|
+
clip with no detail at all, a flat color)."""
|
|
988
|
+
if edge >= empty or med < SCAN_EMPTY[0]:
|
|
989
|
+
return 0
|
|
990
|
+
full = np.flatnonzero(~flags)
|
|
991
|
+
return int(full[0]) if full.size else n
|
|
992
|
+
def fade_out() -> int:
|
|
993
|
+
"""Frames after the last full one of a clip that shrinks away to nothing (an X that shrinks after it
|
|
994
|
+
landed): the fall from its last peak, past the frames with SCAN_FADE[1] of that peak."""
|
|
995
|
+
if float(e[-1]) >= empty or med < SCAN_EMPTY[0]:
|
|
996
|
+
return 0
|
|
997
|
+
i = n - 1
|
|
998
|
+
while i > 0 and e[i - 1] >= e[i] - SCAN_FADE[0] * med:
|
|
999
|
+
i -= 1
|
|
1000
|
+
fall = e[i:]
|
|
1001
|
+
kept = np.flatnonzero(fall >= SCAN_FADE[1] * float(fall.max()))
|
|
1002
|
+
return n - 1 - (i + int(kept[-1]))
|
|
1003
|
+
out["lead"] = int(min(run(thin, float(e[0])), n // 3))
|
|
1004
|
+
out["tail"] = int(min(max(run(thin[::-1], float(e[-1])), fade_out()), n // 3))
|
|
1005
|
+
rm, cm = acc.mean(axis=1), acc.mean(axis=0)
|
|
1006
|
+
rows = np.flatnonzero(rm > max(SCAN_ROW, 0.1 * float(rm.max())))
|
|
1007
|
+
cols = np.flatnonzero(cm > max(SCAN_ROW, 0.1 * float(cm.max())))
|
|
1008
|
+
if rows.size and cols.size:
|
|
1009
|
+
pad = 0.02
|
|
1010
|
+
box = [max(0.0, cols[0] / sw - pad), max(0.0, rows[0] / sh - pad),
|
|
1011
|
+
min(1.0, (cols[-1] + 1) / sw + pad), min(1.0, (rows[-1] + 1) / sh + pad)]
|
|
1012
|
+
if not (box[2] - box[0] > 0.9 and box[3] - box[1] > 0.9): # a busy clip is all main area
|
|
1013
|
+
cum = np.cumsum(rm) / max(1e-9, float(rm.sum()))
|
|
1014
|
+
cut = (1.0 - SCAN_MASS) / 2.0
|
|
1015
|
+
r0 = max(int(rows[0]), int(np.searchsorted(cum, cut)))
|
|
1016
|
+
r1 = min(int(rows[-1]) + 1, int(np.searchsorted(cum, 1.0 - cut)) + 1)
|
|
1017
|
+
box[1], box[3] = max(0.0, r0 / sh - pad), min(1.0, r1 / sh + pad)
|
|
1018
|
+
out["content"] = [round(float(v), 4) for v in box]
|
|
1019
|
+
out["quiet"] = quiet_border(acc)
|
|
1020
|
+
if cached:
|
|
1021
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
1022
|
+
M.save_json(cached, out)
|
|
1023
|
+
return out
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def extend_weights(place: tuple[int, int, int, int], W: int, H: int) -> np.ndarray:
|
|
1027
|
+
"""0 on a plain-border copy and 1 far from it (EXTEND fall), at 1/8 size: how far the border color has
|
|
1028
|
+
faded into its darker average."""
|
|
1029
|
+
sw, sh = max(2, int(round(W / 8))), max(2, int(round(H / 8)))
|
|
1030
|
+
x, y, w, h = (v / 8.0 for v in place)
|
|
1031
|
+
xs = np.arange(sw, dtype=np.float32) + 0.5
|
|
1032
|
+
ys = np.arange(sh, dtype=np.float32) + 0.5
|
|
1033
|
+
dx = np.maximum(0.0, np.maximum(x - xs, xs - (x + w)))
|
|
1034
|
+
dy = np.maximum(0.0, np.maximum(y - ys, ys - (y + h)))
|
|
1035
|
+
d = np.sqrt(dx[None, :] ** 2 + dy[:, None] ** 2) / max(1.0, EXTEND["fall"] * W / 1080.0 / 8.0)
|
|
1036
|
+
t = np.clip(d, 0, 1)
|
|
1037
|
+
return (t * t * (3 - 2 * t)).astype(np.float32)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def extend_backdrop(sharp: np.ndarray, place: tuple[int, int, int, int], W: int, H: int,
|
|
1041
|
+
weights: np.ndarray) -> np.ndarray:
|
|
1042
|
+
"""The screen around a plain-border copy: its own border carried outwards (blurred) and fading into its
|
|
1043
|
+
average color, a little darker, so the copy has no visible box."""
|
|
1044
|
+
cv2 = _cv2()
|
|
1045
|
+
sw, sh = weights.shape[1], weights.shape[0]
|
|
1046
|
+
x, y, w, h = place
|
|
1047
|
+
cw, ch = max(1, int(round(w / 8))), max(1, int(round(h / 8)))
|
|
1048
|
+
small = cv2.resize(sharp, (cw, ch), interpolation=cv2.INTER_AREA)
|
|
1049
|
+
x8, y8 = int(round(x / 8)), int(round(y / 8))
|
|
1050
|
+
# the part of the small copy on the screen, then its border carried to the screen edges
|
|
1051
|
+
cx0, cy0 = max(0, -x8), max(0, -y8)
|
|
1052
|
+
cx1, cy1 = min(cw, sw - x8), min(ch, sh - y8)
|
|
1053
|
+
if cx1 <= cx0 or cy1 <= cy0:
|
|
1054
|
+
part = small
|
|
1055
|
+
top = bottom = left = right = 0
|
|
1056
|
+
canvas = cv2.resize(small, (sw, sh), interpolation=cv2.INTER_AREA)
|
|
1057
|
+
else:
|
|
1058
|
+
part = np.ascontiguousarray(small[cy0:cy1, cx0:cx1])
|
|
1059
|
+
top, left = max(0, y8), max(0, x8)
|
|
1060
|
+
bottom, right = max(0, sh - top - part.shape[0]), max(0, sw - left - part.shape[1])
|
|
1061
|
+
canvas = cv2.copyMakeBorder(part, top, bottom, left, right, cv2.BORDER_REPLICATE)[:sh, :sw]
|
|
1062
|
+
sigma = EXTEND["blur"] * W / 1080.0 / 8.0
|
|
1063
|
+
canvas = cv2.GaussianBlur(canvas, (0, 0), sigma).astype(np.float32)
|
|
1064
|
+
ring = np.concatenate([part[:1].reshape(-1, 3), part[-1:].reshape(-1, 3), part[:, :1].reshape(-1, 3),
|
|
1065
|
+
part[:, -1:].reshape(-1, 3)]).astype(np.float32)
|
|
1066
|
+
flat = np.median(ring, axis=0) * (1.0 - EXTEND["darken"])
|
|
1067
|
+
t = weights[..., None]
|
|
1068
|
+
out = canvas * (1.0 - t) + flat[None, None, :] * t
|
|
1069
|
+
return cv2.resize(np.clip(out, 0, 255).astype(np.uint8), (W, H), interpolation=cv2.INTER_LINEAR)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def feather_mask(w: int, h: int, x: int, y: int, W: int, H: int) -> np.ndarray:
|
|
1073
|
+
"""Blend weights of a sharp copy at (x, y): smooth ramps on the sides that fall inside the screen."""
|
|
1074
|
+
f = max(2, int(round(BACKDROP["feather"] * W / 1080.0)))
|
|
1075
|
+
|
|
1076
|
+
def ramp(n: int, lead: bool, trail: bool) -> np.ndarray:
|
|
1077
|
+
r = np.ones(n, np.float32)
|
|
1078
|
+
m = min(f, n // 4)
|
|
1079
|
+
if m > 1:
|
|
1080
|
+
e = np.linspace(0.0, 1.0, m, dtype=np.float32)
|
|
1081
|
+
e = e * e * (3 - 2 * e)
|
|
1082
|
+
if lead:
|
|
1083
|
+
r[:m] = np.minimum(r[:m], e)
|
|
1084
|
+
if trail:
|
|
1085
|
+
r[n - m:] = np.minimum(r[n - m:], e[::-1])
|
|
1086
|
+
return r
|
|
1087
|
+
|
|
1088
|
+
return np.ascontiguousarray(ramp(h, y > 0, y + h < H)[:, None] * ramp(w, x > 0, x + w < W)[None, :])
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def whole_frame(sharp: np.ndarray, place: tuple[int, int, int, int], W: int, H: int, mask: np.ndarray,
|
|
1092
|
+
extend: np.ndarray | None = None) -> np.ndarray:
|
|
1093
|
+
"""The sharp copy (already w x h) at its place over the backdrop made from it (its own border carried
|
|
1094
|
+
outwards when extend, the weights of extend_weights, is given)."""
|
|
1095
|
+
cv2 = _cv2()
|
|
1096
|
+
base = backdrop(sharp, W, H) if extend is None else extend_backdrop(sharp, place, W, H, extend)
|
|
1097
|
+
x, y, w, h = place
|
|
1098
|
+
if sharp.shape[1] != w or sharp.shape[0] != h:
|
|
1099
|
+
sharp = cv2.resize(sharp, (w, h), interpolation=cv2.INTER_AREA if w < sharp.shape[1] else cv2.INTER_CUBIC)
|
|
1100
|
+
X0, Y0, X1, Y1 = max(0, x), max(0, y), min(W, x + w), min(H, y + h)
|
|
1101
|
+
if X1 <= X0 or Y1 <= Y0:
|
|
1102
|
+
return base
|
|
1103
|
+
part = np.ascontiguousarray(sharp[Y0 - y:Y1 - y, X0 - x:X1 - x])
|
|
1104
|
+
m = np.ascontiguousarray(mask[Y0 - y:Y1 - y, X0 - x:X1 - x])
|
|
1105
|
+
base[Y0:Y1, X0:X1] = cv2.blendLinear(np.ascontiguousarray(base[Y0:Y1, X0:X1]), part, 1.0 - m, m)
|
|
1106
|
+
return base
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def picture_frame(img: np.ndarray, b: Background, W: int, H: int) -> tuple[np.ndarray, tuple[float, float]]:
|
|
1110
|
+
"""A still picture on the screen -> (frame, Ken Burns center)."""
|
|
1111
|
+
cv2 = _cv2()
|
|
1112
|
+
ih, iw = img.shape[:2]
|
|
1113
|
+
if b.fit == "inteiro" and b.place:
|
|
1114
|
+
x, y, w, h = b.place
|
|
1115
|
+
sharp = cv2.resize(img, (w, h), interpolation=cv2.INTER_AREA if w < iw else cv2.INTER_CUBIC)
|
|
1116
|
+
frame = whole_frame(sharp, b.place, W, H, feather_mask(w, h, x, y, W, H))
|
|
1117
|
+
c = b.content
|
|
1118
|
+
return frame, (x + (c[0] + c[2]) / 2.0 * w, y + (c[1] + c[3]) / 2.0 * h)
|
|
1119
|
+
tall = iw / ih < (W / H) / FIT_RATIO and W < H
|
|
1120
|
+
return _cover(img, W, H, top=tall), (W / 2.0, H * 0.3 if tall else H / 2.0)
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _kb_frame(base: np.ndarray, center: tuple[float, float], p: float, W: int, H: int) -> np.ndarray:
|
|
1124
|
+
cv2 = _cv2()
|
|
1125
|
+
z = 1.0 + KB_DRIFT * ease(p)
|
|
1126
|
+
cx, cy = center
|
|
1127
|
+
m = np.array([[z, 0.0, cx - z * cx], [0.0, z, cy - z * cy]], np.float32)
|
|
1128
|
+
return cv2.warpAffine(base, m, (W, H), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def read_image(path: Path) -> np.ndarray:
|
|
1132
|
+
from PIL import Image, ImageOps # noqa: PLC0415
|
|
1133
|
+
with Image.open(path) as im:
|
|
1134
|
+
im = ImageOps.exif_transpose(im)
|
|
1135
|
+
if im.mode in ("RGBA", "LA", "P"):
|
|
1136
|
+
rgba = im.convert("RGBA")
|
|
1137
|
+
bg = Image.new("RGB", rgba.size, (18, 18, 18))
|
|
1138
|
+
bg.paste(rgba, mask=rgba.getchannel("A"))
|
|
1139
|
+
im = bg
|
|
1140
|
+
return np.ascontiguousarray(np.asarray(im.convert("RGB")))
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
class LoopReader:
|
|
1144
|
+
"""Frames of a background video in order. Every pass plays untouched (an insert shows its clip frame by frame,
|
|
1145
|
+
as in the split screen), except its empty frames: the `lead` empty frames at the start show the first frame
|
|
1146
|
+
with something in it and the `tail` ones at the end the last (a pass never flashes an empty scene;
|
|
1147
|
+
first_lead replaces lead on the first pass, 0 when a dissolve covers the start). Only
|
|
1148
|
+
when the clip has to go on past its end does it start again (a new ffmpeg reader), and the first `fade`
|
|
1149
|
+
frames of the new pass dissolve from the last frame shown, so the loop neither jumps nor freezes."""
|
|
1150
|
+
|
|
1151
|
+
def __init__(self, cmd: list[str], size: tuple[int, int], what: str, fade: int, lead: int = 0, tail: int = 0,
|
|
1152
|
+
frames: int = 0, first_lead: int | None = None):
|
|
1153
|
+
self.cmd, self.size, self.what = cmd, size, what
|
|
1154
|
+
self.fade = max(0, int(fade))
|
|
1155
|
+
self.lead, self.tail, self.frames = max(0, int(lead)), max(0, int(tail)), max(0, int(frames))
|
|
1156
|
+
self.first_lead = self.lead if first_lead is None else max(0, int(first_lead)) # the first pass
|
|
1157
|
+
self.cur: M.FrameReader | None = self._open()
|
|
1158
|
+
self.cycle = 0 # frames given from the current pass
|
|
1159
|
+
self.length = 0 # frames of one pass (known when the first one ends)
|
|
1160
|
+
self.shown: np.ndarray | None = None # the last frame given (before a dissolve)
|
|
1161
|
+
self.ahead = 0 # frames of this pass already read (the empty start)
|
|
1162
|
+
self.hold: np.ndarray | None = None # the frame shown over the empty start or end
|
|
1163
|
+
self.blend_from: np.ndarray | None = None # the last frame of the pass before, while the new one dissolves in
|
|
1164
|
+
self.blend_n = 0
|
|
1165
|
+
self.loops = 0
|
|
1166
|
+
|
|
1167
|
+
def _open(self) -> "M.FrameReader":
|
|
1168
|
+
return M.FrameReader(self.cmd, self.size, 3, self.what, queue_len=4)
|
|
1169
|
+
|
|
1170
|
+
def read(self) -> np.ndarray | None:
|
|
1171
|
+
if self.cur is None:
|
|
1172
|
+
return None
|
|
1173
|
+
if self.ahead > 0:
|
|
1174
|
+
self.ahead -= 1
|
|
1175
|
+
return self._give(self.hold)
|
|
1176
|
+
fr = self.cur.read()
|
|
1177
|
+
if fr is None:
|
|
1178
|
+
if self.cycle == 0 or self.shown is None:
|
|
1179
|
+
self.close()
|
|
1180
|
+
return None
|
|
1181
|
+
self.length = self.length or self.cycle
|
|
1182
|
+
self.cur.close()
|
|
1183
|
+
self.cur = self._open()
|
|
1184
|
+
self.loops += 1
|
|
1185
|
+
self.cycle = 0
|
|
1186
|
+
self.blend_from, self.blend_n = self.shown, min(self.fade, self.length // 3)
|
|
1187
|
+
fr = self.cur.read()
|
|
1188
|
+
if fr is None:
|
|
1189
|
+
self.close()
|
|
1190
|
+
return None
|
|
1191
|
+
i = self.cycle
|
|
1192
|
+
lead = self.first_lead if self.loops == 0 else self.lead
|
|
1193
|
+
if i == 0 and lead > 0:
|
|
1194
|
+
for _ in range(lead):
|
|
1195
|
+
nxt = self.cur.read()
|
|
1196
|
+
if nxt is None:
|
|
1197
|
+
break
|
|
1198
|
+
fr = nxt
|
|
1199
|
+
self.ahead += 1
|
|
1200
|
+
self.hold = fr
|
|
1201
|
+
elif self.frames and self.tail > 0 and i >= self.frames - self.tail and self.hold is not None:
|
|
1202
|
+
fr = self.hold
|
|
1203
|
+
if self.frames and self.tail > 0 and i == self.frames - self.tail - 1:
|
|
1204
|
+
self.hold = fr
|
|
1205
|
+
return self._give(fr)
|
|
1206
|
+
|
|
1207
|
+
def _give(self, fr: np.ndarray) -> np.ndarray:
|
|
1208
|
+
self.cycle += 1
|
|
1209
|
+
self.shown = fr
|
|
1210
|
+
if self.blend_from is not None and self.cycle <= self.blend_n:
|
|
1211
|
+
w = self.cycle / (self.blend_n + 1)
|
|
1212
|
+
return _cv2().addWeighted(self.blend_from, 1.0 - w, fr, w, 0)
|
|
1213
|
+
self.blend_from = None
|
|
1214
|
+
return fr
|
|
1215
|
+
|
|
1216
|
+
def close(self) -> None:
|
|
1217
|
+
if self.cur is not None:
|
|
1218
|
+
self.cur.close()
|
|
1219
|
+
self.cur = None
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
class BackgroundTrack:
|
|
1223
|
+
"""Background frames in order: fetch(f) reads what has to be read in order (video frames) and returns a
|
|
1224
|
+
function that makes the frame (run in the worker threads)."""
|
|
1225
|
+
|
|
1226
|
+
def __init__(self, cfg: Config, W: int, H: int, fps: int):
|
|
1227
|
+
self.cfg, self.W, self.H, self.fps = cfg, W, H, fps
|
|
1228
|
+
self.runs = cfg.runs
|
|
1229
|
+
self.run_i = 0
|
|
1230
|
+
self.stills: dict[int, tuple[np.ndarray, tuple[float, float]]] = {}
|
|
1231
|
+
self.videos: dict[int, dict] = {}
|
|
1232
|
+
self.masks: dict[int, np.ndarray] = {}
|
|
1233
|
+
self.dark = np.empty((H, W, 3), np.uint8)
|
|
1234
|
+
self.dark[:] = np.array(hex_rgb(DARK_DEFAULT), np.uint8)
|
|
1235
|
+
self.ends: dict[int, int] = {}
|
|
1236
|
+
for f0, f1, k in cfg.runs: # a background lasts until the last run it owns (gaps included)
|
|
1237
|
+
if k is not None:
|
|
1238
|
+
self.ends[k] = max(self.ends.get(k, 0), f1)
|
|
1239
|
+
self.extends: dict[int, np.ndarray] = {}
|
|
1240
|
+
self.last: tuple[int, int | None, Callable[[], np.ndarray]] | None = None # (frame, owner, make)
|
|
1241
|
+
self.dissolve: tuple[int, np.ndarray | None] | None = None # (owner, frame it comes from)
|
|
1242
|
+
self.no_hold: set[int] = set() # videos whose empty start plays under a dissolve (first pass)
|
|
1243
|
+
|
|
1244
|
+
def kb_span(self, k: int) -> int:
|
|
1245
|
+
"""Frames of the Ken Burns: the item's own time, stretched over a held gap up to KB_HOLD_AFTER times it."""
|
|
1246
|
+
b = self.cfg.backgrounds[k]
|
|
1247
|
+
own = max(1, b.f1 - b.f0)
|
|
1248
|
+
total = max(own, self.ends.get(k, b.f1) - b.f0)
|
|
1249
|
+
return max(1, min(total, int(round(own * KB_HOLD_AFTER))) - 1)
|
|
1250
|
+
|
|
1251
|
+
def _still(self, k: int) -> tuple[np.ndarray, tuple[float, float]]:
|
|
1252
|
+
if k not in self.stills:
|
|
1253
|
+
b = self.cfg.backgrounds[k]
|
|
1254
|
+
if b.kind == "color":
|
|
1255
|
+
frame = np.empty((self.H, self.W, 3), np.uint8)
|
|
1256
|
+
frame[:] = np.array(b.color, np.uint8)
|
|
1257
|
+
self.stills[k] = (_darken(frame, b.darken), (self.W / 2.0, self.H / 2.0))
|
|
1258
|
+
else:
|
|
1259
|
+
frame, center = picture_frame(read_image(b.src), b, self.W, self.H)
|
|
1260
|
+
frame = _darken(_blur(frame, b.blur, self.W), b.darken)
|
|
1261
|
+
self.stills[k] = (frame, center)
|
|
1262
|
+
return self.stills[k]
|
|
1263
|
+
|
|
1264
|
+
def _mask(self, k: int) -> np.ndarray:
|
|
1265
|
+
if k not in self.masks:
|
|
1266
|
+
b = self.cfg.backgrounds[k]
|
|
1267
|
+
x, y, w, h = b.place
|
|
1268
|
+
self.masks[k] = feather_mask(w, h, x, y, self.W, self.H)
|
|
1269
|
+
if b.quiet:
|
|
1270
|
+
self.extends[k] = extend_weights(b.place, self.W, self.H)
|
|
1271
|
+
return self.masks[k]
|
|
1272
|
+
|
|
1273
|
+
def _video_raw(self, k: int, local: int) -> np.ndarray:
|
|
1274
|
+
b = self.cfg.backgrounds[k]
|
|
1275
|
+
v = self.videos.get(k)
|
|
1276
|
+
if v is None:
|
|
1277
|
+
if b.fit == "inteiro" and b.place:
|
|
1278
|
+
size = (b.place[2], b.place[3])
|
|
1279
|
+
vf = f"fps={self.fps},scale={size[0]}:{size[1]}:flags=bicubic,setsar=1,format=rgb24"
|
|
1280
|
+
else:
|
|
1281
|
+
size = (self.W, self.H)
|
|
1282
|
+
vf = (f"fps={self.fps},scale={self.W}:{self.H}:force_original_aspect_ratio=increase:flags=bicubic,"
|
|
1283
|
+
f"crop={self.W}:{self.H},setsar=1,format=rgb24")
|
|
1284
|
+
cmd = M._ff() + ["-i", str(b.src), "-map", "0:v:0", "-an", "-vf", vf, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
|
|
1285
|
+
v = {"reader": LoopReader(cmd, size, "ler o vídeo do fundo", int(round(LOOP_FADE * self.fps)),
|
|
1286
|
+
lead=b.lead, tail=b.tail, frames=b.pass_frames,
|
|
1287
|
+
first_lead=0 if k in self.no_hold else None),
|
|
1288
|
+
"pos": 0, "last": None}
|
|
1289
|
+
self.videos[k] = v
|
|
1290
|
+
while v["pos"] <= local:
|
|
1291
|
+
fr = v["reader"].read()
|
|
1292
|
+
if fr is None:
|
|
1293
|
+
break
|
|
1294
|
+
v["last"] = fr
|
|
1295
|
+
v["pos"] += 1
|
|
1296
|
+
if v["last"] is None:
|
|
1297
|
+
raise TelaVerdeError(f"não consegui ler o vídeo do fundo ({b.name}): {b.src}", 1)
|
|
1298
|
+
return v["last"]
|
|
1299
|
+
|
|
1300
|
+
def _video_frame(self, k: int, raw: np.ndarray, local: int) -> np.ndarray:
|
|
1301
|
+
b = self.cfg.backgrounds[k]
|
|
1302
|
+
frame, center = raw, (self.W / 2.0, self.H / 2.0)
|
|
1303
|
+
if b.fit == "inteiro" and b.place:
|
|
1304
|
+
frame = whole_frame(raw, b.place, self.W, self.H, self._mask(k), self.extends.get(k))
|
|
1305
|
+
x, y, w, h = b.place
|
|
1306
|
+
center = (x + w / 2.0, y + h / 2.0)
|
|
1307
|
+
if b.ken_burns:
|
|
1308
|
+
frame = _kb_frame(frame, center, min(1.0, local / self.kb_span(k)), self.W, self.H)
|
|
1309
|
+
frame = _darken(_blur(frame, b.blur, self.W), b.darken)
|
|
1310
|
+
return frame if frame is not raw else frame.copy()
|
|
1311
|
+
|
|
1312
|
+
def owner(self, f: int) -> int | None:
|
|
1313
|
+
while self.run_i < len(self.runs) - 1 and f >= self.runs[self.run_i][1]:
|
|
1314
|
+
self.run_i += 1
|
|
1315
|
+
return self.runs[self.run_i][2]
|
|
1316
|
+
|
|
1317
|
+
def fetch(self, f: int, original: np.ndarray | None) -> Callable[[], np.ndarray]:
|
|
1318
|
+
k = self.owner(f)
|
|
1319
|
+
src, w = None, 1.0
|
|
1320
|
+
if k is not None:
|
|
1321
|
+
b = self.cfg.backgrounds[k]
|
|
1322
|
+
local = f - b.f0
|
|
1323
|
+
if b.kind == "video" and b.lead > 0 and local < b.lead:
|
|
1324
|
+
# the clip starts empty: the background right before it dissolves into it while it builds up
|
|
1325
|
+
# (its own frames play under the dissolve); with nothing before it, its first full frame is held
|
|
1326
|
+
if self.dissolve is None or self.dissolve[0] != k:
|
|
1327
|
+
prev = self.last
|
|
1328
|
+
first = local == 0 and prev and prev[0] == f - 1 and prev[1] is not None and prev[1] != k
|
|
1329
|
+
self.dissolve = (k, prev[2]() if first else None)
|
|
1330
|
+
if first and k not in self.videos:
|
|
1331
|
+
self.no_hold.add(k)
|
|
1332
|
+
src = self.dissolve[1]
|
|
1333
|
+
w = (local + 1) / (b.lead + 1)
|
|
1334
|
+
make = self._make(f, k, original)
|
|
1335
|
+
if src is not None:
|
|
1336
|
+
inner = make
|
|
1337
|
+
make = lambda: _cv2().addWeighted(src, 1.0 - w, inner(), w, 0) # noqa: E731
|
|
1338
|
+
self.last = (f, k, make)
|
|
1339
|
+
return make
|
|
1340
|
+
|
|
1341
|
+
def _make(self, f: int, k: int | None, original: np.ndarray | None) -> Callable[[], np.ndarray]:
|
|
1342
|
+
W, H = self.W, self.H
|
|
1343
|
+
if k is None:
|
|
1344
|
+
if self.cfg.blurred_original and original is not None:
|
|
1345
|
+
return lambda: _darken(_blur(original, ORIGINAL_BLUR["sigma"], W), ORIGINAL_BLUR["darken"])
|
|
1346
|
+
return self.dark.copy
|
|
1347
|
+
b = self.cfg.backgrounds[k]
|
|
1348
|
+
local = f - b.f0
|
|
1349
|
+
if b.kind == "video":
|
|
1350
|
+
raw = self._video_raw(k, local)
|
|
1351
|
+
if k not in self.masks and b.fit == "inteiro" and b.place:
|
|
1352
|
+
self._mask(k) # made once, here, before the workers share it
|
|
1353
|
+
return lambda: self._video_frame(k, raw, local)
|
|
1354
|
+
base, center = self._still(k)
|
|
1355
|
+
if b.ken_burns:
|
|
1356
|
+
p = min(1.0, local / self.kb_span(k))
|
|
1357
|
+
return lambda: _kb_frame(base, center, p, W, H)
|
|
1358
|
+
return base.copy
|
|
1359
|
+
|
|
1360
|
+
def frame(self, f: int, original: np.ndarray | None) -> np.ndarray:
|
|
1361
|
+
return self.fetch(f, original)()
|
|
1362
|
+
|
|
1363
|
+
def close(self) -> None:
|
|
1364
|
+
for v in self.videos.values():
|
|
1365
|
+
v["reader"].close()
|
|
1366
|
+
self.videos.clear()
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
# ================================================================== room plate (the room of a tripod shot)
|
|
1370
|
+
|
|
1371
|
+
def _ss(x: np.ndarray, a: float, b: float) -> np.ndarray:
|
|
1372
|
+
t = np.clip((x - a) / (b - a), 0.0, 1.0)
|
|
1373
|
+
return t * t * (3.0 - 2.0 * t)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def plate_size(full: tuple[int, int]) -> tuple[int, int]:
|
|
1377
|
+
"""The plate works at the matte work size (half the output)."""
|
|
1378
|
+
return max(2, (full[0] // 2) // 2 * 2), max(2, (full[1] // 2) // 2 * 2)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
@dataclass
|
|
1382
|
+
class RoomPlate:
|
|
1383
|
+
"""What the room behind a take looks like, learned from every other frame of its ranges where the matte says
|
|
1384
|
+
room (the camera on a tripod). evidence() finds where a frame's matte is unsure (a haze, a patch the model
|
|
1385
|
+
flickers on) and the picture there is exactly that room: those pieces go (a dark room only when it is unlike
|
|
1386
|
+
the body next to it), and so does each pixel of a light halo of the room around a darker body. A matte that
|
|
1387
|
+
is sure is never touched (a kept object), nor is the rest of the soft edge of the body or a dark shirt
|
|
1388
|
+
blurred over a dark desk; clean_edge() takes the room's color out of that soft edge."""
|
|
1389
|
+
key: str
|
|
1390
|
+
size: tuple[int, int]
|
|
1391
|
+
P: np.ndarray # blurred room color (float32 h x w x 3)
|
|
1392
|
+
rel: np.ndarray # 0..1 how much the plate is trusted there
|
|
1393
|
+
tol: np.ndarray # color distance that still is the room there
|
|
1394
|
+
known: float = 0.0 # share of the frame the plate knows
|
|
1395
|
+
spread: float = 0.0 # median spread of the room color (0-255)
|
|
1396
|
+
mask: np.ndarray | None = None
|
|
1397
|
+
|
|
1398
|
+
def __post_init__(self):
|
|
1399
|
+
self.mask = self.rel > 0.02
|
|
1400
|
+
|
|
1401
|
+
def evidence(self, frame: np.ndarray, alpha: np.ndarray) -> np.ndarray | None:
|
|
1402
|
+
"""frame: RGB uint8 at the plate size; alpha: 0..1. -> 0..1 map of room to take out of the matte (None
|
|
1403
|
+
when there is none)."""
|
|
1404
|
+
cv2 = _cv2()
|
|
1405
|
+
lo, hi = PLATE["unsure"]
|
|
1406
|
+
cand = self.mask & (alpha > 0.02) & (alpha < hi)
|
|
1407
|
+
rows = np.flatnonzero(cand.any(axis=1))
|
|
1408
|
+
if rows.size == 0:
|
|
1409
|
+
return None
|
|
1410
|
+
cols = np.flatnonzero(cand.any(axis=0))
|
|
1411
|
+
w, h = self.size
|
|
1412
|
+
pad = PLATE["unsure_px"] + 8
|
|
1413
|
+
y0, y1 = max(0, int(rows[0]) - pad), min(h, int(rows[-1]) + pad + 1)
|
|
1414
|
+
x0, x1 = max(0, int(cols[0]) - pad), min(w, int(cols[-1]) + pad + 1)
|
|
1415
|
+
sl = (slice(y0, y1), slice(x0, x1))
|
|
1416
|
+
img = cv2.GaussianBlur(np.ascontiguousarray(frame[sl]).astype(np.float32), (0, 0), PLATE["blur"])
|
|
1417
|
+
a = alpha[sl]
|
|
1418
|
+
room_P = self.P[sl]
|
|
1419
|
+
tol = self.tol[sl]
|
|
1420
|
+
win = PLATE["win"]
|
|
1421
|
+
d1 = cv2.absdiff(img, room_P).max(axis=2)
|
|
1422
|
+
d = cv2.blur(d1, (win, win))
|
|
1423
|
+
# a dark room must look unlike the body next to it (a dark shirt blurred over a dark desk is left alone)
|
|
1424
|
+
core = (a >= hi).astype(np.float32)
|
|
1425
|
+
sig = PLATE["near"]
|
|
1426
|
+
body = cv2.GaussianBlur(img * core[..., None], (0, 0), sig) / np.maximum(
|
|
1427
|
+
cv2.GaussianBlur(core, (0, 0), sig), 1e-3)[..., None]
|
|
1428
|
+
unlike = np.maximum(_ss(cv2.absdiff(body, room_P).max(axis=2), *PLATE["unlike"]),
|
|
1429
|
+
_ss(room_P.mean(axis=2), *PLATE["bright"]))
|
|
1430
|
+
base = self.rel[sl] * (1.0 - _ss(a, lo, hi)) * cand[sl]
|
|
1431
|
+
# a light halo of the room around a darker body goes pixel by pixel (only the pixels that are that room)
|
|
1432
|
+
halo = (base * _ss(room_P.mean(axis=2) - body.mean(axis=2), *PLATE["halo"])
|
|
1433
|
+
* (1.0 - _ss(d1, tol, tol + PLATE["width"])))
|
|
1434
|
+
halo = np.where(halo > 0.05, halo, 0.0).astype(np.float32)
|
|
1435
|
+
e = base * unlike * (1.0 - _ss(d, tol, tol + PLATE["width"]))
|
|
1436
|
+
m = (e > PLATE["on"]).astype(np.uint8)
|
|
1437
|
+
# only pieces wider than the soft edge of a body, rounded, and only when they reach the room around the
|
|
1438
|
+
# person or the frame edge (a piece inside the body would leave a hole)
|
|
1439
|
+
ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (PLATE["unsure_px"], PLATE["unsure_px"]))
|
|
1440
|
+
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, ker)
|
|
1441
|
+
out_sl = halo
|
|
1442
|
+
if m.any():
|
|
1443
|
+
round_ = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (PLATE["round_px"], PLATE["round_px"]))
|
|
1444
|
+
m = cv2.morphologyEx(cv2.morphologyEx(m, cv2.MORPH_CLOSE, round_), cv2.MORPH_OPEN, round_)
|
|
1445
|
+
n, lab, st, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
|
|
1446
|
+
room = cv2.dilate((a < 0.5).astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
|
|
1447
|
+
keep = np.zeros(n, bool)
|
|
1448
|
+
keep[np.unique(lab[room & (m > 0)])] = True
|
|
1449
|
+
bx, by, bw, bh = st.T[:4]
|
|
1450
|
+
keep |= (bx + x0 == 0) | (by + y0 == 0) | (bx + bw + x0 == w) | (by + bh + y0 == h)
|
|
1451
|
+
keep[0] = False
|
|
1452
|
+
k = keep[lab]
|
|
1453
|
+
if k.any():
|
|
1454
|
+
whole = cv2.GaussianBlur(cv2.dilate(k.astype(np.uint8), np.ones((3, 3), np.uint8)).astype(np.float32),
|
|
1455
|
+
(0, 0), PLATE["soft"])
|
|
1456
|
+
out_sl = np.maximum(out_sl, whole)
|
|
1457
|
+
if not out_sl.any():
|
|
1458
|
+
return None
|
|
1459
|
+
out = np.zeros((h, w), np.float32)
|
|
1460
|
+
out[sl] = out_sl
|
|
1461
|
+
return out
|
|
1462
|
+
|
|
1463
|
+
def clean_edge(self, frame: np.ndarray, alpha: np.ndarray) -> np.ndarray:
|
|
1464
|
+
"""The colors of the soft edge of the matte without the known room mixed into them (a light or blue
|
|
1465
|
+
fringe from the room behind): each partly transparent pixel is solved for the body color, where the plate
|
|
1466
|
+
knows the room. frame: RGB uint8, alpha: uint8, both at the layer size. -> a new frame (or frame)."""
|
|
1467
|
+
lo, hi = PLATE["edge"]
|
|
1468
|
+
h, w = alpha.shape
|
|
1469
|
+
pw, ph = self.size
|
|
1470
|
+
ys, xs = np.nonzero((alpha > lo) & (alpha < hi))
|
|
1471
|
+
if ys.size == 0:
|
|
1472
|
+
return frame
|
|
1473
|
+
py = np.minimum(ph - 1, (ys * ph) // h)
|
|
1474
|
+
px = np.minimum(pw - 1, (xs * pw) // w)
|
|
1475
|
+
rel = self.rel[py, px]
|
|
1476
|
+
use = rel > 0.3
|
|
1477
|
+
if not use.any():
|
|
1478
|
+
return frame
|
|
1479
|
+
ys, xs, py, px, rel = ys[use], xs[use], py[use], px[use], rel[use]
|
|
1480
|
+
a = (alpha[ys, xs].astype(np.float32) / 255.0)[:, None]
|
|
1481
|
+
img = frame[ys, xs].astype(np.float32)
|
|
1482
|
+
B = self.P[py, px]
|
|
1483
|
+
solved = (img - (1.0 - a) * B) / np.maximum(a, PLATE["edge_floor"])
|
|
1484
|
+
out = frame.copy()
|
|
1485
|
+
out[ys, xs] = np.clip(img + rel[:, None] * (solved - img), 0, 255).astype(np.uint8)
|
|
1486
|
+
return out
|
|
1487
|
+
|
|
1488
|
+
def save(self, path: Path) -> None:
|
|
1489
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1490
|
+
tmp = path.with_name(".tmp-" + path.name)
|
|
1491
|
+
with open(tmp, "wb") as fh:
|
|
1492
|
+
np.savez_compressed(fh, P=self.P, rel=self.rel, tol=self.tol,
|
|
1493
|
+
meta=np.array(json.dumps({"key": self.key, "size": list(self.size),
|
|
1494
|
+
"known": self.known, "spread": self.spread})))
|
|
1495
|
+
os.replace(tmp, path)
|
|
1496
|
+
|
|
1497
|
+
@staticmethod
|
|
1498
|
+
def load(path: Path, key: str) -> RoomPlate | None:
|
|
1499
|
+
try:
|
|
1500
|
+
with np.load(path) as z:
|
|
1501
|
+
meta = json.loads(str(z["meta"]))
|
|
1502
|
+
if meta.get("key") != key:
|
|
1503
|
+
return None
|
|
1504
|
+
return RoomPlate(key=key, size=tuple(meta["size"]), P=z["P"], rel=z["rel"], tol=z["tol"],
|
|
1505
|
+
known=float(meta.get("known", 0.0)), spread=float(meta.get("spread", 0.0)))
|
|
1506
|
+
except (OSError, ValueError, KeyError):
|
|
1507
|
+
return None
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
class _PlateAcc:
|
|
1511
|
+
def __init__(self, size: tuple[int, int]):
|
|
1512
|
+
w, h = size
|
|
1513
|
+
self.size = size
|
|
1514
|
+
self.sum = np.zeros((h, w, 3), np.float32)
|
|
1515
|
+
self.sq = np.zeros((h, w, 3), np.float32)
|
|
1516
|
+
self.n = np.zeros((h, w), np.float32)
|
|
1517
|
+
cv2 = _cv2()
|
|
1518
|
+
r = PLATE["fringe"]
|
|
1519
|
+
self.ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
|
|
1520
|
+
|
|
1521
|
+
def add(self, frame: np.ndarray, alpha: np.ndarray) -> None:
|
|
1522
|
+
cv2 = _cv2()
|
|
1523
|
+
room = (cv2.dilate((alpha >= PLATE["bg"]).astype(np.uint8), self.ker) == 0).astype(np.uint8)
|
|
1524
|
+
x = frame.astype(np.float32)
|
|
1525
|
+
cv2.accumulate(x, self.sum, room)
|
|
1526
|
+
cv2.accumulateSquare(x, self.sq, room)
|
|
1527
|
+
cv2.accumulate(room.astype(np.float32), self.n)
|
|
1528
|
+
|
|
1529
|
+
def finish(self, key: str) -> tuple[RoomPlate | None, str]:
|
|
1530
|
+
cv2 = _cv2()
|
|
1531
|
+
n = self.n
|
|
1532
|
+
nn = np.maximum(n, 1.0)[..., None]
|
|
1533
|
+
mean = self.sum / nn
|
|
1534
|
+
std = np.sqrt(np.maximum(self.sq / nn - mean * mean, 0.0)).max(axis=2)
|
|
1535
|
+
well = n >= PLATE["n"][1]
|
|
1536
|
+
known = float(well.mean())
|
|
1537
|
+
if known < PLATE["known"]:
|
|
1538
|
+
return None, "o fundo quase não aparece nesses trechos"
|
|
1539
|
+
spread = float(np.median(std[well]))
|
|
1540
|
+
if spread > PLATE["static"]:
|
|
1541
|
+
return None, "a câmera se mexe nesses trechos"
|
|
1542
|
+
P = cv2.GaussianBlur(mean, (0, 0), PLATE["blur"]).astype(np.float32)
|
|
1543
|
+
rel = (_ss(n, *PLATE["n"]) * (1.0 - _ss(std, *PLATE["std"]))).astype(np.float32)
|
|
1544
|
+
tol = (PLATE["tol"] + np.minimum(std, PLATE["std"][0] + 2.0)).astype(np.float32)
|
|
1545
|
+
return RoomPlate(key=key, size=self.size, P=P, rel=rel, tol=tol, known=round(known, 3),
|
|
1546
|
+
spread=round(spread, 2)), ""
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def room_plates(base: Path, base_pre: list[str], base_fps: float, fps: int, full_size: tuple[int, int],
|
|
1550
|
+
groups: dict[str, list[tuple[int, int, M.RangeJob]]], cache_dir: Path,
|
|
1551
|
+
progress: Callable[[str], None] | None = None) -> tuple[dict[str, RoomPlate], dict[str, str]]:
|
|
1552
|
+
"""One plate per take (groups: take -> [(f0, f1, matte job)]), read from the cut at the plate size in one
|
|
1553
|
+
pass (only when a plate is not cached). -> ({take: plate}, {take: why there is none})."""
|
|
1554
|
+
size = plate_size(full_size)
|
|
1555
|
+
sig = M.file_signature(base)
|
|
1556
|
+
keys, plates, notes = {}, {}, {}
|
|
1557
|
+
for g, parts in groups.items():
|
|
1558
|
+
keys[g] = hashlib.sha1(json.dumps([PLATE_VERSION, PLATE, str(base), sig, base_pre, fps, list(full_size),
|
|
1559
|
+
sorted([a, b, j.key] for a, b, j in parts)],
|
|
1560
|
+
sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
|
1561
|
+
todo = {}
|
|
1562
|
+
for g, key in keys.items():
|
|
1563
|
+
path = cache_dir / f"plate_{key}.npz"
|
|
1564
|
+
note_path = cache_dir / f"plate_{key}.json"
|
|
1565
|
+
if path.exists():
|
|
1566
|
+
plate = RoomPlate.load(path, key)
|
|
1567
|
+
if plate is not None:
|
|
1568
|
+
plates[g] = plate
|
|
1569
|
+
continue
|
|
1570
|
+
if note_path.exists():
|
|
1571
|
+
data = M.load_json(note_path) or {}
|
|
1572
|
+
if data.get("key") == key:
|
|
1573
|
+
notes[g] = str(data.get("note") or "")
|
|
1574
|
+
continue
|
|
1575
|
+
todo[g] = key
|
|
1576
|
+
if not todo:
|
|
1577
|
+
return plates, notes
|
|
1578
|
+
started = time.time()
|
|
1579
|
+
spans = sorted((a, b, g, j) for g in todo for a, b, j in groups[g])
|
|
1580
|
+
last = max(b for a, b, _, _ in spans)
|
|
1581
|
+
accs = {g: _PlateAcc(size) for g in todo}
|
|
1582
|
+
reader = M.FrameReader(M.base_cmd(base, base_pre, base_fps, fps, 0, last, size=size, full_size=full_size),
|
|
1583
|
+
size, 3, "ler o corte", queue_len=8)
|
|
1584
|
+
try:
|
|
1585
|
+
f = 0
|
|
1586
|
+
for a, b, g, job in spans:
|
|
1587
|
+
while f < a:
|
|
1588
|
+
if reader.read() is None:
|
|
1589
|
+
break
|
|
1590
|
+
f += 1
|
|
1591
|
+
mreader = M.FrameReader(M.matte_cmd(job.path, size, full_size), size, 1, "ler o recorte", queue_len=8)
|
|
1592
|
+
try:
|
|
1593
|
+
for k in range(b - a):
|
|
1594
|
+
frame = reader.read()
|
|
1595
|
+
alpha = mreader.read()
|
|
1596
|
+
f += 1
|
|
1597
|
+
if frame is None or alpha is None:
|
|
1598
|
+
break
|
|
1599
|
+
if k % PLATE["step"] == 0:
|
|
1600
|
+
accs[g].add(frame, alpha)
|
|
1601
|
+
finally:
|
|
1602
|
+
mreader.close()
|
|
1603
|
+
finally:
|
|
1604
|
+
reader.close()
|
|
1605
|
+
for g, key in todo.items():
|
|
1606
|
+
plate, note = accs[g].finish(key)
|
|
1607
|
+
if plate is not None:
|
|
1608
|
+
plate.save(cache_dir / f"plate_{key}.npz")
|
|
1609
|
+
plates[g] = plate
|
|
1610
|
+
else:
|
|
1611
|
+
notes[g] = note
|
|
1612
|
+
M.save_json(cache_dir / f"plate_{key}.json", {"key": key, "note": note})
|
|
1613
|
+
if progress:
|
|
1614
|
+
progress(f"tela verde: fundo do cenário aprendido em {time.time() - started:.1f}s")
|
|
1615
|
+
return plates, notes
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
# ================================================================== the person layer
|
|
1619
|
+
|
|
1620
|
+
@dataclass
|
|
1621
|
+
class Look:
|
|
1622
|
+
W: int
|
|
1623
|
+
H: int
|
|
1624
|
+
outline: tuple[np.ndarray, float] | None # (rgb float, width px at this size)
|
|
1625
|
+
shadow: bool
|
|
1626
|
+
fade: float # px at this size (the top cut)
|
|
1627
|
+
side_fade: float | None = None # px at this size (a side cut; None = fade)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def _edge_profile(edge: np.ndarray) -> np.ndarray:
|
|
1631
|
+
"""0..1 along one edge of the camera frame: where the body touches it (widened and softened, so the fade
|
|
1632
|
+
has no steps and covers the whole cut)."""
|
|
1633
|
+
cv2 = _cv2()
|
|
1634
|
+
n = edge.size
|
|
1635
|
+
t = (edge > 127).astype(np.float32).reshape(-1, 1)
|
|
1636
|
+
if not t.any():
|
|
1637
|
+
return np.zeros(n, np.float32)
|
|
1638
|
+
k = max(3, n // 40) | 1
|
|
1639
|
+
t = cv2.dilate(t, np.ones((k, 1), np.uint8))
|
|
1640
|
+
if n > 4:
|
|
1641
|
+
t = cv2.GaussianBlur(t, (1, 0), sigmaX=0.1, sigmaY=k / 6.0)
|
|
1642
|
+
return np.clip(t[:, 0], 0, 1)
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def enclosed_gaps(inside: np.ndarray, wall: np.ndarray) -> np.ndarray | None:
|
|
1646
|
+
"""0/1 map of the background pockets of `inside` (0/1) that do not reach the open border, or None. wall marks
|
|
1647
|
+
the edges where the body goes on past the camera frame (the bottom, a cut side): a pocket that only reaches
|
|
1648
|
+
the border through them is closed (the gap between an arm and the body, a hole in a hand)."""
|
|
1649
|
+
cv2 = _cv2()
|
|
1650
|
+
free = ((inside == 0) & (wall == 0)).astype(np.uint8)
|
|
1651
|
+
if not free.any():
|
|
1652
|
+
return None
|
|
1653
|
+
n, lab = cv2.connectedComponents(free, connectivity=4)
|
|
1654
|
+
border = np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]])
|
|
1655
|
+
outside = np.zeros(n, bool)
|
|
1656
|
+
outside[np.unique(border)] = True
|
|
1657
|
+
outside[0] = False
|
|
1658
|
+
if not outside.any():
|
|
1659
|
+
return None # nothing is open (a body filling the frame): leave it as it is
|
|
1660
|
+
holes = (~outside[lab]) & (free > 0)
|
|
1661
|
+
if not holes.any():
|
|
1662
|
+
return None
|
|
1663
|
+
holes = holes.astype(np.uint8)
|
|
1664
|
+
return holes | (wall & (inside == 0) & cv2.dilate(holes, np.ones((3, 3), np.uint8)))
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def compose_person(bg: np.ndarray, person: np.ndarray, alpha: np.ndarray, s: float, x0: float, y0: float,
|
|
1668
|
+
look: Look) -> np.ndarray:
|
|
1669
|
+
"""Draw the cut-out person (scaled by s, camera frame corner at x0, y0) with outline and shadow onto bg."""
|
|
1670
|
+
cv2 = _cv2()
|
|
1671
|
+
W, H = look.W, look.H
|
|
1672
|
+
ow = look.outline[1] if look.outline else 0.0
|
|
1673
|
+
sh = SHADOW
|
|
1674
|
+
k = W / 1080.0
|
|
1675
|
+
pad = int(math.ceil(ow + (3 * sh["blur"] + max(sh["dx"], sh["dy"])) * k if look.shadow else ow)) + 2
|
|
1676
|
+
lw, lh = int(round(W * s)), int(round(H * s))
|
|
1677
|
+
lx, ly = int(round(x0)), int(round(y0))
|
|
1678
|
+
if lw < 4 or lh < 4:
|
|
1679
|
+
return bg
|
|
1680
|
+
X0, Y0 = max(0, lx - pad), max(0, ly - pad)
|
|
1681
|
+
X1, Y1 = min(W, lx + lw + pad), min(H, ly + lh + pad)
|
|
1682
|
+
if X1 - X0 < 2 or Y1 - Y0 < 2:
|
|
1683
|
+
return bg
|
|
1684
|
+
interp = cv2.INTER_AREA if s < 1 else cv2.INTER_LINEAR
|
|
1685
|
+
# only the part of the camera frame that lands on the screen
|
|
1686
|
+
sx0, sx1 = max(0, X0 - lx), min(lw, X1 - lx)
|
|
1687
|
+
sy0, sy1 = max(0, Y0 - ly), min(lh, Y1 - ly)
|
|
1688
|
+
P = cv2.resize(person, (lw, lh), interpolation=interp)[sy0:sy1, sx0:sx1]
|
|
1689
|
+
A = cv2.resize(alpha, (lw, lh), interpolation=interp)[sy0:sy1, sx0:sx1]
|
|
1690
|
+
rw, rh = X1 - X0, Y1 - Y0
|
|
1691
|
+
ox, oy = lx + sx0 - X0, ly + sy0 - Y0 # where P starts inside the region
|
|
1692
|
+
A_reg = np.zeros((rh, rw), np.uint8)
|
|
1693
|
+
A_reg[oy:oy + A.shape[0], ox:ox + A.shape[1]] = A
|
|
1694
|
+
P_reg = np.zeros((rh, rw, 3), np.uint8)
|
|
1695
|
+
P_reg[oy:oy + P.shape[0], ox:ox + P.shape[1]] = P
|
|
1696
|
+
|
|
1697
|
+
# edges where the camera frame cuts the body inside the screen: left, right, top
|
|
1698
|
+
cuts = {}
|
|
1699
|
+
e = max(2, alpha.shape[1] // 270) # a body within this many px of the edge counts as cut by it
|
|
1700
|
+
if lx > 0 and sx0 == 0:
|
|
1701
|
+
cuts["left"] = _edge_profile(alpha[:, :e].max(axis=1))
|
|
1702
|
+
if lx + lw < W and sx1 == lw:
|
|
1703
|
+
cuts["right"] = _edge_profile(alpha[:, -e:].max(axis=1))
|
|
1704
|
+
if ly > 0 and sy0 == 0:
|
|
1705
|
+
cuts["top"] = _edge_profile(alpha[:e, :].max(axis=0))
|
|
1706
|
+
cuts = {name: prof for name, prof in cuts.items() if prof.any()}
|
|
1707
|
+
for name in list(cuts): # the rows/columns of the edge profile map to the layer, then to the region
|
|
1708
|
+
prof = cuts[name]
|
|
1709
|
+
if name in ("left", "right"):
|
|
1710
|
+
full = np.zeros(rh, np.float32)
|
|
1711
|
+
rows = cv2.resize(prof.reshape(-1, 1), (1, lh), interpolation=cv2.INTER_LINEAR)[:, 0][sy0:sy1]
|
|
1712
|
+
full[oy:oy + rows.size] = rows
|
|
1713
|
+
else:
|
|
1714
|
+
full = np.zeros(rw, np.float32)
|
|
1715
|
+
cols = cv2.resize(prof.reshape(1, -1), (lw, 1), interpolation=cv2.INTER_LINEAR)[0][sx0:sx1]
|
|
1716
|
+
full[ox:ox + cols.size] = cols
|
|
1717
|
+
cuts[name] = full
|
|
1718
|
+
|
|
1719
|
+
# outline and shadow at half size; the matte is carried past the bottom edge, so neither draws a line along it
|
|
1720
|
+
# (past a side or top cut the keep mask below removes them)
|
|
1721
|
+
hw, hh = max(2, rw // 2), max(2, rh // 2)
|
|
1722
|
+
Ah = cv2.resize(A_reg, (hw, hh), interpolation=cv2.INTER_AREA).astype(np.float32) / 255.0
|
|
1723
|
+
hox = int(ox // 2)
|
|
1724
|
+
hx1, hy1 = min(hw, int(math.ceil((ox + A.shape[1]) / 2))), min(hh, int(math.ceil((oy + A.shape[0]) / 2)))
|
|
1725
|
+
if hy1 < hh and ly + lh >= H - 1:
|
|
1726
|
+
Ah[hy1:, hox:hx1] = Ah[hy1 - 1:hy1, hox:hx1]
|
|
1727
|
+
D = None
|
|
1728
|
+
gaps = None
|
|
1729
|
+
if look.outline:
|
|
1730
|
+
oh = ow / 2.0
|
|
1731
|
+
sm = cv2.GaussianBlur(Ah, (0, 0), max(0.6, oh / 2.5))
|
|
1732
|
+
inside = ((sm > 0.5) | (Ah > 0.5)).astype(np.uint8)
|
|
1733
|
+
# the outline goes around the body only: a gap it encloses (with the edges the body goes on past) stays
|
|
1734
|
+
# see-through but gets no outline
|
|
1735
|
+
wall = np.zeros((hh, hw), np.uint8)
|
|
1736
|
+
hoy = int(oy // 2)
|
|
1737
|
+
if hy1 > hoy and hx1 > hox:
|
|
1738
|
+
if ly + lh >= H - 1:
|
|
1739
|
+
wall[hy1 - 1, hox:hx1] = 1
|
|
1740
|
+
if "top" in cuts:
|
|
1741
|
+
wall[hoy, hox:hx1] = 1
|
|
1742
|
+
if "left" in cuts:
|
|
1743
|
+
wall[hoy:hy1, hox] = 1
|
|
1744
|
+
elif lx <= 0:
|
|
1745
|
+
wall[hoy:hy1, 0] = 1
|
|
1746
|
+
if "right" in cuts:
|
|
1747
|
+
wall[hoy:hy1, hx1 - 1] = 1
|
|
1748
|
+
elif lx + lw >= W:
|
|
1749
|
+
wall[hoy:hy1, hw - 1] = 1
|
|
1750
|
+
gaps = enclosed_gaps(inside, wall)
|
|
1751
|
+
if gaps is not None:
|
|
1752
|
+
inside = inside | gaps
|
|
1753
|
+
dist = cv2.distanceTransform(1 - inside, cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
|
|
1754
|
+
Dh = np.clip(oh + 0.5 - dist, 0, 1).astype(np.float32)
|
|
1755
|
+
Dh = cv2.GaussianBlur(Dh, (0, 0), 0.6)
|
|
1756
|
+
D = cv2.resize(Dh, (rw, rh), interpolation=cv2.INTER_LINEAR)
|
|
1757
|
+
S = None
|
|
1758
|
+
if look.shadow:
|
|
1759
|
+
src = Dh if D is not None else Ah
|
|
1760
|
+
q = cv2.resize(src, (max(2, hw // 2), max(2, hh // 2)), interpolation=cv2.INTER_AREA)
|
|
1761
|
+
q = cv2.GaussianBlur(q, (0, 0), max(0.5, sh["blur"] * k / 4.0))
|
|
1762
|
+
S = cv2.resize(q, (rw, rh), interpolation=cv2.INTER_LINEAR)
|
|
1763
|
+
mshift = np.float32([[1, 0, sh["dx"] * k], [0, 1, sh["dy"] * k]])
|
|
1764
|
+
S = cv2.warpAffine(S, mshift, (rw, rh), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
|
1765
|
+
S *= sh["alpha"]
|
|
1766
|
+
if gaps is not None:
|
|
1767
|
+
# an enclosed gap shows the background as it is: no outline color, no shadow in it (2 px wider, so the
|
|
1768
|
+
# soft edge of the body around it blends with the background, not with the outline)
|
|
1769
|
+
g = cv2.resize(cv2.dilate(gaps, np.ones((5, 5), np.uint8)).astype(np.float32), (rw, rh),
|
|
1770
|
+
interpolation=cv2.INTER_LINEAR)
|
|
1771
|
+
gap_keep = 1.0 - g
|
|
1772
|
+
if S is not None:
|
|
1773
|
+
S = S * gap_keep
|
|
1774
|
+
|
|
1775
|
+
# keep mask: short fade on the cut rows near a cut edge, nothing past it
|
|
1776
|
+
K = None
|
|
1777
|
+
if cuts:
|
|
1778
|
+
top_fade = max(2.0, look.fade)
|
|
1779
|
+
side_fade = max(2.0, look.side_fade if look.side_fade is not None else look.fade)
|
|
1780
|
+
K = np.ones((rh, rw), np.float32)
|
|
1781
|
+
xs = np.arange(rw, dtype=np.float32)
|
|
1782
|
+
ys = np.arange(rh, dtype=np.float32)
|
|
1783
|
+
|
|
1784
|
+
def ramp(d: np.ndarray, fade: float) -> np.ndarray:
|
|
1785
|
+
r = np.clip(d / fade, 0, 1)
|
|
1786
|
+
return r * r * (3 - 2 * r)
|
|
1787
|
+
if "left" in cuts:
|
|
1788
|
+
K *= 1.0 - cuts["left"][:, None] * (1.0 - ramp(xs - float(ox), side_fade)[None, :])
|
|
1789
|
+
if "right" in cuts:
|
|
1790
|
+
K *= 1.0 - cuts["right"][:, None] * (1.0 - ramp(float(ox + A.shape[1]) - xs, side_fade)[None, :])
|
|
1791
|
+
if "top" in cuts:
|
|
1792
|
+
K *= 1.0 - cuts["top"][None, :] * (1.0 - ramp(ys - float(oy), top_fade)[:, None])
|
|
1793
|
+
|
|
1794
|
+
region0 = bg[Y0:Y1, X0:X1]
|
|
1795
|
+
region = region0
|
|
1796
|
+
Af = A_reg.astype(np.float32) / 255.0
|
|
1797
|
+
if D is not None:
|
|
1798
|
+
D = np.maximum(D, Af)
|
|
1799
|
+
if gaps is not None:
|
|
1800
|
+
D *= gap_keep
|
|
1801
|
+
if S is not None:
|
|
1802
|
+
region = cv2.blendLinear(region, np.zeros_like(region), 1.0 - S, S)
|
|
1803
|
+
if D is not None:
|
|
1804
|
+
color = np.empty_like(region)
|
|
1805
|
+
color[:] = look.outline[0].astype(np.uint8)
|
|
1806
|
+
region = cv2.blendLinear(region, color, 1.0 - D, D)
|
|
1807
|
+
region = cv2.blendLinear(region, P_reg, 1.0 - Af, Af)
|
|
1808
|
+
if K is not None:
|
|
1809
|
+
# shadow, outline and person fade together (the outline never shows through a fading arm)
|
|
1810
|
+
region = cv2.blendLinear(region0, region, 1.0 - K, K)
|
|
1811
|
+
bg[Y0:Y1, X0:X1] = region
|
|
1812
|
+
return bg
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def without_room(plate: RoomPlate, frame: np.ndarray, alpha: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
1816
|
+
"""(frame, matte) of a camera frame with the room its take's plate recognizes taken out of the matte and out
|
|
1817
|
+
of the colors of its soft edge (uint8, camera frame size)."""
|
|
1818
|
+
cv2 = _cv2()
|
|
1819
|
+
h, w = alpha.shape
|
|
1820
|
+
pw, ph = plate.size
|
|
1821
|
+
if (w, h) != (pw, ph):
|
|
1822
|
+
small = cv2.resize(frame, (pw, ph), interpolation=cv2.INTER_AREA)
|
|
1823
|
+
a = cv2.resize(alpha, (pw, ph), interpolation=cv2.INTER_AREA)
|
|
1824
|
+
else:
|
|
1825
|
+
small, a = frame, alpha
|
|
1826
|
+
e = plate.evidence(small, a.astype(np.float32) / 255.0)
|
|
1827
|
+
if e is not None:
|
|
1828
|
+
if (w, h) != (pw, ph):
|
|
1829
|
+
e = cv2.resize(e, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
1830
|
+
alpha = (alpha.astype(np.float32) * (1.0 - e) + 0.5).astype(np.uint8)
|
|
1831
|
+
return plate.clean_edge(frame, alpha), alpha
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def zoom_original(frame: np.ndarray, z: float, focus_y: float) -> np.ndarray:
|
|
1835
|
+
"""The camera zoom of compose_final (crop centered on x, eye line kept) on a full frame."""
|
|
1836
|
+
if abs(z - 1.0) < 1e-4:
|
|
1837
|
+
return frame
|
|
1838
|
+
cv2 = _cv2()
|
|
1839
|
+
H, W = frame.shape[:2]
|
|
1840
|
+
top = H * focus_y * (1 - 1 / z)
|
|
1841
|
+
left = W * 0.5 * (1 - 1 / z)
|
|
1842
|
+
m = np.array([[z, 0.0, -left * z], [0.0, z, -top * z]], np.float32)
|
|
1843
|
+
return cv2.warpAffine(frame, m, (W, H), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
# ================================================================== the clip
|
|
1847
|
+
|
|
1848
|
+
@dataclass
|
|
1849
|
+
class Job:
|
|
1850
|
+
cfg: Config
|
|
1851
|
+
segments: list[Segment]
|
|
1852
|
+
mattes: dict[int, "M.RangeJob"] # segment index -> matte job
|
|
1853
|
+
base: Path
|
|
1854
|
+
base_pre: list[str]
|
|
1855
|
+
base_fps: float
|
|
1856
|
+
fps: int
|
|
1857
|
+
size: tuple[int, int] # render size
|
|
1858
|
+
full_size: tuple[int, int] # output size (the mattes are stored at it)
|
|
1859
|
+
nf: int
|
|
1860
|
+
out_dir: Path
|
|
1861
|
+
extra_key: list = field(default_factory=list)
|
|
1862
|
+
plates: dict[int, RoomPlate] = field(default_factory=dict) # segment index -> the room plate of its take
|
|
1863
|
+
|
|
1864
|
+
def key(self) -> str:
|
|
1865
|
+
blob = [TV_VERSION, M.engine_version(), str(self.base), M.file_signature(self.base), self.base_pre,
|
|
1866
|
+
self.fps, self.size, self.full_size, self.nf, self.cfg.position, round(self.cfg.height, 5),
|
|
1867
|
+
self.cfg.outline and [list(self.cfg.outline[0]), self.cfg.outline[1]], self.cfg.shadow,
|
|
1868
|
+
self.cfg.blurred_original, self.cfg.no_face, self.cfg.mirror, self.cfg.runs, EDGE_FADE, SIDE_FADE,
|
|
1869
|
+
[b.signature() for b in self.cfg.backgrounds], LOOP_FADE, KB_HOLD_AFTER, EXTEND, BACKDROP,
|
|
1870
|
+
[[s.f0, s.f1, s.mode, round(s.zoom, 5), round(s.push, 5), round(s.scale, 6), s.flush_left,
|
|
1871
|
+
s.flush_right, round(s.focus_y, 5), s.stats, s.side, s.fill, s.drop] for s in self.segments],
|
|
1872
|
+
{k: j.key for k, j in self.mattes.items()}, {k: p.key for k, p in self.plates.items()},
|
|
1873
|
+
self.extra_key]
|
|
1874
|
+
return hashlib.sha1(json.dumps(blob, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16]
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def _encoder(tmp: Path, W: int, H: int, fps: int, n: int) -> subprocess.Popen:
|
|
1878
|
+
return subprocess.Popen(
|
|
1879
|
+
M._ff() + ["-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-framerate", str(fps), "-i", "-",
|
|
1880
|
+
"-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
|
|
1881
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-g", str(fps),
|
|
1882
|
+
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
|
|
1883
|
+
"-frames:v", str(n), "-f", "mp4", str(tmp)],
|
|
1884
|
+
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def render(job: Job, progress: Callable[[str], None] | None = None, workers: int | None = None) -> dict:
|
|
1888
|
+
"""Write the tela verde clip (cached). -> {file, cached, seconds, frames, key}."""
|
|
1889
|
+
started = time.time()
|
|
1890
|
+
W, H = job.size
|
|
1891
|
+
key = job.key()
|
|
1892
|
+
job.out_dir.mkdir(parents=True, exist_ok=True)
|
|
1893
|
+
out = job.out_dir / f"tv_{key}.mp4"
|
|
1894
|
+
meta_path = out.with_suffix(".json")
|
|
1895
|
+
if out.exists() and meta_path.exists():
|
|
1896
|
+
meta = M.load_json(meta_path) or {}
|
|
1897
|
+
if meta.get("key") == key and out.stat().st_size > 0:
|
|
1898
|
+
return {**meta, "file": str(out), "cached": True, "render_seconds": round(time.time() - started, 2)}
|
|
1899
|
+
cfg = job.cfg
|
|
1900
|
+
k = W / 1080.0
|
|
1901
|
+
look = Look(W=W, H=H, shadow=cfg.shadow, fade=EDGE_FADE * k, side_fade=SIDE_FADE * k,
|
|
1902
|
+
outline=(np.array(cfg.outline[0], np.float32), cfg.outline[1] * k) if cfg.outline else None)
|
|
1903
|
+
base_reader = M.FrameReader(M.base_cmd(job.base, job.base_pre, job.base_fps, job.fps, 0, job.nf, size=(W, H),
|
|
1904
|
+
full_size=job.full_size), (W, H), 3, "ler o corte", queue_len=8)
|
|
1905
|
+
bgs = BackgroundTrack(cfg, W, H, job.fps)
|
|
1906
|
+
tmp = out.with_name(".tmp-" + out.name)
|
|
1907
|
+
enc = _encoder(tmp, W, H, job.fps, job.nf)
|
|
1908
|
+
workers = workers or max(2, min(6, (os.cpu_count() or 4) // 2))
|
|
1909
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
1910
|
+
pending: deque = deque()
|
|
1911
|
+
written = 0
|
|
1912
|
+
last_frame = None
|
|
1913
|
+
last_print = time.time()
|
|
1914
|
+
|
|
1915
|
+
def flush(block: bool) -> None:
|
|
1916
|
+
nonlocal written
|
|
1917
|
+
while pending and (block or pending[0].done()):
|
|
1918
|
+
fr = pending.popleft().result()
|
|
1919
|
+
enc.stdin.write(fr.tobytes())
|
|
1920
|
+
written += 1
|
|
1921
|
+
|
|
1922
|
+
def person_frame(make_bg, person, alpha, seg, f, plate):
|
|
1923
|
+
s, x0, y0 = layer_rect(cfg, seg, seg.zoom_at(f), W, H)
|
|
1924
|
+
if plate is not None:
|
|
1925
|
+
person, alpha = without_room(plate, person, alpha)
|
|
1926
|
+
if cfg.mirror:
|
|
1927
|
+
cv2 = _cv2()
|
|
1928
|
+
person, alpha = cv2.flip(person, 1), cv2.flip(alpha, 1)
|
|
1929
|
+
return compose_person(make_bg(), person, alpha, s, x0, y0, look)
|
|
1930
|
+
|
|
1931
|
+
try:
|
|
1932
|
+
f = 0
|
|
1933
|
+
for si, seg in enumerate(job.segments):
|
|
1934
|
+
mreader = None
|
|
1935
|
+
mjob = job.mattes.get(si)
|
|
1936
|
+
plate = job.plates.get(si)
|
|
1937
|
+
if seg.mode == "pessoa" and mjob is not None:
|
|
1938
|
+
mreader = M.FrameReader(M.matte_cmd(mjob.path, (W, H), job.full_size), (W, H), 1, "ler o recorte",
|
|
1939
|
+
queue_len=8)
|
|
1940
|
+
last_alpha = None
|
|
1941
|
+
try:
|
|
1942
|
+
for f in range(seg.f0, seg.f1):
|
|
1943
|
+
frame = base_reader.read()
|
|
1944
|
+
if frame is None:
|
|
1945
|
+
frame = last_frame
|
|
1946
|
+
if frame is None:
|
|
1947
|
+
raise TelaVerdeError("não consegui ler o corte para montar a tela verde", 1)
|
|
1948
|
+
last_frame = frame
|
|
1949
|
+
if seg.mode == "original":
|
|
1950
|
+
z, fy = seg.zoom_at(f), seg.focus_y
|
|
1951
|
+
pending.append(pool.submit(zoom_original, frame, z, fy))
|
|
1952
|
+
else:
|
|
1953
|
+
make_bg = bgs.fetch(f, frame)
|
|
1954
|
+
alpha = mreader.read() if mreader is not None else None
|
|
1955
|
+
if alpha is None:
|
|
1956
|
+
alpha = last_alpha
|
|
1957
|
+
if alpha is None:
|
|
1958
|
+
pending.append(pool.submit(make_bg))
|
|
1959
|
+
else:
|
|
1960
|
+
last_alpha = alpha
|
|
1961
|
+
pending.append(pool.submit(person_frame, make_bg, frame, alpha, seg, f, plate))
|
|
1962
|
+
flush(len(pending) >= 2 * workers)
|
|
1963
|
+
now = time.time()
|
|
1964
|
+
if progress and now - last_print >= M.PROGRESS_EVERY:
|
|
1965
|
+
last_print = now
|
|
1966
|
+
progress(f"tela verde: montando {100.0 * (f + 1) / job.nf:.0f}%")
|
|
1967
|
+
finally:
|
|
1968
|
+
if mreader is not None:
|
|
1969
|
+
mreader.close()
|
|
1970
|
+
flush(True)
|
|
1971
|
+
except BrokenPipeError:
|
|
1972
|
+
pass
|
|
1973
|
+
finally:
|
|
1974
|
+
pool.shutdown(wait=True)
|
|
1975
|
+
base_reader.close()
|
|
1976
|
+
bgs.close()
|
|
1977
|
+
try:
|
|
1978
|
+
enc.stdin.close()
|
|
1979
|
+
except OSError:
|
|
1980
|
+
pass
|
|
1981
|
+
err = enc.stderr.read().decode("utf-8", "replace") if enc.stderr else ""
|
|
1982
|
+
code = enc.wait()
|
|
1983
|
+
if code != 0 or written != job.nf:
|
|
1984
|
+
tmp.unlink(missing_ok=True)
|
|
1985
|
+
raise TelaVerdeError(f"falha ao gravar a tela verde ({written} de {job.nf} quadros): {err.strip()[-300:]}", 1)
|
|
1986
|
+
os.replace(tmp, out)
|
|
1987
|
+
seconds = time.time() - started
|
|
1988
|
+
meta = {"key": key, "frames": job.nf, "size": [W, H], "fps": job.fps, "seconds": round(seconds, 2),
|
|
1989
|
+
"created_at": M.now_iso()}
|
|
1990
|
+
M.save_json(meta_path, meta)
|
|
1991
|
+
return {**meta, "file": str(out), "cached": False, "render_seconds": round(seconds, 2)}
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def plan_stages(cfg: Config, segs: list[Segment], W: int, H: int,
|
|
1995
|
+
reserve: Callable[[list[tuple[int, int]]], float] | None = None) -> None:
|
|
1996
|
+
"""Place every background shown whole (encaixe inteiro). Its stage is the band from STAGE_TOP to a little
|
|
1997
|
+
above the highest head top of the person segments it is shown with; when auto captions play over it and
|
|
1998
|
+
the picture keeps at least RESERVE_KEEP of the size it would have without them, the stage leaves room for
|
|
1999
|
+
them right above the head. Needs cfg.runs; sets b.place and b.stage (render px)."""
|
|
2000
|
+
gap = STAGE_GAP * H
|
|
2001
|
+
top = STAGE_TOP * H
|
|
2002
|
+
for k, b in enumerate(cfg.backgrounds):
|
|
2003
|
+
b.place, b.stage = None, None
|
|
2004
|
+
if b.fit != "inteiro" or not b.size:
|
|
2005
|
+
continue
|
|
2006
|
+
spans = [(f0, f1) for f0, f1, kk in cfg.runs if kk == k]
|
|
2007
|
+
if not spans:
|
|
2008
|
+
continue
|
|
2009
|
+
heads = []
|
|
2010
|
+
for s in segs:
|
|
2011
|
+
if s.mode != "pessoa" or not s.stats or not s.stats.get("person_frames"):
|
|
2012
|
+
continue
|
|
2013
|
+
if not any(s.f0 < b1 and a0 < s.f1 for a0, b1 in spans):
|
|
2014
|
+
continue
|
|
2015
|
+
z = screen_zones(cfg, s, W, H)
|
|
2016
|
+
if z["person"]:
|
|
2017
|
+
heads.append(z["person"]["y0"] * H)
|
|
2018
|
+
head = min(heads) if heads else H * (1.0 - cfg.height)
|
|
2019
|
+
bottom = max(top + 0.1 * H, head - gap)
|
|
2020
|
+
iw, ih = b.size
|
|
2021
|
+
need = (b.content[3] - b.content[1]) * ih * W / iw # main area height at the full width
|
|
2022
|
+
room = reserve(spans) if reserve else 0.0
|
|
2023
|
+
# a video with a plain border melts into the screen: it stays wide (down to QUIET_MIN, to leave the
|
|
2024
|
+
# captions their band; what still does not fit above the head goes behind the captions and the person)
|
|
2025
|
+
# instead of becoming a small box
|
|
2026
|
+
least = QUIET_MIN if (b.kind == "video" and b.quiet) else WHOLE_MIN
|
|
2027
|
+
|
|
2028
|
+
def size(h: float) -> float:
|
|
2029
|
+
return min(need, max(h, least * need))
|
|
2030
|
+
if room > 0 and size(bottom - room - top) >= RESERVE_KEEP * size(bottom - top):
|
|
2031
|
+
bottom -= room
|
|
2032
|
+
b.stage = (round(top, 1), round(bottom, 1))
|
|
2033
|
+
b.place = whole_place(b.size, b.content, b.stage, W, H, least)
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def image_zones(cfg: Config, segs: list[Segment], W: int, H: int) -> list[tuple[int, int, tuple]]:
|
|
2037
|
+
"""(f0, f1, rect) where the main area of a whole picture is on the screen (render px), outside the segments
|
|
2038
|
+
shown as recorded (the background is not seen there)."""
|
|
2039
|
+
out = []
|
|
2040
|
+
shown_as_recorded = [(s.f0, s.f1) for s in segs if s.mode == "original"]
|
|
2041
|
+
for f0, f1, k in cfg.runs:
|
|
2042
|
+
if k is None:
|
|
2043
|
+
continue
|
|
2044
|
+
rect = cfg.backgrounds[k].content_rect(W, H)
|
|
2045
|
+
if rect is None:
|
|
2046
|
+
continue
|
|
2047
|
+
pieces = [(f0, f1)]
|
|
2048
|
+
for a, b in shown_as_recorded:
|
|
2049
|
+
nxt = []
|
|
2050
|
+
for p0, p1 in pieces:
|
|
2051
|
+
if b <= p0 or a >= p1:
|
|
2052
|
+
nxt.append((p0, p1))
|
|
2053
|
+
continue
|
|
2054
|
+
if p0 < a:
|
|
2055
|
+
nxt.append((p0, a))
|
|
2056
|
+
if b < p1:
|
|
2057
|
+
nxt.append((b, p1))
|
|
2058
|
+
pieces = nxt
|
|
2059
|
+
out += [(p0, p1, rect) for p0, p1 in pieces if p1 > p0]
|
|
2060
|
+
return out
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def report_backgrounds(cfg: Config, fps: int, W: int, H: int, scale_to_out: float,
|
|
2064
|
+
rel: Callable[[Path], str]) -> list[dict]:
|
|
2065
|
+
"""The background runs for the report (what shows when, from fundo or from an insert, and where)."""
|
|
2066
|
+
out = []
|
|
2067
|
+
for f0, f1, k in cfg.runs:
|
|
2068
|
+
b = cfg.backgrounds[k] if k is not None else None
|
|
2069
|
+
row = {"start": round(f0 / fps, 3), "end": round(f1 / fps, 3),
|
|
2070
|
+
"src": (rel(b.src) if b and b.src else (b.asked if b else
|
|
2071
|
+
("original desfocado" if cfg.blurred_original else DARK_DEFAULT))),
|
|
2072
|
+
"item": (b.idx if b and not b.insert else None), "insert": (b.insert if b else None),
|
|
2073
|
+
"kind": b.kind if b else ("original" if cfg.blurred_original else "color"),
|
|
2074
|
+
"encaixe": b.fit if b else None,
|
|
2075
|
+
"ken_burns": b.ken_burns if b else False, "escurecer": b.darken if b else None,
|
|
2076
|
+
"desfocar": b.blur if b else None}
|
|
2077
|
+
if b and b.place:
|
|
2078
|
+
row["rect"] = [int(round(v * scale_to_out)) for v in b.place]
|
|
2079
|
+
row["content"] = [int(round(v * scale_to_out)) for v in b.content_rect(W, H)]
|
|
2080
|
+
if b and b.kind == "video":
|
|
2081
|
+
row["borda_lisa"] = b.quiet
|
|
2082
|
+
row["quadros_vazios"] = {"inicio": b.lead, "fim": b.tail}
|
|
2083
|
+
out.append(row)
|
|
2084
|
+
return out
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
def report_segments(cfg: Config, segs: list[Segment], fps: int, W: int, H: int, scale_to_out: float) -> list[dict]:
|
|
2088
|
+
out = []
|
|
2089
|
+
for s in segs:
|
|
2090
|
+
row = {"start": round(s.f0 / fps, 3), "end": round(s.f1 / fps, 3), "mode": s.mode}
|
|
2091
|
+
if s.mode == "pessoa" and cfg.mirror:
|
|
2092
|
+
row["espelhado"] = True
|
|
2093
|
+
if s.side:
|
|
2094
|
+
row["posicao"] = s.side
|
|
2095
|
+
if s.fill:
|
|
2096
|
+
row["largura_toda"] = True
|
|
2097
|
+
row["corte_embaixo"] = s.drop
|
|
2098
|
+
if s.mode == "pessoa" and s.stats and s.stats.get("person_frames"):
|
|
2099
|
+
sc, x0, y0 = layer_rect(cfg, s, s.zoom, W, H)
|
|
2100
|
+
lw = W * sc
|
|
2101
|
+
row.update({"scale": round(sc, 4), "x": int(round(x0 * scale_to_out)), "y": int(round(y0 * scale_to_out)),
|
|
2102
|
+
"zoom": s.zoom, "push_in": s.push,
|
|
2103
|
+
"on_screen_edge": [n for n, on in (("esquerda", s.flush_left), ("direita", s.flush_right)) if on],
|
|
2104
|
+
"height_share": round((H - (y0 + float(s.stats.get("top", 0)) * H * sc)) / H, 3),
|
|
2105
|
+
"width_share": round((float(s.stats.get("right", 1)) - float(s.stats.get("left", 0))) * lw / W, 3),
|
|
2106
|
+
"cut_edge_inside": [side for side, cond in (("esquerda", s.stats.get("touch_left", 0) > 0.05 and x0 > 0.5),
|
|
2107
|
+
("direita", s.stats.get("touch_right", 0) > 0.05 and x0 + lw < W - 0.5))
|
|
2108
|
+
if cond]})
|
|
2109
|
+
if s.note:
|
|
2110
|
+
row["note"] = s.note
|
|
2111
|
+
out.append(row)
|
|
2112
|
+
return out
|
|
2113
|
+
|