Simplified process parameters

This commit is contained in:
Arnaud_Cayrol 2025-04-12 14:45:22 +02:00
parent 2bfc68c520
commit 9df9d7519c
2 changed files with 43 additions and 91 deletions

84
main.py
View file

@ -2,13 +2,11 @@ import logging
import multiprocessing import multiprocessing
import os import os
import threading import threading
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from typing import Callable, Dict, List, Tuple
from typing import Callable, Dict, List, Optional, Tuple
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request
from timelapse import ProcessConfig, process_faces, validate_immich_connection from timelapse import process_faces, validate_immich_connection
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,16 +25,15 @@ class AppConfig:
"""Configuration for the application.""" """Configuration for the application."""
api_key: str = os.environ.get("IMMICH_API_KEY", "") api_key: str = os.environ.get("IMMICH_API_KEY", "")
base_url: str = os.environ.get("IMMICH_BASE_URL", "") base_url: str = os.environ.get("IMMICH_BASE_URL", "")
person_id: str = None
output_folder: str = "output" output_folder: str = "output"
landmark_model: str ="shape_predictor_68_face_landmarks.dat" landmark_model: str = "shape_predictor_68_face_landmarks.dat"
default_resize_size: int = 512 resize_size: int = 512
default_face_resolution_threshold: int = 128 face_resolution_threshold: int = 128
default_pose_threshold: float = 25.0 pose_threshold: float = 25.0
default_left_eye_pos: Tuple[float, float] = (0.35, 0.4) left_eye_pos: Tuple[float, float] = (0.35, 0.4)
default_framerate: int = 24 date_from: str = None
default_date_format: str = "%Y-%m-%d" date_to: str = None
# Initialize Flask app # Initialize Flask app
app = Flask(__name__) app = Flask(__name__)
@ -44,7 +41,7 @@ app = Flask(__name__)
# Global state # Global state
AVAILABLE_CORES = multiprocessing.cpu_count() AVAILABLE_CORES = multiprocessing.cpu_count()
progress_info: Dict[str, any] = {"completed": 0, "total": 0, "status": "idle"} progress_info: Dict[str, any] = {"completed": 0, "total": 0, "status": "idle"}
processing_thread: Optional[threading.Thread] = None processing_thread: threading.Thread = None
cancel_requested: bool = False cancel_requested: bool = False
config = AppConfig() config = AppConfig()
@ -74,26 +71,12 @@ def check_output_folder() -> Tuple[bool, int]:
return len(files) == 0, len(files) return len(files) == 0, len(files)
def background_process( def background_process(
person_id: str, progress_callback: Callable = None,
resize_size: int, cancel_flag: Callable = None
face_resolution_threshold: int,
pose_threshold: float,
left_eye_pos: Tuple[float, float],
date_from: Optional[str] = None,
date_to: Optional[str] = None,
progress_callback: Optional[Callable] = None,
cancel_flag: Optional[Callable] = None
) -> List[str]: ) -> List[str]:
"""Process faces in the background. """Process faces in the background.
Args: Args:
person_id: ID of the person to process
resize_size: Size to resize output images to
face_resolution_threshold: Minimum face resolution threshold
pose_threshold: Maximum allowed head pose deviation
left_eye_pos: Desired position of the left eye in output
date_from: Optional start date in YYYY-MM-DD format
date_to: Optional end date in YYYY-MM-DD format
progress_callback: Optional callback for progress updates progress_callback: Optional callback for progress updates
cancel_flag: Optional function to check for cancellation cancel_flag: Optional function to check for cancellation
@ -101,24 +84,8 @@ def background_process(
List of processed file paths List of processed file paths
""" """
try: try:
process_config = ProcessConfig(
api_key=config.api_key,
base_url=config.base_url,
person_id=person_id,
output_folder=config.output_folder,
resize_width=resize_size,
resize_height=resize_size,
min_face_width=face_resolution_threshold,
min_face_height=face_resolution_threshold,
pose_threshold=pose_threshold,
left_eye_pos=left_eye_pos,
landmark_model_path=config.landmark_model,
date_from=date_from,
date_to=date_to
)
return process_faces( return process_faces(
config=process_config, config=config,
max_workers=1, max_workers=1,
progress_callback=progress_callback, progress_callback=progress_callback,
cancel_flag=cancel_flag cancel_flag=cancel_flag
@ -178,20 +145,17 @@ def index() -> str:
try: try:
cancel_requested = False cancel_requested = False
# Get form data with defaults # Get form data
person_id = request.form["person_id"] config.person_id = request.form["person_id"]
resize_size = int(request.form.get("resize_size", config.default_resize_size)) config.resize_size = int(request.form.get("resize_size"))
face_resolution_threshold = int(request.form.get("face_resolution_threshold", config.face_resolution_threshold = int(request.form.get("face_resolution_threshold"))
config.default_face_resolution_threshold)) config.pose_threshold = float(request.form.get("pose_threshold"))
pose_threshold = float(request.form.get("pose_threshold", config.default_pose_threshold)) config.date_from = request.form.get("date_from")
config.date_to = request.form.get("date_to")
# Optional date ranges
date_from = request.form.get("date_from") or None
date_to = request.form.get("date_to") or None
# Video compilation options # Video compilation options
compile_video = request.form.get("compile_video") == "on" compile_video = request.form.get("compile_video") == "on"
framerate = int(request.form.get("framerate", config.default_framerate)) framerate = int(request.form.get("framerate", 15))
# Reset progress info # Reset progress info
progress_info.update({ progress_info.update({
@ -204,9 +168,7 @@ def index() -> str:
# Start processing # Start processing
processing_thread = threading.Thread( processing_thread = threading.Thread(
target=background_process, target=background_process,
args=(person_id, resize_size, face_resolution_threshold, pose_threshold, args=(update_progress, lambda: cancel_requested)
config.default_left_eye_pos, config.output_folder, date_from, date_to,
update_progress, lambda: cancel_requested)
) )
processing_thread.start() processing_thread.start()
result = "Processing started. Please wait and watch the progress bar below." result = "Processing started. Please wait and watch the progress bar below."

View file

@ -11,7 +11,7 @@ import cv2
import dlib import dlib
from tqdm import tqdm from tqdm import tqdm
import logging import logging
from typing import Tuple
class TqdmLoggingHandler(logging.Handler): class TqdmLoggingHandler(logging.Handler):
def __init__(self, level=logging.NOTSET): def __init__(self, level=logging.NOTSET):
@ -36,21 +36,19 @@ face_predictor = None
@dataclass @dataclass
class ProcessConfig: class AppConfig:
""" """Configuration for the application."""
Dataclass to hold configuration parameters for processing assets.
"""
api_key: str api_key: str
base_url: str base_url: str
person_id: str person_id: str
output_folder: str = "output" output_folder: str
resize_width: int = 512 landmark_model: str
resize_height: int = 512 resize_size: int
min_face_width: int = 128 face_resolution_threshold: int
min_face_height: int = 128 pose_threshold: float
pose_threshold: float = 25 left_eye_pos: Tuple[float, float]
left_eye_pos: tuple = (0.35, 0.4) date_from: str
landmark_model_path: str = "shape_predictor_68_face_landmarks.dat" date_to: str
def draw_landmarks(image, landmarks, face_rect, output_path): def draw_landmarks(image, landmarks, face_rect, output_path):
""" """
@ -486,7 +484,7 @@ def crop_and_align_face(image, face_data, resize_size, face_resolution_threshold
return None return None
def process_asset_worker(asset, config: ProcessConfig): def process_asset_worker(asset, config: AppConfig):
""" """
Worker function to process a single asset. Worker function to process a single asset.
@ -495,14 +493,13 @@ def process_asset_worker(asset, config: ProcessConfig):
Args: Args:
asset (dict): The asset metadata. asset (dict): The asset metadata.
config (ProcessConfig): Configuration parameters. config (AppConfig): Configuration parameters.
Returns: Returns:
str or None: The file path of the saved image if processing is successful; otherwise None. str or None: The file path of the saved image if processing is successful; otherwise None.
""" """
try: try:
asset_id = asset['id'] asset_id = asset['id']
image_bytes = download_asset(config.api_key, config.base_url, asset_id) image_bytes = download_asset(config.api_key, config.base_url, asset_id)
image = Image.open(io.BytesIO(image_bytes)) image = Image.open(io.BytesIO(image_bytes))
image = ImageOps.exif_transpose(image) image = ImageOps.exif_transpose(image)
@ -514,12 +511,11 @@ def process_asset_worker(asset, config: ProcessConfig):
matching_person = next((p for p in asset.get('people', []) if p.get('id') == config.person_id), None) matching_person = next((p for p in asset.get('people', []) if p.get('id') == config.person_id), None)
face_data = matching_person.get('faces', [])[0] face_data = matching_person.get('faces', [])[0]
aligned_face = crop_and_align_face( aligned_face = crop_and_align_face(
image, image,
face_data, face_data,
resize_size=config.resize_width, resize_size=config.resize_size,
face_resolution_threshold=config.min_face_width, face_resolution_threshold=config.face_resolution_threshold,
pose_threshold=config.pose_threshold, pose_threshold=config.pose_threshold,
left_eye_pos=config.left_eye_pos left_eye_pos=config.left_eye_pos
) )
@ -527,15 +523,13 @@ def process_asset_worker(asset, config: ProcessConfig):
if aligned_face is None: if aligned_face is None:
return None return None
os.makedirs(config.output_folder, exist_ok=True)
dt = datetime.fromisoformat(asset['fileCreatedAt'].replace("Z", "+00:00")) dt = datetime.fromisoformat(asset['fileCreatedAt'].replace("Z", "+00:00"))
timestamp = dt.strftime("%Y%m%d_%H%M%S") timestamp = dt.strftime("%Y%m%d_%H%M%S")
filename = os.path.join(config.output_folder, f"{timestamp}.jpg") filename = os.path.join(config.output_folder, f"{timestamp}.jpg")
aligned_face.save(filename) aligned_face.save(filename)
return filename return filename
def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None, date_from=None, date_to=None, def process_faces(config: AppConfig, max_workers=1, progress_callback=None, cancel_flag=None):
cancel_flag=None):
""" """
Processes assets containing the person and saves aligned face images. Processes assets containing the person and saves aligned face images.
@ -543,23 +537,19 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None,
concurrently download, crop, and align faces. concurrently download, crop, and align faces.
Args: Args:
config (ProcessConfig): Configuration parameters. config (AppConfig): Configuration parameters.
max_workers (int): Number of worker processes. max_workers (int): Number of worker processes.
progress_callback (callable, optional): A callback function for progress updates. progress_callback (callable, optional): A callback function for progress updates.
date_from (str, optional): Start date for filtering assets.
date_to (str, optional): End date for filtering assets.
cancel_flag (callable, optional): A function that returns True if processing should be cancelled. cancel_flag (callable, optional): A function that returns True if processing should be cancelled.
Returns: Returns:
list: A list of file paths of the saved images. list: A list of file paths of the saved images.
""" """
os.makedirs(config.output_folder, exist_ok=True)
if cancel_flag and cancel_flag(): if cancel_flag and cancel_flag():
logger.info("Processing was cancelled.") logger.info("Processing was cancelled.")
return [] return []
assets = get_assets_with_person(config.api_key, config.base_url, config.person_id, date_from, date_to) assets = get_assets_with_person(config.api_key, config.base_url, config.person_id, config.date_from, config.date_to)
logger.info(f"Found {len(assets)} assets containing the person.") logger.info(f"Found {len(assets)} assets containing the person.")
total_assets = len(assets) total_assets = len(assets)
@ -568,7 +558,7 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None,
processed_files = [] processed_files = []
completed_count = 0 completed_count = 0
initializer_args = (config.landmark_model_path,) initializer_args = (config.landmark_model)
with concurrent.futures.ProcessPoolExecutor( with concurrent.futures.ProcessPoolExecutor(
max_workers=max_workers, max_workers=max_workers,
initializer=initialize_worker, initializer=initialize_worker,