mirror of
https://github.com/azaion/annotations.git
synced 2026-04-22 15:26:30 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Azaion.Annotations.DTOs;
|
||||
|
||||
public class UpdateAnnotationRequest
|
||||
{
|
||||
public List<DetectionDto> Detections { get; set; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Azaion.Annotations.Enums;
|
||||
|
||||
namespace Azaion.Annotations.DTOs;
|
||||
|
||||
public class UpdateStatusRequest
|
||||
{
|
||||
public AnnotationStatus Status { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
""";
|
||||
}
|
||||
@@ -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; } = "[]";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum AffiliationEnum
|
||||
{
|
||||
None = 0,
|
||||
Friendly = 10,
|
||||
Hostile = 20,
|
||||
Unknown = 30
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum AnnotationSource
|
||||
{
|
||||
AI = 0,
|
||||
Manual = 1
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum AnnotationStatus
|
||||
{
|
||||
None = 0,
|
||||
Created = 10,
|
||||
Edited = 20,
|
||||
Validated = 30,
|
||||
Deleted = 40
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum CombatReadiness
|
||||
{
|
||||
Ready = 0,
|
||||
NotReady = 1,
|
||||
Unknown = 2
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum MediaType
|
||||
{
|
||||
None = 0,
|
||||
Video = 1,
|
||||
Image = 2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Azaion.Annotations.Enums;
|
||||
|
||||
public enum QueueOperation
|
||||
{
|
||||
Created = 0,
|
||||
Validated = 1,
|
||||
Deleted = 2
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using LinqToDB.Async;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? ""}";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Azaion.Annotations
|
||||
|
||||
.NET 8 REST API for media, annotations, dataset, and settings management.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user