Contando carros para mercados de previsão
Um mercado de previsão precisa de algo para resolver. Queríamos um sinal local, contínuo, público e difícil de contestar. Carros cruzando uma linha numa pista.
A cada cinco minutos abre um mercado: a contagem vai ficar acima de N? As pessoas apostam para cima ou para baixo, acompanham o stream ao vivo, e o mercado é liquidado on-chain quando o ciclo fecha. No pico, cerca de 5.000 pessoas acompanham a mesma contagem ao mesmo tempo. Se o número estiver errado, dinheiro de verdade resolve errado.
Eu construí o pipeline que produz esse número. A maior parte do código, das auditorias, das correções e dos runbooks foi escrita por agentes. Este post é sobre como a detecção funciona e como o sistema em volta dos agentes manteve tudo vivo.
O pipeline
câmera pública (HLS) ──▶ ffmpeg ──▶ fila do frame mais novo
│
▼
YOLO (carro, ônibus, caminhão) + ByteTrack
│
▼
contador: cruzamento da linha por track
│
┌────────────────────────┼─────────────────┐
▼ ▼ ▼
overlay no frame ciclos de 5 minutos WebSocket
(OpenCV, só ROIs) (SQLite, alinhados (contagem
│ ao relógio) parcial)
▼ │ │
ffmpeg ──▶ MediaMTX ──▶ WebRTC ▼ ▼
stream público keeper resolve market maker
~5.000 espectadores o mercado cota as odds
Python, OpenCV, Ultralytics, ByteTrack, FFmpeg, MediaMTX, um VPS de 8 cores sem GPU. Essa última parte moldou cada decisão.
Como um carro vira uma contagem
Detectar, sozinho, não conta. Um carro fica no frame por cem frames, e um modelo que diz "carro" cem vezes não contou nada. Então o modelo roda com um tracker, e a contagem é uma propriedade de um track, não de uma detecção.
track 4127 (carro)
frame 1..8 visto, ainda inelegível ← mínimo de frames
frame 9 lado A confirmado ← distância com sinal
frame 23 lado B ← cruzamento
count += 1 track 4127 marcado como contado, nunca mais
frame 60 track some, estado expira ← TTL
Cada track carrega o próprio estado: em quantos frames foi visto, de que lado da linha foi confirmado, se já foi contado. Um cruzamento só conta quando um track foi confirmado de um lado e depois confirmado do outro. Um track que oscila em cima da linha não conta. Um track que se perde atrás de um ônibus e volta com um id novo é o modo de falha que sobra, e é para ele que existem as checagens por amostra.
A linha é um polígono no frame, editável no admin, por câmera. Caminhões e ônibus contam como veículos. Pessoas e peixes têm perfis próprios, com modelos próprios, porque o pipeline não se importa com o que cruza a linha.
Rápido o bastante numa CPU
A câmera manda 1080p a 25 frames por segundo. O YOLO olha para 640 pixels. O stream sai em 854 por 480. Nada no meio precisa da resolução cheia, então o pipeline não a carrega. As filas guardam só o frame mais novo e descartam o resto. O encoder roda na própria thread, com o próprio ritmo.
O overlay foi onde isso foi aprendido do jeito difícil. Um redesign moveu o desenho para o PIL, com camadas de frame inteiro. A produção foi de fluida para 5,6 frames por segundo, 188% de CPU no processo Python e 11 segundos de atraso, enquanto a GPU ficava em 39%. O gargalo era a composição, não a detecção. A correção foi usar primitivas do OpenCV desenhadas só dentro das regiões de interesse. Os efeitos voltaram um por vez, cada um medido em três resoluções antes de ir para produção.
Esse incidente virou um documento, e o documento virou uma regra: medir antes e depois em 720p, 1024p e 1080p, ou não sai.
Público de propósito
A contagem é o mercado. Se as pessoas não conseguem ver, não vão confiar, e se não confiam, não apostam.
Então o stream anotado é público via WebRTC, com menos de um segundo de atraso, direto do gateway de mídia. Os snapshots são públicos. A contagem parcial é enviada por WebSocket para quem estiver ouvindo, o que inclui o market maker cotando odds enquanto o ciclo ainda está rodando. Só o admin e todos os endpoints de escrita ficam atrás de autenticação.
Continuar vivo
Um mercado que resolve com um número errado é pior que um mercado que pausa. Então a maior parte do trabalho depois do primeiro mês não foi detecção. Foi fazer o número sobreviver ao mundo real.
câmera cai ──▶ supervisor reinicia com backoff
──▶ 120s offline ──▶ failover para a próxima câmera
de um catálogo de 155, com
health check, cada uma com
uma linha pré-configurada
──▶ alerta no Telegram, deduplicado
ciclo quebra ──▶ marcado parcial, nunca liquidado como completo
processo cai ──▶ estado do ciclo recuperado do SQLite no boot
Uma queda rápida no meio de um ciclo apagava as contagens anteriores. Um loop de snapshot que quebrava morria em silêncio na primeira exceção. O backoff do supervisor se resetava sozinho e reiniciava a cada dez segundos, para sempre. Cada um desses agora é um teste.
Construído com agentes
O repo tem 24 arquivos de teste e mais ou menos o mesmo número de documentos. Essa proporção é o método.
audita ─▶ planeja ─▶ corrige ─▶ mede ─▶ registra ─▶ nova auditoria
│ │
│ 2 críticos, 7 altos, 14 médios, │ docs/ + memória:
│ 19 baixos, cada um com file:line, │ o que quebrou,
│ severidade e ordem de ataque │ por quê, e a regra
└───────────────────────────────────────┘
Um agente auditou a base de código inteira e produziu uma lista ranqueada: dois críticos, sete altos, catorze médios, dezenove baixos, cada um com arquivo e linha, uma reprodução e uma ordem de ataque sugerida. Os críticos eram um deploy que sobrescrevia a configuração de câmera em produção e loops em background que morriam em silêncio. Os dois foram corrigidos na mesma semana, os dois têm testes, e o documento da auditoria ficou como mapa para a próxima passada.
Toda investigação termina num arquivo. O incidente do overlay. A análise de performance que achou resolução vazando pelo pipeline inteiro. O mapa de como três serviços downstream consomem a API, que define o contrato que nada pode quebrar. O plano do catálogo de câmeras, em cinco fases, cada uma entregue e marcada como feita.
E uma memória que sobrevive ao chat. Quando o restream de câmeras do YouTube a partir de um IP de datacenter continuava falhando, o agente achou a combinação que funciona, e depois achou a parede que não: o YouTube invalida a sessão depois de uma hora naquele IP, não importa o que se faça, enquanto um IP residencial segura indefinidamente. Isso está registrado como um fato com data, para que nenhuma sessão futura gaste um dia redescobrindo.
Por que isso não é sobre carros
câmera de trânsito qualquer sinal ao vivo
───────────────────── ──────────────────────────────
track, não detecção conta a entidade, não o evento
lado A, depois lado B duas confirmações antes de um fato
overlay medido em 3 res performance é teste, não achismo
stream público o número que dá para verificar
ciclo parcial leitura ruim pausa, nunca mente
catálogo de failover a próxima fonte, já configurada
auditoria, ordem de ataque agentes que ranqueiam o que fazem
memória com datas o que era verdade, e quando
A parte difícil nunca foi o modelo. Detecção foi resolvida em uma semana. Produzir um número em que milhares de pessoas podem apostar, a cada cinco minutos, a partir de uma câmera pública numa máquina sem GPU, por meses, é um problema de sistemas. Os agentes fizeram o trabalho de sistemas, porque o loop deu a eles um jeito de auditar, corrigir, medir e lembrar.
O que eu fiz
Eu escolhi o sinal, desenhei a primeira linha, defini a regra de que o número é público e a regra de que um ciclo quebrado nunca liquida. Construí o primeiro pipeline, depois passei as auditorias, as correções, o catálogo e o failover para os agentes, e li todo documento que eles deixaram. Quando um desses documentos disse "não faça deploy sem medir em três resoluções", isso era o sistema melhorando a si mesmo.