Showing posts with label Blazor. Show all posts
Showing posts with label Blazor. Show all posts

Monday 22 April 2024

Pii - Detecting Personally Identifiable Information using Azure Cognitive Services

This article will look at detecting Person Identifiable Information (Pii) using Azure Cognitive Services. I have created a demo using .NET Maui Blazor has been created and the Github repo is here:
https://github.com/toreaurstadboss/PiiDetectionDemo

Person Identifiable Information (Pii) is desired to detect and also redact, that is using censorship or obscuring Pii to prepare documents for publication. The Pii feature in Azure Cognitive Services is a part of the Language resource service. A quickstart for using Pii is available here:
https://learn.microsoft.com/en-us/azure/ai-services/language-service/personally-identifiable-information/quickstart?pivots=programming-language-csharp

After creating the Language resource, look up the keys and endpoints for you service. Using Azure CLI inside Cloud shell, you can enter this command to find the keys, in Azure many services has got two keys you can exchange with new keys through regeneration:

az cognitiveservices account keys list --resource-group SomeAzureResourceGroup --name SomeAccountAzureCognitiveServices
This is how you can query after endpoint of language resource using Azure CLI : az cognitiveservices account show --query "properties.endpoint" --resource-group SomeAzureResourceGroup --name SomeAccountAzureCognitiveServices
Next, the demo of this article. Connecting to the Pii Removal Text Analytics is possible using this Nuget package (REST calls can also be done manually): - Azure.AI.TextAnalytics version 5.3.0 Here is the other Nugets of my Demo included from the .csproj file :

PiiDetectionDemo.csproj


  <ItemGroup>
        <PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
        <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
        <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
        <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
        <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
    </ItemGroup>


A service using this Pii removal feature is simply making use of a TextAnalyticsClient and method RecognizePiiEntitiesAsync.

PiiRemovalTextClientService.cs IPiiRemovalTextClientService.cs



using Azure;
using Azure.AI.TextAnalytics;

namespace PiiDetectionDemo.Util
{
    public interface IPiiRemovalTextAnalyticsClientService
    {
        Task<Response<PiiEntityCollection>> RecognizePiiEntitiesAsync(string? document, string? language);
    }
}


namespace PiiDetectionDemo.Util
{
    public class PiiRemovalTextAnalyticsClientService : IPiiRemovalTextAnalyticsClientService
    {

        private TextAnalyticsClient _client;

        public PiiRemovalTextAnalyticsClientService()
        {
            var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_ENDPOINT");
            var azureKey = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_KEY");

            if (string.IsNullOrWhiteSpace(azureEndpoint))
            {
                throw new ArgumentNullException(nameof(azureEndpoint), "Missing system environment variable: AZURE_COGNITIVE_SERVICE_ENDPOINT");
            }
            if (string.IsNullOrWhiteSpace(azureKey))
            {
                throw new ArgumentNullException(nameof(azureKey), "Missing system environment variable: AZURE_COGNITIVE_SERVICE_KEY");
            }

            _client = new TextAnalyticsClient(new Uri(azureEndpoint), new AzureKeyCredential(azureKey));
        }

        public async Task<Response<PiiEntityCollection>> RecognizePiiEntitiesAsync(string? document, string? language)
        {
            var piiEntities = await _client.RecognizePiiEntitiesAsync(document, language);
            return piiEntities;
        }

    }
}


The UI codebehind of the razor component page showing the UI looks like this:

Home.razor.cs


using Azure;
using Microsoft.AspNetCore.Components;
using PiiDetectionDemo.Models;
using PiiDetectionDemo.Util;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PiiDetectionDemo.Components.Pages
{
    public partial class Home
    {

        private IndexModel Model = new();
        private bool isProcessing = false;
        private bool isSearchPerformed = false;

        private async Task Submit()
        {
            isSearchPerformed = false;
            isProcessing = true;
            try
            {
                var response = await _piiRemovalTextAnalyticsClientService.RecognizePiiEntitiesAsync(Model.InputText, null);
                Model.RedactedText = response?.Value?.RedactedText;
                Model.UpdateHtmlRedactedText();
                Model.AnalysisResult = response?.Value;
                StateHasChanged();
            }
            catch (Exception ex)
            {
                await Console.Out.WriteLineAsync(ex.ToString());
            }
            isProcessing = false;
            isSearchPerformed = true;
        }

        private void removeWhitespace(ChangeEventArgs args)
        {
            Model.InputText = args.Value?.ToString()?.CleanupAllWhiteSpace();
            StateHasChanged();
        }



    }
}



To get the redacted or censored text void of any Pii that the Pii detection feature was able to detect, access the Value of type Azure.AI.TextAnalytics.PiiEntityCollection. Inside this object, the string RedactedText contains the censored / redacted text. The IndexModel looks like this :


using Azure.AI.TextAnalytics;
using Microsoft.AspNetCore.Components;
using PiiDetectionDemo.Util;
using System.ComponentModel.DataAnnotations;
using System.Text;

namespace PiiDetectionDemo.Models
{

    public class IndexModel
    {

        [Required]
        public string? InputText { get; set; }

        public string? RedactedText { get; set; }

        public string? HtmlRedactedText { get; set; }

        public MarkupString HtmlRedactedTextMarkupString { get; set; }

        public void UpdateHtmlRedactedText()
        {
            var sb = new StringBuilder(RedactedText);
            if (AnalysisResult != null && RedactedText != null)
            {
                foreach (var piiEntity in AnalysisResult.OrderByDescending(a => a.Offset))
                {
                    sb.Insert(piiEntity.Offset + piiEntity.Length, "</b></span>");
                    sb.Insert(piiEntity.Offset, $"<span style='background-color:lightgray;border:1px solid black;corner-radius:2px; color:{GetBackgroundColor(piiEntity)}' title='{piiEntity.Category}: {piiEntity.SubCategory} Confidence: {piiEntity.ConfidenceScore} Redacted Text: {piiEntity.Text}'><b>");
                }
            }
            HtmlRedactedText = sb.ToString()?.CleanupAllWhiteSpace();    
            HtmlRedactedTextMarkupString = new MarkupString(HtmlRedactedText ?? string.Empty);
        }

        private string GetBackgroundColor(PiiEntity piiEntity)
        {
            if (piiEntity.Category == PiiEntityCategory.PhoneNumber)
            {
                return "yellow";
            }
            if (piiEntity.Category == PiiEntityCategory.Organization)
            {
                return "orange";
            }
            if (piiEntity.Category == PiiEntityCategory.Address)
            {
                return "green";
            }
            return "gray";                   
        }

        public long ExecutionTime { get; set; }
        public PiiEntityCollection? AnalysisResult { get; set; }

    }
}




Frontend UI looks like this: Home.razor


@page "/"
@using PiiDetectionDemo.Util

@inject IPiiRemovalTextAnalyticsClientService _piiRemovalTextAnalyticsClientService;

<h3>Azure HealthCare Text Analysis - Pii detection feature - Azure Cognitive Services</h3>

<em>Pii = Person identifiable information</em>

<EditForm Model="@Model" OnValidSubmit="@Submit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="form-group row">
        <label><strong>Text input</strong></label>
        <InputTextArea @oninput="removeWhitespace" class="overflow-scroll" style="max-height:500px;max-width:900px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif" @bind-Value="@Model.InputText" rows="5" />
    </div>

    <div class="form-group row">
        <div class="col">
            <br />
            <button class="btn btn-outline-primary" type="submit">Run</button>
        </div>
        <div class="col">
        </div>
        <div class="col">
        </div>
    </div>

    <br />

    @if (isProcessing)
    {

        <div class="progress" style="max-width: 90%">
            <div class="progress-bar progress-bar-striped progress-bar-animated"
                 style="width: 100%; background-color: green">
                Retrieving result from Azure Text Analysis Pii detection feature. Processing..
            </div>
        </div>
        <br />

    }

    <div class="form-group row">
        <label><strong>Analysis result</strong></label>

        @if (isSearchPerformed)
        {
            <br />
            <b>Execution time took: @Model.ExecutionTime ms (milliseconds)</b>

            <br />
            <br />

            <b>Redacted text (Pii removed)</b>
            <br />

            <div class="form-group row">
               <label><strong>Categorized Pii redacted text</strong></label>
               <div>
               @Model.HtmlRedactedTextMarkupString
               </div>
            </div>

            <br />
            <br />

            <table class="table table-striped table-dark table-hover">
                <thead>
                <th>Pii text</th>
                <th>Category</th>
                <th>SubCategory</th>
                <th>Offset</th>
                <th>Length</th>
                <th>ConfidenceScore</th>
                </thead>
                <tbody>
                    @if (Model.AnalysisResult != null) {
                        @foreach (var entity in Model.AnalysisResult)
                        {
                            <tr>
                                <td>@entity.Text</td>
                                <td>@entity.Category.ToString()</td>
                                <td>@entity.SubCategory</td>
                                <td>@entity.Offset</td>
                                <td>@entity.Length</td>
                                <td>@entity.ConfidenceScore</td>                                        
                            </tr>
                        }
                    }
                </tbody>
            </table>

        }
    </div>

</EditForm>



The Demo uses Bootstrap 5 to build up a HTML table styled and showing the Azure.AI.TextAnalytics.PiiEntity properties.

Saturday 21 October 2023

Using Azure Health Information extraction in Azure Cognitive Services

This article presents code how to extract Health information from arbitrary text using Azure Health Information extraction in Azure Cognitive Services. This technology uses NLP - natural language processing combined with AI techniques. A Github repo exists with the code for a running .NET MAUI Blazor demo in .NET 7 here:

https://github.com/toreaurstadboss/HealthTextAnalytics

A screenshot from the demo shows how it works below. The demo uses Azure AI Healthcare information extraction to extract entities of the text, such as a person's age, gender, employment and medical history and condition such as diagnosises, procedures and so on. The returned data in the demo is shown at the bottom of the demo, the raw data shows it is in the format as a json and in a FHIR format. Since we want FHIR format, we must use the REST api to get this information. Azure AI Healthcare information also extracts relations, which is connecting the entities together for semantic analysis of the text. Also, links exist for each entity for further reading. These are external systems such as Snomed CT and Snomed codes for each entity. Let's look at the source code for the demo next. We define a named http client in the MauiProgram.cs file which starts the application. We could move the code into a middleware extension method, but the code is kept simple in the demo.

