From aae857ec53a7fd1a3ee47ab8c7afe9352ce3c155 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:07:09 -0400 Subject: [PATCH 01/24] Add pet/wildlife enums, event types, and MQTT topics Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_constants.py | 51 ++++++++++++++++++++++++++++++++++++ vigilar/constants.py | 37 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/unit/test_constants.py diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..fb84193 --- /dev/null +++ b/tests/unit/test_constants.py @@ -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" diff --git a/vigilar/constants.py b/vigilar/constants.py index 57f20f1..e17cd6d 100644 --- a/vigilar/constants.py +++ b/vigilar/constants.py @@ -40,6 +40,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 +74,8 @@ class RecordingTrigger(StrEnum): MANUAL = "MANUAL" PERSON = "PERSON" VEHICLE = "VEHICLE" + PET = "PET" + WILDLIFE = "WILDLIFE" # --- Alert Channels --- @@ -103,6 +111,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 +172,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" From 6e3ef1dcdcb0ac93a3e48470e8352c6e3495fda6 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:08:40 -0400 Subject: [PATCH 02/24] Add pet detection, wildlife, and activity config models Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_config.py | 59 +++++++++++++++++++++++++++++++++++++++ vigilar/config.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index aadcac0..ae2c928 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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" diff --git a/vigilar/config.py b/vigilar/config.py index 75652b9..44eef74 100644 --- a/vigilar/config.py +++ b/vigilar/config.py @@ -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: str = "INTERIOR" # EXTERIOR | INTERIOR | TRANSITION # --- 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") From 4ca06651b6b437b36157d692bc99f72bb2dbbaa1 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:08:59 -0400 Subject: [PATCH 03/24] Fix import sort order in config.py (ruff I001) Co-Authored-By: Claude Sonnet 4.6 --- vigilar/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/vigilar/config.py b/vigilar/config.py index 44eef74..1739560 100644 --- a/vigilar/config.py +++ b/vigilar/config.py @@ -22,7 +22,6 @@ from vigilar.constants import ( DEFAULT_WEB_PORT, ) - # --- Camera Config --- class CameraConfig(BaseModel): From ea092bca1028b3e4e3b3466a3f2739e327867b6d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:11:01 -0400 Subject: [PATCH 04/24] Add pets, pet_sightings, wildlife_sightings, pet_training_images tables Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_schema.py | 51 +++++++++++++++++++++++++++++++++++- vigilar/storage/schema.py | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 8f492aa..11a6d48 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -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" diff --git a/vigilar/storage/schema.py b/vigilar/storage/schema.py index b63fec0..410af24 100644 --- a/vigilar/storage/schema.py +++ b/vigilar/storage/schema.py @@ -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), +) From d83710b839b88b779667c4b863561b8ad229ae1e Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:13:56 -0400 Subject: [PATCH 05/24] Add pet and wildlife database query functions Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_pet_queries.py | 101 ++++++++++++++++++ vigilar/storage/queries.py | 187 +++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 tests/unit/test_pet_queries.py diff --git a/tests/unit/test_pet_queries.py b/tests/unit/test_pet_queries.py new file mode 100644 index 0000000..8890394 --- /dev/null +++ b/tests/unit/test_pet_queries.py @@ -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" diff --git a/vigilar/storage/queries.py b/vigilar/storage/queries.py index 1f87050..001f8a4 100644 --- a/vigilar/storage/queries.py +++ b/vigilar/storage/queries.py @@ -11,10 +11,14 @@ 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, ) @@ -271,3 +275,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] From 131eed73b1a98be92e069038c3f2826f918f741d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:15:31 -0400 Subject: [PATCH 06/24] Add YOLOv8 unified detector with class classification Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_yolo_detector.py | 52 +++++++++++++++++++++ vigilar/detection/yolo.py | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/unit/test_yolo_detector.py create mode 100644 vigilar/detection/yolo.py diff --git a/tests/unit/test_yolo_detector.py b/tests/unit/test_yolo_detector.py new file mode 100644 index 0000000..0d67ad2 --- /dev/null +++ b/tests/unit/test_yolo_detector.py @@ -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" diff --git a/vigilar/detection/yolo.py b/vigilar/detection/yolo.py new file mode 100644 index 0000000..93baabe --- /dev/null +++ b/vigilar/detection/yolo.py @@ -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" From 13b7c2a219e4c7f8035c9aff9701ab460af05cbc Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:17:21 -0400 Subject: [PATCH 07/24] Add wildlife threat classification with size heuristics Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_wildlife.py | 50 +++++++++++++++++++++++++++++++++++ vigilar/detection/wildlife.py | 38 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/unit/test_wildlife.py create mode 100644 vigilar/detection/wildlife.py diff --git a/tests/unit/test_wildlife.py b/tests/unit/test_wildlife.py new file mode 100644 index 0000000..0abdd35 --- /dev/null +++ b/tests/unit/test_wildlife.py @@ -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" diff --git a/vigilar/detection/wildlife.py b/vigilar/detection/wildlife.py new file mode 100644 index 0000000..85fb175 --- /dev/null +++ b/vigilar/detection/wildlife.py @@ -0,0 +1,38 @@ +"""Wildlife threat level classification.""" + +from vigilar.config import WildlifeConfig +from vigilar.detection.person import Detection + + +def classify_wildlife_threat( + detection: Detection, + config: WildlifeConfig, + frame_area: int, +) -> tuple[str, 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 "PREDATOR", species + if species in threat_map.nuisance: + return "NUISANCE", species + if species in threat_map.passive: + return "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 "NUISANCE", species + elif area_ratio < heuristics.medium: + return "PREDATOR", species + else: + return "PASSIVE", species From c7f9304f2a64dcb2115d2a7e860d558057969cab Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:18:42 -0400 Subject: [PATCH 08/24] Add pet ID classifier with species-filtered identification Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_pet_id.py | 49 +++++++++++++ vigilar/detection/pet_id.py | 132 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/unit/test_pet_id.py create mode 100644 vigilar/detection/pet_id.py diff --git a/tests/unit/test_pet_id.py b/tests/unit/test_pet_id.py new file mode 100644 index 0000000..afefe02 --- /dev/null +++ b/tests/unit/test_pet_id.py @@ -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 diff --git a/vigilar/detection/pet_id.py b/vigilar/detection/pet_id.py new file mode 100644 index 0000000..f758561 --- /dev/null +++ b/vigilar/detection/pet_id.py @@ -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) From e48ba305ea2cbf6e0e998f6e8a763c70e21c1f40 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:19:12 -0400 Subject: [PATCH 09/24] Add pet ID model trainer with MobileNetV3-Small transfer learning Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_trainer.py | 55 ++++++++++++++ vigilar/detection/trainer.py | 141 +++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 tests/unit/test_trainer.py create mode 100644 vigilar/detection/trainer.py diff --git a/tests/unit/test_trainer.py b/tests/unit/test_trainer.py new file mode 100644 index 0000000..aad7dd7 --- /dev/null +++ b/tests/unit/test_trainer.py @@ -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 diff --git a/vigilar/detection/trainer.py b/vigilar/detection/trainer.py new file mode 100644 index 0000000..5a68fcc --- /dev/null +++ b/vigilar/detection/trainer.py @@ -0,0 +1,141 @@ +"""Pet ID model trainer using MobileNetV3-Small with transfer learning.""" + +import logging +import shutil +from dataclasses import dataclass, field +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_suffix(".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 From 53daf58c78f40dfd12d3f1113287f52eb6c3c1c4 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:20:37 -0400 Subject: [PATCH 10/24] Add camera_location filtering to alert profile matching Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_profiles.py | 53 +++++++++++++++++++++++++++++++++++++ vigilar/alerts/profiles.py | 14 +++++++--- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index 1bf5362..0d2af03 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -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" diff --git a/vigilar/alerts/profiles.py b/vigilar/alerts/profiles.py index e1e1460..8307f2d 100644 --- a/vigilar/alerts/profiles.py +++ b/vigilar/alerts/profiles.py @@ -46,11 +46,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" From d0acf7703c866cc3b640f7b4c59e86347ba26459 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:20:46 -0400 Subject: [PATCH 11/24] Handle pet and wildlife events in event processor --- tests/unit/test_events.py | 54 +++++++++++++++++++++++++++++++++++++ vigilar/events/processor.py | 15 +++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py index aadabee..689ea30 100644 --- a/tests/unit/test_events.py +++ b/tests/unit/test_events.py @@ -327,3 +327,57 @@ 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_name": "Angel", "confidence": 0.92}, + ) + assert etype == EventType.PET_DETECTED + assert sev == Severity.INFO + assert source == "kitchen" + + 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" diff --git a/vigilar/events/processor.py b/vigilar/events/processor.py index 0983aa7..91b33ec 100644 --- a/vigilar/events/processor.py +++ b/vigilar/events/processor.py @@ -127,6 +127,21 @@ 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": + 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 From 4c9ebe029d756293c1c0f3853ebc1d6bd6aec06b Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:22:25 -0400 Subject: [PATCH 12/24] Integrate YOLOv8 detection and pet ID into camera worker --- vigilar/camera/worker.py | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/vigilar/camera/worker.py b/vigilar/camera/worker.py index 60c0eea..227d91e 100644 --- a/vigilar/camera/worker.py +++ b/vigilar/camera/worker.py @@ -22,6 +22,9 @@ from vigilar.camera.recorder import AdaptiveRecorder from vigilar.camera.ring_buffer import RingBuffer from vigilar.config import CameraConfig, MQTTConfig, RemoteConfig from vigilar.constants import Topics +from vigilar.detection.yolo import YOLODetector +from vigilar.detection.pet_id import PetIDClassifier +from vigilar.detection.wildlife import classify_wildlife_threat 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,6 +111,21 @@ def run_camera_worker( bitrate_kbps=remote_cfg.remote_hls_bitrate_kbps, ) + # Object detection (YOLOv8 unified detector) + yolo_detector = None + pet_classifier = 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, + ) + state = CameraState() shutdown = False @@ -243,6 +262,49 @@ def run_camera_worker( if state.frame_count % idle_skip_factor == 0: recorder.write_frame(frame) + # Run object detection on motion frames + if state.motion_active and yolo_detector and yolo_detector.is_loaded: + detections = yolo_detector.detect(frame) + for det in detections: + category = YOLODetector.classify(det) + if category == "domestic_animal": + # Crop for pet ID + x, y, w, h = det.bbox + crop = frame[max(0, y):y + h, max(0, x):x + w] + 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, + } + 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, + ) + # Heartbeat every 10 seconds if now - last_heartbeat >= 10: last_heartbeat = now From 45007dcac2551ae8284c096672835c1bdb3464dc Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:22:26 -0400 Subject: [PATCH 13/24] Add crop manager for staging and training image lifecycle Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_crop_manager.py | 48 +++++++++++++++++++++++++++++++ vigilar/detection/crop_manager.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/unit/test_crop_manager.py create mode 100644 vigilar/detection/crop_manager.py diff --git a/tests/unit/test_crop_manager.py b/tests/unit/test_crop_manager.py new file mode 100644 index 0000000..09de943 --- /dev/null +++ b/tests/unit/test_crop_manager.py @@ -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() diff --git a/vigilar/detection/crop_manager.py b/vigilar/detection/crop_manager.py new file mode 100644 index 0000000..b58db83 --- /dev/null +++ b/vigilar/detection/crop_manager.py @@ -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 From 547193fd79434215bd5544b3b2302e7e175e80c0 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:23:23 -0400 Subject: [PATCH 14/24] Add ultralytics and torchvision dependencies for pet detection --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 01a392c..c6f3268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] From 2b3a4ba853748fd00ae734d2ac51a2170a271679 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:24:33 -0400 Subject: [PATCH 15/24] Add pet and wildlife counts to daily digest Co-Authored-By: Claude Sonnet 4.6 --- tests/conftest.py | 2 +- tests/unit/test_health.py | 44 +++++++++++++++++++++++++++++++++ vigilar/health/digest.py | 51 ++++++++++++++++++++++++++++++++++----- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 21be9a3..138641a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index 19e4c2c..6abb78a 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -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): diff --git a/vigilar/health/digest.py b/vigilar/health/digest.py index 7a36585..38ed016 100644 --- a/vigilar/health/digest.py +++ b/vigilar/health/digest.py @@ -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,29 @@ 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_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 +70,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) From 94c5184f4661b3c6c667e0cb1fbfcef0617952e1 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:27:46 -0400 Subject: [PATCH 16/24] Add pets web blueprint with API endpoints Implements all /pets/* routes (register, status, sightings, wildlife, unlabeled crops, label, upload, update, delete, train, training-status, highlights), registers the blueprint in app.py, adds a placeholder dashboard template, and covers the API with 11 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_pets_api.py | 109 ++++++++++++ vigilar/web/app.py | 2 + vigilar/web/blueprints/pets.py | 205 ++++++++++++++++++++++ vigilar/web/templates/pets/dashboard.html | 19 ++ 4 files changed, 335 insertions(+) create mode 100644 tests/unit/test_pets_api.py create mode 100644 vigilar/web/blueprints/pets.py create mode 100644 vigilar/web/templates/pets/dashboard.html diff --git a/tests/unit/test_pets_api.py b/tests/unit/test_pets_api.py new file mode 100644 index 0000000..9aec54b --- /dev/null +++ b/tests/unit/test_pets_api.py @@ -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 diff --git a/vigilar/web/app.py b/vigilar/web/app.py index c6194ab..06f7784 100644 --- a/vigilar/web/app.py +++ b/vigilar/web/app.py @@ -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) diff --git a/vigilar/web/blueprints/pets.py b/vigilar/web/blueprints/pets.py new file mode 100644 index 0000000..2d533cb --- /dev/null +++ b/vigilar/web/blueprints/pets.py @@ -0,0 +1,205 @@ +"""Pets blueprint — pet registration, sightings, labeling, training.""" + +import time +from pathlib import Path + +from flask import Blueprint, current_app, jsonify, render_template, request + +pets_bp = Blueprint("pets", __name__, url_prefix="/pets") + + +def _engine(): + return current_app.config.get("DB_ENGINE") + + +@pets_bp.route("/") +def dashboard(): + return render_template("pets/dashboard.html") + + +@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) + result = [] + for pet in pets_list: + last = get_pet_last_location(engine, pet["id"]) + result.append({ + **pet, + "last_seen_ts": last["ts"] if last else None, + "last_camera": last["camera_id"] if last else None, + }) + 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 = request.args.get("limit", 100, type=int) + + 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 = request.args.get("limit", 100, type=int) + + 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("//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("//upload", methods=["POST"]) +def upload_photo(pet_id: str): + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + files = request.files.getlist("photos") + if not files: + return jsonify({"error": "no files provided"}), 400 + + cfg = current_app.config.get("VIGILAR_CONFIG") + upload_dir = Path(cfg.system.data_dir if cfg else "/tmp/vigilar") / "training" / pet_id + upload_dir.mkdir(parents=True, exist_ok=True) + + from vigilar.storage.queries import insert_training_image + saved = [] + for f in files: + if not f.filename: + continue + dest = upload_dir / f"{int(time.time() * 1000)}_{f.filename}" + 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("//update", methods=["POST"]) +def update_pet(pet_id: str): + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + 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("//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 trigger_training(): + # Publish training trigger via MQTT or queue; for now return accepted + return jsonify({"ok": True, "status": "queued"}) + + +@pets_bp.route("/api/training-status") +def training_status(): + # TODO: read actual training progress from shared state / DB + return jsonify({"status": "idle", "progress": 0, "message": ""}) + + +@pets_bp.route("/api/highlights") +def highlights(): + # TODO: query today's highlight clips from recordings + return jsonify({"highlights": []}) diff --git a/vigilar/web/templates/pets/dashboard.html b/vigilar/web/templates/pets/dashboard.html new file mode 100644 index 0000000..82678fa --- /dev/null +++ b/vigilar/web/templates/pets/dashboard.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Pets — Vigilar{% endblock %} + +{% block content %} +
+

