From 25815fcdd6bdfecaef87fbc22dc1df97ed10dd03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:32:49 +0000 Subject: [PATCH] Rewrite EcoWatch AI as a modular package - Split the 870-line main.py into the ecowatch/ package: detector, tracker, monitor, sources, storage, notifier, recorder, overlay, ui, cli - Remove the hardcoded Telegram bot token; secrets now come from .env - Temporal smoothing (EMA) plus hysteresis turns frames into incidents with onset and end, escalating from smoke to fire within one incident - Monitoring runs in a background thread; the UI polls it from a Streamlit fragment, so switching tabs or reloading the page doesn't stop it - Live cameras use a latest-frame grabber that reconnects on drops - SQLite incident journal with snapshots and clips; the legacy JSON history is imported automatically - Telegram sending moved to a background queue with cooldown - Incident clips include pre-roll frames from before the alert - Headless CLI mode: python -m ecowatch - Map shows real camera coordinates instead of random points - train.py: argparse, classify and detect modes, backs up the old model - Tests (pytest), ruff config and a GitHub Actions workflow Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKYmgFdxkcjGu11gXfcToj --- .env.example | 14 + .github/workflows/tests.yml | 19 + .gitignore | 25 +- .streamlit/config.toml | 15 + README.md | 130 ++++-- ecowatch/__init__.py | 3 + ecowatch/__main__.py | 5 + ecowatch/cli.py | 114 +++++ ecowatch/config.py | 94 ++++ ecowatch/detector.py | 97 ++++ ecowatch/hazards.py | 52 +++ ecowatch/monitor.py | 249 ++++++++++ ecowatch/notifier.py | 140 ++++++ ecowatch/overlay.py | 107 +++++ ecowatch/recorder.py | 87 ++++ ecowatch/sources.py | 211 +++++++++ ecowatch/storage.py | 136 ++++++ ecowatch/tracker.py | 104 +++++ ecowatch/ui.py | 495 ++++++++++++++++++++ logo/logo_small.png | Bin 0 -> 74622 bytes main.py | 874 +---------------------------------- pyproject.toml | 15 + requirements-dev.txt | 2 + requirements.txt | 18 +- run_app.bat | 15 +- start_ecowatch.bat | 4 - tests/conftest.py | 4 + tests/test_config_sources.py | 29 ++ tests/test_detector.py | 47 ++ tests/test_notifier.py | 69 +++ tests/test_recorder.py | 37 ++ tests/test_storage.py | 68 +++ tests/test_tracker.py | 59 +++ train.py | 172 ++++--- 34 files changed, 2499 insertions(+), 1011 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/tests.yml create mode 100644 .streamlit/config.toml create mode 100644 ecowatch/__init__.py create mode 100644 ecowatch/__main__.py create mode 100644 ecowatch/cli.py create mode 100644 ecowatch/config.py create mode 100644 ecowatch/detector.py create mode 100644 ecowatch/hazards.py create mode 100644 ecowatch/monitor.py create mode 100644 ecowatch/notifier.py create mode 100644 ecowatch/overlay.py create mode 100644 ecowatch/recorder.py create mode 100644 ecowatch/sources.py create mode 100644 ecowatch/storage.py create mode 100644 ecowatch/tracker.py create mode 100644 ecowatch/ui.py create mode 100644 logo/logo_small.png create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt delete mode 100644 start_ecowatch.bat create mode 100644 tests/conftest.py create mode 100644 tests/test_config_sources.py create mode 100644 tests/test_detector.py create mode 100644 tests/test_notifier.py create mode 100644 tests/test_recorder.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_tracker.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e07ec97 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Скопируйте в .env и заполните. Файл .env не попадает в git. + +# Telegram: токен от @BotFather и ID чата (узнать можно у @userinfobot) +ECOWATCH_TG_TOKEN= +ECOWATCH_TG_CHAT_ID= + +# Координаты камеры — для карты инцидентов (необязательно) +ECOWATCH_CAMERA_LAT= +ECOWATCH_CAMERA_LON= + +# Необязательно: путь к модели, папка данных, устройство (cpu / 0 для GPU) +# ECOWATCH_MODEL=models/best.pt +# ECOWATCH_DATA_DIR=data +# ECOWATCH_DEVICE=0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2f9343a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,19 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + # Тесты не требуют ultralytics/torch — ставим только лёгкие зависимости. + - run: pip install numpy opencv-python-headless Pillow requests -r requirements-dev.txt + - run: ruff check . + - run: ruff format --check . + - run: pytest -q diff --git a/.gitignore b/.gitignore index 28c577c..fe555bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,27 @@ .venv/ +venv/ .idea/ .vscode/ __pycache__/ *.pyc +.pytest_cache/ +.ruff_cache/ +# секреты +.env + +# данные приложения: журнал, снимки, клипы, загрузки +data/ +recordings/ +detection_history.json + +# датасеты и результаты обучения dataset_detect/ dataset_retrain/ fire_smoke_images/ +runs/ runs_retrain/ - shop project/ *.pt !models/best.pt - -recordings/ -detection_history.json - -1111.py -1111_backup*.py -check_classes.py -check_imports.py -download_extra_data.py -retrain_model.py -train_detect.py diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..6fbc758 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,15 @@ +[theme] +base = "dark" +primaryColor = "#ef4444" +backgroundColor = "#0f1117" +secondaryBackgroundColor = "#1a1d26" +textColor = "#e2e4eb" + +[client] +toolbarMode = "minimal" + +[browser] +gatherUsageStats = false + +[server] +maxUploadSize = 1024 diff --git a/README.md b/README.md index f75b483..c66fa42 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,132 @@ -# EcoWatch AI +# 🛡️ EcoWatch AI -Система обнаружения экологических угроз на основе YOLOv8. +Система обнаружения пожаров и дыма на видео в реальном времени на базе YOLOv8. -Анализирует видеопоток в реальном времени и определяет: -- 🔥 Пожар -- 💨 Дым -- 🗑️ Сжигание мусора +Работает с веб-камерой, IP-камерой (RTSP/HTTP), видеофайлом или захватом экрана. +Подтверждённые угрозы записываются в журнал со снимком и видеоклипом, а уведомления уходят в Telegram. ## Возможности -- Мониторинг с веб-камеры, IP-камеры, экрана или загруженного файла -- Уведомления в Telegram с фото -- Аналитика и графики -- Журнал обнаружений с экспортом в CSV -- Запись видео при обнаружении угрозы +- **Без ложных «миганий».** Вероятности сглаживаются по времени, а инцидент + начинается только после нескольких подряд опасных кадров и заканчивается после серии чистых. + Если дым перерастает в пожар, это фиксируется в том же инциденте. +- **Инциденты, а не спам.** Одна угроза — одна запись в журнале (начало, конец, пиковая + вероятность, снимок, клип) и одно уведомление в Telegram с настраиваемой паузой. +- **Фоновый мониторинг.** Видео обрабатывается в отдельном потоке: интерфейс не подвисает, + а мониторинг продолжается при переключении вкладок и перезагрузке страницы. +- **Клипы с предзаписью.** В клип попадают несколько секунд *до* тревоги. +- **Переподключение к камере** при обрыве связи. +- **Консольный режим** для круглосуточной работы на сервере без браузера. +- Аналитика по периодам, тепловая карта активности, карта камер, экспорт журнала в CSV. +- Поддерживаются модели classify (как текущая `models/best.pt`) и detect (с рамками). ## Установка +Нужен Python 3.10 или новее. + ```bash +python -m venv .venv +.venv\Scripts\activate # Windows +source .venv/bin/activate # Linux/macOS pip install -r requirements.txt ``` +### Настройка Telegram + +Скопируйте `.env.example` в `.env` и заполните: + +```ini +ECOWATCH_TG_TOKEN=123456:ABC... # токен от @BotFather +ECOWATCH_TG_CHAT_ID=123456789 # ID чата, можно узнать у @userinfobot +``` + +Токен никогда не хранится в коде: файл `.env` исключён из git. + ## Запуск +### Веб-интерфейс + ```bash streamlit run main.py ``` -Или через батник: +Или двойной клик по `run_app.bat` в Windows. Откроется http://localhost:8501. +### Консольный режим + +```bash +python -m ecowatch 0 # веб-камера №0 +python -m ecowatch rtsp://user:pass@192.168.1.10/live --record +python -m ecowatch video.mp4 --show # с окном предпросмотра +python -m ecowatch screen # захват экрана +python -m ecowatch report # отчёт за сегодня в Telegram +python -m ecowatch --help # все параметры ``` -run_app.bat -``` + +Ежедневный отчёт удобно отправлять по расписанию: через планировщик заданий Windows или cron. + +## Настройка чувствительности + +| Параметр | По умолчанию | Смысл | +|---|---|---| +| Порог вероятности | 0.65 | минимальная сглаженная вероятность опасного класса | +| Отрыв от 2-го класса | 0.15 | защита от неоднозначных кадров | +| Сглаживание | 0.35 | вес нового кадра: меньше — стабильнее, но медленнее реакция | +| Кадров для подтверждения | 4 | сколько опасных кадров подряд нужно для тревоги | +| Кадров для отбоя | 15 | сколько чистых кадров подряд завершают инцидент | + +В веб-интерфейсе пороги меняются на лету, без перезапуска мониторинга. ## Обучение модели -Подготовьте датасет в папке `dataset_detect/` со структурой: +**Классификация** (как у текущей модели): датасет из папок по классам. ``` dataset_detect/ -├── train/ -│ ├── fire/ -│ ├── normal/ -│ └── smoke/ -└── valid/ - ├── fire/ - ├── normal/ - └── smoke/ +├── train/ {fire, normal, smoke}/ +└── val/ {fire, normal, smoke}/ +``` + +```bash +python train.py --data dataset_detect ``` -Запустите: +**Детекция с рамками** (рекомендуется: точнее и показывает, *где* огонь): датасет в формате YOLO. ```bash -python train.py +python train.py --data fire_smoke/data.yaml --model yolov8s.pt --imgsz 640 +``` + +Лучшие веса автоматически копируются в `models/best.pt`, а прежняя модель сохраняется +рядом как `best_<дата>.pt`. + +## Структура проекта + +``` +main.py точка входа Streamlit +train.py обучение модели +ecowatch/ +├── config.py настройки и пороги (.env) +├── detector.py обёртка YOLO: classify и detect +├── tracker.py сглаживание и гистерезис → инциденты +├── monitor.py фоновый цикл мониторинга +├── sources.py камеры, потоки, файлы, экран +├── storage.py журнал инцидентов (SQLite) +├── notifier.py Telegram (в фоне) +├── recorder.py клипы с предзаписью +├── overlay.py отрисовка поверх видео +├── ui.py веб-интерфейс +└── cli.py консольный режим +tests/ тесты (pytest) ``` -## Стек +Данные хранятся в `data/`: база `ecowatch.db`, а также папки `snapshots/` и `clips/`. +Журнал старой версии (`detection_history.json`) импортируется автоматически при первом запуске. -- Python 3.11 -- Streamlit -- YOLOv8 (ultralytics) -- OpenCV -- Plotly +## Разработка + +```bash +pip install -r requirements-dev.txt +pytest +ruff check . && ruff format --check . +``` diff --git a/ecowatch/__init__.py b/ecowatch/__init__.py new file mode 100644 index 0000000..9ee53b8 --- /dev/null +++ b/ecowatch/__init__.py @@ -0,0 +1,3 @@ +"""EcoWatch AI — обнаружение пожаров и дыма на видео с помощью YOLO.""" + +__version__ = "4.0.0" diff --git a/ecowatch/__main__.py b/ecowatch/__main__.py new file mode 100644 index 0000000..dd8a8c9 --- /dev/null +++ b/ecowatch/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import main + +sys.exit(main()) diff --git a/ecowatch/cli.py b/ecowatch/cli.py new file mode 100644 index 0000000..c4caee8 --- /dev/null +++ b/ecowatch/cli.py @@ -0,0 +1,114 @@ +"""Консольный режим без браузера — для круглосуточной работы на сервере. + +Примеры: + python -m ecowatch 0 # веб-камера 0 + python -m ecowatch rtsp://user:pass@ip/stream --record + python -m ecowatch video.mp4 --show # с окном предпросмотра + python -m ecowatch report # отправить отчёт за сегодня в Telegram +""" + +from __future__ import annotations + +import argparse +import logging +import signal +import sys +import time + +from .config import DetectionParams, Settings +from .notifier import AsyncSender, TelegramNotifier, format_daily_report +from .sources import parse_source +from .storage import EventStore + + +def build_parser() -> argparse.ArgumentParser: + d = DetectionParams() + p = argparse.ArgumentParser(prog="ecowatch", description="EcoWatch AI — мониторинг пожаров и дыма") + p.add_argument("source", help="0 (камера), rtsp://…, путь к видео, screen[:N] или 'report'") + p.add_argument("--model", help="путь к весам YOLO (по умолчанию models/best.pt)") + p.add_argument("--conf", type=float, default=d.conf_threshold, help="порог вероятности") + p.add_argument("--margin", type=float, default=d.margin, help="отрыв от второго класса") + p.add_argument("--confirm", type=int, default=d.confirm_frames, help="кадров для подтверждения") + p.add_argument("--clear", type=int, default=d.clear_frames, help="кадров для отбоя") + p.add_argument("--record", action="store_true", help="записывать клипы инцидентов") + p.add_argument("--no-telegram", action="store_true", help="не отправлять уведомления") + p.add_argument("--cooldown", type=float, default=60, help="пауза между тревогами, с") + p.add_argument("--show", action="store_true", help="показывать окно с видео") + p.add_argument("-v", "--verbose", action="store_true") + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + datefmt="%H:%M:%S", + ) + settings = Settings.from_env() + if args.model: + from pathlib import Path + + settings.model_path = Path(args.model) + settings.ensure_dirs() + store = EventStore(settings.db_path) + notifier = TelegramNotifier(settings.telegram_token, settings.telegram_chat_id) + + if args.source == "report": + if not notifier.enabled: + logging.error("Telegram не настроен (ECOWATCH_TG_TOKEN / ECOWATCH_TG_CHAT_ID)") + return 1 + return 0 if notifier.send_message(format_daily_report(store.day_summary())) else 1 + + from .detector import Detector + from .monitor import Monitor, MonitorOptions + + detector = Detector(settings.model_path, settings.device) + logging.info("Модель %s (%s): %s", settings.model_path.name, detector.task, ", ".join(detector.names)) + options = MonitorOptions( + source=parse_source(args.source), + params=DetectionParams( + conf_threshold=args.conf, margin=args.margin, confirm_frames=args.confirm, clear_frames=args.clear + ), + notify=not args.no_telegram and notifier.enabled, + notify_cooldown=args.cooldown, + record=args.record, + lat=settings.camera_lat, + lon=settings.camera_lon, + ) + if not args.no_telegram and not notifier.enabled: + logging.warning("Telegram не настроен — уведомления отключены") + sender = AsyncSender() + monitor = Monitor(detector, store, settings, options, notifier, sender) + + signal.signal(signal.SIGINT, lambda *_: monitor.stop(wait=False)) + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, lambda *_: monitor.stop(wait=False)) + + monitor.start() + logging.info("Мониторинг запущен: %s (Ctrl+C — остановить)", options.source.label) + last_log = 0.0 + while monitor.alive: + s = monitor.state() + if args.show and s.frame is not None: + import cv2 + + cv2.imshow("EcoWatch AI", s.frame) + if cv2.waitKey(1) & 0xFF in (ord("q"), 27): + monitor.stop(wait=False) + else: + time.sleep(0.2) + if time.monotonic() - last_log > 30: + last_log = time.monotonic() + logging.info("кадров: %d · %.1f FPS · инцидентов: %d", s.frames, s.fps, s.incidents) + monitor.stop() + sender.join() + s = monitor.state() + if s.error: + logging.error("Ошибка: %s", s.error) + logging.info("Готово. Кадров: %d, инцидентов: %d", s.frames, s.incidents) + return 1 if s.error else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ecowatch/config.py b/ecowatch/config.py new file mode 100644 index 0000000..06f0164 --- /dev/null +++ b/ecowatch/config.py @@ -0,0 +1,94 @@ +"""Настройки приложения: пути, секреты и пороги обнаружения. + +Секреты (токен Telegram) читаются только из переменных окружения или файла +`.env` в корне проекта и никогда не хранятся в коде. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def load_dotenv(path: Path) -> None: + """Минимальный парсер .env: KEY=VALUE, комментарии через #. Не перетирает окружение.""" + if not path.is_file(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip().removeprefix("export ").strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def _float_or_none(value: str | None) -> float | None: + try: + return float(value) if value not in (None, "") else None + except ValueError: + return None + + +@dataclass +class DetectionParams: + """Пороги принятия решения. Подобраны под classify-модель из models/best.pt.""" + + conf_threshold: float = 0.65 # минимальная сглаженная вероятность опасного класса + margin: float = 0.15 # отрыв лидера от второго класса + box_conf: float = 0.35 # мин. уверенность рамки (только для detect-моделей) + ema_alpha: float = 0.35 # вес нового кадра при сглаживании вероятностей + confirm_frames: int = 4 # подряд «опасных» кадров для начала инцидента + clear_frames: int = 15 # подряд «чистых» кадров для завершения инцидента + + +@dataclass +class Settings: + model_path: Path = field(default_factory=lambda: ROOT_DIR / "models" / "best.pt") + data_dir: Path = field(default_factory=lambda: ROOT_DIR / "data") + telegram_token: str | None = None + telegram_chat_id: str | None = None + camera_lat: float | None = None + camera_lon: float | None = None + device: str | None = None # "cpu", "0" (GPU) и т.п.; None — автоматически + + @property + def db_path(self) -> Path: + return self.data_dir / "ecowatch.db" + + @property + def snapshots_dir(self) -> Path: + return self.data_dir / "snapshots" + + @property + def clips_dir(self) -> Path: + return self.data_dir / "clips" + + @property + def telegram_configured(self) -> bool: + return bool(self.telegram_token and self.telegram_chat_id) + + @classmethod + def from_env(cls, dotenv: Path | None = ROOT_DIR / ".env") -> Settings: + if dotenv is not None: + load_dotenv(dotenv) + env = os.environ + s = cls() + if env.get("ECOWATCH_MODEL"): + s.model_path = Path(env["ECOWATCH_MODEL"]).expanduser() + if env.get("ECOWATCH_DATA_DIR"): + s.data_dir = Path(env["ECOWATCH_DATA_DIR"]).expanduser() + s.telegram_token = env.get("ECOWATCH_TG_TOKEN") or None + s.telegram_chat_id = env.get("ECOWATCH_TG_CHAT_ID") or None + s.camera_lat = _float_or_none(env.get("ECOWATCH_CAMERA_LAT")) + s.camera_lon = _float_or_none(env.get("ECOWATCH_CAMERA_LON")) + s.device = env.get("ECOWATCH_DEVICE") or None + return s + + def ensure_dirs(self) -> None: + for d in (self.data_dir, self.snapshots_dir, self.clips_dir): + d.mkdir(parents=True, exist_ok=True) diff --git a/ecowatch/detector.py b/ecowatch/detector.py new file mode 100644 index 0000000..229bcea --- /dev/null +++ b/ecowatch/detector.py @@ -0,0 +1,97 @@ +"""Обёртка над YOLO: единый результат для classify- и detect-моделей.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np + +from .hazards import get_hazard, normalize + + +@dataclass(frozen=True) +class Box: + x1: int + y1: int + x2: int + y2: int + conf: float + label: str + + +@dataclass +class FrameResult: + """Вероятности классов для одного кадра (+ рамки, если модель detect).""" + + scores: dict[str, float] + boxes: list[Box] = field(default_factory=list) + + def top(self) -> tuple[str, float]: + if not self.scores: + return "normal", 0.0 + label = max(self.scores, key=lambda k: (self.scores[k], get_hazard(k).severity)) + return label, self.scores[label] + + +def _to_numpy(x: Any) -> np.ndarray: + if hasattr(x, "cpu"): + x = x.cpu() + if hasattr(x, "numpy"): + x = x.numpy() + return np.asarray(x) + + +def parse_result(result: Any, box_conf: float = 0.35) -> FrameResult: + """Превращает `ultralytics.engine.results.Results` в `FrameResult`.""" + names = {int(k): normalize(v) for k, v in dict(result.names).items()} + + probs = getattr(result, "probs", None) + if probs is not None: + raw = _to_numpy(probs.data).astype(float).ravel() + return FrameResult({names[i]: float(raw[i]) for i in names if i < len(raw)}) + + boxes_obj = getattr(result, "boxes", None) + scores = {name: 0.0 for name in names.values()} + boxes: list[Box] = [] + if boxes_obj is not None and len(boxes_obj) > 0: + confs = _to_numpy(boxes_obj.conf).ravel() + clss = _to_numpy(boxes_obj.cls).ravel().astype(int) + xyxy = _to_numpy(boxes_obj.xyxy).reshape(-1, 4) + for conf, cls_id, (x1, y1, x2, y2) in zip(confs, clss, xyxy, strict=False): + if conf < box_conf: + continue + label = names.get(int(cls_id), f"class_{cls_id}") + boxes.append(Box(int(x1), int(y1), int(x2), int(y2), float(conf), label)) + scores[label] = max(scores.get(label, 0.0), float(conf)) + # У detect-модели нет класса «норма» — выводим его как отсутствие угрозы. + danger_max = max((v for k, v in scores.items() if get_hazard(k).danger), default=0.0) + scores["normal"] = max(scores.get("normal", 0.0), 1.0 - danger_max) + return FrameResult(scores, boxes) + + +class Detector: + def __init__(self, model_path: str | Path, device: str | None = None) -> None: + model_path = Path(model_path) + if not model_path.is_file(): + raise FileNotFoundError(f"Модель не найдена: {model_path}") + from ultralytics import YOLO # тяжёлый импорт — только когда реально нужен + + self.path = model_path + self.model = YOLO(str(model_path)) + self.task: str = self.model.task + self.names: list[str] = [normalize(n) for n in self.model.names.values()] + self.device = device + self._lock = threading.Lock() + + def predict(self, frame: np.ndarray, box_conf: float = 0.35) -> FrameResult: + with self._lock: + kwargs: dict[str, Any] = {"verbose": False} + if self.device: + kwargs["device"] = self.device + if self.task == "detect": + kwargs["conf"] = box_conf + result = self.model(frame, **kwargs)[0] + return parse_result(result, box_conf) diff --git a/ecowatch/hazards.py b/ecowatch/hazards.py new file mode 100644 index 0000000..69bb7ae --- /dev/null +++ b/ecowatch/hazards.py @@ -0,0 +1,52 @@ +"""Каталог классов, которые умеет различать модель.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Hazard: + key: str + title: str + icon: str + color: str # HEX для интерфейса + description: str + danger: bool + severity: int # чем больше, тем опаснее; влияет на выбор класса при равенстве + + @property + def bgr(self) -> tuple[int, int, int]: + h = self.color.lstrip("#") + r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4)) + return b, g, r + + +HAZARDS: dict[str, Hazard] = { + h.key: h + for h in ( + Hazard("fire", "Пожар", "🔥", "#ef4444", "Открытое пламя · критическая опасность", True, 3), + Hazard("trash", "Сжигание мусора", "🗑️", "#eab308", "Горение бытовых отходов, пластика", True, 2), + Hazard("smoke", "Дым", "💨", "#94a3b8", "Обнаружен дым · требуется проверка", True, 1), + Hazard("factory", "Производственный дым", "🏭", "#3b82f6", "Промышленный выброс", False, 0), + Hazard("normal", "Норма", "✅", "#22c55e", "Угроз не обнаружено", False, 0), + ) +} + +_ALIASES = {"flame": "fire", "fires": "fire", "garbage": "trash", "background": "normal", "none": "normal"} + + +def normalize(name: str) -> str: + key = str(name).strip().lower() + return _ALIASES.get(key, key) + + +def get_hazard(name: str) -> Hazard: + key = normalize(name) + if key in HAZARDS: + return HAZARDS[key] + return Hazard(key, key.capitalize(), "❔", "#8b8fa3", "Неизвестный класс модели", False, 0) + + +def is_danger(name: str) -> bool: + return get_hazard(name).danger diff --git a/ecowatch/monitor.py b/ecowatch/monitor.py new file mode 100644 index 0000000..e844acd --- /dev/null +++ b/ecowatch/monitor.py @@ -0,0 +1,249 @@ +"""Фоновый мониторинг: источник → модель → трекер → журнал/уведомления/запись. + +Работает в отдельном потоке, поэтому интерфейс Streamlit остаётся отзывчивым, +а мониторинг не прерывается при переключении вкладок или перезагрузке страницы. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections import deque +from dataclasses import dataclass, field, replace +from datetime import datetime +from pathlib import Path + +import cv2 +import numpy as np + +from .config import DetectionParams, Settings +from .detector import Detector +from .notifier import AsyncSender, TelegramNotifier, format_alert, format_incident_closed +from .overlay import draw_overlay +from .recorder import ClipRecorder +from .sources import SourceSpec +from .storage import EventStore +from .tracker import Decision, ThreatTracker + +log = logging.getLogger(__name__) + + +PREVIEW_MAX_WIDTH = 960 + + +def encode_preview(frame: np.ndarray, max_width: int = PREVIEW_MAX_WIDTH) -> bytes | None: + h, w = frame.shape[:2] + if w > max_width: + frame = cv2.resize(frame, (max_width, int(h * max_width / w)), interpolation=cv2.INTER_AREA) + ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) + return buf.tobytes() if ok else None + + +@dataclass +class MonitorOptions: + source: SourceSpec + params: DetectionParams = field(default_factory=DetectionParams) + notify: bool = True + notify_photo: bool = True + notify_on_clear: bool = False + notify_cooldown: float = 60.0 # мин. секунд между тревогами в Telegram + record: bool = False + record_pre: float = 3.0 + record_post: float = 10.0 + lat: float | None = None + lon: float | None = None + + +@dataclass +class MonitorState: + running: bool = False + finished: bool = False + error: str | None = None + source: str = "" + started_at: datetime | None = None + frame: np.ndarray | None = None + preview: bytes | None = None # JPEG для интерфейса, кодируется один раз в рабочем потоке + decision: Decision | None = None + fps: float = 0.0 + frames: int = 0 + incidents: int = 0 + incident_id: int | None = None + recording: bool = False + + +class Monitor: + def __init__( + self, + detector: Detector, + store: EventStore, + settings: Settings, + options: MonitorOptions, + notifier: TelegramNotifier | None = None, + sender: AsyncSender | None = None, + ) -> None: + self.detector = detector + self.store = store + self.settings = settings + self.options = options + self.notifier = notifier + self.sender = sender + self.tracker = ThreatTracker(options.params) + self._state = MonitorState(source=options.source.label) + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, name="ecowatch-monitor", daemon=True) + self._incident: dict | None = None + self._last_alert = float("-inf") + + # ---------- управление ---------- + def start(self) -> None: + with self._lock: + self._state.running = True + self._state.started_at = datetime.now() + self._thread.start() + + def stop(self, wait: bool = True) -> None: + self._stop.set() + if wait and self._thread.is_alive() and threading.current_thread() is not self._thread: + self._thread.join(timeout=10) + + def update_params(self, params: DetectionParams) -> None: + """Пороги можно менять на лету, не перезапуская мониторинг.""" + self.tracker.params = params + + def state(self) -> MonitorState: + with self._lock: + return replace(self._state) + + @property + def alive(self) -> bool: + return self._thread.is_alive() + + def _set(self, **kw: object) -> None: + with self._lock: + for k, v in kw.items(): + setattr(self._state, k, v) + + # ---------- уведомления ---------- + def _notify(self, text: str, image: np.ndarray | None) -> None: + if not (self.options.notify and self.notifier and self.notifier.enabled and self.sender): + return + if image is not None and self.options.notify_photo: + self.sender.submit(self.notifier.send_photo, image, text) + else: + self.sender.submit(self.notifier.send_message, text) + + # ---------- инциденты ---------- + def _on_onset(self, d: Decision, frame: np.ndarray) -> None: + now = datetime.now() + snap: Path | None = self.settings.snapshots_dir / f"{now:%Y%m%d_%H%M%S}_{d.label}.jpg" + try: + snap.parent.mkdir(parents=True, exist_ok=True) + if not cv2.imwrite(str(snap), frame): + snap = None + except (OSError, cv2.error): + snap = None + incident_id = self.store.open_incident( + d.label, + d.confidence, + self.options.source.label, + started_at=now, + snapshot=str(snap) if snap else None, + lat=self.options.lat, + lon=self.options.lon, + ) + self._incident = {"id": incident_id, "label": d.label, "peak": d.confidence, "started": now} + log.warning("Инцидент #%s: %s (%.0f%%)", incident_id, d.label, d.confidence * 100) + if time.monotonic() - self._last_alert >= self.options.notify_cooldown: + self._last_alert = time.monotonic() + self._notify(format_alert(d.label, d.confidence, self.options.source.label, now), frame) + + def _close_incident(self) -> None: + inc = self._incident + if inc is None: + return + ended = datetime.now() + self.store.update_incident(inc["id"], ended_at=ended, peak_conf=inc["peak"], label=inc["label"]) + log.info("Инцидент #%s завершён", inc["id"]) + if self.options.notify_on_clear: + self._notify(format_incident_closed(inc["label"], inc["peak"], inc["started"], ended), None) + self._incident = None + + def _attach_clip(self, path: Path | None, incident_id: int | None) -> None: + if path and incident_id is not None: + self.store.update_incident(incident_id, clip=str(path)) + + # ---------- основной цикл ---------- + def _run(self) -> None: + source = self.options.source.build() + recorder = ( + ClipRecorder( + self.settings.clips_dir, pre_seconds=self.options.record_pre, post_seconds=self.options.record_post + ) + if self.options.record + else None + ) + clip_incident: int | None = None + fps = 0.0 + stamps: deque[float] = deque(maxlen=30) # FPS по скользящему окну последних кадров + try: + source.open() + if recorder and not source.live: + recorder.set_fps(source.fps) + while not self._stop.is_set(): + frame = source.read(timeout=5.0) + if frame is None: + if source.live: + self._set(error=getattr(source, "error", None) or "Нет кадров от источника…") + continue + self._set(finished=True) + break + + result = self.detector.predict(frame, self.tracker.params.box_conf) + d = self.tracker.update(result) + + stamps.append(time.monotonic()) + if len(stamps) > 1: + fps = (len(stamps) - 1) / max(stamps[-1] - stamps[0], 1e-6) + + annotated = draw_overlay( + frame, + d, + result.boxes, + self.options.source.label, + fps, + recording=bool(recorder and recorder.recording), + ) + + if d.onset: + self._on_onset(d, annotated) + if self._incident is not None and d.active: + self._incident["peak"] = max(self._incident["peak"], d.confidence) + self._incident["label"] = d.label + if recorder: + if source.live: + recorder.set_fps(fps) + if d.active and not recorder.recording and self._incident: + clip_incident = self._incident["id"] + self._attach_clip(recorder.push(annotated, d.active), clip_incident) + if d.cleared: + self._close_incident() + + preview = encode_preview(annotated) + with self._lock: + s = self._state + s.frame, s.preview, s.decision, s.fps, s.error = annotated, preview, d, fps, None + s.frames += 1 + s.incidents += int(d.onset) + s.incident_id = self._incident["id"] if self._incident else None + s.recording = bool(recorder and recorder.recording) + except Exception as exc: # noqa: BLE001 — ошибку показываем в интерфейсе + log.exception("Мониторинг остановлен из-за ошибки") + self._set(error=str(exc)) + finally: + self._close_incident() + if recorder: + self._attach_clip(recorder.close(), clip_incident) + source.close() + self._set(running=False, recording=False) diff --git a/ecowatch/notifier.py b/ecowatch/notifier.py new file mode 100644 index 0000000..13914ca --- /dev/null +++ b/ecowatch/notifier.py @@ -0,0 +1,140 @@ +"""Уведомления в Telegram. Отправка идёт в фоновом потоке и не тормозит видео.""" + +from __future__ import annotations + +import logging +import queue +import threading +from collections.abc import Callable +from datetime import datetime +from html import escape +from typing import Any + +import cv2 +import numpy as np +import requests + +from .hazards import get_hazard + +log = logging.getLogger(__name__) + +API = "https://api.telegram.org/bot{token}/{method}" +CAPTION_LIMIT = 1024 + + +class TelegramNotifier: + def __init__(self, token: str | None, chat_id: str | None, session: Any = None, timeout: float = 15) -> None: + self.token = token + self.chat_id = chat_id + self.session = session or requests.Session() + self.timeout = timeout + + @property + def enabled(self) -> bool: + return bool(self.token and self.chat_id) + + def _call(self, method: str, **kwargs: Any) -> bool: + if not self.enabled: + return False + try: + resp = self.session.post(API.format(token=self.token, method=method), timeout=self.timeout, **kwargs) + ok = bool(resp.json().get("ok")) + if not ok: + log.warning("Telegram %s: %s", method, resp.text[:300]) + return ok + except (requests.RequestException, ValueError) as exc: + log.warning("Telegram %s не отправлен: %s", method, exc) + return False + + def send_message(self, text: str) -> bool: + return self._call("sendMessage", json={"chat_id": self.chat_id, "text": text, "parse_mode": "HTML"}) + + def send_photo(self, image_bgr: np.ndarray, caption: str = "") -> bool: + ok, buf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85]) + if not ok: + return self.send_message(caption) + if len(caption) > CAPTION_LIMIT: + # Подпись к фото ограничена — длинный текст отправляем отдельным сообщением. + return self.send_message(caption) and self.send_photo(image_bgr) + return self._call( + "sendPhoto", + data={"chat_id": self.chat_id, "caption": caption, "parse_mode": "HTML"}, + files={"photo": ("alert.jpg", buf.tobytes(), "image/jpeg")}, + ) + + +class AsyncSender: + """Очередь задач отправки с одним рабочим потоком.""" + + def __init__(self, maxsize: int = 32) -> None: + self._queue: queue.Queue[tuple[Callable[..., bool], tuple[Any, ...]]] = queue.Queue(maxsize) + self.sent = 0 + self.failed = 0 + self._thread = threading.Thread(target=self._run, name="telegram-sender", daemon=True) + self._thread.start() + + def submit(self, fn: Callable[..., bool], *args: Any) -> bool: + try: + self._queue.put_nowait((fn, args)) + return True + except queue.Full: + self.failed += 1 + return False + + def _run(self) -> None: + while True: + fn, args = self._queue.get() + try: + if fn(*args): + self.sent += 1 + else: + self.failed += 1 + except Exception: # noqa: BLE001 — поток отправки не должен падать + log.exception("Ошибка при отправке уведомления") + self.failed += 1 + finally: + self._queue.task_done() + + def join(self) -> None: + self._queue.join() + + +def format_alert(label: str, conf: float, source: str, when: datetime | None = None) -> str: + h = get_hazard(label) + when = when or datetime.now() + return ( + f"🚨 ЭКОЛОГИЧЕСКАЯ УГРОЗА\n\n" + f"{h.icon} {escape(h.title.upper())}\n" + f"{escape(h.description)}\n\n" + f"📊 Вероятность: {conf * 100:.0f}%\n" + f"📹 Источник: {escape(source)}\n" + f"🕐 {when:%d.%m.%Y %H:%M:%S}\n\n" + f"— EcoWatch AI" + ) + + +def format_incident_closed(label: str, peak: float, started: datetime, ended: datetime) -> str: + h = get_hazard(label) + minutes, seconds = divmod(int((ended - started).total_seconds()), 60) + return ( + f"✅ Угроза больше не наблюдается\n\n" + f"{h.icon} {escape(h.title)} · пик {peak * 100:.0f}%\n" + f"⏱ Длительность: {minutes} мин {seconds} с\n" + f"🕐 {ended:%d.%m.%Y %H:%M:%S}" + ) + + +def format_daily_report(summary: dict[str, Any]) -> str: + by_label: dict[str, int] = summary.get("by_label", {}) + lines = [f"📊 ОТЧЁТ ЗА {summary['day']:%d.%m.%Y}", ""] + if by_label: + for label, n in sorted(by_label.items(), key=lambda kv: -kv[1]): + h = get_hazard(label) + lines.append(f"{h.icon} {escape(h.title)}: {n}") + lines += ["", f"📋 Всего инцидентов: {summary['total']}"] + lines.append(f"📈 Максимальная вероятность: {summary['peak_conf'] * 100:.0f}%") + else: + lines.append("Инцидентов не зафиксировано.") + dangerous = sum(n for label, n in by_label.items() if get_hazard(label).danger) + lines += ["", "⚠️ Повышенная активность!" if dangerous > 5 else "✅ Ситуация стабильна", "", "— EcoWatch AI"] + return "\n".join(lines) diff --git a/ecowatch/overlay.py b/ecowatch/overlay.py new file mode 100644 index 0000000..fa6103c --- /dev/null +++ b/ecowatch/overlay.py @@ -0,0 +1,107 @@ +"""Отрисовка рамок и статуса поверх кадра (с поддержкой кириллицы).""" + +from __future__ import annotations + +from datetime import datetime +from functools import lru_cache + +import cv2 +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from .detector import Box +from .hazards import get_hazard +from .tracker import Decision + +_FONT_CANDIDATES = ( + "DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "arial.ttf", + "C:/Windows/Fonts/segoeui.ttf", + "C:/Windows/Fonts/arial.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", +) + + +@lru_cache(maxsize=16) +def _font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: + for name in _FONT_CANDIDATES: + try: + return ImageFont.truetype(name, size) + except OSError: + continue + try: + return ImageFont.load_default(size=size) + except TypeError: # Pillow < 10.1 + return ImageFont.load_default() + + +def _corners(img: np.ndarray, b: Box, color: tuple[int, int, int]) -> None: + length = max(6, min(24, (b.x2 - b.x1) // 4, (b.y2 - b.y1) // 4)) + for (x, y), (dx, dy) in ( + ((b.x1, b.y1), (1, 1)), + ((b.x2, b.y1), (-1, 1)), + ((b.x1, b.y2), (1, -1)), + ((b.x2, b.y2), (-1, -1)), + ): + cv2.line(img, (x, y), (x + dx * length, y), color, 3, cv2.LINE_AA) + cv2.line(img, (x, y), (x, y + dy * length), color, 3, cv2.LINE_AA) + + +def draw_overlay( + frame: np.ndarray, + decision: Decision, + boxes: list[Box] | None = None, + source: str = "", + fps: float | None = None, + recording: bool = False, +) -> np.ndarray: + img = frame.copy() + h, w = img.shape[:2] + scale = max(0.6, min(2.0, w / 960)) + fs = int(15 * scale) + bar_h = int(34 * scale) + hz = get_hazard(decision.label) + texts: list[tuple[tuple[int, int], str, tuple[int, int, int], int]] = [] + + for b in boxes or []: + bh = get_hazard(b.label) + cv2.rectangle(img, (b.x1, b.y1), (b.x2, b.y2), bh.bgr, 1, cv2.LINE_AA) + _corners(img, b, bh.bgr) + text = f"{bh.title} {b.conf * 100:.0f}%" + tw = int(_font(fs).getlength(text)) + ty = max(0, b.y1 - fs - 8) + cv2.rectangle(img, (b.x1, ty), (b.x1 + tw + 10, ty + fs + 8), bh.bgr, -1) + texts.append(((b.x1 + 5, ty + 3), text, (255, 255, 255), fs)) + + # Полупрозрачная верхняя панель статуса + top = img[:bar_h].astype(np.float32) + img[:bar_h] = (top * 0.35).astype(np.uint8) + dot = (60, 60, 235) if decision.active else ((40, 190, 235) if decision.frame_danger else (80, 200, 80)) + cv2.circle(img, (int(16 * scale), bar_h // 2), int(6 * scale), dot, -1, cv2.LINE_AA) + + status = f"{hz.title.upper()} · {decision.confidence * 100:.0f}%" + if not decision.active and decision.frame_danger: + status = "ПРОВЕРКА…" + texts.append(((int(30 * scale), bar_h // 2 - fs // 2 - 2), status, (235, 235, 235), fs)) + right = datetime.now().strftime("%H:%M:%S") + if fps is not None: + right = f"{fps:.1f} FPS · {right}" + if source: + right = f"{source} · {right}" + rw = int(_font(fs - 2).getlength(right)) + texts.append(((w - rw - 12, bar_h // 2 - fs // 2), right, (170, 170, 170), fs - 2)) + + if recording: + cv2.circle(img, (w - 18, bar_h + 18), 7, (40, 40, 230), -1, cv2.LINE_AA) + texts.append(((w - 58, bar_h + 9), "REC", (40, 40, 230), fs - 3)) + + if decision.active and not boxes: + cv2.rectangle(img, (2, bar_h + 2), (w - 3, h - 3), hz.bgr, max(2, int(3 * scale))) + + # Весь текст — за одно преобразование в PIL (раньше было по одному на каждую надпись). + pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) + draw = ImageDraw.Draw(pil) + for pos, text, (b, g, r), size in texts: + draw.text(pos, text, font=_font(size), fill=(r, g, b)) + return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR) diff --git a/ecowatch/recorder.py b/ecowatch/recorder.py new file mode 100644 index 0000000..34a1bda --- /dev/null +++ b/ecowatch/recorder.py @@ -0,0 +1,87 @@ +"""Запись видеоклипов инцидентов с предзаписью (несколько секунд до тревоги).""" + +from __future__ import annotations + +import time +from collections import deque +from datetime import datetime +from pathlib import Path + +import cv2 +import numpy as np + + +class ClipRecorder: + def __init__( + self, + out_dir: str | Path, + fps: float = 10.0, + pre_seconds: float = 3.0, + post_seconds: float = 10.0, + max_seconds: float = 300.0, + ) -> None: + self.out_dir = Path(out_dir) + self.fps = max(1.0, float(fps)) + self.pre_seconds = pre_seconds + self.post_seconds = post_seconds + self.max_seconds = max_seconds + # Буфер с запасом на 30 FPS; при старте записи берём из него последние pre_seconds. + self._buffer: deque[np.ndarray] = deque(maxlen=max(1, int(30 * pre_seconds))) + self._writer: cv2.VideoWriter | None = None + self._size: tuple[int, int] | None = None + self._path: Path | None = None + self._started = 0.0 + self._last_active = 0.0 + + @property + def recording(self) -> bool: + return self._writer is not None + + def set_fps(self, fps: float) -> None: + """FPS клипа = реальная скорость обработки; меняется только между записями.""" + if self._writer is None and fps > 0: + self.fps = max(1.0, min(30.0, float(fps))) + + def _open(self, frame: np.ndarray) -> None: + self.out_dir.mkdir(parents=True, exist_ok=True) + h, w = frame.shape[:2] + self._size = (w, h) + self._path = self.out_dir / f"incident_{datetime.now():%Y%m%d_%H%M%S}.mp4" + self._writer = cv2.VideoWriter(str(self._path), cv2.VideoWriter_fourcc(*"mp4v"), self.fps, self._size) + if not self._writer.isOpened(): + self._writer = None + raise RuntimeError(f"Не удалось открыть файл записи {self._path}") + + def _write(self, frame: np.ndarray) -> None: + assert self._writer is not None and self._size is not None + if (frame.shape[1], frame.shape[0]) != self._size: + frame = cv2.resize(frame, self._size) + self._writer.write(frame) + + def push(self, frame: np.ndarray, active: bool, now: float | None = None) -> Path | None: + """Добавляет кадр. Возвращает путь к клипу, когда тот закончен.""" + now = time.monotonic() if now is None else now + if active: + self._last_active = now + if self._writer is None: + self._open(frame) + self._started = now + keep = int(self.fps * self.pre_seconds) + for old in list(self._buffer)[-keep:] if keep > 0 else []: + self._write(old) + self._buffer.clear() + if self._writer is None: + self._buffer.append(frame) + return None + self._write(frame) + if now - self._last_active > self.post_seconds or now - self._started > self.max_seconds: + return self.close() + return None + + def close(self) -> Path | None: + if self._writer is None: + return None + self._writer.release() + self._writer = None + path, self._path = self._path, None + return path diff --git a/ecowatch/sources.py b/ecowatch/sources.py new file mode 100644 index 0000000..e999054 --- /dev/null +++ b/ecowatch/sources.py @@ -0,0 +1,211 @@ +"""Источники кадров: веб-камера, IP-камера (RTSP/HTTP), видеофайл, экран.""" + +from __future__ import annotations + +import threading +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + + +class FrameSource(ABC): + live: bool = True + fps: float = 0.0 + + @abstractmethod + def open(self) -> None: ... + + @abstractmethod + def read(self, timeout: float = 5.0) -> np.ndarray | None: + """Возвращает следующий кадр (BGR) или None, если кадров больше нет/нет связи.""" + + @abstractmethod + def close(self) -> None: ... + + def __enter__(self) -> FrameSource: + self.open() + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +class VideoFileSource(FrameSource): + """Видеофайл читается последовательно — каждый кадр будет проанализирован.""" + + live = False + + def __init__(self, path: str, frame_step: int = 1) -> None: + self.path = path + self.frame_step = max(1, frame_step) + self._cap: cv2.VideoCapture | None = None + + def open(self) -> None: + self._cap = cv2.VideoCapture(self.path) + if not self._cap.isOpened(): + raise RuntimeError(f"Не удалось открыть видео: {self.path}") + self.fps = (self._cap.get(cv2.CAP_PROP_FPS) or 25.0) / self.frame_step + + def read(self, timeout: float = 5.0) -> np.ndarray | None: + assert self._cap is not None + for _ in range(self.frame_step - 1): + if not self._cap.grab(): + return None + ok, frame = self._cap.read() + return frame if ok else None + + def close(self) -> None: + if self._cap is not None: + self._cap.release() + self._cap = None + + +class StreamSource(FrameSource): + """Камера/поток. Кадры читаются фоновым потоком, отдаётся всегда самый свежий — + поэтому медленная модель не создаёт растущую задержку.""" + + live = True + + def __init__(self, target: int | str, reconnect: bool = True) -> None: + self.target = target + self.reconnect = reconnect + self._cap: cv2.VideoCapture | None = None + self._frame: np.ndarray | None = None + self._seq = 0 + self._read_seq = 0 + self._cond = threading.Condition() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self.error: str | None = None + + def _connect(self) -> bool: + cap = cv2.VideoCapture(self.target) + if not cap.isOpened(): + cap.release() + return False + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + self._cap = cap + self.fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 + return True + + def open(self) -> None: + if not self._connect(): + raise RuntimeError(f"Не удалось подключиться к источнику: {self.target}") + self._stop.clear() + self._thread = threading.Thread(target=self._grab_loop, name="frame-grabber", daemon=True) + self._thread.start() + + def _grab_loop(self) -> None: + backoff = 1.0 + while not self._stop.is_set(): + cap = self._cap + ok, frame = cap.read() if cap is not None else (False, None) + if ok: + backoff = 1.0 + with self._cond: + self._frame = frame + self._seq += 1 + self._cond.notify_all() + continue + if not self.reconnect: + break + self.error = "Потеряна связь с камерой, переподключение…" + if cap is not None: + cap.release() + self._cap = None + if self._stop.wait(backoff): + break + backoff = min(backoff * 2, 30.0) + if self._connect(): + self.error = None + with self._cond: + self._cond.notify_all() + + def read(self, timeout: float = 5.0) -> np.ndarray | None: + deadline = time.monotonic() + timeout + with self._cond: + while self._seq == self._read_seq and not self._stop.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0 or (self._thread is not None and not self._thread.is_alive()): + return None + self._cond.wait(remaining) + if self._seq == self._read_seq: + return None + self._read_seq = self._seq + return self._frame + + def close(self) -> None: + self._stop.set() + with self._cond: + self._cond.notify_all() + if self._thread is not None: + self._thread.join(timeout=3) + if self._cap is not None: + self._cap.release() + self._cap = None + + +class ScreenSource(FrameSource): + live = True + + def __init__(self, monitor: int = 1, max_fps: float = 10.0) -> None: + self.monitor = monitor + self.fps = max_fps + self._sct = None + self._last = 0.0 + + def open(self) -> None: + from mss import mss + + self._sct = mss() + if self.monitor >= len(self._sct.monitors): + raise RuntimeError(f"Монитор {self.monitor} не найден") + + def read(self, timeout: float = 5.0) -> np.ndarray | None: + assert self._sct is not None + wait = 1.0 / self.fps - (time.monotonic() - self._last) + if wait > 0: + time.sleep(wait) + self._last = time.monotonic() + img = np.asarray(self._sct.grab(self._sct.monitors[self.monitor])) + return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + + def close(self) -> None: + if self._sct is not None: + self._sct.close() + self._sct = None + + +@dataclass +class SourceSpec: + kind: str # "webcam" | "stream" | "file" | "screen" + value: str | int + label: str + + def build(self) -> FrameSource: + if self.kind == "webcam": + return StreamSource(int(self.value)) + if self.kind == "stream": + return StreamSource(str(self.value)) + if self.kind == "file": + return VideoFileSource(str(self.value)) + if self.kind == "screen": + return ScreenSource(int(self.value)) + raise ValueError(f"Неизвестный тип источника: {self.kind}") + + +def parse_source(value: str) -> SourceSpec: + """Для CLI: '0' → веб-камера, 'rtsp://…' → поток, 'screen' / 'screen:2' → экран, иначе — файл.""" + v = value.strip() + if v.isdigit(): + return SourceSpec("webcam", int(v), f"Камера {v}") + if v.startswith("screen"): + idx = int(v.split(":", 1)[1]) if ":" in v else 1 + return SourceSpec("screen", idx, f"Экран {idx}") + if "://" in v: + return SourceSpec("stream", v, v.split("@")[-1]) + return SourceSpec("file", v, Path(v).name) diff --git a/ecowatch/storage.py b/ecowatch/storage.py new file mode 100644 index 0000000..efc4830 --- /dev/null +++ b/ecowatch/storage.py @@ -0,0 +1,136 @@ +"""Журнал инцидентов в SQLite (вместо перезаписываемого JSON-файла).""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from collections import Counter +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from .hazards import normalize + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + ended_at TEXT, + label TEXT NOT NULL, + peak_conf REAL NOT NULL, + source TEXT NOT NULL DEFAULT '', + snapshot TEXT, + clip TEXT, + lat REAL, + lon REAL +); +CREATE INDEX IF NOT EXISTS idx_incidents_started ON incidents(started_at); +""" + +COLUMNS = ("id", "started_at", "ended_at", "label", "peak_conf", "source", "snapshot", "clip", "lat", "lon") + + +def _ts(dt: datetime | None) -> str | None: + return dt.isoformat(timespec="seconds") if dt else None + + +class EventStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(self.path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + with self._lock, self._conn: + self._conn.executescript(_SCHEMA) + + def close(self) -> None: + with self._lock: + self._conn.close() + + def open_incident( + self, + label: str, + conf: float, + source: str, + started_at: datetime | None = None, + snapshot: str | None = None, + lat: float | None = None, + lon: float | None = None, + ) -> int: + with self._lock, self._conn: + cur = self._conn.execute( + "INSERT INTO incidents (started_at, label, peak_conf, source, snapshot, lat, lon)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + (_ts(started_at or datetime.now()), label, float(conf), source, snapshot, lat, lon), + ) + return int(cur.lastrowid) + + def update_incident(self, incident_id: int, **fields: Any) -> None: + allowed = {"ended_at", "label", "peak_conf", "snapshot", "clip"} + unknown = set(fields) - allowed + if unknown: + raise ValueError(f"Нельзя обновить поля: {unknown}") + if not fields: + return + if isinstance(fields.get("ended_at"), datetime): + fields["ended_at"] = _ts(fields["ended_at"]) + assignments = ", ".join(f"{k} = ?" for k in fields) + with self._lock, self._conn: + self._conn.execute(f"UPDATE incidents SET {assignments} WHERE id = ?", (*fields.values(), incident_id)) + + def list(self, since: datetime | None = None, limit: int | None = None) -> list[dict[str, Any]]: + sql = "SELECT * FROM incidents" + args: list[Any] = [] + if since: + sql += " WHERE started_at >= ?" + args.append(_ts(since)) + sql += " ORDER BY started_at DESC, id DESC" + if limit: + sql += " LIMIT ?" + args.append(limit) + with self._lock: + return [dict(r) for r in self._conn.execute(sql, args).fetchall()] + + def count(self) -> int: + with self._lock: + return int(self._conn.execute("SELECT COUNT(*) FROM incidents").fetchone()[0]) + + def day_summary(self, day: date | None = None) -> dict[str, Any]: + day = day or date.today() + prefix = day.isoformat() + with self._lock: + rows = self._conn.execute( + "SELECT label, peak_conf FROM incidents WHERE substr(started_at, 1, 10) = ?", (prefix,) + ).fetchall() + counts = Counter(r["label"] for r in rows) + peak = max((r["peak_conf"] for r in rows), default=0.0) + return {"day": day, "total": len(rows), "by_label": dict(counts), "peak_conf": peak} + + def clear(self) -> None: + with self._lock, self._conn: + self._conn.execute("DELETE FROM incidents") + + def import_legacy_json(self, path: str | Path) -> int: + """Переносит detection_history.json из старой версии. Возвращает число записей.""" + path = Path(path) + if not path.is_file(): + return 0 + try: + entries = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return 0 + rows = [] + for e in entries if isinstance(entries, list) else []: + try: + started = datetime.strptime(str(e["time"]), "%Y-%m-%d %H:%M:%S") + except (KeyError, ValueError): + continue + conf = float(e.get("confidence", 0)) / 100.0 # в старом формате — проценты + rows.append((_ts(started), normalize(e.get("label", "normal")), conf, str(e.get("source", "")))) + with self._lock, self._conn: + self._conn.executemany( + "INSERT INTO incidents (started_at, label, peak_conf, source) VALUES (?, ?, ?, ?)", rows + ) + return len(rows) diff --git a/ecowatch/tracker.py b/ecowatch/tracker.py new file mode 100644 index 0000000..d47c107 --- /dev/null +++ b/ecowatch/tracker.py @@ -0,0 +1,104 @@ +"""Временное сглаживание и гистерезис: превращает поток кадров в инциденты. + +Вероятности сглаживаются экспоненциальным средним, а инцидент начинается +только после `confirm_frames` подряд опасных кадров и завершается после +`clear_frames` подряд чистых. Это убирает «мигание» тревоги и ложные +срабатывания на единичных кадрах. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .config import DetectionParams +from .detector import FrameResult +from .hazards import get_hazard + + +@dataclass +class Decision: + label: str # активный класс угрозы или "normal" + confidence: float # сглаженная вероятность label + active: bool # идёт ли сейчас инцидент + frame_danger: bool # опасен ли текущий кадр (до гистерезиса) + scores: dict[str, float] = field(default_factory=dict) + onset: bool = False # инцидент начался на этом кадре + cleared: bool = False # инцидент завершился на этом кадре + + @property + def ambiguous(self) -> bool: + ranked = sorted(self.scores.values(), reverse=True) + return len(ranked) >= 2 and ranked[0] - ranked[1] < 0.12 + + +def classify_frame(scores: dict[str, float], params: DetectionParams) -> tuple[str, float, bool]: + """Решение по одному набору вероятностей: (кандидат, вероятность, опасно ли).""" + if not scores: + return "normal", 0.0, False + ranked = sorted(scores.items(), key=lambda kv: (kv[1], get_hazard(kv[0]).severity), reverse=True) + top_label, top_conf = ranked[0] + second = ranked[1][1] if len(ranked) > 1 else 0.0 + danger = get_hazard(top_label).danger and top_conf >= params.conf_threshold and top_conf - second >= params.margin + return top_label, top_conf, danger + + +class ThreatTracker: + def __init__(self, params: DetectionParams | None = None) -> None: + self.params = params or DetectionParams() + self.reset() + + def reset(self) -> None: + self._ema: dict[str, float] = {} + self._danger_streak = 0 + self._clear_streak = 0 + self.active = False + self.label = "normal" + + def _smooth(self, scores: dict[str, float]) -> dict[str, float]: + a = self.params.ema_alpha + if not self._ema: + self._ema = dict(scores) + else: + for k in set(self._ema) | set(scores): + self._ema[k] = a * scores.get(k, 0.0) + (1 - a) * self._ema.get(k, 0.0) + return dict(self._ema) + + def update(self, result: FrameResult) -> Decision: + p = self.params + smoothed = self._smooth(result.scores) + candidate, cand_conf, frame_danger = classify_frame(smoothed, p) + onset = cleared = False + + if frame_danger: + self._danger_streak += 1 + self._clear_streak = 0 + else: + self._clear_streak += 1 + self._danger_streak = 0 + + if not self.active: + if self._danger_streak >= p.confirm_frames: + self.active, onset, self.label = True, True, candidate + else: + if frame_danger and get_hazard(candidate).severity >= get_hazard(self.label).severity: + self.label = candidate # эскалация: дым → пожар + if self._clear_streak >= p.clear_frames: + self.active, cleared = False, True + + if self.active: + label, conf = self.label, smoothed.get(self.label, 0.0) + else: + # Пока угроза не подтверждена, статус — «норма» (frame_danger покажет, что идёт проверка). + label = "normal" + conf = smoothed.get("normal", 1.0 - cand_conf if get_hazard(candidate).danger else cand_conf) + return Decision(label, conf, self.active, frame_danger, smoothed, onset, cleared) + + +def evaluate_single(result: FrameResult, params: DetectionParams | None = None) -> Decision: + """Решение для отдельного изображения — без сглаживания и гистерезиса.""" + params = params or DetectionParams() + candidate, conf, danger = classify_frame(result.scores, params) + label = candidate if danger else "normal" + if not danger: + conf = result.scores.get("normal", conf) + return Decision(label, conf, danger, danger, dict(result.scores), onset=danger) diff --git a/ecowatch/ui.py b/ecowatch/ui.py new file mode 100644 index 0000000..aeaaa73 --- /dev/null +++ b/ecowatch/ui.py @@ -0,0 +1,495 @@ +"""Веб-интерфейс EcoWatch AI на Streamlit.""" + +from __future__ import annotations + +import threading +from datetime import datetime, timedelta +from pathlib import Path + +import cv2 +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import streamlit as st + +from .config import ROOT_DIR, DetectionParams, Settings +from .detector import Detector +from .hazards import HAZARDS, get_hazard +from .monitor import Monitor, MonitorOptions +from .notifier import AsyncSender, TelegramNotifier, format_daily_report +from .overlay import draw_overlay +from .sources import SourceSpec +from .storage import EventStore +from .tracker import Decision, evaluate_single + +CSS = """ + +""" + +SOURCES = {"webcam": "Веб-камера", "stream": "IP-камера (RTSP/HTTP)", "file": "Видеофайл", "screen": "Захват экрана"} + + +# ---------- общие ресурсы (одни на весь сервер) ---------- +@st.cache_resource +def get_settings() -> Settings: + s = Settings.from_env() + s.ensure_dirs() + return s + + +@st.cache_resource(show_spinner="Загрузка модели…") +def get_detector(path: str, device: str | None) -> Detector: + return Detector(path, device) + + +@st.cache_resource +def get_store(path: str) -> EventStore: + store = EventStore(path) + legacy = ROOT_DIR / "detection_history.json" + if store.count() == 0 and legacy.is_file(): + store.import_legacy_json(legacy) + return store + + +@st.cache_resource +def get_sender() -> AsyncSender: + return AsyncSender() + + +class MonitorHolder: + """Один активный мониторинг на сервер — переживает перезагрузку страницы.""" + + def __init__(self) -> None: + self.monitor: Monitor | None = None + self.lock = threading.Lock() + + +@st.cache_resource +def get_holder() -> MonitorHolder: + return MonitorHolder() + + +# ---------- вспомогательное ---------- +def scores_html(scores: dict[str, float]) -> str: + rows = [] + for name, p in sorted(scores.items(), key=lambda kv: -kv[1]): + h = get_hazard(name) + rows.append( + f"
" + f"{h.icon} {h.title}{p * 100:.1f}%
" + f"
" + ) + return "".join(rows) + + +def status_card(d: Decision | None, subtitle: str = "") -> str: + if d is None: + return ( + "
Система готова
" + "
Выберите источник и запустите мониторинг
" + ) + h = get_hazard(d.label) + if d.active: + cls, title, desc = "ew-danger", f"{h.icon} {h.title.upper()}", h.description + elif d.frame_danger: + cls, title, desc = "ew-warn", "⏳ Проверка…", "Возможная угроза, ждём подтверждения на нескольких кадрах" + else: + cls, title, desc = "ew-safe", "✅ Угроз не обнаружено", "" + note = "
Результат неоднозначен
" if d.ambiguous else "" + return ( + f"
{title}
" + f"
{desc}
{scores_html(d.scores)}
" + f"{note}
{subtitle}
" + ) + + +def incidents_df(store: EventStore) -> pd.DataFrame: + df = pd.DataFrame(store.list()) + if df.empty: + return df + df["started_at"] = pd.to_datetime(df["started_at"]) + df["ended_at"] = pd.to_datetime(df["ended_at"]) + df["Тип"] = df["label"].map(lambda k: f"{get_hazard(k).icon} {get_hazard(k).title}") + df["Вероятность, %"] = (df["peak_conf"] * 100).round(1) + df["Длительность, с"] = (df["ended_at"] - df["started_at"]).dt.total_seconds() + return df + + +def existing_file(value: object) -> Path | None: + """Путь из журнала, если файл ещё на диске (пустые значения pandas приходят как NaN).""" + if isinstance(value, str) and value and Path(value).is_file(): + return Path(value) + return None + + +def empty_state(icon: str, text: str) -> None: + st.markdown( + f"
" + f"
{icon}
{text}
", + unsafe_allow_html=True, + ) + + +# ---------- сайдбар ---------- +def sidebar( + settings: Settings, detector: Detector, holder: MonitorHolder, store: EventStore +) -> tuple[MonitorOptions | None, DetectionParams]: + """Рисует настройки. Возвращает (опции запуска, если нажали «Запустить»; текущие пороги).""" + sb = st.sidebar + logo = ROOT_DIR / "logo" / "logo_small.png" + if logo.is_file(): + sb.image(str(logo), width=130) + sb.markdown("### 🛡️ EcoWatch AI") + sb.caption(f"Модель: `{detector.path.name}` · {detector.task} · классы: {', '.join(detector.names)}") + + running = holder.monitor is not None and holder.monitor.alive + sb.subheader("Источник") + kind = sb.selectbox("Тип", list(SOURCES), format_func=SOURCES.get, disabled=running) + spec: SourceSpec | None = None + if kind == "webcam": + cam = sb.number_input("Номер камеры", 0, 10, 0, disabled=running) + spec = SourceSpec("webcam", int(cam), f"Камера {cam}") + elif kind == "stream": + url = sb.text_input("URL потока", placeholder="rtsp://user:pass@192.168.1.10:554/stream", disabled=running) + if url.strip(): + spec = SourceSpec("stream", url.strip(), url.split("@")[-1][:40]) + elif kind == "file": + up = sb.file_uploader("Видео", type=["mp4", "avi", "mov", "mkv"], disabled=running) + if up is not None: + path = settings.data_dir / "uploads" / up.name + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists() or path.stat().st_size != up.size: + path.write_bytes(up.getbuffer()) + spec = SourceSpec("file", str(path), up.name) + else: + mon = sb.number_input("Монитор", 1, 8, 1, disabled=running) + spec = SourceSpec("screen", int(mon), f"Экран {mon}") + + with sb.expander("Чувствительность", expanded=False): + p = DetectionParams() + params = DetectionParams( + conf_threshold=st.slider( + "Порог вероятности", + 0.3, + 0.99, + p.conf_threshold, + 0.01, + help="Минимальная сглаженная вероятность опасного класса", + ), + margin=st.slider("Отрыв от 2-го класса", 0.0, 0.6, p.margin, 0.01, help="Защита от неоднозначных кадров"), + box_conf=st.slider("Порог рамки (detect)", 0.1, 0.9, p.box_conf, 0.05), + ema_alpha=st.slider("Сглаживание (вес нового кадра)", 0.05, 1.0, p.ema_alpha, 0.05), + confirm_frames=st.slider("Кадров для подтверждения", 1, 30, p.confirm_frames), + clear_frames=st.slider("Кадров для отбоя", 1, 120, p.clear_frames), + ) + if running: + holder.monitor.update_params(params) + + with sb.expander("Telegram", expanded=False): + if settings.telegram_configured: + st.success("Бот настроен") + else: + st.warning("Не настроен. Укажите ECOWATCH_TG_TOKEN и ECOWATCH_TG_CHAT_ID в файле .env") + notify = st.checkbox("Отправлять тревоги", True, disabled=not settings.telegram_configured) + notify_photo = st.checkbox("Прикладывать снимок", True) + notify_on_clear = st.checkbox("Сообщать об окончании угрозы", False) + cooldown = st.slider("Пауза между тревогами, с", 0, 600, 60, 10) + c1, c2 = st.columns(2) + tg = TelegramNotifier(settings.telegram_token, settings.telegram_chat_id) + if c1.button("Тест", disabled=not settings.telegram_configured, width="stretch"): + ok = tg.send_message(f"✅ EcoWatch AI на связи\n{datetime.now():%d.%m.%Y %H:%M:%S}") + (st.success if ok else st.error)("Отправлено" if ok else "Ошибка отправки") + if c2.button("Отчёт", disabled=not settings.telegram_configured, width="stretch"): + ok = tg.send_message(format_daily_report(store.day_summary())) + (st.success if ok else st.error)("Отправлен" if ok else "Ошибка отправки") + + with sb.expander("Запись и карта", expanded=False): + record = st.checkbox("Записывать клипы инцидентов", False) + pre = st.slider("Секунд до тревоги", 0, 10, 3, disabled=not record) + post = st.slider("Секунд после окончания", 2, 60, 10, disabled=not record) + lat = st.number_input("Широта камеры", value=settings.camera_lat or 0.0, format="%.5f") + lon = st.number_input("Долгота камеры", value=settings.camera_lon or 0.0, format="%.5f") + has_geo = bool(lat or lon) + + sb.divider() + if running: + if sb.button("⏹ Остановить", type="primary", width="stretch"): + holder.monitor.stop() + st.rerun() + return None, params + if sb.button("▶ Запустить мониторинг", type="primary", width="stretch", disabled=spec is None): + return MonitorOptions( + source=spec, # type: ignore[arg-type] + params=params, + notify=notify and settings.telegram_configured, + notify_photo=notify_photo, + notify_on_clear=notify_on_clear, + notify_cooldown=float(cooldown), + record=record, + record_pre=float(pre), + record_post=float(post), + lat=lat if has_geo else None, + lon=lon if has_geo else None, + ), params + return None, params + + +# ---------- вкладки ---------- +@st.fragment(run_every=0.25) +def live_view(holder: MonitorHolder) -> None: + mon = holder.monitor + col_v, col_s = st.columns([3, 1.2]) + if mon is None: + col_v.markdown("
📹  Мониторинг не запущен
", unsafe_allow_html=True) + col_s.markdown(status_card(None), unsafe_allow_html=True) + return + s = mon.state() + if s.preview is not None: + col_v.image(s.preview, width="stretch") + else: + col_v.markdown("
⏳ Подключение к источнику…
", unsafe_allow_html=True) + if s.error: + col_v.error(s.error) + if s.finished: + col_v.info("Видео обработано до конца.") + elif not s.running: + col_v.info("Мониторинг остановлен.") + + col_s.markdown(status_card(s.decision, f"Источник: {s.source}"), unsafe_allow_html=True) + m1, m2 = col_s.columns(2) + m1.metric("FPS", f"{s.fps:.1f}") + m2.metric("Кадров", s.frames) + m3, m4 = col_s.columns(2) + m3.metric("Инцидентов", s.incidents) + elapsed = (datetime.now() - s.started_at) if s.started_at else timedelta() + m4.metric("Время", str(elapsed).split(".")[0]) + if s.recording: + col_s.markdown("🔴 **Идёт запись клипа**") + + +def tab_image(detector: Detector, params: DetectionParams) -> None: + st.caption("Проверка отдельного снимка — без сглаживания по времени.") + up = st.file_uploader("Изображение", type=["jpg", "jpeg", "png", "webp", "bmp"], key="img_upload") + if up is None: + empty_state("🖼️", "Загрузите изображение для анализа") + return + frame = cv2.imdecode(np.frombuffer(up.getvalue(), np.uint8), cv2.IMREAD_COLOR) + if frame is None: + st.error("Не удалось прочитать изображение") + return + result = detector.predict(frame, params.box_conf) + d = evaluate_single(result, params) + c1, c2 = st.columns([3, 1.2]) + c1.image(draw_overlay(frame, d, result.boxes, up.name), channels="BGR", width="stretch") + c2.markdown(status_card(d), unsafe_allow_html=True) + + +def tab_analytics(df: pd.DataFrame) -> None: + if df.empty: + empty_state("📊", "Аналитика появится после первых инцидентов") + return + period = st.radio("Период", ["24 часа", "7 дней", "30 дней", "Всё время"], index=1, horizontal=True) + days = {"24 часа": 1, "7 дней": 7, "30 дней": 30}.get(period) + if days: + df = df[df["started_at"] >= datetime.now() - timedelta(days=days)] + if df.empty: + empty_state("📊", "За выбранный период инцидентов нет") + return + + cols = st.columns(4) + cols[0].metric("Инцидентов", len(df)) + cols[1].metric("🔥 Пожаров", int((df["label"] == "fire").sum())) + cols[2].metric("💨 Дыма", int((df["label"] == "smoke").sum())) + cols[3].metric("Ср. длительность", f"{df['Длительность, с'].mean():.0f} с" if df["ended_at"].notna().any() else "—") + + colors = {f"{h.icon} {h.title}": h.color for h in HAZARDS.values()} + layout = dict(margin=dict(t=40, b=10, l=10, r=10), legend=dict(orientation="h", y=-0.2)) + + c1, c2 = st.columns(2) + bucket = df["started_at"].dt.floor("h" if days == 1 else "D") + by_time = df.assign(t=bucket).groupby(["t", "Тип"]).size().reset_index(name="n") + by_time["t"] = by_time["t"].dt.strftime("%d.%m %H:00" if days == 1 else "%d.%m.%Y") + fig = px.bar( + by_time, + x="t", + y="n", + color="Тип", + color_discrete_map=colors, + labels={"t": "", "n": ""}, + title="Инциденты по времени", + ) + c1.plotly_chart(fig.update_layout(**layout), width="stretch") + + by_type = df["Тип"].value_counts().reset_index() + by_type.columns = ["Тип", "n"] + fig = px.pie( + by_type, values="n", names="Тип", hole=0.5, color="Тип", color_discrete_map=colors, title="Распределение" + ) + c2.plotly_chart(fig.update_layout(**layout), width="stretch") + + c3, c4 = st.columns(2) + fig = px.histogram( + df, + x="Вероятность, %", + color="Тип", + nbins=20, + color_discrete_map=colors, + title="Пиковая вероятность", + labels={"count": ""}, + ) + c3.plotly_chart(fig.update_layout(**layout, bargap=0.05), width="stretch") + + days_ru = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] + heat = np.zeros((7, 24), dtype=int) + for ts in df["started_at"]: + heat[ts.weekday(), ts.hour] += 1 + fig = go.Figure( + go.Heatmap( + z=heat, + x=[f"{h}:00" for h in range(24)], + y=days_ru, + colorscale="YlOrRd", + hovertemplate="%{y} %{x}
Инцидентов: %{z}", + ) + ) + c4.plotly_chart( + fig.update_layout( + title="Активность по часам", margin=dict(t=40, b=10, l=10, r=10), yaxis=dict(autorange="reversed") + ), + width="stretch", + ) + + +def tab_map(df: pd.DataFrame) -> None: + geo = df.dropna(subset=["lat", "lon"]) if not df.empty else df + if geo.empty: + empty_state( + "🗺️", "Нет инцидентов с координатами. Укажите широту и долготу камеры в настройках «Запись и карта»." + ) + return + points = geo.groupby(["lat", "lon"]).agg(n=("id", "count"), last=("started_at", "max")).reset_index() + fig = px.scatter_map( + points, + lat="lat", + lon="lon", + size="n", + hover_data={"n": True, "last": True}, + zoom=11, + height=520, + color_discrete_sequence=["#ef4444"], + ) + st.plotly_chart(fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)), width="stretch") + st.caption(f"Камер: {len(points)} · инцидентов: {int(points['n'].sum())}") + + +def tab_journal(df: pd.DataFrame, store: EventStore) -> None: + if df.empty: + empty_state("📋", "Журнал пуст") + return + c1, c2 = st.columns([2, 1]) + types = c1.multiselect("Тип", sorted(df["Тип"].unique()), default=sorted(df["Тип"].unique())) + min_conf = c2.slider("Мин. вероятность, %", 0, 100, 0) + view = df[df["Тип"].isin(types) & (df["Вероятность, %"] >= min_conf)] + table = view[["id", "started_at", "ended_at", "Тип", "Вероятность, %", "Длительность, с", "source"]].rename( + columns={"id": "№", "started_at": "Начало", "ended_at": "Конец", "source": "Источник"} + ) + event = st.dataframe( + table, width="stretch", height=360, hide_index=True, on_select="rerun", selection_mode="single-row" + ) + + rows = event.selection.rows if event and event.selection else [] + if rows: + rec = view.iloc[rows[0]] + st.markdown(f"#### Инцидент №{rec['id']} · {rec['Тип']}") + a, b = st.columns(2) + snapshot, clip = existing_file(rec["snapshot"]), existing_file(rec["clip"]) + if snapshot: + a.image(str(snapshot), caption="Снимок в момент тревоги", width="stretch") + else: + a.caption("Снимок недоступен") + if clip: + b.video(str(clip)) + b.download_button("Скачать клип", clip.read_bytes(), clip.name) + else: + b.caption("Клип не записывался") + else: + st.caption("Выберите строку, чтобы посмотреть снимок и клип.") + + c1, c2, c3 = st.columns(3) + c1.download_button( + "⬇ CSV", + table.to_csv(index=False).encode("utf-8-sig"), + f"ecowatch_{datetime.now():%Y%m%d}.csv", + "text/csv", + width="stretch", + ) + c2.download_button( + "⬇ Отчёт за сегодня", + format_daily_report(store.day_summary()) + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + .encode("utf-8"), + f"report_{datetime.now():%Y%m%d}.txt", + width="stretch", + ) + with c3.popover("🗑 Очистить журнал", width="stretch"): + st.write("Удалить все записи? Снимки и клипы на диске останутся.") + if st.button("Да, удалить", type="primary"): + store.clear() + st.rerun() + + +# ---------- точка входа ---------- +def main() -> None: + st.set_page_config(page_title="EcoWatch AI", page_icon="🛡️", layout="wide") + st.markdown(CSS, unsafe_allow_html=True) + + settings = get_settings() + try: + detector = get_detector(str(settings.model_path), settings.device) + except Exception as exc: # noqa: BLE001 + st.error(f"Не удалось загрузить модель: {exc}") + st.stop() + store = get_store(str(settings.db_path)) + holder = get_holder() + + options, params = sidebar(settings, detector, holder, store) + if options is not None: + with holder.lock: + if holder.monitor is None or not holder.monitor.alive: + notifier = TelegramNotifier(settings.telegram_token, settings.telegram_chat_id) + holder.monitor = Monitor(detector, store, settings, options, notifier, get_sender()) + holder.monitor.start() + st.rerun() + + st.title("🛡️ EcoWatch AI") + st.caption("Обнаружение пожаров и дыма в реальном времени") + + t_live, t_img, t_an, t_map, t_log = st.tabs(["Мониторинг", "Анализ снимка", "Аналитика", "Карта", "Журнал"]) + with t_live: + live_view(holder) + with t_img: + tab_image(detector, params) + df = incidents_df(store) + with t_an: + tab_analytics(df) + with t_map: + tab_map(df) + with t_log: + tab_journal(df, store) diff --git a/logo/logo_small.png b/logo/logo_small.png new file mode 100644 index 0000000000000000000000000000000000000000..2918ba203306dd65454d8d1d936ba84de0f44aef GIT binary patch literal 74622 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 -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); -:root{ - --bg:#0f1117; --surface:#1a1d26; --card:#21242f; - --border:#2a2d3a; --text:#e2e4eb; --muted:#8b8fa3; --dim:#5c5f73; - --green:#22c55e; --red:#ef4444; --amber:#eab308; --blue:#3b82f6; --violet:#a78bfa; -} -*{font-family:'Inter',system-ui,sans-serif!important} -.stApp{background:var(--bg)} - -[data-testid="stSidebar"]{background:var(--surface);border-right:1px solid var(--border)} -[data-testid="stSidebar"] *{font-size:.84rem} - -h1{color:var(--text)!important;font-weight:700!important;font-size:1.5rem!important;letter-spacing:-.3px} -h2,h3,h4{color:var(--text)!important;font-weight:600!important} -.section-label{color:var(--dim);font-size:.65rem;font-weight:600;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:4px} - -.stTabs [data-baseweb="tab-list"]{gap:2px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:3px} -.stTabs [data-baseweb="tab"]{border-radius:6px;color:var(--muted);font-weight:600;font-size:.82rem;padding:7px 16px} -.stTabs [aria-selected="true"]{background:var(--card)!important;color:var(--text)!important;border-bottom:none!important} - -.stButton>button{background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:7px 16px;font-weight:600;font-size:.8rem;transition:.15s} -.stButton>button:hover{border-color:var(--green);color:var(--green)} -.stDownloadButton>button{background:var(--card)!important;color:var(--text)!important;border:1px solid var(--border)!important;border-radius:6px!important;font-weight:600!important} - -[data-testid="stMetric"]{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 14px} -[data-testid="stMetricValue"]{color:var(--text)!important;font-weight:700!important;font-size:1.4rem!important} -[data-testid="stMetricLabel"]{color:var(--muted)!important;font-weight:500!important;font-size:.72rem!important} - -.panel{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;margin:4px 0} - -.alert-danger{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-left:3px solid var(--red);color:var(--text);padding:14px 16px;border-radius:8px} -.alert-safe{background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.12);border-left:3px solid var(--green);color:var(--text);padding:14px 16px;border-radius:8px} - -.video-idle{background:var(--surface);border:1px dashed var(--border);border-radius:8px;height:380px;display:flex;align-items:center;justify-content:center} - -.sg{display:grid;grid-template-columns:1fr 1fr;gap:8px} -.sg-item{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:10px;text-align:center} -.sg-val{font-size:1.3rem;font-weight:700;color:var(--text);line-height:1.1} -.sg-lbl{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.8px;margin-top:2px} - -.sb-stats{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px;margin-top:10px;font-size:.76rem;color:var(--muted);line-height:1.7} - -.app-footer{text-align:center;color:var(--dim);font-size:.7rem;padding:24px 0 8px;border-top:1px solid var(--border);margin-top:40px} - -#MainMenu,footer,header{visibility:hidden} - -""", unsafe_allow_html=True) - - -@st.cache_resource -def load_model(): - paths = [ - (os.path.join(SCRIPT_DIR, "models", "best.pt"), "classify"), - (os.path.join(SCRIPT_DIR, "shop project", "runs", "detect", "train", "weights", "best.pt"), "detect"), - (os.path.join(SCRIPT_DIR, "shop project", "runs", "classify", "train_v2", "weights", "best.pt"), "classify"), - (os.path.join(SCRIPT_DIR, "shop project", "runs", "classify", "train", "weights", "best.pt"), "classify"), - ] - for path, mtype in paths: - if os.path.exists(path): - return YOLO(path), mtype - st.error("Модель не найдена. Поместите best.pt в папку models/") - return None, None - - -model = None -MODEL_TYPE = None -try: - model, MODEL_TYPE = load_model() - if MODEL_TYPE == "classify": - st.sidebar.warning("⚠️ Используется classify-модель. Для точного обнаружения обучите detect-модель (train.py)") -except Exception as e: - st.error(f"Ошибка модели: {e}") - -BOT_TOKEN = "8084419721:AAFRMEcaoORZwbCwLpnEKmmmtVK_bG-Xunw" -CHAT_ID = "1719237834" - - -def send_telegram_alert(message, image_array=None): - import requests, io - try: - r = requests.post( - f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", - json={"chat_id": CHAT_ID, "text": message, "parse_mode": "HTML"}, - timeout=10, - ) - if not r.json().get("ok"): - return False - if image_array is not None: - ok, buf = cv2.imencode(".jpg", image_array) - if ok: - requests.post( - f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto", - data={"chat_id": CHAT_ID, "caption": f"Снимок {datetime.now():%d.%m.%Y %H:%M:%S}"}, - files={"photo": ("alert.jpg", io.BytesIO(buf), "image/jpeg")}, - timeout=10, - ) - st.session_state["tg_sent_count"] += 1 - return True - except Exception: - return False - - -def send_daily_report(): - history = load_history() - today = datetime.now().strftime("%Y-%m-%d") - today_events = [e for e in history if e.get("time", "").startswith(today)] - fires = sum(1 for e in today_events if e.get("label") == "fire") - trash = sum(1 for e in today_events if e.get("label") == "trash") - factory = sum(1 for e in today_events if e.get("label") == "factory") - avg = sum(e.get("confidence", 0) for e in today_events) / max(len(today_events), 1) - msg = ( - f"📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ\n" - f"📅 {datetime.now():%d.%m.%Y}\n\n" - f"🔥 Пожаров: {fires}\n" - f"🗑️ Сжигание мусора: {trash}\n" - f"🏭 Заводской дым: {factory}\n" - f"📈 Средняя вероятность: {avg:.1f}%\n" - f"📋 Всего: {len(today_events)}\n\n" - f"{'⚠️ Повышенная активность!' if fires + trash > 5 else '✅ Ситуация стабильна'}\n\n" - f"— EcoWatch AI" - ) - return send_telegram_alert(msg) - - -def process_frame(results, frame): - r = results[0] - - if hasattr(r, 'boxes') and r.boxes is not None and len(r.boxes) > 0: - boxes = r.boxes - names = r.names - detections = [] - class_counts = {} - best_conf = 0.0 - best_label = "normal" - - for i in range(len(boxes)): - conf = float(boxes.conf[i]) - if conf < DETECT_CONF: - continue - cls_id = int(boxes.cls[i]) - cls_name = names.get(cls_id, f"class_{cls_id}") - x1, y1, x2, y2 = boxes.xyxy[i].cpu().numpy().astype(int) - detections.append((x1, y1, x2, y2, conf, cls_name)) - - class_counts[cls_name] = class_counts.get(cls_name, 0) + 1 - if conf > best_conf: - best_conf = conf - best_label = cls_name - - if not detections: - return "normal", 0.0, {}, [] - - total_det = sum(class_counts.values()) - scores = {name: class_counts.get(name, 0) / total_det for name in names.values()} - return best_label, best_conf, scores, detections - - if hasattr(r, 'probs') and r.probs is not None: - raw = r.probs.data.cpu().numpy().flatten() - scores = {} - for i, name in r.names.items(): - scores[name] = float(raw[i]) - - final_label = max(scores, key=scores.get) - final_conf = scores[final_label] - - if final_conf < CLASSIFY_CONF: - return "normal", final_conf, scores, [] - sorted_vals = sorted(scores.values(), reverse=True) - if len(sorted_vals) >= 2 and (sorted_vals[0] - sorted_vals[1]) < CLASSIFY_GAP: - return "normal", final_conf, scores, [] - if final_label == "normal": - return "normal", final_conf, scores, [] - - return final_label, final_conf, scores, [] - - return "normal", 0.0, {}, [] - - -CLASS_COLORS = { - "fire": (60, 76, 231), - "trash": (11, 160, 232), - "factory": (210, 130, 50), - "smoke": (180, 163, 148), - "normal": (80, 180, 80), -} - -_pil_font_cache = {} - - -def _get_font(size=16): - if size in _pil_font_cache: - return _pil_font_cache[size] - for name in ["arial.ttf", "C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/segoeui.ttf"]: - try: - f = ImageFont.truetype(name, size) - _pil_font_cache[size] = f - return f - except Exception: - continue - f = ImageFont.load_default() - _pil_font_cache[size] = f - return f - - -def put_cyrillic(img, text, pos, font_size=16, color=(255, 255, 255)): - pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) - draw = ImageDraw.Draw(pil_img) - font = _get_font(font_size) - draw.text(pos, text, font=font, fill=(color[2], color[1], color[0])) - return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR) - - -def get_text_size_pil(text, font_size=16): - font = _get_font(font_size) - bbox = font.getbbox(text) - return bbox[2] - bbox[0], bbox[3] - bbox[1] - - -def smooth_label(label, conf): - buf = st.session_state.get("label_buffer", deque(maxlen=SMOOTH_WINDOW)) - buf.append(label) - st.session_state["label_buffer"] = buf - if label not in DANGER_CLASSES: - return label, conf - if len(buf) >= SMOOTH_WINDOW and all(l in DANGER_CLASSES for l in buf): - return label, conf - return "normal", conf - - -def draw_overlay(frame, label, confidence, scores, detections=None): - overlay = frame.copy() - h, w = frame.shape[:2] - - color = CLASS_COLORS.get(label, (180, 180, 180)) - info = HAZARD_INFO.get(label, {}) - label_ru = info.get("ru", label.upper()) - - if detections: - for (x1, y1, x2, y2, det_conf, cls_name) in detections: - box_color = CLASS_COLORS.get(cls_name, (180, 180, 180)) - det_info = HAZARD_INFO.get(cls_name, {}) - box_label = det_info.get("ru", cls_name.upper()) - - cv2.rectangle(overlay, (x1, y1), (x2, y2), box_color, 2) - - text = f"{box_label} {det_conf*100:.0f}%" - tw, th = get_text_size_pil(text, 14) - cv2.rectangle(overlay, (x1, y1 - th - 10), (x1 + tw + 8, y1), box_color, -1) - overlay = put_cyrillic(overlay, text, (x1 + 4, y1 - th - 6), font_size=14, color=(255, 255, 255)) - - bw, bh = x2 - x1, y2 - y1 - cl = min(20, bw // 4, bh // 4) - ct = 3 - cv2.line(overlay, (x1, y1), (x1 + cl, y1), box_color, ct) - cv2.line(overlay, (x1, y1), (x1, y1 + cl), box_color, ct) - cv2.line(overlay, (x2, y1), (x2 - cl, y1), box_color, ct) - cv2.line(overlay, (x2, y1), (x2, y1 + cl), box_color, ct) - cv2.line(overlay, (x1, y2), (x1 + cl, y2), box_color, ct) - cv2.line(overlay, (x1, y2), (x1, y2 - cl), box_color, ct) - cv2.line(overlay, (x2, y2), (x2 - cl, y2), box_color, ct) - cv2.line(overlay, (x2, y2), (x2, y2 - cl), box_color, ct) - - bar_h = 36 - bar_overlay = overlay.copy() - cv2.rectangle(bar_overlay, (0, 0), (w, bar_h), (0, 0, 0), -1) - cv2.addWeighted(bar_overlay, 0.65, overlay, 0.35, 0, overlay) - - dot_color = (0, 0, 255) if label in DANGER_CLASSES else (0, 200, 0) - cv2.circle(overlay, (18, bar_h // 2), 5, dot_color, -1) - - status_text = f" {label_ru} | {confidence*100:.0f}% | EcoWatch AI" - overlay = put_cyrillic(overlay, status_text, (30, bar_h // 2 - 8), font_size=15, color=(220, 220, 220)) - - time_text = datetime.now().strftime("%H:%M:%S") - ttw, _ = get_text_size_pil(time_text, 14) - overlay = put_cyrillic(overlay, time_text, (w - ttw - 12, bar_h // 2 - 7), font_size=14, color=(150, 150, 150)) - - bottom_h = 28 - bot_y = h - bottom_h - bot_overlay = overlay.copy() - cv2.rectangle(bot_overlay, (0, bot_y), (w, h), (0, 0, 0), -1) - cv2.addWeighted(bot_overlay, 0.6, overlay, 0.4, 0, overlay) - - bar_x = 10 - bar_w_max = w - 20 - bar_y = bot_y + 8 - bar_height = 12 - cv2.rectangle(overlay, (bar_x, bar_y), (bar_x + bar_w_max, bar_y + bar_height), (40, 40, 40), -1) - fill_w = int(bar_w_max * confidence) - cv2.rectangle(overlay, (bar_x, bar_y), (bar_x + fill_w, bar_y + bar_height), color, -1) - - if not detections and label in DANGER_CLASSES and confidence > MIN_CONF: - cv2.rectangle(overlay, (2, bar_h + 2), (w - 2, bot_y - 2), color, 3) - - return overlay - - -def build_probs_html(scores): - sorted_items = sorted(scores.items(), key=lambda x: x[1], reverse=True) - top_name = sorted_items[0][0] - lines = [] - for name, score in sorted_items: - p = score * 100 - info = HAZARD_INFO.get(name, {}) - icon = info.get("icon", "") - ru = info.get("ru", name) - clr = info.get("color", "#8b8fa3") - bar_w = max(int(p), 2) - bold = "font-weight:700;" if name == top_name else "font-weight:400;opacity:.65;" - lines.append( - f"
" - f"{icon} {ru}" - f"{p:.1f}%" - f"
" - f"
" - ) - html = "\n".join(lines) - - if len(sorted_items) >= 2: - gap = (sorted_items[0][1] - sorted_items[1][1]) * 100 - if gap < 12: - html += ( - "
" - "Низкая разница — результат неоднозначен
" - ) - - return html - - -HAZARD_INFO = { - "trash": { - "ru": "СЖИГАНИЕ МУСОРА", "icon": "🗑️", "color": "#eab308", - "desc": "Серый пятнистый дым · Хаотичная форма · Бытовые отходы", - "tg_desc": "• Серый пятнистый дым\n• Хаотичная форма\n• Бытовые отходы, пластик", - }, - "fire": { - "ru": "ПОЖАР", "icon": "🔥", "color": "#ef4444", - "desc": "Густой чёрный/белый дым · Вертикальный столб · Критическая опасность", - "tg_desc": "• Густой чёрный/белый дым\n• Вертикальный столб\n• Критическая опасность", - }, - "factory": { - "ru": "ПРОИЗВОДСТВЕННЫЙ ДЫМ", "icon": "🏭", "color": "#3b82f6", - "desc": "Белый однородный дым · Ровная струя · Промышленный выброс", - "tg_desc": "• Белый однородный дым\n• Ровная струя\n• Промышленный выброс", - }, - "smoke": { - "ru": "ДЫМ", "icon": "💨", "color": "#94a3b8", - "desc": "Обнаружен дым · Возможная угроза · Требуется внимание", - "tg_desc": "• Обнаружен дым\n• Возможная угроза\n• Требуется внимание", - }, - "normal": { - "ru": "НОРМА", "icon": "✅", "color": "#22c55e", - "desc": "Угроз не обнаружено · Чистый кадр", - "tg_desc": "", - }, -} - -with st.sidebar: - logo_path = os.path.join(SCRIPT_DIR, "logo", "logo.png") - if os.path.exists(logo_path): - st.image(logo_path, width=140) - else: - st.markdown( - "
" - "
🛡️
" - "
EcoWatch AI
" - "
v3.1 · Smoke Detection
" - "
", - unsafe_allow_html=True, - ) - - st.markdown('', unsafe_allow_html=True) - source_type = st.selectbox( - "Источник", ["Веб-камера", "IP-камера", "Загрузить видео", "Загрузить изображение", "Захват экрана"], - label_visibility="collapsed", - ) - - if source_type == "Веб-камера": - camera_id = st.number_input("ID камеры", 0, 10, 0) - elif source_type == "IP-камера": - ip_url = st.text_input("RTSP URL", "rtsp://") - elif source_type == "Загрузить видео": - uploaded_video = st.file_uploader("Видео", type=["mp4", "avi", "mov"], label_visibility="collapsed") - elif source_type == "Загрузить изображение": - uploaded_image = st.file_uploader("Изображение", type=["jpg", "jpeg", "png"], label_visibility="collapsed") - - st.markdown("---") - st.markdown('', unsafe_allow_html=True) - enable_telegram = st.checkbox("Отправлять алерты", value=True) - send_photo_tg = st.checkbox("Прикреплять фото", value=True) - tg_cooldown = st.slider("Интервал (сек)", 5, 60, 15) - tg_threshold = st.slider("Мин. уверенность (%)", 30, 95, 50) - - c1, c2 = st.columns(2) - with c1: - if st.button("🧪 Тест"): - ok = send_telegram_alert( - "✅ Тест\n\nEcoWatch AI работает.\n" + datetime.now().strftime("%d.%m.%Y %H:%M:%S") - ) - st.success("OK") if ok else st.error("Ошибка") - with c2: - if st.button("📊 Отчёт"): - ok = send_daily_report() - st.success("Отправлен") if ok else st.error("Ошибка") - - st.markdown("---") - st.markdown('', unsafe_allow_html=True) - enable_sound = st.checkbox("Звуковой сигнал", value=False) - enable_recording = st.checkbox("Запись при угрозе", value=False) - if enable_recording: - record_duration = st.slider("Длительность записи (сек)", 5, 60, 15) - - st.markdown( - f"
" - f"📨 TG: {st.session_state.get('tg_sent_count', 0)}  " - f"🚨 Алертов: {st.session_state.get('alert_count', 0)}
" - f"🎞️ Кадров: {st.session_state.get('total_frames', 0)}  " - f"⏱️ Сессия: {str(datetime.now() - st.session_state['session_start']).split('.')[0]}" - f"
", - unsafe_allow_html=True, - ) - -st.markdown("

🛡️ EcoWatch AI

", unsafe_allow_html=True) -st.markdown( - "

" - "Система обнаружения экологических угроз

", - unsafe_allow_html=True, -) - -tab1, tab2, tab3, tab4 = st.tabs(["Мониторинг", "Аналитика", "Карта", "Журнал"]) - -with tab1: - col_v, col_s = st.columns([5, 2]) - with col_v: - video_placeholder = st.empty() - is_running = st.toggle("Запустить мониторинг", value=False, key="run_toggle") - if not is_running: - video_placeholder.markdown( - "
" - "
" - "
📹
" - "

Ожидание запуска

" - "
", - unsafe_allow_html=True, - ) - with col_s: - alert_placeholder = st.empty() - stats_placeholder = st.empty() - sound_placeholder = st.empty() - if not is_running: - alert_placeholder.markdown( - "
" - "
Система готова
" - "
" - "Включите мониторинг для анализа
", - unsafe_allow_html=True, - ) - - if is_running and model: - cap = None - sct = None - frame_count = 0 - start_time = time.time() - - try: - if source_type == "Веб-камера": - cap = cv2.VideoCapture(int(camera_id)) - elif source_type == "IP-камера": - cap = cv2.VideoCapture(ip_url) - elif source_type == "Загрузить видео" and uploaded_video: - tfile = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") - tfile.write(uploaded_video.read()) - cap = cv2.VideoCapture(tfile.name) - elif source_type == "Захват экрана": - sct = mss() - monitor = {"top": 100, "left": 100, "width": 800, "height": 600} - elif source_type == "Загрузить изображение" and uploaded_image: - file_bytes = np.asarray(bytearray(uploaded_image.read()), dtype=np.uint8) - frame = cv2.imdecode(file_bytes, 1) - results = model(frame) - - label, conf, scores, detections = process_frame(results, frame) - info = HAZARD_INFO.get(label, {}) - label_ru = info.get("ru", label.upper()) - is_img_danger = label in DANGER_CLASSES and conf > MIN_CONF - probs_html = build_probs_html(scores) - - annotated = draw_overlay(frame, label, conf, scores, detections) - video_placeholder.image(annotated, channels="BGR") - - if is_img_danger: - alert_placeholder.markdown( - f"
" - f"
{info.get('icon', '')} {label_ru}
" - f"
{info.get('desc', '')}
" - f"
{probs_html}
" - f"
", - unsafe_allow_html=True, - ) - entry = { - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "type": label_ru, "label": label, - "confidence": round(conf * 100, 1), "source": source_type, - } - st.session_state["detection_log"].append(entry) - save_to_history(entry) - st.session_state["alert_count"] += 1 - else: - alert_placeholder.markdown( - f"
" - f"
Угроз не обнаружено
" - f"
{probs_html}
" - f"
", - unsafe_allow_html=True, - ) - except Exception as e: - st.error(f"Ошибка: {e}") - - if cap or sct: - while is_running: - if sct: - img = np.array(sct.grab(monitor)) - frame = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) - else: - ret, frame = cap.read() - if not ret: - st.warning("Конец потока") - break - - results = model(frame) - label, conf, scores, detections = process_frame(results, frame) - label, conf = smooth_label(label, conf) - - annotated = draw_overlay(frame, label, conf, scores, detections) - video_placeholder.image(annotated, channels="BGR") - - frame_count += 1 - st.session_state["total_frames"] += 1 - elapsed = time.time() - start_time - fps = frame_count / elapsed if elapsed > 0 else 0 - - info = HAZARD_INFO.get(label, {}) - label_ru = info.get("ru", label.upper()) - is_danger = label in DANGER_CLASSES and conf > MIN_CONF - probs_html = build_probs_html(scores) - - if is_danger: - alert_placeholder.markdown( - f"
" - f"
{info.get('icon', '')} {label_ru}
" - f"
{info.get('desc', '')}
" - f"
{probs_html}
" - f"
{datetime.now():%H:%M:%S}
" - f"
", - unsafe_allow_html=True, - ) - if enable_sound: - sound_placeholder.markdown( - '', - unsafe_allow_html=True, - ) - else: - alert_placeholder.markdown( - f"
" - f"
Норма
" - f"
{probs_html}
" - f"
{datetime.now():%H:%M:%S}
", - unsafe_allow_html=True, - ) - sound_placeholder.empty() - - dur = str(timedelta(seconds=int(elapsed))) - stats_placeholder.markdown( - f"
" - f"
{fps:.0f}
FPS
" - f"
{frame_count}
Кадров
" - f"
{st.session_state['alert_count']}
Алертов
" - f"
{dur}
Время
" - f"
", - unsafe_allow_html=True, - ) - - if is_danger and frame_count % 30 == 0: - entry = { - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "type": label_ru, "label": label, - "confidence": round(conf * 100, 1), "source": source_type, - } - st.session_state["detection_log"].append(entry) - save_to_history(entry) - st.session_state["alert_count"] += 1 - - if enable_telegram and is_danger and conf > tg_threshold / 100.0: - diff = (datetime.now() - st.session_state.get("last_tg_time", datetime.min)).total_seconds() - if diff > tg_cooldown: - msg = ( - f"🚨 ЭКОЛОГИЧЕСКАЯ УГРОЗА\n\n" - f"{info.get('icon', '')} {label_ru}\n" - f"📊 Вероятность: {conf * 100:.1f}%\n" - f"📹 Источник: {source_type}\n" - f"🕐 {datetime.now():%d.%m.%Y %H:%M:%S}\n\n" - f"{info.get('tg_desc', '')}\n\n" - f"— EcoWatch AI" - ) - send_telegram_alert(msg, annotated if send_photo_tg else None) - st.session_state["last_tg_time"] = datetime.now() - - if enable_recording and is_danger: - if st.session_state.get("video_writer") is None: - os.makedirs(RECORD_DIR, exist_ok=True) - fname = os.path.join(RECORD_DIR, f"alert_{datetime.now():%Y%m%d_%H%M%S}.avi") - fh, fw = frame.shape[:2] - st.session_state["video_writer"] = cv2.VideoWriter( - fname, cv2.VideoWriter_fourcc(*"XVID"), 15, (fw, fh) - ) - st.session_state["record_start"] = time.time() - wr = st.session_state.get("video_writer") - if wr: - wr.write(annotated) - if time.time() - st.session_state.get("record_start", 0) > record_duration: - wr.release() - st.session_state["video_writer"] = None - - if cap: - cap.release() - wr = st.session_state.get("video_writer") - if wr: - wr.release() - st.session_state["video_writer"] = None - - -_PLOT = dict( - plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", - font=dict(color="#8b8fa3", family="Inter"), - margin=dict(t=30, b=20, l=20, r=20), - xaxis=dict(gridcolor="rgba(255,255,255,.04)", zerolinecolor="rgba(255,255,255,.04)"), - yaxis=dict(gridcolor="rgba(255,255,255,.04)", zerolinecolor="rgba(255,255,255,.04)"), -) - -with tab2: - all_data = get_all_data() - if len(all_data) > 0: - df = pd.DataFrame(all_data) - df["time"] = pd.to_datetime(df["time"]) - - m1, m2, m3, m4 = st.columns(4) - with m1: - st.metric("Всего", len(df)) - with m2: - st.metric("Пожаров", len(df[df["label"] == "fire"])) - with m3: - st.metric("Мусор", len(df[df["label"] == "trash"])) - with m4: - st.metric("Заводской", len(df[df["label"] == "factory"])) - - c1, c2 = st.columns(2) - with c1: - daily = df.groupby(df["time"].dt.date).size().reset_index(name="count") - fig = px.bar(daily, x="time", y="count", labels={"time": "", "count": ""}, color_discrete_sequence=["#22c55e"]) - fig.update_layout(**_PLOT, title="События по дням") - st.plotly_chart(fig, use_container_width=True) - - with c2: - tc = df["type"].value_counts().reset_index() - tc.columns = ["Тип", "N"] - fig = px.pie(tc, values="N", names="Тип", color_discrete_sequence=["#ef4444", "#eab308", "#3b82f6"], hole=0.45) - fig.update_layout( - plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", - font=dict(color="#8b8fa3", family="Inter"), margin=dict(t=30, b=20), title="Распределение", - ) - fig.update_traces(textinfo="percent+label", textfont_size=11) - st.plotly_chart(fig, use_container_width=True) - - c3, c4 = st.columns(2) - with c3: - fig = px.scatter( - df.tail(100), x="time", y="confidence", color="type", size="confidence", - labels={"time": "", "confidence": "%", "type": "Тип"}, - color_discrete_sequence=["#ef4444", "#eab308", "#3b82f6"], - ) - fig.update_layout(**_PLOT, title="Вероятности обнаружений", showlegend=True, legend=dict(orientation="h", y=-0.15)) - st.plotly_chart(fig, use_container_width=True) - - with c4: - avg = df.groupby("type")["confidence"].mean().reset_index() - avg.columns = ["Тип", "Ср. %"] - fig = px.bar(avg, x="Тип", y="Ср. %", color="Тип", color_discrete_sequence=["#ef4444", "#eab308", "#3b82f6"]) - fig.update_layout(**_PLOT, title="Средняя уверенность", showlegend=False) - st.plotly_chart(fig, use_container_width=True) - - if len(df) > 5: - st.markdown("#### Активность по часам") - df["hour"] = df["time"].dt.hour - df["weekday"] = df["time"].dt.day_name() - day_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - heat = df.groupby(["weekday", "hour"]).size().reset_index(name="count") - hp = heat.pivot_table(index="weekday", columns="hour", values="count", fill_value=0) - hp = hp.reindex([d for d in day_order if d in hp.index]) - day_ru = {"Monday": "Пн", "Tuesday": "Вт", "Wednesday": "Ср", "Thursday": "Чт", "Friday": "Пт", "Saturday": "Сб", "Sunday": "Вс"} - fig = go.Figure( - data=go.Heatmap( - z=hp.values, - x=[f"{hh}:00" for hh in hp.columns], - y=[day_ru.get(d, d) for d in hp.index], - colorscale=[[0, "#0f1117"], [0.5, "#22c55e"], [1, "#ef4444"]], - hovertemplate="%{y} %{x}
Событий: %{z}", - ) - ) - fig.update_layout( - plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", - font=dict(color="#8b8fa3", family="Inter"), margin=dict(t=10, b=10, l=10, r=10), height=220, - ) - st.plotly_chart(fig, use_container_width=True) - else: - st.markdown( - "
" - "
📊
" - "

Данные появятся после запуска мониторинга

", - unsafe_allow_html=True, - ) - -with tab3: - st.markdown("#### Карта обнаружений") - st.caption("Координаты привязаны к расположению камер.") - - all_data_map = get_all_data() - base_lat = st.number_input("Широта камеры", value=51.1694, format="%.4f", key="map_lat") - base_lon = st.number_input("Долгота камеры", value=71.4491, format="%.4f", key="map_lon") - - if len(all_data_map) > 0: - map_points = [] - for i, e in enumerate(all_data_map[-100:]): - np.random.seed(hash(e.get("time", "")) % 2**31) - map_points.append({"lat": base_lat + np.random.uniform(-0.015, 0.015), "lon": base_lon + np.random.uniform(-0.015, 0.015)}) - df_map = pd.DataFrame(map_points) - st.map(df_map, latitude="lat", longitude="lon") - st.markdown( - f"
" - f"Показано {len(map_points)}" - f" последних обнаружений
", - unsafe_allow_html=True, - ) - else: - st.markdown( - "
" - "
🗺️
" - "

Нет данных

", - unsafe_allow_html=True, - ) - -with tab4: - all_data_log = get_all_data() - if len(all_data_log) > 0: - df_l = pd.DataFrame(all_data_log) - df_l["time"] = pd.to_datetime(df_l["time"]) - df_l = df_l.sort_values("time", ascending=False) - - fc1, fc2 = st.columns(2) - with fc1: - f_type = st.multiselect("Тип", df_l["type"].unique(), default=list(df_l["type"].unique())) - with fc2: - f_conf = st.slider("Мин. уверенность", 0, 100, 0, key="log_conf") - - filt = df_l[(df_l["type"].isin(f_type)) & (df_l["confidence"] >= f_conf)] - disp = filt.rename(columns={"time": "Время", "type": "Тип", "confidence": "%", "source": "Источник"}) - cols = ["Время", "Тип", "%", "Источник"] - disp = disp[[c for c in cols if c in disp.columns]] - st.dataframe(disp, use_container_width=True, height=400) - - bc1, bc2, bc3 = st.columns(3) - with bc1: - csv = disp.to_csv(index=False).encode("utf-8") - st.download_button("Скачать CSV", csv, f"ecowatch_{datetime.now():%Y%m%d}.csv", "text/csv") - with bc2: - report = ( - f"ОТЧЁТ ECOWATCH AI\n{'=' * 40}\n" - f"Дата: {datetime.now():%d.%m.%Y %H:%M}\n" - f"Событий: {len(filt)}\n" - f"Пожаров: {len(filt[filt['type'].str.contains('ПОЖАР', na=False)])}\n" - f"Мусор: {len(filt[filt['type'].str.contains('МУСОР', na=False)])}\n" - f"{'=' * 40}\n" - ) - for _, r in filt.head(50).iterrows(): - report += f"\n{r.get('time', '')} | {r.get('type', '')} | {r.get('confidence', '')}%" - st.download_button("Скачать отчёт", report.encode("utf-8"), f"report_{datetime.now():%Y%m%d}.txt") - with bc3: - if st.button("Очистить историю"): - if os.path.exists(LOG_FILE): - os.remove(LOG_FILE) - st.session_state["detection_log"] = [] - st.session_state["alert_count"] = 0 - st.rerun() - else: - st.markdown( - "
" - "
📋
" - "

Журнал пуст

", - unsafe_allow_html=True, - ) - -st.markdown( - "", - unsafe_allow_html=True, -) +main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2299489 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "ecowatch" +version = "4.0.0" +description = "EcoWatch AI — обнаружение пожаров и дыма на видео (YOLO + Streamlit)" +requires-python = ">=3.10" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9d7103c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8 +ruff>=0.6 diff --git a/requirements.txt b/requirements.txt index 0989f88..f143192 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -streamlit -pandas -plotly -opencv-python -ultralytics -mss -Pillow -requests -numpy \ No newline at end of file +ultralytics>=8.1 +streamlit>=1.48 +pandas>=2.0 +plotly>=5.24 +opencv-python>=4.8 +numpy>=1.24 +Pillow>=10.1 +requests>=2.31 +mss>=9.0 diff --git a/run_app.bat b/run_app.bat index 88c95ed..f345da4 100644 --- a/run_app.bat +++ b/run_app.bat @@ -1,8 +1,7 @@ -@echo off -cd /d "%~dp0" -echo Starting EcoWatch AI... -echo. -echo If the browser does not open automatically, go to: http://localhost:8501 -echo. -"C:\Program Files\Python311\python.exe" -m streamlit run main.py -pause +@echo off +cd /d "%~dp0" +echo Starting EcoWatch AI... +echo If the browser does not open automatically, go to: http://localhost:8501 +echo. +where py >nul 2>nul && (py -3 -m streamlit run main.py) || (python -m streamlit run main.py) +pause diff --git a/start_ecowatch.bat b/start_ecowatch.bat deleted file mode 100644 index 842ff63..0000000 --- a/start_ecowatch.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -echo Starting EcoWatch AI... -"C:\Program Files\Python311\python.exe" -m streamlit run main.py -pause diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ece8e0c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/tests/test_config_sources.py b/tests/test_config_sources.py new file mode 100644 index 0000000..124403c --- /dev/null +++ b/tests/test_config_sources.py @@ -0,0 +1,29 @@ +from ecowatch.config import Settings +from ecowatch.hazards import get_hazard, is_danger +from ecowatch.sources import parse_source + + +def test_settings_from_dotenv(tmp_path, monkeypatch): + for k in ("ECOWATCH_TG_TOKEN", "ECOWATCH_TG_CHAT_ID", "ECOWATCH_CAMERA_LAT", "ECOWATCH_DATA_DIR"): + monkeypatch.delenv(k, raising=False) + env = tmp_path / ".env" + env.write_text( + '# comment\nECOWATCH_TG_TOKEN="abc"\nexport ECOWATCH_TG_CHAT_ID=42\nECOWATCH_CAMERA_LAT=bad\n' + f"ECOWATCH_DATA_DIR={tmp_path / 'd'}\n" + ) + s = Settings.from_env(env) + assert s.telegram_token == "abc" and s.telegram_chat_id == "42" and s.telegram_configured + assert s.camera_lat is None and s.db_path == tmp_path / "d" / "ecowatch.db" + + +def test_parse_source(): + assert parse_source("0").kind == "webcam" + assert parse_source("screen:2").value == 2 + s = parse_source("rtsp://u:p@10.0.0.1/live") + assert s.kind == "stream" and "p@" not in s.label # пароль не попадает в подписи + assert parse_source("clip.mp4").kind == "file" + + +def test_hazards(): + assert is_danger("Fire") and is_danger("flame") and not is_danger("normal") + assert get_hazard("unknown_cls").title == "Unknown_cls" diff --git a/tests/test_detector.py b/tests/test_detector.py new file mode 100644 index 0000000..22d7f3c --- /dev/null +++ b/tests/test_detector.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace + +import numpy as np + +from ecowatch.detector import parse_result + + +def test_parse_classify_result(): + r = SimpleNamespace( + names={0: "fire", 1: "normal", 2: "Smoke"}, probs=SimpleNamespace(data=np.array([0.7, 0.2, 0.1])) + ) + res = parse_result(r) + assert res.scores == {"fire": 0.7, "normal": 0.2, "smoke": 0.1} + assert res.top() == ("fire", 0.7) + assert res.boxes == [] + + +def test_parse_detect_result_filters_boxes_and_derives_normal(): + boxes = SimpleNamespace( + conf=np.array([0.8, 0.2, 0.5]), + cls=np.array([0, 0, 1]), + xyxy=np.array([[1, 2, 30, 40], [0, 0, 5, 5], [10, 10, 50, 60]], dtype=float), + ) + boxes.__len__ = lambda: 3 # noqa: E731 + r = SimpleNamespace(names={0: "fire", 1: "smoke"}, probs=None, boxes=_Sized(boxes, 3)) + res = parse_result(r, box_conf=0.3) + assert len(res.boxes) == 2 + assert res.scores["fire"] == 0.8 and res.scores["smoke"] == 0.5 + assert abs(res.scores["normal"] - 0.2) < 1e-9 + + +def test_parse_detect_result_empty(): + r = SimpleNamespace(names={0: "fire"}, probs=None, boxes=None) + res = parse_result(r) + assert res.scores == {"fire": 0.0, "normal": 1.0} + assert res.top()[0] == "normal" + + +class _Sized: + def __init__(self, inner, n): + self._inner, self._n = inner, n + + def __len__(self): + return self._n + + def __getattr__(self, item): + return getattr(self._inner, item) diff --git a/tests/test_notifier.py b/tests/test_notifier.py new file mode 100644 index 0000000..d79ed87 --- /dev/null +++ b/tests/test_notifier.py @@ -0,0 +1,69 @@ +from datetime import date, datetime + +import numpy as np + +from ecowatch.notifier import AsyncSender, TelegramNotifier, format_alert, format_daily_report + + +class FakeResp: + def __init__(self, ok=True): + self._ok = ok + self.text = "" + + def json(self): + return {"ok": self._ok} + + +class FakeSession: + def __init__(self, ok=True): + self.calls = [] + self.ok = ok + + def post(self, url, **kw): + self.calls.append((url, kw)) + return FakeResp(self.ok) + + +def test_disabled_without_credentials(): + s = FakeSession() + n = TelegramNotifier(None, "1", session=s) + assert not n.enabled and not n.send_message("hi") and s.calls == [] + + +def test_send_message_and_photo(): + s = FakeSession() + n = TelegramNotifier("T", "42", session=s) + assert n.send_message("hello") + assert n.send_photo(np.zeros((10, 10, 3), np.uint8), "cap") + assert s.calls[0][0].endswith("/botT/sendMessage") + assert s.calls[1][0].endswith("/sendPhoto") and s.calls[1][1]["data"]["chat_id"] == "42" + + +def test_long_caption_split(): + s = FakeSession() + TelegramNotifier("T", "1", session=s).send_photo(np.zeros((4, 4, 3), np.uint8), "x" * 2000) + assert [c[0].rsplit("/", 1)[1] for c in s.calls] == ["sendMessage", "sendPhoto"] + + +def test_async_sender_counts(): + sender = AsyncSender() + sender.submit(lambda: True) + sender.submit(lambda: False) + sender.submit(lambda: 1 / 0) + sender.join() + assert sender.sent == 1 and sender.failed == 2 + + +def test_format_alert_escapes_html(): + text = format_alert("fire", 0.876, "", datetime(2026, 1, 1, 9, 5)) + assert "ПОЖАР" in text and "88%" in text and "<cam>" in text and "01.01.2026 09:05:00" in text + + +def test_daily_report(): + text = format_daily_report( + {"day": date(2026, 1, 1), "total": 3, "by_label": {"fire": 2, "smoke": 1}, "peak_conf": 0.9} + ) + assert "Пожар: 2" in text and "Всего инцидентов: 3" in text and "стабильна" in text + assert "не зафиксировано" in format_daily_report( + {"day": date(2026, 1, 1), "total": 0, "by_label": {}, "peak_conf": 0} + ) diff --git a/tests/test_recorder.py b/tests/test_recorder.py new file mode 100644 index 0000000..9554cc5 --- /dev/null +++ b/tests/test_recorder.py @@ -0,0 +1,37 @@ +import cv2 +import numpy as np + +from ecowatch.recorder import ClipRecorder + + +def frame(v): + return np.full((48, 64, 3), v, np.uint8) + + +def count_frames(path): + cap = cv2.VideoCapture(str(path)) + n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + return n + + +def test_clip_has_preroll_and_postroll(tmp_path): + rec = ClipRecorder(tmp_path, fps=10, pre_seconds=1, post_seconds=0.55) + t = 0.0 + for _ in range(30): # 3 с без тревоги — в клип попадёт только последняя секунда + assert rec.push(frame(0), False, t) is None + t += 0.1 + for _ in range(5): + rec.push(frame(255), True, t) + t += 0.1 + assert rec.recording + path = None + while path is None: + path = rec.push(frame(0), False, t) + t += 0.1 + assert path.exists() and not rec.recording + assert count_frames(path) == 10 + 5 + 6 + + +def test_close_without_recording(tmp_path): + assert ClipRecorder(tmp_path).close() is None diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..b28cdc6 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,68 @@ +import json +from datetime import date, datetime + +import pytest + +from ecowatch.storage import EventStore + + +@pytest.fixture +def store(tmp_path): + s = EventStore(tmp_path / "db.sqlite") + yield s + s.close() + + +def test_open_update_list(store): + i = store.open_incident("fire", 0.8, "Камера 0", started_at=datetime(2026, 1, 2, 10, 0, 0), lat=51.1, lon=71.4) + store.update_incident(i, ended_at=datetime(2026, 1, 2, 10, 1, 0), peak_conf=0.93, clip="/x.mp4") + (row,) = store.list() + assert row["label"] == "fire" and row["peak_conf"] == 0.93 + assert row["ended_at"] == "2026-01-02T10:01:00" and row["clip"] == "/x.mp4" and row["lat"] == 51.1 + + +def test_update_rejects_unknown_fields(store): + i = store.open_incident("smoke", 0.7, "s") + with pytest.raises(ValueError): + store.update_incident(i, id=5) + + +def test_list_order_since_limit_and_clear(store): + for h in (8, 9, 10): + store.open_incident("smoke", 0.7, "s", started_at=datetime(2026, 1, 2, h)) + assert [r["started_at"][11:13] for r in store.list()] == ["10", "09", "08"] + assert len(store.list(since=datetime(2026, 1, 2, 9))) == 2 + assert len(store.list(limit=1)) == 1 + store.clear() + assert store.count() == 0 + + +def test_day_summary(store): + store.open_incident("fire", 0.9, "s", started_at=datetime(2026, 3, 1, 12)) + store.open_incident("fire", 0.7, "s", started_at=datetime(2026, 3, 1, 13)) + store.open_incident("smoke", 0.6, "s", started_at=datetime(2026, 3, 1, 14)) + store.open_incident("fire", 0.99, "s", started_at=datetime(2026, 3, 2, 1)) + s = store.day_summary(date(2026, 3, 1)) + assert s["total"] == 3 and s["by_label"] == {"fire": 2, "smoke": 1} and s["peak_conf"] == 0.9 + + +def test_import_legacy_json(store, tmp_path): + legacy = tmp_path / "detection_history.json" + legacy.write_text( + json.dumps( + [ + { + "time": "2025-05-01 12:00:00", + "type": "ПОЖАР", + "label": "fire", + "confidence": 87.5, + "source": "Веб-камера", + }, + {"time": "broken"}, + ] + ), + encoding="utf-8", + ) + assert store.import_legacy_json(legacy) == 1 + (row,) = store.list() + assert row["label"] == "fire" and row["peak_conf"] == pytest.approx(0.875) diff --git a/tests/test_tracker.py b/tests/test_tracker.py new file mode 100644 index 0000000..16c94d0 --- /dev/null +++ b/tests/test_tracker.py @@ -0,0 +1,59 @@ +from ecowatch.config import DetectionParams +from ecowatch.detector import FrameResult +from ecowatch.tracker import ThreatTracker, classify_frame, evaluate_single + +FIRE = FrameResult({"fire": 0.9, "normal": 0.05, "smoke": 0.05}) +CLEAN = FrameResult({"fire": 0.02, "normal": 0.95, "smoke": 0.03}) +PARAMS = DetectionParams(conf_threshold=0.6, margin=0.15, ema_alpha=1.0, confirm_frames=3, clear_frames=2) + + +def test_classify_frame_requires_threshold_and_margin(): + p = DetectionParams(conf_threshold=0.6, margin=0.2) + assert classify_frame({"fire": 0.9, "normal": 0.1}, p)[2] + assert not classify_frame({"fire": 0.55, "normal": 0.45}, p)[2] # ниже порога + assert not classify_frame({"fire": 0.62, "smoke": 0.5}, p)[2] # мал отрыв + assert not classify_frame({"normal": 0.9, "fire": 0.1}, p)[2] + assert not classify_frame({}, p)[2] + + +def test_onset_after_confirm_frames_and_clear_after_clear_frames(): + t = ThreatTracker(PARAMS) + decisions = [t.update(FIRE) for _ in range(3)] + assert [d.active for d in decisions] == [False, False, True] + assert decisions[0].frame_danger and decisions[0].label == "normal" + assert decisions[2].onset and decisions[2].label == "fire" + assert sum(d.onset for d in decisions + [t.update(FIRE) for _ in range(5)]) == 1 + + d1 = t.update(CLEAN) + assert d1.active and not d1.cleared # одного чистого кадра мало + d2 = t.update(CLEAN) + assert d2.cleared and not d2.active and d2.label == "normal" + + +def test_single_noisy_frame_does_not_trigger(): + t = ThreatTracker(PARAMS) + seq = [CLEAN, FIRE, CLEAN, FIRE, FIRE, CLEAN] + assert not any(t.update(r).active for r in seq) + + +def test_ema_suppresses_spike(): + t = ThreatTracker(DetectionParams(ema_alpha=0.3, confirm_frames=1)) + for _ in range(10): + t.update(CLEAN) + assert not t.update(FIRE).frame_danger # один всплеск не перебивает историю + + +def test_escalation_from_smoke_to_fire(): + t = ThreatTracker(PARAMS) + smoke = FrameResult({"smoke": 0.9, "normal": 0.05, "fire": 0.05}) + for _ in range(3): + d = t.update(smoke) + assert d.label == "smoke" + d = t.update(FIRE) + assert d.active and d.label == "fire" and not d.onset + + +def test_evaluate_single(): + assert evaluate_single(FIRE).label == "fire" + d = evaluate_single(CLEAN) + assert d.label == "normal" and d.confidence == 0.95 and not d.active diff --git a/train.py b/train.py index 52ff411..ae574b5 100644 --- a/train.py +++ b/train.py @@ -1,94 +1,114 @@ -import os -import sys - -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +"""Обучение модели EcoWatch AI. -DATASET_DIR = os.path.join(SCRIPT_DIR, "dataset_detect") -PROJECT_DIR = os.path.join(SCRIPT_DIR, "shop project", "runs", "classify") +Классификация (как текущая models/best.pt) — датасет из папок по классам: + python train.py --data dataset_detect -BASE_MODEL = "yolov8s-cls.pt" -EPOCHS = 50 -IMG_SIZE = 224 -BATCH_SIZE = 32 -PATIENCE = 15 +Детекция с рамками — датасет в формате YOLO с data.yaml: + python train.py --data fire_smoke/data.yaml --model yolov8s.pt --imgsz 640 +Лучшие веса копируются в models/best.pt, прежняя модель сохраняется как models/best_<дата>.pt. +""" -def check_dataset(): - train_dir = os.path.join(DATASET_DIR, "train") - valid_dir = os.path.join(DATASET_DIR, "valid") +from __future__ import annotations - if not os.path.isdir(train_dir): - print(f"Папка train не найдена: {train_dir}") - return False - if not os.path.isdir(valid_dir): - print(f"Папка valid не найдена: {valid_dir}") +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} + + +def describe_classify_dataset(data: Path) -> bool: + ok = True + for split in ("train", "val", "valid"): + d = data / split + if not d.is_dir(): + continue + counts = { + c.name: sum(1 for f in c.iterdir() if f.suffix.lower() in IMAGE_EXT) + for c in sorted(d.iterdir()) + if c.is_dir() + } + print(f" {split}: " + ", ".join(f"{k}={v}" for k, v in counts.items()) + f" (всего {sum(counts.values())})") + empty = [k for k, v in counts.items() if v == 0] + if empty: + print(f" ⚠ пустые классы в {split}: {empty}") + ok = False + if not (data / "train").is_dir(): + print(f"Нет папки {data / 'train'}") return False - - print("Датасет:") - total = 0 - for split_name, split_dir in [("train", train_dir), ("valid", valid_dir)]: - classes = sorted(os.listdir(split_dir)) - counts = {} - for cls in classes: - cls_path = os.path.join(split_dir, cls) - if os.path.isdir(cls_path): - n = len([f for f in os.listdir(cls_path) if os.path.isfile(os.path.join(cls_path, f))]) - counts[cls] = n - total += n - parts = ", ".join(f"{k}: {v}" for k, v in counts.items()) - print(f" {split_name}: {parts}") - - print(f" Всего: {total} изображений") - print(f" Классы: {classes}") - return True - - -def train(): - if not check_dataset(): - return + return ok + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--data", default=str(ROOT / "dataset_detect"), help="папка датасета или data.yaml") + p.add_argument("--model", help="базовые веса (по умолчанию yolov8s-cls.pt / yolov8s.pt)") + p.add_argument("--epochs", type=int, default=50) + p.add_argument("--imgsz", type=int, help="размер входа (224 для classify, 640 для detect)") + p.add_argument("--batch", type=int, default=32) + p.add_argument("--patience", type=int, default=15) + p.add_argument("--device", help="cpu, 0, 0,1 … (по умолчанию — автоматически)") + p.add_argument("--name", default=f"run_{datetime.now():%Y%m%d_%H%M}") + p.add_argument("--no-install", action="store_true", help="не копировать результат в models/best.pt") + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + data = Path(args.data) + task = "detect" if data.suffix in {".yaml", ".yml"} else "classify" + print(f"Задача: {task}, датасет: {data}") + if not data.exists(): + print("Датасет не найден") + return 1 + if task == "classify" and not describe_classify_dataset(data): + return 1 from ultralytics import YOLO - print(f"Базовая модель: {BASE_MODEL}") - print(f"Эпох: {EPOCHS}, Размер: {IMG_SIZE}px, Батч: {BATCH_SIZE}") - print(f"Сохранение: {PROJECT_DIR}") - - model = YOLO(BASE_MODEL) - - results = model.train( - data=DATASET_DIR, - epochs=EPOCHS, - imgsz=IMG_SIZE, - batch=BATCH_SIZE, - patience=PATIENCE, - project=PROJECT_DIR, - name="train_v2", + base = args.model or ("yolov8s-cls.pt" if task == "classify" else "yolov8s.pt") + imgsz = args.imgsz or (224 if task == "classify" else 640) + model = YOLO(base) + model.train( + data=str(data), + epochs=args.epochs, + imgsz=imgsz, + batch=args.batch, + patience=args.patience, + device=args.device, + project=str(ROOT / "runs" / task), + name=args.name, exist_ok=True, - pretrained=True, - optimizer="auto", - lr0=0.01, - lrf=0.01, - degrees=10.0, - flipud=0.0, + # Огонь и дым не бывают вверх ногами, а цвет важен — аугментации умеренные. fliplr=0.5, - hsv_h=0.015, - hsv_s=0.7, + flipud=0.0, + degrees=10.0, + hsv_h=0.01, + hsv_s=0.5, hsv_v=0.4, - save=True, plots=True, - verbose=True, ) - best_path = os.path.join(PROJECT_DIR, "train_v2", "weights", "best.pt") - models_dir = os.path.join(SCRIPT_DIR, "models") - if os.path.exists(best_path): - os.makedirs(models_dir, exist_ok=True) - import shutil - shutil.copy2(best_path, os.path.join(models_dir, "best.pt")) - print(f"Модель обучена и скопирована в models/best.pt") - else: - print("Ошибка обучения. Проверь логи выше.") + best = Path(model.trainer.best) + if not best.is_file(): + print("Обучение не дало весов — смотрите лог выше") + return 1 + print(f"Лучшие веса: {best}") + if not args.no_install: + target = ROOT / "models" / "best.pt" + target.parent.mkdir(exist_ok=True) + if target.exists(): + backup = target.with_name(f"best_{datetime.now():%Y%m%d_%H%M%S}.pt") + shutil.copy2(target, backup) + print(f"Старая модель сохранена: {backup.name}") + shutil.copy2(best, target) + print(f"Модель установлена: {target}") + return 0 if __name__ == "__main__": - train() + sys.exit(main())