Fix a bug in sanity check image.
[python_utils.git] / camera_utils.py
1 #!/usr/bin/env python3
2
3 """Utilities for dealing with webcam images."""
4
5 import logging
6 import platform
7 import subprocess
8 import warnings
9 from dataclasses import dataclass
10 from typing import Optional
11
12 import cv2  # type: ignore
13 import numpy as np
14 import requests
15
16 import decorator_utils
17 import exceptions
18
19 logger = logging.getLogger(__name__)
20
21
22 @dataclass
23 class RawJpgHsv:
24     """Raw image bytes, the jpeg image and the HSV (hue saturation value) image."""
25
26     raw: Optional[bytes] = None
27     jpg: Optional[np.ndarray] = None
28     hsv: Optional[np.ndarray] = None
29
30
31 @dataclass
32 class SanityCheckImageMetadata:
33     """Is a Blue Iris image bad (big grey borders around it) or infrared?"""
34
35     is_infrared_image: bool = False
36     is_bad_image: bool = False
37
38
39 def sanity_check_image(hsv: np.ndarray) -> SanityCheckImageMetadata:
40     """See if a Blue Iris or Shinobi image is bad and infrared."""
41
42     def is_near(a, b) -> bool:
43         return abs(a - b) < 3
44
45     rows, cols, _ = hsv.shape
46     num_pixels = rows * cols
47     weird_orange_count = 0
48     hs_zero_count = 0
49     for r in range(rows):
50         for c in range(cols):
51             pixel = hsv[(r, c)]
52             if is_near(pixel[0], 16) and is_near(pixel[1], 117) and is_near(pixel[2], 196):
53                 weird_orange_count += 1
54             elif is_near(pixel[0], 0) and is_near(pixel[1], 0):
55                 hs_zero_count += 1
56     logger.debug("hszero#=%d, weird_orange=%d", hs_zero_count, weird_orange_count)
57     return SanityCheckImageMetadata(
58         hs_zero_count > (num_pixels * 0.75),
59         weird_orange_count > (num_pixels * 0.75),
60     )
61
62
63 @decorator_utils.retry_if_none(tries=2, delay_sec=1, backoff=1.1)
64 def fetch_camera_image_from_video_server(
65     camera_name: str, *, width: int = 256, quality: int = 70
66 ) -> Optional[bytes]:
67     """Fetch the raw webcam image from the video server."""
68     camera_name = camera_name.replace(".house", "")
69     camera_name = camera_name.replace(".cabin", "")
70     url = (
71         f"http://10.0.0.226:8080/Umtxxf1uKMBniFblqeQ9KRbb6DDzN4/jpeg/GKlT2FfiSQ/{camera_name}/s.jpg"
72     )
73     logger.debug('Fetching image from %s', url)
74     try:
75         response = requests.get(url, stream=False, timeout=10.0)
76         if response.ok:
77             raw = response.content
78             logger.debug('Read %d byte image from HTTP server', len(response.content))
79             tmp = np.frombuffer(raw, dtype="uint8")
80             logger.debug(
81                 'Translated raw content into %s %s with element type %s',
82                 tmp.shape,
83                 type(tmp),
84                 type(tmp[0]),
85             )
86             jpg = cv2.imdecode(tmp, cv2.IMREAD_COLOR)
87             logger.debug(
88                 'Decoded into %s jpeg %s with element type %s',
89                 jpg.shape,
90                 type(jpg),
91                 type(jpg[0][0]),
92             )
93             hsv = cv2.cvtColor(jpg, cv2.COLOR_BGR2HSV)
94             logger.debug(
95                 'Converted JPG into %s HSV HSV %s with element type %s',
96                 hsv.shape,
97                 type(hsv),
98                 type(hsv[0][0]),
99             )
100             ret = sanity_check_image(hsv)
101             if not ret.is_bad_image:
102                 return raw
103     except Exception as e:
104         logger.exception(e)
105     msg = f"Got a bad image or HTTP error from {url}; returning None."
106     logger.warning(msg)
107     warnings.warn(msg, stacklevel=2)
108     return None
109
110
111 def camera_name_to_hostname(camera_name: str) -> str:
112     """Map a camera name to a hostname
113
114     >>> camera_name_to_hostname('driveway')
115     'driveway.house'
116
117     >>> camera_name_to_hostname('cabin_driveway')
118     'driveway.cabin'
119
120     """
121     mapping = {
122         "driveway": "driveway.house",
123         "backyard": "backyard.house",
124         "frontdoor": "frontdoor.house",
125         "cabin_driveway": "driveway.cabin",
126     }
127     camera_name = mapping.get(camera_name, camera_name)
128     if "." not in camera_name:
129         hostname = platform.node()
130         suffix = hostname.split(".")[-1]
131         camera_name += f".{suffix}"
132     return camera_name
133
134
135 @decorator_utils.retry_if_none(tries=2, delay_sec=1, backoff=1.1)
136 def fetch_camera_image_from_rtsp_stream(camera_name: str, *, width: int = 256) -> Optional[bytes]:
137     """Fetch the raw webcam image straight from the webcam's RTSP stream."""
138     hostname = camera_name_to_hostname(camera_name)
139     stream = f"rtsp://camera:IaLaIok@{hostname}:554/live"
140     logger.debug('Fetching image from RTSP stream %s', stream)
141     try:
142         cmd = [
143             "/usr/bin/timeout",
144             "-k 9s",
145             "8s",
146             "/usr/local/bin/ffmpeg",
147             "-y",
148             "-i",
149             f"{stream}",
150             "-f",
151             "singlejpeg",
152             "-vframes",
153             "1",
154             "-vf",
155             f"scale={width}:-1",
156             "-",
157         ]
158         with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as proc:
159             out, _ = proc.communicate(timeout=10)
160             return out
161     except Exception as e:
162         logger.exception(e)
163     msg = f"Failed to retrieve image via RTSP {stream}, returning None."
164     logger.warning(msg)
165     warnings.warn(msg, stacklevel=2)
166     return None
167
168
169 @decorator_utils.timeout(seconds=30, use_signals=False)
170 def _fetch_camera_image(camera_name: str, *, width: int = 256, quality: int = 70) -> RawJpgHsv:
171     """Fetch a webcam image given the camera name."""
172     logger.debug("Trying to fetch camera image from video server")
173     raw = fetch_camera_image_from_video_server(camera_name, width=width, quality=quality)
174     if raw is None:
175         logger.debug("Reading from video server failed; trying direct RTSP stream")
176         raw = fetch_camera_image_from_rtsp_stream(camera_name, width=width)
177     if raw is not None and len(raw) > 0:
178         tmp = np.frombuffer(raw, dtype="uint8")
179         jpg = cv2.imdecode(tmp, cv2.IMREAD_COLOR)
180         hsv = cv2.cvtColor(jpg, cv2.COLOR_BGR2HSV)
181         return RawJpgHsv(
182             raw=raw,
183             jpg=jpg,
184             hsv=hsv,
185         )
186     msg = "Failed to retieve image from both video server and direct RTSP stream"
187     logger.warning(msg)
188     warnings.warn(msg, stacklevel=2)
189     return RawJpgHsv(None, None, None)
190
191
192 def fetch_camera_image(camera_name: str, *, width: int = 256, quality: int = 70) -> RawJpgHsv:
193     try:
194         return _fetch_camera_image(camera_name, width=width, quality=quality)
195     except exceptions.TimeoutError:
196         logger.warning('Fetching camera image operation timed out.')
197         return RawJpgHsv(None, None, None)
198
199
200 if __name__ == '__main__':
201     import doctest
202
203     doctest.testmod()