Use immich face bounding box rather that recomputing with dlib

This commit is contained in:
Arnaud_Cayrol 2025-04-10 21:52:02 +02:00
parent a2b1000eb7
commit 94e199366f
2 changed files with 77 additions and 44 deletions

25
main.py
View file

@ -13,10 +13,8 @@ BASE_URL = os.environ.get("IMMICH_BASE_URL", "")
OUTPUT_FOLDER = "output" OUTPUT_FOLDER = "output"
# Model paths # Model paths
FACE_DETECT_MODEL = "mmod_human_face_detector.dat"
LANDMARK_MODEL = "shape_predictor_68_face_landmarks.dat" LANDMARK_MODEL = "shape_predictor_68_face_landmarks.dat"
LEFT_EYE_POS = (0.35, 0.45)
AVAILABLE_CORES = multiprocessing.cpu_count() AVAILABLE_CORES = multiprocessing.cpu_count()
# Global progress dictionary only one job at a time is assumed here # Global progress dictionary only one job at a time is assumed here
@ -40,8 +38,9 @@ def update_progress(current, total):
progress_info["status"] = "running" if current < total else "done" progress_info["status"] = "running" if current < total else "done"
def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers, def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold,
date_from, date_to, compile_video, framerate): left_eye_pos,
max_workers, date_from, date_to, compile_video, framerate):
""" """
Background process that creates a configuration object and calls process_faces. Background process that creates a configuration object and calls process_faces.
@ -51,6 +50,7 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_
resize_size (int): Desired width and height for the aligned face image. resize_size (int): Desired width and height for the aligned face image.
face_resolution_threshold (int): Minimum required face resolution. face_resolution_threshold (int): Minimum required face resolution.
pose_threshold (float): Maximum allowed head pose deviation. 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. max_workers (int): Number of concurrent worker processes.
date_from (str): Start date for asset filtering. date_from (str): Start date for asset filtering.
date_to (str): End date for asset filtering. date_to (str): End date for asset filtering.
@ -72,12 +72,10 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_
min_face_width=face_resolution_threshold, min_face_width=face_resolution_threshold,
min_face_height=face_resolution_threshold, min_face_height=face_resolution_threshold,
pose_threshold=pose_threshold, pose_threshold=pose_threshold,
desired_left_eye=LEFT_EYE_POS, left_eye_pos=left_eye_pos,
face_detect_model_path=FACE_DETECT_MODEL,
landmark_model_path=LANDMARK_MODEL landmark_model_path=LANDMARK_MODEL
) )
# Pass the cancel flag to the process_faces function
processed_files = process_faces(config, max_workers=max_workers, progress_callback=update_progress, 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) date_from=date_from, date_to=date_to, cancel_flag=lambda: cancel_requested)
@ -217,6 +215,12 @@ def index():
resize_size = int(request.form.get("resize_size", 512)) resize_size = int(request.form.get("resize_size", 512))
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128)) face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
pose_threshold = float(request.form.get("pose_threshold", 25)) pose_threshold = float(request.form.get("pose_threshold", 25))
# Parse left_eye_pos tuple from form
left_eye_x = float(request.form.get("left_eye_x", 0.35))
left_eye_y = float(request.form.get("left_eye_y", 0.45))
left_eye_pos = (left_eye_x, left_eye_y)
max_workers = int(request.form.get("max_workers", 1)) max_workers = int(request.form.get("max_workers", 1))
# Date ranges are optional # Date ranges are optional
@ -235,8 +239,8 @@ def index():
# Start the processing in a background thread # Start the processing in a background thread
processing_thread = threading.Thread( processing_thread = threading.Thread(
target=background_process, target=background_process,
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers, args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold,
date_from, date_to, compile_video, framerate) left_eye_pos, max_workers, date_from, date_to, compile_video, framerate)
) )
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."
@ -248,5 +252,4 @@ def index():
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000) app.run(host="0.0.0.0", port=5000)

View file

