Face Liveness Detection สำหรับ Flutter แบบไหลปรี๊ดๆ

Amorn Apichattanakul
KBTG Life
Published in
6 min readDec 15, 2023

อ้างอิงจากบทความครั้งที่แล้ว ที่ผมได้พูดถึงการทำ Face Liveness Detection บน Flutter โดยใช้ Camera Package

บทความนี้ถือเป็นภาคต่อ ผมจะมาปรับแก้ไขสิ่งที่ครั้งแรกยังไม่ได้ทำ นั่นคือเครื่อง iPhone 6s Plus ที่ยังไม่สามารถทำงานได้ เนื่องจากช้าไป ในครั้งนี้ผมจะปรับคุณภาพให้ออกไปทาง Native มากขึ้น แทนที่จะใช้วิธีแบบเดิม

เริ่มจากการวาดภาพให้ดูว่าเราจะปรับไปยังไงบ้าง

สำหรับวิธีในอดีต พอเราได้รูปจาก Flutter Camera ที่ส่งมา เราส่งกลับไปให้ Native เลย โดยส่งเป็นแบบ Snapshot ซึ่งวิธีนี้จะเสียทั้ง CPU และเวลาในการส่งรูปไปๆ กลับๆ ทั้งที่ทำไมเราไม่ส่งรูปจาก Native ตรงเข้า SDK เลยนะ แถมการส่งกลับยังต้อง Convert รูปอีกต่างหาก

เมื่อสงสัยว่า เอ๊ะ! ถ้าเราส่งตรงๆ ไปเลยจาก Native แล้วส่งแค่ Result กลับมาให้ Flutter ก็พอแล้ว ตามทฤษฎีน่าจะดีกว่ามากๆ นะ แต่ก็เป็นเพียงแค่ทฤษฎีว่ามันคง… จะดีกว่านะ แต่ผมก็ต้องลอง POC ก่อนว่าดีกว่า นะ ไม่ใช่ทำจนเสร็จเรียบร้อยแล้วปรากฏว่าไม่ค่อยดีขึ้นเท่าไหร่

ผมจึงลองไปคุ้ยดูว่าเจ้า Camera Packages ของ Flutter ทำงานยังไงนะ กว่าเค้าจะส่งรูปมาให้เรา มันมี Process การทำงานยังไงบ้าง หลังจากที่ทำการค้นคว้าไปได้ระดับนึง ก็ไปเจอ Lib ที่ชื่อว่า mobile_scanner ซึ่งตรงกับที่ผมต้องการเลย

จาก Lib ตัวนี้ทำให้เห็นว่าเค้าก็ใช้ Native Camera ส่งรูปตรงเข้าไปให้ ML ที่ Detect QR Code เลยนะ ซึ่งอืมม… เหมือนที่เราจะทำเลย เราส่งเข้า ML เหมือนกัน แต่ Detect Face แทน แล้ว Lib ตัวนี้ Performance ดีด้วย ไม่กระตุก ใช้งานได้ลื่นไหล ต่อให้เป็นกล้อง iPhone 6s Plus ก็ตาม แสดงว่าถ้าเราไปแบบเค้า เราก็ต้องเร็วเหมือนกันแน่นอน!!! สบายใจได้ละว่าวิธีนี้มีคนทำมาแล้วเวิร์คจริง

ถึงเวลาลงมือครับ หลังจากตัดสินใจแล้วว่าเราจะไปวิธีนี้ ผมและเพื่อนในทีมก็ได้มาช่วยกันปรับปรุงครั้งนี้ โดยเราได้ตัดสินใจทำเป็น Flutter Plugin ขึ้นมา เพื่อต่อกับ FaceLiveness SDK Internal ของเรา ซึ่งด้านล่างของบทความนี้ ผมได้แนบโค้ดตัวอย่างเข้ามาให้ด้วย แต่จะไม่มี SDK ของเรานะครับ เผื่อใครสนใจจะนำไปต่อกับ SDK ของตัวเอง ซึ่งพอเราทำเป็น Lib แล้ว แค่ใส่เข้าไป pubspec.yaml ก็สามารถนำไปใช้งานได้ปกติเลย วิธีทำ Lib ตามด้านล่างเลยครับ

เปิดวาร์ปข้ามไปเลย หลังจาก Implement เสร็จแล้ว มาลองเทส Performance กับเครื่องเจ้าปัญหาเลย

ด้านล่างคือผลลัพธ์ที่ได้ครับ

