歡迎存取我的GitHub
這裡分類和彙總了欣宸的全部原創(含配套原始碼):https://github.com/zq2599/blog_demos
本篇概覽
- 作為《Java版人臉跟蹤三部曲》系列的終篇,本文會與大家一起寫出完整的人臉跟蹤應用程式碼
- 前文《開發設計》中,已經對人臉跟蹤的核心技術、應用主流程、例外處理等方方面面做了詳細設計,建議您簡單回顧一下
- 接下來,自頂向下,先整體設計好主框架和關鍵類
程式主框架和關鍵類
- 聽欣宸嘮叨了兩篇文章,終於要看具體程式碼了,整體上看,最關鍵的三個類如下圖:
- 可見把功能、流程、知識點梳理清楚後,程式碼其實並不多,而且各司其職,分工明確,接下來開始編碼,ObejctTracker負責實現跟蹤功能,就從它開始
ObejctTracker.java:跟蹤能力的提供者
- 從前面的圖中可知,與跟蹤有關的服務都是ObejctTracker類提供的,此類涉及知識點略多,在編寫程式碼前,先做一下簡單的設計
- 從功能看,ObejctTracker會對外提供如下兩個方法:
方法名 |
作用 |
入參 |
返回 |
內部實現 |
createTrackedObject |
主程式如果從視訊幀中首次次檢測到人臉,就會呼叫createTrackedObject方法,表示開始跟蹤了 |
mRgba:出現人臉的圖片 region:人臉在圖片中的位置 |
無 |
提取人臉的hue,生成直方圖 |
objectTracking |
開始跟蹤後,主程式從攝像頭取到的每一幀圖片後,都會呼叫此方法,用於得到人臉在這一幀中的位置 |
mRgba:圖片 |
人臉在輸入圖片中位置 |
用人臉hue直方圖對輸入圖片進行計算,得到反向投影圖,在反向投影圖上做CamShift計算得到人臉位置 |
- 除了上述兩個對外方法,ObejctTracker內部還要準備如下兩個輔助方法:
方法名 |
作用 |
入參 |
返回 |
內部實現 |
rgba2Hue |
將RGB顏色空間的圖片轉為HSV,再提取出hue通道,生成直方圖 |
rgba:人臉圖片 |
無 |
List<Mat>:直方圖 |
lostTrace |
對比objectTracking方法返回的結果與上次出現的位置,確定人有沒有跟丟 |
lastRect:上次出現的位置 currentRect:objectTracking方法檢測到的當前幀上的位置 |
true表示跟丟了,false表示沒有跟丟 |
對比兩個矩形的差距是否超過一個門限,正常情況下連續兩幀中的人臉差別不會太大,所以一旦差別大了就表示跟丟了,currentRect的位置上不是人臉 |
// 每一幀影象的反向投影圖都用這個成員變數來儲存
private Mat prob;
// 儲存最近一次確認的頭像的位置,每當新的一幀到來時,都從這個位置開始追蹤(也就是反向投影圖做CamShift計算的起始位置)
private Rect trackRect;
// 直方圖,在跟丟之前,每一幀影象都要用到這個直方圖來生成反向投影
private Mat hist;
- 設計完成,現在可以給出完整的ObejctTracker.java原始碼了:
package com.bolingcavalry.grabpush.extend;
import lombok.extern.slf4j.Slf4j;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.video.Video;
import java.util.Collections;
import java.util.List;
import java.util.Vector;
/**
* @author willzhao
* @version 1.0
* @description TODO
* @date 2022/1/8 21:21
*/
@Slf4j
public class ObjectTracker {
/**
* 上一個矩形和當前矩形的差距達到多少的時候,才算跟丟,您可以自行調整
*/
private static final double LOST_GATE = 0.8d;
// [0.0, 256.0]表示直方圖能表示畫素值從0.0到256的畫素
private static final MatOfFloat RANGES = new MatOfFloat(0f, 256f);
private Mat mask;
// 儲存用來追蹤的每一幀的反向投影圖
private Mat prob;
// 儲存最近一次確認的頭像的位置,每當新的一幀到來時,都從這個位置開始追蹤(也就是反向投影圖做CamShift計算的起始位置)
private Rect trackRect;
// 直方圖
private Mat hist;
public ObjectTracker(Mat rgba) {
hist = new Mat();
trackRect = new Rect();
mask = new Mat(rgba.size(), CvType.CV_8UC1);
prob = new Mat(rgba.size(), CvType.CV_8UC1);
}
/**
* 將攝像頭傳來的圖片提取出hue通道,放入hueList中
* 將攝像頭傳來的RGB顏色空間的圖片轉為HSV顏色空間,
* 然後檢查HSV三個通道的值是否在指定範圍內,mask中記錄了檢查結果
* 再將hsv中的hue提取出來
* @param rgba
*/
private List<Mat> rgba2Hue(Mat rgba) {
// 範例化Mat,顯然,hsv是三通道,hue是hsv三通道其中的一個,所以hue是一通道
Mat hsv = new Mat(rgba.size(), CvType.CV_8UC3);
Mat hue = new Mat(rgba.size(), CvType.CV_8UC1);
// 1. 先轉換
// 轉換顏色空間,RGB到HSV
Imgproc.cvtColor(rgba, hsv, Imgproc.COLOR_RGB2HSV);
int vMin = 65, vMax = 256, sMin = 55;
//inRange函數的功能是檢查輸入陣列每個元素大小是否在2個給定數值之間,可以有多通道,mask儲存0通道的最小值,也就是h分量
//這裡利用了hsv的3個通道,比較h,0~180,s,smin~256,v,min(vmin,vmax),max(vmin,vmax)。如果3個通道都在對應的範圍內,
//則mask對應的那個點的值全為1(0xff),否則為0(0x00).
Core.inRange(
hsv,
new Scalar(0, sMin, Math.min(vMin, vMax)),
new Scalar(180, 256, Math.max(vMin, vMax)),
mask
);
// 2. 再提取
// 把hsv的資料放入hsvList中,用於稍後提取出其中的hue
List<Mat> hsvList = new Vector<>();
hsvList.add(hsv);
// 準備好hueList,用於接收通道
// hue初始化為與hsv大小深度一樣的矩陣,色調的度量是用角度表示的,紅綠藍之間相差120度,反色相差180度
hue.create(hsv.size(), hsv.depth());
List<Mat> hueList = new Vector<>();
hueList.add(hue);
// 描述如何提取:從目標的0位置提取到目的地的0位置
MatOfInt from_to = new MatOfInt(0, 0);
// 提取操作:將hsv第一個通道(也就是色調)的數複製到hue中,0索引陣列
Core.mixChannels(hsvList, hueList, from_to);
return hueList;
}
/**
* 當外部呼叫方確定了人臉在圖片中的位置後,就可以呼叫createTrackedObject開始跟蹤,
* 該方法中會先生成人臉的hue的直方圖,用於給後續幀生成反向投影
* @param mRgba
* @param region
*/
public void createTrackedObject(Mat mRgba, Rect region) {
hist.release();
//將攝像頭的視訊幀轉化成hsv,然後再提取出其中的hue通道
List<Mat> hueList = rgba2Hue(mRgba);
// 人臉區域的mask
Mat tempMask = mask.submat(region);
// histSize表示這個直方圖分成多少份(即多少個直方柱),就是 bin的個數
MatOfInt histSize = new MatOfInt(25);
// 只要頭像區域的資料
List<Mat> images = Collections.singletonList(hueList.get(0).submat(region));
// 計算頭像的hue直方圖,結果在hist中
Imgproc.calcHist(images, new MatOfInt(0), tempMask, hist, histSize, RANGES);
// 將hist矩陣進行陣列範圍歸一化,都歸一化到0~255
Core.normalize(hist, hist, 0, 255, Core.NORM_MINMAX);
// 這個trackRect記錄了人臉最後一次出現的位置,後面新的幀到來時,就從trackRect位置開始做CamShift計算
trackRect = region;
}
/**
* 在開始跟蹤後,每當攝像頭新的一幀到來時,外部就會呼叫objectTracking,將新的幀傳入,
* 此時,會用前面準備好的人臉hue直方圖,將新的幀計算出反向投影圖,
* 再在反向投影圖上執行CamShift計算,找到密度最大處,即人臉在新的幀上的位置,
* 將這個位置作為返回值,返回
* @param mRgba 新的一幀
* @return 人臉在新的一幀上的位置
*/
public Rect objectTracking(Mat mRgba) {
// 新的圖片,提取hue
List<Mat> hueList;
try {
// 實測此處可能丟擲異常,要注意捕獲,避免程式退出
hueList = rgba2Hue(mRgba);
} catch (CvException cvException) {
log.error("cvtColor exception", cvException);
trackRect = null;
return null;
}
// 用頭像直方圖在新圖片的hue通道資料中計算反向投影。
Imgproc.calcBackProject(hueList, new MatOfInt(0), hist, prob, RANGES, 1.0);
// 計算兩個陣列的按位元連線(dst = src1 & src2)計算兩個陣列或陣列和標量的每個元素的逐位連線。
Core.bitwise_and(prob, mask, prob, new Mat());
// 在反向投影上進行CamShift計算,返回值就是密度最大處,即追蹤結果
RotatedRect rotatedRect = Video.CamShift(prob, trackRect, new TermCriteria(TermCriteria.EPS, 10, 1));
// 轉為Rect物件
Rect camShiftRect = rotatedRect.boundingRect();
// 比較追蹤前和追蹤後的資料,如果出現太大偏差,就認為追蹤失敗
if (lostTrace(trackRect, camShiftRect)) {
log.info("lost trace!");
trackRect = null;
return null;
}
// 將本次最終到的目標作為下次追蹤的物件
trackRect = camShiftRect;
return camShiftRect;
}
/**
* 變化率的絕對值
* @param last 變化前
* @param current 變化後
* @return
*/
private static double changeRate(int last, int current) {
return Math.abs((double)(current-last)/(double) last);
}
/**
* 本次和上一次寬度或者高度的變化率,一旦超過閾值就認為跟蹤失敗
* @param lastRect
* @param currentRect
* @return
*/
private static boolean lostTrace(Rect lastRect, Rect currentRect) {
// 0不能做除數,如果發現0就認跟丟了
if (lastRect.width<1 || lastRect.height<1) {
return true;
}
double widthChangeRate = changeRate(lastRect.width, currentRect.width);
if (widthChangeRate>LOST_GATE) {
log.info("1. lost trace, old [{}], new [{}], rate [{}]", lastRect.width, currentRect.width, widthChangeRate);
return true;
}
double heightChangeRate = changeRate(lastRect.height, currentRect.height);
if (heightChangeRate>LOST_GATE) {
log.info("2. lost trace, old [{}], new [{}], rate [{}]", lastRect.height, currentRect.height, heightChangeRate);
return true;
}
return false;
}
}
- 最核心的跟蹤服務已經完成,接下來要實現完整業務邏輯,即:CamShiftDetectService.java
CamShiftDetectService.java:業務邏輯的提供者
- 有了核心能力,接下來要做的就是在業務中使用這個能力,前文已設計好完整的業務邏輯,這裡先簡單回顧一下:
- 可見主要業務流程可以用兩個狀態+行為來表示:
- 還未開始跟蹤:對每一幀做人臉檢測,一旦檢測到,就進入跟蹤狀態,並呼叫ObjectTracker.createTrackedObject生成人臉的hue直方圖
- 已處於跟蹤狀態:對每一幀影象,都呼叫ObjectTracker.objectTracking去檢查人臉在影象中的位置,直到到跟丟了為止,一旦跟丟了,就重新進入到還未開始跟蹤的狀態
- 現在我們已經清楚了CamShiftDetectService.java要做的具體事情,接下來看看有哪些重要方法:
方法名 |
作用 |
入參 |
返回 |
內部實現 |
init |
被主程式呼叫的初始化方法,在應用啟動的時候會呼叫一次 |
無 |
無 |
載入人臉檢測的模型 |
convert |
每當主程式從攝像頭拿到新的一幀後,都會呼叫此方法 |
frame:來自攝像頭的最新一幀 |
被處理後的幀,會被主程式展現在預覽視窗 |
convert方法內部實現了前面提到的兩種狀態和行為(還未開始跟蹤、已處於跟蹤狀態) |
releaseOutputResource |
程式結束前,被主程式呼叫的釋放資源的方法 |
無 |
無 |
釋放一些成員變數的資源 |
- 再來看看有哪些重要的成員變數,如下所示,isInTracing表示當前是否處於跟蹤狀態,classifier用於檢測人臉:
/**
* 每一幀原始圖片的物件
*/
private Mat grabbedImage = null;
/**
* 分類器
*/
private CascadeClassifier classifier;
/**
* 轉換器
*/
private OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat();
/**
* 模型檔案的下載地址
*/
private String modelFilePath;
/**
* 存放RGBA圖片Mat
*/
private Mat mRgba;
/**
* 存放灰度圖片的Mat,僅用在人臉檢測的時候
*/
private Mat mGray;
/**
* 跟蹤服務類
*/
private ObjectTracker objectTracker;
/**
* 表示當前是否正在跟蹤目標
*/
private boolean isInTracing = false;
- 現在可以給出CamShiftDetectService.java的完整程式碼了:
package com.bolingcavalry.grabpush.extend;
import com.bolingcavalry.grabpush.Util;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.OpenCVFrameConverter;
import org.bytedeco.opencv.opencv_core.Mat;
import org.bytedeco.opencv.opencv_core.Rect;
import org.bytedeco.opencv.opencv_core.RectVector;
import org.bytedeco.opencv.opencv_objdetect.CascadeClassifier;
import java.io.File;
import static org.bytedeco.opencv.global.opencv_imgproc.CV_BGR2GRAY;
import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor;
@Slf4j
public class CamShiftDetectService implements DetectService {
/**
* 每一幀原始圖片的物件
*/
private Mat grabbedImage = null;
/**
* 分類器
*/
private CascadeClassifier classifier;
/**
* 轉換器
*/
private OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat();
/**
* 模型檔案的下載地址
*/
private String modelFilePath;
/**
* 存放RGBA圖片Mat
*/
private Mat mRgba;
/**
* 存放灰度圖片的Mat,僅用在人臉檢測的時候
*/
private Mat mGray;
/**
* 跟蹤服務類
*/
private ObjectTracker objectTracker;
/**
* 表示當前是否正在跟蹤目標
*/
private boolean isInTracing = false;
/**
* 構造方法,在此指定模型檔案的下載地址
* @param modelFilePath
*/
public CamShiftDetectService(String modelFilePath) {
this.modelFilePath = modelFilePath;
}
/**
* 音訊取樣物件的初始化
* @throws Exception
*/
@Override
public void init() throws Exception {
log.info("開始載入模型檔案");
// 模型檔案下載後的完整地址
String classifierName = new File(modelFilePath).getAbsolutePath();
// 根據模型檔案範例化分類器
classifier = new CascadeClassifier(classifierName);
if (classifier == null) {
log.error("Error loading classifier file [{}]", classifierName);
System.exit(1);
}
log.info("模型檔案載入完畢,初始化完成");
}
@Override
public Frame convert(Frame frame) {
// 由幀轉為Mat
grabbedImage = converter.convert(frame);
// 初始化灰度Mat
if (null==mGray) {
mGray = Util.initGrayImageMat(grabbedImage);
}
// 初始化RGBA的Mat
if (null==mRgba) {
mRgba = Util.initRgbaImageMat(grabbedImage);
}
// 如果未在追蹤狀態
if (!isInTracing) {
// 存放檢測結果的容器
RectVector objects = new RectVector();
// 當前圖片轉為灰度圖片
cvtColor(grabbedImage, mGray, CV_BGR2GRAY);
// 開始檢測
classifier.detectMultiScale(mGray, objects);
// 檢測結果總數
long total = objects.size();
// 當前範例是隻追蹤一人,因此一旦檢測結果不等於一,就不處理,您可以根據自己業務情況修改此處
if (total!=1) {
objects.close();
return frame;
}
log.info("start new trace");
Rect r = objects.get(0);
int x = r.x(), y = r.y(), w = r.width(), h = r.height();
// 得到opencv的mat,其格式是RGBA
org.opencv.core.Mat openCVRGBAMat = Util.buildJavacvBGR2OpenCVRGBA(grabbedImage, mRgba);
// 在buildJavacvBGR2OpenCVRGBA方法內部,有可能在執行native方法的是否發生異常,要做針對性處理
if (null==openCVRGBAMat) {
objects.close();
return frame;
}
// 如果第一次追蹤,要範例化objectTracker
if (null==objectTracker) {
objectTracker = new ObjectTracker(openCVRGBAMat);
}
// 建立跟蹤目標
objectTracker.createTrackedObject(openCVRGBAMat, new org.opencv.core.Rect(x, y, w, h));
// 根據本次檢測結果給原圖示注人臉矩形框
Util.rectOnImage(grabbedImage, x, y, w, h);
// 釋放檢測結果資源
objects.close();
// 修改標誌,表示當前正在跟蹤
isInTracing = true;
// 將標註過的圖片轉為幀,返回
return converter.convert(grabbedImage);
}
// 程式碼走到這裡,表示已經在追蹤狀態了
// 得到opencv的mat,其格式是RGBA
org.opencv.core.Mat openCVRGBAMat = Util.buildJavacvBGR2OpenCVRGBA(grabbedImage, mRgba);
// 在buildJavacvBGR2OpenCVRGBA方法內部,有可能在執行native方法的是否發生異常,要做針對性處理
if (null==openCVRGBAMat) {
return frame;
}
// 基於上一次的檢測結果開始跟蹤
org.opencv.core.Rect rotatedRect = objectTracker.objectTracking(openCVRGBAMat);
// 如果rotatedRect為空,表示跟蹤失敗,此時要修改狀態為"未跟蹤"
if (null==rotatedRect) {
isInTracing = false;
// 返回原始幀
return frame;
}
// 程式碼能走到這裡,表示跟蹤成功,拿到的新的一幀上的目標的位置,此時就在新位置上
// Util.rectOnImage(grabbedImage, rotatedRect.x, rotatedRect.y, rotatedRect.width, rotatedRect.height);
// 矩形框的整體向下放一些(總高度的五分之一),另外跟蹤得到的高度過大,畫出的矩形框把脖子也框上了,這裡改用寬度作為高度
Util.rectOnImage(grabbedImage, rotatedRect.x, rotatedRect.y + rotatedRect.height/5, rotatedRect.width, rotatedRect.width);
return converter.convert(grabbedImage);
}
/**
* 程式結束前,釋放臉部辨識的資源
*/
@Override
public void releaseOutputResource() {
if (null!=grabbedImage) {
grabbedImage.release();
}
if (null!=mGray) {
mGray.release();
}
if (null!=mRgba) {
mRgba.release();
}
if (null==classifier) {
classifier.close();
}
}
}
- 至此·,功能已經完成得七七八八,再來寫完主程式就可以執行了;
PreviewCameraWithCamShift.java:主程式
- 《JavaCV的攝像頭實戰之一:基礎》建立的simple-grab-push工程中已經準備好了父類別AbstractCameraApplication,所以本篇繼續使用該工程,建立子類PreviewCameraWithCamShift實現那些抽象方法即可
- 編碼前先回顧父類別的基礎結構,如下圖,粗體是父類別定義的各個方法,紅色塊都是需要子類來實現抽象方法,所以接下來,咱們以本地視窗預覽為目標實現這三個紅色方法即可:
- 新建檔案PreviewCameraWithCamShift.java,這是AbstractCameraApplication的子類,其程式碼很簡單,接下來按上圖順序依次說明
- 先定義CanvasFrame型別的成員變數previewCanvas,這是展示視訊幀的本地視窗:
protected CanvasFrame previewCanvas
- 把前面建立的DetectService作為成員變數,後面檢測的時候會用到:
/**
* 檢測工具介面
*/
private DetectService detectService;
- PreviewCameraWithCamShift的構造方法,接受DetectService的範例:
/**
* 不同的檢測工具,可以通過構造方法傳入
* @param detectService
*/
public PreviewCameraWithCamShift(DetectService detectService) {
this.detectService = detectService;
}
- 然後是初始化操作,可見是previewCanvas的範例化和引數設定,還有檢測、識別的初始化操作:
@Override
protected void initOutput() throws Exception {
previewCanvas = new CanvasFrame("攝像頭預覽", CanvasFrame.getDefaultGamma() / grabber.getGamma());
previewCanvas.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
previewCanvas.setAlwaysOnTop(true);
// 檢測服務的初始化操作
detectService.init();
}
- 接下來是output方法,定義了拿到每一幀視訊資料後做什麼事情,這裡呼叫了detectService.convert檢測人臉並識別性別,然後在本地視窗顯示:
@Override
protected void output(Frame frame) {
// 原始幀先交給檢測服務處理,這個處理包括物體檢測,再將檢測結果標註在原始圖片上,
// 然後轉換為幀返回
Frame detectedFrame = detectService.convert(frame);
// 預覽視窗上顯示的幀是標註了檢測結果的幀
previewCanvas.showImage(detectedFrame);
}
- 最後是處理視訊的迴圈結束後,程式退出前要做的事情,先關閉本地視窗,再釋放檢測服務的資源:
@Override
protected void releaseOutputResource() {
if (null!= previewCanvas) {
previewCanvas.dispose();
}
// 檢測工具也要釋放資源
detectService.releaseOutputResource();
}
- 由於檢測有些耗時,所以兩幀之間的間隔時間要低於普通預覽:
@Override
protected int getInterval() {
return super.getInterval()/8;
}
- 至此,功能已開發完成,再寫上main方法,程式碼如下,請注意人臉檢測所需的模型檔案的路徑來自系統變數:
public static void main(String[] args) {
String modelFilePath = System.getProperty("model.file.path");
log.info("模型檔案本地路徑:{}", modelFilePath);
new PreviewCameraWithCamShift(new CamShiftDetectService(modelFilePath)).action(1000);
}
執行程式要注意的地方
- 下載opencv在windows環境的動態連結庫:https://download.csdn.net/download/boling_cavalry/75121158,我這裡下載後放在:C:\study\javacv\lib\opencv_java453.dll
- 人臉檢測的模型檔案,在GitHub下載,地址是:https://raw.github.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt.xml,我這裡下載後放在:C:\study\javacv\model\haarcascade_frontalface_alt.xml
- 執行程式的時候,不論是打包成jar,還是直接在IDEA中執行,都要新增下面這兩個命令引數,才能確保應用載入到dll和模型檔案(請按照您自己的儲存位置修改下面引數的值):
- -Djava.library.path=C:\study\javacv\lib
- -Dmodel.file.path=C:\study\javacv\model\haarcascade_frontalface_alt.xml
- 程式執行起來後,具體的效果與像《Java版人臉跟蹤三部曲之一:極速體驗》中一模一樣,這裡就不再贅述了,您自行驗證就好
- 其實本篇不執行程式,還有一個原因就是要過年了,用來檢測人臉的群眾演員臨時漲價,要兩份盒飯,欣宸實在是負擔不起...
原始碼下載
- 這個git專案中有多個資料夾,本篇的原始碼在javacv-tutorials資料夾下,如下圖紅框所示:
- javacv-tutorials裡面有多個子工程,《JavaCV的攝像頭實戰》系列的程式碼在simple-grab-push工程下:
- 至此,《Java版人臉跟蹤三部曲》完美收官,但是《JavaCV的攝像頭實戰》系列還會繼續呈現更多精彩內容,歡迎關注;
歡迎關注部落格園:程式設計師欣宸
學習路上,你不孤單,欣宸原創一路相伴...