MauiProgram.cs


  var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_ENDPOINT");
  var azureKey = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_KEY");

  if (string.IsNullOrWhiteSpace(azureEndpoint))
  {
      throw new ArgumentNullException(nameof(azureEndpoint), "Missing system environment variable: AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_ENDPOINT");
  }
  if (string.IsNullOrWhiteSpace(azureKey))
  {
      throw new ArgumentNullException(nameof(azureKey), "Missing system environment variable: AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_KEY");
  }

  var azureEndpointHost = new Uri(azureEndpoint);

  builder.Services.AddHttpClient("Az", httpClient =>
  {
      string baseUrl = azureEndpointHost.GetLeftPart(UriPartial.Authority); //https://stackoverflow.com/a/18708268/741368
      httpClient.BaseAddress = new Uri(baseUrl);
      //httpClient..Add("Content-type", "application/json");
      //httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));//ACCEPT header
      httpClient.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", azureKey);
  });



The content-type header will be specified instead inside the HttpRequestMessage shown further below and not in this named client. As we see, we must add both the endpoint base url and also the key in the Ocp-Apim-Subscription-key http header. Let's next look at how to create a POST request to the language resource endpoint that offers the health text analysis below.

HealthAnalyticsTextClientService.cs




using HealthTextAnalytics.Models;
using System.Diagnostics;
using System.Text;
using System.Text.Json.Nodes;

namespace HealthTextAnalytics.Util
{

    public class HealthAnalyticsTextClientService : IHealthAnalyticsTextClientService
    {

        private readonly IHttpClientFactory _httpClientFactory;
        private const int awaitTimeInMs = 500;
        private const int maxTimerWait = 10000;

        public HealthAnalyticsTextClientService(IHttpClientFactory httpClientFactory)
        {
            _httpClientFactory = httpClientFactory;
        }

        public async Task<HealthTextAnalyticsResponse> GetHealthTextAnalytics(string inputText)
        {
            var client = _httpClientFactory.CreateClient("Az");
            string requestBodyRaw = HealthAnalyticsTextHelper.CreateRequest(inputText);
            //https://learn.microsoft.com/en-us/azure/ai-services/language-service/text-analytics-for-health/how-to/call-api?tabs=ner
            var stopWatch = Stopwatch.StartNew();
            HttpRequestMessage request = CreateTextAnalyticsRequest(requestBodyRaw);
            var response = await client.SendAsync(request);
            var result = new HealthTextAnalyticsResponse();
            var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(awaitTimeInMs));
            int timeAwaited = 0;

            while (await timer.WaitForNextTickAsync())
            {
                if (response.IsSuccessStatusCode)
                {
                    result.IsSearchPerformed = true;
                    var operationLocation = response.Headers.First(h => h.Key?.ToLower() == Constants.Constants.HttpHeaderOperationResultAvailable).Value.FirstOrDefault();

                    var resultFromHealthAnalysis = await client.GetAsync(operationLocation);
                    JsonNode resultFromService = await resultFromHealthAnalysis.GetJsonFromHttpResponse();
                    if (resultFromService.GetValue<string>("status") == "succeeded")
                    {
                        result.AnalysisResultRawJson = await resultFromHealthAnalysis.Content.ReadAsStringAsync();
                        result.ExecutionTimeInMilliseconds = stopWatch.ElapsedMilliseconds;
                        result.Entities.AddRange(HealthAnalyticsTextHelper.GetEntities(result.AnalysisResultRawJson));
                        result.CategorizedInputText = HealthAnalyticsTextHelper.GetCategorizedInputText(inputText, result.AnalysisResultRawJson);
                        break;
                    }
                }
                timeAwaited += 500;
                if (timeAwaited >= maxTimerWait)
                {
                    result.CategorizedInputText = $"ERR: Timeout. Operation to analyze input text using Azure HealthAnalytics language service timed out after waiting for {timeAwaited} ms.";
                    break;
                }
            }

            return result;
        }

        private static HttpRequestMessage CreateTextAnalyticsRequest(string requestBodyRaw)
        {
            var request = new HttpRequestMessage(HttpMethod.Post, Constants.Constants.AnalyzeTextEndpoint);
            request.Content = new StringContent(requestBodyRaw, Encoding.UTF8, "application/json");//CONTENT-TYPE header
            return request;
        }
    }

}



The code is using some helper methods to be shown next. As the code above shows, we must poll the Azure service until we get a reply from the service. We poll every 0.5 second up to a maxium of 10 seconds from the service. Typical requests takes about 3-4 seconds to process. Longer input text / 'documents' would need more processing time than 10 seconds, but for this demo, it works great.

HealthAnalyticsTextHelper.CreateRequest method


  public static string CreateRequest(string inputText)
  {
      //note - the id 1 here in the request is a 'local id' that must be unique per request. only one text is supported in the 
      //request genreated, however the service allows multiple documents and id's if necessary. in this demo, we only will send in one text at a time
      var request = new
      {
          analysisInput = new
          {
              documents = new[]
              {
                  new { text = inputText, id = "1", language = "en" }
              }
          },
          tasks = new[]
          {
              new { id = "analyze 1", kind = "Healthcare", parameters = new { fhirVersion = "4.0.1" } }
          }
      };
      return JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = true });
  }



Creating the body of POST we use a template via a new anonymized object shown above which is what the REST service excepts. We could have multiple documents here, that is input texts, in this demo only one text / document is sent in. Note the use of id='1' and 'analyze 1' here. We have some helper methods in System.Text.Json here to extract the JSON data sent in the response.

JsonNodeUtil


 public static class JsonNodeUtil
 {

     public static async Task<JsonNode> GetJsonFromHttpResponse(this HttpResponseMessage response)
     {
         var resultFromService = JsonSerializer.Deserialize<JsonNode>(await response.Content.ReadAsStringAsync());
         return resultFromService;
     }

     public static T? GetValue<T>(this JsonNode jsonNode, string key)
     {
         if (jsonNode == null)
         {
             return default;
         }
         return jsonNode[key] != null ? jsonNode[key].GetValue<T>() : default;
     }

 }

 

More code exists for returning a categorized colored input text showing the entities of the input text in the helper below.

HealthAnalyticsTextHelper.cs - methods GetCategorizedInputText and GetBackgroundColor


 public static string GetCategorizedInputText(string inputText, string analysisText)
 {
     var sb = new StringBuilder(inputText);
     try
     {
         Root doc = JsonSerializer.Deserialize<Root>(analysisText);

         //try loading up the documents inside of the analysisText
         var entities = doc?.tasks?.items.FirstOrDefault()?.results?.documents?.SelectMany(d => d.entities)?.ToList();
         if (entities != null)
         {
             foreach (var row in entities.OrderByDescending(r => r.offset))
             {
                 sb.Insert(row.offset + row.length, "</b></span>");
                 sb.Insert(row.offset, $"<span style='color:{GetBackgroundColor(row)}' title='{row.category}: {row.text} Confidence: {row.confidenceScore} {row.name}'><b>");
             }
         }
     }
     catch (Exception err)
     {

         Console.WriteLine("Got an error while trying to load in analysis healthcare json: " + err.ToString());
     }
     return $"<pre style='text-wrap:wrap; max-height:500px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif;'>{sb}</pre>";
 }

 private static string GetBackgroundColor(Entity row)
 {
     var cat = row?.category?.ToLower();
     string backgroundColor = cat switch
     {
         "age" => "purple",
         "diagnosis" => "orange",
         "gender" => "purple",
         "symptomorsign" => "purple",
         "direction" => "blue",
         "symptom" => "purple",
         "symptoms" => "purple",
         "bodystructure" => "blue",
         "body" => "purple",
         "structure" => "purple",
         "examinationname" => "green",
         "procedure" => "green",
         "treatmentname" => "green",
         "conditionqualifier" => "lightgreen",
         "time" => "lightgreen",
         "date" => "lightgreen",
         "familyrelation" => "purple",
         "employment" => "purple",
         "livingstatus" => "purple",
         "administrativeevent" => "darkgreen",
         "careenvironment" => "darkgreen",
         _ => "darkgray"
     };
     return backgroundColor;
 }




I have added the Domain classes from the service using the https://json2csharp.com/ website on the intial responses I got from the REST service using Postman. The REST Api might change in the future, that is, the JSON returned. In that case, you might want to adjust the domain classes here if the deserialization fails. It seems relatively stable though, I have tested the code for some weeks now. Finally, the categorized colored text code here had to remove newlines to get a correct indexing of the different entities found in the text. This code shows how to get rid of newlines of the inputted text.


 public static class StringExtensions
 {
     
     public static string CleanupAllWhiteSpace(this string input) => Regex.Replace(input ?? string.Empty, @"\s+", " ");
     
 }


Let's look at the UI in the Index.razor file below.

Index.razor


@page "/"
@using HealthTextAnalytics.Models;
@inject IHttpClientFactory _httpClientFactory;
@inject IHealthAnalyticsTextClientService _healthAnalyticsTextClientService;

<h3>Azure HealthCare Text Analysis - Azure Cognitive Services</h3>

<EditForm Model="@Model" OnValidSubmit="@Submit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <InputWatcher @ref="inputWatcher" FieldChanged="@FieldChanged" />

    <div class="form-group row">
        <label><strong>Text input</strong></label>
        <InputTextArea @onkeyup="@removeWhitespace" class="overflow-scroll" style="max-height:500px;max-width:900px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif" @bind-Value="@Model.InputText" rows="5" />
    </div>

    <div class="form-group row"> 
        <div class="col">
            <br />
            <button class="btn btn-outline-primary" disabled="@isInvalid" type="submit">Run</button>
        </div>
        <div class="col">
        </div>
        <div class="col">
        </div>
    </div>

    <br />

