This commit is contained in:
parent
4601f7aaea
commit
3f77f0e94b
7 changed files with 287 additions and 110 deletions
|
|
@ -12,7 +12,12 @@ sys.path.append(str(Path(__file__).parent / "lib"))
|
|||
from crop import face_aware_crop
|
||||
from env import load_env, require
|
||||
from homeassistant import HomeAssistantClient
|
||||
from immich import ImmichClient, get_random_photo_from_album, get_random_photo_of_people
|
||||
from immich import (
|
||||
ImmichClient,
|
||||
get_random_photo_from_album,
|
||||
get_random_photo_of_people,
|
||||
target_size_for_orientation,
|
||||
)
|
||||
from overlay import format_age, format_location
|
||||
|
||||
# waveshare_epd is imported lazily only when a render will actually happen.
|
||||
|
|
@ -97,9 +102,11 @@ def main() -> None:
|
|||
try:
|
||||
epd.init()
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
faces = client.get_asset_faces(asset["id"])
|
||||
faces = asset.get("_faces")
|
||||
if faces is None:
|
||||
faces = client.get_asset_faces(asset["id"])
|
||||
print(f"Faces: {len(faces)}")
|
||||
target_w, target_h = (480, 800) if args.orientation in (90, 270) else (800, 480)
|
||||
target_w, target_h = target_size_for_orientation(args.orientation)
|
||||
img = face_aware_crop(img, target_w, target_h, faces)
|
||||
if args.orientation:
|
||||
img = img.rotate(args.orientation, expand=True)
|
||||
|
|
|
|||
133
src/lib/crop.py
133
src/lib/crop.py
|
|
@ -1,4 +1,4 @@
|
|||
"""Resize-to-cover with face-aware positioning.
|
||||
"""Resize-to-cover with face-aware positioning and head-fit checks.
|
||||
|
||||
When a portrait source is cropped onto a landscape target, the face joint-span
|
||||
centre lands on the top third of the crop window instead of the middle, so the
|
||||
|
|
@ -6,6 +6,7 @@ eyes sit on the upper-third line where landscape composition naturally reads.
|
|||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
|
@ -14,6 +15,102 @@ from PIL import Image
|
|||
# bare face centre.
|
||||
HEAD_EXTENSION = 0.4
|
||||
|
||||
# Extra room around the head-extended face box required before a photo is
|
||||
# accepted for display.
|
||||
HEAD_SAFETY_MARGIN = 0.08
|
||||
|
||||
_FIT_EPSILON = 1e-6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CropGeometry:
|
||||
"""Resize-to-cover geometry used by the face-aware crop."""
|
||||
|
||||
resized_size: tuple[int, int]
|
||||
crop_box: tuple[int, int, int, int]
|
||||
head_boxes: list[tuple[float, float, float, float]]
|
||||
|
||||
|
||||
def _cover_size(img_w: int, img_h: int, target_w: int, target_h: int) -> tuple[int, int]:
|
||||
img_aspect = img_w / img_h
|
||||
target_aspect = target_w / target_h
|
||||
if img_aspect < target_aspect:
|
||||
return target_w, math.ceil(target_w / img_aspect)
|
||||
return math.ceil(target_h * img_aspect), target_h
|
||||
|
||||
|
||||
def _head_boxes(
|
||||
faces: list[dict], img_w: int, img_h: int, new_w: int, new_h: int
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
boxes = []
|
||||
for f in faces:
|
||||
sx = new_w / (f.get("imageWidth") or img_w)
|
||||
sy = new_h / (f.get("imageHeight") or img_h)
|
||||
x1 = f["boundingBoxX1"] * sx
|
||||
y1 = f["boundingBoxY1"] * sy
|
||||
x2 = f["boundingBoxX2"] * sx
|
||||
y2 = f["boundingBoxY2"] * sy
|
||||
face_w = x2 - x1
|
||||
face_h = y2 - y1
|
||||
x_margin = face_w * HEAD_SAFETY_MARGIN
|
||||
y_margin = face_h * HEAD_SAFETY_MARGIN
|
||||
boxes.append(
|
||||
(
|
||||
x1 - x_margin,
|
||||
y1 - face_h * HEAD_EXTENSION - y_margin,
|
||||
x2 + x_margin,
|
||||
y2 + y_margin,
|
||||
)
|
||||
)
|
||||
return boxes
|
||||
|
||||
|
||||
def face_aware_crop_geometry(
|
||||
image_size: tuple[int, int], target_w: int, target_h: int, faces: list[dict]
|
||||
) -> CropGeometry:
|
||||
"""Return the resize size, crop box, and safety-expanded head boxes."""
|
||||
img_w, img_h = image_size
|
||||
new_w, new_h = _cover_size(img_w, img_h, target_w, target_h)
|
||||
|
||||
cx, cy = new_w / 2, new_h / 2
|
||||
boxes = _head_boxes(faces, img_w, img_h, new_w, new_h) if faces else []
|
||||
if boxes:
|
||||
cx = (min(b[0] for b in boxes) + max(b[2] for b in boxes)) / 2
|
||||
cy = (min(b[1] for b in boxes) + max(b[3] for b in boxes)) / 2
|
||||
|
||||
y_anchor = target_h / 3 if img_h > img_w and target_w > target_h else target_h / 2
|
||||
x_off = max(0, min(int(cx - target_w / 2), new_w - target_w))
|
||||
y_off = max(0, min(int(cy - y_anchor), new_h - target_h))
|
||||
return CropGeometry(
|
||||
resized_size=(new_w, new_h),
|
||||
crop_box=(x_off, y_off, x_off + target_w, y_off + target_h),
|
||||
head_boxes=boxes,
|
||||
)
|
||||
|
||||
|
||||
def heads_fit_in_crop_size(
|
||||
image_size: tuple[int, int], target_w: int, target_h: int, faces: list[dict]
|
||||
) -> bool:
|
||||
"""True when the face-aware crop keeps every visible head area inside."""
|
||||
if not faces:
|
||||
return True
|
||||
|
||||
geometry = face_aware_crop_geometry(image_size, target_w, target_h, faces)
|
||||
new_w, new_h = geometry.resized_size
|
||||
crop_x1, crop_y1, crop_x2, crop_y2 = geometry.crop_box
|
||||
return all(
|
||||
max(0, head_x1) >= crop_x1 - _FIT_EPSILON
|
||||
and max(0, head_y1) >= crop_y1 - _FIT_EPSILON
|
||||
and min(new_w, head_x2) <= crop_x2 + _FIT_EPSILON
|
||||
and min(new_h, head_y2) <= crop_y2 + _FIT_EPSILON
|
||||
for head_x1, head_y1, head_x2, head_y2 in geometry.head_boxes
|
||||
)
|
||||
|
||||
|
||||
def heads_fit_in_crop(image: Image.Image, target_w: int, target_h: int, faces: list[dict]) -> bool:
|
||||
"""True when `face_aware_crop` would keep all heads inside the output frame."""
|
||||
return heads_fit_in_crop_size(image.size, target_w, target_h, faces)
|
||||
|
||||
|
||||
def face_aware_crop(
|
||||
image: Image.Image, target_w: int, target_h: int, faces: list[dict]
|
||||
|
|
@ -25,35 +122,7 @@ def face_aware_crop(
|
|||
the top third of the crop window (rule of thirds) instead of the middle.
|
||||
Plain centre crop when no faces.
|
||||
"""
|
||||
img_w, img_h = image.size
|
||||
img_aspect = img_w / img_h
|
||||
target_aspect = target_w / target_h
|
||||
if img_aspect < target_aspect:
|
||||
new_w = target_w
|
||||
new_h = math.ceil(target_w / img_aspect)
|
||||
else:
|
||||
new_w = math.ceil(target_h * img_aspect)
|
||||
new_h = target_h
|
||||
|
||||
geometry = face_aware_crop_geometry(image.size, target_w, target_h, faces)
|
||||
new_w, new_h = geometry.resized_size
|
||||
resized = image.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
cx, cy = new_w / 2, new_h / 2
|
||||
if faces:
|
||||
boxes = []
|
||||
for f in faces:
|
||||
sx = new_w / (f.get("imageWidth") or img_w)
|
||||
sy = new_h / (f.get("imageHeight") or img_h)
|
||||
x1 = f["boundingBoxX1"] * sx
|
||||
y1 = f["boundingBoxY1"] * sy
|
||||
x2 = f["boundingBoxX2"] * sx
|
||||
y2 = f["boundingBoxY2"] * sy
|
||||
boxes.append((x1, y1, x2, y2))
|
||||
cx = (min(b[0] for b in boxes) + max(b[2] for b in boxes)) / 2
|
||||
y_lo_ext = min(b[1] - (b[3] - b[1]) * HEAD_EXTENSION for b in boxes)
|
||||
y_hi = max(b[3] for b in boxes)
|
||||
cy = (y_lo_ext + y_hi) / 2
|
||||
|
||||
y_anchor = target_h / 3 if img_h > img_w and target_w > target_h else target_h / 2
|
||||
x_off = max(0, min(int(cx - target_w / 2), new_w - target_w))
|
||||
y_off = max(0, min(int(cy - y_anchor), new_h - target_h))
|
||||
return resized.crop((x_off, y_off, x_off + target_w, y_off + target_h))
|
||||
return resized.crop(geometry.crop_box)
|
||||
|
|
|
|||
|
|
@ -8,16 +8,20 @@ from datetime import UTC, datetime, timedelta
|
|||
from pathlib import Path
|
||||
from urllib.request import Request
|
||||
|
||||
from crop import heads_fit_in_crop
|
||||
from net import urlopen_with_retry
|
||||
from PIL import Image
|
||||
|
||||
HISTORY_FILE = Path(__file__).parent.parent / "photo_history.json"
|
||||
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_cache"
|
||||
|
||||
# Soft preference for picking photos whose orientation matches the frame.
|
||||
# Mismatched-orientation photos still appear, just less often, since
|
||||
# face_aware_crop handles them via the rule-of-thirds composition.
|
||||
# face_aware_crop can often compose them without losing heads.
|
||||
ORIENTATION_MATCH_WEIGHT = 0.8
|
||||
ORIENTATION_DIFFER_WEIGHT = 0.2
|
||||
FRAME_LANDSCAPE = (800, 480)
|
||||
FRAME_PORTRAIT = (480, 800)
|
||||
|
||||
_ROTATED_EXIF_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"}
|
||||
|
||||
|
|
@ -184,6 +188,11 @@ def _bias_by_orientation(candidates: list[dict], frame_portrait: bool) -> list[d
|
|||
return pool
|
||||
|
||||
|
||||
def target_size_for_orientation(orientation: int) -> tuple[int, int]:
|
||||
"""Pre-rotation crop target for the Waveshare panel."""
|
||||
return FRAME_PORTRAIT if orientation in (90, 270) else FRAME_LANDSCAPE
|
||||
|
||||
|
||||
def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
|
||||
"""Photos taken on today's month-day in past years, with a ±3-day fallback.
|
||||
|
||||
|
|
@ -241,6 +250,75 @@ def _pick_weighted_random(assets: list[dict]) -> dict:
|
|||
return random.choice(pool)
|
||||
|
||||
|
||||
def _asset_label(asset: dict) -> str:
|
||||
return asset.get("originalFileName") or asset.get("originalPath") or asset.get("id", "unknown")
|
||||
|
||||
|
||||
def _download_if_heads_fit(
|
||||
client: ImmichClient, asset: dict, target_w: int, target_h: int
|
||||
) -> tuple[Path, dict] | None:
|
||||
faces = client.get_asset_faces(asset["id"])
|
||||
with tempfile.NamedTemporaryFile(prefix="immich_photo_", suffix=".jpg", delete=False) as tmp:
|
||||
dest = Path(tmp.name)
|
||||
|
||||
path = client.download_asset(asset["id"], dest)
|
||||
try:
|
||||
if faces:
|
||||
with Image.open(path) as img:
|
||||
fits = heads_fit_in_crop(img, target_w, target_h, faces)
|
||||
if not fits:
|
||||
path.unlink(missing_ok=True)
|
||||
print(
|
||||
f"Rejected photo: {_asset_label(asset)} "
|
||||
f"(heads do not fit {target_w}x{target_h} crop)"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
selected = dict(asset)
|
||||
selected["_faces"] = faces
|
||||
return path, selected
|
||||
|
||||
|
||||
def _pick_eligible_and_download(
|
||||
client: ImmichClient,
|
||||
candidates: list[dict],
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
rejected_ids: set[str],
|
||||
) -> tuple[Path, dict] | None:
|
||||
remaining = [a for a in candidates if a.get("id") not in rejected_ids]
|
||||
while remaining:
|
||||
asset = _pick_weighted_random(remaining)
|
||||
asset_id = asset["id"]
|
||||
result = _download_if_heads_fit(client, asset, target_w, target_h)
|
||||
if result is not None:
|
||||
return result
|
||||
rejected_ids.add(asset_id)
|
||||
remaining = [a for a in remaining if a.get("id") not in rejected_ids]
|
||||
return None
|
||||
|
||||
|
||||
def _pick_eligible_with_orientation_bias(
|
||||
client: ImmichClient,
|
||||
candidates: list[dict],
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
frame_portrait: bool,
|
||||
rejected_ids: set[str],
|
||||
) -> tuple[Path, dict] | None:
|
||||
biased_candidates = _bias_by_orientation(candidates, frame_portrait)
|
||||
result = _pick_eligible_and_download(
|
||||
client, biased_candidates, target_w, target_h, rejected_ids
|
||||
)
|
||||
if result is None and len(biased_candidates) < len(candidates):
|
||||
print("No eligible photos in picked orientation pool, trying other orientations")
|
||||
result = _pick_eligible_and_download(client, candidates, target_w, target_h, rejected_ids)
|
||||
return result
|
||||
|
||||
|
||||
def _pick_and_download(
|
||||
client: ImmichClient, assets: list[dict], orientation: int, source_label: str
|
||||
) -> tuple[Path, dict]:
|
||||
|
|
@ -249,18 +327,33 @@ def _pick_and_download(
|
|||
|
||||
displayed, created_at = _load_history()
|
||||
candidates = [a for a in assets if a.get("id") not in displayed]
|
||||
history_filtered = len(candidates) < len(assets)
|
||||
if not candidates:
|
||||
print(f"All {len(assets)} photos shown, picking from full list")
|
||||
candidates = assets
|
||||
else:
|
||||
print(f"Photos: {len(candidates)} new / {len(assets)} total")
|
||||
|
||||
candidates = _bias_by_orientation(candidates, orientation in (90, 270))
|
||||
target_w, target_h = target_size_for_orientation(orientation)
|
||||
rejected_ids: set[str] = set()
|
||||
frame_portrait = orientation in (90, 270)
|
||||
result = _pick_eligible_with_orientation_bias(
|
||||
client, candidates, target_w, target_h, frame_portrait, rejected_ids
|
||||
)
|
||||
|
||||
asset = _pick_weighted_random(candidates)
|
||||
with tempfile.NamedTemporaryFile(prefix="immich_photo_", suffix=".jpg", delete=False) as tmp:
|
||||
dest = Path(tmp.name)
|
||||
path = client.download_asset(asset["id"], dest)
|
||||
if result is None and history_filtered:
|
||||
print("No eligible new photos after head-fit checks, picking from full list")
|
||||
result = _pick_eligible_with_orientation_bias(
|
||||
client, assets, target_w, target_h, frame_portrait, rejected_ids
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError(
|
||||
f"No photos in {source_label} can be cropped to {target_w}x{target_h} "
|
||||
"without cutting off heads"
|
||||
)
|
||||
|
||||
path, asset = result
|
||||
displayed.add(asset["id"])
|
||||
_save_history(displayed, created_at)
|
||||
return path, asset
|
||||
|
|
@ -291,4 +384,4 @@ def get_random_photo_from_album(
|
|||
if not assets:
|
||||
raise ValueError(f"No photos in album: {album_name}")
|
||||
|
||||
return _pick_and_download(client, assets, orientation, f"album: {album_name}")
|
||||
return _pick_and_download(client, assets, orientation, f"album {album_name!r}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue