トップへ(mam-mam.net/)

Delphiで顔認証:TEdgeBrowser × face-api.jsによる人物照合システム

Delphiで顔認証:TEdgeBrowser × face-api.jsによる人物照合システム

Delphiで顔認証システムを構築する際、JavaScriptライブラリ「face-api.js」をWebView2経由で呼び出すことで、 登録済みの正面顔写真と対象画像の特徴点を比較し、同一人物かどうかを判定することが可能になります。
本記事では、TEdgeBrowserを活用した顔認証の実装手順と、特徴点の抽出・ユークリッド距離による照合・結果の受信までを詳細に解説します。
単なる顔検出ではなく、人物照合を行っています。

この例では、閾値として 距離0.45未満 の場合に同一人物としています。

また、本ホームページでは「PAKUTASO」様
https://www.pakutaso.com/
のフリー顔写真を使用させていただいております。

face-api.jsをダウンロードする

https://github.com/justadudewhohacks/face-api.js
の右上にある「code」⇒「Download ZIP」をクリックすると face-api.js-master.zip がダウンロードできます。

画面設計

Delphi IDEを起動し、「ファイル」⇒「Windows VCLアプリケーション -Delphi」をクリックします。
「ファイル」⇒「すべて保存 Ctrl+Shift+S」をクリックして、プロジェクト保存用フォルダを作成して ユニット(Unit1)とプロジェクト(Project1)を保存します
次に、「プロジェクト」⇒「Project1をビルト Shift+F9」をクリックして事前に一度コンパイルしておきます。(フォルダが生成される)

フォームに以下のコンポーネントをドラッグ&ドロップします。

Delphiで顔認証(同じ顔が写っている写真をピックアップ)

「WebView2Loader.dll」ファイル

「WebView2Loader.dll」ファイルを、実行ファイルと同じフォルダ内(プロジェクト保存フォルダ\Win32\Debug)にコピーします

(A)「Debug」ビルトで
「Windows 32ビット」
プロジェクト保存フォルダ\Win32\Debug
(B)「Debug」ビルトで
「Windows 64ビット」
プロジェクト保存フォルダ\Win64\Debug
(C)「Release」ビルトで
「Windows 32ビット」
プロジェクト保存フォルダ\Win32\Release
(D)「Release」ビルトで
「Windows 64ビット」
プロジェクト保存フォルダ\Win64\Release

