Skip to content

Commit d328733

Browse files
committed
Add viewing thermal reference images
1 parent 5c4f5d0 commit d328733

12 files changed

Lines changed: 633 additions & 4 deletions

File tree

api/.env.example

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# Storage
2-
Storage__ThermalReferenceStorageAccount=saradevthermalref
3-
41
# PostgreSQL server name (without .postgres.database.azure.com suffix)
52
Database__Server=robotics-dev-psql-server
63

@@ -12,4 +9,3 @@ Database__User=sara-dev
129

1310
# Thermal reference storage account
1411
Storage__ThermalReferenceStorageAccount=saradevthermalref
15-

api/Controllers/ThermalReferenceMetadataController.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace api.Controllers;
1111
public class ThermalReferenceMetadataController(
1212
ILogger<ThermalReferenceMetadataController> logger,
1313
IThermalReferenceMetadataService thermalReferenceMetadataService,
14+
IThermalImageService thermalImageService,
1415
IConfiguration configuration
1516
) : ControllerBase
1617
{
@@ -164,6 +165,51 @@ public async Task<ActionResult> DeleteThermalReferenceMetadata([FromRoute] Guid
164165
}
165166
}
166167

168+
[HttpGet("id/{id}/image")]
169+
[Authorize(Roles = Role.Any)]
170+
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
171+
[ProducesResponseType(StatusCodes.Status404NotFound)]
172+
public async Task<ActionResult> GetThermalReferenceImage([FromRoute] Guid id)
173+
{
174+
try
175+
{
176+
var metadata = await thermalReferenceMetadataService.ReadById(id);
177+
if (metadata is null)
178+
{
179+
return NotFound($"Could not find thermal reference metadata with id {id}");
180+
}
181+
182+
var result = await thermalImageService.GetThermalImageDataAsync(
183+
metadata.ReferenceImageBlobStorageLocation
184+
);
185+
186+
Response.Headers["X-Image-Width"] = result.Width.ToString();
187+
Response.Headers["X-Image-Height"] = result.Height.ToString();
188+
Response.Headers["X-Temperature-Min"] = result.MinTemperature.ToString(
189+
"G9",
190+
System.Globalization.CultureInfo.InvariantCulture
191+
);
192+
Response.Headers["X-Temperature-Max"] = result.MaxTemperature.ToString(
193+
"G9",
194+
System.Globalization.CultureInfo.InvariantCulture
195+
);
196+
Response.Headers.Append(
197+
"Access-Control-Expose-Headers",
198+
"X-Image-Width, X-Image-Height, X-Temperature-Min, X-Temperature-Max"
199+
);
200+
201+
return File(result.FloatBytes, "application/octet-stream");
202+
}
203+
catch (Exception ex)
204+
{
205+
logger.LogError(ex, "Error generating thermal reference image for id {Id}", id);
206+
return StatusCode(
207+
StatusCodes.Status500InternalServerError,
208+
"An error occurred while generating the thermal reference image"
209+
);
210+
}
211+
}
212+
167213
private (
168214
BlobStorageLocation imageLocation,
169215
BlobStorageLocation polygonLocation

api/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
);
7070

7171
builder.Services.AddScoped<IThermalReferenceMetadataService, ThermalReferenceMetadataService>();
72+
builder.Services.AddScoped<IBlobStorageService, BlobStorageService>();
73+
builder.Services.AddScoped<IThermalImageService, ThermalImageService>();
7274
builder.Services.AddScoped<IInspectionRecordService, InspectionRecordService>();
7375
builder.Services.AddScoped<IAnalysisService, AnalysisService>();
7476
builder.Services.AddScoped<IAnalysisGroupService, AnalysisGroupService>();

