Pixelate faces: Ruby & ImageMagick
Use Google Cloud Vision to locate faces and RMagick to replace those regions with visible pixel blocks. The Ruby program below rotates the pixels according to EXIF orientation before sending them to Vision, then edits that same image and saves a PNG for review.
Why pixelate faces?
Pixelation reduces visible facial detail, but a detector can miss faces and a pixelated face can still be identifiable. Treat the output as a review candidate. Check every face, including people at the image edges, and consider names, clothing, reflections, and other identifying details before sharing it. The original image is sent to Google for detection.
Setting up Ruby and ImageMagick
This walkthrough targets a Linux shell with MRI Ruby 4.0, ImageMagick 7 with JPEG and PNG support,
its development headers, a C++ compiler, make, and pkg-config already installed. The tested
versions are Ruby 4.0.7, ImageMagick 7.1.2-31, RMagick 7.2.0, and google-cloud-vision 2.2.1.
Use a maintained Ruby patch from the Ruby downloads page.
See RMagick’s prerequisites for the system
packages your distribution needs; the gem builds a native extension against those headers.
Run this block from a writable directory. It creates a fresh face-pixelation directory and keeps
the gems inside it. An existing directory causes the setup to stop without replacing it. The
parent shell stays in its original directory on both success and failure.
(
command -v ruby >/dev/null || exit 1
command -v make >/dev/null || exit 1
command -v g++ >/dev/null || exit 1
command -v pkg-config >/dev/null || exit 1
ruby -e 'abort "Use MRI Ruby 4.0" unless RUBY_ENGINE == "ruby" && RUBY_VERSION.start_with?("4.0.")' || exit 1
pkg-config --exists MagickCore || exit 1
mkdir face-pixelation || exit 1
cd face-pixelation || exit 1
GEM_SPEC_CACHE="$PWD/gem-spec-cache" ruby -S gem install \
--install-dir "$PWD/gems" --no-user-install --no-document \
rmagick:7.2.0 google-cloud-vision:2.2.1
)
If installation fails, keep its compiler diagnostic, fix the missing prerequisite, and repeat the
gem installation command from inside the created directory. Do not repeat mkdir or delete an
existing project to make setup succeed. Run the example outside bundle exec; this recipe uses
its own gem directory rather than an enclosing application’s Gemfile.
Setting up Google Cloud Vision API
Follow Google’s Vision setup guide for a project with billing enabled and the Vision API enabled. Detection is a billable cloud operation; check the project’s quotas and limits before batch work. This example sends one local image per run and needs no Cloud Storage bucket.
For local development, install the Google Cloud CLI and follow
Google’s ADC instructions.
Run gcloud auth application-default login, then
gcloud auth application-default set-quota-project PROJECT_ID, replacing PROJECT_ID with your
billing project. The identity needs serviceusage.services.use in that project. These credentials
are separate from ordinary gcloud login credentials.
The program uses Application Default Credentials. An existing GOOGLE_APPLICATION_CREDENTIALS
environment variable takes precedence over the local ADC file, so check that it selects the
intended credentials. Avoid downloading a service account key for this local example. For a
Google Cloud workload, Google recommends the service account attached to that workload.
Integrating face detection with Ruby using Google Cloud Vision API
Use an intact, static, 8-bit JPEG or PNG in sRGB or grayscale, without transparency or an embedded ICC profile. Export other color spaces and transparent images to an opaque sRGB image first. The program accepts at most 2 MiB of input, 4,096 pixels per side, and 4,000,000 pixels in total. It also limits the normalized PNG sent to Vision to 8 MiB. A compressed input can exceed that second limit after decoding; reduce its dimensions and try again.
Vision’s face response documentation warns that partial faces can have missing polygon coordinates. Protobuf integer fields can read as zero when absent. Taking the minimum and maximum of those values can select the wrong region. This example accepts only four corners that form an ordered, positive, axis-aligned rectangle inside the image. A complete rectangle may touch coordinate zero. Incomplete, inverted, rotated, or otherwise ambiguous polygons stop the whole run without publishing an output; inspect those images manually rather than guessing a rectangle.
Applying pixelation effects with ImageMagick
For each accepted rectangle, the program crops the region, samples it down to roughly one pixel
per 16 original pixels on each axis, and samples it back up. RMagick’s
sample method uses pixel sampling rather than a
smoothing filter, producing rectangular blocks. Every returned detection is processed, including
low-confidence ones. Hitting the requested detection limit is a refusal because the response may
be incomplete.
Step-by-step Ruby code example
Save this complete program as face-pixelation/pixelate.rb:
require 'google/cloud/vision/v1'
require 'rmagick'
require 'tempfile'
class ReviewRequired < StandardError; end
MAX_INPUT = 2 * 1024 * 1024
MAX_REQUEST_IMAGE = 8 * 1024 * 1024
MAX_FACES = 100
def face_rectangle(face, width, height)
points = face.bounding_poly&.vertices
raise ReviewRequired, 'Ambiguous face polygon; review the original' unless points&.length == 4
a, b, c, d = points
valid = a.x == d.x && b.x == c.x && a.y == b.y && c.y == d.y &&
a.x >= 0 && a.y >= 0 && b.x > a.x && c.y > a.y &&
b.x <= width && c.y <= height
raise ReviewRequired, 'Ambiguous face polygon; review the original' unless valid
[a.x, a.y, b.x - a.x, c.y - a.y]
end
def pixelate_region(image, rectangle)
region = small = blocks = nil
x, y, width, height = rectangle
region = image.crop(x, y, width, height, true)
small = region.sample([(width / 16.0).ceil, 1].max, [(height / 16.0).ceil, 1].max)
blocks = small.sample(width, height)
image.composite!(blocks, x, y, Magick::CopyCompositeOp)
ensure
[region, small, blocks].compact.each(&:destroy!)
end
def run(input, output)
raise ReviewRequired, 'Output must have a .png extension' unless File.extname(output).downcase == '.png'
raise ReviewRequired, 'Destination already exists; choose a new path' if File.exist?(output) || File.symlink?(output)
# Bound native pixel caches; this is not a limit on the Ruby process's total memory.
Magick.limit_resource(:memory, 256 * 1024 * 1024)
Magick.limit_resource(:map, 0)
Magick.limit_resource(:disk, 0)
blob = File.open(input, 'rb') { |file| file.read(MAX_INPUT + 1) || ''.b }
raise ReviewRequired, 'Input must contain 1 byte to 2 MiB' if blob.empty? || blob.bytesize > MAX_INPUT
format = if blob.start_with?("\x89PNG\r\n\x1a\n".b)
'PNG'
elsif blob.start_with?("\xff\xd8\xff".b)
'JPEG'
end
raise ReviewRequired, 'Use a JPEG or PNG' unless format
# Conservative marker refusals also exclude APNG and multi-picture JPEG containers.
raise ReviewRequired, 'Use a static, single-picture image' if blob.include?('acTL') || blob.include?("MPF\0")
headers = images = nil
Tempfile.create(['face-input-', ".#{format.downcase}"]) do |file|
file.binmode
file.write(blob)
file.rewind
headers = Magick::Image.ping(file) { |info| info.format = format }
raise ReviewRequired, 'Use a single-frame image' unless headers.length == 1
header = headers.first
unless header.columns.between?(1, 4096) && header.rows.between?(1, 4096) &&
header.columns * header.rows <= 4_000_000
raise ReviewRequired, 'Input exceeds the dimension or pixel limit; resize it first'
end
end
images = Magick::Image.from_blob(blob) { |info| info.format = format }
raise ReviewRequired, 'Use a single-frame image' unless images.length == 1
image = images.first
unless image.depth <= 8 && !image.alpha? &&
[Magick::SRGBColorspace, Magick::GRAYColorspace].include?(image.colorspace) &&
image.color_profile.to_s.empty?
raise ReviewRequired, 'Use an opaque 8-bit sRGB or grayscale image without an ICC profile'
end
image.auto_orient!
image.strip!
image.format = 'PNG'
upright_png = image.to_blob
raise ReviewRequired, 'Normalized PNG exceeds 8 MiB; resize the input first' if upright_png.bytesize > MAX_REQUEST_IMAGE
client = Google::Cloud::Vision::V1::ImageAnnotator::Client.new do |config|
config.rpcs.batch_annotate_images.timeout = 15
config.rpcs.batch_annotate_images.retry_policy = { retry_codes: [] }
config.channel_args = { 'grpc.enable_retries' => 0 }
config.logger = nil
end
response = client.batch_annotate_images(
{ requests: [{ image: { content: upright_png },
features: [{ type: :FACE_DETECTION, max_results: MAX_FACES }] }] }
)
raise ReviewRequired, 'Unexpected Vision response' unless response.responses.length == 1
result = response.responses.first
raise ReviewRequired, 'Vision returned a detection error; check project access' if result.error && result.error.code != 0
faces = result.face_annotations
raise ReviewRequired, 'No faces detected; review the original' if faces.empty?
raise ReviewRequired, 'Detection limit reached; review the original' if faces.length >= MAX_FACES
rectangles = faces.map { |face| face_rectangle(face, image.columns, image.rows) }
rectangles.each { |rectangle| pixelate_region(image, rectangle) }
# Write completely before publishing; a hard link refuses an existing destination.
Tempfile.create(['face-output-', '.png'], File.dirname(File.expand_path(output))) do |file|
file.binmode
file.write(image.to_blob)
file.flush
File.link(file.path, output)
end
puts "Wrote #{faces.length} pixelated region(s). Review every face before sharing."
ensure
headers&.each(&:destroy!)
images&.each(&:destroy!)
end
begin
raise ReviewRequired, 'Usage: ruby pixelate.rb INPUT OUTPUT.png' unless ARGV.length == 2
unless ARGV.all? { |path| path.dup.force_encoding(Encoding::UTF_8).valid_encoding? }
raise ReviewRequired, 'Use UTF-8 file paths'
end
run(*ARGV)
rescue ReviewRequired => error
warn error.message
exit 1
rescue StandardError
warn 'Processing failed; check the input, project credentials, network, and writable output directory.'
exit 1
end
Put your photograph at face-pixelation/input.jpg. From the same parent directory used for setup,
run:
(
cd face-pixelation || exit 1
GEM_HOME="$PWD/gems" GEM_PATH="$PWD/gems" \
ruby pixelate.rb ./input.jpg ./output.png
)
Exit status zero means the program wrote face-pixelation/output.png and printed a review reminder.
Open that PNG beside the original and inspect every visible face before publishing. Use a new
destination name on subsequent runs. The program refuses existing files and symlinks, leaves the
input untouched, and removes its temporary files. Run it sequentially in a directory you control.
Optimizing performance and handling edge cases
The Vision RPC has a 15-second timeout and automatic SDK retries are disabled. This is an RPC timeout, not a deadline for the entire program: dependency loading, decoding, credential acquisition, and writing take additional time. For an unattended service, add process supervision and an appropriate total deadline rather than treating this local script as a worker service.
No-face responses, per-image provider errors, ambiguous polygons, and a full 100-result response produce a nonzero exit and no new output. A transport or credential exception also fails without printing success. Check the original when detection finds nothing; an empty response does not mean the image contains no people. Rotated or partially visible faces may need manual editing even when the detector finds other faces successfully.
The size and pixel limits keep this example focused on small photographs. The 256 MiB native
pixel-cache setting and disabled disk cache can cause a resource refusal even below those limits;
they do not bound all process allocations. ImageMagick can recover some damaged images, so decoding
success is not an integrity check. Use intact originals and inspect the complete output, including
its edges. The conservative acTL and MPF marker checks can also refuse an ordinary file containing
those byte sequences in metadata; re-export it as a static image if that occurs.
If sending the original to a cloud provider is unsuitable, choose a local detector and verify its own coordinate and orientation contract. Swapping detector APIs requires adapting the response; the rectangle handling here is specific to the accepted Vision polygon shape.
How does ImageMagick integrate with Ruby for image processing?
RMagick calls ImageMagick’s native image operations from Ruby. Here it handles EXIF orientation,
cropping, sampling, and compositing; Vision supplies the detections. The normalized PNG is created
in memory from the same RMagick image that is later edited, so detection and pixelation share pixel
dimensions and orientation. strip! removes input profiles and properties before the request and
output, including EXIF metadata; the PNG encoder may still add its own format fields.
Privacy considerations
The selected block size is a visual effect, not an anonymity threshold. A face can remain recognizable after pixelation, and removing metadata does not remove identifying image content. Review the provider’s processing terms and your authority to send the original before running detection.
Keep reviewed outputs separate from originals. If you add this to an upload workflow, require an explicit review decision before making the resulting PNG public.