@if (isProcessing)
{

        <div class="progress" style="max-width: 90%">
            <div class="progress-bar progress-bar-striped progress-bar-animated"
                 style="width: 100%; background-color: green">
                 Retrieving result from Azure HealthCare Text Analysis. Processing..
            </div>
        </div>
        <br />

}

    <div class="form-group row">
        <label><strong>Analysis result</strong></label>

        @if (isSearchPerformed)
    {
        <br />
        <b>Execution time took: @Model.ExecutionTime ms (milliseconds)</b><br />
        <br />

        <b>Categorized and analyzed Health Analysis of inputted text</b>
        @ms
        <br />
     
        <table class="table table-striped table-dark table-hover">
                <th>Category</th>
                <th>Text</th>
                <th>Name</th>
                <th>ConfidenceScore</th>
                <th>Offset</th>
                <th>Length</th>
                <th>Links</th>
            <tbody>
            @foreach (var entity in Model.EntititesInAnalyzedResult)
        {
            <tr>
                    <td>@entity.category</td>
                    <td>@entity.text</td>
                    <td>@entity.name</td>
                    <td>@entity.confidenceScore</td>
                    <td>@entity.offset</td>
                    <td>@entity.length</td>
                    <td>@string.Join(Environment.NewLine, (@entity.links ?? new List<Link>()).Select(l => l?.dataSource + " " + l?.id + " | "))</td>
                </tr>
            
        }
            </tbody>
            </table>

        <b>Health Analysis raw text from Azure service</b>
        <InputTextArea class="overflow-scroll" readonly="readonly" style="max-height:500px; max-width:900px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif" @bind-Value="@Model.AnalysisResult" rows="1000" />

    }
   </div>

</EditForm>


The code-behind of Index.razor , looks like this.


using HealthTextAnalytics.Models;
using HealthTextAnalytics.Util;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;

namespace HealthTextAnalytics.Pages
{
    public partial class Index
    {

        private IndexModel Model = new();
        MarkupString ms = new();
        private bool isProcessing = false;
        private bool isSearchPerformed = false;   

        private InputWatcher inputWatcher = new InputWatcher();
        private bool isInvalid = false;

        private void FieldChanged(string fieldName)
        {
            isInvalid = !inputWatcher.Validate();
        }
        
        protected override void OnParametersSet()
        {
            Model.InputText = SampleData.Sampledata.SamplePatientTextNote2.CleanupAllWhiteSpace();
            StateHasChanged();
        }

        private void removeWhitespace(KeyboardEventArgs eventArgs)
        {
            Model.InputText = Model.InputText.CleanupAllWhiteSpace();
            StateHasChanged();
        }

        private async Task Submit()
        {
            try
            {
                ResetFieldsForBeforeSearch();

                HealthTextAnalyticsResponse response = await _healthAnalyticsTextClientService.GetHealthTextAnalytics(Model.InputText);
                Model.EntititesInAnalyzedResult = response.Entities;
                Model.ExecutionTime = response.ExecutionTimeInMilliseconds;
                Model.AnalysisResult = response.AnalysisResultRawJson;

                ms = new MarkupString(response.CategorizedInputText);              
            }
            catch (Exception err)
            {
                Console.WriteLine(err);
            }
            finally
            {
                ResetFieldsAfterSearch();
                StateHasChanged();
            }
        }

        private void ResetFieldsForBeforeSearch()
        {
            isProcessing = true;
            isSearchPerformed = false;
            ms = new MarkupString(string.Empty);
            Model.EntititesInAnalyzedResult.Clear();
            Model.AnalysisResult = string.Empty;
        }

        private void ResetFieldsAfterSearch()
        {
            isProcessing = false;
            isSearchPerformed = true;
        }

    }
}


Saturday 14 October 2023

Using Image Analysis in Azure AI Cognitive Services

I have added a demo .NET MAUI Blazor app that uses Image Analysis in Computer Vision in Azure Cognitive Services. Note that Image Analysis is not available in all Azure data centers. For example, Norway East does not have this feature. However, North Europe Azure data center do have the feature, the data center i Ireland. A Github repo exists for this demo here:

https://github.com/toreaurstadboss/Image.Analyze.Azure.Ai

A screen shot for this demo is shown below: Demo screenshot The demo allows you to upload a picture (supported formats are .jpeg, .jpg and .png, but Azure AI Image Analyzer supports a lot of other image formats too). The demo shows a preview of the selected image and to the right an image of bounding boxes of objects in the image. A list of tags extracted from the image are also shown. Raw data from the Azure Image Analyzer service is shown in the text box area below the pictures, with a list of tags to the right. The demo is written with .NET Maui Blazor and .NET 6. Let us look at some code for making this demo. ImageSaveService.cs


using Image.Analyze.Azure.Ai.Models;
using Microsoft.AspNetCore.Components.Forms;

namespace Ocr.Handwriting.Azure.AI.Services
{

    public class ImageSaveService : IImageSaveService
    {

        public async Task<ImageSaveModel> SaveImage(IBrowserFile browserFile)
        {
            var buffers = new byte[browserFile.Size];
            var bytes = await browserFile.OpenReadStream(maxAllowedSize: 30 * 1024 * 1024).ReadAsync(buffers);
            string imageType = browserFile.ContentType;

            var basePath = FileSystem.Current.AppDataDirectory;
            var imageSaveModel = new ImageSaveModel
            {
                SavedFilePath = Path.Combine(basePath, $"{Guid.NewGuid().ToString("N")}-{browserFile.Name}"),
                PreviewImageUrl = $"data:{imageType};base64,{Convert.ToBase64String(buffers)}",
                FilePath = browserFile.Name,
                FileSize = bytes / 1024,
            };

            await File.WriteAllBytesAsync(imageSaveModel.SavedFilePath, buffers);

            return imageSaveModel;
        }

    }
}

//Interface defined inside IImageService.cs shown below
using Image.Analyze.Azure.Ai.Models;
using Microsoft.AspNetCore.Components.Forms;

namespace Ocr.Handwriting.Azure.AI.Services
{
  
    public interface IImageSaveService
    {

        Task<ImageSaveModel> SaveImage(IBrowserFile browserFile);

    }

}


The ImageSaveService saves the uploaded image from the IBrowserFile into a base-64 string from the image bytes of the uploaded IBrowserFile via OpenReadStream of the IBrowserFile. This allows us to preview the uploaded image. The code also saves the image to the AppDataDirectory that MAUI supports - FileSystem.Current.AppDataDirectory. Let's look at how to call the analysis service itself, it is actually quite straight forward. ImageAnalyzerService.cs


using Azure;
using Azure.AI.Vision.Common;
using Azure.AI.Vision.ImageAnalysis;

namespace Image.Analyze.Azure.Ai.Lib
{

    public class ImageAnalyzerService : IImageAnalyzerService
    {

        public ImageAnalyzer CreateImageAnalyzer(string imageFile)
        {
            string key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_SECONDARY_KEY");
            string endpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_SECONDARY_ENDPOINT");
            var visionServiceOptions = new VisionServiceOptions(new Uri(endpoint), new AzureKeyCredential(key));

            using VisionSource visionSource = CreateVisionSource(imageFile);

            var analysisOptions = CreateImageAnalysisOptions();

            var analyzer = new ImageAnalyzer(visionServiceOptions, visionSource, analysisOptions);
            return analyzer;

        }

        private static VisionSource CreateVisionSource(string imageFile)
        {
            using var stream = File.OpenRead(imageFile);
            using var reader = new StreamReader(stream);
            byte[] imageBuffer;
            using (var streamReader = new MemoryStream())
            {
                stream.CopyTo(streamReader);
                imageBuffer = streamReader.ToArray();
            }

            using var imageSourceBuffer = new ImageSourceBuffer();
            imageSourceBuffer.GetWriter().Write(imageBuffer);
            return VisionSource.FromImageSourceBuffer(imageSourceBuffer);
        }

        private static ImageAnalysisOptions CreateImageAnalysisOptions() => new ImageAnalysisOptions
        {
            Language = "en",
            GenderNeutralCaption = false,
            Features =
              ImageAnalysisFeature.CropSuggestions
            | ImageAnalysisFeature.Caption
            | ImageAnalysisFeature.DenseCaptions
            | ImageAnalysisFeature.Objects
            | ImageAnalysisFeature.People
            | ImageAnalysisFeature.Text
            | ImageAnalysisFeature.Tags
        };

    }

}

//interface shown below 

 public interface IImageAnalyzerService
 {
     ImageAnalyzer CreateImageAnalyzer(string imageFile);
 }



We retrieve environment variables here and we create an ImageAnalyzer. We create a Vision source from the saved picture we uploaded and open a stream to it using File.OpenRead method on System.IO. Since we saved the file in the AppData folder of the .NET MAUI app, we can read this file. We set up the image analysis options and the vision service options. We then call the return the image analyzer. Let's look at the code-behind of the index.razor file that initializes the Image analyzer, and runs the Analyze method of it. Index.razor.cs
 
 
 using Azure.AI.Vision.ImageAnalysis;
using Image.Analyze.Azure.Ai.Extensions;
using Image.Analyze.Azure.Ai.Models;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System.Text;

namespace Image.Analyze.Azure.Ai.Pages
{
    partial class Index
    {

        private IndexModel Model = new();

        //https://learn.microsoft.com/en-us/azure/ai-services/computer-vision/how-to/call-analyze-image-40?WT.mc_id=twitter&pivots=programming-language-csharp

        private string ImageInfo = string.Empty;

        private async Task Submit()
        {
            if (Model.PreviewImageUrl == null || Model.SavedFilePath == null)
            {
                await Application.Current.MainPage.DisplayAlert($"MAUI Blazor Image Analyzer App", $"You must select an image first before running Image Analysis. Supported formats are .jpeg, .jpg and .png", "Ok", "Cancel");
                return;
            }

            using var imageAnalyzer = ImageAnalyzerService.CreateImageAnalyzer(Model.SavedFilePath);

            ImageAnalysisResult analysisResult = await imageAnalyzer.AnalyzeAsync();

            if (analysisResult.Reason == ImageAnalysisResultReason.Analyzed)
            {
                Model.ImageAnalysisOutputText = analysisResult.OutputImageAnalysisResult();
                Model.Caption = $"{analysisResult.Caption.Content} Confidence: {analysisResult.Caption.Confidence.ToString("F2")}";
                Model.Tags = analysisResult.Tags.Select(t => $"{t.Name} (Confidence: {t.Confidence.ToString("F2")})").ToList();
                var jsonBboxes = analysisResult.GetBoundingBoxesJson();
                await JsRunTime.InvokeVoidAsync("LoadBoundingBoxes", jsonBboxes);
            }
            else
            {
                ImageInfo = $"The image analysis did not perform its analysis. Reason: {analysisResult.Reason}";
            }

            StateHasChanged(); //visual refresh here
        }