api/Services/BlobStorageService.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using api.Database.Models;
2+
using Azure.Core;
3+
using Azure.Storage.Blobs;
4+
5+
namespace api.Services;
6+
7+
public interface IBlobStorageService
8+
{
9+
Task<MemoryStream> DownloadBlobAsync(BlobStorageLocation location);
10+
}
11+
12+
public class BlobStorageService(TokenCredential credential, IConfiguration configuration)
13+
: IBlobStorageService
14+
{
15+
public async Task<MemoryStream> DownloadBlobAsync(BlobStorageLocation location)
16+
{
17+
if (string.IsNullOrWhiteSpace(location.StorageAccount))
18+
throw new InvalidOperationException("BlobStorageLocation.StorageAccount is empty.");
19+
20+
if (string.IsNullOrWhiteSpace(location.BlobContainer))
21+
throw new InvalidOperationException(
22+
$"BlobStorageLocation.BlobContainer is empty for storage account '{location.StorageAccount}'."
23+
);
24+
25+
if (string.IsNullOrWhiteSpace(location.BlobName))
26+
throw new InvalidOperationException(
27+
$"BlobStorageLocation.BlobName is empty for storage account '{location.StorageAccount}/{location.BlobContainer}'."
28+
);
29+
30+
var serviceClient = CreateBlobServiceClient(location.StorageAccount);
31+
32+
var containerClient = serviceClient.GetBlobContainerClient(location.BlobContainer);
33+
var blobClient = containerClient.GetBlobClient(location.BlobName);
34+
35+
var stream = new MemoryStream();
36+
await blobClient.DownloadToAsync(stream);
37+
stream.Position = 0;
38+
return stream;
39+
}
40+
41+
private BlobServiceClient CreateBlobServiceClient(string accountName)
42+
{
43+
// Per-account connection string override (e.g. for Azurite in local dev).
44+
// Config key: BlobStorage:{accountName}:ConnectionString
45+
var connectionString = configuration[$"BlobStorage:{accountName}:ConnectionString"];
46+
if (!string.IsNullOrEmpty(connectionString))
47+
{
48+
return new BlobServiceClient(connectionString);
49+
}
50+
51+
return new BlobServiceClient(
52+
new Uri($"https://{accountName}.blob.core.windows.net"),
53+
credential
54+
);
55+
}
56+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Runtime.InteropServices;
2+
using api.Database.Models;
3+
using BitMiracle.LibTiff.Classic;
4+
5+
namespace api.Services;
6+
7+
public record ThermalImageData(
8+
byte[] FloatBytes,
9+
int Width,
10+
int Height,
11+
float MinTemperature,
12+
float MaxTemperature
13+
);
14+
15+
public interface IThermalImageService
16+
{
17+
Task<ThermalImageData> GetThermalImageDataAsync(BlobStorageLocation location);
18+
}
19+
20+
public class ThermalImageService(IBlobStorageService blobStorageService) : IThermalImageService
21+
{
22+
public async Task<ThermalImageData> GetThermalImageDataAsync(BlobStorageLocation location)
23+
{
24+
using var tiffStream = await blobStorageService.DownloadBlobAsync(location);
25+
26+
var (temperatures, width, height) = ReadTiffTemperatures(tiffStream);
27+
28+
float minTemp = float.MaxValue;
29+
float maxTemp = float.MinValue;
30+
for (int i = 0; i < temperatures.Length; i++)
31+
{
32+
float temp = temperatures[i];
33+
if (temp < minTemp)
34+
minTemp = temp;
35+
if (temp > maxTemp)
36+
maxTemp = temp;
37+
}
38+
39+
// Serialize float[] to little-endian bytes.
40+
var floatBytes = new byte[temperatures.Length * sizeof(float)];
41+
MemoryMarshal.AsBytes(temperatures.AsSpan()).CopyTo(floatBytes);
42+
43+
return new ThermalImageData(floatBytes, width, height, minTemp, maxTemp);
44+
}
45+
46+
/// <summary>
47+
/// Read pixel data from a TIFF stream as float temperatures.
48+
/// Supports 32-bit float, 64-bit float, 16-bit unsigned, and 8-bit unsigned sample formats.
49+
/// </summary>
50+
private static (float[] temperatures, int width, int height) ReadTiffTemperatures(
51+
Stream tiffStream
52+
)
53+
{
54+
using var tiff = Tiff.ClientOpen("thermal", "r", tiffStream, new TiffStream());
55+
if (tiff == null)
56+
throw new InvalidOperationException("Failed to open TIFF stream.");
57+
58+
int width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt();
59+
int height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt();
60+
61+
int bitsPerSample = 32;
62+
var bpsField = tiff.GetField(TiffTag.BITSPERSAMPLE);
63+
if (bpsField != null)
64+
bitsPerSample = bpsField[0].ToInt();
65+
66+
int sampleFormat = (int)SampleFormat.UINT;
67+
var sfField = tiff.GetField(TiffTag.SAMPLEFORMAT);
68+
if (sfField != null)
69+
sampleFormat = sfField[0].ToInt();
70+
71+
var temperatures = new float[width * height];
72+
int scanlineSize = tiff.ScanlineSize();
73+
byte[] buffer = new byte[scanlineSize];
74+
75+
for (int y = 0; y < height; y++)
76+
{
77+
tiff.ReadScanline(buffer, y);
78+
79+
for (int x = 0; x < width; x++)
80+
{
81+
float value = (bitsPerSample, sampleFormat) switch
82+
{
83+
(32, (int)SampleFormat.IEEEFP) => BitConverter.ToSingle(buffer, x * 4),
84+
(64, (int)SampleFormat.IEEEFP) => (float)BitConverter.ToDouble(buffer, x * 8),
85+
(16, (int)SampleFormat.UINT or (int)SampleFormat.VOID) => BitConverter.ToUInt16(
86+
buffer,
87+
x * 2
88+
),
89+
(16, (int)SampleFormat.INT) => BitConverter.ToInt16(buffer, x * 2),
90+
(8, _) => buffer[x],
91+
_ => throw new NotSupportedException(
92+
$"Unsupported TIFF pixel format: {bitsPerSample} bits/sample, sample format {sampleFormat}."
93+
),
94+
};
95+
96+
temperatures[y * width + x] = value;
97+
}
98+
}
99+
100+
return (temperatures, width, height);
101+
}
102+
}

api/api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<ItemGroup>
1818
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
19+
<PackageReference Include="BitMiracle.LibTiff.NET" Version="2.4.660" />
1920
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="3.1.0" />
2021
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
2122
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />

frontend/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
"@equinor/eds-core-react": "^2.5.0",
1515
"@equinor/eds-icons": "^1.4.0",
1616
"@equinor/eds-tokens": "^2.2.0",
17+
"d3-scale-chromatic": "^3.1.0",
18+
"konva": "^10.3.0",
1719
"react": "^19.2.5",
1820
"react-dom": "^19.2.5",
21+
"react-konva": "^19.2.4",
1922
"react-router": "^7.14.0",
2023
"styled-components": "^6.4.0"
2124
},
2225
"devDependencies": {
26+
"@types/d3-scale-chromatic": "^3.1.0",
2327
"@types/react": "^19.2.14",
2428
"@types/react-dom": "^19.2.3",
2529
"@types/styled-components": "^5.1.36",

0 commit comments

Comments
 (0)