Skip to content

Commit

Permalink
add pattern matching
Browse files Browse the repository at this point in the history
  • Loading branch information
kfrancis committed May 28, 2024
1 parent 8ee7a14 commit 16fdb80
Show file tree
Hide file tree
Showing 10 changed files with 455 additions and 30 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,51 @@ Install with the dotnet CLI: `dotnet add package Plugin.Maui.OCR` or `dotnet add
| Android | 5.0 (API 21) |
| Windows | 11 and 10 version 1809+ |

## Pattern Matching

One of the more common things that I personally do with OCR is to recognize a pattern in the text. For example, I might want to recognize a date or a phone number or an email address. This is where the `OcrPatternConfig` class comes in.

Let's say you want to recognize an Ontario Health Card Number in the text of your image. Numbers of those types have some specific qualities that make it easy to match.

1. An Ontario HCN is 10 digits long.
1. The number must be [Luhn valid](https://en.wikipedia.org/wiki/Luhn_algorithm) (meaning it has a check digit and it's correct).

To do this, you can create a `OcrPatternConfig` object like so:

```csharp
bool IsValidLuhn(string number)
{
// Convert the string to an array of digits
int[] digits = number.Select(d => int.Parse(d.ToString())).ToArray();
int checkDigit = 0;

// Luhn algorithm implementation
for (int i = digits.Length - 2; i >= 0; i--)
{
int currentDigit = digits[i];
if ((digits.Length - 2 - i) % 2 == 0) // check if it's an even index from the right
{
currentDigit *= 2;
if (currentDigit > 9)
{
currentDigit -= 9;
}
}
checkDigit += currentDigit;
}

return (10 - (checkDigit % 10)) % 10 == digits.Last();
}

var ohipPattern = new OcrPatternConfig(@"\d{10}", IsLuhnValid);

var options = new OcrOptions(tryHard: true, patternConfig: ohipPattern);

var result = await OcrPlugin.Default.RecognizeTextAsync(imageData, options);

