diff --git a/.gitignore b/.gitignore index 723ef36..d208dff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -.idea \ No newline at end of file +.idea +dlib-19.24.99-cp312-cp312-win_amd64.whl +mmod_human_face_detector.dat +shape_predictor_68_face_landmarks.dat +output/ diff --git a/immich_selfie_timelapse.py b/immich_selfie_timelapse.py index dcecf8a..ac1d61f 100644 --- a/immich_selfie_timelapse.py +++ b/immich_selfie_timelapse.py @@ -1,14 +1,4 @@ -#!/usr/bin/env python3 -""" -This tool helps create selfie timelapses from your Immich instance. -It uses the powerful machine learning features of Immich to gather all the photographs where a particular individual -appears, retrieves the bounding box metadata, and automatically crops and aligns the photos. -Some manual sorting is still required to achieve the best effect in the video. -I personally found that a video frame rate of 15 fps looks pretty good. - -Script by Arnaud Cayrol -""" - +# timelapse.py import os import io @@ -36,7 +26,6 @@ class TqdmLoggingHandler(logging.Handler): except Exception: self.handleError(record) -# Configure logging logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) tqdm_handler = TqdmLoggingHandler() @@ -150,13 +139,11 @@ def align_face(image, predictor, detector, desired_face_width, desired_face_heig image_np = np.array(image) gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) - # Detect faces using the provided detector. detections = detector(gray) if not detections: logger.info("No face detected in the crop. Discarding.") return None - # If using CNN detector, extract the rectangle from the detection. if hasattr(detections[0], "rect"): rect = detections[0].rect else: @@ -203,7 +190,6 @@ def process_asset_worker(asset, api_key, base_url, person_id, output_folder, cnn_model_path, predictor_model_path): detector = dlib.cnn_face_detection_model_v1(cnn_model_path) - # Use predictor_model_path from command line instead of hard-coded path. local_predictor = dlib.shape_predictor(predictor_model_path) try: asset_id = asset['id'] @@ -245,45 +231,39 @@ def process_asset_wrapper(asset, process_args): return process_asset_worker(asset, *process_args) -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 orientation towards camera") - 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") - parser.add_argument("--face-detect-model-path", default="mmod_human_face_detector.dat", help="Path to the CNN face detector model file") - parser.add_argument("--landmark-model-path", default="shape_predictor_68_face_landmarks.dat", help="Path to the face landmark predictor model file") - args = parser.parse_args() +def process_faces( + api_key, + base_url, + person_id, + output_folder="output", + padding_percent=0.3, + resize_width=512, + resize_height=512, + min_face_width=128, + min_face_height=128, + pose_threshold=25, + desired_left_eye=(0.35, 0.45), + max_workers=4, + face_detect_model_path="mmod_human_face_detector.dat", + landmark_model_path="shape_predictor_68_face_landmarks.dat" +): + os.makedirs(output_folder, exist_ok=True) - os.makedirs(args.output_folder, exist_ok=True) - - assets = get_assets_with_person(args.api_key, args.base_url, args.person_id) + assets = get_assets_with_person(api_key, base_url, person_id) logger.info(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), - args.face_detect_model_path, args.landmark_model_path + 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, + face_detect_model_path, landmark_model_path ) - with concurrent.futures.ProcessPoolExecutor(max_workers=args.max_workers) as executor: + with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor: results = list(tqdm( executor.map(process_asset_wrapper, assets, [process_args] * len(assets)), total=len(assets) )) processed_files = [r for r in results if r is not None] logger.info(f"Finished processing. {len(processed_files)} images saved.") - - -if __name__ == "__main__": - main() + return processed_files diff --git a/main.py b/main.py new file mode 100644 index 0000000..19a19d1 --- /dev/null +++ b/main.py @@ -0,0 +1,52 @@ +# main.py + +from flask import Flask, request, render_template +from immich_selfie_timelapse import process_faces + +app = Flask(__name__) + + +@app.route("/", methods=["GET", "POST"]) +def index(): + result = None + if request.method == "POST": + # Read form fields, converting as necessary. + api_key = request.form["api_key"] + base_url = request.form["base_url"] + person_id = request.form["person_id"] + output_folder = request.form.get("output_folder", "output") + padding_percent = float(request.form.get("padding_percent", 0.3)) + resize_width = int(request.form.get("resize_width", 512)) + resize_height = int(request.form.get("resize_height", 512)) + min_face_width = int(request.form.get("min_face_width", 128)) + min_face_height = int(request.form.get("min_face_height", 128)) + pose_threshold = float(request.form.get("pose_threshold", 25)) + desired_left_eye_x = float(request.form.get("desired_left_eye_x", 0.35)) + desired_left_eye_y = float(request.form.get("desired_left_eye_y", 0.45)) + max_workers = int(request.form.get("max_workers", 4)) + face_detect_model_path = request.form.get("face_detect_model_path", "mmod_human_face_detector.dat") + landmark_model_path = request.form.get("landmark_model_path", "shape_predictor_68_face_landmarks.dat") + + # Run the process_faces function with provided parameters + processed_files = process_faces( + api_key=api_key, + base_url=base_url, + person_id=person_id, + output_folder=output_folder, + padding_percent=padding_percent, + resize_width=resize_width, + resize_height=resize_height, + min_face_width=min_face_width, + min_face_height=min_face_height, + pose_threshold=pose_threshold, + desired_left_eye=(desired_left_eye_x, desired_left_eye_y), + max_workers=max_workers, + face_detect_model_path=face_detect_model_path, + landmark_model_path=landmark_model_path + ) + result = f"Finished processing. {len(processed_files)} images saved in {output_folder}" + return render_template("index.html", result=result) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a0a6e74 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,30 @@ + + + + Immich Selfie Timelapse Tool + + +

Create a Selfie Timelapse

+
+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+ +
+ {% if result %} +

{{ result }}

+ {% endif %} + +