$cell) { $key = (string)($cell['key'] ?? ''); $label = (string)($cell['label'] ?? $cell['text'] ?? ''); $elements[] = PrintPlanner::buildElement([ 'id' => $cell['id'] ?? ('c' . ($idx + 1)), 'type' => 'field', 'role' => 'fill', 'key' => $key, 'label' => $label, 'text' => '', 'x' => $cell['x'], 'y' => $cell['y'], 'w' => $cell['w'], 'h' => $cell['h'], 'border' => false, ], 1); } $fillCells = []; foreach ($elements as $el) { $fillCells[] = [ 'id' => $el['id'], 'key' => $el['key'], 'label' => $el['label'], 'x' => $el['x'], 'y' => $el['y'], 'w' => $el['w'], 'h' => $el['h'], ]; } if ($name === '') { $name = ($factoryId === 0 ? '官方模板 ' : '工厂模板 ') . date('Y-m-d H:i'); } $dpi = PaperSize::dpiOf($imgW, $paper['w']); $schema = [ 'name' => mb_substr($name, 0, 100), 'title' => mb_substr($name, 0, 100), 'mode' => 'overlay', 'paper_size' => $paper['name'], 'page' => [ 'w' => $paper['w'], 'h' => $paper['h'], 'background' => $probe['background'] ?: '#ffffff', 'image_width' => $imgW, 'image_height' => $imgH, 'dpi' => $dpi, ], 'background' => $cover, 'file_url' => $cover, 'mark_image' => $mark, 'elements' => $elements, 'cells' => $fillCells, 'fields' => $fillCells, 'source' => 'marker', ]; $now = time(); $isSystem = $factoryId === 0 ? 1 : 0; $templateId = (int)Db::name('ps_template')->insertGetId([ 'factory_id' => $factoryId, 'creator_user_id' => 0, 'name' => $schema['name'], 'cover' => $cover, 'paper_size' => $paper['name'], 'page_w' => $paper['w'], 'page_h' => $paper['h'], 'mark_cover' => $mark, 'source' => $isSystem ? 'system' : 'ai', 'license_type' => $isSystem ? 'self' : 'self', 'locked' => 0, 'source_template_id' => 0, 'source_version_id' => 0, 'current_version_id' => 0, 'is_system' => $isSystem, 'status' => 'normal', 'createtime' => $now, 'updatetime' => $now, ]); $versionId = (int)Db::name('ps_template_version')->insertGetId([ 'template_id' => $templateId, 'factory_id' => $factoryId, 'version' => 1, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'remark' => '色块识版入库', 'createtime' => $now, ]); Db::name('ps_template')->where('id', $templateId)->update([ 'current_version_id' => $versionId, 'updatetime' => $now, ]); $unlabeled = 0; foreach ($fillCells as $cell) { if (trim((string)$cell['key']) === '') { $unlabeled++; } } return [ 'template_id' => $templateId, 'cells' => count($fillCells), 'unlabeled' => $unlabeled, 'dpi' => $dpi, 'image' => $imgW . '×' . $imgH, 'paper' => $paper['name'] . ' ' . $paper['w'] . '×' . $paper['h'] . 'mm', ]; } /** * 用当前标准图和色块图重新识版,更新已有模板的格子。 * @param int $templateId * @return array * @throws Exception */ public static function rebuild($templateId) { $row = Db::name('ps_template')->where('id', (int)$templateId)->find(); if (!$row) { throw new Exception('模板不存在'); } $cover = AiRecognizer::localFileUrl((string)$row['cover']); $mark = AiRecognizer::localFileUrl((string)$row['mark_cover']); $cleanPath = self::resolvePath($cover); $markPath = self::resolvePath($mark); $detected = MarkerDetector::detect($cleanPath, $markPath); $cells = $detected['cells']; if (!$cells) { throw new Exception('没有识别到色块'); } $cells = self::labelCells($cells, $markPath); $elements = []; foreach ($cells as $idx => $cell) { $elements[] = PrintPlanner::buildElement([ 'id' => $cell['id'] ?? ('c' . ($idx + 1)), 'type' => 'field', 'role' => 'fill', 'key' => (string)($cell['key'] ?? ''), 'label' => (string)($cell['label'] ?? ''), 'text' => '', 'x' => $cell['x'], 'y' => $cell['y'], 'w' => $cell['w'], 'h' => $cell['h'], 'border' => false, ], 1); } $schema = PrintPlanner::decodeSchema(json_encode([ 'name' => $row['name'], 'title' => $row['name'], 'mode' => 'overlay', 'paper_size' => $row['paper_size'], 'page' => [ 'w' => (float)$row['page_w'], 'h' => (float)$row['page_h'], 'image_width' => $detected['width'], 'image_height' => $detected['height'], ], 'background' => $cover, 'file_url' => $cover, 'mark_image' => $mark, 'elements' => $elements, 'cells' => $elements, 'source' => 'marker', ], JSON_UNESCAPED_UNICODE), $cover); $versionId = (int)$row['current_version_id']; $now = time(); if ($versionId > 0) { Db::name('ps_template_version')->where('id', $versionId)->update([ 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), ]); } Db::name('ps_template')->where('id', (int)$templateId)->update(['updatetime' => $now]); $unlabeled = 0; foreach ($schema['cells'] as $cell) { if (trim((string)($cell['key'] ?? '')) === '') { $unlabeled++; } } return [ 'template_id' => (int)$templateId, 'cells' => count($schema['cells']), 'unlabeled' => $unlabeled, ]; } /** * 用色块上的文字对照字段目录。视觉模型只认字,不再猜格子位置。 * @param array $cells * @param string $markPath * @return array */ protected static function labelCells(array $cells, $markPath) { foreach ($cells as &$cell) { $cell['key'] = isset($cell['key']) ? (string)$cell['key'] : ''; $cell['label'] = isset($cell['label']) ? (string)$cell['label'] : ''; } unset($cell); $catalog = PrintPlanner::fieldCatalog(); $lines = []; foreach ($catalog as $item) { $lines[] = $item['key'] . ' = ' . $item['label']; } $boxLines = []; foreach ($cells as $idx => $cell) { $boxLines[] = ($idx + 1) . ': x=' . $cell['x'] . ' y=' . $cell['y'] . ' w=' . $cell['w'] . ' h=' . $cell['h']; } $prompt = "这是一张流程单的色块标注图。每个彩色色块覆盖一个待填写格子,色块上写了字段名称。\n" . "色块位置已经用百分比给出(相对整张图 0-100),请只读取每个色块上的文字,不要改坐标。\n" . "可选字段:\n" . implode("\n", $lines) . "\n" . "色块列表:\n" . implode("\n", $boxLines) . "\n" . "只返回 JSON:{\"items\":[{\"i\":1,\"text\":\"产品名称\",\"key\":\"product.name\"}]}\n" . "text 用色块上看到的原文;key 必须是上面的字段名,看不清就留空字符串。"; try { $client = LlmClient::forRecognize(); if ($client->enabled() || $client->visionModel()) { $raw = $client->visionRaw($prompt, $markPath, $client->defaultModel('image'), true); $json = LlmClient::extractJson($raw); $items = []; if (isset($json['items']) && is_array($json['items'])) { $items = $json['items']; } elseif (isset($json[0])) { $items = $json; } $map = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $i = (int)($item['i'] ?? $item['index'] ?? 0); if ($i < 1) { continue; } $text = trim((string)($item['text'] ?? $item['label'] ?? '')); $key = PrintPlanner::mapFieldKey((string)($item['key'] ?? '')); if ($key === '' && $text !== '') { $key = PrintPlanner::mapFieldKey($text); } $map[$i] = ['text' => $text, 'key' => $key]; } foreach ($cells as $idx => &$cell) { $hit = $map[$idx + 1] ?? null; $cell['label'] = $hit ? $hit['text'] : ''; $cell['key'] = $hit ? $hit['key'] : ''; if ($cell['key'] === '' && $cell['label'] !== '') { $cell['key'] = PrintPlanner::mapFieldKey($cell['label']); } } unset($cell); } } catch (\Exception $e) { foreach ($cells as &$cell) { if (!isset($cell['key'])) { $cell['key'] = ''; } if (!isset($cell['label'])) { $cell['label'] = ''; } } unset($cell); } return $cells; } /** * @param string $fileUrl * @return string * @throws Exception */ public static function resolvePath($fileUrl) { $fileUrl = AiRecognizer::localFileUrl($fileUrl); if ($fileUrl === '' || $fileUrl === '/') { throw new Exception('文件路径为空'); } $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\')); $candidates = [ ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative, ROOT_PATH . $relative, ]; foreach ($candidates as $path) { if (is_file($path)) { return $path; } } throw new Exception('找不到已上传文件:' . $fileUrl); } }