sara.mesquita commited on
Commit
9319a7c
Β·
1 Parent(s): be251cf

adjusts ia

Browse files
.idea/material_theme_project_new.xml CHANGED
@@ -3,7 +3,9 @@
3
  <component name="MaterialThemeProjectNewConfig">
4
  <option name="metadata">
5
  <MTProjectMetadataState>
6
- <option name="userId" value="-47692bf2:19e97ac351f:-7ffd" />
 
 
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="ProjectRootManager">
 
 
 
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 β€” Spotted Animal
3
- Collaborative mapping of stray animals.
4
- Build Small Hackathon Β· Backyard AI Track Β· June 2026
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 para carregar Γ­cones ──────────────────────────────────────────
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
- margin: 0;
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 look */
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
- REGISTER TAB & FORM UI
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
- /* Evitar que os textos fiquem invisΓ­veis no dark mode */
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
- /* Status card */
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
- /* GPS box */
266
- #gps-box {{
267
- display: flex;
268
- align-items: center;
269
- gap: 10px;
270
- background: {C_GREEN_L};
271
- border-radius: 10px;
272
- padding: 12px 14px;
273
- font-size: 13px;
274
- color: #2e7d32;
275
- font-weight: 500;
276
- border: 1px solid #c8e6c9;
277
- transition: all 0.3s ease;
278
- }}
279
- #gps-btn {{
280
- transition: all 0.2s ease;
281
- }}
282
- #gps-btn:hover:not(:disabled) {{
283
- transform: scale(1.05);
284
- box-shadow: 0 2px 8px rgba(56, 140, 89, 0.4);
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-avatar {{
305
- width: 46px; height: 46px;
306
- border-radius: 50%;
307
- display: flex; align-items: center; justify-content: center;
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">Click the button to detect location</span>
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> Get Location
342
  </button>
343
  </div>
344
  <script>
345
  (function() {
346
- var icon = document.getElementById('gps-icon');
347
- var text = document.getElementById('gps-text');
348
- var btn = document.getElementById('gps-btn');
349
-
350
- function setCoords(coords) {
351
- // Aguarda o Gradio renderizar o textarea
352
- setTimeout(function() {
353
- var tb = document.querySelector('#gps-coords textarea');
354
- if (tb) {
355
- var nativeSet = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
356
- nativeSet.call(tb, JSON.stringify(coords));
357
- tb.dispatchEvent(new Event('input', { bubbles: true }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  }
359
- }, 100);
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
- btn.disabled = true;
371
- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detecting...';
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
- btn.innerHTML = '<i class="fa-solid fa-location-dot"></i> Try Again';
391
- btn.disabled = false;
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
- // Evento de clique no botΓ£o
415
- btn.addEventListener('click', requestLocation);
416
-
417
- // Tenta automaticamente apΓ³s 500ms (apΓ³s Gradio carregar)
418
- setTimeout(function() {
419
- if (navigator.geolocation) {
420
- requestLocation();
 
 
 
 
 
 
 
 
 
 
421
  }
422
- }, 500);
 
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='No sightings yet <i class=\"fa-solid fa-paw\"></i>';return d;}};
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'?'<i class=\"fa-solid fa-dog\"></i>':'<i class=\"fa-solid fa-cat\"></i>';
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:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;color:white;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;">'+em+badge+'</div>',className:'',iconSize:[36,36],iconAnchor:[18,18],popupAnchor:[0,-20]}});
461
- var sp=a.species==='dog'?'Dog':'Cat';
462
- var urg=a.days_since>30?'<br><small style="color:#E53935"><i class=\"fa-solid fa-triangle-exclamation\"></i> Not seen for '+a.days_since+' days</small>':'';
463
- L.marker([a.lat,a.lng],{{icon:ico}}).addTo(map).bindPopup('<b>'+em+' '+sp+' #'+a.id+'</b>'+(a.desc?'<br><small>'+a.desc+'</small>':'')+'<br><small><i class=\"fa-solid fa-eye\"></i> '+a.count+'x Β· '+a.last_seen+'</small>'+urg,{{maxWidth:220}});
 
 
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;"><i class="fa-solid fa-paw"></i></div>'
480
- '<div style="font-size:15px;font-weight:500;">No animals registered yet</div>'
481
- '<div style="font-size:13px;margin-top:6px;">Go to Register and take the first photo!</div>'
482
  "</div>"
483
  )
484
 
@@ -489,27 +498,34 @@ def build_animals_html() -> str:
489
  except Exception:
490
  desc = {}
491
 
492
- is_dog = a["species"] == "dog"
493
- em = '<i class="fa-solid fa-dog"></i>' if is_dog else '<i class="fa-solid fa-cat"></i>'
494
- sp_pt = "Dog" if is_dog else "Cat"
495
- urgent = a.get("days_since", 0) > 30
496
- color = C_RED if urgent else C_GREEN
497
- badge_cls = "animal-badge urgent" if urgent else "animal-badge"
498
  urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ''
499
- breed = desc.get("breed_estimate", "unknown breed")
500
- color_coat = desc.get("primary_color", "")
501
- meta = f"{breed}{' Β· ' + color_coat if color_coat else ''}"
502
- last_seen = a.get("last_seen_short", "")
503
- count = a["sighting_count"]
 
 
 
 
 
 
 
504
 
505
  cards.append(f"""
506
  <div class="animal-card">
507
- <div class="animal-avatar" style="background:{color}20; color:{color};">{em}</div>
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}Seen {count}x Β· last: {last_seen}
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 = '<i class="fa-solid fa-dog"></i>' if species == "dog" else '<i class="fa-solid fa-cat"></i>'
530
- sp_pt = "dog" if species == "dog" else "cat"
531
  if is_new:
532
- title = f"New {sp_pt} registered!"
533
- sub = "1st sighting β€” thank you for registering!"
534
  else:
535
- title = f"{em} Animal recognized!"
536
- sub = f"This {sp_pt} has already been spotted <b>{count}x</b> in the region."
 
 
 
 
537
 
538
  return f"""
539
  <div id="status-card" class="ok">
540
- <div style="font-size:24px;margin-bottom:6px;color:{C_GREEN};"><i class="fa-solid fa-check"></i> {em}</div>
 
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} Β· Sighting saved with location
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> Take a photo of the animal before registering.</div>',
557
  )
558
 
559
- progress(0.15, desc="Analyzing image...")
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="Identifying animal with AI...")
569
  description = ai.analyze_image(image)
570
  embedding = ai.get_embedding(description)
571
 
572
- progress(0.70, desc="Checking previous sightings...")
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="Saved!")
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="Spotted Animal") as demo:
612
 
