Sarolanda commited on
Commit
b43c522
Β·
1 Parent(s): 2e97ac3

adds error treat

Browse files
Files changed (6) hide show
  1. README.md +39 -27
  2. app.py +13 -1
  3. core/ai.py +17 -6
  4. core/seed.py +185 -0
  5. index.html +24 -2
  6. static/style.css +12 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Animal Visto
3
  emoji: 🐾
4
  colorFrom: green
5
  colorTo: green
@@ -10,49 +10,61 @@ license: mit
10
  short_description: Mapeamento colaborativo de animais de rua com IA
11
  ---
12
 
13
- # 🐾 Animal Visto
14
 
15
- **Mapeamento colaborativo de animais de rua** Β· Build Small Hackathon Β· Trilha Backyard AI
 
16
 
17
- 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.
18
 
19
- ## Como funciona
20
 
21
- 1. **πŸ“· Registrar** β€” tire uma foto, confirme a localizaΓ§Γ£o GPS e envie
22
- 2. **πŸ€– IA (Nemotron Nano VL)** β€” identifica espΓ©cie, raΓ§a, cor e marcaΓ§Γ΅es
23
- 3. **πŸ” Matching** β€” cosine similarity entre embeddings verifica se Γ© o mesmo animal
24
- 4. **πŸ—ΊοΈ Mapa** β€” pin colorido no mapa com histΓ³rico de avistamentos
 
 
 
 
25
 
26
- ## Secrets necessΓ‘rios no HF Space
 
 
 
 
 
 
 
27
 
28
  | Secret | DescriΓ§Γ£o |
29
  |--------|-----------|
30
- | `HF_TOKEN` | Token HF com permissΓ£o de leitura (Settings β†’ Tokens) |
31
- | `HF_MODEL` | Opcional. PadrΓ£o: `nvidia/Llama-3.2-Nemotron-11B-Vision-Instruct` |
32
  | `MATCH_THRESHOLD` | Opcional. Threshold de similaridade. PadrΓ£o: `0.80` |
33
 
34
- > Os embeddings rodam com `sentence-transformers` **localmente no Space** (sem custo de API).
35
- > SΓ³ a anΓ‘lise de imagem usa crΓ©ditos HF (1 chamada por foto enviada).
36
 
37
- ## Storage Bucket
38
 
39
- Configure um Persistent Storage Bucket no Space para que `/data/` persista entre restarts.
40
- O banco (`viralata.db`) e as fotos ficam em `/data/`.
41
 
42
  ## Stack
43
 
44
- - **Frontend**: Gradio 4 + Leaflet.js
45
- - **Backend**: Python Β· SQLite Β· sentence-transformers
46
- - **IA**: Nemotron Nano VL via NVIDIA NIM API
47
- - **Matching**: Cosine similarity em embeddings `all-MiniLM-L6-v2`
48
- - **Mapa**: OpenStreetMap via Leaflet.js
49
 
50
- ## Cores dos pins
51
 
52
- - 🟒 Verde β€” cΓ£o visto pela 1Βͺ vez
53
- - 🟠 Laranja β€” animal com mΓΊltiplos avistamentos
54
- - πŸ”΄ Vermelho β€” nΓ£o visto hΓ‘ mais de 30 dias
 
 
55
 
56
  ---
57
 
58
- *Feito com πŸ’š para Vinhedo, SP β€” e qualquer outra cidade.*
 
1
  ---
2
+ title: PawMap
3
  emoji: 🐾
4
  colorFrom: green
5
  colorTo: green
 
10
  short_description: Mapeamento colaborativo de animais de rua com IA
11
  ---
12
 
13
+ # PawMap 🐾
14
 
15
+ **Mapeamento colaborativo de animais de rua com identificaΓ§Γ£o por IA**
16
+ Build Small Hackathon Β· Junho 2026 Β· Trilha Backyard AI
17
 
18
+ Qualquer pessoa fotografa um animal de rua pelo celular. O app usa IA para identificar espΓ©cie, raΓ§a e cor, e verifica via cosine similarity se aquele animal jΓ‘ foi registrado antes β€” agrupando avistamentos no mapa e mostrando a trajetΓ³ria do animal ao longo do tempo.
19
 
20
+ ## Telas
21
 
