|
|
|
import gradio as gr |
|
|
|
import cv2 |
|
import matplotlib |
|
import matplotlib.cm |
|
import mediapipe as mp |
|
import numpy as np |
|
import os |
|
import struct |
|
import tempfile |
|
import torch |
|
|
|
from mediapipe.framework.formats import landmark_pb2 |
|
from mediapipe.python.solutions.drawing_utils import _normalized_to_pixel_coordinates |
|
from PIL import Image |
|
from quads import QUADS |
|
from typing import List, Mapping, Optional, Tuple, Union |
|
from utils import colorize, get_most_recent_subdirectory |
|
|
|
class face_image_to_face_mesh: |
|
def __init__(self): |
|
self.zoe_me = True |
|
self.uvwrap = not True |
|
|
|
def demo(self): |
|
if self.zoe_me: |
|
DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu' |
|
self.zoe = torch.hub.load('isl-org/ZoeDepth', "ZoeD_N", pretrained=True).to(DEVICE).eval() |
|
|
|
demo = gr.Blocks(css=self.css(), cache_examples=True) |
|
with demo: |
|
gr.Markdown(self.header()) |
|
|
|
with gr.Row(): |
|
with gr.Column(): |
|
upload_image = gr.Image(label="Input image", type="numpy", source="upload") |
|
self.temp_dir = get_most_recent_subdirectory( upload_image.DEFAULT_TEMP_DIR ) |
|
print( f'The temp_dir is {self.temp_dir}' ) |
|
|
|
gr.Examples( examples=[ |
|
'examples/blonde-00081-399357008.png', |
|
'examples/dude-00110-1227390728.png', |
|
'examples/granny-00056-1867315302.png', |
|
'examples/tuffie-00039-499759385.png', |
|
'examples/character.png', |
|
], inputs=[upload_image] ) |
|
upload_image_btn = gr.Button(value="Detect faces") |
|
if self.zoe_me: |
|
with gr.Group(): |
|
zoe_scale = gr.Slider(label="Mix the ZoeDepth with the MediaPipe Depth", value=1, minimum=0, maximum=1, step=.01) |
|
flat_scale = gr.Slider(label="Depth scale, smaller is flatter and possibly more flattering", value=1, minimum=0, maximum=1, step=.01) |
|
min_detection_confidence = gr.Slider(label="Mininum face detection confidence", value=.5, minimum=0, maximum=1.0, step=0.01) |
|
else: |
|
use_zoe = False |
|
zoe_scale = 0 |
|
with gr.Group(): |
|
gr.Markdown(self.footer()) |
|
|
|
with gr.Column(): |
|
with gr.Group(): |
|
output_mesh = gr.Model3D(clear_color=3*[0], label="3D Model",elem_id='mesh-display-output') |
|
output_image = gr.Image(label="Output image",elem_id='img-display-output') |
|
depth_image = gr.Image(label="Depth image",elem_id='img-display-output') |
|
num_faces_detected = gr.Number(label="Number of faces detected", value=0) |
|
|
|
upload_image_btn.click( |
|
fn=self.detect, |
|
inputs=[upload_image, min_detection_confidence,zoe_scale,flat_scale], |
|
outputs=[output_mesh, output_image, depth_image, num_faces_detected] |
|
) |
|
demo.launch() |
|
|
|
|
|
def detect(self, image, min_detection_confidence, zoe_scale, flat_scale): |
|
width = image.shape[1] |
|
height = image.shape[0] |
|
ratio = width / height |
|
|
|
mp_drawing = mp.solutions.drawing_utils |
|
mp_drawing_styles = mp.solutions.drawing_styles |
|
mp_face_mesh = mp.solutions.face_mesh |
|
|
|
mesh = "examples/converted/in-granny.obj" |
|
|
|
if self.zoe_me and 0 < zoe_scale: |
|
depth = self.zoe.infer_pil(image) |
|
idepth = colorize(depth, cmap='gray_r') |
|
else: |
|
depth = None |
|
idepth = image |
|
|
|
drawing_spec = mp_drawing.DrawingSpec(thickness=1, circle_radius=1) |
|
with mp_face_mesh.FaceMesh( |
|
static_image_mode=True, |
|
max_num_faces=1, |
|
min_detection_confidence=min_detection_confidence) as face_mesh: |
|
results = face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) |
|
if not results.multi_face_landmarks: |
|
return mesh, image, idepth, 0 |
|
|
|
annotated_image = image.copy() |
|
for face_landmarks in results.multi_face_landmarks: |
|
(mesh,mtl,png) = self.toObj(image=image, width=width, height=height, ratio=ratio, landmark_list=face_landmarks, depth=depth, zoe_scale=zoe_scale, flat_scale=flat_scale) |
|
|
|
mp_drawing.draw_landmarks( |
|
image=annotated_image, |
|
landmark_list=face_landmarks, |
|
connections=mp_face_mesh.FACEMESH_TESSELATION, |
|
landmark_drawing_spec=None, |
|
connection_drawing_spec=mp_drawing_styles |
|
.get_default_face_mesh_tesselation_style()) |
|
mp_drawing.draw_landmarks( |
|
image=annotated_image, |
|
landmark_list=face_landmarks, |
|
connections=mp_face_mesh.FACEMESH_CONTOURS, |
|
landmark_drawing_spec=None, |
|
connection_drawing_spec=mp_drawing_styles |
|
.get_default_face_mesh_contours_style()) |
|
|
|
return mesh, annotated_image, idepth, 1 |
|
|
|
def toObj( self, image: np.ndarray, width:int, height:int, ratio: float, landmark_list: landmark_pb2.NormalizedLandmarkList, depth: np.ndarray, zoe_scale: float, flat_scale: float): |
|
print( f'you have such pretty hair', self.temp_dir ) |
|
|
|
hf_hack = True |
|
if hf_hack: |
|
obj_file = tempfile.NamedTemporaryFile(suffix='.obj', delete=False) |
|
mtl_file = tempfile.NamedTemporaryFile(suffix='.mtl', delete=False) |
|
png_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False) |
|
else: |
|
obj_file = tempfile.NamedTemporaryFile(suffix='.obj', dir=self.temp_dir, delete=False) |
|
mtl_file = tempfile.NamedTemporaryFile(suffix='.mtl', dir=self.temp_dir, delete=False) |
|
png_file = tempfile.NamedTemporaryFile(suffix='.png', dir=self.temp_dir, delete=False) |
|
|
|
|
|
(points,coordinates,colors) = self.landmarksToPoints( image, width, height, ratio, landmark_list, depth, zoe_scale, flat_scale ) |
|
|
|
|
|
lines = [] |
|
|
|
lines.append( f'o MyMesh' ) |
|
|
|
if hf_hack: |
|
|
|
lines.append( f'#mtllib file={mtl_file.name}' ) |
|
else: |
|
|
|
lines.append( f'mtllib file={mtl_file.name}' ) |
|
|
|
for index, point in enumerate(points): |
|
color = colors[index] |
|
scaled_color = [value / 255 for value in color] |
|
flipped = [-value for value in point] |
|
flipped[ 0 ] = -flipped[ 0 ] |
|
lines.append( "v " + " ".join(map(str, flipped + color)) ) |
|
|
|
for coordinate in coordinates: |
|
lines.append( "vt " + " ".join([str(value) for value in coordinate]) ) |
|
|
|
for quad in QUADS: |
|
|
|
normal = self.totallyNormal( points[ quad[ 0 ] -1 ], points[ quad[ 1 ] -1 ], points[ quad[ 2 ] -1 ] ) |
|
lines.append( "vn " + " ".join([str(value) for value in normal]) ) |
|
|
|
lines.append( 'usemtl MyMaterial' ) |
|
|
|
quadIndex = 0 |
|
for quad in QUADS: |
|
quadIndex = 1 + quadIndex |
|
face_uv = "f " + " ".join([f'{vertex}/{vertex}/{quadIndex}' for vertex in quad]) |
|
face_un = "f " + " ".join([str(vertex) for vertex in quad]) |
|
if self.uvwrap: |
|
lines.append( face_uv ) |
|
else: |
|
lines.append( f'#{face_uv}' ) |
|
lines.append( f'{face_un}' ) |
|
|
|
|
|
out = open( obj_file.name, 'w' ) |
|
out.write( '\n'.join( lines ) + '\n' ) |
|
out.close() |
|
|
|
|
|
|
|
lines = [] |
|
lines.append( 'newmtl MyMaterial' ) |
|
lines.append( f'map_Kd file={png_file.name}' ) |
|
|
|
out = open( mtl_file.name, 'w' ) |
|
out.write( '\n'.join( lines ) + '\n' ) |
|
out.close() |
|
|
|
|
|
|
|
cv2.imwrite(png_file.name, cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) |
|
|
|
|
|
|
|
print( f'I know it is special to you so I saved it to {obj_file.name} since we are friends' ) |
|
return (obj_file.name,mtl_file.name,png_file.name) |
|
|
|
def landmarksToPoints( self, image:np.ndarray, width: int, height: int, ratio: float, landmark_list: landmark_pb2.NormalizedLandmarkList, depth: np.ndarray, zoe_scale: float, flat_scale: float ): |
|
points = [] |
|
coordinates = [] |
|
colors = [] |
|
|
|
mins = [+np.inf] * 3 |
|
maxs = [-np.inf] * 3 |
|
|
|
mp_scale = 1 - zoe_scale |
|
print( f'zoe_scale:{zoe_scale}, mp_scale:{mp_scale}' ) |
|
|
|
for idx, landmark in enumerate(landmark_list.landmark): |
|
x, y = _normalized_to_pixel_coordinates(landmark.x,landmark.y,width,height) |
|
color = image[y,x] |
|
colors.append( [value / 255 for value in color ] ) |
|
coordinates.append( [x/width,1-y/height] ) |
|
|
|
if depth is not None: |
|
landmark.z = depth[y, x] * zoe_scale + mp_scale * landmark.z |
|
|
|
landmark.z = landmark.z * flat_scale |
|
|
|
point = [landmark.x * ratio, landmark.y, landmark.z]; |
|
for pidx,value in enumerate( point ): |
|
mins[pidx] = min(mins[pidx],value) |
|
maxs[pidx] = max(maxs[pidx],value) |
|
points.append( point ) |
|
|
|
mids = [(min_val + max_val) / 2 for min_val, max_val in zip(mins, maxs)] |
|
for idx,point in enumerate( points ): |
|
points[idx] = [(val-mid) for val, mid in zip(point,mids)] |
|
|
|
print( f'mins: {mins}' ) |
|
print( f'mids: {mids}' ) |
|
print( f'maxs: {maxs}' ) |
|
return (points,coordinates,colors) |
|
|
|
|
|
def totallyNormal(self, p0, p1, p2): |
|
v1 = np.array(p1) - np.array(p0) |
|
v2 = np.array(p2) - np.array(p0) |
|
normal = np.cross(v1, v2) |
|
normal = normal / np.linalg.norm(normal) |
|
return normal.tolist() |
|
|
|
|
|
def header(self): |
|
return (""" |
|
# Image to Quad Mesh |
|
|
|
Uses MediaPipe to detect a face in an image and convert it to a quad mesh. |
|
Saves to OBJ since gltf does not support quad faces. The 3d viewer has Y pointing the opposite direction from Blender, so ya hafta spin it. |
|
|
|
The face depth with Zoe can be a bit much and without it is a bit generic. In blender you can fix this just by snapping to the high poly model. For photos turning it down to .4 helps, but may still need cleanup... |
|
|
|
Highly recommend running it locally. The 3D model has uv values in the faces, but you will have to either use the script or do some manually tomfoolery. |
|
|
|
Quick import result in examples/converted/movie-gallery.mp4 under files |
|
""") |
|
|
|
|
|
def footer(self): |
|
return ( """ |
|
# Using the Textured Mesh in Blender |
|
|
|
There a couple of annoying steps atm after you download the obj from the 3d viewer. |
|
|
|
You can use the script meshin-around.sh in the files section to do the conversion or manually: |
|
|
|
1. edit the file and change the mtllib line to use fun.mtl |
|
2. replace / delete all lines that start with 'f', eg :%s,^f.*,, |
|
3. uncomment all the lines that start with '#f', eg: :%s,^#f,f, |
|
4. save and exit |
|
5. create fun.mtl to point to the texture like: |
|
|
|
``` |
|
newmtl MyMaterial |
|
map_Kd fun.png |
|
``` |
|
|
|
Make sure the obj, mtl and png are all in the same directory |
|
|
|
Now the import will have the texture data: File -> Import -> Wavefront (obj) -> fun.obj |
|
|
|
This is all a work around for a weird hf+gradios+babylonjs bug which seems to be related to the version |
|
of babylonjs being used... It works fine in a local babylonjs sandbox... |
|
|
|
# Suggested Workflows |
|
|
|
Here are some workflow ideas. |
|
|
|
## retopologize high poly face mesh |
|
|
|
1. sculpt high poly mesh in blender |
|
2. snapshot the face |
|
3. generate the mesh using the mediapipe stuff |
|
4. import the low poly mediapipe face |
|
5. snap the mesh to the high poly model |
|
6. model the rest of the low poly model |
|
7. bake the normal / etc maps to the low poly face model |
|
8. it's just that easy 😛 |
|
|
|
Ideally it would be a plugin... |
|
|
|
## stable diffusion integration |
|
|
|
1. generate a face in sd |
|
2. generate the mesh |
|
3. repose it and use it for further generation |
|
|
|
May need to expanded the generated mesh to cover more, maybe with |
|
<a href="https://github.com/shunsukesaito/PIFu" target="_blank">PIFu model</a>. |
|
|
|
""") |
|
|
|
|
|
def css(self): |
|
return (""" |
|
#mesh-display-output { |
|
max-height: 44vh; |
|
max-width: 44vh; |
|
width:auto; |
|
height:auto |
|
} |
|
#img-display-output { |
|
max-height: 28vh; |
|
max-width: 28vh; |
|
width:auto; |
|
height:auto |
|
} |
|
""") |
|
|
|
|
|
face_image_to_face_mesh().demo() |
|
|
|
|
|
|
|
|