เทียบ V1 (ซ้าย) กับ V2 (ขวา) สำหรับ iPhone 14 Pro Max — CPU ใช้น้อยลง 70%
เทียบ V1 (ซ้าย) กับ V2 (ขวา) สำหรับ iPhone 6s Plus— CPU ใช้น้อยลง 40% และกล้องไม่กระตุกเลย!!!

ด้านล่างคือ Liveness ที่เป็น Native เลยครับ แต่ผมยังงงๆ แฮะ ว่าทำไม Native ใช้ CPU มากกว่าแบบ Flutter สำหรับ iPhone 14 Pro Max แต่ iPhone 6s Plus น้อยกว่าเยอะเลยครับ อย่างไรก็ตามทั้งคู่ใช้ RAM น้อยกว่าค่อนข้างเยอะ Ram Flutter ใช้ 556 MB ในขณะที่ Native ใช้เพียง 363 MB เท่านั้น ลองหลายครั้งก็ยังได้ผลตามนี้

แต่คิดว่าจริงๆ แล้วมันน่าจะใช้น้อยกว่าแหละ เครื่อง iPhone 14 Pro ผมอาจจะทำงานอะไรอยู่สักอย่าง เพราะมีเครื่องเทสแค่ 2 เครื่อง เลยยังไม่ชัวร์ว่ามันน้อยกว่าจริงๆ ไหม

FaceLiveness ด้วยกล้องแบบ Native, iPhone 14 Pro Max
FaceLiveness ด้วยกล้องแบบ Native, iPhone 6s Plus

จากผลลัพธ์ก็ทำให้ชื่นใจละ iPhone 6s Plus ผมลื่นหัวแตกแล้ว จากใช้งานแทบไม่ได้ เป็นไม่รู้สึกถึงความแตกต่างจาก Native เลย

ซึ่งการนำ SDK เข้าไปใน Lib ก็จะต้องมีเทคนิคนิดหน่อย เราจะต้องเพิ่มโค้ดส่วนนี้เข้าไปด้วย

iOS

ให้เพิ่มโค้ดส่วนนี้ใน xxx.podspec xxx คือชื่อ Lib ของ Package ที่คุณสร้างครับ

s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
s.preserve_paths = 'xxx.xcframework/**/*'
s.xcconfig = { 'OTHER_LDFLAGS' => '-framework xxx' }
s.vendored_frameworks = 'xxx.xcframework'

ซึ่งจะทำให้ xcFramework นี้ถูก Embed เข้าไปด้วย ในขณะที่ Import Lib เข้าไป

Android

สำหรับ AAR นั้นยากพิเศษ ให้เติมโค้ดส่วนล่างนี้ลงไปใน build.gradle ของ Lib ครับ ซึ่งมันจะทำการ Compile AAR ให้กลายเป็น Internal Lib สำหรับนำไปใช้

