| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- <?php
- namespace app\common\library\ps;
- use think\Exception;
- /**
- * 对比「标准原稿」和「色块标注图」,用色块外接矩形作为填写格。
- * 百分比坐标相对整张图,打印时再按纸张毫米还原。
- */
- class MarkerDetector
- {
- /**
- * @param string $cleanPath 标准展示图
- * @param string $markPath 涂了色块的图
- * @return array {width,height,cells:[{x,y,w,h,color,area}]}
- * @throws Exception
- */
- public static function detect($cleanPath, $markPath)
- {
- if (!function_exists('imagecreatefromstring')) {
- throw new Exception('当前 PHP 未开启 GD,无法识别色块');
- }
- @ini_set('memory_limit', '512M');
- $clean = self::loadImage($cleanPath);
- $mark = self::loadImage($markPath);
- $cw = imagesx($clean);
- $ch = imagesy($clean);
- $mw = imagesx($mark);
- $mh = imagesy($mark);
- if (abs($cw - $mw) > 2 || abs($ch - $mh) > 2) {
- if (abs(($ch / max($cw, 1)) - ($mh / max($mw, 1))) > 0.02) {
- imagedestroy($clean);
- imagedestroy($mark);
- throw new Exception('两张图的像素尺寸必须一致(请用同一张扫描件复制后涂色块)。标准图 '
- . $cw . '×' . $ch . ',色块图 ' . $mw . '×' . $mh);
- }
- $resized = imagecreatetruecolor($cw, $ch);
- imagecopyresampled($resized, $mark, 0, 0, 0, 0, $cw, $ch, $mw, $mh);
- imagedestroy($mark);
- $mark = $resized;
- }
- $maxEdge = 900;
- $scale = min(1, $maxEdge / max($cw, $ch));
- $ww = max(1, (int)round($cw * $scale));
- $wh = max(1, (int)round($ch * $scale));
- $cWork = $clean;
- $mWork = $mark;
- if ($scale < 0.999) {
- $cWork = imagecreatetruecolor($ww, $wh);
- $mWork = imagecreatetruecolor($ww, $wh);
- imagecopyresampled($cWork, $clean, 0, 0, 0, 0, $ww, $wh, $cw, $ch);
- imagecopyresampled($mWork, $mark, 0, 0, 0, 0, $ww, $wh, $cw, $ch);
- }
- $mask = self::diffMask($cWork, $mWork);
- if ($cWork !== $clean) {
- imagedestroy($cWork);
- }
- if ($mWork !== $mark) {
- imagedestroy($mWork);
- }
- imagedestroy($clean);
- imagedestroy($mark);
- $minArea = max(30, (int)($ww * $wh * 0.00035));
- $blobs = self::connectedBoxes($mask, $ww, $wh, $minArea, 0, 0, $ww, $wh);
- $split = [];
- foreach ($blobs as $blob) {
- foreach (self::splitByValleys($blob, $mask, $ww, $wh, $minArea, 0) as $part) {
- $split[] = $part;
- }
- }
- $cells = [];
- $i = 0;
- foreach ($split as $blob) {
- $i++;
- $cells[] = [
- 'id' => 'c' . $i,
- 'x' => round($blob['x'] / $ww * 100, 2),
- 'y' => round($blob['y'] / $wh * 100, 2),
- 'w' => round($blob['w'] / $ww * 100, 2),
- 'h' => round($blob['h'] / $wh * 100, 2),
- 'color' => $blob['color'],
- 'area' => $blob['area'],
- ];
- }
- usort($cells, function ($a, $b) {
- if (abs($a['y'] - $b['y']) > 2) {
- return $a['y'] < $b['y'] ? -1 : 1;
- }
- return $a['x'] < $b['x'] ? -1 : 1;
- });
- foreach ($cells as $idx => &$cell) {
- $cell['id'] = 'c' . ($idx + 1);
- }
- unset($cell);
- return [
- 'width' => $cw,
- 'height' => $ch,
- 'cells' => $cells,
- ];
- }
- /**
- * @param string $path
- * @return resource
- * @throws Exception
- */
- protected static function loadImage($path)
- {
- if (!is_file($path)) {
- throw new Exception('图片不存在:' . $path);
- }
- $bin = @file_get_contents($path);
- if ($bin === false || $bin === '') {
- throw new Exception('图片无法读取');
- }
- $im = @imagecreatefromstring($bin);
- if (!$im) {
- throw new Exception('无法解析图片,请使用 JPG / PNG');
- }
- if (!imageistruecolor($im)) {
- $true = imagecreatetruecolor(imagesx($im), imagesy($im));
- imagefill($true, 0, 0, imagecolorallocate($true, 255, 255, 255));
- imagecopy($true, $im, 0, 0, 0, 0, imagesx($im), imagesy($im));
- imagedestroy($im);
- $im = $true;
- }
- return $im;
- }
- /**
- * @param resource $clean
- * @param resource $mark
- * @return string 0/1 packed rows
- */
- protected static function diffMask($clean, $mark)
- {
- $w = imagesx($clean);
- $h = imagesy($clean);
- $mask = str_repeat("\0", $w * $h);
- for ($y = 0; $y < $h; $y++) {
- $row = $y * $w;
- for ($x = 0; $x < $w; $x++) {
- $c = imagecolorat($clean, $x, $y);
- $m = imagecolorat($mark, $x, $y);
- $dr = (($c >> 16) & 0xFF) - (($m >> 16) & 0xFF);
- $dg = (($c >> 8) & 0xFF) - (($m >> 8) & 0xFF);
- $db = ($c & 0xFF) - ($m & 0xFF);
- if (($dr * $dr + $dg * $dg + $db * $db) < 1600) {
- continue;
- }
- $mr = ($m >> 16) & 0xFF;
- $mg = ($m >> 8) & 0xFF;
- $mb = $m & 0xFF;
- $cr = ($c >> 16) & 0xFF;
- $cg = ($c >> 8) & 0xFF;
- $cb = $c & 0xFF;
- $markSat = max($mr, $mg, $mb) - min($mr, $mg, $mb);
- $cleanSat = max($cr, $cg, $cb) - min($cr, $cg, $cb);
- // 只要涂上去的鲜艳色,忽略原稿浅底色/JPEG 色差,否则页眉会把二维码和备注连成一块。
- if ($markSat < 80 || $markSat < $cleanSat + 35) {
- continue;
- }
- $mask[$row + $x] = "\1";
- }
- }
- return $mask;
- }
- /**
- * @param string $mask
- * @param int $w
- * @param int $h
- * @param int $minArea
- * @param int $x0
- * @param int $y0
- * @param int $x1
- * @param int $y1
- * @return array
- */
- protected static function connectedBoxes($mask, $w, $h, $minArea, $x0, $y0, $x1, $y1)
- {
- $seen = str_repeat("\0", $w * $h);
- $boxes = [];
- $maxBlobs = 80;
- $x0 = max(0, $x0);
- $y0 = max(0, $y0);
- $x1 = min($w, $x1);
- $y1 = min($h, $y1);
- for ($y = $y0; $y < $y1; $y++) {
- $row = $y * $w;
- for ($x = $x0; $x < $x1; $x++) {
- $i = $row + $x;
- if ($mask[$i] === "\0" || $seen[$i] === "\1") {
- continue;
- }
- $box = self::flood($mask, $seen, $w, $h, $x, $y, $x0, $y0, $x1, $y1);
- if ($box && $box['area'] >= $minArea) {
- $boxes[] = $box;
- if (count($boxes) >= $maxBlobs) {
- return $boxes;
- }
- }
- }
- }
- return $boxes;
- }
- /**
- * 色块贴在一起(二维码右下角碰到备注条)时,沿投影空隙切开。
- * @param array $box
- * @param string $mask
- * @param int $w
- * @param int $h
- * @param int $minArea
- * @param int $depth
- * @return array
- */
- protected static function splitByValleys(array $box, $mask, $w, $h, $minArea, $depth)
- {
- if ($depth >= 5) {
- return [$box];
- }
- $mask = self::clipBlobMask($mask, $w, $h, $box);
- $bw = (int)$box['w'];
- $bh = (int)$box['h'];
- $fill = $box['area'] / max(1, $bw * $bh);
- if ($fill >= 0.78 && $bw < $w * 0.45 && $bh < $h * 0.18) {
- return [$box];
- }
- if ($bw * $bh < $minArea * 4) {
- return [$box];
- }
- $rowHist = [];
- $colHist = [];
- $x0 = (int)$box['x'];
- $y0 = (int)$box['y'];
- for ($y = 0; $y < $bh; $y++) {
- $cnt = 0;
- $row = ($y0 + $y) * $w + $x0;
- for ($x = 0; $x < $bw; $x++) {
- if ($mask[$row + $x] === "\1") {
- $cnt++;
- }
- }
- $rowHist[$y] = $cnt;
- }
- for ($x = 0; $x < $bw; $x++) {
- $cnt = 0;
- for ($y = 0; $y < $bh; $y++) {
- if ($mask[($y0 + $y) * $w + $x0 + $x] === "\1") {
- $cnt++;
- }
- }
- $colHist[$x] = $cnt;
- }
- $rowGap = self::bestValley($rowHist, $minArea);
- $colGap = self::bestValley($colHist, $minArea);
- $axis = null;
- if ($rowGap && (!$colGap || $rowGap['score'] >= $colGap['score'])) {
- $axis = ['row', $rowGap['a'], $rowGap['b']];
- } elseif ($colGap) {
- $axis = ['col', $colGap['a'], $colGap['b']];
- }
- if (!$axis) {
- return [$box];
- }
- if ($axis[0] === 'row') {
- $rects = [
- [$x0, $y0, $bw, $axis[1]],
- [$x0, $y0 + $axis[2], $bw, $bh - $axis[2]],
- ];
- } else {
- $rects = [
- [$x0, $y0, $axis[1], $bh],
- [$x0 + $axis[2], $y0, $bw - $axis[2], $bh],
- ];
- }
- $parts = [];
- foreach ($rects as $rect) {
- if ($rect[2] < 4 || $rect[3] < 3) {
- continue;
- }
- $found = self::connectedBoxes($mask, $w, $h, $minArea, $rect[0], $rect[1], $rect[0] + $rect[2], $rect[1] + $rect[3]);
- foreach ($found as $child) {
- foreach (self::splitByValleys($child, $mask, $w, $h, $minArea, $depth + 1) as $part) {
- $parts[] = $part;
- }
- }
- }
- return $parts ?: [$box];
- }
- /**
- * 只保留当前色块的像素,避免切开时把框内其它独立色块算进去。
- * @param string $mask
- * @param int $w
- * @param int $h
- * @param array $box
- * @return string
- */
- protected static function clipBlobMask($mask, $w, $h, array $box)
- {
- $clip = str_repeat("\0", $w * $h);
- $seen = str_repeat("\0", $w * $h);
- $x0 = (int)$box['x'];
- $y0 = (int)$box['y'];
- $x1 = $x0 + (int)$box['w'];
- $y1 = $y0 + (int)$box['h'];
- $best = str_repeat("\0", $w * $h);
- $bestArea = 0;
- for ($sy = $y0; $sy < $y1; $sy++) {
- $row = $sy * $w;
- for ($sx = $x0; $sx < $x1; $sx++) {
- $si = $row + $sx;
- if ($mask[$si] === "\0" || $seen[$si] === "\1") {
- continue;
- }
- $stack = [[$sx, $sy]];
- $cur = str_repeat("\0", $w * $h);
- $area = 0;
- while ($stack) {
- list($x, $y) = array_pop($stack);
- if ($x < $x0 || $y < $y0 || $x >= $x1 || $y >= $y1) {
- continue;
- }
- $i = $y * $w + $x;
- if ($mask[$i] === "\0" || $seen[$i] === "\1") {
- continue;
- }
- $seen[$i] = "\1";
- $cur[$i] = "\1";
- $area++;
- $stack[] = [$x + 1, $y];
- $stack[] = [$x - 1, $y];
- $stack[] = [$x, $y + 1];
- $stack[] = [$x, $y - 1];
- }
- if ($area > $bestArea) {
- $bestArea = $area;
- $best = $cur;
- }
- }
- }
- return $best;
- }
- /**
- * @param array $hist
- * @param int $minArea
- * @return array|null {a,b,score}
- */
- protected static function bestValley(array $hist, $minArea)
- {
- $n = count($hist);
- if ($n < 12) {
- return null;
- }
- $maxH = max($hist);
- if ($maxH < 8) {
- return null;
- }
- $limit = max(6, (int)round($maxH * 0.12));
- $best = null;
- $i = 1;
- while ($i < $n - 1) {
- if ($hist[$i] > $limit) {
- $i++;
- continue;
- }
- $a = $i;
- while ($i < $n - 1 && $hist[$i] <= $limit) {
- $i++;
- }
- $b = $i;
- if ($b - $a < 1) {
- continue;
- }
- if ($a < 2 || $b > $n - 2) {
- continue;
- }
- $left = 0;
- for ($k = 0; $k < $a; $k++) {
- $left += $hist[$k];
- }
- $right = 0;
- for ($k = $b; $k < $n; $k++) {
- $right += $hist[$k];
- }
- if ($left < $minArea || $right < $minArea) {
- continue;
- }
- $score = ($b - $a) * min($left, $right);
- if (!$best || $score > $best['score']) {
- $best = ['a' => $a, 'b' => $b, 'score' => $score];
- }
- }
- return $best;
- }
- /**
- * @param string $mask
- * @param string $seen
- * @param int $w
- * @param int $h
- * @param int $sx
- * @param int $sy
- * @param int $x0
- * @param int $y0
- * @param int $x1
- * @param int $y1
- * @return array|null
- */
- protected static function flood($mask, &$seen, $w, $h, $sx, $sy, $x0, $y0, $x1, $y1)
- {
- $stack = [[$sx, $sy]];
- $minX = $maxX = $sx;
- $minY = $maxY = $sy;
- $area = 0;
- $limit = $w * $h;
- while ($stack) {
- list($x, $y) = array_pop($stack);
- if ($x < $x0 || $y < $y0 || $x >= $x1 || $y >= $y1) {
- continue;
- }
- $i = $y * $w + $x;
- if ($mask[$i] === "\0" || $seen[$i] === "\1") {
- continue;
- }
- $seen[$i] = "\1";
- $area++;
- if ($area > $limit) {
- break;
- }
- if ($x < $minX) {
- $minX = $x;
- }
- if ($x > $maxX) {
- $maxX = $x;
- }
- if ($y < $minY) {
- $minY = $y;
- }
- if ($y > $maxY) {
- $maxY = $y;
- }
- $stack[] = [$x + 1, $y];
- $stack[] = [$x - 1, $y];
- $stack[] = [$x, $y + 1];
- $stack[] = [$x, $y - 1];
- }
- if ($area < 1) {
- return null;
- }
- $bw = $maxX - $minX + 1;
- $bh = $maxY - $minY + 1;
- if ($bw < 4 || $bh < 3) {
- return null;
- }
- return [
- 'x' => $minX,
- 'y' => $minY,
- 'w' => $bw,
- 'h' => $bh,
- 'area' => $area,
- 'color' => '#ffff00',
- ];
- }
- }
|