Merge feature/pet-aware-security into main
Pet detection (YOLOv8), pet ID classifier (MobileNetV3-Small), wildlife threat-tiered alerting, pet dashboard, training UI, and recording labeling overlay. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6436076c8a
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.12.0
|
||||
@ -22,6 +22,8 @@ dependencies = [
|
||||
"pynut2>=0.1.0",
|
||||
"pywebpush>=2.0.0",
|
||||
"py-vapid>=1.9.0",
|
||||
"ultralytics>=8.2.0",
|
||||
"torchvision>=0.18.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -23,7 +23,7 @@ def _create_test_engine(db_path: Path):
|
||||
def tmp_data_dir(tmp_path):
|
||||
"""Temporary data directory for tests."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
return data_dir
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Tests for config loading and validation."""
|
||||
|
||||
from vigilar.config import CameraConfig, VigilarConfig
|
||||
from vigilar.config import PetsConfig, WildlifeThreatMap, WildlifeSizeHeuristics, PetActivityConfig
|
||||
|
||||
|
||||
def test_default_config():
|
||||
@ -33,3 +34,61 @@ def test_camera_sensitivity_bounds():
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost", motion_sensitivity=1.5)
|
||||
|
||||
|
||||
class TestPetsConfig:
|
||||
def test_defaults(self):
|
||||
cfg = PetsConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.model == "yolov8s"
|
||||
assert cfg.confidence_threshold == 0.5
|
||||
assert cfg.pet_id_threshold == 0.7
|
||||
assert cfg.pet_id_low_confidence == 0.5
|
||||
assert cfg.min_training_images == 20
|
||||
assert cfg.crop_retention_days == 7
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = PetsConfig(enabled=True, model="yolov8m", confidence_threshold=0.6)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.model == "yolov8m"
|
||||
assert cfg.confidence_threshold == 0.6
|
||||
|
||||
|
||||
class TestWildlifeThreatMap:
|
||||
def test_defaults(self):
|
||||
tm = WildlifeThreatMap()
|
||||
assert "bear" in tm.predator
|
||||
assert "bird" in tm.passive
|
||||
|
||||
def test_custom_mapping(self):
|
||||
tm = WildlifeThreatMap(predator=["bear", "wolf"], nuisance=["raccoon"])
|
||||
assert "wolf" in tm.predator
|
||||
assert "raccoon" in tm.nuisance
|
||||
|
||||
|
||||
class TestWildlifeSizeHeuristics:
|
||||
def test_defaults(self):
|
||||
sh = WildlifeSizeHeuristics()
|
||||
assert sh.small == 0.02
|
||||
assert sh.medium == 0.08
|
||||
assert sh.large == 0.15
|
||||
|
||||
|
||||
class TestPetActivityConfig:
|
||||
def test_defaults(self):
|
||||
cfg = PetActivityConfig()
|
||||
assert cfg.daily_digest is True
|
||||
assert cfg.highlight_clips is True
|
||||
assert cfg.zoomie_threshold == 0.8
|
||||
|
||||
|
||||
class TestCameraConfigLocation:
|
||||
def test_default_location_is_interior(self):
|
||||
from vigilar.config import CameraConfig
|
||||
cfg = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://x")
|
||||
assert cfg.location == "INTERIOR"
|
||||
|
||||
def test_exterior_location(self):
|
||||
from vigilar.config import CameraConfig
|
||||
cfg = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://x", location="EXTERIOR")
|
||||
assert cfg.location == "EXTERIOR"
|
||||
|
||||
51
tests/unit/test_constants.py
Normal file
51
tests/unit/test_constants.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Tests for new pet/wildlife constants."""
|
||||
|
||||
from vigilar.constants import (
|
||||
CameraLocation,
|
||||
EventType,
|
||||
ThreatLevel,
|
||||
Topics,
|
||||
)
|
||||
|
||||
|
||||
class TestThreatLevel:
|
||||
def test_values(self):
|
||||
assert ThreatLevel.PREDATOR == "PREDATOR"
|
||||
assert ThreatLevel.NUISANCE == "NUISANCE"
|
||||
assert ThreatLevel.PASSIVE == "PASSIVE"
|
||||
|
||||
def test_is_strenum(self):
|
||||
assert isinstance(ThreatLevel.PREDATOR, str)
|
||||
|
||||
|
||||
class TestCameraLocation:
|
||||
def test_values(self):
|
||||
assert CameraLocation.EXTERIOR == "EXTERIOR"
|
||||
assert CameraLocation.INTERIOR == "INTERIOR"
|
||||
assert CameraLocation.TRANSITION == "TRANSITION"
|
||||
|
||||
def test_is_strenum(self):
|
||||
assert isinstance(CameraLocation.EXTERIOR, str)
|
||||
|
||||
|
||||
class TestNewEventTypes:
|
||||
def test_pet_events_exist(self):
|
||||
assert EventType.PET_DETECTED == "PET_DETECTED"
|
||||
assert EventType.PET_ESCAPE == "PET_ESCAPE"
|
||||
assert EventType.UNKNOWN_ANIMAL == "UNKNOWN_ANIMAL"
|
||||
|
||||
def test_wildlife_events_exist(self):
|
||||
assert EventType.WILDLIFE_PREDATOR == "WILDLIFE_PREDATOR"
|
||||
assert EventType.WILDLIFE_NUISANCE == "WILDLIFE_NUISANCE"
|
||||
assert EventType.WILDLIFE_PASSIVE == "WILDLIFE_PASSIVE"
|
||||
|
||||
|
||||
class TestPetTopics:
|
||||
def test_pet_detected_topic(self):
|
||||
assert Topics.camera_pet_detected("front") == "vigilar/camera/front/pet/detected"
|
||||
|
||||
def test_wildlife_detected_topic(self):
|
||||
assert Topics.camera_wildlife_detected("front") == "vigilar/camera/front/wildlife/detected"
|
||||
|
||||
def test_pet_location_topic(self):
|
||||
assert Topics.pet_location("angel") == "vigilar/pets/angel/location"
|
||||
48
tests/unit/test_crop_manager.py
Normal file
48
tests/unit/test_crop_manager.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Tests for detection crop saving and staging cleanup."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vigilar.detection.crop_manager import CropManager
|
||||
|
||||
|
||||
class TestCropManager:
|
||||
def test_save_crop(self, tmp_path):
|
||||
manager = CropManager(staging_dir=str(tmp_path / "staging"),
|
||||
training_dir=str(tmp_path / "training"))
|
||||
crop = np.zeros((100, 80, 3), dtype=np.uint8)
|
||||
path = manager.save_staging_crop(crop, species="cat", camera_id="kitchen")
|
||||
assert Path(path).exists()
|
||||
assert "cat" in path
|
||||
assert "kitchen" in path
|
||||
|
||||
def test_promote_to_training(self, tmp_path):
|
||||
manager = CropManager(staging_dir=str(tmp_path / "staging"),
|
||||
training_dir=str(tmp_path / "training"))
|
||||
crop = np.zeros((100, 80, 3), dtype=np.uint8)
|
||||
staging_path = manager.save_staging_crop(crop, species="cat", camera_id="kitchen")
|
||||
training_path = manager.promote_to_training(staging_path, pet_name="angel")
|
||||
assert Path(training_path).exists()
|
||||
assert "angel" in training_path
|
||||
assert not Path(staging_path).exists()
|
||||
|
||||
def test_cleanup_old_crops(self, tmp_path):
|
||||
staging = tmp_path / "staging"
|
||||
staging.mkdir(parents=True)
|
||||
|
||||
old_file = staging / "old_crop.jpg"
|
||||
old_file.write_bytes(b"fake")
|
||||
old_time = time.time() - 10 * 86400
|
||||
import os
|
||||
os.utime(old_file, (old_time, old_time))
|
||||
|
||||
new_file = staging / "new_crop.jpg"
|
||||
new_file.write_bytes(b"fake")
|
||||
|
||||
manager = CropManager(staging_dir=str(staging), training_dir=str(tmp_path / "training"))
|
||||
deleted = manager.cleanup_expired(retention_days=7)
|
||||
assert deleted == 1
|
||||
assert not old_file.exists()
|
||||
assert new_file.exists()
|
||||
@ -327,3 +327,105 @@ class TestEventHistory:
|
||||
assert len(rows_offset) == 2
|
||||
# Should be different events
|
||||
assert rows[0]["id"] != rows_offset[0]["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pet / Wildlife Event Classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPetEventClassification:
|
||||
def test_pet_detected_event(self):
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/kitchen/pet/detected",
|
||||
{"pet_id": "p1", "pet_name": "Angel", "confidence": 0.92, "camera_location": "INTERIOR"}, # noqa: E501
|
||||
)
|
||||
assert etype == EventType.PET_DETECTED
|
||||
assert sev == Severity.INFO
|
||||
assert source == "kitchen"
|
||||
|
||||
def test_pet_escape_exterior(self):
|
||||
"""Known pet in exterior zone → PET_ESCAPE/ALERT."""
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/front/pet/detected",
|
||||
{"pet_id": "p1", "pet_name": "Angel", "camera_location": "EXTERIOR"},
|
||||
)
|
||||
assert etype == EventType.PET_ESCAPE
|
||||
assert sev == Severity.ALERT
|
||||
|
||||
def test_pet_escape_transition(self):
|
||||
"""Known pet in transition zone → PET_ESCAPE/ALERT."""
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/garage/pet/detected",
|
||||
{"pet_id": "p1", "pet_name": "Milo", "camera_location": "TRANSITION"},
|
||||
)
|
||||
assert etype == EventType.PET_ESCAPE
|
||||
assert sev == Severity.ALERT
|
||||
|
||||
def test_unknown_animal(self):
|
||||
"""No pet_id → UNKNOWN_ANIMAL/WARNING."""
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/kitchen/pet/detected",
|
||||
{"species": "cat", "camera_location": "INTERIOR"},
|
||||
)
|
||||
assert etype == EventType.UNKNOWN_ANIMAL
|
||||
assert sev == Severity.WARNING
|
||||
|
||||
def test_known_pet_interior(self):
|
||||
"""Known pet in interior → PET_DETECTED/INFO."""
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/kitchen/pet/detected",
|
||||
{"pet_id": "p1", "pet_name": "Angel", "camera_location": "INTERIOR"},
|
||||
)
|
||||
assert etype == EventType.PET_DETECTED
|
||||
assert sev == Severity.INFO
|
||||
|
||||
def test_wildlife_predator_event(self):
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/front/wildlife/detected",
|
||||
{"species": "bear", "threat_level": "PREDATOR"},
|
||||
)
|
||||
assert etype == EventType.WILDLIFE_PREDATOR
|
||||
assert sev == Severity.CRITICAL
|
||||
assert source == "front"
|
||||
|
||||
def test_wildlife_nuisance_event(self):
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/back/wildlife/detected",
|
||||
{"species": "raccoon", "threat_level": "NUISANCE"},
|
||||
)
|
||||
assert etype == EventType.WILDLIFE_NUISANCE
|
||||
assert sev == Severity.WARNING
|
||||
assert source == "back"
|
||||
|
||||
def test_wildlife_passive_event(self):
|
||||
from vigilar.events.processor import EventProcessor
|
||||
from vigilar.constants import EventType, Severity
|
||||
processor = EventProcessor.__new__(EventProcessor)
|
||||
etype, sev, source = processor._classify_event(
|
||||
"vigilar/camera/front/wildlife/detected",
|
||||
{"species": "deer", "threat_level": "PASSIVE"},
|
||||
)
|
||||
assert etype == EventType.WILDLIFE_PASSIVE
|
||||
assert sev == Severity.INFO
|
||||
assert source == "front"
|
||||
|
||||
@ -5,8 +5,10 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from vigilar.health.digest import build_digest, format_digest
|
||||
from vigilar.health.pruner import find_prunable_recordings, calculate_disk_usage_pct
|
||||
from vigilar.health.monitor import HealthCheck, HealthStatus, check_disk, check_mqtt_port
|
||||
from vigilar.storage.schema import pet_sightings, wildlife_sightings
|
||||
|
||||
|
||||
class TestDiskCheck:
|
||||
@ -40,6 +42,48 @@ class TestPruner:
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPetDigest:
|
||||
def test_digest_includes_pet_sightings(self, test_db, tmp_data_dir):
|
||||
import time
|
||||
with test_db.begin() as conn:
|
||||
conn.execute(pet_sightings.insert().values(
|
||||
ts=time.time(), pet_id="p1", species="cat",
|
||||
camera_id="kitchen", confidence=0.9, labeled=1,
|
||||
))
|
||||
conn.execute(pet_sightings.insert().values(
|
||||
ts=time.time(), pet_id="p2", species="dog",
|
||||
camera_id="kitchen", confidence=0.85, labeled=1,
|
||||
))
|
||||
data = build_digest(test_db, str(tmp_data_dir), since_hours=1)
|
||||
assert data["pet_sightings"] == 2
|
||||
|
||||
def test_digest_includes_wildlife(self, test_db, tmp_data_dir):
|
||||
import time
|
||||
with test_db.begin() as conn:
|
||||
conn.execute(wildlife_sightings.insert().values(
|
||||
ts=time.time(), species="bear", threat_level="PREDATOR",
|
||||
camera_id="front", confidence=0.9,
|
||||
))
|
||||
conn.execute(wildlife_sightings.insert().values(
|
||||
ts=time.time(), species="deer", threat_level="PASSIVE",
|
||||
camera_id="back", confidence=0.8,
|
||||
))
|
||||
data = build_digest(test_db, str(tmp_data_dir), since_hours=1)
|
||||
assert data["wildlife_predators"] == 1
|
||||
assert data["wildlife_passive"] == 1
|
||||
|
||||
def test_format_includes_pets(self):
|
||||
data = {
|
||||
"person_detections": 2, "unknown_vehicles": 0,
|
||||
"recordings": 5, "disk_used_gb": 100.0, "disk_used_pct": 50,
|
||||
"since_hours": 12, "pet_sightings": 15,
|
||||
"wildlife_predators": 0, "wildlife_nuisance": 1, "wildlife_passive": 3,
|
||||
}
|
||||
text = format_digest(data)
|
||||
assert "15 pet" in text
|
||||
assert "wildlife" in text.lower() or "nuisance" in text.lower()
|
||||
|
||||
|
||||
class TestMQTTCheck:
|
||||
@patch("vigilar.health.monitor.socket.create_connection")
|
||||
def test_mqtt_reachable(self, mock_conn):
|
||||
|
||||
49
tests/unit/test_pet_id.py
Normal file
49
tests/unit/test_pet_id.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Tests for pet ID classifier."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vigilar.detection.pet_id import PetIDClassifier, PetIDResult
|
||||
|
||||
|
||||
class TestPetIDResult:
|
||||
def test_identified(self):
|
||||
r = PetIDResult(pet_id="pet-1", pet_name="Angel", confidence=0.9)
|
||||
assert r.is_identified
|
||||
assert not r.is_low_confidence
|
||||
|
||||
def test_low_confidence(self):
|
||||
r = PetIDResult(pet_id="pet-1", pet_name="Angel", confidence=0.6)
|
||||
assert r.is_identified
|
||||
assert r.is_low_confidence
|
||||
|
||||
def test_unknown(self):
|
||||
r = PetIDResult(pet_id=None, pet_name=None, confidence=0.3)
|
||||
assert not r.is_identified
|
||||
|
||||
|
||||
class TestPetIDClassifier:
|
||||
def test_not_loaded_returns_unknown(self):
|
||||
classifier = PetIDClassifier(model_path="nonexistent.pt")
|
||||
assert not classifier.is_loaded
|
||||
crop = np.zeros((224, 224, 3), dtype=np.uint8)
|
||||
result = classifier.identify(crop, species="cat")
|
||||
assert not result.is_identified
|
||||
|
||||
def test_no_pets_registered_returns_unknown(self):
|
||||
classifier = PetIDClassifier(model_path="nonexistent.pt")
|
||||
assert classifier.pet_count == 0
|
||||
|
||||
def test_register_pet(self):
|
||||
classifier = PetIDClassifier(model_path="nonexistent.pt")
|
||||
classifier.register_pet("pet-1", "Angel", "cat")
|
||||
classifier.register_pet("pet-2", "Milo", "dog")
|
||||
assert classifier.pet_count == 2
|
||||
|
||||
def test_species_filter(self):
|
||||
classifier = PetIDClassifier(model_path="nonexistent.pt")
|
||||
classifier.register_pet("pet-1", "Angel", "cat")
|
||||
classifier.register_pet("pet-2", "Taquito", "cat")
|
||||
classifier.register_pet("pet-3", "Milo", "dog")
|
||||
assert len(classifier.get_pets_by_species("cat")) == 2
|
||||
assert len(classifier.get_pets_by_species("dog")) == 1
|
||||
101
tests/unit/test_pet_queries.py
Normal file
101
tests/unit/test_pet_queries.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Tests for pet and wildlife query functions."""
|
||||
|
||||
import time
|
||||
|
||||
from vigilar.storage.queries import (
|
||||
insert_pet,
|
||||
get_pet,
|
||||
get_all_pets,
|
||||
insert_pet_sighting,
|
||||
get_pet_sightings,
|
||||
get_pet_last_location,
|
||||
insert_wildlife_sighting,
|
||||
get_wildlife_sightings,
|
||||
insert_training_image,
|
||||
get_training_images,
|
||||
get_unlabeled_sightings,
|
||||
label_sighting,
|
||||
)
|
||||
|
||||
|
||||
class TestPetCRUD:
|
||||
def test_insert_and_get_pet(self, test_db):
|
||||
pet_id = insert_pet(test_db, name="Angel", species="cat", breed="DSH",
|
||||
color_description="black")
|
||||
pet = get_pet(test_db, pet_id)
|
||||
assert pet is not None
|
||||
assert pet["name"] == "Angel"
|
||||
assert pet["species"] == "cat"
|
||||
assert pet["training_count"] == 0
|
||||
|
||||
def test_get_all_pets(self, test_db):
|
||||
insert_pet(test_db, name="Angel", species="cat")
|
||||
insert_pet(test_db, name="Milo", species="dog")
|
||||
all_pets = get_all_pets(test_db)
|
||||
assert len(all_pets) == 2
|
||||
|
||||
|
||||
class TestPetSightings:
|
||||
def test_insert_and_query(self, test_db):
|
||||
pet_id = insert_pet(test_db, name="Angel", species="cat")
|
||||
insert_pet_sighting(test_db, pet_id=pet_id, species="cat",
|
||||
camera_id="kitchen", confidence=0.92)
|
||||
sightings = get_pet_sightings(test_db, limit=10)
|
||||
assert len(sightings) == 1
|
||||
assert sightings[0]["camera_id"] == "kitchen"
|
||||
|
||||
def test_last_location(self, test_db):
|
||||
pet_id = insert_pet(test_db, name="Angel", species="cat")
|
||||
insert_pet_sighting(test_db, pet_id=pet_id, species="cat",
|
||||
camera_id="kitchen", confidence=0.9)
|
||||
insert_pet_sighting(test_db, pet_id=pet_id, species="cat",
|
||||
camera_id="living_room", confidence=0.95)
|
||||
loc = get_pet_last_location(test_db, pet_id)
|
||||
assert loc is not None
|
||||
assert loc["camera_id"] == "living_room"
|
||||
|
||||
def test_unlabeled_sightings(self, test_db):
|
||||
insert_pet_sighting(test_db, pet_id=None, species="cat",
|
||||
camera_id="kitchen", confidence=0.6, crop_path="/tmp/crop.jpg")
|
||||
unlabeled = get_unlabeled_sightings(test_db, limit=10)
|
||||
assert len(unlabeled) == 1
|
||||
assert unlabeled[0]["labeled"] == 0
|
||||
|
||||
def test_label_sighting(self, test_db):
|
||||
pet_id = insert_pet(test_db, name="Angel", species="cat")
|
||||
insert_pet_sighting(test_db, pet_id=None, species="cat",
|
||||
camera_id="kitchen", confidence=0.6)
|
||||
sightings = get_unlabeled_sightings(test_db, limit=10)
|
||||
sighting_id = sightings[0]["id"]
|
||||
label_sighting(test_db, sighting_id, pet_id)
|
||||
updated = get_pet_sightings(test_db, pet_id=pet_id)
|
||||
assert len(updated) == 1
|
||||
assert updated[0]["labeled"] == 1
|
||||
|
||||
|
||||
class TestWildlifeSightings:
|
||||
def test_insert_and_query(self, test_db):
|
||||
insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR",
|
||||
camera_id="front", confidence=0.88)
|
||||
sightings = get_wildlife_sightings(test_db, limit=10)
|
||||
assert len(sightings) == 1
|
||||
assert sightings[0]["threat_level"] == "PREDATOR"
|
||||
|
||||
def test_filter_by_threat(self, test_db):
|
||||
insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR",
|
||||
camera_id="front", confidence=0.88)
|
||||
insert_wildlife_sighting(test_db, species="deer", threat_level="PASSIVE",
|
||||
camera_id="back", confidence=0.75)
|
||||
predators = get_wildlife_sightings(test_db, threat_level="PREDATOR")
|
||||
assert len(predators) == 1
|
||||
|
||||
|
||||
class TestTrainingImages:
|
||||
def test_insert_and_query(self, test_db):
|
||||
pet_id = insert_pet(test_db, name="Angel", species="cat")
|
||||
insert_training_image(test_db, pet_id=pet_id,
|
||||
image_path="/var/vigilar/pets/training/angel/001.jpg",
|
||||
source="upload")
|
||||
images = get_training_images(test_db, pet_id)
|
||||
assert len(images) == 1
|
||||
assert images[0]["source"] == "upload"
|
||||
109
tests/unit/test_pets_api.py
Normal file
109
tests/unit/test_pets_api.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Tests for pets web blueprint API endpoints."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from vigilar.config import VigilarConfig
|
||||
from vigilar.storage.schema import metadata
|
||||
from vigilar.web.app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""Flask test client wired to an in-memory test DB."""
|
||||
db_path = tmp_path / "pets_test.db"
|
||||
engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
metadata.create_all(engine)
|
||||
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
app.config["DB_ENGINE"] = engine
|
||||
app.config["TESTING"] = True
|
||||
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestPetsAPI:
|
||||
def test_register_pet(self, client):
|
||||
resp = client.post("/pets/register", json={
|
||||
"name": "Angel", "species": "cat", "breed": "DSH",
|
||||
"color_description": "black",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["name"] == "Angel"
|
||||
assert "id" in data
|
||||
|
||||
def test_get_pet_status(self, client):
|
||||
client.post("/pets/register", json={"name": "Angel", "species": "cat"})
|
||||
resp = client.get("/pets/api/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data["pets"]) == 1
|
||||
assert data["pets"][0]["name"] == "Angel"
|
||||
|
||||
def test_get_sightings_empty(self, client):
|
||||
resp = client.get("/pets/api/sightings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["sightings"] == []
|
||||
|
||||
def test_get_wildlife_empty(self, client):
|
||||
resp = client.get("/pets/api/wildlife")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["sightings"] == []
|
||||
|
||||
def test_get_unlabeled_empty(self, client):
|
||||
resp = client.get("/pets/api/unlabeled")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["crops"] == []
|
||||
|
||||
def test_pets_dashboard_loads(self, client):
|
||||
resp = client.get("/pets/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_register_pet_missing_fields(self, client):
|
||||
resp = client.post("/pets/register", json={"name": "Buddy"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_label_crop(self, client):
|
||||
# Register a pet first
|
||||
reg = client.post("/pets/register", json={"name": "Angel", "species": "cat"})
|
||||
pet_id = reg.get_json()["id"]
|
||||
|
||||
# Insert an unlabeled sighting directly via DB
|
||||
|
||||
from vigilar.storage.queries import insert_pet_sighting
|
||||
app_engine = client.application.config["DB_ENGINE"]
|
||||
sighting_id = insert_pet_sighting(app_engine, species="cat", camera_id="cam1",
|
||||
confidence=0.9)
|
||||
|
||||
resp = client.post(f"/pets/{sighting_id}/label", json={"pet_id": pet_id})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
def test_delete_pet(self, client):
|
||||
reg = client.post("/pets/register", json={"name": "Ghost", "species": "cat"})
|
||||
pet_id = reg.get_json()["id"]
|
||||
|
||||
resp = client.delete(f"/pets/{pet_id}/delete")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
status = client.get("/pets/api/status").get_json()
|
||||
assert len(status["pets"]) == 0
|
||||
|
||||
def test_training_status(self, client):
|
||||
resp = client.get("/pets/api/training-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "status" in data
|
||||
|
||||
def test_highlights(self, client):
|
||||
resp = client.get("/pets/api/highlights")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "highlights" in data
|
||||
@ -75,3 +75,56 @@ class TestActionForEvent:
|
||||
action, recipients = get_action_for_event(profile, "person", "front_door")
|
||||
assert action == "quiet_log"
|
||||
assert recipients == "none"
|
||||
|
||||
|
||||
class TestPetAlertRouting:
|
||||
def test_known_pet_exterior_gets_push(self):
|
||||
rules = [
|
||||
AlertProfileRule(detection_type="known_pet", camera_location="EXTERIOR",
|
||||
action="push_and_record", recipients="all"),
|
||||
AlertProfileRule(detection_type="known_pet", camera_location="INTERIOR",
|
||||
action="quiet_log", recipients="none"),
|
||||
]
|
||||
profile = _make_profile("Away", ["EMPTY"], rules=rules)
|
||||
action, recipients = get_action_for_event(profile, "known_pet", "front_entrance",
|
||||
camera_location="EXTERIOR")
|
||||
assert action == "push_and_record"
|
||||
|
||||
def test_known_pet_interior_gets_quiet_log(self):
|
||||
rules = [
|
||||
AlertProfileRule(detection_type="known_pet", camera_location="EXTERIOR",
|
||||
action="push_and_record", recipients="all"),
|
||||
AlertProfileRule(detection_type="known_pet", camera_location="INTERIOR",
|
||||
action="quiet_log", recipients="none"),
|
||||
]
|
||||
profile = _make_profile("Home", ["ALL_HOME"], rules=rules)
|
||||
action, recipients = get_action_for_event(profile, "known_pet", "kitchen",
|
||||
camera_location="INTERIOR")
|
||||
assert action == "quiet_log"
|
||||
|
||||
def test_wildlife_predator_gets_urgent(self):
|
||||
rules = [
|
||||
AlertProfileRule(detection_type="wildlife_predator",
|
||||
action="push_and_record", recipients="all"),
|
||||
]
|
||||
profile = _make_profile("Always", ["EMPTY", "ALL_HOME"], rules=rules)
|
||||
action, _ = get_action_for_event(profile, "wildlife_predator", "front",
|
||||
camera_location="EXTERIOR")
|
||||
assert action == "push_and_record"
|
||||
|
||||
def test_camera_location_any_matches_all(self):
|
||||
rules = [
|
||||
AlertProfileRule(detection_type="wildlife_predator", camera_location="any",
|
||||
action="push_and_record", recipients="all"),
|
||||
]
|
||||
profile = _make_profile("Always", ["EMPTY"], rules=rules)
|
||||
action, _ = get_action_for_event(profile, "wildlife_predator", "kitchen",
|
||||
camera_location="INTERIOR")
|
||||
assert action == "push_and_record"
|
||||
|
||||
def test_backward_compatible_without_camera_location(self):
|
||||
"""Existing calls without camera_location still work."""
|
||||
rules = [AlertProfileRule(detection_type="person", action="push_and_record", recipients="all")]
|
||||
profile = _make_profile("Away", ["EMPTY"], rules=rules)
|
||||
action, _ = get_action_for_event(profile, "person", "front_door")
|
||||
assert action == "push_and_record"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from vigilar.storage.schema import metadata
|
||||
from vigilar.storage.schema import metadata, pets, pet_sightings, wildlife_sightings, pet_training_images
|
||||
|
||||
|
||||
def test_tables_created(tmp_path):
|
||||
@ -21,3 +21,52 @@ def test_tables_created(tmp_path):
|
||||
]
|
||||
for name in expected:
|
||||
assert name in table_names, f"Missing table: {name}"
|
||||
|
||||
|
||||
class TestPetTables:
|
||||
def test_pets_table_exists(self, test_db):
|
||||
with test_db.connect() as conn:
|
||||
result = conn.execute(pets.insert().values(
|
||||
id="pet-1", name="Angel", species="cat", breed="DSH",
|
||||
color_description="black", training_count=0, created_at=1000.0,
|
||||
))
|
||||
row = conn.execute(pets.select().where(pets.c.id == "pet-1")).first()
|
||||
assert row is not None
|
||||
assert row.name == "Angel"
|
||||
assert row.species == "cat"
|
||||
|
||||
def test_pet_sightings_table(self, test_db):
|
||||
with test_db.begin() as conn:
|
||||
conn.execute(pets.insert().values(
|
||||
id="pet-1", name="Angel", species="cat", training_count=0, created_at=1000.0,
|
||||
))
|
||||
conn.execute(pet_sightings.insert().values(
|
||||
ts=1000.0, pet_id="pet-1", species="cat", camera_id="kitchen",
|
||||
confidence=0.92, labeled=True,
|
||||
))
|
||||
rows = conn.execute(pet_sightings.select()).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].camera_id == "kitchen"
|
||||
|
||||
def test_wildlife_sightings_table(self, test_db):
|
||||
with test_db.begin() as conn:
|
||||
conn.execute(wildlife_sightings.insert().values(
|
||||
ts=1000.0, species="bear", threat_level="PREDATOR",
|
||||
camera_id="front", confidence=0.88,
|
||||
))
|
||||
rows = conn.execute(wildlife_sightings.select()).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].threat_level == "PREDATOR"
|
||||
|
||||
def test_pet_training_images_table(self, test_db):
|
||||
with test_db.begin() as conn:
|
||||
conn.execute(pets.insert().values(
|
||||
id="pet-1", name="Angel", species="cat", training_count=0, created_at=1000.0,
|
||||
))
|
||||
conn.execute(pet_training_images.insert().values(
|
||||
pet_id="pet-1", image_path="/var/vigilar/pets/training/angel/001.jpg",
|
||||
source="upload", created_at=1000.0,
|
||||
))
|
||||
rows = conn.execute(pet_training_images.select()).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].source == "upload"
|
||||
|
||||
55
tests/unit/test_trainer.py
Normal file
55
tests/unit/test_trainer.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Tests for pet ID model trainer."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vigilar.detection.trainer import PetTrainer, TrainingStatus
|
||||
|
||||
|
||||
class TestTrainingStatus:
|
||||
def test_initial_status(self):
|
||||
status = TrainingStatus()
|
||||
assert status.is_training is False
|
||||
assert status.progress == 0.0
|
||||
assert status.error is None
|
||||
|
||||
|
||||
class TestPetTrainer:
|
||||
def test_check_readiness_no_pets(self, tmp_path):
|
||||
trainer = PetTrainer(training_dir=str(tmp_path), model_output_path=str(tmp_path / "model.pt"))
|
||||
ready, msg = trainer.check_readiness(min_images=20)
|
||||
assert not ready
|
||||
assert "No pet" in msg
|
||||
|
||||
def test_check_readiness_insufficient_images(self, tmp_path):
|
||||
pet_dir = tmp_path / "angel"
|
||||
pet_dir.mkdir()
|
||||
for i in range(5):
|
||||
(pet_dir / f"{i}.jpg").write_bytes(b"fake")
|
||||
|
||||
trainer = PetTrainer(training_dir=str(tmp_path), model_output_path=str(tmp_path / "model.pt"))
|
||||
ready, msg = trainer.check_readiness(min_images=20)
|
||||
assert not ready
|
||||
assert "angel" in msg.lower()
|
||||
|
||||
def test_check_readiness_sufficient_images(self, tmp_path):
|
||||
for name in ["angel", "taquito"]:
|
||||
pet_dir = tmp_path / name
|
||||
pet_dir.mkdir()
|
||||
for i in range(25):
|
||||
(pet_dir / f"{i}.jpg").write_bytes(b"fake")
|
||||
|
||||
trainer = PetTrainer(training_dir=str(tmp_path), model_output_path=str(tmp_path / "model.pt"))
|
||||
ready, msg = trainer.check_readiness(min_images=20)
|
||||
assert ready
|
||||
|
||||
def test_get_class_names(self, tmp_path):
|
||||
for name in ["angel", "milo", "taquito"]:
|
||||
(tmp_path / name).mkdir()
|
||||
|
||||
trainer = PetTrainer(training_dir=str(tmp_path), model_output_path=str(tmp_path / "model.pt"))
|
||||
names = trainer.get_class_names()
|
||||
assert names == ["angel", "milo", "taquito"] # sorted
|
||||
50
tests/unit/test_wildlife.py
Normal file
50
tests/unit/test_wildlife.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Tests for wildlife threat classification."""
|
||||
|
||||
from vigilar.config import WildlifeConfig, WildlifeThreatMap, WildlifeSizeHeuristics
|
||||
from vigilar.detection.person import Detection
|
||||
from vigilar.detection.wildlife import classify_wildlife_threat
|
||||
|
||||
|
||||
def _make_config(**kwargs):
|
||||
return WildlifeConfig(**kwargs)
|
||||
|
||||
|
||||
class TestWildlifeThreatClassification:
|
||||
def test_bear_is_predator(self):
|
||||
cfg = _make_config()
|
||||
d = Detection(class_name="bear", class_id=21, confidence=0.9, bbox=(100, 100, 200, 300))
|
||||
level, species = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "PREDATOR"
|
||||
assert species == "bear"
|
||||
|
||||
def test_bird_is_passive(self):
|
||||
cfg = _make_config()
|
||||
d = Detection(class_name="bird", class_id=14, confidence=0.8, bbox=(10, 10, 30, 20))
|
||||
level, species = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "PASSIVE"
|
||||
assert species == "bird"
|
||||
|
||||
def test_unknown_small_is_nuisance(self):
|
||||
cfg = _make_config()
|
||||
d = Detection(class_name="unknown", class_id=99, confidence=0.7, bbox=(100, 100, 30, 30))
|
||||
level, species = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "NUISANCE"
|
||||
assert species == "unknown"
|
||||
|
||||
def test_unknown_medium_is_predator(self):
|
||||
cfg = _make_config()
|
||||
d = Detection(class_name="unknown", class_id=99, confidence=0.7, bbox=(100, 100, 300, 300))
|
||||
level, species = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "PREDATOR"
|
||||
|
||||
def test_unknown_large_is_passive(self):
|
||||
cfg = _make_config()
|
||||
d = Detection(class_name="unknown", class_id=99, confidence=0.7, bbox=(100, 100, 600, 500))
|
||||
level, species = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "PASSIVE"
|
||||
|
||||
def test_custom_threat_map(self):
|
||||
cfg = _make_config(threat_map=WildlifeThreatMap(predator=["bear", "wolf"]))
|
||||
d = Detection(class_name="wolf", class_id=99, confidence=0.85, bbox=(100, 100, 200, 200))
|
||||
level, _ = classify_wildlife_threat(d, cfg, frame_area=1920 * 1080)
|
||||
assert level == "PREDATOR"
|
||||
52
tests/unit/test_yolo_detector.py
Normal file
52
tests/unit/test_yolo_detector.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Tests for YOLOv8 unified detector."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vigilar.detection.person import Detection
|
||||
from vigilar.detection.yolo import YOLODetector, ANIMAL_CLASSES, WILDLIFE_CLASSES
|
||||
|
||||
|
||||
class TestYOLOConstants:
|
||||
def test_animal_classes(self):
|
||||
assert "cat" in ANIMAL_CLASSES
|
||||
assert "dog" in ANIMAL_CLASSES
|
||||
|
||||
def test_wildlife_classes(self):
|
||||
assert "bear" in WILDLIFE_CLASSES
|
||||
assert "bird" in WILDLIFE_CLASSES
|
||||
|
||||
def test_no_overlap_animal_wildlife(self):
|
||||
assert not ANIMAL_CLASSES.intersection(WILDLIFE_CLASSES)
|
||||
|
||||
|
||||
class TestYOLODetector:
|
||||
def test_initializes_without_model(self):
|
||||
detector = YOLODetector(model_path="nonexistent.pt", confidence_threshold=0.5)
|
||||
assert not detector.is_loaded
|
||||
|
||||
def test_detect_returns_empty_when_not_loaded(self):
|
||||
detector = YOLODetector(model_path="nonexistent.pt")
|
||||
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||
detections = detector.detect(frame)
|
||||
assert detections == []
|
||||
|
||||
def test_classify_detection_person(self):
|
||||
d = Detection(class_name="person", class_id=0, confidence=0.9, bbox=(10, 20, 100, 200))
|
||||
assert YOLODetector.classify(d) == "person"
|
||||
|
||||
def test_classify_detection_vehicle(self):
|
||||
d = Detection(class_name="car", class_id=2, confidence=0.85, bbox=(10, 20, 100, 200))
|
||||
assert YOLODetector.classify(d) == "vehicle"
|
||||
|
||||
def test_classify_detection_domestic_animal(self):
|
||||
d = Detection(class_name="cat", class_id=15, confidence=0.9, bbox=(10, 20, 100, 200))
|
||||
assert YOLODetector.classify(d) == "domestic_animal"
|
||||
|
||||
def test_classify_detection_wildlife(self):
|
||||
d = Detection(class_name="bear", class_id=21, confidence=0.8, bbox=(10, 20, 100, 200))
|
||||
assert YOLODetector.classify(d) == "wildlife"
|
||||
|
||||
def test_classify_detection_other(self):
|
||||
d = Detection(class_name="chair", class_id=56, confidence=0.7, bbox=(10, 20, 100, 200))
|
||||
assert YOLODetector.classify(d) == "other"
|
||||
@ -1,9 +1,8 @@
|
||||
"""Smart alert profile matching engine."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from vigilar.config import AlertProfileConfig, AlertProfileRule
|
||||
from vigilar.config import AlertProfileConfig
|
||||
from vigilar.constants import HouseholdState
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -46,11 +45,19 @@ def get_action_for_event(
|
||||
profile: AlertProfileConfig,
|
||||
detection_type: str,
|
||||
camera_id: str,
|
||||
camera_location: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
for rule in profile.rules:
|
||||
if rule.detection_type != detection_type:
|
||||
continue
|
||||
if rule.camera_location not in ("any", camera_id):
|
||||
continue
|
||||
# Check camera_location match
|
||||
if rule.camera_location == "any":
|
||||
return rule.action, rule.recipients
|
||||
if rule.camera_location == camera_id:
|
||||
return rule.action, rule.recipients
|
||||
if camera_location and rule.camera_location == camera_location:
|
||||
return rule.action, rule.recipients
|
||||
if not camera_location and rule.camera_location == "any":
|
||||
return rule.action, rule.recipients
|
||||
continue
|
||||
return "quiet_log", "none"
|
||||
|
||||
@ -13,15 +13,18 @@ import signal
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from vigilar.bus import MessageBus
|
||||
from vigilar.camera.hls import HLSStreamer, RemoteHLSStreamer
|
||||
from vigilar.camera.motion import MotionDetector
|
||||
from vigilar.camera.recorder import AdaptiveRecorder
|
||||
from vigilar.camera.ring_buffer import RingBuffer
|
||||
from vigilar.config import CameraConfig, MQTTConfig, RemoteConfig
|
||||
from vigilar.config import CameraConfig, MQTTConfig, PetsConfig, RemoteConfig
|
||||
from vigilar.constants import Topics
|
||||
from vigilar.detection.crop_manager import CropManager
|
||||
from vigilar.detection.pet_id import PetIDClassifier
|
||||
from vigilar.detection.wildlife import classify_wildlife_threat
|
||||
from vigilar.detection.yolo import YOLODetector
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -46,6 +49,7 @@ def run_camera_worker(
|
||||
recordings_dir: str,
|
||||
hls_dir: str,
|
||||
remote_cfg: RemoteConfig | None = None,
|
||||
pets_cfg: PetsConfig | None = None,
|
||||
) -> None:
|
||||
"""Main entry point for a camera worker process."""
|
||||
camera_id = camera_cfg.id
|
||||
@ -107,8 +111,29 @@ def run_camera_worker(
|
||||
bitrate_kbps=remote_cfg.remote_hls_bitrate_kbps,
|
||||
)
|
||||
|
||||
# Object detection (YOLOv8 unified detector)
|
||||
yolo_detector = None
|
||||
pet_classifier = None
|
||||
crop_manager = None
|
||||
if pets_cfg and pets_cfg.enabled:
|
||||
yolo_detector = YOLODetector(
|
||||
model_path=pets_cfg.model_path,
|
||||
confidence_threshold=pets_cfg.confidence_threshold,
|
||||
)
|
||||
if pets_cfg.pet_id_enabled:
|
||||
pet_classifier = PetIDClassifier(
|
||||
model_path=pets_cfg.pet_id_model_path,
|
||||
high_threshold=pets_cfg.pet_id_threshold,
|
||||
low_threshold=pets_cfg.pet_id_low_confidence,
|
||||
)
|
||||
crop_manager = CropManager(
|
||||
staging_dir=pets_cfg.crop_staging_dir,
|
||||
training_dir=pets_cfg.training_dir,
|
||||
)
|
||||
|
||||
state = CameraState()
|
||||
shutdown = False
|
||||
last_detection_time: float = 0
|
||||
|
||||
def handle_signal(signum, frame):
|
||||
nonlocal shutdown
|
||||
@ -243,6 +268,74 @@ def run_camera_worker(
|
||||
if state.frame_count % idle_skip_factor == 0:
|
||||
recorder.write_frame(frame)
|
||||
|
||||
# Run object detection on motion frames — throttled to 1 inference/second
|
||||
if (state.motion_active and yolo_detector and yolo_detector.is_loaded
|
||||
and now - last_detection_time >= 1.0):
|
||||
last_detection_time = now
|
||||
detections = yolo_detector.detect(frame)
|
||||
for det in detections:
|
||||
category = YOLODetector.classify(det)
|
||||
if category == "domestic_animal":
|
||||
# Crop for pet ID and staging
|
||||
x, y, w, h = det.bbox
|
||||
crop = frame[max(0, y):y + h, max(0, x):x + w]
|
||||
|
||||
crop_path = None
|
||||
if crop_manager and crop.size > 0:
|
||||
crop_path = crop_manager.save_staging_crop(
|
||||
crop, species=det.class_name, camera_id=camera_id
|
||||
)
|
||||
|
||||
pet_result = None
|
||||
if pet_classifier and pet_classifier.is_loaded and crop.size > 0:
|
||||
pet_result = pet_classifier.identify(crop, species=det.class_name)
|
||||
|
||||
payload = {
|
||||
"species": det.class_name,
|
||||
"confidence": round(det.confidence, 3),
|
||||
"camera_location": camera_cfg.location,
|
||||
"crop_path": crop_path,
|
||||
}
|
||||
if pet_result and pet_result.is_identified:
|
||||
payload["pet_id"] = pet_result.pet_id
|
||||
payload["pet_name"] = pet_result.pet_name
|
||||
payload["pet_confidence"] = round(pet_result.confidence, 3)
|
||||
bus.publish_event(
|
||||
Topics.pet_location(pet_result.pet_name.lower()),
|
||||
camera_id=camera_id,
|
||||
camera_location=camera_cfg.location,
|
||||
)
|
||||
|
||||
bus.publish_event(Topics.camera_pet_detected(camera_id), **payload)
|
||||
|
||||
elif category == "wildlife" and pets_cfg:
|
||||
frame_area = frame.shape[0] * frame.shape[1]
|
||||
threat_level, species = classify_wildlife_threat(
|
||||
det, pets_cfg.wildlife, frame_area,
|
||||
)
|
||||
bus.publish_event(
|
||||
Topics.camera_wildlife_detected(camera_id),
|
||||
species=species,
|
||||
threat_level=threat_level,
|
||||
confidence=round(det.confidence, 3),
|
||||
camera_location=camera_cfg.location,
|
||||
crop_path=None,
|
||||
)
|
||||
|
||||
elif category == "person":
|
||||
bus.publish_event(
|
||||
Topics.camera_motion_start(camera_id),
|
||||
detection="person",
|
||||
confidence=round(det.confidence, 3),
|
||||
)
|
||||
|
||||
elif category == "vehicle":
|
||||
bus.publish_event(
|
||||
Topics.camera_motion_start(camera_id),
|
||||
detection="vehicle",
|
||||
confidence=round(det.confidence, 3),
|
||||
)
|
||||
|
||||
# Heartbeat every 10 seconds
|
||||
if now - last_heartbeat >= 10:
|
||||
last_heartbeat = now
|
||||
|
||||
@ -20,9 +20,9 @@ from vigilar.constants import (
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
DEFAULT_UPS_POLL_INTERVAL_S,
|
||||
DEFAULT_WEB_PORT,
|
||||
CameraLocation,
|
||||
)
|
||||
|
||||
|
||||
# --- Camera Config ---
|
||||
|
||||
class CameraConfig(BaseModel):
|
||||
@ -43,6 +43,7 @@ class CameraConfig(BaseModel):
|
||||
resolution_capture: list[int] = Field(default_factory=lambda: [1920, 1080])
|
||||
resolution_motion: list[int] = Field(default_factory=lambda: [640, 360])
|
||||
zones: list["CameraZone"] = Field(default_factory=list)
|
||||
location: CameraLocation = CameraLocation.INTERIOR
|
||||
|
||||
|
||||
# --- Sensor Config ---
|
||||
@ -239,6 +240,48 @@ class HealthConfig(BaseModel):
|
||||
daily_digest_time: str = "08:00"
|
||||
|
||||
|
||||
# --- Pet Detection Config ---
|
||||
|
||||
class WildlifeThreatMap(BaseModel):
|
||||
predator: list[str] = Field(default_factory=lambda: ["bear"])
|
||||
nuisance: list[str] = Field(default_factory=list)
|
||||
passive: list[str] = Field(default_factory=lambda: ["bird", "horse", "cow", "sheep"])
|
||||
|
||||
|
||||
class WildlifeSizeHeuristics(BaseModel):
|
||||
small: float = 0.02 # < 2% of frame → nuisance
|
||||
medium: float = 0.08 # 2-8% → predator
|
||||
large: float = 0.15 # > 8% → passive (deer-sized)
|
||||
|
||||
|
||||
class WildlifeConfig(BaseModel):
|
||||
threat_map: WildlifeThreatMap = Field(default_factory=WildlifeThreatMap)
|
||||
size_heuristics: WildlifeSizeHeuristics = Field(default_factory=WildlifeSizeHeuristics)
|
||||
|
||||
|
||||
class PetActivityConfig(BaseModel):
|
||||
daily_digest: bool = True
|
||||
highlight_clips: bool = True
|
||||
zoomie_threshold: float = 0.8
|
||||
|
||||
|
||||
class PetsConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
model: str = "yolov8s"
|
||||
model_path: str = "/var/vigilar/models/yolov8s.pt"
|
||||
confidence_threshold: float = 0.5
|
||||
pet_id_enabled: bool = True
|
||||
pet_id_model_path: str = "/var/vigilar/models/pet_id.pt"
|
||||
pet_id_threshold: float = 0.7
|
||||
pet_id_low_confidence: float = 0.5
|
||||
training_dir: str = "/var/vigilar/pets/training"
|
||||
crop_staging_dir: str = "/var/vigilar/pets/staging"
|
||||
crop_retention_days: int = 7
|
||||
min_training_images: int = 20
|
||||
wildlife: WildlifeConfig = Field(default_factory=WildlifeConfig)
|
||||
activity: PetActivityConfig = Field(default_factory=PetActivityConfig)
|
||||
|
||||
|
||||
# --- Rule Config ---
|
||||
|
||||
class RuleCondition(BaseModel):
|
||||
@ -284,6 +327,7 @@ class VigilarConfig(BaseModel):
|
||||
detection: DetectionConfig = Field(default_factory=DetectionConfig)
|
||||
vehicles: VehicleConfig = Field(default_factory=VehicleConfig)
|
||||
health: HealthConfig = Field(default_factory=HealthConfig)
|
||||
pets: PetsConfig = Field(default_factory=PetsConfig)
|
||||
cameras: list[CameraConfig] = Field(default_factory=list)
|
||||
sensors: list[SensorConfig] = Field(default_factory=list)
|
||||
sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio")
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
# --- Arm States ---
|
||||
|
||||
class ArmState(StrEnum):
|
||||
@ -40,6 +39,12 @@ class EventType(StrEnum):
|
||||
VEHICLE_DETECTED = "VEHICLE_DETECTED"
|
||||
KNOWN_VEHICLE_ARRIVED = "KNOWN_VEHICLE_ARRIVED"
|
||||
UNKNOWN_VEHICLE_DETECTED = "UNKNOWN_VEHICLE_DETECTED"
|
||||
PET_DETECTED = "PET_DETECTED"
|
||||
PET_ESCAPE = "PET_ESCAPE"
|
||||
UNKNOWN_ANIMAL = "UNKNOWN_ANIMAL"
|
||||
WILDLIFE_PREDATOR = "WILDLIFE_PREDATOR"
|
||||
WILDLIFE_NUISANCE = "WILDLIFE_NUISANCE"
|
||||
WILDLIFE_PASSIVE = "WILDLIFE_PASSIVE"
|
||||
|
||||
|
||||
# --- Sensor Types ---
|
||||
@ -68,6 +73,8 @@ class RecordingTrigger(StrEnum):
|
||||
MANUAL = "MANUAL"
|
||||
PERSON = "PERSON"
|
||||
VEHICLE = "VEHICLE"
|
||||
PET = "PET"
|
||||
WILDLIFE = "WILDLIFE"
|
||||
|
||||
|
||||
# --- Alert Channels ---
|
||||
@ -103,6 +110,22 @@ class HouseholdState(StrEnum):
|
||||
ALL_HOME = "ALL_HOME"
|
||||
|
||||
|
||||
# --- Threat Levels (Wildlife) ---
|
||||
|
||||
class ThreatLevel(StrEnum):
|
||||
PREDATOR = "PREDATOR"
|
||||
NUISANCE = "NUISANCE"
|
||||
PASSIVE = "PASSIVE"
|
||||
|
||||
|
||||
# --- Camera Location ---
|
||||
|
||||
class CameraLocation(StrEnum):
|
||||
EXTERIOR = "EXTERIOR"
|
||||
INTERIOR = "INTERIOR"
|
||||
TRANSITION = "TRANSITION"
|
||||
|
||||
|
||||
# --- MQTT Topics ---
|
||||
|
||||
class Topics:
|
||||
@ -148,6 +171,19 @@ class Topics:
|
||||
|
||||
PRESENCE_STATUS = "vigilar/presence/status"
|
||||
|
||||
# Pet
|
||||
@staticmethod
|
||||
def camera_pet_detected(camera_id: str) -> str:
|
||||
return f"vigilar/camera/{camera_id}/pet/detected"
|
||||
|
||||
@staticmethod
|
||||
def camera_wildlife_detected(camera_id: str) -> str:
|
||||
return f"vigilar/camera/{camera_id}/wildlife/detected"
|
||||
|
||||
@staticmethod
|
||||
def pet_location(pet_name: str) -> str:
|
||||
return f"vigilar/pets/{pet_name}/location"
|
||||
|
||||
# System
|
||||
SYSTEM_ARM_STATE = "vigilar/system/arm_state"
|
||||
SYSTEM_ALERT = "vigilar/system/alert"
|
||||
|
||||
48
vigilar/detection/crop_manager.py
Normal file
48
vigilar/detection/crop_manager.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Manage detection crop images for training and staging."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CropManager:
|
||||
def __init__(self, staging_dir: str, training_dir: str):
|
||||
self._staging_dir = Path(staging_dir)
|
||||
self._training_dir = Path(training_dir)
|
||||
|
||||
def save_staging_crop(self, crop: np.ndarray, species: str, camera_id: str) -> str:
|
||||
self._staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = int(time.time() * 1000)
|
||||
filename = f"{species}_{camera_id}_{timestamp}.jpg"
|
||||
filepath = self._staging_dir / filename
|
||||
cv2.imwrite(str(filepath), crop)
|
||||
return str(filepath)
|
||||
|
||||
def promote_to_training(self, staging_path: str, pet_name: str) -> str:
|
||||
pet_dir = self._training_dir / pet_name.lower()
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
src = Path(staging_path)
|
||||
dst = pet_dir / src.name
|
||||
shutil.move(str(src), str(dst))
|
||||
return str(dst)
|
||||
|
||||
def cleanup_expired(self, retention_days: int = 7) -> int:
|
||||
if not self._staging_dir.exists():
|
||||
return 0
|
||||
|
||||
cutoff = time.time() - retention_days * 86400
|
||||
deleted = 0
|
||||
for filepath in self._staging_dir.iterdir():
|
||||
if filepath.is_file() and filepath.stat().st_mtime < cutoff:
|
||||
filepath.unlink()
|
||||
deleted += 1
|
||||
|
||||
if deleted:
|
||||
log.info("Cleaned up %d expired staging crops", deleted)
|
||||
return deleted
|
||||
132
vigilar/detection/pet_id.py
Normal file
132
vigilar/detection/pet_id.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Pet identification classifier using MobileNetV3-Small."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HIGH_THRESHOLD = 0.7
|
||||
DEFAULT_LOW_THRESHOLD = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class PetIDResult:
|
||||
pet_id: str | None
|
||||
pet_name: str | None
|
||||
confidence: float
|
||||
high_threshold: float = DEFAULT_HIGH_THRESHOLD
|
||||
low_threshold: float = DEFAULT_LOW_THRESHOLD
|
||||
|
||||
@property
|
||||
def is_identified(self) -> bool:
|
||||
return self.pet_id is not None and self.confidence >= self.low_threshold
|
||||
|
||||
@property
|
||||
def is_low_confidence(self) -> bool:
|
||||
return (
|
||||
self.pet_id is not None
|
||||
and self.low_threshold <= self.confidence < self.high_threshold
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegisteredPet:
|
||||
pet_id: str
|
||||
name: str
|
||||
species: str
|
||||
class_index: int
|
||||
|
||||
|
||||
class PetIDClassifier:
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str,
|
||||
high_threshold: float = DEFAULT_HIGH_THRESHOLD,
|
||||
low_threshold: float = DEFAULT_LOW_THRESHOLD,
|
||||
):
|
||||
self._model_path = model_path
|
||||
self._high_threshold = high_threshold
|
||||
self._low_threshold = low_threshold
|
||||
self._model = None
|
||||
self.is_loaded = False
|
||||
self._pets: list[RegisteredPet] = []
|
||||
|
||||
if Path(model_path).exists():
|
||||
try:
|
||||
import torch
|
||||
self._model = torch.load(model_path, map_location="cpu", weights_only=False)
|
||||
self._model.eval()
|
||||
self.is_loaded = True
|
||||
log.info("Pet ID model loaded from %s", model_path)
|
||||
except Exception as e:
|
||||
log.error("Failed to load pet ID model: %s", e)
|
||||
else:
|
||||
log.info(
|
||||
"Pet ID model not found at %s — identification disabled until trained",
|
||||
model_path,
|
||||
)
|
||||
|
||||
@property
|
||||
def pet_count(self) -> int:
|
||||
return len(self._pets)
|
||||
|
||||
def register_pet(self, pet_id: str, name: str, species: str) -> None:
|
||||
idx = len(self._pets)
|
||||
self._pets.append(RegisteredPet(pet_id=pet_id, name=name, species=species,
|
||||
class_index=idx))
|
||||
|
||||
def get_pets_by_species(self, species: str) -> list[RegisteredPet]:
|
||||
return [p for p in self._pets if p.species == species]
|
||||
|
||||
def identify(self, crop: np.ndarray, species: str) -> PetIDResult:
|
||||
if not self.is_loaded or self._model is None:
|
||||
return PetIDResult(pet_id=None, pet_name=None, confidence=0.0)
|
||||
|
||||
candidates = self.get_pets_by_species(species)
|
||||
if not candidates:
|
||||
return PetIDResult(pet_id=None, pet_name=None, confidence=0.0)
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import torch
|
||||
from torchvision import transforms
|
||||
|
||||
resized = cv2.resize(crop, (224, 224))
|
||||
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
||||
transform = transforms.Compose([
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(mean=[0.485, 0.456, 0.406],
|
||||
std=[0.229, 0.224, 0.225]),
|
||||
])
|
||||
tensor = transform(rgb).unsqueeze(0)
|
||||
|
||||
with torch.no_grad():
|
||||
output = self._model(tensor)
|
||||
probs = torch.softmax(output, dim=1)[0]
|
||||
|
||||
best_conf = 0.0
|
||||
best_pet = None
|
||||
for pet in candidates:
|
||||
if pet.class_index < len(probs):
|
||||
conf = float(probs[pet.class_index])
|
||||
if conf > best_conf:
|
||||
best_conf = conf
|
||||
best_pet = pet
|
||||
|
||||
if best_pet and best_conf >= self._low_threshold:
|
||||
return PetIDResult(
|
||||
pet_id=best_pet.pet_id,
|
||||
pet_name=best_pet.name,
|
||||
confidence=best_conf,
|
||||
high_threshold=self._high_threshold,
|
||||
low_threshold=self._low_threshold,
|
||||
)
|
||||
|
||||
return PetIDResult(pet_id=None, pet_name=None, confidence=best_conf)
|
||||
|
||||
except Exception as e:
|
||||
log.error("Pet ID inference failed: %s", e)
|
||||
return PetIDResult(pet_id=None, pet_name=None, confidence=0.0)
|
||||
143
vigilar/detection/trainer.py
Normal file
143
vigilar/detection/trainer.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Pet ID model trainer using MobileNetV3-Small with transfer learning."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainingStatus:
|
||||
is_training: bool = False
|
||||
progress: float = 0.0
|
||||
epoch: int = 0
|
||||
total_epochs: int = 0
|
||||
accuracy: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PetTrainer:
|
||||
def __init__(self, training_dir: str, model_output_path: str):
|
||||
self._training_dir = Path(training_dir)
|
||||
self._model_output_path = Path(model_output_path)
|
||||
self.status = TrainingStatus()
|
||||
|
||||
def get_class_names(self) -> list[str]:
|
||||
if not self._training_dir.exists():
|
||||
return []
|
||||
return sorted([
|
||||
d.name for d in self._training_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith(".")
|
||||
])
|
||||
|
||||
def check_readiness(self, min_images: int = 20) -> tuple[bool, str]:
|
||||
class_names = self.get_class_names()
|
||||
if not class_names:
|
||||
return False, "No pet directories found in training directory."
|
||||
|
||||
insufficient = []
|
||||
for name in class_names:
|
||||
pet_dir = self._training_dir / name
|
||||
image_count = sum(1 for f in pet_dir.iterdir()
|
||||
if f.suffix.lower() in (".jpg", ".jpeg", ".png"))
|
||||
if image_count < min_images:
|
||||
insufficient.append(f"{name}: {image_count}/{min_images}")
|
||||
|
||||
if insufficient:
|
||||
return False, f"Insufficient training images: {', '.join(insufficient)}"
|
||||
|
||||
return True, f"Ready to train with {len(class_names)} classes."
|
||||
|
||||
def train(self, epochs: int = 30, batch_size: int = 16) -> bool:
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets, models, transforms
|
||||
|
||||
self.status = TrainingStatus(is_training=True, total_epochs=epochs)
|
||||
class_names = self.get_class_names()
|
||||
num_classes = len(class_names)
|
||||
|
||||
if num_classes < 2:
|
||||
self.status.error = "Need at least 2 pets to train."
|
||||
self.status.is_training = False
|
||||
return False
|
||||
|
||||
train_transform = transforms.Compose([
|
||||
transforms.Resize((256, 256)),
|
||||
transforms.RandomCrop(224),
|
||||
transforms.RandomHorizontalFlip(),
|
||||
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||
])
|
||||
|
||||
dataset = datasets.ImageFolder(str(self._training_dir), transform=train_transform)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=2)
|
||||
|
||||
model = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT)
|
||||
model.classifier[-1] = nn.Linear(model.classifier[-1].in_features, num_classes)
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
model = model.to(device)
|
||||
|
||||
for param in model.features.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
optimizer = torch.optim.Adam(model.classifier.parameters(), lr=1e-3)
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
for epoch in range(epochs):
|
||||
model.train()
|
||||
running_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
|
||||
if epoch == 5:
|
||||
for param in model.features.parameters():
|
||||
param.requires_grad = True
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
|
||||
|
||||
for inputs, labels in loader:
|
||||
inputs, labels = inputs.to(device), labels.to(device)
|
||||
optimizer.zero_grad()
|
||||
outputs = model(inputs)
|
||||
loss = criterion(outputs, labels)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
running_loss += loss.item()
|
||||
_, predicted = outputs.max(1)
|
||||
total += labels.size(0)
|
||||
correct += predicted.eq(labels).sum().item()
|
||||
|
||||
accuracy = correct / total if total > 0 else 0
|
||||
self.status.epoch = epoch + 1
|
||||
self.status.progress = (epoch + 1) / epochs
|
||||
self.status.accuracy = accuracy
|
||||
log.info("Epoch %d/%d — loss: %.4f, accuracy: %.4f",
|
||||
epoch + 1, epochs, running_loss / len(loader), accuracy)
|
||||
|
||||
if self._model_output_path.exists():
|
||||
backup_path = self._model_output_path.with_name(
|
||||
self._model_output_path.stem + "_backup.pt"
|
||||
)
|
||||
shutil.copy2(self._model_output_path, backup_path)
|
||||
log.info("Backed up previous model to %s", backup_path)
|
||||
|
||||
model = model.to("cpu")
|
||||
torch.save(model, self._model_output_path)
|
||||
log.info("Pet ID model saved to %s (accuracy: %.2f%%)",
|
||||
self._model_output_path, self.status.accuracy * 100)
|
||||
|
||||
self.status.is_training = False
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Training failed: %s", e)
|
||||
self.status.error = str(e)
|
||||
self.status.is_training = False
|
||||
return False
|
||||
39
vigilar/detection/wildlife.py
Normal file
39
vigilar/detection/wildlife.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Wildlife threat level classification."""
|
||||
|
||||
from vigilar.config import WildlifeConfig
|
||||
from vigilar.constants import ThreatLevel
|
||||
from vigilar.detection.person import Detection
|
||||
|
||||
|
||||
def classify_wildlife_threat(
|
||||
detection: Detection,
|
||||
config: WildlifeConfig,
|
||||
frame_area: int,
|
||||
) -> tuple[ThreatLevel, str]:
|
||||
"""Classify a wildlife detection into threat level and species.
|
||||
|
||||
Returns (threat_level, species_name).
|
||||
"""
|
||||
species = detection.class_name
|
||||
threat_map = config.threat_map
|
||||
|
||||
# Direct COCO class mapping first
|
||||
if species in threat_map.predator:
|
||||
return ThreatLevel.PREDATOR, species
|
||||
if species in threat_map.nuisance:
|
||||
return ThreatLevel.NUISANCE, species
|
||||
if species in threat_map.passive:
|
||||
return ThreatLevel.PASSIVE, species
|
||||
|
||||
# Fallback to size heuristics for unknown species
|
||||
_, _, w, h = detection.bbox
|
||||
bbox_area = w * h
|
||||
area_ratio = bbox_area / frame_area if frame_area > 0 else 0
|
||||
|
||||
heuristics = config.size_heuristics
|
||||
if area_ratio < heuristics.small:
|
||||
return ThreatLevel.NUISANCE, species
|
||||
elif area_ratio < heuristics.medium:
|
||||
return ThreatLevel.PREDATOR, species
|
||||
else:
|
||||
return ThreatLevel.PASSIVE, species
|
||||
78
vigilar/detection/yolo.py
Normal file
78
vigilar/detection/yolo.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""Unified object detection using YOLOv8 via ultralytics."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vigilar.detection.person import Detection
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# COCO class names for domestic animals
|
||||
ANIMAL_CLASSES = {"cat", "dog"}
|
||||
|
||||
# COCO class names for wildlife (subset that YOLO can detect)
|
||||
WILDLIFE_CLASSES = {"bear", "bird", "horse", "cow", "sheep", "elephant", "zebra", "giraffe"}
|
||||
|
||||
# Vehicle class names from COCO
|
||||
VEHICLE_CLASSES = {"car", "motorcycle", "bus", "truck", "boat"}
|
||||
|
||||
|
||||
class YOLODetector:
|
||||
def __init__(self, model_path: str, confidence_threshold: float = 0.5):
|
||||
self._threshold = confidence_threshold
|
||||
self._model = None
|
||||
self.is_loaded = False
|
||||
|
||||
if Path(model_path).exists():
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
self._model = YOLO(model_path)
|
||||
self.is_loaded = True
|
||||
log.info("YOLO model loaded from %s", model_path)
|
||||
except Exception as e:
|
||||
log.error("Failed to load YOLO model: %s", e)
|
||||
else:
|
||||
log.warning("YOLO model not found at %s — detection disabled", model_path)
|
||||
|
||||
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||
if not self.is_loaded or self._model is None:
|
||||
return []
|
||||
|
||||
results = self._model(frame, conf=self._threshold, verbose=False)
|
||||
detections = []
|
||||
|
||||
for result in results:
|
||||
for box in result.boxes:
|
||||
class_id = int(box.cls[0])
|
||||
confidence = float(box.conf[0])
|
||||
class_name = result.names[class_id]
|
||||
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
if bw <= 0 or bh <= 0:
|
||||
continue
|
||||
|
||||
detections.append(Detection(
|
||||
class_name=class_name,
|
||||
class_id=class_id,
|
||||
confidence=confidence,
|
||||
bbox=(x1, y1, bw, bh),
|
||||
))
|
||||
|
||||
return detections
|
||||
|
||||
@staticmethod
|
||||
def classify(detection: Detection) -> str:
|
||||
name = detection.class_name
|
||||
if name == "person":
|
||||
return "person"
|
||||
if name in VEHICLE_CLASSES:
|
||||
return "vehicle"
|
||||
if name in ANIMAL_CLASSES:
|
||||
return "domestic_animal"
|
||||
if name in WILDLIFE_CLASSES:
|
||||
return "wildlife"
|
||||
return "other"
|
||||
@ -1,6 +1,5 @@
|
||||
"""Event processor — subscribes to MQTT, logs events, evaluates rules."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import time
|
||||
@ -10,7 +9,7 @@ from sqlalchemy.engine import Engine
|
||||
|
||||
from vigilar.bus import MessageBus
|
||||
from vigilar.config import VigilarConfig
|
||||
from vigilar.constants import ArmState, EventType, Severity, Topics
|
||||
from vigilar.constants import EventType, Severity, Topics
|
||||
from vigilar.events.rules import RuleEngine
|
||||
from vigilar.events.state import ArmStateFSM
|
||||
from vigilar.storage.db import get_db_path, init_db
|
||||
@ -102,6 +101,34 @@ class EventProcessor:
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# Insert pet/wildlife sightings
|
||||
if event_type in (
|
||||
EventType.PET_DETECTED, EventType.PET_ESCAPE, EventType.UNKNOWN_ANIMAL
|
||||
):
|
||||
from vigilar.storage.queries import insert_pet_sighting
|
||||
insert_pet_sighting(
|
||||
engine,
|
||||
pet_id=payload.get("pet_id"),
|
||||
species=payload.get("species", "unknown"),
|
||||
camera_id=source_id or "",
|
||||
confidence=payload.get("confidence", 0.0),
|
||||
crop_path=payload.get("crop_path"),
|
||||
event_id=event_id,
|
||||
)
|
||||
elif event_type in (
|
||||
EventType.WILDLIFE_PREDATOR, EventType.WILDLIFE_NUISANCE, EventType.WILDLIFE_PASSIVE
|
||||
):
|
||||
from vigilar.storage.queries import insert_wildlife_sighting
|
||||
insert_wildlife_sighting(
|
||||
engine,
|
||||
species=payload.get("species", "unknown"),
|
||||
threat_level=payload.get("threat_level", "PASSIVE"),
|
||||
camera_id=source_id or "",
|
||||
confidence=payload.get("confidence", 0.0),
|
||||
crop_path=payload.get("crop_path"),
|
||||
event_id=event_id,
|
||||
)
|
||||
|
||||
# Evaluate rules
|
||||
actions = rule_engine.evaluate(topic, payload, fsm.state)
|
||||
|
||||
@ -127,6 +154,30 @@ class EventProcessor:
|
||||
if suffix in _TOPIC_EVENT_MAP:
|
||||
etype, sev = _TOPIC_EVENT_MAP[suffix]
|
||||
return etype, sev, camera_id
|
||||
|
||||
# Pet detection
|
||||
if suffix == "pet/detected":
|
||||
pet_id = payload.get("pet_id")
|
||||
camera_location = payload.get("camera_location", "INTERIOR")
|
||||
|
||||
if not pet_id:
|
||||
return EventType.UNKNOWN_ANIMAL, Severity.WARNING, camera_id
|
||||
|
||||
if camera_location in ("EXTERIOR", "TRANSITION"):
|
||||
return EventType.PET_ESCAPE, Severity.ALERT, camera_id
|
||||
|
||||
return EventType.PET_DETECTED, Severity.INFO, camera_id
|
||||
|
||||
# Wildlife detection — severity depends on threat_level in payload
|
||||
if suffix == "wildlife/detected":
|
||||
threat = payload.get("threat_level", "PASSIVE")
|
||||
if threat == "PREDATOR":
|
||||
return EventType.WILDLIFE_PREDATOR, Severity.CRITICAL, camera_id
|
||||
elif threat == "NUISANCE":
|
||||
return EventType.WILDLIFE_NUISANCE, Severity.WARNING, camera_id
|
||||
else:
|
||||
return EventType.WILDLIFE_PASSIVE, Severity.INFO, camera_id
|
||||
|
||||
# Ignore heartbeats etc.
|
||||
return None, None, None
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import time
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from vigilar.storage.schema import events, recordings
|
||||
from vigilar.storage.schema import events, pet_sightings, recordings, wildlife_sightings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -36,6 +36,31 @@ def build_digest(engine: Engine, data_dir: str, since_hours: int = 12) -> dict:
|
||||
.where(recordings.c.started_at >= since_ts // 1000)
|
||||
).scalar() or 0
|
||||
|
||||
# pet_sightings/wildlife_sightings use float seconds (time.time()),
|
||||
# while events table uses integer milliseconds (time.time() * 1000)
|
||||
pet_count = conn.execute(
|
||||
select(func.count()).select_from(pet_sightings)
|
||||
.where(pet_sightings.c.ts >= since_ts / 1000)
|
||||
).scalar() or 0
|
||||
|
||||
wildlife_predator_count = conn.execute(
|
||||
select(func.count()).select_from(wildlife_sightings)
|
||||
.where(wildlife_sightings.c.ts >= since_ts / 1000,
|
||||
wildlife_sightings.c.threat_level == "PREDATOR")
|
||||
).scalar() or 0
|
||||
|
||||
wildlife_nuisance_count = conn.execute(
|
||||
select(func.count()).select_from(wildlife_sightings)
|
||||
.where(wildlife_sightings.c.ts >= since_ts / 1000,
|
||||
wildlife_sightings.c.threat_level == "NUISANCE")
|
||||
).scalar() or 0
|
||||
|
||||
wildlife_passive_count = conn.execute(
|
||||
select(func.count()).select_from(wildlife_sightings)
|
||||
.where(wildlife_sightings.c.ts >= since_ts / 1000,
|
||||
wildlife_sightings.c.threat_level == "PASSIVE")
|
||||
).scalar() or 0
|
||||
|
||||
usage = shutil.disk_usage(data_dir)
|
||||
disk_pct = usage.used / usage.total * 100
|
||||
disk_gb = usage.used / (1024**3)
|
||||
@ -47,15 +72,31 @@ def build_digest(engine: Engine, data_dir: str, since_hours: int = 12) -> dict:
|
||||
"disk_used_gb": round(disk_gb, 1),
|
||||
"disk_used_pct": round(disk_pct, 0),
|
||||
"since_hours": since_hours,
|
||||
"pet_sightings": pet_count,
|
||||
"wildlife_predators": wildlife_predator_count,
|
||||
"wildlife_nuisance": wildlife_nuisance_count,
|
||||
"wildlife_passive": wildlife_passive_count,
|
||||
}
|
||||
|
||||
|
||||
def format_digest(data: dict) -> str:
|
||||
return (
|
||||
f"Vigilar Daily Summary\n"
|
||||
lines = [
|
||||
"Vigilar Daily Summary",
|
||||
f"Last {data['since_hours']}h: "
|
||||
f"{data['person_detections']} person detections, "
|
||||
f"{data['unknown_vehicles']} unknown vehicles, "
|
||||
f"{data['recordings']} recordings\n"
|
||||
f"Storage: {data['disk_used_gb']} GB ({data['disk_used_pct']:.0f}%)"
|
||||
)
|
||||
f"{data['recordings']} recordings",
|
||||
]
|
||||
if data.get("pet_sightings", 0) > 0:
|
||||
lines.append(f"Pets: {data['pet_sightings']} pet sightings")
|
||||
wildlife_parts = []
|
||||
if data.get("wildlife_predators", 0) > 0:
|
||||
wildlife_parts.append(f"{data['wildlife_predators']} predator")
|
||||
if data.get("wildlife_nuisance", 0) > 0:
|
||||
wildlife_parts.append(f"{data['wildlife_nuisance']} nuisance")
|
||||
if data.get("wildlife_passive", 0) > 0:
|
||||
wildlife_parts.append(f"{data['wildlife_passive']} passive")
|
||||
if wildlife_parts:
|
||||
lines.append(f"Wildlife: {', '.join(wildlife_parts)}")
|
||||
lines.append(f"Storage: {data['disk_used_gb']} GB ({data['disk_used_pct']:.0f}%)")
|
||||
return "\n".join(lines)
|
||||
|
||||
@ -11,13 +11,16 @@ from vigilar.storage.schema import (
|
||||
alert_log,
|
||||
arm_state_log,
|
||||
events,
|
||||
pet_sightings,
|
||||
pet_training_images,
|
||||
pets,
|
||||
push_subscriptions,
|
||||
recordings,
|
||||
sensor_states,
|
||||
system_events,
|
||||
wildlife_sightings,
|
||||
)
|
||||
|
||||
|
||||
# --- Events ---
|
||||
|
||||
def insert_event(
|
||||
@ -271,3 +274,186 @@ def delete_push_subscription(engine: Engine, endpoint: str) -> bool:
|
||||
push_subscriptions.delete().where(push_subscriptions.c.endpoint == endpoint)
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
# --- Pets ---
|
||||
|
||||
def insert_pet(
|
||||
engine: Engine,
|
||||
name: str,
|
||||
species: str,
|
||||
breed: str | None = None,
|
||||
color_description: str | None = None,
|
||||
photo_path: str | None = None,
|
||||
) -> str:
|
||||
import uuid
|
||||
pet_id = str(uuid.uuid4())
|
||||
with engine.begin() as conn:
|
||||
conn.execute(pets.insert().values(
|
||||
id=pet_id, name=name, species=species, breed=breed,
|
||||
color_description=color_description, photo_path=photo_path,
|
||||
training_count=0, created_at=time.time(),
|
||||
))
|
||||
return pet_id
|
||||
|
||||
|
||||
def get_pet(engine: Engine, pet_id: str) -> dict[str, Any] | None:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(pets.select().where(pets.c.id == pet_id)).first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def get_all_pets(engine: Engine) -> list[dict[str, Any]]:
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(pets.select().order_by(pets.c.name)).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
# --- Pet Sightings ---
|
||||
|
||||
def insert_pet_sighting(
|
||||
engine: Engine,
|
||||
species: str,
|
||||
camera_id: str,
|
||||
confidence: float,
|
||||
pet_id: str | None = None,
|
||||
crop_path: str | None = None,
|
||||
event_id: int | None = None,
|
||||
) -> int:
|
||||
with engine.begin() as conn:
|
||||
result = conn.execute(pet_sightings.insert().values(
|
||||
ts=time.time(), pet_id=pet_id, species=species,
|
||||
camera_id=camera_id, confidence=confidence,
|
||||
crop_path=crop_path, labeled=1 if pet_id else 0,
|
||||
event_id=event_id,
|
||||
))
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
|
||||
def get_pet_sightings(
|
||||
engine: Engine,
|
||||
pet_id: str | None = None,
|
||||
camera_id: str | None = None,
|
||||
since_ts: float | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = select(pet_sightings).order_by(desc(pet_sightings.c.ts)).limit(limit)
|
||||
if pet_id:
|
||||
query = query.where(pet_sightings.c.pet_id == pet_id)
|
||||
if camera_id:
|
||||
query = query.where(pet_sightings.c.camera_id == camera_id)
|
||||
if since_ts:
|
||||
query = query.where(pet_sightings.c.ts >= since_ts)
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(query).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
def get_pet_last_location(engine: Engine, pet_id: str) -> dict[str, Any] | None:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(pet_sightings)
|
||||
.where(pet_sightings.c.pet_id == pet_id)
|
||||
.order_by(desc(pet_sightings.c.ts))
|
||||
.limit(1)
|
||||
).first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def get_unlabeled_sightings(
|
||||
engine: Engine,
|
||||
species: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = (
|
||||
select(pet_sightings)
|
||||
.where(pet_sightings.c.labeled == 0)
|
||||
.order_by(desc(pet_sightings.c.ts))
|
||||
.limit(limit)
|
||||
)
|
||||
if species:
|
||||
query = query.where(pet_sightings.c.species == species)
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(query).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
def label_sighting(engine: Engine, sighting_id: int, pet_id: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
pet_sightings.update()
|
||||
.where(pet_sightings.c.id == sighting_id)
|
||||
.values(pet_id=pet_id, labeled=1)
|
||||
)
|
||||
|
||||
|
||||
# --- Wildlife Sightings ---
|
||||
|
||||
def insert_wildlife_sighting(
|
||||
engine: Engine,
|
||||
species: str,
|
||||
threat_level: str,
|
||||
camera_id: str,
|
||||
confidence: float,
|
||||
crop_path: str | None = None,
|
||||
event_id: int | None = None,
|
||||
) -> int:
|
||||
with engine.begin() as conn:
|
||||
result = conn.execute(wildlife_sightings.insert().values(
|
||||
ts=time.time(), species=species, threat_level=threat_level,
|
||||
camera_id=camera_id, confidence=confidence,
|
||||
crop_path=crop_path, event_id=event_id,
|
||||
))
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
|
||||
def get_wildlife_sightings(
|
||||
engine: Engine,
|
||||
threat_level: str | None = None,
|
||||
camera_id: str | None = None,
|
||||
since_ts: float | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = select(wildlife_sightings).order_by(desc(wildlife_sightings.c.ts)).limit(limit)
|
||||
if threat_level:
|
||||
query = query.where(wildlife_sightings.c.threat_level == threat_level)
|
||||
if camera_id:
|
||||
query = query.where(wildlife_sightings.c.camera_id == camera_id)
|
||||
if since_ts:
|
||||
query = query.where(wildlife_sightings.c.ts >= since_ts)
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(query).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
# --- Training Images ---
|
||||
|
||||
def insert_training_image(
|
||||
engine: Engine,
|
||||
pet_id: str,
|
||||
image_path: str,
|
||||
source: str,
|
||||
) -> int:
|
||||
with engine.begin() as conn:
|
||||
result = conn.execute(pet_training_images.insert().values(
|
||||
pet_id=pet_id, image_path=image_path,
|
||||
source=source, created_at=time.time(),
|
||||
))
|
||||
conn.execute(
|
||||
pets.update().where(pets.c.id == pet_id)
|
||||
.values(training_count=pets.c.training_count + 1)
|
||||
)
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
|
||||
def get_training_images(
|
||||
engine: Engine,
|
||||
pet_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
select(pet_training_images)
|
||||
.where(pet_training_images.c.pet_id == pet_id)
|
||||
.order_by(desc(pet_training_images.c.created_at))
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
@ -123,3 +123,58 @@ push_subscriptions = Table(
|
||||
Column("last_used_at", Integer),
|
||||
Column("user_agent", String),
|
||||
)
|
||||
|
||||
pets = Table(
|
||||
"pets",
|
||||
metadata,
|
||||
Column("id", String, primary_key=True),
|
||||
Column("name", String, nullable=False),
|
||||
Column("species", String, nullable=False),
|
||||
Column("breed", String),
|
||||
Column("color_description", String),
|
||||
Column("photo_path", String),
|
||||
Column("training_count", Integer, nullable=False, default=0),
|
||||
Column("created_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
pet_sightings = Table(
|
||||
"pet_sightings",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", Float, nullable=False),
|
||||
Column("pet_id", String),
|
||||
Column("species", String, nullable=False),
|
||||
Column("camera_id", String, nullable=False),
|
||||
Column("confidence", Float),
|
||||
Column("crop_path", String),
|
||||
Column("labeled", Integer, nullable=False, default=0),
|
||||
Column("event_id", Integer),
|
||||
)
|
||||
Index("idx_pet_sightings_ts", pet_sightings.c.ts.desc())
|
||||
Index("idx_pet_sightings_pet", pet_sightings.c.pet_id, pet_sightings.c.ts.desc())
|
||||
Index("idx_pet_sightings_camera", pet_sightings.c.camera_id, pet_sightings.c.ts.desc())
|
||||
|
||||
wildlife_sightings = Table(
|
||||
"wildlife_sightings",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", Float, nullable=False),
|
||||
Column("species", String, nullable=False),
|
||||
Column("threat_level", String, nullable=False),
|
||||
Column("camera_id", String, nullable=False),
|
||||
Column("confidence", Float),
|
||||
Column("crop_path", String),
|
||||
Column("event_id", Integer),
|
||||
)
|
||||
Index("idx_wildlife_ts", wildlife_sightings.c.ts.desc())
|
||||
Index("idx_wildlife_threat", wildlife_sightings.c.threat_level, wildlife_sightings.c.ts.desc())
|
||||
|
||||
pet_training_images = Table(
|
||||
"pet_training_images",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("pet_id", String, nullable=False),
|
||||
Column("image_path", String, nullable=False),
|
||||
Column("source", String, nullable=False),
|
||||
Column("created_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
@ -26,6 +26,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
||||
from vigilar.web.blueprints.cameras import cameras_bp
|
||||
from vigilar.web.blueprints.events import events_bp
|
||||
from vigilar.web.blueprints.kiosk import kiosk_bp
|
||||
from vigilar.web.blueprints.pets import pets_bp
|
||||
from vigilar.web.blueprints.recordings import recordings_bp
|
||||
from vigilar.web.blueprints.sensors import sensors_bp
|
||||
from vigilar.web.blueprints.system import system_bp
|
||||
@ -33,6 +34,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
||||
app.register_blueprint(cameras_bp)
|
||||
app.register_blueprint(events_bp)
|
||||
app.register_blueprint(kiosk_bp)
|
||||
app.register_blueprint(pets_bp)
|
||||
app.register_blueprint(recordings_bp)
|
||||
app.register_blueprint(sensors_bp)
|
||||
app.register_blueprint(system_bp)
|
||||
|
||||
307
vigilar/web/blueprints/pets.py
Normal file
307
vigilar/web/blueprints/pets.py
Normal file
@ -0,0 +1,307 @@
|
||||
"""Pets blueprint — pet registration, sightings, labeling, training."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
pets_bp = Blueprint("pets", __name__, url_prefix="/pets")
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
|
||||
def _engine():
|
||||
return current_app.config.get("DB_ENGINE")
|
||||
|
||||
|
||||
@pets_bp.route("/")
|
||||
def dashboard():
|
||||
engine = _engine()
|
||||
all_pets = []
|
||||
if engine:
|
||||
from vigilar.storage.queries import get_all_pets
|
||||
all_pets = get_all_pets(engine)
|
||||
return render_template("pets/dashboard.html", pets=all_pets)
|
||||
|
||||
|
||||
@pets_bp.route("/register", methods=["POST"])
|
||||
def register_pet():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get("name")
|
||||
species = data.get("species")
|
||||
if not name or not species:
|
||||
return jsonify({"error": "name and species required"}), 400
|
||||
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"error": "database not available"}), 503
|
||||
|
||||
from vigilar.storage.queries import insert_pet
|
||||
pet_id = insert_pet(
|
||||
engine,
|
||||
name=name,
|
||||
species=species,
|
||||
breed=data.get("breed"),
|
||||
color_description=data.get("color_description"),
|
||||
photo_path=data.get("photo_path"),
|
||||
)
|
||||
return jsonify({"id": pet_id, "name": name, "species": species})
|
||||
|
||||
|
||||
@pets_bp.route("/api/status")
|
||||
def pet_status():
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"pets": []})
|
||||
|
||||
from vigilar.storage.queries import get_all_pets, get_pet_last_location
|
||||
pets_list = get_all_pets(engine)
|
||||
now = time.time()
|
||||
result = []
|
||||
for pet in pets_list:
|
||||
last = get_pet_last_location(engine, pet["id"])
|
||||
if last:
|
||||
last_seen_ago = now - last["ts"]
|
||||
status = "safe" if last_seen_ago < 300 else "unknown" # seen in last 5 min
|
||||
else:
|
||||
status = "unknown"
|
||||
result.append({
|
||||
**pet,
|
||||
"last_seen_ts": last["ts"] if last else None,
|
||||
"last_camera": last["camera_id"] if last else None,
|
||||
"status": status,
|
||||
})
|
||||
return jsonify({"pets": result})
|
||||
|
||||
|
||||
@pets_bp.route("/api/sightings")
|
||||
def pet_sightings():
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"sightings": []})
|
||||
|
||||
pet_id = request.args.get("pet_id")
|
||||
camera_id = request.args.get("camera_id")
|
||||
since_ts = request.args.get("since", type=float)
|
||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||
|
||||
from vigilar.storage.queries import get_pet_sightings
|
||||
sightings = get_pet_sightings(engine, pet_id=pet_id, camera_id=camera_id,
|
||||
since_ts=since_ts, limit=limit)
|
||||
return jsonify({"sightings": sightings})
|
||||
|
||||
|
||||
@pets_bp.route("/api/wildlife")
|
||||
def wildlife_sightings():
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"sightings": []})
|
||||
|
||||
threat_level = request.args.get("threat_level")
|
||||
camera_id = request.args.get("camera_id")
|
||||
since_ts = request.args.get("since", type=float)
|
||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||
|
||||
from vigilar.storage.queries import get_wildlife_sightings
|
||||
sightings = get_wildlife_sightings(engine, threat_level=threat_level, camera_id=camera_id,
|
||||
since_ts=since_ts, limit=limit)
|
||||
return jsonify({"sightings": sightings})
|
||||
|
||||
|
||||
@pets_bp.route("/api/unlabeled")
|
||||
def unlabeled_crops():
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"crops": []})
|
||||
|
||||
species = request.args.get("species")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
|
||||
from vigilar.storage.queries import get_unlabeled_sightings
|
||||
crops = get_unlabeled_sightings(engine, species=species, limit=limit)
|
||||
return jsonify({"crops": crops})
|
||||
|
||||
|
||||
@pets_bp.route("/<sighting_id>/label", methods=["POST"])
|
||||
def label_crop(sighting_id: str):
|
||||
data = request.get_json(silent=True) or {}
|
||||
pet_id = data.get("pet_id")
|
||||
if not pet_id:
|
||||
return jsonify({"error": "pet_id required"}), 400
|
||||
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"error": "database not available"}), 503
|
||||
|
||||
from vigilar.storage.queries import label_sighting
|
||||
try:
|
||||
label_sighting(engine, int(sighting_id), pet_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "invalid sighting_id"}), 400
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@pets_bp.route("/<pet_id>/upload", methods=["POST"])
|
||||
def upload_photo(pet_id: str):
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"error": "database not available"}), 503
|
||||
|
||||
from vigilar.storage.queries import get_pet, insert_training_image
|
||||
pet = get_pet(engine, pet_id)
|
||||
if not pet:
|
||||
return jsonify({"error": "Pet not found"}), 404
|
||||
|
||||
files = request.files.getlist("photos")
|
||||
if not files:
|
||||
return jsonify({"error": "no files provided"}), 400
|
||||
|
||||
cfg = current_app.config.get("VIGILAR_CONFIG")
|
||||
pets_cfg = current_app.config.get("PETS_CONFIG")
|
||||
if pets_cfg:
|
||||
training_dir = pets_cfg.training_dir
|
||||
else:
|
||||
training_dir = os.path.join(cfg.system.data_dir if cfg else "/tmp/vigilar", "training")
|
||||
|
||||
pet_dir = Path(training_dir) / pet["name"].lower()
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved = []
|
||||
for f in files:
|
||||
if not f.filename:
|
||||
continue
|
||||
safe_name = secure_filename(f.filename)
|
||||
if not safe_name:
|
||||
continue
|
||||
if Path(safe_name).suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
continue
|
||||
dest = pet_dir / f"{int(time.time() * 1000)}_{safe_name}"
|
||||
f.save(str(dest))
|
||||
insert_training_image(engine, pet_id=pet_id, image_path=str(dest), source="upload")
|
||||
saved.append(str(dest))
|
||||
|
||||
return jsonify({"ok": True, "saved": len(saved)})
|
||||
|
||||
|
||||
@pets_bp.route("/<pet_id>/update", methods=["POST"])
|
||||
def update_pet(pet_id: str):
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"error": "database not available"}), 503
|
||||
|
||||
from vigilar.storage.queries import get_pet
|
||||
pet = get_pet(engine, pet_id)
|
||||
if not pet:
|
||||
return jsonify({"error": "Pet not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from vigilar.storage.schema import pets as pets_table
|
||||
|
||||
allowed = {"name", "species", "breed", "color_description", "photo_path"}
|
||||
values = {k: v for k, v in data.items() if k in allowed}
|
||||
if not values:
|
||||
return jsonify({"error": "no valid fields to update"}), 400
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa_update(pets_table).where(pets_table.c.id == pet_id).values(**values))
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@pets_bp.route("/<pet_id>/delete", methods=["DELETE"])
|
||||
def delete_pet(pet_id: str):
|
||||
engine = _engine()
|
||||
if engine is None:
|
||||
return jsonify({"error": "database not available"}), 503
|
||||
|
||||
from vigilar.storage.schema import pets as pets_table
|
||||
with engine.begin() as conn:
|
||||
conn.execute(pets_table.delete().where(pets_table.c.id == pet_id))
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@pets_bp.route("/train", methods=["POST"])
|
||||
def train_model():
|
||||
pets_cfg = current_app.config.get("PETS_CONFIG")
|
||||
if not pets_cfg:
|
||||
return jsonify({"error": "Pet detection not configured"}), 400
|
||||
|
||||
from vigilar.detection.trainer import PetTrainer
|
||||
trainer = PetTrainer(
|
||||
training_dir=pets_cfg.training_dir,
|
||||
model_output_path=pets_cfg.pet_id_model_path,
|
||||
)
|
||||
|
||||
ready, msg = trainer.check_readiness(min_images=pets_cfg.min_training_images)
|
||||
if not ready:
|
||||
return jsonify({"error": msg}), 400
|
||||
|
||||
# Store trainer on app for status polling
|
||||
current_app.extensions["pet_trainer"] = trainer
|
||||
|
||||
import threading
|
||||
thread = threading.Thread(target=trainer.train, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({"ok": True, "status": "training_started", "message": msg})
|
||||
|
||||
|
||||
@pets_bp.route("/api/training-status")
|
||||
def training_status():
|
||||
trainer = current_app.extensions.get("pet_trainer")
|
||||
if not trainer:
|
||||
return jsonify({"status": "idle"})
|
||||
s = trainer.status
|
||||
return jsonify({
|
||||
"status": "training" if s.is_training else "complete" if s.progress >= 1.0 else "idle",
|
||||
"progress": round(s.progress, 2),
|
||||
"epoch": s.epoch,
|
||||
"total_epochs": s.total_epochs,
|
||||
"accuracy": round(s.accuracy, 4),
|
||||
"error": s.error,
|
||||
})
|
||||
|
||||
|
||||
@pets_bp.route("/api/highlights")
|
||||
def highlights():
|
||||
engine = _engine()
|
||||
if not engine:
|
||||
return jsonify({"highlights": []})
|
||||
|
||||
import time as time_mod
|
||||
since = time_mod.time() - 86400
|
||||
|
||||
from vigilar.storage.queries import get_pet_sightings, get_wildlife_sightings
|
||||
|
||||
result = []
|
||||
|
||||
# High-confidence pet sightings
|
||||
sightings = get_pet_sightings(engine, since_ts=since, limit=50)
|
||||
for s in sightings:
|
||||
if s.get("confidence", 0) > 0.8 and s.get("pet_id"):
|
||||
result.append({
|
||||
"type": "pet_sighting",
|
||||
"pet_id": s.get("pet_id"),
|
||||
"camera_id": s["camera_id"],
|
||||
"timestamp": s["ts"],
|
||||
"confidence": s.get("confidence"),
|
||||
})
|
||||
|
||||
# Wildlife events
|
||||
wildlife = get_wildlife_sightings(engine, since_ts=since, limit=20)
|
||||
for w in wildlife:
|
||||
result.append({
|
||||
"type": "wildlife",
|
||||
"species": w["species"],
|
||||
"threat_level": w["threat_level"],
|
||||
"camera_id": w["camera_id"],
|
||||
"timestamp": w["ts"],
|
||||
"confidence": w.get("confidence"),
|
||||
})
|
||||
|
||||
# Sort by timestamp descending, limit to 20
|
||||
result.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return jsonify({"highlights": result[:20]})
|
||||
205
vigilar/web/static/css/pet-labeling.css
Normal file
205
vigilar/web/static/css/pet-labeling.css
Normal file
@ -0,0 +1,205 @@
|
||||
/* Vigilar — Pet labeling overlay for recording playback */
|
||||
|
||||
/* Container: sits on top of the video element, sized to match it */
|
||||
.detection-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none; /* pass-through by default; bboxes re-enable */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Individual bounding box */
|
||||
.detection-bbox {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #00d4d4; /* cyan default for animals */
|
||||
border-radius: 3px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.detection-bbox:hover {
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-person {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-person:hover {
|
||||
border-color: #ff4d5e;
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-vehicle {
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-vehicle:hover {
|
||||
border-color: #3d8bfd;
|
||||
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-labeled {
|
||||
border-style: solid;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Species + confidence tag shown on top-left corner of bbox */
|
||||
.detection-bbox-label {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background-color: #00d4d4;
|
||||
color: #0d1117;
|
||||
border-radius: 2px 0 2px 0;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-person .detection-bbox-label {
|
||||
background-color: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detection-bbox.bbox-vehicle .detection-bbox-label {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* "Who is this?" popup card */
|
||||
.label-popup {
|
||||
position: fixed; /* positioned by JS via clientX/clientY */
|
||||
z-index: 9999;
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
background-color: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
padding: 10px;
|
||||
animation: popup-appear 0.12s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes popup-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.label-popup-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.label-popup-pets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.label-popup-pet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e6edf3;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.label-popup-pet-btn:hover {
|
||||
background-color: #21262d;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.label-popup-pet-btn .pet-species-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #00d4d4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-popup-divider {
|
||||
border: none;
|
||||
border-top: 1px solid #30363d;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.label-popup-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
border: 1px dashed #30363d;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.label-popup-action-btn:hover {
|
||||
color: #e6edf3;
|
||||
border-color: #8b949e;
|
||||
}
|
||||
|
||||
/* Spinner shown while fetching pets */
|
||||
.label-popup-loading {
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
font-size: 0.8rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Confirmation flash on successful label */
|
||||
.label-confirm-flash {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(0, 212, 212, 0.2);
|
||||
pointer-events: none;
|
||||
animation: confirm-flash 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes confirm-flash {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
377
vigilar/web/static/js/pet-labeling.js
Normal file
377
vigilar/web/static/js/pet-labeling.js
Normal file
@ -0,0 +1,377 @@
|
||||
/* Vigilar — Pet labeling overlay for recording playback
|
||||
*
|
||||
* Integration:
|
||||
* 1. Add to the page:
|
||||
* <link rel="stylesheet" href="{{ url_for('static', filename='css/pet-labeling.css') }}">
|
||||
* <script src="{{ url_for('static', filename='js/pet-labeling.js') }}"></script>
|
||||
*
|
||||
* 2. Wrap the <video> element in a position:relative container and add
|
||||
* `data-pet-labeling="true"` to that container, along with a
|
||||
* `data-detections` attribute holding the JSON array of detections:
|
||||
*
|
||||
* <div class="position-relative" data-pet-labeling="true"
|
||||
* data-detections='[{"id":1,"species":"cat","confidence":0.92,
|
||||
* "bbox":[0.1,0.15,0.35,0.6]}]'>
|
||||
* <video id="player-video" class="w-100" controls></video>
|
||||
* </div>
|
||||
*
|
||||
* bbox values are fractional [x1, y1, x2, y2] relative to video dimensions
|
||||
* (same convention as YOLO normalised coords).
|
||||
*
|
||||
* 3. The module self-initialises on DOMContentLoaded and also exposes
|
||||
* window.PetLabeling for manual calls:
|
||||
*
|
||||
* PetLabeling.init(containerEl, detectionsArray);
|
||||
* PetLabeling.updateDetections(containerEl, detectionsArray);
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const ANIMAL_SPECIES = new Set(['cat', 'dog', 'bird', 'rabbit', 'hamster', 'fish', 'animal']);
|
||||
|
||||
// Map species to bbox CSS class (animals get default cyan)
|
||||
function bboxClass(species) {
|
||||
if (species === 'person') return 'bbox-person';
|
||||
if (species === 'vehicle' || species === 'car' || species === 'truck') return 'bbox-vehicle';
|
||||
return ''; // cyan default (animals / unknown)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pets cache — fetched once per page load, filtered by species on demand
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
let _petsCache = null; // null = not fetched yet; [] = fetched, none found
|
||||
|
||||
async function fetchPets() {
|
||||
if (_petsCache !== null) return _petsCache;
|
||||
try {
|
||||
const resp = await fetch('/pets/api/status');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
_petsCache = Array.isArray(data.pets) ? data.pets : [];
|
||||
} catch (e) {
|
||||
_petsCache = [];
|
||||
}
|
||||
return _petsCache;
|
||||
}
|
||||
|
||||
function petsForSpecies(pets, species) {
|
||||
if (!species) return pets;
|
||||
// For generic "animal" detections show all pets; otherwise filter by species
|
||||
if (ANIMAL_SPECIES.has(species) && species !== 'animal') {
|
||||
return pets.filter(p => p.species && p.species.toLowerCase() === species.toLowerCase());
|
||||
}
|
||||
return pets;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Active popup — only one at a time
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
let _activePopup = null;
|
||||
|
||||
function dismissActivePopup() {
|
||||
if (_activePopup) {
|
||||
_activePopup.remove();
|
||||
_activePopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Label popup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function showLabelPopup(bbox, detection, anchorEl) {
|
||||
dismissActivePopup();
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'label-popup';
|
||||
popup.innerHTML = `
|
||||
<div class="label-popup-title">
|
||||
<i class="bi bi-tag-fill me-1"></i>Who is this?
|
||||
</div>
|
||||
<div class="label-popup-loading">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Loading…
|
||||
</div>`;
|
||||
document.body.appendChild(popup);
|
||||
_activePopup = popup;
|
||||
|
||||
// Position near the clicked bbox, keep on screen
|
||||
positionPopup(popup, anchorEl);
|
||||
|
||||
// Fetch pets (cached after first call)
|
||||
const allPets = await fetchPets();
|
||||
if (_activePopup !== popup) return; // dismissed while loading
|
||||
|
||||
const pets = petsForSpecies(allPets, detection.species);
|
||||
|
||||
let petsHtml = '';
|
||||
if (pets.length > 0) {
|
||||
petsHtml = pets.map(p => `
|
||||
<button class="label-popup-pet-btn" data-pet-id="${p.id}" data-pet-name="${escHtml(p.name)}">
|
||||
<span class="pet-species-dot"></span>
|
||||
${escHtml(p.name)}
|
||||
<small class="ms-auto text-muted">${escHtml(p.species || '')}</small>
|
||||
</button>`).join('');
|
||||
}
|
||||
|
||||
popup.innerHTML = `
|
||||
<div class="label-popup-title">
|
||||
<i class="bi bi-tag-fill me-1"></i>Who is this?
|
||||
<small class="float-end text-muted text-capitalize">${escHtml(detection.species || 'animal')}</small>
|
||||
</div>
|
||||
<div class="label-popup-pets">
|
||||
${petsHtml}
|
||||
</div>
|
||||
${pets.length > 0 ? '<hr class="label-popup-divider">' : ''}
|
||||
<button class="label-popup-action-btn" data-action="unknown">
|
||||
<i class="bi bi-question-circle"></i>Unknown
|
||||
</button>
|
||||
<button class="label-popup-action-btn mt-1" data-action="new-pet">
|
||||
<i class="bi bi-plus-circle"></i>+ New Pet
|
||||
</button>`;
|
||||
|
||||
// Re-position after content change (height may differ)
|
||||
positionPopup(popup, anchorEl);
|
||||
|
||||
// Pet selection
|
||||
popup.querySelectorAll('.label-popup-pet-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const petId = btn.dataset.petId;
|
||||
const petName = btn.dataset.petName;
|
||||
applyLabel(detection, petId, petName, bbox);
|
||||
dismissActivePopup();
|
||||
});
|
||||
});
|
||||
|
||||
// Unknown
|
||||
const unknownBtn = popup.querySelector('[data-action="unknown"]');
|
||||
if (unknownBtn) {
|
||||
unknownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
applyLabel(detection, 'unknown', 'Unknown', bbox);
|
||||
dismissActivePopup();
|
||||
});
|
||||
}
|
||||
|
||||
// New Pet — open the pets dashboard in a new tab
|
||||
const newPetBtn = popup.querySelector('[data-action="new-pet"]');
|
||||
if (newPetBtn) {
|
||||
newPetBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
window.open('/pets/', '_blank');
|
||||
dismissActivePopup();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopup(popup, anchorEl) {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const popW = popup.offsetWidth || 200;
|
||||
const popH = popup.offsetHeight || 160;
|
||||
const margin = 8;
|
||||
|
||||
// Prefer right of bbox; fall back to left
|
||||
let left = rect.right + margin;
|
||||
if (left + popW > window.innerWidth - margin) {
|
||||
left = rect.left - popW - margin;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
|
||||
// Align top with bbox; clamp to viewport
|
||||
let top = rect.top;
|
||||
if (top + popH > window.innerHeight - margin) {
|
||||
top = window.innerHeight - popH - margin;
|
||||
}
|
||||
if (top < margin) top = margin;
|
||||
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Label API call
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function applyLabel(detection, petId, petName, bboxEl) {
|
||||
// Optimistic UI: update the label tag immediately
|
||||
const labelTag = bboxEl.querySelector('.detection-bbox-label');
|
||||
if (labelTag) {
|
||||
labelTag.textContent = petId === 'unknown' ? 'Unknown' : petName;
|
||||
}
|
||||
bboxEl.classList.add('bbox-labeled');
|
||||
|
||||
// Confirmation flash
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'label-confirm-flash';
|
||||
bboxEl.appendChild(flash);
|
||||
flash.addEventListener('animationend', () => flash.remove());
|
||||
|
||||
if (!detection.id || petId === 'unknown') return;
|
||||
|
||||
try {
|
||||
await fetch(`/pets/${detection.id}/label`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pet_id: petId }),
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently fail — offline mode
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Overlay rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function renderOverlay(container, detections) {
|
||||
// Remove any existing overlay
|
||||
const existing = container.querySelector('.detection-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!detections || detections.length === 0) return;
|
||||
|
||||
const video = container.querySelector('video');
|
||||
if (!video) return;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'detection-overlay';
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Draw bboxes sized to the video's rendered dimensions
|
||||
function drawBboxes() {
|
||||
overlay.innerHTML = '';
|
||||
|
||||
const vRect = video.getBoundingClientRect();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
|
||||
// Offset from container origin
|
||||
const offX = vRect.left - cRect.left;
|
||||
const offY = vRect.top - cRect.top;
|
||||
const vW = vRect.width;
|
||||
const vH = vRect.height;
|
||||
|
||||
if (vW === 0 || vH === 0) return;
|
||||
|
||||
detections.forEach(det => {
|
||||
const [x1f, y1f, x2f, y2f] = det.bbox || [0, 0, 1, 1];
|
||||
|
||||
const left = offX + x1f * vW;
|
||||
const top = offY + y1f * vH;
|
||||
const width = (x2f - x1f) * vW;
|
||||
const height = (y2f - y1f) * vH;
|
||||
|
||||
const bbox = document.createElement('div');
|
||||
bbox.className = `detection-bbox ${bboxClass(det.species)}`.trimEnd();
|
||||
bbox.style.cssText = `
|
||||
left: ${left}px;
|
||||
top: ${top}px;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
`;
|
||||
|
||||
const confidence = det.confidence !== undefined
|
||||
? ` ${Math.round(det.confidence * 100)}%`
|
||||
: '';
|
||||
const labelText = det.pet_name
|
||||
? det.pet_name
|
||||
: `${det.species || 'animal'}${confidence}`;
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'detection-bbox-label';
|
||||
label.textContent = labelText;
|
||||
bbox.appendChild(label);
|
||||
|
||||
// Only show "Who is this?" popup for animal detections
|
||||
if (!det.species || ANIMAL_SPECIES.has(det.species.toLowerCase())) {
|
||||
bbox.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showLabelPopup(bbox, det, bbox);
|
||||
});
|
||||
}
|
||||
|
||||
overlay.appendChild(bbox);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw immediately and on video resize/metadata
|
||||
drawBboxes();
|
||||
video.addEventListener('loadedmetadata', drawBboxes);
|
||||
const ro = new ResizeObserver(drawBboxes);
|
||||
ro.observe(video);
|
||||
|
||||
// Store cleanup handle
|
||||
overlay._roCleanup = () => ro.disconnect();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function init(container, detections) {
|
||||
if (!container) return;
|
||||
renderOverlay(container, detections);
|
||||
}
|
||||
|
||||
function updateDetections(container, detections) {
|
||||
if (!container) return;
|
||||
const old = container.querySelector('.detection-overlay');
|
||||
if (old && old._roCleanup) old._roCleanup();
|
||||
renderOverlay(container, detections);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-init: find all [data-pet-labeling="true"] containers on page load
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function autoInit() {
|
||||
document.querySelectorAll('[data-pet-labeling="true"]').forEach(container => {
|
||||
let detections = [];
|
||||
const raw = container.dataset.detections;
|
||||
if (raw) {
|
||||
try { detections = JSON.parse(raw); } catch (e) { detections = []; }
|
||||
}
|
||||
init(container, detections);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', autoInit);
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
|
||||
// Dismiss popup when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (_activePopup && !_activePopup.contains(e.target)) {
|
||||
dismissActivePopup();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') dismissActivePopup();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Expose public API
|
||||
window.PetLabeling = { init, updateDetections };
|
||||
})();
|
||||
513
vigilar/web/templates/pets/dashboard.html
Normal file
513
vigilar/web/templates/pets/dashboard.html
Normal file
@ -0,0 +1,513 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Pets — Vigilar{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-heart-fill me-2 text-danger"></i>Pet & Wildlife Monitor</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-train" title="Train model">
|
||||
<i class="bi bi-cpu me-1"></i>Train Model
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#registerPetModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Register Pet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-pet status cards -->
|
||||
<div class="row g-3 mb-3" id="pet-cards">
|
||||
{% for pet in pets %}
|
||||
<div class="col-sm-6 col-lg-4 col-xl-3">
|
||||
<div class="card bg-dark border-secondary h-100 pet-card" data-pet-id="{{ pet.id }}">
|
||||
<div class="card-body d-flex flex-column gap-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="fs-4 me-2">{{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }}</span>
|
||||
<span class="fw-semibold">{{ pet.name }}</span>
|
||||
{% if pet.breed %}
|
||||
<small class="text-muted ms-1">{{ pet.breed }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge pet-status-badge bg-secondary" id="status-{{ pet.id }}">
|
||||
<i class="bi bi-circle-fill me-1"></i>—
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<div><i class="bi bi-camera me-1"></i><span class="pet-location" id="loc-{{ pet.id }}">—</span></div>
|
||||
<div><i class="bi bi-clock me-1"></i><span class="pet-last-seen" id="seen-{{ pet.id }}">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body text-center text-muted py-4">
|
||||
<i class="bi bi-heart me-2"></i>No pets registered yet.
|
||||
<a href="#" class="ms-2" data-bs-toggle="modal" data-bs-target="#registerPetModal">Register your first pet</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Wildlife summary bar -->
|
||||
<div class="card bg-dark border-secondary mb-3">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-2">
|
||||
<span class="small fw-semibold"><i class="bi bi-binoculars me-2"></i>Wildlife Today</span>
|
||||
<small class="text-muted" id="wildlife-updated">—</small>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex flex-wrap gap-3" id="wildlife-summary">
|
||||
<span class="text-muted small">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity timeline -->
|
||||
<div class="card bg-dark border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-2">
|
||||
<span class="small fw-semibold"><i class="bi bi-bar-chart-line me-2"></i>24-Hour Activity</span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="activity-timeline">
|
||||
{% for pet in pets %}
|
||||
<div class="mb-2 activity-row" data-pet-id="{{ pet.id }}">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="small text-muted" style="min-width:8rem">
|
||||
{{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }}
|
||||
{{ pet.name }}
|
||||
</span>
|
||||
<div class="flex-grow-1 position-relative" style="height:20px;background:rgba(255,255,255,.05);border-radius:3px;">
|
||||
<canvas class="activity-canvas w-100 h-100" data-pet-id="{{ pet.id }}" style="display:block;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small text-center py-2">No pets registered</div>
|
||||
{% endfor %}
|
||||
<!-- Hour labels -->
|
||||
{% if pets %}
|
||||
<div class="d-flex justify-content-between small text-muted px-1" style="margin-left:8rem;">
|
||||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlabeled detection queue -->
|
||||
<div class="card bg-dark border-secondary mb-3">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-2">
|
||||
<span class="small fw-semibold"><i class="bi bi-question-circle me-2"></i>Unlabeled Detections</span>
|
||||
<span class="badge bg-secondary" id="unlabeled-count">0</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2" id="unlabeled-grid">
|
||||
<div class="col-12 text-muted small text-center py-2">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlight reel -->
|
||||
<div class="card bg-dark border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-2">
|
||||
<span class="small fw-semibold"><i class="bi bi-stars me-2"></i>Today's Highlights</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0 small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pet</th>
|
||||
<th>Camera</th>
|
||||
<th>Time</th>
|
||||
<th>Description</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="highlights-list">
|
||||
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Pet Modal -->
|
||||
<div class="modal fade" id="registerPetModal" tabindex="-1" aria-labelledby="registerPetLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h6 class="modal-title" id="registerPetLabel">
|
||||
<i class="bi bi-plus-circle me-2"></i>Register Pet
|
||||
</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="register-pet-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
id="pet-name" name="name" required placeholder="e.g. Mochi">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Species <span class="text-danger">*</span></label>
|
||||
<select class="form-select form-select-sm bg-dark text-light border-secondary"
|
||||
id="pet-species" name="species" required>
|
||||
<option value="">Select…</option>
|
||||
<option value="cat">Cat</option>
|
||||
<option value="dog">Dog</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Breed <span class="text-muted">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
id="pet-breed" name="breed" placeholder="e.g. Tabby">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Color / Description <span class="text-muted">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
id="pet-color" name="color_description" placeholder="e.g. orange with white socks">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Upload Photos <span class="text-muted">(optional)</span></label>
|
||||
<input type="file" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
id="pet-photos" name="photos" accept="image/*" multiple>
|
||||
<div class="form-text text-muted small">Upload clear photos to improve detection accuracy.</div>
|
||||
</div>
|
||||
<div id="register-error" class="alert alert-danger py-2 small d-none"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btn-register-submit">
|
||||
<i class="bi bi-check-lg me-1"></i>Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label crop modal -->
|
||||
<div class="modal fade" id="labelModal" tabindex="-1" aria-labelledby="labelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content bg-dark border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h6 class="modal-title" id="labelModalLabel">Label Detection</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img id="label-crop-img" src="" alt="Detection crop"
|
||||
class="img-fluid rounded mb-3" style="max-height:200px;">
|
||||
<div class="d-grid gap-2" id="label-pet-buttons">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary mt-2 w-100" id="btn-label-unknown">
|
||||
Not a registered pet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const PET_SPECIES_ICON = { cat: '🐈\u200d⬛', dog: '🐕' };
|
||||
const THREAT_COLORS = { low: 'success', medium: 'warning', high: 'danger' };
|
||||
|
||||
// ---- pet status -------------------------------------------------------
|
||||
function relativeTime(isoStr) {
|
||||
if (!isoStr) return '—';
|
||||
const diff = Math.floor((Date.now() - new Date(isoStr)) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return new Date(isoStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function statusBadgeClass(status) {
|
||||
if (!status) return 'bg-secondary';
|
||||
const s = status.toLowerCase();
|
||||
if (s === 'safe' || s === 'interior') return 'bg-success';
|
||||
if (s === 'check' || s === 'transition') return 'bg-warning text-dark';
|
||||
if (s === 'alert' || s === 'exterior') return 'bg-danger';
|
||||
return 'bg-secondary';
|
||||
}
|
||||
|
||||
function loadPetStatus() {
|
||||
fetch('/pets/api/status')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => {
|
||||
(data.pets || []).forEach(pet => {
|
||||
const badge = document.getElementById('status-' + pet.id);
|
||||
const loc = document.getElementById('loc-' + pet.id);
|
||||
const seen = document.getElementById('seen-' + pet.id);
|
||||
if (badge) {
|
||||
badge.className = 'badge pet-status-badge ' + statusBadgeClass(pet.status);
|
||||
badge.innerHTML = `<i class="bi bi-circle-fill me-1"></i>${pet.status || '—'}`;
|
||||
}
|
||||
if (loc) loc.textContent = pet.last_camera || '—';
|
||||
if (seen) seen.textContent = relativeTime(pet.last_seen);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ---- wildlife summary -------------------------------------------------
|
||||
function loadWildlife() {
|
||||
fetch('/pets/api/wildlife')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => {
|
||||
const bar = document.getElementById('wildlife-summary');
|
||||
const upd = document.getElementById('wildlife-updated');
|
||||
const counts = {};
|
||||
(data.sightings || []).forEach(s => {
|
||||
const key = s.species || 'unknown';
|
||||
if (!counts[key]) counts[key] = { total: 0, tier: s.threat_tier || 'low' };
|
||||
counts[key].total++;
|
||||
});
|
||||
if (Object.keys(counts).length === 0) {
|
||||
bar.innerHTML = '<span class="text-muted small">No wildlife detected today</span>';
|
||||
} else {
|
||||
bar.innerHTML = Object.entries(counts).map(([sp, v]) =>
|
||||
`<span class="badge bg-${THREAT_COLORS[v.tier] || 'secondary'}">
|
||||
${sp} <span class="fw-bold">${v.total}</span>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
upd.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('wildlife-summary').innerHTML =
|
||||
'<span class="text-muted small">Unavailable</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- activity timeline ------------------------------------------------
|
||||
function loadActivityTimeline() {
|
||||
fetch('/pets/api/sightings')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => {
|
||||
const sightings = data.sightings || [];
|
||||
document.querySelectorAll('.activity-canvas').forEach(canvas => {
|
||||
const petId = canvas.dataset.petId;
|
||||
const petSightings = sightings.filter(s => String(s.pet_id) === String(petId));
|
||||
drawActivityBar(canvas, petSightings);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function drawActivityBar(canvas, sightings) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.offsetWidth || 400;
|
||||
const H = canvas.offsetHeight || 20;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const now = new Date();
|
||||
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const dayMs = 86400000;
|
||||
|
||||
sightings.forEach(s => {
|
||||
const t = new Date(s.ts).getTime();
|
||||
const x = Math.floor(((t - dayStart) / dayMs) * W);
|
||||
const w = Math.max(2, Math.floor(((s.duration_s || 60) * 1000 / dayMs) * W));
|
||||
ctx.fillStyle = s.location === 'exterior' ? '#dc3545' :
|
||||
s.location === 'transition' ? '#ffc107' : '#198754';
|
||||
ctx.fillRect(x, 1, w, H - 2);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- unlabeled crops --------------------------------------------------
|
||||
let pendingLabelCropId = null;
|
||||
|
||||
function loadUnlabeled() {
|
||||
fetch('/pets/api/unlabeled')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => {
|
||||
const crops = data.crops || [];
|
||||
const grid = document.getElementById('unlabeled-grid');
|
||||
const badge = document.getElementById('unlabeled-count');
|
||||
badge.textContent = crops.length;
|
||||
|
||||
if (crops.length === 0) {
|
||||
grid.innerHTML = '<div class="col-12 text-muted small text-center py-2">No unlabeled detections</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = crops.map(crop =>
|
||||
`<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<div class="card bg-dark border-secondary h-100 crop-thumb" data-crop-id="${crop.id}"
|
||||
style="cursor:pointer;" data-img="${crop.thumb_url || ''}">
|
||||
<img src="${crop.thumb_url || ''}" class="card-img-top" alt="Detection crop"
|
||||
style="object-fit:cover;height:80px;"
|
||||
onerror="this.src='/static/img/no-preview.svg'">
|
||||
<div class="card-body p-1 small text-muted text-center">
|
||||
${crop.camera_id || '?'}<br>
|
||||
<span class="x-small">${relativeTime(crop.ts)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
grid.querySelectorAll('.crop-thumb').forEach(el => {
|
||||
el.addEventListener('click', () => openLabelModal(el.dataset.cropId, el.dataset.img));
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('unlabeled-grid').innerHTML =
|
||||
'<div class="col-12 text-muted small text-center py-2">Unavailable</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function openLabelModal(cropId, imgUrl) {
|
||||
pendingLabelCropId = cropId;
|
||||
document.getElementById('label-crop-img').src = imgUrl;
|
||||
|
||||
const btnContainer = document.getElementById('label-pet-buttons');
|
||||
const petCards = document.querySelectorAll('.pet-card');
|
||||
btnContainer.innerHTML = '';
|
||||
petCards.forEach(card => {
|
||||
const petId = card.dataset.petId;
|
||||
const name = card.querySelector('.fw-semibold')?.textContent?.trim() || petId;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-outline-primary w-100';
|
||||
btn.textContent = name;
|
||||
btn.addEventListener('click', () => submitLabel(petId));
|
||||
btnContainer.appendChild(btn);
|
||||
});
|
||||
|
||||
if (petCards.length === 0) {
|
||||
btnContainer.innerHTML = '<p class="text-muted small">No pets registered.</p>';
|
||||
}
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('labelModal')).show();
|
||||
}
|
||||
|
||||
function submitLabel(petId) {
|
||||
if (!pendingLabelCropId) return;
|
||||
fetch(`/pets/${pendingLabelCropId}/label`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sighting_id: pendingLabelCropId, pet_id: petId }),
|
||||
}).finally(() => {
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('labelModal')).hide();
|
||||
loadUnlabeled();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-label-unknown')?.addEventListener('click', () => {
|
||||
submitLabel(null);
|
||||
});
|
||||
|
||||
// ---- highlights -------------------------------------------------------
|
||||
function loadHighlights() {
|
||||
fetch('/pets/api/highlights')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => {
|
||||
const list = document.getElementById('highlights-list');
|
||||
const rows = data.highlights || [];
|
||||
if (rows.length === 0) {
|
||||
list.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No highlights yet today</td></tr>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(h =>
|
||||
`<tr>
|
||||
<td>${h.pet_name || '—'}</td>
|
||||
<td>${h.camera_id || '—'}</td>
|
||||
<td>${relativeTime(h.ts)}</td>
|
||||
<td>${h.description || '—'}</td>
|
||||
<td>
|
||||
${h.recording_id
|
||||
? `<a href="/recordings/${h.recording_id}/download"
|
||||
class="btn btn-sm btn-outline-light" title="Download">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</a>`
|
||||
: ''}
|
||||
</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('highlights-list').innerHTML =
|
||||
'<tr><td colspan="5" class="text-center text-muted py-3">Unavailable</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- register pet form ------------------------------------------------
|
||||
document.getElementById('btn-register-submit').addEventListener('click', () => {
|
||||
const form = document.getElementById('register-pet-form');
|
||||
const errBox = document.getElementById('register-error');
|
||||
errBox.classList.add('d-none');
|
||||
|
||||
const name = document.getElementById('pet-name').value.trim();
|
||||
const species = document.getElementById('pet-species').value;
|
||||
if (!name || !species) {
|
||||
errBox.textContent = 'Name and species are required.';
|
||||
errBox.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { name, species };
|
||||
const breed = document.getElementById('pet-breed').value.trim();
|
||||
const color = document.getElementById('pet-color').value.trim();
|
||||
if (breed) body.breed = breed;
|
||||
if (color) body.color_description = color;
|
||||
|
||||
fetch('/pets/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'Error')))
|
||||
.then(() => {
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('registerPetModal')).hide();
|
||||
form.reset();
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(err => {
|
||||
errBox.textContent = typeof err === 'string' ? err : 'Registration failed.';
|
||||
errBox.classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- train model button -----------------------------------------------
|
||||
document.getElementById('btn-train').addEventListener('click', () => {
|
||||
const btn = document.getElementById('btn-train');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Training…';
|
||||
fetch('/pets/train', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(() => {
|
||||
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Queued';
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-cpu me-1"></i>Train Model';
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-cpu me-1"></i>Train Model';
|
||||
});
|
||||
});
|
||||
|
||||
// ---- initial load + polling ------------------------------------------
|
||||
function refreshAll() {
|
||||
loadPetStatus();
|
||||
loadWildlife();
|
||||
loadActivityTimeline();
|
||||
loadUnlabeled();
|
||||
loadHighlights();
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,6 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Vigilar — Recordings{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/pet-labeling.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
|
||||
@ -38,8 +42,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- data-pet-labeling and data-detections are updated by JS when a recording is loaded -->
|
||||
<div class="position-relative" id="player-video-wrap" data-pet-labeling="true" data-detections="[]">
|
||||
<video id="player-video" class="w-100" controls></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
@ -90,6 +97,7 @@
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/timeline.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/pet-labeling.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
@ -112,11 +120,35 @@
|
||||
playerVideo.src = '';
|
||||
});
|
||||
|
||||
const playerVideoWrap = document.getElementById('player-video-wrap');
|
||||
|
||||
async function loadDetections(recordingId) {
|
||||
try {
|
||||
const resp = await fetch(`/recordings/${recordingId}/detections`);
|
||||
if (!resp.ok) return [];
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleSegmentClick(seg) {
|
||||
playerCard.classList.remove('d-none');
|
||||
playerTitle.textContent = `Recording #${seg.id} (${seg.type || 'motion'})`;
|
||||
playerVideo.src = `/recordings/${seg.id}/download`;
|
||||
playerVideo.play().catch(() => {});
|
||||
|
||||
// Reset overlay while detections load
|
||||
if (window.PetLabeling) {
|
||||
PetLabeling.updateDetections(playerVideoWrap, []);
|
||||
}
|
||||
|
||||
// Fetch detections for this recording and render overlay
|
||||
loadDetections(seg.id).then(detections => {
|
||||
if (window.PetLabeling) {
|
||||
PetLabeling.updateDetections(playerVideoWrap, detections);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadTimeline() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user