Spaces:
Runtime error
Runtime error
sara.mesquita commited on
Commit ·
b2ebfa6
0
Parent(s):
feat: Animal Visto app
Browse files- .idea/.gitignore +8 -0
- .idea/animal-visto.iml +9 -0
- .idea/material_theme_project_new.xml +10 -0
- .idea/misc.xml +6 -0
- .idea/modules.xml +8 -0
- .idea/vcs.xml +6 -0
- README.md +59 -0
- app.py +639 -0
- core/__init__.py +0 -0
- core/__pycache__/__init__.cpython-310.pyc +0 -0
- core/__pycache__/database.cpython-310.pyc +0 -0
- core/__pycache__/matcher.cpython-310.pyc +0 -0
- core/ai.py +147 -0
- core/database.py +241 -0
- core/matcher.py +54 -0
- data/viralata.db +0 -0
- data/viralata.db-journal +0 -0
- db/schema.sql +23 -0
- requirements.txt +5 -0
- setup-git.sh +52 -0
.idea/.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Default ignored files
|
| 2 |
+
/shelf/
|
| 3 |
+
/workspace.xml
|
| 4 |
+
# Editor-based HTTP Client requests
|
| 5 |
+
/httpRequests/
|
| 6 |
+
# Datasource local storage ignored files
|
| 7 |
+
/dataSources/
|
| 8 |
+
/dataSources.local.xml
|
.idea/animal-visto.iml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<module type="JAVA_MODULE" version="4">
|
| 3 |
+
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
| 4 |
+
<exclude-output />
|
| 5 |
+
<content url="file://$MODULE_DIR$" />
|
| 6 |
+
<orderEntry type="inheritedJdk" />
|
| 7 |
+
<orderEntry type="sourceFolder" forTests="false" />
|
| 8 |
+
</component>
|
| 9 |
+
</module>
|
.idea/material_theme_project_new.xml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="MaterialThemeProjectNewConfig">
|
| 4 |
+
<option name="metadata">
|
| 5 |
+
<MTProjectMetadataState>
|
| 6 |
+
<option name="userId" value="-47692bf2:19e97ac351f:-7ffd" />
|
| 7 |
+
</MTProjectMetadataState>
|
| 8 |
+
</option>
|
| 9 |
+
</component>
|
| 10 |
+
</project>
|
.idea/misc.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectRootManager">
|
| 4 |
+
<output url="file://$PROJECT_DIR$/out" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
.idea/modules.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectModuleManager">
|
| 4 |
+
<modules>
|
| 5 |
+
<module fileurl="file://$PROJECT_DIR$/.idea/animal-visto.iml" filepath="$PROJECT_DIR$/.idea/animal-visto.iml" />
|
| 6 |
+
</modules>
|
| 7 |
+
</component>
|
| 8 |
+
</project>
|
.idea/vcs.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="VcsDirectoryMappings">
|
| 4 |
+
<mapping directory="" vcs="Git" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Animal Visto
|
| 3 |
+
emoji: 🐾
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Mapeamento colaborativo de animais de rua com IA
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# 🐾 Animal Visto
|
| 15 |
+
|
| 16 |
+
**Mapeamento colaborativo de animais de rua** · Build Small Hackathon · Trilha Backyard AI
|
| 17 |
+
|
| 18 |
+
Qualquer pessoa tira uma foto de um animal de rua pelo celular, o app detecta automaticamente a localização GPS, e usa IA para identificar se aquele animal já foi registrado antes — agrupando avistamentos e mostrando a trajetória no mapa.
|
| 19 |
+
|
| 20 |
+
## Como funciona
|
| 21 |
+
|
| 22 |
+
1. **📷 Registrar** — tire uma foto, confirme a localização GPS e envie
|
| 23 |
+
2. **🤖 IA (Nemotron Nano VL)** — identifica espécie, raça, cor e marcações
|
| 24 |
+
3. **🔍 Matching** — cosine similarity entre embeddings verifica se é o mesmo animal
|
| 25 |
+
4. **🗺️ Mapa** — pin colorido no mapa com histórico de avistamentos
|
| 26 |
+
|
| 27 |
+
## Secrets necessários no HF Space
|
| 28 |
+
|
| 29 |
+
| Secret | Descrição |
|
| 30 |
+
|--------|-----------|
|
| 31 |
+
| `HF_TOKEN` | Token HF com permissão de leitura (Settings → Tokens) |
|
| 32 |
+
| `HF_MODEL` | Opcional. Padrão: `meta-llama/Llama-3.2-11B-Vision-Instruct` |
|
| 33 |
+
| `MATCH_THRESHOLD` | Opcional. Threshold de similaridade. Padrão: `0.80` |
|
| 34 |
+
|
| 35 |
+
> Os embeddings rodam com `sentence-transformers` **localmente no Space** (sem custo de API).
|
| 36 |
+
> Só a análise de imagem usa créditos HF (1 chamada por foto enviada).
|
| 37 |
+
|
| 38 |
+
## Storage Bucket
|
| 39 |
+
|
| 40 |
+
Configure um Persistent Storage Bucket no Space para que `/data/` persista entre restarts.
|
| 41 |
+
O banco (`viralata.db`) e as fotos ficam em `/data/`.
|
| 42 |
+
|
| 43 |
+
## Stack
|
| 44 |
+
|
| 45 |
+
- **Frontend**: Gradio 4 + Leaflet.js
|
| 46 |
+
- **Backend**: Python · SQLite · sentence-transformers
|
| 47 |
+
- **IA**: Nemotron Nano VL via NVIDIA NIM API
|
| 48 |
+
- **Matching**: Cosine similarity em embeddings `all-MiniLM-L6-v2`
|
| 49 |
+
- **Mapa**: OpenStreetMap via Leaflet.js
|
| 50 |
+
|
| 51 |
+
## Cores dos pins
|
| 52 |
+
|
| 53 |
+
- 🟢 Verde — cão visto pela 1ª vez
|
| 54 |
+
- 🟠 Laranja — animal com múltiplos avistamentos
|
| 55 |
+
- 🔴 Vermelho — não visto há mais de 30 dias
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
*Feito com 💚 para Vinhedo, SP — e qualquer outra cidade.*
|
app.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py — Animal Visto 🐾
|
| 3 |
+
Mapeamento colaborativo de animais de rua.
|
| 4 |
+
Build Small Hackathon · Trilha Backyard AI · Junho 2026
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import gradio as gr
|
| 11 |
+
|
| 12 |
+
from core.ai import AnimalAI
|
| 13 |
+
from core.database import Database
|
| 14 |
+
from core.matcher import AnimalMatcher
|
| 15 |
+
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
|
| 18 |
+
db = Database()
|
| 19 |
+
ai = AnimalAI()
|
| 20 |
+
matcher = AnimalMatcher()
|
| 21 |
+
|
| 22 |
+
# ─── Paleta (spec §4) ────────────────────────────────────────────────────────
|
| 23 |
+
C_GREEN = "#388C59"
|
| 24 |
+
C_GREEN_L = "#D9EBD9"
|
| 25 |
+
C_TEXT = "#212121"
|
| 26 |
+
C_CARD = "#F4F4F0"
|
| 27 |
+
C_RED = "#E53935"
|
| 28 |
+
C_ORANGE = "#FB8C00"
|
| 29 |
+
|
| 30 |
+
# ─── CSS ─────────────────────────────────────────────────────────────────────
|
| 31 |
+
CSS = f"""
|
| 32 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 33 |
+
|
| 34 |
+
* {{ box-sizing: border-box; font-family: 'Inter', sans-serif; }}
|
| 35 |
+
|
| 36 |
+
/* Wrapper mobile-first */
|
| 37 |
+
.gradio-container {{
|
| 38 |
+
max-width: 480px !important;
|
| 39 |
+
margin: 0 auto !important;
|
| 40 |
+
padding: 0 !important;
|
| 41 |
+
background: #ffffff !important;
|
| 42 |
+
min-height: 100dvh;
|
| 43 |
+
}}
|
| 44 |
+
|
| 45 |
+
footer {{ display: none !important; }}
|
| 46 |
+
.svelte-1gfkn6j {{ display: none !important; }}
|
| 47 |
+
|
| 48 |
+
/* Top bar */
|
| 49 |
+
#top-bar {{
|
| 50 |
+
background: {C_GREEN};
|
| 51 |
+
color: white;
|
| 52 |
+
padding: 14px 16px 12px;
|
| 53 |
+
display: flex;
|
| 54 |
+
align-items: center;
|
| 55 |
+
justify-content: space-between;
|
| 56 |
+
position: sticky;
|
| 57 |
+
top: 0;
|
| 58 |
+
z-index: 100;
|
| 59 |
+
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
| 60 |
+
}}
|
| 61 |
+
#top-bar h1 {{
|
| 62 |
+
margin: 0;
|
| 63 |
+
font-size: 18px;
|
| 64 |
+
font-weight: 700;
|
| 65 |
+
letter-spacing: -0.3px;
|
| 66 |
+
}}
|
| 67 |
+
#top-bar small {{
|
| 68 |
+
font-size: 12px;
|
| 69 |
+
opacity: .75;
|
| 70 |
+
font-weight: 400;
|
| 71 |
+
}}
|
| 72 |
+
#stats-badge {{
|
| 73 |
+
background: rgba(255,255,255,.2);
|
| 74 |
+
border-radius: 20px;
|
| 75 |
+
padding: 4px 10px;
|
| 76 |
+
font-size: 12px;
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
}}
|
| 79 |
+
|
| 80 |
+
/* Tabs — visual de nav bar */
|
| 81 |
+
.tabs > .tab-nav {{
|
| 82 |
+
background: #fff;
|
| 83 |
+
border-top: 1px solid #eee;
|
| 84 |
+
border-bottom: none !important;
|
| 85 |
+
display: flex;
|
| 86 |
+
position: sticky;
|
| 87 |
+
bottom: 0;
|
| 88 |
+
z-index: 100;
|
| 89 |
+
box-shadow: 0 -2px 10px rgba(0,0,0,.08);
|
| 90 |
+
padding: 0;
|
| 91 |
+
gap: 0;
|
| 92 |
+
}}
|
| 93 |
+
.tabs > .tab-nav button {{
|
| 94 |
+
flex: 1 !important;
|
| 95 |
+
padding: 10px 4px 8px !important;
|
| 96 |
+
border-radius: 0 !important;
|
| 97 |
+
border: none !important;
|
| 98 |
+
background: transparent !important;
|
| 99 |
+
color: #999 !important;
|
| 100 |
+
font-size: 11px !important;
|
| 101 |
+
font-weight: 500 !important;
|
| 102 |
+
line-height: 1.4 !important;
|
| 103 |
+
transition: color .15s, border-top .15s;
|
| 104 |
+
border-top: 3px solid transparent !important;
|
| 105 |
+
}}
|
| 106 |
+
.tabs > .tab-nav button.selected {{
|
| 107 |
+
color: {C_GREEN} !important;
|
| 108 |
+
border-top: 3px solid {C_GREEN} !important;
|
| 109 |
+
font-weight: 700 !important;
|
| 110 |
+
}}
|
| 111 |
+
|
| 112 |
+
/* Filter pills */
|
| 113 |
+
.filter-row {{
|
| 114 |
+
display: flex;
|
| 115 |
+
gap: 6px;
|
| 116 |
+
padding: 10px 12px;
|
| 117 |
+
overflow-x: auto;
|
| 118 |
+
scrollbar-width: none;
|
| 119 |
+
background: white;
|
| 120 |
+
border-bottom: 1px solid #f0f0f0;
|
| 121 |
+
}}
|
| 122 |
+
.filter-row button {{
|
| 123 |
+
border-radius: 20px !important;
|
| 124 |
+
border: 1.5px solid #ddd !important;
|
| 125 |
+
background: white !important;
|
| 126 |
+
color: {C_TEXT} !important;
|
| 127 |
+
font-size: 13px !important;
|
| 128 |
+
padding: 5px 14px !important;
|
| 129 |
+
white-space: nowrap;
|
| 130 |
+
font-weight: 500 !important;
|
| 131 |
+
transition: all .15s;
|
| 132 |
+
min-width: unset !important;
|
| 133 |
+
}}
|
| 134 |
+
.filter-row button:hover,
|
| 135 |
+
.filter-row button.active {{
|
| 136 |
+
background: {C_GREEN} !important;
|
| 137 |
+
border-color: {C_GREEN} !important;
|
| 138 |
+
color: white !important;
|
| 139 |
+
}}
|
| 140 |
+
.filter-row button.secondary {{
|
| 141 |
+
border-color: #eee !important;
|
| 142 |
+
color: #888 !important;
|
| 143 |
+
font-size: 12px !important;
|
| 144 |
+
padding: 5px 10px !important;
|
| 145 |
+
}}
|
| 146 |
+
.filter-row button.secondary:hover {{
|
| 147 |
+
border-color: {C_GREEN} !important;
|
| 148 |
+
color: {C_GREEN} !important;
|
| 149 |
+
background: {C_GREEN_L} !important;
|
| 150 |
+
}}
|
| 151 |
+
|
| 152 |
+
/* Register form */
|
| 153 |
+
#register-tab {{
|
| 154 |
+
padding: 0 0 80px;
|
| 155 |
+
}}
|
| 156 |
+
.reg-section {{
|
| 157 |
+
padding: 16px 16px 0;
|
| 158 |
+
}}
|
| 159 |
+
.reg-label {{
|
| 160 |
+
font-size: 13px;
|
| 161 |
+
font-weight: 600;
|
| 162 |
+
color: #555;
|
| 163 |
+
margin-bottom: 6px;
|
| 164 |
+
}}
|
| 165 |
+
#register-tab .image-container {{
|
| 166 |
+
border-radius: 12px !important;
|
| 167 |
+
border: 2px dashed #ddd !important;
|
| 168 |
+
background: {C_GREEN_L} !important;
|
| 169 |
+
min-height: 200px;
|
| 170 |
+
}}
|
| 171 |
+
#register-tab textarea {{
|
| 172 |
+
border-radius: 10px !important;
|
| 173 |
+
border: 1.5px solid #e0e0e0 !important;
|
| 174 |
+
font-size: 14px !important;
|
| 175 |
+
resize: none;
|
| 176 |
+
}}
|
| 177 |
+
#register-tab textarea:focus {{
|
| 178 |
+
border-color: {C_GREEN} !important;
|
| 179 |
+
box-shadow: 0 0 0 3px {C_GREEN}22 !important;
|
| 180 |
+
}}
|
| 181 |
+
|
| 182 |
+
/* Submit button */
|
| 183 |
+
#submit-btn {{
|
| 184 |
+
margin: 16px !important;
|
| 185 |
+
width: calc(100% - 32px) !important;
|
| 186 |
+
background: {C_GREEN} !important;
|
| 187 |
+
border: none !important;
|
| 188 |
+
border-radius: 12px !important;
|
| 189 |
+
font-size: 15px !important;
|
| 190 |
+
font-weight: 600 !important;
|
| 191 |
+
padding: 14px !important;
|
| 192 |
+
color: white !important;
|
| 193 |
+
box-shadow: 0 4px 12px {C_GREEN}55;
|
| 194 |
+
}}
|
| 195 |
+
#submit-btn:hover {{ background: #2d7a4a !important; }}
|
| 196 |
+
#submit-btn:disabled {{ background: #ccc !important; box-shadow: none !important; }}
|
| 197 |
+
|
| 198 |
+
/* Status card */
|
| 199 |
+
#status-card {{
|
| 200 |
+
margin: 8px 16px 80px;
|
| 201 |
+
border-radius: 12px;
|
| 202 |
+
padding: 14px 16px;
|
| 203 |
+
font-size: 14px;
|
| 204 |
+
line-height: 1.5;
|
| 205 |
+
}}
|
| 206 |
+
#status-card.ok {{ background: #e8f5e9; color: #2e7d32; border-left: 4px solid {C_GREEN}; }}
|
| 207 |
+
#status-card.err {{ background: #ffebee; color: #b71c1c; border-left: 4px solid {C_RED}; }}
|
| 208 |
+
|
| 209 |
+
/* Animals list */
|
| 210 |
+
#animals-tab {{ padding: 0 0 80px; }}
|
| 211 |
+
.animal-card {{
|
| 212 |
+
display: flex;
|
| 213 |
+
align-items: center;
|
| 214 |
+
gap: 12px;
|
| 215 |
+
background: white;
|
| 216 |
+
border-radius: 12px;
|
| 217 |
+
padding: 14px 16px;
|
| 218 |
+
margin: 8px 12px;
|
| 219 |
+
box-shadow: 0 2px 8px rgba(0,0,0,.07);
|
| 220 |
+
border: 1px solid #f0f0f0;
|
| 221 |
+
}}
|
| 222 |
+
.animal-avatar {{
|
| 223 |
+
width: 46px; height: 46px;
|
| 224 |
+
border-radius: 50%;
|
| 225 |
+
display: flex; align-items: center; justify-content: center;
|
| 226 |
+
font-size: 22px;
|
| 227 |
+
flex-shrink: 0;
|
| 228 |
+
}}
|
| 229 |
+
.animal-info {{ flex: 1; min-width: 0; }}
|
| 230 |
+
.animal-name {{ font-weight: 600; font-size: 14px; color: {C_TEXT}; }}
|
| 231 |
+
.animal-meta {{ font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
| 232 |
+
.animal-badge {{
|
| 233 |
+
background: {C_GREEN_L};
|
| 234 |
+
color: {C_GREEN};
|
| 235 |
+
border-radius: 20px;
|
| 236 |
+
padding: 3px 9px;
|
| 237 |
+
font-size: 12px;
|
| 238 |
+
font-weight: 700;
|
| 239 |
+
flex-shrink: 0;
|
| 240 |
+
}}
|
| 241 |
+
.animal-badge.urgent {{ background: #ffebee; color: {C_RED}; }}
|
| 242 |
+
.section-header {{
|
| 243 |
+
padding: 14px 16px 6px;
|
| 244 |
+
font-size: 13px;
|
| 245 |
+
font-weight: 600;
|
| 246 |
+
color: #888;
|
| 247 |
+
text-transform: uppercase;
|
| 248 |
+
letter-spacing: .5px;
|
| 249 |
+
}}
|
| 250 |
+
|
| 251 |
+
/* GPS box */
|
| 252 |
+
#gps-box {{
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
gap: 10px;
|
| 256 |
+
background: {C_GREEN_L};
|
| 257 |
+
border-radius: 10px;
|
| 258 |
+
padding: 10px 14px;
|
| 259 |
+
font-size: 13px;
|
| 260 |
+
color: #2e7d32;
|
| 261 |
+
font-weight: 500;
|
| 262 |
+
}}
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
# ─── GPS JavaScript ───────────────────────────────────────────────────────────
|
| 266 |
+
GPS_HTML = """
|
| 267 |
+
<div id="gps-box">
|
| 268 |
+
<span id="gps-icon" style="font-size:18px">📍</span>
|
| 269 |
+
<span id="gps-text">Detectando localização...</span>
|
| 270 |
+
</div>
|
| 271 |
+
<script>
|
| 272 |
+
(function() {
|
| 273 |
+
var icon = document.getElementById('gps-icon');
|
| 274 |
+
var text = document.getElementById('gps-text');
|
| 275 |
+
function setCoords(coords) {
|
| 276 |
+
// Atualiza hidden textbox do Gradio via evento sintético
|
| 277 |
+
var tb = document.querySelector('#gps-coords textarea');
|
| 278 |
+
if (tb) {
|
| 279 |
+
var nativeSet = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
| 280 |
+
nativeSet.call(tb, JSON.stringify(coords));
|
| 281 |
+
tb.dispatchEvent(new Event('input', { bubbles: true }));
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
if (!navigator.geolocation) {
|
| 285 |
+
icon.textContent = '⚠️';
|
| 286 |
+
text.textContent = 'GPS não disponível neste navegador.';
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
navigator.geolocation.getCurrentPosition(
|
| 290 |
+
function(pos) {
|
| 291 |
+
var lat = parseFloat(pos.coords.latitude.toFixed(5));
|
| 292 |
+
var lng = parseFloat(pos.coords.longitude.toFixed(5));
|
| 293 |
+
icon.textContent = '✅';
|
| 294 |
+
text.textContent = 'Localização: ' + lat + ', ' + lng;
|
| 295 |
+
setCoords({ lat: lat, lng: lng });
|
| 296 |
+
},
|
| 297 |
+
function(err) {
|
| 298 |
+
icon.textContent = '⚠️';
|
| 299 |
+
text.textContent = 'Localização não disponível — avistamento sem GPS.';
|
| 300 |
+
},
|
| 301 |
+
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
| 302 |
+
);
|
| 303 |
+
})();
|
| 304 |
+
</script>
|
| 305 |
+
"""
|
| 306 |
+
|
| 307 |
+
# ─── Leaflet map HTML ─────────────────────────────────────────────────────────
|
| 308 |
+
def build_map_html(species: str = "all", timeframe: str = "all") -> str:
|
| 309 |
+
data = db.get_map_data(species, timeframe)
|
| 310 |
+
data_js = json.dumps(data, ensure_ascii=False)
|
| 311 |
+
|
| 312 |
+
return f"""
|
| 313 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
| 314 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 315 |
+
<div id="pawmap" style="height:calc(100dvh - 185px);min-height:300px;width:100%;"></div>
|
| 316 |
+
<script>
|
| 317 |
+
(function() {{
|
| 318 |
+
if (window._pawmap) {{ window._pawmap.remove(); window._pawmap = null; }}
|
| 319 |
+
var map = L.map('pawmap', {{ zoomControl: true }}).setView([-23.0316, -46.9785], 13);
|
| 320 |
+
window._pawmap = map;
|
| 321 |
+
|
| 322 |
+
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
| 323 |
+
attribution: '© <a href="https://openstreetmap.org">OSM</a>',
|
| 324 |
+
maxZoom: 19
|
| 325 |
+
}}).addTo(map);
|
| 326 |
+
|
| 327 |
+
var animals = {data_js};
|
| 328 |
+
if (animals.length === 0) {{
|
| 329 |
+
var msg = L.control({{ position: 'topright' }});
|
| 330 |
+
msg.onAdd = function() {{
|
| 331 |
+
var d = L.DomUtil.create('div');
|
| 332 |
+
d.style.cssText = 'background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;box-shadow:0 2px 8px rgba(0,0,0,.1)';
|
| 333 |
+
d.textContent = 'Nenhum avistamento ainda 🐾';
|
| 334 |
+
return d;
|
| 335 |
+
}};
|
| 336 |
+
msg.addTo(map);
|
| 337 |
+
}}
|
| 338 |
+
|
| 339 |
+
animals.forEach(function(a) {{
|
| 340 |
+
var color = a.days_since > 30 ? '{C_RED}'
|
| 341 |
+
: a.count > 1 ? '{C_ORANGE}'
|
| 342 |
+
: '{C_GREEN}';
|
| 343 |
+
var em = a.species === 'dog' ? '🐕' : '🐈';
|
| 344 |
+
var badge = a.count > 1
|
| 345 |
+
? '<span style="position:absolute;top:-5px;right:-5px;background:white;color:' + color +
|
| 346 |
+
';border:1.5px solid ' + color + ';border-radius:10px;min-width:16px;height:16px;' +
|
| 347 |
+
'font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 2px;">'
|
| 348 |
+
+ a.count + '</span>'
|
| 349 |
+
: '';
|
| 350 |
+
var ico = L.divIcon({{
|
| 351 |
+
html: '<div style="position:relative;background:' + color + ';width:36px;height:36px;' +
|
| 352 |
+
'border-radius:50%;display:flex;align-items:center;justify-content:center;' +
|
| 353 |
+
'font-size:20px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;">' +
|
| 354 |
+
em + badge + '</div>',
|
| 355 |
+
className: '',
|
| 356 |
+
iconSize: [36, 36],
|
| 357 |
+
iconAnchor: [18, 18],
|
| 358 |
+
popupAnchor: [0, -20]
|
| 359 |
+
}});
|
| 360 |
+
var speciesPt = a.species === 'dog' ? 'Cão' : 'Gato';
|
| 361 |
+
var urgency = a.days_since > 30 ? '<br><span style="color:{C_RED};font-size:11px;">⚠️ Não visto há ' + a.days_since + ' dias</span>' : '';
|
| 362 |
+
L.marker([a.lat, a.lng], {{ icon: ico }}).addTo(map)
|
| 363 |
+
.bindPopup(
|
| 364 |
+
'<div style="min-width:160px;">' +
|
| 365 |
+
'<b style="font-size:14px;">' + em + ' ' + speciesPt + ' #' + a.id + '</b>' +
|
| 366 |
+
(a.desc ? '<br><span style="font-size:12px;color:#666;">' + a.desc + '</span>' : '') +
|
| 367 |
+
'<br><span style="font-size:12px;">👁 Visto <b>' + a.count + 'x</b> · último: ' + a.last_seen + '</span>' +
|
| 368 |
+
urgency + '</div>',
|
| 369 |
+
{{ maxWidth: 220 }}
|
| 370 |
+
);
|
| 371 |
+
}});
|
| 372 |
+
}})();
|
| 373 |
+
</script>
|
| 374 |
+
"""
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
# ─── Animals list HTML ────────────────────────────────────────────────────────
|
| 378 |
+
def build_animals_html() -> str:
|
| 379 |
+
animals = db.get_recent_animals(limit=30)
|
| 380 |
+
if not animals:
|
| 381 |
+
return (
|
| 382 |
+
'<div style="padding:40px 16px;text-align:center;color:#aaa;">'
|
| 383 |
+
'<div style="font-size:48px;margin-bottom:12px;">🐾</div>'
|
| 384 |
+
'<div style="font-size:15px;font-weight:500;">Nenhum animal registrado ainda</div>'
|
| 385 |
+
'<div style="font-size:13px;margin-top:6px;">Vá para Registrar e tire a primeira foto!</div>'
|
| 386 |
+
"</div>"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
cards = []
|
| 390 |
+
for a in animals:
|
| 391 |
+
try:
|
| 392 |
+
desc = json.loads(a.get("description") or "{}")
|
| 393 |
+
except Exception:
|
| 394 |
+
desc = {}
|
| 395 |
+
|
| 396 |
+
is_dog = a["species"] == "dog"
|
| 397 |
+
em = "🐕" if is_dog else "🐈"
|
| 398 |
+
sp_pt = "Cão" if is_dog else "Gato"
|
| 399 |
+
urgent = a.get("days_since", 0) > 30
|
| 400 |
+
color = C_RED if urgent else C_GREEN
|
| 401 |
+
badge_cls = "animal-badge urgent" if urgent else "animal-badge"
|
| 402 |
+
breed = desc.get("breed_estimate", "raça desconhecida")
|
| 403 |
+
color_coat = desc.get("primary_color", "")
|
| 404 |
+
meta = f"{breed}{' · ' + color_coat if color_coat else ''}"
|
| 405 |
+
last_seen = a.get("last_seen_short", "")
|
| 406 |
+
count = a["sighting_count"]
|
| 407 |
+
|
| 408 |
+
cards.append(f"""
|
| 409 |
+
<div class="animal-card">
|
| 410 |
+
<div class="animal-avatar" style="background:{color}20;">{em}</div>
|
| 411 |
+
<div class="animal-info">
|
| 412 |
+
<div class="animal-name">{sp_pt} #{a['id']}</div>
|
| 413 |
+
<div class="animal-meta">{meta}</div>
|
| 414 |
+
<div class="animal-meta" style="color:{color};">
|
| 415 |
+
{'⚠️ ' if urgent else ''}Visto {count}x · último: {last_seen}
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
<div class="{badge_cls}">{count}x</div>
|
| 419 |
+
</div>
|
| 420 |
+
""")
|
| 421 |
+
|
| 422 |
+
total_a = db.total_animals()
|
| 423 |
+
total_s = db.total_sightings()
|
| 424 |
+
header = (
|
| 425 |
+
f'<div class="section-header">🐾 {total_a} animais · {total_s} avistamentos</div>'
|
| 426 |
+
)
|
| 427 |
+
return header + "".join(cards)
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
# ─── Confirmation card HTML ───────────────────────────────────────────────────
|
| 431 |
+
def build_confirmation_html(animal_id: int, is_new: bool, count: int, species: str) -> str:
|
| 432 |
+
em = "🐕" if species == "dog" else "🐈"
|
| 433 |
+
sp_pt = "cão" if species == "dog" else "gato"
|
| 434 |
+
if is_new:
|
| 435 |
+
title = f"Novo {sp_pt} registrado!"
|
| 436 |
+
sub = "1º avistamento — obrigada por registrar! 🙏"
|
| 437 |
+
else:
|
| 438 |
+
title = f"{em} Animal reconhecido!"
|
| 439 |
+
sub = f"Este {sp_pt} já foi avistado <b>{count}x</b> na região."
|
| 440 |
+
|
| 441 |
+
return f"""
|
| 442 |
+
<div style="background:#e8f5e9;border-left:4px solid {C_GREEN};
|
| 443 |
+
border-radius:10px;padding:14px 16px;margin:8px 0;
|
| 444 |
+
font-size:14px;color:#2e7d32;line-height:1.6;">
|
| 445 |
+
<div style="font-size:24px;margin-bottom:6px;">✅ {em}</div>
|
| 446 |
+
<div style="font-weight:700;font-size:15px;">{title}</div>
|
| 447 |
+
<div>{sub}</div>
|
| 448 |
+
<div style="font-size:12px;margin-top:6px;color:#555;">
|
| 449 |
+
ID #{animal_id} · Avistamento salvo com localização
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
"""
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ─── Backend: processar avistamento ──────────────────────────────────────────
|
| 456 |
+
def process_sighting(image, gps_json: str, notes: str, progress=gr.Progress()):
|
| 457 |
+
if image is None:
|
| 458 |
+
return (
|
| 459 |
+
gr.update(),
|
| 460 |
+
'<div style="color:#b71c1c;background:#ffebee;border-left:4px solid #E53935;'
|
| 461 |
+
'border-radius:10px;padding:12px 16px;font-size:14px;">'
|
| 462 |
+
"❌ Tire uma foto do animal antes de registrar.</div>",
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
progress(0.15, desc="Analisando imagem...")
|
| 466 |
+
try:
|
| 467 |
+
coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
|
| 468 |
+
except Exception:
|
| 469 |
+
coords = {}
|
| 470 |
+
|
| 471 |
+
lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
|
| 472 |
+
lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
|
| 473 |
+
|
| 474 |
+
progress(0.40, desc="Identificando animal com IA...")
|
| 475 |
+
description = ai.analyze_image(image)
|
| 476 |
+
embedding = ai.get_embedding(description)
|
| 477 |
+
|
| 478 |
+
progress(0.70, desc="Verificando avistamentos anteriores...")
|
| 479 |
+
candidates = db.get_all_animals_with_embeddings()
|
| 480 |
+
match = matcher.find_match(embedding, candidates)
|
| 481 |
+
|
| 482 |
+
photo_path = db.save_photo(image)
|
| 483 |
+
|
| 484 |
+
if match:
|
| 485 |
+
animal_id, _score = match
|
| 486 |
+
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 487 |
+
db.update_animal(animal_id)
|
| 488 |
+
animal = db.get_animal(animal_id)
|
| 489 |
+
count = animal["sighting_count"]
|
| 490 |
+
species = animal["species"]
|
| 491 |
+
is_new = False
|
| 492 |
+
else:
|
| 493 |
+
animal_id = db.create_animal(description, embedding)
|
| 494 |
+
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 495 |
+
count = 1
|
| 496 |
+
species = description.get("species", "dog")
|
| 497 |
+
is_new = True
|
| 498 |
+
|
| 499 |
+
progress(1.0, desc="Salvo!")
|
| 500 |
+
html = build_confirmation_html(animal_id, is_new, count, species)
|
| 501 |
+
return gr.update(value=build_map_html()), html
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# ─── Filter helpers ───────────────────────────────────────────────────────────
|
| 505 |
+
def filter_map(species: str, timeframe: str):
|
| 506 |
+
return gr.update(value=build_map_html(species, timeframe)), species, timeframe
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# ─── Stats for top bar ────────────────────────────────────────────────────────
|
| 510 |
+
def get_stats_html():
|
| 511 |
+
a = db.total_animals()
|
| 512 |
+
s = db.total_sightings()
|
| 513 |
+
return f'<div id="stats-badge">{a} animais · {s} avistamentos</div>'
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
# ─── Gradio app ───────────────────────────────────────────────────────────────
|
| 517 |
+
with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="Animal Visto 🐾") as demo:
|
| 518 |
+
|
| 519 |
+
# Top bar
|
| 520 |
+
gr.HTML(
|
| 521 |
+
'<div id="top-bar">'
|
| 522 |
+
'<h1>🐾 Animal Visto</h1>'
|
| 523 |
+
'<small>Vinhedo, SP</small>'
|
| 524 |
+
"</div>"
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
species_st = gr.State("all")
|
| 528 |
+
timeframe_st = gr.State("all")
|
| 529 |
+
|
| 530 |
+
with gr.Tabs(elem_classes="tabs") as tabs:
|
| 531 |
+
|
| 532 |
+
# ── Tab 1: Mapa ───────────────────────────────────────────────────────
|
| 533 |
+
with gr.Tab("🗺️ Mapa"):
|
| 534 |
+
with gr.Row(elem_classes="filter-row"):
|
| 535 |
+
btn_all = gr.Button("🐾 Todos", elem_classes="filter-row")
|
| 536 |
+
btn_dogs = gr.Button("🐕 Cães", elem_classes="filter-row")
|
| 537 |
+
btn_cats = gr.Button("🐈 Gatos", elem_classes="filter-row")
|
| 538 |
+
btn_today = gr.Button("Hoje", elem_classes="filter-row secondary")
|
| 539 |
+
btn_week = gr.Button("Esta semana", elem_classes="filter-row secondary")
|
| 540 |
+
|
| 541 |
+
map_html = gr.HTML(build_map_html(), elem_id="map-container")
|
| 542 |
+
|
| 543 |
+
# ── Tab 2: Registrar ──────────────────────────────────────────────────
|
| 544 |
+
with gr.Tab("📷 Registrar", elem_id="register-tab"):
|
| 545 |
+
with gr.Column(elem_classes="reg-section"):
|
| 546 |
+
gr.HTML(GPS_HTML)
|
| 547 |
+
|
| 548 |
+
# Hidden textbox recebe coords do JS
|
| 549 |
+
gps_coords = gr.Textbox(
|
| 550 |
+
value="",
|
| 551 |
+
visible=False,
|
| 552 |
+
elem_id="gps-coords",
|
| 553 |
+
interactive=True,
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
with gr.Column(elem_classes="reg-section"):
|
| 557 |
+
gr.HTML('<div class="reg-label">📸 Foto do animal</div>')
|
| 558 |
+
photo_input = gr.Image(
|
| 559 |
+
label="",
|
| 560 |
+
type="pil",
|
| 561 |
+
sources=["upload", "webcam"],
|
| 562 |
+
interactive=True,
|
| 563 |
+
show_label=False,
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
with gr.Column(elem_classes="reg-section"):
|
| 567 |
+
gr.HTML('<div class="reg-label">📝 Observações (opcional)</div>')
|
| 568 |
+
notes_input = gr.Textbox(
|
| 569 |
+
label="",
|
| 570 |
+
placeholder="Ex: parece ferido, tem coleira, está com filhotes...",
|
| 571 |
+
lines=2,
|
| 572 |
+
max_lines=4,
|
| 573 |
+
show_label=False,
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
submit_btn = gr.Button(
|
| 577 |
+
"📍 Registrar avistamento",
|
| 578 |
+
variant="primary",
|
| 579 |
+
elem_id="submit-btn",
|
| 580 |
+
)
|
| 581 |
+
status_html = gr.HTML("", elem_id="status-card")
|
| 582 |
+
|
| 583 |
+
# ── Tab 3: Avistados ──────────────────────────────────────────────────
|
| 584 |
+
with gr.Tab("🐾 Avistados", elem_id="animals-tab"):
|
| 585 |
+
refresh_btn = gr.Button("🔄 Atualizar lista", size="sm", variant="secondary")
|
| 586 |
+
animals_display = gr.HTML(build_animals_html())
|
| 587 |
+
|
| 588 |
+
# ─── Events ───────────────────────────────────────────────────────────────
|
| 589 |
+
|
| 590 |
+
# Registrar avistamento
|
| 591 |
+
submit_btn.click(
|
| 592 |
+
process_sighting,
|
| 593 |
+
inputs=[photo_input, gps_coords, notes_input],
|
| 594 |
+
outputs=[map_html, status_html],
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
# Filtros do mapa — Todos
|
| 598 |
+
btn_all.click(
|
| 599 |
+
lambda: (build_map_html("all", "all"), "all", "all"),
|
| 600 |
+
outputs=[map_html, species_st, timeframe_st],
|
| 601 |
+
)
|
| 602 |
+
# Cães (mantém timeframe)
|
| 603 |
+
btn_dogs.click(
|
| 604 |
+
lambda t: (build_map_html("dog", t), "dog", t),
|
| 605 |
+
inputs=[timeframe_st],
|
| 606 |
+
outputs=[map_html, species_st, timeframe_st],
|
| 607 |
+
)
|
| 608 |
+
# Gatos (mantém timeframe)
|
| 609 |
+
btn_cats.click(
|
| 610 |
+
lambda t: (build_map_html("cat", t), "cat", t),
|
| 611 |
+
inputs=[timeframe_st],
|
| 612 |
+
outputs=[map_html, species_st, timeframe_st],
|
| 613 |
+
)
|
| 614 |
+
# Hoje (mantém espécie)
|
| 615 |
+
btn_today.click(
|
| 616 |
+
lambda s: (build_map_html(s, "today"), s, "today"),
|
| 617 |
+
inputs=[species_st],
|
| 618 |
+
outputs=[map_html, species_st, timeframe_st],
|
| 619 |
+
)
|
| 620 |
+
# Esta semana (mantém espécie)
|
| 621 |
+
btn_week.click(
|
| 622 |
+
lambda s: (build_map_html(s, "week"), s, "week"),
|
| 623 |
+
inputs=[species_st],
|
| 624 |
+
outputs=[map_html, species_st, timeframe_st],
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Atualizar lista de avistados
|
| 628 |
+
refresh_btn.click(build_animals_html, outputs=[animals_display])
|
| 629 |
+
|
| 630 |
+
# Recarregar mapa ao abrir o app
|
| 631 |
+
demo.load(build_map_html, outputs=[map_html])
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
if __name__ == "__main__":
|
| 635 |
+
demo.launch(
|
| 636 |
+
server_name="0.0.0.0",
|
| 637 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 638 |
+
show_error=True,
|
| 639 |
+
)
|
core/__init__.py
ADDED
|
File without changes
|
core/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (157 Bytes). View file
|
|
|
core/__pycache__/database.cpython-310.pyc
ADDED
|
Binary file (8.96 kB). View file
|
|
|
core/__pycache__/matcher.cpython-310.pyc
ADDED
|
Binary file (1.76 kB). View file
|
|
|
core/ai.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ai.py — Integração com HF Inference API + sentence-transformers.
|
| 3 |
+
|
| 4 |
+
Variáveis de ambiente:
|
| 5 |
+
HF_TOKEN — token do Hugging Face (obrigatório para análise de imagem)
|
| 6 |
+
HF_MODEL — modelo de visão a usar (padrão: meta-llama/Llama-3.2-11B-Vision-Instruct)
|
| 7 |
+
"""
|
| 8 |
+
import base64
|
| 9 |
+
import io
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
log = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_DEFAULT_MODEL = "meta-llama/Llama-3.2-11B-Vision-Instruct"
|
| 20 |
+
|
| 21 |
+
PROMPT = (
|
| 22 |
+
"You are analyzing a photo of a stray animal. "
|
| 23 |
+
"Return ONLY a valid JSON object with these exact fields:\n"
|
| 24 |
+
'{"species":"dog or cat",'
|
| 25 |
+
'"breed_estimate":"mixed or specific breed",'
|
| 26 |
+
'"size":"small or medium or large",'
|
| 27 |
+
'"primary_color":"main coat color",'
|
| 28 |
+
'"secondary_colors":["list of other colors or empty"],'
|
| 29 |
+
'"distinctive_marks":["any spots, patches, scars, collar etc or empty"],'
|
| 30 |
+
'"condition":"healthy or thin or injured",'
|
| 31 |
+
'"description_text":"one concise sentence describing this specific animal for identity matching"}'
|
| 32 |
+
"\nReturn only the JSON, no explanation."
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class AnimalAI:
|
| 37 |
+
def __init__(self):
|
| 38 |
+
token = os.environ.get("HF_TOKEN", "")
|
| 39 |
+
self.model = os.environ.get("HF_MODEL", _DEFAULT_MODEL)
|
| 40 |
+
self.client = None
|
| 41 |
+
|
| 42 |
+
if token:
|
| 43 |
+
try:
|
| 44 |
+
from huggingface_hub import InferenceClient
|
| 45 |
+
self.client = InferenceClient(model=self.model, token=token)
|
| 46 |
+
log.info(f"HF InferenceClient initialized: {self.model}")
|
| 47 |
+
except ImportError:
|
| 48 |
+
log.warning("huggingface_hub not installed. AI analysis disabled.")
|
| 49 |
+
else:
|
| 50 |
+
log.warning("HF_TOKEN not set — AI analysis will use fallback description.")
|
| 51 |
+
|
| 52 |
+
# sentence-transformers para embeddings (gratuito, roda local no Space)
|
| 53 |
+
self.embedder = None
|
| 54 |
+
try:
|
| 55 |
+
from sentence_transformers import SentenceTransformer
|
| 56 |
+
self.embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 57 |
+
log.info("sentence-transformers loaded: all-MiniLM-L6-v2")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
log.warning(f"Could not load sentence-transformers: {e}")
|
| 60 |
+
|
| 61 |
+
# ─── Public API ───────────────────────────────────────────────────────────
|
| 62 |
+
|
| 63 |
+
def analyze_image(self, image) -> dict:
|
| 64 |
+
"""
|
| 65 |
+
Recebe PIL.Image, retorna dict com descrição estruturada do animal.
|
| 66 |
+
Usa HF Inference API (créditos HF). Fallback se token não configurado.
|
| 67 |
+
"""
|
| 68 |
+
if self.client is None:
|
| 69 |
+
log.info("No HF client — returning fallback description.")
|
| 70 |
+
return self._fallback()
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
img_b64 = self._image_to_b64(image)
|
| 74 |
+
response = self.client.chat.completions.create(
|
| 75 |
+
messages=[
|
| 76 |
+
{
|
| 77 |
+
"role": "user",
|
| 78 |
+
"content": [
|
| 79 |
+
{
|
| 80 |
+
"type": "image_url",
|
| 81 |
+
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
|
| 82 |
+
},
|
| 83 |
+
{"type": "text", "text": PROMPT},
|
| 84 |
+
],
|
| 85 |
+
}
|
| 86 |
+
],
|
| 87 |
+
max_tokens=300,
|
| 88 |
+
temperature=0.1,
|
| 89 |
+
)
|
| 90 |
+
raw = response.choices[0].message.content or ""
|
| 91 |
+
return self._parse_json(raw)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
log.error(f"HF Inference error: {e}")
|
| 94 |
+
return self._fallback()
|
| 95 |
+
|
| 96 |
+
def get_embedding(self, description: dict) -> list:
|
| 97 |
+
"""
|
| 98 |
+
Gera embedding (384-dim, float32) a partir da descrição textual.
|
| 99 |
+
Roda 100% local com sentence-transformers — sem custo de API.
|
| 100 |
+
"""
|
| 101 |
+
if self.embedder is None:
|
| 102 |
+
vec = np.random.randn(384).astype(np.float32)
|
| 103 |
+
vec /= np.linalg.norm(vec)
|
| 104 |
+
return vec.tolist()
|
| 105 |
+
|
| 106 |
+
text = description.get("description_text") or self._desc_to_text(description)
|
| 107 |
+
return self.embedder.encode(text, normalize_embeddings=True).tolist()
|
| 108 |
+
|
| 109 |
+
# ─── Helpers ──────────────────────────────────────────────────────────────
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def _image_to_b64(image) -> str:
|
| 113 |
+
buf = io.BytesIO()
|
| 114 |
+
image.thumbnail((800, 800)) # economiza tokens/créditos
|
| 115 |
+
image.save(buf, format="JPEG", quality=80)
|
| 116 |
+
return base64.b64encode(buf.getvalue()).decode()
|
| 117 |
+
|
| 118 |
+
@staticmethod
|
| 119 |
+
def _parse_json(raw: str) -> dict:
|
| 120 |
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 121 |
+
if match:
|
| 122 |
+
try:
|
| 123 |
+
return json.loads(match.group())
|
| 124 |
+
except json.JSONDecodeError:
|
| 125 |
+
pass
|
| 126 |
+
return AnimalAI._fallback()
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def _desc_to_text(d: dict) -> str:
|
| 130 |
+
parts = [d.get("size", ""), d.get("primary_color", ""), d.get("species", ""), d.get("breed_estimate", "")]
|
| 131 |
+
marks = d.get("distinctive_marks", [])
|
| 132 |
+
if marks:
|
| 133 |
+
parts.append("with " + ", ".join(marks))
|
| 134 |
+
return " ".join(p for p in parts if p).strip() or "unknown animal"
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _fallback() -> dict:
|
| 138 |
+
return {
|
| 139 |
+
"species": "dog",
|
| 140 |
+
"breed_estimate": "mixed",
|
| 141 |
+
"size": "medium",
|
| 142 |
+
"primary_color": "brown",
|
| 143 |
+
"secondary_colors": [],
|
| 144 |
+
"distinctive_marks": [],
|
| 145 |
+
"condition": "healthy",
|
| 146 |
+
"description_text": "medium brown mixed breed dog",
|
| 147 |
+
}
|
core/database.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
database.py — SQLite CRUD para animais e avistamentos.
|
| 3 |
+
Usa /data/ em produção (HF Storage Bucket) ou ./data/ localmente.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sqlite3
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image
|
| 15 |
+
|
| 16 |
+
# ─── Paths ────────────────────────────────────────────────────────────────────
|
| 17 |
+
_hf_data = Path("/data")
|
| 18 |
+
DATA_DIR = _hf_data if (_hf_data.exists() and os.access(_hf_data, os.W_OK)) else Path("./data")
|
| 19 |
+
DB_PATH = DATA_DIR / "viralata.db"
|
| 20 |
+
PHOTOS_DIR = DATA_DIR / "photos"
|
| 21 |
+
SCHEMA = Path(__file__).parent.parent / "db" / "schema.sql"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Database:
|
| 25 |
+
def __init__(self):
|
| 26 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
self._init_db()
|
| 29 |
+
|
| 30 |
+
# ─── Internal ─────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def _conn(self) -> sqlite3.Connection:
|
| 33 |
+
conn = sqlite3.connect(str(DB_PATH))
|
| 34 |
+
conn.row_factory = sqlite3.Row
|
| 35 |
+
try:
|
| 36 |
+
conn.execute("PRAGMA journal_mode=WAL") # pode falhar em FUSE mounts
|
| 37 |
+
except sqlite3.OperationalError:
|
| 38 |
+
pass
|
| 39 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 40 |
+
return conn
|
| 41 |
+
|
| 42 |
+
def _init_db(self):
|
| 43 |
+
with self._conn() as conn:
|
| 44 |
+
if SCHEMA.exists():
|
| 45 |
+
conn.executescript(SCHEMA.read_text())
|
| 46 |
+
else:
|
| 47 |
+
conn.executescript("""
|
| 48 |
+
CREATE TABLE IF NOT EXISTS animals (
|
| 49 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 50 |
+
species TEXT NOT NULL,
|
| 51 |
+
description TEXT,
|
| 52 |
+
embedding BLOB,
|
| 53 |
+
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 54 |
+
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 55 |
+
sighting_count INTEGER DEFAULT 1
|
| 56 |
+
);
|
| 57 |
+
CREATE TABLE IF NOT EXISTS sightings (
|
| 58 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 59 |
+
animal_id INTEGER NOT NULL REFERENCES animals(id),
|
| 60 |
+
photo_path TEXT,
|
| 61 |
+
latitude REAL,
|
| 62 |
+
longitude REAL,
|
| 63 |
+
notes TEXT,
|
| 64 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 65 |
+
);
|
| 66 |
+
""")
|
| 67 |
+
|
| 68 |
+
# ─── Photos ───────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
def save_photo(self, image: Image.Image) -> str:
|
| 71 |
+
"""Salva PIL Image em /data/photos/, retorna caminho relativo."""
|
| 72 |
+
filename = f"{uuid.uuid4().hex}.jpg"
|
| 73 |
+
path = PHOTOS_DIR / filename
|
| 74 |
+
image.save(str(path), format="JPEG", quality=85)
|
| 75 |
+
return f"photos/{filename}"
|
| 76 |
+
|
| 77 |
+
def photo_url(self, relative_path: Optional[str]) -> Optional[str]:
|
| 78 |
+
"""Retorna path absoluto para exibição, ou None."""
|
| 79 |
+
if not relative_path:
|
| 80 |
+
return None
|
| 81 |
+
full = DATA_DIR / relative_path
|
| 82 |
+
return str(full) if full.exists() else None
|
| 83 |
+
|
| 84 |
+
# ─── Animals ──────────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
def create_animal(self, description: dict, embedding: list) -> int:
|
| 87 |
+
"""Cria novo animal, retorna seu id."""
|
| 88 |
+
emb_blob = np.array(embedding, dtype=np.float32).tobytes()
|
| 89 |
+
with self._conn() as conn:
|
| 90 |
+
cur = conn.execute(
|
| 91 |
+
"""INSERT INTO animals (species, description, embedding)
|
| 92 |
+
VALUES (?, ?, ?)""",
|
| 93 |
+
(
|
| 94 |
+
description.get("species", "dog"),
|
| 95 |
+
json.dumps(description, ensure_ascii=False),
|
| 96 |
+
emb_blob,
|
| 97 |
+
),
|
| 98 |
+
)
|
| 99 |
+
return cur.lastrowid
|
| 100 |
+
|
| 101 |
+
def update_animal(self, animal_id: int):
|
| 102 |
+
"""Incrementa sighting_count e atualiza last_seen."""
|
| 103 |
+
with self._conn() as conn:
|
| 104 |
+
conn.execute(
|
| 105 |
+
"""UPDATE animals
|
| 106 |
+
SET sighting_count = sighting_count + 1,
|
| 107 |
+
last_seen = CURRENT_TIMESTAMP
|
| 108 |
+
WHERE id = ?""",
|
| 109 |
+
(animal_id,),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def get_animal(self, animal_id: int) -> Optional[dict]:
|
| 113 |
+
with self._conn() as conn:
|
| 114 |
+
row = conn.execute(
|
| 115 |
+
"SELECT * FROM animals WHERE id = ?", (animal_id,)
|
| 116 |
+
).fetchone()
|
| 117 |
+
return dict(row) if row else None
|
| 118 |
+
|
| 119 |
+
def get_all_animals_with_embeddings(self) -> list[dict]:
|
| 120 |
+
"""Retorna todos os animais com embedding desserializado para matching."""
|
| 121 |
+
with self._conn() as conn:
|
| 122 |
+
rows = conn.execute(
|
| 123 |
+
"SELECT id, species, description, embedding FROM animals WHERE embedding IS NOT NULL"
|
| 124 |
+
).fetchall()
|
| 125 |
+
result = []
|
| 126 |
+
for row in rows:
|
| 127 |
+
d = dict(row)
|
| 128 |
+
blob = d.pop("embedding")
|
| 129 |
+
try:
|
| 130 |
+
d["embedding"] = np.frombuffer(blob, dtype=np.float32).tolist()
|
| 131 |
+
except Exception:
|
| 132 |
+
d["embedding"] = None
|
| 133 |
+
result.append(d)
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
# ─── Sightings ────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
def add_sighting(
|
| 139 |
+
self,
|
| 140 |
+
animal_id: int,
|
| 141 |
+
photo_path: Optional[str],
|
| 142 |
+
lat: Optional[float],
|
| 143 |
+
lng: Optional[float],
|
| 144 |
+
notes: Optional[str],
|
| 145 |
+
):
|
| 146 |
+
with self._conn() as conn:
|
| 147 |
+
conn.execute(
|
| 148 |
+
"""INSERT INTO sightings (animal_id, photo_path, latitude, longitude, notes)
|
| 149 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 150 |
+
(animal_id, photo_path, lat, lng, notes or ""),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
def get_animal_sightings(self, animal_id: int) -> list[dict]:
|
| 154 |
+
with self._conn() as conn:
|
| 155 |
+
rows = conn.execute(
|
| 156 |
+
"""SELECT * FROM sightings WHERE animal_id = ?
|
| 157 |
+
ORDER BY created_at DESC""",
|
| 158 |
+
(animal_id,),
|
| 159 |
+
).fetchall()
|
| 160 |
+
return [dict(r) for r in rows]
|
| 161 |
+
|
| 162 |
+
# ─── Map data ─────────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
def get_map_data(self, species: str = "all", timeframe: str = "all") -> list[dict]:
|
| 165 |
+
"""
|
| 166 |
+
Retorna dados de todos os animais para os pins do mapa.
|
| 167 |
+
Usa a última localização conhecida de cada animal.
|
| 168 |
+
"""
|
| 169 |
+
filters = []
|
| 170 |
+
params: list = []
|
| 171 |
+
|
| 172 |
+
if species in ("dog", "cat"):
|
| 173 |
+
filters.append("a.species = ?")
|
| 174 |
+
params.append(species)
|
| 175 |
+
if timeframe == "today":
|
| 176 |
+
filters.append("date(a.last_seen) = date('now')")
|
| 177 |
+
elif timeframe == "week":
|
| 178 |
+
filters.append("a.last_seen >= datetime('now', '-7 days')")
|
| 179 |
+
|
| 180 |
+
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
| 181 |
+
# Subquery para pegar a última sighting com coordenadas
|
| 182 |
+
sql = f"""
|
| 183 |
+
SELECT
|
| 184 |
+
a.id,
|
| 185 |
+
a.species,
|
| 186 |
+
a.sighting_count AS count,
|
| 187 |
+
a.description,
|
| 188 |
+
strftime('%d/%m/%Y', a.last_seen) AS last_seen,
|
| 189 |
+
CAST(julianday('now') - julianday(a.last_seen) AS INTEGER) AS days_since,
|
| 190 |
+
s.latitude AS lat,
|
| 191 |
+
s.longitude AS lng
|
| 192 |
+
FROM animals a
|
| 193 |
+
JOIN sightings s ON s.id = (
|
| 194 |
+
SELECT id FROM sightings
|
| 195 |
+
WHERE animal_id = a.id AND latitude IS NOT NULL
|
| 196 |
+
ORDER BY created_at DESC LIMIT 1
|
| 197 |
+
)
|
| 198 |
+
{where}
|
| 199 |
+
ORDER BY a.last_seen DESC
|
| 200 |
+
"""
|
| 201 |
+
with self._conn() as conn:
|
| 202 |
+
rows = conn.execute(sql, params).fetchall()
|
| 203 |
+
|
| 204 |
+
result = []
|
| 205 |
+
for row in rows:
|
| 206 |
+
d = dict(row)
|
| 207 |
+
try:
|
| 208 |
+
desc_obj = json.loads(d["description"] or "{}")
|
| 209 |
+
d["desc"] = desc_obj.get("description_text") or (
|
| 210 |
+
f"{desc_obj.get('size','')} {desc_obj.get('primary_color','')} "
|
| 211 |
+
f"{desc_obj.get('breed_estimate','')}"
|
| 212 |
+
).strip()
|
| 213 |
+
except Exception:
|
| 214 |
+
d["desc"] = ""
|
| 215 |
+
del d["description"]
|
| 216 |
+
result.append(d)
|
| 217 |
+
return result
|
| 218 |
+
|
| 219 |
+
# ─── Animals list ─────────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
def get_recent_animals(self, limit: int = 30) -> list[dict]:
|
| 222 |
+
sql = """
|
| 223 |
+
SELECT
|
| 224 |
+
a.*,
|
| 225 |
+
CAST(julianday('now') - julianday(a.last_seen) AS INTEGER) AS days_since,
|
| 226 |
+
strftime('%d/%m', a.last_seen) AS last_seen_short
|
| 227 |
+
FROM animals a
|
| 228 |
+
ORDER BY a.last_seen DESC
|
| 229 |
+
LIMIT ?
|
| 230 |
+
"""
|
| 231 |
+
with self._conn() as conn:
|
| 232 |
+
rows = conn.execute(sql, (limit,)).fetchall()
|
| 233 |
+
return [dict(r) for r in rows]
|
| 234 |
+
|
| 235 |
+
def total_sightings(self) -> int:
|
| 236 |
+
with self._conn() as conn:
|
| 237 |
+
return conn.execute("SELECT COUNT(*) FROM sightings").fetchone()[0]
|
| 238 |
+
|
| 239 |
+
def total_animals(self) -> int:
|
| 240 |
+
with self._conn() as conn:
|
| 241 |
+
return conn.execute("SELECT COUNT(*) FROM animals").fetchone()[0]
|
core/matcher.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
matcher.py — Cosine similarity para identificar se dois avistamentos são do mesmo animal.
|
| 3 |
+
|
| 4 |
+
Threshold padrão: 0.80 (ajustável via MATCH_THRESHOLD env var).
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
from typing import Optional, Tuple
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
THRESHOLD = float(os.environ.get("MATCH_THRESHOLD", "0.80"))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AnimalMatcher:
|
| 15 |
+
def find_match(
|
| 16 |
+
self,
|
| 17 |
+
new_embedding: list,
|
| 18 |
+
candidates: list[dict],
|
| 19 |
+
) -> Optional[Tuple[int, float]]:
|
| 20 |
+
"""
|
| 21 |
+
Compara new_embedding com os embeddings de candidates.
|
| 22 |
+
Retorna (animal_id, score) do melhor match acima do threshold,
|
| 23 |
+
ou None se nenhum match encontrado.
|
| 24 |
+
|
| 25 |
+
candidates: lista de dicts com chaves 'id' e 'embedding' (list[float]).
|
| 26 |
+
"""
|
| 27 |
+
if not candidates or not new_embedding:
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
new_vec = np.array(new_embedding, dtype=np.float32)
|
| 31 |
+
|
| 32 |
+
best_id: Optional[int] = None
|
| 33 |
+
best_score: float = 0.0
|
| 34 |
+
|
| 35 |
+
for animal in candidates:
|
| 36 |
+
emb = animal.get("embedding")
|
| 37 |
+
if not emb:
|
| 38 |
+
continue
|
| 39 |
+
score = self._cosine(new_vec, np.array(emb, dtype=np.float32))
|
| 40 |
+
if score > best_score:
|
| 41 |
+
best_score = score
|
| 42 |
+
best_id = animal["id"]
|
| 43 |
+
|
| 44 |
+
if best_score >= THRESHOLD:
|
| 45 |
+
return best_id, best_score
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
@staticmethod
|
| 49 |
+
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
|
| 50 |
+
norm_a = np.linalg.norm(a)
|
| 51 |
+
norm_b = np.linalg.norm(b)
|
| 52 |
+
if norm_a == 0.0 or norm_b == 0.0:
|
| 53 |
+
return 0.0
|
| 54 |
+
return float(np.dot(a, b) / (norm_a * norm_b))
|
data/viralata.db
ADDED
|
File without changes
|
data/viralata.db-journal
ADDED
|
Binary file (512 Bytes). View file
|
|
|
db/schema.sql
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE TABLE IF NOT EXISTS animals (
|
| 2 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 3 |
+
species TEXT NOT NULL CHECK (species IN ('dog', 'cat')),
|
| 4 |
+
description TEXT, -- JSON com atributos do Nemotron
|
| 5 |
+
embedding BLOB, -- vetor numpy serializado (float32, 384-dim)
|
| 6 |
+
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 7 |
+
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 8 |
+
sighting_count INTEGER NOT NULL DEFAULT 1
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
CREATE TABLE IF NOT EXISTS sightings (
|
| 12 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 13 |
+
animal_id INTEGER NOT NULL REFERENCES animals(id) ON DELETE CASCADE,
|
| 14 |
+
photo_path TEXT, -- caminho relativo em /data/photos/
|
| 15 |
+
latitude REAL,
|
| 16 |
+
longitude REAL,
|
| 17 |
+
notes TEXT,
|
| 18 |
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
CREATE INDEX IF NOT EXISTS idx_sightings_animal ON sightings(animal_id);
|
| 22 |
+
CREATE INDEX IF NOT EXISTS idx_animals_last_seen ON animals(last_seen);
|
| 23 |
+
CREATE INDEX IF NOT EXISTS idx_animals_species ON animals(species);
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.0
|
| 2 |
+
pillow>=10.0.0
|
| 3 |
+
huggingface_hub>=0.23.0
|
| 4 |
+
sentence-transformers>=2.7.0
|
| 5 |
+
numpy>=1.26.0
|
setup-git.sh
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# setup-git.sh — Conecta o projeto ao HF Space e faz o primeiro push.
|
| 3 |
+
# Execute no terminal dentro da pasta animal-visto/
|
| 4 |
+
#
|
| 5 |
+
# Antes de rodar:
|
| 6 |
+
# 1. Crie um token de escrita em https://huggingface.co/settings/tokens
|
| 7 |
+
# 2. Exporte: export HF_TOKEN=hf_xxxxxxxxxxxxxxxxx
|
| 8 |
+
# 3. chmod +x setup-git.sh && ./setup-git.sh
|
| 9 |
+
|
| 10 |
+
set -e
|
| 11 |
+
|
| 12 |
+
SPACE_URL="https://huggingface.co/spaces/build-small-hackathon/pawmap"
|
| 13 |
+
HF_GIT_URL="https://user:${HF_TOKEN}@huggingface.co/spaces/build-small-hackathon/pawmap"
|
| 14 |
+
|
| 15 |
+
echo "🐾 Animal Visto — setup git"
|
| 16 |
+
|
| 17 |
+
# Inicializar git se ainda não existir
|
| 18 |
+
if [ ! -d ".git" ]; then
|
| 19 |
+
git init
|
| 20 |
+
git branch -M main
|
| 21 |
+
fi
|
| 22 |
+
|
| 23 |
+
# Configurar remote
|
| 24 |
+
if git remote get-url origin &>/dev/null; then
|
| 25 |
+
git remote set-url origin "$HF_GIT_URL"
|
| 26 |
+
else
|
| 27 |
+
git remote add origin "$HF_GIT_URL"
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
# LFS para arquivos grandes (HF recomenda)
|
| 31 |
+
git lfs install
|
| 32 |
+
git lfs track "*.jpg" "*.png" "*.db"
|
| 33 |
+
git add .gitattributes 2>/dev/null || true
|
| 34 |
+
|
| 35 |
+
# Commit e push
|
| 36 |
+
git add .
|
| 37 |
+
git commit -m "feat: initial Animal Visto app
|
| 38 |
+
|
| 39 |
+
- Gradio UI mobile-first com mapa Leaflet
|
| 40 |
+
- Integração NVIDIA NIM (Nemotron Nano VL)
|
| 41 |
+
- Matching por cosine similarity (sentence-transformers)
|
| 42 |
+
- SQLite persistente via HF Storage Bucket"
|
| 43 |
+
|
| 44 |
+
echo ""
|
| 45 |
+
echo "Fazendo push para $SPACE_URL ..."
|
| 46 |
+
git push --force origin main
|
| 47 |
+
|
| 48 |
+
echo ""
|
| 49 |
+
echo "✅ Deploy feito! Acesse: $SPACE_URL"
|
| 50 |
+
echo ""
|
| 51 |
+
echo "⚙️ Não esqueça de configurar os secrets no Space Settings:"
|
| 52 |
+
echo " NVIDIA_API_KEY = sua chave em https://build.nvidia.com"
|