22
+ | Tela | DescriΓ§Γ£o |
23
+ |------|-----------|
24
+ | πŸ—ΊοΈ Mapa | Pins coloridos por espΓ©cie/urgΓͺncia, card flutuante com "Ver ficha" |
25
+ | πŸ“· Registrar | CΓ’mera + GPS + anΓ‘lise da IA |
26
+ | πŸ€– AnΓ‘lise | IdentificaΓ§Γ£o automΓ‘tica com campos editΓ‘veis + animais semelhantes |
27
+ | βœ… ConfirmaΓ§Γ£o | Resumo do avistamento com grade de identificaΓ§Γ£o pela IA |
28
+ | πŸ‘οΈ Avistados | Lista de todos os animais catalogados |
29
+ | 🐾 Ficha | Perfil completo com galeria, trajetória no mapa e descrição da IA |
30
 
31
+ ## Fluxo
32
+
33
+ 1. **Registrar** β€” foto + GPS
34
+ 2. **IA analisa** β€” identifica espΓ©cie, raΓ§a, cor e gera embedding semΓ’ntico
35
+ 3. **Matching** β€” cosine similarity (threshold 0.80) agrupa avistamentos do mesmo animal
36
+ 4. **Mapa** β€” verde = cΓ£o Β· laranja = gato Β· vermelho = nΓ£o visto hΓ‘ +30 dias
37
+
38
+ ## Secrets do Space
39
 
40
  | Secret | DescriΓ§Γ£o |
41
  |--------|-----------|
42
+ | `HF_TOKEN` | Token HuggingFace para Llama-3.2-11B-Vision via Serverless Inference |
43
+ | `NVIDIA_API_KEY` | Alternativa: Nemotron Omni via NVIDIA NIM (tem precedΓͺncia) |
44
  | `MATCH_THRESHOLD` | Opcional. Threshold de similaridade. PadrΓ£o: `0.80` |
45
 
46
+ > Sem nenhuma chave o app funciona com fallback β€” registros funcionam, mas sem identificaΓ§Γ£o por IA.
 
47
 
48
+ ## Storage
49
 
50
+ Configure um **Persistent Storage Bucket** no Space para que `/data/` sobreviva a restarts.
51
+ Sem persistent storage os dados sΓ£o apagados a cada restart.
52
 
53
  ## Stack
54
 
55
+ - **Frontend**: SPA via `gradio.Server` (Off-Brand badge) + Leaflet.js + Lucide Icons
56
+ - **Backend**: FastAPI (Gradio 6) Β· SQLite Β· sentence-transformers
57
+ - **IA**: Llama-3.2-11B-Vision (HF) ou Nemotron Omni (NVIDIA NIM)
58
+ - **Matching**: Cosine similarity Β· all-MiniLM-L6-v2 (384-dim)
 
59
 
60
+ ## Desenvolvimento local
61
 
62
+ ```bash
63
+ pip install -r requirements.txt
64
+ HF_TOKEN=hf_... python app.py
65
+ # http://localhost:7860
66
+ ```
67
 
68
  ---
69
 
70
+ *Feito para Vinhedo, SP β€” e qualquer cidade que queira mapear seus animais de rua.*
app.py CHANGED
@@ -20,11 +20,13 @@ from fastapi.staticfiles import StaticFiles
20
  from core.ai import AnimalAI
21
  from core.database import Database, DATA_DIR, PHOTOS_DIR
22
  from core.matcher import AnimalMatcher
 
23
 
24
  logging.basicConfig(level=logging.INFO)
25
  db = Database()
26
  ai = AnimalAI()
27
  matcher = AnimalMatcher()
 
28
 
29
 
30
  def _photo_url(photo_path: str) -> str:
@@ -110,6 +112,16 @@ def analyze_image(image_path: FileData) -> dict:
110
  img = PILImage.open(image_path["path"]).convert("RGB")
111
 
112
  description = ai.analyze_image(img)
 
 
 
 
 
 
 
 
 
 
113
  embedding = ai.get_embedding(description)
114
  candidates = db.get_all_animals_with_embeddings()
115
  top_matches = matcher.find_top_matches(embedding, candidates, top_n=3)
@@ -243,7 +255,7 @@ def _cleanup_sessions():
243
  _pending.pop(k, None)
244
 
245
 
246
- # ─── Launch ───────────────────────────────────────────────────────────────────
247
 
