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:
Aaron D. Lee 2026-04-03 13:53:10 -04:00
commit 6436076c8a
35 changed files with 3235 additions and 21 deletions

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12.0

View File

@ -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]

View File

@ -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

View File

@ -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"

View 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"

View 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()

View File

@ -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"

View File

@ -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
View 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

View 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
View 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

View File

@ -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"

View File

@ -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"

View 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

View 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"

View 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"

View File

@ -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
return rule.action, rule.recipients
# 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"

View File

@ -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

View File

@ -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")

View File

@ -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"

View 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
View 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)

View 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

View 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
View 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"

View File

@ -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

View File

@ -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)

View File

@ -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]

View File

@ -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),
)

View File

@ -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)

View 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]})

View 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; }
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Expose public API
window.PetLabeling = { init, updateDetections };
})();

View 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 &amp; 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 %}

View File

@ -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,7 +42,10 @@
</button>
</div>
<div class="card-body p-0">
<video id="player-video" class="w-100" controls></video>
<!-- 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>
@ -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() {