Difuminado facial automático: protege la privacidad (AWS y .NET)
Usa AWS Rekognition para localizar rostros y luego difumina esas regiones localmente con C# y Magick.NET. Esta aplicación de consola crea un nuevo PNG para que lo revises antes de compartirlo. La detección puede pasar por alto algunos rostros y el difuminado no garantiza el anonimato: envía fotografías a AWS solo cuando tengas permiso y comprueba si hay rostros reconocibles u otros detalles que permitan identificar a alguien en el resultado.
Configura tu proyecto .NET
Estos comandos usan Bash en Linux y el SDK de .NET 10.
El SDK probado es el 10.0.111; usa un SDK de .NET 10 con actualizaciones de mantenimiento para nuevos
proyectos. Instala el SDK, en lugar de solo el entorno de ejecución, y comprueba que
dotnet --version funcione antes de empezar.
Magick.NET incluye su biblioteca nativa de imágenes en el paquete
NuGet, por lo que esta aplicación no necesita un ejecutable de ImageMagick independiente.
Empieza en un directorio con permisos de escritura fuera de un árbol de código fuente .NET existente. La configuración rechaza las configuraciones de compilación, paquetes, SDK y NuGet de los directorios superiores para que este pequeño proyecto no herede ajustes ajenos. Pega este bloque; los paréntesis mantienen intactos el directorio y las opciones de tu 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
)
Si la instalación falla después de crear el proyecto, conserva ese directorio y vuelve a ejecutar
los dos comandos de paquetes cuando se restablezca la conexión. La configuración completa rechaza
intencionalmente la reutilización de un proyecto existente. Todos los comandos posteriores se
ejecutan desde el directorio original usando --project.
Para este tutorial local, usa un perfil existente de credenciales compartidas o credenciales
temporales proporcionadas mediante tu entorno. Configúralas mediante la
cadena predeterminada de proveedores de credenciales del SDK.
La identidad necesita rekognition:DetectFaces. Exporta AWS_PROFILE con el
nombre de tu perfil de credenciales compartidas si es necesario, y AWS_REGION
con una región de Rekognition como us-east-1 en tu sesión de Bash.
No pegues claves en el código.
Los análisis de Rekognition generan cargos de AWS. Este ejemplo envía directamente los bytes de la
imagen; no necesita un bucket de S3 ni permisos de S3.
Comprende AWS Rekognition
DetectFaces
devuelve rectángulos delimitadores para hasta los 100 rostros más grandes. Cada coordenada es una
fracción del ancho o del alto de la imagen. Los rostros situados en un borde pueden producir
coordenadas fuera de la imagen, por lo que el código redondea hacia afuera y limita los rectángulos
a sus límites. Los rostros de frente funcionan mejor; una respuesta vacía es motivo para una revisión
manual, no una prueba de que una imagen no contiene personas.
Implementación de la detección y el difuminado de rostros
Usa un JPEG o PNG de una sola imagen con píxeles RGB o en escala de grises de 8 bits. Los PNG no deben tener canal alfa. El ejemplo rechaza CMYK, canales alfa (incluidos los completamente opacos), mayores profundidades de bits, PNG animados y JPEG con varias imágenes; primero exporta una sola imagen como PNG sRGB de 8 bits sin alfa en tu editor de imágenes. Sus límites locales de entrada son 20 MiB y 20 millones de píxeles decodificados, con ambos lados de al menos 80 píxeles. Estos son límites del tutorial para fotografías locales de confianza, no un entorno aislado para subidas maliciosas.
La aplicación orienta los píxeles, convierte a sRGB el color RGB que tiene un perfil, elimina los perfiles y comentarios de entrada y reduce las fotografías cuyo lado más largo supera los 1.600 píxeles. Envía a AWS un PNG de esos mismos píxeles y edita esa imagen de trabajo. Por lo tanto, una fotografía grande produce una imagen de salida más pequeña; inspecciona con cuidado los rostros más pequeños. Si el PNG normalizado aún supera el límite de 5 MB para el envío directo mediante Bytes, el error te indica que exportes una fotografía más pequeña y vuelvas a intentarlo. El límite independiente de 15 MB para objetos de S3 no se aplica aquí.
Reemplaza 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;
}
}
}
Guarda una fotografía que tengas permiso para analizar como input.jpg en el
directorio original. Elige un destino nuevo y luego compila y ejecuta:
dotnet build FaceBlurAutomation &&
dotnet run --no-build --project FaceBlurAutomation -- "./input.jpg" "./output.png"
Una ejecución completada informa el número de regiones difuminadas y te indica que revises
output.png.
Abre ese archivo exacto en un visor de imágenes y compáralo con el original: comprueba cada rostro
visible, la rotación, el tamaño de la imagen y si el difuminado es lo suficientemente intenso.
Una persona aún puede ser identificable por su cabello, ropa, contexto o un rostro que el detector
pasó por alto. Usa un nombre de salida nuevo para otra ejecución; el programa conserva los archivos
existentes.
Cómo funciona
La orientación y el cambio de tamaño se realizan antes de la detección, por lo que las coordenadas devueltas corresponden a los píxeles que se están editando. Cada rectángulo limitado se recorta, se difumina y se copia de vuelta; los píxeles fuera de la unión de esos rectángulos permanecen como estaban en la imagen de trabajo normalizada. Sigma es de al menos 12 píxeles y aumenta con el lado más corto de cada rectángulo facial. El radio del kernel se establece explícitamente para mantener la eficacia del difuminado con el paquete Q8. Esto hace que el efecto sea considerable en distintos tamaños de rostro, pero el ajuste no constituye un umbral de anonimato.
El PNG se codifica primero en un archivo provisional con un nombre único junto al destino. Una vez
cerrado, se publica mediante un movimiento que no sobrescribe archivos. Los errores de escritura y
movimiento capturados eliminan ese archivo provisional; la terminación forzada del proceso o una
pérdida de energía pueden dejar un archivo .faceblur-*.tmp. Inspecciona y elimina solo
el archivo temporal que pertenezca a tu ejecución interrumpida. Este ejemplo local está pensado para
una sola ejecución a la vez.
Manejo de errores y pruebas
El código de salida cero significa que el PNG se publicó y aún necesita revisión. El código de salida dos significa que no se detectaron rostros y no se escribió ningún archivo de salida. El código de salida uno abarca entradas no válidas, errores de AWS y errores de procesamiento o publicación de archivos. No se imprimen los cuerpos de las excepciones de AWS ni las credenciales.
Prueba una fotografía con varios rostros y otra sin rostros. Prueba también un JPEG rotado mediante EXIF, un destino existente, una imagen dañada y un directorio de salida no disponible. Los rechazos deben evitar la creación de un archivo nuevo finalizado y conservar la entrada. Si se supera un límite de bytes o píxeles, cambia el tamaño y exporta localmente; si se rechaza el color o el formato, exporta una sola imagen como PNG sRGB de 8 bits sin alfa. El tiempo de espera se aplica a la solicitud a AWS, no a la ejecución completa del procesamiento de imágenes. Comprueba la conexión antes de volver a intentarlo; los reintentos del SDK están desactivados en este ejemplo.
Amplía tu aplicación
Una API web necesita su propia validación de subidas, límites de recursos, autorización y almacenamiento temporal privado; el ejemplo anterior para archivos de confianza no proporciona esos controles. Una ruta de entrada de S3 también necesita acceso a S3, y el objeto debe estar en la misma región que Rekognition. Si añades almacenamiento, mantén la detección y el difuminado vinculados a la misma imagen orientada y redimensionada, en lugar de detectar una versión y editar otra.
Resumen
Conserva la fotografía original hasta que hayas inspeccionado el PNG y tratado manualmente los rostros que no se hayan detectado. Para otras tareas de análisis de imágenes con AWS, el 🤖 /image/describe Robot de Transloadit devuelve etiquetas de objetos; no sustituye los pasos de difuminado facial y revisión de este tutorial.