@ -32,9 +32,9 @@ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datef
tqdm_handler.setFormatter(formatter) tqdm_handler.setFormatter(formatter)
logger.addHandler(tqdm_handler) logger.addHandler(tqdm_handler)
face_detector = None
face_predictor = None face_predictor = None
@dataclass @dataclass
class ProcessConfig: class ProcessConfig:
""" """
@ -50,8 +50,7 @@ class ProcessConfig:
min_face_width: int = 128 min_face_width: int = 128
min_face_height: int = 128 min_face_height: int = 128
pose_threshold: float = 25 pose_threshold: float = 25
desired_left_eye: tuple = (0.35, 0.45) left_eye_pos: tuple = (0.35, 0.45)
face_detect_model_path: str = "mmod_human_face_detector.dat"
landmark_model_path: str = "shape_predictor_68_face_landmarks.dat" landmark_model_path: str = "shape_predictor_68_face_landmarks.dat"
@ -93,12 +92,11 @@ def validate_immich_connection(api_key, base_url):
return False, f"Unexpected error: {str(e)}" return False, f"Unexpected error: {str(e)}"
def initialize_worker(face_detect_model_path, landmark_model_path): def initialize_worker(landmark_model_path):
""" """
Initializes the face detector and predictor in each worker process. Initializes the face predictor in each worker process.
""" """
global face_detector, face_predictor global face_predictor
face_detector = dlib.cnn_face_detection_model_v1(face_detect_model_path)
face_predictor = dlib.shape_predictor(landmark_model_path) face_predictor = dlib.shape_predictor(landmark_model_path)
@ -153,6 +151,7 @@ def get_assets_with_person(api_key, base_url, person_id, date_from=None, date_to
payload["page"] = data['assets'].get('nextPage') payload["page"] = data['assets'].get('nextPage')
return all_assets return all_assets
def download_asset(api_key, base_url, asset_id): def download_asset(api_key, base_url, asset_id):
""" """
Downloads the original image asset from the API. Downloads the original image asset from the API.
@ -253,15 +252,17 @@ def get_head_pose(shape, img_size):
return pitch, yaw, roll return pitch, yaw, roll
def align_face(image, desired_face_width, desired_face_height, desired_left_eye, pose_threshold):
def align_face(image, face_data, desired_face_width, desired_face_height, left_eye_pos, pose_threshold):
""" """
Aligns the face in the image using facial landmarks and head pose estimation. Aligns the face in the image using facial landmarks and head pose estimation.
Args: Args:
image (PIL.Image): The image containing the face. image (PIL.Image): The cropped image containing just the face.
face_data (dict): Metadata containing face bounding box info (not used for detection).
desired_face_width (int): The desired output face width. desired_face_width (int): The desired output face width.
desired_face_height (int): The desired output face height. desired_face_height (int): The desired output face height.
desired_left_eye (tuple): The desired relative position of the left eye. left_eye_pos (tuple): The desired relative position of the left eye.
pose_threshold (float): The maximum allowable head pose deviation. pose_threshold (float): The maximum allowable head pose deviation.
Returns: Returns:
@ -269,41 +270,60 @@ def align_face(image, desired_face_width, desired_face_height, desired_left_eye,
""" """
image_np = np.array(image) image_np = np.array(image)
gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY)
detections = face_detector(gray)
if not detections: # Since we already have a cropped face image, create a rectangle for the whole image
logger.info("No face detected in the crop. Discarding.") img_height, img_width = gray.shape
return None rect = dlib.rectangle(0, 0, img_width, img_height)
# Use the first detection; handle both dlib rectangle and CNN detection type.
detection = detections[0] # Get facial landmarks
rect = detection.rect if hasattr(detection, "rect") else detection
shape = face_predictor(gray, rect) shape = face_predictor(gray, rect)
img_size = (image_np.shape[1], image_np.shape[0])
head_pose = get_head_pose(shape, img_size) # Check if face landmarks were detected
if shape.num_parts() != 68:
logger.info("Landmark detection failed - could not find all 68 facial landmarks.")
return None
# Get head pose
head_pose = get_head_pose(shape, (img_width, img_height))
if head_pose is None: if head_pose is None:
return None return None
pitch, yaw, roll = head_pose pitch, yaw, roll = head_pose
if abs(abs(pitch) - 180) > pose_threshold or abs(yaw) > pose_threshold: 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.") logger.info(f"Face not frontal enough: pitch={pitch:.2f}, yaw={yaw:.2f}, roll={roll:.2f}. Discarding.")
return None # return None
# Convert landmarks to numpy array
shape_np = np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)], dtype="int") shape_np = np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)], dtype="int")
# Calculate eye centers
left_eye_center = shape_np[36:42].mean(axis=0).astype("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") 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] dY = right_eye_center[1] - left_eye_center[1]
dX = right_eye_center[0] - left_eye_center[0] dX = right_eye_center[0] - left_eye_center[0]
angle = np.degrees(np.arctan2(dY, dX)) angle = np.degrees(np.arctan2(dY, dX))
eye_distance = np.linalg.norm(right_eye_center - left_eye_center) eye_distance = np.linalg.norm(right_eye_center - left_eye_center)
desired_right_eye_x = 1.0 - desired_left_eye[0] right_eye_pos = 1.0 - left_eye_pos[0]
desired_eye_distance = (desired_right_eye_x - desired_left_eye[0]) * desired_face_width desired_eye_distance = (right_eye_pos - left_eye_pos[0]) * desired_face_width
scale = desired_eye_distance / eye_distance scale = desired_eye_distance / eye_distance
# Calculate center of eyes
eyes_center = ((left_eye_center[0] + right_eye_center[0]) / 2.0, eyes_center = ((left_eye_center[0] + right_eye_center[0]) / 2.0,
(left_eye_center[1] + right_eye_center[1]) / 2.0) (left_eye_center[1] + right_eye_center[1]) / 2.0)
# Create transformation matrix
M = cv2.getRotationMatrix2D(eyes_center, angle, scale) M = cv2.getRotationMatrix2D(eyes_center, angle, scale)
# Update translation component of the matrix
tX = desired_face_width * 0.5 tX = desired_face_width * 0.5
tY = desired_face_height * desired_left_eye[1] tY = desired_face_height * left_eye_pos[1]
M[0, 2] += (tX - eyes_center[0]) M[0, 2] += (tX - eyes_center[0])
M[1, 2] += (tY - eyes_center[1]) M[1, 2] += (tY - eyes_center[1])
# Apply transformation
aligned_face_np = cv2.warpAffine( aligned_face_np = cv2.warpAffine(
image_np, image_np,
M, M,
@ -311,6 +331,7 @@ def align_face(image, desired_face_width, desired_face_height, desired_left_eye,
flags=cv2.INTER_CUBIC, flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE borderMode=cv2.BORDER_REPLICATE
) )
return Image.fromarray(aligned_face_np) return Image.fromarray(aligned_face_np)
@ -330,8 +351,8 @@ def process_asset_worker(asset, config: ProcessConfig):
""" """
try: try:
asset_id = asset['id'] asset_id = asset['id']
dt = datetime.fromisoformat(asset['fileCreatedAt'].replace("Z", "+00:00"))
timestamp = dt.strftime("%Y%m%d_%H%M%S")
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)
@ -350,24 +371,33 @@ def process_asset_worker(asset, config: ProcessConfig):
return None return None
face_data = faces[0] face_data = faces[0]
cropped_face = crop_face_from_metadata(image, face_data, config.padding_percent) cropped_face = crop_face_from_metadata(image, face_data, config.padding_percent)
# filename = os.path.join(config.output_folder, f"crop_{timestamp}.jpg")
# cropped_face.save(f"{filename}")
face_width, face_height = cropped_face.size face_width, face_height = cropped_face.size
if face_width < config.min_face_width or face_height < config.min_face_height: 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}).") logger.info(f"Face resolution too low ({face_width}x{face_height}).")
return None return None
aligned_face = align_face(cropped_face,
desired_face_width=config.resize_width, # Pass face_data to align_face
desired_face_height=config.resize_height, aligned_face = align_face(
desired_left_eye=config.desired_left_eye, cropped_face,
pose_threshold=config.pose_threshold) face_data,
desired_face_width=config.resize_width,
desired_face_height=config.resize_height,
left_eye_pos=config.left_eye_pos,
pose_threshold=config.pose_threshold
)
if aligned_face is None: if aligned_face is None:
return None return None
os.makedirs(config.output_folder, exist_ok=True) 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")
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: ProcessConfig, max_workers=1, progress_callback=None, date_from=None, date_to=None,
cancel_flag=None): cancel_flag=None):
""" """
@ -402,7 +432,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.face_detect_model_path, config.landmark_model_path) initializer_args = (config.landmark_model_path,)
with concurrent.futures.ProcessPoolExecutor( with concurrent.futures.ProcessPoolExecutor(
max_workers=max_workers, max_workers=max_workers,
initializer=initialize_worker, initializer=initialize_worker,
@ -430,4 +460,4 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None,
progress_callback(completed_count, total_assets) progress_callback(completed_count, total_assets)
logger.info(f"Finished processing. {len(processed_files)} images saved out of {total_assets} assets.") logger.info(f"Finished processing. {len(processed_files)} images saved out of {total_assets} assets.")
return processed_files return processed_files