var patientHcn = result.MatchedValues.FirstOrDefault(); // This will be the HCN (and only the HCN) if it's found
```

## MAUI Setup and Usage

For MAUI, to initialize make sure you use the MauiAppBuilder extension `AddOcr()` like so:
Expand Down
161 changes: 157 additions & 4 deletions src/Plugin.Maui.OCR/Abstractions/IOcrService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
using System.Text.RegularExpressions;

namespace Plugin.Maui.OCR;

/// <summary>
/// A callback that can be used to provide custom validation for the extracted text.
/// </summary>
/// <param name="extractedText">
/// The extracted text to validate.
/// </param>
/// <returns>
/// True if the text is valid, otherwise false.
/// </returns>
public delegate bool CustomOcrValidationCallback(string extractedText);

/// <summary>
/// OCR API.
/// </summary>
Expand Down Expand Up @@ -56,17 +69,49 @@ public interface IOcrService
}

/// <summary>
/// The options for OCR.
/// A helper class to extract patterns from text and use the custom validation function (if defined).
/// </summary>
/// <param name="Language">The BCP-47 language code</param>
/// <param name="TryHard">True to try and tell the API to be more accurate, otherwise just be fast.</param>
public record OcrOptions(string? Language = null, bool TryHard = false);
public static class OcrPatternMatcher
{
/// <summary>
/// Extracts a pattern from the input text using the provided configuration.
/// </summary>
/// <param name="input">
/// The input text to extract the pattern from.
/// </param>
/// <param name="config">
/// The configuration to use for pattern extraction.
/// </param>
/// <returns>
/// The extracted pattern, or null if no pattern was found or the pattern failed validation.
/// </returns>
public static string? ExtractPattern(string input, OcrPatternConfig config)
{
var regex = new Regex(config.RegexPattern);
var match = regex.Match(input);

if (match.Success && (config.ValidationFunction == null || config.ValidationFunction(match.Value)))
{
return match.Value;
}
return null;
}
}

/// <summary>
/// Provides data for the RecognitionCompleted event.
/// </summary>
public class OcrCompletedEventArgs : EventArgs
{
/// <summary>
/// Constructor
/// </summary>
/// <param name="result">
/// The result of the OCR operation.
/// </param>
/// <param name="errorMessage">
/// Any error message if the OCR operation failed, or empty string otherwise.
/// </param>
public OcrCompletedEventArgs(OcrResult? result, string? errorMessage = null)
{
Result = result;
Expand All @@ -89,6 +134,109 @@ public OcrCompletedEventArgs(OcrResult? result, string? errorMessage = null)
public OcrResult? Result { get; }
}

/// <summary>
/// The options for OCR.
/// </summary>
public class OcrOptions
{
/// <summary>
/// Constructor
/// </summary>
/// <param name="language">
/// (Optional) The BCP-47 language code of the language to recognize.
/// </param>
/// <param name="tryHard">
/// (Optional) True to try and tell the API to be more accurate, otherwise just be fast. Default is just to be fast.
/// </param>
/// <param name="patternConfigs">
/// (Optional) The pattern configurations for OCR.
/// </param>
/// <param name="customCallback">
/// (Optional) A callback that can be used to provide custom validation for the extracted text.
/// </param>
public OcrOptions(string? language = null, bool tryHard = false, List<OcrPatternConfig>? patternConfigs = null, CustomOcrValidationCallback? customCallback = null)
{
Language = language;
TryHard = tryHard;
PatternConfigs = patternConfigs;
CustomCallback = customCallback;
}

/// <summary>
/// Constructor
/// </summary>
/// <param name="language">
/// (Optional) The BCP-47 language code of the language to recognize.
/// </param>
/// <param name="tryHard">
/// (Optional) True to try and tell the API to be more accurate, otherwise just be fast. Default is just to be fast.
/// </param>
/// <param name="patternConfig">
/// (Optional) The pattern configuration for OCR.
/// </param>
/// <param name="customCallback">
/// (Optional) A callback that can be used to provide custom validation for the extracted text.
/// </param>
public OcrOptions(string? language = null, bool tryHard = false, OcrPatternConfig? patternConfig = null, CustomOcrValidationCallback? customCallback = null)
{
Language = language;
TryHard = tryHard;
PatternConfigs = new List<OcrPatternConfig> { patternConfig };
CustomCallback = customCallback;
}

/// <summary>
/// A callback that can be used to provide custom validation for the extracted text.
/// </summary>
public CustomOcrValidationCallback? CustomCallback { get; set; }

/// <summary>
/// The BCP-47 language code of the language to recognize.
/// </summary>
public string? Language { get; set; }

/// <summary>
/// The pattern configurations for OCR.
/// </summary>
public List<OcrPatternConfig>? PatternConfigs { get; set; }

/// <summary>
/// True to try and tell the API to be more accurate, otherwise just be fast.
/// </summary>
public bool TryHard { get; set; }
}

/// <summary>
/// Configuration for OCR patterns.
/// </summary>
public class OcrPatternConfig
{
/// <summary>
/// Constructor
/// </summary>
/// <param name="regexPattern">
/// The regex pattern to match.
/// </param>
/// <param name="validationFunction">
/// If provided, the extracted text will be validated against this function.
/// </param>
public OcrPatternConfig(string regexPattern, Func<string, bool> validationFunction = null)
{
RegexPattern = regexPattern;
ValidationFunction = validationFunction;
}

/// <summary>
/// The regex pattern to match.
/// </summary>
public string RegexPattern { get; set; }

/// <summary>
/// If provided, the extracted text will be validated against this function.
/// </summary>
public Func<string, bool> ValidationFunction { get; set; }
}

/// <summary>
/// The result of an OCR operation.
/// </summary>
Expand All @@ -109,6 +257,11 @@ public class OcrResult
/// </summary>
public IList<string> Lines { get; set; } = new List<string>();

/// <summary>
/// The matched values of the OCR result.
/// </summary>
public IList<string> MatchedValues { get; set; } = new List<string>();

/// <summary>
/// Was the OCR successful?
/// </summary>
Expand Down
23 changes: 19 additions & 4 deletions src/Plugin.Maui.OCR/OcrImplementation.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal class OcrImplementation : IOcrService

public IReadOnlyCollection<string> SupportedLanguages => throw new NotImplementedException();

public static OcrResult ProcessOcrResult(Java.Lang.Object result)
public static OcrResult ProcessOcrResult(Java.Lang.Object result, OcrOptions options)
{
var ocrResult = new OcrResult();
var textResult = (Text)result;
Expand All @@ -45,6 +45,21 @@ public static OcrResult ProcessOcrResult(Java.Lang.Object result)
}
}
}

if (options.PatternConfigs != null)
{
foreach (var config in options.PatternConfigs)
{
var match = OcrPatternMatcher.ExtractPattern(ocrResult.AllText, config);
if (!string.IsNullOrEmpty(match))
{
ocrResult.MatchedValues.Add(match);
}
}
}

options.CustomCallback?.Invoke(ocrResult.AllText);

ocrResult.Success = true;
return ocrResult;
}
Expand All @@ -70,7 +85,7 @@ public Task InitAsync(System.Threading.CancellationToken ct = default)
/// <returns>The OCR result</returns>
public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, bool tryHard = false, System.Threading.CancellationToken ct = default)
{
return await RecognizeTextAsync(imageData, new OcrOptions(TryHard: tryHard), ct);
return await RecognizeTextAsync(imageData, new OcrOptions(tryHard: tryHard, patternConfig: null), ct);
}

public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, OcrOptions options, System.Threading.CancellationToken ct = default)
Expand Down Expand Up @@ -103,7 +118,7 @@ public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, OcrOptions opt
// Try to perform the OCR operation. We should be installing the model necessary when this app is installed, but just in case ..
var processImageTask = ToAwaitableTask(textScanner.Process(srcImage).AddOnSuccessListener(new OnSuccessListener()).AddOnFailureListener(new OnFailureListener()));
var result = await processImageTask;
return ProcessOcrResult(result);
return ProcessOcrResult(result, options);
}
catch (MlKitException ex) when ((ex.Message ?? string.Empty).Contains("Waiting for the text optional module to be downloaded"))
{
Expand Down Expand Up @@ -158,7 +173,7 @@ public async Task StartRecognizeTextAsync(byte[] imageData, OcrOptions options,
}

// Try to perform the OCR operation. We should be installing the model necessary when this app is installed, but just in case ..
var result = ProcessOcrResult(await ToAwaitableTask(textScanner.Process(srcImage).AddOnSuccessListener(new OnSuccessListener()).AddOnFailureListener(new OnFailureListener())));
var result = ProcessOcrResult(await ToAwaitableTask(textScanner.Process(srcImage).AddOnSuccessListener(new OnSuccessListener()).AddOnFailureListener(new OnFailureListener())), options);
RecognitionCompleted?.Invoke(this, new OcrCompletedEventArgs(result, null));
}
catch (MlKitException ex) when ((ex.Message ?? string.Empty).Contains("Waiting for the text optional module to be downloaded"))
Expand Down
22 changes: 18 additions & 4 deletions src/Plugin.Maui.OCR/OcrImplementation.macios.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public Task InitAsync(CancellationToken ct = default)
return Task.CompletedTask;
}

private static OcrResult ProcessRecognitionResults(VNRequest request, CGSize imageSize)
private static OcrResult ProcessOcrResult(VNRequest request, CGSize imageSize, OcrOptions options)
{
var ocrResult = new OcrResult();

Expand Down Expand Up @@ -106,6 +106,20 @@ private static OcrResult ProcessRecognitionResults(VNRequest request, CGSize ima
}
}

if (options?.PatternConfigs != null)
{
foreach (var config in options.PatternConfigs)
{
var match = OcrPatternMatcher.ExtractPattern(ocrResult.AllText, config);
if (!string.IsNullOrEmpty(match))
{
ocrResult.MatchedValues.Add(match);
}
}
}

options?.CustomCallback?.Invoke(ocrResult.AllText);

ocrResult.Success = true;
return ocrResult;
}
Expand Down Expand Up @@ -148,7 +162,7 @@ private static Point NormalizePoint(CGPoint point, CGSize imageSize)
/// <exception cref="ArgumentException"></exception>
public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, bool tryHard = false, CancellationToken ct = default)
{
return await RecognizeTextAsync(imageData, new OcrOptions(TryHard: tryHard), ct);
return await RecognizeTextAsync(imageData, new OcrOptions(tryHard: tryHard, patternConfig: null), ct);
}

/// <summary>
Expand Down Expand Up @@ -220,7 +234,7 @@ public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, OcrOptions opt
return;
}

var result = ProcessRecognitionResults(request, imageSize);
var result = ProcessOcrResult(request, imageSize, options);
tcs.TrySetResult(result);
});

Expand Down Expand Up @@ -370,7 +384,7 @@ public Task StartRecognizeTextAsync(byte[] imageData, OcrOptions options, Cancel

try
{
var result = ProcessRecognitionResults(request, imageSize);
var result = ProcessOcrResult(request, imageSize, options);
RecognitionCompleted?.Invoke(this, new OcrCompletedEventArgs(result, null));
}
catch (Exception ex)
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin.Maui.OCR/OcrImplementation.net.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public Task InitAsync(CancellationToken ct = default)
/// <returns>The OCR result</returns>
public async Task<OcrResult> RecognizeTextAsync(byte[] imageData, bool tryHard = false, CancellationToken ct = default)
{
return await RecognizeTextAsync(imageData, new OcrOptions(null, tryHard), ct);
return await RecognizeTextAsync(imageData, new OcrOptions(null, tryHard, patternConfig: null), ct);
}

public Task<OcrResult> RecognizeTextAsync(byte[] imageData, OcrOptions options, CancellationToken ct = default)
Expand Down
Loading

0 comments on commit 16fdb80

Please sign in to comment.