        private async Task CopyTextToClipboard()
        {
            await Clipboard.SetTextAsync(Model.ImageAnalysisOutputText);
            await Application.Current.MainPage.DisplayAlert($"MAUI Blazor Image Analyzer App", $"The copied text was put into the clipboard. Character length: {Model.ImageAnalysisOutputText?.Length}", "Ok", "Cancel");
        }

        private async Task OnInputFile(InputFileChangeEventArgs args)
        {
            var imageSaveModel = await ImageSaveService.SaveImage(args.File);
            Model = new IndexModel(imageSaveModel);
            await Application.Current.MainPage.DisplayAlert($"MAUI Blazor ImageAnalyzer app App", $"Wrote file to location : {Model.SavedFilePath} Size is: {Model.FileSize} kB", "Ok", "Cancel");
        }


    }
}
 
 
In the code-behind above we have a submit handler called Submit. We there analyze the image and send the result both to the UI and also to a client side Javascript method using IJSRuntime in .NET MAUI Blazor. Let's look at the two helper methods of ImageAnalysisResult next. ImageAnalysisResultExtensions.cs
 
 
 using Azure.AI.Vision.ImageAnalysis;
using System.Text;

namespace Image.Analyze.Azure.Ai.Extensions
{
    public static class ImageAnalysisResultExtensions
    {

        public static string GetBoundingBoxesJson(this ImageAnalysisResult result)
        {
            var sb = new StringBuilder();
            sb.AppendLine(@"[");

            int objectIndex = 0;
            foreach (var detectedObject in result.Objects)
            {
                sb.Append($"{{ \"Name\": \"{detectedObject.Name}\", \"Y\": {detectedObject.BoundingBox.Y}, \"X\": {detectedObject.BoundingBox.X}, \"Height\": {detectedObject.BoundingBox.Height}, \"Width\": {detectedObject.BoundingBox.Width}, \"Confidence\": \"{detectedObject.Confidence:0.0000}\" }}");
                objectIndex++;
                if (objectIndex < result.Objects?.Count)
                {
                    sb.Append($",{Environment.NewLine}");
                }
                else
                {
                    sb.Append($"{Environment.NewLine}");
                }
            }
            sb.Remove(sb.Length - 2, 1); //remove trailing comma at the end
            sb.AppendLine(@"]");
            return sb.ToString();
        }

        public static string OutputImageAnalysisResult(this ImageAnalysisResult result)
        {
            var sb = new StringBuilder();

            if (result.Reason == ImageAnalysisResultReason.Analyzed)
            {

                sb.AppendLine($" Image height = {result.ImageHeight}");
                sb.AppendLine($" Image width = {result.ImageWidth}");
                sb.AppendLine($" Model version = {result.ModelVersion}");

                if (result.Caption != null)
                {
                    sb.AppendLine(" Caption:");
                    sb.AppendLine($"   \"{result.Caption.Content}\", Confidence {result.Caption.Confidence:0.0000}");
                }

                if (result.DenseCaptions != null)
                {
                    sb.AppendLine(" Dense Captions:");
                    foreach (var caption in result.DenseCaptions)
                    {
                        sb.AppendLine($"   \"{caption.Content}\", Bounding box {caption.BoundingBox}, Confidence {caption.Confidence:0.0000}");
                    }
                }

                if (result.Objects != null)
                {
                    sb.AppendLine(" Objects:");
                    foreach (var detectedObject in result.Objects)
                    {
                        sb.AppendLine($"   \"{detectedObject.Name}\", Bounding box {detectedObject.BoundingBox}, Confidence {detectedObject.Confidence:0.0000}");
                    }
                }

                if (result.Tags != null)
                {
                    sb.AppendLine($" Tags:");
                    foreach (var tag in result.Tags)
                    {
                        sb.AppendLine($"   \"{tag.Name}\", Confidence {tag.Confidence:0.0000}");
                    }
                }

                if (result.People != null)
                {
                    sb.AppendLine($" People:");
                    foreach (var person in result.People)
                    {
                        sb.AppendLine($"   Bounding box {person.BoundingBox}, Confidence {person.Confidence:0.0000}");
                    }
                }

                if (result.CropSuggestions != null)
                {
                    sb.AppendLine($" Crop Suggestions:");
                    foreach (var cropSuggestion in result.CropSuggestions)
                    {
                        sb.AppendLine($"   Aspect ratio {cropSuggestion.AspectRatio}: "
                            + $"Crop suggestion {cropSuggestion.BoundingBox}");
                    };
                }

                if (result.Text != null)
                {
                    sb.AppendLine($" Text:");
                    foreach (var line in result.Text.Lines)
                    {
                        string pointsToString = "{" + string.Join(',', line.BoundingPolygon.Select(pointsToString => pointsToString.ToString())) + "}";
                        sb.AppendLine($"   Line: '{line.Content}', Bounding polygon {pointsToString}");

                        foreach (var word in line.Words)
                        {
                            pointsToString = "{" + string.Join(',', word.BoundingPolygon.Select(pointsToString => pointsToString.ToString())) + "}";
                            sb.AppendLine($"     Word: '{word.Content}', Bounding polygon {pointsToString}, Confidence {word.Confidence:0.0000}");
                        }
                    }
                }

                var resultDetails = ImageAnalysisResultDetails.FromResult(result);
                sb.AppendLine($" Result details:");
                sb.AppendLine($"   Image ID = {resultDetails.ImageId}");
                sb.AppendLine($"   Result ID = {resultDetails.ResultId}");
                sb.AppendLine($"   Connection URL = {resultDetails.ConnectionUrl}");
                sb.AppendLine($"   JSON result = {resultDetails.JsonResult}");
            }
            else
            {
                var errorDetails = ImageAnalysisErrorDetails.FromResult(result);
                sb.AppendLine(" Analysis failed.");
                sb.AppendLine($"   Error reason : {errorDetails.Reason}");
                sb.AppendLine($"   Error code : {errorDetails.ErrorCode}");
                sb.AppendLine($"   Error message: {errorDetails.Message}");
            }

            return sb.ToString();
        }

    }
}


  
 
Finally, let's look at the client side Javascript function that we call and send the bounding boxes json to draw the boxes. We will use Canvas in HTML 5 to show the picture and the bounding boxes of objects found in the image. index.html
 
 
 	<script type="text/javascript">

		var colorPalette = ["red", "yellow", "blue", "green", "fuchsia", "moccasin", "purple", "magenta", "aliceblue", "lightyellow", "lightgreen"];

		function rescaleCanvas() {
			var img = document.getElementById('PreviewImage');
			var canvas = document.getElementById('PreviewImageBbox');
			canvas.width = img.width;
			canvas.height = img.height;
		}

		function getColor() {
			var colorIndex = parseInt(Math.random() * 10);
			var color = colorPalette[colorIndex];
			return color;
		}

		function LoadBoundingBoxes(objectDescriptions) {
			if (objectDescriptions == null || objectDescriptions == false) {
				alert('did not find any objects in image. returning from calling load bounding boxes : ' + objectDescriptions);
				return;
			}

			var objectDesc = JSON.parse(objectDescriptions);
			//alert('calling load bounding boxes, starting analysis on clientside js : ' + objectDescriptions);

			rescaleCanvas();
			var canvas = document.getElementById('PreviewImageBbox');
			var img = document.getElementById('PreviewImage');

			var ctx = canvas.getContext('2d');
			ctx.drawImage(img, img.width, img.height);

			ctx.font = "10px Verdana";

			for (var i = 0; i < objectDesc.length; i++) {
				ctx.beginPath();
				ctx.strokeStyle = "black";
				ctx.lineWidth = 1;
				ctx.fillText(objectDesc[i].Name, objectDesc[i].X + objectDesc[i].Width / 2, objectDesc[i].Y + objectDesc[i].Height / 2);
				ctx.fillText("Confidence: " + objectDesc[i].Confidence, objectDesc[i].X + objectDesc[i].Width / 2, 10 + objectDesc[i].Y + objectDesc[i].Height / 2);
			}

			for (var i = 0; i < objectDesc.length; i++) {
				ctx.fillStyle = getColor();
				ctx.globalAlpha = 0.2;
				ctx.fillRect(objectDesc[i].X, objectDesc[i].Y, objectDesc[i].Width, objectDesc[i].Height);
				ctx.lineWidth = 3;
				ctx.strokeStyle = "blue";
				ctx.rect(objectDesc[i].X, objectDesc[i].Y, objectDesc[i].Width, objectDesc[i].Height);
				ctx.fillStyle = "black";
				ctx.fillText("Color: " + getColor(), objectDesc[i].X + objectDesc[i].Width / 2, 20 + objectDesc[i].Y + objectDesc[i].Height / 2);

				ctx.stroke();
			}

			ctx.drawImage(img, 0, 0);


			console.log('got these object descriptions:');
			console.log(objectDescriptions);

		}
	</script>

 
  
The index.html file in wwwroot is the place we usually put extra css and js for MAUI Blazor apps and Blazor apps. I have chosen to put the script directly into the index.html file and not in a .js file, but that is an option to be chosen to tidy up a bit more. So there you have it, we can relatively easily find objects in images using Azure analyze image service in Azure Cognitive Services. We can get tags and captions of the image. In the demo the caption is shown above the picture loaded. Azure Computer vision service is really good since it has got a massive training set and can recognize a lot of different objects for different usages. As you see in the source code, I have the key and endpoint inside environment variables that the code expects exists. Never expose keys and endpoints in your source code.

Friday 22 September 2023

Using Azure Computer Vision to perform Optical Character Recognition (OCR)

