Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Ambiente geral
TZ=UTC
APP_ENV=development
CORS_ORIGINS=http://localhost:3000

# Banco
POSTGRES_DB=visaodecria
POSTGRES_USER=visaouser
POSTGRES_PASSWORD=visaopass
DATABASE_URL=postgresql+asyncpg://visaouser:visaopass@postgres:5432/visaodecria

# Redis/Celery
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2

# MinIO
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123
MINIO_ENDPOINT=minio:9000
MINIO_SECURE=false

# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws/live
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Dependências
node_modules/

# Python
__pycache__/
*.pyc
.venv/

# Next.js
.next/
out/

# Ambiente
.env
.env.*
!.env.example

# Logs
*.log

# SO
.DS_Store
79 changes: 55 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,64 @@
# Build Instructions for APK
# 🥋 Visão de Cria

To build the APK for this project, follow the steps below:
Plataforma de análise de combate em tempo real (Boxe, MMA, BJJ Gi e No-Gi), otimizada para baixa latência e uso em produção com overlay para OBS.

1. **Clone the Repository**
Run the following command to clone the repository:
```bash
git clone https://github.com/ringuemkt-rgb/cria-do-tatame.git
cd cria-do-tatame
```
## O que foi otimizado nesta revisão completa

2. **Install Dependencies**
Use Gradle to install all necessary dependencies:
```bash
./gradlew build
```
- ✅ API com `liveness` e `readiness` cacheado para reduzir custo por requisição.
- ✅ Motor de análise técnica modular (detecção de modalidade, regras BJJ, pontuação e insights).
- ✅ Endpoint de simulação para validar pipeline técnico sem depender de vídeo real.
- ✅ Testes unitários cobrindo regras, pontuação e geração de insight.
- ✅ Frontend com dashboard/overlay prontos para operação inicial.

3. **Build the APK**
Now you can build the APK by running:
```bash
./gradlew assembleDebug
```
The APK will be located in the `app/build/outputs/apk/debug/` directory.
## Arquitetura base

---
- **Backend:** FastAPI + Celery + Redis + PostgreSQL/TimescaleDB + MinIO
- **Análise:** módulos dedicados em `backend/app/analysis/`
- **Frontend:** Next.js 14 + Tailwind
- **Infra local:** Docker Compose

# Using GitHub Actions
## Endpoints principais

This repository integrates GitHub Actions to automate workflows. Here's how to use it:
- `GET /api/v1/health/live`
- `GET /api/v1/health/ready`
- `POST /api/v1/modality/override`
- `POST /api/v1/analysis/simulate`

1. **Continuous Integration**: Whenever a push is made to the main branch, GitHub Actions will automatically trigger the CI workflow to run tests and build the project.
## Rodando localmente

2. **Configuration**: You can find the configuration files under the `.github/workflows/` directory. Modify these YAML files to customize the workflow as per your requirements.
```bash
cp .env.example .env
docker compose up --build
```

## Testes rápidos

```bash
python -m compileall backend/app
PYTHONPATH=backend pytest -q backend/tests
cd frontend && npm run lint && npm run build
```

## Simulação da análise técnica (exemplo)

```bash
curl -X POST http://localhost:8000/api/v1/analysis/simulate \
-H "Content-Type: application/json" \
-d '{
"modalidade": "bjj_gi",
"eventos": [
{"atleta":"azul","acao":"guard_pass","duracao_ms":3500,"confianca":0.93},
{"atleta":"branco","acao":"stalling","duracao_ms":900,"confianca":0.71}
]
}'
```

## Inspirações técnicas (adaptação para nosso contexto)

A organização do pipeline foi estruturada com referências de boas práticas de ecossistemas abertos como:
- arquitetura de inferência modular (separação detecção/classificação/regras);
- contracts tipados para eventos;
- readiness para orquestração;
- processamento incremental para stream.

