- reorganizado o fluxo de edição para validar a análise do Scanner, selecionar o tipo de vídeo e enviar suas instruções no JSON. - banco de análises: métricas de fala viraram colunas, busca lexical FTS5, enunciados com embeddings, views achatadas de leitura (fala/palavra/linha do tempo/fala com visual), ingestor do pipeline de voz e gravação idempotente de evidências visuais e cenas. - retakes passam a ser gravados no banco de análises (SQLite) em vez de JSON por caso, com status de revisão persistido. - planos de edição e suas aplicações passam a ser registrados no banco, em vez de se perderem no arquivo temporário. - comando de limpeza das evidências visuais e cenas duplicadas por execuções antigas do Scanner. - análise visual: OpenCV (rostos) e PySceneDetect passam a entrar no detector local por padrão; novo adapter InsightFace gera assinatura facial (embedding) para reconhecer a mesma pessoa entre tomadas, gravada como evidência visual no banco. - corrigido: métricas de fala (energia, pitch, velocidade) agora gravam nas colunas dedicadas, não só no JSON; a view fala+visual passa a casar por vídeo e tempo, já que o mesmo arquivo entra na timeline como clipes distintos de vídeo e áudio; views são recriadas a cada abertura do banco para uma correção de consulta chegar a bancos já existentes. - reordenadas as abas do painel CEP para Scanner, Refinar e Editar vídeo Resumo: - 24 arquivos alterados - 9 novos - 15 modificados - 0 removidos 15 files changed, 872 insertions(+), 29 deletions(-) Arquivos: - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/aplicar_plano_de_edicao.py - code/engine/integracoes/visual/README.md - code/engine/integracoes/visual/__init__.py - code/engine/integracoes/visual/analisadores.py - code/engine/persistencia/__init__.py - code/engine/persistencia/esquema.py - code/engine/persistencia/repositorio_de_analises_sqlite.py - code/engine/persistencia/repositorio_de_retakes_sqlite.py - code/engine/requirements-visual.txt - code/engine/scanner/configuracao_visual.py - code/engine/scanner/visual.py - code/engine/testes/test_analise_visual_local.py - .jhonny/analises.db.pos-scanner-084841 - code/engine/integracoes/embeddings/ - code/engine/limpar_duplicatas.py - code/engine/persistencia/busca_de_conteudo.py - code/engine/persistencia/enunciados.py - code/engine/persistencia/ingestao_de_voz.py - code/engine/persistencia/limpeza.py - code/engine/persistencia/repositorio_de_planos.py - code/engine/testes/test_busca_de_conteudo.py
453 lines
22 KiB
Python
453 lines
22 KiB
Python
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
from typing import Any, Callable
|
|
|
|
from ...scanner.modelos import EvidenciaVisual
|
|
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
|
|
from .modelos import QuadroDeVideo
|
|
|
|
|
|
class AnalisadorDeQualidadeOpenCV(AnalisadorDeFrame):
|
|
"""Mede nitidez, brilho e contraste de cada frame usando OpenCV."""
|
|
|
|
nome = "opencv"
|
|
|
|
def __init__(self, cv2_module: Any | None = None) -> None:
|
|
self._cv2 = cv2_module
|
|
|
|
@property
|
|
def cv2(self) -> Any:
|
|
if self._cv2 is None:
|
|
try:
|
|
import cv2
|
|
except ImportError as exc:
|
|
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
|
self._cv2 = cv2
|
|
return self._cv2
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
imagem = quadro.imagem
|
|
cinza = self.cv2.cvtColor(imagem, self.cv2.COLOR_BGR2GRAY) if len(imagem.shape) == 3 else imagem
|
|
media, desvio = self.cv2.meanStdDev(cinza)
|
|
nitidez = float(self.cv2.Laplacian(cinza, self.cv2.CV_64F).var())
|
|
valor = {
|
|
"largura": quadro.largura,
|
|
"altura": quadro.altura,
|
|
"brilho": float(media[0][0]),
|
|
"contraste": float(desvio[0][0]),
|
|
"nitidez_laplaciana": nitidez,
|
|
}
|
|
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp, valor,
|
|
provider=self.nome)]
|
|
|
|
|
|
class _AdapterOpenCV:
|
|
"""Implementação compartilhada para adapters OpenCV, com importação tardia."""
|
|
|
|
def __init__(self, cv2_module: Any | None = None) -> None:
|
|
self._cv2 = cv2_module
|
|
|
|
@property
|
|
def cv2(self) -> Any:
|
|
if self._cv2 is None:
|
|
try:
|
|
import cv2
|
|
except ImportError as exc:
|
|
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
|
self._cv2 = cv2
|
|
return self._cv2
|
|
|
|
|
|
class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
|
"""Encontra rostos e olhos localmente; não infere identidade nem emoção."""
|
|
|
|
nome = "opencv_haar"
|
|
|
|
def __init__(self, escala: float = 1.1, vizinhos_minimos: int = 5,
|
|
diretorio_de_modelos: str | Path | None = None,
|
|
tamanho_minimo_relativo: float = 0.04,
|
|
cv2_module: Any | None = None) -> None:
|
|
super().__init__(cv2_module)
|
|
self.escala = escala
|
|
self.vizinhos_minimos = vizinhos_minimos
|
|
self.diretorio_de_modelos = Path(diretorio_de_modelos) if diretorio_de_modelos else None
|
|
self.tamanho_minimo_relativo = tamanho_minimo_relativo
|
|
self._detectores: tuple[Any, Any] | None = None
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
detector_de_rostos, detector_de_olhos = self._obter_detectores()
|
|
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
|
rostos = detector_de_rostos.detectMultiScale(cinza, scaleFactor=self.escala,
|
|
minNeighbors=self.vizinhos_minimos)
|
|
evidencias: list[EvidenciaVisual] = []
|
|
for x, y, largura, altura in rostos:
|
|
caixa = _caixa_normalizada(x, y, largura, altura, quadro.largura, quadro.altura)
|
|
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
|
|
continue
|
|
rosto = EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
|
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)}, provider=self.nome)
|
|
olhos = detector_de_olhos.detectMultiScale(cinza[y:y + altura, x:x + largura],
|
|
scaleFactor=1.1, minNeighbors=4)
|
|
evidencias.extend((rosto, EvidenciaVisual("olhos_visiveis", quadro.timestamp, quadro.timestamp,
|
|
{"quantidade": len(olhos), "rosto": caixa}, provider=self.nome)))
|
|
return evidencias
|
|
|
|
def _obter_detectores(self) -> tuple[Any, Any]:
|
|
if self._detectores is None:
|
|
base = self.diretorio_de_modelos or Path(self.cv2.data.haarcascades)
|
|
rostos = self.cv2.CascadeClassifier(str(base / "haarcascade_frontalface_default.xml"))
|
|
olhos = self.cv2.CascadeClassifier(str(base / "haarcascade_eye.xml"))
|
|
if rostos.empty() or olhos.empty():
|
|
raise RuntimeError(f"Cascades Haar do OpenCV não estão disponíveis em: {base}")
|
|
self._detectores = rostos, olhos
|
|
return self._detectores
|
|
|
|
|
|
class AnalisadorDeRostosInsightFace(AnalisadorDeFrame):
|
|
"""Detecta rostos e extrai a assinatura facial (embedding) com InsightFace.
|
|
|
|
Complementa `AnalisadorDeRostosOpenCV` e o Apple Vision: ambos encontram
|
|
o rosto, mas nenhum reconhece que dois rostos em clipes diferentes são a
|
|
mesma pessoa. A assinatura facial normalizada é o fato que a camada
|
|
editorial usa depois para agrupar tomadas pela pessoa em cena.
|
|
"""
|
|
|
|
nome = "insightface"
|
|
|
|
def __init__(self, modelo: str = "buffalo_l", tamanho_de_deteccao: tuple[int, int] = (640, 640),
|
|
usar_coreml: bool = True, tamanho_minimo_relativo: float = 0.04,
|
|
app: Any | None = None) -> None:
|
|
if tamanho_minimo_relativo < 0 or tamanho_minimo_relativo >= 1:
|
|
raise ValueError("tamanho_minimo_relativo deve estar entre 0 e 1.")
|
|
self.modelo = modelo
|
|
self.tamanho_de_deteccao = tamanho_de_deteccao
|
|
self.usar_coreml = usar_coreml
|
|
self.tamanho_minimo_relativo = tamanho_minimo_relativo
|
|
self._app = app
|
|
|
|
@property
|
|
def app(self) -> Any:
|
|
"""Instância preparada do `FaceAnalysis`, criada de forma tardia."""
|
|
if self._app is None:
|
|
try:
|
|
from insightface.app import FaceAnalysis
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"InsightFace não está instalado. Instale insightface e onnxruntime."
|
|
) from exc
|
|
self._app = self._preparar_app(FaceAnalysis)
|
|
return self._app
|
|
|
|
def _preparar_app(self, classe_face_analysis: Any) -> Any:
|
|
"""Monta o `FaceAnalysis` com os providers do ONNX Runtime disponíveis."""
|
|
try:
|
|
import onnxruntime
|
|
except ImportError as exc:
|
|
raise RuntimeError("InsightFace requer onnxruntime instalado.") from exc
|
|
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if self.usar_coreml \
|
|
else ["CPUExecutionProvider"]
|
|
disponiveis = set(onnxruntime.get_available_providers())
|
|
providers = [item for item in preferidos if item in disponiveis] or ["CPUExecutionProvider"]
|
|
app = classe_face_analysis(name=self.modelo, providers=providers)
|
|
app.prepare(ctx_id=0, det_size=self.tamanho_de_deteccao)
|
|
return app
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
"""Devolve uma evidência de rosto e, quando possível, sua assinatura facial."""
|
|
rostos = self.app.get(self._obter_imagem(quadro))
|
|
evidencias: list[EvidenciaVisual] = []
|
|
for rosto in rostos:
|
|
caixa = _caixa_normalizada_do_retangulo(rosto.bbox, quadro.largura, quadro.altura)
|
|
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
|
|
continue
|
|
confianca = float(rosto.det_score) if getattr(rosto, "det_score", None) is not None else None
|
|
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
|
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)},
|
|
confianca=confianca, provider=self.nome, modelo=self.modelo))
|
|
evidencia_de_identidade = self._evidencia_de_identidade(rosto, quadro, caixa, confianca)
|
|
if evidencia_de_identidade is not None:
|
|
evidencias.append(evidencia_de_identidade)
|
|
return evidencias
|
|
|
|
def _obter_imagem(self, quadro: QuadroDeVideo) -> Any:
|
|
"""Devolve a matriz BGR do frame, decodificando o arquivo quando necessário.
|
|
|
|
O extrator local (OpenCV) já entrega o frame decodificado em
|
|
``quadro.imagem``. O extrator usado com o Apple Vision persiste PNGs
|
|
e mantém ``imagem`` vazio para não carregar OpenCV nesse processo; o
|
|
InsightFace precisa do array decodificado, então lê o PNG nesse caso.
|
|
"""
|
|
if quadro.imagem is not None:
|
|
return quadro.imagem
|
|
if quadro.caminho is None:
|
|
raise ValueError("InsightFace requer o frame decodificado ou persistido em disco.")
|
|
try:
|
|
import cv2
|
|
except ImportError as exc:
|
|
raise RuntimeError("Ler o frame do disco para o InsightFace requer OpenCV.") from exc
|
|
imagem = cv2.imread(str(quadro.caminho))
|
|
if imagem is None:
|
|
raise RuntimeError(f"Não foi possível ler o frame do InsightFace: {quadro.caminho}")
|
|
return imagem
|
|
|
|
def _evidencia_de_identidade(self, rosto: Any, quadro: QuadroDeVideo, caixa: dict[str, float],
|
|
confianca: float | None) -> EvidenciaVisual | None:
|
|
embedding = getattr(rosto, "normed_embedding", None)
|
|
if embedding is None:
|
|
return None
|
|
valor: dict[str, Any] = {
|
|
"bounding_box": caixa,
|
|
"assinatura_facial": [float(item) for item in embedding],
|
|
"dimensoes": len(embedding),
|
|
}
|
|
idade = getattr(rosto, "age", None)
|
|
if idade is not None:
|
|
valor["idade_aproximada"] = int(idade)
|
|
genero = getattr(rosto, "sex", None)
|
|
if genero is not None:
|
|
valor["genero_aparente"] = str(genero)
|
|
return EvidenciaVisual("identidade_facial", quadro.timestamp, quadro.timestamp, valor,
|
|
confianca=confianca, provider=self.nome, modelo=self.modelo)
|
|
|
|
|
|
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
|
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
|
|
|
|
nome = "opencv_composicao"
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
|
bordas = self.cv2.Canny(cinza, 80, 160)
|
|
densidade_de_bordas = float((bordas > 0).mean())
|
|
terco_inferior = cinza[int(quadro.altura * 2 / 3):, :]
|
|
zona_de_legenda_livre = float((self.cv2.Canny(terco_inferior, 80, 160) > 0).mean()) < 0.08
|
|
valor = {
|
|
"orientacao": "vertical" if quadro.altura > quadro.largura else "horizontal",
|
|
"densidade_de_bordas": densidade_de_bordas,
|
|
"fundo_visual_poluido": densidade_de_bordas > 0.16,
|
|
"zona_inferior_livre_para_legenda": zona_de_legenda_livre,
|
|
}
|
|
return [EvidenciaVisual("composicao", quadro.timestamp, quadro.timestamp, valor, provider=self.nome)]
|
|
|
|
|
|
class AnalisadorDeTremorOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
|
"""Estima deslocamento global entre frames para sinalizar possível tremor de câmera."""
|
|
|
|
nome = "opencv_movimento"
|
|
|
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
|
if len(quadros) < 2:
|
|
return []
|
|
deslocamentos = [_deslocamento_global(self.cv2, anterior.imagem, atual.imagem)
|
|
for anterior, atual in zip(quadros, quadros[1:])]
|
|
deslocamentos = [item for item in deslocamentos if item is not None]
|
|
if not deslocamentos:
|
|
return []
|
|
medio = sum(deslocamentos) / len(deslocamentos)
|
|
variacao = sum(abs(item - medio) for item in deslocamentos) / len(deslocamentos)
|
|
inicio, fim = quadros[0].timestamp, quadros[-1].timestamp
|
|
return [EvidenciaVisual("movimento_de_camera", inicio, fim, {
|
|
"deslocamento_medio_pixels": medio,
|
|
"variacao_do_deslocamento": variacao,
|
|
"possivel_tremor": variacao > 2.0 and medio > 1.0,
|
|
"amostras": len(deslocamentos),
|
|
}, provider=self.nome)]
|
|
|
|
|
|
class AnalisadorDeContinuidadeOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
|
"""Compara pares de frames e retorna indícios, não certezas, de descontinuidade ou congelamento."""
|
|
|
|
nome = "opencv_continuidade"
|
|
|
|
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
|
evidencias: list[EvidenciaVisual] = []
|
|
for anterior, atual in zip(quadros, quadros[1:]):
|
|
cinza_anterior = self.cv2.cvtColor(anterior.imagem, self.cv2.COLOR_BGR2GRAY)
|
|
cinza_atual = self.cv2.cvtColor(atual.imagem, self.cv2.COLOR_BGR2GRAY)
|
|
diferenca = float(self.cv2.absdiff(cinza_anterior, cinza_atual).mean())
|
|
evidencias.append(EvidenciaVisual("continuidade", anterior.timestamp, atual.timestamp, {
|
|
"diferenca_media_de_luminancia": diferenca,
|
|
"possivel_frame_congelado": diferenca < 0.8,
|
|
"possivel_descontinuidade": diferenca > 38.0,
|
|
}, provider=self.nome))
|
|
return evidencias
|
|
|
|
|
|
class AnalisadorDeObjetosONNX(AnalisadorDeFrame):
|
|
"""Adapter de modelo ONNX; pré e pós-processamento pertencem ao modelo escolhido."""
|
|
|
|
nome = "onnxruntime"
|
|
|
|
def __init__(self, modelo: str | Path, preparar: Callable[[Any, list[str]], dict[str, Any]],
|
|
decodificar: Callable[[list[Any], QuadroDeVideo], list[dict[str, Any]]],
|
|
usar_coreml: bool = True, ort_module: Any | None = None) -> None:
|
|
self.modelo = str(modelo)
|
|
self.preparar = preparar
|
|
self.decodificar = decodificar
|
|
self._ort = ort_module
|
|
ort = self.ort
|
|
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if usar_coreml else ["CPUExecutionProvider"]
|
|
disponiveis = set(ort.get_available_providers())
|
|
self.providers = [provider for provider in preferidos if provider in disponiveis]
|
|
if not self.providers:
|
|
raise RuntimeError("ONNX Runtime não possui provider compatível neste ambiente.")
|
|
self.sessao = ort.InferenceSession(self.modelo, providers=self.providers)
|
|
self.entradas = [entrada.name for entrada in self.sessao.get_inputs()]
|
|
|
|
@property
|
|
def ort(self) -> Any:
|
|
if self._ort is None:
|
|
try:
|
|
import onnxruntime
|
|
except ImportError as exc:
|
|
raise RuntimeError("ONNX Runtime não está instalado. Instale onnxruntime.") from exc
|
|
self._ort = onnxruntime
|
|
return self._ort
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
entradas = self.preparar(quadro.imagem, self.entradas)
|
|
saidas = self.sessao.run(None, entradas)
|
|
deteccoes = self.decodificar(saidas, quadro)
|
|
return [EvidenciaVisual("objeto", quadro.timestamp, quadro.timestamp, deteccao,
|
|
confianca=deteccao.get("confianca"), provider=self.nome,
|
|
modelo=Path(self.modelo).name) for deteccao in deteccoes]
|
|
|
|
|
|
class AnalisadorDePoseMediaPipe(AnalisadorDeFrame):
|
|
"""Adapter MediaPipe Tasks para pose e mãos em um frame."""
|
|
|
|
nome = "mediapipe"
|
|
|
|
def __init__(self, modelo_de_pose: str | Path | None = None,
|
|
modelo_de_maos: str | Path | None = None) -> None:
|
|
self.modelo_de_pose = Path(modelo_de_pose) if modelo_de_pose else None
|
|
self.modelo_de_maos = Path(modelo_de_maos) if modelo_de_maos else None
|
|
if self.modelo_de_pose is None and self.modelo_de_maos is None:
|
|
raise ValueError("Informe ao menos um modelo MediaPipe de pose ou mãos.")
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
if quadro.caminho is None:
|
|
raise ValueError("MediaPipe requer que o frame tenha caminho persistido em disco.")
|
|
for modelo in (self.modelo_de_pose, self.modelo_de_maos):
|
|
if modelo:
|
|
self._validar_modelo(modelo)
|
|
carga = {
|
|
"frame": str(quadro.caminho), "pose": str(self.modelo_de_pose) if self.modelo_de_pose else None,
|
|
"maos": str(self.modelo_de_maos) if self.modelo_de_maos else None,
|
|
}
|
|
processo = subprocess.run(
|
|
[sys.executable, "-m", "engine.integracoes.visual.mediapipe_runner"],
|
|
input=json.dumps(carga), capture_output=True, text=True, timeout=60,
|
|
)
|
|
if processo.returncode != 0:
|
|
detalhe = processo.stderr.strip().splitlines()[-1] if processo.stderr.strip() else "falha nativa sem diagnóstico"
|
|
raise RuntimeError(f"MediaPipe falhou em processo isolado (código {processo.returncode}): {detalhe}")
|
|
dados = json.loads(processo.stdout)
|
|
return [EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
|
|
provider=self.nome) for item in dados]
|
|
|
|
@staticmethod
|
|
def _validar_modelo(caminho: Path) -> None:
|
|
if not caminho.is_file():
|
|
raise FileNotFoundError(f"Modelo MediaPipe não encontrado: {caminho}")
|
|
|
|
|
|
class AnalisadorAppleVision(AnalisadorDeFrame):
|
|
"""Usa PyObjC/Vision para OCR e detecção de faces em frames persistidos."""
|
|
|
|
nome = "apple_vision"
|
|
|
|
def __init__(self, reconhecer_texto: bool = True, detectar_faces: bool = True) -> None:
|
|
self.reconhecer_texto = reconhecer_texto
|
|
self.detectar_faces = detectar_faces
|
|
|
|
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
|
if quadro.caminho is None:
|
|
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
|
try:
|
|
from Foundation import NSURL
|
|
from Vision import VNDetectFaceRectanglesRequest, VNImageRequestHandler, VNRecognizeTextRequest
|
|
except ImportError as exc:
|
|
raise RuntimeError("Apple Vision requer PyObjC e macOS.") from exc
|
|
requisicoes = []
|
|
texto = VNRecognizeTextRequest.alloc().initWithCompletionHandler_(None) if self.reconhecer_texto else None
|
|
faces = VNDetectFaceRectanglesRequest.alloc().initWithCompletionHandler_(None) if self.detectar_faces else None
|
|
if texto:
|
|
requisicoes.append(texto)
|
|
if faces:
|
|
requisicoes.append(faces)
|
|
handler = VNImageRequestHandler.alloc().initWithURL_options_(NSURL.fileURLWithPath_(str(quadro.caminho)), {})
|
|
sucesso, erro = handler.performRequests_error_(requisicoes, None)
|
|
if not sucesso:
|
|
detalhe = str(erro) if erro else "o macOS não retornou detalhe; verifique Vision/Neural Engine no host"
|
|
raise RuntimeError(f"Apple Vision falhou: {detalhe}")
|
|
evidencias: list[EvidenciaVisual] = []
|
|
if texto:
|
|
for observacao in texto.results() or []:
|
|
candidatos = observacao.topCandidates_(1)
|
|
if candidatos:
|
|
candidato = candidatos[0]
|
|
evidencias.append(EvidenciaVisual("ocr", quadro.timestamp, quadro.timestamp,
|
|
{"texto": str(candidato.string()), "bounding_box": _retangulo(observacao.boundingBox())},
|
|
confianca=float(candidato.confidence()), provider=self.nome))
|
|
if faces:
|
|
for observacao in faces.results() or []:
|
|
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
|
{"bounding_box": _retangulo(observacao.boundingBox())}, provider=self.nome))
|
|
return evidencias
|
|
|
|
|
|
def _retangulo(retangulo: Any) -> dict[str, float]:
|
|
return {"x": float(retangulo.origin.x), "y": float(retangulo.origin.y),
|
|
"largura": float(retangulo.size.width), "altura": float(retangulo.size.height)}
|
|
|
|
|
|
def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
|
|
largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
|
|
return {"x": float(x / largura_do_frame), "y": float(y / altura_do_frame),
|
|
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
|
|
|
|
|
|
def _caixa_normalizada_do_retangulo(bbox: Any, largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
|
|
"""Converte um retângulo em cantos ``[x1, y1, x2, y2]`` (pixels) para a caixa normalizada.
|
|
|
|
Detectores como o InsightFace podem devolver cantos levemente fora do
|
|
quadro; por isso o resultado é sempre recortado para o intervalo [0, 1].
|
|
"""
|
|
x1, y1, x2, y2 = (float(valor) for valor in bbox)
|
|
x = min(max(x1 / largura_do_frame, 0.0), 1.0)
|
|
y = min(max(y1 / altura_do_frame, 0.0), 1.0)
|
|
largura = min(max(x2 / largura_do_frame, 0.0), 1.0) - x
|
|
altura = min(max(y2 / altura_do_frame, 0.0), 1.0) - y
|
|
return {"x": x, "y": y, "largura": max(largura, 0.0), "altura": max(altura, 0.0)}
|
|
|
|
|
|
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
|
|
return (caixa["x"] < margem or caixa["y"] < margem
|
|
or caixa["x"] + caixa["largura"] > 1 - margem
|
|
or caixa["y"] + caixa["altura"] > 1 - margem)
|
|
|
|
|
|
def _deslocamento_global(cv2: Any, imagem_anterior: Any, imagem_atual: Any) -> float | None:
|
|
"""Retorna o módulo do deslocamento de câmera, descartando pares sem pontos úteis."""
|
|
import math
|
|
|
|
anterior = cv2.cvtColor(imagem_anterior, cv2.COLOR_BGR2GRAY)
|
|
atual = cv2.cvtColor(imagem_atual, cv2.COLOR_BGR2GRAY)
|
|
pontos = cv2.goodFeaturesToTrack(anterior, maxCorners=150, qualityLevel=0.01,
|
|
minDistance=12, blockSize=7)
|
|
if pontos is None or len(pontos) < 8:
|
|
return None
|
|
encontrados, status, _ = cv2.calcOpticalFlowPyrLK(anterior, atual, pontos, None)
|
|
if encontrados is None or status is None:
|
|
return None
|
|
origem = pontos[status.ravel() == 1]
|
|
destino = encontrados[status.ravel() == 1]
|
|
if len(origem) < 8:
|
|
return None
|
|
matriz, _ = cv2.estimateAffinePartial2D(origem, destino, method=cv2.RANSAC)
|
|
if matriz is None:
|
|
return None
|
|
return math.hypot(float(matriz[0, 2]), float(matriz[1, 2]))
|