MarkerDetector.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. <?php
  2. namespace app\common\library\ps;
  3. use think\Exception;
  4. /**
  5. * 对比「标准原稿」和「色块标注图」,用色块外接矩形作为填写格。
  6. * 百分比坐标相对整张图,打印时再按纸张毫米还原。
  7. */
  8. class MarkerDetector
  9. {
  10. /**
  11. * @param string $cleanPath 标准展示图
  12. * @param string $markPath 涂了色块的图
  13. * @return array {width,height,cells:[{x,y,w,h,color,area}]}
  14. * @throws Exception
  15. */
  16. public static function detect($cleanPath, $markPath)
  17. {
  18. if (!function_exists('imagecreatefromstring')) {
  19. throw new Exception('当前 PHP 未开启 GD,无法识别色块');
  20. }
  21. @ini_set('memory_limit', '512M');
  22. $clean = self::loadImage($cleanPath);
  23. $mark = self::loadImage($markPath);
  24. $cw = imagesx($clean);
  25. $ch = imagesy($clean);
  26. $mw = imagesx($mark);
  27. $mh = imagesy($mark);
  28. if (abs($cw - $mw) > 2 || abs($ch - $mh) > 2) {
  29. if (abs(($ch / max($cw, 1)) - ($mh / max($mw, 1))) > 0.02) {
  30. imagedestroy($clean);
  31. imagedestroy($mark);
  32. throw new Exception('两张图的像素尺寸必须一致(请用同一张扫描件复制后涂色块)。标准图 '
  33. . $cw . '×' . $ch . ',色块图 ' . $mw . '×' . $mh);
  34. }
  35. $resized = imagecreatetruecolor($cw, $ch);
  36. imagecopyresampled($resized, $mark, 0, 0, 0, 0, $cw, $ch, $mw, $mh);
  37. imagedestroy($mark);
  38. $mark = $resized;
  39. }
  40. $maxEdge = 900;
  41. $scale = min(1, $maxEdge / max($cw, $ch));
  42. $ww = max(1, (int)round($cw * $scale));
  43. $wh = max(1, (int)round($ch * $scale));
  44. $cWork = $clean;
  45. $mWork = $mark;
  46. if ($scale < 0.999) {
  47. $cWork = imagecreatetruecolor($ww, $wh);
  48. $mWork = imagecreatetruecolor($ww, $wh);
  49. imagecopyresampled($cWork, $clean, 0, 0, 0, 0, $ww, $wh, $cw, $ch);
  50. imagecopyresampled($mWork, $mark, 0, 0, 0, 0, $ww, $wh, $cw, $ch);
  51. }
  52. $mask = self::diffMask($cWork, $mWork);
  53. if ($cWork !== $clean) {
  54. imagedestroy($cWork);
  55. }
  56. if ($mWork !== $mark) {
  57. imagedestroy($mWork);
  58. }
  59. imagedestroy($clean);
  60. imagedestroy($mark);
  61. $minArea = max(30, (int)($ww * $wh * 0.00035));
  62. $blobs = self::connectedBoxes($mask, $ww, $wh, $minArea, 0, 0, $ww, $wh);
  63. $split = [];
  64. foreach ($blobs as $blob) {
  65. foreach (self::splitByValleys($blob, $mask, $ww, $wh, $minArea, 0) as $part) {
  66. $split[] = $part;
  67. }
  68. }
  69. $cells = [];
  70. $i = 0;
  71. foreach ($split as $blob) {
  72. $i++;
  73. $cells[] = [
  74. 'id' => 'c' . $i,
  75. 'x' => round($blob['x'] / $ww * 100, 2),
  76. 'y' => round($blob['y'] / $wh * 100, 2),
  77. 'w' => round($blob['w'] / $ww * 100, 2),
  78. 'h' => round($blob['h'] / $wh * 100, 2),
  79. 'color' => $blob['color'],
  80. 'area' => $blob['area'],
  81. ];
  82. }
  83. usort($cells, function ($a, $b) {
  84. if (abs($a['y'] - $b['y']) > 2) {
  85. return $a['y'] < $b['y'] ? -1 : 1;
  86. }
  87. return $a['x'] < $b['x'] ? -1 : 1;
  88. });
  89. foreach ($cells as $idx => &$cell) {
  90. $cell['id'] = 'c' . ($idx + 1);
  91. }
  92. unset($cell);
  93. return [
  94. 'width' => $cw,
  95. 'height' => $ch,
  96. 'cells' => $cells,
  97. ];
  98. }
  99. /**
  100. * @param string $path
  101. * @return resource
  102. * @throws Exception
  103. */
  104. protected static function loadImage($path)
  105. {
  106. if (!is_file($path)) {
  107. throw new Exception('图片不存在:' . $path);
  108. }
  109. $bin = @file_get_contents($path);
  110. if ($bin === false || $bin === '') {
  111. throw new Exception('图片无法读取');
  112. }
  113. $im = @imagecreatefromstring($bin);
  114. if (!$im) {
  115. throw new Exception('无法解析图片,请使用 JPG / PNG');
  116. }
  117. if (!imageistruecolor($im)) {
  118. $true = imagecreatetruecolor(imagesx($im), imagesy($im));
  119. imagefill($true, 0, 0, imagecolorallocate($true, 255, 255, 255));
  120. imagecopy($true, $im, 0, 0, 0, 0, imagesx($im), imagesy($im));
  121. imagedestroy($im);
  122. $im = $true;
  123. }
  124. return $im;
  125. }
  126. /**
  127. * @param resource $clean
  128. * @param resource $mark
  129. * @return string 0/1 packed rows
  130. */
  131. protected static function diffMask($clean, $mark)
  132. {
  133. $w = imagesx($clean);
  134. $h = imagesy($clean);
  135. $mask = str_repeat("\0", $w * $h);
  136. for ($y = 0; $y < $h; $y++) {
  137. $row = $y * $w;
  138. for ($x = 0; $x < $w; $x++) {
  139. $c = imagecolorat($clean, $x, $y);
  140. $m = imagecolorat($mark, $x, $y);
  141. $dr = (($c >> 16) & 0xFF) - (($m >> 16) & 0xFF);
  142. $dg = (($c >> 8) & 0xFF) - (($m >> 8) & 0xFF);
  143. $db = ($c & 0xFF) - ($m & 0xFF);
  144. if (($dr * $dr + $dg * $dg + $db * $db) < 1600) {
  145. continue;
  146. }
  147. $mr = ($m >> 16) & 0xFF;
  148. $mg = ($m >> 8) & 0xFF;
  149. $mb = $m & 0xFF;
  150. $cr = ($c >> 16) & 0xFF;
  151. $cg = ($c >> 8) & 0xFF;
  152. $cb = $c & 0xFF;
  153. $markSat = max($mr, $mg, $mb) - min($mr, $mg, $mb);
  154. $cleanSat = max($cr, $cg, $cb) - min($cr, $cg, $cb);
  155. // 只要涂上去的鲜艳色,忽略原稿浅底色/JPEG 色差,否则页眉会把二维码和备注连成一块。
  156. if ($markSat < 80 || $markSat < $cleanSat + 35) {
  157. continue;
  158. }
  159. $mask[$row + $x] = "\1";
  160. }
  161. }
  162. return $mask;
  163. }
  164. /**
  165. * @param string $mask
  166. * @param int $w
  167. * @param int $h
  168. * @param int $minArea
  169. * @param int $x0
  170. * @param int $y0
  171. * @param int $x1
  172. * @param int $y1
  173. * @return array
  174. */
  175. protected static function connectedBoxes($mask, $w, $h, $minArea, $x0, $y0, $x1, $y1)
  176. {
  177. $seen = str_repeat("\0", $w * $h);
  178. $boxes = [];
  179. $maxBlobs = 80;
  180. $x0 = max(0, $x0);
  181. $y0 = max(0, $y0);
  182. $x1 = min($w, $x1);
  183. $y1 = min($h, $y1);
  184. for ($y = $y0; $y < $y1; $y++) {
  185. $row = $y * $w;
  186. for ($x = $x0; $x < $x1; $x++) {
  187. $i = $row + $x;
  188. if ($mask[$i] === "\0" || $seen[$i] === "\1") {
  189. continue;
  190. }
  191. $box = self::flood($mask, $seen, $w, $h, $x, $y, $x0, $y0, $x1, $y1);
  192. if ($box && $box['area'] >= $minArea) {
  193. $boxes[] = $box;
  194. if (count($boxes) >= $maxBlobs) {
  195. return $boxes;
  196. }
  197. }
  198. }
  199. }
  200. return $boxes;
  201. }
  202. /**
  203. * 色块贴在一起(二维码右下角碰到备注条)时,沿投影空隙切开。
  204. * @param array $box
  205. * @param string $mask
  206. * @param int $w
  207. * @param int $h
  208. * @param int $minArea
  209. * @param int $depth
  210. * @return array
  211. */
  212. protected static function splitByValleys(array $box, $mask, $w, $h, $minArea, $depth)
  213. {
  214. if ($depth >= 5) {
  215. return [$box];
  216. }
  217. $mask = self::clipBlobMask($mask, $w, $h, $box);
  218. $bw = (int)$box['w'];
  219. $bh = (int)$box['h'];
  220. $fill = $box['area'] / max(1, $bw * $bh);
  221. if ($fill >= 0.78 && $bw < $w * 0.45 && $bh < $h * 0.18) {
  222. return [$box];
  223. }
  224. if ($bw * $bh < $minArea * 4) {
  225. return [$box];
  226. }
  227. $rowHist = [];
  228. $colHist = [];
  229. $x0 = (int)$box['x'];
  230. $y0 = (int)$box['y'];
  231. for ($y = 0; $y < $bh; $y++) {
  232. $cnt = 0;
  233. $row = ($y0 + $y) * $w + $x0;
  234. for ($x = 0; $x < $bw; $x++) {
  235. if ($mask[$row + $x] === "\1") {
  236. $cnt++;
  237. }
  238. }
  239. $rowHist[$y] = $cnt;
  240. }
  241. for ($x = 0; $x < $bw; $x++) {
  242. $cnt = 0;
  243. for ($y = 0; $y < $bh; $y++) {
  244. if ($mask[($y0 + $y) * $w + $x0 + $x] === "\1") {
  245. $cnt++;
  246. }
  247. }
  248. $colHist[$x] = $cnt;
  249. }
  250. $rowGap = self::bestValley($rowHist, $minArea);
  251. $colGap = self::bestValley($colHist, $minArea);
  252. $axis = null;
  253. if ($rowGap && (!$colGap || $rowGap['score'] >= $colGap['score'])) {
  254. $axis = ['row', $rowGap['a'], $rowGap['b']];
  255. } elseif ($colGap) {
  256. $axis = ['col', $colGap['a'], $colGap['b']];
  257. }
  258. if (!$axis) {
  259. return [$box];
  260. }
  261. if ($axis[0] === 'row') {
  262. $rects = [
  263. [$x0, $y0, $bw, $axis[1]],
  264. [$x0, $y0 + $axis[2], $bw, $bh - $axis[2]],
  265. ];
  266. } else {
  267. $rects = [
  268. [$x0, $y0, $axis[1], $bh],
  269. [$x0 + $axis[2], $y0, $bw - $axis[2], $bh],
  270. ];
  271. }
  272. $parts = [];
  273. foreach ($rects as $rect) {
  274. if ($rect[2] < 4 || $rect[3] < 3) {
  275. continue;
  276. }
  277. $found = self::connectedBoxes($mask, $w, $h, $minArea, $rect[0], $rect[1], $rect[0] + $rect[2], $rect[1] + $rect[3]);
  278. foreach ($found as $child) {
  279. foreach (self::splitByValleys($child, $mask, $w, $h, $minArea, $depth + 1) as $part) {
  280. $parts[] = $part;
  281. }
  282. }
  283. }
  284. return $parts ?: [$box];
  285. }
  286. /**
  287. * 只保留当前色块的像素,避免切开时把框内其它独立色块算进去。
  288. * @param string $mask
  289. * @param int $w
  290. * @param int $h
  291. * @param array $box
  292. * @return string
  293. */
  294. protected static function clipBlobMask($mask, $w, $h, array $box)
  295. {
  296. $clip = str_repeat("\0", $w * $h);
  297. $seen = str_repeat("\0", $w * $h);
  298. $x0 = (int)$box['x'];
  299. $y0 = (int)$box['y'];
  300. $x1 = $x0 + (int)$box['w'];
  301. $y1 = $y0 + (int)$box['h'];
  302. $best = str_repeat("\0", $w * $h);
  303. $bestArea = 0;
  304. for ($sy = $y0; $sy < $y1; $sy++) {
  305. $row = $sy * $w;
  306. for ($sx = $x0; $sx < $x1; $sx++) {
  307. $si = $row + $sx;
  308. if ($mask[$si] === "\0" || $seen[$si] === "\1") {
  309. continue;
  310. }
  311. $stack = [[$sx, $sy]];
  312. $cur = str_repeat("\0", $w * $h);
  313. $area = 0;
  314. while ($stack) {
  315. list($x, $y) = array_pop($stack);
  316. if ($x < $x0 || $y < $y0 || $x >= $x1 || $y >= $y1) {
  317. continue;
  318. }
  319. $i = $y * $w + $x;
  320. if ($mask[$i] === "\0" || $seen[$i] === "\1") {
  321. continue;
  322. }
  323. $seen[$i] = "\1";
  324. $cur[$i] = "\1";
  325. $area++;
  326. $stack[] = [$x + 1, $y];
  327. $stack[] = [$x - 1, $y];
  328. $stack[] = [$x, $y + 1];
  329. $stack[] = [$x, $y - 1];
  330. }
  331. if ($area > $bestArea) {
  332. $bestArea = $area;
  333. $best = $cur;
  334. }
  335. }
  336. }
  337. return $best;
  338. }
  339. /**
  340. * @param array $hist
  341. * @param int $minArea
  342. * @return array|null {a,b,score}
  343. */
  344. protected static function bestValley(array $hist, $minArea)
  345. {
  346. $n = count($hist);
  347. if ($n < 12) {
  348. return null;
  349. }
  350. $maxH = max($hist);
  351. if ($maxH < 8) {
  352. return null;
  353. }
  354. $limit = max(6, (int)round($maxH * 0.12));
  355. $best = null;
  356. $i = 1;
  357. while ($i < $n - 1) {
  358. if ($hist[$i] > $limit) {
  359. $i++;
  360. continue;
  361. }
  362. $a = $i;
  363. while ($i < $n - 1 && $hist[$i] <= $limit) {
  364. $i++;
  365. }
  366. $b = $i;
  367. if ($b - $a < 1) {
  368. continue;
  369. }
  370. if ($a < 2 || $b > $n - 2) {
  371. continue;
  372. }
  373. $left = 0;
  374. for ($k = 0; $k < $a; $k++) {
  375. $left += $hist[$k];
  376. }
  377. $right = 0;
  378. for ($k = $b; $k < $n; $k++) {
  379. $right += $hist[$k];
  380. }
  381. if ($left < $minArea || $right < $minArea) {
  382. continue;
  383. }
  384. $score = ($b - $a) * min($left, $right);
  385. if (!$best || $score > $best['score']) {
  386. $best = ['a' => $a, 'b' => $b, 'score' => $score];
  387. }
  388. }
  389. return $best;
  390. }
  391. /**
  392. * @param string $mask
  393. * @param string $seen
  394. * @param int $w
  395. * @param int $h
  396. * @param int $sx
  397. * @param int $sy
  398. * @param int $x0
  399. * @param int $y0
  400. * @param int $x1
  401. * @param int $y1
  402. * @return array|null
  403. */
  404. protected static function flood($mask, &$seen, $w, $h, $sx, $sy, $x0, $y0, $x1, $y1)
  405. {
  406. $stack = [[$sx, $sy]];
  407. $minX = $maxX = $sx;
  408. $minY = $maxY = $sy;
  409. $area = 0;
  410. $limit = $w * $h;
  411. while ($stack) {
  412. list($x, $y) = array_pop($stack);
  413. if ($x < $x0 || $y < $y0 || $x >= $x1 || $y >= $y1) {
  414. continue;
  415. }
  416. $i = $y * $w + $x;
  417. if ($mask[$i] === "\0" || $seen[$i] === "\1") {
  418. continue;
  419. }
  420. $seen[$i] = "\1";
  421. $area++;
  422. if ($area > $limit) {
  423. break;
  424. }
  425. if ($x < $minX) {
  426. $minX = $x;
  427. }
  428. if ($x > $maxX) {
  429. $maxX = $x;
  430. }
  431. if ($y < $minY) {
  432. $minY = $y;
  433. }
  434. if ($y > $maxY) {
  435. $maxY = $y;
  436. }
  437. $stack[] = [$x + 1, $y];
  438. $stack[] = [$x - 1, $y];
  439. $stack[] = [$x, $y + 1];
  440. $stack[] = [$x, $y - 1];
  441. }
  442. if ($area < 1) {
  443. return null;
  444. }
  445. $bw = $maxX - $minX + 1;
  446. $bh = $maxY - $minY + 1;
  447. if ($bw < 4 || $bh < 3) {
  448. return null;
  449. }
  450. return [
  451. 'x' => $minX,
  452. 'y' => $minY,
  453. 'w' => $bw,
  454. 'h' => $bh,
  455. 'area' => $area,
  456. 'color' => '#ffff00',
  457. ];
  458. }
  459. }