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"
Источник
', 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('Telegram
', 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"" - "Система обнаружения экологических угроз
", - 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( - "Ожидание запуска
" - "Данные появятся после запуска мониторинга
Нет данных
Журнал пуст