Spaces:
Runtime error
Runtime error
sara.mesquita commited on
Commit Β·
9319a7c
1
Parent(s): be251cf
adjusts ia
Browse files- .idea/material_theme_project_new.xml +3 -1
- .idea/misc.xml +4 -1
- app.py +246 -244
- core/ai.py +83 -32
- core/database.py +94 -90
- data/viralata.db +0 -0
- data/viralata.db-journal +0 -0
- requirements.txt +1 -0
.idea/material_theme_project_new.xml
CHANGED
|
@@ -3,7 +3,9 @@
|
|
| 3 |
<component name="MaterialThemeProjectNewConfig">
|
| 4 |
<option name="metadata">
|
| 5 |
<MTProjectMetadataState>
|
| 6 |
-
<option name="
|
|
|
|
|
|
|
| 7 |
</MTProjectMetadataState>
|
| 8 |
</option>
|
| 9 |
</component>
|
|
|
|
| 3 |
<component name="MaterialThemeProjectNewConfig">
|
| 4 |
<option name="metadata">
|
| 5 |
<MTProjectMetadataState>
|
| 6 |
+
<option name="migrated" value="true" />
|
| 7 |
+
<option name="pristineConfig" value="false" />
|
| 8 |
+
<option name="userId" value="7a3dfa22:19aa6d9c7fa:-7ffd" />
|
| 9 |
</MTProjectMetadataState>
|
| 10 |
</option>
|
| 11 |
</component>
|
.idea/misc.xml
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
<project version="4">
|
| 3 |
-
<component name="
|
|
|
|
|
|
|
|
|
|
| 4 |
<output url="file://$PROJECT_DIR$/out" />
|
| 5 |
</component>
|
| 6 |
</project>
|
|
|
|
| 1 |
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
<project version="4">
|
| 3 |
+
<component name="Black">
|
| 4 |
+
<option name="sdkName" value="Python 3.11 (novopac-dashboard)" />
|
| 5 |
+
</component>
|
| 6 |
+
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="Python 3.11 (novopac-dashboard)" project-jdk-type="Python SDK">
|
| 7 |
<output url="file://$PROJECT_DIR$/out" />
|
| 8 |
</component>
|
| 9 |
</project>
|
app.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
app.py β
|
| 3 |
-
|
| 4 |
-
Build Small Hackathon Β· Backyard AI Track Β·
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
import logging
|
|
@@ -42,8 +42,7 @@ C_CARD = "#F4F4F0"
|
|
| 42 |
C_RED = "#E53935"
|
| 43 |
C_ORANGE = "#FB8C00"
|
| 44 |
|
| 45 |
-
# βββ Head HTML
|
| 46 |
-
# Esta Γ© a forma correta de injetar bibliotecas externas no Gradio
|
| 47 |
HEAD_HTML = """
|
| 48 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous">
|
| 49 |
"""
|
|
@@ -54,7 +53,6 @@ CSS = f"""
|
|
| 54 |
|
| 55 |
* {{ box-sizing: border-box; font-family: 'Inter', sans-serif; }}
|
| 56 |
|
| 57 |
-
/* Mobile-first wrapper */
|
| 58 |
.gradio-container {{
|
| 59 |
max-width: 480px !important;
|
| 60 |
margin: 0 auto !important;
|
|
@@ -79,17 +77,8 @@ footer {{ display: none !important; }}
|
|
| 79 |
z-index: 100;
|
| 80 |
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
| 81 |
}}
|
| 82 |
-
#top-bar h1 {{
|
| 83 |
-
|
| 84 |
-
font-size: 18px;
|
| 85 |
-
font-weight: 700;
|
| 86 |
-
letter-spacing: -0.3px;
|
| 87 |
-
}}
|
| 88 |
-
#top-bar small {{
|
| 89 |
-
font-size: 12px;
|
| 90 |
-
opacity: .75;
|
| 91 |
-
font-weight: 400;
|
| 92 |
-
}}
|
| 93 |
#stats-badge {{
|
| 94 |
background: rgba(255,255,255,.2);
|
| 95 |
border-radius: 20px;
|
|
@@ -98,7 +87,7 @@ footer {{ display: none !important; }}
|
|
| 98 |
font-weight: 600;
|
| 99 |
}}
|
| 100 |
|
| 101 |
-
/* Tabs β nav bar
|
| 102 |
.tabs > .tab-nav {{
|
| 103 |
background: #fff;
|
| 104 |
border-top: 1px solid #eee;
|
|
@@ -170,12 +159,8 @@ footer {{ display: none !important; }}
|
|
| 170 |
background: {C_GREEN_L} !important;
|
| 171 |
}}
|
| 172 |
|
| 173 |
-
/*
|
| 174 |
-
|
| 175 |
-
========================================= */
|
| 176 |
-
#register-tab {{
|
| 177 |
-
padding: 16px 16px 80px;
|
| 178 |
-
}}
|
| 179 |
.form-card {{
|
| 180 |
background: #ffffff !important;
|
| 181 |
border-radius: 16px !important;
|
|
@@ -195,28 +180,20 @@ footer {{ display: none !important; }}
|
|
| 195 |
align-items: center;
|
| 196 |
gap: 8px;
|
| 197 |
}}
|
| 198 |
-
.reg-label i {{
|
| 199 |
-
color: {C_GREEN};
|
| 200 |
-
font-size: 16px;
|
| 201 |
-
}}
|
| 202 |
|
| 203 |
-
/* ForΓ§ando cores e visual do upload de imagem do Gradio */
|
| 204 |
#photo-upload {{
|
| 205 |
border: 2px dashed #cfcfcf !important;
|
| 206 |
border-radius: 12px !important;
|
| 207 |
background: #f9f9f9 !important;
|
| 208 |
min-height: 180px;
|
| 209 |
}}
|
| 210 |
-
|
| 211 |
-
#photo-upload * {{
|
| 212 |
-
color: #555555 !important;
|
| 213 |
-
}}
|
| 214 |
#photo-upload:hover {{
|
| 215 |
border-color: {C_GREEN} !important;
|
| 216 |
background: #f4fbf6 !important;
|
| 217 |
}}
|
| 218 |
|
| 219 |
-
/* Estilo do Textbox de anotaΓ§Γ΅es */
|
| 220 |
#register-tab textarea {{
|
| 221 |
border-radius: 10px !important;
|
| 222 |
border: 1px solid #e0e0e0 !important;
|
|
@@ -231,11 +208,8 @@ footer {{ display: none !important; }}
|
|
| 231 |
background: #ffffff !important;
|
| 232 |
box-shadow: 0 0 0 3px {C_GREEN}22 !important;
|
| 233 |
}}
|
| 234 |
-
#register-tab textarea::placeholder {{
|
| 235 |
-
color: #a0a0a0 !important;
|
| 236 |
-
}}
|
| 237 |
|
| 238 |
-
/* Submit button dentro do card */
|
| 239 |
#submit-btn {{
|
| 240 |
margin-top: 10px !important;
|
| 241 |
width: 100% !important;
|
|
@@ -252,41 +226,31 @@ footer {{ display: none !important; }}
|
|
| 252 |
#submit-btn:hover {{ background: #2d7a4a !important; transform: translateY(-1px); }}
|
| 253 |
#submit-btn:disabled {{ background: #ccc !important; box-shadow: none !important; color: #888 !important; }}
|
| 254 |
|
| 255 |
-
|
| 256 |
-
#status-card {{
|
| 257 |
-
margin-top: 16px;
|
| 258 |
-
border-radius: 12px;
|
| 259 |
-
font-size: 14px;
|
| 260 |
-
line-height: 1.5;
|
| 261 |
-
}}
|
| 262 |
#status-card.ok {{ background: #e8f5e9; color: #2e7d32; border-left: 4px solid {C_GREEN}; padding: 14px 16px; }}
|
| 263 |
#status-card.err {{ background: #ffebee; color: #b71c1c; border-left: 4px solid {C_RED}; padding: 14px 16px; }}
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
#gps-btn:disabled {{
|
| 287 |
-
opacity: 0.7;
|
| 288 |
-
cursor: not-allowed;
|
| 289 |
-
}}
|
| 290 |
|
| 291 |
/* Animals list */
|
| 292 |
#animals-tab {{ padding: 0 0 80px; background: white; }}
|
|
@@ -301,13 +265,17 @@ footer {{ display: none !important; }}
|
|
| 301 |
box-shadow: 0 2px 8px rgba(0,0,0,.07);
|
| 302 |
border: 1px solid #f0f0f0;
|
| 303 |
}}
|
| 304 |
-
.animal-
|
| 305 |
-
width:
|
| 306 |
-
border-radius:
|
| 307 |
-
|
| 308 |
-
font-size: 22px;
|
| 309 |
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
}}
|
|
|
|
| 311 |
.animal-info {{ flex: 1; min-width: 0; }}
|
| 312 |
.animal-name {{ font-weight: 600; font-size: 14px; color: {C_TEXT}; }}
|
| 313 |
.animal-meta {{ font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
@@ -331,95 +299,132 @@ footer {{ display: none !important; }}
|
|
| 331 |
}}
|
| 332 |
"""
|
| 333 |
|
| 334 |
-
# βββ GPS JavaScript βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 335 |
# βββ GPS JavaScript βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 336 |
GPS_HTML = """
|
| 337 |
<div id="gps-box">
|
| 338 |
<span id="gps-icon" style="font-size:18px"><i class="fa-solid fa-location-crosshairs"></i></span>
|
| 339 |
-
<span id="gps-text">
|
| 340 |
<button id="gps-btn" style="margin-left:auto;background:#388C59;color:white;border:none;padding:6px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">
|
| 341 |
-
<i class="fa-solid fa-location-dot"></i>
|
| 342 |
</button>
|
| 343 |
</div>
|
| 344 |
<script>
|
| 345 |
(function() {
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
-
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
function requestLocation() {
|
| 363 |
-
if (!navigator.geolocation) {
|
| 364 |
-
icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i>';
|
| 365 |
-
text.textContent = 'GPS not available in this browser';
|
| 366 |
-
btn.style.display = 'none';
|
| 367 |
-
return;
|
| 368 |
}
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
icon.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
| 373 |
-
text.textContent = 'Waiting for GPS...';
|
| 374 |
-
|
| 375 |
-
navigator.geolocation.getCurrentPosition(
|
| 376 |
-
function(pos) {
|
| 377 |
-
var lat = parseFloat(pos.coords.latitude.toFixed(5));
|
| 378 |
-
var lng = parseFloat(pos.coords.longitude.toFixed(5));
|
| 379 |
-
|
| 380 |
-
icon.innerHTML = '<i class="fa-solid fa-check"></i>';
|
| 381 |
-
text.innerHTML = '<strong>Location detected:</strong> ' + lat + ', ' + lng;
|
| 382 |
-
btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Update';
|
| 383 |
-
btn.disabled = false;
|
| 384 |
-
btn.style.background = '#2d7a4a';
|
| 385 |
-
|
| 386 |
-
setCoords({ lat: lat, lng: lng });
|
| 387 |
-
},
|
| 388 |
-
function(err) {
|
| 389 |
icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i>';
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
btn.style.background = '#E53935';
|
| 393 |
-
|
| 394 |
-
if (err.code === 1) {
|
| 395 |
-
text.innerHTML = '<strong>Permission denied.</strong> Enable location in browser settings.';
|
| 396 |
-
} else if (err.code === 2) {
|
| 397 |
-
text.innerHTML = '<strong>Location unavailable.</strong> Check if GPS is enabled.';
|
| 398 |
-
} else if (err.code === 3) {
|
| 399 |
-
text.innerHTML = '<strong>Timeout.</strong> GPS took too long to respond.';
|
| 400 |
-
} else {
|
| 401 |
-
text.textContent = 'Error: ' + err.message;
|
| 402 |
-
}
|
| 403 |
-
|
| 404 |
-
setCoords({});
|
| 405 |
-
},
|
| 406 |
-
{
|
| 407 |
-
enableHighAccuracy: true,
|
| 408 |
-
timeout: 15000,
|
| 409 |
-
maximumAge: 0
|
| 410 |
}
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
}
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
-
}
|
|
|
|
| 423 |
})();
|
| 424 |
</script>
|
| 425 |
"""
|
|
@@ -434,11 +439,13 @@ def build_map_html(species: str = "all", timeframe: str = "all") -> str:
|
|
| 434 |
<html>
|
| 435 |
<head>
|
| 436 |
<meta charset="utf-8"/>
|
|
|
|
| 437 |
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
| 438 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
| 439 |
<style>
|
| 440 |
body{{margin:0;padding:0;}}
|
| 441 |
#map{{height:100vh;width:100%;}}
|
|
|
|
| 442 |
</style>
|
| 443 |
</head>
|
| 444 |
<body>
|
|
@@ -450,17 +457,19 @@ L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{attri
|
|
| 450 |
var animals = {data_js};
|
| 451 |
if(!animals.length){{
|
| 452 |
var info=L.control({{position:'topright'}});
|
| 453 |
-
info.onAdd=function(){{var d=L.DomUtil.create('div');d.style='background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;';d.innerHTML='
|
| 454 |
info.addTo(map);
|
| 455 |
}}
|
| 456 |
animals.forEach(function(a){{
|
| 457 |
var color=a.days_since>30?'#E53935':a.count>1?'#FB8C00':'#388C59';
|
| 458 |
-
var em=a.species==='dog'?'
|
| 459 |
var badge=a.count>1?'<span style="position:absolute;top:-5px;right:-5px;background:white;color:'+color+';border:1.5px solid '+color+';border-radius:10px;min-width:16px;height:16px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 2px;">'+a.count+'</span>':'';
|
| 460 |
-
var ico=L.divIcon({{html:'<div style="position:relative;background:'+color+';width:
|
| 461 |
-
var sp=a.species==='dog'?'
|
| 462 |
-
var urg=a.days_since>30?'<br><small style="color:#E53935"><i class=
|
| 463 |
-
|
|
|
|
|
|
|
| 464 |
}});
|
| 465 |
</script>
|
| 466 |
</body>
|
|
@@ -476,9 +485,9 @@ def build_animals_html() -> str:
|
|
| 476 |
if not animals:
|
| 477 |
return (
|
| 478 |
'<div style="padding:40px 16px;text-align:center;color:#aaa;">'
|
| 479 |
-
'<div style="font-size:48px;margin-bottom:12px;"><
|
| 480 |
-
'<div style="font-size:15px;font-weight:500;">
|
| 481 |
-
'<div style="font-size:13px;margin-top:6px;">
|
| 482 |
"</div>"
|
| 483 |
)
|
| 484 |
|
|
@@ -489,27 +498,34 @@ def build_animals_html() -> str:
|
|
| 489 |
except Exception:
|
| 490 |
desc = {}
|
| 491 |
|
| 492 |
-
is_dog
|
| 493 |
-
em
|
| 494 |
-
sp_pt
|
| 495 |
-
urgent
|
| 496 |
-
color
|
| 497 |
-
badge_cls
|
| 498 |
urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ''
|
| 499 |
-
breed
|
| 500 |
-
color_coat
|
| 501 |
-
meta
|
| 502 |
-
last_seen
|
| 503 |
-
count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
cards.append(f"""
|
| 506 |
<div class="animal-card">
|
| 507 |
-
<div class="animal-
|
| 508 |
<div class="animal-info">
|
| 509 |
<div class="animal-name">{sp_pt} #{a['id']}</div>
|
| 510 |
<div class="animal-meta">{meta}</div>
|
| 511 |
<div class="animal-meta" style="color:{color};">
|
| 512 |
-
{urgent_icon}
|
| 513 |
</div>
|
| 514 |
</div>
|
| 515 |
<div class="{badge_cls}">{count}x</div>
|
|
@@ -518,30 +534,33 @@ def build_animals_html() -> str:
|
|
| 518 |
|
| 519 |
total_a = db.total_animals()
|
| 520 |
total_s = db.total_sightings()
|
| 521 |
-
header =
|
| 522 |
-
f'<div class="section-header"><i class="fa-solid fa-paw"></i> {total_a} animals Β· {total_s} sightings</div>'
|
| 523 |
-
)
|
| 524 |
return header + "".join(cards)
|
| 525 |
|
| 526 |
|
| 527 |
# βββ Confirmation card HTML βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 528 |
-
def build_confirmation_html(animal_id: int, is_new: bool, count: int, species: str) -> str:
|
| 529 |
-
em =
|
| 530 |
-
sp_pt = "
|
| 531 |
if is_new:
|
| 532 |
-
title = f"
|
| 533 |
-
sub = "
|
| 534 |
else:
|
| 535 |
-
title = f"{em} Animal
|
| 536 |
-
sub = f"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
return f"""
|
| 539 |
<div id="status-card" class="ok">
|
| 540 |
-
|
|
|
|
| 541 |
<div style="font-weight:700;font-size:15px;">{title}</div>
|
| 542 |
<div>{sub}</div>
|
| 543 |
<div style="font-size:12px;margin-top:6px;color:#555;">
|
| 544 |
-
ID #{animal_id} Β·
|
| 545 |
</div>
|
| 546 |
</div>
|
| 547 |
"""
|
|
@@ -553,10 +572,10 @@ def process_sighting(image, gps_json: str, notes: str, progress=gr.Progress()):
|
|
| 553 |
return (
|
| 554 |
gr.update(),
|
| 555 |
'<div id="status-card" class="err">'
|
| 556 |
-
'<i class="fa-solid fa-xmark" style="margin-right:6px;"></i>
|
| 557 |
)
|
| 558 |
|
| 559 |
-
progress(0.15, desc="
|
| 560 |
try:
|
| 561 |
coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
|
| 562 |
except Exception:
|
|
@@ -565,15 +584,16 @@ def process_sighting(image, gps_json: str, notes: str, progress=gr.Progress()):
|
|
| 565 |
lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
|
| 566 |
lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
|
| 567 |
|
| 568 |
-
progress(0.40, desc="
|
| 569 |
description = ai.analyze_image(image)
|
| 570 |
embedding = ai.get_embedding(description)
|
| 571 |
|
| 572 |
-
progress(0.70, desc="
|
| 573 |
candidates = db.get_all_animals_with_embeddings()
|
| 574 |
match = matcher.find_match(embedding, candidates)
|
| 575 |
|
| 576 |
photo_path = db.save_photo(image)
|
|
|
|
| 577 |
|
| 578 |
if match:
|
| 579 |
animal_id, _score = match
|
|
@@ -590,166 +610,148 @@ def process_sighting(image, gps_json: str, notes: str, progress=gr.Progress()):
|
|
| 590 |
species = description.get("species", "dog")
|
| 591 |
is_new = True
|
| 592 |
|
| 593 |
-
progress(1.0, desc="
|
| 594 |
-
html = build_confirmation_html(animal_id, is_new, count, species)
|
| 595 |
return gr.update(value=build_map_html()), html
|
| 596 |
|
| 597 |
|
| 598 |
-
# βββ Filter helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 599 |
-
def filter_map(species: str, timeframe: str):
|
| 600 |
-
return gr.update(value=build_map_html(species, timeframe)), species, timeframe
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
# βββ Stats for top bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 604 |
-
def get_stats_html():
|
| 605 |
-
a = db.total_animals()
|
| 606 |
-
s = db.total_sightings()
|
| 607 |
-
return f'<div id="stats-badge">{a} animals Β· {s} sightings</div>'
|
| 608 |
-
|
| 609 |
-
|
| 610 |
# βββ Gradio app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 611 |
-
with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="
|
| 612 |
|
| 613 |
# Top bar
|
| 614 |
gr.HTML(
|
| 615 |
'<div id="top-bar">'
|
| 616 |
-
'<h1>
|
| 617 |
'<small>Vinhedo, SP</small>'
|
| 618 |
"</div>"
|
| 619 |
)
|
| 620 |
|
| 621 |
-
species_st
|
| 622 |
timeframe_st = gr.State("all")
|
| 623 |
|
| 624 |
with gr.Tabs(elem_classes="tabs") as tabs:
|
| 625 |
|
| 626 |
-
# ββ Tab 1:
|
| 627 |
-
with gr.Tab("
|
| 628 |
with gr.Row(elem_classes="filter-row"):
|
| 629 |
-
btn_all
|
| 630 |
-
btn_dogs
|
| 631 |
-
btn_cats
|
| 632 |
-
btn_today
|
| 633 |
-
btn_week
|
| 634 |
|
| 635 |
map_html = gr.HTML(build_map_html(), elem_id="map-container")
|
| 636 |
|
| 637 |
-
# ββ Tab 2:
|
| 638 |
-
with gr.Tab("
|
| 639 |
|
| 640 |
with gr.Column(elem_classes="form-card"):
|
| 641 |
|
| 642 |
-
#
|
| 643 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i>
|
| 644 |
gr.HTML(GPS_HTML)
|
| 645 |
|
| 646 |
-
#
|
|
|
|
| 647 |
gps_coords = gr.Textbox(
|
| 648 |
value="",
|
| 649 |
-
visible=False,
|
| 650 |
elem_id="gps-coords",
|
| 651 |
interactive=True,
|
|
|
|
|
|
|
| 652 |
)
|
| 653 |
|
| 654 |
-
#
|
| 655 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i>
|
|
|
|
| 656 |
photo_input = gr.Image(
|
| 657 |
label="",
|
| 658 |
type="pil",
|
| 659 |
sources=["upload", "webcam"],
|
| 660 |
interactive=True,
|
| 661 |
show_label=False,
|
| 662 |
-
elem_id="photo-upload"
|
| 663 |
)
|
| 664 |
|
| 665 |
-
#
|
| 666 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i>
|
| 667 |
notes_input = gr.Textbox(
|
| 668 |
label="",
|
| 669 |
-
placeholder="
|
| 670 |
lines=2,
|
| 671 |
max_lines=4,
|
| 672 |
show_label=False,
|
| 673 |
)
|
| 674 |
|
| 675 |
-
# Submit Button
|
| 676 |
submit_btn = gr.Button(
|
| 677 |
-
"
|
| 678 |
variant="primary",
|
| 679 |
elem_id="submit-btn",
|
| 680 |
)
|
| 681 |
|
| 682 |
-
# Feedback message container
|
| 683 |
status_html = gr.HTML("")
|
| 684 |
|
| 685 |
-
# ββ Tab 3:
|
| 686 |
-
with gr.Tab("
|
| 687 |
-
refresh_btn
|
| 688 |
animals_display = gr.HTML(build_animals_html())
|
| 689 |
|
| 690 |
-
# βββ
|
| 691 |
|
| 692 |
-
# Register sighting
|
| 693 |
submit_btn.click(
|
| 694 |
process_sighting,
|
| 695 |
inputs=[photo_input, gps_coords, notes_input],
|
| 696 |
outputs=[map_html, status_html],
|
| 697 |
)
|
| 698 |
|
| 699 |
-
# Map filters β All
|
| 700 |
btn_all.click(
|
| 701 |
lambda: (build_map_html("all", "all"), "all", "all"),
|
| 702 |
outputs=[map_html, species_st, timeframe_st],
|
| 703 |
)
|
| 704 |
-
# Dogs (keep timeframe)
|
| 705 |
btn_dogs.click(
|
| 706 |
lambda t: (build_map_html("dog", t), "dog", t),
|
| 707 |
inputs=[timeframe_st],
|
| 708 |
outputs=[map_html, species_st, timeframe_st],
|
| 709 |
)
|
| 710 |
-
# Cats (keep timeframe)
|
| 711 |
btn_cats.click(
|
| 712 |
lambda t: (build_map_html("cat", t), "cat", t),
|
| 713 |
inputs=[timeframe_st],
|
| 714 |
outputs=[map_html, species_st, timeframe_st],
|
| 715 |
)
|
| 716 |
-
# Today (keep species)
|
| 717 |
btn_today.click(
|
| 718 |
lambda s: (build_map_html(s, "today"), s, "today"),
|
| 719 |
inputs=[species_st],
|
| 720 |
outputs=[map_html, species_st, timeframe_st],
|
| 721 |
)
|
| 722 |
-
# This week (keep species)
|
| 723 |
btn_week.click(
|
| 724 |
lambda s: (build_map_html(s, "week"), s, "week"),
|
| 725 |
inputs=[species_st],
|
| 726 |
outputs=[map_html, species_st, timeframe_st],
|
| 727 |
)
|
| 728 |
|
| 729 |
-
# Refresh spotted list
|
| 730 |
refresh_btn.click(build_animals_html, outputs=[animals_display])
|
| 731 |
-
|
| 732 |
-
# Reload map when opening app
|
| 733 |
demo.load(build_map_html, outputs=[map_html])
|
| 734 |
|
| 735 |
|
| 736 |
if __name__ == "__main__":
|
| 737 |
-
# Patch Blocks.get_api_info to prevent crashing with bool schemas.
|
| 738 |
-
# The bug: gradio_client._json_schema_to_python_type doesn't handle additionalProperties=bool.
|
| 739 |
-
# This makes the HEAD / route return 500, which prevents launch() from completing.
|
| 740 |
import gradio.blocks as _gb_mod
|
| 741 |
_orig_gai = _gb_mod.Blocks.get_api_info
|
| 742 |
def _safe_get_api_info(self):
|
| 743 |
try:
|
| 744 |
return _orig_gai(self)
|
| 745 |
except Exception as _e:
|
| 746 |
-
|
| 747 |
return {}
|
| 748 |
_gb_mod.Blocks.get_api_info = _safe_get_api_info
|
| 749 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
demo.launch(
|
| 751 |
server_name="0.0.0.0",
|
| 752 |
server_port=int(os.environ.get("PORT", 7860)),
|
| 753 |
show_error=True,
|
| 754 |
show_api=False,
|
| 755 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
app.py β Animal Visto πΎ
|
| 3 |
+
Mapeamento colaborativo de animais de rua.
|
| 4 |
+
Build Small Hackathon Β· Backyard AI Track Β· Junho 2026
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
import logging
|
|
|
|
| 42 |
C_RED = "#E53935"
|
| 43 |
C_ORANGE = "#FB8C00"
|
| 44 |
|
| 45 |
+
# βββ Head HTML ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 46 |
HEAD_HTML = """
|
| 47 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous">
|
| 48 |
"""
|
|
|
|
| 53 |
|
| 54 |
* {{ box-sizing: border-box; font-family: 'Inter', sans-serif; }}
|
| 55 |
|
|
|
|
| 56 |
.gradio-container {{
|
| 57 |
max-width: 480px !important;
|
| 58 |
margin: 0 auto !important;
|
|
|
|
| 77 |
z-index: 100;
|
| 78 |
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
| 79 |
}}
|
| 80 |
+
#top-bar h1 {{ margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }}
|
| 81 |
+
#top-bar small {{ font-size: 12px; opacity: .75; font-weight: 400; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
#stats-badge {{
|
| 83 |
background: rgba(255,255,255,.2);
|
| 84 |
border-radius: 20px;
|
|
|
|
| 87 |
font-weight: 600;
|
| 88 |
}}
|
| 89 |
|
| 90 |
+
/* Tabs β nav bar na base */
|
| 91 |
.tabs > .tab-nav {{
|
| 92 |
background: #fff;
|
| 93 |
border-top: 1px solid #eee;
|
|
|
|
| 159 |
background: {C_GREEN_L} !important;
|
| 160 |
}}
|
| 161 |
|
| 162 |
+
/* Register tab */
|
| 163 |
+
#register-tab {{ padding: 16px 16px 80px; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
.form-card {{
|
| 165 |
background: #ffffff !important;
|
| 166 |
border-radius: 16px !important;
|
|
|
|
| 180 |
align-items: center;
|
| 181 |
gap: 8px;
|
| 182 |
}}
|
| 183 |
+
.reg-label i {{ color: {C_GREEN}; font-size: 16px; }}
|
|
|
|
|
|
|
|
|
|
| 184 |
|
|
|
|
| 185 |
#photo-upload {{
|
| 186 |
border: 2px dashed #cfcfcf !important;
|
| 187 |
border-radius: 12px !important;
|
| 188 |
background: #f9f9f9 !important;
|
| 189 |
min-height: 180px;
|
| 190 |
}}
|
| 191 |
+
#photo-upload * {{ color: #555555 !important; }}
|
|
|
|
|
|
|
|
|
|
| 192 |
#photo-upload:hover {{
|
| 193 |
border-color: {C_GREEN} !important;
|
| 194 |
background: #f4fbf6 !important;
|
| 195 |
}}
|
| 196 |
|
|
|
|
| 197 |
#register-tab textarea {{
|
| 198 |
border-radius: 10px !important;
|
| 199 |
border: 1px solid #e0e0e0 !important;
|
|
|
|
| 208 |
background: #ffffff !important;
|
| 209 |
box-shadow: 0 0 0 3px {C_GREEN}22 !important;
|
| 210 |
}}
|
| 211 |
+
#register-tab textarea::placeholder {{ color: #a0a0a0 !important; }}
|
|
|
|
|
|
|
| 212 |
|
|
|
|
| 213 |
#submit-btn {{
|
| 214 |
margin-top: 10px !important;
|
| 215 |
width: 100% !important;
|
|
|
|
| 226 |
#submit-btn:hover {{ background: #2d7a4a !important; transform: translateY(-1px); }}
|
| 227 |
#submit-btn:disabled {{ background: #ccc !important; box-shadow: none !important; color: #888 !important; }}
|
| 228 |
|
| 229 |
+
#status-card {{ margin-top: 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
#status-card.ok {{ background: #e8f5e9; color: #2e7d32; border-left: 4px solid {C_GREEN}; padding: 14px 16px; }}
|
| 231 |
#status-card.err {{ background: #ffebee; color: #b71c1c; border-left: 4px solid {C_RED}; padding: 14px 16px; }}
|
| 232 |
|
| 233 |
+
#gps-box {{
|
| 234 |
+
display: flex;
|
| 235 |
+
align-items: center;
|
| 236 |
+
gap: 10px;
|
| 237 |
+
background: {C_GREEN_L};
|
| 238 |
+
border-radius: 10px;
|
| 239 |
+
padding: 12px 14px;
|
| 240 |
+
font-size: 13px;
|
| 241 |
+
color: #2e7d32;
|
| 242 |
+
font-weight: 500;
|
| 243 |
+
border: 1px solid #c8e6c9;
|
| 244 |
+
}}
|
| 245 |
+
|
| 246 |
+
/* Esconder o campo GPS sem tirar do DOM (para Gradio atualizar o state) */
|
| 247 |
+
#gps-coords {{
|
| 248 |
+
display: none !important;
|
| 249 |
+
height: 0 !important;
|
| 250 |
+
overflow: hidden !important;
|
| 251 |
+
margin: 0 !important;
|
| 252 |
+
padding: 0 !important;
|
| 253 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
/* Animals list */
|
| 256 |
#animals-tab {{ padding: 0 0 80px; background: white; }}
|
|
|
|
| 265 |
box-shadow: 0 2px 8px rgba(0,0,0,.07);
|
| 266 |
border: 1px solid #f0f0f0;
|
| 267 |
}}
|
| 268 |
+
.animal-thumb {{
|
| 269 |
+
width: 52px; height: 52px;
|
| 270 |
+
border-radius: 10px;
|
| 271 |
+
object-fit: cover;
|
|
|
|
| 272 |
flex-shrink: 0;
|
| 273 |
+
background: #eee;
|
| 274 |
+
display: flex; align-items: center; justify-content: center;
|
| 275 |
+
font-size: 24px;
|
| 276 |
+
overflow: hidden;
|
| 277 |
}}
|
| 278 |
+
.animal-thumb img {{ width: 100%; height: 100%; object-fit: cover; }}
|
| 279 |
.animal-info {{ flex: 1; min-width: 0; }}
|
| 280 |
.animal-name {{ font-weight: 600; font-size: 14px; color: {C_TEXT}; }}
|
| 281 |
.animal-meta {{ font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
|
|
| 299 |
}}
|
| 300 |
"""
|
| 301 |
|
|
|
|
| 302 |
# βββ GPS JavaScript βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
GPS_HTML = """
|
| 304 |
<div id="gps-box">
|
| 305 |
<span id="gps-icon" style="font-size:18px"><i class="fa-solid fa-location-crosshairs"></i></span>
|
| 306 |
+
<span id="gps-text">Clique para detectar localizaΓ§Γ£o</span>
|
| 307 |
<button id="gps-btn" style="margin-left:auto;background:#388C59;color:white;border:none;padding:6px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">
|
| 308 |
+
<i class="fa-solid fa-location-dot"></i> Localizar
|
| 309 |
</button>
|
| 310 |
</div>
|
| 311 |
<script>
|
| 312 |
(function() {
|
| 313 |
+
// Aguarda o DOM estar completamente pronto antes de inicializar
|
| 314 |
+
function init() {
|
| 315 |
+
var icon = document.getElementById('gps-icon');
|
| 316 |
+
var text = document.getElementById('gps-text');
|
| 317 |
+
var btn = document.getElementById('gps-btn');
|
| 318 |
+
if (!icon || !text || !btn) { setTimeout(init, 200); return; }
|
| 319 |
+
|
| 320 |
+
// Atualiza o campo oculto do Gradio com as coordenadas
|
| 321 |
+
function setGradioValue(val) {
|
| 322 |
+
// Tenta encontrar o textarea dentro de #gps-coords (Gradio renderiza visible=True)
|
| 323 |
+
var attempts = 0;
|
| 324 |
+
function trySet() {
|
| 325 |
+
var container = document.getElementById('gps-coords');
|
| 326 |
+
var tb = container ? (container.querySelector('textarea') || container.querySelector('input')) : null;
|
| 327 |
+
if (!tb) {
|
| 328 |
+
// fallback: busca por qualquer textarea com valor de coords
|
| 329 |
+
tb = document.querySelector('#gps-coords textarea');
|
| 330 |
+
}
|
| 331 |
+
if (tb) {
|
| 332 |
+
// Usa native setter para funcionar com Svelte/React
|
| 333 |
+
var proto = tb.tagName === 'TEXTAREA'
|
| 334 |
+
? window.HTMLTextAreaElement.prototype
|
| 335 |
+
: window.HTMLInputElement.prototype;
|
| 336 |
+
var nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
| 337 |
+
nativeSetter.call(tb, val);
|
| 338 |
+
tb.dispatchEvent(new Event('input', { bubbles: true }));
|
| 339 |
+
tb.dispatchEvent(new Event('change', { bubbles: true }));
|
| 340 |
+
} else if (attempts < 10) {
|
| 341 |
+
attempts++;
|
| 342 |
+
setTimeout(trySet, 200);
|
| 343 |
+
}
|
| 344 |
}
|
| 345 |
+
trySet();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
}
|
| 347 |
+
|
| 348 |
+
function requestLocation() {
|
| 349 |
+
if (!navigator.geolocation) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i>';
|
| 351 |
+
text.textContent = 'GPS nΓ£o disponΓvel neste navegador';
|
| 352 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
+
|
| 355 |
+
btn.disabled = true;
|
| 356 |
+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detectando...';
|
| 357 |
+
icon.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
| 358 |
+
text.textContent = 'Aguardando GPS...';
|
| 359 |
+
|
| 360 |
+
navigator.geolocation.getCurrentPosition(
|
| 361 |
+
function(pos) {
|
| 362 |
+
var lat = parseFloat(pos.coords.latitude.toFixed(5));
|
| 363 |
+
var lng = parseFloat(pos.coords.longitude.toFixed(5));
|
| 364 |
+
|
| 365 |
+
icon.innerHTML = '<i class="fa-solid fa-check" style="color:#388C59"></i>';
|
| 366 |
+
text.innerHTML = '<strong>LocalizaΓ§Γ£o detectada:</strong> ' + lat + ', ' + lng;
|
| 367 |
+
btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Atualizar';
|
| 368 |
+
btn.disabled = false;
|
| 369 |
+
btn.style.background = '#2d7a4a';
|
| 370 |
+
|
| 371 |
+
setGradioValue(JSON.stringify({ lat: lat, lng: lng }));
|
| 372 |
+
},
|
| 373 |
+
function(err) {
|
| 374 |
+
icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation" style="color:#E53935"></i>';
|
| 375 |
+
btn.innerHTML = '<i class="fa-solid fa-location-dot"></i> Tentar novamente';
|
| 376 |
+
btn.disabled = false;
|
| 377 |
+
btn.style.background = '#E53935';
|
| 378 |
+
|
| 379 |
+
var msgs = {
|
| 380 |
+
1: '<strong>PermissΓ£o negada.</strong> Habilite localizaΓ§Γ£o no navegador.',
|
| 381 |
+
2: '<strong>LocalizaΓ§Γ£o indisponΓvel.</strong> Verifique o GPS.',
|
| 382 |
+
3: '<strong>Timeout.</strong> GPS demorou demais para responder.',
|
| 383 |
+
};
|
| 384 |
+
text.innerHTML = msgs[err.code] || 'Erro: ' + err.message;
|
| 385 |
+
|
| 386 |
+
// Salva coordenadas vazias para nΓ£o bloquear o envio
|
| 387 |
+
setGradioValue(JSON.stringify({}));
|
| 388 |
+
},
|
| 389 |
+
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
| 390 |
+
);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
btn.addEventListener('click', requestLocation);
|
| 394 |
+
|
| 395 |
+
// Tenta automaticamente ao carregar (apΓ³s Gradio inicializar)
|
| 396 |
+
setTimeout(function() {
|
| 397 |
+
if (navigator.geolocation) requestLocation();
|
| 398 |
+
}, 800);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// Inicializa apΓ³s o DOM carregar
|
| 402 |
+
if (document.readyState === 'loading') {
|
| 403 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 404 |
+
} else {
|
| 405 |
+
setTimeout(init, 300);
|
| 406 |
}
|
| 407 |
+
})();
|
| 408 |
+
</script>
|
| 409 |
+
"""
|
| 410 |
+
|
| 411 |
+
# βββ JS para cΓ’mera mobile (capture=environment) βββββββββββββββββββββββββββββ
|
| 412 |
+
CAMERA_JS = """
|
| 413 |
+
<script>
|
| 414 |
+
// Adiciona capture=environment ao input de arquivo da foto para abrir cΓ’mera traseira no mobile
|
| 415 |
+
(function addCameraCapture() {
|
| 416 |
+
function patch() {
|
| 417 |
+
var photoUpload = document.getElementById('photo-upload');
|
| 418 |
+
if (!photoUpload) { setTimeout(patch, 500); return; }
|
| 419 |
+
var inp = photoUpload.querySelector('input[type=file]');
|
| 420 |
+
if (inp) {
|
| 421 |
+
inp.setAttribute('capture', 'environment');
|
| 422 |
+
inp.setAttribute('accept', 'image/*');
|
| 423 |
+
} else {
|
| 424 |
+
setTimeout(patch, 500);
|
| 425 |
}
|
| 426 |
+
}
|
| 427 |
+
setTimeout(patch, 1000);
|
| 428 |
})();
|
| 429 |
</script>
|
| 430 |
"""
|
|
|
|
| 439 |
<html>
|
| 440 |
<head>
|
| 441 |
<meta charset="utf-8"/>
|
| 442 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 443 |
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
| 444 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
| 445 |
<style>
|
| 446 |
body{{margin:0;padding:0;}}
|
| 447 |
#map{{height:100vh;width:100%;}}
|
| 448 |
+
.popup-photo{{width:100%;height:80px;object-fit:cover;border-radius:6px;margin-bottom:6px;}}
|
| 449 |
</style>
|
| 450 |
</head>
|
| 451 |
<body>
|
|
|
|
| 457 |
var animals = {data_js};
|
| 458 |
if(!animals.length){{
|
| 459 |
var info=L.control({{position:'topright'}});
|
| 460 |
+
info.onAdd=function(){{var d=L.DomUtil.create('div');d.style='background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;box-shadow:0 2px 8px rgba(0,0,0,.1);';d.innerHTML='Nenhum avistamento ainda <i class="fa-solid fa-paw"></i>';return d;}};
|
| 461 |
info.addTo(map);
|
| 462 |
}}
|
| 463 |
animals.forEach(function(a){{
|
| 464 |
var color=a.days_since>30?'#E53935':a.count>1?'#FB8C00':'#388C59';
|
| 465 |
+
var em=a.species==='dog'?'π':'π';
|
| 466 |
var badge=a.count>1?'<span style="position:absolute;top:-5px;right:-5px;background:white;color:'+color+';border:1.5px solid '+color+';border-radius:10px;min-width:16px;height:16px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 2px;">'+a.count+'</span>':'';
|
| 467 |
+
var ico=L.divIcon({{html:'<div style="position:relative;background:'+color+';width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;">'+em+badge+'</div>',className:'',iconSize:[38,38],iconAnchor:[19,19],popupAnchor:[0,-22]}});
|
| 468 |
+
var sp=a.species==='dog'?'CΓ£o':'Gato';
|
| 469 |
+
var urg=a.days_since>30?'<br><small style="color:#E53935"><i class="fa-solid fa-triangle-exclamation"></i> NΓ£o visto hΓ‘ '+a.days_since+' dias</small>':'';
|
| 470 |
+
var photoHtml=a.photo_url?'<img src="'+a.photo_url+'" class="popup-photo" onerror="this.style.display=\'none\'"/>':'';
|
| 471 |
+
var popup='<div style="min-width:180px;">'+photoHtml+'<b>'+sp+' #'+a.id+'</b>'+(a.desc?'<br><small style="color:#555;">'+a.desc+'</small>':'')+'<br><small><i class="fa-solid fa-eye"></i> '+a.count+'x Β· '+a.last_seen+'</small>'+urg+'</div>';
|
| 472 |
+
L.marker([a.lat,a.lng],{{icon:ico}}).addTo(map).bindPopup(popup,{{maxWidth:220}});
|
| 473 |
}});
|
| 474 |
</script>
|
| 475 |
</body>
|
|
|
|
| 485 |
if not animals:
|
| 486 |
return (
|
| 487 |
'<div style="padding:40px 16px;text-align:center;color:#aaa;">'
|
| 488 |
+
'<div style="font-size:48px;margin-bottom:12px;">πΎ</div>'
|
| 489 |
+
'<div style="font-size:15px;font-weight:500;">Nenhum animal registrado ainda</div>'
|
| 490 |
+
'<div style="font-size:13px;margin-top:6px;">VΓ‘ para Registrar e tire a primeira foto!</div>'
|
| 491 |
"</div>"
|
| 492 |
)
|
| 493 |
|
|
|
|
| 498 |
except Exception:
|
| 499 |
desc = {}
|
| 500 |
|
| 501 |
+
is_dog = a["species"] == "dog"
|
| 502 |
+
em = "π" if is_dog else "π"
|
| 503 |
+
sp_pt = "CΓ£o" if is_dog else "Gato"
|
| 504 |
+
urgent = a.get("days_since", 0) > 30
|
| 505 |
+
color = C_RED if urgent else C_GREEN
|
| 506 |
+
badge_cls = "animal-badge urgent" if urgent else "animal-badge"
|
| 507 |
urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ''
|
| 508 |
+
breed = desc.get("breed_estimate", "raΓ§a desconhecida")
|
| 509 |
+
color_coat = desc.get("primary_color", "")
|
| 510 |
+
meta = f"{breed}{' Β· ' + color_coat if color_coat else ''}"
|
| 511 |
+
last_seen = a.get("last_seen_short", "")
|
| 512 |
+
count = a["sighting_count"]
|
| 513 |
+
|
| 514 |
+
# Foto de thumbnail (usa a mais recente do animal)
|
| 515 |
+
photo_url = a.get("last_photo_url") or ""
|
| 516 |
+
if photo_url:
|
| 517 |
+
thumb_html = f'<img src="{photo_url}" style="width:52px;height:52px;border-radius:10px;object-fit:cover;" onerror="this.parentElement.innerHTML=\'{em}\'">'
|
| 518 |
+
else:
|
| 519 |
+
thumb_html = em
|
| 520 |
|
| 521 |
cards.append(f"""
|
| 522 |
<div class="animal-card">
|
| 523 |
+
<div class="animal-thumb">{thumb_html}</div>
|
| 524 |
<div class="animal-info">
|
| 525 |
<div class="animal-name">{sp_pt} #{a['id']}</div>
|
| 526 |
<div class="animal-meta">{meta}</div>
|
| 527 |
<div class="animal-meta" style="color:{color};">
|
| 528 |
+
{urgent_icon}Visto {count}x Β· ΓΊltimo: {last_seen}
|
| 529 |
</div>
|
| 530 |
</div>
|
| 531 |
<div class="{badge_cls}">{count}x</div>
|
|
|
|
| 534 |
|
| 535 |
total_a = db.total_animals()
|
| 536 |
total_s = db.total_sightings()
|
| 537 |
+
header = f'<div class="section-header">πΎ {total_a} animais Β· {total_s} avistamentos</div>'
|
|
|
|
|
|
|
| 538 |
return header + "".join(cards)
|
| 539 |
|
| 540 |
|
| 541 |
# βββ Confirmation card HTML βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 542 |
+
def build_confirmation_html(animal_id: int, is_new: bool, count: int, species: str, photo_url: str = "") -> str:
|
| 543 |
+
em = "π" if species == "dog" else "π"
|
| 544 |
+
sp_pt = "cΓ£o" if species == "dog" else "gato"
|
| 545 |
if is_new:
|
| 546 |
+
title = f"Novo {sp_pt} registrado!"
|
| 547 |
+
sub = "1ΒΊ avistamento β obrigada por registrar!"
|
| 548 |
else:
|
| 549 |
+
title = f"{em} Animal reconhecido!"
|
| 550 |
+
sub = f"Este {sp_pt} jΓ‘ foi avistado <b>{count}x</b> na regiΓ£o."
|
| 551 |
+
|
| 552 |
+
photo_html = ""
|
| 553 |
+
if photo_url:
|
| 554 |
+
photo_html = f'<img src="{photo_url}" style="width:100%;max-height:160px;object-fit:cover;border-radius:8px;margin-bottom:8px;" onerror="this.style.display=\'none\'">'
|
| 555 |
|
| 556 |
return f"""
|
| 557 |
<div id="status-card" class="ok">
|
| 558 |
+
{photo_html}
|
| 559 |
+
<div style="font-size:24px;margin-bottom:6px;color:{C_GREEN};">β
{em}</div>
|
| 560 |
<div style="font-weight:700;font-size:15px;">{title}</div>
|
| 561 |
<div>{sub}</div>
|
| 562 |
<div style="font-size:12px;margin-top:6px;color:#555;">
|
| 563 |
+
ID #{animal_id} Β· Avistamento salvo com localizaΓ§Γ£o
|
| 564 |
</div>
|
| 565 |
</div>
|
| 566 |
"""
|
|
|
|
| 572 |
return (
|
| 573 |
gr.update(),
|
| 574 |
'<div id="status-card" class="err">'
|
| 575 |
+
'<i class="fa-solid fa-xmark" style="margin-right:6px;"></i> Tire uma foto do animal antes de registrar.</div>',
|
| 576 |
)
|
| 577 |
|
| 578 |
+
progress(0.15, desc="Analisando imagem...")
|
| 579 |
try:
|
| 580 |
coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
|
| 581 |
except Exception:
|
|
|
|
| 584 |
lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
|
| 585 |
lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
|
| 586 |
|
| 587 |
+
progress(0.40, desc="Identificando animal com IA Nemotron...")
|
| 588 |
description = ai.analyze_image(image)
|
| 589 |
embedding = ai.get_embedding(description)
|
| 590 |
|
| 591 |
+
progress(0.70, desc="Verificando avistamentos anteriores...")
|
| 592 |
candidates = db.get_all_animals_with_embeddings()
|
| 593 |
match = matcher.find_match(embedding, candidates)
|
| 594 |
|
| 595 |
photo_path = db.save_photo(image)
|
| 596 |
+
photo_url = db.photo_url(photo_path) or ""
|
| 597 |
|
| 598 |
if match:
|
| 599 |
animal_id, _score = match
|
|
|
|
| 610 |
species = description.get("species", "dog")
|
| 611 |
is_new = True
|
| 612 |
|
| 613 |
+
progress(1.0, desc="Salvo!")
|
| 614 |
+
html = build_confirmation_html(animal_id, is_new, count, species, photo_url)
|
| 615 |
return gr.update(value=build_map_html()), html
|
| 616 |
|
| 617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
# βββ Gradio app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 619 |
+
with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Visto πΎ") as demo:
|
| 620 |
|
| 621 |
# Top bar
|
| 622 |
gr.HTML(
|
| 623 |
'<div id="top-bar">'
|
| 624 |
+
'<h1>πΎ Animal Visto</h1>'
|
| 625 |
'<small>Vinhedo, SP</small>'
|
| 626 |
"</div>"
|
| 627 |
)
|
| 628 |
|
| 629 |
+
species_st = gr.State("all")
|
| 630 |
timeframe_st = gr.State("all")
|
| 631 |
|
| 632 |
with gr.Tabs(elem_classes="tabs") as tabs:
|
| 633 |
|
| 634 |
+
# ββ Tab 1: Mapa ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 635 |
+
with gr.Tab("πΊοΈ Mapa"):
|
| 636 |
with gr.Row(elem_classes="filter-row"):
|
| 637 |
+
btn_all = gr.Button("Todos", elem_classes="filter-row")
|
| 638 |
+
btn_dogs = gr.Button("π CΓ£es", elem_classes="filter-row")
|
| 639 |
+
btn_cats = gr.Button("π Gatos", elem_classes="filter-row")
|
| 640 |
+
btn_today = gr.Button("Hoje", elem_classes="filter-row secondary")
|
| 641 |
+
btn_week = gr.Button("Esta semana", elem_classes="filter-row secondary")
|
| 642 |
|
| 643 |
map_html = gr.HTML(build_map_html(), elem_id="map-container")
|
| 644 |
|
| 645 |
+
# ββ Tab 2: Registrar βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 646 |
+
with gr.Tab("π· Registrar", elem_id="register-tab"):
|
| 647 |
|
| 648 |
with gr.Column(elem_classes="form-card"):
|
| 649 |
|
| 650 |
+
# Passo 1: LocalizaΓ§Γ£o
|
| 651 |
+
gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i> Passo 1: LocalizaΓ§Γ£o</div>')
|
| 652 |
gr.HTML(GPS_HTML)
|
| 653 |
|
| 654 |
+
# Campo oculto que recebe as coords via JS
|
| 655 |
+
# Mantemos visible=True mas escondemos via CSS β essencial para Gradio atualizar o state
|
| 656 |
gps_coords = gr.Textbox(
|
| 657 |
value="",
|
|
|
|
| 658 |
elem_id="gps-coords",
|
| 659 |
interactive=True,
|
| 660 |
+
visible=True,
|
| 661 |
+
label="gps",
|
| 662 |
)
|
| 663 |
|
| 664 |
+
# Passo 2: Foto
|
| 665 |
+
gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i> Passo 2: Foto do Animal</div>')
|
| 666 |
+
gr.HTML(CAMERA_JS)
|
| 667 |
photo_input = gr.Image(
|
| 668 |
label="",
|
| 669 |
type="pil",
|
| 670 |
sources=["upload", "webcam"],
|
| 671 |
interactive=True,
|
| 672 |
show_label=False,
|
| 673 |
+
elem_id="photo-upload",
|
| 674 |
)
|
| 675 |
|
| 676 |
+
# Passo 3: ObservaΓ§Γ΅es
|
| 677 |
+
gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Passo 3: ObservaΓ§Γ΅es (opcional)</div>')
|
| 678 |
notes_input = gr.Textbox(
|
| 679 |
label="",
|
| 680 |
+
placeholder="Ex.: parece ferido, tem coleira, tem filhotes...",
|
| 681 |
lines=2,
|
| 682 |
max_lines=4,
|
| 683 |
show_label=False,
|
| 684 |
)
|
| 685 |
|
|
|
|
| 686 |
submit_btn = gr.Button(
|
| 687 |
+
"Registrar avistamento",
|
| 688 |
variant="primary",
|
| 689 |
elem_id="submit-btn",
|
| 690 |
)
|
| 691 |
|
|
|
|
| 692 |
status_html = gr.HTML("")
|
| 693 |
|
| 694 |
+
# ββ Tab 3: Avistados βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 695 |
+
with gr.Tab("πΎ Avistados", elem_id="animals-tab"):
|
| 696 |
+
refresh_btn = gr.Button("Atualizar lista", size="sm", variant="secondary")
|
| 697 |
animals_display = gr.HTML(build_animals_html())
|
| 698 |
|
| 699 |
+
# βββ Eventos ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 700 |
|
|
|
|
| 701 |
submit_btn.click(
|
| 702 |
process_sighting,
|
| 703 |
inputs=[photo_input, gps_coords, notes_input],
|
| 704 |
outputs=[map_html, status_html],
|
| 705 |
)
|
| 706 |
|
|
|
|
| 707 |
btn_all.click(
|
| 708 |
lambda: (build_map_html("all", "all"), "all", "all"),
|
| 709 |
outputs=[map_html, species_st, timeframe_st],
|
| 710 |
)
|
|
|
|
| 711 |
btn_dogs.click(
|
| 712 |
lambda t: (build_map_html("dog", t), "dog", t),
|
| 713 |
inputs=[timeframe_st],
|
| 714 |
outputs=[map_html, species_st, timeframe_st],
|
| 715 |
)
|
|
|
|
| 716 |
btn_cats.click(
|
| 717 |
lambda t: (build_map_html("cat", t), "cat", t),
|
| 718 |
inputs=[timeframe_st],
|
| 719 |
outputs=[map_html, species_st, timeframe_st],
|
| 720 |
)
|
|
|
|
| 721 |
btn_today.click(
|
| 722 |
lambda s: (build_map_html(s, "today"), s, "today"),
|
| 723 |
inputs=[species_st],
|
| 724 |
outputs=[map_html, species_st, timeframe_st],
|
| 725 |
)
|
|
|
|
| 726 |
btn_week.click(
|
| 727 |
lambda s: (build_map_html(s, "week"), s, "week"),
|
| 728 |
inputs=[species_st],
|
| 729 |
outputs=[map_html, species_st, timeframe_st],
|
| 730 |
)
|
| 731 |
|
|
|
|
| 732 |
refresh_btn.click(build_animals_html, outputs=[animals_display])
|
|
|
|
|
|
|
| 733 |
demo.load(build_map_html, outputs=[map_html])
|
| 734 |
|
| 735 |
|
| 736 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
| 737 |
import gradio.blocks as _gb_mod
|
| 738 |
_orig_gai = _gb_mod.Blocks.get_api_info
|
| 739 |
def _safe_get_api_info(self):
|
| 740 |
try:
|
| 741 |
return _orig_gai(self)
|
| 742 |
except Exception as _e:
|
| 743 |
+
logging.warning(f"get_api_info non-fatal: {_e}")
|
| 744 |
return {}
|
| 745 |
_gb_mod.Blocks.get_api_info = _safe_get_api_info
|
| 746 |
|
| 747 |
+
# Serve /data/photos/ para exibir as fotos no app
|
| 748 |
+
from core.database import DATA_DIR, PHOTOS_DIR
|
| 749 |
+
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 750 |
+
|
| 751 |
demo.launch(
|
| 752 |
server_name="0.0.0.0",
|
| 753 |
server_port=int(os.environ.get("PORT", 7860)),
|
| 754 |
show_error=True,
|
| 755 |
show_api=False,
|
| 756 |
+
allowed_paths=[str(DATA_DIR)],
|
| 757 |
+
)
|
core/ai.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
-
ai.py β IntegraΓ§Γ£o com
|
| 3 |
-
|
| 4 |
-
VariΓ‘veis de ambiente:
|
| 5 |
-
HF_TOKEN
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
import base64
|
| 9 |
import io
|
|
@@ -16,7 +23,12 @@ import numpy as np
|
|
| 16 |
|
| 17 |
log = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
PROMPT = (
|
| 22 |
"You are analyzing a photo of a stray animal. "
|
|
@@ -35,67 +47,97 @@ PROMPT = (
|
|
| 35 |
|
| 36 |
class AnimalAI:
|
| 37 |
def __init__(self):
|
| 38 |
-
|
| 39 |
-
self.model = os.environ.get("HF_MODEL", _DEFAULT_MODEL)
|
| 40 |
self.client = None
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 58 |
except Exception as e:
|
| 59 |
-
log.warning(f"
|
| 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 (
|
| 67 |
"""
|
| 68 |
if self.client is None:
|
| 69 |
-
log.info("
|
| 70 |
return self._fallback()
|
| 71 |
|
| 72 |
try:
|
| 73 |
img_b64 = self._image_to_b64(image)
|
| 74 |
-
response = self.client.
|
|
|
|
| 75 |
messages=[
|
| 76 |
{
|
| 77 |
"role": "user",
|
| 78 |
"content": [
|
| 79 |
{
|
| 80 |
"type": "image_url",
|
| 81 |
-
"image_url": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
},
|
| 83 |
-
{"type": "text", "text": PROMPT},
|
| 84 |
],
|
| 85 |
}
|
| 86 |
],
|
| 87 |
-
max_tokens=
|
| 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"
|
| 94 |
return self._fallback()
|
| 95 |
|
| 96 |
def get_embedding(self, description: dict) -> list:
|
| 97 |
"""
|
| 98 |
-
Gera embedding (384-dim
|
| 99 |
Roda 100% local com sentence-transformers β sem custo de API.
|
| 100 |
"""
|
| 101 |
if self.embedder is None:
|
|
@@ -110,24 +152,33 @@ class AnimalAI:
|
|
| 110 |
|
| 111 |
@staticmethod
|
| 112 |
def _image_to_b64(image) -> str:
|
|
|
|
| 113 |
buf = io.BytesIO()
|
| 114 |
-
image.
|
| 115 |
-
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
marks = d.get("distinctive_marks", [])
|
| 132 |
if marks:
|
| 133 |
parts.append("with " + ", ".join(marks))
|
|
|
|
| 1 |
"""
|
| 2 |
+
ai.py β IntegraΓ§Γ£o com Nemotron 3 Nano Omni via HuggingFace ou NVIDIA NIM.
|
| 3 |
+
|
| 4 |
+
VariΓ‘veis de ambiente (uma das duas Γ© necessΓ‘ria para IA funcionar):
|
| 5 |
+
HF_TOKEN β token do Hugging Face (usa HF Serverless Inference com crΓ©ditos)
|
| 6 |
+
NVIDIA_API_KEY β chave NVIDIA NIM (usa integrate.api.nvidia.com β free tier disponΓvel)
|
| 7 |
+
|
| 8 |
+
Modelo: nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16
|
| 9 |
+
- NEMOTRON 3 NANO OMNI β modelo multimodal (imagem, Γ‘udio, vΓdeo, texto)
|
| 10 |
+
- 30B parΓ’metros totais, 3B ativos por token (MoE hΓbrido Mamba2-Transformer)
|
| 11 |
+
- Dentro do limite de 32B do hackathon β
|
| 12 |
+
- HF: nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16
|
| 13 |
+
- NVIDIA NIM: nvidia/nemotron-3-nano-omni-30b-a3b
|
| 14 |
"""
|
| 15 |
import base64
|
| 16 |
import io
|
|
|
|
| 23 |
|
| 24 |
log = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
+
# Nemotron 3 Nano Omni β multimodal, 30B total / 3B ativos (MoE)
|
| 27 |
+
# HF model ID usado pelo InferenceClient
|
| 28 |
+
_HF_MODEL = "nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16"
|
| 29 |
+
# NVIDIA NIM model ID (usado se NVIDIA_API_KEY estiver disponΓvel)
|
| 30 |
+
_NIM_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b"
|
| 31 |
+
_DEFAULT_MODEL = _HF_MODEL
|
| 32 |
|
| 33 |
PROMPT = (
|
| 34 |
"You are analyzing a photo of a stray animal. "
|
|
|
|
| 47 |
|
| 48 |
class AnimalAI:
|
| 49 |
def __init__(self):
|
| 50 |
+
self.model = os.environ.get("NVIDIA_MODEL", _DEFAULT_MODEL)
|
|
|
|
| 51 |
self.client = None
|
| 52 |
|
| 53 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 54 |
+
nvidia_key = os.environ.get("NVIDIA_API_KEY", "")
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from openai import OpenAI
|
| 58 |
+
|
| 59 |
+
if nvidia_key:
|
| 60 |
+
# NVIDIA NIM β preferencial quando disponΓvel (free tier em build.nvidia.com)
|
| 61 |
+
self.model = os.environ.get("NVIDIA_MODEL", _NIM_MODEL)
|
| 62 |
+
self.client = OpenAI(
|
| 63 |
+
base_url="https://integrate.api.nvidia.com/v1",
|
| 64 |
+
api_key=nvidia_key,
|
| 65 |
+
)
|
| 66 |
+
log.info(f"Nemotron 3 Nano Omni via NVIDIA NIM: {self.model}")
|
| 67 |
+
|
| 68 |
+
elif hf_token:
|
| 69 |
+
# HuggingFace Inference API β usa crΓ©ditos HF
|
| 70 |
+
self.model = os.environ.get("NVIDIA_MODEL", _HF_MODEL)
|
| 71 |
+
self.client = OpenAI(
|
| 72 |
+
base_url="https://api-inference.huggingface.co/v1/",
|
| 73 |
+
api_key=hf_token,
|
| 74 |
+
)
|
| 75 |
+
log.info(f"Nemotron 3 Nano Omni via HuggingFace: {self.model}")
|
| 76 |
+
|
| 77 |
+
else:
|
| 78 |
+
log.warning(
|
| 79 |
+
"Sem chave de API β anΓ‘lise de imagem desabilitada.\n"
|
| 80 |
+
"Configure HF_TOKEN no Space (Settings β Secrets).\n"
|
| 81 |
+
"Ou NVIDIA_API_KEY (free em https://build.nvidia.com)."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
except ImportError:
|
| 85 |
+
log.warning("openai package nΓ£o instalado. Execute: pip install openai")
|
| 86 |
+
|
| 87 |
+
# sentence-transformers para embeddings locais (sem custo de API)
|
| 88 |
self.embedder = None
|
| 89 |
try:
|
| 90 |
from sentence_transformers import SentenceTransformer
|
| 91 |
self.embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 92 |
+
log.info("sentence-transformers carregado: all-MiniLM-L6-v2")
|
| 93 |
except Exception as e:
|
| 94 |
+
log.warning(f"NΓ£o foi possΓvel carregar sentence-transformers: {e}")
|
| 95 |
|
| 96 |
# βββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
|
| 98 |
def analyze_image(self, image) -> dict:
|
| 99 |
"""
|
| 100 |
Recebe PIL.Image, retorna dict com descriΓ§Γ£o estruturada do animal.
|
| 101 |
+
Usa Nemotron via HF Inference API (ou NVIDIA NIM). Fallback se sem chave.
|
| 102 |
"""
|
| 103 |
if self.client is None:
|
| 104 |
+
log.info("Sem cliente de IA β retornando descriΓ§Γ£o fallback.")
|
| 105 |
return self._fallback()
|
| 106 |
|
| 107 |
try:
|
| 108 |
img_b64 = self._image_to_b64(image)
|
| 109 |
+
response = self.client.chat.completions.create(
|
| 110 |
+
model=self.model,
|
| 111 |
messages=[
|
| 112 |
{
|
| 113 |
"role": "user",
|
| 114 |
"content": [
|
| 115 |
{
|
| 116 |
"type": "image_url",
|
| 117 |
+
"image_url": {
|
| 118 |
+
"url": f"data:image/jpeg;base64,{img_b64}",
|
| 119 |
+
},
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"type": "text",
|
| 123 |
+
"text": PROMPT,
|
| 124 |
},
|
|
|
|
| 125 |
],
|
| 126 |
}
|
| 127 |
],
|
| 128 |
+
max_tokens=400,
|
| 129 |
temperature=0.1,
|
| 130 |
)
|
| 131 |
raw = response.choices[0].message.content or ""
|
| 132 |
+
log.info(f"Nemotron resposta: {raw[:200]}")
|
| 133 |
return self._parse_json(raw)
|
| 134 |
except Exception as e:
|
| 135 |
+
log.error(f"Erro na API Nemotron: {e}")
|
| 136 |
return self._fallback()
|
| 137 |
|
| 138 |
def get_embedding(self, description: dict) -> list:
|
| 139 |
"""
|
| 140 |
+
Gera embedding (384-dim) a partir da descriΓ§Γ£o textual.
|
| 141 |
Roda 100% local com sentence-transformers β sem custo de API.
|
| 142 |
"""
|
| 143 |
if self.embedder is None:
|
|
|
|
| 152 |
|
| 153 |
@staticmethod
|
| 154 |
def _image_to_b64(image) -> str:
|
| 155 |
+
"""Converte PIL Image para base64 JPEG (max 800px para economizar tokens)."""
|
| 156 |
buf = io.BytesIO()
|
| 157 |
+
img = image.copy()
|
| 158 |
+
img.thumbnail((800, 800))
|
| 159 |
+
img.save(buf, format="JPEG", quality=80)
|
| 160 |
return base64.b64encode(buf.getvalue()).decode()
|
| 161 |
|
| 162 |
@staticmethod
|
| 163 |
def _parse_json(raw: str) -> dict:
|
| 164 |
+
"""Extrai JSON da resposta do modelo (ignora texto ao redor)."""
|
| 165 |
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 166 |
if match:
|
| 167 |
try:
|
| 168 |
return json.loads(match.group())
|
| 169 |
except json.JSONDecodeError:
|
| 170 |
pass
|
| 171 |
+
log.warning("NΓ£o foi possΓvel parsear JSON da resposta β usando fallback")
|
| 172 |
return AnimalAI._fallback()
|
| 173 |
|
| 174 |
@staticmethod
|
| 175 |
def _desc_to_text(d: dict) -> str:
|
| 176 |
+
parts = [
|
| 177 |
+
d.get("size", ""),
|
| 178 |
+
d.get("primary_color", ""),
|
| 179 |
+
d.get("species", ""),
|
| 180 |
+
d.get("breed_estimate", ""),
|
| 181 |
+
]
|
| 182 |
marks = d.get("distinctive_marks", [])
|
| 183 |
if marks:
|
| 184 |
parts.append("with " + ", ".join(marks))
|
core/database.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
database.py β SQLite CRUD para animais e avistamentos.
|
| 3 |
-
Usa /data/ em
|
| 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"
|
|
@@ -27,13 +26,13 @@ class Database:
|
|
| 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")
|
| 37 |
except sqlite3.OperationalError:
|
| 38 |
pass
|
| 39 |
conn.execute("PRAGMA foreign_keys=ON")
|
|
@@ -44,52 +43,48 @@ class Database:
|
|
| 44 |
if SCHEMA.exists():
|
| 45 |
conn.executescript(SCHEMA.read_text())
|
| 46 |
else:
|
| 47 |
-
conn.executescript(
|
| 48 |
-
CREATE TABLE IF NOT EXISTS animals (
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
);
|
| 57 |
-
CREATE TABLE IF NOT EXISTS sightings (
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 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 |
-
"
|
| 92 |
-
VALUES (?, ?, ?)""",
|
| 93 |
(
|
| 94 |
description.get("species", "dog"),
|
| 95 |
json.dumps(description, ensure_ascii=False),
|
|
@@ -99,13 +94,10 @@ class Database:
|
|
| 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 |
-
"
|
| 106 |
-
|
| 107 |
-
last_seen = CURRENT_TIMESTAMP
|
| 108 |
-
WHERE id = ?""",
|
| 109 |
(animal_id,),
|
| 110 |
)
|
| 111 |
|
|
@@ -116,8 +108,7 @@ class Database:
|
|
| 116 |
).fetchone()
|
| 117 |
return dict(row) if row else None
|
| 118 |
|
| 119 |
-
def get_all_animals_with_embeddings(self) -> list
|
| 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"
|
|
@@ -133,7 +124,7 @@ class Database:
|
|
| 133 |
result.append(d)
|
| 134 |
return result
|
| 135 |
|
| 136 |
-
# βββ Sightings βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
|
| 138 |
def add_sighting(
|
| 139 |
self,
|
|
@@ -145,29 +136,24 @@ class Database:
|
|
| 145 |
):
|
| 146 |
with self._conn() as conn:
|
| 147 |
conn.execute(
|
| 148 |
-
"
|
| 149 |
-
|
| 150 |
(animal_id, photo_path, lat, lng, notes or ""),
|
| 151 |
)
|
| 152 |
|
| 153 |
-
def get_animal_sightings(self, animal_id: int) -> list
|
| 154 |
with self._conn() as conn:
|
| 155 |
rows = conn.execute(
|
| 156 |
-
"
|
| 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
|
| 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
|
| 171 |
|
| 172 |
if species in ("dog", "cat"):
|
| 173 |
filters.append("a.species = ?")
|
|
@@ -178,26 +164,30 @@ class Database:
|
|
| 178 |
filters.append("a.last_seen >= datetime('now', '-7 days')")
|
| 179 |
|
| 180 |
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
| 181 |
-
|
| 182 |
-
sql =
|
| 183 |
-
SELECT
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
ORDER BY
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
with self._conn() as conn:
|
| 202 |
rows = conn.execute(sql, params).fetchall()
|
| 203 |
|
|
@@ -205,32 +195,44 @@ class Database:
|
|
| 205 |
for row in rows:
|
| 206 |
d = dict(row)
|
| 207 |
try:
|
| 208 |
-
desc_obj = json.loads(d
|
| 209 |
d["desc"] = desc_obj.get("description_text") or (
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
| 212 |
).strip()
|
| 213 |
except Exception:
|
| 214 |
d["desc"] = ""
|
| 215 |
-
|
|
|
|
| 216 |
result.append(d)
|
| 217 |
return result
|
| 218 |
|
| 219 |
-
# βββ Animals list ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 220 |
-
|
| 221 |
-
def get_recent_animals(self, limit: int = 30) -> list
|
| 222 |
-
sql =
|
| 223 |
-
SELECT
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
LIMIT
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
with self._conn() as conn:
|
| 232 |
rows = conn.execute(sql, (limit,)).fetchall()
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
def total_sightings(self) -> int:
|
| 236 |
with self._conn() as conn:
|
|
@@ -239,3 +241,5 @@ class Database:
|
|
| 239 |
def total_animals(self) -> int:
|
| 240 |
with self._conn() as conn:
|
| 241 |
return conn.execute("SELECT COUNT(*) FROM animals").fetchone()[0]
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
database.py β SQLite CRUD para animais e avistamentos.
|
| 3 |
+
Usa /data/ em producao (HF Storage Bucket) ou ./data/ localmente.
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
import sqlite3
|
| 8 |
import uuid
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
import numpy as np
|
| 13 |
from PIL import Image
|
| 14 |
|
| 15 |
+
# βββ Paths βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
_hf_data = Path("/data")
|
| 17 |
DATA_DIR = _hf_data if (_hf_data.exists() and os.access(_hf_data, os.W_OK)) else Path("./data")
|
| 18 |
DB_PATH = DATA_DIR / "viralata.db"
|
|
|
|
| 26 |
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 27 |
self._init_db()
|
| 28 |
|
| 29 |
+
# βββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
|
| 31 |
def _conn(self) -> sqlite3.Connection:
|
| 32 |
conn = sqlite3.connect(str(DB_PATH))
|
| 33 |
conn.row_factory = sqlite3.Row
|
| 34 |
try:
|
| 35 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 36 |
except sqlite3.OperationalError:
|
| 37 |
pass
|
| 38 |
conn.execute("PRAGMA foreign_keys=ON")
|
|
|
|
| 43 |
if SCHEMA.exists():
|
| 44 |
conn.executescript(SCHEMA.read_text())
|
| 45 |
else:
|
| 46 |
+
conn.executescript(
|
| 47 |
+
"CREATE TABLE IF NOT EXISTS animals ("
|
| 48 |
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
| 49 |
+
" species TEXT NOT NULL,"
|
| 50 |
+
" description TEXT,"
|
| 51 |
+
" embedding BLOB,"
|
| 52 |
+
" first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,"
|
| 53 |
+
" last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,"
|
| 54 |
+
" sighting_count INTEGER DEFAULT 1"
|
| 55 |
+
");"
|
| 56 |
+
"CREATE TABLE IF NOT EXISTS sightings ("
|
| 57 |
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
| 58 |
+
" animal_id INTEGER NOT NULL REFERENCES animals(id),"
|
| 59 |
+
" photo_path TEXT,"
|
| 60 |
+
" latitude REAL,"
|
| 61 |
+
" longitude REAL,"
|
| 62 |
+
" notes TEXT,"
|
| 63 |
+
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
| 64 |
+
");"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# βββ Photos ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
|
| 69 |
def save_photo(self, image: Image.Image) -> str:
|
|
|
|
| 70 |
filename = f"{uuid.uuid4().hex}.jpg"
|
| 71 |
path = PHOTOS_DIR / filename
|
| 72 |
image.save(str(path), format="JPEG", quality=85)
|
| 73 |
return f"photos/{filename}"
|
| 74 |
|
| 75 |
def photo_url(self, relative_path: Optional[str]) -> Optional[str]:
|
|
|
|
| 76 |
if not relative_path:
|
| 77 |
return None
|
| 78 |
full = DATA_DIR / relative_path
|
| 79 |
return str(full) if full.exists() else None
|
| 80 |
|
| 81 |
+
# βββ Animals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
|
| 83 |
def create_animal(self, description: dict, embedding: list) -> int:
|
|
|
|
| 84 |
emb_blob = np.array(embedding, dtype=np.float32).tobytes()
|
| 85 |
with self._conn() as conn:
|
| 86 |
cur = conn.execute(
|
| 87 |
+
"INSERT INTO animals (species, description, embedding) VALUES (?, ?, ?)",
|
|
|
|
| 88 |
(
|
| 89 |
description.get("species", "dog"),
|
| 90 |
json.dumps(description, ensure_ascii=False),
|
|
|
|
| 94 |
return cur.lastrowid
|
| 95 |
|
| 96 |
def update_animal(self, animal_id: int):
|
|
|
|
| 97 |
with self._conn() as conn:
|
| 98 |
conn.execute(
|
| 99 |
+
"UPDATE animals SET sighting_count = sighting_count + 1,"
|
| 100 |
+
" last_seen = CURRENT_TIMESTAMP WHERE id = ?",
|
|
|
|
|
|
|
| 101 |
(animal_id,),
|
| 102 |
)
|
| 103 |
|
|
|
|
| 108 |
).fetchone()
|
| 109 |
return dict(row) if row else None
|
| 110 |
|
| 111 |
+
def get_all_animals_with_embeddings(self) -> list:
|
|
|
|
| 112 |
with self._conn() as conn:
|
| 113 |
rows = conn.execute(
|
| 114 |
"SELECT id, species, description, embedding FROM animals WHERE embedding IS NOT NULL"
|
|
|
|
| 124 |
result.append(d)
|
| 125 |
return result
|
| 126 |
|
| 127 |
+
# βββ Sightings βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
|
| 129 |
def add_sighting(
|
| 130 |
self,
|
|
|
|
| 136 |
):
|
| 137 |
with self._conn() as conn:
|
| 138 |
conn.execute(
|
| 139 |
+
"INSERT INTO sightings (animal_id, photo_path, latitude, longitude, notes)"
|
| 140 |
+
" VALUES (?, ?, ?, ?, ?)",
|
| 141 |
(animal_id, photo_path, lat, lng, notes or ""),
|
| 142 |
)
|
| 143 |
|
| 144 |
+
def get_animal_sightings(self, animal_id: int) -> list:
|
| 145 |
with self._conn() as conn:
|
| 146 |
rows = conn.execute(
|
| 147 |
+
"SELECT * FROM sightings WHERE animal_id = ? ORDER BY created_at DESC",
|
|
|
|
| 148 |
(animal_id,),
|
| 149 |
).fetchall()
|
| 150 |
return [dict(r) for r in rows]
|
| 151 |
|
| 152 |
+
# βββ Map data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
|
| 154 |
+
def get_map_data(self, species: str = "all", timeframe: str = "all") -> list:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
filters = []
|
| 156 |
+
params = []
|
| 157 |
|
| 158 |
if species in ("dog", "cat"):
|
| 159 |
filters.append("a.species = ?")
|
|
|
|
| 164 |
filters.append("a.last_seen >= datetime('now', '-7 days')")
|
| 165 |
|
| 166 |
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
| 167 |
+
|
| 168 |
+
sql = (
|
| 169 |
+
"SELECT"
|
| 170 |
+
" a.id,"
|
| 171 |
+
" a.species,"
|
| 172 |
+
" a.sighting_count AS count,"
|
| 173 |
+
" a.description,"
|
| 174 |
+
" strftime('%d/%m/%Y', a.last_seen) AS last_seen,"
|
| 175 |
+
" CAST(julianday('now') - julianday(a.last_seen) AS INTEGER) AS days_since,"
|
| 176 |
+
" s.latitude AS lat,"
|
| 177 |
+
" s.longitude AS lng,"
|
| 178 |
+
" (SELECT photo_path FROM sightings"
|
| 179 |
+
" WHERE animal_id = a.id AND photo_path IS NOT NULL"
|
| 180 |
+
" ORDER BY created_at DESC LIMIT 1) AS last_photo"
|
| 181 |
+
" FROM animals a"
|
| 182 |
+
" JOIN sightings s ON s.id = ("
|
| 183 |
+
" SELECT id FROM sightings"
|
| 184 |
+
" WHERE animal_id = a.id AND latitude IS NOT NULL"
|
| 185 |
+
" ORDER BY created_at DESC LIMIT 1"
|
| 186 |
+
" )"
|
| 187 |
+
" " + where +
|
| 188 |
+
" ORDER BY a.last_seen DESC"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
with self._conn() as conn:
|
| 192 |
rows = conn.execute(sql, params).fetchall()
|
| 193 |
|
|
|
|
| 195 |
for row in rows:
|
| 196 |
d = dict(row)
|
| 197 |
try:
|
| 198 |
+
desc_obj = json.loads(d.get("description") or "{}")
|
| 199 |
d["desc"] = desc_obj.get("description_text") or (
|
| 200 |
+
" ".join(filter(None, [
|
| 201 |
+
desc_obj.get("size", ""),
|
| 202 |
+
desc_obj.get("primary_color", ""),
|
| 203 |
+
desc_obj.get("breed_estimate", ""),
|
| 204 |
+
]))
|
| 205 |
).strip()
|
| 206 |
except Exception:
|
| 207 |
d["desc"] = ""
|
| 208 |
+
d.pop("description", None)
|
| 209 |
+
d["photo_url"] = self.photo_url(d.get("last_photo")) or ""
|
| 210 |
result.append(d)
|
| 211 |
return result
|
| 212 |
|
| 213 |
+
# βββ Animals list ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
|
| 215 |
+
def get_recent_animals(self, limit: int = 30) -> list:
|
| 216 |
+
sql = (
|
| 217 |
+
"SELECT"
|
| 218 |
+
" a.*,"
|
| 219 |
+
" CAST(julianday('now') - julianday(a.last_seen) AS INTEGER) AS days_since,"
|
| 220 |
+
" strftime('%d/%m', a.last_seen) AS last_seen_short,"
|
| 221 |
+
" (SELECT photo_path FROM sightings"
|
| 222 |
+
" WHERE animal_id = a.id AND photo_path IS NOT NULL"
|
| 223 |
+
" ORDER BY created_at DESC LIMIT 1) AS last_photo_path"
|
| 224 |
+
" FROM animals a"
|
| 225 |
+
" ORDER BY a.last_seen DESC"
|
| 226 |
+
" LIMIT ?"
|
| 227 |
+
)
|
| 228 |
with self._conn() as conn:
|
| 229 |
rows = conn.execute(sql, (limit,)).fetchall()
|
| 230 |
+
result = []
|
| 231 |
+
for row in rows:
|
| 232 |
+
d = dict(row)
|
| 233 |
+
d["last_photo_url"] = self.photo_url(d.get("last_photo_path")) or ""
|
| 234 |
+
result.append(d)
|
| 235 |
+
return result
|
| 236 |
|
| 237 |
def total_sightings(self) -> int:
|
| 238 |
with self._conn() as conn:
|
|
|
|
| 241 |
def total_animals(self) -> int:
|
| 242 |
with self._conn() as conn:
|
| 243 |
return conn.execute("SELECT COUNT(*) FROM animals").fetchone()[0]
|
| 244 |
+
as conn:
|
| 245 |
+
return conn.execute("SELECT COUNT(*) FROM animals").fetchone()[0]
|
data/viralata.db
CHANGED
|
Binary files a/data/viralata.db and b/data/viralata.db differ
|
|
|
data/viralata.db-journal
DELETED
|
Binary file (512 Bytes)
|
|
|
requirements.txt
CHANGED
|
@@ -5,3 +5,4 @@ starlette<0.28.0
|
|
| 5 |
pillow>=10.0.0
|
| 6 |
sentence-transformers>=2.7.0
|
| 7 |
numpy>=1.26.0
|
|
|
|
|
|
| 5 |
pillow>=10.0.0
|
| 6 |
sentence-transformers>=2.7.0
|
| 7 |
numpy>=1.26.0
|
| 8 |
+
openai>=1.0.0
|