首先从这里下载已经训练好的模型文件:
这个模型是使用Caffe实现的Google MobileNet SSD检测模型。有个Caffe Zoo项目,收集了很多已经训练好的模型,有兴趣的可以看一下。下载好模型之后,在app/src/main/
下面创建一个assets
目录,把两个模型文件放进去。至此,模型的准备工作就完成了。
布局文件activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/imageSelect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginLeft="32dp"
android:layout_marginTop="16dp"
android:text="@string/image_select"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/recognize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="16dp"
android:text="@string/recognize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageSelect"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imageView"
android:layout_width="387dp"
android:layout_height="259dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="22dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:contentDescription="images"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageSelect" />
</androidx.constraintlayout.widget.ConstraintLayout>
刚接触安卓开发没几天,布局是瞎写的,仅考虑了功能。
MainActivity.java代码:
package com.niyanchun.demo;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import org.opencv.android.OpenCVLoader;
import org.opencv.android.Utils;
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.Point;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.dnn.Dnn;
import org.opencv.dnn.Net;
import org.opencv.imgproc.Imgproc;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
@SuppressLint("SetTextI18n")
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (OpenCVLoader.initDebug()) {
Log.i("CV", "load OpenCV Library Successful.");
} else {
Log.i("CV", "load OpenCV Library Failed.");
}
imageView = findViewById(R.id.imageView);
imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
Button selectBtn = findViewById(R.id.imageSelect);
selectBtn.setOnClickListener(v -> {
Intent intent = new Intent();
intent.setType("image/*");
intent.setAction(Intent.ACTION_GET_CONTENT);
startActivityForResult(Intent.createChooser(intent, "选择图片"), PICK_IMAGE_REQUEST);
});
Button recognizeBtn = findViewById(R.id.recognize);
recognizeBtn.setOnClickListener(v -> {
// 确保加载完成
if (net == null) {
Toast.makeText(this, "正在加载模型,请稍后...", Toast.LENGTH_LONG).show();
while (net == null) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
recognize();
});
}
@Override
protected void onResume() {
super.onResume();
loadModel();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == PICK_IMAGE_REQUEST && resultCode == RESULT_OK
&& data != null && data.getData() != null) {
Uri uri = data.getData();
try {
Log.d("image-decode", "start to decode selected image now...");
InputStream input = getContentResolver().openInputStream(uri);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(input, null, options);
int rawWidth = options.outWidth;
int rawHeight = options.outHeight;
int max = Math.max(rawWidth, rawHeight);
int newWidth, newHeight;
float inSampleSize = 1.0f;
if (max > MAX_SIZE) {
newWidth = rawWidth / 2;
newHeight = rawHeight / 2;
while ((newWidth / inSampleSize) > MAX_SIZE || (newHeight / inSampleSize) > MAX_SIZE) {
inSampleSize *= 2;
}
}
options.inSampleSize = (int) inSampleSize;
options.inJustDecodeBounds = false;
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
image = BitmapFactory.decodeStream(getContentResolver().openInputStream(uri), null, options);
imageView.setImageBitmap(image);
} catch (Exception e) {
Log.e("image-decode", "decode image error", e);
}
}
}
/**
* 加载模型
*/
private void loadModel() {
if (net == null) {
Toast.makeText(this, "开始加载模型...", Toast.LENGTH_LONG).show();
String proto = getPath("MobileNetSSD_deploy.prototxt", this);
String weights = getPath("mobilenet_iter_73000.caffemodel", this);
net = Dnn.readNetFromCaffe(proto, weights);
Log.i("model", "load model successfully.");
Toast.makeText(this, "模型加载成功!", Toast.LENGTH_LONG).show();
}
}
/**
* 识别
*/
private void recognize() {
// 该网络的输入层要求的图片尺寸为 300*300
final int IN_WIDTH = 300;
final int IN_HEIGHT = 300;
final float WH_RATIO = (float) IN_WIDTH / IN_HEIGHT;
final double IN_SCALE_FACTOR = 0.007843;
final double MEAN_VAL = 127.5;
final double THRESHOLD = 0.2;
Mat imageMat = new Mat();
Utils.bitmapToMat(image, imageMat);
Imgproc.cvtColor(imageMat, imageMat, Imgproc.COLOR_RGBA2RGB);
Mat blob = Dnn.blobFromImage(imageMat, IN_SCALE_FACTOR,
new Size(IN_WIDTH, IN_HEIGHT),
new Scalar(MEAN_VAL, MEAN_VAL, MEAN_VAL),
false, false);
net.setInput(blob);
Mat detections = net.forward();
int cols = imageMat.cols();
int rows = imageMat.rows();
detections = detections.reshape(1, (int) detections.total() / 7);
boolean detected = false;
for (int i = 0; i < detections.rows(); ++i) {
double confidenceTmp = detections.get(i, 2)[0];
if (confidenceTmp > THRESHOLD) {
detected = true;
int classId = (int) detections.get(i, 1)[0];
int left = (int) (detections.get(i, 3)[0] * cols);
int top = (int) (detections.get(i, 4)[0] * rows);
int right = (int) (detections.get(i, 5)[0] * cols);
int bottom = (int) (detections.get(i, 6)[0] * rows);
// Draw rectangle around detected object.
Imgproc.rectangle(imageMat, new Point(left, top), new Point(right, bottom),
new Scalar(0, 255, 0), 4);
String label = classNames[classId] + ": " + confidenceTmp;
int[] baseLine = new int[1];
Size labelSize = Imgproc.getTextSize(label, Core.FONT_HERSHEY_COMPLEX, 0.5, 5, baseLine);
// Draw background for label.
Imgproc.rectangle(imageMat, new Point(left, top - labelSize.height),
new Point(left + labelSize.width, top + baseLine[0]),
new Scalar(255, 255, 255), Core.FILLED);
// Write class name and confidence.
Imgproc.putText(imageMat, label, new Point(left, top),
Core.FONT_HERSHEY_COMPLEX, 0.5, new Scalar(0, 0, 0));
}
}
if (!detected) {
Toast.makeText(this, "没有检测到目标!", Toast.LENGTH_LONG).show();
return;
}
Utils.matToBitmap(imageMat, image);
imageView.setImageBitmap(image);
}
// Upload file to storage and return a path.
private static String getPath(String file, Context context) {
Log.i("getPath", "start upload file " + file);
AssetManager assetManager = context.getAssets();
BufferedInputStream inputStream = null;
try {
// Read data from assets.
inputStream = new BufferedInputStream(assetManager.open(file));
byte[] data = new byte[inputStream.available()];
inputStream.read(data);
inputStream.close();
// Create copy file in storage.
File outFile = new File(context.getFilesDir(), file);
FileOutputStream os = new FileOutputStream(outFile);
os.write(data);
os.close();
Log.i("getPath", "upload file " + file + "done");
// Return a path to file which may be read in common way.
return outFile.getAbsolutePath();
} catch (IOException ex) {
Log.e("getPath", "Failed to upload a file");
}
return "";
}
private static final int MAX_SIZE = 1024;
private ImageView imageView;
private Bitmap image;
private Net net = null;
private int PICK_IMAGE_REQUEST = 1;
private static final String[] classNames = {"background",
"aeroplane", "bicycle", "bird", "boat",
"bottle", "bus", "car", "cat", "chair",
"cow", "diningtable", "dog", "horse",
"motorbike", "person", "pottedplant",
"sheep", "sofa", "train", "tvmonitor"};
}
代码中的一些关键点说明如下:
loadModel
:实现了模型的加载,OpenCV提供了readNetFromCaffe
方法用于加载Caffe训练的模型,其输入就是两个模型文件。onActivityResult
:实现了选择图片后的图片处理和展示。recognize
:实现利用加载的模型进行目标检测,并根据检测结果用框画出目标的位置。和之前的基于HOG特征的目标检测类似。然后点击运行,效果如下:
可以看到,检测到了显示器、盆栽、猫、人等。对安卓还不太熟,后面有时间了弄一从摄像头视频中实时检测的App玩玩。
Reference:
]]>我使用的环境是:
Android Studio的安装就不说了非常简单(只要你网络畅通),OpenCV Android SDK下载后直接解压即可。关于OpenCV Android SDK目前最新的4.x版本是4.2.0,我刚开始使用的是这个版本,但发现问题比较多,所以最后使用了3.x的最新版本3.4.9。也强烈建议你使用3.x版本。
OpenCV底层的算法都是使用C/C++实现的,并且编译成了.so/.a,所以安卓(Java)要使用这些库必须通过JNI,安卓提供了一个开发工具包NDK(Native Development Kit),可以更方便的帮助开发人员通过JNI访问本地代码。这个包默认是没有安装的,所以第一步就是安装NDK及其一些依赖包。
直接上图吧:
安装NDK和CMake。
先创建一个Android的工程,按照下面的图来就行(重点地方已标记):
项目创建成功后在build的时候可能会出现如下错误:
A problem occurred configuring project ':app'.
> NDK not configured. Download it with SDK manager. Preferred NDK version is '20.0.5594570'. Log: /Volumes/Files/Study/android/opencv/Demo/app/.cxx/ndk_locator_record.json
这个时候打开“Project Structure”设置一下NDK的路径即可(NDK安装完成后再Android SDK目录下):
然后再Build,应该就没有什么错误了。接下来就是导入OpenCV SDK了。
右键工程,选择新增module:
module type选择Eclipse ADT Project:
然后就是选择模块的Source directory了,选择OpenCV Android SDK解压目录下面的sdk里面的java目录,如图所示:
选择以后,会自动生成一个模块名,自己也可以修改,建议使用默认值,因为随sdk的那些samples里面都用的是这个名字。4.x版本就没有这个贴心了。然后Next,后面都保持默认,直到Finish。
导入以后,修改导入的OpenCV模块的build.gradle
文件。主要有这么几个修改点:
apply plugin: 'com.android.application'
为apply plugin: 'com.android.library'
,这样才能将导入的OpenCV作为模块使用。compileSdkVersion
、buildToolsVersion
、 minSdkVersion
、 targetSdkVersion
值替换掉OpenCV build.gradle里面对应的值。然后编译。有可能会提示“AndroidManifest.xml”文件里面不应该有版本之类的错误,直接根据提示移除即可,如下图:修改完之后,确保build成功。
然后就是将导入的OpenCV添加为app的依赖:
确定即可。然后确保编译没有错误。到这里openCV依赖就算添加完了,只要编译没有错误,就可以在项目中使用OpenCV了。
但工作并没有完,OpenCV为了减小应用的体积,单独提供了一个OpenCV Manager的apk文件,这个文件包含了OpenCV的一些库。也就是要使用OpenCV,你的手机上面除了要安装你自己的app,还要安装OpenCV Manager才可以。如果没有装,你自己的app启动的时候,就会提示去Google Play下载。这里有几个坑:
本来这个OpenCV Manager的apk文件是随sdk一起的,但4.2的版本里面没有这个文件了(我估计4.x的都没有),但3.x的sdk包里面却有,就在解压目录里面有个apk目录,里面有各个硬件架构的apk:
当然很多时候,让别人为了使用你的App,再去安装另外一个app不是很OK,所以也有一种方式可以将需要的OpenCV的库直接打到自己的这个App里面。操作方式如下(这里有很多坑,网上绝大多数都没有说清楚):
将工程显示方式从Android切换为Project:
然后在app目录下面创建一个"jniLibs"目录,将OpenCV Android SDK的sdk->native->libs下面的目录拷贝到这个目录下(只拷贝需要的架构即可,一般手机都是arm64-v8a,模拟器一般都是x86、x86_64),如下图:
然后在app目录下面的build.gradle文件的android里面添加如下内容(网上绝大多数教程都漏了这一步):
文字版:
sourceSets {
main {
jni.srcDirs = []
jniLibs.srcDirs = ['jniLibs']
}
}
最后就是验证环境是否完全部署好了:修改MainActivity.java文件,在onCreate里面增加一些内容,如下:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Example of a call to a native method
TextView tv = findViewById(R.id.sample_text);
// tv.setText(stringFromJNI());
if (OpenCVLoader.initDebug()) {
Log.i("CV", "load OpenCV Library Successful.");
tv.setText("load OpenCV Library successfully.");
} else {
Log.i("CV", "load OpenCV Library Failed.");
tv.setText("load OpenCV Library failed.");;
}
}
然后连上你的手机或者模拟机,运行一下,如果提示加载OpenCV库成功,那整个环境搭建就算完成了:
至此,环境搭建就算完成了,总体来说,网上的文章有很多问题,包括官方那古老的文档。祝你好运!下篇文章介绍一下如何基于整个环境构建一个手机端的目标检测程序。
]]>注:图片来自这里.
提取出特征之后,就可以使用一些分类算法进行模型训练了。当时论文作者使用线性SVM进行了模型的训练,所以现在HOG特征也都基本是和SVM一起使用的(记得以前有统计称普通机器学习算法中最受欢迎的就是SVM和随机森林了)。完整的流程如下(图片来自HOG的原始论文):
OpenCV也支持基于HOG特征的目标检测,并且预先训练了一些模型,下面我们通过一个例子进行介绍:
import cv2
// 初始化HOG及SVM分类器
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
vs = cv2.VideoCapture("/Users/allan/Downloads/TownCentreXVID.avi")
threshold = 0.7
while True:
grabbed, frame = vs.read()
if grabbed:
frame = cv2.resize(frame, (1280, 720))
gray_frame = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
rects, weights = hog.detectMultiScale(gray_frame)
for i, (x, y, w, h) in enumerate(rects):
if weights[i] < 0.7:
continue
else:
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imshow("frame", frame)
k = cv2.waitKey(1) & 0xFF
if k == ord("q"):
break
vs.release()
cv2.destroyAllWindows()
代码整体逻辑比较简单,和上篇文章代码非常相似。刚开始初始化HOG Descriptor,并设置SVM检测器为默认的行人检测器。然后从视频读取一帧帧的图片进行处理(文中的测试视频可在公众号回复"机器视觉实战3"获取)。
代码效果如下:
这里对检测函数detectMultiScale
的返回值稍作说明。我们知道分类算法的结果一般返回的是label,也就是告诉你目标属于哪个类别。而检测类算法要更进一步,不仅要解决图片中有没有目标出现,如果有,还要给出在哪里。所以不论是上节的Haar Cascades,还是这节的HOG,检测函数的返回值都很相似,HOG返回的信息更多,我们以HOG为例介绍。HOG返回了两个列表:rects和weights。检测到多少个目标,列表中就会有多少个值,即列表的大小回答了图片中有没有目标的问题。rects中的每个值是包含四个元素的元组,比如(952, 3, 77, 82),这四个值限定了图片中一个矩形,前两个值是矩形的一个顶点的坐标,后面两个值则是矩形的宽和高。而这个矩形就是检测出来的目标的位置,这些从代码中的cv2.rectangle
也能看出来。相比于Haar,HOG还多返回了一个weights列表,这个列表的行和rects是一致的,它指的是识别出来的目标的权重,可以理解为可信度,DNN模型里面一般称为confidence。
对于目标检测来说,还有一个非常重要的知识点,就是NMS(Non Maximum Suppression),一般翻译为非极大值抑制。我们进行检测的时候,是用一个个滑框去获取特征值的,所以会产生大量的重复区域,效果就是最终返回的矩形有很多会产生重复。而NMS就是用来消除这些局部区域的极大值,最终获得最大值,从而消除重复,下面是一张效果图:
左侧是没有经过NMS处理的,右侧是经过NMS处理。这里推荐一篇文章:非极大值抑制(Non-Maximum Suppression). NMS的算法原理相对简单,需要的时候可以自己实现,也可以使用一些已有实现。这里推荐一个Python的:
# pip install imutils
from imutils.object_detection import non_max_suppression
rects = np.array([[x, y, x + w, y + h] for (x, y, w, h) in rects])
pick = non_max_suppression(rects, probs=None, overlapThresh=0.65)
Haar Cascades和Hog特征检测是DNN没有出来之前主要的目标检测算法,即使现在有很多基于DNN的模型,Haar Cascades和Hog在工业界依旧有很多应用场景。
References
]]>然后用这些长方形去划过图片(非常类似于CNN里面的卷积核),每划一次,用白色框中的所有的像素值减去黑色框中所有像素的值,得到一个该特征的值。这样有了数据,有了特征,就可以训练分类器了,比如使用AdaBoost分类器对数据进行训练。目前这个算法已经非常成熟,本文演示一下如何借助OpenCV使用Haar Cascades进行目标检测。OpenCV已经集成了Haar Cascades,而且内置了一些训练好的模型,可以从这里获取和查看。
下面代码实现了通过Haar特征进行人脸和眼睛检测:
import cv2
from imutils.video import VideoStream
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_eye_tree_eyeglasses.xml")
vs = VideoStream(src=0).start()
while True:
frame = vs.read()
frame = cv2.resize(frame, (640, 480))
# Haar Cascades必须使用灰度图
gray_frame = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
rects_face = face_cascade.detectMultiScale(gray_frame)
for (x, y, w, h) in rects_face:
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
y = y - 10 if y - 10 > 10 else y + 10
cv2.putText(frame, "face", (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 0), 1)
rects_eye = eye_cascade.detectMultiScale(gray_frame)
for (x, y, w, h) in rects_eye:
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
y = y - 10 if y - 10 > 10 else y + 10
cv2.putText(frame, "eye", (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 255), 1)
# 滤镜不能少...
frame = cv2.bilateralFilter(frame, 0, 20, 5)
cv2.imshow("preview", frame)
k = cv2.waitKey(1) & 0xFF
if k == ord("q"):
break
代码见GitHub.
下面是检测效果(原谅我厚颜无耻的加了一点滤镜...):
关于Haar Cascades算法需要知道以下几点:
虽然Haar Cascades一般没有一些基于深度学习的目标检测模型强大,但他也有自己的一些优势,比如容易训练、资源占用少等,所以仍旧有一定的使用场景。本文介绍的比较简单,一些细节会放在下篇文章中和另外一种目标检测算法一起讲解。
]]>原文出处:Ball Tracking with OpenCV.
我使用的环境是:MacOS+Python3.7.4+OpenCV 4.2.0.
相比于原文,我对代码做了一些小修改,并且加了一些中文注释,方便你理解。整个项目代码如下:
import argparse
import time
from collections import deque
import cv2
import imutils
import numpy as np
from imutils.video import VideoStream
# 命令行参数
ap = argparse.ArgumentParser()
ap.add_argument("-v", "--video", help="path to video")
ap.add_argument("-b", "--buffer", type=int, default=64, help="max buffer size")
args = vars(ap.parse_args())
# 绿色球的HSV色域空间范围
greenLower = (29, 86, 6)
greenUpper = (64, 255, 255)
pts = deque(maxlen=args["buffer"])
# 判断是读入的视频文件,还是摄像头实时采集的,这里作区分是因为两种情况下后面的有些操作是有区别的
if args.get("video", None) is None:
useCamera = True
print("video is none, use camera...")
vs = VideoStream(src=0).start()
else:
useCamera = False
vs = cv2.VideoCapture(args["video"])
time.sleep(2.0)
while True:
frame = vs.read()
# 摄像头返回的数据格式为(帧数据),而从视频抓取的格式为(grabbed, 帧数据),grabbed表示是否读到了数据
frame = frame if useCamera else frame[1]
# 对于从视频读取的情况,frame为None表示数据读完了
if frame is None:
break
# resize the frame(become small) to process faster(increase FPS)
frame = imutils.resize(frame, width=600)
# blur the frame to reduce high frequency noise, and allow
# us to focus on the structural objects inside the frame
# 通过高斯滤波去除掉一些高频噪声,使得重要的数据更加突出
blurred = cv2.GaussianBlur(frame, (11, 11), 0)
# convert frame to HSV color space
hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)
# handles the actual localization of the green ball in the frame
# inRange的作用是根据阈值进行二值化:阈值内的像素设置为白色(255),阈值外的设置为黑色(0)
mask = cv2.inRange(hsv, greenLower, greenUpper)
# A series of erosions and dilations remove any small blobs that may be left on the mask
# 腐蚀(erode)和膨胀(dilate)的作用:
# 1. 消除噪声;
# 2. 分割(isolate)独立的图像元素,以及连接(join)相邻的元素;
# 3. 寻找图像中的明显的极大值区域或极小值区域
mask = cv2.erode(mask, None, iterations=2)
mask = cv2.dilate(mask, None, iterations=2)
# 寻找轮廓,不同opencv的版本cv2.findContours返回格式有区别,所以调用了一下imutils.grab_contours做了一些兼容性处理
cnts = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
center = None
# only proceed if at least one contour was found
if len(cnts) > 0:
# find the largest contour in the mask, then use it to compute the minimum enclosing circle
# and centroid
c = max(cnts, key=cv2.contourArea)
((x, y), radius) = cv2.minEnclosingCircle(c)
M = cv2.moments(c)
# 对于01二值化的图像,m00即为轮廓的面积, 一下公式用于计算中心距
center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"]))
# only proceed if the radius meets a minimum size
if radius > 10:
# draw the circle and centroid on the frame, then update the list of tracked points
cv2.circle(frame, (int(x), int(y)), int(radius), (0, 255, 255), 2)
cv2.circle(frame, center, 5, (0, 0, 255), -1)
pts.appendleft(center)
for i in range(1, len(pts)):
# if either of the tracked points are None, ignore them
if pts[i - 1] is None or pts[i] is None:
continue
# compute the thickness of the line and draw the connecting line
thickness = int(np.sqrt(args["buffer"] / float(i + 1)) * 2.5)
cv2.line(frame, pts[i - 1], pts[i], (0, 0, 255), thickness)
cv2.imshow("Frame", frame)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
break
if useCamera:
vs.stop()
else:
vs.release()
cv2.destroyAllWindows()
该项目主要使用OpenCV实现,里面重要的地方我都已经加了注释说明,这里再理一下整个流程:
循环处理视频中的帧:
对于没有图像处理基础的人来说,可能不了解其中一些处理的作用,这里我把一些关键步骤处理的效果图放上来,方便你理解(以其中一帧图片为例):
resize后的效果:
这一步除了尺寸有变化,其它都和原图一样。
高斯滤波后的效果:
滤掉了高频噪音,看着变模糊了。
转换到HSV色域后的效果:
二值化后的效果:
腐蚀和膨胀后的效果:
先简单介绍下HSV颜色空间吧(其实还有个HSL,和HSV类似,但有些不同)。RGB是在笛卡尔坐标系来表示颜色的,而HSV则是在圆柱坐标系中表示颜色的,所以它能表示更多的信息。下图是一个HSV的图(图片来自维基百科):
简单说就是HSV使用圆柱坐标系表示颜色,分三个维度(如上图中的e):
圆柱的中心轴底部为黑色,顶部为白色,中间为灰色。更详细的信息请参加维基百科:HSV。
那为什么我们要将图像从RGB转到HSV再做颜色识别呢?因为虽然RGB通道对人眼来说比较友好,但却不能很好地反映出物体具体的颜色信息。相反,HSV空间能够非常直观的表达色彩的明暗,色调,以及鲜艳程度,方便进行颜色之间的对比。所以在通过颜色识别对象时,都是采用HSV颜色空间的。
最后说一下两个实战时需要注意的事项:
一个颜色在HSV中没有特别明确的值或者范围,实际使用的时候往往需要我们自己去观察测试进行确定。下面是一个常见颜色H值大致的范围参考(注意范围是0\~360):
通过这个项目我们可以学到如何通过颜色来做物体识别。
]]>在OpenCV中有分别有1通道图,2通道图,3通道图,4通道图。我们最常用的是3通道图,即RGB图,4通道图是在3通道图的基础上多了一个Alpha分量,用于表示透明这个特性。虽然我们一般说的时候都是RGBA的顺序,但其实在内存里面,图像的存储顺序是BGRA BGRA BGRA …。而我们知道在OpenCV里面使用CvScalar结构表示颜色,正好该结构包含4个双精度浮点型成员。用该结构表示颜色时,它的4个成员变量对应的颜色时B、G、R、A,即是颜色在内存里面存储的顺序,而不是我们通常说的颜色的顺序(RGBA)。所以,在设计颜色的函数调用中,我们就需要注意。比如,我们想设置蓝色,则应该是cvScalar(255, 0, 0),设置红色应该是cvScalar(0, 255, 0)。当然,为了满足人们平时的习惯,OpenCV里面有一个宏CV_RGB,其实是一个宏定义:
#define CV_RGB(r, g, b) cvScalar((b), (g), (r), 0)
所以如果我们习惯了RGB的顺序,可以使用该宏来获得颜色,例如设置绿色:CV_RGB(0, 255, 0)。
]]>函数原型:
void cvLine( CvArr* array, CvPoint pt1, CvPoint pt2, CvScalar color, int thickness = 1, int connectivity = 8 );
第一个参数 一般为一个图像类型的指针IplImage*。
第二个和第三个参数分别为直线的起点和终点,是CvPoint类型的,我们可以使用cvPoint(int x, int y)构造函数很方便的构造一个CvPoint类型的变量。
第四个参数是一个CvScalar类型的颜色变量,其结构前面已经介绍过,是一个包含四个双精度浮点型变量的集合。在这里。前三个分别代表红、绿、蓝通道(但其实因为RGB在内存里面的存储顺序是BGRA BGRA,所以当使用CVScalar类型表示颜色时,其实四个分量分别代表B、G、R、A。如果想按照RGBA的顺序,可以使用后面介绍的宏CV_RGB构造CvScalar类型,而不是cvScalar。关于这一点,最后面的例子中也会有体现);没有用到第四个(它只在适当的时候用于alpha通道)。一个常用的便捷宏指令是CV_RGB(r, g, b),该指令采用三个数字作为参数并将其封装到CvScalar。
最后两个参数是可选的,因为都有默认值。thickness是线的粗细(像素)。connectivity被设为反走样模式,默认值为“八连通”,这种是较为平滑不会走样的线型。也可以设置为“4连通”,这样的话,斜线会产生重叠以致看上去过于粗重,不过画起来速度很快。
函数原型:
void cvRectangle( CvArr* array, CvPoint pt1, CvPoint pt2, CvScalar color, int thickness = 1 );
从函数原型就可以看出,这个函数和cvLine( )除了最后一个参数外,其他都一样。的确是,因为这个函数画的矩形总是平行于X和Y轴。利用这个函数画矩形,只需要给出两个对顶点就可以。
函数原型:
void cvCircle ( CvArr* array, CvPoint center, int radius, CvScalar color, int thickness = 1, int connectivity = 8 ); void cvEllipse( CvArr* img, CvPoint center, CvSize axes, double angle, double start_angle, double end_angle, CvScalar color, int thickness =1, int line_type = 8 ); // 使用外接矩形绘制椭圆 void cvEllipseBox( CvArr* img, CvBox2D box, CvScalar color, int thickness = 1, int line_type = 8, int shift = 0 );
画圆也很简单,参数和前面介绍的基本相同,这里就不多介绍了。对于圆形和矩阵等很多封闭图形来说,thickness参数也可以设置为CV_FILL,其值是-1,;其结果是使用与边一样的颜色填充封闭空间。
椭圆函数比cvCircle( )略复杂一点。主要的新成员是axes属性,其类型为CvSize,其结构前面已经介绍过,是一个包含宽度和高度的简单结构。同样,可以利用其构造函数cvSize( )得到一个CvSize结构。在这种情况下,height和width参数分别代表椭圆的长短半轴长。 angle是指偏离主轴的角度,从X轴算起,逆时针方向为正。同样,start_angle和end_angle表示弧线开始和结束位置的角度。因此,一个完整的椭圆必须分别将这两个值分别设为0°和360°。
除了cvEllipse( )函数外,还可以使用外接矩形的方法绘制椭圆,函数是cvEllipseBox( )。该函数用到一个CvBox2D结构:
typedef struct { CvPoint2D32f center, CvSize2D32f size, float angle } CvBox2D;
绘制多边形有多个函数:
void cvFillPoly ( CvArr* img, CvPoint** pts, int* npts, int contours, CvScalar color int line_type = 8 ); void cvFillConvexPoly ( CvArr* img, CvPoint* pts, int npts, CvScalar color, int line_tyoe = 8 ); void cvPolyLine ( CvArr* img, CvPoint** pts, int* npts, int contours, int is_closed, CvScalar color, int thickness = 1, int line_type = 8 );
这三个函数都可以绘制多边形,思路也基本相同,主要区别是如何描述点:
在cvFillPolly( )中,点是由CvPoint数组提供的。它允许cvFillPoly( )在一次调用中绘制多个多边形。同样的,npts是由计数点构成的数组,与多边形对应。如果把变量is_closed设为ture,那么下一个多边形的第一个线段就会从上一多边形最后一个点开始。cvFillPoly很稳定,可以处理自相交多边形,有孔的多边形等复杂问题。然而缺点是函数运行起来相对缓慢。
cvFillConvexPoly( )和cvFillPoly( )类似。不同的是,它一次只能画一个多边形,而且只能画凸多边形。优点是函数运行速度快。
第三个cvPolyLine( ),七参数与cvFillPoly( )基本相同,但因为只需画出多边形的边,不需处理相交情况。因此,函数运行速度远远超过cvFillPoly( )。
OpenCV有一个主要的函数,叫cvPutText( ),这个函数可以在图像上输出一些文本。函数原型:
void cvPutText ( CvArr* img, const text, CvPoint origin, const CvFont* font, CvScalar color );
参数text所指向的文本将打印到图像上。
参数origin指定文本框左下角位置。
参数color指定文本颜色。
要获取CvFont*指针需要调用函数cvInitFont( )函数。函数原型:
void cvInitFont ( CvFont* font, int font_face, double hscale, double vscale, double shear = 0, int thickness = 1, int line_type = 8 );
该函数采用一组参数配置一些用于屏幕输出的基本特定字体。为了建立一个可以传值给cvPutText( )的cvFont,首先必须声明一个CvFont变量,然后把它传递给cvInitFont( )。
font_face是选择字体,且该标志可以和CV_FONT_ITALIC(字体标志)组合使用(使用与或)。以下是全部可用的字体:
标志名称 | 描述 |
CV_FONT_HERSHEY_SIMPLEX | 正常尺寸的sanserif字体 |
CV_FONT_HERSHEY_PLAIN | 小尺寸的sanserif字体 |
CV_FONT_HERSHEY_DUPLEX | 正常尺寸的sanserif字体,但比CV_FONT_HERSHEY_SIMPLEX 复杂 |
CV_FONT_HERSHEY_COMPLEX | 正常尺寸serif,比CV_FONT_HERSHEY_DUPLEX更复杂 |
CV_FONT_HERSHEY_TRIPLEX | 正常尺寸serif,比CV_FONT_HERSHEY_COMPLEX更复杂 |
CV_FONT_HERSHEY_COMPLEX_SMALL | 小尺寸的CV_FONT_HERSHEY_COMPLEX |
CV_FONT_HERSHEY_SCRIPT_SIMPLEX | 手写风格 |
CV_FONT_HERSHEY_SCRIPT_COMPLEX | 比CV_FONT_HERSHEY_SCRIPT_SIMPLEX更复杂 |
hscale和vscale只能设定为1.0或0.5,。字体渲染时选择全高或半高(宽度同比缩放),绘制效果与指定字体的基本定义有关。
参数shear创建斜体字,如果设置为0.0,字体不倾斜。当设置为1.0时,字体倾斜范围接近45°。
其他两个参数与以前相同。
下面用一个例子来说明前面介绍的知识:
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <stdio.h>
using namespace cv;
using namespace std;
int main(int argc, char** argv)
{
IplImage *image = cvCreateImage(cvSize(800, 600), 8, 3);
// Draw Line
cvLine(image, cvPoint(10, 10), cvPoint(500, 20),
cvScalar(255), 2, 8, 1);
// Draw Circle
cvCircle(image, cvPoint(100, 100), 50, cvScalar(0, 255, 0), 2, 8, 0);
// Draw Ellipse
cvEllipse(image, cvPoint(400, 150),
cvSize(100, 150), 45, 0, 360, cvScalar(0, 0, 255), 2);
cvEllipse(image, cvPoint(600, 150),
cvSize(100, 150), 0, 0, 360, CV_RGB(0, 0, 255), 2);
// Draw Text
CvFont font1, font2, font3, font4, font5, font6, font7, font8;
char* text = "Time Track";
cvInitFont(&font1, CV_FONT_HERSHEY_COMPLEX, 1, 1, 0);
cvPutText(image, text, cvPoint(100, 200), &font1, CV_RGB(0, 0, 0));
cvInitFont(&font2, CV_FONT_HERSHEY_PLAIN, 1, 0.5, 0.2);
cvPutText(image, text, cvPoint(100, 250), &font2, CV_RGB(0, 0, 0));
cvInitFont(&font3, CV_FONT_HERSHEY_DUPLEX, 0.5, 1, 0.4);
cvPutText(image, text, cvPoint(100, 300), &font3, CV_RGB(0, 0, 0));
cvInitFont(&font4, CV_FONT_HERSHEY_COMPLEX, 0.5, 1, 0.6);
cvPutText(image, text, cvPoint(100, 350), &font4, CV_RGB(0, 0, 0));
cvInitFont(&font5, CV_FONT_HERSHEY_TRIPLEX | CV_FONT_ITALIC, 1, 1, 0.8);
cvPutText(image, text, cvPoint(100, 400), &font5, CV_RGB(0, 0, 0));
cvInitFont(&font6, CV_FONT_HERSHEY_COMPLEX_SMALL, 1, 1, 1);
cvPutText(image, text, cvPoint(100, 450), &font6, CV_RGB(0, 0, 0));
cvInitFont(&font7, CV_FONT_HERSHEY_SCRIPT_SIMPLEX, 1, 1, 2);
cvPutText(image, text, cvPoint(100, 500), &font7, CV_RGB(0, 0, 0));
cvInitFont(&font8, CV_FONT_HERSHEY_SCRIPT_COMPLEX, 1, 1, 3);
cvPutText(image, text, cvPoint(200, 550), &font8, CV_RGB(0, 0, 0));
cvNamedWindow("Display");
cvShowImage("Display", image);
cvWaitKey();
return 0;
}
]]>ROI在实际工作中有很重要的作用,在许多情况下,使用它会提高计算机视觉代码的执行速度。这是因为他们允许对图像的某一部分进行操作,而不是对整个图像进行操作。在OpenCV中,普遍支持ROI,函数的操作被限于感兴趣的区域。要设置或取消ROI,就要使用 cvSetImageROI( )和cvResetImageROI( )函数。如果设置ROI,可以使用函数 cvSetImageROI( ),并为其传递一个图像指针和矩形。要取消ROI,只需要为函数 cvResetImageROI( )传递一个图像指针。
void cvSetImageROI(IplImage* image, CvRect rect); void cvResetImageROI(IplImage* image);
下面用一个例子来来说明ROI的用法。例子中:我们加载一幅图像并修改一些区域——设置ROI的x,y,width和height的值,最后将ROI区域中像素都加上一个整数。代码如下:
#include <opencv2/highgui/highgui.hpp> #include <opencv2/imgproc/imgproc.hpp> #include <stdio.h> using namespace cv; using namespace std; int main(int argc, char** argv) { IplImage *src; src = cvLoadImage("E:OpenCVimagescat.jpg"); int x = 90; int y = 110; int width = 180; int height = 180; int add = 150; cvSetImageROI(src, cvRect(x, y, width, height)); cvAddS(src, cvScalar(add), src); cvResetImageROI(src); cvNamedWindow("Roi_Add", 1); cvShowImage("Roi_Add", src); cvWaitKey(); return 0; }
上述代码把ROI集中于猫的脸部,并将其蓝色通道增加150。
最后有两点需要说明:
1. cvAddS( )函数,该函数用于实现一个数组和一个标量的元素级的相加运算。例子中通过cvScalar( )构造函数将CvScalar的第一个分量设置为add代表的值,其他值为默认值0,然后和src里面的元素相加。那为什么是蓝色通道增加150,而不是红色通道呢?因为RGB图在内存中的排列顺序是:BGRA BGRA BGRA …。所以,实际上第一个通道是B(blue)。该函数其实还有第四个参数(默认为NULL),其代表的含义可参考OpenCV文档。
2. 显示图像之前的" cvResetImageROI(src); ”这一句代码千万不能少。如果没有这行代码,那接下去显示图像的时候,只会显示ROI区域。因为前面已经说过,设置了ROI区域后,OpenCV的函数只会对ROI区域有效。
]]>
int cvCreateTrackbar( const char* trackbar_name, const char* window_name, int* value, int count, CvTrackbarCallback on_change );
前两个参数分别指定了滑动条的名字以及滑动条附属窗口的名字。当滑动条被创建后,会位于窗口的顶部或者底部(由操作系统决定)。滑动条不会遮挡窗口中的图像。
第三个参数value是一个整数指针。当滑动条被拖动时,OpenCV会自动将当前位置所代表的值传递给指针指向的整数。第四个参数count是一个整数数值,为滑动条所能表示的最大值。
最后一个参数是一个指向回调函数的指针。当滑动条被拖动时,回调函数会自动被调用。回调函数必须为CvTrackbarCallback格式,即:
void (*callback) (int position)
不过,这个回调函数不是必须的,所以如果不需要一个回调函数,可以将参数设置为NULL(默认值就是NULL)。没有回调函数,当滑动条被拖动时,唯一的影响就是改变指针value所指向的整数值。
HighGUI还提供了两个函数分别用来读取和设置滚动条的value值,不过前提是必须知道滑动条的名称。
int cvGetTrackbarPos( const char* trackbar_name, const char* window_name ); void cvSetTrackbarPos( const char* trackbar_name, const char* window_name, int pos );
下面用对《学习OpenCV》书上一个简单的例子进行改进来说明滚动条的具体应用:用滑动条模拟按钮。
#include <opencv2/highgui/highgui.hpp> #include <opencv2/imgproc/imgproc.hpp> #include <stdio.h> using namespace cv; using namespace std; int g_switch_value = 0; //callback function void switch_callback(int postion); void switch_off_funtion(); void switch_on_function(); int main(int argc, char** argv) { cvNamedWindow("Demo Window", 1); cvCreateTrackbar( "Switch", "Demo Window", &g_switch_value, 1, switch_callback ); while (1) { if (cvWaitKey(15) == 27) { break; } } } void switch_callback(int postion) { if (postion == 0) { switch_off_funtion(); } else { switch_on_function(); } } void switch_on_function() { printf("Switch is On!n"); } void switch_off_funtion() { printf("Switch is off!n"); }
这单个结构都非常简单,用于表示二维或三维的点(确切的说是点的坐标,通常坐标都是以0为基点),既有表示整数也有表示浮点型的。他们的定义分别如下:
typedef struct CvPoint { int x; // X坐标 int y; // Y坐标 } CvPoint; typedef struct CvPoint2D32f { float x; // X坐标 float y; // Y坐标 } CvPoint2D32f; typedef struct CvPoint3D32f { float x; // X坐标 float y; // Y坐标 float z; // Z坐标 } CvPoint3D32f;
所有的OpenCV的数据类型都具有以其名称来定义的构造函数,例如cvPoint( )。(通常构造函数具有和结构类型一样的名称,只是首字母不大写)。因为C中没有类的概念,所以构造函数其实都是由内联函数实现的。比如上面介绍的3种类型都有如下构造函数:
inline CvPoint cvPoint(int x, int y); inline CvPoint2D32f cvPoint2D32f(double x, double y); inline CvPoint3D32f cvPoint3D32f(double x, double y, double z); /* * 除了基本的构造函数以外,往往还可以通过一些转换函数进行构造 */ inline CvPoint cvPointFrom32f(CvPoint2D32f point); inline CvPoint2D32f cvPointTo32f(CvPoint point);
这三个结构都和矩形有关系,前两个结构都用来表示矩形框的大小,第一个以像素为精度,第二个以亚像素为精度。CvRect结构用于表示矩形框的偏移和大小。他们的定义及其构造函数如下:
typedef struct CvSize { int width; // 矩形宽 int height; // 矩形高 } CvSize; inline CvSize cvSize(int width, int height) { CvSize s; s.width = width; s.height = height; return s; } typedef struct CvSize2D32f { float width; // 矩形宽 float height; // 矩形高 } CvSize2D32f; inline CvSize2D32f cvSize2D32f(double width, double height) { CvSize2D32f s; s.width = (float)width; s.height = (float)height; return s; } typedef struct CvRect { int x; // 方形的最左角的X坐标 int y; // 方形的最上或者最下角的Y坐标 int width; // 宽 int height; // 高 } CvRect; inline CvRect cvRect(int x, int y, int width, int height) { CvRect os; os.x = x; os.y = y; os.width = width; os.height = heigth; return os; }
这个结构也比较常用,它可以用来表示一个1、2、3、4元组(比如表示RGAB值)。定义如下:
typedef struct CvScalar { double val[4]; } CvScalar;
这个结构有3个构造函数:
/* * 第一个:cvScalar( )。他需要1~4个参数,如果参数不足4个,其他量默认为0。 */ inline CvScalar cvScalar(double val0, double val1, double val2, double val3) { CvScalar scalar; scalar.val[0] = val0; scalar.val[1] = val1; scalar.val[2] = val2; scalar.val[3] = val3; return scalar; } /* * 第二个:cvRealScalar().它只需要一个参数,用于初始化val[0],其他值设为0. */ inline CvScalar cvRealScalar(double val0) { CvScalar scalar; scalar.val[0] = val0; scalar.val[1] = 0; scalar.val[2] = 0; scalar.val[3] = 0; return scalar; } /* * 第三个:cvScalarAll(). 他需要一个参数,并且将val[]数组的4个元素都设为此值。 */ inline CvScalar cvRealScalar(double val) { CvScalar scalar; scalar.val[0] = val; scalar.val[1] = val; scalar.val[2] = val; scalar.val[3] = val; return scalar; }
更多的数据类型可以参考OpenCV官方文档:http://docs.opencv.org/modules/core/doc/core.html
]]>