From 44af006d7355c200cc16b62de2eab62b15129ed0 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Fri, 28 Mar 2025 20:15:48 +0100 Subject: [PATCH] no message --- .idea/.gitignore | 3 + .idea/ImmichFaceTimelapse.iml | 10 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + immich_selfie_timelapse.py | 244 ++++++++++++++++++ main.py | 16 ++ 8 files changed, 300 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/ImmichFaceTimelapse.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 immich_selfie_timelapse.py create mode 100644 main.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/ImmichFaceTimelapse.iml b/.idea/ImmichFaceTimelapse.iml new file mode 100644 index 0000000..2c80e12 --- /dev/null +++ b/.idea/ImmichFaceTimelapse.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d8332b2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3997f2f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/immich_selfie_timelapse.py b/immich_selfie_timelapse.py new file mode 100644 index 0000000..3000b48 --- /dev/null +++ b/immich_selfie_timelapse.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Script to process assets containing a specific person and align faces. +""" + +import os +import io +import requests +import concurrent.futures +import argparse +from datetime import datetime + +from PIL import Image, ImageOps +import numpy as np +import cv2 +import dlib +from tqdm import tqdm + + +def get_assets_with_person(api_key, base_url, person_id): + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'x-api-key': api_key, + } + url = f"{base_url}/search/metadata" + all_assets = [] + payload = { + "page": 1, + "type": "IMAGE", + "personIds": [person_id], + "withArchived": False, + "withDeleted": True, + "withExif": True, + "withPeople": True, + "withStacked": True, + } + while payload["page"] is not None: + response = requests.post(url, headers=headers, json=payload) + if response.status_code != 200: + print(f"Error fetching page {payload['page']}: {response.status_code} - {response.text}") + break + data = response.json() + if not data: + break + all_assets.extend(data['assets']['items']) + print(f"Fetched page {payload['page']} with {len(data['assets']['items'])} assets") + payload["page"] = data['assets'].get('nextPage') + return all_assets + + +def download_asset(api_key, base_url, asset_id): + headers = {'x-api-key': api_key} + response = requests.get(f'{base_url}/assets/{asset_id}/original', headers=headers) + response.raise_for_status() + return response.content + + +def format_timestamp(timestamp): + dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + return dt.strftime("%Y%m%d_%H%M%S") + + +def crop_face_from_metadata(image, face_data, padding_percent): + face_img_width = face_data.get("imageWidth") + face_img_height = face_data.get("imageHeight") + img_width, img_height = image.size + scale_x = img_width / face_img_width + scale_y = img_height / face_img_height + x1 = int(face_data.get("boundingBoxX1", 0) * scale_x) + x2 = int(face_data.get("boundingBoxX2", 0) * scale_x) + y1 = int(face_data.get("boundingBoxY1", 0) * scale_y) + y2 = int(face_data.get("boundingBoxY2", 0) * scale_y) + w = x2 - x1 + h = y2 - y1 + padding = int(max(w, h) * padding_percent) + new_x1 = max(x1 - padding, 0) + new_y1 = max(y1 - padding, 0) + new_x2 = min(x2 + padding, img_width) + new_y2 = min(y2 + padding, img_height) + return image.crop((new_x1, new_y1, new_x2, new_y2)) + + +def get_head_pose(shape, img_size): + image_points = np.array([ + (shape.part(30).x, shape.part(30).y), + (shape.part(8).x, shape.part(8).y), + (shape.part(36).x, shape.part(36).y), + (shape.part(45).x, shape.part(45).y), + (shape.part(48).x, shape.part(48).y), + (shape.part(54).x, shape.part(54).y) + ], dtype="double") + model_points = np.array([ + (0.0, 0.0, 0.0), + (0.0, -330.0, -65.0), + (-225.0, 170.0, -135.0), + (225.0, 170.0, -135.0), + (-150.0, -150.0, -125.0), + (150.0, -150.0, -125.0) + ]) + w, h = img_size + focal_length = w + center = (w / 2, h / 2) + camera_matrix = np.array( + [[focal_length, 0, center[0]], + [0, focal_length, center[1]], + [0, 0, 1]], dtype="double" + ) + dist_coeffs = np.zeros((4, 1)) + success, rotation_vector, translation_vector = cv2.solvePnP( + model_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE + ) + rotation_mat, _ = cv2.Rodrigues(rotation_vector) + proj_matrix = np.hstack((rotation_mat, translation_vector)) + _, _, _, _, _, _, eulerAngles = cv2.decomposeProjectionMatrix(proj_matrix) + pitch, yaw, roll = [float(angle) for angle in eulerAngles] + return pitch, yaw, roll + + +def align_face(image, predictor, detector, desired_face_width, desired_face_height, + desired_left_eye, pose_threshold): + image_np = np.array(image) + gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) + rects = detector(gray, 2) + if not rects: + print("No face detected in the crop. Discarding.") + return None + rect = rects[0] + shape = predictor(gray, rect) + img_size = (image_np.shape[1], image_np.shape[0]) + pitch, yaw, roll = get_head_pose(shape, img_size) + if abs(abs(pitch) - 180) > pose_threshold or abs(yaw) > pose_threshold: + print(f"Face not frontal enough: pitch={pitch:.2f}, yaw={yaw:.2f}, roll={roll:.2f}. Discarding.") + return None + shape_np = np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)], dtype="int") + left_eye_center = shape_np[36:42].mean(axis=0).astype("int") + right_eye_center = shape_np[42:48].mean(axis=0).astype("int") + dY = right_eye_center[1] - left_eye_center[1] + dX = right_eye_center[0] - left_eye_center[0] + angle = np.degrees(np.arctan2(dY, dX)) + eye_distance = np.linalg.norm(right_eye_center - left_eye_center) + desired_right_eye_x = 1.0 - desired_left_eye[0] + desired_eye_distance = (desired_right_eye_x - desired_left_eye[0]) * desired_face_width + scale = desired_eye_distance / eye_distance + eyes_center = ((left_eye_center[0] + right_eye_center[0]) / 2.0, + (left_eye_center[1] + right_eye_center[1]) / 2.0) + adjusted_scale = scale * 0.8 + M = cv2.getRotationMatrix2D(eyes_center, angle, adjusted_scale) + extra_offset_x = 10 + tX = desired_face_width * 0.5 + extra_offset_x + tY = desired_face_height * desired_left_eye[1] + M[0, 2] += (tX - eyes_center[0]) + M[1, 2] += (tY - eyes_center[1]) + aligned_face_np = cv2.warpAffine( + image_np, + M, + (desired_face_width, desired_face_height), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE + ) + return Image.fromarray(aligned_face_np) + + +def process_asset_worker(asset, api_key, base_url, person_id, output_folder, + padding_percent, min_face_width, min_face_height, + resize_width, resize_height, pose_threshold, desired_left_eye): + local_detector = dlib.get_frontal_face_detector() + predictor_path = "shape_predictor_68_face_landmarks.dat" + local_predictor = dlib.shape_predictor(predictor_path) + try: + asset_id = asset['id'] + timestamp = format_timestamp(asset['fileCreatedAt']) + image_bytes = download_asset(api_key, base_url, asset_id) + image = Image.open(io.BytesIO(image_bytes)) + image = ImageOps.exif_transpose(image) + image = image.convert("RGB") + except Exception as e: + print(f"Error processing asset {asset.get('id')}: {e}") + return None + matching_person = next((p for p in asset.get('people', []) if p.get('id') == person_id), None) + if not matching_person: + print("Subject not in image.") + return None + faces = matching_person.get('faces', []) + if not faces: + print("No face data available.") + return None + face_data = faces[0] + cropped_face = crop_face_from_metadata(image, face_data, padding_percent) + face_width, face_height = cropped_face.size + if face_width < min_face_width or face_height < min_face_height: + print(f"Face resolution too low ({face_width}x{face_height}).") + return None + aligned_face = align_face(cropped_face, local_predictor, local_detector, + desired_face_width=resize_width, + desired_face_height=resize_height, + desired_left_eye=desired_left_eye, + pose_threshold=pose_threshold) + if aligned_face is None: + return None + filename = os.path.join(output_folder, f"{timestamp}.jpg") + aligned_face.save(filename) + return filename + + +def main(): + parser = argparse.ArgumentParser(description="Process and align faces from assets.") + parser.add_argument("--api-key", required=True, help="API key for authentication") + parser.add_argument("--base-url", required=True, help="Base URL for the API") + parser.add_argument("--person-id", required=True, help="ID of the person to search for") + parser.add_argument("--output-folder", default="output", help="Folder to save output images") + parser.add_argument("--padding-percent", type=float, default=0.3, help="Padding percentage for face crop") + parser.add_argument("--resize-width", type=int, default=512, help="Output image width") + parser.add_argument("--resize-height", type=int, default=512, help="Output image height") + parser.add_argument("--min-face-width", type=int, default=128, help="Minimum face width") + parser.add_argument("--min-face-height", type=int, default=128, help="Minimum face height") + parser.add_argument("--pose-threshold", type=float, default=25, help="Threshold for acceptable head pose") + parser.add_argument("--desired-left-eye", type=float, nargs=2, default=[0.35, 0.45], + help="Desired left eye position as fraction (x y) in the output image") + parser.add_argument("--max-workers", type=int, default=4, help="Maximum number of parallel workers") + args = parser.parse_args() + + os.makedirs(args.output_folder, exist_ok=True) + + assets = get_assets_with_person(args.api_key, args.base_url, args.person_id) + print(f"Found {len(assets)} assets containing the person.") + + process_args = ( + args.api_key, args.base_url, args.person_id, args.output_folder, + args.padding_percent, args.min_face_width, args.min_face_height, + args.resize_width, args.resize_height, args.pose_threshold, tuple(args.desired_left_eye) + ) + + with concurrent.futures.ProcessPoolExecutor(max_workers=args.max_workers) as executor: + results = list(tqdm( + executor.map(lambda asset: process_asset_worker(asset, *process_args), assets), + total=len(assets) + )) + processed_files = [r for r in results if r is not None] + print(f"Finished processing. {len(processed_files)} images saved.") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..20a033a --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +# This is a sample Python script. + +# Press Maj+F10 to execute it or replace it with your code. +# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings. + + +def print_hi(name): + # Use a breakpoint in the code line below to debug your script. + print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint. + + +# Press the green button in the gutter to run the script. +if __name__ == '__main__': + print_hi('PyCharm') + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/