From 9af81e40f9daf20257c64c6b11a8e8909ac93b2d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:16 +0200 Subject: [PATCH 01/15] fix: Update asset transfer tests to use current stage's target data source ID --- .../Workflow/Steps/AssetTransferStepTests.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs index ca5a202c..e8a2e033 100644 --- a/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs +++ b/server/Server.Tests/Core/Workflow/Steps/AssetTransferStepTests.cs @@ -42,8 +42,8 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithValidContext_Sho var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value)) - .ReturnsAsync(true); + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + .ReturnsAsync(true); var step = CreateStep(); @@ -53,7 +53,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithValidContext_Sho // Assert Assert.NotNull(result); Assert.Equal(targetStage, result.TargetStage); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } [Fact] @@ -67,8 +67,8 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithTransferFailure_ var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value)) - .ReturnsAsync(false); + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + .ReturnsAsync(false); var step = CreateStep(); @@ -98,13 +98,13 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithNullTargetStage_ } [Fact] - public async System.Threading.Tasks.Task TransferAssetAsync_WithNullTargetDataSourceId_ShouldThrowException() + public async System.Threading.Tasks.Task TransferAssetAsync_WithNullCurrentStageTargetDataSourceId_ShouldThrowException() { // Arrange var task = CreateTestTask(1, TaskStatus.IN_PROGRESS); var asset = CreateTestAsset(1, 1); - var currentStage = CreateTestWorkflowStage(1, WorkflowStageType.ANNOTATION, 1); - var targetStage = CreateTestWorkflowStage(2, WorkflowStageType.COMPLETION, null); // No target data source + var currentStage = CreateTestWorkflowStage(1, WorkflowStageType.ANNOTATION, null); // Current stage has no target data source + var targetStage = CreateTestWorkflowStage(2, WorkflowStageType.COMPLETION, 3); var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); @@ -130,10 +130,10 @@ public async System.Threading.Tasks.Task TransferAssetToAnnotationAsync_WithVali var mockDataSource = new DataSourceDto { Id = 1, Name = "Test Annotation Source", IsDefault = true, ProjectId = asset.ProjectId, SourceType = DataSourceType.MINIO_BUCKET, Status = DataSourceStatus.ACTIVE, CreatedAt = DateTime.UtcNow, AssetCount = 10 }; var mockWorkflowDataSources = new WorkflowDataSources { AnnotationDataSource = mockDataSource }; _mockDataSourceService.Setup(x => x.EnsureRequiredDataSourcesExistAsync(asset.ProjectId, false)) - .ReturnsAsync(mockWorkflowDataSources); + .ReturnsAsync(mockWorkflowDataSources); _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, mockDataSource.Id)) - .ReturnsAsync(true); + .ReturnsAsync(true); var step = CreateStep(); @@ -158,10 +158,10 @@ public async System.Threading.Tasks.Task TransferAssetToAnnotationAsync_WithTran var mockDataSource = new DataSourceDto { Id = 1, Name = "Test Annotation Source", IsDefault = true, ProjectId = asset.ProjectId, SourceType = DataSourceType.MINIO_BUCKET, Status = DataSourceStatus.ACTIVE, CreatedAt = DateTime.UtcNow, AssetCount = 10 }; var mockWorkflowDataSources = new WorkflowDataSources { AnnotationDataSource = mockDataSource }; _mockDataSourceService.Setup(x => x.EnsureRequiredDataSourcesExistAsync(asset.ProjectId, false)) - .ReturnsAsync(mockWorkflowDataSources); + .ReturnsAsync(mockWorkflowDataSources); _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, mockDataSource.Id)) - .ReturnsAsync(false); + .ReturnsAsync(false); var step = CreateStep(); @@ -183,8 +183,8 @@ public async System.Threading.Tasks.Task ExecuteAsync_ShouldDelegateToTransferAs var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value)) - .ReturnsAsync(true); + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + .ReturnsAsync(true); var step = CreateStep(); @@ -193,7 +193,7 @@ public async System.Threading.Tasks.Task ExecuteAsync_ShouldDelegateToTransferAs // Assert Assert.NotNull(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } [Fact] @@ -208,7 +208,7 @@ public async System.Threading.Tasks.Task RollbackAsync_WithValidContext_ShouldTr .WithTargetStage(targetStage); _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) - .ReturnsAsync(true); + .ReturnsAsync(true); var step = CreateStep(); @@ -230,7 +230,7 @@ public async System.Threading.Tasks.Task RollbackAsync_WithTransferFailure_Shoul var context = new PipelineContext(task, asset, currentStage, "user123"); _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .ReturnsAsync(false); var step = CreateStep(); @@ -268,8 +268,8 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithVariousWorkflowS var context = new PipelineContext(task, asset, currentStage, "user123") .WithTargetStage(targetStage); - _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value)) - .ReturnsAsync(true); + _mockAssetService.Setup(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value)) + .ReturnsAsync(true); var step = CreateStep(); @@ -278,7 +278,7 @@ public async System.Threading.Tasks.Task TransferAssetAsync_WithVariousWorkflowS // Assert Assert.NotNull(result); - _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, targetStage.TargetDataSourceId!.Value), Times.Once); + _mockAssetService.Verify(x => x.TransferAssetToDataSourceAsync(asset.AssetId, currentStage.TargetDataSourceId!.Value), Times.Once); } #region Helper Methods From 5eea4d0c9a3b87d90d3797a113e14d7f2cdccd25 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:20 +0200 Subject: [PATCH 02/15] feat: Add comprehensive tests for ExportService including metadata retrieval and COCO format export --- .../Services/ExportServiceTests.cs | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 server/Server.Tests/Services/ExportServiceTests.cs diff --git a/server/Server.Tests/Services/ExportServiceTests.cs b/server/Server.Tests/Services/ExportServiceTests.cs new file mode 100644 index 00000000..10145d08 --- /dev/null +++ b/server/Server.Tests/Services/ExportServiceTests.cs @@ -0,0 +1,431 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using server.Data; +using server.Models.Domain; +using server.Models.Domain.Enums; +using server.Models.DTOs.Export; +using server.Services; +using Server.Tests.Factories; +using System.Text.Json; +using LaberisTask = server.Models.Domain.Task; +using TaskAsync = System.Threading.Tasks.Task; + +namespace Server.Tests.Services; + +public class ExportServiceTests +{ + private readonly Mock> _mockLogger; + + public ExportServiceTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async TaskAsync GetExportMetadataAsync_WithValidStage_ReturnsCorrectMetadata() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + var (projectId, workflowStageId) = await SetupTestDataAsync(context); + + // Act + var result = await exportService.GetExportMetadataAsync(projectId, workflowStageId); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Project", result.ProjectName); + Assert.Equal("Completion Stage", result.WorkflowStageName); + Assert.Equal(2, result.CompletedTasksCount); + Assert.Equal(3, result.AnnotationsCount); // 2 + 1 annotations + Assert.Equal(2, result.AnnotatedAssetsCount); // 2 unique assets + Assert.Equal(2, result.CategoriesCount); // 2 unique labels + Assert.Contains("COCO", result.AvailableFormats); + } + + [Fact] + public async TaskAsync GetExportMetadataAsync_WithNonExistentStage_ThrowsInvalidOperationException() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + // Act & Assert + await Assert.ThrowsAsync( + () => exportService.GetExportMetadataAsync(1, 999)); + } + + [Fact] + public async TaskAsync ExportCocoFormatAsync_WithCompletedTasks_ReturnsValidCocoData() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + var (projectId, workflowStageId) = await SetupTestDataAsync(context); + + // Act + var result = await exportService.ExportCocoFormatAsync(projectId, workflowStageId, true, false); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Parse the JSON to verify structure + var jsonString = System.Text.Encoding.UTF8.GetString(result); + var cocoData = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + Assert.NotNull(cocoData); + Assert.NotNull(cocoData.Info); + Assert.Equal("Export from Test Project - Completion Stage", cocoData.Info.Description); + Assert.Equal(2, cocoData.Images.Count); + Assert.Equal(3, cocoData.Annotations.Count); + Assert.Equal(2, cocoData.Categories.Count); + + // Verify image data + var firstImage = cocoData.Images.First(); + Assert.Equal("test-asset-1.jpg", firstImage.FileName); + Assert.Equal(800, firstImage.Width); + Assert.Equal(600, firstImage.Height); + + // Verify annotation data + var firstAnnotation = cocoData.Annotations.First(); + Assert.True(firstAnnotation.Id > 0); + Assert.True(firstAnnotation.ImageId > 0); + Assert.True(firstAnnotation.CategoryId > 0); + Assert.NotNull(firstAnnotation.Attributes); + Assert.True((bool)firstAnnotation.Attributes["is_ground_truth"]); + + // Verify category data + var firstCategory = cocoData.Categories.First(); + Assert.Equal("Test Label 1", firstCategory.Name); + Assert.Equal("Test Label Scheme", firstCategory.SuperCategory); + } + + [Fact] + public async TaskAsync ExportCocoFormatAsync_WithIncludePredictions_IncludesPredictionAnnotations() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + var (projectId, workflowStageId) = await SetupTestDataWithPredictionsAsync(context); + + // Act - Include predictions + var result = await exportService.ExportCocoFormatAsync(projectId, workflowStageId, true, true); + + // Parse and verify + var jsonString = System.Text.Encoding.UTF8.GetString(result); + var cocoData = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + // Should include both ground truth and prediction annotations + Assert.NotNull(cocoData); + Assert.Equal(4, cocoData.Annotations.Count); // 3 ground truth + 1 prediction + + // Verify prediction annotation exists + var predictionAnnotations = cocoData.Annotations + .Where(a => a.Attributes != null && a.Attributes.ContainsKey("is_prediction") && (bool)a.Attributes["is_prediction"]) + .ToList(); + Assert.Single(predictionAnnotations); + } + + [Fact] + public async TaskAsync ExportCocoFormatAsync_WithExcludePredictions_OnlyIncludesGroundTruth() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + var (projectId, workflowStageId) = await SetupTestDataWithPredictionsAsync(context); + + // Act - Exclude predictions + var result = await exportService.ExportCocoFormatAsync(projectId, workflowStageId, true, false); + + // Parse and verify + var jsonString = System.Text.Encoding.UTF8.GetString(result); + var cocoData = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + // Should only include ground truth annotations + Assert.NotNull(cocoData); + Assert.Equal(3, cocoData.Annotations.Count); // Only ground truth + + // Verify no prediction annotations + var predictionAnnotations = cocoData.Annotations + .Where(a => a.Attributes != null && a.Attributes.ContainsKey("is_prediction") && (bool)a.Attributes["is_prediction"]) + .ToList(); + Assert.Empty(predictionAnnotations); + } + + [Fact] + public async TaskAsync ExportCocoFormatAsync_WithOnlyInProgressTasks_ReturnsEmpty() + { + // Arrange + using var factory = new DbContextFactory(); + var context = factory.Context; + var exportService = new ExportService(context, _mockLogger.Object); + + var (projectId, workflowStageId) = await SetupTestDataWithInProgressTasksAsync(context); + + // Act + var result = await exportService.ExportCocoFormatAsync(projectId, workflowStageId, true, false); + + // Parse and verify + var jsonString = System.Text.Encoding.UTF8.GetString(result); + var cocoData = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + // Should be empty since no completed tasks + Assert.NotNull(cocoData); + Assert.Empty(cocoData.Images); + Assert.Empty(cocoData.Annotations); + Assert.Empty(cocoData.Categories); + } + + private async Task<(int projectId, int workflowStageId)> SetupTestDataAsync(LaberisDbContext context) + { + // Create project + var project = new Project + { + Name = "Test Project", + Description = "Test project for export", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Projects.Add(project); + await context.SaveChangesAsync(); + + // Create label scheme + var labelScheme = new LabelScheme + { + Name = "Test Label Scheme", + Description = "Test labels", + ProjectId = project.ProjectId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.LabelSchemes.Add(labelScheme); + await context.SaveChangesAsync(); + + // Create labels + var label1 = new Label + { + Name = "Test Label 1", + Color = "#FF0000", + LabelSchemeId = labelScheme.LabelSchemeId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var label2 = new Label + { + Name = "Test Label 2", + Color = "#00FF00", + LabelSchemeId = labelScheme.LabelSchemeId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Labels.AddRange(label1, label2); + await context.SaveChangesAsync(); + + // Create data source + var dataSource = new DataSource + { + Name = "Test Data Source", + ProjectId = project.ProjectId, + SourceType = DataSourceType.S3_BUCKET, + Status = DataSourceStatus.ACTIVE, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.DataSources.Add(dataSource); + await context.SaveChangesAsync(); + + // Create workflow + var workflow = new Workflow + { + Name = "Test Workflow", + ProjectId = project.ProjectId, + LabelSchemeId = labelScheme.LabelSchemeId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Workflows.Add(workflow); + await context.SaveChangesAsync(); + + // Create workflow stage (COMPLETION type) + var workflowStage = new WorkflowStage + { + Name = "Completion Stage", + WorkflowId = workflow.WorkflowId, + StageType = WorkflowStageType.COMPLETION, + StageOrder = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.WorkflowStages.Add(workflowStage); + await context.SaveChangesAsync(); + + // Create assets + var asset1 = new Asset + { + ExternalId = "asset-1", + Filename = "test-asset-1.jpg", + Width = 800, + Height = 600, + MimeType = "image/jpeg", + ProjectId = project.ProjectId, + DataSourceId = dataSource.DataSourceId, + Status = AssetStatus.IMPORTED, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var asset2 = new Asset + { + ExternalId = "asset-2", + Filename = "test-asset-2.jpg", + Width = 1024, + Height = 768, + MimeType = "image/jpeg", + ProjectId = project.ProjectId, + DataSourceId = dataSource.DataSourceId, + Status = AssetStatus.IMPORTED, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Assets.AddRange(asset1, asset2); + await context.SaveChangesAsync(); + + // Create completed tasks + var task1 = new LaberisTask + { + AssetId = asset1.AssetId, + ProjectId = project.ProjectId, + WorkflowId = workflow.WorkflowId, + WorkflowStageId = workflowStage.WorkflowStageId, + Status = server.Models.Domain.Enums.TaskStatus.COMPLETED, + CompletedAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var task2 = new LaberisTask + { + AssetId = asset2.AssetId, + ProjectId = project.ProjectId, + WorkflowId = workflow.WorkflowId, + WorkflowStageId = workflowStage.WorkflowStageId, + Status = server.Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Tasks.AddRange(task1, task2); + await context.SaveChangesAsync(); + + // Create annotations + var annotation1 = new Annotation + { + TaskId = task1.TaskId, + AssetId = asset1.AssetId, + LabelId = label1.LabelId, + AnnotationType = AnnotationType.BOUNDING_BOX, + Data = JsonSerializer.Serialize(new { x = 10, y = 20, width = 100, height = 80 }), + IsGroundTruth = true, + AnnotatorUserId = "test-user", + Version = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var annotation2 = new Annotation + { + TaskId = task1.TaskId, + AssetId = asset1.AssetId, + LabelId = label2.LabelId, + AnnotationType = AnnotationType.POLYGON, + Data = JsonSerializer.Serialize(new { points = new[] { new { x = 50, y = 60 }, new { x = 150, y = 60 }, new { x = 100, y = 140 } } }), + IsGroundTruth = true, + AnnotatorUserId = "test-user", + Version = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + var annotation3 = new Annotation + { + TaskId = task2.TaskId, + AssetId = asset2.AssetId, + LabelId = label1.LabelId, + AnnotationType = AnnotationType.POINT, + Data = JsonSerializer.Serialize(new { x = 200, y = 300 }), + IsGroundTruth = true, + AnnotatorUserId = "test-user", + Version = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Annotations.AddRange(annotation1, annotation2, annotation3); + await context.SaveChangesAsync(); + + return (project.ProjectId, workflowStage.WorkflowStageId); + } + + private async System.Threading.Tasks.Task<(int projectId, int workflowStageId)> SetupTestDataWithPredictionsAsync(LaberisDbContext context) + { + var (projectId, workflowStageId) = await SetupTestDataAsync(context); + + // Add prediction annotation + var task = await context.Tasks.FirstAsync(); + var asset = await context.Assets.FirstAsync(); + var label = await context.Labels.FirstAsync(); + + var predictionAnnotation = new Annotation + { + TaskId = task.TaskId, + AssetId = asset.AssetId, + LabelId = label.LabelId, + AnnotationType = AnnotationType.BOUNDING_BOX, + Data = JsonSerializer.Serialize(new { x = 50, y = 50, width = 80, height = 60 }), + IsPrediction = true, + IsGroundTruth = false, + ConfidenceScore = 0.85, + AnnotatorUserId = "ai-model", + Version = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Annotations.Add(predictionAnnotation); + await context.SaveChangesAsync(); + + return (projectId, workflowStageId); + } + + private async Task<(int projectId, int workflowStageId)> SetupTestDataWithInProgressTasksAsync(LaberisDbContext context) + { + var (projectId, workflowStageId) = await SetupTestDataAsync(context); + + // Update all tasks to be IN_PROGRESS instead of COMPLETED + var tasks = await context.Tasks.ToListAsync(); + foreach (var task in tasks) + { + task.Status = server.Models.Domain.Enums.TaskStatus.IN_PROGRESS; + task.CompletedAt = null; + } + await context.SaveChangesAsync(); + + return (projectId, workflowStageId); + } +} \ No newline at end of file From ac3b554803c6cfe2f90d24f0d6fce832d15527fe Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:27 +0200 Subject: [PATCH 03/15] feat: Implement ExportService and IExportService for COCO format data export --- server/Server/Services/ExportService.cs | 447 ++++++++++++++++++ .../Services/Interfaces/IExportService.cs | 27 ++ 2 files changed, 474 insertions(+) create mode 100644 server/Server/Services/ExportService.cs create mode 100644 server/Server/Services/Interfaces/IExportService.cs diff --git a/server/Server/Services/ExportService.cs b/server/Server/Services/ExportService.cs new file mode 100644 index 00000000..82d63795 --- /dev/null +++ b/server/Server/Services/ExportService.cs @@ -0,0 +1,447 @@ +using Microsoft.EntityFrameworkCore; +using server.Data; +using server.Models.DTOs.Export; +using server.Models.Domain.Enums; +using server.Services.Interfaces; +using System.Text.Json; + +namespace server.Services; + +/// +/// Service for exporting annotated data in various formats +/// +public class ExportService : IExportService +{ + private readonly LaberisDbContext _context; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public ExportService(LaberisDbContext context, ILogger logger) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Exports completed tasks from a workflow stage in COCO format + /// + public async Task ExportCocoFormatAsync(int projectId, int workflowStageId, bool includeGroundTruth = true, bool includePredictions = false) + { + _logger.LogInformation("Starting COCO export for project {ProjectId}, workflow stage {WorkflowStageId}", projectId, workflowStageId); + + // Get workflow stage info + var workflowStage = await _context.WorkflowStages + .Include(ws => ws.Workflow) + .ThenInclude(w => w.Project) + .FirstOrDefaultAsync( + ws => ws.WorkflowStageId == workflowStageId && ws.Workflow.ProjectId == projectId + ) ?? throw new InvalidOperationException($"Workflow stage {workflowStageId} not found in project {projectId}"); + + // Get completed tasks from the workflow stage with their annotations + _logger.LogDebug("Querying for tasks with: ProjectId={ProjectId}, WorkflowStageId={WorkflowStageId}, IncludeGroundTruth={IncludeGroundTruth}, IncludePredictions={IncludePredictions}", + projectId, workflowStageId, includeGroundTruth, includePredictions); + + var completedTasks = await _context.Tasks + .Where(t => t.WorkflowStageId == workflowStageId && + t.ProjectId == projectId && + (t.Status == Models.Domain.Enums.TaskStatus.COMPLETED || t.Status == Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION)) + .Include(t => t.Asset) + .Include(t => t.Annotations + .Where(a => a.DeletedAt == null && (includeGroundTruth && a.IsGroundTruth || includePredictions && a.IsPrediction))) + .ThenInclude(a => a.Label) + .ThenInclude(l => l.LabelScheme) + .ToListAsync(); + + _logger.LogInformation("Found {TaskCount} completed tasks for export", completedTasks.Count); + + // Debug task statuses + var allTasks = await _context.Tasks + .Where(t => t.WorkflowStageId == workflowStageId && t.ProjectId == projectId) + .Select(t => new { t.TaskId, t.Status, t.WorkflowStageId, t.ProjectId }) + .ToListAsync(); + _logger.LogDebug("All tasks in workflow stage: {@Tasks}", allTasks); + + // Debug annotations + var totalAnnotations = completedTasks.SelectMany(t => t.Annotations).Count(); + _logger.LogInformation("Total annotations found: {AnnotationCount}", totalAnnotations); + + // Create COCO dataset + var cocoDataset = new CocoDatasetDto + { + Info = new CocoInfoDto + { + Year = DateTime.UtcNow.Year, + Version = "1.0", + Description = $"Export from {workflowStage.Workflow.Project.Name} - {workflowStage.Name}", + Contributor = "Laberis", + DateCreated = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + } + }; + + // Get all unique labels (categories) + var allLabels = completedTasks + .SelectMany(t => t.Annotations.Select(a => a.Label)) + .DistinctBy(l => l.LabelId) + .OrderBy(l => l.LabelId) + .ToList(); + + // Create categories + cocoDataset.Categories = [.. allLabels.Select(label => new CocoCategoryDto + { + Id = label.LabelId, + Name = label.Name, + SuperCategory = label.LabelScheme.Name, + Color = label.Color, + Metadata = string.IsNullOrEmpty(label.Metadata) ? null : + JsonSerializer.Deserialize>(label.Metadata) + })]; + + // Get all unique assets with annotations + var assetsWithAnnotations = completedTasks + .Where(t => t.Annotations.Count != 0) + .Select(t => t.Asset) + .DistinctBy(a => a.AssetId) + .OrderBy(a => a.AssetId) + .ToList(); + + // Create images + cocoDataset.Images = [.. assetsWithAnnotations.Select(asset => new CocoImageDto + { + Id = asset.AssetId, + Width = asset.Width ?? 0, + Height = asset.Height ?? 0, + FileName = asset.Filename, + DateCaptured = asset.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") + })]; + + // Create annotations + var annotationId = 1L; + foreach (var task in completedTasks.Where(t => t.Annotations.Count != 0)) + { + foreach (var annotation in task.Annotations) + { + var cocoAnnotation = new CocoAnnotationDto + { + Id = annotationId++, + ImageId = task.Asset.AssetId, + CategoryId = annotation.LabelId + }; + + // Parse annotation data to extract segmentation and bounding box + try + { + var annotationData = JsonSerializer.Deserialize(annotation.Data); + + // Handle different annotation types + switch (annotation.AnnotationType) + { + case AnnotationType.BOUNDING_BOX: + var bbox = ExtractBoundingBox(annotationData); + cocoAnnotation.BBox = bbox; + cocoAnnotation.Area = bbox.Count >= 4 ? bbox[2] * bbox[3] : 0; + cocoAnnotation.Segmentation = new List(); // Empty for bounding box + break; + + case AnnotationType.POLYGON: + var polygon = ExtractPolygon(annotationData); + cocoAnnotation.Segmentation = polygon; + cocoAnnotation.Area = CalculatePolygonArea(polygon); + cocoAnnotation.BBox = CalculateBoundingBoxFromPolygon(polygon); + break; + + case AnnotationType.POLYLINE: + var polyline = ExtractPolyline(annotationData); + cocoAnnotation.Segmentation = new List(); + cocoAnnotation.Area = 0; // Area is zero for polyline // TODO: Should polyline have an area? + cocoAnnotation.BBox = CalculateBoundingBoxFromPolyline(polyline); + break; + + case AnnotationType.LINE: + var line = ExtractLine(annotationData); + cocoAnnotation.Segmentation = new List(); + cocoAnnotation.Area = 0; // TODO: Should line have an area? + cocoAnnotation.BBox = [0, 0, 0, 0]; + break; + + case AnnotationType.POINT: + var point = ExtractPoint(annotationData); + cocoAnnotation.Segmentation = new List(); + cocoAnnotation.Area = 1; // Single pixel + cocoAnnotation.BBox = [point[0], point[1], 1, 1]; // 1x1 bbox around point + break; + + default: + // For unsupported types, create empty segmentation + cocoAnnotation.Segmentation = new List(); + cocoAnnotation.Area = 0; + cocoAnnotation.BBox = [0, 0, 0, 0]; + break; + } + + // Add custom attributes + cocoAnnotation.Attributes = new Dictionary + { + ["confidence_score"] = annotation.ConfidenceScore ?? 1.0, + ["is_ground_truth"] = annotation.IsGroundTruth, + ["is_prediction"] = annotation.IsPrediction, + ["annotation_type"] = annotation.AnnotationType.ToString(), + ["version"] = annotation.Version + }; + + if (!string.IsNullOrEmpty(annotation.Notes)) + { + cocoAnnotation.Attributes["notes"] = annotation.Notes; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse annotation data for annotation {AnnotationId}", annotation.AnnotationId); + // Create empty annotation + cocoAnnotation.Segmentation = new List(); + cocoAnnotation.Area = 0; + cocoAnnotation.BBox = [0, 0, 0, 0]; + } + + cocoDataset.Annotations.Add(cocoAnnotation); + } + } + + // Serialize to JSON + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(cocoDataset, _jsonOptions); + + _logger.LogInformation("COCO export completed: {ImagesCount} images, {AnnotationsCount} annotations, {CategoriesCount} categories", + cocoDataset.Images.Count, cocoDataset.Annotations.Count, cocoDataset.Categories.Count); + + return jsonBytes; + } + + /// + /// Gets export metadata for a workflow stage + /// + public async Task GetExportMetadataAsync(int projectId, int workflowStageId) + { + var workflowStage = await _context.WorkflowStages + .Include(ws => ws.Workflow) + .ThenInclude(w => w.Project) + .FirstOrDefaultAsync( + ws => ws.WorkflowStageId == workflowStageId && ws.Workflow.ProjectId == projectId + ) ?? throw new InvalidOperationException($"Workflow stage {workflowStageId} not found in project {projectId}"); + + var completedTasksCount = await _context.Tasks + .CountAsync(t => t.WorkflowStageId == workflowStageId && + t.ProjectId == projectId && + (t.Status == Models.Domain.Enums.TaskStatus.COMPLETED || t.Status == Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION)); + + var annotationsCount = await _context.Annotations + .CountAsync(a => a.Task.WorkflowStageId == workflowStageId && + a.Task.ProjectId == projectId && + (a.Task.Status == Models.Domain.Enums.TaskStatus.COMPLETED || a.Task.Status == Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION) && + a.DeletedAt == null); + + var annotatedAssetsCount = await _context.Annotations + .Where(a => a.Task.WorkflowStageId == workflowStageId && + a.Task.ProjectId == projectId && + (a.Task.Status == Models.Domain.Enums.TaskStatus.COMPLETED || a.Task.Status == Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION) && + a.DeletedAt == null) + .Select(a => a.AssetId) + .Distinct() + .CountAsync(); + + var categoriesCount = await _context.Annotations + .Where(a => a.Task.WorkflowStageId == workflowStageId && + a.Task.ProjectId == projectId && + (a.Task.Status == Models.Domain.Enums.TaskStatus.COMPLETED || a.Task.Status == Models.Domain.Enums.TaskStatus.READY_FOR_COMPLETION) && + a.DeletedAt == null) + .Select(a => a.LabelId) + .Distinct() + .CountAsync(); + + return new ExportMetadataDto + { + CompletedTasksCount = completedTasksCount, + AnnotationsCount = annotationsCount, + AnnotatedAssetsCount = annotatedAssetsCount, + CategoriesCount = categoriesCount, + WorkflowStageName = workflowStage.Name, + ProjectName = workflowStage.Workflow.Project.Name, + AvailableFormats = ["COCO"] + }; + } + + #region Private Helper Methods + + /// + /// Extracts bounding box from annotation data + /// Expected format: [x, y, width, height] + /// + private static List ExtractBoundingBox(JsonElement data) + { + if (data.TryGetProperty("bbox", out var bboxElement) && bboxElement.ValueKind == JsonValueKind.Array) + { + return [.. bboxElement.EnumerateArray().Select(e => e.GetDouble())]; + } + + if (data.TryGetProperty("x", out var xElement) && + data.TryGetProperty("y", out var yElement) && + data.TryGetProperty("width", out var widthElement) && + data.TryGetProperty("height", out var heightElement)) + { + return [xElement.GetDouble(), yElement.GetDouble(), widthElement.GetDouble(), heightElement.GetDouble()]; + } + + return [0, 0, 0, 0]; + } + + /// + /// Extracts line from annotation data + /// Expected format: array of [x1, y1, x2, y2] + /// + private static List ExtractLine(JsonElement data) + { + if (data.TryGetProperty("line", out var lineElement) && lineElement.ValueKind == JsonValueKind.Array) + { + return [.. lineElement.EnumerateArray().Select(e => e.GetDouble())]; + } + + return [0, 0, 0, 0]; + } + + /// + /// Extracts polyline from annotation data + /// Expected format: array of [x1, y1, x2, y2, ..., xn, yn] + /// + private static List ExtractPolyline(JsonElement data) + { + if (data.TryGetProperty("polyline", out var polylineElement) && polylineElement.ValueKind == JsonValueKind.Array) + { + return [.. polylineElement.EnumerateArray().Select(e => e.GetDouble())]; + } + + return []; + } + + /// + /// Extracts polygon from annotation data + /// Expected format: array of [x1, y1, x2, y2, ..., xn, yn] + /// + private static List ExtractPolygon(JsonElement data) + { + if (data.TryGetProperty("points", out var pointsElement) && pointsElement.ValueKind == JsonValueKind.Array) + { + var points = new List(); + foreach (var point in pointsElement.EnumerateArray()) + { + if (point.TryGetProperty("x", out var x) && point.TryGetProperty("y", out var y)) + { + points.Add(x.GetDouble()); + points.Add(y.GetDouble()); + } + } + return points; + } + + if (data.TryGetProperty("polygon", out var polygonElement) && polygonElement.ValueKind == JsonValueKind.Array) + { + return [.. polygonElement.EnumerateArray().Select(e => e.GetDouble())]; + } + + return []; + } + + /// + /// Extracts point from annotation data + /// Expected format: [x, y] + /// + private static List ExtractPoint(JsonElement data) + { + if (data.TryGetProperty("x", out var xElement) && data.TryGetProperty("y", out var yElement)) + { + return [xElement.GetDouble(), yElement.GetDouble()]; + } + + if (data.TryGetProperty("point", out var pointElement) && pointElement.ValueKind == JsonValueKind.Array) + { + return [.. pointElement.EnumerateArray().Select(e => e.GetDouble())]; + } + + return [0, 0]; + } + + /// + /// Calculates the area of a polygon using the shoelace formula + /// + private static double CalculatePolygonArea(List polygon) + { + if (polygon.Count < 6) return 0; // Need at least 3 points (6 coordinates) + + double area = 0; + int n = polygon.Count / 2; + + for (int i = 0; i < n; i++) + { + int j = (i + 1) % n; + area += polygon[i * 2] * polygon[j * 2 + 1]; + area -= polygon[j * 2] * polygon[i * 2 + 1]; + } + + return Math.Abs(area) / 2.0; + } + + /// + /// Calculates bounding box from polyline points + /// Returns [x, y, width, height] + /// + private static List CalculateBoundingBoxFromPolyline(List polyline) + { + if (polyline.Count < 4) return [0, 0, 0, 0]; + + var minX = polyline[0]; + var maxX = polyline[0]; + var minY = polyline[1]; + var maxY = polyline[1]; + + for (int i = 2; i < polyline.Count; i += 2) + { + minX = Math.Min(minX, polyline[i]); + maxX = Math.Max(maxX, polyline[i]); + minY = Math.Min(minY, polyline[i + 1]); + maxY = Math.Max(maxY, polyline[i + 1]); + } + + return [minX, minY, maxX - minX, maxY - minY]; + } + + /// + /// Calculates bounding box from polygon points + /// Returns [x, y, width, height] + /// + private static List CalculateBoundingBoxFromPolygon(List polygon) + { + if (polygon.Count < 2) return [0, 0, 0, 0]; + + var xCoords = new List(); + var yCoords = new List(); + + for (int i = 0; i < polygon.Count; i += 2) + { + xCoords.Add(polygon[i]); + if (i + 1 < polygon.Count) + yCoords.Add(polygon[i + 1]); + } + + if (xCoords.Count == 0 || yCoords.Count == 0) return [0, 0, 0, 0]; + + var minX = xCoords.Min(); + var maxX = xCoords.Max(); + var minY = yCoords.Min(); + var maxY = yCoords.Max(); + + return [minX, minY, maxX - minX, maxY - minY]; + } + + #endregion +} \ No newline at end of file diff --git a/server/Server/Services/Interfaces/IExportService.cs b/server/Server/Services/Interfaces/IExportService.cs new file mode 100644 index 00000000..d1c72bfe --- /dev/null +++ b/server/Server/Services/Interfaces/IExportService.cs @@ -0,0 +1,27 @@ +using server.Models.DTOs.Export; + +namespace server.Services.Interfaces; + +/// +/// Service for exporting annotated data in various formats +/// +public interface IExportService +{ + /// + /// Exports completed tasks from a workflow stage in COCO format + /// + /// The project ID + /// The workflow stage ID to export from + /// Whether to include ground truth annotations + /// Whether to include prediction annotations + /// COCO dataset as byte array (JSON) + Task ExportCocoFormatAsync(int projectId, int workflowStageId, bool includeGroundTruth = true, bool includePredictions = false); + + /// + /// Gets export metadata for a workflow stage + /// + /// The project ID + /// The workflow stage ID + /// Export metadata including task counts and available formats + Task GetExportMetadataAsync(int projectId, int workflowStageId); +} \ No newline at end of file From f1b4c9f4f982ec10a6f3d4173951e52036164825 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:31 +0200 Subject: [PATCH 04/15] feat: Add COCO export DTOs for dataset, request, and metadata representation --- .../Models/DTOs/Export/CocoExportDto.cs | 126 ++++++++++++++++++ .../DTOs/Export/CocoExportRequestDto.cs | 47 +++++++ .../Models/DTOs/Export/ExportMetadataDto.cs | 42 ++++++ 3 files changed, 215 insertions(+) create mode 100644 server/Server/Models/DTOs/Export/CocoExportDto.cs create mode 100644 server/Server/Models/DTOs/Export/CocoExportRequestDto.cs create mode 100644 server/Server/Models/DTOs/Export/ExportMetadataDto.cs diff --git a/server/Server/Models/DTOs/Export/CocoExportDto.cs b/server/Server/Models/DTOs/Export/CocoExportDto.cs new file mode 100644 index 00000000..74bd43ac --- /dev/null +++ b/server/Server/Models/DTOs/Export/CocoExportDto.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Serialization; + +namespace server.Models.DTOs.Export; + +/// +/// Represents the root COCO dataset format for export +/// +public class CocoDatasetDto +{ + [JsonPropertyName("info")] + public CocoInfoDto Info { get; set; } = null!; + + [JsonPropertyName("images")] + public List Images { get; set; } = []; + + [JsonPropertyName("annotations")] + public List Annotations { get; set; } = []; + + [JsonPropertyName("categories")] + public List Categories { get; set; } = []; +} + +/// +/// COCO dataset information metadata +/// +public class CocoInfoDto +{ + [JsonPropertyName("year")] + public int Year { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } = "1.0"; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("contributor")] + public string Contributor { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("date_created")] + public string DateCreated { get; set; } = string.Empty; +} + +/// +/// COCO image representation +/// +public class CocoImageDto +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("width")] + public int Width { get; set; } + + [JsonPropertyName("height")] + public int Height { get; set; } + + [JsonPropertyName("file_name")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("license")] + public int? License { get; set; } + + [JsonPropertyName("flickr_url")] + public string? FlickrUrl { get; set; } + + [JsonPropertyName("coco_url")] + public string? CocoUrl { get; set; } + + [JsonPropertyName("date_captured")] + public string? DateCaptured { get; set; } +} + +/// +/// COCO annotation representation +/// +public class CocoAnnotationDto +{ + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("image_id")] + public int ImageId { get; set; } + + [JsonPropertyName("category_id")] + public int CategoryId { get; set; } + + [JsonPropertyName("segmentation")] + public object Segmentation { get; set; } = new object(); + + [JsonPropertyName("area")] + public double Area { get; set; } + + [JsonPropertyName("bbox")] + public List BBox { get; set; } = []; + + [JsonPropertyName("iscrowd")] + public int IsCrowd { get; set; } = 0; + + [JsonPropertyName("attributes")] + public Dictionary? Attributes { get; set; } +} + +/// +/// COCO category (label) representation +/// +public class CocoCategoryDto +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("supercategory")] + public string SuperCategory { get; set; } = string.Empty; + + [JsonPropertyName("color")] + public string? Color { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} \ No newline at end of file diff --git a/server/Server/Models/DTOs/Export/CocoExportRequestDto.cs b/server/Server/Models/DTOs/Export/CocoExportRequestDto.cs new file mode 100644 index 00000000..9e0ab5bb --- /dev/null +++ b/server/Server/Models/DTOs/Export/CocoExportRequestDto.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace server.Models.DTOs.Export; + +/// +/// Request DTO for COCO export with configuration options +/// +public class CocoExportRequestDto +{ + /// + /// Whether to include ground truth annotations in the export + /// + public bool IncludeGroundTruth { get; set; } = true; + + /// + /// Whether to include prediction annotations in the export + /// + public bool IncludePredictions { get; set; } = false; + + /// + /// Custom filename for the export (optional) + /// + [StringLength(200, ErrorMessage = "Filename cannot exceed 200 characters")] + public string? FileName { get; set; } + + /// + /// Optional description for the export + /// + [StringLength(500, ErrorMessage = "Description cannot exceed 500 characters")] + public string? Description { get; set; } + + /// + /// Optional contributor information + /// + [StringLength(100, ErrorMessage = "Contributor cannot exceed 100 characters")] + public string? Contributor { get; set; } + + /// + /// Specific task IDs to include in export (optional - if not provided, all completed tasks are included) + /// + public List? TaskIds { get; set; } + + /// + /// Specific label IDs to include in export (optional - if not provided, all labels are included) + /// + public List? LabelIds { get; set; } +} \ No newline at end of file diff --git a/server/Server/Models/DTOs/Export/ExportMetadataDto.cs b/server/Server/Models/DTOs/Export/ExportMetadataDto.cs new file mode 100644 index 00000000..9b7958a2 --- /dev/null +++ b/server/Server/Models/DTOs/Export/ExportMetadataDto.cs @@ -0,0 +1,42 @@ +namespace server.Models.DTOs.Export; + +/// +/// Metadata about available export data for a workflow stage +/// +public class ExportMetadataDto +{ + /// + /// Number of completed tasks available for export + /// + public int CompletedTasksCount { get; set; } + + /// + /// Number of annotations available for export + /// + public int AnnotationsCount { get; set; } + + /// + /// Number of unique assets with annotations + /// + public int AnnotatedAssetsCount { get; set; } + + /// + /// Number of unique labels/categories + /// + public int CategoriesCount { get; set; } + + /// + /// Workflow stage name + /// + public string WorkflowStageName { get; set; } = string.Empty; + + /// + /// Project name + /// + public string ProjectName { get; set; } = string.Empty; + + /// + /// Available export formats + /// + public List AvailableFormats { get; set; } = ["COCO"]; +} \ No newline at end of file From 169fc3a0a1e4b1617e138cd19e186adfb1853d71 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:37 +0200 Subject: [PATCH 05/15] feat: Register IExportService with its implementation in the service collection --- server/Server/Extensions/ServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/Server/Extensions/ServiceCollectionExtensions.cs b/server/Server/Extensions/ServiceCollectionExtensions.cs index 6e5cee41..0f301c18 100644 --- a/server/Server/Extensions/ServiceCollectionExtensions.cs +++ b/server/Server/Extensions/ServiceCollectionExtensions.cs @@ -209,6 +209,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } From 32c36a0a8af9ee2e54dfd29ec626d68261bb27c0 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:41 +0200 Subject: [PATCH 06/15] fix: Update AssetTransferStep to use CurrentStage for data source ID and improve error logging --- .../Core/Workflow/Steps/AssetTransferStep.cs | 16 ++++++++-------- .../Core/Workflow/Steps/TaskManagementStep.cs | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/Server/Core/Workflow/Steps/AssetTransferStep.cs b/server/Server/Core/Workflow/Steps/AssetTransferStep.cs index 18191ffb..366c1477 100644 --- a/server/Server/Core/Workflow/Steps/AssetTransferStep.cs +++ b/server/Server/Core/Workflow/Steps/AssetTransferStep.cs @@ -43,20 +43,20 @@ public async Task TransferAssetAsync( PipelineContext context, CancellationToken cancellationToken = default) { - if (context == null) throw new ArgumentNullException(nameof(context)); - + ArgumentNullException.ThrowIfNull(context); + if (context.TargetStage == null) { throw new InvalidOperationException("Target stage is required for asset transfer"); } - if (context.TargetStage.TargetDataSourceId == null) + if (context.CurrentStage.TargetDataSourceId == null) { throw new InvalidOperationException("Target data source is required for asset transfer"); } _logger.LogInformation("Transferring asset {AssetId} from data source {FromDataSource} to {ToDataSource}", - context.Asset.AssetId, context.Asset.DataSourceId, context.TargetStage.TargetDataSourceId.Value); + context.Asset.AssetId, context.Asset.DataSourceId, context.CurrentStage.TargetDataSourceId.Value); // Store original data source ID for potential rollback _originalDataSourceId = context.Asset.DataSourceId; @@ -65,23 +65,23 @@ public async Task TransferAssetAsync( { var transferResult = await _assetService.TransferAssetToDataSourceAsync( context.Asset.AssetId, - context.TargetStage.TargetDataSourceId.Value); + context.CurrentStage.TargetDataSourceId.Value); if (!transferResult) { throw new InvalidOperationException( - $"Failed to transfer asset {context.Asset.AssetId} to data source {context.TargetStage.TargetDataSourceId.Value}"); + $"Failed to transfer asset {context.Asset.AssetId} to data source {context.CurrentStage.TargetDataSourceId.Value}"); } _logger.LogInformation("Successfully transferred asset {AssetId} to data source {DataSourceId}", - context.Asset.AssetId, context.TargetStage.TargetDataSourceId.Value); + context.Asset.AssetId, context.CurrentStage.TargetDataSourceId.Value); return context; } catch (Exception ex) { _logger.LogError(ex, "Failed to transfer asset {AssetId} to data source {DataSourceId}", - context.Asset.AssetId, context.TargetStage.TargetDataSourceId.Value); + context.Asset.AssetId, context.CurrentStage.TargetDataSourceId.Value); throw; } } diff --git a/server/Server/Core/Workflow/Steps/TaskManagementStep.cs b/server/Server/Core/Workflow/Steps/TaskManagementStep.cs index 0d80679e..3b87c6f2 100644 --- a/server/Server/Core/Workflow/Steps/TaskManagementStep.cs +++ b/server/Server/Core/Workflow/Steps/TaskManagementStep.cs @@ -80,14 +80,15 @@ public async Task CreateOrUpdateTaskForTargetStageAsync( if (existingTask == null) { - // Create a new task + // Create a new task with appropriate ready status + var readyStatus = GetReadyStatusForStageType(context.TargetStage.StageType); var newTask = new LaberisTask { AssetId = context.Asset.AssetId, WorkflowStageId = context.TargetStage.WorkflowStageId, ProjectId = context.Asset.ProjectId, WorkflowId = context.TargetStage.WorkflowId, - Status = TaskStatus.NOT_STARTED, + Status = readyStatus, AssignedToUserId = null, // Will be assigned later based on workflow stage assignments CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow @@ -104,8 +105,8 @@ public async Task CreateOrUpdateTaskForTargetStageAsync( }; context.SetStepContext(StepName, rollbackData); - _logger.LogInformation("Created new task {TaskId} for asset {AssetId} in stage {StageId}", - newTask.TaskId, context.Asset.AssetId, context.TargetStage.WorkflowStageId); + _logger.LogInformation("Created new task {TaskId} with {Status} status for asset {AssetId} in stage {StageId}", + newTask.TaskId, readyStatus, context.Asset.AssetId, context.TargetStage.WorkflowStageId); } else { From ec7fc50ad095827ff634c5ee02489bfbed0c03fb Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:46 +0200 Subject: [PATCH 07/15] fix: Enhance immutability in PipelineContext by clarifying context creation in WithTargetStage method --- server/Server/Core/Workflow/Models/PipelineContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/Server/Core/Workflow/Models/PipelineContext.cs b/server/Server/Core/Workflow/Models/PipelineContext.cs index 4ff1a83e..237373c6 100644 --- a/server/Server/Core/Workflow/Models/PipelineContext.cs +++ b/server/Server/Core/Workflow/Models/PipelineContext.cs @@ -71,6 +71,7 @@ public PipelineContext( /// public PipelineContext WithTargetStage(WorkflowStage targetStage) { + // Create a new context to keep the original context unchanged - immutability due to threading var newContext = new PipelineContext(Task, Asset, CurrentStage, UserId, Reason) { TargetStage = targetStage ?? throw new ArgumentNullException(nameof(targetStage)), From 50b06add074635c2573acf6991dd163c34ad9bc4 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:28:50 +0200 Subject: [PATCH 08/15] feat: Implement ExportController for COCO format data export with metadata retrieval and error handling --- server/Server/Controllers/ExportController.cs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 server/Server/Controllers/ExportController.cs diff --git a/server/Server/Controllers/ExportController.cs b/server/Server/Controllers/ExportController.cs new file mode 100644 index 00000000..edb477a5 --- /dev/null +++ b/server/Server/Controllers/ExportController.cs @@ -0,0 +1,236 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using server.Authentication; +using server.Models.DTOs.Export; +using server.Services.Interfaces; + +namespace server.Controllers; + +[Route("api/projects/{projectId:int}/[controller]")] +[ApiController] +[Authorize] +[ProjectAccess] +[EnableRateLimiting("project")] +public class ExportController : ControllerBase +{ + private readonly IExportService _exportService; + private readonly ILogger _logger; + + public ExportController(IExportService exportService, ILogger logger) + { + _exportService = exportService ?? throw new ArgumentNullException(nameof(exportService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets export metadata for a workflow stage + /// + /// The project ID + /// The workflow stage ID + /// Export metadata including task counts and available formats + /// Returns the export metadata + /// If the workflow stage is not found + /// If an unexpected error occurs + [HttpGet("workflow-stages/{workflowStageId:int}/metadata")] + [ProducesResponseType(typeof(ExportMetadataDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetExportMetadata(int projectId, int workflowStageId) + { + try + { + var metadata = await _exportService.GetExportMetadataAsync(projectId, workflowStageId); + return Ok(metadata); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Workflow stage {WorkflowStageId} not found in project {ProjectId}", workflowStageId, projectId); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An error occurred while getting export metadata for workflow stage {WorkflowStageId} in project {ProjectId}", + workflowStageId, + projectId + ); + return StatusCode( + StatusCodes.Status500InternalServerError, + "An unexpected error occurred. Please try again later." + ); + } + } + + /// + /// Exports completed tasks from a workflow stage in COCO format + /// + /// The project ID + /// The workflow stage ID + /// Whether to include ground truth annotations (default: true) + /// Whether to include prediction annotations (default: false) + /// COCO format JSON file download + /// Returns the COCO format file + /// If the workflow stage is not found + /// If an unexpected error occurs + [HttpGet("workflow-stages/{workflowStageId:int}/coco")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ExportCoco( + int projectId, + int workflowStageId, + [FromQuery] bool includeGroundTruth = true, + [FromQuery] bool includePredictions = false) + { + try + { + _logger.LogInformation("Starting COCO export for project {ProjectId}, workflow stage {WorkflowStageId}", projectId, workflowStageId); + + var cocoData = await _exportService.ExportCocoFormatAsync(projectId, workflowStageId, includeGroundTruth, includePredictions); + + // Get metadata for filename + var metadata = await _exportService.GetExportMetadataAsync(projectId, workflowStageId); + var fileName = $"{metadata.ProjectName}_{metadata.WorkflowStageName}_coco_export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json"; + + // Sanitize filename + fileName = SanitizeFileName(fileName); + + _logger.LogInformation("COCO export completed for project {ProjectId}, workflow stage {WorkflowStageId}. File size: {FileSize} bytes", + projectId, workflowStageId, cocoData.Length); + + return File(cocoData, "application/json", fileName); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Workflow stage {WorkflowStageId} not found in project {ProjectId}", workflowStageId, projectId); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An error occurred while exporting COCO data for workflow stage {WorkflowStageId} in project {ProjectId}", + workflowStageId, + projectId + ); + return StatusCode( + StatusCodes.Status500InternalServerError, + "An unexpected error occurred during export. Please try again later." + ); + } + } + + /// + /// Exports completed tasks from a workflow stage in COCO format (POST version for complex filtering) + /// + /// The project ID + /// The workflow stage ID + /// Export configuration options + /// COCO format JSON file download + /// Returns the COCO format file + /// If the export request is invalid + /// If the workflow stage is not found + /// If an unexpected error occurs + [HttpPost("workflow-stages/{workflowStageId:int}/coco")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ExportCocoWithOptions( + int projectId, + int workflowStageId, + [FromBody] CocoExportRequestDto exportRequest) + { + try + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + _logger.LogInformation( + "Starting COCO export with options for project {ProjectId}, workflow stage {WorkflowStageId}", + projectId, + workflowStageId + ); + + var cocoData = await _exportService.ExportCocoFormatAsync( + projectId, + workflowStageId, + exportRequest.IncludeGroundTruth, + exportRequest.IncludePredictions); + + // Get metadata for filename + var metadata = await _exportService.GetExportMetadataAsync(projectId, workflowStageId); + var fileName = ( + exportRequest.FileName ?? + $"{metadata.ProjectName}_{metadata.WorkflowStageName}_coco_export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json" + ); + + // Sanitize filename + fileName = SanitizeFileName(fileName); + if (!fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + fileName += ".json"; + } + + _logger.LogInformation( + "COCO export with options completed for project {ProjectId}, workflow stage {WorkflowStageId}. File size: {FileSize} bytes", + projectId, + workflowStageId, + cocoData.Length + ); + + return File(cocoData, "application/json", fileName); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Workflow stage {WorkflowStageId} not found in project {ProjectId}", workflowStageId, projectId); + return NotFound(ex.Message); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An error occurred while exporting COCO data with options for workflow stage {WorkflowStageId} in project {ProjectId}", + workflowStageId, + projectId + ); + return StatusCode( + StatusCodes.Status500InternalServerError, + "An unexpected error occurred during export. Please try again later." + ); + } + } + + #region Private Helper Methods + + /// + /// Sanitizes a filename by removing invalid characters + /// + /// The filename to sanitize + /// A sanitized filename + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(fileName.Where(c => !invalidChars.Contains(c)).ToArray()); + + // Replace spaces with underscores + sanitized = sanitized.Replace(' ', '_'); + + // Limit length to 200 characters + // TODO: Implement proper filename length handling + if (sanitized.Length > 200) + { + var extension = Path.GetExtension(sanitized); + var nameWithoutExtension = Path.GetFileNameWithoutExtension(sanitized); + sanitized = nameWithoutExtension[..(200 - extension.Length)] + extension; + } + + return sanitized; + } + + #endregion +} \ No newline at end of file From 43e2a0bd4bd9c6f908f041003bcb6dd6e7611d4c Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:03 +0200 Subject: [PATCH 09/15] refactor: Remove unused loadProjectLabels method and related functionality from projectStore fix: Update isGroundTruth assignment in workspaceStore to avoid defaulting to false --- frontend/src/stores/projectStore.ts | 24 ------------------------ frontend/src/stores/workspaceStore.ts | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/frontend/src/stores/projectStore.ts b/frontend/src/stores/projectStore.ts index 3ef92fb4..604e24ad 100644 --- a/frontend/src/stores/projectStore.ts +++ b/frontend/src/stores/projectStore.ts @@ -75,7 +75,6 @@ export const useProjectStore = defineStore("project", { // Load related data in parallel await Promise.all([ this.loadTeamMembers(projectId), - this.loadProjectLabels(projectId), ]); } catch (error) { logger.error(`Failed to load project ${projectId}`, error); @@ -102,23 +101,6 @@ export const useProjectStore = defineStore("project", { } }, - async loadProjectLabels(projectId: number): Promise { - const { handleError } = useErrorHandler(); - - this.labelsLoading = true; - try { - // TODO: This would need a proper API endpoint for project labels - // For now, we'll skip this functionality until the API is available - this.projectLabels = []; - logger.info(`Labels functionality not yet implemented for project ${projectId}`); - } catch (error) { - logger.error(`Failed to load labels for project ${projectId}`, error); - handleError(error, "Failed to load project labels"); - } finally { - this.labelsLoading = false; - } - }, - addTeamMember(member: ProjectMember): void { this.teamMembers.push(member); logger.info(`Added team member: ${member.userName || member.email}`); @@ -213,12 +195,6 @@ export const useProjectStore = defineStore("project", { } }, - async refreshProjectLabels(): Promise { - if (this.currentProjectId) { - await this.loadProjectLabels(this.currentProjectId); - } - }, - setCurrentStageType(stageType: string): void { this.currentStageType = stageType; logger.info(`Updated current stage type: ${stageType}`); diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/stores/workspaceStore.ts index 80ba1e0e..87d83ceb 100644 --- a/frontend/src/stores/workspaceStore.ts +++ b/frontend/src/stores/workspaceStore.ts @@ -474,7 +474,7 @@ export const useWorkspaceStore = defineStore("workspace", { labelId: annotation.labelId, isPrediction: annotation.isPrediction || false, confidenceScore: annotation.confidenceScore, - isGroundTruth: annotation.isGroundTruth || false, + isGroundTruth: annotation.isGroundTruth, version: annotation.version || 1, notes: annotation.notes, annotatorEmail: annotation.annotatorEmail, From 89be009ebbd9ebc7a3e67502c79c61f54a95a058 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:08 +0200 Subject: [PATCH 10/15] feat: Add ExportService and related types for COCO data export functionality --- .../services/project/export/export.types.ts | 41 +++++ .../services/project/export/exportService.ts | 158 ++++++++++++++++++ frontend/src/services/project/export/index.ts | 12 ++ 3 files changed, 211 insertions(+) create mode 100644 frontend/src/services/project/export/export.types.ts create mode 100644 frontend/src/services/project/export/exportService.ts create mode 100644 frontend/src/services/project/export/index.ts diff --git a/frontend/src/services/project/export/export.types.ts b/frontend/src/services/project/export/export.types.ts new file mode 100644 index 00000000..b769785b --- /dev/null +++ b/frontend/src/services/project/export/export.types.ts @@ -0,0 +1,41 @@ +/** + * Export metadata response + */ +export interface ExportMetadata { + completedTasksCount: number + annotationsCount: number + annotatedAssetsCount: number + categoriesCount: number + workflowStageName: string + projectName: string + availableFormats: string[] +} + +/** + * COCO export request configuration + */ +export interface CocoExportRequest { + includeGroundTruth?: boolean + includePredictions?: boolean + fileName?: string + description?: string + contributor?: string + taskIds?: number[] + labelIds?: number[] +} + +/** + * Export format types + */ +export enum ExportFormat { + COCO = 'COCO' +} + +/** + * Export download response + */ +export interface ExportDownloadResponse { + data: Blob + filename: string + contentType: string +} \ No newline at end of file diff --git a/frontend/src/services/project/export/exportService.ts b/frontend/src/services/project/export/exportService.ts new file mode 100644 index 00000000..616cc00f --- /dev/null +++ b/frontend/src/services/project/export/exportService.ts @@ -0,0 +1,158 @@ +import { BaseProjectService } from '../baseProjectService' +import apiClient from '@/services/apiClient' +import type { + ExportMetadata, + CocoExportRequest, + ExportDownloadResponse +} from './export.types' + +/** + * Service for exporting annotated data in various formats + */ +export class ExportService extends BaseProjectService { + constructor() { + super('ExportService') + } + + /** + * Gets export metadata for a workflow stage + */ + async getExportMetadata(projectId: number, workflowStageId: number): Promise { + const url = this.buildProjectUrl(projectId, `export/workflow-stages/${workflowStageId}/metadata`) + return this.get(url) + } + + /** + * Exports workflow stage data in COCO format (simple GET request) + */ + async exportCoco( + projectId: number, + workflowStageId: number, + includeGroundTruth: boolean = true, + includePredictions: boolean = false + ): Promise { + const url = this.buildProjectUrl(projectId, `export/workflow-stages/${workflowStageId}/coco`) + const params = { + includeGroundTruth: includeGroundTruth.toString(), + includePredictions: includePredictions.toString() + } + + try { + const response = await apiClient.get(url, { + params, + responseType: 'blob' + }) + + // Extract filename from Content-Disposition header + const contentDisposition = response.headers['content-disposition'] || response.headers['Content-Disposition'] + let filename = 'coco_export.json' + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, '') + } + } + + return { + data: response.data as Blob, + filename, + contentType: 'application/json' + } + } catch (error) { + this.logger.error('Failed to export COCO data', error) + throw error + } + } + + /** + * Exports workflow stage data in COCO format with advanced options (POST request) + */ + async exportCocoWithOptions( + projectId: number, + workflowStageId: number, + exportRequest: CocoExportRequest + ): Promise { + const url = this.buildProjectUrl(projectId, `export/workflow-stages/${workflowStageId}/coco`) + + try { + const response = await apiClient.post(url, exportRequest, { + responseType: 'blob' + }) + + // Extract filename from Content-Disposition header + const contentDisposition = response.headers['content-disposition'] || response.headers['Content-Disposition'] + let filename = exportRequest.fileName || 'coco_export.json' + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, '') + } + } + + if (!filename.toLowerCase().endsWith('.json')) { + filename += '.json' + } + + return { + data: response.data as Blob, + filename, + contentType: 'application/json' + } + } catch (error) { + this.logger.error('Failed to export COCO data with options', error) + throw error + } + } + + /** + * Downloads a blob as a file + */ + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } + + /** + * Downloads COCO export directly (convenience method) + */ + async downloadCocoExport( + projectId: number, + workflowStageId: number, + includeGroundTruth: boolean = true, + includePredictions: boolean = false + ): Promise { + const exportResponse = await this.exportCoco( + projectId, + workflowStageId, + includeGroundTruth, + includePredictions + ) + + this.downloadBlob(exportResponse.data, exportResponse.filename) + } + + /** + * Downloads COCO export with options directly (convenience method) + */ + async downloadCocoExportWithOptions( + projectId: number, + workflowStageId: number, + exportRequest: CocoExportRequest + ): Promise { + const exportResponse = await this.exportCocoWithOptions( + projectId, + workflowStageId, + exportRequest + ) + + this.downloadBlob(exportResponse.data, exportResponse.filename) + } +} \ No newline at end of file diff --git a/frontend/src/services/project/export/index.ts b/frontend/src/services/project/export/index.ts new file mode 100644 index 00000000..b7fb5379 --- /dev/null +++ b/frontend/src/services/project/export/index.ts @@ -0,0 +1,12 @@ +import { ExportService } from './exportService' + +export { ExportService } from './exportService' +export type { + ExportMetadata, + CocoExportRequest, + ExportDownloadResponse +} from './export.types' +export { ExportFormat } from './export.types' + +// Create singleton service instance +export const exportService = new ExportService() \ No newline at end of file From d278a480187cd9a103e154b4eba0af3b5add7039 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:18 +0200 Subject: [PATCH 11/15] feat: ExportService added to project service exports --- frontend/src/services/project/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/services/project/index.ts b/frontend/src/services/project/index.ts index 411cdce3..9e43f72a 100644 --- a/frontend/src/services/project/index.ts +++ b/frontend/src/services/project/index.ts @@ -4,6 +4,7 @@ export { annotationService } from './annotationService'; export { assetService } from './asset'; export { dashboardService } from './dashboard'; export { dataSourceService } from './dataSource'; +export { exportService } from './export'; export { labelService, labelSchemeService } from './labelScheme'; export { taskService, taskBulkOperations } from './task'; export { From 8daee0760961559abd001ff2c5aea012f887c31d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:22 +0200 Subject: [PATCH 12/15] feat: Add isGroundTruth property to annotation in tool handlers --- .../workspace/interaction/toolHandlers/boundingBoxToolHandler.ts | 1 + .../core/workspace/interaction/toolHandlers/lineToolHandler.ts | 1 + .../core/workspace/interaction/toolHandlers/pointToolHandler.ts | 1 + .../workspace/interaction/toolHandlers/polygonToolHandler.ts | 1 + .../workspace/interaction/toolHandlers/polylineToolHandler.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts index d670833d..3029569c 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts @@ -105,6 +105,7 @@ export class BoundingBoxToolHandler implements ToolHandler { coordinates: boundingBoxCoordinates, assetId: Number(store.currentAssetId), taskId: Number(store.currentTaskId), + isGroundTruth: true }; store.addAnnotation(newAnnotation); diff --git a/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts index 55e961a6..fa210110 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts @@ -96,6 +96,7 @@ export class LineToolHandler implements ToolHandler { coordinates: lineCoordinates, assetId: Number(store.currentAssetId), taskId: Number(store.currentTaskId), + isGroundTruth: true }; store.addAnnotation(newAnnotation); diff --git a/frontend/src/core/workspace/interaction/toolHandlers/pointToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/pointToolHandler.ts index b1c60d80..db09b213 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/pointToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/pointToolHandler.ts @@ -53,6 +53,7 @@ export class PointToolHandler implements ToolHandler { coordinates: pointCoordinates, assetId: Number(store.currentAssetId), taskId: Number(store.currentTaskId), + isGroundTruth: true }; store.addAnnotation(newAnnotation); diff --git a/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts index e0cdd26e..8c16a4cc 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts @@ -173,6 +173,7 @@ export class PolygonToolHandler implements ToolHandler { coordinates: polygonCoordinates, assetId: Number(store.currentAssetId), taskId: Number(store.currentTaskId), + isGroundTruth: true }; store.addAnnotation(newAnnotation); diff --git a/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts index 28c18fa3..4e923c85 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts @@ -164,6 +164,7 @@ export class PolylineToolHandler implements ToolHandler { coordinates: polylineCoordinates, assetId: Number(store.currentAssetId), taskId: Number(store.currentTaskId), + isGroundTruth: true }; store.addAnnotation(newAnnotation); From 8d336b2fcddf7a9b00e917322bec7428b1a3d605 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:27 +0200 Subject: [PATCH 13/15] feat: Add targetDataSourceId handling in workflow submission stages --- .../src/components/project/workflow/CreateWorkflowWizard.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/project/workflow/CreateWorkflowWizard.vue b/frontend/src/components/project/workflow/CreateWorkflowWizard.vue index e96dfa4b..b8da5c5e 100644 --- a/frontend/src/components/project/workflow/CreateWorkflowWizard.vue +++ b/frontend/src/components/project/workflow/CreateWorkflowWizard.vue @@ -894,6 +894,9 @@ const handleSubmit = async () => { isInitialStage: true, isFinalStage: false, // Annotation stage is never final - completion stage is always final inputDataSourceId: form.annotationInputDataSourceId as number, + targetDataSourceId: form.includeRevision + ? (form.revisionInputDataSourceId as number || undefined) + : (form.completionInputDataSourceId as number || undefined), assignedProjectMemberIds: form.annotationMembers }); @@ -907,6 +910,7 @@ const handleSubmit = async () => { isInitialStage: false, isFinalStage: false, inputDataSourceId: form.revisionInputDataSourceId as number || undefined, + targetDataSourceId: form.completionInputDataSourceId as number || undefined, assignedProjectMemberIds: form.revisionMembers }); } @@ -920,6 +924,7 @@ const handleSubmit = async () => { isInitialStage: false, isFinalStage: true, inputDataSourceId: form.completionInputDataSourceId as number || undefined, + targetDataSourceId: undefined, // Final stage has no target assignedProjectMemberIds: form.completionMembers }); From 9feb2b61551c3b5f0a3b6ffa4524e723e3d7cf8c Mon Sep 17 00:00:00 2001 From: Cemonix Date: Fri, 29 Aug 2025 20:29:32 +0200 Subject: [PATCH 14/15] feat: Add COCO export functionality in completion stage --- frontend/src/views/project/TasksView.vue | 49 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/project/TasksView.vue b/frontend/src/views/project/TasksView.vue index 519627c7..73bb7e30 100644 --- a/frontend/src/views/project/TasksView.vue +++ b/frontend/src/views/project/TasksView.vue @@ -15,6 +15,16 @@
+