361 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-06 17:11:43 +00:00
1 parent d2c5af43e3
commit bf886c9347
362 files changed
+22060 -1285

No files matched your search

+207
View File
@@ -14,12 +14,15 @@ API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
country, description) - used to enrich location/description per asset.
- ``GET /api/faces?id={id}`` -> recognised faces and bounding boxes - used to
keep faces whole in cover-mode crops.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
"""
from __future__ import annotations
import json
import math
from datetime import datetime, timezone
from typing import Any
@@ -241,6 +244,198 @@ def parse_asset_exif(asset: Any) -> dict[str, Any]:
return out
def selected_person_ids(
selection_type: str | None, selection_id: str | None
) -> set[str]:
"""Return the people explicitly selected for an Immich source.
Current entries use a composite JSON selection, while older entries can
still carry the legacy ``person``/``people`` shapes. Album-only, favorite,
random and custom-search sources intentionally return an empty set: there
is no user-selected face to prioritise for those sources.
"""
if selection_type == "person":
return {selection_id} if selection_id else set()
if selection_type == "people":
return {person_id for person_id in (selection_id or "").split(",") if person_id}
if selection_type == "composite":
return set(parse_composite_selection(selection_id)["people"])
return set()
def _normalised_face_box(
face: Any,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float, float, float] | None:
"""Return a face's bounding box normalised to 0..1, or None if invalid."""
if not isinstance(face, dict):
return None
width = face.get("imageWidth")
height = face.get("imageHeight")
coords = (
face.get("boundingBoxX1"),
face.get("boundingBoxY1"),
face.get("boundingBoxX2"),
face.get("boundingBoxY2"),
)
if (
any(isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in (width, height, *coords))
or width <= 0
or height <= 0
):
return None
x1, y1, x2, y2 = coords
if x2 <= x1 or y2 <= y1:
return None
box = _original_face_box(
(x1 / width, y1 / height, x2 / width, y2 / height),
edits or [], original_size,
)
box = tuple(max(0.0, min(1.0, value)) for value in box)
return box if box[2] > box[0] and box[3] > box[1] else None
def parse_face_boxes(
faces: Any,
person_ids: set[str] | None = None,
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> list[list[float | bool]]:
"""Return normalised ``[x1, y1, x2, y2, area, selected]`` face boxes.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes it
independent of whether Album Slideshow downloads a preview, full-size
derivative or original. Faces Immich has not linked to a named person
are included. Explicit person selection is a separate priority from area.
"""
if not isinstance(faces, list):
return []
boxes: list[list[float | bool]] = []
for face in faces:
box = _normalised_face_box(face, edits, original_size)
if box is None:
continue
x1, y1, x2, y2 = box
weight = (x2 - x1) * (y2 - y1)
person = face.get("person")
selected = bool(
person_ids and isinstance(person, dict) and person.get("id") in person_ids
)
boxes.append([x1, y1, x2, y2, weight, selected])
return boxes
def _original_face_box(
box: tuple[float, float, float, float],
edits: list[dict[str, Any]],
original_size: tuple[float, float] | None,
) -> tuple[float, float, float, float]:
points = [(box[0], box[1]), (box[2], box[3])]
crop = None
for edit in reversed(edits):
if not isinstance(edit, dict) or not isinstance(edit.get("parameters"), dict):
raise ValueError("Invalid Immich edit metadata")
params = edit["parameters"]
action = edit.get("action")
if action == "rotate":
angle = params.get("angle")
if angle == 90:
points = [(vertical, 1 - horizontal) for horizontal, vertical in points]
elif angle == 180:
points = [(1 - horizontal, 1 - vertical) for horizontal, vertical in points]
elif angle == 270:
points = [(1 - vertical, horizontal) for horizontal, vertical in points]
elif angle != 0:
raise ValueError("Unsupported Immich rotation")
elif action == "mirror":
axis = params.get("axis")
if axis == "horizontal":
points = [(horizontal, 1 - vertical) for horizontal, vertical in points]
elif axis == "vertical":
points = [(1 - horizontal, vertical) for horizontal, vertical in points]
else:
raise ValueError("Unsupported Immich mirror axis")
elif action == "crop" and crop is None:
crop = params
else:
raise ValueError("Unsupported Immich edit")
if crop is not None:
values = [crop.get(name) for name in ("x", "y", "width", "height")]
if original_size is None or any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) for value in values
):
raise ValueError("Missing Immich crop dimensions")
crop_left, crop_top, crop_width, crop_height = values
original_width, original_height = original_size
if (
crop_left < 0 or crop_top < 0 or crop_width <= 0 or crop_height <= 0
or crop_left + crop_width > original_width
or crop_top + crop_height > original_height
):
raise ValueError("Invalid Immich crop dimensions")
points = [
((horizontal * crop_width + crop_left) / original_width,
(vertical * crop_height + crop_top) / original_height)
for horizontal, vertical in points
]
return (
min(point[0] for point in points), min(point[1] for point in points),
max(point[0] for point in points), max(point[1] for point in points),
)
def original_image_size(asset: Any) -> tuple[float, float] | None:
info = asset.get("exifInfo") if isinstance(asset, dict) else None
if not isinstance(info, dict):
return None
width, height = info.get("exifImageWidth"), info.get("exifImageHeight")
if any(
isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value <= 0 for value in (width, height)
):
return None
if str(info.get("orientation")) in {"5", "6", "7", "8", "-90", "90"}:
return (float(height), float(width))
return (float(width), float(height))
def parse_face_focus(
faces: Any,
person_ids: set[str],
*,
edits: list[dict[str, Any]] | None = None,
original_size: tuple[float, float] | None = None,
) -> tuple[float, float] | None:
"""Return a normalised crop focus for the selected people in an asset.
Immich reports face boxes in the coordinate space described by each
face's ``imageWidth``/``imageHeight``. Normalising each box makes the
focus independent of resolution. Edited coordinates are mapped back to the
unedited image before clamping. When several selected people appear in the
same photo, focus on the centre of their combined region.
"""
if not isinstance(faces, list) or not person_ids:
return None
selected_faces = [
face for face in faces
if isinstance(face, dict) and isinstance(face.get("person"), dict)
and face["person"].get("id") in person_ids
]
boxes = parse_face_boxes(selected_faces, edits=edits, original_size=original_size)
if not boxes:
return None
left = min(box[0] for box in boxes)
top = min(box[1] for box in boxes)
right = max(box[2] for box in boxes)
bottom = max(box[3] for box in boxes)
return ((left + right) / 2, (top + bottom) / 2)
class ImmichClient:
"""Thin async wrapper over the Immich REST API."""
@@ -373,3 +568,15 @@ class ImmichClient:
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
async def async_get_faces(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/faces?id={asset_id}")
if not isinstance(data, list):
raise ValueError("Invalid Immich face response")
return data
async def async_get_asset_edits(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/assets/{asset_id}/edits")
if not isinstance(data, dict) or not isinstance(data.get("edits"), list):
raise ValueError("Invalid Immich edit response")
return data["edits"]