├── LICENSE ├── README.md └── docs ├── css └── style.css ├── index.html └── js ├── item-extract.js └── opencv.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 GaHing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 写于 2019 年,注意时效性 2 | 3 | ## 介绍 4 | 5 | 基于 opencv.js 实现矩形抽离的纯前端项目 6 | 7 | ![q0.png](https://upload-images.jianshu.io/upload_images/9277731-a65795c8ec1f9735.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 8 | 9 | ## 在线地址 10 | 11 | [rectangle-extract-opencvjs](https://francecil.github.io/rectangle-extract-opencvjs/) 12 | 13 | ## 实现思路 14 | 15 | 详见 [基于 opencv.js 实现图像内矩形物品的自动提取](https://juejin.im/post/5e268bd86fb9a0300d61a0c7) 16 | -------------------------------------------------------------------------------- /docs/css/style.css: -------------------------------------------------------------------------------- 1 | /* display loading gif and hide webpage */ 2 | .modal { 3 | display: none; 4 | position: fixed; 5 | z-index: 1000; 6 | top: 0; 7 | left: 0; 8 | height: 100%; 9 | width: 100%; 10 | background: rgba( 255, 255, 255, .8) 11 | url('http://i.stack.imgur.com/FhHRx.gif') 12 | 50% 50% 13 | no-repeat; 14 | } 15 | 16 | /* prevent scrollbar from display during load */ 17 | body.loading { 18 | overflow: hidden; 19 | } 20 | 21 | /* display the modal when loading class is added to body */ 22 | body.loading .modal { 23 | display: block; 24 | } -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 矩形提取 8 | 9 | 10 | 11 | 16 | 17 | 18 | 19 |
20 |
21 |

OpenCV - 矩形提取