Tudo foi adaptado para o contexto do **Visão de Cria**, priorizando pt-BR, regras de luta e integração com OBS.
24 changes: 24 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip==25.0.1 && pip install -r /tmp/requirements.txt

COPY . /app
RUN useradd --create-home --shell /bin/bash appuser && chown -R appuser:appuser /app

USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Empty file added backend/app/__init__.py
Empty file.
1 change: 1 addition & 0 deletions backend/app/analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Módulos de análise técnica de combate."""
32 changes: 32 additions & 0 deletions backend/app/analysis/grappling_classifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Classificador leve de ações de grappling (baseline de produção)."""


class GrapplingClassifier:
taxonomy = {
"takedown_attempt": "Tentativa de queda",
"takedown_complete": "Queda executada",
"guard_pull": "Puxada para guarda",
"sweep_attempt": "Tentativa de raspagem",
"sweep_success": "Raspagem finalizada",
"guard_pass_attempt": "Tentativa de passagem",
"guard_pass_complete": "Guarda passada",
"mount_established": "Montada consolidada",
"back_control": "Pegada nas costas",
"submission_attempt": "Tentativa de finalização",
"submission_defense": "Defesa de finalização",
"stalling": "Evitação de combate",
"illegal_grip": "Pegada irregular",
}

def classify_window(self, action_probs: dict[str, float]) -> dict[str, str | float]:
"""Mapeia distribuição de probabilidades para melhor ação técnica."""

if not action_probs:
return {"codigo": "unknown", "rotulo": "Sem evento", "confianca": 0.0}

codigo = max(action_probs, key=action_probs.get)
return {
"codigo": codigo,
"rotulo": self.taxonomy.get(codigo, "Evento desconhecido"),
"confianca": float(action_probs[codigo]),
}
43 changes: 43 additions & 0 deletions backend/app/analysis/insight_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Geração de insights técnicos em pt-BR."""

from app.schemas.analysis import FightEvent, MatchContext


class TechnicalInsightEngine:
templates = {
"guard_pass_complete": [
"✅ Passagem de guarda técnica com controle de quadril e cabeça alinhada.",
"🔍 Boa progressão: pressão alta + quebra de postura antes da passagem.",
"⚠️ Atenção: braço exposto durante a passagem. Risco de triângulo.",
],
"sweep_success": [
"✅ Raspagem executada com timing excelente e transferência de peso limpa.",
"🔍 Excelente alavanca: ombro no solo + quadril elevado.",
"⚠️ Base estreita no final da raspagem. Possível contra-raspagem.",
],
"submission_attempt": [
"🔍 Finalização bem montada: isolamento de membro + controle de postura.",
"⚠️ Falta de pressão contínua. Defesa adversária recuperando espaço.",
"✅ Sequência de transição impecável: guarda → costas → mata-leão.",
],
Comment on lines +18 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Template ordering for submission_attempt is inverted vs. the other tipos.

The selection logic at lines 29–32 treats index 1 as "low-confidence cautionary" and index 2 as "biomechanical warning". That holds for guard_pass_complete and sweep_success, but for submission_attempt the array is reversed — index 2 is the ✅ Sequência de transição impecável... (positive) message. So an athlete with biomechanical flags on a submission attempt gets a celebratory message instead of a warning. The biomec appendix (line 38) still fires, but the primary line contradicts the cue.

🐛 Proposed reorder to match siblings
         "submission_attempt": [
-            "🔍 Finalização bem montada: isolamento de membro + controle de postura.",
-            "⚠️ Falta de pressão contínua. Defesa adversária recuperando espaço.",
             "✅ Sequência de transição impecável: guarda → costas → mata-leão.",
+            "🔍 Finalização bem montada: isolamento de membro + controle de postura.",
+            "⚠️ Falta de pressão contínua. Defesa adversária recuperando espaço.",
         ],

Longer term, consider replacing the positional index with explicit categories (e.g. {"default": ..., "low_confidence": ..., "biomech_warning": ...}) so this class of bug cannot recur.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"submission_attempt": [
"🔍 Finalização bem montada: isolamento de membro + controle de postura.",
"⚠️ Falta de pressão contínua. Defesa adversária recuperando espaço.",
"✅ Sequência de transição impecável: guarda → costas → mata-leão.",
],
"submission_attempt": [
"✅ Sequência de transição impecável: guarda → costas → mata-leão.",
"🔍 Finalização bem montada: isolamento de membro + controle de postura.",
"⚠️ Falta de pressão contínua. Defesa adversária recuperando espaço.",
],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/analysis/insight_generator.py` around lines 18 - 22, The
"submission_attempt" template array is ordered incorrectly so that index 2 is
the positive message while other tipos treat index 1 as the low-confidence
caution and index 2 as the biomechanical warning; fix this by reordering the
strings in the submission_attempt list to match the convention used by
guard_pass_complete and sweep_success (index 0 = default, index 1 =
low-confidence cautionary, index 2 = biomechanical warning) so biomechanical
flags produce the warning message (also consider migrating these positional
lists to a keyed dict like {"default", "low_confidence", "biomech_warning"} in
the future to prevent this class of bug).

}

def generate(self, event: FightEvent, context: MatchContext) -> str:
opcoes = self.templates.get(event.tipo, ["🔍 Evento técnico detectado."])

indice = 0
if event.confianca < 0.6 and len(opcoes) > 1:
indice = 1
if event.biomecanica_flags and len(opcoes) > 2:
indice = 2

base_msg = opcoes[indice]

if event.biomecanica_flags:
alertas = ", ".join(event.biomecanica_flags)
base_msg += f" | 🦴 Ajustes biomecânicos: {alertas}."

if context.modalidade in {"bjj_gi", "bjj_nogi"}:
base_msg += " (Referência CBJJ/IBJJF)"

return base_msg
25 changes: 25 additions & 0 deletions backend/app/analysis/modality_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Detector leve de modalidade com heurísticas dos primeiros segundos."""

