Desfoque facial automático: proteger a privacidade com AWS & .NET
Use o AWS Rekognition para localizar rostos e, em seguida, desfoque essas regiões localmente com C# e Magick.NET. Esta aplicação de console cria um novo PNG para você revisar antes de compartilhar. A detecção pode deixar passar rostos, e o desfoque não garante anonimato: só envie fotografias à AWS quando tiver permissão e confira se o resultado contém rostos reconhecíveis ou outros detalhes que permitam identificar alguém.
Configurar seu projeto .NET
Estes comandos usam Bash no Linux e o SDK do .NET 10.
O SDK testado é o 10.0.111; use um SDK do .NET 10 com atualizações de manutenção para novos projetos.
Instale o SDK, em vez de apenas o runtime, e confira se dotnet --version funciona antes
de começar.
O Magick.NET inclui sua biblioteca nativa de imagens no pacote
NuGet, então esta aplicação não precisa de um executável separado do ImageMagick.
Comece em um diretório com permissão de escrita, fora de uma árvore de código-fonte .NET existente. A configuração recusa configurações de compilação, pacotes, SDK e NuGet nos diretórios ancestrais para que este pequeno projeto não herde configurações sem relação com ele. Cole este bloco; os parênteses preservam o diretório atual e as opções do seu shell:
(
set -eu
command -v dotnet >/dev/null || { printf '%s\n' 'Install the .NET 10 SDK first.' >&2; exit 1; }
parent=$PWD
while :; do
for config in global.json Directory.Build.props Directory.Build.targets Directory.Packages.props NuGet.Config NuGet.config nuget.config; do
[ ! -e "$parent/$config" ] || { printf '%s\n' 'Use a directory outside an existing .NET configuration tree.' >&2; exit 1; }
done
[ "$parent" -ef / ] && break
parent="$parent/.."
done
[ ! -e FaceBlurAutomation ] || { printf '%s\n' 'Choose a new project directory; FaceBlurAutomation already exists.' >&2; exit 1; }
dotnet new console --framework net10.0 --no-restore -n FaceBlurAutomation
dotnet add FaceBlurAutomation package AWSSDK.Rekognition --version 4.0.101.2
dotnet add FaceBlurAutomation package Magick.NET-Q8-AnyCPU --version 14.17.2
)
Se a instalação falhar depois de criar o projeto, mantenha esse diretório e execute novamente os
dois comandos de pacotes quando a conectividade for restabelecida. A configuração completa recusa
intencionalmente a reutilização de um projeto existente. Todos os comandos seguintes são executados
a partir do diretório original usando --project.
Para este tutorial local, use um perfil existente de credenciais compartilhadas ou credenciais
temporárias fornecidas por meio do seu ambiente. Configure-as por meio da
cadeia padrão de provedores de credenciais do SDK.
A identidade precisa de rekognition:DetectFaces. Exporte AWS_PROFILE com o
nome do seu perfil de credenciais compartilhadas, se necessário, e AWS_REGION
com uma região do Rekognition, como us-east-1, na sua sessão Bash.
Não cole chaves no código.
As análises do Rekognition geram cobranças da AWS. Este exemplo envia os bytes da imagem diretamente;
ele não precisa de um bucket S3 nem de permissões do S3.
Entender o AWS Rekognition
DetectFaces
retorna caixas delimitadoras para até os 100 maiores rostos. Cada coordenada é uma fração da largura
ou da altura da imagem. Rostos nas bordas podem produzir coordenadas fora da imagem, por isso o
código arredonda para fora e restringe os retângulos aos limites da imagem. Rostos de frente
funcionam melhor; uma resposta vazia é motivo para revisão manual, não uma prova de que a imagem
não contém pessoas.
Implementar a detecção e o desfoque de rostos
Use um JPEG ou PNG com uma única imagem e pixels RGB ou em escala de cinza de 8 bits. PNGs não podem ter canal alfa. O exemplo recusa CMYK, canais alfa (inclusive os totalmente opacos), profundidades de bits maiores, PNGs animados e JPEGs com várias imagens; primeiro, exporte uma única imagem como PNG sRGB de 8 bits sem canal alfa no seu editor de imagens. Os limites locais de entrada são 20 MiB e 20 milhões de pixels decodificados, com ambos os lados medindo pelo menos 80 pixels. Esses são limites do tutorial para fotografias locais confiáveis, não um sandbox para uploads maliciosos.
A aplicação orienta os pixels, converte as cores RGB com perfil de cor para sRGB, remove os perfis e comentários da entrada e reduz fotografias cujo lado maior excede 1.600 pixels. Ela envia um PNG com esses mesmos pixels à AWS e edita essa imagem de trabalho. Assim, uma fotografia grande produz um resultado menor; inspecione cuidadosamente os rostos menores. Se o PNG normalizado ainda exceder o limite de 5 MB para envio direto por Bytes, o erro orientará você a exportar uma fotografia menor e tentar novamente. O limite separado de 15 MB para objetos do S3 não se aplica aqui.
Substitua FaceBlurAutomation/Program.cs por este programa completo:
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Amazon.Rekognition;
using Amazon.Rekognition.Model;
using ImageMagick;
namespace FaceBlurAutomation;
public static class Program
{
public static async Task<int> Main(string[] args)
{
try
{
if (args.Length != 2)
throw new ArgumentException("Usage: FaceBlurAutomation <inputImagePath> <newOutput.png>");
return await ProcessImageAsync(args[0], args[1]);
}
catch (Exception error)
{
string message = error switch
{
ArgumentException or InvalidDataException => error.Message,
OperationCanceledException => "AWS analysis timed out after 45 seconds. Check connectivity and retry with a new output path.",
AmazonRekognitionException => "AWS analysis failed. Check your profile, region, DetectFaces permission, and service availability.",
Amazon.Runtime.AmazonClientException => "AWS credentials or client setup failed. Check your shared-credentials profile and AWS_REGION.",
System.Net.Http.HttpRequestException => "AWS connectivity failed. Check your network and region before retrying.",
MagickException => "Image decoding or processing failed. Re-export an intact 8-bit RGB PNG and retry.",
IOException or UnauthorizedAccessException => "File access or publication failed. Check the input, output directory, and existing destination.",
_ => "Processing failed; no successful result was reported."
};
Console.Error.WriteLine(message);
return 1;
}
}
private static async Task<int> ProcessImageAsync(string inputPath, string outputPath)
{
outputPath = Path.GetFullPath(outputPath);
if (!string.Equals(Path.GetExtension(outputPath), ".png", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Choose a .png output path.");
if (File.Exists(outputPath) || Directory.Exists(outputPath))
throw new ArgumentException("Choose an output path that does not already exist.");
if (!Directory.Exists(Path.GetDirectoryName(outputPath)))
throw new ArgumentException("Create the output directory first.");
using var input = new FileStream(inputPath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (input.Length == 0 || input.Length > 20L * 1024 * 1024)
throw new InvalidDataException("Use a nonempty input no larger than 20 MiB; export a smaller image and retry.");
if (HasMultiplePictureIndex(input))
throw new InvalidDataException("This JPEG contains a multi-picture index. Export one picture as an opaque 8-bit RGB PNG first.");
if (HasPngAnimation(input))
throw new InvalidDataException("This PNG contains animation controls. Export one frame as an 8-bit RGB PNG without alpha first.");
using var headers = new MagickImageCollection();
headers.Ping(input);
if (headers.Count != 1)
throw new InvalidDataException("Export a single image as an opaque 8-bit RGB PNG first.");
var header = headers[0];
if (header.Format is not (MagickFormat.Jpeg or MagickFormat.Png)
|| header.Depth != 8 || header.HasAlpha
|| header.ColorSpace is not (ColorSpace.sRGB or ColorSpace.Gray))
throw new InvalidDataException("Use an 8-bit RGB or grayscale JPEG, or a PNG without an alpha channel; convert CMYK or alpha in an image editor first.");
if (header.Width < 80 || header.Height < 80
|| (ulong)header.Width * header.Height > 20_000_000)
throw new InvalidDataException("Use sides of at least 80 pixels and no more than 20 million pixels; resize in your image editor first.");
input.Position = 0;
using var image = new MagickImage();
image.Warning += (_, _) => throw new InvalidDataException("The decoder reported damaged image data. Re-export an intact image first.");
image.Read(input);
image.AutoOrient();
if (image.GetColorProfile() is not null)
image.TransformColorSpace(ColorProfiles.SRGB);
image.ColorSpace = ColorSpace.sRGB;
image.Strip();
if (image.Width > 1600 || image.Height > 1600)
image.Resize(new MagickGeometry(1600, 1600) { Greater = true });
if (image.Width < 80 || image.Height < 80)
throw new InvalidDataException("The resized image has a side below 80 pixels; crop the extreme aspect ratio first.");
using var bytes = new MemoryStream(image.ToByteArray(MagickFormat.Png));
if (bytes.Length > 5L * 1024 * 1024)
throw new InvalidDataException("The normalized PNG exceeds AWS’s 5 MB Bytes limit. Export with a longest side of 1200 pixels or less and retry.");
using var client = new AmazonRekognitionClient(new AmazonRekognitionConfig { MaxErrorRetry = 0 });
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var response = await client.DetectFacesAsync(new DetectFacesRequest
{
Image = new Amazon.Rekognition.Model.Image { Bytes = bytes },
Attributes = new List<string> { "DEFAULT" }
}, deadline.Token);
var faces = response.FaceDetails ?? new List<FaceDetail>();
if (faces.Count == 0)
{
Console.Error.WriteLine("No faces detected; no output written. Review the original manually.");
return 2;
}
foreach (var face in faces)
{
var box = face.BoundingBox;
if (box?.Left is not float left || box.Top is not float top
|| box.Width is not float width || box.Height is not float height
|| !float.IsFinite(left) || !float.IsFinite(top)
|| !float.IsFinite(width) || !float.IsFinite(height) || width <= 0 || height <= 0)
throw new InvalidDataException("AWS returned an invalid face bounding box; no output written.");
int x1 = (int)Math.Floor(Math.Clamp((double)left * image.Width, 0, image.Width));
int y1 = (int)Math.Floor(Math.Clamp((double)top * image.Height, 0, image.Height));
int x2 = (int)Math.Ceiling(Math.Clamp(((double)left + width) * image.Width, 0, image.Width));
int y2 = (int)Math.Ceiling(Math.Clamp(((double)top + height) * image.Height, 0, image.Height));
if (x2 <= x1 || y2 <= y1)
throw new InvalidDataException("A face box does not intersect the image; no output written.");
using var region = image.Clone();
region.Crop(new MagickGeometry(x1, y1, (uint)(x2 - x1), (uint)(y2 - y1)));
region.ResetPage();
double sigma = Math.Max(12, Math.Min(region.Width, region.Height) / 4.0);
// An explicit kernel radius keeps large-sigma blur effective with the Q8 library.
region.GaussianBlur(Math.Ceiling(3 * sigma), sigma);
image.Composite(region, x1, y1, CompositeOperator.Copy);
}
string staging = Path.Combine(Path.GetDirectoryName(outputPath)!,
$".faceblur-{Guid.NewGuid():N}.tmp");
try
{
using (var output = new FileStream(staging, FileMode.CreateNew, FileAccess.Write))
{
image.Write(output, MagickFormat.Png);
output.Flush(flushToDisk: true);
}
File.Move(staging, outputPath, overwrite: false);
}
finally
{
if (File.Exists(staging)) File.Delete(staging);
}
Console.WriteLine($"Blurred {faces.Count} detected face region(s). Review {outputPath} before sharing.");
return 0;
}
private static bool HasPngAnimation(Stream input)
{
// PNG decoders can expose only an APNG's default image; inspect its control chunks first.
try
{
Span<byte> header = stackalloc byte[8];
ReadOnlySpan<byte> signature = stackalloc byte[] { 137, 80, 78, 71, 13, 10, 26, 10 };
if (input.Read(header) != 8 || !header.SequenceEqual(signature)) return false;
while (input.Length - input.Position >= 12)
{
input.ReadExactly(header);
uint length = BinaryPrimitives.ReadUInt32BigEndian(header[..4]);
if ((long)length + 4 > input.Length - input.Position) break;
if (header[4..].SequenceEqual("acTL"u8)) return true;
// A valid animation control chunk must precede the first image-data chunk.
if (header[4..].SequenceEqual("IDAT"u8) || header[4..].SequenceEqual("IEND"u8)) break;
input.Position += length + 4L;
}
return false;
}
finally
{
input.Position = 0;
}
}
private static bool HasMultiplePictureIndex(Stream input)
{
// Some JPEG decoders expose only the primary picture of an MPF/MPO file.
try
{
if (input.ReadByte() != 0xff || input.ReadByte() != 0xd8) return false;
while (input.Position < input.Length)
{
if (input.ReadByte() != 0xff) break;
int marker;
do { marker = input.ReadByte(); } while (marker == 0xff);
if (marker < 0 || marker == 0xda || marker == 0xd9) break;
if (marker == 0x01 || marker is >= 0xd0 and <= 0xd7) continue;
int high = input.ReadByte();
int low = input.ReadByte();
if (high < 0 || low < 0) break;
int length = (high << 8) + low - 2;
if (length < 0 || length > input.Length - input.Position) break;
long next = input.Position + length;
if (marker == 0xe2 && length >= 4
&& input.ReadByte() == 'M' && input.ReadByte() == 'P'
&& input.ReadByte() == 'F' && input.ReadByte() == 0)
return true;
input.Position = next;
}
return false;
}
finally
{
input.Position = 0;
}
}
}
Salve uma fotografia que você tem permissão para analisar como input.jpg no
diretório original. Escolha um novo destino e, em seguida, compile e execute:
dotnet build FaceBlurAutomation &&
dotnet run --no-build --project FaceBlurAutomation -- "./input.jpg" "./output.png"
Uma execução concluída informa o número de regiões desfocadas e orienta você a revisar
output.png.
Abra esse arquivo exato em um visualizador de imagens e compare-o com o original: confira cada rosto
visível, a rotação, o tamanho da imagem e se o desfoque é forte o suficiente. Uma pessoa ainda pode
ser identificável pelo cabelo, pelas roupas, pelo contexto ou por um rosto que o detector deixou
passar. Use um novo nome de arquivo de saída para outra execução; o programa preserva os arquivos
existentes.
Como funciona
A orientação e o redimensionamento ocorrem antes da detecção, então as coordenadas retornadas se referem aos pixels que estão sendo editados. Cada retângulo ajustado aos limites é recortado, desfocado e copiado de volta; os pixels fora da união desses retângulos permanecem como estavam na imagem de trabalho normalizada. Sigma é de pelo menos 12 pixels e aumenta conforme o lado menor de cada caixa delimitadora de rosto. O raio do kernel é definido explicitamente para manter esse desfoque eficaz com o pacote Q8. Isso torna o efeito substancial em rostos de diferentes tamanhos, mas essa configuração não é um limiar de anonimato.
O PNG é primeiro codificado em um arquivo intermediário com nome único no mesmo diretório do
destino. Depois que esse arquivo é fechado, uma operação de movimentação sem sobrescrita o publica.
Falhas capturadas durante a gravação ou a movimentação resultam na remoção desse arquivo
intermediário; um encerramento forçado do processo ou uma queda de energia pode deixar um arquivo
.faceblur-*.tmp. Inspecione e remova apenas o arquivo temporário que pertence à sua
execução interrompida. Este exemplo local foi feito para uma execução por vez.
Tratamento de erros e testes
O código de saída zero significa que o PNG foi publicado e ainda precisa de revisão. O código de saída dois significa que nenhum rosto foi detectado e nenhum arquivo de saída foi gravado. O código de saída um abrange entradas inválidas, falhas da AWS e falhas no processamento ou na publicação de arquivos. Os corpos das exceções da AWS e as credenciais não são exibidos.
Experimente uma fotografia com vários rostos e outra sem rostos. Teste também um JPEG com rotação EXIF, um destino existente, uma imagem corrompida e um diretório de saída indisponível. As recusas não devem deixar nenhum novo arquivo finalizado e devem preservar a entrada. Em caso de limite de bytes ou pixels, redimensione e exporte localmente; em caso de recusa por cor ou formato, exporte uma única imagem como PNG sRGB de 8 bits sem canal alfa. Um tempo limite se aplica à requisição à AWS, não à execução completa do processamento da imagem. Confira a conectividade antes de tentar novamente; as novas tentativas do SDK estão desativadas neste exemplo.
Expandir sua aplicação
Uma API web precisa de validação de uploads, limites de recursos, autorização e armazenamento temporário privado próprios; o exemplo acima para arquivos confiáveis não fornece esses controles. Um caminho de entrada no S3 também precisa de acesso ao S3, e o objeto deve estar na mesma região do Rekognition. Se adicionar armazenamento, mantenha a detecção e o desfoque vinculados à mesma imagem orientada e redimensionada, em vez de detectar em uma versão e editar outra.
Resumo
Mantenha a fotografia original até inspecionar o PNG e tratar manualmente os rostos que não foram detectados. Para outras tarefas de análise de imagens com a AWS, o 🤖 /image/describe Robot da Transloadit retorna rótulos de objetos; ele não substitui as etapas de desfoque de rostos e revisão apresentadas aqui.