248
  if __name__ == "__main__":
249
  DATA_DIR.mkdir(parents=True, exist_ok=True)
 
20
  from core.ai import AnimalAI
21
  from core.database import Database, DATA_DIR, PHOTOS_DIR
22
  from core.matcher import AnimalMatcher
23
+ from core.seed import seed_if_empty
24
 
25
  logging.basicConfig(level=logging.INFO)
26
  db = Database()
27
  ai = AnimalAI()
28
  matcher = AnimalMatcher()
29
+ seed_if_empty(db) # popula o mapa com dados de demo se o banco estiver vazio
30
 
31
 
32
  def _photo_url(photo_path: str) -> str:
 
112
  img = PILImage.open(image_path["path"]).convert("RGB")
113
 
114
  description = ai.analyze_image(img)
115
+
116
+ # RejeiΓ§Γ£o: a IA nΓ£o detectou nenhum animal na foto
117
+ if description.get("is_animal") is False:
118
+ return {
119
+ "error": "Nenhum cΓ£o ou gato identificado na foto. Por favor, fotografe um animal de rua.",
120
+ "session_id": "",
121
+ "description": {},
122
+ "similar": [],
123
+ }
124
+
125
  embedding = ai.get_embedding(description)
126
  candidates = db.get_all_animals_with_embeddings()
127
  top_matches = matcher.find_top_matches(embedding, candidates, top_n=3)
 
255
  _pending.pop(k, None)
256
 
257
 
258
+ # ─── Launch ────────────────────────────────────────────
259
 
260
  if __name__ == "__main__":
261
  DATA_DIR.mkdir(parents=True, exist_ok=True)
core/ai.py CHANGED
@@ -23,17 +23,19 @@ _HF_MODEL = "meta-llama/Llama-3.2-11B-Vision-Instruct"
23
  _NIM_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Nemotron Omni VLM, NVIDIA NIM
24
 
25
  PROMPT = (
26
- "You are analyzing a photo of a stray animal. "
27
- "Return ONLY a valid JSON object with these exact fields:\n"
28
- '{"species":"dog or cat",'
 
 
 
29
  '"breed_estimate":"mixed or specific breed",'
30
  '"size":"small or medium or large",'
31
- '"primary_color":"main coat color",'
32
  '"secondary_colors":["list of other colors or empty"],'
33
  '"distinctive_marks":["any spots, patches, scars, collar etc or empty"],'
34
  '"condition":"healthy or thin or injured",'
35
- '"description_text":"one concise sentence describing this specific animal for identity matching"}'
36
- "\nReturn only the JSON, no explanation."
37
  )
38
 
39
 
@@ -127,6 +129,13 @@ class AnimalAI:
127
 
128
  log.info("AI resposta: %s", raw[:200])
129
  result = self._parse(raw)
 
 
 
 
 
 
 
130
  result["_ai_success"] = True
131
  return result
132
 
@@ -179,7 +188,9 @@ class AnimalAI:
179
 
180
  @staticmethod
181
  def _fallback() -> dict:
 
