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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Carbon.Application/Contracts/IFacialUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Carbon.Application.Dtos.Facial;

namespace Carbon.Application.Contracts;

public interface IFacialUseCase
{
Task<FaceRegisterResponseDto> Register(Guid userId, FaceImageRequestDto request, CancellationToken ct);
Task<FaceVerificationResponseDto> Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct);
}
8 changes: 8 additions & 0 deletions Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.ComponentModel.DataAnnotations;

namespace Carbon.Application.Dtos.Facial;

// Foto em base64. Aceita base64 puro ou "data:image/jpeg;base64,..." (formato da webcam)
public record FaceImageRequestDto(
[Required(ErrorMessage = "ImageBase64 field is required!", AllowEmptyStrings = false)]
string ImageBase64);
5 changes: 5 additions & 0 deletions Carbon.Application/Dtos/Facial/FaceResponseDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Carbon.Application.Dtos.Facial;

public record FaceRegisterResponseDto(Guid UserId, DateTime RegisteredAt);

public record FaceVerificationResponseDto(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt);
55 changes: 55 additions & 0 deletions Carbon.Application/UseCases/FacialUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Carbon.Application.Contracts;
using Carbon.Application.Dtos.Facial;
using Carbon.Domain.Contracts.Services.Facial;
using Carbon.Domain.Exceptions;

namespace Carbon.Application.UseCases;

public class FacialUseCase : IFacialUseCase
{
private readonly IFacialService _facialService;

public FacialUseCase(IFacialService facialService)
{
_facialService = facialService;
}

public async Task<FaceRegisterResponseDto> Register(Guid userId, FaceImageRequestDto request, CancellationToken ct = default)
{
var image = DecodeImage(request.ImageBase64);
var result = await _facialService.RegisterFaceAsync(userId, image, ct);

return new FaceRegisterResponseDto(result.UserId, result.RegisteredAt);
}

public async Task<FaceVerificationResponseDto> Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct = default)
{
var image = DecodeImage(request.ImageBase64);
var result = await _facialService.VerifyFaceAsync(userId, image, ct);

return new FaceVerificationResponseDto(result.UserId, result.IsMatch, result.Similarity, result.VerifiedAt);
}

