459 lines
17 KiB
Python
459 lines
17 KiB
Python
# rtps_daily_recorder.py
|
|
import os
|
|
import time
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
import subprocess
|
|
import logging
|
|
from pathlib import Path
|
|
try:
|
|
from PIL import Image
|
|
import numpy as np
|
|
except ImportError:
|
|
print("PIL/numpy nicht installiert. Führen Sie 'pip install Pillow numpy' aus.")
|
|
sys.exit(1)
|
|
|
|
# Einheitliche Konfiguration
|
|
CONFIG = {
|
|
"BASE_DIR": "/var/www/html/image/",
|
|
"RESIZE_DIR": "/var/www/html/images/",
|
|
"RTSP_URL": "rtsp://aurora:%2B61946194@192.168.1.133:88/videoMain",
|
|
"LOG_FILE": "/var/www/html/rtsp-recorder.log",
|
|
"HOURS_TO_RUN": 24,
|
|
"SCREENSHOT_INTERVAL": 33,
|
|
"VIDEO_FPS": 5,
|
|
"VIDEO_RETENTION_DAYS": 7,
|
|
"TARGET_WIDTH": 274,
|
|
"TARGET_HEIGHT": 52,
|
|
"SCREENSHOTS_PER_HOUR": 109,
|
|
"GREY_THRESHOLD": 20, # NEU: RGB-Differenz Schwellwert für Grau-Erkennung
|
|
}
|
|
|
|
# Logging Setup
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
handlers=[
|
|
logging.FileHandler(CONFIG["LOG_FILE"]),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
|
|
class CameraService:
|
|
def __init__(self):
|
|
self.execution_count = 0
|
|
self.base_dir = Path(CONFIG["BASE_DIR"])
|
|
self.resize_dir = Path(CONFIG["RESIZE_DIR"])
|
|
self.ensure_directories()
|
|
|
|
def ensure_directories(self):
|
|
"""Stelle sicher, dass alle benötigten Verzeichnisse existieren"""
|
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
self.resize_dir.mkdir(parents=True, exist_ok=True)
|
|
logging.info(f"Arbeitsverzeichnisse bereit: {self.base_dir}, {self.resize_dir}")
|
|
|
|
def validate_config(self):
|
|
"""Überprüft die Konfigurationswerte"""
|
|
if not self.base_dir.exists():
|
|
logging.error(f"Verzeichnis nicht gefunden: {self.base_dir}")
|
|
return False
|
|
return True
|
|
|
|
def check_and_create_missing_videos(self):
|
|
"""Prüft ob Videos für die letzten 7 Tage existieren und erstellt fehlende"""
|
|
logging.info("Prüfe auf fehlende Videos der letzten 7 Tage...")
|
|
|
|
for days_ago in range(CONFIG["VIDEO_RETENTION_DAYS"]):
|
|
target_date = datetime.now() - timedelta(days=days_ago)
|
|
date_str = target_date.strftime("%Y%m%d")
|
|
|
|
existing_videos = list(self.base_dir.glob(f"daily_video_{date_str}_*.mp4"))
|
|
|
|
if not existing_videos:
|
|
logging.info(f"Kein Video für {date_str} gefunden. Erstelle aus vorhandenen Screenshots...")
|
|
self.create_video_for_date(target_date)
|
|
else:
|
|
logging.info(f"Video für {date_str} bereits vorhanden: {existing_videos[0].name}")
|
|
|
|
def is_grey_image(self, image_path, max_rgb_diff=None):
|
|
"""
|
|
VERBESSERTE Grau-Erkennung mit RGB-Differenz-Methode
|
|
Funktioniert auch bei sehr dunklen Bildern!
|
|
|
|
Prüft ob R ≈ G ≈ B für alle Pixel (bei Grau sind RGB-Werte identisch)
|
|
"""
|
|
if max_rgb_diff is None:
|
|
max_rgb_diff = CONFIG["GREY_THRESHOLD"]
|
|
|
|
try:
|
|
img = Image.open(image_path)
|
|
|
|
# Direkter Graustufen-Modus
|
|
if img.mode == 'L':
|
|
logging.info(f"✓ Graubild (L-Modus): {image_path.name}")
|
|
return True
|
|
|
|
# RGB Bild analysieren mit NumPy
|
|
if img.mode in ('RGB', 'RGBA'):
|
|
img_rgb = img.convert('RGB')
|
|
|
|
# Resize für Performance (behält Genauigkeit)
|
|
img_rgb.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
|
|
|
# NumPy Array - vektorisierte Berechnung
|
|
img_array = np.array(img_rgb, dtype=np.float32)
|
|
|
|
r = img_array[:, :, 0]
|
|
g = img_array[:, :, 1]
|
|
b = img_array[:, :, 2]
|
|
|
|
# Berechne maximale Differenz zwischen R, G, B für jeden Pixel
|
|
diff_rg = np.abs(r - g)
|
|
diff_gb = np.abs(g - b)
|
|
diff_rb = np.abs(r - b)
|
|
|
|
max_diff_per_pixel = np.maximum(np.maximum(diff_rg, diff_gb), diff_rb)
|
|
|
|
# Statistiken
|
|
mean_diff = np.mean(max_diff_per_pixel)
|
|
p95_diff = np.percentile(max_diff_per_pixel, 95)
|
|
|
|
# ENTSCHEIDUNG: Grau wenn 95% der Pixel RGB-Differenz < Schwellwert haben
|
|
ist_grau = p95_diff < max_rgb_diff
|
|
|
|
if ist_grau:
|
|
logging.info(f"✓ Graubild (P95={p95_diff:.1f}, Mean={mean_diff:.1f}): {image_path.name}")
|
|
else:
|
|
logging.debug(f"✗ Farbig (P95={p95_diff:.1f}, Mean={mean_diff:.1f}): {image_path.name}")
|
|
|
|
return ist_grau
|
|
|
|
except Exception as e:
|
|
logging.error(f"Fehler bei Graubildprüfung {image_path.name}: {e}")
|
|
return False
|
|
|
|
return False
|
|
|
|
def create_video_for_date(self, target_date):
|
|
"""Erstellt ein Video für ein spezifisches Datum aus vorhandenen Screenshots"""
|
|
date_str = target_date.strftime("%Y%m%d")
|
|
|
|
jpg_files = []
|
|
for jpg in sorted(self.base_dir.glob(f"screenshot_{date_str}_*.jpg")):
|
|
# Filtere auch hier graue Bilder aus
|
|
if not self.is_grey_image(jpg):
|
|
jpg_files.append(jpg)
|
|
|
|
if len(jpg_files) < 10:
|
|
logging.warning(f"Zu wenige Screenshots für {date_str} ({len(jpg_files)} gefunden)")
|
|
return False
|
|
|
|
timestamp = target_date.strftime("%Y%m%d_%H%M%S")
|
|
output_file = self.base_dir / f"daily_video_{timestamp}.mp4"
|
|
temp_list = Path(f"/tmp/files_{timestamp}.txt")
|
|
|
|
try:
|
|
with temp_list.open('w') as f:
|
|
for jpg in jpg_files:
|
|
f.write(f"file '{jpg.absolute()}'\n")
|
|
f.write(f"duration 0.2\n")
|
|
|
|
cmd = [
|
|
'ffmpeg', '-y',
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', str(temp_list),
|
|
'-vsync', 'vfr',
|
|
'-c:v', 'libx264',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-preset', 'fast',
|
|
'-crf', '23',
|
|
str(output_file)
|
|
]
|
|
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
|
|
if output_file.exists() and output_file.stat().st_size > 0:
|
|
logging.info(f"Nachträglich Video erstellt für {date_str}: {output_file}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logging.error(f"Fehler beim Erstellen des Videos für {date_str}: {e}")
|
|
return False
|
|
finally:
|
|
if temp_list.exists():
|
|
temp_list.unlink()
|
|
|
|
def resize_image(self, image_path):
|
|
"""Passt die Bildgröße an die Zielgröße an und behält das Seitenverhältnis bei"""
|
|
try:
|
|
with Image.open(image_path) as img:
|
|
current_width, current_height = img.size
|
|
target_width = CONFIG["TARGET_WIDTH"]
|
|
target_height = CONFIG["TARGET_HEIGHT"]
|
|
|
|
aspect_ratio = current_width / current_height
|
|
target_ratio = target_width / target_height
|
|
|
|
if current_width != target_width or current_height != target_height:
|
|
if aspect_ratio > target_ratio:
|
|
new_width = target_width
|
|
new_height = int(target_width / aspect_ratio)
|
|
else:
|
|
new_height = target_height
|
|
new_width = int(target_height * aspect_ratio)
|
|
|
|
background = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
|
resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
x = (target_width - new_width) // 2
|
|
y = (target_height - new_height) // 2
|
|
|
|
background.paste(resized_img, (x, y))
|
|
background.save(image_path, "PNG", optimize=True)
|
|
logging.info(f"Bild angepasst: {image_path}")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logging.error(f"Fehler bei Bildanpassung {image_path}: {e}")
|
|
return False
|
|
|
|
def resize_all_images(self):
|
|
"""Passt alle PNG-Bilder im RESIZE_DIR an"""
|
|
for png_file in Path(CONFIG["RESIZE_DIR"]).glob("*.png"):
|
|
self.resize_image(png_file)
|
|
|
|
def take_screenshot(self):
|
|
"""Erstelle einen Screenshot von der RTSP-Kamera"""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_file = self.base_dir / f"screenshot_{timestamp}.jpg"
|
|
|
|
logging.info("Erstelle Screenshot...")
|
|
|
|
try:
|
|
cmd = [
|
|
'ffmpeg', '-y', '-rtsp_transport', 'tcp',
|
|
'-analyzeduration', '20M', '-probesize', '20M',
|
|
'-i', CONFIG["RTSP_URL"], '-vframes', '1', '-q:v', '2',
|
|
'-ss', '00:00:01', str(output_file)
|
|
]
|
|
|
|
process = subprocess.run(cmd, capture_output=True, text=True)
|
|
if process.returncode != 0:
|
|
logging.error(f"FFmpeg Fehler: {process.stderr}")
|
|
return False
|
|
|
|
if output_file.exists():
|
|
logging.info(f"Screenshot erstellt: {output_file}")
|
|
return True
|
|
return False
|
|
except subprocess.CalledProcessError as e:
|
|
logging.error(f"Screenshot-Fehler: {e.stderr}")
|
|
return False
|
|
except Exception as e:
|
|
logging.error(f"Unerwarteter Fehler: {e}")
|
|
return False
|
|
|
|
def create_daily_video(self):
|
|
"""Erstelle ein Video aus den gesammelten Screenshots - LÖSCHE GRAUE BILDER"""
|
|
logging.info("="*60)
|
|
logging.info("Starte Videoerstellung mit verbessertem Graufilter...")
|
|
logging.info("="*60)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_file = self.base_dir / f"daily_video_{timestamp}.mp4"
|
|
temp_list = Path(f"/tmp/files_{timestamp}.txt")
|
|
|
|
cutoff_time = datetime.now() - timedelta(hours=CONFIG["HOURS_TO_RUN"])
|
|
|
|
# Sammle ALLE JPG-Dateien
|
|
all_jpg_files = sorted([
|
|
f for f in self.base_dir.glob("screenshot_*.jpg")
|
|
if f.stat().st_mtime > cutoff_time.timestamp()
|
|
])
|
|
|
|
logging.info(f"Gefunden: {len(all_jpg_files)} Screenshots insgesamt")
|
|
|
|
# FILTERE und LÖSCHE graue Bilder
|
|
jpg_files = []
|
|
grey_files_to_delete = []
|
|
|
|
for idx, jpg in enumerate(all_jpg_files, 1):
|
|
if idx % 50 == 0:
|
|
logging.info(f" Prüfe Bild {idx}/{len(all_jpg_files)}...")
|
|
|
|
if not self.is_grey_image(jpg):
|
|
jpg_files.append(jpg)
|
|
else:
|
|
grey_files_to_delete.append(jpg)
|
|
|
|
# LÖSCHE alle grauen Bilder
|
|
for grey_file in grey_files_to_delete:
|
|
try:
|
|
grey_file.unlink()
|
|
logging.info(f"🗑️ Graues Bild gelöscht: {grey_file.name}")
|
|
except Exception as e:
|
|
logging.error(f"Fehler beim Löschen von {grey_file.name}: {e}")
|
|
|
|
grey_count = len(grey_files_to_delete)
|
|
logging.info("")
|
|
logging.info("="*60)
|
|
logging.info(f"FILTER-ERGEBNIS:")
|
|
logging.info(f" Gute Bilder: {len(jpg_files)}")
|
|
logging.info(f" Graue gelöscht: {grey_count}")
|
|
|
|
if len(all_jpg_files) > 0:
|
|
filter_rate = (grey_count / len(all_jpg_files)) * 100
|
|
logging.info(f" Löschrate: {filter_rate:.1f}%")
|
|
|
|
if filter_rate > 50:
|
|
logging.warning("⚠️ WARNUNG: Über 50% gelöscht - Filter möglicherweise zu strikt!")
|
|
logging.warning(f"⚠️ Erhöhe GREY_THRESHOLD in CONFIG (aktuell: {CONFIG['GREY_THRESHOLD']})")
|
|
logging.info("="*60)
|
|
|
|
if len(jpg_files) < 10:
|
|
logging.warning(f"Zu wenige Bilder ({len(jpg_files)}) für Video")
|
|
return False
|
|
|
|
try:
|
|
with temp_list.open('w') as f:
|
|
for jpg in jpg_files:
|
|
f.write(f"file '{jpg.absolute()}'\n")
|
|
f.write(f"duration 0.2\n")
|
|
|
|
# Erstelle Timestamp-Text (z.B. "Oktober 14:25")
|
|
start_time = cutoff_time.strftime("%d.%m.%Y %H:%M")
|
|
|
|
|
|
cmd = [
|
|
'ffmpeg', '-y',
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', str(temp_list),
|
|
'-vsync', 'vfr',
|
|
'-vf', f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:text='{start_time}':fontcolor=white:fontsize=24:box=1:boxcolor=black@0.7:boxborderw=5:x=10:y=h-th-10",
|
|
'-c:v', 'libx264',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-preset', 'fast',
|
|
'-crf', '23',
|
|
str(output_file)
|
|
]
|
|
|
|
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
|
|
if output_file.exists() and output_file.stat().st_size > 0:
|
|
logging.info(f"✅ Video erstellt: {output_file}")
|
|
|
|
# Lösche nur die GUTEN JPGs (graue wurden schon gelöscht)
|
|
for jpg in jpg_files:
|
|
jpg.unlink()
|
|
logging.info(f"✅ {len(jpg_files)} gute Screenshots gelöscht nach Videoerstellung")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logging.error(f"Fehler bei Videoerstellung: {e}")
|
|
return False
|
|
finally:
|
|
if temp_list.exists():
|
|
temp_list.unlink()
|
|
|
|
|
|
def cleanup_old_files(self):
|
|
"""Lösche alte Video- und Bilddateien (nur älter als 7 Tage)"""
|
|
cutoff_time = datetime.now() - timedelta(days=CONFIG["VIDEO_RETENTION_DAYS"])
|
|
|
|
# Lösche verwaiste Screenshots älter als 7 Tage
|
|
for jpg in self.base_dir.glob("screenshot_*.jpg"):
|
|
if jpg.stat().st_mtime < cutoff_time.timestamp():
|
|
jpg.unlink()
|
|
logging.info(f"Verwaistes Bild gelöscht (>7 Tage): {jpg}")
|
|
|
|
def cleanup(self):
|
|
"""Aufräumen vor Beendigung"""
|
|
try:
|
|
self.cleanup_old_files()
|
|
logging.info("Aufräumen abgeschlossen")
|
|
except Exception as e:
|
|
logging.error(f"Fehler beim Aufräumen: {e}")
|
|
|
|
def main():
|
|
service = CameraService()
|
|
logging.info("="*60)
|
|
logging.info("Starte Kamera-Service mit verbesserter Grau-Erkennung")
|
|
logging.info(f"Grau-Schwellwert: RGB-Differenz < {CONFIG['GREY_THRESHOLD']}")
|
|
logging.info("="*60)
|
|
|
|
|
|
# Warte unbegrenzt bis grey.py fertig ist
|
|
logging.info("Starte Grau-Filterung (warte auf Abschluss)...")
|
|
returncode = subprocess.call([sys.executable, "grey.py"])
|
|
|
|
if returncode == 0:
|
|
logging.info("✓ Grau-Filterung abgeschlossen")
|
|
else:
|
|
logging.error(f"✗ Grau-Filterung fehlgeschlagen (Exit-Code: {returncode})")
|
|
|
|
|
|
retry_count = 0
|
|
max_retries = 3
|
|
|
|
try:
|
|
if not service.validate_config():
|
|
raise RuntimeError("Ungültige Konfiguration")
|
|
|
|
service.check_and_create_missing_videos()
|
|
|
|
while True:
|
|
service.execution_count = 0
|
|
total_screenshots = CONFIG["HOURS_TO_RUN"] * CONFIG["SCREENSHOTS_PER_HOUR"]
|
|
logging.info(f"Starte neuen 24-Stunden-Zyklus mit {total_screenshots} Screenshots")
|
|
|
|
service.resize_all_images()
|
|
|
|
while service.execution_count < total_screenshots:
|
|
if service.take_screenshot():
|
|
service.execution_count += 1
|
|
retry_count = 0
|
|
logging.info(f"Screenshot {service.execution_count} von {total_screenshots}")
|
|
else:
|
|
retry_count += 1
|
|
if retry_count >= max_retries:
|
|
logging.error(f"Screenshot nach {max_retries} Versuchen fehlgeschlagen")
|
|
raise RuntimeError("Zu viele fehlgeschlagene Versuche")
|
|
logging.warning(f"Screenshot fehlgeschlagen ({retry_count}/{max_retries}), warte 60 Sekunden...")
|
|
time.sleep(60)
|
|
continue
|
|
|
|
if service.execution_count % 100 == 0:
|
|
service.cleanup_old_files()
|
|
|
|
if service.execution_count < total_screenshots:
|
|
time.sleep(CONFIG["SCREENSHOT_INTERVAL"])
|
|
|
|
if service.create_daily_video():
|
|
logging.info("✓ 24-Stunden-Video erfolgreich erstellt")
|
|
else:
|
|
logging.error("✗ Fehler beim Erstellen des Tagesvideos")
|
|
|
|
service.check_and_create_missing_videos()
|
|
|
|
logging.info("Starte neuen 24-Stunden-Zyklus...")
|
|
|
|
except KeyboardInterrupt:
|
|
logging.info("Programm durch Benutzer beendet")
|
|
except Exception as e:
|
|
logging.error(f"Unerwarteter Fehler: {e}")
|
|
raise
|
|
finally:
|
|
service.cleanup()
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1 and sys.argv[1] == "video":
|
|
service = CameraService()
|
|
service.create_daily_video()
|
|
sys.exit(0)
|
|
else:
|
|
main()
|