182
  return {
 
183
  "species": "dog",
184
  "breed_estimate": "mixed",
185
  "size": "medium",
 
23
  _NIM_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Nemotron Omni VLM, NVIDIA NIM
24
 
25
  PROMPT = (
26
+ "Look at this image carefully.\n"
27
+ "FIRST: Is there a dog or cat clearly visible in the image?\n"
28
+ "If NO dog or cat is visible, respond with exactly: {\"is_animal\": false}\n\n"
29
+ "If YES, respond with ONLY this JSON (no explanation, no markdown):\n"
30
+ '{"is_animal":true,'
31
+ '"species":"dog or cat",'
32
  '"breed_estimate":"mixed or specific breed",'
33
  '"size":"small or medium or large",'
34
+ '"primary_color":"main coat color in Portuguese (e.g. caramelo, preto, branco, cinza, marrom)",'
35
  '"secondary_colors":["list of other colors or empty"],'
36
  '"distinctive_marks":["any spots, patches, scars, collar etc or empty"],'
37
  '"condition":"healthy or thin or injured",'
38
+ '"description_text":"one concise Portuguese sentence describing this specific animal for identity matching"}'
 
39
  )
40
 
41
 
 
129
 
130
  log.info("AI resposta: %s", raw[:200])
131
  result = self._parse(raw)
132
+
133
+ # RejeiΓ§Γ£o explΓ­cita: a IA nΓ£o viu nenhum animal
134
+ if result.get("is_animal") is False:
135
+ log.info("IA: nenhum animal detectado na imagem.")
136
+ return {"is_animal": False, "_ai_success": True}
137
+
138
+ result["is_animal"] = True
139
  result["_ai_success"] = True
140
  return result
141
 
 
188
 
189
  @staticmethod
190
  def _fallback() -> dict:
191
+ # Sem API configurada: aceita a foto sem validaΓ§Γ£o (usuΓ‘rio sabe o que estΓ‘ fotografando)
192
  return {
193
+ "is_animal": True,
194
  "species": "dog",
195
  "breed_estimate": "mixed",
196
  "size": "medium",
core/seed.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ seed.py β€” Dados de demonstraΓ§Γ£o para o mapa de Vinhedo, SP.
3
+ Chamado no startup do app.py somente se o banco estiver vazio.
4
+ """
5
+ import json
6
+ import logging
7
+ from datetime import datetime, timedelta
8
+
9
+ import numpy as np
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ # Animais de demonstraΓ§Γ£o em Vinhedo, SP
14
+ _SEED_ANIMALS = [
15
+ {
16
+ "species": "dog",
17
+ "description": {
18
+ "species": "dog",
19
+ "breed_estimate": "SRD",
20
+ "size": "medium",
21
+ "primary_color": "caramelo",
22
+ "secondary_colors": ["branco"],
23
+ "distinctive_marks": ["mancha branca no peito"],
24
+ "condition": "healthy",
25
+ "description_text": "medium caramel mixed breed dog with white chest patch",
26
+ "_ai_success": True,
27
+ },
28
+ "sightings": [
29
+ {"lat": -23.0298, "lng": -46.9768, "days_ago": 45, "notes": "Avistado perto da padaria"},
30
+ {"lat": -23.0305, "lng": -46.9772, "days_ago": 20, "notes": "Mesmo cΓ£o, mais magro"},
31
+ {"lat": -23.0316, "lng": -46.9785, "days_ago": 2, "notes": "Aparenta saudΓ‘vel"},
32
+ ],
33
+ },
34
+ {
35
+ "species": "cat",
36
+ "description": {
37
+ "species": "cat",
38
+ "breed_estimate": "SRD",
39
+ "size": "small",
40
+ "primary_color": "preto",
41
+ "secondary_colors": [],
42
+ "distinctive_marks": [],
43
+ "condition": "healthy",
44
+ "description_text": "small black mixed breed cat",
45
+ "_ai_success": True,
46
+ },
47
+ "sightings": [
48
+ {"lat": -23.0271, "lng": -46.9752, "days_ago": 0, "notes": "Gatinha dΓ³cil, aceita carinho"},
49
+ ],
50
+ },
51
+ {
52
+ "species": "dog",
53
+ "description": {
54
+ "species": "dog",
55
+ "breed_estimate": "SRD",
56
+ "size": "large",
57
+ "primary_color": "branco",
58
+ "secondary_colors": ["marrom"],
59
+ "distinctive_marks": ["orelha marrom"],
60
+ "condition": "thin",
61
+ "description_text": "large white dog with brown ear, appears thin",
62
+ "_ai_success": True,
63
+ },
64
+ "sightings": [
65
+ {"lat": -23.0358, "lng": -46.9815, "days_ago": 52, "notes": "Magro, precisa de ajuda"},
66
+ {"lat": -23.0362, "lng": -46.9820, "days_ago": 38, "notes": "Ainda na regiΓ£o do hospital"},
67
+ ],
68
+ },
69
+ {
70
+ "species": "cat",
71
+ "description": {
72
+ "species": "cat",
73
+ "breed_estimate": "SRD",
74
+ "size": "medium",
75
+ "primary_color": "cinza",
76
+ "secondary_colors": ["branco"],
77
+ "distinctive_marks": ["listras tigradas"],
78
+ "condition": "healthy",
79
+ "description_text": "medium grey tabby cat with white markings",
80
+ "_ai_success": True,
81
+ },
82
+ "sightings": [
83
+ {"lat": -23.0293, "lng": -46.9838, "days_ago": 10, "notes": "PrΓ³ximo ao supermercado"},
84
+ {"lat": -23.0288, "lng": -46.9832, "days_ago": 5, "notes": "Aceita comida"},
85
+ ],
86
+ },
87
+ {
88
+ "species": "dog",
89
+ "description": {
90
+ "species": "dog",
91
+ "breed_estimate": "Pitbull",
92
+ "size": "large",
93
+ "primary_color": "marrom",
94
+ "secondary_colors": [],
95
+ "distinctive_marks": ["cicatriz na pata dianteira"],
96
+ "condition": "healthy",
97
+ "description_text": "large brown pitbull mix with scar on front leg",
98
+ "_ai_success": True,
99
+ },
100
+ "sightings": [
101
+ {"lat": -23.0381, "lng": -46.9803, "days_ago": 5, "notes": "DΓ³cil, sem coleira"},
102
+ ],
103
+ },
104
+ {
105
+ "species": "dog",
106
+ "description": {
107
+ "species": "dog",
108
+ "breed_estimate": "SRD",
109
+ "size": "small",
110
+ "primary_color": "caramelo",
111
+ "secondary_colors": [],
112
+ "distinctive_marks": ["coleira azul desgastada"],
113
+ "condition": "healthy",
114
+ "description_text": "small caramel mixed breed dog with worn blue collar",
115
+ "_ai_success": True,
116
+ },
117
+ "sightings": [
118
+ {"lat": -23.0340, "lng": -46.9758, "days_ago": 14, "notes": "Coleira velha, pode ser perdido"},
119
+ {"lat": -23.0335, "lng": -46.9762, "days_ago": 7, "notes": "Continua na Γ‘rea"},
120
+ {"lat": -23.0330, "lng": -46.9755, "days_ago": 1, "notes": "Parece bem alimentado"},
121
+ ],
122
+ },
123
+ ]
124
+
125
+
126
+ def _random_embedding() -> bytes:
127
+ """Gera um embedding aleatΓ³rio normalizado (384-dim, float32)."""
128
+ v = np.random.randn(384).astype(np.float32)
129
+ v /= np.linalg.norm(v)
130
+ return v.tobytes()
131
+
132
+
133
+ def seed_if_empty(db) -> None:
134
+ """Insere dados de demo se o banco estiver completamente vazio."""
135
+ with db._conn() as conn:
136
+ count = conn.execute("SELECT COUNT(*) FROM animals").fetchone()[0]
137
+ if count > 0:
138
+ log.info("Banco jΓ‘ tem dados β€” seed ignorado.")
139
+ return
140
+
141
+ log.info("Banco vazio β€” inserindo %d animais de demonstraΓ§Γ£o...", len(_SEED_ANIMALS))
142
+ now = datetime.utcnow()
143
+
144
+ with db._conn() as conn:
145
+ for animal_data in _SEED_ANIMALS:
146
+ sightings = animal_data["sightings"]
147
+ desc = animal_data["description"]
148
+
149
+ # last_seen = data do avistamento mais recente
150
+ most_recent_days = min(s["days_ago"] for s in sightings)
151
+ last_seen = now - timedelta(days=most_recent_days)
152
+ first_seen = now - timedelta(days=max(s["days_ago"] for s in sightings))
153
+
154
+ cur = conn.execute(
155
+ "INSERT INTO animals (species, description, embedding, first_seen, last_seen, sighting_count)"
156
+ " VALUES (?, ?, ?, ?, ?, ?)",
157
+ (
158
+ animal_data["species"],
159
+ json.dumps(desc, ensure_ascii=False),
160
+ _random_embedding(),
161
+ first_seen.strftime("%Y-%m-%d %H:%M:%S"),
162
+ last_seen.strftime("%Y-%m-%d %H:%M:%S"),
163
+ len(sightings),
164
+ ),
165
+ )
166
+ animal_id = cur.lastrowid
167
+
168
+ for s in sightings:
169
+ created_at = now - timedelta(days=s["days_ago"])
170
+ conn.execute(
171
+ "INSERT INTO sightings (animal_id, photo_path, latitude, longitude, notes, created_at)"
172
+ " VALUES (?, ?, ?, ?, ?, ?)",
173
+ (
174
+ animal_id,
175
+ None,
176
+ s["lat"],
177
+ s["lng"],
178
+ s.get("notes", ""),
179
+ created_at.strftime("%Y-%m-%d %H:%M:%S"),
180
+ ),
181
+ )
182
+
183
+ log.info("Seed concluΓ­do: %d animais, %d avistamentos inseridos.",
184
+ len(_SEED_ANIMALS),
185
+ sum(len(a["sightings"]) for a in _SEED_ANIMALS))
index.html CHANGED
@@ -678,7 +678,20 @@
678
  try {
679
  const { client, handleFile } = await getClient();
680
  const res = await client.predict('/analyze_image', { image_path: handleFile(selectedFile) });
681
- const data = res.data[0]; sessionId = data.session_id;
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  const desc = data.description || {};
683
  setSelectVal('sel-species', desc.species === 'cat' ? 'cat' : 'dog');
684
  setSelectVal('sel-breed', desc.breed_estimate || 'SRD');
@@ -724,7 +737,16 @@
724
 
725
  document.querySelectorAll('.cond-chip').forEach(b => b.addEventListener('click', () => b.classList.toggle('active')));
726
  document.getElementById('analysis-back').addEventListener('click', () => showScreen('register'));
727
- document.getElementById('discard-btn').addEventListener('click', () => { resetRegister(); showScreen('register'); });
 
 
 
 
 
 
 
 
 
728
 
729
  // ── CONFIRM ────────────────────────────────────────────────────────────────
730
  document.getElementById('confirm-btn').addEventListener('click', async () => {
 
678
  try {
679
  const { client, handleFile } = await getClient();
680
  const res = await client.predict('/analyze_image', { image_path: handleFile(selectedFile) });
681
+ const data = res.data[0];
682
+
683
+ // IA rejeitou a foto β€” nΓ£o Γ© um animal
684
+ if (data.error) {
685
+ aiBadge.className = 'error';
686
+ document.getElementById('ai-badge-text').textContent = 'Nenhum animal';
687
+ document.getElementById('result-badge-text').textContent = data.error;
688
+ document.getElementById('animal-result-badge').classList.add('visible', 'error');
689
+ confirmBtn.style.display = 'none';
690
+ document.getElementById('discard-btn').textContent = '← Tirar nova foto';
691
+ return;
692
+ }
693
+
694
+ sessionId = data.session_id;
695
  const desc = data.description || {};
696
  setSelectVal('sel-species', desc.species === 'cat' ? 'cat' : 'dog');
697
  setSelectVal('sel-breed', desc.breed_estimate || 'SRD');
 
737
 
738
  document.querySelectorAll('.cond-chip').forEach(b => b.addEventListener('click', () => b.classList.toggle('active')));
739
  document.getElementById('analysis-back').addEventListener('click', () => showScreen('register'));
740
+ document.getElementById('discard-btn').addEventListener('click', () => {
741
+ // Restaura estado do botΓ£o caso tenha sido alterado no erro de validaΓ§Γ£o
742
+ const confirmBtn = document.getElementById('confirm-btn');
743
+ confirmBtn.style.display = '';
744
+ confirmBtn.disabled = false;
745
+ confirmBtn.innerHTML = 'Confirmar e Registrar β†’';
746
+ document.getElementById('discard-btn').textContent = 'Descartar Foto';
747
+ document.getElementById('animal-result-badge').classList.remove('visible','error');
748
+ resetRegister(); showScreen('register');
749
+ });
750
 
751
  // ── CONFIRM ────────────────────────────────────────────────────────────────
752
  document.getElementById('confirm-btn').addEventListener('click', async () => {
static/style.css CHANGED
@@ -348,3 +348,15 @@
348
  gap: 4px;
349
  }
350
  .cig-val svg { width: 13px; height: 13px; flex-shrink: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  gap: 4px;
349
  }
350
  .cig-val svg { width: 13px; height: 13px; flex-shrink: 0; }
351
+
352
+ /* ── AI badge error state ────────────────────────────────────────────────── */
353
+ #ai-badge.error { background: rgba(229,57,53,.88); }
354
+ #animal-result-badge.error {
355
+ background: rgba(229,57,53,.92);
356
+ font-size: 12px;
357
+ max-width: calc(100% - 24px);
358
+ text-align: center;
359
+ white-space: normal;
360
+ line-height: 1.4;
361
+ padding: 10px 16px;
362
+ }