Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions api/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# Storage
Storage__ThermalReferenceStorageAccount=saradevthermalref

# PostgreSQL server name (without .postgres.database.azure.com suffix)
Database__Server=robotics-dev-psql-server

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

# Thermal reference storage account
Storage__ThermalReferenceStorageAccount=saradevthermalref

46 changes: 46 additions & 0 deletions api/Controllers/ThermalReferenceMetadataController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace api.Controllers;
public class ThermalReferenceMetadataController(
ILogger<ThermalReferenceMetadataController> logger,
IThermalReferenceMetadataService thermalReferenceMetadataService,
IThermalImageService thermalImageService,
IConfiguration configuration
) : ControllerBase
{
Expand Down Expand Up @@ -164,6 +165,51 @@ public async Task<ActionResult> DeleteThermalReferenceMetadata([FromRoute] Guid
}
}

[HttpGet("id/{id}/image")]
[Authorize(Roles = Role.Any)]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetThermalReferenceImage([FromRoute] Guid id)
{
try
{
var metadata = await thermalReferenceMetadataService.ReadById(id);
if (metadata is null)
{
return NotFound($"Could not find thermal reference metadata with id {id}");
}

var result = await thermalImageService.GetThermalImageDataAsync(
metadata.ReferenceImageBlobStorageLocation
);

Response.Headers["X-Image-Width"] = result.Width.ToString();
Response.Headers["X-Image-Height"] = result.Height.ToString();
Response.Headers["X-Temperature-Min"] = result.MinTemperature.ToString(
"G9",
System.Globalization.CultureInfo.InvariantCulture
);
Response.Headers["X-Temperature-Max"] = result.MaxTemperature.ToString(
"G9",
System.Globalization.CultureInfo.InvariantCulture
);
Response.Headers.Append(
"Access-Control-Expose-Headers",
"X-Image-Width, X-Image-Height, X-Temperature-Min, X-Temperature-Max"
);

return File(result.FloatBytes, "application/octet-stream");
}
catch (Exception ex)
{
logger.LogError(ex, "Error generating thermal reference image for id {Id}", id);
return StatusCode(
StatusCodes.Status500InternalServerError,
"An error occurred while generating the thermal reference image"
);
}
}

private (
BlobStorageLocation imageLocation,
BlobStorageLocation polygonLocation
Expand Down
2 changes: 2 additions & 0 deletions api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
);

builder.Services.AddScoped<IThermalReferenceMetadataService, ThermalReferenceMetadataService>();
builder.Services.AddScoped<IBlobStorageService, BlobStorageService>();
builder.Services.AddScoped<IThermalImageService, ThermalImageService>();
builder.Services.AddScoped<IInspectionRecordService, InspectionRecordService>();
builder.Services.AddScoped<IAnalysisService, AnalysisService>();
builder.Services.AddScoped<IAnalysisGroupService, AnalysisGroupService>();
Expand Down
56 changes: 56 additions & 0 deletions api/Services/BlobStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using api.Database.Models;
using Azure.Core;
using Azure.Storage.Blobs;

namespace api.Services;

public interface IBlobStorageService
{
Task<MemoryStream> DownloadBlobAsync(BlobStorageLocation location);
}

public class BlobStorageService(TokenCredential credential, IConfiguration configuration)
: IBlobStorageService
{
public async Task<MemoryStream> DownloadBlobAsync(BlobStorageLocation location)
{
if (string.IsNullOrWhiteSpace(location.StorageAccount))
throw new InvalidOperationException("BlobStorageLocation.StorageAccount is empty.");

if (string.IsNullOrWhiteSpace(location.BlobContainer))
throw new InvalidOperationException(
$"BlobStorageLocation.BlobContainer is empty for storage account '{location.StorageAccount}'."
);

if (string.IsNullOrWhiteSpace(location.BlobName))
throw new InvalidOperationException(
$"BlobStorageLocation.BlobName is empty for storage account '{location.StorageAccount}/{location.BlobContainer}'."
);

var serviceClient = CreateBlobServiceClient(location.StorageAccount);

var containerClient = serviceClient.GetBlobContainerClient(location.BlobContainer);
var blobClient = containerClient.GetBlobClient(location.BlobName);

var stream = new MemoryStream();
await blobClient.DownloadToAsync(stream);
stream.Position = 0;
return stream;
}

private BlobServiceClient CreateBlobServiceClient(string accountName)
{
// Per-account connection string override (e.g. for Azurite in local dev).
// Config key: BlobStorage:{accountName}:ConnectionString
var connectionString = configuration[$"BlobStorage:{accountName}:ConnectionString"];
if (!string.IsNullOrEmpty(connectionString))
{
return new BlobServiceClient(connectionString);
}

return new BlobServiceClient(
new Uri($"https://{accountName}.blob.core.windows.net"),
credential
);
}
}
102 changes: 102 additions & 0 deletions api/Services/ThermalImageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Runtime.InteropServices;
using api.Database.Models;
using BitMiracle.LibTiff.Classic;