String localMavenPath = project.mkdir("build").absolutePath
String aarPath = localMavenPath
task useAar {
File file = project.file("libs")
if (file.exists() && file.isDirectory()) {
file.listFiles(new FileFilter() {
@Override
boolean accept(File pathname) {
return pathname.name.endsWith(".aar")
}
}).each { item ->
String aarName = item.name.substring(0, item.name.length() - 4)
String[] aarInfo = aarName.split("-")
String sha1 = getFileSha1(item)
String md5 = getFileMD5(item)
String fromStr = item.path
String intoStr = aarPath + "/" + aarInfo[0].replace(".", "/") + "/" + aarInfo[1] + "/" + aarInfo[2]
String newName = aarInfo[1] + "-" + aarInfo[2] + ".aar"
println("localMavenPath: " + localMavenPath)
println("aar: " + aarInfo + " file sha1:" + sha1 + " md5:" + md5)
println("aarPath: " + aarPath)
println("intoStr: " + intoStr)
println("newName: " + newName)
println("fromStr: " + fromStr)
println("intoStr: " + intoStr)
project.copy {
from fromStr
into intoStr
rename(item.name, newName)
}
project.file(intoStr + "/" + newName + ".md5").write(md5)
project.file(intoStr + "/" + newName + ".sha1").write(sha1)
String pomPath = intoStr + "/" + newName.substring(0, newName.length() - 4) + ".pom"
project.file(pomPath).write(createPomStr(aarInfo[0], aarInfo[1], aarInfo[2]))
project.file(pomPath + ".md5").write(getFileMD5(project.file(pomPath)))
project.file(pomPath + ".sha1").write(getFileSha1(project.file(pomPath)))
String metadataPath = project.file(intoStr).getParentFile().path + "/maven-metadata.xml"
project.file(metadataPath).write(createMetadataStr(aarInfo[0], aarInfo[1], aarInfo[2]))
project.file(metadataPath + ".md5").write(getFileMD5(project.file(metadataPath)))
project.file(metadataPath + ".sha1").write(getFileSha1(project.file(metadataPath)))
dependencies {
implementation "${aarInfo[0]}:${aarInfo[1]}:${aarInfo[2]}"
}
}
}
}
static String createMetadataStr(String groupId, String artifactId, String version) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<metadata>\n" +
" <groupId>$groupId</groupId>\n" +
" <artifactId>$artifactId</artifactId>\n" +
" <versioning>\n" +
" <release>$version</release>\n" +
" <versions>\n" +
" <version>$version</version>\n" +
" </versions>\n" +
" <lastUpdated>${new Date().format('yyyyMMdd')}000000</lastUpdated>\n" +
" </versioning>\n" +
"</metadata>\n"
}
static String createPomStr(String groupId, String artifactId, String version) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<project xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\" xmlns=\"http://maven.apache.org/POM/4.0.0\"\n" +
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n" +
" <modelVersion>4.0.0</modelVersion>\n" +
" <groupId>$groupId</groupId>\n" +
" <artifactId>$artifactId</artifactId>\n" +
" <version>$version</version>\n" +
" <packaging>aar</packaging>\n" +
"</project>\n"
}
static String getFileSha1(File file) {
FileInputStream input = null
try {
input = new FileInputStream(file)
MessageDigest digest = MessageDigest.getInstance("SHA-1")
byte[] buffer = new byte[1024 * 1024 * 10]
int len
while ((len = input.read(buffer)) > 0) {
digest.update(buffer, 0, len)
}
String sha1 = new BigInteger(1, digest.digest()).toString(16)
int length = 40 - sha1.length()
if (length > 0) {
for (int i = 0; i < length; i++) {
sha1 = "0" + sha1
}
}
return sha1
}
catch (IOException e) {
System.out.println(e)
}
catch (NoSuchAlgorithmException e) {
System.out.println(e)
}
finally {
try {
if (input != null) {
input.close()
}
}
catch (IOException e) {
System.out.println(e)
}
}
}
static String getFileMD5(File file) {
FileInputStream input = null
try {
input = new FileInputStream(file)
MessageDigest digest = MessageDigest.getInstance("MD5")
byte[] buffer = new byte[1024 * 1024 * 10]
int len
while ((len = input.read(buffer)) > 0) {
digest.update(buffer, 0, len)
}
String md5 = new BigInteger(1, digest.digest()).toString(16)
int length = 32 - md5.length()
if (length > 0) {
for (int i = 0; i < length; i++) {
md5 = "0" + md5
}
}
return md5
}
catch (IOException e) {
System.out.println(e)
}
catch (NoSuchAlgorithmException e) {
System.out.println(e)
}
finally {
try {
if (input != null) {
input.close()
}
}
catch (IOException e) {
System.out.println(e)
}
}
}

มีเพื่อนร่วมทีมได้ลองเอาไปทดสอบเทียบกับ Hosting View ดูว่าวิธีนี้ยังดีกว่าวิธี Native View แบบนั้นรึเปล่า

สรุปว่าท่าของผม Performance ดีกว่าครับ ใช้ CPU/Ram น้อยกว่า แถมยัง Flexible กว่าด้วย เพราะเรา Render UI บน Flutter ทำให้สามารถ Access UI ทุกอย่างได้หมด ถ้า Native View จะมีข้อจำกัดบางส่วน แต่ถ้าคุณมี Native UI อยู่แล้ว และอยากเอามาใส่ก็ได้ครับ แต่ผมยังมองไม่เห็นความคุ้มค่าสำหรับวิธีนี้

สำหรับคนที่สนใจจะนำไปลองกับโปรเจคของตัวเอง Pull โค้ดไปลอง Implement ดูได้ครับ โค้ดที่ให้จะรันไม่ได้ทันทีนะ จะต้องนำไปปรับเพิ่มเติม

ให้ค้นคำว่า TODO: จากนั้นเอา Implementation ตัวเองใส่เข้าไปได้นะครับ

