Refactoring code

This commit is contained in:
Arnaud_Cayrol 2025-04-12 11:15:13 +02:00
parent 2c1aa9bf5a
commit e72070b75e
3 changed files with 284 additions and 240 deletions

157
main.py
View file

@ -5,6 +5,8 @@ import subprocess
from flask import Flask, request, render_template, jsonify, redirect, url_for
from timelapse import process_faces, ProcessConfig, validate_immich_connection
import logging
import uuid
from datetime import datetime
app = Flask(__name__)
@ -46,98 +48,50 @@ def update_progress(current, total):
progress_info["status"] = "running" if current < total else "done"
def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold,
left_eye_pos,
max_workers, date_from, date_to, compile_video, framerate):
def background_process(person_id, resize_size, face_resolution_threshold, pose_threshold,
left_eye_pos, output_folder, api_key, base_url, progress_callback=None, cancel_flag=None):
"""
Background process that creates a configuration object and calls process_faces.
Background process to handle face alignment and timelapse creation.
Args:
person_id (str): The target person ID.
padding_percent (float): Padding percentage for face cropping.
resize_size (int): Desired width and height for the aligned face image.
face_resolution_threshold (int): Minimum required face resolution.
person_id (str): ID of the person to process.
resize_size (int): Size to resize the output images to.
face_resolution_threshold (int): Minimum face resolution threshold.
pose_threshold (float): Maximum allowed head pose deviation.
left_eye_pos (tuple): The desired relative position of the left eye.
max_workers (int): Number of concurrent worker processes.
date_from (str): Start date for asset filtering.
date_to (str): End date for asset filtering.
compile_video (bool): Whether to compile the images into a video.
framerate (int): Frames per second for the output video.
left_eye_pos (tuple): Desired position of the left eye in the output.
output_folder (str): Folder to save the output images.
api_key (str): API key for authentication.
base_url (str): Base URL of the API.
progress_callback (callable, optional): Callback for progress updates.
cancel_flag (callable, optional): Function to check if process should be cancelled.
"""
global cancel_requested
try:
progress_info["status"] = "running"
# Build the configuration object for processing
config = ProcessConfig(
api_key=API_KEY,
base_url=BASE_URL,
api_key=api_key,
base_url=base_url,
person_id=person_id,
output_folder=OUTPUT_FOLDER,
padding_percent=padding_percent,
output_folder=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=LANDMARK_MODEL
left_eye_pos=left_eye_pos
)
processed_files = process_faces(config, max_workers=max_workers, progress_callback=update_progress,
date_from=date_from, date_to=date_to, cancel_flag=lambda: cancel_requested)
# Process the faces
processed_files = process_faces(
config=config,
max_workers=1,
progress_callback=progress_callback,
cancel_flag=cancel_flag
)
# Check if processing was cancelled
if cancel_requested:
progress_info["status"] = "cancelled"
cancel_requested = False
return
# Compile video if requested and there are processed files
if compile_video and processed_files:
output_video = os.path.join(OUTPUT_FOLDER, "timelapse.mp4")
ffmpeg_command = [
"ffmpeg", "-y",
"-framerate", str(framerate),
"-pattern_type", "glob",
"-i", os.path.join(OUTPUT_FOLDER, "*.jpg"),
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-progress", "pipe:1",
output_video
]
try:
progress_info["completed"] = 0
progress_info["total"] = 100
progress_info["status"] = "video_compiling"
process = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
universal_newlines=True)
for line in process.stdout:
line = line.strip()
if line:
if line.startswith("frame="):
try:
parts = line.split("=")
frame = int(parts[1].strip())
progress_info["completed"] = min(frame, progress_info["total"])
except Exception:
pass
if "progress=end" in line:
progress_info["completed"] = progress_info["total"]
break
process.wait()
if process.returncode == 0:
progress_info["status"] = "video_done"
else:
progress_info["status"] = "Video compilation failed."
except subprocess.CalledProcessError as e:
progress_info["status"] = f"Video compilation failed: {e}"
else:
progress_info["status"] = "done"
return processed_files
except Exception as e:
progress_info["status"] = f"error: {e}"
logger.error(f"Error in background process: {str(e)}")
raise
def check_output_folder():
@ -190,6 +144,51 @@ def cancel():
return jsonify({"success": False, "message": "No active processing to cancel."})
@app.route("/process", methods=["POST"])
def process():
"""Handle the processing request."""
try:
person_id = request.form.get("person_id")
if not person_id:
return jsonify({"error": "Person ID is required"}), 400
resize_size = int(request.form.get("resize_size", 512))
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
pose_threshold = float(request.form.get("pose_threshold", 25))
left_eye_x = float(request.form.get("left_eye_x", 0.4))
left_eye_y = float(request.form.get("left_eye_y", 0.4))
left_eye_pos = (left_eye_x, left_eye_y)
# Create output folder with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_folder = os.path.join("output", f"timelapse_{timestamp}")
os.makedirs(output_folder, exist_ok=True)
# Start background process
process_id = str(uuid.uuid4())
process_info = {
"status": "running",
"start_time": datetime.now().isoformat(),
"output_folder": output_folder
}
active_processes[process_id] = process_info
# Start the background process
process = multiprocessing.Process(
target=background_process,
args=(person_id, resize_size, face_resolution_threshold, pose_threshold,
left_eye_pos, output_folder, API_KEY, BASE_URL)
)
process.start()
active_processes[process_id]["process"] = process
return jsonify({"process_id": process_id})
except Exception as e:
logger.error(f"Error in process route: {str(e)}")
return jsonify({"error": str(e)}), 500
@app.route("/", methods=["GET", "POST"])
def index():
"""
@ -219,12 +218,12 @@ def index():
cancel_requested = False
person_id = request.form["person_id"]
padding_percent = float(request.form.get("padding_percent", 30)) / 100
resize_size = int(request.form.get("resize_size", 512))
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
pose_threshold = float(request.form.get("pose_threshold", 25))
max_workers = int(request.form.get("max_workers", 1))
left_eye_pos = (0.4, 0.4)
left_eye_x = float(request.form.get("left_eye_x", 0.4))
left_eye_y = float(request.form.get("left_eye_y", 0.4))
left_eye_pos = (left_eye_x, left_eye_y)
# Date ranges are optional
date_from = request.form.get("date_from") or None
@ -242,8 +241,8 @@ def index():
# Start the processing in a background thread
processing_thread = threading.Thread(
target=background_process,
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold,
left_eye_pos, max_workers, date_from, date_to, compile_video, framerate)
args=(person_id, resize_size, face_resolution_threshold, pose_threshold,
left_eye_pos, OUTPUT_FOLDER, API_KEY, BASE_URL, update_progress, lambda: cancel_requested)
)
processing_thread.start()
result = "Processing started. Please wait and watch the progress bar below."

View file

@ -188,28 +188,19 @@
<div class="section-header">Face Processing Settings</div>
<div class="form-group">
<label>
Padding added around the face in percent:
<input type="number" step="1" name="padding_percent" value="{{ request.form.padding_percent or 30 }}">
</label>
<label for="resize_size">Output Image Size:</label>
<input type="number" step="1" name="resize_size" value="{{ request.form.resize_size or 512 }}">
<small class="form-text text-muted">Width and height of the output images in pixels.</small>
</div>
<div class="form-group">
<label>
Output image size:
<input type="number" name="resize_size" value="{{ request.form.resize_size or 512 }}">
</label>
<label for="face_resolution_threshold">Minimum Face Resolution:</label>
<input type="number" step="1" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
<small class="form-text text-muted">Minimum width/height of detected faces in pixels.</small>
</div>
<div class="form-group">
<label>
Face resolution threshold:
<input type="number" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
</label>
</div>
<div class="form-group">
<label>
Max orientation of the face (degrees):
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
</label>
<label for="pose_threshold">Maximum Head Pose Deviation:</label>
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
<small class="form-text text-muted">Maximum allowed head pose deviation in degrees.</small>
</div>
<div class="form-group">
<label>

View file

@ -44,7 +44,6 @@ class ProcessConfig:
base_url: str
person_id: str
output_folder: str = "output"
padding_percent: float = 0.3
resize_width: int = 512
resize_height: int = 512
min_face_width: int = 128
@ -53,6 +52,35 @@ class ProcessConfig:
left_eye_pos: tuple = (0.4, 0.4)
landmark_model_path: str = "shape_predictor_68_face_landmarks.dat"
def draw_landmarks(image, shape, face_rect, output_path):
"""
Draws facial landmarks and face rectangle on the image and saves it.
Args:
image (numpy.ndarray): The input image in BGR format.
shape (dlib.full_object_detection): Detected facial landmarks.
face_rect (tuple): Face rectangle coordinates (x1, y1, x2, y2).
output_path (str): Path to save the output image.
"""
# Create a copy of the image to draw on
img = image.copy()
# Draw face rectangle
x1, y1, x2, y2 = face_rect
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
# Draw landmarks if available
if shape is not None:
for i in range(68):
x = shape.part(i).x
y = shape.part(i).y
cv2.circle(img, (x, y), 2, (0, 0, 255), -1)
# Create output directory if it doesn't exist
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Save the image
cv2.imwrite(output_path, img)
def validate_immich_connection(api_key, base_url):
"""
@ -170,48 +198,20 @@ def download_asset(api_key, base_url, asset_id):
return response.content
def crop_face_from_metadata(image, face_data, padding_percent):
"""
Crops the face from the image using metadata and applies padding.
Args:
image (PIL.Image): The original image.
face_data (dict): Metadata containing face bounding box info.
padding_percent (float): Padding as a percentage of face dimensions.
Returns:
PIL.Image: The cropped face image.
"""
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):
def get_head_pose(shape, image):
"""
Estimates the head pose (pitch, yaw, roll) using facial landmarks.
Args:
shape (dlib.full_object_detection): Detected facial landmarks.
img_size (tuple): The size of the image (width, height).
image (PIL.Image): The input image.
Returns:
tuple or None: (pitch, yaw, roll) in degrees if successful; otherwise None.
"""
# Get image size
img_width, img_height = image.size
image_points = np.array([
(shape.part(30).x, shape.part(30).y), # Nose tip
(shape.part(8).x, shape.part(8).y), # Chin
@ -230,9 +230,8 @@ def get_head_pose(shape, img_size):
(150.0, -150.0, -125.0) # Right mouth corner
])
w, h = img_size
focal_length = w
center = (w / 2, h / 2)
focal_length = img_width
center = (img_width / 2, img_height / 2)
camera_matrix = np.array(
[[focal_length, 0, center[0]],
[0, focal_length, center[1]],
@ -252,124 +251,184 @@ def get_head_pose(shape, img_size):
return pitch, yaw, roll
def align_face(image, desired_face_width, desired_face_height, left_eye_pos, pose_threshold, padding_percent):
def detect_landmarks(image, face_data, max_landmark_size=300):
"""
Aligns the face in the image using facial landmarks and head pose estimation.
Detects facial landmarks in the image, resizing if necessary for better detection.
Args:
image (PIL.Image): The cropped image containing just the face.
face_data (dict): Metadata containing face bounding box info.
desired_face_width (int): The desired output face width.
desired_face_height (int): The desired output face height.
left_eye_pos (tuple): The desired relative position of the left eye.
pose_threshold (float): The maximum allowable head pose deviation.
padding_percent (float): The padding percentage used to create the crop.
image (PIL.Image): The input image.
face_data (dict): Face metadata containing bounding box information.
max_landmark_size (int): Maximum size for landmark detection.
Returns:
PIL.Image or None: The aligned face image if successful, otherwise None.
dlib.full_object_detection or None: The detected facial landmarks in original image coordinates,
or None if detection failed.
"""
image_np = np.array(image)
gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY)
# Get the face rectangle in the cropped image coordinates
# Get face rectangle from metadata with proper scaling
face_img_width = face_data.get("imageWidth")
face_img_height = face_data.get("imageHeight")
img_width, img_height = image.size
face_width = img_width / (1 + 2 * padding_percent)
face_height = img_height / (1 + 2 * padding_percent)
x1 = int((img_width - face_width) / 2)
x2 = int(x1 + face_width)
y1 = int((img_height - face_height) / 2)
y2 = int(y1 + face_height)
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
# Resize the image for landmark detection
optimal_size = 256 # optimal image resolution for landmark detection (between 200-400px)
scale_factor = optimal_size / face_width
resized_width = int(img_width * scale_factor)
resized_height = int(img_height * scale_factor)
resized_gray = cv2.resize(gray, (resized_width, resized_height))
# Crop the face region from the image
face_crop = image.crop((x1, y1, x2, y2))
# Scale the face rectangle coordinates to the resized image
resized_x1 = int(x1 * scale_factor)
resized_x2 = int(x2 * scale_factor)
resized_y1 = int(y1 * scale_factor)
resized_y2 = int(y2 * scale_factor)
# Create dlib rectangle from the scaled face bounding box
rect = dlib.rectangle(resized_x1, resized_y1, resized_x2, resized_y2)
# If the cropped face is too large, resize it for better landmark detection
scale_factor = 1.0
if w > max_landmark_size or h > max_landmark_size:
scale_factor = max_landmark_size / max(w, h)
new_w = int(w * scale_factor)
new_h = int(h * scale_factor)
face_crop = face_crop.resize((new_w, new_h), Image.Resampling.LANCZOS)
w, h = new_w, new_h
# Get facial landmarks on the resized image
shape = face_predictor(resized_gray, rect)
# Convert PIL Image to numpy array for OpenCV processing
img_np = np.array(face_crop)
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
# Check if face landmarks were detected
if shape.num_parts != 68:
logger.info("Landmark detection failed - could not find all 68 facial landmarks.")
# Create dlib rectangle spanning the whole cropped image
face_rect = dlib.rectangle(0, 0, w, h)
# Detect facial landmarks
shape = face_predictor(img_np, face_rect)
if not shape:
return None
# Scale the landmarks back to the original image coordinates
shape_np = np.array([(shape.part(i).x / scale_factor, shape.part(i).y / scale_factor)
for i in range(68)], dtype="float32")
# Scale landmarks back to original image coordinates
if scale_factor != 1.0:
for i in range(68):
shape.part(i).x = int(shape.part(i).x / scale_factor)
shape.part(i).y = int(shape.part(i).y / scale_factor)
# Adjust coordinates to be relative to the original image
for i in range(68):
shape.part(i).x += x1
shape.part(i).y += y1
return shape
def check_eye_visibility(shape, ear_threshold=0.2):
"""
Checks if both eyes are visible by calculating the Eye Aspect Ratio (EAR).
Args:
shape (dlib.full_object_detection): Detected facial landmarks.
ear_threshold (float): Threshold for eye visibility.
Returns:
tuple: (left_ear, right_ear) if both eyes are visible, None otherwise.
"""
# Get eye landmarks
left_eye = np.array([(shape.part(i).x, shape.part(i).y) for i in range(36, 42)])
right_eye = np.array([(shape.part(i).x, shape.part(i).y) for i in range(42, 48)])
# Calculate eye aspect ratios to check if eyes are open enough
def eye_aspect_ratio(eye):
def calculate_ear(eye):
v1 = np.linalg.norm(eye[1] - eye[5])
v2 = np.linalg.norm(eye[2] - eye[4])
h = np.linalg.norm(eye[0] - eye[3])
ear = (v1 + v2) / (2.0 * h)
return ear
left_eye_ratio = eye_aspect_ratio(shape_np[36:42]) # Left eye landmarks
right_eye_ratio = eye_aspect_ratio(shape_np[42:48]) # Right eye landmarks
ear_threshold = 0.15 # Adjust this value based on testing
if left_eye_ratio < ear_threshold or right_eye_ratio < ear_threshold:
left_ear = calculate_ear(left_eye)
right_ear = calculate_ear(right_eye)
if left_ear < ear_threshold or right_ear < ear_threshold:
logger.info(f"Face turned too much to the side (EAR: L={left_ear:.2f}, R={right_ear:.2f})")
return None
head_pose = get_head_pose(shape, (img_width, img_height))
if head_pose is None:
return left_ear, right_ear
def crop_and_align_face(image, face_data, resize_size, face_resolution_threshold, pose_threshold, left_eye_pos):
"""
Aligns a face in an image using facial landmarks and head pose estimation.
Args:
image (PIL.Image): The input image.
face_data (dict): Face metadata containing bounding box information.
resize_size (int): Size to resize the output image to.
face_resolution_threshold (int): Minimum face resolution threshold.
pose_threshold (float): Maximum allowed head pose deviation.
left_eye_pos (tuple): Desired position of the left eye in the output.
Returns:
PIL.Image or None: The aligned face image if successful, None otherwise.
"""
try:
# Detect landmarks in the face region
face_shape = detect_landmarks(image, face_data)
if not face_shape:
logger.info("No facial landmarks detected")
return None
# Check if both eyes are visible
if not check_eye_visibility(face_shape):
logger.info("Eyes are too closed")
return None
# Get head pose
pose = get_head_pose(face_shape, image)
if not pose:
logger.info("Could not estimate head pose")
# draw_landmarks(np.array(image), shape, (x1, y1, x2, y2), "discarded/no_pose.jpg")
return None
# Check if head pose is within acceptable range
pitch, yaw, roll = pose
if abs(pitch) > pose_threshold or abs(yaw) > pose_threshold or abs(roll) > pose_threshold:
logger.info(f"Head pose exceeds threshold: pitch={pitch:.1f}°, yaw={yaw:.1f}°, roll={roll:.1f}°")
# draw_landmarks(np.array(image), shape, (x1, y1, x2, y2), f"discarded/pose_pitch_{pitch:.1f}_yaw_{yaw:.1f}_roll_{roll:.1f}.jpg")
# return None
# Get eye positions for alignment
left_eye_center = np.mean([(face_shape.part(i).x, face_shape.part(i).y) for i in range(36, 42)], axis=0)
right_eye_center = np.mean([(face_shape.part(i).x, face_shape.part(i).y) for i in range(42, 48)], axis=0)
# Calculate angle between eyes
eye_angle = np.degrees(np.arctan2(right_eye_center[1] - left_eye_center[1],
right_eye_center[0] - left_eye_center[0]))
# Calculate rotation center (midpoint between eyes)
center = ((left_eye_center[0] + right_eye_center[0]) / 2,
(left_eye_center[1] + right_eye_center[1]) / 2)
# Create rotation matrix
rotation_matrix = cv2.getRotationMatrix2D(center, eye_angle, 1.0)
# Calculate translation to position left eye
tx = resize_size * left_eye_pos[0] - left_eye_center[0]
ty = resize_size * left_eye_pos[1] - left_eye_center[1]
rotation_matrix[0, 2] += tx
rotation_matrix[1, 2] += ty
# Convert image to numpy array for OpenCV processing
img_np = np.array(image)
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
# Apply transformation
aligned_face = cv2.warpAffine(img_np, rotation_matrix, (resize_size, resize_size),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
# Convert back to PIL Image
aligned_face = cv2.cvtColor(aligned_face, cv2.COLOR_BGR2RGB)
aligned_face = Image.fromarray(aligned_face)
# draw_landmarks(img_np, shape, (x1, y1, x2, y2), "final/aligned_face.jpg")
return aligned_face
except Exception as e:
logger.error(f"Error in crop_and_align_face: {str(e)}")
return None
pitch, yaw, roll = head_pose
if abs(abs(pitch) - 180) > pose_threshold or abs(yaw) > pose_threshold:
logger.info(f"Face not frontal enough: pitch={pitch:.2f}, yaw={yaw:.2f}, roll={roll:.2f}. Discarding.")
# return None
# Calculate eye centers using the scaled-back landmarks
left_eye_center = shape_np[36:42].mean(axis=0).astype("int")
right_eye_center = shape_np[42:48].mean(axis=0).astype("int")
# Calculate angle and scale
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)
right_eye_pos = 1.0 - left_eye_pos[0]
desired_eye_distance = (right_eye_pos - left_eye_pos[0]) * desired_face_width
scale = desired_eye_distance / eye_distance
# Calculate center of eyes
eyes_center = ((left_eye_center[0] + right_eye_center[0]) / 2.0,
(left_eye_center[1] + right_eye_center[1]) / 2.0)
# Create transformation matrix
M = cv2.getRotationMatrix2D(eyes_center, angle, scale)
# Update translation component of the matrix
tX = desired_face_width * 0.5
tY = desired_face_height * left_eye_pos[1]
M[0, 2] += (tX - eyes_center[0])
M[1, 2] += (tY - eyes_center[1])
# Apply transformation
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, config: ProcessConfig):
"""
@ -388,7 +447,6 @@ def process_asset_worker(asset, config: ProcessConfig):
try:
asset_id = asset['id']
image_bytes = download_asset(config.api_key, config.base_url, asset_id)
image = Image.open(io.BytesIO(image_bytes))
image = ImageOps.exif_transpose(image)
@ -400,23 +458,19 @@ 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)
face_data = matching_person.get('faces', [])[0]
cropped_face = crop_face_from_metadata(image, face_data, config.padding_percent)
face_width, face_height = cropped_face.size
if face_width < config.min_face_width or face_height < config.min_face_height:
logger.info(f"Face resolution too low ({face_width}x{face_height}).")
return None
aligned_face = align_face(
cropped_face,
desired_face_width=config.resize_width,
desired_face_height=config.resize_height,
left_eye_pos=config.left_eye_pos,
aligned_face = crop_and_align_face(
image,
face_data,
resize_size=config.resize_width,
face_resolution_threshold=config.min_face_width,
pose_threshold=config.pose_threshold,
padding_percent=config.padding_percent
left_eye_pos=config.left_eye_pos
)
if aligned_face is None:
return None
os.makedirs(config.output_folder, exist_ok=True)
dt = datetime.fromisoformat(asset['fileCreatedAt'].replace("Z", "+00:00"))
timestamp = dt.strftime("%Y%m%d_%H%M%S")