613
  # Top bar
614
  gr.HTML(
615
  '<div id="top-bar">'
616
- '<h1><i class="fa-solid fa-paw"></i> Spotted Animal</h1>'
617
  '<small>Vinhedo, SP</small>'
618
  "</div>"
619
  )
620
 
621
- species_st = gr.State("all")
622
  timeframe_st = gr.State("all")
623
 
624
  with gr.Tabs(elem_classes="tabs") as tabs:
625
 
626
- # ── Tab 1: Map ───────────────────────────────────────────────────────
627
- with gr.Tab("Map"):
628
  with gr.Row(elem_classes="filter-row"):
629
- btn_all = gr.Button("All", elem_classes="filter-row")
630
- btn_dogs = gr.Button("Dogs", elem_classes="filter-row")
631
- btn_cats = gr.Button("Cats", elem_classes="filter-row")
632
- btn_today = gr.Button("Today", elem_classes="filter-row secondary")
633
- btn_week = gr.Button("This week", elem_classes="filter-row secondary")
634
 
635
  map_html = gr.HTML(build_map_html(), elem_id="map-container")
636
 
637
- # ── Tab 2: Register ──────────────────────────────────────────────────
638
- with gr.Tab("Register", elem_id="register-tab"):
639
 
640
  with gr.Column(elem_classes="form-card"):
641
 
642
- # Step 1: Location
643
- gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i> Step 1: Location</div>')
644
  gr.HTML(GPS_HTML)
645
 
646
- # Hidden textbox receives coords from JS
 
647
  gps_coords = gr.Textbox(
648
  value="",
649
- visible=False,
650
  elem_id="gps-coords",
651
  interactive=True,
 
 
652
  )
653
 
654
- # Step 2: Photo
655
- gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i> Step 2: Animal Photo</div>')
 
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
- # Step 3: Notes
666
- gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Step 3: Notes (optional)</div>')
667
  notes_input = gr.Textbox(
668
  label="",
669
- placeholder="E.g.: looks injured, has a collar, has puppies...",
670
  lines=2,
671
  max_lines=4,
672
  show_label=False,
673
  )
674
 
675
- # Submit Button
676
  submit_btn = gr.Button(
677
- "Register sighting",
678
  variant="primary",
679
  elem_id="submit-btn",
680
  )
681
 
682
- # Feedback message container
683
  status_html = gr.HTML("")
684
 
685
- # ── Tab 3: Spotted ──────────────────────────────────────────────────
686
- with gr.Tab("Spotted", elem_id="animals-tab"):
687
- refresh_btn = gr.Button("Refresh list", size="sm", variant="secondary")
688
  animals_display = gr.HTML(build_animals_html())
689
 
690
- # ─── Events ───────────────────────────────────────────────────────────────
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
- log.warning(f"get_api_info non-fatal: {_e}")
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 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
@@ -16,7 +23,12 @@ import numpy as np
16
 
17
  log = logging.getLogger(__name__)
18
 
19
- _DEFAULT_MODEL = "nvidia/Llama-3.2-Nemotron-11B-Vision-Instruct"
 
 
 
 
 
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
- 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_completion(
 
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:
@@ -110,24 +152,33 @@ class AnimalAI:
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))
 
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 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"
@@ -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") # pode falhar em FUSE mounts
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
- 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),
@@ -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
- """UPDATE animals
106
- SET sighting_count = sighting_count + 1,
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[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"
@@ -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
- """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 = ?")
@@ -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
- # 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
 
@@ -205,32 +195,44 @@ class Database:
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:
@@ -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