HTMLファイル、jsファイルなどの配置

  1. プロジェクト保存フォルダ\Win32\Debug に「htdocs」フォルダを作成します。
  2. プロジェクト保存フォルダ\Win32\Debug\htdocs に、
    face-api.js-master.zip ファイルを解凍した フォルダ「face-api.js-master」を丸ごとコピーします。
  3. プロジェクト保存フォルダ\Win32\Debug\htdocs に、以下の index.html ファイルをUTF-8で保存します。
    SSD-MobileNetV1を使用して顔検出するように設定しています。
    SSD-MobileNetV1はCNN(畳み込みニューラルネットワーク)ベースのモデルでDepthwise Separable Convolution を採用した約28層の構成のようです。
    <!DOCTYPE html>
    <html lang="ja">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="./face-api.js-master/dist/face-api.min.js"></script>
    
    <script>
    let faceMatcher=[];//顔画像から特徴点を取り出した値を入れる
    
    window.addEventListener('DOMContentLoaded',async function(event){
      await Promise.all([
        //SsdMobilenetv1Modelを使用する
        faceapi.nets.ssdMobilenetv1.loadFromUri('./face-api.js-master/weights'),
        //faceLandmark68Netを使用する
        faceapi.nets.faceLandmark68Net.loadFromUri("./face-api.js-master/weights"),
        //faceRecognitionNetを使用する
        faceapi.nets.faceRecognitionNet.loadFromUri("./face-api.js-master/weights")
      ]);
      document.getElementById("res").innerHTML="face-api.js loaded.";
    });
    
    
    //顔識別させたい正面顔画像を登録する
    async function registImg(src){
      //document.getElementById("res").innerHTML=src;
      document.getElementById("res").innerHTML="";
    
      let img=await loadImage(src);
      let singleResult=await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
      let num=faceMatcher.length;
      if(singleResult.length>0){
        const labeledDescriptors = [
          new faceapi.LabeledFaceDescriptors(
            'label'+num,//ラベルを設定する
            [singleResult[0].descriptor]
          ),
        ];
        faceMatcher[num] = new faceapi.FaceMatcher(labeledDescriptors);
        document.getElementById("res").innerHTML="Registed Image";
      }
    }
    
    async function loadImage(src){
      return new Promise(function(resolve,reject){
        let img=new Image();
        img.onload=function(){resolve(img);}
        img.onerror=function(e){reject(e);}
        img.src=src;
      });
    }
    
    async function descript(src){
      document.getElementById("res").innerHTML="";
      let img=await loadImage(src);
      faceapi.detectAllFaces(img, new faceapi.SsdMobilenetv1Options()).withFaceLandmarks().withFaceDescriptors().then(
        function(result){
          let res=null;
          let dis=1;
          if(faceMatcher!=null && result.length>0){
            for(i=0;i<result.length;i++){
              for(let j=0;j<faceMatcher.length;j++){
                let bestMatch = faceMatcher[j].findBestMatch(result[i].descriptor);
                if(bestMatch.distance<dis){
                  res=result[i];
                  dis=bestMatch.distance;
                }
                if(res){
                  document.getElementById("res").innerHTML=
                    res.detection.box.x+","+
                    res.detection.box.y+","+
                    res.detection.box.width+","+
                    res.detection.box.height+","+
                    dis  ;
                }else{
                  document.getElementById("res").innerHTML="0,0,0,0,1";
                }
              }
            }
          }
        }
      );
    }
    </script>
    </head>
    <body>
      <h3>face-api.jsサンプル</h3>
      <div id="res"></div>
    </body>
    </html>
    

以下のようなフォルダ、ファイル構成になります。

プロジェクト保存フォルダ\Win32\Debug\ WebView2Loader.dll
htdocs\ index.html
face-api.js-master\ dist\・・・
examples\・・・
src\・・・
weights\・・・
・・・

ソースコードの記述

以下ソースコードをコピー&ペーストして、IDEから各イベントプロパティを設定します。

オブジェクト イベント プロシージャ
Form1 OnCreate FormCreate
Button1 OnClick Button1Click
Button2 OnClick Button2Click
EdgeBrowser1 OnExecuteScript EdgeBrowser1ExecuteScript
ListView1 OnChange ListView1Change
unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Winapi.WebView2, Winapi.ActiveX,
  Vcl.Edge, Vcl.StdCtrls, Vcl.ExtCtrls, Vcl.ComCtrls;

type
  TForm1 = class(TForm)
    EdgeBrowser1: TEdgeBrowser;
    Memo1: TMemo;
    Button1: TButton;
    Button2: TButton;
    OpenDialog1: TOpenDialog;
    Image1: TImage;
    ListView1: TListView;
    OpenDialog2: TOpenDialog;
    Image2: TImage;
    procedure FormCreate(Sender: TObject);
    procedure EdgeBrowser1ExecuteScript(Sender: TCustomEdgeBrowser;
      AResult: HRESULT; const AResultObjectAsJson: string);
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
    procedure ListView1Change(Sender: TObject; Item: TListItem;
      Change: TItemChange);
  private
    { Private 宣言 }
    ResultString:String;
  public
    { Public 宣言 }
  end;

  TDOMContentLoadedHandler = class(TInterfacedObject, ICoreWebView2DOMContentLoadedEventHandler)
    function Invoke(const sender: ICoreWebView2;
      const args: ICoreWebView2DOMContentLoadedEventArgs): HRESULT; stdcall;
  end;


var
  Form1: TForm1;

implementation

{$R *.dfm}

uses system.IOUtils, Vcl.Imaging.jpeg, Vcl.Imaging.pngimage, System.NetENcoding;