Pet & Wildlife Monitor

+ +
+ +
+ + Full pet dashboard UI coming in the next task. API endpoints are available at + /pets/api/status, /pets/api/sightings, + /pets/api/wildlife, and /pets/api/unlabeled. +
+{% endblock %} From 32955bc7e4628981ae4b4d7c95efb69a2a1273a5 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:30:07 -0400 Subject: [PATCH 17/24] Add pets dashboard template with Bootstrap 5 dark theme --- vigilar/web/templates/pets/dashboard.html | 514 +++++++++++++++++++++- 1 file changed, 504 insertions(+), 10 deletions(-) diff --git a/vigilar/web/templates/pets/dashboard.html b/vigilar/web/templates/pets/dashboard.html index 82678fa..ef89123 100644 --- a/vigilar/web/templates/pets/dashboard.html +++ b/vigilar/web/templates/pets/dashboard.html @@ -1,19 +1,513 @@ {% extends "base.html" %} - {% block title %}Pets — Vigilar{% endblock %} {% block content %}
-

Pet & Wildlife Monitor

- +
Pet & Wildlife Monitor
+
+ + +
-
- - Full pet dashboard UI coming in the next task. API endpoints are available at - /pets/api/status, /pets/api/sightings, - /pets/api/wildlife, and /pets/api/unlabeled. + +
+ {% for pet in pets %} +
+
+
+
+
+ {{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }} + {{ pet.name }} + {% if pet.breed %} + {{ pet.breed }} + {% endif %} +
+ + — + +
+
+
+
+
+
+
+
+ {% else %} +
+
+
+ No pets registered yet. + Register your first pet +
+
+
+ {% endfor %} +
+ + +
+
+ Wildlife Today + +
+
+
+ Loading… +
+
+
+ + +
+
+ 24-Hour Activity +
+
+ {% for pet in pets %} +
+
+ + {{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }} + {{ pet.name }} + +
+ +
+
+
+ {% else %} +
No pets registered
+ {% endfor %} + + {% if pets %} +
+ 00:0006:0012:0018:0024:00 +
+ {% endif %} +
+
+ + +
+
+ Unlabeled Detections + 0 +
+
+
+
Loading…
+
+
+
+ + +
+
+ Today's Highlights +
+
+
+ + + + + + + + + + + + + +
PetCameraTimeDescription
Loading…
+
+
+
+ + + + + + {% endblock %} + +{% block scripts %} + +{% endblock %} From 4274d1373fbf31eb041352db0133d9a6b60cae10 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:30:52 -0400 Subject: [PATCH 18/24] Add pet labeling UI overlay to recording playback Co-Authored-By: Claude Sonnet 4.6 --- vigilar/web/static/css/pet-labeling.css | 205 +++++++++++++ vigilar/web/static/js/pet-labeling.js | 377 ++++++++++++++++++++++++ vigilar/web/templates/recordings.html | 34 ++- 3 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 vigilar/web/static/css/pet-labeling.css create mode 100644 vigilar/web/static/js/pet-labeling.js diff --git a/vigilar/web/static/css/pet-labeling.css b/vigilar/web/static/css/pet-labeling.css new file mode 100644 index 0000000..98bd426 --- /dev/null +++ b/vigilar/web/static/css/pet-labeling.css @@ -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; } +} diff --git a/vigilar/web/static/js/pet-labeling.js b/vigilar/web/static/js/pet-labeling.js new file mode 100644 index 0000000..e785599 --- /dev/null +++ b/vigilar/web/static/js/pet-labeling.js @@ -0,0 +1,377 @@ +/* Vigilar — Pet labeling overlay for recording playback + * + * Integration: + * 1. Add to the page: + * + * + * + * 2. Wrap the
@@ -90,6 +97,7 @@ +