ocr.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. /**
  2. * 步骤:
  3. * 1. 创建startOcr()函数
  4. * 2. 创建变量cutDialogBlockImgNameArr 用来接收外部传入的图片路径数组
  5. * 3. 遍历cutDialogBlockImgNameArr,依次调用ocrComicImage()对图片中的的文字进行识别,根据图片所在路径保存识别结果json文件,json文件名称与图片名称相同,json文件内容与图片名称相同,json保存位置与图片所在文件夹相同
  6. * 4. 返回识别结果json文件路径数组
  7. */
  8. import { execSync } from 'child_process';
  9. import fs from 'fs';
  10. import path from 'path';
  11. import { fileURLToPath } from 'url';
  12. import { sortDialoguesByPanels } from './sort-dialog.js';
  13. import { getPythonPath as getPythonPathFromModule } from './python-path.js';
  14. // import { start as startDialogBlockReg } from './dialog-block-reg.js'; // 文件不存在,暂时注释
  15. // import { sortSentenceCharacters } from './sort-sentence-character.js'; // 未使用,暂时注释
  16. // ES 模块中获取 __dirname 的兼容方式
  17. const __filename = fileURLToPath(import.meta.url);
  18. const __dirname = path.dirname(__filename);
  19. /**
  20. * 获取项目根目录
  21. */
  22. function getProjectRoot() {
  23. return path.join(__dirname, '..');
  24. }
  25. /**
  26. * 使用OnnxOCR进行OCR识别
  27. * @param {string} imagePath - 图片路径
  28. * @param {string} outputDir - 输出目录
  29. */
  30. function ocrWithOnnxOCR(imagePath, outputDir) {
  31. const projectRoot = getProjectRoot();
  32. // 使用便携版Python
  33. const pythonEnv = getPythonPathFromModule(projectRoot);
  34. const pythonScript = path.join(projectRoot, 'python', 'generate-anim', 'ocr_with_onnxocr.py');
  35. if (!fs.existsSync(pythonScript)) {
  36. throw new Error(`OnnxOCR脚本不存在: ${pythonScript}`);
  37. }
  38. // 构建命令: python ocr_with_onnxocr.py <image_path> <text_mask_path> <output_dir> [config_json]
  39. // text_mask_path 为空字符串,config_json 使用默认配置
  40. const command = `"${pythonEnv}" "${pythonScript}" "${imagePath}" "" "${outputDir}" "{}"`;
  41. console.log(` 🔍 正在使用OnnxOCR识别文字: ${path.basename(imagePath)}`);
  42. try {
  43. execSync(command, {
  44. encoding: 'utf-8',
  45. stdio: 'pipe',
  46. cwd: projectRoot,
  47. env: {
  48. ...process.env,
  49. PYTHONIOENCODING: 'utf-8',
  50. PYTHONUTF8: '1'
  51. },
  52. shell: true
  53. });
  54. } catch (error) {
  55. console.error(` ❌ OnnxOCR识别失败: ${error.message}`);
  56. throw new Error(`OnnxOCR识别失败: ${error.message}`);
  57. }
  58. }
  59. /**
  60. * 步骤1: 创建startOcr()函数
  61. * @param {Array<string>} cutDialogBlockImgNameArr - 步骤2: 图片路径数组(外部传入)
  62. * @returns {Promise<Array>} 步骤4: 返回识别结果json文件路径数组
  63. */
  64. async function startOcr(cutDialogBlockImgNameArr) {
  65. try {
  66. console.log('🚀 开始OCR识别流程...');
  67. // 步骤2: 创建变量cutDialogBlockImgNameArr 用来接收外部传入的图片路径数组
  68. console.log('\n📋 步骤2: 验证图片路径数组参数');
  69. if (!cutDialogBlockImgNameArr || !Array.isArray(cutDialogBlockImgNameArr)) {
  70. throw new Error('步骤2失败: cutDialogBlockImgNameArr 必须是一个数组');
  71. }
  72. if (cutDialogBlockImgNameArr.length === 0) {
  73. throw new Error('步骤2失败: cutDialogBlockImgNameArr 不能为空数组');
  74. }
  75. console.log(`✅ 图片路径数组长度: ${cutDialogBlockImgNameArr.length}`);
  76. // 步骤3: 遍历cutDialogBlockImgNameArr,依次调用ocrComicImage()对图片中的的文字进行识别
  77. console.log('\n🔤 步骤3: 开始遍历图片数组进行OCR识别...');
  78. const jsonFilePaths = [];
  79. for (let i = 0; i < cutDialogBlockImgNameArr.length; i++) {
  80. const imagePath = cutDialogBlockImgNameArr[i];
  81. console.log(`\n 🔍 [${i + 1}/${cutDialogBlockImgNameArr.length}] 处理图片: ${path.basename(imagePath)}`);
  82. // 验证图片文件存在
  83. if (!fs.existsSync(imagePath)) {
  84. console.log(` ⚠️ 图片文件不存在,跳过: ${imagePath}`);
  85. continue;
  86. }
  87. // 获取图片所在文件夹路径(json保存位置与图片所在文件夹相同)
  88. const imageDir = path.dirname(imagePath);
  89. const imageName = path.basename(imagePath, path.extname(imagePath));
  90. // json文件名称与图片名称相同(只是扩展名不同)
  91. const jsonFileName = `${imageName}.json`;
  92. const jsonFilePath = path.join(imageDir, jsonFileName);
  93. console.log(` 📄 JSON将保存到: ${jsonFileName}`);
  94. // 调用OnnxOCR进行识别
  95. // outputDir设置为图片所在文件夹,这样JSON会保存到图片所在文件夹
  96. ocrWithOnnxOCR(imagePath, imageDir);
  97. // 检查JSON文件是否生成(ocrComicImage可能生成 {imageName}_dialogues.json)
  98. const possibleJsonPaths = [
  99. jsonFilePath, // {imageName}.json
  100. path.join(imageDir, `${imageName}_dialogues.json`) // {imageName}_dialogues.json
  101. ];
  102. let foundJsonPath = null;
  103. for (const possiblePath of possibleJsonPaths) {
  104. if (fs.existsSync(possiblePath)) {
  105. foundJsonPath = possiblePath;
  106. break;
  107. }
  108. }
  109. if (foundJsonPath) {
  110. // 如果生成的文件名不是期望的 {imageName}.json,则重命名
  111. if (foundJsonPath !== jsonFilePath) {
  112. // 读取JSON内容,确保包含图片名称信息
  113. const jsonContent = JSON.parse(fs.readFileSync(foundJsonPath, 'utf-8'));
  114. // 确保JSON内容包含图片名称(json文件内容与图片名称相同)
  115. if (!jsonContent.image_name) {
  116. jsonContent.image_name = imageName;
  117. }
  118. // 保存为期望的文件名
  119. fs.writeFileSync(jsonFilePath, JSON.stringify(jsonContent, null, 2), 'utf-8');
  120. // 删除旧文件
  121. fs.unlinkSync(foundJsonPath);
  122. console.log(` 📝 已重命名JSON文件: ${path.basename(foundJsonPath)} -> ${jsonFileName}`);
  123. }
  124. jsonFilePaths.push(jsonFilePath);
  125. const stats = fs.statSync(jsonFilePath);
  126. console.log(` ✅ OCR识别完成: ${jsonFileName} (${Math.round(stats.size / 1024)}KB)`);
  127. } else {
  128. console.log(` ⚠️ JSON文件未生成: ${jsonFilePath}`);
  129. }
  130. }
  131. // 步骤4: 返回识别结果json文件路径数组
  132. console.log('\n📋 步骤4: 准备返回识别结果JSON文件路径数组...');
  133. console.log(`📄 JSON文件路径列表 (${jsonFilePaths.length} 个):`);
  134. jsonFilePaths.forEach((jsonPath, index) => {
  135. console.log(` ${index + 1}. ${path.basename(jsonPath)}`);
  136. });
  137. console.log(`✅ 步骤4完成: JSON文件路径数组已准备就绪 (${jsonFilePaths.length} 个路径)`);
  138. console.log('\n🎉 所有步骤完成!');
  139. console.log(`📊 共处理 ${cutDialogBlockImgNameArr.length} 个图片`);
  140. console.log(`📊 共生成 ${jsonFilePaths.length} 个JSON文件`);
  141. return jsonFilePaths; // 步骤4: 返回识别结果json文件路径数组
  142. } catch (error) {
  143. console.error(`\n❌ OCR识别失败: ${error.message}`);
  144. throw error;
  145. }
  146. }
  147. /**
  148. * 步骤1: 创建start()函数
  149. * 步骤2: 创建变量imagePath 用来接收外部传入的图片路径
  150. * 步骤3: 创建变量outputDir 用来接收外部传入的结果保存路径
  151. * 步骤4: 根据imagePath调用ocrComicImage()函数识别漫画图片中的对白文字,和文字块区域json文件
  152. * 步骤5: 根据outputDir路径保存结果图片
  153. *
  154. * @param {string} imagePath - 图片路径(外部传入)
  155. * @param {string} outputDir - 输出目录(外部传入)
  156. * @param {string} projectRoot - 项目根目录(可选)
  157. * @returns {Object} OCR识别结果,包含对白文字和文字块区域信息
  158. */
  159. async function start(imagePath, outputDir, projectRoot = null) {
  160. try {
  161. // 步骤2: 创建变量imagePath 用来接收外部传入的图片路径
  162. if (!imagePath) {
  163. throw new Error('imagePath 参数不能为空');
  164. }
  165. // 步骤3: 创建变量outputDir 用来接收外部传入的结果保存路径
  166. if (!outputDir) {
  167. throw new Error('outputDir 参数不能为空');
  168. }
  169. if (!projectRoot) {
  170. projectRoot = getProjectRoot();
  171. }
  172. // 如果路径是相对路径,转换为绝对路径
  173. if (!path.isAbsolute(imagePath)) {
  174. imagePath = path.resolve(projectRoot, imagePath);
  175. }
  176. if (!path.isAbsolute(outputDir)) {
  177. outputDir = path.resolve(projectRoot, outputDir);
  178. }
  179. if (!fs.existsSync(imagePath)) {
  180. throw new Error(`图片文件不存在: ${imagePath}`);
  181. }
  182. // 确保输出目录存在
  183. if (!fs.existsSync(outputDir)) {
  184. fs.mkdirSync(outputDir, { recursive: true });
  185. }
  186. console.log('='.repeat(60));
  187. console.log('🔤 OCR识别漫画图片中的对白文字');
  188. console.log('='.repeat(60));
  189. console.log(`📷 图片路径: ${imagePath}`);
  190. console.log(`📂 输出目录: ${outputDir}`);
  191. // 步骤4: 根据imagePath调用ocrComicImage()函数识别漫画图片中的对白文字,和计算整个文字区域的面积,根据文字块区域给出json定位文件
  192. console.log('\n🔤 步骤4: 调用ocrComicImage()函数识别漫画图片中的对白文字,计算文字区域面积,生成定位文件...');
  193. // 注意:现在区域检测都由comic-text-detector完成,不再使用OCR进行区域检测
  194. // 如果需要进行OCR识别,请使用startOcr函数,它只进行文字识别,不进行区域检测
  195. console.log(`⚠️ start()函数已废弃,区域检测应由comic-text-detector完成`);
  196. console.log(`⚠️ 如需进行OCR识别,请使用startOcr()函数`);
  197. throw new Error('start()函数已废弃,请使用comic-text-detector进行区域检测,使用startOcr()进行OCR识别');
  198. // 步骤5: 根据outputDir路径保存结果图片和JSON文件
  199. // 使用baseImageName而不是imageName(去掉_text_mask后缀)
  200. const textBlocksJsonPath = path.join(outputDir, `${baseImageName}_text_blocks.json`);
  201. // 生成文字块区域JSON文件(从dialogues中提取bbox信息,计算面积)
  202. // 根据OCR返回的坐标信息,计算每个文字块的区域(类似参考图中的绿色框)
  203. let totalTextArea = 0; // 整个文字区域的总面积
  204. let imageWidth = 0;
  205. let imageHeight = 0;
  206. if (ocrResult.dialogues && ocrResult.dialogues.length > 0) {
  207. const textBlocks = ocrResult.dialogues.map((dialogue, index) => {
  208. let width = null;
  209. let height = null;
  210. let area = null;
  211. let center_x = null;
  212. let center_y = null;
  213. let bbox = null;
  214. // 处理bbox信息(支持多种格式)
  215. if (dialogue.bbox) {
  216. // 如果bbox已经是对象格式 {x1, y1, x2, y2, ...}
  217. if (typeof dialogue.bbox === 'object' && dialogue.bbox.x1 !== undefined) {
  218. bbox = {
  219. x1: dialogue.bbox.x1,
  220. y1: dialogue.bbox.y1,
  221. x2: dialogue.bbox.x2,
  222. y2: dialogue.bbox.y2
  223. };
  224. width = bbox.x2 - bbox.x1;
  225. height = bbox.y2 - bbox.y1;
  226. }
  227. // 如果bbox是数组格式 [x1, y1, x2, y2]
  228. else if (Array.isArray(dialogue.bbox) && dialogue.bbox.length >= 4) {
  229. bbox = {
  230. x1: dialogue.bbox[0],
  231. y1: dialogue.bbox[1],
  232. x2: dialogue.bbox[2],
  233. y2: dialogue.bbox[3]
  234. };
  235. width = bbox.x2 - bbox.x1;
  236. height = bbox.y2 - bbox.y1;
  237. }
  238. // 如果bbox已经是完整格式(包含width和height)
  239. else if (typeof dialogue.bbox === 'object' && dialogue.bbox.width !== undefined) {
  240. bbox = {
  241. x1: dialogue.bbox.x1,
  242. y1: dialogue.bbox.y1,
  243. x2: dialogue.bbox.x2,
  244. y2: dialogue.bbox.y2
  245. };
  246. width = dialogue.bbox.width;
  247. height = dialogue.bbox.height;
  248. }
  249. // 计算区域信息
  250. if (bbox && width !== null && height !== null) {
  251. area = width * height; // 计算文字块区域面积(像素²)
  252. center_x = (bbox.x1 + bbox.x2) / 2;
  253. center_y = (bbox.y1 + bbox.y2) / 2;
  254. // 累加总面积
  255. totalTextArea += area;
  256. // 更新图片尺寸(取最大坐标值)
  257. imageWidth = Math.max(imageWidth, bbox.x2);
  258. imageHeight = Math.max(imageHeight, bbox.y2);
  259. }
  260. }
  261. return {
  262. block_index: index + 1,
  263. order: dialogue.order || index + 1,
  264. text: dialogue.text || '',
  265. bbox: bbox || dialogue.bbox || null,
  266. center_x: center_x,
  267. center_y: center_y,
  268. width: width,
  269. height: height,
  270. area: area // 单个文字块区域面积(像素²)
  271. };
  272. });
  273. // 计算图片总面积
  274. const totalImageArea = imageWidth * imageHeight;
  275. const textAreaRatio = totalImageArea > 0 ? (totalTextArea / totalImageArea * 100).toFixed(2) : 0;
  276. const textBlocksResult = {
  277. image_file: imageName + path.extname(imagePath),
  278. image_size: {
  279. width: imageWidth,
  280. height: imageHeight,
  281. total_area: totalImageArea
  282. },
  283. text_blocks: textBlocks,
  284. total_count: textBlocks.length,
  285. total_text_area: totalTextArea, // 整个文字区域的总面积
  286. text_area_ratio: parseFloat(textAreaRatio) // 文字区域占图片总面积的百分比
  287. };
  288. fs.writeFileSync(textBlocksJsonPath, JSON.stringify(textBlocksResult, null, 2), 'utf-8');
  289. console.log(` ✅ 文字块区域JSON: ${path.basename(textBlocksJsonPath)}`);
  290. console.log(` 📊 文字区域总面积: ${totalTextArea.toFixed(0)} 像素²`);
  291. console.log(` 📊 文字区域占比: ${textAreaRatio}%`);
  292. } else {
  293. // 如果没有dialogues,创建一个空的text_blocks.json文件
  294. const textBlocksResult = {
  295. image_file: imageName + path.extname(imagePath),
  296. image_size: {
  297. width: 0,
  298. height: 0,
  299. total_area: 0
  300. },
  301. text_blocks: [],
  302. total_count: 0,
  303. total_text_area: 0,
  304. text_area_ratio: 0
  305. };
  306. fs.writeFileSync(textBlocksJsonPath, JSON.stringify(textBlocksResult, null, 2), 'utf-8');
  307. console.log(` ⚠️ 没有检测到文字块,已创建空的text_blocks.json文件`);
  308. }
  309. // 验证文字块区域JSON文件是否存在(这是后续流程需要的文件)
  310. if (!fs.existsSync(textBlocksJsonPath)) {
  311. throw new Error(`文字块区域JSON文件未生成: ${textBlocksJsonPath}`);
  312. // 步骤5: 生成带绿色线框的文字区域图片
  313. console.log(`\n🖼️ 步骤5: 生成带绿色线框的文字区域图片...`);
  314. const textRegionImagePath = path.join(outputDir, `${imageName}_text_region.png`);
  315. try {
  316. // 调用dialog-block-reg.js生成带绿色线框的图片
  317. // 注意: dialog-block-reg.js 文件不存在,此功能暂时禁用
  318. console.log(` ⚠️ 生成文字区域图片功能暂时禁用(dialog-block-reg.js 不存在)`);
  319. // const targetImagePath = pureTextImagePath || imagePath;
  320. // const markedImagePath = await startDialogBlockReg(
  321. // targetImagePath,
  322. // textBlocksJsonPath,
  323. // outputDir,
  324. // 2,
  325. // projectRoot
  326. // );
  327. } catch (error) {
  328. console.error(` ⚠️ 生成文字区域图片失败: ${error.message}`);
  329. }
  330. }
  331. console.log('\n✅ 步骤5: 结果已保存到outputDir目录');
  332. console.log(` ✅ 文字块区域JSON: ${path.basename(textBlocksJsonPath)}`);
  333. console.log(` ✅ 识别到 ${ocrResult.text_count} 段对白文字`);
  334. console.log('\n' + '='.repeat(60));
  335. console.log('✅ 处理完成!');
  336. console.log('='.repeat(60));
  337. // 返回包含文字块区域信息的完整结果
  338. const textRegionImagePath = path.join(outputDir, `${imageName}_text_region.png`);
  339. return {
  340. ...ocrResult,
  341. text_blocks_json_path: textBlocksJsonPath,
  342. text_region_image_path: fs.existsSync(textRegionImagePath) ? textRegionImagePath : null
  343. };
  344. } catch (error) {
  345. console.error(`\n❌ 处理失败: ${error.message}`);
  346. if (error.stack) {
  347. console.error(error.stack);
  348. }
  349. throw error;
  350. }
  351. }
  352. /**
  353. * 步骤4: 根据imagePath调用ocrComicImage()函数识别漫画图片中的对白文字
  354. * 调用Python OCR脚本识别漫画图片中的对白文字(使用气泡框检测模式)
  355. * @param {string} imagePath - 图片路径
  356. * @param {string} outputDir - 输出目录(可选)
  357. * @param {boolean} useBubbleDetection - 是否使用气泡框检测模式(默认true)
  358. * @param {boolean} useOptimized - 是否使用优化版OCR(默认true,提升准确率)
  359. * @param {string} projectRoot - 项目根目录(可选,默认从__dirname推断)
  360. * @param {string} pythonScript - Python脚本路径(可选,默认从projectRoot推断)
  361. * @param {string} pythonEnv - Python环境路径(可选,默认从projectRoot推断)
  362. * @returns {Object} OCR识别结果
  363. */
  364. function ocrComicImage(imagePath, outputDir = null, useBubbleDetection = true, useOptimized = true, projectRoot = null, pythonScript = null, pythonEnv = null) {
  365. try {
  366. // 如果没有传入projectRoot,从__dirname推断
  367. if (!projectRoot) {
  368. projectRoot = path.join(__dirname, '..');
  369. }
  370. // 如果没有传入pythonScript,从projectRoot推断
  371. if (!pythonScript) {
  372. pythonScript = path.join(projectRoot, 'python', 'generate-anim', 'detect_and_ocr_comic.py');
  373. }
  374. // 如果没有传入pythonEnv,从projectRoot推断
  375. if (!pythonEnv) {
  376. pythonEnv = getPythonPath(projectRoot);
  377. }
  378. // 使用虚拟环境的 Python,不需要设置 PYTHONPATH
  379. const env = { ...process.env };
  380. // 构建命令
  381. let command = `"${pythonEnv}" "${pythonScript}" "${imagePath}"`;
  382. if (outputDir) {
  383. command += ` -o "${outputDir}"`;
  384. }
  385. console.log(`🔍 正在检测文字区域并识别文字: ${path.basename(imagePath)}`);
  386. // 执行Python脚本
  387. execSync(command, {
  388. encoding: 'utf-8',
  389. stdio: 'inherit',
  390. cwd: projectRoot,
  391. env: env
  392. });
  393. // 读取生成的JSON文件
  394. const imageDir = path.dirname(imagePath);
  395. const imageName = path.basename(imagePath, path.extname(imagePath));
  396. // 检测结果文件名
  397. const jsonFileName = `${imageName}_dialogues.json`;
  398. // 最终JSON文件保存到outputDir(ocr目录)
  399. const jsonPath = outputDir
  400. ? path.join(outputDir, jsonFileName)
  401. : path.join(imageDir, jsonFileName);
  402. // 中间文件在tmp目录
  403. const tmpDir = outputDir
  404. ? path.join(outputDir, 'tmp')
  405. : path.join(imageDir, 'tmp');
  406. if (fs.existsSync(jsonPath)) {
  407. const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
  408. const ocrResult = JSON.parse(jsonContent);
  409. // 读取格子信息(从tmp目录)
  410. const panelsJsonPath = path.join(tmpDir, `${imageName}_panels.json`);
  411. let panels = [];
  412. if (fs.existsSync(panelsJsonPath)) {
  413. const panelsContent = fs.readFileSync(panelsJsonPath, 'utf-8');
  414. const panelsData = JSON.parse(panelsContent);
  415. panels = panelsData.panels || [];
  416. }
  417. // 获取图片尺寸(从OCR结果或panels中推断)
  418. let imageWidth = 1334; // 默认值
  419. let imageHeight = 1940; // 默认值
  420. // 如果有对话,从第一个对话的bbox推断图片尺寸
  421. if (ocrResult.dialogues && ocrResult.dialogues.length > 0) {
  422. const firstDialogue = ocrResult.dialogues[0];
  423. if (firstDialogue.bbox) {
  424. // 估算图片尺寸(bbox通常是相对坐标,需要估算)
  425. imageWidth = Math.max(imageWidth, firstDialogue.bbox.x2 || 1334);
  426. imageHeight = Math.max(imageHeight, firstDialogue.bbox.y2 || 1940);
  427. }
  428. }
  429. // 步骤1: 先排序所有识别到的气泡框里的对话
  430. console.log(`📋 步骤1: 排序对话(基于格子位置)...`);
  431. let sortedDialogues = ocrResult.dialogues || [];
  432. // 确保每个对话都有bbox信息
  433. sortedDialogues = sortedDialogues.map((dialogue, index) => {
  434. if (!dialogue.bbox && dialogue.order) {
  435. // 如果没有bbox,尝试从原始数据中获取
  436. // 这里假设原始数据中有bbox信息
  437. return dialogue;
  438. }
  439. return dialogue;
  440. });
  441. // 如果有格子信息,使用格子排序
  442. if (panels.length > 0 && sortedDialogues.length > 0) {
  443. // 确保所有对话都有bbox
  444. const dialoguesWithBbox = sortedDialogues.filter(d => d.bbox);
  445. if (dialoguesWithBbox.length > 0) {
  446. sortedDialogues = sortDialoguesByPanels(
  447. dialoguesWithBbox,
  448. panels,
  449. imageWidth,
  450. imageHeight
  451. );
  452. }
  453. }
  454. // 步骤2: 对每个对话内的字符进行排序
  455. console.log(`📝 步骤2: 排序每个对话内的字符...`);
  456. // 计算实际图片高度(从所有对话的bbox中找到最大的y2值)
  457. let actualImageHeight = imageHeight;
  458. if (sortedDialogues.length > 0) {
  459. const maxY2 = Math.max(...sortedDialogues
  460. .filter(d => d.bbox && d.bbox.y2)
  461. .map(d => d.bbox.y2));
  462. if (maxY2 > 0) {
  463. actualImageHeight = Math.max(actualImageHeight, maxY2);
  464. }
  465. }
  466. const finalDialogues = sortedDialogues.map((dialogue, index) => {
  467. // 暂时跳过字符排序,直接使用OCR原始识别结果
  468. const originalText = dialogue.text;
  469. // 调试输出:显示OCR原始识别结果
  470. if (originalText && (originalText.includes('远道') || originalText.includes('石田'))) {
  471. console.log(` [DEBUG ocr.js] 对话${dialogue.order || index + 1}: OCR原始文本="${originalText}"`);
  472. }
  473. return {
  474. order: dialogue.order || index + 1,
  475. text: originalText, // 直接使用OCR原始文本,不排序
  476. bbox: dialogue.bbox || null,
  477. character_positions: dialogue.character_positions || null
  478. };
  479. });
  480. // 保存排序后的结果
  481. const finalResult = {
  482. image_file: ocrResult.image_file || path.basename(imagePath),
  483. reading_order: '从右到左、从上到下(日式漫画阅读顺序)',
  484. dialogues: finalDialogues,
  485. total_count: finalDialogues.length
  486. };
  487. // 覆盖原JSON文件
  488. fs.writeFileSync(jsonPath, JSON.stringify(finalResult, null, 2), 'utf-8');
  489. console.log(`✅ 已保存排序后的对话结果: ${jsonPath}`);
  490. // 统一返回格式
  491. const result = {
  492. text_count: finalResult.total_count,
  493. texts: finalDialogues.map(d => d.text),
  494. dialogues: finalDialogues,
  495. reading_order: finalResult.reading_order
  496. };
  497. console.log(`✅ OCR识别完成: 识别到 ${result.text_count} 段文字`);
  498. return result;
  499. } else {
  500. throw new Error(`OCR结果文件不存在: ${jsonPath}`);
  501. }
  502. } catch (error) {
  503. console.error(`❌ OCR识别失败: ${error.message}`);
  504. throw error;
  505. }
  506. }
  507. /**
  508. * 使用PaddleOCR文本检测模块进行精确的文字区域检测(不依赖inference模块)
  509. * @param {string} imagePath - 图片路径
  510. * @param {string} textMaskPath - 文字遮罩图路径(可选,当前未使用)
  511. * @param {string} outputDir - 输出目录
  512. * @param {string} projectRoot - 项目根目录
  513. * @returns {Object} OCR识别结果
  514. */
  515. async function ocrWithPaddleOcr(imagePath, textMaskPath, outputDir, projectRoot) {
  516. try {
  517. // 使用便携版Python
  518. const pythonEnv = getPythonPathFromModule(projectRoot);
  519. // 使用新的文本检测脚本
  520. const detectionScript = path.join(projectRoot, 'python', 'generate-anim', 'paddleocr_text_detection.py');
  521. console.log(`🔍 使用PaddleOCR文本检测模块检测文字区域: ${path.basename(imagePath)}`);
  522. console.log(`📷 检测图片路径: ${imagePath}`);
  523. console.log(`📂 输出目录: ${outputDir}`);
  524. // 调用文本检测脚本(只做检测,不做识别)
  525. // 确保使用原始图片路径(图1)进行检测
  526. // 使用绝对路径避免编码问题
  527. const absImagePath = path.resolve(imagePath);
  528. const absOutputDir = path.resolve(outputDir);
  529. const command = `"${pythonEnv}" "${detectionScript}" "${absImagePath}" "${absOutputDir}" 0.5`;
  530. execSync(command, {
  531. encoding: 'utf-8',
  532. stdio: 'inherit',
  533. cwd: projectRoot,
  534. env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
  535. });
  536. // 读取生成的JSON文件(只读取text_blocks.json,不读取dialogues.json)
  537. const imageName = path.basename(imagePath, path.extname(imagePath));
  538. // 如果文件名包含_text_mask,去掉这个后缀
  539. let baseImageName = imageName;
  540. if (baseImageName.endsWith('_text_mask')) {
  541. baseImageName = baseImageName.replace(/_text_mask$/, '');
  542. }
  543. const textBlocksJsonPath = path.join(outputDir, `${baseImageName}_text_blocks.json`);
  544. // 等待文件生成
  545. let retries = 50;
  546. while (retries > 0 && !fs.existsSync(textBlocksJsonPath)) {
  547. const start = Date.now();
  548. while (Date.now() - start < 100) {
  549. // 等待100ms
  550. }
  551. retries--;
  552. }
  553. if (fs.existsSync(textBlocksJsonPath)) {
  554. const jsonContent = fs.readFileSync(textBlocksJsonPath, 'utf-8');
  555. const ocrResult = JSON.parse(jsonContent);
  556. // 从text_blocks转换为dialogues格式(用于返回)
  557. const dialogues = (ocrResult.text_blocks || []).map((block, index) => ({
  558. order: block.order || block.block_index || index + 1,
  559. text: block.text || '',
  560. bbox: block.bbox || null
  561. }));
  562. // 统一返回格式
  563. return {
  564. text_count: ocrResult.total_count || dialogues.length || 0,
  565. texts: dialogues.map(d => d.text) || [],
  566. dialogues: dialogues,
  567. reading_order: '从右到左、从上到下(日式漫画阅读顺序)'
  568. };
  569. } else {
  570. throw new Error(`文字块区域JSON文件未生成: ${textBlocksJsonPath}`);
  571. }
  572. } catch (error) {
  573. console.error(`❌ PaddleOCR文本检测失败: ${error.message}`);
  574. throw error;
  575. }
  576. }
  577. /**
  578. * 从text_detection.json生成dialogues.json(当OCR失败时的备用方案)
  579. * @param {string} imagePath - 图片路径
  580. * @param {string} textDetectionJsonPath - text_detection.json路径
  581. * @param {string} outputDir - 输出目录
  582. * @param {string} projectRoot - 项目根目录
  583. * @returns {Object} OCR识别结果
  584. */
  585. async function ocrFromTextDetection(imagePath, textDetectionJsonPath, outputDir, projectRoot) {
  586. try {
  587. // 读取text_detection.json
  588. const textDetectionData = JSON.parse(fs.readFileSync(textDetectionJsonPath, 'utf-8'));
  589. const textBlocks = textDetectionData.text_blocks || [];
  590. console.log(`📖 从text_detection.json读取到 ${textBlocks.length} 个文字块`);
  591. // 将text_blocks转换为dialogues格式
  592. const dialogues = textBlocks.map((block, index) => ({
  593. order: index + 1,
  594. text: '', // text_detection.json中没有文本内容,需要后续OCR识别
  595. bbox: block.bbox || null
  596. }));
  597. // 注意:如果imagePath是text_mask.png,需要提取原始图片名称
  598. let imageName = path.basename(imagePath, path.extname(imagePath));
  599. // 如果文件名包含_text_mask,去掉这个后缀
  600. if (imageName.endsWith('_text_mask')) {
  601. imageName = imageName.replace(/_text_mask$/, '');
  602. }
  603. // 不再生成dialogues.json文件,因为后续流程只使用text_blocks.json
  604. // dialogues.json文件中的text字段都是空的,没有实际用途
  605. return {
  606. text_count: dialogues.length,
  607. texts: dialogues.map(d => d.text),
  608. dialogues: dialogues,
  609. reading_order: '从右到左、从上到下(日式漫画阅读顺序)'
  610. };
  611. } catch (error) {
  612. console.error(`❌ 从text_detection.json生成dialogues失败: ${error.message}`);
  613. throw error;
  614. }
  615. }
  616. /**
  617. * 检查Python依赖是否已安装
  618. * @param {string} pythonPath - Python可执行文件路径
  619. * @returns {boolean} 依赖是否已安装
  620. */
  621. function checkPythonDependencies(pythonPath) {
  622. try {
  623. // 检查基础依赖(comic-text-detector 和 onnxocr 需要的)
  624. const checkCommand = `"${pythonPath}" -c "import torch; import cv2; import numpy; from onnxocr import onnx_paddleocr; print('OK')"`;
  625. const result = execSync(checkCommand, {
  626. encoding: 'utf-8',
  627. stdio: 'pipe',
  628. timeout: 10000,
  629. windowsHide: true
  630. });
  631. // 如果命令执行成功(没有抛出异常),说明依赖已安装
  632. return result.includes('OK') || true; // 只要没抛异常就认为成功
  633. } catch (error) {
  634. // 如果导入失败,说明依赖未安装
  635. return false;
  636. }
  637. }
  638. /**
  639. * 获取Python可执行文件路径(Windows 11)
  640. * @param {string} projectRoot - 项目根目录
  641. * @returns {string} Python可执行文件路径
  642. */
  643. function getPythonPath(projectRoot) {
  644. // 使用便携版Python
  645. return getPythonPathFromModule(projectRoot);
  646. }
  647. /**
  648. * 批量识别目录下的所有图片
  649. * @param {string} imageDir - 图片目录
  650. * @param {string} outputDir - 输出目录(可选)
  651. * @returns {Array} OCR识别结果列表
  652. */
  653. function ocrComicImages(imageDir, outputDir = null) {
  654. try {
  655. // 读取目录下的所有图片
  656. const imageFiles = fs.readdirSync(imageDir)
  657. .filter(file => /\.(jpg|jpeg|png|bmp|webp)$/i.test(file))
  658. .sort();
  659. const results = [];
  660. for (const imageFile of imageFiles) {
  661. const imagePath = path.join(imageDir, imageFile);
  662. try {
  663. const result = ocrComicImage(imagePath, outputDir);
  664. results.push(result);
  665. } catch (error) {
  666. console.error(`❌ 处理 ${imageFile} 失败: ${error.message}`);
  667. }
  668. }
  669. console.log(`✅ 批量OCR识别完成: 共处理 ${results.length} 张图片`);
  670. return results;
  671. } catch (error) {
  672. console.error(`❌ OCR识别失败: ${error.message}`);
  673. throw error;
  674. }
  675. }
  676. /**
  677. * 读取OCR JSON文件
  678. * @param {string} jsonPath - JSON文件路径
  679. * @returns {Object} OCR识别结果
  680. */
  681. function readOcrJson(jsonPath) {
  682. try {
  683. if (!fs.existsSync(jsonPath)) {
  684. return null;
  685. }
  686. const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
  687. return JSON.parse(jsonContent);
  688. } catch (error) {
  689. console.error(`❌ 读取OCR JSON失败: ${error.message}`);
  690. return null;
  691. }
  692. }
  693. // 导出函数
  694. export { start, startOcr, ocrComicImage, ocrComicImages, readOcrJson };
  695. // 自动执行的测试代码已删除
  696. // 现在区域检测都由comic-text-detector完成,不再使用OCR进行区域检测