{ TDOMContentLoadedHandler }

function TDOMContentLoadedHandler.Invoke(const sender: ICoreWebView2;
  const args: ICoreWebView2DOMContentLoadedEventArgs): HRESULT;
begin
  Form1.Button1.Enabled:=True;
  Form1.button2.Enabled:=False;
  Result := S_OK;
end;

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var jpg:TJpegImage;
    bmp:TBitmap;
    png:TPngImage;
    strm:TMemoryStream;
    b:TBytes;
    st:string;
    FileName:String;
    ext:string;
begin
  if not OpenDialog1.Execute(self.Handle) then exit;
  Button1.Enabled:=False;

  FileName:=OpenDialog1.FileName;
  ext:=LowerCase(ExtractFileExt(FileName));
  Image1.Picture.LoadFromFile(FileName);

  jpg:=TJPEGImage.Create;
  bmp:=TBitmap.create;
  png:=TPngImage.create;
  strm:=TMemoryStream.Create;
  try
    if (ext='.jpg') or (ext='.jpeg') then
    begin
      jpg.LoadFromFile(FileName);
      bmp.Assign(jpg);
      png.Assign(bmp);
    end
    else
    begin
      png.LoadFromFile(FileName);
    end;
    png.SaveToStream(strm);
    strm.Position:=0;
    setlength(b, strm.Size);
    strm.ReadData(@b[0], strm.Size);
    //PNG画像データをData URIスキーム(Base64)形式に変換してJavascript関数へ送る
    st:='data:image/png;base64,'+
      System.NetEncoding.TNetEncoding.Base64.EncodeBytesToString(b);
    st:=StringReplace(st,#13#10,'',[rfReplaceAll]);
    Memo1.Lines.Add('認証画像登録中・・・');

    ResultString:='';
    EdgeBrowser1.ExecuteScript('registImg("'+st+'");');
    while (ResultString<>'Registed Image') do
    begin
      EdgeBrowser1.ExecuteScript('document.getElementById("res").innerHTML');
      Sleep(100);
      Application.ProcessMessages;
    end;
    Memo1.Lines.Add('認証画像登録完了');
    Button2.Enabled:=True;

  finally
    jpg.free;
    png.Free;
    bmp.free;
    strm.free;
  end;
end;



procedure TForm1.Button2Click(Sender: TObject);
var jpg:TJpegImage;
    bmp:TBitmap;
    png:TPngImage;
    strm:TMemoryStream;
    b:TBytes;
    st:string;
    i:Integer;
    ext:string;
begin
  if not OpenDialog2.execute then exit;
  ListView1.Clear;
  for i := 0 to OpenDialog2.Files.Count-1 do
  begin
    ListView1.AddItem(OpenDialog2.Files[i],nil);
  end;

  jpg:=TJPEGImage.Create;
  bmp:=TBitmap.create;
  png:=TPngImage.create;
  strm:=TMemoryStream.Create;
  try
    for i := 0 to ListView1.Items.Count-1 do
    begin
      ext:=LowerCase(ExtractFileExt(Listview1.items[i].Caption));
      if (ext='.jpg') or (ext='.jpeg') then
      begin
        jpg.LoadFromFile(ListView1.items[i].Caption);
        bmp.Assign(jpg);
        png.Assign(bmp);
      end
      else
      begin
        png.LoadFromFile(ListView1.Items[i].Caption);
      end;
      strm.size:=0;
      png.SaveToStream(strm);
      strm.Position:=0;
      setlength(b, strm.Size);
      strm.ReadData(@b[0], strm.Size);
      //PNG画像データをData URIスキーム(Base64)形式に変換してJavascript関数へ送る
      st:='data:image/png;base64,'+
        System.NetEncoding.TNetEncoding.Base64.EncodeBytesToString(b);
      st:=StringReplace(st,#13#10,'',[rfReplaceAll]);

      Memo1.Lines.Add(ListView1.Items[i].Caption);
      Memo1.Lines.Add('の顔を照合中');

      ResultString:='';
      EdgeBrowser1.ExecuteScript('descript("'+st+'");');
      while (ResultString='') do
      begin
        EdgeBrowser1.ExecuteScript('document.getElementById("res").innerHTML');
        Sleep(100);
        Application.ProcessMessages;
      end;
      ListView1.items[i].SubItems.CommaText:=ResultString;

      EdgeBrowser1.ExecuteScript('document.getElementById("res").innerHTML="";');
      while (ResultString<>'') do
      begin
        EdgeBrowser1.ExecuteScript('document.getElementById("res").innerHTML');
        Sleep(100);
        Application.ProcessMessages;
      end;
    end;
  finally
    jpg.free;
    png.Free;
    bmp.free;
    strm.free;
  end;
  ListView1.ItemIndex:=0;
end;



procedure TForm1.EdgeBrowser1ExecuteScript(Sender: TCustomEdgeBrowser;
  AResult: HRESULT; const AResultObjectAsJson: string);
var st:String;
begin
  //戻り値が無い関数(例えばScan)を呼出した場合AResultObjectAsJsonの値は'{}'
  if AResultObjectAsJson='{}' then
  begin
    ResultString:='';
  end
  else if AResultObjectAsJson<>'null' then
  begin
    st:=AResultObjectAsJson;
    if st.Substring(0,1)='"' then
      st:=st.Substring(1,Length(st)-1);
    if st.Substring(Length(st)-1,1)='"' then
      st:=st.Substring(0,Length(st)-1);
    st:=StringReplace(st,'\"','"',[rfReplaceAll]);
    ResultString:=st;
  end
  else
  begin
    ResultString:='';
  end;
end;



procedure TForm1.FormCreate(Sender: TObject);
var cachepath:string;
    Handler: ICoreWebView2DOMContentLoadedEventHandler;
    token:EventRegistrationToken;
    col:TListColumn;
begin
  Button1.Enabled:=False;
  Button2.Enabled:=False;
  Button1.Caption:='顔認証したい顔写真登録';
  Button1.Width:=Form1.canvas.TextWidth(Button1.Caption)+8;
  Button2.Caption:='調べたい複数の写真を選択';
  Button2.Width:=Form1.canvas.TextWidth(Button2.Caption)+8;
  OpenDialog1.Filter:='jpeg又はpng|*.jpg;*.jpeg;*.png';
  OpenDialog2.Filter:=OpenDialog1.Filter;
  OpenDialog2.Options:=OpenDialog2.Options+[ofAllowMultiSelect];
  ListView1.ViewStyle:=vsReport;
  ListView1.RowSelect:=True;
  col:=ListView1.columns.Add;
  col.Caption:='ファイル名';
  col.Width:=128;
  col:=ListView1.columns.Add;
  col.Caption:='x';
  col.Width:=32;
  col:=ListView1.columns.Add;
  col.Caption:='y';
  col.Width:=32;
  col:=ListView1.columns.Add;
  col.Caption:='w';
  col.Width:=32;
  col:=ListView1.columns.Add;
  col.Caption:='h';
  col.Width:=32;
  col:=ListView1.columns.Add;
  col.Caption:='distance';
  col.Width:=128;

  Image1.Proportional:=True;
  Image1.Stretch:=True;
  Image2.Proportional:=True;
  Image2.Stretch:=True;
  //キャッシュのフォルダ位置
  cachepath:=ExtractFilePath(Application.ExeName)+'cache';
  //キャッシュを削除する
  if DirectoryExists(cachepath) then TDirectory.Delete(cachepath,true);

  //キャッシュのフォルダを指定する
  //ここに作成される「EBWebView」フォルダを削除すればキャッシュを消せる
  EdgeBrowser1.UserDataFolder:=cachepath;

  //WebViewの生成待ち
  if not EdgeBrowser1.WebViewCreated then
  begin
    EdgeBrowser1.CreateWebView;
    while not EdgeBrowser1.WebViewCreated do
    begin
      sleep(100);
      application.ProcessMessages;
    end;
  end;

  // https://hoge/ を .\htdocsフォルダに割り当て
  ICoreWebView2_3(EdgeBrowser1.DefaultInterface).SetVirtualHostNameToFolderMapping(
    'hoge',
    PWideChar(ExtractFilePath(Application.ExeName)+'htdocs'),
    COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW
  );

  //DOMContentLoadedイベントの割り当て
  Handler := TDOMContentLoadedHandler.Create;
  ICoreWebView2_2(EdgeBrowser1.DefaultInterface).add_DOMContentLoaded(
    Handler, token
  );

  //ローカルファイル \hoge\index.htmlを読む
  EdgeBrowser1.Navigate('https://hoge/index.html');
end;



procedure TForm1.ListView1Change(Sender: TObject; Item: TListItem;
  Change: TItemChange);
var ext:string;
    jpg:TJpegImage;
    bmp:TBitmap;
    png:TPngImage;
    x,y,w,h,dist:Single;
begin
  if ListView1.ItemIndex>=0 then
  begin
    ext:=LowerCase(ExtractFileExt(ListView1.Items[ListView1.ItemIndex].Caption));
    jpg:=TJpegImage.Create;
    bmp:=TBitmap.Create;
    png:=TPngImage.Create;
    try
      if (ext='.jpg') or (ext='.jpeg') then
      begin
        jpg.LoadFromFile(ListView1.Items[ListView1.ItemIndex].Caption);
        bmp.Assign(jpg);
      end
      else
      begin
        png.LoadFromFile(ListView1.Items[ListView1.ItemIndex].Caption);
        bmp.Assign(png);
      end;

      x:=StrToFloatDef(ListView1.Items[ListView1.ItemIndex].SubItems[0],0);
      y:=StrToFloatDef(ListView1.Items[ListView1.ItemIndex].SubItems[1],0);
      w:=StrToFloatDef(ListView1.Items[ListView1.ItemIndex].SubItems[2],0);
      h:=StrToFloatDef(ListView1.Items[ListView1.ItemIndex].SubItems[3],0);
      dist:=StrToFloatDef(ListView1.Items[ListView1.ItemIndex].SubItems[4],0);

      Image2.Picture.Bitmap.Assign(bmp);
      //★★★距離が4.5未満は同一人物とみなす
      if dist<0.45 then
      begin
        Image2.Picture.Bitmap.Canvas.pen.Color:=clRed;
        Image2.Picture.Bitmap.Canvas.pen.Width:=4;
        Image2.Picture.Bitmap.Canvas.brush.Style:=bsClear;
        Image2.Picture.Bitmap.Canvas.Rectangle(
          trunc(x), trunc(y), trunc(x+w), trunc(y+h)
        );
      end;
    finally
      jpg.Free;
      bmp.Free;
      png.Free;
    end;
  end;
end;


end.

実行する

実行し、しばらく待ちます。(face-api.jsのロード待ち)

Delphiで顔認証(同じ顔が写っている写真をピックアップ)

「顔認証したい顔写真登録」ボタンをクリックします。
顔認証したい正面顔写真の.jpg 又は .jpg ファイルを選択します。

Delphiで顔認証(同じ顔が写っている写真をピックアップ)

20秒程度待つと、データのロードと正面顔写真の登録が完了します。
「調べたい複数の写真を選択」をクリックします。

Delphiとface-api.jsで写真ファイルの笑顔度を取得

同じ人物が写っている写真、写っていない写真などをCtrlやShiftキーを押しながらクリックして複数写真を選びます。

Delphiで顔認証(同じ顔が写っている写真をピックアップ)

しばらく待つと、各写真に認証したい顔の人物が写っている可能性を distance(0~1)として計算結果が表示されます。
このソースコードでは、distance(距離)が 0.45未満 の場合に同一人物としています。

Delphiで顔認証(同じ顔が写っている写真をピックアップ)
Delphiで顔認証(同じ顔が写っている写真をピックアップ)
Delphiで顔認証(同じ顔が写っている写真をピックアップ)
Delphiで顔認証(同じ顔が写っている写真をピックアップ)