namespace api.Services;

public record ThermalImageData(
byte[] FloatBytes,
int Width,
int Height,
float MinTemperature,
float MaxTemperature
);

public interface IThermalImageService
{
Task<ThermalImageData> GetThermalImageDataAsync(BlobStorageLocation location);
}

public class ThermalImageService(IBlobStorageService blobStorageService) : IThermalImageService
{
public async Task<ThermalImageData> GetThermalImageDataAsync(BlobStorageLocation location)
{
using var tiffStream = await blobStorageService.DownloadBlobAsync(location);

var (temperatures, width, height) = ReadTiffTemperatures(tiffStream);

Comment on lines +22 to +27
float minTemp = float.MaxValue;
float maxTemp = float.MinValue;
for (int i = 0; i < temperatures.Length; i++)
{
float temp = temperatures[i];
if (temp < minTemp)
minTemp = temp;
if (temp > maxTemp)
maxTemp = temp;
}

// Serialize float[] to little-endian bytes.
var floatBytes = new byte[temperatures.Length * sizeof(float)];
MemoryMarshal.AsBytes(temperatures.AsSpan()).CopyTo(floatBytes);

Comment on lines +39 to +42
return new ThermalImageData(floatBytes, width, height, minTemp, maxTemp);
}

/// <summary>
/// Read pixel data from a TIFF stream as float temperatures.
/// Supports 32-bit float, 64-bit float, 16-bit unsigned, and 8-bit unsigned sample formats.
/// </summary>
private static (float[] temperatures, int width, int height) ReadTiffTemperatures(
Stream tiffStream
)
{
using var tiff = Tiff.ClientOpen("thermal", "r", tiffStream, new TiffStream());
if (tiff == null)
throw new InvalidOperationException("Failed to open TIFF stream.");

int width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt();
int height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt();

int bitsPerSample = 32;
var bpsField = tiff.GetField(TiffTag.BITSPERSAMPLE);
if (bpsField != null)
bitsPerSample = bpsField[0].ToInt();

int sampleFormat = (int)SampleFormat.UINT;
var sfField = tiff.GetField(TiffTag.SAMPLEFORMAT);
if (sfField != null)
sampleFormat = sfField[0].ToInt();

var temperatures = new float[width * height];
int scanlineSize = tiff.ScanlineSize();
byte[] buffer = new byte[scanlineSize];

for (int y = 0; y < height; y++)
{
tiff.ReadScanline(buffer, y);

for (int x = 0; x < width; x++)
{
float value = (bitsPerSample, sampleFormat) switch
{
(32, (int)SampleFormat.IEEEFP) => BitConverter.ToSingle(buffer, x * 4),
(64, (int)SampleFormat.IEEEFP) => (float)BitConverter.ToDouble(buffer, x * 8),
(16, (int)SampleFormat.UINT or (int)SampleFormat.VOID) => BitConverter.ToUInt16(
buffer,
x * 2
),
(16, (int)SampleFormat.INT) => BitConverter.ToInt16(buffer, x * 2),
(8, _) => buffer[x],
_ => throw new NotSupportedException(
$"Unsupported TIFF pixel format: {bitsPerSample} bits/sample, sample format {sampleFormat}."
),
};

temperatures[y * width + x] = value;
}
}

return (temperatures, width, height);
}
}
1 change: 1 addition & 0 deletions api/api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="BitMiracle.LibTiff.NET" Version="2.4.660" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="3.1.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
Expand Down
4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@
"@equinor/eds-core-react": "^2.5.0",
"@equinor/eds-icons": "^1.4.0",
"@equinor/eds-tokens": "^2.2.0",
"d3-scale-chromatic": "^3.1.0",
"konva": "^10.3.0",
Comment on lines 16 to +18
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-konva": "^19.2.4",
"react-router": "^7.14.0",
"styled-components": "^6.4.0"
},
"devDependencies": {
"@types/d3-scale-chromatic": "^3.1.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36",
Expand Down
Loading
Loading