feature: Optional OCR-Assisted Draft Pre-Fill

This commit is contained in:
voltsrage
2026-06-28 01:28:54 +08:00
parent 5039dbb979
commit c160c5f912
42 changed files with 3592 additions and 33 deletions
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Tesseract;
public class TesseractOcrService : IOcrService
{
private static readonly (Regex Pattern, string FieldName)[] ClinicalPatterns =
[
(new Regex(@"(?:Patient\s+)?Name[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "patient.fullName"),
(new Regex(@"DOB[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"),
(new Regex(@"(?:Date of Birth|Birth Date)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"),
(new Regex(@"(?:Sex|Gender)[:\s]+(\S+)", RegexOptions.IgnoreCase), "patient.sex"),
(new Regex(@"(?:Admission Date|Admitted)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "encounter.admissionDate"),
(new Regex(@"(?:Department|Ward|Unit)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.department"),
(new Regex(@"(?:Room(?:/Bed)?|Bed)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.roomBed"),
(new Regex(@"HR[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"),
(new Regex(@"(?:Heart Rate|Pulse)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"),
(new Regex(@"(?:Temp|Temperature)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.TEMP_C.value"),
(new Regex(@"(?:BP Sys|Systolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_SYSTOLIC.value"),
(new Regex(@"(?:BP Dia|Diastolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_DIASTOLIC.value"),
(new Regex(@"(?:RR|Resp(?:iratory)?\s*Rate)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.RESP_RATE.value"),
(new Regex(@"(?:SpO2|O2 Sat)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.SPO2.value"),
];
private readonly TesseractOcrOptions _options;
private readonly ImagePreprocessor _preprocessor;
private readonly ILogger<TesseractOcrService> _logger;
public TesseractOcrService(
IOptions<OcrOptions> options,
ImagePreprocessor preprocessor,
ILogger<TesseractOcrService> logger)
{
_options = options.Value.Tesseract;
_preprocessor = preprocessor;
_logger = logger;
}
public async Task<OcrExtractionResult> ExtractAsync(
Stream documentStream, string contentType)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
using var preprocessed = _preprocessor.Preprocess(documentStream, contentType);
using var memStream = new MemoryStream();
await preprocessed.CopyToAsync(memStream);
var imageBytes = memStream.ToArray();
using var engine = new TesseractEngine(
_options.DataPath, _options.Language, EngineMode.Default);
using var pix = Pix.LoadFromMemory(imageBytes);
using var page = engine.Process(pix);
var rawText = page.GetText();
var meanConfidence = page.GetMeanConfidence();
var fields = ParseClinicalText(rawText, meanConfidence);
sw.Stop();
return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds);
}
private static List<OcrExtractedField> ParseClinicalText(string text, float meanConfidence)
{
var fields = new List<OcrExtractedField>();
if (string.IsNullOrWhiteSpace(text))
return fields;
var baseConfidence = Math.Clamp(meanConfidence / 100f, 0.0, 1.0);
var matchedFields = new HashSet<string>(StringComparer.Ordinal);
foreach (var (pattern, fieldName) in ClinicalPatterns)
{
if (matchedFields.Contains(fieldName))
continue;
var match = pattern.Match(text);
if (!match.Success)
continue;
var value = match.Groups[1].Value.Trim();
if (value.Length == 0)
continue;
fields.Add(new OcrExtractedField(fieldName, value, baseConfidence));
matchedFields.Add(fieldName);
}
return fields;
}
}