comic-text-detector.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. /**
  2. * 步骤:
  3. * 1. 新建接收imagePath参数
  4. * 2. 新建接收textMaskImgPath参数 用来接收输出文字区域识别框图片路径
  5. * 3. 新建接收textBlocksJsonPath参数用来接收输出文字区域JSON路径
  6. * 4. 调用comic-text-detector生成文字区域坐标JSON
  7. * 5. 复制imagePath 在这个图片基础上,根据json坐标绘制出所有文字区域绿色线框
  8. * 5. 保存带有绿色线框的图片保存到textMaskImgPath路径
  9. */
  10. import fs from 'fs';
  11. import path from 'path';
  12. import { fileURLToPath } from 'url';
  13. import { execSync } from 'child_process';
  14. import { getPythonPath } from './python-path.js';
  15. const __filename = fileURLToPath(import.meta.url);
  16. const __dirname = path.dirname(__filename);
  17. const projectRoot = path.join(__dirname, '..');
  18. /**
  19. * 预设检测配置
  20. */
  21. const DETECTION_PRESETS = {
  22. // 标准配置(默认)
  23. standard: {
  24. inputSize: 1536, // 输入尺寸,越大精度越高但速度越慢
  25. confThresh: 0.1, // 置信度阈值,0-1,越高越严格
  26. nmsThresh: 0.1, // NMS阈值,0-1,越高保留越多重叠框
  27. maskThresh: 0.3, // Mask阈值,0-1,用于分割网络
  28. act: 'leaky', // 激活函数,可选'leaky'或'relu'
  29. refineMode: 0, // 0=INPAINT, 1=ANNOTATION
  30. keepUndetectedMask: 0, // 是否保留未检测区域
  31. erodeIterations: 0, // 腐蚀迭代次数
  32. invertMask: 1 // 反转mask(白底黑字)
  33. },
  34. // 高精度配置(保留更多文字细节)
  35. high_detail: {
  36. inputSize: 2048, // 更大输入尺寸,保持更多细节
  37. confThresh: 0.05, // 更低置信度阈值,捕获更多文字
  38. nmsThresh: 0.2, // 适中NMS阈值,保留更多重叠区域
  39. maskThresh: 0.2, // 更低Mask阈值,保留更多细节
  40. act: 'leaky',
  41. refineMode: 0,
  42. keepUndetectedMask: 0,
  43. erodeIterations: -1, // 负数表示膨胀操作,让文字更粗
  44. invertMask: 1
  45. },
  46. // 超高精度配置(最大化保留细节,速度较慢)
  47. ultra_detail: {
  48. inputSize: 3072, // 极高输入尺寸(原图1334x1940的2.3倍)
  49. confThresh: 0.01, // 极低置信度阈值,捕获所有可能的文字
  50. nmsThresh: 0.3, // 更宽松的NMS,保留更多重叠区域
  51. maskThresh: 0.05, // 极低的Mask阈值,最大化细节保留
  52. act: 'leaky',
  53. refineMode: 0,
  54. keepUndetectedMask: 0,
  55. erodeIterations: -3, // 3次膨胀操作,强力填充文字空洞和细节
  56. invertMask: 1
  57. },
  58. // 日文精细优化配置(专门针对复杂汉字和假名)
  59. japanese_fine: {
  60. inputSize: 2048, // 适中输入尺寸,平衡质量和处理效果
  61. confThresh: 0.08, // 较低置信度,但避免过多噪声
  62. nmsThresh: 0.2, // 适中的NMS,保留重要重叠
  63. maskThresh: 0.2, // 适中Mask阈值,平衡细节和清晰度
  64. act: 'leaky',
  65. refineMode: 0, // INPAINT模式,更好的文字连贯性
  66. keepUndetectedMask: 0,
  67. erodeIterations: -1, // 只1次膨胀,轻微填充空洞但保持清晰
  68. invertMask: 1
  69. },
  70. // 清晰优先配置(减少模糊,保持锐利)
  71. sharp_detail: {
  72. inputSize: 2048, // 高分辨率但不过度
  73. confThresh: 0.15, // 更高置信度,减少噪声
  74. nmsThresh: 0.15, // 较严格NMS,避免重叠干扰
  75. maskThresh: 0.25, // 中等Mask阈值,保持清晰度
  76. act: 'leaky',
  77. refineMode: 0, // INPAINT模式
  78. keepUndetectedMask: 0,
  79. erodeIterations: 0, // 不做形态学操作,保持原始锐度
  80. invertMask: 1
  81. },
  82. // 极致清晰配置(最锐利文字)
  83. ultra_sharp: {
  84. inputSize: 1536, // 适中尺寸,避免过度处理
  85. confThresh: 0.3, // 高置信度,只保留清晰文字
  86. nmsThresh: 0.1, // 严格NMS,避免重叠模糊
  87. maskThresh: 0.4, // 高阈值,只保留最清晰部分
  88. act: 'leaky',
  89. refineMode: 0,
  90. keepUndetectedMask: 0,
  91. erodeIterations: 1, // 1次腐蚀,让文字更细更锐利
  92. invertMask: 1
  93. },
  94. // 锐化专用配置(腐蚀+高阈值)
  95. crisp_text: {
  96. inputSize: 1280, // 较小尺寸,减少噪声
  97. confThresh: 0.4, // 很高置信度
  98. nmsThresh: 0.05, // 非常严格NMS
  99. maskThresh: 0.5, // 很高Mask阈值
  100. act: 'leaky',
  101. refineMode: 0,
  102. keepUndetectedMask: 0,
  103. erodeIterations: 2, // 2次腐蚀,强力锐化
  104. invertMask: 1
  105. },
  106. // 极致锐化配置(最强腐蚀+极高阈值)
  107. extreme_sharp: {
  108. inputSize: 1280, // 图像处理尺寸,适中避免过度处理
  109. confThresh: 0.5, // 置信度阈值,越高越严格
  110. nmsThresh: 0.03, // NMS阈值,越低越严格
  111. maskThresh: 0.6, // Mask阈值,越高越清晰
  112. act: 'leaky', // 激活函数
  113. refineMode: 0, // 0=INPAINT, 1=ANNOTATION
  114. keepUndetectedMask: 0,
  115. erodeIterations: 3, // 腐蚀次数,正数=腐蚀让文字更细更锐利
  116. invertMask: 1 // 反转mask(白底黑字)
  117. },
  118. // 精细平衡配置(轻微腐蚀+适中阈值,让文字细但可见)
  119. fine_balance: {
  120. inputSize: 1536, // 适中分辨率,保持细节
  121. confThresh: 0.3, // 适中置信度,保留更多文字
  122. nmsThresh: 0.1, // 适中NMS
  123. maskThresh: 0.4, // 适中阈值,平衡清晰度和可见性
  124. act: 'leaky',
  125. refineMode: 0,
  126. keepUndetectedMask: 0,
  127. erodeIterations: 1, // 只1次腐蚀,让文字细但不消失
  128. invertMask: 1
  129. },
  130. // 微调锐化配置(最小腐蚀+优化阈值)
  131. subtle_sharp: {
  132. inputSize: 1536, // 高分辨率处理
  133. confThresh: 0.25, // 较低置信度,保留细节
  134. nmsThresh: 0.15, // 适中NMS
  135. maskThresh: 0.35, // 适中阈值
  136. act: 'leaky',
  137. refineMode: 0,
  138. keepUndetectedMask: 0,
  139. erodeIterations: 1, // 1次轻微腐蚀,温和锐化
  140. invertMask: 1
  141. },
  142. // 超细文字配置(2次腐蚀但保持可见性)
  143. ultra_thin: {
  144. inputSize: 1536, // 高分辨率保持细节
  145. confThresh: 0.2, // 更低置信度,确保不丢失细节
  146. nmsThresh: 0.12, // 适中NMS
  147. maskThresh: 0.45, // 稍高阈值,让文字更细
  148. act: 'leaky',
  149. refineMode: 0,
  150. keepUndetectedMask: 0,
  151. erodeIterations: 2, // 2次腐蚀,让文字更细但仍可见
  152. invertMask: 1
  153. },
  154. // 极细文字配置(接近极限的细度)
  155. super_thin: {
  156. inputSize: 1536, // 高分辨率
  157. confThresh: 0.15, // 很低置信度,保留所有细节
  158. nmsThresh: 0.08, // 严格NMS
  159. maskThresh: 0.45, // 高阈值,让文字非常细
  160. act: 'leaky',
  161. refineMode: 0,
  162. keepUndetectedMask: 0,
  163. erodeIterations: 2.5, // 2.5次腐蚀(会被取整为2,但参数更激进)
  164. invertMask: 1
  165. }
  166. };
  167. /**
  168. * 步骤1&2&3: 生成带绿色线框的文字遮罩图和坐标JSON
  169. * @param {string} imagePath - 步骤1: 输入图片路径参数
  170. * @param {string} textMaskImgPath - 步骤2: 输出文字遮罩图路径参数
  171. * @param {string} textBlocksJsonPath - 步骤3: 输出文字区域JSON路径参数
  172. * @param {string|Object} detectionConfig - 检测配置:'standard'/'high_detail'/'ultra_detail' 或自定义配置对象
  173. * @returns {Object} 生成结果
  174. */
  175. async function startComicTextDetector(imagePath, textMaskImgPath, textBlocksJsonPath) {
  176. try {
  177. console.log('📖 开始生成带绿色线框的文字区域识别图和坐标JSON');
  178. console.log(`📷 输入图片: ${imagePath}`);
  179. console.log(`🎯 输出识别框图: ${textMaskImgPath}`);
  180. console.log(`📄 输出JSON: ${textBlocksJsonPath}`);
  181. // 步骤1: 验证imagePath参数
  182. if (!imagePath) {
  183. throw new Error('imagePath 参数不能为空');
  184. }
  185. if (!fs.existsSync(imagePath)) {
  186. throw new Error(`图片文件不存在: ${imagePath}`);
  187. }
  188. // 步骤2: 验证textMaskImgPath参数
  189. if (!textMaskImgPath) {
  190. throw new Error('textMaskImgPath 参数不能为空');
  191. }
  192. // 步骤3: 验证textBlocksJsonPath参数
  193. if (!textBlocksJsonPath) {
  194. throw new Error('textBlocksJsonPath 参数不能为空');
  195. }
  196. // 确保输出目录存在
  197. const outputDir = path.dirname(textMaskImgPath);
  198. if (!fs.existsSync(outputDir)) {
  199. fs.mkdirSync(outputDir, { recursive: true });
  200. }
  201. const jsonOutputDir = path.dirname(textBlocksJsonPath);
  202. if (!fs.existsSync(jsonOutputDir)) {
  203. fs.mkdirSync(jsonOutputDir, { recursive: true });
  204. }
  205. // 步骤4: 调用comic-text-detector生成文字区域坐标JSON
  206. console.log('\n🔍 步骤4: 正在调用文字检测器生成文字区域坐标...');
  207. const textRegions = await generateTextRegionsOnly(imagePath, outputDir, 'ultra_thin');
  208. // 步骤5: 保存坐标JSON文件
  209. console.log('\n📄 步骤5: 生成坐标JSON文件...');
  210. await saveTextRegionsJson(textRegions, textBlocksJsonPath);
  211. // 步骤6: 复制原图片并在其上绘制绿色识别框
  212. console.log('\n🎨 步骤6: 在原图片上绘制绿色文字区域识别框...');
  213. await drawGreenBoxesOnOriginalImage(imagePath, textRegions, textMaskImgPath);
  214. // 步骤7: 验证文件保存
  215. console.log('\n💾 步骤7: 验证文件保存...');
  216. await verifyMaskSaved(textMaskImgPath); // 使用现有函数验证图片
  217. await verifyJsonSaved(textBlocksJsonPath);
  218. console.log('✅ 带绿色线框的文字区域识别图和坐标JSON生成完成');
  219. return {
  220. textMaskImgPath: textMaskImgPath,
  221. textBlocksJsonPath: textBlocksJsonPath,
  222. success: true
  223. };
  224. } catch (error) {
  225. console.error(`❌ 带绿色线框的文字区域识别图生成失败: ${error.message}`);
  226. throw error;
  227. }
  228. }
  229. /**
  230. * 步骤4: 调用comic-text-detector只生成文字区域坐标
  231. * @param {string} imagePath - 输入图片路径
  232. * @param {string} outputDir - 输出目录
  233. * @param {string|Object} detectionConfig - 检测配置
  234. * @returns {Array} 文字区域数组
  235. */
  236. async function generateTextRegionsOnly(imagePath, outputDir, detectionConfig = 'standard') {
  237. const pythonEnv = getPythonPath();
  238. const pythonScript = path.join(projectRoot, 'python', 'generate-anim', 'detect_comic_text_with_boxes.py');
  239. // 检查Python脚本是否存在
  240. if (!fs.existsSync(pythonScript)) {
  241. throw new Error(`Python脚本不存在: ${pythonScript}`);
  242. }
  243. // 解析检测配置
  244. let params;
  245. if (typeof detectionConfig === 'string') {
  246. if (!DETECTION_PRESETS[detectionConfig]) {
  247. console.warn(`⚠️ 未知的预设配置: ${detectionConfig},使用标准配置`);
  248. params = DETECTION_PRESETS.standard;
  249. } else {
  250. params = DETECTION_PRESETS[detectionConfig];
  251. console.log(`📋 使用预设配置: ${detectionConfig}`);
  252. }
  253. } else if (typeof detectionConfig === 'object') {
  254. params = { ...DETECTION_PRESETS.standard, ...detectionConfig };
  255. console.log(`📋 使用自定义配置`);
  256. } else {
  257. params = DETECTION_PRESETS.standard;
  258. console.log(`📋 使用默认标准配置`);
  259. }
  260. // 构建命令 - 传递输出目录给Python脚本
  261. const command = `"${pythonEnv}" "${pythonScript}" "${imagePath}" "${outputDir}" "${projectRoot}" ${params.inputSize} ${params.confThresh} ${params.nmsThresh} ${params.maskThresh} "${params.act}" ${params.refineMode} ${params.keepUndetectedMask} ${params.erodeIterations} ${params.invertMask}`;
  262. console.log(`🔍 正在检测图片中的文字区域: ${path.basename(imagePath)}`);
  263. console.log(`⚙️ 检测参数: 尺寸=${params.inputSize}, 置信度=${params.confThresh}, Mask阈值=${params.maskThresh}, 腐蚀=${params.erodeIterations}`);
  264. // 执行Python脚本生成文字区域坐标JSON
  265. execSync(command, {
  266. encoding: 'utf-8',
  267. stdio: 'inherit',
  268. cwd: projectRoot,
  269. env: {
  270. ...process.env,
  271. PYTHONIOENCODING: 'utf-8',
  272. PYTHONUTF8: '1'
  273. },
  274. shell: true
  275. });
  276. // 读取生成的文字区域坐标JSON文件
  277. const baseImageName = path.basename(imagePath, path.extname(imagePath));
  278. const textRegionsJsonPath = path.join(outputDir, `${baseImageName}_text_regions.json`);
  279. // 等待JSON文件生成
  280. let retries = 50;
  281. while (retries > 0 && !fs.existsSync(textRegionsJsonPath)) {
  282. await new Promise(resolve => setTimeout(resolve, 100));
  283. retries--;
  284. }
  285. if (!fs.existsSync(textRegionsJsonPath)) {
  286. throw new Error(`步骤4失败: 文字区域坐标文件未生成: ${textRegionsJsonPath}`);
  287. }
  288. const textRegionsData = JSON.parse(fs.readFileSync(textRegionsJsonPath, 'utf-8'));
  289. console.log(`✅ 文字区域检测完成: 检测到 ${textRegionsData.text_blocks.length} 个区域`);
  290. return textRegionsData.text_blocks;
  291. }
  292. /**
  293. * 步骤5: 处理坐标JSON文件
  294. * @param {string} imagePath - 原图路径
  295. * @param {string} outputDir - 临时输出目录
  296. * @param {string} targetJsonPath - 目标JSON路径
  297. * @returns {Array} 文字区域数组
  298. */
  299. async function processCoordinatesJson(imagePath, outputDir, targetJsonPath) {
  300. const imgBaseName = path.basename(imagePath, path.extname(imagePath));
  301. const textRegionsJsonPath = path.join(outputDir, `${imgBaseName}_text_regions.json`);
  302. // 等待文字区域JSON文件生成
  303. let retries = 50;
  304. while (retries > 0 && !fs.existsSync(textRegionsJsonPath)) {
  305. await new Promise(resolve => setTimeout(resolve, 100));
  306. retries--;
  307. }
  308. if (!fs.existsSync(textRegionsJsonPath)) {
  309. throw new Error(`步骤5失败: 文字区域JSON文件未生成: ${textRegionsJsonPath}`);
  310. }
  311. // 读取文字区域数据
  312. console.log(`📖 读取文字区域数据: ${path.basename(textRegionsJsonPath)}`);
  313. const textRegionsContent = fs.readFileSync(textRegionsJsonPath, 'utf-8');
  314. const textRegionsData = JSON.parse(textRegionsContent);
  315. // 转换为OCR兼容格式
  316. console.log(`🔄 转换为OCR兼容格式...`);
  317. const ocrCompatibleResult = {
  318. dialogues: []
  319. };
  320. const textRegions = [];
  321. if (textRegionsData.text_blocks && Array.isArray(textRegionsData.text_blocks)) {
  322. for (const block of textRegionsData.text_blocks) {
  323. const bbox = block.bbox;
  324. // 构造四个角点坐标(左上、右上、右下、左下)
  325. const bboxPoints = [
  326. [bbox.x1, bbox.y1], // 左上
  327. [bbox.x2, bbox.y1], // 右上
  328. [bbox.x2, bbox.y2], // 右下
  329. [bbox.x1, bbox.y2] // 左下
  330. ];
  331. const region = {
  332. bbox: bboxPoints,
  333. text: `[文字区域${block.index}]`, // 占位符文字
  334. confidence: 0.95, // 高置信度,因为是专门的检测器
  335. source: 'comic-text-detector',
  336. region_info: {
  337. width: bbox.width,
  338. height: bbox.height,
  339. center_x: bbox.center_x,
  340. center_y: bbox.center_y,
  341. vertical: block.vertical,
  342. language: block.language
  343. }
  344. };
  345. ocrCompatibleResult.dialogues.push(region);
  346. textRegions.push(region);
  347. }
  348. }
  349. // 对文字区域进行排序:从右到左,从上到下
  350. console.log(`🔄 对文字区域进行排序(从右到左,从上到下)...`);
  351. textRegions.sort((a, b) => {
  352. const centerA_x = (a.bbox[0][0] + a.bbox[2][0]) / 2; // A区域中心点x
  353. const centerA_y = (a.bbox[0][1] + a.bbox[2][1]) / 2; // A区域中心点y
  354. const centerB_x = (b.bbox[0][0] + b.bbox[2][0]) / 2; // B区域中心点x
  355. const centerB_y = (b.bbox[0][1] + b.bbox[2][1]) / 2; // B区域中心点y
  356. // 首先按x坐标降序排序(越靠右越靠前)
  357. if (Math.abs(centerA_x - centerB_x) > 50) { // 如果x坐标差距较大,按x排序
  358. return centerB_x - centerA_x; // 降序:右边的在前
  359. }
  360. // 如果x坐标相近,按y坐标升序排序(越靠上越靠前)
  361. return centerA_y - centerB_y; // 升序:上面的在前
  362. });
  363. // 更新排序后的OCR兼容结果
  364. ocrCompatibleResult.dialogues = textRegions;
  365. // 保存到指定路径
  366. console.log(`💾 保存OCR兼容JSON: ${path.basename(targetJsonPath)}`);
  367. fs.writeFileSync(targetJsonPath, JSON.stringify(ocrCompatibleResult, null, 2), 'utf-8');
  368. console.log(`✅ 转换完成: ${ocrCompatibleResult.dialogues.length} 个文字区域(已按从右到左、从上到下排序)`);
  369. return textRegions;
  370. }
  371. /**
  372. * 步骤5: 保存文字区域坐标JSON文件
  373. * @param {Array} textRegions - 文字区域数组
  374. * @param {string} textBlocksJsonPath - 目标JSON文件路径
  375. */
  376. async function saveTextRegionsJson(textRegions, textBlocksJsonPath) {
  377. console.log('🔄 转换为OCR兼容格式...');
  378. // 将文字区域转换为OCR兼容的dialogues格式
  379. const dialogues = textRegions.map((block, index) => {
  380. // bbox格式: {x1: number, y1: number, x2: number, y2: number, ...}
  381. const bbox = block.bbox;
  382. const x1 = bbox.x1;
  383. const y1 = bbox.y1;
  384. const x2 = bbox.x2;
  385. const y2 = bbox.y2;
  386. const centerX = (x1 + x2) / 2;
  387. const centerY = (y1 + y2) / 2;
  388. return {
  389. bbox: [x1, y1, x2, y2], // 矩形格式
  390. center: [centerX, centerY],
  391. text: "", // 暂时为空,等待OCR识别
  392. confidence: block.confidence || 0.8,
  393. region_id: index + 1,
  394. source: "comic-text-detector"
  395. };
  396. });
  397. // 对文字区域进行排序:从右到左,然后从上到下
  398. console.log('🔄 对文字区域进行排序(从右到左,从上到下)...');
  399. dialogues.sort((a, b) => {
  400. // 先按X坐标从右到左排序(X值大的在前)
  401. const xDiff = b.center[0] - a.center[0];
  402. if (Math.abs(xDiff) > 50) { // 如果X坐标差距超过50像素,按X排序
  403. return xDiff;
  404. }
  405. // 如果X坐标接近,按Y坐标从上到下排序(Y值小的在前)
  406. return a.center[1] - b.center[1];
  407. });
  408. // 重新分配region_id
  409. dialogues.forEach((dialogue, index) => {
  410. dialogue.region_id = index + 1;
  411. });
  412. const resultJson = {
  413. image_file: "输入图片",
  414. dialogues: dialogues,
  415. total_count: dialogues.length,
  416. source: "comic-text-detector",
  417. processing_time: new Date().toISOString()
  418. };
  419. console.log(`💾 保存OCR兼容JSON: ${path.basename(textBlocksJsonPath)}`);
  420. fs.writeFileSync(textBlocksJsonPath, JSON.stringify(resultJson, null, 2), 'utf-8');
  421. console.log(`✅ 转换完成: ${dialogues.length} 个文字区域(已按从右到左、从上到下排序)`);
  422. }
  423. /**
  424. * 步骤6: 在原图片上绘制绿色文字区域识别框
  425. * @param {string} originalImagePath - 原图片路径
  426. * @param {Array} textRegions - 文字区域数组
  427. * @param {string} outputImagePath - 输出图片路径
  428. */
  429. async function drawGreenBoxesOnOriginalImage(originalImagePath, textRegions, outputImagePath) {
  430. const pythonEnv = getPythonPath();
  431. const drawScript = path.join(projectRoot, 'python', 'generate-anim', 'draw_green_boxes_on_original_image.py');
  432. // 创建绘制绿色边框的Python脚本(如果不存在)
  433. if (!fs.existsSync(drawScript)) {
  434. console.log('📝 创建绘制绿色边框的Python脚本...');
  435. await createDrawGreenBoxesOnOriginalImageScript(drawScript);
  436. }
  437. // 将文字区域数据转换为Python脚本期望的格式并写入临时JSON文件
  438. const tempJsonPath = path.join(path.dirname(outputImagePath), 'temp_text_regions_for_drawing.json');
  439. // 转换数据格式:将{x1,y1,x2,y2}格式转换为[[x1,y1],[x2,y1],[x2,y2],[x1,y2]]格式
  440. const pythonFormatRegions = textRegions.map(region => ({
  441. bbox: [
  442. [region.bbox.x1, region.bbox.y1], // 左上
  443. [region.bbox.x2, region.bbox.y1], // 右上
  444. [region.bbox.x2, region.bbox.y2], // 右下
  445. [region.bbox.x1, region.bbox.y2] // 左下
  446. ],
  447. index: region.index,
  448. vertical: region.vertical || false
  449. }));
  450. fs.writeFileSync(tempJsonPath, JSON.stringify(pythonFormatRegions, null, 2), 'utf-8');
  451. const absOriginalImagePath = path.resolve(originalImagePath);
  452. const absOutputImagePath = path.resolve(outputImagePath);
  453. const absTempJsonPath = path.resolve(tempJsonPath);
  454. const command = `"${pythonEnv}" "${drawScript}" "${absOriginalImagePath}" "${absTempJsonPath}" "${absOutputImagePath}"`;
  455. console.log(`🎨 在原图片上绘制 ${textRegions.length} 个绿色文字区域识别框...`);
  456. try {
  457. execSync(command, {
  458. encoding: 'utf-8',
  459. stdio: 'inherit',
  460. cwd: projectRoot,
  461. env: {
  462. ...process.env,
  463. PYTHONIOENCODING: 'utf-8',
  464. PYTHONUTF8: '1'
  465. },
  466. shell: true
  467. });
  468. console.log(`✅ 绿色识别框绘制完成: ${path.basename(outputImagePath)}`);
  469. } finally {
  470. // 删除临时文件
  471. if (fs.existsSync(tempJsonPath)) {
  472. fs.unlinkSync(tempJsonPath);
  473. }
  474. }
  475. }
  476. /**
  477. * 创建在原图片上绘制绿色边框的Python脚本
  478. * @param {string} scriptPath - 脚本路径
  479. */
  480. async function createDrawGreenBoxesOnOriginalImageScript(scriptPath) {
  481. const scriptContent = `#!/usr/bin/env python3
  482. # -*- coding: utf-8 -*-
  483. """
  484. 在遮罩图上绘制绿色文字区域边框
  485. """
  486. import cv2
  487. import json
  488. import sys
  489. from pathlib import Path
  490. import numpy as np
  491. def draw_green_boxes_on_original_image(image_path, regions_json_path, output_path):
  492. """
  493. 在原图片上绘制绿色边框(支持中文路径)
  494. """
  495. # 读取原图片(支持中文路径)
  496. image_data = np.fromfile(str(image_path), dtype=np.uint8)
  497. image = cv2.imdecode(image_data, cv2.IMREAD_COLOR)
  498. if image is None:
  499. raise ValueError(f"无法读取图片: {image_path}")
  500. print(f"[INFO] 图片尺寸: {image.shape[1]}x{image.shape[0]}")
  501. # 读取文字区域JSON
  502. with open(regions_json_path, 'r', encoding='utf-8') as f:
  503. text_regions = json.load(f)
  504. print(f"[INFO] 需要绘制 {len(text_regions)} 个绿色边框")
  505. # 绘制每个文字区域的绿色边框
  506. for i, region in enumerate(text_regions):
  507. bbox = region['bbox']
  508. # bbox格式: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]]
  509. x1, y1 = int(bbox[0][0]), int(bbox[0][1])
  510. x2, y2 = int(bbox[2][0]), int(bbox[2][1])
  511. # 绘制绿色矩形框
  512. cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 3) # 绿色,线宽3
  513. # 移除了编号相关逻辑
  514. print(f"[INFO] 绘制区域 {i+1}: ({x1},{y1}) -> ({x2},{y2})")
  515. # 保存结果(支持中文路径)
  516. success, encoded_img = cv2.imencode('.png', image)
  517. if success:
  518. encoded_img.tofile(str(output_path))
  519. print(f"[SUCCESS] 已保存带绿色边框的图片: {output_path}")
  520. else:
  521. raise RuntimeError(f"保存图片失败: {output_path}")
  522. def main():
  523. if len(sys.argv) != 4:
  524. print("用法: python draw_green_boxes_on_original_image.py <原图片路径> <区域JSON路径> <输出图片路径>")
  525. sys.exit(1)
  526. image_path = Path(sys.argv[1])
  527. regions_json_path = Path(sys.argv[2])
  528. output_path = Path(sys.argv[3])
  529. try:
  530. draw_green_boxes_on_original_image(image_path, regions_json_path, output_path)
  531. except Exception as e:
  532. print(f"[ERROR] 绘制失败: {e}")
  533. sys.exit(1)
  534. if __name__ == "__main__":
  535. main()
  536. `;
  537. // 确保目录存在
  538. const scriptDir = path.dirname(scriptPath);
  539. if (!fs.existsSync(scriptDir)) {
  540. fs.mkdirSync(scriptDir, { recursive: true });
  541. }
  542. fs.writeFileSync(scriptPath, scriptContent, 'utf-8');
  543. console.log(`✅ Python绘制脚本已创建: ${path.basename(scriptPath)}`);
  544. }
  545. /**
  546. * 步骤7: 验证带绿色框的图片已保存到textMaskImgPath路径
  547. * @param {string} textMaskImgPath - 带绿色框的图片路径
  548. */
  549. async function verifyMaskSaved(textMaskImgPath) {
  550. // 等待文件生成
  551. let retries = 50;
  552. while (retries > 0 && !fs.existsSync(textMaskImgPath)) {
  553. await new Promise(resolve => setTimeout(resolve, 100));
  554. retries--;
  555. }
  556. if (!fs.existsSync(textMaskImgPath)) {
  557. throw new Error(`步骤7失败: 带绿色边框的图片未保存到指定路径: ${textMaskImgPath}`);
  558. }
  559. // 验证文件大小
  560. const stats = fs.statSync(textMaskImgPath);
  561. if (stats.size === 0) {
  562. throw new Error(`步骤7失败: 生成的图片文件为空: ${textMaskImgPath}`);
  563. }
  564. console.log(`✅ 步骤7完成: 带绿色边框的图片已保存到 ${path.basename(textMaskImgPath)} (${Math.round(stats.size / 1024)}KB)`);
  565. }
  566. /**
  567. * 步骤7: 验证JSON文件已保存
  568. * @param {string} jsonPath - JSON文件路径
  569. */
  570. async function verifyJsonSaved(jsonPath) {
  571. if (!fs.existsSync(jsonPath)) {
  572. throw new Error(`步骤7失败: 坐标JSON未保存到指定路径: ${jsonPath}`);
  573. }
  574. // 验证文件内容
  575. try {
  576. const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
  577. const data = JSON.parse(jsonContent);
  578. if (data.dialogues && Array.isArray(data.dialogues)) {
  579. const regionCount = data.dialogues.length;
  580. console.log(`✅ 步骤7完成: 坐标JSON已保存到 ${path.basename(jsonPath)} (${regionCount}个区域)`);
  581. } else {
  582. throw new Error('JSON格式不正确');
  583. }
  584. } catch (error) {
  585. throw new Error(`步骤7失败: 坐标JSON文件格式错误: ${error.message}`);
  586. }
  587. }
  588. export { startComicTextDetector, DETECTION_PRESETS };