Java版人臉跟蹤三部曲之三:編碼實戰

2023-07-08 15:00:18

歡迎存取我的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:業務邏輯的提供者

  • 有了核心能力,接下來要做的就是在業務中使用這個能力,前文已設計好完整的業務邏輯,這裡先簡單回顧一下:
  • 可見主要業務流程可以用兩個狀態+行為來表示:
  1. 還未開始跟蹤:對每一幀做人臉檢測,一旦檢測到,就進入跟蹤狀態,並呼叫ObjectTracker.createTrackedObject生成人臉的hue直方圖
  2. 已處於跟蹤狀態:對每一幀影象,都呼叫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);
    }

執行程式要注意的地方

  1. 下載opencv在windows環境的動態連結庫:https://download.csdn.net/download/boling_cavalry/75121158,我這裡下載後放在:C:\study\javacv\lib\opencv_java453.dll
  2. 人臉檢測的模型檔案,在GitHub下載,地址是:https://raw.github.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt.xml,我這裡下載後放在:C:\study\javacv\model\haarcascade_frontalface_alt.xml
  3. 執行程式的時候,不論是打包成jar,還是直接在IDEA中執行,都要新增下面這兩個命令引數,才能確保應用載入到dll和模型檔案(請按照您自己的儲存位置修改下面引數的值):
  • -Djava.library.path=C:\study\javacv\lib
  • -Dmodel.file.path=C:\study\javacv\model\haarcascade_frontalface_alt.xml
  • 程式執行起來後,具體的效果與像《Java版人臉跟蹤三部曲之一:極速體驗》中一模一樣,這裡就不再贅述了,您自行驗證就好
  • 其實本篇不執行程式,還有一個原因就是要過年了,用來檢測人臉的群眾演員臨時漲價,要兩份盒飯,欣宸實在是負擔不起...

原始碼下載

名稱 連結 備註
專案主頁 https://github.com/zq2599/blog_demos 該專案在GitHub上的主頁
git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該專案原始碼的倉庫地址,https協定
git倉庫地址(ssh) [email protected]:zq2599/blog_demos.git 該專案原始碼的倉庫地址,ssh協定
  • 這個git專案中有多個資料夾,本篇的原始碼在javacv-tutorials資料夾下,如下圖紅框所示:
  • javacv-tutorials裡面有多個子工程,《JavaCV的攝像頭實戰》系列的程式碼在simple-grab-push工程下:
  • 至此,《Java版人臉跟蹤三部曲》完美收官,但是《JavaCV的攝像頭實戰》系列還會繼續呈現更多精彩內容,歡迎關注;

歡迎關注部落格園:程式設計師欣宸

學習路上,你不孤單,欣宸原創一路相伴...