Home Assistant Unofficial Reference 2024.12.1
image_processing.py
Go to the documentation of this file.
1 """Support for the DOODS service."""
2 
3 from __future__ import annotations
4 
5 import io
6 import logging
7 import os
8 import time
9 
10 from PIL import Image, ImageDraw, UnidentifiedImageError
11 from pydoods import PyDOODS
12 import voluptuous as vol
13 
15  CONF_CONFIDENCE,
16  PLATFORM_SCHEMA as IMAGE_PROCESSING_PLATFORM_SCHEMA,
17  ImageProcessingEntity,
18 )
19 from homeassistant.const import (
20  CONF_COVERS,
21  CONF_ENTITY_ID,
22  CONF_NAME,
23  CONF_SOURCE,
24  CONF_TIMEOUT,
25  CONF_URL,
26 )
27 from homeassistant.core import HomeAssistant, split_entity_id
28 from homeassistant.helpers import template
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
32 from homeassistant.util.pil import draw_box
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 ATTR_MATCHES = "matches"
37 ATTR_SUMMARY = "summary"
38 ATTR_TOTAL_MATCHES = "total_matches"
39 ATTR_PROCESS_TIME = "process_time"
40 
41 CONF_AUTH_KEY = "auth_key"
42 CONF_DETECTOR = "detector"
43 CONF_LABELS = "labels"
44 CONF_AREA = "area"
45 CONF_TOP = "top"
46 CONF_BOTTOM = "bottom"
47 CONF_RIGHT = "right"
48 CONF_LEFT = "left"
49 CONF_FILE_OUT = "file_out"
50 
51 AREA_SCHEMA = vol.Schema(
52  {
53  vol.Optional(CONF_BOTTOM, default=1): cv.small_float,
54  vol.Optional(CONF_LEFT, default=0): cv.small_float,
55  vol.Optional(CONF_RIGHT, default=1): cv.small_float,
56  vol.Optional(CONF_TOP, default=0): cv.small_float,
57  vol.Optional(CONF_COVERS, default=True): cv.boolean,
58  }
59 )
60 
61 LABEL_SCHEMA = vol.Schema(
62  {
63  vol.Required(CONF_NAME): cv.string,
64  vol.Optional(CONF_AREA): AREA_SCHEMA,
65  vol.Optional(CONF_CONFIDENCE): vol.Range(min=0, max=100),
66  }
67 )
68 
69 PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA.extend(
70  {
71  vol.Required(CONF_URL): cv.string,
72  vol.Required(CONF_DETECTOR): cv.string,
73  vol.Required(CONF_TIMEOUT, default=90): cv.positive_int,
74  vol.Optional(CONF_AUTH_KEY, default=""): cv.string,
75  vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]),
76  vol.Optional(CONF_CONFIDENCE, default=0.0): vol.Range(min=0, max=100),
77  vol.Optional(CONF_LABELS, default=[]): vol.All(
78  cv.ensure_list, [vol.Any(cv.string, LABEL_SCHEMA)]
79  ),
80  vol.Optional(CONF_AREA): AREA_SCHEMA,
81  }
82 )
83 
84 
86  hass: HomeAssistant,
87  config: ConfigType,
88  add_entities: AddEntitiesCallback,
89  discovery_info: DiscoveryInfoType | None = None,
90 ) -> None:
91  """Set up the Doods client."""
92  url = config[CONF_URL]
93  auth_key = config[CONF_AUTH_KEY]
94  detector_name = config[CONF_DETECTOR]
95  timeout = config[CONF_TIMEOUT]
96 
97  doods = PyDOODS(url, auth_key, timeout)
98  response = doods.get_detectors()
99  if not isinstance(response, dict):
100  _LOGGER.warning("Could not connect to doods server: %s", url)
101  return
102 
103  detector = {}
104  for server_detector in response["detectors"]:
105  if server_detector["name"] == detector_name:
106  detector = server_detector
107  break
108 
109  if not detector:
110  _LOGGER.warning(
111  "Detector %s is not supported by doods server %s", detector_name, url
112  )
113  return
114 
115  add_entities(
116  Doods(
117  hass,
118  camera[CONF_ENTITY_ID],
119  camera.get(CONF_NAME),
120  doods,
121  detector,
122  config,
123  )
124  for camera in config[CONF_SOURCE]
125  )
126 
127 
129  """Doods image processing service client."""
130 
131  def __init__(self, hass, camera_entity, name, doods, detector, config):
132  """Initialize the DOODS entity."""
133  self.hasshasshass = hass
134  self._camera_entity_camera_entity = camera_entity
135  if name:
136  self._name_name = name
137  else:
138  name = split_entity_id(camera_entity)[1]
139  self._name_name = f"Doods {name}"
140  self._doods_doods = doods
141  self._file_out_file_out = config[CONF_FILE_OUT]
142  self._detector_name_detector_name = detector["name"]
143 
144  # detector config and aspect ratio
145  self._width_width = None
146  self._height_height = None
147  self._aspect_aspect = None
148  if detector["width"] and detector["height"]:
149  self._width_width = detector["width"]
150  self._height_height = detector["height"]
151  self._aspect_aspect = self._width_width / self._height_height
152 
153  # the base confidence
154  dconfig = {}
155  confidence = config[CONF_CONFIDENCE]
156 
157  # handle labels and specific detection areas
158  labels = config[CONF_LABELS]
159  self._label_areas_label_areas = {}
160  self._label_covers_label_covers = {}
161  for label in labels:
162  if isinstance(label, dict):
163  label_name = label[CONF_NAME]
164  if label_name not in detector["labels"] and label_name != "*":
165  _LOGGER.warning("Detector does not support label %s", label_name)
166  continue
167 
168  # If label confidence is not specified, use global confidence
169  if not (label_confidence := label.get(CONF_CONFIDENCE)):
170  label_confidence = confidence
171  if label_name not in dconfig or dconfig[label_name] > label_confidence:
172  dconfig[label_name] = label_confidence
173 
174  # Label area
175  label_area = label.get(CONF_AREA)
176  self._label_areas_label_areas[label_name] = [0, 0, 1, 1]
177  self._label_covers_label_covers[label_name] = True
178  if label_area:
179  self._label_areas_label_areas[label_name] = [
180  label_area[CONF_TOP],
181  label_area[CONF_LEFT],
182  label_area[CONF_BOTTOM],
183  label_area[CONF_RIGHT],
184  ]
185  self._label_covers_label_covers[label_name] = label_area[CONF_COVERS]
186  else:
187  if label not in detector["labels"] and label != "*":
188  _LOGGER.warning("Detector does not support label %s", label)
189  continue
190  self._label_areas_label_areas[label] = [0, 0, 1, 1]
191  self._label_covers_label_covers[label] = True
192  if label not in dconfig or dconfig[label] > confidence:
193  dconfig[label] = confidence
194 
195  if not dconfig:
196  dconfig["*"] = confidence
197 
198  # Handle global detection area
199  self._area_area = [0, 0, 1, 1]
200  self._covers_covers = True
201  if area_config := config.get(CONF_AREA):
202  self._area_area = [
203  area_config[CONF_TOP],
204  area_config[CONF_LEFT],
205  area_config[CONF_BOTTOM],
206  area_config[CONF_RIGHT],
207  ]
208  self._covers_covers = area_config[CONF_COVERS]
209 
210  self._dconfig_dconfig = dconfig
211  self._matches_matches = {}
212  self._total_matches_total_matches = 0
213  self._last_image_last_image = None
214  self._process_time_process_time = 0
215 
216  @property
217  def camera_entity(self):
218  """Return camera entity id from process pictures."""
219  return self._camera_entity_camera_entity
220 
221  @property
222  def name(self):
223  """Return the name of the image processor."""
224  return self._name_name
225 
226  @property
227  def state(self):
228  """Return the state of the entity."""
229  return self._total_matches_total_matches
230 
231  @property
233  """Return device specific state attributes."""
234  return {
235  ATTR_MATCHES: self._matches_matches,
236  ATTR_SUMMARY: {
237  label: len(values) for label, values in self._matches_matches.items()
238  },
239  ATTR_TOTAL_MATCHES: self._total_matches_total_matches,
240  ATTR_PROCESS_TIME: self._process_time_process_time,
241  }
242 
243  def _save_image(self, image, matches, paths):
244  img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
245  img_width, img_height = img.size
246  draw = ImageDraw.Draw(img)
247 
248  # Draw custom global region/area
249  if self._area_area != [0, 0, 1, 1]:
250  draw_box(
251  draw, self._area_area, img_width, img_height, "Detection Area", (0, 255, 255)
252  )
253 
254  for label, values in matches.items():
255  # Draw custom label regions/areas
256  if label in self._label_areas_label_areas and self._label_areas_label_areas[label] != [0, 0, 1, 1]:
257  box_label = f"{label.capitalize()} Detection Area"
258  draw_box(
259  draw,
260  self._label_areas_label_areas[label],
261  img_width,
262  img_height,
263  box_label,
264  (0, 255, 0),
265  )
266 
267  # Draw detected objects
268  for instance in values:
269  box_label = f'{label} {instance["score"]:.1f}%'
270  # Already scaled, use 1 for width and height
271  draw_box(
272  draw,
273  instance["box"],
274  img_width,
275  img_height,
276  box_label,
277  (255, 255, 0),
278  )
279 
280  for path in paths:
281  _LOGGER.debug("Saving results image to %s", path)
282  os.makedirs(os.path.dirname(path), exist_ok=True)
283  img.save(path)
284 
285  def process_image(self, image):
286  """Process the image."""
287  try:
288  img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
289  except UnidentifiedImageError:
290  _LOGGER.warning("Unable to process image, bad data")
291  return
292  img_width, img_height = img.size
293 
294  if self._aspect_aspect and abs((img_width / img_height) - self._aspect_aspect) > 0.1:
295  _LOGGER.debug(
296  (
297  "The image aspect: %s and the detector aspect: %s differ by more"
298  " than 0.1"
299  ),
300  (img_width / img_height),
301  self._aspect_aspect,
302  )
303 
304  # Run detection
305  start = time.monotonic()
306  response = self._doods_doods.detect(
307  image, dconfig=self._dconfig_dconfig, detector_name=self._detector_name_detector_name
308  )
309  _LOGGER.debug(
310  "doods detect: %s response: %s duration: %s",
311  self._dconfig_dconfig,
312  response,
313  time.monotonic() - start,
314  )
315 
316  matches = {}
317  total_matches = 0
318 
319  if not response or "error" in response:
320  if "error" in response:
321  _LOGGER.error(response["error"])
322  self._matches_matches = matches
323  self._total_matches_total_matches = total_matches
324  self._process_time_process_time = time.monotonic() - start
325  return
326 
327  for detection in response["detections"]:
328  score = detection["confidence"]
329  boxes = [
330  detection["top"],
331  detection["left"],
332  detection["bottom"],
333  detection["right"],
334  ]
335  label = detection["label"]
336 
337  # Exclude unlisted labels
338  if "*" not in self._dconfig_dconfig and label not in self._dconfig_dconfig:
339  continue
340 
341  # Exclude matches outside global area definition
342  if self._covers_covers:
343  if (
344  boxes[0] < self._area_area[0]
345  or boxes[1] < self._area_area[1]
346  or boxes[2] > self._area_area[2]
347  or boxes[3] > self._area_area[3]
348  ):
349  continue
350  elif (
351  boxes[0] > self._area_area[2]
352  or boxes[1] > self._area_area[3]
353  or boxes[2] < self._area_area[0]
354  or boxes[3] < self._area_area[1]
355  ):
356  continue
357 
358  # Exclude matches outside label specific area definition
359  if self._label_areas_label_areas.get(label):
360  if self._label_covers_label_covers[label]:
361  if (
362  boxes[0] < self._label_areas_label_areas[label][0]
363  or boxes[1] < self._label_areas_label_areas[label][1]
364  or boxes[2] > self._label_areas_label_areas[label][2]
365  or boxes[3] > self._label_areas_label_areas[label][3]
366  ):
367  continue
368  elif (
369  boxes[0] > self._label_areas_label_areas[label][2]
370  or boxes[1] > self._label_areas_label_areas[label][3]
371  or boxes[2] < self._label_areas_label_areas[label][0]
372  or boxes[3] < self._label_areas_label_areas[label][1]
373  ):
374  continue
375 
376  if label not in matches:
377  matches[label] = []
378  matches[label].append({"score": float(score), "box": boxes})
379  total_matches += 1
380 
381  # Save Images
382  if total_matches and self._file_out_file_out:
383  paths = []
384  for path_template in self._file_out_file_out:
385  if isinstance(path_template, template.Template):
386  paths.append(
387  path_template.render(camera_entity=self._camera_entity_camera_entity)
388  )
389  else:
390  paths.append(path_template)
391  self._save_image_save_image(image, matches, paths)
392  else:
393  _LOGGER.debug(
394  "Not saving image(s), no detections found or no output file configured"
395  )
396 
397  self._matches_matches = matches
398  self._total_matches_total_matches = total_matches
399  self._process_time_process_time = time.monotonic() - start
def __init__(self, hass, camera_entity, name, doods, detector, config)
None add_entities(AsusWrtRouter router, AddEntitiesCallback async_add_entities, set[str] tracked)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
None draw_box(ImageDraw draw, tuple[float, float, float, float] box, int img_width, int img_height, str text="", tuple[int, int, int] color=(255, 255, 0))
Definition: pil.py:18