sara.mesquita commited on
Commit
b2ebfa6
·
0 Parent(s):

feat: Animal Visto app

Browse files
.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"