id-scanner-lib 1.6.6 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/id-scanner-lib.esm.js +915 -838
- package/dist/id-scanner-lib.esm.js.map +1 -1
- package/dist/id-scanner-lib.js +915 -838
- package/dist/id-scanner-lib.js.map +1 -1
- package/package.json +1 -1
- package/src/core/camera-manager.ts +43 -76
- package/src/core/camera-stream-manager.ts +318 -0
- package/src/core/logger.ts +158 -81
- package/src/modules/face/face-comparator.ts +150 -0
- package/src/modules/face/face-detector-options.ts +104 -0
- package/src/modules/face/face-detector.ts +121 -376
- package/src/modules/face/face-detector.ts.bak +991 -0
- package/src/modules/face/face-model-loader.ts +222 -0
- package/src/modules/face/face-result-converter.ts +225 -0
- package/src/modules/face/face-tracker.ts +207 -0
- package/src/modules/face/liveness-detector.ts +2 -2
- package/src/modules/id-card/id-card-text-parser.ts +151 -0
- package/src/modules/id-card/ocr-processor.ts +20 -257
- package/src/modules/id-card/ocr-worker.ts +2 -183
- package/src/utils/canvas-pool.ts +273 -0
- package/src/utils/edge-detector.ts +232 -0
- package/src/utils/image-processing.ts +110 -446
- package/src/utils/index.ts +1 -0
- package/src/core/plugin-manager.ts +0 -429
|
@@ -282,18 +282,20 @@ class ConsoleLogHandler {
|
|
|
282
282
|
handle(entry) {
|
|
283
283
|
const timestamp = new Date(entry.timestamp).toISOString();
|
|
284
284
|
const prefix = `[${timestamp}] [${entry.level.toUpperCase()}] [${entry.tag}]`;
|
|
285
|
+
// 优先使用 error,其次使用 data
|
|
286
|
+
const extra = entry.error || entry.data || '';
|
|
285
287
|
switch (entry.level) {
|
|
286
288
|
case LoggerLevel.DEBUG:
|
|
287
|
-
console.debug(prefix, entry.message,
|
|
289
|
+
console.debug(prefix, entry.message, extra);
|
|
288
290
|
break;
|
|
289
291
|
case LoggerLevel.INFO:
|
|
290
|
-
console.info(prefix, entry.message,
|
|
292
|
+
console.info(prefix, entry.message, extra);
|
|
291
293
|
break;
|
|
292
294
|
case LoggerLevel.WARN:
|
|
293
|
-
console.warn(prefix, entry.message,
|
|
295
|
+
console.warn(prefix, entry.message, extra);
|
|
294
296
|
break;
|
|
295
297
|
case LoggerLevel.ERROR:
|
|
296
|
-
console.error(prefix, entry.message,
|
|
298
|
+
console.error(prefix, entry.message, extra);
|
|
297
299
|
break;
|
|
298
300
|
// 输出什么也不做
|
|
299
301
|
}
|
|
@@ -367,10 +369,13 @@ class RemoteLogHandler {
|
|
|
367
369
|
this.queue = [];
|
|
368
370
|
/** 定时发送的计时器ID */
|
|
369
371
|
this.timerId = null;
|
|
372
|
+
/** 当前连续失败计数 */
|
|
373
|
+
this.consecutiveFailures = 0;
|
|
370
374
|
this.endpoint = endpoint;
|
|
371
375
|
this.maxQueueSize = maxQueueSize;
|
|
372
376
|
this.flushInterval = flushInterval;
|
|
373
377
|
this.isBrowser = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
|
|
378
|
+
this.maxConsecutiveFailures = 10;
|
|
374
379
|
// 设置定时发送
|
|
375
380
|
this.startTimer();
|
|
376
381
|
// 页面卸载前尝试发送剩余日志
|
|
@@ -400,47 +405,55 @@ class RemoteLogHandler {
|
|
|
400
405
|
flush() {
|
|
401
406
|
if (this.queue.length === 0)
|
|
402
407
|
return;
|
|
403
|
-
|
|
404
|
-
this.
|
|
405
|
-
|
|
406
|
-
const sendCount = this._sendCount || 0;
|
|
407
|
-
this._sendCount = sendCount + 1;
|
|
408
|
-
// 如果发送次数过多,停止发送以防止无限循环
|
|
409
|
-
if (sendCount > 10) {
|
|
410
|
-
console.warn('RemoteLogHandler: Too many failed sends, stopping. Clear queue.');
|
|
408
|
+
// 如果连续失败次数过多,停止发送以防止无限循环
|
|
409
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
410
|
+
console.warn('RemoteLogHandler: Too many consecutive failures, stopping. Clear queue.');
|
|
411
411
|
this.queue = [];
|
|
412
|
-
this.
|
|
412
|
+
this.consecutiveFailures = 0;
|
|
413
413
|
return;
|
|
414
414
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
415
|
+
const entriesToSend = [...this.queue];
|
|
416
|
+
this.queue = [];
|
|
417
|
+
this.sendLogEntries(entriesToSend);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 发送日志条目到远程服务器
|
|
421
|
+
* @param entries 日志条目数组
|
|
422
|
+
*/
|
|
423
|
+
sendLogEntries(entries) {
|
|
424
|
+
if (entries.length === 0)
|
|
425
|
+
return;
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s 超时
|
|
428
|
+
fetch(this.endpoint, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: {
|
|
431
|
+
'Content-Type': 'application/json'
|
|
432
|
+
},
|
|
433
|
+
body: JSON.stringify(entries),
|
|
434
|
+
keepalive: true,
|
|
435
|
+
signal: controller.signal
|
|
436
|
+
}).then(() => {
|
|
437
|
+
clearTimeout(timeoutId);
|
|
438
|
+
this.consecutiveFailures = 0; // 发送成功,重置失败计数
|
|
439
|
+
}).catch((err) => {
|
|
440
|
+
clearTimeout(timeoutId);
|
|
441
|
+
console.error('Failed to send logs to remote server:', err);
|
|
442
|
+
this.consecutiveFailures++;
|
|
443
|
+
// 如果失败次数过多,丢弃日志防止内存泄漏
|
|
444
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
445
|
+
console.warn('RemoteLogHandler: Max consecutive failures exceeded, discarding logs');
|
|
446
|
+
this.queue = [];
|
|
447
|
+
this.consecutiveFailures = 0;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// 失败时把日志放回队列,但防止无限增长
|
|
451
|
+
const maxReturn = Math.min(entries.length, this.maxQueueSize - this.queue.length);
|
|
452
|
+
if (maxReturn > 0) {
|
|
453
|
+
const returnedEntries = entries.slice(0, maxReturn);
|
|
454
|
+
this.queue = [...returnedEntries, ...this.queue];
|
|
455
|
+
}
|
|
456
|
+
});
|
|
444
457
|
}
|
|
445
458
|
/**
|
|
446
459
|
* 开始定时发送
|
|
@@ -450,7 +463,7 @@ class RemoteLogHandler {
|
|
|
450
463
|
return;
|
|
451
464
|
if (this.timerId !== null)
|
|
452
465
|
return;
|
|
453
|
-
this.timerId =
|
|
466
|
+
this.timerId = setInterval(() => {
|
|
454
467
|
this.flush();
|
|
455
468
|
}, this.flushInterval);
|
|
456
469
|
}
|
|
@@ -459,7 +472,7 @@ class RemoteLogHandler {
|
|
|
459
472
|
*/
|
|
460
473
|
stopTimer() {
|
|
461
474
|
if (this.timerId !== null) {
|
|
462
|
-
|
|
475
|
+
clearInterval(this.timerId);
|
|
463
476
|
this.timerId = null;
|
|
464
477
|
}
|
|
465
478
|
}
|
|
@@ -536,37 +549,57 @@ class Logger {
|
|
|
536
549
|
* 记录调试级别日志
|
|
537
550
|
* @param tag 标签
|
|
538
551
|
* @param message 消息
|
|
539
|
-
* @param
|
|
552
|
+
* @param errorOrData 错误对象或结构化数据
|
|
540
553
|
*/
|
|
541
|
-
debug(tag, message,
|
|
542
|
-
|
|
554
|
+
debug(tag, message, errorOrData) {
|
|
555
|
+
if (errorOrData instanceof Error) {
|
|
556
|
+
this.log(LoggerLevel.DEBUG, tag, message, errorOrData);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
this.logWithData(LoggerLevel.DEBUG, tag, message, errorOrData);
|
|
560
|
+
}
|
|
543
561
|
}
|
|
544
562
|
/**
|
|
545
563
|
* 记录信息级别日志
|
|
546
564
|
* @param tag 标签
|
|
547
565
|
* @param message 消息
|
|
548
|
-
* @param
|
|
566
|
+
* @param errorOrData 错误对象或结构化数据
|
|
549
567
|
*/
|
|
550
|
-
info(tag, message,
|
|
551
|
-
|
|
568
|
+
info(tag, message, errorOrData) {
|
|
569
|
+
if (errorOrData instanceof Error) {
|
|
570
|
+
this.log(LoggerLevel.INFO, tag, message, errorOrData);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
this.logWithData(LoggerLevel.INFO, tag, message, errorOrData);
|
|
574
|
+
}
|
|
552
575
|
}
|
|
553
576
|
/**
|
|
554
577
|
* 记录警告级别日志
|
|
555
578
|
* @param tag 标签
|
|
556
579
|
* @param message 消息
|
|
557
|
-
* @param
|
|
580
|
+
* @param errorOrData 错误对象或结构化数据
|
|
558
581
|
*/
|
|
559
|
-
warn(tag, message,
|
|
560
|
-
|
|
582
|
+
warn(tag, message, errorOrData) {
|
|
583
|
+
if (errorOrData instanceof Error) {
|
|
584
|
+
this.log(LoggerLevel.WARN, tag, message, errorOrData);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
this.logWithData(LoggerLevel.WARN, tag, message, errorOrData);
|
|
588
|
+
}
|
|
561
589
|
}
|
|
562
590
|
/**
|
|
563
591
|
* 记录错误级别日志
|
|
564
592
|
* @param tag 标签
|
|
565
593
|
* @param message 消息
|
|
566
|
-
* @param
|
|
594
|
+
* @param errorOrData 错误对象或结构化数据
|
|
567
595
|
*/
|
|
568
|
-
error(tag, message,
|
|
569
|
-
|
|
596
|
+
error(tag, message, errorOrData) {
|
|
597
|
+
if (errorOrData instanceof Error) {
|
|
598
|
+
this.log(LoggerLevel.ERROR, tag, message, errorOrData);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
this.logWithData(LoggerLevel.ERROR, tag, message, errorOrData);
|
|
602
|
+
}
|
|
570
603
|
}
|
|
571
604
|
/**
|
|
572
605
|
* 创建标记了特定标签的日志记录器
|
|
@@ -611,6 +644,42 @@ class Logger {
|
|
|
611
644
|
this.consoleOutput(entry);
|
|
612
645
|
}
|
|
613
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* 记录日志(支持结构化数据)
|
|
649
|
+
* @param level 日志级别
|
|
650
|
+
* @param tag 标签
|
|
651
|
+
* @param message 消息
|
|
652
|
+
* @param data 结构化数据
|
|
653
|
+
*/
|
|
654
|
+
logWithData(level, tag, message, data) {
|
|
655
|
+
// 检查日志级别
|
|
656
|
+
const levelValue = this.getLevelValue(level);
|
|
657
|
+
const currentLevelValue = this.getLevelValue(this.logLevel);
|
|
658
|
+
if (levelValue < currentLevelValue) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// 创建日志条目
|
|
662
|
+
const entry = {
|
|
663
|
+
timestamp: Date.now(),
|
|
664
|
+
level: level,
|
|
665
|
+
tag: tag || this.defaultTag,
|
|
666
|
+
message,
|
|
667
|
+
data
|
|
668
|
+
};
|
|
669
|
+
// 分发到所有处理程序
|
|
670
|
+
for (const handler of this.handlers) {
|
|
671
|
+
try {
|
|
672
|
+
handler.handle(entry);
|
|
673
|
+
}
|
|
674
|
+
catch (handlerError) {
|
|
675
|
+
console.error(`[Logger] 处理程序错误:`, handlerError);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// 如果没有处理程序,使用控制台
|
|
679
|
+
if (this.handlers.length === 0) {
|
|
680
|
+
this.consoleOutput(entry);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
614
683
|
/**
|
|
615
684
|
* 控制台输出
|
|
616
685
|
* @param entry 日志条目
|
|
@@ -618,18 +687,20 @@ class Logger {
|
|
|
618
687
|
consoleOutput(entry) {
|
|
619
688
|
const timestamp = new Date(entry.timestamp).toISOString();
|
|
620
689
|
const prefix = `[${timestamp}] [${entry.level.toUpperCase()}] [${entry.tag}]`;
|
|
690
|
+
// 构造日志内容:错误对象或数据对象
|
|
691
|
+
const extra = entry.error || entry.data || '';
|
|
621
692
|
switch (entry.level) {
|
|
622
693
|
case LoggerLevel.DEBUG:
|
|
623
|
-
console.debug(`${prefix} ${entry.message}`,
|
|
694
|
+
console.debug(`${prefix} ${entry.message}`, extra);
|
|
624
695
|
break;
|
|
625
696
|
case LoggerLevel.INFO:
|
|
626
|
-
console.info(`${prefix} ${entry.message}`,
|
|
697
|
+
console.info(`${prefix} ${entry.message}`, extra);
|
|
627
698
|
break;
|
|
628
699
|
case LoggerLevel.WARN:
|
|
629
|
-
console.warn(`${prefix} ${entry.message}`,
|
|
700
|
+
console.warn(`${prefix} ${entry.message}`, extra);
|
|
630
701
|
break;
|
|
631
702
|
case LoggerLevel.ERROR:
|
|
632
|
-
console.error(`${prefix} ${entry.message}`,
|
|
703
|
+
console.error(`${prefix} ${entry.message}`, extra);
|
|
633
704
|
break;
|
|
634
705
|
}
|
|
635
706
|
}
|
|
@@ -1596,93 +1667,685 @@ class IDCardDetector extends EventEmitter {
|
|
|
1596
1667
|
}
|
|
1597
1668
|
|
|
1598
1669
|
/**
|
|
1599
|
-
*
|
|
1600
|
-
* @description 提供图像预处理功能,用于提高OCR识别率
|
|
1601
|
-
* @module ImageProcessor
|
|
1602
|
-
* @version 1.3.2
|
|
1670
|
+
* 格式化日期字符串为标准格式 (YYYY-MM-DD)
|
|
1603
1671
|
*/
|
|
1672
|
+
function formatDateString(dateStr) {
|
|
1673
|
+
const dateMatch = dateStr.match(/(\d{4})[-\.\u5e74\s]*(\d{1,2})[-\.\u6708\s]*(\d{1,2})[日]*/);
|
|
1674
|
+
if (dateMatch) {
|
|
1675
|
+
const year = dateMatch[1];
|
|
1676
|
+
const month = dateMatch[2].padStart(2, "0");
|
|
1677
|
+
const day = dateMatch[3].padStart(2, "0");
|
|
1678
|
+
return `${year}-${month}-${day}`;
|
|
1679
|
+
}
|
|
1680
|
+
if (/^\d{8}$/.test(dateStr)) {
|
|
1681
|
+
const year = dateStr.substring(0, 4);
|
|
1682
|
+
const month = dateStr.substring(4, 6);
|
|
1683
|
+
const day = dateStr.substring(6, 8);
|
|
1684
|
+
return `${year}-${month}-${day}`;
|
|
1685
|
+
}
|
|
1686
|
+
return dateStr;
|
|
1687
|
+
}
|
|
1604
1688
|
/**
|
|
1605
|
-
*
|
|
1689
|
+
* IDCardTextParser - 统一解析身份证OCR文本
|
|
1690
|
+
* 提取 ocr-processor.ts 和 ocr-worker.ts 中的解析逻辑
|
|
1691
|
+
*/
|
|
1692
|
+
class IDCardTextParser {
|
|
1693
|
+
/**
|
|
1694
|
+
* 解析身份证文本
|
|
1695
|
+
* @param text OCR识别的原始文本
|
|
1696
|
+
* @returns 解析后的身份证信息
|
|
1697
|
+
*/
|
|
1698
|
+
static parse(text) {
|
|
1699
|
+
const info = {};
|
|
1700
|
+
const processedText = text.replace(/\s+/g, " ").trim();
|
|
1701
|
+
const lines = processedText.split("\n").filter((line) => line.trim());
|
|
1702
|
+
// 1. 解析身份证号码
|
|
1703
|
+
const idNumberRegex = /(\d{17}[\dX])/;
|
|
1704
|
+
const idNumberWithPrefixRegex = /公民身份号码[\s\:]*(\d{17}[\dX])/;
|
|
1705
|
+
const basicMatch = processedText.match(idNumberRegex);
|
|
1706
|
+
const prefixMatch = processedText.match(idNumberWithPrefixRegex);
|
|
1707
|
+
if (prefixMatch && prefixMatch[1]) {
|
|
1708
|
+
info.idNumber = prefixMatch[1];
|
|
1709
|
+
}
|
|
1710
|
+
else if (basicMatch && basicMatch[1]) {
|
|
1711
|
+
info.idNumber = basicMatch[1];
|
|
1712
|
+
}
|
|
1713
|
+
// 2. 解析姓名
|
|
1714
|
+
const nameWithLabelRegex = /姓名[\s\:]*([一-龥]{2,4})/;
|
|
1715
|
+
const nameMatch = processedText.match(nameWithLabelRegex);
|
|
1716
|
+
if (nameMatch && nameMatch[1]) {
|
|
1717
|
+
info.name = nameMatch[1].trim();
|
|
1718
|
+
}
|
|
1719
|
+
else {
|
|
1720
|
+
for (const line of lines) {
|
|
1721
|
+
if (line.length >= 2 && line.length <= 5 && /^[一-龥]+$/.test(line) &&
|
|
1722
|
+
!/性别|民族|住址|公民|签发|有效/.test(line)) {
|
|
1723
|
+
info.name = line.trim();
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
// 3. 解析性别和民族
|
|
1729
|
+
const genderAndNationalityRegex = /性别[\s\:]*([男女])[\s ]*民族[\s\:]*([一-龥]+族)/;
|
|
1730
|
+
const genderOnlyRegex = /性别[\s\:]*([男女])/;
|
|
1731
|
+
const nationalityOnlyRegex = /民族[\s\:]*([一-龥]+族)/;
|
|
1732
|
+
const genderNationalityMatch = processedText.match(genderAndNationalityRegex);
|
|
1733
|
+
const genderOnlyMatch = processedText.match(genderOnlyRegex);
|
|
1734
|
+
const nationalityOnlyMatch = processedText.match(nationalityOnlyRegex);
|
|
1735
|
+
if (genderNationalityMatch) {
|
|
1736
|
+
info.gender = genderNationalityMatch[1];
|
|
1737
|
+
info.ethnicity = genderNationalityMatch[2];
|
|
1738
|
+
}
|
|
1739
|
+
else {
|
|
1740
|
+
if (genderOnlyMatch)
|
|
1741
|
+
info.gender = genderOnlyMatch[1];
|
|
1742
|
+
if (nationalityOnlyMatch)
|
|
1743
|
+
info.ethnicity = nationalityOnlyMatch[1];
|
|
1744
|
+
}
|
|
1745
|
+
// 4. 判断身份证类型
|
|
1746
|
+
if (processedText.includes('出生') || processedText.includes('公民身份号码')) {
|
|
1747
|
+
info.type = IDCardType.FRONT;
|
|
1748
|
+
}
|
|
1749
|
+
else if (processedText.includes('签发机关') || processedText.includes('有效期')) {
|
|
1750
|
+
info.type = IDCardType.BACK;
|
|
1751
|
+
}
|
|
1752
|
+
// 5. 解析出生日期
|
|
1753
|
+
const birthDateRegex1 = /出生[\s\:]*(\d{4})年(\d{1,2})月(\d{1,2})[日号]/;
|
|
1754
|
+
const birthDateRegex2 = /出生[\s\:]*(\d{4})[-\/\.](\d{1,2})[-\/\.](\d{1,2})/;
|
|
1755
|
+
const birthDateRegex3 = /出生日期[\s\:]*(\d{4})[-\/\.\u5e74](\d{1,2})[-\/\.\u6708](\d{1,2})[日号]?/;
|
|
1756
|
+
const birthDateMatch = processedText.match(birthDateRegex1) || processedText.match(birthDateRegex2) || processedText.match(birthDateRegex3);
|
|
1757
|
+
if (!birthDateMatch && info.idNumber && info.idNumber.length === 18) {
|
|
1758
|
+
const year = info.idNumber.substring(6, 10);
|
|
1759
|
+
const month = info.idNumber.substring(10, 12);
|
|
1760
|
+
const day = info.idNumber.substring(12, 14);
|
|
1761
|
+
info.birthDate = `${year}-${month}-${day}`;
|
|
1762
|
+
}
|
|
1763
|
+
else if (birthDateMatch) {
|
|
1764
|
+
const year = birthDateMatch[1];
|
|
1765
|
+
const month = birthDateMatch[2].padStart(2, "0");
|
|
1766
|
+
const day = birthDateMatch[3].padStart(2, "0");
|
|
1767
|
+
info.birthDate = `${year}-${month}-${day}`;
|
|
1768
|
+
}
|
|
1769
|
+
// 6. 解析地址
|
|
1770
|
+
const addressRegex1 = /住址[\s\:]*([\s\S]*?)(?=公民身份|出生|性别|签发)/;
|
|
1771
|
+
const addressRegex2 = /住址[\s\:]*([一-龥a-zA-Z0-9\s\.\-]+)/;
|
|
1772
|
+
const addressMatch = processedText.match(addressRegex1) || processedText.match(addressRegex2);
|
|
1773
|
+
if (addressMatch && addressMatch[1]) {
|
|
1774
|
+
info.address = addressMatch[1].replace(/\s+/g, "").replace(/\n/g, "").trim();
|
|
1775
|
+
if (info.address.length > 70)
|
|
1776
|
+
info.address = info.address.substring(0, 70);
|
|
1777
|
+
if (!/[一-龥]/.test(info.address))
|
|
1778
|
+
info.address = '';
|
|
1779
|
+
}
|
|
1780
|
+
// 7. 解析签发机关
|
|
1781
|
+
const authorityRegex1 = /签发机关[\s\:]*([\s\S]*?)(?=有效|公民|出生|\d{8}|$)/;
|
|
1782
|
+
const authorityRegex2 = /签发机关[\s\:]*([一-龥\s]+)/;
|
|
1783
|
+
const authorityMatch = processedText.match(authorityRegex1) || processedText.match(authorityRegex2);
|
|
1784
|
+
if (authorityMatch && authorityMatch[1]) {
|
|
1785
|
+
info.issueAuthority = authorityMatch[1].replace(/\s+/g, "").replace(/\n/g, "").trim();
|
|
1786
|
+
}
|
|
1787
|
+
// 8. 解析有效期限
|
|
1788
|
+
const validPeriodRegex1 = /有效期限[\s\:]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日\s]*)[-\s]*(至|-)[-\s]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日]*|[永久长期]*)/;
|
|
1789
|
+
const validPeriodRegex2 = /有效期限[\s\:]*(\d{8})[-\s]*(至|-)[-\s]*(\d{8}|[永久长期]*)/;
|
|
1790
|
+
const validPeriodMatch = processedText.match(validPeriodRegex1) || processedText.match(validPeriodRegex2);
|
|
1791
|
+
if (validPeriodMatch && validPeriodMatch[1] && validPeriodMatch[3]) {
|
|
1792
|
+
const startDate = formatDateString(validPeriodMatch[1]);
|
|
1793
|
+
const endDate = /\d/.test(validPeriodMatch[3]) ? formatDateString(validPeriodMatch[3]) : '长期有效';
|
|
1794
|
+
info.validFrom = startDate;
|
|
1795
|
+
info.validTo = endDate;
|
|
1796
|
+
info.validPeriod = `${startDate}-${endDate}`;
|
|
1797
|
+
}
|
|
1798
|
+
else if (validPeriodMatch) {
|
|
1799
|
+
info.validPeriod = validPeriodMatch[0].replace("有效期限", "").trim();
|
|
1800
|
+
}
|
|
1801
|
+
return info;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* @file Canvas 对象池
|
|
1807
|
+
* @description 提供 Canvas 元素的复用机制,减少内存分配和 GC 压力
|
|
1808
|
+
* @module utils/canvas-pool
|
|
1809
|
+
*/
|
|
1810
|
+
/**
|
|
1811
|
+
* Canvas 对象池
|
|
1606
1812
|
*
|
|
1607
|
-
*
|
|
1813
|
+
* 复用 Canvas 元素,避免频繁创建和销毁导致的内存抖动
|
|
1814
|
+
*
|
|
1815
|
+
* @example
|
|
1816
|
+
* ```typescript
|
|
1817
|
+
* const pool = CanvasPool.getInstance();
|
|
1818
|
+
* const { canvas, context } = pool.acquire(100, 200);
|
|
1819
|
+
* // 使用 canvas 进行绘制...
|
|
1820
|
+
* pool.release(canvas);
|
|
1821
|
+
* ```
|
|
1608
1822
|
*/
|
|
1609
|
-
class
|
|
1823
|
+
class CanvasPool {
|
|
1610
1824
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
* @param {ImageData} imageData - 要转换的图像数据
|
|
1614
|
-
* @returns {HTMLCanvasElement} 包含图像的Canvas元素
|
|
1825
|
+
* 获取单例实例
|
|
1615
1826
|
*/
|
|
1616
|
-
static
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
canvas.height = imageData.height;
|
|
1620
|
-
const ctx = canvas.getContext("2d");
|
|
1621
|
-
if (ctx) {
|
|
1622
|
-
ctx.putImageData(imageData, 0, 0);
|
|
1827
|
+
static getInstance() {
|
|
1828
|
+
if (!CanvasPool.instance) {
|
|
1829
|
+
CanvasPool.instance = new CanvasPool();
|
|
1623
1830
|
}
|
|
1624
|
-
return
|
|
1831
|
+
return CanvasPool.instance;
|
|
1625
1832
|
}
|
|
1626
1833
|
/**
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
* @param {HTMLCanvasElement} canvas - 要转换的Canvas元素
|
|
1630
|
-
* @returns {ImageData|null} Canvas的图像数据,如果获取失败则返回null
|
|
1834
|
+
* 重置单例实例(主要用于测试)
|
|
1631
1835
|
*/
|
|
1632
|
-
static
|
|
1633
|
-
|
|
1634
|
-
|
|
1836
|
+
static resetInstance() {
|
|
1837
|
+
if (CanvasPool.instance) {
|
|
1838
|
+
CanvasPool.instance.dispose();
|
|
1839
|
+
CanvasPool.instance = null;
|
|
1840
|
+
}
|
|
1635
1841
|
}
|
|
1636
1842
|
/**
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
* @param imageData 原始图像数据
|
|
1640
|
-
* @param brightness 亮度调整值 (-100到100)
|
|
1641
|
-
* @param contrast 对比度调整值 (-100到100)
|
|
1642
|
-
* @returns 处理后的图像数据
|
|
1843
|
+
* 私有构造函数
|
|
1643
1844
|
*/
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1845
|
+
constructor() {
|
|
1846
|
+
/** Canvas 池存储 */
|
|
1847
|
+
this.pool = new Map();
|
|
1848
|
+
/** 已借出的 Canvas */
|
|
1849
|
+
this.borrowed = new Map();
|
|
1850
|
+
/** 最大池大小(每个尺寸) */
|
|
1851
|
+
this.maxPoolSize = 4;
|
|
1852
|
+
/** Canvas 尺寸容差(允许一定范围的尺寸复用) */
|
|
1853
|
+
this.sizeTolerance = 10;
|
|
1854
|
+
// 页面卸载前清理
|
|
1855
|
+
if (typeof window !== 'undefined') {
|
|
1856
|
+
window.addEventListener('beforeunload', () => this.dispose());
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 生成尺寸键
|
|
1861
|
+
* @param width 宽度
|
|
1862
|
+
* @param height 高度
|
|
1863
|
+
*/
|
|
1864
|
+
getSizeKey(width, height) {
|
|
1865
|
+
return `${width}x${height}`;
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* 查找匹配的尺寸键(考虑容差)
|
|
1869
|
+
* @param width 宽度
|
|
1870
|
+
* @param height 高度
|
|
1871
|
+
*/
|
|
1872
|
+
findMatchingSizeKey(width, height) {
|
|
1873
|
+
for (const [key, items] of this.pool.entries()) {
|
|
1874
|
+
const [w, h] = key.split('x').map(Number);
|
|
1875
|
+
if (Math.abs(w - width) <= this.sizeTolerance &&
|
|
1876
|
+
Math.abs(h - height) <= this.sizeTolerance) {
|
|
1877
|
+
// 找到可用的
|
|
1878
|
+
const available = items.filter(item => !item.inUse);
|
|
1879
|
+
if (available.length > 0) {
|
|
1880
|
+
return key;
|
|
1881
|
+
}
|
|
1659
1882
|
}
|
|
1660
|
-
// Alpha 通道保持不变
|
|
1661
1883
|
}
|
|
1662
|
-
return
|
|
1884
|
+
return null;
|
|
1663
1885
|
}
|
|
1664
1886
|
/**
|
|
1665
|
-
*
|
|
1887
|
+
* 从池中获取 Canvas
|
|
1666
1888
|
*
|
|
1667
|
-
* @param
|
|
1668
|
-
* @
|
|
1889
|
+
* @param width 宽度
|
|
1890
|
+
* @param height 高度
|
|
1891
|
+
* @returns Canvas 和其上下文
|
|
1892
|
+
*/
|
|
1893
|
+
acquire(width, height) {
|
|
1894
|
+
// 先尝试精确匹配
|
|
1895
|
+
let sizeKey = this.getSizeKey(width, height);
|
|
1896
|
+
let items = this.pool.get(sizeKey);
|
|
1897
|
+
// 如果没有精确匹配,尝试模糊匹配
|
|
1898
|
+
if (!items || items.every(item => item.inUse)) {
|
|
1899
|
+
const matchedKey = this.findMatchingSizeKey(width, height);
|
|
1900
|
+
if (matchedKey) {
|
|
1901
|
+
sizeKey = matchedKey;
|
|
1902
|
+
items = this.pool.get(sizeKey);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
// 如果没有可用的,创建一个新的
|
|
1906
|
+
if (!items || items.every(item => item.inUse)) {
|
|
1907
|
+
const canvas = document.createElement('canvas');
|
|
1908
|
+
canvas.width = width;
|
|
1909
|
+
canvas.height = height;
|
|
1910
|
+
const context = canvas.getContext('2d');
|
|
1911
|
+
const item = {
|
|
1912
|
+
canvas,
|
|
1913
|
+
context,
|
|
1914
|
+
inUse: true,
|
|
1915
|
+
lastUsed: Date.now(),
|
|
1916
|
+
sizeKey: this.getSizeKey(width, height)
|
|
1917
|
+
};
|
|
1918
|
+
// 如果池已满,移除最老的
|
|
1919
|
+
if (!items) {
|
|
1920
|
+
items = [];
|
|
1921
|
+
this.pool.set(sizeKey, items);
|
|
1922
|
+
}
|
|
1923
|
+
else if (items.length >= this.maxPoolSize) {
|
|
1924
|
+
// 找到最老的未使用项并移除
|
|
1925
|
+
let oldestIdx = 0;
|
|
1926
|
+
let oldestTime = Infinity;
|
|
1927
|
+
items.forEach((item, idx) => {
|
|
1928
|
+
if (!item.inUse && item.lastUsed < oldestTime) {
|
|
1929
|
+
oldestTime = item.lastUsed;
|
|
1930
|
+
oldestIdx = idx;
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
const removed = items.splice(oldestIdx, 1)[0];
|
|
1934
|
+
this.borrowed.delete(removed.canvas);
|
|
1935
|
+
}
|
|
1936
|
+
items.push(item);
|
|
1937
|
+
this.borrowed.set(canvas, item);
|
|
1938
|
+
return { canvas, context };
|
|
1939
|
+
}
|
|
1940
|
+
// 找到一个空闲的
|
|
1941
|
+
const available = items.find(item => !item.inUse);
|
|
1942
|
+
available.inUse = true;
|
|
1943
|
+
available.lastUsed = Date.now();
|
|
1944
|
+
// 如果尺寸变化,更新 canvas
|
|
1945
|
+
if (available.canvas.width !== width || available.canvas.height !== height) {
|
|
1946
|
+
available.canvas.width = width;
|
|
1947
|
+
available.canvas.height = height;
|
|
1948
|
+
available.sizeKey = sizeKey;
|
|
1949
|
+
}
|
|
1950
|
+
// 清除之前的上下文状态
|
|
1951
|
+
available.context.setTransform(1, 0, 0, 1, 0, 0);
|
|
1952
|
+
available.context.clearRect(0, 0, width, height);
|
|
1953
|
+
this.borrowed.set(available.canvas, available);
|
|
1954
|
+
return { canvas: available.canvas, context: available.context };
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* 释放 Canvas 回池中
|
|
1958
|
+
*
|
|
1959
|
+
* @param canvas 要释放的 Canvas
|
|
1669
1960
|
*/
|
|
1670
|
-
|
|
1671
|
-
const
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const gray = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
|
|
1676
|
-
data[i] = data[i + 1] = data[i + 2] = gray;
|
|
1961
|
+
release(canvas) {
|
|
1962
|
+
const item = this.borrowed.get(canvas);
|
|
1963
|
+
if (!item) {
|
|
1964
|
+
// 不属于我们管理的 Canvas,忽略
|
|
1965
|
+
return;
|
|
1677
1966
|
}
|
|
1678
|
-
|
|
1967
|
+
item.inUse = false;
|
|
1968
|
+
item.lastUsed = Date.now();
|
|
1969
|
+
this.borrowed.delete(canvas);
|
|
1679
1970
|
}
|
|
1680
1971
|
/**
|
|
1681
|
-
*
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1972
|
+
* 批量释放所有借出的 Canvas
|
|
1973
|
+
*/
|
|
1974
|
+
releaseAll() {
|
|
1975
|
+
for (const [, item] of this.borrowed) {
|
|
1976
|
+
item.inUse = false;
|
|
1977
|
+
item.lastUsed = Date.now();
|
|
1978
|
+
}
|
|
1979
|
+
this.borrowed.clear();
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* 预热池(预创建指定尺寸的 Canvas)
|
|
1983
|
+
*
|
|
1984
|
+
* @param sizes 尺寸数组,每项为 [width, height]
|
|
1985
|
+
*/
|
|
1986
|
+
warmup(sizes) {
|
|
1987
|
+
for (const [width, height] of sizes) {
|
|
1988
|
+
this.acquire(width, height);
|
|
1989
|
+
// 立即释放,让它们进入池中
|
|
1990
|
+
const sizeKey = this.getSizeKey(width, height);
|
|
1991
|
+
const items = this.pool.get(sizeKey);
|
|
1992
|
+
if (items && items.length > 0) {
|
|
1993
|
+
const item = items[items.length - 1];
|
|
1994
|
+
item.inUse = false;
|
|
1995
|
+
this.borrowed.delete(item.canvas);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* 获取池统计信息
|
|
2001
|
+
*/
|
|
2002
|
+
getStats() {
|
|
2003
|
+
let totalItems = 0;
|
|
2004
|
+
let borrowedCount = 0;
|
|
2005
|
+
const poolSizes = {};
|
|
2006
|
+
for (const [key, items] of this.pool.entries()) {
|
|
2007
|
+
totalItems += items.length;
|
|
2008
|
+
borrowedCount += items.filter(i => i.inUse).length;
|
|
2009
|
+
poolSizes[key] = {
|
|
2010
|
+
total: items.length,
|
|
2011
|
+
available: items.filter(i => !i.inUse).length
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
return { totalItems, borrowedCount, poolSizes };
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* 清理并释放所有资源
|
|
2018
|
+
*/
|
|
2019
|
+
dispose() {
|
|
2020
|
+
this.pool.clear();
|
|
2021
|
+
this.borrowed.clear();
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
/** 单例实例 */
|
|
2025
|
+
CanvasPool.instance = null;
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* @file 边缘检测器
|
|
2029
|
+
* @description 提供边缘检测算法(Sobel、Canny等)
|
|
2030
|
+
* @module utils/edge-detector
|
|
2031
|
+
*/
|
|
2032
|
+
/**
|
|
2033
|
+
* 边缘检测器类
|
|
2034
|
+
* 提供各种边缘检测算法用于图像处理
|
|
2035
|
+
*/
|
|
2036
|
+
class EdgeDetector {
|
|
2037
|
+
/**
|
|
2038
|
+
* 使用Sobel算子进行边缘检测
|
|
2039
|
+
* @param imageData 灰度图像数据
|
|
2040
|
+
* @param threshold 边缘阈值,默认为30
|
|
2041
|
+
* @returns 检测到边缘的图像数据
|
|
2042
|
+
*/
|
|
2043
|
+
static detectEdges(imageData, threshold = 30) {
|
|
2044
|
+
const grayscaleImage = this.toGrayscale(new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height));
|
|
2045
|
+
const width = grayscaleImage.width;
|
|
2046
|
+
const height = grayscaleImage.height;
|
|
2047
|
+
const inputData = grayscaleImage.data;
|
|
2048
|
+
const outputData = new Uint8ClampedArray(inputData.length);
|
|
2049
|
+
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
|
2050
|
+
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
|
2051
|
+
for (let y = 1; y < height - 1; y++) {
|
|
2052
|
+
for (let x = 1; x < width - 1; x++) {
|
|
2053
|
+
let gx = 0, gy = 0;
|
|
2054
|
+
for (let ky = -1; ky <= 1; ky++) {
|
|
2055
|
+
for (let kx = -1; kx <= 1; kx++) {
|
|
2056
|
+
const pixelPos = ((y + ky) * width + (x + kx)) * 4;
|
|
2057
|
+
const pixelVal = inputData[pixelPos];
|
|
2058
|
+
const kernelIdx = (ky + 1) * 3 + (kx + 1);
|
|
2059
|
+
gx += pixelVal * sobelX[kernelIdx];
|
|
2060
|
+
gy += pixelVal * sobelY[kernelIdx];
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
let magnitude = Math.sqrt(gx * gx + gy * gy);
|
|
2064
|
+
magnitude = magnitude > threshold ? 255 : 0;
|
|
2065
|
+
const pos = (y * width + x) * 4;
|
|
2066
|
+
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = magnitude;
|
|
2067
|
+
outputData[pos + 3] = 255;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
// 处理边缘
|
|
2071
|
+
for (let i = 0; i < width * 4; i++) {
|
|
2072
|
+
outputData[i] = 0;
|
|
2073
|
+
outputData[(height - 1) * width * 4 + i] = 0;
|
|
2074
|
+
}
|
|
2075
|
+
for (let i = 0; i < height; i++) {
|
|
2076
|
+
const leftPos = i * width * 4;
|
|
2077
|
+
const rightPos = (i * width + width - 1) * 4;
|
|
2078
|
+
for (let j = 0; j < 4; j++) {
|
|
2079
|
+
outputData[leftPos + j] = 0;
|
|
2080
|
+
outputData[rightPos + j] = 0;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
return new ImageData(outputData, width, height);
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* 卡尼-德里奇边缘检测
|
|
2087
|
+
*/
|
|
2088
|
+
static cannyEdgeDetection(imageData, lowThreshold = 20, highThreshold = 50) {
|
|
2089
|
+
const grayscaleImage = this.toGrayscale(new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height));
|
|
2090
|
+
const blurredImage = this.gaussianBlur(grayscaleImage, 1.5);
|
|
2091
|
+
const { gradientMagnitude, gradientDirection } = this.computeGradients(blurredImage);
|
|
2092
|
+
const nonMaxSuppressed = this.nonMaxSuppression(gradientMagnitude, gradientDirection, blurredImage.width, blurredImage.height);
|
|
2093
|
+
const thresholdResult = this.hysteresisThresholding(nonMaxSuppressed, blurredImage.width, blurredImage.height, lowThreshold, highThreshold);
|
|
2094
|
+
const outputData = new Uint8ClampedArray(imageData.data.length);
|
|
2095
|
+
for (let i = 0; i < thresholdResult.length; i++) {
|
|
2096
|
+
const pos = i * 4;
|
|
2097
|
+
const value = thresholdResult[i] ? 255 : 0;
|
|
2098
|
+
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = value;
|
|
2099
|
+
outputData[pos + 3] = 255;
|
|
2100
|
+
}
|
|
2101
|
+
return new ImageData(outputData, blurredImage.width, blurredImage.height);
|
|
2102
|
+
}
|
|
2103
|
+
static toGrayscale(imageData) {
|
|
2104
|
+
const srcData = imageData.data;
|
|
2105
|
+
const destData = new Uint8ClampedArray(srcData);
|
|
2106
|
+
for (let i = 0; i < srcData.length; i += 4) {
|
|
2107
|
+
const gray = srcData[i] * 0.3 + srcData[i + 1] * 0.59 + srcData[i + 2] * 0.11;
|
|
2108
|
+
destData[i] = destData[i + 1] = destData[i + 2] = gray;
|
|
2109
|
+
destData[i + 3] = srcData[i + 3];
|
|
2110
|
+
}
|
|
2111
|
+
return new ImageData(destData, imageData.width, imageData.height);
|
|
2112
|
+
}
|
|
2113
|
+
static gaussianBlur(imageData, sigma = 1.5) {
|
|
2114
|
+
const width = imageData.width, height = imageData.height;
|
|
2115
|
+
const inputData = imageData.data, outputData = new Uint8ClampedArray(inputData.length);
|
|
2116
|
+
const kernelSize = Math.max(3, Math.floor(sigma * 3) * 2 + 1);
|
|
2117
|
+
const halfKernel = Math.floor(kernelSize / 2);
|
|
2118
|
+
const kernel = this.generateGaussianKernel(kernelSize, sigma);
|
|
2119
|
+
for (let y = 0; y < height; y++) {
|
|
2120
|
+
for (let x = 0; x < width; x++) {
|
|
2121
|
+
let sum = 0, weightSum = 0;
|
|
2122
|
+
for (let ky = -halfKernel; ky <= halfKernel; ky++) {
|
|
2123
|
+
for (let kx = -halfKernel; kx <= halfKernel; kx++) {
|
|
2124
|
+
const pixelY = Math.min(Math.max(y + ky, 0), height - 1);
|
|
2125
|
+
const pixelX = Math.min(Math.max(x + kx, 0), width - 1);
|
|
2126
|
+
const pixelPos = (pixelY * width + pixelX) * 4;
|
|
2127
|
+
const kernelY = ky + halfKernel, kernelX = kx + halfKernel;
|
|
2128
|
+
const weight = kernel[kernelY * kernelSize + kernelX];
|
|
2129
|
+
sum += inputData[pixelPos] * weight;
|
|
2130
|
+
weightSum += weight;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const pos = (y * width + x) * 4;
|
|
2134
|
+
const value = Math.round(sum / weightSum);
|
|
2135
|
+
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = value;
|
|
2136
|
+
outputData[pos + 3] = 255;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return new ImageData(outputData, width, height);
|
|
2140
|
+
}
|
|
2141
|
+
static generateGaussianKernel(size, sigma) {
|
|
2142
|
+
const kernel = new Array(size * size);
|
|
2143
|
+
const center = Math.floor(size / 2);
|
|
2144
|
+
let sum = 0;
|
|
2145
|
+
for (let y = 0; y < size; y++) {
|
|
2146
|
+
for (let x = 0; x < size; x++) {
|
|
2147
|
+
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
|
|
2148
|
+
kernel[y * size + x] = Math.exp(-(distance ** 2) / (2 * sigma ** 2));
|
|
2149
|
+
sum += kernel[y * size + x];
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
for (let i = 0; i < kernel.length; i++)
|
|
2153
|
+
kernel[i] /= sum;
|
|
2154
|
+
return kernel;
|
|
2155
|
+
}
|
|
2156
|
+
static computeGradients(imageData) {
|
|
2157
|
+
const width = imageData.width, height = imageData.height;
|
|
2158
|
+
const inputData = imageData.data;
|
|
2159
|
+
const gradientMagnitude = new Array(width * height);
|
|
2160
|
+
const gradientDirection = new Array(width * height);
|
|
2161
|
+
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
|
2162
|
+
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
|
2163
|
+
for (let y = 1; y < height - 1; y++) {
|
|
2164
|
+
for (let x = 1; x < width - 1; x++) {
|
|
2165
|
+
let gx = 0, gy = 0;
|
|
2166
|
+
for (let ky = -1; ky <= 1; ky++) {
|
|
2167
|
+
for (let kx = -1; kx <= 1; kx++) {
|
|
2168
|
+
const pixelPos = ((y + ky) * width + (x + kx)) * 4;
|
|
2169
|
+
const pixelVal = inputData[pixelPos];
|
|
2170
|
+
const kernelIdx = (ky + 1) * 3 + (kx + 1);
|
|
2171
|
+
gx += pixelVal * sobelX[kernelIdx];
|
|
2172
|
+
gy += pixelVal * sobelY[kernelIdx];
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
const idx = y * width + x;
|
|
2176
|
+
gradientMagnitude[idx] = Math.sqrt(gx * gx + gy * gy);
|
|
2177
|
+
gradientDirection[idx] = Math.atan2(gy, gx);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
return { gradientMagnitude, gradientDirection };
|
|
2181
|
+
}
|
|
2182
|
+
static nonMaxSuppression(gradientMagnitude, gradientDirection, width, height) {
|
|
2183
|
+
const result = new Array(width * height).fill(0);
|
|
2184
|
+
for (let y = 1; y < height - 1; y++) {
|
|
2185
|
+
for (let x = 1; x < width - 1; x++) {
|
|
2186
|
+
const idx = y * width + x;
|
|
2187
|
+
const magnitude = gradientMagnitude[idx];
|
|
2188
|
+
const direction = gradientDirection[idx];
|
|
2189
|
+
const degrees = (direction * 180 / Math.PI + 180) % 180;
|
|
2190
|
+
let neighbor1Idx, neighbor2Idx;
|
|
2191
|
+
if ((degrees >= 0 && degrees < 22.5) || (degrees >= 157.5 && degrees <= 180)) {
|
|
2192
|
+
neighbor1Idx = idx - 1;
|
|
2193
|
+
neighbor2Idx = idx + 1;
|
|
2194
|
+
}
|
|
2195
|
+
else if (degrees >= 22.5 && degrees < 67.5) {
|
|
2196
|
+
neighbor1Idx = (y - 1) * width + (x + 1);
|
|
2197
|
+
neighbor2Idx = (y + 1) * width + (x - 1);
|
|
2198
|
+
}
|
|
2199
|
+
else if (degrees >= 67.5 && degrees < 112.5) {
|
|
2200
|
+
neighbor1Idx = (y - 1) * width + x;
|
|
2201
|
+
neighbor2Idx = (y + 1) * width + x;
|
|
2202
|
+
}
|
|
2203
|
+
else {
|
|
2204
|
+
neighbor1Idx = (y - 1) * width + (x - 1);
|
|
2205
|
+
neighbor2Idx = (y + 1) * width + (x + 1);
|
|
2206
|
+
}
|
|
2207
|
+
if (magnitude >= gradientMagnitude[neighbor1Idx] && magnitude >= gradientMagnitude[neighbor2Idx]) {
|
|
2208
|
+
result[idx] = magnitude;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return result;
|
|
2213
|
+
}
|
|
2214
|
+
static hysteresisThresholding(nonMaxSuppressed, width, height, lowThreshold, highThreshold) {
|
|
2215
|
+
const result = new Array(width * height).fill(false);
|
|
2216
|
+
const visited = new Array(width * height).fill(false);
|
|
2217
|
+
const stack = [];
|
|
2218
|
+
for (let i = 0; i < nonMaxSuppressed.length; i++) {
|
|
2219
|
+
if (nonMaxSuppressed[i] >= highThreshold) {
|
|
2220
|
+
result[i] = true;
|
|
2221
|
+
stack.push(i);
|
|
2222
|
+
visited[i] = true;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
const dx = [-1, 0, 1, -1, 1, -1, 0, 1];
|
|
2226
|
+
const dy = [-1, -1, -1, 0, 0, 1, 1, 1];
|
|
2227
|
+
while (stack.length > 0) {
|
|
2228
|
+
const currentIdx = stack.pop();
|
|
2229
|
+
const currentX = currentIdx % width;
|
|
2230
|
+
const currentY = Math.floor(currentIdx / width);
|
|
2231
|
+
for (let i = 0; i < 8; i++) {
|
|
2232
|
+
const newX = currentX + dx[i];
|
|
2233
|
+
const newY = currentY + dy[i];
|
|
2234
|
+
if (newX >= 0 && newX < width && newY >= 0 && newY < height) {
|
|
2235
|
+
const newIdx = newY * width + newX;
|
|
2236
|
+
if (!visited[newIdx] && nonMaxSuppressed[newIdx] >= lowThreshold) {
|
|
2237
|
+
result[newIdx] = true;
|
|
2238
|
+
stack.push(newIdx);
|
|
2239
|
+
visited[newIdx] = true;
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
return result;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* @file 图像处理工具类
|
|
2250
|
+
* @description 提供图像预处理功能,用于提高OCR识别率
|
|
2251
|
+
* @module ImageProcessor
|
|
2252
|
+
* @version 1.4.0
|
|
2253
|
+
*/
|
|
2254
|
+
/**
|
|
2255
|
+
* 图像处理工具类
|
|
2256
|
+
*
|
|
2257
|
+
* 提供各种图像处理功能,用于优化识别效果
|
|
2258
|
+
*/
|
|
2259
|
+
class ImageProcessor {
|
|
2260
|
+
/**
|
|
2261
|
+
* 将ImageData转换为Canvas元素
|
|
2262
|
+
*
|
|
2263
|
+
* @param {ImageData} imageData - 要转换的图像数据
|
|
2264
|
+
* @returns {HTMLCanvasElement} 包含图像的Canvas元素
|
|
2265
|
+
*/
|
|
2266
|
+
static imageDataToCanvas(imageData, usePool = true) {
|
|
2267
|
+
let canvas;
|
|
2268
|
+
let context;
|
|
2269
|
+
if (usePool) {
|
|
2270
|
+
({ canvas, context } = CanvasPool.getInstance().acquire(imageData.width, imageData.height));
|
|
2271
|
+
}
|
|
2272
|
+
else {
|
|
2273
|
+
canvas = document.createElement("canvas");
|
|
2274
|
+
canvas.width = imageData.width;
|
|
2275
|
+
canvas.height = imageData.height;
|
|
2276
|
+
context = canvas.getContext("2d");
|
|
2277
|
+
}
|
|
2278
|
+
context.putImageData(imageData, 0, 0);
|
|
2279
|
+
if (usePool) {
|
|
2280
|
+
// 立即释放回池中,用户保留 canvas 引用即可
|
|
2281
|
+
CanvasPool.getInstance().release(canvas);
|
|
2282
|
+
}
|
|
2283
|
+
return canvas;
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* 将Canvas转换为ImageData
|
|
2287
|
+
*
|
|
2288
|
+
* @param {HTMLCanvasElement} canvas - 要转换的Canvas元素
|
|
2289
|
+
* @returns {ImageData|null} Canvas的图像数据,如果获取失败则返回null
|
|
2290
|
+
*/
|
|
2291
|
+
static canvasToImageData(canvas) {
|
|
2292
|
+
const ctx = canvas.getContext("2d");
|
|
2293
|
+
return ctx ? ctx.getImageData(0, 0, canvas.width, canvas.height) : null;
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* 调整图像亮度和对比度
|
|
2297
|
+
*
|
|
2298
|
+
* @param imageData 原始图像数据
|
|
2299
|
+
* @param brightness 亮度调整值 (-100到100)
|
|
2300
|
+
* @param contrast 对比度调整值 (-100到100)
|
|
2301
|
+
* @returns 处理后的图像数据
|
|
2302
|
+
*/
|
|
2303
|
+
static adjustBrightnessContrast(imageData, brightness = 0, contrast = 0) {
|
|
2304
|
+
// 将亮度和对比度范围限制在 -100 到 100 之间
|
|
2305
|
+
brightness = Math.max(-100, Math.min(100, brightness));
|
|
2306
|
+
contrast = Math.max(-100, Math.min(100, contrast));
|
|
2307
|
+
// 将范围转换为适合计算的值
|
|
2308
|
+
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
|
|
2309
|
+
const briAdjust = (brightness / 100) * 255;
|
|
2310
|
+
const data = imageData.data;
|
|
2311
|
+
const length = data.length;
|
|
2312
|
+
for (let i = 0; i < length; i += 4) {
|
|
2313
|
+
// 分别处理 RGB 三个通道
|
|
2314
|
+
for (let j = 0; j < 3; j++) {
|
|
2315
|
+
// 应用亮度和对比度调整公式
|
|
2316
|
+
const newValue = factor * (data[i + j] + briAdjust - 128) + 128;
|
|
2317
|
+
data[i + j] = Math.max(0, Math.min(255, newValue));
|
|
2318
|
+
}
|
|
2319
|
+
// Alpha 通道保持不变
|
|
2320
|
+
}
|
|
2321
|
+
return imageData;
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* 将图像转换为灰度图(返回新 ImageData,不修改原图)
|
|
2325
|
+
*
|
|
2326
|
+
* @param imageData 原始图像数据
|
|
2327
|
+
* @returns 灰度图像数据(新对象)
|
|
2328
|
+
*/
|
|
2329
|
+
static toGrayscale(imageData) {
|
|
2330
|
+
const srcData = imageData.data;
|
|
2331
|
+
const length = srcData.length;
|
|
2332
|
+
// 创建新数组,避免修改原图
|
|
2333
|
+
const destData = new Uint8ClampedArray(srcData);
|
|
2334
|
+
for (let i = 0; i < length; i += 4) {
|
|
2335
|
+
// 使用加权平均法将 RGB 转换为灰度值
|
|
2336
|
+
const gray = srcData[i] * 0.3 + srcData[i + 1] * 0.59 + srcData[i + 2] * 0.11;
|
|
2337
|
+
destData[i] = destData[i + 1] = destData[i + 2] = gray;
|
|
2338
|
+
// Alpha 通道保持不变
|
|
2339
|
+
destData[i + 3] = srcData[i + 3];
|
|
2340
|
+
}
|
|
2341
|
+
return new ImageData(destData, imageData.width, imageData.height);
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* 锐化图像
|
|
2345
|
+
*
|
|
2346
|
+
* @param imageData 原始图像数据
|
|
2347
|
+
* @param amount 锐化程度,默认为2
|
|
2348
|
+
* @returns 锐化后的图像数据
|
|
1686
2349
|
*/
|
|
1687
2350
|
static sharpen(imageData, amount = 2) {
|
|
1688
2351
|
if (!imageData || !imageData.data)
|
|
@@ -1722,52 +2385,79 @@ class ImageProcessor {
|
|
|
1722
2385
|
outputData[pos + 3] = data[pos + 3]; // 保持透明度不变
|
|
1723
2386
|
}
|
|
1724
2387
|
}
|
|
1725
|
-
//
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
2388
|
+
// 处理边缘像素(仅遍历四条边,而非全图 O(width×height) → O(width+height))
|
|
2389
|
+
// 上边 + 下边
|
|
2390
|
+
for (let x = 0; x < width; x++) {
|
|
2391
|
+
const topPos = x * 4;
|
|
2392
|
+
const bottomPos = ((height - 1) * width + x) * 4;
|
|
2393
|
+
outputData[topPos] = data[topPos];
|
|
2394
|
+
outputData[topPos + 1] = data[topPos + 1];
|
|
2395
|
+
outputData[topPos + 2] = data[topPos + 2];
|
|
2396
|
+
outputData[topPos + 3] = data[topPos + 3];
|
|
2397
|
+
outputData[bottomPos] = data[bottomPos];
|
|
2398
|
+
outputData[bottomPos + 1] = data[bottomPos + 1];
|
|
2399
|
+
outputData[bottomPos + 2] = data[bottomPos + 2];
|
|
2400
|
+
outputData[bottomPos + 3] = data[bottomPos + 3];
|
|
2401
|
+
}
|
|
2402
|
+
// 左边 + 右边(排除四角,它们已在上下一行处理)
|
|
2403
|
+
for (let y = 1; y < height - 1; y++) {
|
|
2404
|
+
const leftPos = y * width * 4;
|
|
2405
|
+
const rightPos = (y * width + width - 1) * 4;
|
|
2406
|
+
outputData[leftPos] = data[leftPos];
|
|
2407
|
+
outputData[leftPos + 1] = data[leftPos + 1];
|
|
2408
|
+
outputData[leftPos + 2] = data[leftPos + 2];
|
|
2409
|
+
outputData[leftPos + 3] = data[leftPos + 3];
|
|
2410
|
+
outputData[rightPos] = data[rightPos];
|
|
2411
|
+
outputData[rightPos + 1] = data[rightPos + 1];
|
|
2412
|
+
outputData[rightPos + 2] = data[rightPos + 2];
|
|
2413
|
+
outputData[rightPos + 3] = data[rightPos + 3];
|
|
1736
2414
|
}
|
|
1737
2415
|
// 创建新的ImageData对象
|
|
1738
2416
|
return new ImageData(outputData, width, height);
|
|
1739
2417
|
}
|
|
1740
2418
|
/**
|
|
1741
|
-
*
|
|
2419
|
+
* 对图像应用阈值操作,增强对比度(二值化)
|
|
1742
2420
|
*
|
|
1743
2421
|
* @param imageData 原始图像数据
|
|
1744
2422
|
* @param threshold 阈值 (0-255)
|
|
1745
|
-
* @returns
|
|
2423
|
+
* @returns 处理后的图像数据(新对象,不修改原图)
|
|
1746
2424
|
*/
|
|
1747
2425
|
static threshold(imageData, threshold = 128) {
|
|
1748
|
-
//
|
|
1749
|
-
const grayscaleImage = this.toGrayscale(
|
|
1750
|
-
const
|
|
1751
|
-
const length =
|
|
2426
|
+
// 先转换为灰度图(返回新 ImageData,不修改原图)
|
|
2427
|
+
const grayscaleImage = this.toGrayscale(imageData);
|
|
2428
|
+
const srcData = grayscaleImage.data;
|
|
2429
|
+
const length = srcData.length;
|
|
2430
|
+
// 创建新数组存储二值化结果
|
|
2431
|
+
const destData = new Uint8ClampedArray(length);
|
|
1752
2432
|
for (let i = 0; i < length; i += 4) {
|
|
1753
2433
|
// 二值化处理
|
|
1754
|
-
const value =
|
|
1755
|
-
|
|
2434
|
+
const value = srcData[i] < threshold ? 0 : 255;
|
|
2435
|
+
destData[i] = destData[i + 1] = destData[i + 2] = value;
|
|
2436
|
+
destData[i + 3] = srcData[i + 3]; // 保持透明度
|
|
1756
2437
|
}
|
|
1757
|
-
return grayscaleImage;
|
|
2438
|
+
return new ImageData(destData, grayscaleImage.width, grayscaleImage.height);
|
|
1758
2439
|
}
|
|
1759
2440
|
/**
|
|
1760
|
-
*
|
|
2441
|
+
* 将图像转换为黑白图像(二值化,使用OTSU自动阈值)
|
|
1761
2442
|
*
|
|
1762
2443
|
* @param imageData 原始图像数据
|
|
1763
|
-
* @returns
|
|
2444
|
+
* @returns 二值化后的图像数据(新对象,不修改原图)
|
|
1764
2445
|
*/
|
|
1765
2446
|
static toBinaryImage(imageData) {
|
|
1766
|
-
//
|
|
1767
|
-
const grayscaleImage = this.toGrayscale(
|
|
2447
|
+
// 先转换为灰度图(返回新 ImageData,不修改原图)
|
|
2448
|
+
const grayscaleImage = this.toGrayscale(imageData);
|
|
1768
2449
|
// 使用OTSU算法自动确定阈值
|
|
1769
2450
|
const threshold = this.getOtsuThreshold(grayscaleImage);
|
|
1770
|
-
|
|
2451
|
+
// 直接对灰度图进行二值化,避免再次调用 toGrayscale
|
|
2452
|
+
const srcData = grayscaleImage.data;
|
|
2453
|
+
const length = srcData.length;
|
|
2454
|
+
const destData = new Uint8ClampedArray(length);
|
|
2455
|
+
for (let i = 0; i < length; i += 4) {
|
|
2456
|
+
const value = srcData[i] < threshold ? 0 : 255;
|
|
2457
|
+
destData[i] = destData[i + 1] = destData[i + 2] = value;
|
|
2458
|
+
destData[i + 3] = srcData[i + 3]; // 保持透明度
|
|
2459
|
+
}
|
|
2460
|
+
return new ImageData(destData, grayscaleImage.width, grayscaleImage.height);
|
|
1771
2461
|
}
|
|
1772
2462
|
/**
|
|
1773
2463
|
* 使用OTSU算法计算最佳阈值
|
|
@@ -1777,8 +2467,9 @@ class ImageProcessor {
|
|
|
1777
2467
|
*/
|
|
1778
2468
|
static getOtsuThreshold(imageData) {
|
|
1779
2469
|
const data = imageData.data;
|
|
1780
|
-
|
|
1781
|
-
|
|
2470
|
+
// 使用 Uint8Array 替代 Array<number>,避免 boxing 开销,提升直方图统计性能
|
|
2471
|
+
const histogram = new Uint32Array(256);
|
|
2472
|
+
// 统计灰度直方图(每4字节取R通道,即灰度值)
|
|
1782
2473
|
for (let i = 0; i < data.length; i += 4) {
|
|
1783
2474
|
histogram[data[i]]++;
|
|
1784
2475
|
}
|
|
@@ -1884,24 +2575,20 @@ class ImageProcessor {
|
|
|
1884
2575
|
const url = URL.createObjectURL(file);
|
|
1885
2576
|
img.onload = () => {
|
|
1886
2577
|
try {
|
|
1887
|
-
//
|
|
1888
|
-
const canvas =
|
|
1889
|
-
const ctx = canvas.getContext("2d");
|
|
1890
|
-
if (!ctx) {
|
|
1891
|
-
reject(new Error("无法创建2D上下文"));
|
|
1892
|
-
return;
|
|
1893
|
-
}
|
|
1894
|
-
canvas.width = img.width;
|
|
1895
|
-
canvas.height = img.height;
|
|
2578
|
+
// 使用 Canvas 池获取 canvas
|
|
2579
|
+
const { canvas, context } = CanvasPool.getInstance().acquire(img.width, img.height);
|
|
1896
2580
|
// 绘制图片到canvas
|
|
1897
|
-
|
|
2581
|
+
context.drawImage(img, 0, 0);
|
|
1898
2582
|
// 获取图像数据
|
|
1899
|
-
const imageData =
|
|
2583
|
+
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
2584
|
+
// 释放回池
|
|
2585
|
+
CanvasPool.getInstance().release(canvas);
|
|
1900
2586
|
// 释放资源
|
|
1901
2587
|
URL.revokeObjectURL(url);
|
|
1902
2588
|
resolve(imageData);
|
|
1903
2589
|
}
|
|
1904
2590
|
catch (e) {
|
|
2591
|
+
URL.revokeObjectURL(url);
|
|
1905
2592
|
reject(e);
|
|
1906
2593
|
}
|
|
1907
2594
|
};
|
|
@@ -1928,16 +2615,12 @@ class ImageProcessor {
|
|
|
1928
2615
|
static async imageDataToFile(imageData, fileName = "image.jpg", fileType = "image/jpeg", quality = 0.8) {
|
|
1929
2616
|
return new Promise((resolve, reject) => {
|
|
1930
2617
|
try {
|
|
1931
|
-
|
|
1932
|
-
canvas
|
|
1933
|
-
|
|
1934
|
-
const ctx = canvas.getContext("2d");
|
|
1935
|
-
if (!ctx) {
|
|
1936
|
-
reject(new Error("无法创建2D上下文"));
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
1939
|
-
ctx.putImageData(imageData, 0, 0);
|
|
2618
|
+
// 使用 Canvas 池
|
|
2619
|
+
const { canvas, context } = CanvasPool.getInstance().acquire(imageData.width, imageData.height);
|
|
2620
|
+
context.putImageData(imageData, 0, 0);
|
|
1940
2621
|
canvas.toBlob((blob) => {
|
|
2622
|
+
// 释放回池
|
|
2623
|
+
CanvasPool.getInstance().release(canvas);
|
|
1941
2624
|
if (!blob) {
|
|
1942
2625
|
reject(new Error("无法创建图片Blob"));
|
|
1943
2626
|
return;
|
|
@@ -1971,327 +2654,67 @@ class ImageProcessor {
|
|
|
1971
2654
|
let height;
|
|
1972
2655
|
if (image instanceof ImageData) {
|
|
1973
2656
|
width = image.width;
|
|
1974
|
-
height = image.height;
|
|
1975
|
-
}
|
|
1976
|
-
else {
|
|
1977
|
-
width = image.width;
|
|
1978
|
-
height = image.height;
|
|
1979
|
-
}
|
|
1980
|
-
// 计算调整后的尺寸
|
|
1981
|
-
let newWidth = width;
|
|
1982
|
-
let newHeight = height;
|
|
1983
|
-
if (keepAspectRatio) {
|
|
1984
|
-
if (width > height) {
|
|
1985
|
-
if (width > maxWidth) {
|
|
1986
|
-
newHeight = Math.round(height * (maxWidth / width));
|
|
1987
|
-
newWidth = maxWidth;
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
else {
|
|
1991
|
-
if (height > maxHeight) {
|
|
1992
|
-
newWidth = Math.round(width * (maxHeight / height));
|
|
1993
|
-
newHeight = maxHeight;
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
else {
|
|
1998
|
-
newWidth = Math.min(width, maxWidth);
|
|
1999
|
-
newHeight = Math.min(height, maxHeight);
|
|
2000
|
-
}
|
|
2001
|
-
// 设置canvas尺寸
|
|
2002
|
-
canvas.width = newWidth;
|
|
2003
|
-
canvas.height = newHeight;
|
|
2004
|
-
// 绘制调整后的图像
|
|
2005
|
-
if (image instanceof ImageData) {
|
|
2006
|
-
// 创建临时canvas存储ImageData
|
|
2007
|
-
const tempCanvas = document.createElement('canvas');
|
|
2008
|
-
const tempCtx = tempCanvas.getContext('2d');
|
|
2009
|
-
if (!tempCtx) {
|
|
2010
|
-
throw new Error('无法创建临时Canvas上下文');
|
|
2011
|
-
}
|
|
2012
|
-
tempCanvas.width = image.width;
|
|
2013
|
-
tempCanvas.height = image.height;
|
|
2014
|
-
tempCtx.putImageData(image, 0, 0);
|
|
2015
|
-
// 绘制调整后的图像
|
|
2016
|
-
ctx.drawImage(tempCanvas, 0, 0, width, height, 0, 0, newWidth, newHeight);
|
|
2017
|
-
}
|
|
2018
|
-
else {
|
|
2019
|
-
ctx.drawImage(image, 0, 0, width, height, 0, 0, newWidth, newHeight);
|
|
2020
|
-
}
|
|
2021
|
-
// 返回调整后的ImageData
|
|
2022
|
-
return ctx.getImageData(0, 0, newWidth, newHeight);
|
|
2023
|
-
}
|
|
2024
|
-
/**
|
|
2025
|
-
* 边缘检测算法,用于识别图像中的边缘
|
|
2026
|
-
* 基于Sobel算子实现
|
|
2027
|
-
*
|
|
2028
|
-
* @param imageData 原始图像数据,应已转为灰度图
|
|
2029
|
-
* @param threshold 边缘阈值,默认为30
|
|
2030
|
-
* @returns 检测到边缘的图像数据
|
|
2031
|
-
*/
|
|
2032
|
-
static detectEdges(imageData, threshold = 30) {
|
|
2033
|
-
// 确保输入图像是灰度图
|
|
2034
|
-
const grayscaleImage = this.toGrayscale(new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height));
|
|
2035
|
-
const width = grayscaleImage.width;
|
|
2036
|
-
const height = grayscaleImage.height;
|
|
2037
|
-
const inputData = grayscaleImage.data;
|
|
2038
|
-
const outputData = new Uint8ClampedArray(inputData.length);
|
|
2039
|
-
// Sobel算子 - 水平和垂直方向
|
|
2040
|
-
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
|
2041
|
-
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
|
2042
|
-
// 对每个像素应用Sobel算子
|
|
2043
|
-
for (let y = 1; y < height - 1; y++) {
|
|
2044
|
-
for (let x = 1; x < width - 1; x++) {
|
|
2045
|
-
let gx = 0;
|
|
2046
|
-
let gy = 0;
|
|
2047
|
-
// 应用卷积
|
|
2048
|
-
for (let ky = -1; ky <= 1; ky++) {
|
|
2049
|
-
for (let kx = -1; kx <= 1; kx++) {
|
|
2050
|
-
const pixelPos = ((y + ky) * width + (x + kx)) * 4;
|
|
2051
|
-
const pixelVal = inputData[pixelPos]; // 灰度值
|
|
2052
|
-
const kernelIdx = (ky + 1) * 3 + (kx + 1);
|
|
2053
|
-
gx += pixelVal * sobelX[kernelIdx];
|
|
2054
|
-
gy += pixelVal * sobelY[kernelIdx];
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
// 计算梯度强度
|
|
2058
|
-
let magnitude = Math.sqrt(gx * gx + gy * gy);
|
|
2059
|
-
// 应用阈值
|
|
2060
|
-
magnitude = magnitude > threshold ? 255 : 0;
|
|
2061
|
-
// 设置输出像素
|
|
2062
|
-
const pos = (y * width + x) * 4;
|
|
2063
|
-
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = magnitude;
|
|
2064
|
-
outputData[pos + 3] = 255; // 透明度保持完全不透明
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
// 处理边缘像素
|
|
2068
|
-
for (let i = 0; i < width * 4; i++) {
|
|
2069
|
-
// 顶部和底部行
|
|
2070
|
-
outputData[i] = 0;
|
|
2071
|
-
outputData[(height - 1) * width * 4 + i] = 0;
|
|
2072
|
-
}
|
|
2073
|
-
for (let i = 0; i < height; i++) {
|
|
2074
|
-
// 左右两侧列
|
|
2075
|
-
const leftPos = i * width * 4;
|
|
2076
|
-
const rightPos = (i * width + width - 1) * 4;
|
|
2077
|
-
for (let j = 0; j < 4; j++) {
|
|
2078
|
-
outputData[leftPos + j] = 0;
|
|
2079
|
-
outputData[rightPos + j] = 0;
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
return new ImageData(outputData, width, height);
|
|
2083
|
-
}
|
|
2084
|
-
/**
|
|
2085
|
-
* 卡尼-德里奇边缘检测
|
|
2086
|
-
* 相比Sobel更精确的边缘检测算法
|
|
2087
|
-
*
|
|
2088
|
-
* @param imageData 灰度图像数据
|
|
2089
|
-
* @param lowThreshold 低阈值
|
|
2090
|
-
* @param highThreshold 高阈值
|
|
2091
|
-
* @returns 边缘检测结果
|
|
2092
|
-
*/
|
|
2093
|
-
static cannyEdgeDetection(imageData, lowThreshold = 20, highThreshold = 50) {
|
|
2094
|
-
const grayscaleImage = this.toGrayscale(new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height));
|
|
2095
|
-
// 1. 高斯模糊
|
|
2096
|
-
const blurredImage = this.gaussianBlur(grayscaleImage, 1.5);
|
|
2097
|
-
// 2. 使用Sobel算子计算梯度
|
|
2098
|
-
const { gradientMagnitude, gradientDirection } = this.computeGradients(blurredImage);
|
|
2099
|
-
// 3. 非极大值抛弃
|
|
2100
|
-
const nonMaxSuppressed = this.nonMaxSuppression(gradientMagnitude, gradientDirection, blurredImage.width, blurredImage.height);
|
|
2101
|
-
// 4. 双阈值处理
|
|
2102
|
-
const thresholdResult = this.hysteresisThresholding(nonMaxSuppressed, blurredImage.width, blurredImage.height, lowThreshold, highThreshold);
|
|
2103
|
-
// 创建输出图像
|
|
2104
|
-
const outputData = new Uint8ClampedArray(imageData.data.length);
|
|
2105
|
-
// 将结果转换为ImageData
|
|
2106
|
-
for (let i = 0; i < thresholdResult.length; i++) {
|
|
2107
|
-
const pos = i * 4;
|
|
2108
|
-
const value = thresholdResult[i] ? 255 : 0;
|
|
2109
|
-
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = value;
|
|
2110
|
-
outputData[pos + 3] = 255;
|
|
2111
|
-
}
|
|
2112
|
-
return new ImageData(outputData, blurredImage.width, blurredImage.height);
|
|
2113
|
-
}
|
|
2114
|
-
/**
|
|
2115
|
-
* 高斯模糊
|
|
2116
|
-
*/
|
|
2117
|
-
static gaussianBlur(imageData, sigma = 1.5) {
|
|
2118
|
-
const width = imageData.width;
|
|
2119
|
-
const height = imageData.height;
|
|
2120
|
-
const inputData = imageData.data;
|
|
2121
|
-
const outputData = new Uint8ClampedArray(inputData.length);
|
|
2122
|
-
// 生成高斯核
|
|
2123
|
-
const kernelSize = Math.max(3, Math.floor(sigma * 3) * 2 + 1);
|
|
2124
|
-
const halfKernel = Math.floor(kernelSize / 2);
|
|
2125
|
-
const kernel = this.generateGaussianKernel(kernelSize, sigma);
|
|
2126
|
-
// 应用高斯核
|
|
2127
|
-
for (let y = 0; y < height; y++) {
|
|
2128
|
-
for (let x = 0; x < width; x++) {
|
|
2129
|
-
let sum = 0;
|
|
2130
|
-
let weightSum = 0;
|
|
2131
|
-
for (let ky = -halfKernel; ky <= halfKernel; ky++) {
|
|
2132
|
-
for (let kx = -halfKernel; kx <= halfKernel; kx++) {
|
|
2133
|
-
const pixelY = Math.min(Math.max(y + ky, 0), height - 1);
|
|
2134
|
-
const pixelX = Math.min(Math.max(x + kx, 0), width - 1);
|
|
2135
|
-
const pixelPos = (pixelY * width + pixelX) * 4;
|
|
2136
|
-
const kernelY = ky + halfKernel;
|
|
2137
|
-
const kernelX = kx + halfKernel;
|
|
2138
|
-
const weight = kernel[kernelY * kernelSize + kernelX];
|
|
2139
|
-
sum += inputData[pixelPos] * weight;
|
|
2140
|
-
weightSum += weight;
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
const pos = (y * width + x) * 4;
|
|
2144
|
-
const value = Math.round(sum / weightSum);
|
|
2145
|
-
outputData[pos] = outputData[pos + 1] = outputData[pos + 2] = value;
|
|
2146
|
-
outputData[pos + 3] = 255;
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
return new ImageData(outputData, width, height);
|
|
2150
|
-
}
|
|
2151
|
-
/**
|
|
2152
|
-
* 生成高斯核
|
|
2153
|
-
*/
|
|
2154
|
-
static generateGaussianKernel(size, sigma) {
|
|
2155
|
-
const kernel = new Array(size * size);
|
|
2156
|
-
const center = Math.floor(size / 2);
|
|
2157
|
-
let sum = 0;
|
|
2158
|
-
for (let y = 0; y < size; y++) {
|
|
2159
|
-
for (let x = 0; x < size; x++) {
|
|
2160
|
-
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
|
|
2161
|
-
const value = Math.exp(-(distance ** 2) / (2 * sigma ** 2));
|
|
2162
|
-
kernel[y * size + x] = value;
|
|
2163
|
-
sum += value;
|
|
2164
|
-
}
|
|
2657
|
+
height = image.height;
|
|
2165
2658
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2659
|
+
else {
|
|
2660
|
+
width = image.width;
|
|
2661
|
+
height = image.height;
|
|
2169
2662
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
const inputData = imageData.data;
|
|
2179
|
-
const gradientMagnitude = new Array(width * height);
|
|
2180
|
-
const gradientDirection = new Array(width * height);
|
|
2181
|
-
// Sobel算子
|
|
2182
|
-
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
|
2183
|
-
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
|
2184
|
-
for (let y = 1; y < height - 1; y++) {
|
|
2185
|
-
for (let x = 1; x < width - 1; x++) {
|
|
2186
|
-
let gx = 0;
|
|
2187
|
-
let gy = 0;
|
|
2188
|
-
for (let ky = -1; ky <= 1; ky++) {
|
|
2189
|
-
for (let kx = -1; kx <= 1; kx++) {
|
|
2190
|
-
const pixelPos = ((y + ky) * width + (x + kx)) * 4;
|
|
2191
|
-
const pixelVal = inputData[pixelPos];
|
|
2192
|
-
const kernelIdx = (ky + 1) * 3 + (kx + 1);
|
|
2193
|
-
gx += pixelVal * sobelX[kernelIdx];
|
|
2194
|
-
gy += pixelVal * sobelY[kernelIdx];
|
|
2195
|
-
}
|
|
2663
|
+
// 计算调整后的尺寸
|
|
2664
|
+
let newWidth = width;
|
|
2665
|
+
let newHeight = height;
|
|
2666
|
+
if (keepAspectRatio) {
|
|
2667
|
+
if (width > height) {
|
|
2668
|
+
if (width > maxWidth) {
|
|
2669
|
+
newHeight = Math.round(height * (maxWidth / width));
|
|
2670
|
+
newWidth = maxWidth;
|
|
2196
2671
|
}
|
|
2197
|
-
const idx = y * width + x;
|
|
2198
|
-
gradientMagnitude[idx] = Math.sqrt(gx * gx + gy * gy);
|
|
2199
|
-
gradientDirection[idx] = Math.atan2(gy, gx);
|
|
2200
2672
|
}
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
if (y === 0 || y === height - 1 || x === 0 || x === width - 1) {
|
|
2206
|
-
const idx = y * width + x;
|
|
2207
|
-
gradientMagnitude[idx] = 0;
|
|
2208
|
-
gradientDirection[idx] = 0;
|
|
2673
|
+
else {
|
|
2674
|
+
if (height > maxHeight) {
|
|
2675
|
+
newWidth = Math.round(width * (maxHeight / height));
|
|
2676
|
+
newHeight = maxHeight;
|
|
2209
2677
|
}
|
|
2210
2678
|
}
|
|
2211
2679
|
}
|
|
2212
|
-
|
|
2680
|
+
else {
|
|
2681
|
+
newWidth = Math.min(width, maxWidth);
|
|
2682
|
+
newHeight = Math.min(height, maxHeight);
|
|
2683
|
+
}
|
|
2684
|
+
// 设置canvas尺寸
|
|
2685
|
+
canvas.width = newWidth;
|
|
2686
|
+
canvas.height = newHeight;
|
|
2687
|
+
// 绘制调整后的图像
|
|
2688
|
+
if (image instanceof ImageData) {
|
|
2689
|
+
// 创建临时canvas存储ImageData
|
|
2690
|
+
const tempCanvas = document.createElement('canvas');
|
|
2691
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
2692
|
+
if (!tempCtx) {
|
|
2693
|
+
throw new Error('无法创建临时Canvas上下文');
|
|
2694
|
+
}
|
|
2695
|
+
tempCanvas.width = image.width;
|
|
2696
|
+
tempCanvas.height = image.height;
|
|
2697
|
+
tempCtx.putImageData(image, 0, 0);
|
|
2698
|
+
// 绘制调整后的图像
|
|
2699
|
+
ctx.drawImage(tempCanvas, 0, 0, width, height, 0, 0, newWidth, newHeight);
|
|
2700
|
+
}
|
|
2701
|
+
else {
|
|
2702
|
+
ctx.drawImage(image, 0, 0, width, height, 0, 0, newWidth, newHeight);
|
|
2703
|
+
}
|
|
2704
|
+
// 返回调整后的ImageData
|
|
2705
|
+
return ctx.getImageData(0, 0, newWidth, newHeight);
|
|
2213
2706
|
}
|
|
2214
2707
|
/**
|
|
2215
|
-
*
|
|
2708
|
+
* @deprecated 请使用 EdgeDetector.detectEdges()
|
|
2216
2709
|
*/
|
|
2217
|
-
static
|
|
2218
|
-
|
|
2219
|
-
for (let y = 1; y < height - 1; y++) {
|
|
2220
|
-
for (let x = 1; x < width - 1; x++) {
|
|
2221
|
-
const idx = y * width + x;
|
|
2222
|
-
const magnitude = gradientMagnitude[idx];
|
|
2223
|
-
const direction = gradientDirection[idx];
|
|
2224
|
-
// 将方向转化为角度
|
|
2225
|
-
const degrees = (direction * 180 / Math.PI + 180) % 180;
|
|
2226
|
-
// 获取相邻像素索引
|
|
2227
|
-
let neighbor1Idx, neighbor2Idx;
|
|
2228
|
-
// 将方向量化为四个方向: 0°, 45°, 90°, 135°
|
|
2229
|
-
if ((degrees >= 0 && degrees < 22.5) || (degrees >= 157.5 && degrees <= 180)) {
|
|
2230
|
-
// 水平方向
|
|
2231
|
-
neighbor1Idx = idx - 1;
|
|
2232
|
-
neighbor2Idx = idx + 1;
|
|
2233
|
-
}
|
|
2234
|
-
else if (degrees >= 22.5 && degrees < 67.5) {
|
|
2235
|
-
// 45度方向
|
|
2236
|
-
neighbor1Idx = (y - 1) * width + (x + 1);
|
|
2237
|
-
neighbor2Idx = (y + 1) * width + (x - 1);
|
|
2238
|
-
}
|
|
2239
|
-
else if (degrees >= 67.5 && degrees < 112.5) {
|
|
2240
|
-
// 垂直方向
|
|
2241
|
-
neighbor1Idx = (y - 1) * width + x;
|
|
2242
|
-
neighbor2Idx = (y + 1) * width + x;
|
|
2243
|
-
}
|
|
2244
|
-
else {
|
|
2245
|
-
// 135度方向
|
|
2246
|
-
neighbor1Idx = (y - 1) * width + (x - 1);
|
|
2247
|
-
neighbor2Idx = (y + 1) * width + (x + 1);
|
|
2248
|
-
}
|
|
2249
|
-
// 检查当前像素是否是最大值
|
|
2250
|
-
if (magnitude >= gradientMagnitude[neighbor1Idx] &&
|
|
2251
|
-
magnitude >= gradientMagnitude[neighbor2Idx]) {
|
|
2252
|
-
result[idx] = magnitude;
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
}
|
|
2256
|
-
return result;
|
|
2710
|
+
static detectEdges(imageData, threshold = 30) {
|
|
2711
|
+
return EdgeDetector.detectEdges(imageData, threshold);
|
|
2257
2712
|
}
|
|
2258
2713
|
/**
|
|
2259
|
-
*
|
|
2714
|
+
* @deprecated 请使用 EdgeDetector.cannyEdgeDetection()
|
|
2260
2715
|
*/
|
|
2261
|
-
static
|
|
2262
|
-
|
|
2263
|
-
const visited = new Array(width * height).fill(false);
|
|
2264
|
-
const stack = [];
|
|
2265
|
-
// 标记强边缘点
|
|
2266
|
-
for (let i = 0; i < nonMaxSuppressed.length; i++) {
|
|
2267
|
-
if (nonMaxSuppressed[i] >= highThreshold) {
|
|
2268
|
-
result[i] = true;
|
|
2269
|
-
stack.push(i);
|
|
2270
|
-
visited[i] = true;
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
// 使用深度优先搜索连接弱边缘
|
|
2274
|
-
const dx = [-1, 0, 1, -1, 1, -1, 0, 1];
|
|
2275
|
-
const dy = [-1, -1, -1, 0, 0, 1, 1, 1];
|
|
2276
|
-
while (stack.length > 0) {
|
|
2277
|
-
const currentIdx = stack.pop();
|
|
2278
|
-
const currentX = currentIdx % width;
|
|
2279
|
-
const currentY = Math.floor(currentIdx / width);
|
|
2280
|
-
// 检查88个相邻方向
|
|
2281
|
-
for (let i = 0; i < 8; i++) {
|
|
2282
|
-
const newX = currentX + dx[i];
|
|
2283
|
-
const newY = currentY + dy[i];
|
|
2284
|
-
if (newX >= 0 && newX < width && newY >= 0 && newY < height) {
|
|
2285
|
-
const newIdx = newY * width + newX;
|
|
2286
|
-
if (!visited[newIdx] && nonMaxSuppressed[newIdx] >= lowThreshold) {
|
|
2287
|
-
result[newIdx] = true;
|
|
2288
|
-
stack.push(newIdx);
|
|
2289
|
-
visited[newIdx] = true;
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
-
return result;
|
|
2716
|
+
static cannyEdgeDetection(imageData, lowThreshold = 20, highThreshold = 50) {
|
|
2717
|
+
return EdgeDetector.cannyEdgeDetection(imageData, lowThreshold, highThreshold);
|
|
2295
2718
|
}
|
|
2296
2719
|
}
|
|
2297
2720
|
|
|
@@ -2581,7 +3004,7 @@ async function processOCRInWorker(input) {
|
|
|
2581
3004
|
// 识别图像
|
|
2582
3005
|
const { data } = await worker.recognize(input.imageBase64);
|
|
2583
3006
|
// 解析身份证信息
|
|
2584
|
-
const idCardInfo =
|
|
3007
|
+
const idCardInfo = IDCardTextParser.parse(data.text);
|
|
2585
3008
|
// 释放Worker资源
|
|
2586
3009
|
await worker.terminate();
|
|
2587
3010
|
const processingTime = performance.now() - startTime;
|
|
@@ -2595,160 +3018,6 @@ async function processOCRInWorker(input) {
|
|
|
2595
3018
|
};
|
|
2596
3019
|
}
|
|
2597
3020
|
}
|
|
2598
|
-
/**
|
|
2599
|
-
* 解析身份证文本
|
|
2600
|
-
* @param text OCR识别的文本
|
|
2601
|
-
* @returns 解析后的身份证信息
|
|
2602
|
-
*/
|
|
2603
|
-
function parseIDCardText(text) {
|
|
2604
|
-
const info = {};
|
|
2605
|
-
// 预处理文本,清除多余空白
|
|
2606
|
-
const processedText = text.replace(/\s+/g, ' ').trim();
|
|
2607
|
-
// 解析身份证号码
|
|
2608
|
-
const idNumberRegex = /(\d{17}[\dX])/;
|
|
2609
|
-
const idNumberWithPrefixRegex = /公民身份号码[\s\:]*(\d{17}[\dX])/;
|
|
2610
|
-
const basicMatch = processedText.match(idNumberRegex);
|
|
2611
|
-
const prefixMatch = processedText.match(idNumberWithPrefixRegex);
|
|
2612
|
-
if (prefixMatch && prefixMatch[1]) {
|
|
2613
|
-
info.idNumber = prefixMatch[1];
|
|
2614
|
-
}
|
|
2615
|
-
else if (basicMatch && basicMatch[1]) {
|
|
2616
|
-
info.idNumber = basicMatch[1];
|
|
2617
|
-
}
|
|
2618
|
-
// 解析姓名
|
|
2619
|
-
const nameWithLabelRegex = /姓名[\s\:]*([一-龥]{2,4})/;
|
|
2620
|
-
const nameMatch = processedText.match(nameWithLabelRegex);
|
|
2621
|
-
if (nameMatch && nameMatch[1]) {
|
|
2622
|
-
info.name = nameMatch[1].trim();
|
|
2623
|
-
}
|
|
2624
|
-
else {
|
|
2625
|
-
// 备用方案:查找短行且内容全是汉字
|
|
2626
|
-
const lines = processedText.split('\n').filter(line => line.trim());
|
|
2627
|
-
for (const line of lines) {
|
|
2628
|
-
if (line.length >= 2 &&
|
|
2629
|
-
line.length <= 5 &&
|
|
2630
|
-
/^[一-龥]+$/.test(line) &&
|
|
2631
|
-
!/性别|民族|住址|公民|签发|有效/.test(line)) {
|
|
2632
|
-
info.name = line.trim();
|
|
2633
|
-
break;
|
|
2634
|
-
}
|
|
2635
|
-
}
|
|
2636
|
-
}
|
|
2637
|
-
// 解析性别和民族
|
|
2638
|
-
const genderAndNationalityRegex = /性别[\s\:]*([男女])[\s ]*民族[\s\:]*([一-龥]+族)/;
|
|
2639
|
-
const genderOnlyRegex = /性别[\s\:]*([男女])/;
|
|
2640
|
-
const nationalityOnlyRegex = /民族[\s\:]*([一-龥]+族)/;
|
|
2641
|
-
const genderNationalityMatch = processedText.match(genderAndNationalityRegex);
|
|
2642
|
-
const genderOnlyMatch = processedText.match(genderOnlyRegex);
|
|
2643
|
-
const nationalityOnlyMatch = processedText.match(nationalityOnlyRegex);
|
|
2644
|
-
if (genderNationalityMatch) {
|
|
2645
|
-
info.gender = genderNationalityMatch[1];
|
|
2646
|
-
info.ethnicity = genderNationalityMatch[2];
|
|
2647
|
-
}
|
|
2648
|
-
else {
|
|
2649
|
-
if (genderOnlyMatch)
|
|
2650
|
-
info.gender = genderOnlyMatch[1];
|
|
2651
|
-
if (nationalityOnlyMatch)
|
|
2652
|
-
info.ethnicity = nationalityOnlyMatch[1];
|
|
2653
|
-
}
|
|
2654
|
-
// 根据内容判断身份证类型
|
|
2655
|
-
if (processedText.includes('出生') || processedText.includes('公民身份号码')) {
|
|
2656
|
-
info.type = IDCardType.FRONT; // 确保类型为枚举值而不是字符串
|
|
2657
|
-
}
|
|
2658
|
-
else if (processedText.includes('签发机关') || processedText.includes('有效期')) {
|
|
2659
|
-
info.type = IDCardType.BACK; // 确保类型为枚举值而不是字符串
|
|
2660
|
-
}
|
|
2661
|
-
// 解析出生日期
|
|
2662
|
-
const birthDateRegex1 = /出生[\s\:]*(\d{4})年(\d{1,2})月(\d{1,2})[日号]/;
|
|
2663
|
-
const birthDateRegex2 = /出生[\s\:]*(\d{4})[-\/\.](\d{1,2})[-\/\.](\d{1,2})/;
|
|
2664
|
-
const birthDateRegex3 = /出生日期[\s\:]*(\d{4})[-\/\.\u5e74](\d{1,2})[-\/\.\u6708](\d{1,2})[日号]?/;
|
|
2665
|
-
const birthDateMatch = processedText.match(birthDateRegex1) ||
|
|
2666
|
-
processedText.match(birthDateRegex2) ||
|
|
2667
|
-
processedText.match(birthDateRegex3);
|
|
2668
|
-
if (!birthDateMatch && info.idNumber && info.idNumber.length === 18) {
|
|
2669
|
-
const year = info.idNumber.substring(6, 10);
|
|
2670
|
-
const month = info.idNumber.substring(10, 12);
|
|
2671
|
-
const day = info.idNumber.substring(12, 14);
|
|
2672
|
-
info.birthDate = `${year}-${month}-${day}`;
|
|
2673
|
-
}
|
|
2674
|
-
else if (birthDateMatch) {
|
|
2675
|
-
const year = birthDateMatch[1];
|
|
2676
|
-
const month = birthDateMatch[2].padStart(2, '0');
|
|
2677
|
-
const day = birthDateMatch[3].padStart(2, '0');
|
|
2678
|
-
info.birthDate = `${year}-${month}-${day}`;
|
|
2679
|
-
}
|
|
2680
|
-
// 解析地址
|
|
2681
|
-
const addressRegex1 = /住址[\s\:]*([\s\S]*?)(?=公民身份|出生|性别|签发)/;
|
|
2682
|
-
const addressRegex2 = /住址[\s\:]*([一-龥a-zA-Z0-9\s\.\-]+)/;
|
|
2683
|
-
const addressMatch = processedText.match(addressRegex1) || processedText.match(addressRegex2);
|
|
2684
|
-
if (addressMatch && addressMatch[1]) {
|
|
2685
|
-
info.address = addressMatch[1]
|
|
2686
|
-
.replace(/\s+/g, '')
|
|
2687
|
-
.replace(/\n/g, '')
|
|
2688
|
-
.trim();
|
|
2689
|
-
if (info.address.length > 70) {
|
|
2690
|
-
info.address = info.address.substring(0, 70);
|
|
2691
|
-
}
|
|
2692
|
-
if (!/[一-龥]/.test(info.address)) {
|
|
2693
|
-
info.address = '';
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
2696
|
-
// 解析签发机关
|
|
2697
|
-
const authorityRegex1 = /签发机关[\s\:]*([\s\S]*?)(?=有效|公民|出生|\d{8}|$)/;
|
|
2698
|
-
const authorityRegex2 = /签发机关[\s\:]*([一-龥\s]+)/;
|
|
2699
|
-
const authorityMatch = processedText.match(authorityRegex1) ||
|
|
2700
|
-
processedText.match(authorityRegex2);
|
|
2701
|
-
if (authorityMatch && authorityMatch[1]) {
|
|
2702
|
-
info.issueAuthority = authorityMatch[1]
|
|
2703
|
-
.replace(/\s+/g, '')
|
|
2704
|
-
.replace(/\n/g, '')
|
|
2705
|
-
.trim();
|
|
2706
|
-
}
|
|
2707
|
-
// 解析有效期限
|
|
2708
|
-
const validPeriodRegex1 = /有效期限[\s\:]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日\s]*)[-\s]*(至|-)[-\s]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日]*|[永久长期]*)/;
|
|
2709
|
-
const validPeriodRegex2 = /有效期限[\s\:]*(\d{8})[-\s]*(至|-)[-\s]*(\d{8}|[永久长期]*)/;
|
|
2710
|
-
const validPeriodMatch = processedText.match(validPeriodRegex1) ||
|
|
2711
|
-
processedText.match(validPeriodRegex2);
|
|
2712
|
-
if (validPeriodMatch) {
|
|
2713
|
-
if (validPeriodMatch[1] && validPeriodMatch[3]) {
|
|
2714
|
-
const startDate = formatDateString(validPeriodMatch[1]);
|
|
2715
|
-
const endDate = /\d/.test(validPeriodMatch[3])
|
|
2716
|
-
? formatDateString(validPeriodMatch[3])
|
|
2717
|
-
: '长期有效';
|
|
2718
|
-
info.validFrom = startDate;
|
|
2719
|
-
info.validTo = endDate;
|
|
2720
|
-
info.validPeriod = `${startDate}-${endDate}`;
|
|
2721
|
-
}
|
|
2722
|
-
else {
|
|
2723
|
-
info.validPeriod = validPeriodMatch[0].replace('有效期限', '').trim();
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
return info;
|
|
2727
|
-
}
|
|
2728
|
-
/**
|
|
2729
|
-
* 格式化日期字符串
|
|
2730
|
-
* @param dateStr 原始日期字符串
|
|
2731
|
-
* @returns 格式化后的日期字符串
|
|
2732
|
-
*/
|
|
2733
|
-
function formatDateString(dateStr) {
|
|
2734
|
-
// 提取年月日
|
|
2735
|
-
const dateMatch = dateStr.match(/(\d{4})[-\.\u5e74\s]*(\d{1,2})[-\.\u6708\s]*(\d{1,2})[日]*/);
|
|
2736
|
-
if (dateMatch) {
|
|
2737
|
-
const year = dateMatch[1];
|
|
2738
|
-
const month = dateMatch[2].padStart(2, '0');
|
|
2739
|
-
const day = dateMatch[3].padStart(2, '0');
|
|
2740
|
-
return `${year}-${month}-${day}`;
|
|
2741
|
-
}
|
|
2742
|
-
// 纯数字格式如 20220101
|
|
2743
|
-
if (/^\d{8}$/.test(dateStr)) {
|
|
2744
|
-
const year = dateStr.substring(0, 4);
|
|
2745
|
-
const month = dateStr.substring(4, 6);
|
|
2746
|
-
const day = dateStr.substring(6, 8);
|
|
2747
|
-
return `${year}-${month}-${day}`;
|
|
2748
|
-
}
|
|
2749
|
-
// 无法格式化,返回原始字符串
|
|
2750
|
-
return dateStr;
|
|
2751
|
-
}
|
|
2752
3021
|
|
|
2753
3022
|
/**
|
|
2754
3023
|
* @file OCR处理器
|
|
@@ -2800,7 +3069,7 @@ class OCRProcessor {
|
|
|
2800
3069
|
/**
|
|
2801
3070
|
* 初始化OCR引擎
|
|
2802
3071
|
*
|
|
2803
|
-
* 加载Tesseract OCR
|
|
3072
|
+
* 加载Tesseract OCR引擎和中文简体语言包,并设置适合身份证识别的参数
|
|
2804
3073
|
*
|
|
2805
3074
|
* @returns {Promise<void>} 初始化完成的Promise
|
|
2806
3075
|
*/
|
|
@@ -2822,11 +3091,11 @@ class OCRProcessor {
|
|
|
2822
3091
|
await this.worker.loadLanguage("chi_sim");
|
|
2823
3092
|
await this.worker.initialize("chi_sim");
|
|
2824
3093
|
await this.worker.setParameters({
|
|
2825
|
-
tessedit_char_whitelist: "0123456789X年月日壹贰叁肆伍陆柒捌玖拾民族汉满回维吾尔藏苗彝壮朝鲜侗瑶白土家哈尼哈萨克傣黎傈僳佤高山拉祜水东乡纳西景颇柯尔克孜达斡尔仫佬羌布朗撒拉毛南仡佬锡伯阿昌普米塔吉克怒乌孜别克俄罗斯鄂温克德昂保安裕固京塔塔尔独龙鄂伦春赫哲门巴珞巴基诺男女住址出生公民身份号码签发机关有效期省市区县乡镇街道号楼单元室ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", //
|
|
3094
|
+
tessedit_char_whitelist: "0123456789X年月日壹贰叁肆伍陆柒捌玖拾民族汉满回维吾尔藏苗彝壮朝鲜侗瑶白土家哈尼哈萨克傣黎傈僳佤高山拉祜水东乡纳西景颇柯尔克孜达斡尔仫佬羌布朗撒拉毛南仡佬锡伯阿昌普米塔吉克怒乌孜别克俄罗斯鄂温克德昂保安裕固京塔塔尔独龙鄂伦春赫哲门巴珞巴基诺男女住址出生公民身份号码签发机关有效期省市区县乡镇街道号楼单元室ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", // 优化字符白名单,增加常见地址字符,移除部分不常用汉字
|
|
2826
3095
|
});
|
|
2827
|
-
//
|
|
3096
|
+
// 增加一些针对性的参数,提高识别率
|
|
2828
3097
|
await this.worker.setParameters({
|
|
2829
|
-
tessedit_pageseg_mode: 7, // PSM_SINGLE_LINE
|
|
3098
|
+
tessedit_pageseg_mode: 7, // PSM_SINGLE_LINE,使用数字而不是字符串
|
|
2830
3099
|
preserve_interword_spaces: "1", // 保留单词间的空格
|
|
2831
3100
|
});
|
|
2832
3101
|
this.initialized = true;
|
|
@@ -2842,7 +3111,7 @@ class OCRProcessor {
|
|
|
2842
3111
|
if (!this.initialized) {
|
|
2843
3112
|
await this.initialize();
|
|
2844
3113
|
}
|
|
2845
|
-
//
|
|
3114
|
+
// 计算图像指纹,用于缓存查找
|
|
2846
3115
|
if (this.options.enableCache) {
|
|
2847
3116
|
const fingerprint = calculateImageFingerprint(imageData);
|
|
2848
3117
|
// 检查缓存中是否有结果
|
|
@@ -2859,7 +3128,7 @@ class OCRProcessor {
|
|
|
2859
3128
|
const enhancedImage = ImageProcessor.batchProcess(downsampledImage, {
|
|
2860
3129
|
brightness: this.options.brightness !== undefined ? this.options.brightness : 10, // 调整默认亮度
|
|
2861
3130
|
contrast: this.options.contrast !== undefined ? this.options.contrast : 20, // 调整默认对比度
|
|
2862
|
-
sharpen: true, //
|
|
3131
|
+
sharpen: true, // 默认启用锐化,通常对OCR有益
|
|
2863
3132
|
});
|
|
2864
3133
|
// 转换为base64供Tesseract处理
|
|
2865
3134
|
// 创建一个canvas元素
|
|
@@ -2881,11 +3150,11 @@ class OCRProcessor {
|
|
|
2881
3150
|
// 使用Worker线程处理
|
|
2882
3151
|
const result = await this.ocrWorker.postMessage({
|
|
2883
3152
|
imageBase64: base64Image,
|
|
2884
|
-
//
|
|
3153
|
+
// 不传递函数对象,避免DataCloneError
|
|
2885
3154
|
tessWorkerOptions: {},
|
|
2886
3155
|
});
|
|
2887
3156
|
idCardInfo = result.idCardInfo;
|
|
2888
|
-
this.options.logger?.(`OCR
|
|
3157
|
+
this.options.logger?.(`OCR处理完成,用时: ${result.processingTime.toFixed(2)}ms`);
|
|
2889
3158
|
}
|
|
2890
3159
|
else {
|
|
2891
3160
|
// 使用主线程处理
|
|
@@ -2898,9 +3167,9 @@ class OCRProcessor {
|
|
|
2898
3167
|
}
|
|
2899
3168
|
const { data } = (await this.worker.recognize(canvas));
|
|
2900
3169
|
// 解析身份证信息
|
|
2901
|
-
idCardInfo =
|
|
3170
|
+
idCardInfo = IDCardTextParser.parse(data.text);
|
|
2902
3171
|
const processingTime = performance.now() - startTime;
|
|
2903
|
-
this.options.logger?.(`OCR
|
|
3172
|
+
this.options.logger?.(`OCR处理完成,用时: ${processingTime.toFixed(2)}ms`);
|
|
2904
3173
|
}
|
|
2905
3174
|
// 缓存结果
|
|
2906
3175
|
if (this.options.enableCache) {
|
|
@@ -2917,7 +3186,7 @@ class OCRProcessor {
|
|
|
2917
3186
|
? JSON.stringify(error)
|
|
2918
3187
|
: String(error);
|
|
2919
3188
|
this.options.logger?.(`OCR识别错误: ${errorMessage}`);
|
|
2920
|
-
// 返回 null
|
|
3189
|
+
// 返回 null,让调用方知道识别失败
|
|
2921
3190
|
return null;
|
|
2922
3191
|
}
|
|
2923
3192
|
}
|
|
@@ -2930,198 +3199,6 @@ class OCRProcessor {
|
|
|
2930
3199
|
* @param {string} text - OCR识别到的文本
|
|
2931
3200
|
* @returns {IDCardInfo} 提取到的身份证信息对象
|
|
2932
3201
|
*/
|
|
2933
|
-
/**
|
|
2934
|
-
* 格式化日期字符串为标准格式 (YYYY-MM-DD)
|
|
2935
|
-
* @param dateStr 原始日期字符串
|
|
2936
|
-
* @returns 格式化后的日期字符串
|
|
2937
|
-
*/
|
|
2938
|
-
formatDateString(dateStr) {
|
|
2939
|
-
// 先尝试提取年月日
|
|
2940
|
-
const dateMatch = dateStr.match(/(\d{4})[-\.\u5e74\s]*(\d{1,2})[-\.\u6708\s]*(\d{1,2})[日]*/);
|
|
2941
|
-
if (dateMatch) {
|
|
2942
|
-
const year = dateMatch[1];
|
|
2943
|
-
const month = dateMatch[2].padStart(2, "0");
|
|
2944
|
-
const day = dateMatch[3].padStart(2, "0");
|
|
2945
|
-
return `${year}-${month}-${day}`;
|
|
2946
|
-
}
|
|
2947
|
-
// 如果是纯数字格式如 20220101
|
|
2948
|
-
if (/^\d{8}$/.test(dateStr)) {
|
|
2949
|
-
const year = dateStr.substring(0, 4);
|
|
2950
|
-
const month = dateStr.substring(4, 6);
|
|
2951
|
-
const day = dateStr.substring(6, 8);
|
|
2952
|
-
return `${year}-${month}-${day}`;
|
|
2953
|
-
}
|
|
2954
|
-
// 如果无法格式化,返回原始字符串
|
|
2955
|
-
return dateStr;
|
|
2956
|
-
}
|
|
2957
|
-
/**
|
|
2958
|
-
* 验证身份证号是否符合规则
|
|
2959
|
-
* @param idNumber 身份证号
|
|
2960
|
-
* @returns 是否有效
|
|
2961
|
-
*/
|
|
2962
|
-
validateIDNumber(idNumber) {
|
|
2963
|
-
// 基本验证,校验位有效性和长度
|
|
2964
|
-
if (!idNumber || idNumber.length !== 18) {
|
|
2965
|
-
return false;
|
|
2966
|
-
}
|
|
2967
|
-
// 检查格式,前17位必须为数字,最后一位可以是数字或'X'
|
|
2968
|
-
const pattern = /^\d{17}[\dX]$/;
|
|
2969
|
-
if (!pattern.test(idNumber)) {
|
|
2970
|
-
return false;
|
|
2971
|
-
}
|
|
2972
|
-
// 检查日期部分
|
|
2973
|
-
parseInt(idNumber.substr(6, 4));
|
|
2974
|
-
const month = parseInt(idNumber.substr(10, 2));
|
|
2975
|
-
const day = parseInt(idNumber.substr(12, 2));
|
|
2976
|
-
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
2977
|
-
return false;
|
|
2978
|
-
}
|
|
2979
|
-
// 更详细的检查可以添加校验位的验证等逻辑...
|
|
2980
|
-
return true;
|
|
2981
|
-
}
|
|
2982
|
-
parseIDCardText(text) {
|
|
2983
|
-
const info = {};
|
|
2984
|
-
// 预处理文本,清除多余空白
|
|
2985
|
-
const processedText = text.replace(/\s+/g, " ").trim();
|
|
2986
|
-
// 拆分为行,并过滤空行
|
|
2987
|
-
const lines = processedText.split("\n").filter((line) => line.trim());
|
|
2988
|
-
// 解析身份证号码 - 多种模式匹配
|
|
2989
|
-
// 1. 普通18位身份证号模式
|
|
2990
|
-
const idNumberRegex = /(\d{17}[\dX])/;
|
|
2991
|
-
// 2. 带前缀的模式
|
|
2992
|
-
const idNumberWithPrefixRegex = /公民身份号码[\s\:]*(\d{17}[\dX])/;
|
|
2993
|
-
// 尝试所有模式
|
|
2994
|
-
let idNumber = null;
|
|
2995
|
-
const basicMatch = processedText.match(idNumberRegex);
|
|
2996
|
-
const prefixMatch = processedText.match(idNumberWithPrefixRegex);
|
|
2997
|
-
if (prefixMatch && prefixMatch[1]) {
|
|
2998
|
-
idNumber = prefixMatch[1]; // 首选带前缀的匹配,因为最可靠
|
|
2999
|
-
}
|
|
3000
|
-
else if (basicMatch && basicMatch[1]) {
|
|
3001
|
-
idNumber = basicMatch[1]; // 其次是常规匹配
|
|
3002
|
-
}
|
|
3003
|
-
if (idNumber) {
|
|
3004
|
-
info.idNumber = idNumber;
|
|
3005
|
-
}
|
|
3006
|
-
// 解析姓名 - 使用多种策略
|
|
3007
|
-
// 1. 直接匹配姓名标签近的内容
|
|
3008
|
-
const nameWithLabelRegex = /姓名[\s\:]*([一-龥]{2,4})/;
|
|
3009
|
-
const nameMatch = processedText.match(nameWithLabelRegex);
|
|
3010
|
-
// 2. 分析行文本寻找姓名
|
|
3011
|
-
if (nameMatch && nameMatch[1]) {
|
|
3012
|
-
info.name = nameMatch[1].trim();
|
|
3013
|
-
}
|
|
3014
|
-
else {
|
|
3015
|
-
// 备用方案:查找短行且内容全是汉字
|
|
3016
|
-
for (const line of lines) {
|
|
3017
|
-
if (line.length >= 2 &&
|
|
3018
|
-
line.length <= 5 &&
|
|
3019
|
-
/^[一-龥]+$/.test(line) &&
|
|
3020
|
-
!/性别|民族|住址|公民|签发|有效/.test(line)) {
|
|
3021
|
-
info.name = line.trim();
|
|
3022
|
-
break;
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
// 解析性别和民族 - 多种模式匹配
|
|
3027
|
-
// 1. 标准格式匹配
|
|
3028
|
-
const genderAndNationalityRegex = /性别[\s\:]*([男女])[\s ]*民族[\s\:]*([一-龥]+族)/;
|
|
3029
|
-
const genderNationalityMatch = processedText.match(genderAndNationalityRegex);
|
|
3030
|
-
// 2. 只匹配性别
|
|
3031
|
-
const genderOnlyRegex = /性别[\s\:]*([男女])/;
|
|
3032
|
-
const genderOnlyMatch = processedText.match(genderOnlyRegex);
|
|
3033
|
-
// 3. 只匹配民族
|
|
3034
|
-
const nationalityOnlyRegex = /民族[\s\:]*([一-龥]+族)/;
|
|
3035
|
-
const nationalityOnlyMatch = processedText.match(nationalityOnlyRegex);
|
|
3036
|
-
if (genderNationalityMatch) {
|
|
3037
|
-
info.gender = genderNationalityMatch[1];
|
|
3038
|
-
info.nationality = genderNationalityMatch[2];
|
|
3039
|
-
}
|
|
3040
|
-
else {
|
|
3041
|
-
// 分开获取
|
|
3042
|
-
if (genderOnlyMatch)
|
|
3043
|
-
info.gender = genderOnlyMatch[1];
|
|
3044
|
-
if (nationalityOnlyMatch)
|
|
3045
|
-
info.nationality = nationalityOnlyMatch[1];
|
|
3046
|
-
}
|
|
3047
|
-
// 解析出生日期 - 支持多种格式
|
|
3048
|
-
// 1. 标准格式:YYYY年MM月DD日
|
|
3049
|
-
const birthDateRegex1 = /出生[\s\:]*(\d{4})年(\d{1,2})月(\d{1,2})[日号]/;
|
|
3050
|
-
// 2. 美式日期格式:YYYY-MM-DD或YYYY/MM/DD
|
|
3051
|
-
const birthDateRegex2 = /出生[\s\:]*(\d{4})[-\/\.](\d{1,2})[-\/\.](\d{1,2})/;
|
|
3052
|
-
// 3. 带前缀的格式
|
|
3053
|
-
const birthDateRegex3 = /出生日期[\s\:]*(\d{4})[-\/\.\u5e74](\d{1,2})[-\/\.\u6708](\d{1,2})[日号]?/;
|
|
3054
|
-
let birthDateMatch = processedText.match(birthDateRegex1) ||
|
|
3055
|
-
processedText.match(birthDateRegex2) ||
|
|
3056
|
-
processedText.match(birthDateRegex3);
|
|
3057
|
-
// 4. 从身份证号码中提取出生日期(如果上述方法失败)
|
|
3058
|
-
if (!birthDateMatch && info.idNumber && info.idNumber.length === 18) {
|
|
3059
|
-
const year = info.idNumber.substring(6, 10);
|
|
3060
|
-
const month = info.idNumber.substring(10, 12);
|
|
3061
|
-
const day = info.idNumber.substring(12, 14);
|
|
3062
|
-
info.birthDate = `${year}-${month}-${day}`;
|
|
3063
|
-
}
|
|
3064
|
-
else if (birthDateMatch) {
|
|
3065
|
-
// 确保月份和日期是两位数
|
|
3066
|
-
const year = birthDateMatch[1];
|
|
3067
|
-
const month = birthDateMatch[2].padStart(2, "0");
|
|
3068
|
-
const day = birthDateMatch[3].padStart(2, "0");
|
|
3069
|
-
info.birthDate = `${year}-${month}-${day}`;
|
|
3070
|
-
}
|
|
3071
|
-
// 解析地址 - 改进的正则匹配
|
|
3072
|
-
// 1. 常规模式
|
|
3073
|
-
const addressRegex1 = /住址[\s\:]*([\s\S]*?)(?=公民身份|出生|性别|签发)/;
|
|
3074
|
-
// 2. 更宽松的模式
|
|
3075
|
-
const addressRegex2 = /住址[\s\:]*([一-龥a-zA-Z0-9\s\.\-]+)/;
|
|
3076
|
-
const addressMatch = processedText.match(addressRegex1) || processedText.match(addressRegex2);
|
|
3077
|
-
if (addressMatch && addressMatch[1]) {
|
|
3078
|
-
// 清理地址中的常见错误和多余空格
|
|
3079
|
-
info.address = addressMatch[1]
|
|
3080
|
-
.replace(/\s+/g, "")
|
|
3081
|
-
.replace(/\n/g, "")
|
|
3082
|
-
.trim();
|
|
3083
|
-
// 限制地址长度并判断地址合理性
|
|
3084
|
-
if (info.address.length > 70) {
|
|
3085
|
-
info.address = info.address.substring(0, 70);
|
|
3086
|
-
}
|
|
3087
|
-
// 确保地址是合理的(不仅仅包含符号或数字)
|
|
3088
|
-
if (!/[一-龥]/.test(info.address)) {
|
|
3089
|
-
info.address = ""; // 如果没有中文字符,可能不是有效地址
|
|
3090
|
-
}
|
|
3091
|
-
}
|
|
3092
|
-
// 解析签发机关
|
|
3093
|
-
const authorityRegex1 = /签发机关[\s\:]*([\s\S]*?)(?=有效|公民|出生|\d{8}|$)/;
|
|
3094
|
-
const authorityRegex2 = /签发机关[\s\:]*([一-龥\s]+)/;
|
|
3095
|
-
const authorityMatch = processedText.match(authorityRegex1) ||
|
|
3096
|
-
processedText.match(authorityRegex2);
|
|
3097
|
-
if (authorityMatch && authorityMatch[1]) {
|
|
3098
|
-
info.issuingAuthority = authorityMatch[1]
|
|
3099
|
-
.replace(/\s+/g, "")
|
|
3100
|
-
.replace(/\n/g, "")
|
|
3101
|
-
.trim();
|
|
3102
|
-
}
|
|
3103
|
-
// 解析有效期限 - 支持多种格式
|
|
3104
|
-
// 1. 常规格式:YYYY.MM.DD-YYYY.MM.DD
|
|
3105
|
-
const validPeriodRegex1 = /有效期限[\s\:]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日\s]*)[-\s]*(至|-)[-\s]*(\d{4}[-\.\u5e74\s]\d{1,2}[-\.\u6708\s]\d{1,2}[日]*|[永久长期]*)/;
|
|
3106
|
-
// 2. 简化格式:YYYYMMDD-YYYYMMDD
|
|
3107
|
-
const validPeriodRegex2 = /有效期限[\s\:]*(\d{8})[-\s]*(至|-)[-\s]*(\d{8}|[永久长期]*)/;
|
|
3108
|
-
const validPeriodMatch = processedText.match(validPeriodRegex1) ||
|
|
3109
|
-
processedText.match(validPeriodRegex2);
|
|
3110
|
-
if (validPeriodMatch) {
|
|
3111
|
-
// 格式化为统一的有效期限形式
|
|
3112
|
-
if (validPeriodMatch[1] && validPeriodMatch[3]) {
|
|
3113
|
-
const startDate = this.formatDateString(validPeriodMatch[1]);
|
|
3114
|
-
const endDate = /\d/.test(validPeriodMatch[3])
|
|
3115
|
-
? this.formatDateString(validPeriodMatch[3])
|
|
3116
|
-
: "长期有效";
|
|
3117
|
-
info.validPeriod = `${startDate}-${endDate}`;
|
|
3118
|
-
}
|
|
3119
|
-
else {
|
|
3120
|
-
info.validPeriod = validPeriodMatch[0].replace("有效期限", "").trim();
|
|
3121
|
-
}
|
|
3122
|
-
}
|
|
3123
|
-
return info;
|
|
3124
|
-
}
|
|
3125
3202
|
/**
|
|
3126
3203
|
* 清除结果缓存
|
|
3127
3204
|
*/
|