private static byte[] DecodeImage(string imageBase64)
{
if (string.IsNullOrWhiteSpace(imageBase64))
{
throw new BadRequestException("Image is required.");
}

// Remove o prefixo "data:image/...;base64," se vier da webcam
var commaIndex = imageBase64.IndexOf(',');
var base64 = imageBase64.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && commaIndex >= 0
? imageBase64[(commaIndex + 1)..]
: imageBase64;

try
{
return Convert.FromBase64String(base64.Trim());
}
catch (FormatException)
{
throw new BadRequestException("Image must be a valid base64 string.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@
<RootNamespace>Carbon.Azure.Facial</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FaceAiSharp.Bundle" Version="0.6.35" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
</ItemGroup>

</Project>
5 changes: 0 additions & 5 deletions Carbon.Azure.Facial.Provider/Class1.cs

This file was deleted.

66 changes: 66 additions & 0 deletions Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using FaceAiSharp;
using FaceAiSharp.Extensions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;

namespace Carbon.Azure.Facial.Clients;

// Reconhecimento facial local com a FaceAiSharp. Registrar como Singleton (modelos pesados).
public sealed class FaceRecognitionClient : IFaceRecognitionClient, IDisposable
{
private readonly IFaceDetectorWithLandmarks _detector;
private readonly IFaceEmbeddingsGenerator _generator;
private readonly object _lock = new();

public FaceRecognitionClient()
{
_detector = FaceAiSharpBundleFactory.CreateFaceDetectorWithLandmarks();
_generator = FaceAiSharpBundleFactory.CreateFaceEmbeddingsGenerator();
}

public FaceEmbeddingResult GetEmbedding(byte[] image)
{
Image<Rgb24> img;

try
{
img = Image.Load<Rgb24>(image);
}
catch (ImageFormatException)
{
return new FaceEmbeddingResult(FaceEmbeddingStatus.InvalidImage, null);
}

using (img)
{
lock (_lock)
{
var faces = _detector.DetectFaces(img);

if (faces.Count == 0)
return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null);

if (faces.Count > 1)
return new FaceEmbeddingResult(FaceEmbeddingStatus.MultipleFacesFound, null);

var face = faces.First();

if (face.Landmarks is null)
return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null);

_generator.AlignFaceUsingLandmarks(img, face.Landmarks);
var embedding = _generator.GenerateEmbedding(img);

return new FaceEmbeddingResult(FaceEmbeddingStatus.Success, embedding);
}
}
}

public float Compare(float[] first, float[] second) => first.Dot(second);

public void Dispose()
{
(_detector as IDisposable)?.Dispose();
(_generator as IDisposable)?.Dispose();
}
}
17 changes: 17 additions & 0 deletions Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Carbon.Azure.Facial.Clients;

public interface IFaceRecognitionClient
{
FaceEmbeddingResult GetEmbedding(byte[] image);
float Compare(float[] first, float[] second);
}

public enum FaceEmbeddingStatus
{
Success,
InvalidImage,
NoFaceFound,
MultipleFacesFound
}

public record FaceEmbeddingResult(FaceEmbeddingStatus Status, float[]? Embedding);
40 changes: 40 additions & 0 deletions Carbon.Core/Controllers/FacialController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Carbon.Application.Contracts;
using Carbon.Application.Dtos.Facial;
using Carbon.Domain.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Carbon.Core.Controllers;

[ApiController]
[Authorize]
[Route("Carbon/[controller]")]
public class FacialController : ControllerBase
{
private readonly IFacialUseCase _useCase;

public FacialController(IFacialUseCase useCase)
{
_useCase = useCase;
}

// Cadastra (ou substitui) o rosto do usuário logado
[HttpPost("Register")]
public async Task<FaceRegisterResponseDto> Register(FaceImageRequestDto request, CancellationToken ct = default)
=> await _useCase.Register(GetUserId(), request, ct);

// Compara a foto com o rosto cadastrado do usuário logado
[HttpPost("Verify")]
public async Task<FaceVerificationResponseDto> Verify(FaceImageRequestDto request, CancellationToken ct = default)
=> await _useCase.Verify(GetUserId(), request, ct);

// O id do usuário vem do token JWT (claim "sub")
private Guid GetUserId()
{
var sub = User.FindFirst("sub")?.Value;

return Guid.TryParse(sub, out var userId)
? userId
: throw new UnauthorizedException("Invalid token: user id not found.");
}
}
2 changes: 2 additions & 0 deletions Carbon.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Carbon.Domain.Contracts.Services.Facial;
using Carbon.Application.Contracts;
using Carbon.Application.UseCases;
using Carbon.Core.Contracts;
Expand All @@ -20,6 +21,7 @@ public static void AddCarbonUseCases(this IServiceCollection services)
{
services.AddTransient<IAuthUseCase, AuthUseCase>();
services.AddTransient<IEmissionsUseCase, EmissionsUseCase>();
services.AddTransient<IFacialUseCase, FacialUseCase>();
}

public static void AddCarbonRateLimiter(this IServiceCollection services)
Expand Down
1 change: 1 addition & 0 deletions Carbon.Core/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
builder.Services.AddCarbonUseCases();
builder.Services.AddDataLayer(builder.Configuration);
builder.Services.AddTImpact(builder.Configuration);
builder.Services.AddFacialRecognition();
builder.Services.AddExceptionHandler<ExceptionHandlerMiddleware>();
builder.Services.AddJwtAuthentication(builder.Configuration);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ namespace Carbon.Domain.Contracts.Data.Repositories;
public interface IUserRepository : ICarbonRepository<User, Guid>
{
Task<User?> GetByEmailAsync(string email, CancellationToken ct);
Task UpdateFaceEmbeddingAsync(Guid userId, byte[] faceEmbedding, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Carbon.Domain.Contracts.Providers.Facial;

public interface IFacialRecognitionProvider
{
float[] GetFaceEmbedding(byte[] image);
float Compare(float[] first, float[] second);
}
15 changes: 15 additions & 0 deletions Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Carbon.Domain.Contracts.Services.Facial
{
public record FaceRegisterResponse(Guid UserId, DateTime RegisteredAt);

public record FaceVerificationResponse(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt);

public static class FacialRules
{
// Similaridade mínima para considerar a mesma pessoa (recomendação da FaceAiSharp)
public const float MatchThreshold = 0.42f;

// Tamanho máximo da foto (5 MB)
public const int MaxImageBytes = 5 * 1024 * 1024;
}
}
7 changes: 7 additions & 0 deletions Carbon.Domain/Contracts/Services/Facial/IFacialService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Carbon.Domain.Contracts.Services.Facial;

public interface IFacialService
{
Task<FaceRegisterResponse> RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default);
Task<FaceVerificationResponse> VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default);
}
1 change: 1 addition & 0 deletions Carbon.Domain/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class User
public required string Email { get; set; }
public required string Password { get; set; }
public bool IsActive { get; set; }
public byte[]? FaceEmbedding { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<Role> Roles { get; set; } = [];
public ICollection<RefreshToken> RefreshTokens = [];
Expand Down
75 changes: 75 additions & 0 deletions Carbon.Domain/Services/FacialService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Runtime.InteropServices;
using Carbon.Domain.Contracts.Data;
using Carbon.Domain.Contracts.Providers.Facial;
using Carbon.Domain.Contracts.Services.Facial;
using Carbon.Domain.Exceptions;
using Carbon.Domain.Models;

namespace Carbon.Domain.Services;

public class FacialService : IFacialService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IFacialRecognitionProvider _provider;

public FacialService(IUnitOfWork unitOfWork, IFacialRecognitionProvider provider)
{
_unitOfWork = unitOfWork;
_provider = provider;
}

public async Task<FaceRegisterResponse> RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default)
{
ValidateImage(image);
await GetUserAsync(userId, ct);

var embedding = _provider.GetFaceEmbedding(image);

await _unitOfWork.UserRepository.UpdateFaceEmbeddingAsync(userId, ToBytes(embedding), ct);

return new FaceRegisterResponse(userId, DateTime.UtcNow);
}

public async Task<FaceVerificationResponse> VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default)
{
ValidateImage(image);
var user = await GetUserAsync(userId, ct);

if (user.FaceEmbedding is null || user.FaceEmbedding.Length == 0)
{
throw new BadRequestException("User has no registered face. Register a face before verifying.");
}

var storedEmbedding = ToFloats(user.FaceEmbedding);
var currentEmbedding = _provider.GetFaceEmbedding(image);

var similarity = _provider.Compare(storedEmbedding, currentEmbedding);
var isMatch = similarity >= FacialRules.MatchThreshold;

return new FaceVerificationResponse(userId, isMatch, MathF.Round(similarity, 4), DateTime.UtcNow);
}