from app.schemas.analysis import ModalityResult


class ModalityDetector:
"""Combina sinais visuais/temporais para inferir modalidade rapidamente."""

def detect_from_signals(self, sinais: dict[str, float]) -> ModalityResult:
"""Detecta modalidade usando sinais sintéticos para etapa inicial da API."""

gi = sinais.get("gi", 0.0)
rashguard = sinais.get("rashguard", 0.0)
ground = sinais.get("ground_time_ratio", 0.0)
strikes = sinais.get("strikes_detected", 0.0)
cage = sinais.get("cage", 0.0)

if gi > 0.7 and ground > 0.6:
return ModalityResult(modality="bjj_gi", confidence=0.92)
if rashguard > 0.7 and ground > 0.5:
return ModalityResult(modality="bjj_nogi", confidence=0.89)
if strikes > 5 and ground < 0.3:
return ModalityResult(modality="mma" if cage > 0.5 else "boxe", confidence=0.95)

return ModalityResult(modality="unknown", confidence=0.0, requires_manual=True)
1 change: 1 addition & 0 deletions backend/app/analysis/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pacote de regras por modalidade."""
38 changes: 38 additions & 0 deletions backend/app/analysis/rules/bjj_rules_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Motor de regras BJJ (IBJJF/ADCC simplificado para etapa atual)."""

from typing import Literal

from app.schemas.analysis import GrapplingAction


class BJJRulesEngine:
def __init__(self, modality: Literal["gi", "nogi"]):
self.modality = modality
self.points_config = self._load_config()

def _load_config(self) -> dict[str, int]:
return {
"takedown": 2,
"sweep": 2,
"guard_pass": 3,
"knee_on_belly": 2,
"mount": 4,
"back_control": 4,
}

def validate_action(self, action: GrapplingAction) -> dict[str, str | bool | int | None]:
"""Valida se ação gera ponto/vantagem/penalidade."""

