0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-04 10:39:43 +08:00
discourse/script/letter_avatar_pixel_diff
Alan Guo Xiang Tan 9e2a10b427
DEV: Add image-processing regression coverage (#42117)
Previously, important image-processing behavior lacked precise
regression coverage, making image-library upgrades difficult to assess
safely.

This change adds coverage without changing production behavior:

- Exercises public upload and image-model outcomes for SVG sanitization,
format detection and conversion, transparency, animation metadata, EXIF
orientation normalization, malformed input rejection, crop positioning,
tiny-image resizing, dominant-color caching, and image-quality
selection.
- Keeps normal LetterAvatar RSpec coverage portable by generating one
representative PNG and checking its format and requested dimensions.
- Adds a separate Linux-only `script/letter_avatar_pixel_diff` workflow
that compares A–Z renders with committed baselines and generates an HTML
expected/actual/highlighted-diff report. Use `--update-baselines` when
intentionally accepting rendering changes.
- Uses deterministic fixtures with regeneration guidance beside the
relevant specs.
2026-07-30 15:28:07 +08:00

234 lines
8.1 KiB
Ruby
Executable file
Vendored

#!/usr/bin/env ruby
# frozen_string_literal: true
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "base64"
require "chunky_png"
require "fileutils"
require "letter_avatar"
require "optparse"
# Compare representative A-Z renders with Linux baselines and highlight pixel drift.
module LetterAvatarPixelDiff
LETTERS = ("A".."Z").to_a.freeze
AVATAR_SIZE = 45
BASELINE_DIRECTORY = Rails.root.join("spec/fixtures/images/letter_avatar_visual/linux")
REPORT_DIRECTORY = Rails.root.join("tmp/letter-avatar-pixel-diff")
class << self
def run(arguments)
options = parse_options(arguments)
generated_paths = {}
if RbConfig::CONFIG["host_os"].match?(/darwin/i)
warn "LetterAvatar pixel baselines are Linux-only."
return 2
end
LETTERS.each do |letter|
generated_paths[letter] = LetterAvatar.generate(letter, AVATAR_SIZE, cache: false)
end
if options[:update_baselines]
update_baselines(generated_paths)
puts "Updated Linux A-Z baselines in #{BASELINE_DIRECTORY}."
return 0
end
missing_baselines =
LETTERS.reject { |letter| BASELINE_DIRECTORY.join("#{letter}.png").exist? }
if missing_baselines.any?
warn(
"Missing Linux baselines for #{missing_baselines.join(", ")}. " \
"Run `script/letter_avatar_pixel_diff --update-baselines`.",
)
return 2
end
comparisons = compare(generated_paths)
write_report(comparisons)
comparisons.each do |comparison|
changed_pixel_count = comparison[:changed_pixel_count]
pixel_count = comparison[:pixel_count]
changed_percentage = pixel_count.zero? ? 0 : (changed_pixel_count.fdiv(pixel_count) * 100)
puts(
"#{comparison[:letter]}: #{changed_pixel_count}/#{pixel_count} pixels differ " \
"(#{format("%.2f", changed_percentage)}%)",
)
end
if comparisons.any? { |comparison| comparison[:changed_pixel_count].positive? }
warn "LetterAvatar pixels changed. Open #{REPORT_DIRECTORY.join("index.html")}."
1
else
puts "All Linux A-Z pixel baselines match. Report: #{REPORT_DIRECTORY.join("index.html")}"
0
end
rescue OptionParser::ParseError => error
warn error.message
2
ensure
remove_generated_avatars(generated_paths)
end
private
def parse_options(arguments)
options = { update_baselines: false }
OptionParser
.new do |parser|
parser.banner = "Usage: script/letter_avatar_pixel_diff [--update-baselines]"
parser.on("--update-baselines", "Regenerate every committed Linux A-Z baseline.") do
options[:update_baselines] = true
end
end
.parse!(arguments)
options
end
def update_baselines(generated_paths)
FileUtils.mkdir_p(BASELINE_DIRECTORY)
generated_paths.each do |letter, generated_path|
FileUtils.cp(generated_path, BASELINE_DIRECTORY.join("#{letter}.png"))
end
end
def compare(generated_paths)
generated_paths.map do |letter, generated_path|
expected = ChunkyPNG::Image.from_file(BASELINE_DIRECTORY.join("#{letter}.png"))
actual = ChunkyPNG::Image.from_file(generated_path)
changed_pixel_count, pixel_count = changed_pixel_count(expected, actual)
{
letter: letter,
expected: expected,
actual: actual,
changed_pixel_count: changed_pixel_count,
pixel_count: pixel_count,
}
end
end
def changed_pixel_count(expected, actual)
width = [expected.width, actual.width].max
height = [expected.height, actual.height].max
count =
height.times.sum do |y_position|
width.times.count do |x_position|
expected_pixel = expected[x_position, y_position] if x_position < expected.width &&
y_position < expected.height
actual_pixel = actual[x_position, y_position] if x_position < actual.width &&
y_position < actual.height
expected_pixel != actual_pixel
end
end
[count, width * height]
end
def write_report(comparisons)
FileUtils.mkdir_p(REPORT_DIRECTORY)
sections = comparisons.map { |comparison| write_comparison(comparison) }
report = <<~HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LetterAvatar pixel diff</title>
<style>
body { color: #222; font: 16px/1.5 sans-serif; margin: 2rem; }
section { border-top: 1px solid #ccc; margin-top: 2rem; }
.images { display: flex; flex-wrap: wrap; gap: 2rem; }
figure { margin: 0; }
img { image-rendering: pixelated; width: 180px; height: 180px; }
</style>
</head>
<body>
<h1>LetterAvatar pixel diff</h1>
<p>Baseline: Linux</p>
#{sections.join}
</body>
</html>
HTML
File.write(REPORT_DIRECTORY.join("index.html"), report)
end
def write_comparison(comparison)
letter = comparison[:letter]
expected = comparison[:expected]
actual = comparison[:actual]
diff = build_diff(expected, actual)
expected_path = REPORT_DIRECTORY.join("#{letter}-expected.png")
actual_path = REPORT_DIRECTORY.join("#{letter}-actual.png")
diff_path = REPORT_DIRECTORY.join("#{letter}-diff.png")
expected.save(expected_path)
actual.save(actual_path)
diff.save(diff_path)
expected_data = Base64.strict_encode64(File.binread(expected_path))
actual_data = Base64.strict_encode64(File.binread(actual_path))
diff_data = Base64.strict_encode64(File.binread(diff_path))
changed_pixel_count = comparison[:changed_pixel_count]
pixel_count = comparison[:pixel_count]
changed_percentage = pixel_count.zero? ? 0 : (changed_pixel_count.fdiv(pixel_count) * 100)
<<~HTML
<section>
<h2>#{letter}: #{changed_pixel_count}/#{pixel_count} pixels differ (#{format("%.2f", changed_percentage)}%)</h2>
<div class="images">
<figure><figcaption>Expected</figcaption><img src="data:image/png;base64,#{expected_data}" alt="Expected #{letter}"></figure>
<figure><figcaption>Actual</figcaption><img src="data:image/png;base64,#{actual_data}" alt="Actual #{letter}"></figure>
<figure><figcaption>Diff (changed pixels are magenta)</figcaption><img src="data:image/png;base64,#{diff_data}" alt="Pixel diff for #{letter}"></figure>
</div>
</section>
HTML
end
def build_diff(expected, actual)
width = [expected.width, actual.width].max
height = [expected.height, actual.height].max
diff = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
height.times do |y_position|
width.times do |x_position|
expected_pixel = expected[x_position, y_position] if x_position < expected.width &&
y_position < expected.height
actual_pixel = actual[x_position, y_position] if x_position < actual.width &&
y_position < actual.height
diff[x_position, y_position] = if expected_pixel.nil? && actual_pixel.nil?
ChunkyPNG::Color::TRANSPARENT
elsif expected_pixel == actual_pixel
dim(actual_pixel)
else
ChunkyPNG::Color.rgba(255, 0, 255, 255)
end
end
end
diff
end
def dim(pixel)
ChunkyPNG::Color.rgba(
ChunkyPNG::Color.r(pixel) / 4,
ChunkyPNG::Color.g(pixel) / 4,
ChunkyPNG::Color.b(pixel) / 4,
ChunkyPNG::Color.a(pixel),
)
end
def remove_generated_avatars(generated_paths)
generated_paths&.each do |letter, generated_path|
FileUtils.rm_f(generated_path)
FileUtils.rm_f(LetterAvatar.fullsize_path(LetterAvatar::Identity.from_username(letter)))
end
end
end
end
exit LetterAvatarPixelDiff.run(ARGV)