"""Apply a Cloud API Face Lifting v3 atlas locally. Requires numpy and opencv-python."""

import argparse
import json
from pathlib import Path

import cv2
import numpy as np


def smooth_cell(cell, radius=5):
    """Box blur in flow-cell pixels; radius 5 means an 11x11 window."""
    if radius < 0 or int(radius) != radius:
        raise ValueError('Blur radius must be a non-negative integer')
    size = 2 * int(radius) + 1
    return cv2.blur(cell, (size, size), borderType=cv2.BORDER_REFLECT_101)


# These indices belong ONLY to the downloadable example, not arbitrary photos.
# Field order: beauty, masculinity, femininity, double chin, hair volume.
FACE_SETTINGS = {
    0: {'depth': 0.6, 'fields': (0.5, 1.0, 0.0, 0.0, 0.0)},  # Man, right; hair off.
    1: {'depth': 0.6, 'fields': (1.0, 0.0, 0.5, 0.0, 0.5)},  # Woman, left.
}
# Effective coefficients: man beauty 0.3, masculinity 0.6, hair 0;
# woman beauty 0.6, femininity 0.3, hair 0.3.


def bilinear(image, x, y):
    """Sample using array indices: (0, 0) is the first pixel's center."""
    x = np.clip(x, 0, image.shape[1] - 1)
    y = np.clip(y, 0, image.shape[0] - 1)
    x0, y0 = np.floor(x).astype(int), np.floor(y).astype(int)
    x1 = np.minimum(x0 + 1, image.shape[1] - 1)
    y1 = np.minimum(y0 + 1, image.shape[0] - 1)
    dx, dy = (x - x0)[..., None], (y - y0)[..., None]
    top = image[y0, x0] * (1 - dx) + image[y0, x1] * dx
    bottom = image[y1, x0] * (1 - dx) + image[y1, x1] * dx
    return top * (1 - dy) + bottom * dy


def apply_lifting(source, atlas_rg, plugin, face_settings=None, blur_radius=5):
    """Sum selected fields per face, then render faces in array order."""
    height, width = source.shape[:2]
    if (width, height) != (plugin['originalWidth'], plugin['originalHeight']):
        raise ValueError('Use the source at the original metadata dimensions')
    faces = plugin['faces']
    if plugin.get('faceLiftingVersion') != 3:
        raise ValueError('This example requires Face Lifting version 3')
    if atlas_rg.shape != (256 * 5 * len(faces), 256, 2):
        raise ValueError('Atlas dimensions do not match five fields per face')
    face_settings = FACE_SETTINGS if face_settings is None else face_settings
    if set(face_settings) != set(range(len(faces))):
        raise ValueError('Provide explicit settings for every face index')
    output = source.astype(np.float32).copy()
    for index, face in enumerate(faces):
        if face['flowCount'] != 5 or len(face['flowMatrices']) != 5:
            raise ValueError('Each face must have five fields and matrices')
        settings = face_settings[index]
        weights = np.asarray(settings['fields'], dtype=np.float32) * settings['depth']
        if weights.shape != (5,) or not np.isfinite(weights).all():
            raise ValueError('Provide five finite field strengths and a finite depth')
        fields = []
        for field_index, weight in enumerate(weights):
            if weight == 0:
                continue
            matrix = np.asarray(face['flowMatrices'][field_index], dtype=np.float32)
            inverse = np.linalg.inv(matrix[:, :2])
            # PNG channels are data: do not apply an sRGB transfer function.
            first_row = (index * 5 + field_index) * 256
            cell = smooth_cell(atlas_rg[first_row:first_row + 256], blur_radius)
            fields.append((matrix, inverse, cell, weight))
        if not fields:
            continue
        previous = output.copy()
        # Stripes bound temporary memory use for large source photographs.
        for top in range(0, height, 128):
            bottom = min(top + 128, height)
            yy, xx = np.mgrid[top:bottom, :width].astype(np.float32)
            # Match DisplacementFlow311 destination pixel centers.
            dest_x, dest_y = xx + 0.5, yy + 0.5
            dx, dy = np.zeros_like(xx), np.zeros_like(yy)
            active = np.zeros(xx.shape, dtype=bool)
            for matrix, inverse, cell, weight in fields:
                px = matrix[0, 0] * dest_x + matrix[0, 1] * dest_y + matrix[0, 2]
                py = matrix[1, 0] * dest_x + matrix[1, 1] * dest_y + matrix[1, 2]
                inside = (px >= 1) & (px <= 255) & (py >= 1) & (py <= 255)
                # Flow shader adds 0.5 before sampling: px/py are array indices here.
                displacement = (bilinear(cell, px, py) - 0.5) * (8.0 * weight)
                dx += np.where(inside, inverse[0, 0] * displacement[..., 0] + inverse[0, 1] * displacement[..., 1], 0)
                dy += np.where(inside, inverse[1, 0] * displacement[..., 0] + inverse[1, 1] * displacement[..., 1], 0)
                active |= inside
            # Add image-space displacements BEFORE resampling, not separate image warps.
            sx, sy = dest_x + dx, dest_y + dy
            active &= (sx >= 0) & (sx <= width - 1) & (sy >= 0) & (sy <= height - 1)
            sampled = bilinear(previous, sx - 0.5, sy - 0.5)
            output[top:bottom] = np.where(active[..., None], sampled, previous[top:bottom])
    return output


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source', default='Face_Lifting_Group_Portrait_Source.webp')
    parser.add_argument('--flow', default='Face_Lifting_Group_Portrait_Flow_v3.png')
    parser.add_argument('--metadata', default='Face_Lifting_Group_Portrait_Result.json')
    parser.add_argument('--output', default='Face_Lifting_Group_Portrait_After.png')
    parser.add_argument('--blur-radius', type=int, default=5)
    args = parser.parse_args()
    source = cv2.imread(args.source, cv2.IMREAD_COLOR)
    atlas = cv2.imread(args.flow, cv2.IMREAD_UNCHANGED)
    if source is None or atlas is None:
        raise ValueError('Could not read source or flow image')
    if atlas.dtype not in (np.uint8, np.uint16) or atlas.ndim != 3 or atlas.shape[2] < 3:
        raise ValueError('Expected an 8-bit or 16-bit RGB flow PNG')
    # OpenCV loads BGR. Preserve PNG bit depth and select red then green.
    rg = atlas[..., [2, 1]].astype(np.float32) / np.iinfo(atlas.dtype).max
    metadata = json.loads(Path(args.metadata).read_text(encoding='utf-8'))
    candidates = [p for p in metadata['plugins'] if p['pluginName'] == 'Face Lifting']
    if len(candidates) != 1:
        raise ValueError('Expected exactly one Face Lifting operation')
    result = apply_lifting(source, rg, candidates[0], blur_radius=args.blur_radius)
    pixels = np.clip(np.rint(result), 0, 255).astype(np.uint8)
    if Path(args.output).suffix.lower() != '.png':
        raise ValueError('Use a .png output to avoid lossy preview artifacts')
    if not cv2.imwrite(args.output, pixels):
        raise OSError('Could not save output')
    print(f'Saved {args.output}: {len(candidates[0]["faces"])} faces, box blur radius {args.blur_radius}')


if __name__ == '__main__':
    main()