if action.acao == "illegal_grip" and self.modality == "nogi":
return {"valid": False, "points": 0, "advantage": False, "penalty": "pegada irregular"}

if action.acao == "stalling":
return {"valid": False, "points": 0, "advantage": False, "penalty": "evitação de combate"}

points = self.points_config.get(action.acao, 0)
advantage = self.calculate_advantage(action.acao, action.duracao_ms)
return {"valid": True, "points": points, "advantage": advantage, "penalty": None}
Comment on lines +26 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

illegal_grip silently treated as a valid, zero-point action in GI.

When modality == "gi" and acao == "illegal_grip", the branch at line 26 is skipped and execution falls through to line 32, returning {"valid": True, "points": 0, "advantage": False, "penalty": None}. If the upstream pipeline has already labeled the action as an illegal grip, the rules engine should still emit a penalty (or at least valid=False) in GI — definitions differ between GI/NoGi, but an event flagged as illegal should never score as a valid neutral action. This will cause ScoringEngine to skip incrementing the penalty counter for GI illegal grips.

🛡️ Suggested fix
-        if action.acao == "illegal_grip" and self.modality == "nogi":
-            return {"valid": False, "points": 0, "advantage": False, "penalty": "pegada irregular"}
+        if action.acao == "illegal_grip":
+            return {"valid": False, "points": 0, "advantage": False, "penalty": "pegada irregular"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/analysis/rules/bjj_rules_engine.py` around lines 26 - 34, The
current branch only treats illegal_grip as a penalty for nogi and lets it fall
through as a valid zero-point action for gi; update the logic that checks
action.acao == "illegal_grip" (in the same method using self.modality,
action.acao, points_config and calculate_advantage) so that any action flagged
"illegal_grip" is returned as invalid and emits an appropriate penalty string
for both modalities (e.g., return {"valid": False, "points": 0, "advantage":
False, "penalty": <penalty_text>}), and if you need modality-specific wording
choose the penalty text based on self.modality before returning.


@staticmethod
def calculate_advantage(action: str, duration_ms: int) -> bool:
return duration_ms >= 3000 and action in {"near_pass", "near_back", "near_mount"}
38 changes: 38 additions & 0 deletions backend/app/analysis/scoring_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Motor unificado de pontuação para modalidade de grappling."""

from collections import defaultdict

from app.analysis.rules.bjj_rules_engine import BJJRulesEngine
from app.schemas.analysis import GrapplingAction


class ScoringEngine:
def __init__(self, modalidade: str):
if modalidade not in {"bjj_gi", "bjj_nogi"}:
raise ValueError("Modalidade ainda não suportada no scoring_engine atual")
self.modalidade = modalidade
self.rules = BJJRulesEngine("gi" if modalidade == "bjj_gi" else "nogi")
self.placar = defaultdict(int)
self.vantagens = defaultdict(int)
self.penalidades = defaultdict(int)

def process_event(self, action: GrapplingAction) -> None:
resultado = self.rules.validate_action(action)
atleta = action.atleta

if resultado["penalty"]:
self.penalidades[atleta] += 1
return

if resultado["valid"] and int(resultado["points"]) > 0:
self.placar[atleta] += int(resultado["points"])

if bool(resultado["advantage"]):
self.vantagens[atleta] += 1

def snapshot(self) -> dict[str, dict[str, int]]:
return {
"placar": {"azul": self.placar["azul"], "branco": self.placar["branco"]},
"vantagens": {"azul": self.vantagens["azul"], "branco": self.vantagens["branco"]},
"penalidades": {"azul": self.penalidades["azul"], "branco": self.penalidades["branco"]},
}
Empty file added backend/app/api/__init__.py
Empty file.
Empty file.
41 changes: 41 additions & 0 deletions backend/app/api/routes/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Rotas de simulação para validação rápida de análise técnica."""

from fastapi import APIRouter

from app.analysis.insight_generator import TechnicalInsightEngine
from app.analysis.scoring_engine import ScoringEngine
from app.schemas.analysis import (
FightEvent,
MatchContext,
SimulateAnalysisRequest,
SimulateAnalysisResponse,
)

router = APIRouter(prefix="/analysis", tags=["analysis"])


@router.post("/simulate", response_model=SimulateAnalysisResponse)
async def simulate_analysis(payload: SimulateAnalysisRequest) -> SimulateAnalysisResponse:
"""Simula pontuação e insights a partir de eventos de grappling enviados pela API."""

scoring = ScoringEngine(modalidade=payload.modalidade)
insight_engine = TechnicalInsightEngine()
insights: list[str] = []

for evento in payload.eventos:
scoring.process_event(evento)

tipo = "submission_attempt" if "mount" in evento.acao else "sweep_success"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map insight type from action instead of mount substring

The simulation endpoint currently derives FightEvent.tipo with "submission_attempt" if "mount" in evento.acao else "sweep_success", so every non-mount action (including stalling, illegal_grip, takedown, and guard_pass) is forced into the sweep_success template. This produces systematically incorrect technical insights (for example, penalized stalling events can emit positive sweep feedback), which makes /api/v1/analysis/simulate outputs unreliable for validating the analysis pipeline.

Useful? React with 👍 / 👎.

insight = insight_engine.generate(
FightEvent(tipo=tipo, confianca=evento.confianca, atleta=evento.atleta),
MatchContext(modalidade=payload.modalidade),
)
insights.append(insight)
Comment on lines +28 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect tipo derivation: substring match and lossy default.

Two problems on line 28:

  1. "mount" in evento.acao is a substring match, so near_mount also resolves to "submission_attempt" — but near_mount is a positional/advantage cue, not a submission attempt.
  2. Every other acao value (takedown, sweep, guard_pass, knee_on_belly, back_control, near_pass, near_back, illegal_grip, stalling) falls into the "sweep_success" default, which is semantically wrong — a takedown isn't a sweep, and stalling is certainly not a "success" event. The generated insights will be misleading for 9 of the 11 possible actions.

Use equality and an explicit mapping so each action maps to a meaningful tipo.

🔧 Suggested fix
+ACAO_TO_TIPO: dict[str, str] = {
+    "mount": "submission_attempt",
+    "back_control": "submission_attempt",
+    "sweep": "sweep_success",
+    "guard_pass": "pass_success",
+    "takedown": "takedown_success",
+    "knee_on_belly": "control_success",
+    "near_pass": "pass_attempt",
+    "near_back": "back_attempt",
+    "near_mount": "mount_attempt",
+    "illegal_grip": "foul",
+    "stalling": "foul",
+}
+
 `@router.post`("/simulate", response_model=SimulateAnalysisResponse)
 async def simulate_analysis(payload: SimulateAnalysisRequest) -> SimulateAnalysisResponse:
@@
     for evento in payload.eventos:
         scoring.process_event(evento)
 
-        tipo = "submission_attempt" if "mount" in evento.acao else "sweep_success"
+        tipo = ACAO_TO_TIPO.get(evento.acao, "generic_event")
         insight = insight_engine.generate(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/routes/analysis.py` around lines 28 - 33, The code
incorrectly derives tipo using a substring check ("mount" in evento.acao) and a
lossy default of "sweep_success", causing many actions to be misclassified;
replace that logic by mapping evento.acao exactly to a well-defined tipo via an
explicit mapping (e.g., a dict of action -> tipo) and use exact equality/key
lookup rather than substring tests, then pass the mapped tipo into FightEvent in
the call to insight_engine.generate (keep existing references to FightEvent,
evento.acao, payload.modalidade, insight_engine.generate, and insights.append to
locate where to change the logic).


snapshot = scoring.snapshot()
return SimulateAnalysisResponse(
placar=snapshot["placar"],
vantagens=snapshot["vantagens"],
penalidades=snapshot["penalidades"],
insights=insights,
)
Loading