This article shows how you can use Azure Computer vision in Azure Cognitive Services to perform Optical Character Recognition (OCR). The Computer vision feature is available by adding a Computer Vision resource in Azure Portal. I have made a .NET MAUI Blazor app and the Github Repo for it is available here : https://github.com/toreaurstadboss/Ocr.Handwriting.Azure.AI.Models
Let us first look at the .csproj of the Lib project in this repo.


<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
		<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
	</ItemGroup>

</Project>


The following class generates ComputerVision clients that can be used to extract different information from streams and files containing video and images. We are going to focus on images and extracting text via OCR. Azure Computer Vision can also extract handwritten text in addition to regular text written by typewriters or text inside images and similar. Azure Computer Vision also can detect shapes in images and classify objects. This demo only focuses on text extraction form images. ComputerVisionClientFactory


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IComputerVisionClientFactory
    {
        ComputerVisionClient CreateClient();
    }

    /// <summary>
    /// Client factory for Azure Cognitive Services - Computer vision.
    /// </summary>
    public class ComputerVisionClientFactory : IComputerVisionClientFactory
    {
        // Add your Computer Vision key and endpoint
        static string? _key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_KEY");
        static string? _endpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT");

        public ComputerVisionClientFactory() : this(_key, _endpoint)
        {
        }

        public ComputerVisionClientFactory(string? key, string? endpoint)
        {
            _key = key;
            _endpoint = endpoint;
        }

        public ComputerVisionClient CreateClient()
        {
            if (_key == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_KEY is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }
            if (_endpoint == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }

            var client = Authenticate(_key!, _endpoint!);
            return client;
        }

        public static ComputerVisionClient Authenticate(string key, string endpoint) =>
            new ComputerVisionClient(new ApiKeyServiceClientCredentials(key))
            {
                Endpoint = endpoint
            };

    }
}



The setup of the endpoint and key of the Computer Vision resource is done via system-level envrionment variables. Next up, let's look at retrieving OCR text from images. Here we use ComputerVisionClient. We open up a stream of a file, an image, using File.OpenReadAsync and then the method ReadInStreamAsync of Computer vision client. The image we will load up in the app is selected by the user and the image is previewed and saved using MAUI Storage lib (inside the Appdata folder). OcrImageService.cs


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using ReadResult = Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models.ReadResult;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IOcrImageService
    {
        Task<IList<ReadResult?>?> GetReadResults(string imageFilePath);
        Task<string> GetReadResultsText(string imageFilePath);
    }

    public class OcrImageService : IOcrImageService
    {
        private readonly IComputerVisionClientFactory _computerVisionClientFactory;
        private readonly ILogger<OcrImageService> _logger;

        public OcrImageService(IComputerVisionClientFactory computerVisionClientFactory, ILogger<OcrImageService> logger)
        {
            _computerVisionClientFactory = computerVisionClientFactory;
            _logger = logger;
        }

        private ComputerVisionClient CreateClient() => _computerVisionClientFactory.CreateClient();

        public async Task<string> GetReadResultsText(string imageFilePath)
        {
            var readResults = await GetReadResults(imageFilePath);
            var ocrText = ExtractText(readResults?.FirstOrDefault());
            return ocrText;
        }

        public async Task<IList<ReadResult?>?> GetReadResults(string imageFilePath)
        {
            if (string.IsNullOrWhiteSpace(imageFilePath))
            {
                return null;
            }

            try
            {
                var client = CreateClient();

                //Retrieve OCR results 

                using (FileStream stream = File.OpenRead(imageFilePath))
                {
                    var textHeaders = await client.ReadInStreamAsync(stream);
                    string operationLocation = textHeaders.OperationLocation;
                    string operationId = operationLocation[^36..]; //hat operator of C# 8.0 : this slices out the last 36 chars, which contains the guid chars which are 32 hexadecimals chars + four hyphens

                    ReadOperationResult results;

                    do
                    {
                        results = await client.GetReadResultAsync(Guid.Parse(operationId));
                        _logger.LogInformation($"Retrieving OCR results for operationId {operationId} for image {imageFilePath}");
                    }
                    while (results.Status == OperationStatusCodes.Running || results.Status == OperationStatusCodes.NotStarted);

                    IList<ReadResult?> result = results.AnalyzeResult.ReadResults;
                    return result;

                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                return null;
            }
        }

        private static string ExtractText(ReadResult? readResult) => string.Join(Environment.NewLine, readResult?.Lines?.Select(l => l.Text) ?? new List<string>());

    }

}
                                           

Let's look at the MAUI Blazor project in the app. The MauiProgram.cs looks like this. MauiProgram.cs


using Ocr.Handwriting.Azure.AI.Data;
using Ocr.Handwriting.Azure.AI.Lib;
using Ocr.Handwriting.Azure.AI.Services;
using TextCopy;

namespace Ocr.Handwriting.Azure.AI;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        builder.Services.AddMauiBlazorWebView();
#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Services.AddLogging();
#endif

        builder.Services.AddSingleton<WeatherForecastService>();
        builder.Services.AddScoped<IComputerVisionClientFactory, ComputerVisionClientFactory>();
        builder.Services.AddScoped<IOcrImageService, OcrImageService>();
        builder.Services.AddScoped<IImageSaveService, ImageSaveService>();

        builder.Services.InjectClipboard();

        return builder.Build();
    }
}



We also need some code to preview and save the image an end user chooses. The IImageService looks like this. ImageSaveService


using Microsoft.AspNetCore.Components.Forms;
using Ocr.Handwriting.Azure.AI.Models;

namespace Ocr.Handwriting.Azure.AI.Services
{

    public class ImageSaveService : IImageSaveService
    {

        public async Task<ImageSaveModel> SaveImage(IBrowserFile browserFile)
        {
            var buffers = new byte[browserFile.Size];
            var bytes = await browserFile.OpenReadStream(maxAllowedSize: 30 * 1024 * 1024).ReadAsync(buffers);
            string imageType = browserFile.ContentType;

            var basePath = FileSystem.Current.AppDataDirectory;
            var imageSaveModel = new ImageSaveModel
            {
                SavedFilePath = Path.Combine(basePath, $"{Guid.NewGuid().ToString("N")}-{browserFile.Name}"),
                PreviewImageUrl = $"data:{imageType};base64,{Convert.ToBase64String(buffers)}",
                FilePath = browserFile.Name,
                FileSize = bytes / 1024,
            };

            await File.WriteAllBytesAsync(imageSaveModel.SavedFilePath, buffers);

            return imageSaveModel;
        }

    }
}


Note the use of maxAllowedSize of IBrowserfile.OpenReadStream method, this is a good practice since IBrowserFile only supports 512 kB per default. I set it in the app to 30 MB to support some high res images too. We preview the image as base-64 here and we also save the image also. Note the use of FileSystem.Current.AppDataDirectory as base path here. Here we use nuget package Microsoft.Maui.Storage. These are the packages that is used for the MAUI Blazor project of the app. Ocr.Handwriting.Azure.AI.csproj



    <ItemGroup>
      <PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
      <PackageReference Include="TextCopy" Version="6.2.1" />
    </ItemGroup>


The GUI looks like this : Index.razor


@page "/"
@using Ocr.Handwriting.Azure.AI.Models;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
@using Ocr.Handwriting.Azure.AI.Lib;
@using Ocr.Handwriting.Azure.AI.Services;
@using TextCopy;

@inject IImageSaveService ImageSaveService
@inject IOcrImageService OcrImageService 
@inject IClipboard Clipboard

<h1>Azure AI OCR Text recognition</h1>


<EditForm Model="Model" OnValidSubmit="@Submit" style="background-color:aliceblue">
    <DataAnnotationsValidator />
    <label><b>Select a picture to run OCR</b></label><br />
    <InputFile OnChange="@OnInputFile" accept=".jpeg,.jpg,.png" />
    <br />
    <code class="alert-secondary">Supported file formats: .jpeg, .jpg and .png</code>
    <br />
    @if (Model.PreviewImageUrl != null) { 
        <label class="alert-info">Preview of the selected image</label>
        <div style="overflow:auto;max-height:300px;max-width:500px">
            <img class="flagIcon" src="@Model.PreviewImageUrl" /><br />
        </div>

        <code class="alert-light">File Size (kB): @Model.FileSize</code>
        <br />
        <code class="alert-light">File saved location: @Model.SavedFilePath</code>
        <br />

        <label class="alert-info">Click the button below to start running OCR using Azure AI</label><br />
        <br />
        <button type="submit">Submit</button> <button style="margin-left:200px" type="button" class="btn-outline-info" @onclick="@CopyTextToClipboard">Copy to clipboard</button>
        <br />
        <br />
        <InputTextArea style="width:1000px;height:300px" readonly="readonly" placeholder="Detected text in the image uploaded" @bind-Value="Model!.OcrOutputText" rows="5"></InputTextArea>
    }
</EditForm>


@code {

    private IndexModel Model = new();

    private async Task OnInputFile(InputFileChangeEventArgs args)
    {       
        var imageSaveModel = await ImageSaveService.SaveImage(args.File);
        Model = new IndexModel(imageSaveModel);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"Wrote file to location : {Model.SavedFilePath} Size is: {Model.FileSize} kB", "Ok", "Cancel");
    }

    public async Task CopyTextToClipboard()
    {
        await Clipboard.SetTextAsync(Model.OcrOutputText);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"The copied text was put into the clipboard. Character length: {Model.OcrOutputText?.Length}", "Ok", "Cancel");

    }

    private async Task Submit()
    {
        if (Model.PreviewImageUrl == null || Model.SavedFilePath == null)
        {
            await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"You must select an image first before running OCR. Supported formats are .jpeg, .jpg and .png", "Ok", "Cancel");
            return;
        }
        Model.OcrOutputText = await OcrImageService.GetReadResultsText(Model.SavedFilePath);
        StateHasChanged(); //visual refresh here
    }

}


The UI works like this. The user selects an image. As we can see by the 'accept' html attribute, the .jpeg, .jpg and .png extensions are allowed in the file input dialog. When the user selects an image, the image is saved and previewed in the UI. By hitting the Submit button, the OCR service in Azure is contacted and text is retrieved and displayed in the text area below, if any text is present in the image. A button allows copying the text into the clipboard. Here are some screenshots of the app.


