作者:Leo Simmons
編譯:ronghuaiyang
導讀
和人臉屬性預測非常相似的一個應用。
這篇文章描述了一個神經網絡,它可以通過人臉圖像預測一個人的BMI([身體質量指數])。這個項目借鑒了另一個項目:https://github.com/yu4u/age-gender-estimation的方法,通過人臉來對一個人的年齡和性別進行分類,這個項目包括一個訓練過的模型的權重和一個腳本,該腳本用攝像頭動態檢測用戶的臉。這除了是一個有趣的機器學習問題外,以這種方式預測BMI可能是一個有用的醫學診斷工具。
訓練數據
使用的訓練數據是4000張圖像,每張都是不同個體的圖像,都是從受試者的正面拍攝的。每個訓練樣本的BMI由受試者的身高和體重計算(BMI是體重(kg)除以身高(米)的平方)。雖然訓練圖像不能在這裡分享,因為它們被用於另一個私人項目,但這種類型的數據可以從網上的不同地方收集。
圖形預處理
為了在訓練前對圖像進行歸一化,將每張圖像裁剪到受試者的面部,不包括面部周圍的區域。使用Python庫dlib檢測每幅圖像中的受試者的面部,並在dlib檢測到的邊界周圍添加額外的邊界,以生成用於實際訓練圖像。我們實驗了幾個邊距,看看哪個能讓網絡表現得最好。我們選擇了20%的邊距,即圖像的高度和寬度擴大40%(每邊都是20%),因為它能產生最佳的驗證性能。
下面顯示了使用不同裁剪邊緣添加到 Bill Murray 的圖像中,還有一個表格,顯示了添加了不同的邊距在驗證集上模型可以達到的最小的平均絕對誤差(MAE)。
原始圖像
使用不同的Margin進行裁剪的圖像
使用不同的Margin的圖像進行訓練的最低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 https://github.com/yu4u/age-gender-estimation/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