ส่วนด้านล่างคือวิดีโอตัวอย่างที่ผมลองกับ iPhone 6s Plus จะเห็นว่าลื่นไหลหัวแตกไปเลยครับ จากรอบที่แล้วทำยังไงก็ไม่ผ่าน กลายเป็นผ่านแล้ว

ด้านล่างคือคำอธิบายในวิดีโอครับ

  • เวลา 0:10 — ผมแสดงให้ดูว่า FaceLiveness ตัวนี้ทำจาก Flutter นะ แล้ว Build App ให้ดูว่ามันถูกรันขึ้นมาสดๆ จากเครื่อง Mac ผมเอง
  • เวลา 0:21 — ลองขยับท่าทางให้กล้อง ให้ดูว่าผมขยับแล้วไม่ได้กระตุกเลย ลื่นไหลปกติ แม้ว่าจะเป็น iPhone 6S Plus
  • เวลา 0:24 — ตั้งใจเอาหน้าออกจากกล้องครับ เพื่อให้ดูว่ามันสามารถ Detect หน้าได้ปกติ
  • เวลา 0:30 — ลองเอามือปิดตา ก็สามารถเช็คว่าไม่พบใบหน้า
  • เวลา 0:52 — เนื่องจากเครื่องที่ทดสองตั้งใจจะใช้ iPhone 6s Plus มันเลยจะช้าๆ ในการ Detect หน้าหน่อยครับ ซึ่งหลายๆ ท่าทางต้องเปลี่ยน เช่น จากกระพริบตา เป็นหลับตาและลืมตาแบบช้าๆ แทน เพราะ Response จาก SDK จะใช้เวลาประมาณ 1.5 วินาที แต่ถ้าเป็นพวก iPhone X ขึ้นไปจะเหลือแค่ 0.4 วินาทีครับ ยิ่ง iPhone 14 Pro Max เหลือแค่ 0.1 วิ เท่านั้นเอง
  • เวลา 1:00 — เมื่อ Liveness SDK ตรวจสอบสำเร็จแล้ว ก็จะส่งรูปหน้าเรากลับมาให้ แล้วให้เอาไปทำ Face Compare บน Server ต่อครับ

สรุปนะครับ เชื่อว่าหลังจากนี้คงไม่มี Improve อะไรเพิ่มอีก เพราะว่าเวลาในการ Detect ใช้เวลาเท่ากับ Native แล้ว แถมยังกล้องก็ลื่นปรื๊ดๆ ไม่มีอะไรให้ Improve สำหรับ Performance แล้วครับ ถ้าจะทำให้ดีกว่านี้คงต้องไป Native ซึ่งกรณีนี้ผมว่าไม่จำเป็นเลย

ทั้งนี้บทความหน้าจะนำเรื่อง QR Code Reader ใน Flutter มาเล่าให้ฟังนะครับ เพราะได้โจทย์ใหม่มาว่าอยากทำ QR Code เร็วแบบสุดๆ ไปเลย ซึ่ง mobile_scanner ที่ใช้อยู่ก็เร็วมากแล้วครับ ลื่นไหล ใช้งานได้ดี แต่คุยกันในทีมแล้วว่าเราอยากให้มันเร็วแบบ K PLUS ไปเลย เป้าหมายต่อไป QR Code แบบ K PLUS (จะทำได้ไหมเนี่ย App Native อื่นๆ ยังอ่าน QR Code ไม่เร็วเท่า K PLUS เลย แล้ว Flutter จะเร็วกว่า Native คนอื่นๆ เนี่ยนะ) เอาวะ ลองดู!!

สุดท้ายนี้ KBTG Face Liveness ที่ผมใช้เนี่ย จากปีที่แล้วเราได้ iBeta 1 นะครับ ส่วนที่ผมใช้คือ iBeta 2 คือความปลอดภัยสูงสุดในการตรวจสอบคุณภาพระดับโลกแล้วครับ ปลื้มมากก คนไทยก็ทำ Lib ระดับโลกได้นะ ⸜(。˃ ᵕ ˂ )⸝♡

สำหรับใครที่ชื่นชอบบทความนี้ อย่าลืมกดติดตาม Medium: KBTG Life เรามีสาระความรู้และเรื่องราวดีๆ จากชาว KBTG พร้อมเสิร์ฟให้ที่นี่ที่แรก

--

--

Amorn Apichattanakul
KBTG Life

Google Developer Expert for Flutter & Dart | Senior Flutter/iOS Software Engineer @ KBTG