22 |
23 | 24 |
25 |
26 |
27 |
28 | 原图 29 |
30 |
31 | 32 |
33 | 36 | 39 |
40 |
41 | 42 |
43 |
44 |
45 | 结果 46 |
47 |
48 | 49 |
50 | 53 |
54 |
55 |
56 |
57 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /docs/js/item-extract.js: -------------------------------------------------------------------------------- 1 | const g_nLowDifference = 35 2 | const g_nUpDifference = 35; //负差最大值、正差最大值 3 | const UNCAL_THETA = 0.5; 4 | class Line { 5 | constructor(rho, theta) { 6 | this.rho = rho 7 | this.theta = theta 8 | let a = Math.cos(theta); 9 | let b = Math.sin(theta); 10 | let x0 = a * rho; 11 | let y0 = b * rho; 12 | this.startPoint = { x: x0 - 400 * b, y: y0 + 400 * a }; 13 | this.endPoint = { x: x0 + 400 * b, y: y0 - 400 * a }; 14 | } 15 | } 16 | /** 17 | * @param {Object} srcMat 18 | */ 19 | function itemExtract (srcMat, name) { 20 | let scale = getScale(Math.max(srcMat.rows, srcMat.cols)) 21 | let preMat = preProcess(srcMat, scale) 22 | let grayMat = getSegmentImage(preMat) 23 | let lines = getLinesWithDetect(grayMat) 24 | let points = getFourVertex(lines, scale, { height: srcMat.rows, width: srcMat.cols }) 25 | let result = getResultWithMap(srcMat, points) 26 | cv.imshow(name, result); 27 | preMat.delete() 28 | grayMat.delete() 29 | srcMat.delete() 30 | result.delete() 31 | } 32 | /** 33 | * 获取缩放比例 34 | * @param {*} len 35 | */ 36 | function getScale (len) { 37 | let scale = 1 38 | while (len > 200) { 39 | scale /= 2 40 | len >>= 1 41 | } 42 | return scale 43 | } 44 | /** 45 | * 预处理 46 | * @param {*} src 47 | */ 48 | function preProcess (src, scale) { 49 | let smallMat = resize(src, scale) 50 | let result = filter(smallMat) 51 | smallMat.delete() 52 | return result 53 | } 54 | /** 55 | * 调整至指定宽高 56 | * @param {*} src 57 | * @param {*} scale 缩放比例 58 | */ 59 | function resize (src, scale = 1) { 60 | let smallMat = new cv.Mat(); 61 | let dsize = new cv.Size(0, 0); 62 | cv.resize(src, smallMat, dsize, scale, scale, cv.INTER_AREA) 63 | return smallMat 64 | } 65 | /** 66 | * 滤波:保边去噪 67 | * @param {*} mat 68 | */ 69 | function filter (src) { 70 | let dst = new cv.Mat(); 71 | cv.cvtColor(src, src, cv.COLOR_RGBA2RGB, 0); 72 | // 双边滤波 73 | cv.bilateralFilter(src, dst, 9, 75, 75, cv.BORDER_DEFAULT); 74 | return dst 75 | } 76 | /** 77 | * 通过分割图像获取前景灰度图 78 | * @param {*} src 79 | */ 80 | function getSegmentImage (src) { 81 | const mask = new cv.Mat(src.rows + 2, src.cols + 2, cv.CV_8U, [0, 0, 0, 0]) 82 | const seed = new cv.Point(src.cols >> 1, src.rows >> 1) 83 | let flags = 4 + (255 << 8) + cv.FLOODFILL_FIXED_RANGE 84 | let ccomp = new cv.Rect() 85 | let newVal = new cv.Scalar(255, 255, 255) 86 | // 选取中点,采用floodFill漫水填充 87 | cv.threshold(mask, mask, 1, 128, cv.THRESH_BINARY); 88 | cv.floodFill(src, mask, seed, newVal, ccomp, new cv.Scalar(g_nLowDifference, g_nLowDifference, g_nLowDifference), new cv.Scalar(g_nUpDifference, g_nUpDifference, g_nUpDifference), flags); 89 | // 再次执行一次滤波去除噪点 90 | cv.medianBlur(mask, mask, 9); 91 | return mask 92 | } 93 | 94 | 95 | function getLinesFromData32F (data32F) { 96 | let lines = [] 97 | let len = data32F.length / 2 98 | for (let i = 0; i < len; ++i) { 99 | let rho = data32F[i * 2]; 100 | let theta = data32F[i * 2 + 1]; 101 | lines.push(new Line(rho, theta)) 102 | } 103 | return lines 104 | } 105 | /** 106 | * 直线检测 107 | * @param {*} mat 108 | */ 109 | function getLinesWithDetect (src) { 110 | let dst = cv.Mat.zeros(src.rows, src.cols, cv.CV_8UC3); 111 | let lines = new cv.Mat(); 112 | // Canny 算子进行边缘检测 113 | cv.Canny(src, src, 50, 200, 3); 114 | cv.HoughLines(src, lines, 1, Math.PI / 180, 115 | 30, 0, 0, 0, Math.PI); 116 | // draw lines 117 | for (let i = 0; i < lines.rows; ++i) { 118 | let rho = lines.data32F[i * 2]; 119 | let theta = lines.data32F[i * 2 + 1]; 120 | let a = Math.cos(theta); 121 | let b = Math.sin(theta); 122 | let x0 = a * rho; 123 | let y0 = b * rho; 124 | let startPoint = { x: x0 - 400 * b, y: y0 + 400 * a }; 125 | let endPoint = { x: x0 + 400 * b, y: y0 - 400 * a }; 126 | cv.line(dst, startPoint, endPoint, [255, 0, 0, 255]); 127 | } 128 | let lineArray = getLinesFromData32F(lines.data32F) 129 | // drawLineMat(src.rows, src.cols, lineArray) 130 | return lineArray 131 | } 132 | /** 133 | * 计算两直线间的交点 134 | * @param {*} l1 135 | * @param {*} l2 136 | */ 137 | function getIntersection (l1, l2) { 138 | //角度差太小 不算, 139 | let minTheta = Math.min(l1.theta, l2.theta) 140 | let maxTheta = Math.max(l1.theta, l2.theta) 141 | if (Math.abs(l1.theta - l2.theta) < UNCAL_THETA || Math.abs(minTheta + Math.PI - maxTheta) < UNCAL_THETA) { 142 | return; 143 | } 144 | //计算两条直线的交点 145 | let intersection; 146 | //y = a * x + b; 147 | let a1 = Math.abs(l1.startPoint.x - l1.endPoint.x) < Number.EPSILON ? 0 : (l1.startPoint.y - l1.endPoint.y) / (l1.startPoint.x - l1.endPoint.x); 148 | let b1 = l1.startPoint.y - a1 * (l1.startPoint.x); 149 | let a2 = Math.abs((l2.startPoint.x - l2.endPoint.x)) < Number.EPSILON ? 0 : (l2.startPoint.y - l2.endPoint.y) / (l2.startPoint.x - l2.endPoint.x); 150 | let b2 = l2.startPoint.y - a2 * (l2.startPoint.x); 151 | if (Math.abs(a2 - a1) > Number.EPSILON) { 152 | let x = (b1 - b2) / (a2 - a1) 153 | let y = a1 * x + b1 154 | intersection = { x, y } 155 | } 156 | return intersection 157 | } 158 | /** 159 | * 计算所有交点 160 | * @param {*} lines 161 | */ 162 | function getAllIntersections (lines) { 163 | let points = [] 164 | for (let i = 0; i < lines.length; i++) { 165 | for (let j = i + 1; j < lines.length; j++) { 166 | let point = getIntersection(lines[i], lines[j]) 167 | if (point) { 168 | points.push(point) 169 | } 170 | } 171 | } 172 | return points 173 | } 174 | /** 175 | * 聚类取均值 176 | * @param {*} points 177 | * @param {*} param1 178 | */ 179 | function getClusterPoints (points, { width, height }) { 180 | const DISTANCE = Math.max(40, (width + height) / 20) 181 | const isNear = (p1, p2) => Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y) < DISTANCE 182 | // 多边形中心点坐标 183 | const center = { 184 | x: points.reduce((sum, p) => sum + p.x, 0) / points.length, 185 | y: points.reduce((sum, p) => sum + p.y, 0) / points.length 186 | } 187 | points.sort((p1,p2)=>{ 188 | // y=kx theta = atan(k) 189 | // TODO cache calc 190 | const theta1 = Math.atan((p1.y-center.y)/((p1.x-center.x) || 0.01)) 191 | const theta2 = Math.atan((p2.y-center.y)/((p2.x-center.x) || 0.01)) 192 | return theta1 - theta2 193 | }) 194 | 195 | 196 | let clusters = [[points[0]]] 197 | for (let i = 1; i < points.length; i++) { 198 | if (isNear(points[i], points[i - 1])) { 199 | clusters[clusters.length - 1].push(points[i]) 200 | } else { 201 | clusters.push([points[i]]) 202 | } 203 | } 204 | // 除去量最少的,仅保留四个聚类 205 | clusters = clusters.sort((c1, c2) => c2.length - c1.length).slice(0, 4) 206 | const result = clusters.map(cluster => { 207 | const x = ~~(cluster.reduce((sum, cur) => sum + cur.x, 0) / cluster.length) 208 | const y = ~~(cluster.reduce((sum, cur) => sum + cur.y, 0) / cluster.length) 209 | return { x, y } 210 | }) 211 | return result 212 | } 213 | /** 214 | * 顺时针排序,以中心点左上角为第一个点 215 | * @param {*} points 216 | */ 217 | function getSortedVertex (points) { 218 | let center = { 219 | x: points.reduce((sum, p) => sum + p.x, 0) / 4, 220 | y: points.reduce((sum, p) => sum + p.y, 0) / 4 221 | } 222 | let sortedPoints = [] 223 | sortedPoints.push(points.find(p => p.x < center.x && p.y < center.y)) 224 | sortedPoints.push(points.find(p => p.x > center.x && p.y < center.y)) 225 | sortedPoints.push(points.find(p => p.x > center.x && p.y > center.y)) 226 | sortedPoints.push(points.find(p => p.x < center.x && p.y > center.y)) 227 | return sortedPoints 228 | } 229 | 230 | /** 231 | * 根据聚类获得四个顶点的坐标 232 | */ 233 | function getFourVertex (lines, scale, { width, height }) { 234 | // 缩放 + 过滤 235 | let allPoints = getAllIntersections(lines).map(point => ({ 236 | x: ~~(point.x / scale), y: ~~(point.y / scale) 237 | })).filter(({ x, y }) => !(x < 0 || x > width || y < 0 || y > height)) 238 | const points = getClusterPoints(allPoints, { width, height }) 239 | const sortedPoints = getSortedVertex(points) 240 | return sortedPoints 241 | } 242 | /** 243 | * 抠图,映射 244 | * @param {*} src 245 | * @param {*} points 246 | */ 247 | function getResultWithMap (src, points) { 248 | let array = [] 249 | points.forEach(point => { 250 | array.push(point.x) 251 | array.push(point.y) 252 | }) 253 | // console.log(points, array) 254 | let dst = new cv.Mat(); 255 | let dsize = new cv.Size(0, 0); 256 | let dstWidth = src.cols 257 | let dstHeight = src.rows 258 | let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, array); 259 | let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, dstWidth, 0, dstWidth, dstHeight, 0, dstHeight]); 260 | let M = cv.getPerspectiveTransform(srcTri, dstTri); 261 | cv.warpPerspective(src, dst, M, dsize); 262 | let resizeDst = resize(dst, 0.5) 263 | M.delete(); srcTri.delete(); dstTri.delete(); dst.delete() 264 | return resizeDst 265 | } 266 | function drawLineMat (rows, cols, lines) { 267 | let dst = cv.Mat.zeros(rows, cols, cv.CV_8UC3); 268 | let color = new cv.Scalar(255, 0, 0); 269 | for (let line of lines) { 270 | cv.line(dst, line.startPoint, line.endPoint, color); 271 | } 272 | cv.imshow("canvasOutput", dst); 273 | } --------------------------------------------------------------------------------