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', ]; } }