Face blur automation: protect privacy with AWS & .NET
Use AWS Rekognition to locate faces, then blur those regions locally with C# and Magick.NET. This console application creates a new PNG for you to review before sharing. Detection can miss faces, and a blur does not guarantee anonymity: only send photographs to AWS when you have permission, and check for recognizable faces and other identifying details in the result.
Set up your .NET project
These commands use Bash on Linux and the .NET 10 SDK.
The tested SDK is 10.0.111; use a serviced .NET 10 SDK for new work. Install the SDK, rather than
only the runtime, and check that dotnet --version works before starting.
Magick.NET includes its native image
library in the NuGet package, so this application does not need a separate ImageMagick executable.
Start in a writable directory outside an existing .NET source tree. The setup refuses ancestor build, package, SDK, and NuGet configuration so this small project does not inherit unrelated settings. Paste this block; the parentheses keep your shell’s directory and options intact:
(
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
)
If installation fails after creating the project, keep that directory and rerun the two package
commands once connectivity is restored. The whole setup intentionally refuses to reuse an existing
project. All later commands run from the original directory using --project.
For this local walkthrough, use an existing shared-credentials profile or temporary credentials
supplied through your environment. Configure them through the SDK’s
default credential provider chain.
The identity needs rekognition:DetectFaces. Export AWS_PROFILE with your shared-credentials
profile’s name if needed, and AWS_REGION with a Rekognition region such as us-east-1 in your
Bash session. Do not paste keys into the code.
Rekognition analyses incur AWS charges. This example sends image bytes directly; it needs no S3
bucket or S3 permissions.
Understand AWS Rekognition
DetectFaces
returns bounding boxes for up to the 100 largest faces. Each coordinate is a fraction of the image
width or height. Faces at an edge can produce coordinates outside the image, so the code rounds
outward and clamps the rectangles. Frontal faces work best; an empty response is a reason for manual
review, not proof that an image contains no people.
Implementing face detection and blurring
Use a single-image JPEG or PNG with 8-bit RGB or grayscale pixels. PNGs must have no alpha channel. The example refuses CMYK, alpha channels (including fully opaque ones), higher bit depths, animated PNGs, and multi-picture JPEGs; export one picture as an 8-bit sRGB PNG without alpha in your image editor first. Its local input limits are 20 MiB and 20 million decoded pixels, with both sides at least 80 pixels. These are tutorial bounds for trusted local photographs, not a sandbox for hostile uploads.
The application orients the pixels, converts tagged RGB color to sRGB, removes input profiles and comments, and reduces photographs whose longest side exceeds 1,600 pixels. It sends a PNG of those same pixels to AWS and edits that working image. Thus a large photograph produces a smaller output; inspect the smaller faces carefully. If the normalized PNG still exceeds the 5 MB direct Bytes limit, the error tells you to export a smaller photograph and retry. The separate 15 MB S3-object limit does not apply here.
Replace FaceBlurAutomation/Program.cs with this complete program:
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;
}
}
}
Save a photograph you are allowed to analyze as input.jpg in the original directory. Choose a
new destination, then build and run:
dotnet build FaceBlurAutomation &&
dotnet run --no-build --project FaceBlurAutomation -- "./input.jpg" "./output.png"
A completed run reports the number of blurred regions and tells you to review output.png.
Open that exact file in an image viewer and compare it with the original: check every visible face,
rotation, image size, and whether the blur is strong enough. A person may still be identifiable from
hair, clothing, context, or a face the detector missed. Use a new output name for another run; the
program preserves existing files.
How it works
Orientation and resizing happen before detection, so the returned coordinates refer to the pixels being edited. Each clamped rectangle is cropped, blurred, and copied back; pixels outside the union of those rectangles remain as they were in the normalized working image. Sigma is at least 12 pixels and grows with the shorter side of each face box. The kernel radius is set explicitly to keep that blur effective with the Q8 package. This makes the effect substantial across different face sizes, but the setting is not an anonymity threshold.
The PNG is first encoded to a uniquely named staging file beside the destination. After it is
closed, a no-overwrite move publishes it. Caught write and move failures remove that staging file;
a forced process kill or power loss can leave a .faceblur-*.tmp file. Inspect and remove only
the temporary file belonging to your interrupted run. This local example is intended for one run
at a time.
Error handling and testing
Exit zero means the PNG was published and still needs review. Exit two means no faces were detected and no output was written. Exit one covers invalid input, AWS failures, and file-processing or publication failures. AWS exception bodies and credentials are not printed.
Try a photograph with several faces and one with no faces. Also test an EXIF-rotated JPEG, an existing destination, a corrupt image, and an unavailable output directory. Refusals should leave no new finalized file and preserve the input. For a byte or pixel limit, resize and export locally; for a color or format refusal, export one picture as an 8-bit sRGB PNG without alpha. A timeout applies to the AWS request, not to the complete image-processing run. Check connectivity before retrying; SDK retries are disabled in this example.
Extend your application
A web API needs its own upload validation, resource limits, authorization, and private temporary storage; the trusted-file example above does not supply those controls. An S3 input path also needs S3 access, and the object must be in the same region as Rekognition. If you add storage, keep detection and blurring bound to the same oriented, resized image rather than detecting one version and editing another.
Summary
Keep the original photograph until you have inspected the PNG and handled any missed faces manually. For other AWS image-analysis tasks, Transloadit’s 🤖 /image/describe Robot returns object labels; it does not replace the face-blurring and review steps here.
