原理:
1、灰度(暂时不使用,实际使用效果不佳)
2、二值化(使用直方图双峰确定阈值)
3、边界查找
4、内容填充
注意:本demo调试适用与证件照白底,其他底图颜色需要根据实际情况更换或调试第三步之前的算法
package xxx.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.Objects;
/**
* 证件照抠图
* 暂适用于白底证件照,其他底色请自行调整灰度和二值化
*
* @author xwt
* @date 2023/4/20 10:35
*/
public class IdPhotoMattingUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(IdPhotoMattingUtils.class);
private static final int[][] DOMAIN = {{1,0},{1,-1},{0,-1},{-1,-1},{-1,0},{-1,1},{0,1},{1,1}};
private IdPhotoMattingUtils() {
}
/***
* 证件照抠图
* @param srcImage 证件照图像
* @return 抠图后图像
*/
public static BufferedImage mat(BufferedImage srcImage){
if(srcImage == null){
LOGGER.warn("证件照为空");
return null;
}
BufferedImage image = srcImage;
// 1.灰度
image = imgGray(image);
// 2、二值化
double triangle = triangle(image);
image = gray(image, triangle);
// 3、边界查找
int[][] imgIndex = borderTracking(image);
// 查看是否扫描完成
int i = 0;
boolean b = true;
for (int[] index : imgIndex) {
for (int in : index) {
i = i + in;
if(i > 300){
b = false;
break;
}
}
if(i > 300){
break;
}
}
if(b){
LOGGER.warn("证件照无内容");
return null;
}
// 4、内容填充
return floodFill(srcImage, imgIndex);
}
/***
* 灰度图像
* @param imgSrc 原始图像
* @return 灰度图像
*/
public static BufferedImage imgGray(BufferedImage imgSrc) {
//创建一个灰度模式的图片
int width = imgSrc.getWidth();
int height = imgSrc.getHeight();
BufferedImage back = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
//Graphics graphics = back.getGraphics();
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
back.setRGB(i,j, imgSrc.getRGB(i, j));
}
}
return back;
}
/***
* 二值化自适应阈值
* 原理:
* 1.图像转灰度
* 2.计算图像灰度直方图
* 3.寻找直方图中两侧边界
* 4.寻找直方图最大值
* 5.检测是否最大波峰在亮的一侧,否则翻转
* 6.计算阈值得到阈值T,如果翻转则255-T
* @param image 灰度图像
* @return 二值化自适应阈值
*/
public static double triangle(BufferedImage image) {
int i, j;
int temp;
boolean isflipped = false;
int width = image.getWidth();
int height = image.getHeight();
int[] histogram = new int[256];
//遍历灰度图像,统计灰度级的个数
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
int value = ((image.getRGB(x, y)) & 0xff0000) >> 16;
histogram[value]++;
}
}
//3. 寻找直方图中两侧边界
int leftBound = 0;
int rightBound = 0;
int max = 0;
int maxIndex = 0;
//左侧为零的位置
for(i = 0; i<256; i++) {
if(histogram[i]>0) {
leftBound = i;
break;
}
}
//直方图为零的位置
if(leftBound > 0) {
leftBound --;
}
//直方图右侧为零的位置
for(i = 255; i>0; i--) {
if(histogram[i]>0) {
rightBound = i;
break;
}
}
//直方图为零的地方
if(rightBound >0) {
rightBound++;
}
//4. 寻找直方图最大值
for(i = 0; i<256;i++) {
if(histogram[i] > max) {
max = histogram[i];
maxIndex = i;
}
}
//判断最大值是否在最左侧,如果是则不用翻转
//因为三角法二值化只能适用于最大值在最右侧
if(maxIndex - leftBound < rightBound - maxIndex) {
isflipped = true;
i = 0;
j = 255;
while( i < j ) {
// 左右交换
temp = histogram[i]; histogram[i] = histogram[j]; histogram[j] = temp;
i++; j--;
}
leftBound = 255-rightBound;
maxIndex = 255-maxIndex;
}
// 计算求得阈值
double thresh = leftBound;
double a, b, dist = 0, tempdist;
a = max; b = leftBound-maxIndex;
for( i = leftBound+1; i <= maxIndex; i++ ) {
// 计算距离 - 不需要真正计算
tempdist = a*i + b*histogram[i];
if( tempdist > dist) {
dist = tempdist;
thresh = i;
}
}
thresh--;
// 对已经得到的阈值T,如果前面已经翻转了,则阈值要用255-T
if( isflipped ) {
thresh = 255 - thresh;
}
return thresh;
}
/***
* 图像二值化
* @param b 灰度图像
* @param triangle 阈值
* @return 二值化图像
*/
public static BufferedImage gray(BufferedImage b, double triangle){
int width = b.getWidth();
int height =b.getHeight();
// 下面这个别忘了定义,不然会出错
BufferedImage bufferedImageEnd = new BufferedImage(width,height, BufferedImage.TYPE_3BYTE_BGR );
// 双层循环更改图片的RGB值,把得到的灰度值存到bufferedImage_end中,然后返回bufferedImage_end
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// 获取到(x,y)此像素点的Colo,转化为灰度
Color color = new Color(b.getRGB(x,y));
double gray = (int)(color.getRed() * 0.299 + color.getGreen() * 0.587 + color.getBlue() *0.114);
int r;
if (gray <= triangle){
r = 0;
}else{
r = 255;
}
bufferedImageEnd.setRGB(x, y, new Color(r,r,r).getRGB());
}
}
return bufferedImageEnd;
}
/***
* 边界跟踪算法之内边界跟踪
* ps:存在特殊性,证件照可以完成,其他图像需要考虑是否可以正常返回起始点
* 参考文档: https://blog.csdn.net/hanshanbuleng/article/details/84639433
* @return 图像
*/
public static int[][] borderTracking(BufferedImage image) {
// 图像的行数
int numRows = image.getHeight();
// 图像列数
int numCols = image.getWidth();
// 全局-表示图像中每个像素是否已经被访问过,0表示未访问,1表示已访问。
int[][] visitedPixels = new int[numRows][numCols];
// 表示图像中每个像素是否已经被访问过,0表示未访问,1表示已访问。
int[][] connectedRegions = new int[0][];
LinkedList<Point> stack = null;
int num = 0;
// 头部起始点存在无法正常返回到起始点结束,因为头像特殊性,底部平滑可以避免无法返回到起始点问题,其他图像请考虑结束点判断
//for (int i = 0; i < numRows; i++) {
// for (int j = 0; j < numCols; j++) {
for (int i = numRows-1; i >= 0; i--) {
for (int j = numCols-1; j >= 0; j--) {
if (getPixel(image, i, j) == 0 && visitedPixels[i][j] == 0) {
connectedRegions = new int[numRows][numCols];
stack = new LinkedList<>();
int temp = 7;
int y = i;
int x = j;
temp = (temp + 6) % 8;
while (true){
boolean a = true;
// 查询周围8个域
for (int k = 0; k < DOMAIN.length; k++) {
if(temp == 8){
temp = 0;
}
// 查询域坐标
int domainY = y + DOMAIN[temp][1];
int domainX = x + DOMAIN[temp][0];
// 图像边界判断
if(numCols-1 < domainX || numRows-1 < domainY || domainY < 0 || domainX < 0){
temp++;
continue;
}
// 判断是否为黑像素,并且没有扫描过
if(getPixel(image, domainY, domainX) == 0 && connectedRegions[domainY][domainX] == 0){
num++;
y = domainY;
x = domainX;
connectedRegions[y][x] = 1;
visitedPixels[y][x] = 1;
stack.add(new Point(x, y, temp));
a = false;
temp = (temp + 6) % 8;
break;
}
temp++;
}
// 判断是否是起始,是则返回,完成边界确认
if(i == y && x == j){
break;
}
// 死胡同回退
if(a){
if(stack.isEmpty()){
break;
}
stack.removeLast();
if(stack.isEmpty()){
break;
}
Point last = stack.getLast();
y = last.getY();
x = last.getX();
temp = last.getTemp();
temp++;
}
}
}
// 避免黑点
if(num > 100){
// 使用
int[][] ints = new int[numRows][numCols];
stack.forEach(iter -> ints[iter.getY()][iter.getX()] = 1);
return ints;
// 返回扫描全路径
//return connectedRegions;
}
}
}
return new int[0][];
}
/***
* 洪泛填充法
* @param image 原始图像
* @param imgIndex 范围坐标
* @return 填充后图像
*/
public static BufferedImage floodFill(BufferedImage image, int[][] imgIndex) {
int width = image.getWidth();
int height = image.getHeight();
// 1、寻找起始点
int x = 0;
int y = 0;
int initX = width, initY = height;
//imgIndex[H][W]
for (int i = 0; i < imgIndex.length; i++) {
for (int j = 0; j < imgIndex[i].length; j++) {
int indexW = imgIndex[i][j];
if(indexW == 1){
if(initX > j){
y = i;
initX = j;
}
if(initY > i){
x = j;
initY = i;
}
}
}
}
// 2、内容填充
BufferedImage imageNew = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics graphics = imageNew.getGraphics();
if (imgIndex[y][x] == 1) {
return imageNew;
}
LinkedList<Point> queue = new LinkedList<>();
queue.offer(new Point(x, y));
while (!queue.isEmpty()) {
Point point = queue.poll();
// 边界范围
if (point.x < 0 || point.x >= width || point.y < 0 || point.y >= height) {
continue;
}
if (imgIndex[point.y][point.x] != 0) {
continue;
}
imgIndex[point.y][point.x] = 1;
imageNew.setRGB(point.x, point.y, image.getRGB(point.x, point.y));
//graphics.drawImage(image, point.x, point.y, null);
queue.offer(new Point(point.x - 1, point.y));
queue.offer(new Point(point.x + 1, point.y));
queue.offer(new Point(point.x, point.y - 1));
queue.offer(new Point(point.x, point.y + 1));
}
graphics.dispose();
return imageNew;
}
/***
* 查询图片像素
* @param image 图像
* @param y 高
* @param x 宽
* @return 像素
*/
private static int getPixel(BufferedImage image, int y, int x){
return (image.getRGB(x, y) & 0xff0000) >> 16;
}
public static class Point{
// w, h
private int x;
private int y;
private int temp;
public Point(int x, int y){
this.x = x;
this.y = y;
this.temp = 0;
}
public Point(int x, int y, int temp) {
this.x = x;
this.y = y;
this.temp = temp;
}
public Point() {
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
public int getTemp() {
return temp;
}
public void setTemp(int temp) {
this.temp = temp;
}
@Override
public String toString() {
return "Point{" +
"x=" + x +
", y=" + y +
'}';
}
@Override
public boolean equals(Object obj) {
if(obj instanceof Point){
Point obj1 = (Point) obj;
return this.x == obj1.x && this.y == obj1.y;
}
return super.equals(obj);
}
public boolean equals(int x, int y) {
return this.x == x && this.y == y;
}
@Override
public int hashCode() {
return Objects.hashCode(x + "" + y);
}
}
}
使用:
BufferedImage bi = ImageIO.read(new File("D:\\test\\c8c56e3838104fcab47af7920ce5723e.png"));
bi = IdPhotoMattingUtils.mat(bi);
ImgUtil.write(bi, new File("D:\\test\\response990.png"));
本文仅用于学习使用,抠图后存在白边,后续考虑优化使用降噪或虚化等