Tuesday 19 September 2023

Using Azure AI TextAnalytics and translation service to build an universal translator

This article shows code how to build a universal translator using Azure AI Cognitive Services. This includes Azure AI Textanalytics to detect languge from text input, and using Azure AI Translation services. The Github repo is here :
https://github.com/toreaurstadboss/MultiLingual.Translator
The following Nuget packages are used in the Lib project csproj file :

 <ItemGroup>
    <PackageReference Include="Azure.AI.Translation.Text" Version="1.0.0-beta.1" />
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
    <PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
  </ItemGroup>


We are going to build a .NET 6 cross platform MAUI Blazor app. First off, we focus on the Razor Library project called 'Lib'. This project contains the library util code to detect language and translate into other language. Let us first look at creating the clients needed to detect language and to translate text. TextAnalyticsFactory.cs


using Azure;
using Azure.AI.TextAnalytics;
using Azure.AI.Translation.Text;
using System;

namespace MultiLingual.Translator.Lib
{
    public static class TextAnalyticsClientFactory
    {

        public static TextAnalyticsClient CreateClient()
        {
            string? uri = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_ENDPOINT", EnvironmentVariableTarget.Machine);
            string? key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_KEY", EnvironmentVariableTarget.Machine);
            if (uri == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_ENDPOINT' Set this variable first.");
            }
            if (key == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_KEY' Set this variable first.");
            }
            var client = new TextAnalyticsClient(new Uri(uri!), new AzureKeyCredential(key!));
            return client;
        }

        public static TextTranslationClient CreateTranslateClient()
        {
            string? keyTranslate = Environment.GetEnvironmentVariable("AZURE_TRANSLATION_SERVICE_KEY", EnvironmentVariableTarget.Machine);
            string? regionForTranslationService = Environment.GetEnvironmentVariable("AZURE_TRANSLATION_SERVICE_REGION", EnvironmentVariableTarget.Machine);

            if (keyTranslate == null)
            {
                throw new ArgumentNullException(nameof(keyTranslate), "Could not get system environment variable named 'AZURE_TRANSLATION_SERVICE_KEY' Set this variable first.");
            }
            if (keyTranslate == null)
            {
                throw new ArgumentNullException(nameof(keyTranslate), "Could not get system environment variable named 'AZURE_TRANSLATION_SERVICE_REGION' Set this variable first.");
            }
            var client = new TextTranslationClient(new AzureKeyCredential(keyTranslate!), region: regionForTranslationService);
            return client;
        }

    }
}


The code assumes that there is four environment variables at the SYSTEM level of your OS. Further on, let us now look at the code to detect language. This uses a TextAnalyticsClient detect the language an input text is written in, using this client. IDetectLanguageUtil.cs


using Azure.AI.TextAnalytics;

namespace MultiLingual.Translator.Lib
{
    public interface IDetectLanguageUtil
    {
        Task<DetectedLanguage> DetectLanguage(string inputText);
        Task<double> DetectLanguageConfidenceScore(string inputText);
        Task<string> DetectLanguageIso6391(string inputText);
        Task<string> DetectLanguageName(string inputText);
    }
}


DetectLanguageUtil.cs


using Azure.AI.TextAnalytics;

namespace MultiLingual.Translator.Lib
{

    public class DetectLanguageUtil : IDetectLanguageUtil
    {

        private TextAnalyticsClient _client;

