====== 顔検出によるサムネイル画像作成ツール ====== 自作のイラストファイルから顔検出して、その顔から160x160のサムネイル画像を自動で作成するツールです。 ====== OpenCVのインストール ====== Python環境は導入済の前提です。 OpenCVは、下記でインストールします。 # 基本モジュール pip install opencv-python # 拡張モジュール pip install opencv-contrib-python ====== 顔検出の方法 ====== Haar Cascadeによる方法と、DNNによる方法があります。 前者が昔ながらの方法で、後者が新しい方法になります。 どちらも検出のための分類器やモデルを入手して設定する必要がありますが、 一般的なリアルな人間の顔を対象に学習したモデルでは、 アニメ風イラストの顔検出の精度はかなり低くなります。 検出対象に合わせた適切なモデルであることが重要です。 ====== Haar Cascade ====== lbpcascade_animeface.xml は自分で入手が必要です。 今回は下記のURLからダウンロードしました。 * https://github.com/nagadomi/lbpcascade_animeface なおOpenCV5からは、基本モジュールにHaar Cascadeは含まれず、 拡張モジュールに含まれています。 OpenCV4以前は基本モジュールに含まれているので、古い資料を見て混乱しないようにご注意ください。 import cv2 import os import numpy as np def crop_and_resize_face_long_edge(image_path, output_path, cascade_path='./lbpcascade_animeface.xml'): if not os.path.exists(image_path): print(f"エラー: 入力ファイルが見つかりません: {image_path}") return print(cv2.__version__) # OpenCVのバージョンが表示されれば正常です img = cv2.imread(image_path) img_h, img_w = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) face_cascade = cv2.CascadeClassifier(cascade_path) if face_cascade.empty(): print(f"エラー: カスケードファイルが見つかりません: {cascade_path}") return faces = face_cascade.detectMultiScale(gray, scaleFactor=1.001, minNeighbors=5, minSize=(200, 200)) if len(faces) == 0: print("顔が検出されませんでした。") return # 最初に見つかった顔を対象にする (x, y, w, h) = faces[0] # ★ 変更点: 長い方の辺に合わせる size = max(w, h) center_x = x + w // 2 center_y = y + h // 2 # 切り出しの開始・終了座標(理想値) start_x = center_x - size // 2 start_y = center_y - size // 2 end_x = start_x + size end_y = start_y + size # ★ 画像外にはみ出るサイズ(パディング量)を計算 pad_top = max(0, -start_y) pad_bottom = max(0, end_y - img_h) pad_left = max(0, -start_x) pad_right = max(0, end_x - img_w) # 画像内の有効なクロップ範囲を調整 crop_start_y = max(0, start_y) crop_end_y = min(img_h, end_y) crop_start_x = max(0, start_x) crop_end_x = min(img_w, end_x) # 画像から切り出し face_crop = img[crop_start_y:crop_end_y, crop_start_x:crop_end_x] # ★ はみ出た部分がある場合は黒色の余白を追加 if pad_top > 0 or pad_bottom > 0 or pad_left > 0 or pad_right > 0: face_crop = cv2.copyMakeBorder( face_crop, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_CONSTANT, value=[0, 0, 0] # 黒色 ) # 160x160へのリサイズ face_resized = cv2.resize(face_crop, (160, 160), interpolation=cv2.INTER_AREA) cv2.imwrite(output_path, face_resized) print(f"成功: 長辺基準で {output_path} に保存しました(160x160)") # --- 実行例 --- crop_and_resize_face_long_edge('serahonoka-osakana.png', 'face_160x160_long.png') ====== DNN(YOLOv8) ====== モデルは下記からダウンロードしました。どちらも同じファイルっぽいですね。 * https://huggingface.co/YZBPXX/anime_face_detect/tree/main * https://huggingface.co/deepghs/anime_face_detection/tree/main/face_detect_v1.4_s [[https://huggingface.co/|Hugging Face]]から"anime"などのキーワードで探すのが良いと思います。 model以外にdatasetも公開されているので、datasetは無視してmodelからonnxファイルを探します。 YOLOv8で利用できるonnxを import cv2 import numpy as np def crop_to_square(image, box): """ 検出されたバウンディングボックスを長辺に合わせた正方形に拡張し、切り出す関数 (画像外にはみ出る場合は黒色でパディング) """ h_img, w_img = image.shape[:2] x, y, w, h = box # 中心座標と長辺の長さを計算 cx = x + w / 2 cy = y + h / 2 side = max(w, h) # 正方形の開始座標と終了座標 x1 = int(cx - side / 2) y1 = int(cy - side / 2) x2 = int(x1 + side) y2 = int(y1 + side) # 画像の範囲内・範囲外の座標を計算(パディング用) pad_x1 = max(0, -x1) pad_y1 = max(0, -y1) pad_x2 = max(0, x2 - w_img) pad_y2 = max(0, y2 - h_img) # 実際の画像からクロップできる範囲 crop_x1 = max(0, x1) crop_y1 = max(0, y1) crop_x2 = min(w_img, x2) crop_y2 = min(h_img, y2) # クロップの実行 cropped = image[crop_y1:crop_y2, crop_x1:crop_x2] # 範囲外にはみ出ていた場合は黒(0)でパディング if pad_x1 > 0 or pad_y1 > 0 or pad_x2 > 0 or pad_y2 > 0: cropped = cv2.copyMakeBorder( cropped, pad_y1, pad_y2, pad_x1, pad_x2, cv2.BORDER_CONSTANT, value=[0, 0, 0] ) return cropped def detect_and_save_faces(image_path, model_path, conf_threshold=0.4, nms_threshold=0.4): # 画像の読み込み img = cv2.imread(image_path) if img is None: print(f"Error: 画像ファイルが見つかりません: {image_path}") return h_img, w_img = img.shape[:2] # ONNXモデルの読み込み (OpenCV 5 DNN) net = cv2.dnn.readNetFromONNX(model_path) # モデルの推奨入力サイズ(一般的にYOLOv8系は640x640) input_size = (640, 640) # 変更前 # blob = cv2.dnn.blobFromImage(img, 1/255.0, input_size, swapRB=False, crop=False) # 変更後:ImageNetの平均値(Mean)と標準偏差(Std)を適用 # 1/255.0 に 加えて 標準偏差(0.229) で割るための係数を計算 scalefactor = 1.0 / (255.0 * 0.229) mean = [0.485 * 255, 0.456 * 255, 0.406 * 255] blob = cv2.dnn.blobFromImage( img, scalefactor=scalefactor, size=input_size, mean=mean, swapRB=True, crop=False ) net.setInput(blob) # 推論の実行 outputs = net.forward() # YOLOv8の出力形状は [1, 5, 8400] (5項目: x_center, y_center, width, height, confidence) # 扱いやすいように転置 predictions = np.squeeze(outputs).T boxes = [] confidences = [] # スケール比率の計算 x_factor = w_img / input_size[0] y_factor = h_img / input_size[1] for pred in predictions: # スコア(顔である確率)を取得 confidence = pred[4] if confidence >= conf_threshold: # 座標を元の画像サイズに復元 cx, cy, w, h = pred[0], pred[1], pred[2], pred[3] x = int((cx - w / 2) * x_factor) y = int((cy - h / 2) * y_factor) w = int(w * x_factor) h = int(h * y_factor) boxes.append([x, y, w, h]) confidences.append(float(confidence)) # 重複するボックスを非大値抑制(NMS)で排除 indices = cv2.dnn.NMSBoxes(boxes, confidences, conf_threshold, nms_threshold) if len(indices) == 0: print("顔は検出されませんでした。") return # 検出した顔の処理と保存 for i, idx in enumerate(indices.flatten()): box = boxes[idx] # 1. 長辺合わせの正方形で切り出し square_crop = crop_to_square(img, box) # 2. 160x160にリサイズ resized_face = cv2.resize(square_crop, (160, 160), interpolation=cv2.INTER_AREA) # 3. ファイル保存 save_path = f"face_{i:02d}.png" cv2.imwrite(save_path, resized_face) print(f"Saved: {save_path} (Conf: {confidences[idx]:.2f})") # --- 実行例 --- if __name__ == "__main__": # HuggingFaceからダウンロードしたONNXファイルのパスを指定してください MODEL_PATH = "model.onnx" IMAGE_PATH = "serahonoka-osakana.png" detect_and_save_faces(IMAGE_PATH, MODEL_PATH) ====== 精度比較 ====== きちんと比較したわけではありませんが、 Haar Cascadeでは想定通りに顔検出できなかった「瞑目」イラストについて、 DNNでは期待通り顔検出していたので、DNNの方が精度が高そうだと思っています。 ====== ツールとして整備したスクリプト ====== 指定した入力ディレクトリ配下にあるファイルを対象に、 サムネイルを作成し、指定した出力ディレクトリ配下に出力します。 import cv2 import numpy as np from pathlib import Path def crop_to_square(image, box): """ 検出されたバウンディングボックスを長辺に合わせた正方形に拡張し、切り出す関数 (画像外にはみ出る場合は黒色でパディング) """ h_img, w_img = image.shape[:2] x, y, w, h = box # 中心座標と長辺の長さを計算 cx = x + w / 2 cy = y + h / 2 side = max(w, h) # 正方形の開始座標と終了座標 x1 = int(cx - side / 2) y1 = int(cy - side / 2) x2 = int(x1 + side) y2 = int(y1 + side) # 画像の範囲内・範囲外の座標を計算(パディング用) pad_x1 = max(0, -x1) pad_y1 = max(0, -y1) pad_x2 = max(0, x2 - w_img) pad_y2 = max(0, y2 - h_img) # 実際の画像からクロップできる範囲 crop_x1 = max(0, x1) crop_y1 = max(0, y1) crop_x2 = min(w_img, x2) crop_y2 = min(h_img, y2) # クロップの実行 cropped = image[crop_y1:crop_y2, crop_x1:crop_x2] # 範囲外にはみ出ていた場合は黒(0)でパディング if pad_x1 > 0 or pad_y1 > 0 or pad_x2 > 0 or pad_y2 > 0: cropped = cv2.copyMakeBorder( cropped, pad_y1, pad_y2, pad_x1, pad_x2, cv2.BORDER_CONSTANT, value=[0, 0, 0] ) return cropped def detect_and_save_faces(image_path, save_path, model_path, conf_threshold=0.4, nms_threshold=0.4): # 画像の読み込み img = cv2.imread(image_path) if img is None: print(f"Error: 画像ファイルが見つかりません: {image_path}") return h_img, w_img = img.shape[:2] # ONNXモデルの読み込み (OpenCV 5 DNN) net = cv2.dnn.readNetFromONNX(model_path) # モデルの推奨入力サイズ(一般的にYOLOv8系は640x640) input_size = (640, 640) # 変更前 # blob = cv2.dnn.blobFromImage(img, 1/255.0, input_size, swapRB=False, crop=False) # 変更後:ImageNetの平均値(Mean)と標準偏差(Std)を適用 # 1/255.0 に 加えて 標準偏差(0.229) で割るための係数を計算 scalefactor = 1.0 / (255.0 * 0.229) mean = [0.485 * 255, 0.456 * 255, 0.406 * 255] blob = cv2.dnn.blobFromImage( img, scalefactor=scalefactor, size=input_size, mean=mean, swapRB=True, crop=False ) net.setInput(blob) # 推論の実行 outputs = net.forward() # YOLOv8の出力形状は [1, 5, 8400] (5項目: x_center, y_center, width, height, confidence) # 扱いやすいように転置 predictions = np.squeeze(outputs).T boxes = [] confidences = [] # スケール比率の計算 x_factor = w_img / input_size[0] y_factor = h_img / input_size[1] for pred in predictions: # スコア(顔である確率)を取得 confidence = pred[4] if confidence >= conf_threshold: # 座標を元の画像サイズに復元 cx, cy, w, h = pred[0], pred[1], pred[2], pred[3] x = int((cx - w / 2) * x_factor) y = int((cy - h / 2) * y_factor) w = int(w * x_factor) h = int(h * y_factor) boxes.append([x, y, w, h]) confidences.append(float(confidence)) # 重複するボックスを非大値抑制(NMS)で排除 indices = cv2.dnn.NMSBoxes(boxes, confidences, conf_threshold, nms_threshold) if len(indices) == 0: print("顔は検出されませんでした。") return # 最もスコアが高い検出範囲を処理する。 # cv2.dnn.NMSBoxes の出力はスコアの降順(高い順)にソートされているため、 # フラット化した配列の先頭([0])が最高スコアのインデックスになる。 best_idx = indices.flatten()[0] box = boxes[best_idx] # 1. 長辺合わせの正方形で切り出し square_crop = crop_to_square(img, box) # 2. 160x160にリサイズ resized_face = cv2.resize(square_crop, (160, 160), interpolation=cv2.INTER_AREA) cv2.imwrite(save_path, resized_face) print(f"Saved: {save_path} (Best Conf: {confidences[best_idx]:.2f})") # 一定以上のスコアを持つ検出範囲を全て処理したい場合 # 複数のキャラクターがいるケースだと意味がある if False: # 検出した顔の処理と保存 for i, idx in enumerate(indices.flatten()): box = boxes[idx] # 1. 長辺合わせの正方形で切り出し square_crop = crop_to_square(img, box) # 2. 160x160にリサイズ resized_face = cv2.resize(square_crop, (160, 160), interpolation=cv2.INTER_AREA) # 3. ファイル保存(※ファイル名の設定は要修正。このままだと同じファイル名で上書きする) cv2.imwrite(save_path, resized_face) print(f"Saved: {save_path} (Conf: {confidences[idx]:.2f})") # --- 実行例 --- if __name__ == "__main__": # HuggingFaceからダウンロードしたONNXファイルのパスを指定してください MODEL_PATH = "model.onnx" input_dir = Path("input") output_dir = Path("output") # ディレクトリ内のファイルを1つずつループ処理 # .iterdir() はディレクトリ内のファイルやフォルダをすべて列挙します for file_path in input_dir.iterdir(): # フォルダ(ディレクトリ)は除外して、ファイルのみを処理する場合 if file_path.is_file(): print(f"処理中のファイル: {file_path.name}") # 出力先ファイルパスを作成する save_path = output_dir / f"{file_path.stem}-s{file_path.suffix}" print(f"保存先パス: {save_path}") detect_and_save_faces(file_path, save_path, MODEL_PATH) ====== 注釈 ====== 本ページで紹介しているコードは、Geminiに生成させたものをベースに修正を加えたものになっています。