作者:Leo Simmons
編譯:ronghuaiyang
導讀
和人臉屬性預測非常相似的一個應用。
這篇文章描述了一個神經網路,它可以透過人臉影像預測一個人的BMI([身體質量指數])。這個專案借鑑了另一個專案:的方法,透過人臉來對一個人的年齡和性別進行分類,這個專案包括一個訓練過的模型的權重和一個指令碼,該指令碼用攝像頭動態檢測使用者的臉。這除了是一個有趣的機器學習問題外,以這種方式預測BMI可能是一個有用的醫學診斷工具。
訓練資料
使用的訓練資料是4000張影像,每張都是不同個體的影像,都是從受試者的正面拍攝的。每個訓練樣本的BMI由受試者的身高和體重計算(BMI是體重(kg)除以身高(米)的平方)。雖然訓練影像不能在這裡分享,因為它們被用於另一個私人專案,但這種型別的資料可以從網上的不同地方收集。
圖形預處理
為了在訓練前對影像進行歸一化,將每張影像裁剪到受試者的面部,不包括面部周圍的區域。使用Python庫dlib檢測每幅影像中的受試者的面部,並在dlib檢測到的邊界周圍新增額外的邊界,以生成用於實際訓練影像。我們實驗了幾個邊距,看看哪個能讓網路表現得最好。我們選擇了20%的邊距,即影像的高度和寬度擴大40%(每邊都是20%),因為它能產生最佳的驗證效能。
下面顯示了使用不同裁剪邊緣新增到 Bill Murray 的影像中,還有一個表格,顯示了新增了不同的邊距在驗證集上模型可以達到的最小的平均絕對誤差(MAE)。
雖然在20%-50%的margin範圍內的MAE值可能太過接近,不能說任何一個都比其他的好,但很明顯,至少增加20%的margin 會比不增加margin 產生更好的MAE。這可能是因為增加的margin 捕獲了前額上部、耳朵和頸部等特徵,這些特徵對模型預測BMI很有用,但大部分被原始的dlib裁剪掉了。
影像預處理程式碼:
import os
import cv2
import dlib
from matplotlib import pyplot as plt
import numpy as np
import config
detector = dlib.get_frontal_face_detector()
def crop_faces():
bad_crop_count =
0
if
not
os.
path.exists(
config.CROPPED_IMGS_DIR):
os.makedirs(
config.CROPPED_IMGS_DIR)
print
'Cropping faces and saving to %s' %
config.CROPPED_IMGS_DIR
good_cropped_images = []
good_cropped_img_file_names = []
detected_cropped_images = []
original_images_detected = []
for file_name
in sorted(
os.listdir(
config.ORIGINAL_IMGS_DIR)):
np_img = cv2.imread(
os.
path.join(
config.ORIGINAL_IMGS_DIR,file_name))
detected = detector(np_img,
1)
img_h, img_w, _ = np.shape(np_img)
original_images_detected.append(np_img)
if
len(detected) !=
1:
bad_crop_count +=
1
continue
d = detected[
0]
x1, y1, x2, y2, w, h = d.left(), d.top(), d.right() +
1, d.bottom() +
1, d.width(), d.height()
xw1 = int(x1 -
config.MARGIN * w)
yw1 = int(y1 -
config.MARGIN * h)
xw2 = int(x2 +
config.MARGIN * w)
yw2 = int(y2 +
config.MARGIN * h)
cropped_img = crop_image_to_dimensions(np_img, xw1, yw1, xw2, yw2)
norm_file_path =
'%s/%s' % (
config.CROPPED_IMGS_DIR, file_name)
cv2.imwrite(norm_file_path, cropped_img)
good_cropped_img_file_names.append(file_name)
# save info of good cropped images
with
open(
config.ORIGINAL_IMGS_INFO_FILE,
'r') as f:
column_headers = f.
read().splitlines()[
0]
all_imgs_info = f.
read().splitlines()[
1:]
cropped_imgs_info = [l
for l
in all_imgs_info
if l.split(
',')[
-1]
in good_cropped_img_file_names]
with
open(
config.CROPPED_IMGS_INFO_FILE,
'w') as f:
f.
write(
'%s\n' % column_headers)
for l
in cropped_imgs_info:
f.
write(
'%s\n' % l)
print
'Cropped %d images and saved in %s - info in %s' % (
len(original_images_detected),
config.CROPPED_IMGS_DIR,
config.CROPPED_IMGS_INFO_FILE)
print
'Error detecting face in %d images - info in Data/unnormalized.txt' % bad_crop_count
return good_cropped_images
# image cropping
function
taken
from:
#
https://
stackoverflow.com/
questions/15589517/
how-
to-
crop-
an-
image-
in-
opencv-
using-
python
def
crop_image_to_dimensions
(img, x1, y1, x2, y2):
if x1 <
0
or y1 <
0
or x2 > img.shape[
1]
or y2 > img.shape[
0]:
img, x1, x2, y1, y2 = pad_img_to_fit_bbox(img, x1, x2, y1, y2)
return img[y1:y2, x1:x2, :]
def pad_img_to_fit_bbox(img, x1, x2, y1, y2):
img = cv2.copyMakeBorder(img, -
min(
0, y1),
max(y2 - img.shape[
0],
0),
-
min(
0, x1),
max(x2 - img.shape[
1],
0), cv2.BORDER_REPLICATE)
y2 += -
min(
0, y1)
y1 += -
min(
0, y1)
x2 += -
min(
0, x1)
x1 += -
min(
0, x1)
return img, x1, x2, y1, y2
if __name__ ==
'__main__':
crop_faces()
影像增強
為了增加每個原始訓練影像用於網路訓練的次數,在每個訓練epoch中對影像進行增強。影像增強庫Augmentor用於動態旋轉、翻轉和扭曲影像不同部分的解析度,並改變影像的對比度和亮度。
影像增強程式碼:
from keras.preprocessing.image import ImageDataGenerator
import pandas as pd
import Augmentor
from PIL import Image
import random
import numpy as np
import matplotlib.pyplot as plt
import math
import config
def plot_imgs_from_generator(generator, number_imgs_to_show=
9):
print (
'Plotting images...')
n_rows_cols = int(
math.
ceil(
math.
sqrt(number_imgs_to_show)))
plot_index =
1
x_batch, _ =
next(generator)
while plot_index <= number_imgs_to_show:
plt.subplot(n_rows_cols, n_rows_cols, plot_index)
plt.imshow(x_batch[plot_index
-1])
plot_index +=
1
plt.show()
def augment_image(np_img):
p = Augmentor.Pipeline()
p.rotate(probability=
1, max_left_rotation=
5, max_right_rotation=
5)
p.flip_left_right(probability=
0.5)
p.random_distortion(probability=
0.25, grid_width=
2, grid_height=
2, magnitude=
8)
p.random_color(probability=
1, min_factor=
0.8, max_factor=
1.2)
p.random_contrast(probability=
.5, min_factor=
0.8, max_factor=
1.2)
p.random_brightness(probability=
1, min_factor=
0.5, max_factor=
1.5)
image = [Image.fromarray(np_img.astype(
'uint8'))]
for operation
in p.operations:
r = round(
random.uniform(
0,
1),
1)
if r <= operation.probability:
image = operation.perform_operation(image)
image = [np.array(i).astype(
'float64')
for i
in image]
return image[
0]
image_processor = ImageDataGenerator(
rescale=
1./
255,
preprocessing_function=augment_image)
# subtract validation size from training data
with
open(
config.CROPPED_IMGS_INFO_FILE) as f:
for i, _
in enumerate(f):
pass
training_n = i -
config.VALIDATION_SIZE
train_df=pd.read_csv(
config.CROPPED_IMGS_INFO_FILE, nrows=training_n)
train_generator=image_processor.flow_from_dataframe(
dataframe=train_df,
directory=
config.CROPPED_IMGS_DIR,
x_col=
'name',
y_col=
'bmi',
class_mode=
'other',
color_mode=
'rgb',
target_size=(
config.RESNET50_DEFAULT_IMG_WIDTH,
config.RESNET50_DEFAULT_IMG_WIDTH),
batch_size=
config.TRAIN_BATCH_SIZE)
模型結構
模型是使用Keras ResNet50類建立的。選擇ResNet50架構,權重是由一個年齡分類器訓練得到的,來自年齡和性別的專案可用於遷移學習,也因為ResNet(殘差網路)架構對於人臉影像識別是很好的模型。
其他網路架構在基於人臉的影像分類任務上也取得了令人印象深刻的結果,未來的工作可以探索其中的一些結構用於BMI 指數的預測。
實現模型架構程式碼:
from tensorflow.python.keras.models import Model
from tensorflow.python.keras.applications import ResNet50
from tensorflow.python.keras.layers import Dense
import config
def get_age_model():
# adapted from /blob/master/age_estimation/model.py
age_model = ResNet50(
include_top=False,
weights=
'imagenet',
input_shape=(
config.RESNET50_DEFAULT_IMG_WIDTH,
config.RESNET50_DEFAULT_IMG_WIDTH,
3),
pooling=
'avg')
prediction = Dense(units=
101,
kernel_initializer=
'he_normal',
use_bias=False,
activation=
'softmax',
name=
'pred_age')(age_model.
output)
age_model = Model(inputs=age_model.
input, outputs=prediction)
age_model.load_weights(
config.AGE_TRAINED_WEIGHTS_FILE)
print
'Loaded weights from age classifier'
return age_model
def get_model():
base_model = get_age_model()
last_hidden_layer = base_model.get_layer(index=
-2)
base_model = Model(
inputs=base_model.
input,
outputs=last_hidden_layer.
output)
prediction = Dense(
1, kernel_initializer=
'normal')(base_model.
output)
model = Model(inputs=base_model.
input, outputs=prediction)
return model
遷移學習
遷移學習是為了利用年齡分類器網路中的權重,因為這些對於檢測用於預測BMI的低階面部特徵應該是有價值的。為年齡網路加一個新的線性迴歸輸出層(輸出一個代表BMI的數字),並使用MAE作為損失函式和Adam作為訓練最佳化器進行訓練。
首先對模型進行訓練,使原始年齡分類器的每一層都被凍結,以允許新輸出層的隨機權值進行更新。第一次訓練包含了10個epoch,因為在此之後,MAE沒有明顯的下降(使用early stop)。
在這個初始訓練階段之後,模型被訓練了30個epoch,網路中的每一層都被解凍,以微調網路中的所有權重。Early stopping也決定了這裡的epoch的數量,只有在觀察到MAE沒有減少的10個epoch後才停止訓練(patience為10)。由於模型在epoch 20達到了最低的驗證性MAE,訓練在epoch 30停止。取模型在epoch 20的權重,並在下面的演示中使用。
平均絕對誤差被選作為損失函式,和均方誤差(MSE)或均方根誤差(RMSE)不一樣,BMI預測的誤差的尺度是線性的(誤差為10的懲罰應該是誤差為5的懲罰的2倍)。
模型訓練程式碼:
import cv2
import numpy
as np
from tensorflow.python.keras.callbacks
import EarlyStopping, ModelCheckpoint, TensorBoard
from train_generator
import train_generator, plot_imgs_from_generator
from mae_callback
import MAECallback
import config
batches_per_epoch=train_generator.n //train_generator.batch_size
def train_top_layer(model):
print
'Training top layer...'
for l
in model.layers[:
-1]:
l.trainable =
False
model.compile(
loss=
'mean_absolute_error',
optimizer=
'adam')
mae_callback = MAECallback()
early_stopping_callback = EarlyStopping(
monitor=
'val_mae',
mode=
'min',
verbose=
1,
patience=
1)
model_checkpoint_callback = ModelCheckpoint(
'saved_models/top_layer_trained_weights.{epoch:02d}-{val_mae:.2f}.h5',
monitor=
'val_mae',
mode=
'min',
verbose=
1,
save_best_only=
True)
tensorboard_callback = TensorBoard(
log_dir=config.TOP_LAYER_LOG_DIR,
batch_size=train_generator.batch_size)
model.fit_generator(
generator=train_generator,
steps_per_epoch=batches_per_epoch,
epochs=
20,
callbacks=[
mae_callback,
early_stopping_callback,
model_checkpoint_callback,
tensorboard_callback])
def
train_all_layers
(model):
print
'Training all layers...'
for l
in model.layers:
l.trainable =
True
mae_callback = MAECallback()
early_stopping_callback = EarlyStopping(
monitor=
'val_mae',
mode=
'min',
verbose=
1,
patience=
10)
model_checkpoint_callback = ModelCheckpoint(
'saved_models/all_layers_trained_weights.{epoch:02d}-{val_mae:.2f}.h5',
monitor=
'val_mae',
mode=
'min',
verbose=
1,
save_best_only=
True)
tensorboard_callback = TensorBoard(
log_dir=config.ALL_LAYERS_LOG_DIR,
batch_size=train_generator.batch_size)
model.compile(
loss=
'mean_absolute_error',
optimizer=
'adam')
model.fit_generator(
generator=train_generator,
steps_per_epoch=batches_per_epoch,
epochs=
100,
callbacks=[
mae_callback,
early_stopping_callback,
model_checkpoint_callback,
tensorboard_callback])
Demo
下面是模型透過Christian Bale的幾張照片預測出的體重指數。之所以選擇貝爾作為研究物件,是因為眾所周知,他會在不同的角色中劇烈地改變自己的體重。知道了他的身高是6英尺0英寸,他的體重就可以從模型的BMI預測中得到。
左邊的圖片來自機械師,其中貝爾說他“大概135磅”。如果他的體重是135磅,那麼他的BMI是18.3 kg/m (BMI的單位),而模型的預測相差約4 kg/m。中間的圖片是我認為代表他的體重,當時他沒有為一個角色徹底改變它。右邊的圖片是在拍攝Vice時拍攝的。在拍攝Vice的時候,我找不到他的體重數字,但我找到幾個訊息來源說他胖了45磅。如果我們假設他的平均體重是200磅,而在拍攝Vice時他體重是245磅,體重指數為33.2,那麼模型對這張照片的體重指數預測將相差約1 kg/m²。
下面是我的BMI預測模型的記錄。我的身體質量指數是23 kg/m²,當我直視相機時,模型偏差2~4 kg/m²,當我的頭偏向一邊或者朝下時,偏差高達8kg/m²。
討論
該模型的驗證MAE為4.48。給定一個人,5“9和195磅,美國男性的平均身高和體重,BMI 為27.35kg/m²,這4.48的錯誤將導致預測範圍為22.87 kg/m² 到 31.83 kg/m²,對應163和227磅重量。顯然,還有改進的餘地,今後的工作將努力減少這種錯誤。
該模型的一個明顯缺點是,當評估從不同角度而不是從被攝者的正面拍攝的影像時,效能很差。當我把頭移到一邊或往下時,模型的預測就變得不那麼準確了。
這個模型的另一個可能的缺點可能有助於解釋這個模型對 Christian Bale的第一張照片的不準確的預測,那就是當主體在黑暗的環境中被一個集中的光源照射時,表現不佳。強烈的光照造成的陰影改變了臉的兩側的曲率和皮膚的微妙的表現,造成了對BMI的影響。
也有可能這個模型只是簡單地高估了總體BMI較低的受試者的BMI,這可以從它對我自己和克里斯蒂安·貝爾的第一張照片的評估中看出。
該模型的這些缺點可能可以用訓練資料中奇怪的角度、集中的光線和較低的BMIs來解釋。大多數訓練影像是在良好的光照下,從受試者的前部拍攝的,並且是由BMI高於25 kg/m²的受試者拍攝的。因此,在這些不同的場景中,該模型可能無法充分了解面部特徵與BMI的相關性。
英文原文:https://medium.com/@leosimmons/estimating-body-mass-index-from-face-images-using-keras-and-transfer-learning-de25e1bc0212