        public DetectLanguageUtil()
        {
            _client = TextAnalyticsClientFactory.CreateClient();
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<DetectedLanguage> DetectLanguage(string inputText)
        {
            DetectedLanguage detectedLanguage = await _client.DetectLanguageAsync(inputText);
            return detectedLanguage;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the language name.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<string> DetectLanguageName(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.Name;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the language code.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<string> DetectLanguageIso6391(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.Iso6391Name;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the confidence score
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<double> DetectLanguageConfidenceScore(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.ConfidenceScore;
        }

    }

}



The Iso6391 code is important when it comes to translation, which will be shown soon. But first let us look at the supported languages of Azure AI Translation services. LanguageCode.cs


namespace MultiLingual.Translator.Lib.Models
{
    /// 
    /// List of supported languages in Azure AI services
    /// https://learn.microsoft.com/en-us/azure/ai-services/translator/language-support
    /// 
    public static class LanguageCode
    {

        public const string Afrikaans = "af";
        public const string Albanian = "sq";
        public const string Amharic = "am";
        public const string Arabic = "ar";
        public const string Armenian = "hy";
        public const string Assamese = "as";
        public const string AzerbaijaniLatin = "az";
        public const string Bangla = "bn";
        public const string Bashkir = "ba";
        public const string Basque = "eu";
        public const string BosnianLatin = "bs";
        public const string Bulgarian = "bg";
        public const string CantoneseTraditional = "yue";
        public const string Catalan = "ca";
        public const string ChineseLiterary = "lzh";
        public const string ChineseSimplified = "zh-Hans";
        public const string ChineseTraditional = "zh-Hant";
        public const string chiShona = "sn";
        public const string Croatian = "hr";
        public const string Czech = "cs";
        public const string Danish = "da";
        public const string Dari = "prs";
        public const string Divehi = "dv";
        public const string Dutch = "nl";
        public const string English = "en";
        public const string Estonian = "et";
        public const string Faroese = "fo";
        public const string Fijian = "fj";
        public const string Filipino = "fil";
        public const string Finnish = "fi";
        public const string French = "fr";
        public const string FrenchCanada = "fr-ca";
        public const string Galician = "gl";
        public const string Georgian = "ka";
        public const string German = "de";
        public const string Greek = "el";
        public const string Gujarati = "gu";
        public const string HaitianCreole = "ht";
        public const string Hausa = "ha";
        public const string Hebrew = "he";
        public const string Hindi = "hi";
        public const string HmongDawLatin = "mww";
        public const string Hungarian = "hu";
        public const string Icelandic = "is";
        public const string Igbo = "ig";
        public const string Indonesian = "id";
        public const string Inuinnaqtun = "ikt";
        public const string Inuktitut = "iu";
        public const string InuktitutLatin = "iu-Latn";
        public const string Irish = "ga";
        public const string Italian = "it";
        public const string Japanese = "ja";
        public const string Kannada = "kn";
        public const string Kazakh = "kk";
        public const string Khmer = "km";
        public const string Kinyarwanda = "rw";
        /// 
        /// Fear my Bak'leth ! 
        /// 
        public const string Klingon = "tlh-Latn";
        public const string KlingonplqaD = "tlh-Piqd";
        public const string Konkani = "gom";
        public const string Korean = "ko";
        public const string KurdishCentral = "ku";
        public const string KurdishNorthern = "kmr";
        public const string KyrgyzCyrillic = "ky";
        public const string Lao = "lo";
        public const string Latvian = "lv";
        public const string Lithuanian = "lt";
        public const string Lingala = "ln";
        public const string LowerSorbian = "dsb";
        public const string Luganda = "lug";
        public const string Macedonian = "mk";
        public const string Maithili = "mai";
        public const string Malagasy = "mg";
        public const string MalayLatin = "ms";
        public const string Malayalam = "ml";
        public const string Maltese = "mt";
        public const string Maori = "mi";
        public const string Marathi = "mr";
        public const string MongolianCyrillic = "mn-Cyrl";
        public const string MongolianTraditional = "mn-Mong";
        public const string Myanmar = "my";
        public const string Nepali = "ne";
        public const string Norwegian = "nb";
        public const string Nyanja = "nya";
        public const string Odia = "or";
        public const string Pashto = "ps";
        public const string Persian = "fa";
        public const string Polish = "pl";
        public const string PortugueseBrazil = "pt";
        public const string PortuguesePortugal = "pt-pt";
        public const string Punjabi = "pa";
        public const string QueretaroOtomi = "otq";
        public const string Romanian = "ro";
        public const string Rundi = "run";
        public const string Russian = "ru";
        public const string SamoanLatin = "sm";
        public const string SerbianCyrillic = "sr-Cyrl";
        public const string SerbianLatin = "sr-Latn";
        public const string Sesotho = "st";
        public const string SesothosaLeboa = "nso";
        public const string Setswana = "tn";
        public const string Sindhi = "sd";
        public const string Sinhala = "si";
        public const string Slovak = "sk";
        public const string Slovenian = "sl";
        public const string SomaliArabic = "so";
        public const string Spanish = "es";
        public const string SwahiliLatin = "sw";
        public const string Swedish = "sv";
        public const string Tahitian = "ty";
        public const string Tamil = "ta";
        public const string TatarLatin = "tt";
        public const string Telugu = "te";
        public const string Thai = "th";
        public const string Tibetan = "bo";
        public const string Tigrinya = "ti";
        public const string Tongan = "to";
        public const string Turkish = "tr";
        public const string TurkmenLatin = "tk";
        public const string Ukrainian = "uk";
        public const string UpperSorbian = "hsb";
        public const string Urdu = "ur";
        public const string UyghurArabic = "ug";
        public const string UzbekLatin = "uz";
        public const string Vietnamese = "vi";
        public const string Welsh = "cy";
        public const string Xhosa = "xh";
        public const string Yoruba = "yo";
        public const string YucatecMaya = "yua";
        public const string Zulu = "zu";
    }
}


As there are about 5-10 000 languages in the World, the list above shows that Azure AI translation services supports about 130 of these, which is 1-2 % of the total amount of languages. Of course, the languages supported by Azure AI are also including the most spoken languages in the World. Let us look at the translation util code next. ITranslateUtil.cs


namespace MultiLingual.Translator.Lib
{
    public interface ITranslateUtil
    {
        Task<string?> Translate(string targetLanguage, string inputText, string? sourceLanguage = null);
    }
}


TranslateUtil.cs


using Azure.AI.Translation.Text;
using MultiLingual.Translator.Lib.Models;

namespace MultiLingual.Translator.Lib
{

    public class TranslateUtil : ITranslateUtil
    {
        private TextTranslationClient _client;


        public TranslateUtil()
        {
            _client = TextAnalyticsClientFactory.CreateTranslateClient();
        }

        /// <summary>
        /// Translates text using Azure AI Translate services. 
        /// </summary>
        /// <param name="targetLanguage"><see cref="LanguageCode" for a list of supported languages/></param>
        /// <param name="inputText"></param>
        /// <param name="sourceLanguage">Pass in null here to auto detect the source language</param>
        /// <returns></returns>
        public async Task<string?> Translate(string targetLanguage, string inputText, string? sourceLanguage = null)
        {
            var translationOfText = await _client.TranslateAsync(targetLanguage, inputText, sourceLanguage);
            if (translationOfText?.Value == null)
            {
                return null;
            }
            var translation = translationOfText.Value.SelectMany(l => l.Translations).Select(l => l.Text)?.ToList();
            string? translationText = translation?.FlattenString();
            return translationText;
        }

    }
}


We use a little helper extension method here too : StringExtensions.cs


using System.Text;

namespace MultiLingual.Translator.Lib
{
    public static class StringExtensions
    {

        /// <summary>
        /// Merges a collection of lines into a flattened string separating each line by a specified line separator.
        /// Newline is deafult
        /// </summary>
        /// <param name="inputLines"></param>
        /// <param name="lineSeparator"></param>
        /// <returns></returns>
        public static string? FlattenString(this IEnumerable<string>? inputLines, string lineSeparator = "\n")
        {
            if (inputLines == null || !inputLines.Any())
            {
                return null;
            }
            var flattenedString = inputLines?.Aggregate(new StringBuilder(),
                (sb, l) => sb.AppendLine(l + lineSeparator),
                sb => sb.ToString().Trim());

            return flattenedString;
        }

    }
}


Here are some tests for detecting language : DetectLanguageUtilTests.cs

  
using Azure.AI.TextAnalytics;
using FluentAssertions;

namespace MultiLingual.Translator.Lib.Test
{
    public class DetectLanguageUtilTests
    {

        private DetectLanguageUtil _detectLanguageUtil;

        public DetectLanguageUtilTests()
        {
            _detectLanguageUtil = new DetectLanguageUtil();
        }

        [Theory]
        [InlineData("Donde esta la playa", "es", "Spanish")]
        [InlineData("Jeg er fra Trøndelag og jeg liker brunost", "no", "Norwegian")]
        public async Task DetectLanguageDetailsSucceeds(string text, string expectedLanguageIso6391, string expectedLanguageName)
        {
            string? detectedLangIso6391 = await _detectLanguageUtil.DetectLanguageIso6391(text);
            detectedLangIso6391.Should().Be(expectedLanguageIso6391);
            string? detectedLangName = await _detectLanguageUtil.DetectLanguageName(text);
            detectedLangName.Should().Be(expectedLanguageName);
        }

        [Theory]
        [InlineData("Du hast mich", "de", "German")]
        public async Task DetectLanguageSucceeds(string text, string expectedLanguageIso6391, string expectedLanguageName)
        {
            DetectedLanguage detectedLanguage = await _detectLanguageUtil.DetectLanguage(text);
            detectedLanguage.Iso6391Name.Should().Be(expectedLanguageIso6391);            
            detectedLanguage.Name.Should().Be(expectedLanguageName);
        }

    }
}  
  

And here are some translation util tests : TranslateUtilTests.cs


using FluentAssertions;
using MultiLingual.Translator.Lib.Models;

namespace MultiLingual.Translator.Lib.Test
{

    public class TranslateUtilTests
    {

        private TranslateUtil _translateUtil;

        public TranslateUtilTests()
        {
            _translateUtil = new TranslateUtil();                
        }

        [Theory]
        [InlineData("Jeg er fra Norge og jeg liker brunost", "i'm from norway and i like brown cheese", LanguageCode.Norwegian,  LanguageCode.English)]
        [InlineData("Jeg er fra Norge og jeg liker brunost", "i'm from norway and i like brown cheese", null, LanguageCode.English)] //auto detect language is tested here
        [InlineData("Ich bin aus Hamburg und ich liebe bier", "i'm from hamburg and i love beer", LanguageCode.German, LanguageCode.English)]
        [InlineData("Ich bin aus Hamburg und ich liebe bier", "i'm from hamburg and i love beer", null, LanguageCode.English)] //Auto detect source language is tested here
        [InlineData("tlhIngan maH", "we are klingons", LanguageCode.Klingon, LanguageCode.English)] //Klingon force !
        public async Task TranslationReturnsExpected(string input, string expectedTranslation, string sourceLanguage, string targetLanguage)
        {
            string? translation = await _translateUtil.Translate(targetLanguage, input, sourceLanguage);
            translation.Should().NotBeNull();
            translation.Should().BeEquivalentTo(expectedTranslation);
        }

    }
}
  

Over to the UI. The app is made with MAUI Blazor. Here are some models for the app : LanguageInputModel.cs


namespace MultiLingual.Translator.Models
{
    public class LanguageInputModel
    {
        public string InputText { get; set; }

        public string DetectedLanguageInfo { get; set; }

        public string DetectedLanguageIso6391 { get; set; }

        public string TargetLanguage { get; set; }

        public string TranslatedText { get; set; }

    }
}



NameValue.cs


namespace MultiLingual.Translator.Models
{
    public class NameValue
    {
        public string Name { get; set; }
        public string Value { get; set; }
    }
}


The UI consists of this razor code in, written for Blazor MAUI app. Index.razor


@page "/"
@inject ITranslateUtil TransUtil
@inject IDetectLanguageUtil DetectLangUtil
@inject IJSRuntime JS

@using MultiLingual.Translator.Lib;
@using MultiLingual.Translator.Lib.Models;
@using MultiLingual.Translator.Models;

<h1>Azure AI Text Translation</h1>

<EditForm Model="@Model" OnValidSubmit="@Submit" class="form-group" style="background-color:aliceblue;">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="form-group row">
        <label for="Model.InputText">Text to translate</label>
        <InputTextArea @bind-Value="Model!.InputText" placeholder="Enter text to translate" @ref="inputTextRef" id="textToTranslate" rows="5" />
    </div>

    <div class="form-group row">
        <span>Detected language of text to translate</span>
        <InputText class="languageLabelText" readonly="readonly" placeholder="The detected language of the text to translate" @bind-Value="Model!.DetectedLanguageInfo"></InputText>
        @if (Model.DetectedLanguageInfo != null){
            <img src="@FlagIcon" class="flagIcon" />
        }
    </div>
    <br />
    
    <div class="form-group row">
        <span>Translate into language</span>
        <InputSelect placeholder="Choose the target language"  @bind-Value="Model!.TargetLanguage">
            @foreach (var item in LanguageCodes){
                <option value="@item.Value">@item.Name</option>
            }
        </InputSelect>
        <br />
          @if (Model.TargetLanguage != null){
            <img src="@TargetFlagIcon" class="flagIcon" />
        }
    </div>
    <br />

    <div class="form-group row">
        <span>Translation</span>
        <InputTextArea readonly="readonly" placeholder="The translated text target language" @bind-Value="Model!.TranslatedText" rows="5"></InputTextArea>
    </div>

    <button type="submit" class="submitButton">Submit</button>

</EditForm>

@code {
    private Azure.AI.TextAnalytics.TextAnalyticsClient _client;

    private InputTextArea inputTextRef;

    public LanguageInputModel Model { get; set; } = new();

    private string FlagIcon {
        get
        {
            return $"images/flags/png100px/{Model.DetectedLanguageIso6391}.png";
        }
    }

    private string TargetFlagIcon {
        get
        {
            return $"images/flags/png100px/{Model.TargetLanguage}.png";
        }
    }

    private List<NameValue> LanguageCodes = typeof(LanguageCode).GetFields().Select(f => new NameValue {
	 Name = f.Name,
	 Value = f.GetValue(f)?.ToString(),
	}).OrderBy(f => f.Name).ToList();


    private async void Submit()
    {
        var detectedLanguage = await DetectLangUtil.DetectLanguage(Model.InputText);
        Model.DetectedLanguageInfo = $"{detectedLanguage.Iso6391Name} {detectedLanguage.Name}";
        Model.DetectedLanguageIso6391 = detectedLanguage.Iso6391Name;
        if (_client == null)
        {
            _client = TextAnalyticsClientFactory.CreateClient();
        }
        Model.TranslatedText = await TransUtil.Translate(Model.TargetLanguage, Model.InputText, detectedLanguage.Iso6391Name);

        StateHasChanged();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            Model.TargetLanguage = LanguageCode.English;
            await JS.InvokeVoidAsync("exampleJsFunctions.focusElement", inputTextRef?.AdditionalAttributes.FirstOrDefault(a => a.Key?.ToLower() == "id").Value);
            StateHasChanged();
        }
    }

}


Finally, a screenshot how the app looks like : You enter the text to translate, then the detected language is shown after you hit Submit. You can select the target language to translate the text into. English is selected as default. The Iso6391 code of the selected language is shown as a flag icon, if there exists a 1:1 mapping between the Iso6391 code and the flag icons available in the app. The top flag show the detected language via the Iso6391 code, IF there is a 1:1 mapping between this code and the available Flag icons.

Thursday 18 May 2023

Animations in Blazor



I tested out animations in Blazor today, using the AOS - Animate on Scroll - library. I will use Blazor WASM for this. The sample code in this article can be cloned from my GitHub repo here: https://github.com/toreaurstadboss/BlazorAnimateSample This library is very easy to set up for Blazor. First off, go to the AOS website for installation instructions. AOS Github Pages On this site, copy the links to the CSS and Js file from the CDN. But note that I instead used VS 2022 and choose:
Add=>Client-side Library


The benefit of using this way of adding Aos is that the CSS and Js is installed into lib folders and you can drag the two files into the <head> section and <script> section inside body tag at the bottom of the index.html file. You set up AOS using the init method. You can either define a startEvent or not, it should default to event DOMContentLoaded i.e. when the element is displayed, the animation is started. The use of AOS and init method is explained in the GitHub repo for AOS: https://github.com/michalsnik/aos I set up AOS like this in index.html:
 

    <script src="_framework/blazor.webassembly.js"></script>
    <script src="js/animate.js"></script>
    <script src="lib/aos/aos.js"></script>
    <script>
        AOS.init({
            easing: 'ease-in-out',
            //startEvent: 'custom'
        });
    </script>
    
 
You can set the startEvent to 'custom' for example if you want to disable automatically loading the start of animations as soon as the element is scrolled into view or displayed in some other control manner. (actually you could set it to 'myEvent' here or some other gibberish value, to turn off automatically loading animations. Over to the file animate.js which will be used by the Animation component in Blazor (will be described later in this article).
 
 



function RegisterAnimationStartupTrigger(wrapperAnimationElementId, triggerElementId, triggerEventId) {
    //debugger
    if (event != null && event.target != null && event.target != undefined && event.target.closest) {
        var closestParentDiv = event.target.closest('div');
        if (closestParentDiv != null && closestParentDiv.id == wrapperAnimationElementId) {

            //sub elements of the wrapper div should not trigger animation, to avoid AOS running animation again
            return;
        }
    }
    
    var elem = document.getElementById(wrapperAnimationElementId);
    if (elem == null || elem == undefined) {
        return;
    }
    var triggerElement = document.getElementById(triggerElementId);
    if (triggerElement == null || triggerElement == undefined) {
        return;
    }

    elem.classList.remove('aos-init'); //remove aos-animate class to avoid auto loading animation on scroll
    elem.classList.remove('aos-animate'); //remove aos-animate class to avoid auto loading animation on scroll

    triggerElement.addEventListener(triggerEventId, function () { AddAosAnimateCssClass(elem, triggerEventId, wrapperAnimationElementId); }); //remove aos-animate class to avoid auto loading animation on scroll
}

function AddAosAnimateCssClass(elem, triggerEventId, wrapperAnimationElementId) {
    //debugger
    if (event != null && event.target != null && event.target != undefined && event.target.closest) {
        var closestParentDiv = event.target.closest('div');
        if (closestParentDiv != null && closestParentDiv.id == wrapperAnimationElementId) {

            //sub elements of the wrapper div should not trigger animation, to avoid AOS running animation again
            return;
        }
    }
    if (elem == null || elem == undefined) {
        return;
    }

    elem.classList.remove('aos-init');
    elem.classList.remove('aos-animate');

    if (triggerEventId.toLowerCase() == 'change') {
        if (!event.target.checked) {
            return; //in case this is a checkbox, only trigger on checked state
        }
    }

    setTimeout(function () {
        elem.classList.add('aos-init');
        elem.classList.add('aos-animate');
    }, 500);
}

function RestartAosEventToImplicitEventDomContentLoaded() {
    AOS.init({
        easing: 'ease-in-out',
        startEvent: 'DOMContentLoaded'
    });
}

function DisableAosEventToImplicitEventDomContentLoaded() {
    AOS.init({
        easing: 'ease-in-out',
        startEvent: 'MyCustomEvent'
    });
}
 
 
We set up some helper methods here to be able control playing animations on demand. This is not required however, it was just an experiment from my side to see how you could select an element in the DOM and a event for that element (DOM event) to control how to start the animation. The Animation.razor component looks like this, with its code behind.
 
 

@inject IJSRuntime JsRuntime
<div id="@_wrapperDivUniqueId" data-aos="@SelectedAnimation.GetDisplayName()" data-aos-delay="@Delay" data-aos-duration="@Duration">
  @ChildContent
</div>

@code {
    private string _wrapperDivUniqueId = $"wrapperDiv_{Guid.NewGuid().ToString("N")}";

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    /// <summary>
    /// Duration must be set between 50 to 3000 ms, see defined limit here : https://github.com/michalsnik/aos
    /// </summary>
    [Parameter]
    public int Duration { get; set; } = 1000;

    /// <summary>
    /// Delay must be set between 0 to 3000 ms, see defined limit here: https://github.com/michalsnik/aos
    /// </summary>
    [Parameter]
    public int Delay { get; set; } = 50;

    /// <summary>
    /// Animation to use. Use name list defined in Animations. See here: <see cref="AnimationNames" /> for a list of supported Animations.
    /// </summary>
    [Parameter]
    public AnimationNames SelectedAnimation { get; set; } = AnimationNames.Fade;

    /// <summary>
    /// DOM id of the element that will trigger the animation. If not set, the animation will happed as default, when element scrolls into view according to AOS standard
    /// </summary>
    [Parameter]
    public string? TriggerElementId { get; set; }

    /// <summary>
    /// DOM event for the element that will trigger the animation. See https://www.w3schools.com/jsref/dom_obj_event.asp for a list of DOM events. If not set, the animation will happen as default, when the element scrolls into view according to AOS standard.
    /// </summary>
    [Parameter]
    public string? TriggerEventId { get; set; }

    protected async override Task OnAfterRenderAsync(bool firstRender)
    {
        if (!string.IsNullOrWhiteSpace(TriggerElementId) && !string.IsNullOrWhiteSpace(TriggerEventId))
        {
            //turn off automatic animation on scroll for the element

            await JsRuntime.InvokeAsync<string>("RegisterAnimationStartupTrigger", new[] {
                _wrapperDivUniqueId, TriggerElementId, TriggerEventId });
        }
    }


    protected override void OnParametersSet()
    {
        if (Duration < 50)
        {
            Duration = 50;
        } 
        else if (Duration > 3000)
        {
            Duration = 3000;
        }
        if (Delay < 0)
        {
            Delay = 0;
        }
        else if (Delay > 3000)
        {
            Delay = 3000;
        }
        if (string.IsNullOrWhiteSpace(SelectedAnimation.GetDisplayName()))
        {
            SelectedAnimation = AnimationNames.Fade;
        }       
    }

}

 
 

In the component above, we use the parameter ChildContent which is a RenderFragment? which is used in the razor markup. We wrap a div and generate a unique id which is used in Javascript to control the on demand coupling of starting the animation. In many cases, you could just use the default DOMContentLoaded if you want to just play the animation when the element is displayed. Here is how you use the Animation component in an example component:
 
 
@page "/counter"
@inject IJSRuntime JS

<PageTitle>Counter</PageTitle>

<h1>Counter with Blazor animations</h1>

<p role="status">Current count: @currentCount</p>

<input type="checkbox" id="CheckboxToggleViaSpecificJsEvent" />
    Check here to start the animation [TriggerElementId: CheckboxToggleViaSpecificJsEvent, TriggerEventId: change]
<br />
<br />
<Animation Duration="1000" SelectedAnimation="@AnimationNames.Fade" TriggerElementId="CheckboxToggleViaSpecificJsEvent" TriggerEventId="change">
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</Animation>
<br />
<br />


<button @onclick="AOSRestartStartEvent" class="btn btn-outline-success">
    Click here to enable AOS start event to DOMContentLoaded
</button>
<br /><br />

<button @onclick="AOSCustomStartEvent" class="btn btn-outline-success">
    Click here to disable AOS start event to DOMContentLoaded (custom start event)
</button>
<br />
<br />

<hr />

<label>
    <h5 class="text-muted">
        Select the animation to show when button is visible
        <InputSelect @bind-Value="SelectedAnimation">
            @foreach (var item in Enum.GetValues(typeof(AnimationNames)).Cast<AnimationNames>())
            {
                <option value="@item">@item.GetDisplayName()</option>                
            }
        </InputSelect>
    </h5>
</label>

<br />

<label>  
 <InputCheckbox @bind-Value="@showAnotherCurrentCountBtn" />
   Check here to start the animation [No trigger element, visbility of button below controlled by data bound flag for the checkbox]
</label>

@if (showAnotherCurrentCountBtn){
    <Animation Duration="1500" SelectedAnimation="@SelectedAnimation" Delay="50">
        <button class="btn btn-outline-primary" @onclick="IncrementCount">Click me</button>
    </Animation>
}
<br />
<br />

@code {
    private int currentCount = 0;
    private bool showAnotherCurrentCountBtn = false;

    private AnimationNames SelectedAnimation = AnimationNames.Fade;


    private void IncrementCount()
    {
        currentCount++;
    }

    private async Task AOSRestartStartEvent()
    {
        await JS.InvokeAsync<string>("RestartAosEventToImplicitEventDomContentLoaded");
    }

    private async Task AOSCustomStartEvent()
    {
        await JS.InvokeAsync<string>("DisableAosEventToImplicitEventDomContentLoaded");
    }

} 
 
 
The AnimationNames is an enum which allows you to set one of the pre-defined animations in AOS. It is possible to define a custom (CSS-based) animation to use with AOS too, I might look into that in another article later on.
 
 using System.ComponentModel.DataAnnotations;

namespace BlazorAnimationSample.Components
{

    public enum AnimationNames
    {
        [Display(Name = "fade")]
        Fade = 0,

        [Display(Name = "fade-up")]
        FadeUp,

        [Display(Name = "fade-down")]
        FadeDown,

        [Display(Name = "fade-left")]
        FadeLeft,

        [Display(Name = "fade-right")]
        FadeRight,

        [Display(Name = "fade-up-right")]
        FadeUpRight,

        [Display(Name = "fade-up-left")]
        FadeUpLeft,

        [Display(Name = "fade-down-right")]
        FadeDownRight,

        [Display(Name = "fade-down-left")]
        FadeDownLeft,

        [Display(Name = "flip-up")]
        FlipUp,

        [Display(Name = "flip-down")]
        FlipDown,

        [Display(Name = "flip-left")]
        FlipLeft,

        [Display(Name = "flip-right")]
        FlipRight,

        [Display(Name = "slide-up")]
        SlideUp,

        [Display(Name = "slide-down")]
        SlideDown,

        [Display(Name = "slide-left")]
        SlideLeft,

        [Display(Name = "slide-right")]
        SlideRight,

        [Display(Name = "zoom-in")]
        ZoomIn,

        [Display(Name = "zoom-in-up")]
        ZoomInUp,

        [Display(Name = "zoom-in-down")]
        ZoomInDown,

        [Display(Name = "zoom-in-left")]
        ZoomInLeft,

        [Display(Name = "zoom-in-right")]
        ZoomInRight,

        [Display(Name = "zoom-out")]
        ZoomOut

    }

}
 


You can clone the Blazor Animation sample easily from Github by following Git command:

git clone https://github.com/toreaurstadboss/BlazorAnimateSample.git