TemplateIngest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <?php
  2. namespace app\common\library\ps;
  3. use think\Db;
  4. use think\Exception;
  5. /**
  6. * 管理后台:双图色块识版入库。
  7. */
  8. class TemplateIngest
  9. {
  10. /**
  11. * @param array $input name,factory_id,cover,mark_cover,paper_size,page_w,page_h
  12. * @return array {template_id, cells, unlabeled}
  13. * @throws Exception
  14. */
  15. public static function ingest(array $input)
  16. {
  17. @set_time_limit(180);
  18. @ini_set('memory_limit', '512M');
  19. $name = trim((string)($input['name'] ?? ''));
  20. $factoryId = (int)($input['factory_id'] ?? 0);
  21. $cover = AiRecognizer::localFileUrl((string)($input['cover'] ?? ''));
  22. $mark = AiRecognizer::localFileUrl((string)($input['mark_cover'] ?? ''));
  23. if ($cover === '' || $cover === '/') {
  24. throw new Exception('请上传标准展示图');
  25. }
  26. if ($mark === '' || $mark === '/') {
  27. throw new Exception('请上传色块标注图');
  28. }
  29. $paper = PaperSize::resolve(
  30. (string)($input['paper_size'] ?? 'A4'),
  31. (float)($input['page_w'] ?? 0),
  32. (float)($input['page_h'] ?? 0)
  33. );
  34. $cleanPath = self::resolvePath($cover);
  35. $markPath = self::resolvePath($mark);
  36. $probe = LayoutProbe::inspect($cleanPath);
  37. $imgW = (int)$probe['width'];
  38. $imgH = (int)$probe['height'];
  39. if ($imgW < 20 || $imgH < 20) {
  40. throw new Exception('标准图无法读取尺寸');
  41. }
  42. if (!PaperSize::ratioMatches($imgW, $imgH, $paper['w'], $paper['h'], 0.05)) {
  43. throw new Exception('标准图像素比与纸张不符。当前图 '
  44. . $imgW . '×' . $imgH . ','
  45. . $paper['name'] . ' 应为约 '
  46. . $paper['px_w'] . '×' . $paper['px_h'] . ' px(300DPI)或同等比例');
  47. }
  48. $detected = MarkerDetector::detect($cleanPath, $markPath);
  49. $cells = $detected['cells'];
  50. if (!$cells) {
  51. throw new Exception('没有识别到色块。请确认色块图是在标准图副本上涂了明显颜色,且两张图像素一致');
  52. }
  53. $cells = self::labelCells($cells, $markPath);
  54. $elements = [];
  55. foreach ($cells as $idx => $cell) {
  56. $key = (string)($cell['key'] ?? '');
  57. $label = (string)($cell['label'] ?? $cell['text'] ?? '');
  58. $elements[] = PrintPlanner::buildElement([
  59. 'id' => $cell['id'] ?? ('c' . ($idx + 1)),
  60. 'type' => 'field',
  61. 'role' => 'fill',
  62. 'key' => $key,
  63. 'label' => $label,
  64. 'text' => '',
  65. 'x' => $cell['x'],
  66. 'y' => $cell['y'],
  67. 'w' => $cell['w'],
  68. 'h' => $cell['h'],
  69. 'border' => false,
  70. ], 1);
  71. }
  72. $fillCells = [];
  73. foreach ($elements as $el) {
  74. $fillCells[] = [
  75. 'id' => $el['id'],
  76. 'key' => $el['key'],
  77. 'label' => $el['label'],
  78. 'x' => $el['x'],
  79. 'y' => $el['y'],
  80. 'w' => $el['w'],
  81. 'h' => $el['h'],
  82. ];
  83. }
  84. if ($name === '') {
  85. $name = ($factoryId === 0 ? '官方模板 ' : '工厂模板 ') . date('Y-m-d H:i');
  86. }
  87. $dpi = PaperSize::dpiOf($imgW, $paper['w']);
  88. $schema = [
  89. 'name' => mb_substr($name, 0, 100),
  90. 'title' => mb_substr($name, 0, 100),
  91. 'mode' => 'overlay',
  92. 'paper_size' => $paper['name'],
  93. 'page' => [
  94. 'w' => $paper['w'],
  95. 'h' => $paper['h'],
  96. 'background' => $probe['background'] ?: '#ffffff',
  97. 'image_width' => $imgW,
  98. 'image_height' => $imgH,
  99. 'dpi' => $dpi,
  100. ],
  101. 'background' => $cover,
  102. 'file_url' => $cover,
  103. 'mark_image' => $mark,
  104. 'elements' => $elements,
  105. 'cells' => $fillCells,
  106. 'fields' => $fillCells,
  107. 'source' => 'marker',
  108. ];
  109. $now = time();
  110. $isSystem = $factoryId === 0 ? 1 : 0;
  111. $templateId = (int)Db::name('ps_template')->insertGetId([
  112. 'factory_id' => $factoryId,
  113. 'creator_user_id' => 0,
  114. 'name' => $schema['name'],
  115. 'cover' => $cover,
  116. 'paper_size' => $paper['name'],
  117. 'page_w' => $paper['w'],
  118. 'page_h' => $paper['h'],
  119. 'mark_cover' => $mark,
  120. 'source' => $isSystem ? 'system' : 'ai',
  121. 'license_type' => $isSystem ? 'self' : 'self',
  122. 'locked' => 0,
  123. 'source_template_id' => 0,
  124. 'source_version_id' => 0,
  125. 'current_version_id' => 0,
  126. 'is_system' => $isSystem,
  127. 'status' => 'normal',
  128. 'createtime' => $now,
  129. 'updatetime' => $now,
  130. ]);
  131. $versionId = (int)Db::name('ps_template_version')->insertGetId([
  132. 'template_id' => $templateId,
  133. 'factory_id' => $factoryId,
  134. 'version' => 1,
  135. 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
  136. 'remark' => '色块识版入库',
  137. 'createtime' => $now,
  138. ]);
  139. Db::name('ps_template')->where('id', $templateId)->update([
  140. 'current_version_id' => $versionId,
  141. 'updatetime' => $now,
  142. ]);
  143. $unlabeled = 0;
  144. foreach ($fillCells as $cell) {
  145. if (trim((string)$cell['key']) === '') {
  146. $unlabeled++;
  147. }
  148. }
  149. return [
  150. 'template_id' => $templateId,
  151. 'cells' => count($fillCells),
  152. 'unlabeled' => $unlabeled,
  153. 'dpi' => $dpi,
  154. 'image' => $imgW . '×' . $imgH,
  155. 'paper' => $paper['name'] . ' ' . $paper['w'] . '×' . $paper['h'] . 'mm',
  156. ];
  157. }
  158. /**
  159. * 用当前标准图和色块图重新识版,更新已有模板的格子。
  160. * @param int $templateId
  161. * @return array
  162. * @throws Exception
  163. */
  164. public static function rebuild($templateId)
  165. {
  166. $row = Db::name('ps_template')->where('id', (int)$templateId)->find();
  167. if (!$row) {
  168. throw new Exception('模板不存在');
  169. }
  170. $cover = AiRecognizer::localFileUrl((string)$row['cover']);
  171. $mark = AiRecognizer::localFileUrl((string)$row['mark_cover']);
  172. $cleanPath = self::resolvePath($cover);
  173. $markPath = self::resolvePath($mark);
  174. $detected = MarkerDetector::detect($cleanPath, $markPath);
  175. $cells = $detected['cells'];
  176. if (!$cells) {
  177. throw new Exception('没有识别到色块');
  178. }
  179. $cells = self::labelCells($cells, $markPath);
  180. $elements = [];
  181. foreach ($cells as $idx => $cell) {
  182. $elements[] = PrintPlanner::buildElement([
  183. 'id' => $cell['id'] ?? ('c' . ($idx + 1)),
  184. 'type' => 'field',
  185. 'role' => 'fill',
  186. 'key' => (string)($cell['key'] ?? ''),
  187. 'label' => (string)($cell['label'] ?? ''),
  188. 'text' => '',
  189. 'x' => $cell['x'],
  190. 'y' => $cell['y'],
  191. 'w' => $cell['w'],
  192. 'h' => $cell['h'],
  193. 'border' => false,
  194. ], 1);
  195. }
  196. $schema = PrintPlanner::decodeSchema(json_encode([
  197. 'name' => $row['name'],
  198. 'title' => $row['name'],
  199. 'mode' => 'overlay',
  200. 'paper_size' => $row['paper_size'],
  201. 'page' => [
  202. 'w' => (float)$row['page_w'],
  203. 'h' => (float)$row['page_h'],
  204. 'image_width' => $detected['width'],
  205. 'image_height' => $detected['height'],
  206. ],
  207. 'background' => $cover,
  208. 'file_url' => $cover,
  209. 'mark_image' => $mark,
  210. 'elements' => $elements,
  211. 'cells' => $elements,
  212. 'source' => 'marker',
  213. ], JSON_UNESCAPED_UNICODE), $cover);
  214. $versionId = (int)$row['current_version_id'];
  215. $now = time();
  216. $payload = json_encode($schema, JSON_UNESCAPED_UNICODE);
  217. if ($versionId > 0) {
  218. Db::name('ps_template_version')->where('id', $versionId)->update([
  219. 'schema_json' => $payload,
  220. ]);
  221. } else {
  222. $versionId = (int)Db::name('ps_template_version')->insertGetId([
  223. 'template_id' => (int)$templateId,
  224. 'factory_id' => (int)$row['factory_id'],
  225. 'version' => 1,
  226. 'schema_json' => $payload,
  227. 'remark' => '重新识版',
  228. 'createtime' => $now,
  229. ]);
  230. }
  231. Db::name('ps_template')->where('id', (int)$templateId)->update([
  232. 'current_version_id' => $versionId,
  233. 'updatetime' => $now,
  234. ]);
  235. $unlabeled = 0;
  236. foreach ($schema['cells'] as $cell) {
  237. if (trim((string)($cell['key'] ?? '')) === '') {
  238. $unlabeled++;
  239. }
  240. }
  241. return [
  242. 'template_id' => (int)$templateId,
  243. 'cells' => count($schema['cells']),
  244. 'unlabeled' => $unlabeled,
  245. ];
  246. }
  247. /**
  248. * 用色块上的文字对照字段目录。视觉模型只认字,不再猜格子位置。
  249. * @param array $cells
  250. * @param string $markPath
  251. * @return array
  252. */
  253. protected static function labelCells(array $cells, $markPath)
  254. {
  255. foreach ($cells as &$cell) {
  256. $cell['key'] = isset($cell['key']) ? (string)$cell['key'] : '';
  257. $cell['label'] = isset($cell['label']) ? (string)$cell['label'] : '';
  258. }
  259. unset($cell);
  260. $catalog = PrintPlanner::fieldCatalog();
  261. $lines = [];
  262. foreach ($catalog as $item) {
  263. $lines[] = $item['key'] . ' = ' . $item['label'];
  264. }
  265. $boxLines = [];
  266. foreach ($cells as $idx => $cell) {
  267. $boxLines[] = ($idx + 1) . ': x=' . $cell['x'] . ' y=' . $cell['y'] . ' w=' . $cell['w'] . ' h=' . $cell['h'];
  268. }
  269. $prompt = "这是一张流程单的色块标注图。每个彩色色块覆盖一个待填写格子,色块上写了字段名称。\n"
  270. . "色块位置已经用百分比给出(相对整张图 0-100),请只读取每个色块上的文字,不要改坐标。\n"
  271. . "可选字段:\n" . implode("\n", $lines) . "\n"
  272. . "色块列表:\n" . implode("\n", $boxLines) . "\n"
  273. . "只返回 JSON:{\"items\":[{\"i\":1,\"text\":\"产品名称\",\"key\":\"product.name\"}]}\n"
  274. . "text 用色块上看到的原文;key 必须是上面的字段名,看不清就留空字符串。";
  275. try {
  276. $client = LlmClient::forRecognize();
  277. if ($client->enabled() || $client->visionModel()) {
  278. $raw = $client->visionRaw($prompt, $markPath, $client->defaultModel('image'), true);
  279. $json = LlmClient::extractJson($raw);
  280. $items = [];
  281. if (isset($json['items']) && is_array($json['items'])) {
  282. $items = $json['items'];
  283. } elseif (isset($json[0])) {
  284. $items = $json;
  285. }
  286. $map = [];
  287. foreach ($items as $item) {
  288. if (!is_array($item)) {
  289. continue;
  290. }
  291. $i = (int)($item['i'] ?? $item['index'] ?? 0);
  292. if ($i < 1) {
  293. continue;
  294. }
  295. $text = trim((string)($item['text'] ?? $item['label'] ?? ''));
  296. $key = PrintPlanner::mapFieldKey((string)($item['key'] ?? ''));
  297. if ($key === '' && $text !== '') {
  298. $key = PrintPlanner::mapFieldKey($text);
  299. }
  300. $map[$i] = ['text' => $text, 'key' => $key];
  301. }
  302. foreach ($cells as $idx => &$cell) {
  303. $hit = $map[$idx + 1] ?? null;
  304. $cell['label'] = $hit ? $hit['text'] : '';
  305. $cell['key'] = $hit ? $hit['key'] : '';
  306. if ($cell['key'] === '' && $cell['label'] !== '') {
  307. $cell['key'] = PrintPlanner::mapFieldKey($cell['label']);
  308. }
  309. }
  310. unset($cell);
  311. }
  312. } catch (\Exception $e) {
  313. foreach ($cells as &$cell) {
  314. if (!isset($cell['key'])) {
  315. $cell['key'] = '';
  316. }
  317. if (!isset($cell['label'])) {
  318. $cell['label'] = '';
  319. }
  320. }
  321. unset($cell);
  322. }
  323. return $cells;
  324. }
  325. /**
  326. * @param string $fileUrl
  327. * @return string
  328. * @throws Exception
  329. */
  330. public static function resolvePath($fileUrl)
  331. {
  332. $fileUrl = AiRecognizer::localFileUrl($fileUrl);
  333. if ($fileUrl === '' || $fileUrl === '/') {
  334. throw new Exception('文件路径为空');
  335. }
  336. $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\'));
  337. $candidates = [
  338. ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative,
  339. ROOT_PATH . $relative,
  340. ];
  341. foreach ($candidates as $path) {
  342. if (is_file($path)) {
  343. return $path;
  344. }
  345. }
  346. throw new Exception('找不到已上传文件:' . $fileUrl);
  347. }
  348. }