private async Task<User> GetUserAsync(Guid userId, CancellationToken ct)
=> await _unitOfWork.UserRepository.GetByIdAsync(userId, ct)
?? throw new NotFoundException($"User with id: {userId} not found!");

private static void ValidateImage(byte[] image)
{
if (image is null || image.Length == 0)
{
throw new BadRequestException("Image is required.");
}

if (image.Length > FacialRules.MaxImageBytes)
{
throw new BadRequestException($"Image must have at most {FacialRules.MaxImageBytes / (1024 * 1024)} MB.");
}
}

// O embedding (512 floats) é guardado no banco como bytes
private static byte[] ToBytes(float[] embedding)
=> MemoryMarshal.AsBytes(embedding.AsSpan()).ToArray();

private static float[] ToFloats(byte[] bytes)
=> MemoryMarshal.Cast<byte, float>(bytes.AsSpan()).ToArray();
}
1 change: 1 addition & 0 deletions Carbon.Infrastructure/Carbon.Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\Carbon.Domain\Carbon.Domain.csproj" />
<ProjectReference Include="..\Carbon.Travel.Impact.Provider\Carbon.Travel.Impact.Provider.csproj" />
<ProjectReference Include="..\Carbon.Azure.Facial.Provider\Carbon.Azure.Facial.Provider.csproj" />
</ItemGroup>

</Project>
Loading
Loading