feature: Optional OCR-Assisted Draft Pre-Fill
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
using Azure;
|
||||
using Azure.AI.DocumentIntelligence;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class AzureDocumentOcrService : IOcrService
|
||||
{
|
||||
private const double DefaultTableConfidence = 0.75;
|
||||
|
||||
private static readonly Dictionary<string, string> ClinicalLabelMap =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["patient name"] = "patient.fullName",
|
||||
["name"] = "patient.fullName",
|
||||
["full name"] = "patient.fullName",
|
||||
["date of birth"] = "patient.dateOfBirth",
|
||||
["dob"] = "patient.dateOfBirth",
|
||||
["birth date"] = "patient.dateOfBirth",
|
||||
["sex"] = "patient.sex",
|
||||
["gender"] = "patient.sex",
|
||||
["admission date"] = "encounter.admissionDate",
|
||||
["admitted"] = "encounter.admissionDate",
|
||||
["department"] = "encounter.department",
|
||||
["ward"] = "encounter.department",
|
||||
["unit"] = "encounter.department",
|
||||
["room"] = "encounter.roomBed",
|
||||
["bed"] = "encounter.roomBed",
|
||||
["room/bed"] = "encounter.roomBed",
|
||||
["hr"] = "observation.HEART_RATE.value",
|
||||
["heart rate"] = "observation.HEART_RATE.value",
|
||||
["pulse"] = "observation.HEART_RATE.value",
|
||||
["temp"] = "observation.TEMP_C.value",
|
||||
["temperature"] = "observation.TEMP_C.value",
|
||||
["bp sys"] = "observation.BP_SYSTOLIC.value",
|
||||
["systolic"] = "observation.BP_SYSTOLIC.value",
|
||||
["bp dia"] = "observation.BP_DIASTOLIC.value",
|
||||
["diastolic"] = "observation.BP_DIASTOLIC.value",
|
||||
["rr"] = "observation.RESP_RATE.value",
|
||||
["resp rate"] = "observation.RESP_RATE.value",
|
||||
["respiratory rate"] = "observation.RESP_RATE.value",
|
||||
["spo2"] = "observation.SPO2.value",
|
||||
["o2 sat"] = "observation.SPO2.value",
|
||||
["oxygen saturation"] = "observation.SPO2.value",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> TimestampHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"time", "date", "datetime", "date/time", "recorded", "recorded at", "timestamp"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> GenericTableHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"label", "name", "parameter", "field", "item", "value", "result", "reading"
|
||||
};
|
||||
|
||||
private readonly DocumentIntelligenceClient _client;
|
||||
private readonly ILogger<AzureDocumentOcrService> _logger;
|
||||
|
||||
public AzureDocumentOcrService(
|
||||
IOptions<OcrOptions> options,
|
||||
ILogger<AzureDocumentOcrService> logger)
|
||||
{
|
||||
var opts = options.Value.Azure;
|
||||
_client = new DocumentIntelligenceClient(
|
||||
new Uri(opts.Endpoint),
|
||||
new AzureKeyCredential(opts.ApiKey));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OcrExtractionResult> ExtractAsync(
|
||||
Stream documentStream, string contentType)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var content = BinaryData.FromStream(documentStream);
|
||||
var operation = await _client.AnalyzeDocumentAsync(
|
||||
WaitUntil.Completed,
|
||||
"prebuilt-document",
|
||||
content);
|
||||
|
||||
var result = operation.Value;
|
||||
var fields = new List<OcrExtractedField>();
|
||||
|
||||
foreach (var kv in result.KeyValuePairs ?? [])
|
||||
{
|
||||
if (kv.Key?.Content is null || kv.Value?.Content is null) continue;
|
||||
|
||||
var fieldName = MapAzureKeyToFieldName(kv.Key.Content);
|
||||
if (fieldName is null) continue;
|
||||
|
||||
fields.Add(new OcrExtractedField(
|
||||
fieldName,
|
||||
kv.Value.Content,
|
||||
kv.Confidence));
|
||||
}
|
||||
|
||||
foreach (var table in result.Tables ?? [])
|
||||
{
|
||||
fields.AddRange(ExtractTableObservations(table));
|
||||
}
|
||||
|
||||
var rawText = string.Join("\n", (result.Pages ?? [])
|
||||
.SelectMany(p => p.Lines?.Select(l => l.Content) ?? []));
|
||||
|
||||
sw.Stop();
|
||||
return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private static string? MapAzureKeyToFieldName(string key)
|
||||
{
|
||||
var normalized = key.Trim();
|
||||
if (normalized.Length == 0)
|
||||
return null;
|
||||
|
||||
return ClinicalLabelMap.GetValueOrDefault(normalized);
|
||||
}
|
||||
|
||||
private static List<OcrExtractedField> ExtractTableObservations(DocumentTable table)
|
||||
{
|
||||
var fields = new List<OcrExtractedField>();
|
||||
if (table.Cells is null || table.Cells.Count == 0)
|
||||
return fields;
|
||||
|
||||
if (table.ColumnCount == 2)
|
||||
{
|
||||
var labelValueFields = ExtractLabelValueTable(table);
|
||||
if (labelValueFields.Count > 0)
|
||||
return labelValueFields;
|
||||
}
|
||||
|
||||
return ExtractGridTable(table);
|
||||
}
|
||||
|
||||
private static List<OcrExtractedField> ExtractLabelValueTable(DocumentTable table)
|
||||
{
|
||||
var fields = new List<OcrExtractedField>();
|
||||
var dataStartRow = HasGenericHeaderRow(table.Cells) ? 1 : 0;
|
||||
|
||||
for (var row = dataStartRow; row < table.RowCount; row++)
|
||||
{
|
||||
var key = GetCellContent(table.Cells, row, 0);
|
||||
var value = GetCellContent(table.Cells, row, 1);
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
|
||||
var fieldName = MapAzureKeyToFieldName(key);
|
||||
if (fieldName is null)
|
||||
continue;
|
||||
|
||||
fields.Add(new OcrExtractedField(fieldName, value.Trim(), DefaultTableConfidence));
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static List<OcrExtractedField> ExtractGridTable(DocumentTable table)
|
||||
{
|
||||
var fields = new List<OcrExtractedField>();
|
||||
var headerCells = table.Cells
|
||||
.Where(c => c.RowIndex == 0)
|
||||
.OrderBy(c => c.ColumnIndex)
|
||||
.ToList();
|
||||
|
||||
if (headerCells.Count == 0)
|
||||
return fields;
|
||||
|
||||
var columnMappings = new Dictionary<int, string?>();
|
||||
foreach (var headerCell in headerCells)
|
||||
{
|
||||
columnMappings[headerCell.ColumnIndex] = MapTableHeader(headerCell.Content);
|
||||
}
|
||||
|
||||
var observationCodes = columnMappings.Values
|
||||
.Where(v => v is not null and not "recordedAt")
|
||||
.Cast<string>()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (observationCodes.Count == 0)
|
||||
return fields;
|
||||
|
||||
for (var row = 1; row < table.RowCount; row++)
|
||||
{
|
||||
string? recordedAt = null;
|
||||
|
||||
foreach (var cell in table.Cells.Where(c => c.RowIndex == row))
|
||||
{
|
||||
if (!columnMappings.TryGetValue(cell.ColumnIndex, out var mapping)
|
||||
|| mapping is null
|
||||
|| string.IsNullOrWhiteSpace(cell.Content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = cell.Content.Trim();
|
||||
|
||||
if (mapping == "recordedAt")
|
||||
{
|
||||
recordedAt = content;
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.Add(new OcrExtractedField(
|
||||
$"observation.{mapping}.value",
|
||||
content,
|
||||
DefaultTableConfidence));
|
||||
}
|
||||
|
||||
if (recordedAt is null)
|
||||
continue;
|
||||
|
||||
foreach (var code in observationCodes)
|
||||
{
|
||||
fields.Add(new OcrExtractedField(
|
||||
$"observation.{code}.recordedAt",
|
||||
recordedAt,
|
||||
DefaultTableConfidence));
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? MapTableHeader(string? header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header))
|
||||
return null;
|
||||
|
||||
var normalized = header.Trim();
|
||||
if (TimestampHeaders.Contains(normalized))
|
||||
return "recordedAt";
|
||||
|
||||
var fieldName = MapAzureKeyToFieldName(normalized);
|
||||
if (fieldName is null)
|
||||
return null;
|
||||
|
||||
const string observationPrefix = "observation.";
|
||||
const string valueSuffix = ".value";
|
||||
if (fieldName.StartsWith(observationPrefix, StringComparison.Ordinal)
|
||||
&& fieldName.EndsWith(valueSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return fieldName[observationPrefix.Length..^valueSuffix.Length];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasGenericHeaderRow(IReadOnlyList<DocumentTableCell> cells)
|
||||
{
|
||||
var headerCells = cells.Where(c => c.RowIndex == 0).ToList();
|
||||
if (headerCells.Count != 2)
|
||||
return false;
|
||||
|
||||
return headerCells.All(c =>
|
||||
GenericTableHeaders.Contains(c.Content?.Trim() ?? string.Empty));
|
||||
}
|
||||
|
||||
private static string? GetCellContent(
|
||||
IReadOnlyList<DocumentTableCell> cells, int row, int column)
|
||||
{
|
||||
return cells
|
||||
.FirstOrDefault(c => c.RowIndex == row && c.ColumnIndex == column)
|
||||
?.Content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Docnet.Core;
|
||||
using Docnet.Core.Models;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
public class ImagePreprocessor
|
||||
{
|
||||
// US Letter at 300 DPI — sufficient for Tesseract on clinical scans.
|
||||
private const int PdfRenderWidth = 2550;
|
||||
private const int PdfRenderHeight = 3300;
|
||||
|
||||
public Stream Preprocess(Stream input, string contentType)
|
||||
{
|
||||
Stream? pdfRenderStream = null;
|
||||
try
|
||||
{
|
||||
if (contentType == "application/pdf")
|
||||
{
|
||||
pdfRenderStream = RenderPdfFirstPage(input);
|
||||
input = pdfRenderStream;
|
||||
}
|
||||
|
||||
using var image = Image.Load(input);
|
||||
|
||||
image.Mutate(ctx => ctx
|
||||
.Grayscale()
|
||||
.GaussianSharpen(1.5f)
|
||||
.BinaryThreshold(0.5f));
|
||||
|
||||
var output = new MemoryStream();
|
||||
image.SaveAsPng(output);
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pdfRenderStream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream RenderPdfFirstPage(Stream pdfStream)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
pdfStream.CopyTo(buffer);
|
||||
var pdfBytes = buffer.ToArray();
|
||||
|
||||
using var docReader = DocLib.Instance.GetDocReader(
|
||||
pdfBytes,
|
||||
new PageDimensions(PdfRenderWidth, PdfRenderHeight));
|
||||
|
||||
if (docReader.GetPageCount() == 0)
|
||||
throw new InvalidOperationException("PDF contains no pages.");
|
||||
|
||||
using var pageReader = docReader.GetPageReader(0);
|
||||
var rawBytes = pageReader.GetImage();
|
||||
var width = pageReader.GetPageWidth();
|
||||
var height = pageReader.GetPageHeight();
|
||||
|
||||
using var image = Image.LoadPixelData<Bgra32>(rawBytes, width, height);
|
||||
var output = new MemoryStream();
|
||||
image.SaveAsPng(output);
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class OcrDraftPreFiller
|
||||
{
|
||||
private static readonly Dictionary<string, string> DefaultUnits = new(StringComparer.Ordinal)
|
||||
{
|
||||
["HEART_RATE"] = "bpm",
|
||||
["TEMP_C"] = "C",
|
||||
["BP_SYSTOLIC"] = "mmHg",
|
||||
["BP_DIASTOLIC"] = "mmHg",
|
||||
["RESP_RATE"] = "breaths/min",
|
||||
["SPO2"] = "%",
|
||||
["POTASSIUM_MEQ_L"] = "mEq/L",
|
||||
["WBC_K_UL"] = "K/uL",
|
||||
["GLUCOSE_MG_DL"] = "mg/dL",
|
||||
["LACTATE_MMOL_L"] = "mmol/L",
|
||||
};
|
||||
|
||||
private static readonly Regex ObservationValueField =
|
||||
new(@"^observation\.([^.]+)\.value$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly OcrOptions _options;
|
||||
private readonly ILogger<OcrDraftPreFiller> _logger;
|
||||
|
||||
public OcrDraftPreFiller(
|
||||
AppDbContext db,
|
||||
IOptions<OcrOptions> options,
|
||||
ILogger<OcrDraftPreFiller> logger)
|
||||
{
|
||||
_db = db;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task PreFillAsync(
|
||||
Guid batchId, BatchType batchType, OcrExtractionResult extraction)
|
||||
{
|
||||
var requirements = BatchTypeFieldRequirements.ForBatchType(batchType);
|
||||
var confidentFields = extraction.Fields
|
||||
.Where(f => f.Confidence >= _options.ConfidenceThreshold)
|
||||
.GroupBy(f => f.FieldName, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
if (requirements.ShowPatientDemographics)
|
||||
await PreFillPatientAsync(batchId, confidentFields);
|
||||
|
||||
if (requirements.ShowEncounterContext)
|
||||
await PreFillEncounterAsync(batchId, confidentFields);
|
||||
|
||||
if (requirements.ShowObservations)
|
||||
await PreFillObservationsAsync(batchId, confidentFields);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task PreFillPatientAsync(
|
||||
Guid batchId, Dictionary<string, OcrExtractedField> fields)
|
||||
{
|
||||
if (!fields.Keys.Any(k => k.StartsWith("patient.", StringComparison.Ordinal)))
|
||||
return;
|
||||
|
||||
var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (patient is null)
|
||||
{
|
||||
patient = new DraftPatient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_db.DraftPatients.Add(patient);
|
||||
}
|
||||
|
||||
if (fields.TryGetValue("patient.fullName", out var name))
|
||||
patient.FullName = name.RawValue.Trim();
|
||||
|
||||
if (fields.TryGetValue("patient.dateOfBirth", out var dob)
|
||||
&& TryParseDate(dob.RawValue, out var parsedDob))
|
||||
patient.DateOfBirth = parsedDob;
|
||||
|
||||
if (fields.TryGetValue("patient.sex", out var sex))
|
||||
patient.Sex = NormalizeSex(sex.RawValue);
|
||||
|
||||
patient.UpdatedAt = now;
|
||||
}
|
||||
|
||||
private async Task PreFillEncounterAsync(
|
||||
Guid batchId, Dictionary<string, OcrExtractedField> fields)
|
||||
{
|
||||
if (!fields.Keys.Any(k => k.StartsWith("encounter.", StringComparison.Ordinal)))
|
||||
return;
|
||||
|
||||
var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (encounter is null)
|
||||
{
|
||||
encounter = new DraftEncounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_db.DraftEncounters.Add(encounter);
|
||||
}
|
||||
|
||||
if (fields.TryGetValue("encounter.admissionDate", out var admissionDate)
|
||||
&& TryParseDateTime(admissionDate.RawValue, out var parsedAdmission))
|
||||
encounter.AdmissionDate = parsedAdmission;
|
||||
|
||||
if (fields.TryGetValue("encounter.department", out var department)
|
||||
&& TryParseDepartment(department.RawValue, out var parsedDepartment))
|
||||
encounter.Department = parsedDepartment;
|
||||
|
||||
if (fields.TryGetValue("encounter.roomBed", out var roomBed))
|
||||
encounter.RoomBed = roomBed.RawValue.Trim();
|
||||
|
||||
if (fields.TryGetValue("encounter.admissionReason", out var admissionReason))
|
||||
encounter.AdmissionReason = admissionReason.RawValue.Trim();
|
||||
|
||||
encounter.UpdatedAt = now;
|
||||
}
|
||||
|
||||
private async Task PreFillObservationsAsync(
|
||||
Guid batchId, Dictionary<string, OcrExtractedField> fields)
|
||||
{
|
||||
var observationCodes = fields.Keys
|
||||
.Select(k => ObservationValueField.Match(k))
|
||||
.Where(m => m.Success)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (observationCodes.Count == 0)
|
||||
return;
|
||||
|
||||
var existingCodes = await _db.DraftObservations
|
||||
.Where(o => o.BatchId == batchId)
|
||||
.Select(o => o.ObservationCode)
|
||||
.ToListAsync();
|
||||
|
||||
var existing = existingCodes.ToHashSet(StringComparer.Ordinal);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var code in observationCodes)
|
||||
{
|
||||
if (existing.Contains(code))
|
||||
continue;
|
||||
|
||||
if (!fields.TryGetValue($"observation.{code}.value", out var valueField))
|
||||
continue;
|
||||
|
||||
if (!decimal.TryParse(
|
||||
valueField.RawValue,
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var value)
|
||||
&& !decimal.TryParse(valueField.RawValue, out value))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping OCR observation {Code} for batch {BatchId}: unparsable value '{Value}'",
|
||||
code, batchId, valueField.RawValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!PlausibilityValidator.IsPlausible(code, value, out var reason))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping OCR observation {Code} for batch {BatchId}: {Reason}",
|
||||
code, batchId, reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
var unit = fields.TryGetValue($"observation.{code}.unit", out var unitField)
|
||||
? unitField.RawValue.Trim()
|
||||
: DefaultUnits.GetValueOrDefault(code, string.Empty);
|
||||
|
||||
var recordedAt = now;
|
||||
if (fields.TryGetValue($"observation.{code}.recordedAt", out var recordedAtField)
|
||||
&& TryParseDateTime(recordedAtField.RawValue, out var parsedRecordedAt))
|
||||
recordedAt = parsedRecordedAt;
|
||||
|
||||
_db.DraftObservations.Add(new DraftObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
ObservationCode = code,
|
||||
Value = value,
|
||||
Unit = unit,
|
||||
RecordedAt = recordedAt,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string raw, out DateOnly result)
|
||||
{
|
||||
if (DateOnly.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
|
||||
return true;
|
||||
|
||||
return DateOnly.TryParse(raw.Trim(), out result);
|
||||
}
|
||||
|
||||
private static bool TryParseDateTime(string raw, out DateTimeOffset result)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result))
|
||||
return true;
|
||||
|
||||
if (TryParseDate(raw, out var dateOnly))
|
||||
{
|
||||
result = new DateTimeOffset(dateOnly.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(raw.Trim(), out result);
|
||||
}
|
||||
|
||||
private static bool TryParseDepartment(string raw, out Department result)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (DepartmentExtensions.TryFromDbString(trimmed, out result))
|
||||
return true;
|
||||
|
||||
foreach (Department department in Enum.GetValues<Department>())
|
||||
{
|
||||
if (department.ToDbString().Equals(trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result = department;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeSex(string raw) =>
|
||||
raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"m" or "male" => "Male",
|
||||
"f" or "female" => "Female",
|
||||
_ => raw.Trim()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user