完整说明使用SpringBoot+js实现滑动图片验证
常见的网站验证方式有手机短信验证,图片字符验证,滑块验证,滑块图片验证.本文主要讲解的是滑块图片验证的实现流程.包括后台和前端的实现.
实现效果
使用的API
java.awt.image.BufferedImage
BufferedImage是Java类库中是一个带缓冲区图像类,主要作用是将一幅图片加载到内存中(BufferedImage生成的图片在内存里有一个图像缓冲区,利用这个缓冲区我们可以很方便地操作这个图片),提供获得绘图对象、图像缩放、选择图像平滑度等功能,通常用来做图片大小变换、图片变灰、设置透明不透明等。
常见的api有
读取一张图片
String imgPath = "/demo.jpg"; BufferedImage image = ImageIO.read(new FileInputStream(imgPath));
保存文件
ImageIO.write(image,"png",new File("xx.png"));
ImageIO提供read()和write()静态方法,读写图片,比以往的InputStream读写更方便。
像素处理
getRGB(int x, int y) setRGB(intx ,inty,int rgb)
获取Graphics2D对象
Graphics2D g2d = parentImage.createGraphics();
Graphics2D是一个画图工具,可以实现对图片进行画画处理,比如花直线,圆,方形等操作.除此之外,还可以创建透明背景的图片.本方案就是用它来对扣出来的图透明化处理.
Thumbnails
该类用于对图片进行压缩以符合大小要求.
Thumbnails.of(image)
.forceSize(width,height) //.width(width).height(height) .asBufferedImage();
使用forceSize强制大小时,对图片会有一定的像素损耗.使用width(width).height(height)时图片的大小不会和设定的一致.
这里用来对网上下载的图片进行大小处理,当然也可以用其他图像处理工具,比如PS
依赖
<dependency> <groupId>net.coobird</groupId> <artifactId>thumbnailator</artifactId> <version>0.4.11</version> </dependency>
基础知识
一张图片的大小通常用像素数量来表示,比如1320*600(宽*高),对于彩色图片,每一个像素点有RGB来表示.比如(250,180,0)表示黄色,或者使用十六进制表示#FFB400.
RGB查询
https://tool.oschina.net/commons?type=3
因此要想把一张图扣出来一部分,只要确定好抠图区域,使用抠图区域的像素值来创建另一张图,原抠图区域填充其他像素值.就可以得到两张分体图,组合在一起构成一副完整的图.而对于被扣出来的图,还要对其进行透明化处理.
处理流程
图片校验最需要考虑的是安全性,因此需要将数据发送给后端进行校验,而不是在前端进行校验.
1.前端刷新图片请求给后台
2.后台收到请后后开始进行处理
3.后台随机从图片文件夹中取一张图片
4.对图片进行大小检测,图片偏小则抛出异常,偏大则进行截图处理.实际生产环境应该使用处理好的图片,就可以省略这一步
5.确定抠图区的原点坐标,为了保证效果.将图片宽度分成四段,最终的原点横坐标位于2/4-3/4范围内.坐标示意如下,图片左上脚是原点(x0,y0)
(x0,y0) (xMax,y0) **************** * * * * * * **************** (x0,yMax) (xMax,yMax)
6.根据抠图区的原点对抠图区进行标定.标定区如上图所示,由正方形和半圆组成,其中一边的半圆突出,另一边凹陷.
7.对原图的被抠区域进行灰度处理,填充其他颜色
8.对抠出来的图进行背景透明化处理,并截图(截取图片范围内的图)
9.返回扣出来图的大小,偏移量,两张图的base64数据返回给前端
10.前端渲染图片
11.移动滑块,滑块移动时抠出来的图也会跟着移动,两者移动的偏移量是一样的.鼠标释放时将偏移量发送给后端进行校验.
12.后端校验后将结果返回给前端,前端根据该结果做不同的处理
13.一般是登录才进行此类的图片验证,因此在点击登录时,偏移量仍然和帐号密码再次发送,后端再一次进行校验
14.完成
后台实现
属性说明
//图片的路径 private String basePathClasspath = "img/"; private String basePathFile = "src/main/resources/img/"; private String basePath = basePathFile; private String basePathOutput = "src/main/resources/img/out/"; //图片的最大大小 private static int IMAGE_MAX_WIDTH = 300; private static int IMAGE_MAX_HEIGHT = 260; //抠图上面的半径 private static int RADIUS = IMAGE_MAX_WIDTH/20; //抠图区域的高度 private static int CUT_HEIGHT = IMAGE_MAX_WIDTH/5; //抠图区域的宽度 private static int CUT_WIDTH = IMAGE_MAX_WIDTH/5; //被扣地方填充的颜色 private static int FLAG = 0x778899; //输出图片后缀 private static String IMAGE_SUFFIX = "png"; // private int imageOffset = 0; //抠图部分凸起的方向 private Location location; ImageResult imageResult = new ImageResult(); // private String ORI_IMAGE_KEY = "ORI_IMAGE_KEY"; private String CUT_IMAGE_KEY = "CUT_IMAGE_KEY"; //抠图区的原点坐标(x0,y0) /* (x0,y0) (xMax,y0) **************** * * * * * * **************** (x0,yMax) (xMax,yMax) */ private int XPOS; private int YPOS;
对外提供的接口
也是任务的流程处理
public ImageResult imageResult(File file) throws IOException { log.info("file = {}",file.getName()); BufferedImage oriBufferedImage = getBufferedImage(file); //检测图片大小 oriBufferedImage = checkImage(oriBufferedImage); //初始化方形的原点坐标 createXYPos(oriBufferedImage); //获取被扣图像的标志图 int[][] blockData = getBlockData(oriBufferedImage); //printBlockData(blockData); //计算抠图区域的信息 createImageMessage(); //获取扣了图的原图和被扣部分的图 Map<String,BufferedImage> imageMap = cutByTemplate(oriBufferedImage,blockData); //处理完成
//设置返回的数据 imageResult.setOriImage(ImageBase64(imageMap.get(ORI_IMAGE_KEY))); imageResult.setCutImage(ImageBase64(imageMap.get(CUT_IMAGE_KEY))); imageResult.setXpos(imageMessage.getXpos()); imageResult.setYpos(imageMessage.getYpos()); imageResult.setCutImageWidth(imageMessage.getCutImageWidth()); imageResult.setCutImageHeight(imageMessage.getCutImageHeight()); return imageResult; }
确定原点的坐标
这里需要注意两点
一是抠图区域不能超过原图范围,因此随机生成范围需要减去抠图区的长度和半圆的半径
二是为了保证用户体验,限定横坐标在图像的2/4-3/4处,才能保证滑块有一定的滑程,同时保证抠图不会超出原图范围.
/** *功能描述 获取抠图区的坐标原点 * @author lgj * @Description * @date 3/29/20 * @param: * @return: void * */ public void createXYPos(BufferedImage oriImage){ int height = oriImage.getHeight(); int width = oriImage.getWidth(); XPOS = new Random().nextInt(width-CUT_WIDTH-RADIUS); YPOS = new Random().nextInt(height-CUT_HEIGHT-RADIUS); //确保横坐标位于2/4--3/4 int div = (IMAGE_MAX_WIDTH/4); if(XPOS/div == 0 ){ XPOS = XPOS + div*2; } else if(XPOS/div == 1 ){ XPOS = XPOS + div; } else if(XPOS/div == 3 ){ XPOS = XPOS - div; } }
标记抠图区域
这里使用一个二维数组locations[width][height]来保存抠图标记数据,每一个数据表示位置和是否为抠图区,使用常量FLAG进行标记
这里的抠图区为一个巨型加上突出的半圆和凹陷的半圆.
对于半圆的处理参考该公式: (x-a)2+(y-b)2=R2,
其中(a,b)为圆中心的坐标,R为圆半径.(x,y)为任一坐标
(x,y)在圆上: (x-a)2+(y-b)2 == R2
(x,y)在圆内: (x-a)2+(y-b)2 < R2
(x,y) 在圆外: (x-a)2+(y-b)2 > R2
public int[][] getBlockData(BufferedImage oriImage){ int height = oriImage.getHeight(); int width = oriImage.getWidth(); int[][] blockData =new int[width][height]; Location locations[] = {Location.UP,Location.LEFT,Location.DOWN,Location.RIGHT}; //矩形 for(int x = 0; x< width; x++){ for(int y = 0; y < height; y++){ blockData[x][y] = 0; if ( (x > XPOS) && (x < (XPOS+CUT_WIDTH)) && (y > YPOS) && (y < (YPOS+CUT_HEIGHT))){ blockData[x][y] = FLAG; } } } //圆形突出区域 //突出圆形的原点坐标(x,y) int xBulgeCenter=0,yBulgeCenter=0; // int xConcaveCenter=0,yConcaveCenter=0; //位于矩形的哪一边,0123--上下左右 location = locations[new Random().nextInt(3)]; if(location == Location.UP){ //上 凸起 xBulgeCenter = XPOS + CUT_WIDTH/2; yBulgeCenter = YPOS; //左 凹陷 xConcaveCenter = XPOS ; yConcaveCenter = YPOS + CUT_HEIGHT/2; } else if(location == Location.DOWN){ //下 凸起 xBulgeCenter = XPOS + CUT_WIDTH/2; yBulgeCenter = YPOS + CUT_HEIGHT; //右 凹陷 xConcaveCenter = XPOS + CUT_WIDTH; yConcaveCenter = YPOS + CUT_HEIGHT/2; } else if(location == Location.LEFT){ //左 凸起 xBulgeCenter = XPOS ; yBulgeCenter = YPOS + CUT_HEIGHT/2; //下 凹陷 xConcaveCenter = XPOS + CUT_WIDTH/2; yConcaveCenter = YPOS + CUT_HEIGHT; } else { //Location.RIGHT //右 凸起 xBulgeCenter = XPOS + CUT_WIDTH; yBulgeCenter = YPOS + CUT_HEIGHT/2; //上 凹陷 xConcaveCenter = XPOS + CUT_WIDTH/2; yConcaveCenter = YPOS; } //for test log.info("突出圆形位置:"+location); log.info("XPOS={} YPOS={}",XPOS,YPOS); log.info("xBulgeCenter={} yBulgeCenter={}",xBulgeCenter,yBulgeCenter); log.info("xConcaveCenter={} yConcaveCenter={}",xConcaveCenter,yConcaveCenter); //半径的平方 int RADIUS_POW2 = RADIUS * RADIUS; //凸起部分 for(int x = xBulgeCenter-RADIUS; x< xBulgeCenter+RADIUS; x++){ for(int y = yBulgeCenter-RADIUS; y < yBulgeCenter+RADIUS; y++){ //(x-a)2+(y-b)2 = r2 if(Math.pow((x-xBulgeCenter),2) + Math.pow((y-yBulgeCenter),2) <= RADIUS_POW2){ blockData[x][y] = FLAG; } } } //凹陷部分 for(int x = xConcaveCenter-RADIUS; x< xConcaveCenter+RADIUS; x++){ for(int y = yConcaveCenter-RADIUS; y < yConcaveCenter+RADIUS; y++){ //(x-a)2+(y-b)2 = r2 if(Math.pow((x-xConcaveCenter),2) + Math.pow((y-yConcaveCenter),2) < RADIUS_POW2){ blockData[x][y] = 0; } } } return blockData; }
获取抠完图的原图和被抠出来的图
通过遍历抠图数据blockData来进行抠图.原图被标记的位置使用FLAG进行填充,而抠出来的部分重新构成一张同样大小的图
这里的操作是:
1.创建一个与抠图区域大小(w*h)的图,并将背景设为透明
2.遍历抠图区域,原图被抠的地方填充其他颜色
3.抠出来的像素点复制到上面创建的透明图
public Map<String,BufferedImage> cutByTemplate(BufferedImage oriImage, int[][] blockData){ Map<String,BufferedImage> imgMap = new HashMap<>(); //创建一个与抠图区域大小的图 BufferedImage cutImage = new BufferedImage(imageMessage.cutImageWidth,imageMessage.cutImageHeight,oriImage.getType()); // 获取Graphics2D Graphics2D g2d = cutImage.createGraphics(); //透明化整张图 cutImage = g2d.getDeviceConfiguration() .createCompatibleImage(imageMessage.cutImageWidth,imageMessage.cutImageHeight, Transparency.BITMASK); g2d.dispose(); g2d = cutImage.createGraphics(); // 背景透明代码结束 log.info("imageMessage = {}",imageMessage); int xmax = imageMessage.xpos + imageMessage.cutImageWidth; int ymax = imageMessage.ypos + imageMessage.cutImageHeight; //只对抠图区域进行遍历 for(int x = imageMessage.xpos; x< xmax; x++){ for(int y = imageMessage.ypos; y < ymax; y++){ int oriRgb = oriImage.getRGB(x,y); if(blockData[x][y] == FLAG){
//原图 oriImage.setRGB(x,y,FLAG); //抠的图 g2d.setColor(color(oriRgb)); g2d.setStroke(new BasicStroke(1f)); g2d.fillRect(x-imageMessage.xpos, y-imageMessage.ypos, 1, 1); } } } // 释放对象 g2d.dispose(); imgMap.put(ORI_IMAGE_KEY,oriImage); imgMap.put(CUT_IMAGE_KEY,cutImage); return imgMap; }
图片原始数据转换成base64格式数据
由于图片原始数据很多是不可打印字符,因此需要将其转换成base64格式,再进行发送
private String ImageBase64(BufferedImage bufferedImage) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); ImageIO.write(bufferedImage, "png", out); //转成byte数组 byte[] bytes = out.toByteArray(); BASE64Encoder encoder = new BASE64Encoder(); //生成BASE64编码 return encoder.encode(bytes); }
控制器
在进行校验时,需要允许一定的误差.
package slide.picture.verification.demo.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import slide.picture.verification.demo.image.ImageResult; import slide.picture.verification.demo.image.ImgUtil; import slide.picture.verification.demo.ret.RetCode; import slide.picture.verification.demo.ret.WebReturn; import slide.picture.verification.demo.time.TimeUtil; import javax.jws.WebResult; import java.util.concurrent.TimeUnit; @Slf4j @RestController @RequestMapping("/slider") public class SliderController { private int xPosCache = 0; @RequestMapping("/image") public WebReturn image(){ log.info("/slider/image"); ImageResult imageResult = null; try{ TimeUtil.start(1); imageResult = new ImgUtil().imageResult(); TimeUtil.end(1); xPosCache = imageResult.getXpos(); return new WebReturn(RetCode.IMAGE_REQ_SUCCESS,imageResult); } catch(Exception ex){ log.error(ex.getMessage()); ex.printStackTrace(); return new WebReturn(RetCode.IMAGE_REQ_FAIL,null); } } @RequestMapping("/verification") public WebReturn verification(@RequestParam("moveX") int moveX){ log.info("/slider/verification/{}",moveX); int MOVE_CHECK_ERROR = 2; if(( moveX < ( xPosCache + MOVE_CHECK_ERROR)) && ( moveX > (xPosCache - MOVE_CHECK_ERROR))){ log.info("验证正确"); return new WebReturn(RetCode.VERIFI_REQ_SUCCESS,true); } return new WebReturn(RetCode.VERIFI_REQ_FAIL,false); } }
后台的关键代码就这些,整个处理流程大概耗时40ms(图片大小320*260).上面的getBlockdata()还可以继续优化,并不需要全局遍历.只要抠图区域遍历就可以了.
由于对BufferedImage对象的操作是操作其内存中的数据,因此在大并发的情况下需要考虑内存占用状况.
前端实现
这里需要注意的地方是抠图和原图的左边緣需要对齐,以及纵坐标位置.
鼠标按下滑块时才会开始计算偏移的距离,滑块滑动的距离会反映到抠图的偏移量.当松开鼠标时会将偏移量发送到后端进行校验.
还有,后端发送过来的数据是base64数据.由于图片原始数据很多是不可打印字符,因此需要将其转换成
图片显示使用base64时的格式.这里的xxxx是图片的base64数据.需要注意base64后面的逗号.
<img src="data:image/png;base64,xxxxxxxx">
html代码
<div id="captchaContainer"> <!-- 标题栏 --> <div class="header"> <span class="headerText">图片滑动验证</span> <span class="refreshIcon"/> </div> <!-- 图片显示区域 --> <div id="captchaImg"> <img id="oriImg" alt="原图"/> <img id="cutImg" alt="抠图"/> </div> <!--滑块显示区域--> <div class="sliderContainer"> <div class="sliderMask"> <div class="slider"> <span class="sliderIcon"></span> </div> </div> <span class="sliderText">向右滑动填充拼图</span> </div>
</div>
JS代码
<script> //图片显示使用base64时的前缀,src=base64PrefixPath + imgBase64Value var base64PrefixPath="data:image/png;base64,"; var IMAGE_WIDTH = 300; //初始化 //滑块初始偏移量 var sliderInitOffset = 0; //滑块移动的最值 var MIN_MOVE = 0; var MAX_MOVE = 0; //鼠标按下标志 var mousedownFlag=false; //滑块移动的距离 var moveX; //滑块位置检测允许的误差,正负2 var MOVE_CHECK_ERROR = 2; //滑块滑动使能 var moveEnable = true; var ImageMsg = { //抠图的坐标 xpos: 0, ypos: 0, //抠图的大小 cutImageWidth: 0, cutImageHeight: 0, //原图的base64 oriImageSrc: 0, //抠图的base64 cutImageSrc: 0, } //加载页面时进行初始化 function init(){ console.log("init") moveEnable = true; mousedownFlag=false; $(".slider").css("left",0+"px"); initClass(); MAX_MOVE = IMAGE_WIDTH - ImageMsg.cutImageWidth; console.log("ImageMsg = " + ImageMsg) $("#cutImg").css("left",0+"px"); $("#oriImg").attr("src",ImageMsg.oriImageSrc) $("#cutImg").attr("src",ImageMsg.cutImageSrc) $("#cutImg").css("width",ImageMsg.cutImageWidth) $("#cutImg").css("height",ImageMsg.cutImageHeight) $("#cutImg").css("top",ImageMsg.ypos) } //加载页面时 $(function(){ httpRequest.requestImage.request(); }) var httpRequest={ //请求获取图片 requestImage:{ path: "slider/image", request:function(){ $.get(httpRequest.requestImage.path,function(data,status){ console.log(data) console.log(data.message); if(data.data != null){ ImageMsg.oriImageSrc = base64PrefixPath + data.data.oriImage; ImageMsg.cutImageSrc = base64PrefixPath + data.data.cutImage; ImageMsg.xpos = data.data.xpos; ImageMsg.ypos = data.data.ypos; ImageMsg.cutImageWidth = data.data.cutImageWidth; ImageMsg.cutImageHeight = data.data.cutImageHeight; init(); } }); }, }, //请求验证 requestVerification:{ path: "slider/verification", request:function(){ $.get(httpRequest.requestVerification.path,{moveX:(moveX)},function(data,status){ console.log(data) console.log(data.code); console.log(data.message); if(data.data == true){ checkSuccessHandle(); } else{ checkFailHandle(); } }); }, }, } //刷新图片操作 $(".refreshIcon").on("click",function(){ httpRequest.requestImage.request(); }) //滑块鼠标按下 $(".slider").mousedown(function(event){ console.log("鼠标按下mousedown:"+event.clientX + " " + event.clientY); sliderInitOffset = event.clientX; mousedownFlag = true; //滑块绑定鼠标滑动事件 $(".slider").on("mousemove",function(event){ if(mousedownFlag == false){ return; } if(moveEnable == false){ return } moveX = event.clientX - sliderInitOffset; moveX<MIN_MOVE?moveX=MIN_MOVE:moveX=moveX; moveX>MAX_MOVE?moveX=MAX_MOVE:moveX=moveX; $(this).css("left",moveX+"px"); $("#cutImg").css("left",moveX+"px"); }) }) //滑块鼠标弹起操作 $(".slider").mouseup(function(event){ console.log("mouseup:"+event.clientX + " " + event.clientY); sliderInitOffset = 0; $(this).off("mousemove"); mousedownFlag=false; console.log("moveX = " + moveX) checkLocation(); }) //检测滑块 位置是否正确 function checkLocation(){ moveEnable = false; //后端请求检测滑块位置 httpRequest.requestVerification.request(); } function checkSuccessHandle(){ $(".sliderContainer").addClass("sliderContainer_success"); $(".slider").addClass("slider_success"); } function checkFailHandle(){ $(".sliderContainer").addClass("sliderContainer_fail"); $(".slider").addClass("slider_success"); } function initClass(){ $(".sliderContainer").removeClass("sliderContainer_success"); $(".slider").removeClass("slider_success"); $(".sliderContainer").removeClass("sliderContainer_fail"); $(".slider").removeClass("slider_fail"); } </script>
推荐:《Java常用技术和书籍推荐》
如果,您认为阅读这篇博客让您有些收获,不妨点击一下右下角的推荐按钮。
如果,您希望更容易地发现我的新博客,不妨关注一下。因为,我的写作热情也离不开您的肯定支持。
感谢您的阅读,如果您对我的博客所讲述的内容有兴趣,请继续关注我的后续博客。
本文版权归博客园-冬眠的山谷(https://www.cnblogs.com/lgjlife/)所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出。