Skip to content

Commit 9a0d66b

Browse files
Add AzureComputerVisionService (#261)
## Purpose This PR adds AzureComputerVisionService, which will be used to create image embedding and generate image embedding from text query. ## Does this introduce a breaking change? <!-- Mark one with an "x". --> ``` [ ] Yes [x] No ``` ## Pull Request Type What kind of change does this Pull Request introduce? #257 <!-- Please check the one that applies to this PR using "x". --> ``` [ ] Bugfix [x] Feature [ ] Code style update (formatting, local variables) [ ] Refactoring (no functional changes, no api changes) [ ] Documentation content changes [ ] Other... Please describe: ``` ## How to Test * Get the code ``` git clone [repo-address] cd [repo-name] git checkout [branch-name] npm install ``` * Test the code <!-- Add steps to run the tests suite and/or manually test --> ``` ``` ## What to Check Verify that the following are valid * ... ## Other Information <!-- Add any other helpful information that may be needed here. --> --------- Co-authored-by: David Pine <[email protected]>
1 parent 6522e97 commit 9a0d66b

File tree

9 files changed

+243
-0
lines changed

9 files changed

+243
-0
lines changed

app/Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@
4343
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
4444
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.4" />
4545
<PackageVersion Include="xunit" Version="2.6.2" />
46+
<PackageVersion Include="FluentAssertions" Version="6.8.0" />
47+
<PackageVersion Include="NSubstitute" Version="5.1.0" />
4648
</ItemGroup>
4749
</Project>

app/app.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrepareDocs", "prepdocs\Pre
2727
EndProject
2828
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EmbedFunctions", "functions\EmbedFunctions\EmbedFunctions.csproj", "{54099AF3-CFA8-4EA9-9118-A26E3E63745B}"
2929
EndProject
30+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalApi.Tests", "tests\MinimalApi.Tests\MinimalApi.Tests.csproj", "{C687997A-A569-47AA-A808-504F7DFCA97B}"
31+
EndProject
3032
Global
3133
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3234
Debug|Any CPU = Debug|Any CPU
@@ -57,6 +59,10 @@ Global
5759
{54099AF3-CFA8-4EA9-9118-A26E3E63745B}.Debug|Any CPU.Build.0 = Debug|Any CPU
5860
{54099AF3-CFA8-4EA9-9118-A26E3E63745B}.Release|Any CPU.ActiveCfg = Release|Any CPU
5961
{54099AF3-CFA8-4EA9-9118-A26E3E63745B}.Release|Any CPU.Build.0 = Release|Any CPU
62+
{C687997A-A569-47AA-A808-504F7DFCA97B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
63+
{C687997A-A569-47AA-A808-504F7DFCA97B}.Debug|Any CPU.Build.0 = Debug|Any CPU
64+
{C687997A-A569-47AA-A808-504F7DFCA97B}.Release|Any CPU.ActiveCfg = Release|Any CPU
65+
{C687997A-A569-47AA-A808-504F7DFCA97B}.Release|Any CPU.Build.0 = Release|Any CPU
6066
EndGlobalSection
6167
GlobalSection(SolutionProperties) = preSolution
6268
HideSolutionNode = FALSE

app/backend/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
builder.Services.AddCrossOriginResourceSharing();
1616
builder.Services.AddAzureServices();
1717
builder.Services.AddAntiforgery(options => { options.HeaderName = "X-CSRF-TOKEN-HEADER"; options.FormFieldName = "X-CSRF-TOKEN-FORM"; });
18+
builder.Services.AddHttpClient();
1819

1920
if (builder.Environment.IsDevelopment())
2021
{
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Net.Http.Headers;
4+
using System.Text;
5+
6+
namespace MinimalApi.Services;
7+
8+
public class AzureComputerVisionService(IHttpClientFactory httpClientFactory, string endPoint, string apiKey)
9+
{
10+
// add virtual keyword to make it mockable
11+
public virtual async Task<ImageEmbeddingResponse> VectorizeImageAsync(string imagePathOrUrl, CancellationToken ct = default)
12+
{
13+
var api = $"{endPoint}/computervision/retrieval:vectorizeImage?api-version=2023-02-01-preview&modelVersion=latest";
14+
// first try to read as local file
15+
if (File.Exists(imagePathOrUrl))
16+
{
17+
using var request = new HttpRequestMessage(HttpMethod.Post, api);
18+
19+
// set authorization header
20+
request.Headers.Add("Ocp-Apim-Subscription-Key", apiKey);
21+
22+
// set body
23+
var bytes = await File.ReadAllBytesAsync(imagePathOrUrl, ct);
24+
request.Content = new ByteArrayContent(bytes);
25+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("image/*");
26+
27+
// send request
28+
using var client = httpClientFactory.CreateClient();
29+
using var response = await client.SendAsync(request, ct);
30+
response.EnsureSuccessStatusCode();
31+
32+
// read response
33+
var json = await response.Content.ReadAsStringAsync();
34+
var result = JsonSerializer.Deserialize<ImageEmbeddingResponse>(json);
35+
36+
return result ?? throw new InvalidOperationException("Failed to deserialize response");
37+
}
38+
else
39+
{
40+
// retrieve as url
41+
using var request = new HttpRequestMessage(HttpMethod.Post, api);
42+
43+
// set content type to application/json
44+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
45+
46+
// set authorization header
47+
request.Headers.Add("Ocp-Apim-Subscription-Key", apiKey);
48+
49+
// set body
50+
var body = new { url = imagePathOrUrl };
51+
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
52+
53+
// send request
54+
using var client = httpClientFactory.CreateClient();
55+
using var response = await client.SendAsync(request, ct);
56+
response.EnsureSuccessStatusCode();
57+
58+
// read response
59+
var json = await response.Content.ReadAsStringAsync();
60+
var result = JsonSerializer.Deserialize<ImageEmbeddingResponse>(json);
61+
62+
return result ?? throw new InvalidOperationException("Failed to deserialize response");
63+
}
64+
}
65+
66+
public virtual async Task<ImageEmbeddingResponse> VectorizeTextAsync(string text, CancellationToken ct = default)
67+
{
68+
var api = $"{endPoint}/computervision/retrieval:vectorizeText?api-version=2023-02-01-preview&modelVersion=latest";
69+
70+
using var request = new HttpRequestMessage(HttpMethod.Post, api);
71+
72+
// set content type to application/json
73+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
74+
75+
// set authorization header
76+
request.Headers.Add("Ocp-Apim-Subscription-Key", apiKey);
77+
78+
// set body
79+
var body = new { text };
80+
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
81+
82+
// send request
83+
using var client = new HttpClient();
84+
using var response = await client.SendAsync(request, ct);
85+
response.EnsureSuccessStatusCode();
86+
87+
// read response
88+
var json = await response.Content.ReadAsStringAsync();
89+
var result = JsonSerializer.Deserialize<ImageEmbeddingResponse>(json);
90+
return result ?? throw new InvalidOperationException("Failed to deserialize response");
91+
}
92+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
public record ImageEmbeddingResponse(string modelVersion, float[] vector);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Xunit;
4+
5+
/// <summary>
6+
/// A base class for environment-specific fact attributes.
7+
/// </summary>
8+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
9+
public abstract class EnvironmentSpecificFactAttribute : FactAttribute
10+
{
11+
private readonly string _skipMessage;
12+
13+
/// <summary>
14+
/// Creates a new instance of the <see cref="EnvironmentSpecificFactAttribute" /> class.
15+
/// </summary>
16+
/// <param name="skipMessage">The message to be used when skipping the test marked with this attribute.</param>
17+
protected EnvironmentSpecificFactAttribute(string skipMessage)
18+
{
19+
ArgumentException.ThrowIfNullOrEmpty(skipMessage);
20+
21+
_skipMessage = skipMessage;
22+
}
23+
24+
public sealed override string Skip => IsEnvironmentSupported() ? null! : _skipMessage;
25+
26+
/// <summary>
27+
/// A method used to evaluate whether to skip a test marked with this attribute. Skips iff this method evaluates to false.
28+
/// </summary>
29+
protected abstract bool IsEnvironmentSupported();
30+
}
31+
32+
public sealed class ApiKeyFactAttribute : EnvironmentSpecificFactAttribute
33+
{
34+
private readonly string[] _envVariableNames;
35+
36+
public ApiKeyFactAttribute(params string[] envVariableNames) : base($"{string.Join(", ", envVariableNames)} is not found in env")
37+
{
38+
_envVariableNames = envVariableNames;
39+
}
40+
41+
/// <inheritdoc />
42+
protected override bool IsEnvironmentSupported()
43+
{
44+
return _envVariableNames.Any(Environment.GetEnvironmentVariables().Contains);
45+
}
46+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using FluentAssertions;
4+
using MinimalApi.Services;
5+
using NSubstitute;
6+
7+
namespace MinimalApi.Tests;
8+
9+
public class AzureComputerVisionServiceTest
10+
{
11+
[ApiKeyFact("AZURE_COMPUTER_VISION_API_KEY", "AZURE_COMPUTER_VISION_ENDPOINT")]
12+
public async Task VectorizeImageTestAsync()
13+
{
14+
var endpoint = Environment.GetEnvironmentVariable("AZURE_COMPUTER_VISION_ENDPOINT") ?? throw new InvalidOperationException();
15+
var apiKey = Environment.GetEnvironmentVariable("AZURE_COMPUTER_VISION_API_KEY") ?? throw new InvalidOperationException();
16+
var httpClientFactory = Substitute.For<IHttpClientFactory>();
17+
httpClientFactory.CreateClient().ReturnsForAnyArgs(x => new HttpClient());
18+
var service = new AzureComputerVisionService(httpClientFactory, endpoint, apiKey);
19+
var imageUrl = @"https://learn.microsoft.com/azure/ai-services/computer-vision/media/quickstarts/presentation.png";
20+
21+
var result = await service.VectorizeImageAsync(imageUrl);
22+
23+
result.modelVersion.Should().NotBeNullOrEmpty();
24+
25+
// download image to local file, and verify the api on local image.
26+
var tempFile = Path.GetTempFileName();
27+
tempFile = Path.ChangeExtension(tempFile, ".png");
28+
try
29+
{
30+
using var client = new HttpClient();
31+
using var stream = await client.GetStreamAsync(imageUrl);
32+
using var fileStream = File.OpenWrite(tempFile);
33+
await stream.CopyToAsync(fileStream);
34+
fileStream.Flush();
35+
fileStream.Close();
36+
37+
var localResult = await service.VectorizeImageAsync(tempFile);
38+
39+
localResult.modelVersion.Should().NotBeNullOrEmpty();
40+
localResult.vector.Should().BeEquivalentTo(result.vector);
41+
}
42+
finally
43+
{
44+
File.Delete(tempFile);
45+
}
46+
}
47+
48+
[ApiKeyFact("AZURE_COMPUTER_VISION_API_KEY", "AZURE_COMPUTER_VISION_ENDPOINT")]
49+
public async Task VectorizeTextTestAsync()
50+
{
51+
var endpoint = Environment.GetEnvironmentVariable("AZURE_COMPUTER_VISION_ENDPOINT") ?? throw new InvalidOperationException();
52+
var apiKey = Environment.GetEnvironmentVariable("AZURE_COMPUTER_VISION_API_KEY") ?? throw new InvalidOperationException();
53+
var httpClientFactory = Substitute.For<IHttpClientFactory>();
54+
httpClientFactory.CreateClient().ReturnsForAnyArgs(x => new HttpClient());
55+
var service = new AzureComputerVisionService(httpClientFactory, endpoint, apiKey);
56+
var text = "Hello world";
57+
var result = await service.VectorizeTextAsync(text);
58+
59+
result.modelVersion.Should().NotBeNullOrEmpty();
60+
result.vector.Length.Should().Be(1024);
61+
}
62+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<LangVersion>preview</LangVersion>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
13+
<PackageReference Include="xunit" />
14+
<PackageReference Include="xunit.runner.visualstudio">
15+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
16+
<PrivateAssets>all</PrivateAssets>
17+
</PackageReference>
18+
<PackageReference Include="coverlet.collector">
19+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
20+
<PrivateAssets>all</PrivateAssets>
21+
</PackageReference>
22+
<PackageReference Include="FluentAssertions" />
23+
<PackageReference Include="NSubstitute" />
24+
</ItemGroup>
25+
26+
<ItemGroup>
27+
<ProjectReference Include="..\..\backend\MinimalApi.csproj" />
28+
</ItemGroup>
29+
30+
</Project>

0 commit comments

Comments
 (0)