361 files
This commit is contained in:
1 parent
d2c5af43e3
commit
bf886c9347
362 files changed
+22060
-1285
No files matched your search
@@ -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"]
|
||||
Reference in new issue
Block a user