Refactor annotation tool from WPF desktop app to .NET API

Replace the WPF desktop application (Azaion.Suite, Azaion.Annotator,
Azaion.Common, Azaion.Inference, Azaion.Loader, Azaion.LoaderUI,
Azaion.Dataset, Azaion.Test) with a standalone .NET Web API in src/.

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-25 04:40:03 +02:00
parent e7ea5a8ded
commit 9e7dc290db
367 changed files with 8840 additions and 16583 deletions
+32
View File
@@ -0,0 +1,32 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Annotations.Auth;
public static class JwtExtensions
{
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
services.AddAuthorizationBuilder()
.AddPolicy("ANN", p => p.RequireClaim("permissions", "ANN"))
.AddPolicy("DATASET", p => p.RequireClaim("permissions", "DATASET"))
.AddPolicy("ADM", p => p.RequireClaim("permissions", "ADM"));
return services;
}
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="linq2db" Version="6.2.0" />
<PackageReference Include="MessagePack" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="RabbitMQ.Stream.Client" Version="1.8.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="System.IO.Hashing" Version="10.0.5" />
</ItemGroup>
</Project>
+90
View File
@@ -0,0 +1,90 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Services;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("annotations")]
[Authorize(Policy = "ANN")]
public class AnnotationsController(AnnotationService annotationService, AnnotationEventService eventService, PathResolver pathResolver) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateAnnotationRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var annotation = await annotationService.CreateAnnotation(request, userId);
return Created($"/annotations/{annotation.Id}", new { annotation.Id });
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] UpdateAnnotationRequest request)
{
await annotationService.UpdateAnnotation(id, request);
return NoContent();
}
[HttpPatch("{id}/status")]
public async Task<IActionResult> UpdateStatus(string id, [FromBody] UpdateStatusRequest request)
{
await annotationService.UpdateStatus(id, request.Status);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
await annotationService.DeleteAnnotation(id);
return NoContent();
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] GetAnnotationsQuery query)
{
var result = await annotationService.GetAnnotations(query);
return Ok(result);
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var annotation = await annotationService.GetAnnotation(id);
return Ok(annotation);
}
[HttpGet("{id}/thumbnail")]
public async Task<IActionResult> GetThumbnail(string id)
{
var path = await pathResolver.GetThumbnailPath(id);
if (!System.IO.File.Exists(path))
return NotFound();
return PhysicalFile(path, "image/jpeg");
}
[HttpGet("{id}/image")]
public async Task<IActionResult> GetImage(string id)
{
var path = await pathResolver.GetImagePath(id);
if (!System.IO.File.Exists(path))
return NotFound();
return PhysicalFile(path, "image/jpeg");
}
[HttpGet("events")]
public async Task Events(CancellationToken ct)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
Response.Headers.Connection = "keep-alive";
await foreach (var evt in eventService.Reader.ReadAllAsync(ct))
{
var data = JsonSerializer.Serialize(evt);
await Response.WriteAsync($"data: {data}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.Services;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("auth")]
public class AuthController(TokenService tokenService) : ControllerBase
{
[HttpPost("refresh")]
[AllowAnonymous]
public IActionResult Refresh([FromBody] RefreshRequest request)
{
var newToken = tokenService.RefreshAccessToken(request.RefreshToken);
if (newToken == null)
return Unauthorized(new { message = "Invalid or expired refresh token" });
return Ok(new { Token = newToken });
}
}
public record RefreshRequest(string RefreshToken);
+19
View File
@@ -0,0 +1,19 @@
using LinqToDB;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.Database;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("classes")]
[Authorize]
public class ClassesController(AppDataConnection db) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var classes = await db.DetectionClasses.ToListAsync();
return Ok(classes);
}
}
+47
View File
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Services;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("dataset")]
[Authorize(Policy = "DATASET")]
public class DatasetController(DatasetService datasetService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] GetDatasetQuery query)
{
var result = await datasetService.GetDataset(query);
return Ok(result);
}
[HttpGet("{annotationId}")]
public async Task<IActionResult> Get(string annotationId)
{
var annotation = await datasetService.GetAnnotationDetail(annotationId);
return Ok(annotation);
}
[HttpPatch("{annotationId}/status")]
public async Task<IActionResult> UpdateStatus(string annotationId, [FromBody] UpdateStatusRequest request)
{
await datasetService.UpdateStatus(annotationId, request.Status);
return NoContent();
}
[HttpPost("bulk-status")]
public async Task<IActionResult> BulkUpdateStatus([FromBody] BulkStatusRequest request)
{
await datasetService.BulkUpdateStatus(request);
return NoContent();
}
[HttpGet("class-distribution")]
public async Task<IActionResult> GetClassDistribution()
{
var result = await datasetService.GetClassDistribution();
return Ok(result);
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Services;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("media")]
[Authorize(Policy = "ANN")]
public class MediaController(MediaService mediaService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateMediaRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var media = await mediaService.CreateMedia(request, userId);
return Created($"/media/{media.Id}", new { media.Id });
}
[HttpPost("batch")]
public async Task<IActionResult> CreateBatch(
[FromForm] Guid waypointId,
IFormFileCollection files)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var mediaList = await mediaService.CreateMediaBatch(waypointId, files, userId);
return Created("", mediaList.Select(m => new { m.Id }));
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] GetMediaQuery query)
{
var result = await mediaService.GetMedia(query);
return Ok(result);
}
[HttpGet("{id}/file")]
public async Task<IActionResult> GetFile(string id)
{
var media = await mediaService.GetMediaById(id);
if (media == null || !System.IO.File.Exists(media.Path))
return NotFound();
var contentType = media.MediaType switch
{
Enums.MediaType.Video => "video/mp4",
_ => "image/jpeg"
};
return PhysicalFile(media.Path, contentType, enableRangeProcessing: true);
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
await mediaService.DeleteMedia(id);
return NoContent();
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Services;
namespace Azaion.Annotations.Controllers;
[ApiController]
[Route("settings")]
[Authorize]
public class SettingsController(SettingsService settingsService) : ControllerBase
{
[HttpGet("system")]
public async Task<IActionResult> GetSystem()
{
var settings = await settingsService.GetSystemSettings();
return Ok(settings);
}
[HttpPut("system")]
[Authorize(Policy = "ADM")]
public async Task<IActionResult> UpdateSystem([FromBody] UpdateSystemSettingsRequest request)
{
await settingsService.UpdateSystemSettings(request);
return NoContent();
}
[HttpGet("directories")]
public async Task<IActionResult> GetDirectories()
{
var settings = await settingsService.GetDirectorySettings();
return Ok(settings);
}
[HttpPut("directories")]
[Authorize(Policy = "ADM")]
public async Task<IActionResult> UpdateDirectories([FromBody] UpdateDirectoriesRequest request)
{
await settingsService.UpdateDirectorySettings(request);
return NoContent();
}
[HttpGet("camera")]
public async Task<IActionResult> GetCamera()
{
var settings = await settingsService.GetCameraSettings();
return Ok(settings);
}
[HttpPut("camera")]
public async Task<IActionResult> UpdateCamera([FromBody] UpdateCameraSettingsRequest request)
{
await settingsService.UpdateCameraSettings(request);
return NoContent();
}
[HttpGet("user")]
public async Task<IActionResult> GetUserSettings()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var settings = await settingsService.GetUserSettings(userId);
return Ok(settings);
}
[HttpPut("user")]
public async Task<IActionResult> UpdateUserSettings([FromBody] UpdateUserSettingsRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await settingsService.UpdateUserSettings(userId, request);
return NoContent();
}
}
+13
View File
@@ -0,0 +1,13 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class AnnotationEventDto
{
public string AnnotationId { get; set; } = string.Empty;
public string MediaId { get; set; } = string.Empty;
public AnnotationStatus Status { get; set; }
public AnnotationSource Source { get; set; }
public List<DetectionDto> Detections { get; set; } = [];
public DateTime CreatedDate { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class AnnotationListItem
{
public string Id { get; set; } = string.Empty;
public string MediaId { get; set; } = string.Empty;
public string? Time { get; set; }
public DateTime CreatedDate { get; set; }
public Guid UserId { get; set; }
public AnnotationSource Source { get; set; }
public AnnotationStatus Status { get; set; }
public bool IsSplit { get; set; }
public string? SplitTile { get; set; }
public List<DetectionListDto> Detections { get; set; } = [];
}
public class DetectionListDto
{
public Guid Id { get; set; }
public int ClassNum { get; set; }
public string Label { get; set; } = string.Empty;
public float Confidence { get; set; }
public AffiliationEnum Affiliation { get; set; }
public CombatReadiness CombatReadiness { get; set; }
public float CenterX { get; set; }
public float CenterY { get; set; }
public float Width { get; set; }
public float Height { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class BulkStatusRequest
{
public List<string> AnnotationIds { get; set; } = [];
public AnnotationStatus Status { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
namespace Azaion.Annotations.DTOs;
public class ClassDistributionItem
{
public int ClassNum { get; set; }
public string Label { get; set; } = string.Empty;
public string Color { get; set; } = string.Empty;
public int Count { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class CreateAnnotationRequest
{
public byte[]? Image { get; set; }
public string? MediaId { get; set; }
public Guid? WaypointId { get; set; }
public AnnotationSource Source { get; set; }
public List<DetectionDto> Detections { get; set; } = [];
public TimeSpan VideoTime { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class CreateMediaRequest
{
public string Path { get; set; } = string.Empty;
public byte[]? Data { get; set; }
public string Name { get; set; } = string.Empty;
public MediaType MediaType { get; set; }
public Guid? WaypointId { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class DatasetItem
{
public string AnnotationId { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public string ThumbnailPath { get; set; } = string.Empty;
public AnnotationStatus Status { get; set; }
public DateTime CreatedDate { get; set; }
public string? CreatedEmail { get; set; }
public string? FlightName { get; set; }
public AnnotationSource Source { get; set; }
public bool IsSeed { get; set; }
public bool IsSplit { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class DetectionDto
{
public float CenterX { get; set; }
public float CenterY { get; set; }
public float Width { get; set; }
public float Height { get; set; }
public int ClassNum { get; set; }
public string Label { get; set; } = string.Empty;
public string? Description { get; set; }
public float Confidence { get; set; }
public AffiliationEnum Affiliation { get; set; }
public CombatReadiness CombatReadiness { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Annotations.DTOs;
public class ErrorResponse
{
public int StatusCode { get; set; }
public string Message { get; set; } = string.Empty;
public List<string>? Errors { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
namespace Azaion.Annotations.DTOs;
public class GetAnnotationsQuery
{
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public Guid? UserId { get; set; }
public string? Name { get; set; }
public string? MediaId { get; set; }
public Guid? FlightId { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
+16
View File
@@ -0,0 +1,16 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class GetDatasetQuery
{
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public Guid? FlightId { get; set; }
public AnnotationStatus? Status { get; set; }
public int? ClassNum { get; set; }
public bool? HasDetections { get; set; }
public string? Name { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
+10
View File
@@ -0,0 +1,10 @@
namespace Azaion.Annotations.DTOs;
public class GetMediaQuery
{
public Guid? FlightId { get; set; }
public string? Name { get; set; }
public string? Path { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
+16
View File
@@ -0,0 +1,16 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class MediaListItem
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public MediaType MediaType { get; set; }
public MediaStatus MediaStatus { get; set; }
public string? Duration { get; set; }
public int AnnotationCount { get; set; }
public Guid? WaypointId { get; set; }
public Guid UserId { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
namespace Azaion.Annotations.DTOs;
public class PaginatedResponse<T>
{
public List<T> Items { get; set; } = [];
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
using MessagePack;
namespace Azaion.Annotations.DTOs;
[MessagePackObject]
public class AnnotationQueueMessage
{
[Key(0)] public DateTime CreatedDate { get; set; }
[Key(1)] public string Name { get; set; } = null!;
[Key(11)] public string MediaHash { get; set; } = null!;
[Key(2)] public string OriginalMediaName { get; set; } = null!;
[Key(3)] public TimeSpan Time { get; set; }
[Key(4)] public string ImageExtension { get; set; } = null!;
[Key(5)] public string Detections { get; set; } = null!;
[Key(6)] public byte[]? Image { get; set; }
[Key(7)] public string Email { get; set; } = null!;
[Key(8)] public int Source { get; set; }
[Key(9)] public int Status { get; set; }
}
[MessagePackObject]
public class AnnotationBulkQueueMessage
{
[Key(0)] public string[] AnnotationIds { get; set; } = null!;
[Key(1)] public int Operation { get; set; }
[Key(2)] public string Email { get; set; } = null!;
[Key(3)] public DateTime CreatedDate { get; set; }
}
+6
View File
@@ -0,0 +1,6 @@
namespace Azaion.Annotations.DTOs;
public class UpdateAnnotationRequest
{
public List<DetectionDto> Detections { get; set; } = [];
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Annotations.DTOs;
public class UpdateCameraSettingsRequest
{
public decimal? Altitude { get; set; }
public decimal? FocalLength { get; set; }
public decimal? SensorWidth { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace Azaion.Annotations.DTOs;
public class UpdateDirectoriesRequest
{
public string? VideosDir { get; set; }
public string? ImagesDir { get; set; }
public string? LabelsDir { get; set; }
public string? ResultsDir { get; set; }
public string? ThumbnailsDir { get; set; }
public string? GpsSatDir { get; set; }
public string? GpsRouteDir { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.DTOs;
public class UpdateStatusRequest
{
public AnnotationStatus Status { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace Azaion.Annotations.DTOs;
public class UpdateSystemSettingsRequest
{
public string? MilitaryUnit { get; set; }
public string? Name { get; set; }
public int? DefaultCameraWidth { get; set; }
public decimal? DefaultCameraFoV { get; set; }
public int? ThumbnailWidth { get; set; }
public int? ThumbnailHeight { get; set; }
public int? ThumbnailBorder { get; set; }
public bool? GenerateAnnotatedImage { get; set; }
public bool? SilentDetection { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
namespace Azaion.Annotations.DTOs;
public class UpdateUserSettingsRequest
{
public Guid? SelectedFlightId { get; set; }
public decimal? AnnotationsLeftPanelWidth { get; set; }
public decimal? AnnotationsRightPanelWidth { get; set; }
public decimal? DatasetLeftPanelWidth { get; set; }
public decimal? DatasetRightPanelWidth { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Database.Entities;
namespace Azaion.Annotations.Database;
public class AppDataConnection(DataOptions options) : DataConnection(options)
{
public ITable<Media> Media => this.GetTable<Media>();
public ITable<Annotation> Annotations => this.GetTable<Annotation>();
public ITable<Detection> Detections => this.GetTable<Detection>();
public ITable<AnnotationsQueueRecord> AnnotationsQueueRecords => this.GetTable<AnnotationsQueueRecord>();
public ITable<SystemSettings> SystemSettings => this.GetTable<SystemSettings>();
public ITable<DirectorySettings> DirectorySettings => this.GetTable<DirectorySettings>();
public ITable<DetectionClass> DetectionClasses => this.GetTable<DetectionClass>();
public ITable<UserSettings> UserSettings => this.GetTable<UserSettings>();
public ITable<CameraSettings> CameraSettings => this.GetTable<CameraSettings>();
}
+150
View File
@@ -0,0 +1,150 @@
using LinqToDB.Data;
namespace Azaion.Annotations.Database;
public static class DatabaseMigrator
{
public static void Migrate(AppDataConnection db)
{
db.Execute(Sql);
}
private const string Sql = """
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
media_type INTEGER NOT NULL DEFAULT 0,
media_status INTEGER NOT NULL DEFAULT 0,
waypoint_id UUID,
user_id UUID NOT NULL
);
CREATE TABLE IF NOT EXISTS annotations (
id TEXT PRIMARY KEY,
media_id TEXT NOT NULL REFERENCES media(id),
time BIGINT NOT NULL DEFAULT 0,
created_date TIMESTAMP NOT NULL DEFAULT NOW(),
user_id UUID NOT NULL,
source INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS detection (
id UUID PRIMARY KEY,
center_x REAL NOT NULL,
center_y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
class_num INTEGER NOT NULL,
label TEXT NOT NULL DEFAULT '',
description TEXT,
confidence REAL NOT NULL DEFAULT 0,
affiliation INTEGER NOT NULL DEFAULT 0,
combat_readiness INTEGER NOT NULL DEFAULT 0,
annotation_id TEXT NOT NULL REFERENCES annotations(id)
);
CREATE TABLE IF NOT EXISTS annotations_queue_records (
id UUID PRIMARY KEY,
date_time TIMESTAMP NOT NULL DEFAULT NOW(),
operation INTEGER NOT NULL DEFAULT 0,
annotation_ids TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY,
name TEXT,
military_unit TEXT,
default_camera_width INTEGER,
default_camera_fov NUMERIC,
thumbnail_width INTEGER NOT NULL DEFAULT 240,
thumbnail_height INTEGER NOT NULL DEFAULT 135,
thumbnail_border INTEGER NOT NULL DEFAULT 10,
generate_annotated_image BOOLEAN NOT NULL DEFAULT false,
silent_detection BOOLEAN NOT NULL DEFAULT false
);
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS thumbnail_width INTEGER NOT NULL DEFAULT 240;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS thumbnail_height INTEGER NOT NULL DEFAULT 135;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS thumbnail_border INTEGER NOT NULL DEFAULT 10;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS generate_annotated_image BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS silent_detection BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS directory_settings (
id UUID PRIMARY KEY,
videos_dir TEXT NOT NULL DEFAULT '/data/videos',
images_dir TEXT NOT NULL DEFAULT '/data/images',
labels_dir TEXT NOT NULL DEFAULT '/data/labels',
results_dir TEXT NOT NULL DEFAULT '/data/results',
thumbnails_dir TEXT NOT NULL DEFAULT '/data/thumbnails',
gps_sat_dir TEXT NOT NULL DEFAULT '/data/gps_sat',
gps_route_dir TEXT NOT NULL DEFAULT '/data/gps_route'
);
ALTER TABLE directory_settings ADD COLUMN IF NOT EXISTS videos_dir TEXT NOT NULL DEFAULT '/data/videos';
ALTER TABLE directory_settings ADD COLUMN IF NOT EXISTS results_dir TEXT NOT NULL DEFAULT '/data/results';
ALTER TABLE directory_settings ADD COLUMN IF NOT EXISTS gps_sat_dir TEXT NOT NULL DEFAULT '/data/gps_sat';
ALTER TABLE directory_settings ADD COLUMN IF NOT EXISTS gps_route_dir TEXT NOT NULL DEFAULT '/data/gps_route';
CREATE TABLE IF NOT EXISTS detection_classes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
short_name TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
max_size_m INTEGER NOT NULL DEFAULT 0,
photo_mode INTEGER NOT NULL DEFAULT 0
);
ALTER TABLE media ADD COLUMN IF NOT EXISTS duration TEXT;
INSERT INTO detection_classes (id, name, short_name, color, max_size_m, photo_mode) VALUES
(0, 'ArmorVehicle', 'Броня', '#FF0000', 7, 0),
(1, 'Truck', 'Вантаж.', '#00FF00', 8, 0),
(2, 'Vehicle', 'Машина', '#0000FF', 7, 0),
(3, 'Artillery', 'Арта', '#FFFF00', 14, 0),
(4, 'Shadow', 'Тінь', '#FF00FF', 9, 0),
(5, 'Trenches', 'Окопи', '#00FFFF', 10, 0),
(6, 'MilitaryMan', 'Військов', '#188021', 2, 0),
(7, 'TyreTracks', 'Накати', '#800000', 5, 0),
(8, 'AdditionArmoredTank', 'Танк.захист', '#008000', 7, 0),
(9, 'Smoke', 'Дим', '#000080', 8, 0),
(10, 'Plane', 'Літак', '#000080', 12, 0),
(11, 'Moto', 'Мото', '#808000', 3, 0),
(12, 'CamouflageNet', 'Сітка', '#800080', 14, 0),
(13, 'CamouflageBranches', 'Гілки', '#2f4f4f', 8, 0),
(14, 'Roof', 'Дах', '#1e90ff', 15, 0),
(15, 'Building', 'Будівля', '#ffb6c1', 20, 0),
(16, 'Caponier', 'Капонір', '#ffb6c1', 10, 0),
(17, 'Ammo', 'БК', '#33658a', 2, 0),
(18, 'Protect.Struct', 'Зуби.драк', '#969647', 2, 0)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS split_tile TEXT;
CREATE TABLE IF NOT EXISTS user_settings (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
selected_flight_id UUID,
annotations_left_panel_width NUMERIC,
annotations_right_panel_width NUMERIC,
dataset_left_panel_width NUMERIC,
dataset_right_panel_width NUMERIC
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_user_settings_user_id ON user_settings(user_id);
CREATE TABLE IF NOT EXISTS camera_settings (
id UUID PRIMARY KEY,
altitude NUMERIC NOT NULL DEFAULT 100,
focal_length NUMERIC NOT NULL DEFAULT 50,
sensor_width NUMERIC NOT NULL DEFAULT 36
);
CREATE INDEX IF NOT EXISTS ix_annotations_media_id ON annotations(media_id);
CREATE INDEX IF NOT EXISTS ix_annotations_created_date ON annotations(created_date);
CREATE INDEX IF NOT EXISTS ix_annotations_user_id ON annotations(user_id);
CREATE INDEX IF NOT EXISTS ix_detection_annotation_id ON detection(annotation_id);
CREATE INDEX IF NOT EXISTS ix_media_waypoint_id ON media(waypoint_id);
""";
}
+49
View File
@@ -0,0 +1,49 @@
using LinqToDB.Mapping;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Database.Entities;
[Table("annotations")]
public class Annotation
{
[PrimaryKey]
[Column("id")]
public string Id { get; set; } = string.Empty;
[Column("media_id")]
public string MediaId { get; set; } = string.Empty;
[Column("time")]
public long TimeTicks { get; set; }
[NotColumn]
public TimeSpan Time
{
get => TimeSpan.FromTicks(TimeTicks);
set => TimeTicks = value.Ticks;
}
[Column("created_date")]
public DateTime CreatedDate { get; set; }
[Column("user_id")]
public Guid UserId { get; set; }
[Column("source")]
public AnnotationSource Source { get; set; }
[Column("status")]
public AnnotationStatus Status { get; set; }
[Column("is_split")]
public bool IsSplit { get; set; }
[Column("split_tile")]
public string? SplitTile { get; set; }
[Association(ThisKey = nameof(MediaId), OtherKey = nameof(Media.Id))]
public Media? Media { get; set; }
[Association(ThisKey = nameof(Id), OtherKey = nameof(Detection.AnnotationId))]
public List<Detection> Detections { get; set; } = [];
}
@@ -0,0 +1,21 @@
using LinqToDB.Mapping;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Database.Entities;
[Table("annotations_queue_records")]
public class AnnotationsQueueRecord
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("date_time")]
public DateTime DateTime { get; set; }
[Column("operation")]
public QueueOperation Operation { get; set; }
[Column("annotation_ids")]
public string AnnotationIds { get; set; } = "[]";
}
+20
View File
@@ -0,0 +1,20 @@
using LinqToDB.Mapping;
namespace Azaion.Annotations.Database.Entities;
[Table("camera_settings")]
public class CameraSettings
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("altitude")]
public decimal Altitude { get; set; }
[Column("focal_length")]
public decimal FocalLength { get; set; }
[Column("sensor_width")]
public decimal SensorWidth { get; set; }
}
+48
View File
@@ -0,0 +1,48 @@
using LinqToDB.Mapping;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Database.Entities;
[Table("detection")]
public class Detection
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("center_x")]
public float CenterX { get; set; }
[Column("center_y")]
public float CenterY { get; set; }
[Column("width")]
public float Width { get; set; }
[Column("height")]
public float Height { get; set; }
[Column("class_num")]
public int ClassNum { get; set; }
[Column("label")]
public string Label { get; set; } = string.Empty;
[Column("description")]
public string? Description { get; set; }
[Column("confidence")]
public float Confidence { get; set; }
[Column("affiliation")]
public AffiliationEnum Affiliation { get; set; }
[Column("combat_readiness")]
public CombatReadiness CombatReadiness { get; set; }
[Column("annotation_id")]
public string AnnotationId { get; set; } = string.Empty;
[Association(ThisKey = nameof(AnnotationId), OtherKey = nameof(Annotation.Id))]
public Annotation? Annotation { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using LinqToDB.Mapping;
namespace Azaion.Annotations.Database.Entities;
[Table("detection_classes")]
public class DetectionClass
{
[PrimaryKey, Identity]
[Column("id")]
public int Id { get; set; }
[Column("name")]
public string Name { get; set; } = string.Empty;
[Column("short_name")]
public string ShortName { get; set; } = string.Empty;
[Column("color")]
public string Color { get; set; } = string.Empty;
[Column("max_size_m")]
public int MaxSizeM { get; set; }
[Column("photo_mode")]
public int PhotoMode { get; set; }
}
@@ -0,0 +1,32 @@
using LinqToDB.Mapping;
namespace Azaion.Annotations.Database.Entities;
[Table("directory_settings")]
public class DirectorySettings
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("videos_dir")]
public string VideosDir { get; set; } = "/data/videos";
[Column("images_dir")]
public string ImagesDir { get; set; } = "/data/images";
[Column("labels_dir")]
public string LabelsDir { get; set; } = "/data/labels";
[Column("results_dir")]
public string ResultsDir { get; set; } = "/data/results";
[Column("thumbnails_dir")]
public string ThumbnailsDir { get; set; } = "/data/thumbnails";
[Column("gps_sat_dir")]
public string GpsSatDir { get; set; } = "/data/gps_sat";
[Column("gps_route_dir")]
public string GpsRouteDir { get; set; } = "/data/gps_route";
}
+36
View File
@@ -0,0 +1,36 @@
using LinqToDB.Mapping;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Database.Entities;
[Table("media")]
public class Media
{
[PrimaryKey]
[Column("id")]
public string Id { get; set; } = string.Empty;
[Column("name")]
public string Name { get; set; } = string.Empty;
[Column("path")]
public string Path { get; set; } = string.Empty;
[Column("media_type")]
public MediaType MediaType { get; set; }
[Column("media_status")]
public MediaStatus MediaStatus { get; set; }
[Column("waypoint_id")]
public Guid? WaypointId { get; set; }
[Column("duration")]
public string? Duration { get; set; }
[Column("user_id")]
public Guid UserId { get; set; }
[Association(ThisKey = nameof(Id), OtherKey = nameof(Annotation.MediaId))]
public List<Annotation> Annotations { get; set; } = [];
}
+38
View File
@@ -0,0 +1,38 @@
using LinqToDB.Mapping;
namespace Azaion.Annotations.Database.Entities;
[Table("system_settings")]
public class SystemSettings
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("name")]
public string? Name { get; set; }
[Column("military_unit")]
public string? MilitaryUnit { get; set; }
[Column("default_camera_width")]
public int? DefaultCameraWidth { get; set; }
[Column("default_camera_fov")]
public decimal? DefaultCameraFoV { get; set; }
[Column("thumbnail_width")]
public int ThumbnailWidth { get; set; } = 240;
[Column("thumbnail_height")]
public int ThumbnailHeight { get; set; } = 135;
[Column("thumbnail_border")]
public int ThumbnailBorder { get; set; } = 10;
[Column("generate_annotated_image")]
public bool GenerateAnnotatedImage { get; set; }
[Column("silent_detection")]
public bool SilentDetection { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
using LinqToDB.Mapping;
namespace Azaion.Annotations.Database.Entities;
[Table("user_settings")]
public class UserSettings
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("user_id")]
public Guid UserId { get; set; }
[Column("selected_flight_id")]
public Guid? SelectedFlightId { get; set; }
[Column("annotations_left_panel_width")]
public decimal? AnnotationsLeftPanelWidth { get; set; }
[Column("annotations_right_panel_width")]
public decimal? AnnotationsRightPanelWidth { get; set; }
[Column("dataset_left_panel_width")]
public decimal? DatasetLeftPanelWidth { get; set; }
[Column("dataset_right_panel_width")]
public decimal? DatasetRightPanelWidth { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Azaion.Annotations.dll"]
+9
View File
@@ -0,0 +1,9 @@
namespace Azaion.Annotations.Enums;
public enum AffiliationEnum
{
None = 0,
Friendly = 10,
Hostile = 20,
Unknown = 30
}
+7
View File
@@ -0,0 +1,7 @@
namespace Azaion.Annotations.Enums;
public enum AnnotationSource
{
AI = 0,
Manual = 1
}
+10
View File
@@ -0,0 +1,10 @@
namespace Azaion.Annotations.Enums;
public enum AnnotationStatus
{
None = 0,
Created = 10,
Edited = 20,
Validated = 30,
Deleted = 40
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Annotations.Enums;
public enum CombatReadiness
{
Ready = 0,
NotReady = 1,
Unknown = 2
}
+12
View File
@@ -0,0 +1,12 @@
namespace Azaion.Annotations.Enums;
public enum MediaStatus
{
None = 0,
New = 1,
AIProcessing = 2,
AIProcessed = 3,
ManualCreated = 4,
Confirmed = 5,
Error = 6
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Annotations.Enums;
public enum MediaType
{
None = 0,
Video = 1,
Image = 2
}
+8
View File
@@ -0,0 +1,8 @@
namespace Azaion.Annotations.Enums;
public enum QueueOperation
{
Created = 0,
Validated = 1,
Deleted = 2
}
+1
View File
@@ -0,0 +1 @@
global using LinqToDB.Async;
+40
View File
@@ -0,0 +1,40 @@
using System.Net;
using System.Text.Json;
namespace Azaion.Annotations.Middleware;
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
public async Task Invoke(HttpContext context)
{
try
{
await next(context);
}
catch (KeyNotFoundException ex)
{
await WriteError(context, HttpStatusCode.NotFound, ex.Message);
}
catch (ArgumentException ex)
{
await WriteError(context, HttpStatusCode.BadRequest, ex.Message);
}
catch (InvalidOperationException ex)
{
await WriteError(context, HttpStatusCode.Conflict, ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
await WriteError(context, HttpStatusCode.InternalServerError, "Internal server error");
}
}
private static async Task WriteError(HttpContext context, HttpStatusCode code, string message)
{
context.Response.StatusCode = (int)code;
context.Response.ContentType = "application/json";
var body = JsonSerializer.Serialize(new { statusCode = (int)code, message });
await context.Response.WriteAsync(body);
}
}
+85
View File
@@ -0,0 +1,85 @@
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Auth;
using Azaion.Annotations.Database;
using Azaion.Annotations.Middleware;
using Azaion.Annotations.Services;
var builder = WebApplication.CreateBuilder(args);
var databaseUrl = builder.Configuration["DATABASE_URL"]
?? Environment.GetEnvironmentVariable("DATABASE_URL")
?? "Host=localhost;Database=azaion;Username=postgres;Password=changeme";
var connectionString = databaseUrl.StartsWith("postgresql://")
? ConvertPostgresUrl(databaseUrl)
: databaseUrl;
var jwtSecret = builder.Configuration["JWT_SECRET"]
?? Environment.GetEnvironmentVariable("JWT_SECRET")
?? "development-secret-key-min-32-chars!!";
builder.Services.AddScoped(_ =>
{
var options = new DataOptions()
.UsePostgreSQL(connectionString);
return new AppDataConnection(options);
});
builder.Services.AddScoped<AnnotationService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<DatasetService>();
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<PathResolver>();
builder.Services.AddSingleton<AnnotationEventService>();
builder.Services.AddSingleton(new TokenService(jwtSecret));
var rabbitMqConfig = new RabbitMqConfig
{
Host = Environment.GetEnvironmentVariable("RABBITMQ_HOST") ?? "127.0.0.1",
Port = int.TryParse(Environment.GetEnvironmentVariable("RABBITMQ_STREAM_PORT"), out var rmqPort) ? rmqPort : 5552,
Username = Environment.GetEnvironmentVariable("RABBITMQ_PRODUCER_USER") ?? "azaion_producer",
Password = Environment.GetEnvironmentVariable("RABBITMQ_PRODUCER_PASS") ?? "producer_pass",
StreamName = Environment.GetEnvironmentVariable("RABBITMQ_STREAM_NAME") ?? "azaion-annotations"
};
builder.Services.AddSingleton(rabbitMqConfig);
builder.Services.AddHostedService<FailsafeProducer>();
builder.Services.AddJwtAuth(jwtSecret);
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDataConnection>();
DatabaseMigrator.Migrate(db);
}
app.UseMiddleware<ErrorHandlingMiddleware>();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.Run();
static string ConvertPostgresUrl(string url)
{
var uri = new Uri(url);
var userInfo = uri.UserInfo.Split(':');
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 5432;
var database = uri.AbsolutePath.TrimStart('/');
return $"Host={host};Port={port};Database={database};Username={userInfo[0]};Password={userInfo.ElementAtOrDefault(1) ?? ""}";
}
+3
View File
@@ -0,0 +1,3 @@
# Azaion.Annotations
.NET 8 REST API for media, annotations, dataset, and settings management.
+16
View File
@@ -0,0 +1,16 @@
using System.Threading.Channels;
using Azaion.Annotations.DTOs;
namespace Azaion.Annotations.Services;
public class AnnotationEventService
{
private readonly Channel<AnnotationEventDto> _channel = Channel.CreateUnbounded<AnnotationEventDto>();
public ChannelReader<AnnotationEventDto> Reader => _channel.Reader;
public async ValueTask PublishAsync(AnnotationEventDto evt)
{
await _channel.Writer.WriteAsync(evt);
}
}
+289
View File
@@ -0,0 +1,289 @@
using System.IO.Hashing;
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class AnnotationService(AppDataConnection db, PathResolver pathResolver, AnnotationEventService events)
{
public async Task<Annotation> CreateAnnotation(CreateAnnotationRequest request, Guid userId)
{
string id;
if (request.Image is { Length: > 0 })
{
id = ComputeHash(request.Image);
var imgPath = await pathResolver.GetImagePath(id);
Directory.CreateDirectory(Path.GetDirectoryName(imgPath)!);
await File.WriteAllBytesAsync(imgPath, request.Image);
var existingMedia = await db.Media.FirstOrDefaultAsync(m => m.Id == id);
if (existingMedia == null)
{
await db.InsertAsync(new Media
{
Id = id,
Name = id,
Path = imgPath,
MediaType = MediaType.Image,
MediaStatus = MediaStatus.New,
WaypointId = request.WaypointId,
UserId = userId
});
}
}
else if (!string.IsNullOrEmpty(request.MediaId))
{
var media = await db.Media.FirstOrDefaultAsync(m => m.Id == request.MediaId)
?? throw new KeyNotFoundException($"Media {request.MediaId} not found");
id = request.MediaId;
var imgPath = await pathResolver.GetImagePath(id);
if (File.Exists(media.Path) && !File.Exists(imgPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(imgPath)!);
File.Copy(media.Path, imgPath);
}
}
else
{
throw new ArgumentException("Provide either Image bytes or MediaId");
}
var annotation = new Annotation
{
Id = id,
MediaId = id,
Time = request.VideoTime,
CreatedDate = DateTime.UtcNow,
UserId = userId,
Source = request.Source,
Status = AnnotationStatus.Created
};
await db.InsertAsync(annotation);
var detections = request.Detections.Select(d => new Detection
{
Id = Guid.NewGuid(),
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height,
ClassNum = d.ClassNum,
Label = d.Label,
Description = d.Description,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
AnnotationId = id
}).ToList();
if (detections.Count > 0)
await db.BulkCopyAsync(detections);
await WriteLabelFile(id, request.Detections);
await events.PublishAsync(new AnnotationEventDto
{
AnnotationId = id,
MediaId = id,
Status = AnnotationStatus.Created,
Source = request.Source,
Detections = request.Detections,
CreatedDate = annotation.CreatedDate
});
var settings = await db.SystemSettings.FirstOrDefaultAsync();
if (settings is not { SilentDetection: true })
await FailsafeProducer.EnqueueAsync(db, id, QueueOperation.Created);
return annotation;
}
public async Task UpdateAnnotation(string id, UpdateAnnotationRequest request)
{
var exists = await db.Annotations.AnyAsync(a => a.Id == id);
if (!exists) throw new KeyNotFoundException($"Annotation {id} not found");
await db.Detections.DeleteAsync(d => d.AnnotationId == id);
var detections = request.Detections.Select(d => new Detection
{
Id = Guid.NewGuid(),
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height,
ClassNum = d.ClassNum,
Label = d.Label,
Description = d.Description,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
AnnotationId = id
}).ToList();
if (detections.Count > 0)
await db.BulkCopyAsync(detections);
await db.Annotations
.Where(a => a.Id == id)
.Set(a => a.Status, AnnotationStatus.Edited)
.UpdateAsync();
await WriteLabelFile(id, request.Detections);
}
public async Task UpdateStatus(string id, AnnotationStatus status)
{
var updated = await db.Annotations
.Where(a => a.Id == id)
.Set(a => a.Status, status)
.UpdateAsync();
if (updated == 0) throw new KeyNotFoundException($"Annotation {id} not found");
}
public async Task DeleteAnnotation(string id)
{
var exists = await db.Annotations.AnyAsync(a => a.Id == id);
if (!exists) throw new KeyNotFoundException($"Annotation {id} not found");
await db.Detections.DeleteAsync(d => d.AnnotationId == id);
await db.Annotations.DeleteAsync(a => a.Id == id);
await DeleteFiles(id);
}
public async Task<PaginatedResponse<AnnotationListItem>> GetAnnotations(GetAnnotationsQuery query)
{
var q = db.Annotations.AsQueryable();
if (query.From.HasValue)
q = q.Where(a => a.CreatedDate >= query.From.Value);
if (query.To.HasValue)
q = q.Where(a => a.CreatedDate <= query.To.Value);
if (query.UserId.HasValue)
q = q.Where(a => a.UserId == query.UserId.Value);
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.Name.ToLower().Contains(query.Name.ToLower())));
if (!string.IsNullOrEmpty(query.MediaId))
q = q.Where(a => a.MediaId == query.MediaId);
if (query.FlightId.HasValue)
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.WaypointId != null));
var totalCount = await q.CountAsync();
var annotations = await q
.OrderByDescending(a => a.CreatedDate)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
var annotationIds = annotations.Select(a => a.Id).ToList();
var detections = await db.Detections
.Where(d => annotationIds.Contains(d.AnnotationId))
.ToListAsync();
var detectionsByAnnotation = detections.GroupBy(d => d.AnnotationId)
.ToDictionary(g => g.Key, g => g.ToList());
var items = annotations.Select(a =>
{
detectionsByAnnotation.TryGetValue(a.Id, out var dets);
return new AnnotationListItem
{
Id = a.Id,
MediaId = a.MediaId,
Time = TimeSpan.FromTicks(a.TimeTicks).ToString(@"hh\:mm\:ss"),
CreatedDate = a.CreatedDate,
UserId = a.UserId,
Source = a.Source,
Status = a.Status,
IsSplit = a.IsSplit,
SplitTile = a.SplitTile,
Detections = (dets ?? []).Select(d => new DetectionListDto
{
Id = d.Id,
ClassNum = d.ClassNum,
Label = d.Label,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height
}).ToList()
};
}).ToList();
return new PaginatedResponse<AnnotationListItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task<Annotation> GetAnnotation(string id)
{
var annotation = await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => a.Id == id)
.FirstOrDefaultAsync();
return annotation ?? throw new KeyNotFoundException($"Annotation {id} not found");
}
private async Task WriteLabelFile(string annotationId, List<DetectionDto> detections)
{
var labelPath = await pathResolver.GetLabelPath(annotationId);
Directory.CreateDirectory(Path.GetDirectoryName(labelPath)!);
var lines = detections.Select(d => $"{d.ClassNum} {d.CenterX} {d.CenterY} {d.Width} {d.Height}");
await File.WriteAllLinesAsync(labelPath, lines);
}
private async Task DeleteFiles(string annotationId)
{
var paths = new[]
{
await pathResolver.GetImagePath(annotationId),
await pathResolver.GetLabelPath(annotationId),
await pathResolver.GetThumbnailPath(annotationId)
};
foreach (var path in paths)
{
if (File.Exists(path))
File.Delete(path);
}
}
private static string ComputeHash(byte[] data)
{
byte[] input;
if (data.Length > 3072)
{
var buffer = new byte[8 + 3072];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, 1024);
Array.Copy(data, data.Length / 2 - 512, buffer, 8 + 1024, 1024);
Array.Copy(data, data.Length - 1024, buffer, 8 + 2048, 1024);
input = buffer;
}
else
{
var buffer = new byte[8 + data.Length];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, data.Length);
input = buffer;
}
var hash = XxHash64.Hash(input);
return Convert.ToHexStringLower(hash);
}
}
+118
View File
@@ -0,0 +1,118 @@
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class DatasetService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<PaginatedResponse<DatasetItem>> GetDataset(GetDatasetQuery query)
{
var q = db.Annotations.AsQueryable();
if (query.FromDate.HasValue)
q = q.Where(a => a.CreatedDate >= query.FromDate.Value);
if (query.ToDate.HasValue)
q = q.Where(a => a.CreatedDate <= query.ToDate.Value);
if (query.FlightId.HasValue)
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.WaypointId != null));
if (query.Status.HasValue)
q = q.Where(a => a.Status == query.Status.Value);
if (query.ClassNum.HasValue)
q = q.Where(a => db.Detections.Any(d => d.AnnotationId == a.Id && d.ClassNum == query.ClassNum.Value));
if (query.HasDetections == true)
q = q.Where(a => db.Detections.Any(d => d.AnnotationId == a.Id));
if (query.HasDetections == false)
q = q.Where(a => !db.Detections.Any(d => d.AnnotationId == a.Id));
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(a => a.Id.ToLower().Contains(query.Name.ToLower()));
var totalCount = await q.CountAsync();
var annotations = await q
.OrderByDescending(a => a.CreatedDate)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
var items = new List<DatasetItem>();
foreach (var a in annotations)
{
items.Add(new DatasetItem
{
AnnotationId = a.Id,
ImageName = $"{a.Id}.jpg",
ThumbnailPath = await pathResolver.GetThumbnailPath(a.Id),
Status = a.Status,
CreatedDate = a.CreatedDate,
Source = a.Source,
IsSplit = a.IsSplit,
IsSeed = a.Source == AnnotationSource.AI && a.Status == AnnotationStatus.Validated
});
}
return new PaginatedResponse<DatasetItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task<Annotation> GetAnnotationDetail(string annotationId)
{
var annotation = await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => a.Id == annotationId)
.FirstOrDefaultAsync();
return annotation ?? throw new KeyNotFoundException($"Annotation {annotationId} not found");
}
public async Task UpdateStatus(string annotationId, AnnotationStatus status)
{
var updated = await db.Annotations
.Where(a => a.Id == annotationId)
.Set(a => a.Status, status)
.UpdateAsync();
if (updated == 0) throw new KeyNotFoundException($"Annotation {annotationId} not found");
}
public async Task BulkUpdateStatus(BulkStatusRequest request)
{
if (request.AnnotationIds.Count == 0)
throw new ArgumentException("Empty annotationIds list");
await db.Annotations
.Where(a => request.AnnotationIds.Contains(a.Id))
.Set(a => a.Status, request.Status)
.UpdateAsync();
}
public async Task<List<ClassDistributionItem>> GetClassDistribution()
{
var classes = await db.DetectionClasses.ToListAsync();
var classMap = classes.ToDictionary(c => c.Id, c => c);
var groups = await db.Detections
.GroupBy(d => d.ClassNum)
.Select(g => new { ClassNum = g.Key, Count = g.Count() })
.ToListAsync();
return groups.Select(g =>
{
classMap.TryGetValue(g.ClassNum, out var cls);
return new ClassDistributionItem
{
ClassNum = g.ClassNum,
Label = cls?.Name ?? $"Class {g.ClassNum}",
Color = cls?.Color ?? "#888888",
Count = g.Count
};
}).OrderByDescending(x => x.Count).ToList();
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Net;
using System.Text.Json;
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
using MessagePack;
using RabbitMQ.Stream.Client;
using RabbitMQ.Stream.Client.AMQP;
using RabbitMQ.Stream.Client.Reliable;
namespace Azaion.Annotations.Services;
public class RabbitMqConfig
{
public string Host { get; set; } = "rabbitmq";
public int Port { get; set; } = 5552;
public string Username { get; set; } = "azaion_producer";
public string Password { get; set; } = "producer_pass";
public string StreamName { get; set; } = "azaion-annotations";
}
public class FailsafeProducer(
IServiceScopeFactory scopeFactory,
PathResolver pathResolver,
RabbitMqConfig config,
ILogger<FailsafeProducer> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
while (!ct.IsCancellationRequested)
{
try
{
await ProcessQueue(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
}
private async Task ProcessQueue(CancellationToken ct)
{
var streamSystem = await StreamSystem.Create(new StreamSystemConfig
{
Endpoints = [new IPEndPoint(IPAddress.Parse(config.Host), config.Port)],
UserName = config.Username,
Password = config.Password
});
var producer = await Producer.Create(new ProducerConfig(streamSystem, config.StreamName));
try
{
while (!ct.IsCancellationRequested)
{
await DrainQueue(producer, ct);
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
finally
{
await producer.Close();
await streamSystem.Close();
}
}
private async Task DrainQueue(Producer producer, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDataConnection>();
var records = await db.AnnotationsQueueRecords
.OrderBy(x => x.DateTime)
.ToListAsync(token: ct);
if (records.Count == 0)
return;
var createdIds = records
.Where(x => x.Operation == QueueOperation.Created)
.SelectMany(x => ParseIds(x.AnnotationIds))
.ToList();
var annotationsDict = createdIds.Count > 0
? await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => createdIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, ct)
: new Dictionary<string, Annotation>();
var messages = new List<Message>();
foreach (var record in records)
{
var ids = ParseIds(record.AnnotationIds);
if (record.Operation is QueueOperation.Validated or QueueOperation.Deleted)
{
var msg = MessagePackSerializer.Serialize(new AnnotationBulkQueueMessage
{
AnnotationIds = ids.ToArray(),
Operation = (int)record.Operation,
CreatedDate = record.DateTime
});
messages.Add(new Message(msg)
{
ApplicationProperties = new ApplicationProperties
{
{ "Operation", record.Operation.ToString() }
}
});
}
else
{
foreach (var id in ids)
{
if (!annotationsDict.TryGetValue(id, out var annotation))
continue;
byte[]? image = null;
try
{
var imgPath = await pathResolver.GetImagePath(id);
if (File.Exists(imgPath))
image = await File.ReadAllBytesAsync(imgPath, ct);
}
catch { }
var detectionsJson = JsonSerializer.Serialize(
annotation.Detections?.Select(d => new
{
d.CenterX, d.CenterY, d.Width, d.Height,
d.ClassNum, d.Label, d.Confidence
}) ?? []);
var msg = MessagePackSerializer.Serialize(new AnnotationQueueMessage
{
Name = annotation.Id,
MediaHash = annotation.MediaId,
OriginalMediaName = annotation.MediaId,
Time = TimeSpan.FromTicks(annotation.TimeTicks),
ImageExtension = ".jpg",
Detections = detectionsJson,
Image = image,
Email = "",
Source = (int)annotation.Source,
Status = (int)annotation.Status,
CreatedDate = annotation.CreatedDate
});
messages.Add(new Message(msg)
{
ApplicationProperties = new ApplicationProperties
{
{ "Operation", record.Operation.ToString() }
}
});
}
}
}
if (messages.Count > 0)
{
await producer.Send(messages, CompressionType.Gzip);
var recordIds = records.Select(x => x.Id).ToList();
await db.AnnotationsQueueRecords
.Where(x => recordIds.Contains(x.Id))
.DeleteAsync(token: ct);
}
}
private static List<string> ParseIds(string json)
{
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
public static async Task EnqueueAsync(AppDataConnection db, string annotationId, QueueOperation operation)
{
var ids = JsonSerializer.Serialize(new[] { annotationId });
await db.InsertAsync(new AnnotationsQueueRecord
{
Id = Guid.NewGuid(),
DateTime = DateTime.UtcNow,
Operation = operation,
AnnotationIds = ids
});
}
}
+233
View File
@@ -0,0 +1,233 @@
using System.Diagnostics;
using System.IO.Hashing;
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class MediaService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<Media> CreateMedia(CreateMediaRequest request, Guid userId)
{
var id = request.Data is { Length: > 0 }
? ComputeHash(request.Data)
: ComputeHash(System.Text.Encoding.UTF8.GetBytes(request.Path));
var media = new Media
{
Id = id,
Name = request.Name,
Path = request.Path,
MediaType = request.MediaType,
MediaStatus = MediaStatus.New,
WaypointId = request.WaypointId,
UserId = userId
};
if (request.Data is { Length: > 0 })
{
Directory.CreateDirectory(Path.GetDirectoryName(request.Path)!);
await File.WriteAllBytesAsync(request.Path, request.Data);
}
if (media.MediaType == MediaType.Video)
media.Duration = await ExtractDuration(media.Path);
await db.InsertAsync(media);
return media;
}
public async Task<List<Media>> CreateMediaBatch(Guid waypointId, IFormFileCollection files, Guid userId)
{
if (files.Count == 0) throw new ArgumentException("No files provided");
var mediaDir = await pathResolver.GetMediaDir();
Directory.CreateDirectory(mediaDir);
var mediaList = new List<Media>();
foreach (var file in files)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
var id = ComputeHash(bytes);
var filePath = Path.Combine(mediaDir, $"{id}{Path.GetExtension(file.FileName)}");
await File.WriteAllBytesAsync(filePath, bytes);
var mediaType = ResolveMediaType(file.ContentType, file.FileName);
var media = new Media
{
Id = id,
Name = file.FileName,
Path = filePath,
MediaType = mediaType,
MediaStatus = MediaStatus.New,
WaypointId = waypointId,
UserId = userId
};
if (mediaType == MediaType.Video)
media.Duration = await ExtractDuration(filePath);
mediaList.Add(media);
}
await db.BulkCopyAsync(mediaList);
return mediaList;
}
private static MediaType ResolveMediaType(string contentType, string fileName)
{
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
return MediaType.Video;
if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return MediaType.Image;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
".mp4" or ".avi" or ".mov" or ".mkv" => MediaType.Video,
".jpg" or ".jpeg" or ".png" or ".bmp" or ".tiff" => MediaType.Image,
_ => MediaType.None
};
}
public async Task<Media?> GetMediaById(string id)
{
return await db.Media.FirstOrDefaultAsync(m => m.Id == id);
}
public async Task<PaginatedResponse<MediaListItem>> GetMedia(GetMediaQuery query)
{
var q = db.Media.AsQueryable();
if (query.FlightId.HasValue)
q = q.Where(m => m.WaypointId != null);
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(m => m.Name.ToLower().Contains(query.Name.ToLower()));
if (!string.IsNullOrEmpty(query.Path))
q = q.Where(m => m.Path.ToLower().Contains(query.Path.ToLower()));
var totalCount = await q.CountAsync();
var items = await q
.OrderByDescending(m => m.Id)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(m => new MediaListItem
{
Id = m.Id,
Name = m.Name,
Path = m.Path,
MediaType = m.MediaType,
MediaStatus = m.MediaStatus,
Duration = m.Duration,
AnnotationCount = db.Annotations.Count(a => a.MediaId == m.Id),
WaypointId = m.WaypointId,
UserId = m.UserId
})
.ToListAsync();
return new PaginatedResponse<MediaListItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task DeleteMedia(string id)
{
var media = await db.Media.FirstOrDefaultAsync(m => m.Id == id)
?? throw new KeyNotFoundException($"Media {id} not found");
var annotationIds = await db.Annotations
.Where(a => a.MediaId == id)
.Select(a => a.Id)
.ToListAsync();
if (annotationIds.Count > 0)
{
await db.Detections.DeleteAsync(d => annotationIds.Contains(d.AnnotationId));
await db.Annotations.DeleteAsync(a => a.MediaId == id);
foreach (var annId in annotationIds)
{
var paths = new[]
{
await pathResolver.GetImagePath(annId),
await pathResolver.GetLabelPath(annId),
await pathResolver.GetThumbnailPath(annId)
};
foreach (var path in paths)
{
if (File.Exists(path))
File.Delete(path);
}
}
}
await db.Media.DeleteAsync(m => m.Id == id);
if (File.Exists(media.Path))
File.Delete(media.Path);
}
private static string ComputeHash(byte[] data)
{
byte[] input;
if (data.Length > 3072)
{
var buffer = new byte[8 + 3072];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, 1024);
Array.Copy(data, data.Length / 2 - 512, buffer, 8 + 1024, 1024);
Array.Copy(data, data.Length - 1024, buffer, 8 + 2048, 1024);
input = buffer;
}
else
{
var buffer = new byte[8 + data.Length];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, data.Length);
input = buffer;
}
var hash = XxHash64.Hash(input);
return Convert.ToHexStringLower(hash);
}
private static async Task<string?> ExtractDuration(string filePath)
{
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{filePath}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (double.TryParse(output.Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var seconds))
{
return TimeSpan.FromSeconds(seconds).ToString(@"hh\:mm\:ss");
}
}
catch (Exception)
{
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
using LinqToDB;
using Azaion.Annotations.Database;
namespace Azaion.Annotations.Services;
public class PathResolver(AppDataConnection db)
{
private string _videosDir = "/data/videos";
private string _imagesDir = "/data/images";
private string _labelsDir = "/data/labels";
private string _resultsDir = "/data/results";
private string _thumbnailsDir = "/data/thumbnails";
private bool _initialized;
private async Task EnsureInitialized()
{
if (_initialized) return;
var dirs = await db.DirectorySettings.FirstOrDefaultAsync();
if (dirs != null)
{
_videosDir = dirs.VideosDir;
_imagesDir = dirs.ImagesDir;
_labelsDir = dirs.LabelsDir;
_resultsDir = dirs.ResultsDir;
_thumbnailsDir = dirs.ThumbnailsDir;
}
_initialized = true;
}
public async Task<string> GetImagePath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_imagesDir, $"{annotationId}.jpg");
}
public async Task<string> GetLabelPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_labelsDir, $"{annotationId}.txt");
}
public async Task<string> GetThumbnailPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_thumbnailsDir, $"{annotationId}.jpg");
}
public async Task<string> GetResultPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_resultsDir, $"{annotationId}_result.jpg");
}
public async Task<string> GetMediaDir()
{
await EnsureInitialized();
return _videosDir;
}
public void Reset() => _initialized = false;
}
+148
View File
@@ -0,0 +1,148 @@
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
namespace Azaion.Annotations.Services;
public class SettingsService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<SystemSettings?> GetSystemSettings()
{
return await db.SystemSettings.FirstOrDefaultAsync();
}
public async Task UpdateSystemSettings(UpdateSystemSettingsRequest request)
{
var existing = await db.SystemSettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new SystemSettings
{
Id = Guid.NewGuid(),
Name = request.Name,
MilitaryUnit = request.MilitaryUnit,
DefaultCameraWidth = request.DefaultCameraWidth,
DefaultCameraFoV = request.DefaultCameraFoV,
ThumbnailWidth = request.ThumbnailWidth ?? 240,
ThumbnailHeight = request.ThumbnailHeight ?? 135,
ThumbnailBorder = request.ThumbnailBorder ?? 10,
GenerateAnnotatedImage = request.GenerateAnnotatedImage ?? false,
SilentDetection = request.SilentDetection ?? false
});
return;
}
await db.SystemSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.Name, request.Name ?? existing.Name)
.Set(x => x.MilitaryUnit, request.MilitaryUnit ?? existing.MilitaryUnit)
.Set(x => x.DefaultCameraWidth, request.DefaultCameraWidth ?? existing.DefaultCameraWidth)
.Set(x => x.DefaultCameraFoV, request.DefaultCameraFoV ?? existing.DefaultCameraFoV)
.Set(x => x.ThumbnailWidth, request.ThumbnailWidth ?? existing.ThumbnailWidth)
.Set(x => x.ThumbnailHeight, request.ThumbnailHeight ?? existing.ThumbnailHeight)
.Set(x => x.ThumbnailBorder, request.ThumbnailBorder ?? existing.ThumbnailBorder)
.Set(x => x.GenerateAnnotatedImage, request.GenerateAnnotatedImage ?? existing.GenerateAnnotatedImage)
.Set(x => x.SilentDetection, request.SilentDetection ?? existing.SilentDetection)
.UpdateAsync();
}
public async Task<DirectorySettings?> GetDirectorySettings()
{
return await db.DirectorySettings.FirstOrDefaultAsync();
}
public async Task UpdateDirectorySettings(UpdateDirectoriesRequest request)
{
var existing = await db.DirectorySettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new DirectorySettings
{
Id = Guid.NewGuid(),
VideosDir = request.VideosDir ?? "/data/videos",
ImagesDir = request.ImagesDir ?? "/data/images",
LabelsDir = request.LabelsDir ?? "/data/labels",
ResultsDir = request.ResultsDir ?? "/data/results",
ThumbnailsDir = request.ThumbnailsDir ?? "/data/thumbnails",
GpsSatDir = request.GpsSatDir ?? "/data/gps_sat",
GpsRouteDir = request.GpsRouteDir ?? "/data/gps_route"
});
pathResolver.Reset();
return;
}
await db.DirectorySettings
.Where(x => x.Id == existing.Id)
.Set(x => x.VideosDir, request.VideosDir ?? existing.VideosDir)
.Set(x => x.ImagesDir, request.ImagesDir ?? existing.ImagesDir)
.Set(x => x.LabelsDir, request.LabelsDir ?? existing.LabelsDir)
.Set(x => x.ResultsDir, request.ResultsDir ?? existing.ResultsDir)
.Set(x => x.ThumbnailsDir, request.ThumbnailsDir ?? existing.ThumbnailsDir)
.Set(x => x.GpsSatDir, request.GpsSatDir ?? existing.GpsSatDir)
.Set(x => x.GpsRouteDir, request.GpsRouteDir ?? existing.GpsRouteDir)
.UpdateAsync();
pathResolver.Reset();
}
public async Task<UserSettings?> GetUserSettings(Guid userId)
{
return await db.UserSettings.FirstOrDefaultAsync(x => x.UserId == userId);
}
public async Task UpdateUserSettings(Guid userId, UpdateUserSettingsRequest request)
{
var existing = await db.UserSettings.FirstOrDefaultAsync(x => x.UserId == userId);
if (existing == null)
{
await db.InsertAsync(new UserSettings
{
Id = Guid.NewGuid(),
UserId = userId,
SelectedFlightId = request.SelectedFlightId,
AnnotationsLeftPanelWidth = request.AnnotationsLeftPanelWidth,
AnnotationsRightPanelWidth = request.AnnotationsRightPanelWidth,
DatasetLeftPanelWidth = request.DatasetLeftPanelWidth,
DatasetRightPanelWidth = request.DatasetRightPanelWidth
});
return;
}
await db.UserSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.SelectedFlightId, request.SelectedFlightId ?? existing.SelectedFlightId)
.Set(x => x.AnnotationsLeftPanelWidth, request.AnnotationsLeftPanelWidth ?? existing.AnnotationsLeftPanelWidth)
.Set(x => x.AnnotationsRightPanelWidth, request.AnnotationsRightPanelWidth ?? existing.AnnotationsRightPanelWidth)
.Set(x => x.DatasetLeftPanelWidth, request.DatasetLeftPanelWidth ?? existing.DatasetLeftPanelWidth)
.Set(x => x.DatasetRightPanelWidth, request.DatasetRightPanelWidth ?? existing.DatasetRightPanelWidth)
.UpdateAsync();
}
public async Task<CameraSettings?> GetCameraSettings()
{
return await db.CameraSettings.FirstOrDefaultAsync();
}
public async Task UpdateCameraSettings(UpdateCameraSettingsRequest request)
{
var existing = await db.CameraSettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new CameraSettings
{
Id = Guid.NewGuid(),
Altitude = request.Altitude ?? 100,
FocalLength = request.FocalLength ?? 50,
SensorWidth = request.SensorWidth ?? 36
});
return;
}
await db.CameraSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.Altitude, request.Altitude ?? existing.Altitude)
.Set(x => x.FocalLength, request.FocalLength ?? existing.FocalLength)
.Set(x => x.SensorWidth, request.SensorWidth ?? existing.SensorWidth)
.UpdateAsync();
}
}
+87
View File
@@ -0,0 +1,87 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Annotations.Services;
public class TokenService
{
private readonly string _jwtSecret;
private readonly double _accessTokenHours;
public TokenService(string jwtSecret, double accessTokenHours = 4)
{
_jwtSecret = jwtSecret;
_accessTokenHours = accessTokenHours;
}
public string? RefreshAccessToken(string refreshToken)
{
var principal = ValidateToken(refreshToken);
if (principal == null)
return null;
var tokenType = principal.FindFirstValue("token_type");
if (tokenType != "refresh")
return null;
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
var email = principal.FindFirstValue(ClaimTypes.Name);
var role = principal.FindFirstValue(ClaimTypes.Role);
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
return null;
return CreateAccessToken(userId, email, role);
}
private string CreateAccessToken(string userId, string email, string? role)
{
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecret));
var tokenHandler = new JwtSecurityTokenHandler();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Name, email),
new("token_type", "access")
};
if (!string.IsNullOrEmpty(role))
claims.Add(new Claim(ClaimTypes.Role, role));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(_accessTokenHours),
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private ClaimsPrincipal? ValidateToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecret)),
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
try
{
return tokenHandler.ValidateToken(token, validationParams, out _);
}
catch
{
return null;
}
}
}