Skip to content

Instantly share code, notes, and snippets.

@SamSaffron
Created September 13, 2026 23:01
Show Gist options
  • Select an option

  • Save SamSaffron/14cc8fbd52d0ee7ba78eee7739d67c05 to your computer and use it in GitHub Desktop.

Select an option

Save SamSaffron/14cc8fbd52d0ee7ba78eee7739d67c05 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
# frozen_string_literal: true
# Usage: ruby script/jpeg_quality.rb FILE [FILE ...]
#
# This estimates the IJG/libjpeg quality setting represented by JPEG DQT tables.
# An exact result means every active table matches an IJG table generated for the
# same quality (1..100); both baseline-clamped 8-bit and extended 16-bit
# scaling are recognized. A close custom table (at most 20% log-RMS deviation)
# is approximate, and other tables are unknown. The number is not perceived
# image quality and cannot recover a universal encoder setting. The parser reads
# bounded marker segments through the first scan header, not the potentially
# large image data, so it does not validate the entropy-coded payload.
module JPEGQuality
USAGE = "Usage: ruby script/jpeg_quality.rb FILE [FILE ...]"
class Error < StandardError
end
class InvalidJPEG < Error
end
Estimate = Struct.new(
:status,
:quality,
:deviation,
:table_count,
:precisions,
:reason,
keyword_init: true,
) do
def exact?
status == :exact
end
def approximate?
status == :approximate
end
def unknown?
status == :unknown
end
end
QuantizationTable = Struct.new(:id, :precision, :values, keyword_init: true)
ParsedJPEG = Struct.new(:tables, :used_table_ids, :lossless, keyword_init: true)
class Parser
TEM = 0x01
DQT = 0xDB
EOI = 0xD9
SOS = 0xDA
SOF_MARKERS = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
LOSSLESS_SOF_MARKERS = [0xC3, 0xC7, 0xCB, 0xCF].freeze
STANDALONE_MARKERS = ([0xD8, 0xD9] + (0xD0..0xD7).to_a).freeze
DISCARD_CHUNK_SIZE = 16 * 1024
def initialize(io)
@io = io
@tables = {}
@used_table_ids = []
@lossless = false
end
def parse
@io.binmode if @io.respond_to?(:binmode)
raise InvalidJPEG, "missing JPEG SOI marker" unless read_exact(2, "truncated JPEG") == "\xFF\xD8".b
loop do
marker = read_marker
case marker
when TEM
next
when DQT
parse_dqt(read_segment(marker))
when *SOF_MARKERS
parse_sof(marker, read_segment(marker))
when SOS
parse_sos(read_segment(marker))
break
when EOI
raise InvalidJPEG, "JPEG ended before its first scan"
else
if STANDALONE_MARKERS.include?(marker)
raise InvalidJPEG, format("unexpected standalone marker FF%02X before first scan", marker)
end
if marker < 0xC0
raise InvalidJPEG, format("unsupported reserved marker FF%02X", marker)
end
discard_segment(marker)
end
end
ParsedJPEG.new(tables: @tables, used_table_ids: @used_table_ids.uniq, lossless: @lossless)
end
private
def discard_segment(marker)
remaining = segment_payload_length(marker)
while remaining.positive?
length = [remaining, DISCARD_CHUNK_SIZE].min
read_exact(length, marker_error(marker, "payload"))
remaining -= length
end
end
def marker_error(marker, part)
format("truncated FF%02X marker %s", marker, part)
end
def parse_dqt(payload)
raise InvalidJPEG, "empty DQT marker" if payload.empty?
offset = 0
while offset < payload.bytesize
descriptor = payload.getbyte(offset)
offset += 1
precision_code = descriptor >> 4
table_id = descriptor & 0x0F
raise InvalidJPEG, "invalid DQT precision #{precision_code}" unless precision_code <= 1
raise InvalidJPEG, "invalid DQT table id #{table_id}" unless table_id <= 3
precision = precision_code.zero? ? 8 : 16
value_size = precision / 8
table_size = 64 * value_size
raise InvalidJPEG, "truncated DQT table #{table_id}" if payload.bytesize - offset < table_size
values =
if precision == 8
payload.byteslice(offset, table_size).bytes
else
64.times.map do |index|
value_offset = offset + (index * 2)
(payload.getbyte(value_offset) << 8) | payload.getbyte(value_offset + 1)
end
end
raise InvalidJPEG, "DQT table #{table_id} contains a zero coefficient" if values.include?(0)
@tables[table_id] = QuantizationTable.new(id: table_id, precision: precision, values: values.freeze)
offset += table_size
end
end
def parse_sof(marker, payload)
raise InvalidJPEG, format("truncated FF%02X frame header", marker) if payload.bytesize < 6
component_count = payload.getbyte(5)
expected_size = 6 + (component_count * 3)
unless component_count.positive? && payload.bytesize == expected_size
raise InvalidJPEG, format("invalid FF%02X frame header length", marker)
end
component_count.times do |index|
table_id = payload.getbyte(8 + (index * 3))
raise InvalidJPEG, "invalid frame DQT table id #{table_id}" unless table_id <= 3
@used_table_ids << table_id
end
@lossless ||= LOSSLESS_SOF_MARKERS.include?(marker)
end
def parse_sos(payload)
raise InvalidJPEG, "scan precedes frame header" if @used_table_ids.empty?
raise InvalidJPEG, "truncated scan header" if payload.empty?
component_count = payload.getbyte(0)
expected_size = 1 + (component_count * 2) + 3
unless component_count.positive? && payload.bytesize == expected_size
raise InvalidJPEG, "invalid scan header length"
end
end
def read_exact(length, error_message)
result = +"".b
while result.bytesize < length
chunk = @io.read(length - result.bytesize)
raise InvalidJPEG, error_message if chunk.nil? || chunk.empty?
result << chunk
end
result
end
def read_marker
prefix = read_exact(1, "truncated JPEG before first scan").getbyte(0)
raise InvalidJPEG, format("expected marker prefix, got %02X", prefix) unless prefix == 0xFF
marker = read_exact(1, "truncated JPEG marker").getbyte(0)
marker = read_exact(1, "truncated JPEG marker").getbyte(0) while marker == 0xFF
raise InvalidJPEG, "unexpected stuffed byte outside image data" if marker.zero?
marker
end
def read_segment(marker)
read_exact(segment_payload_length(marker), marker_error(marker, "payload"))
end
def segment_payload_length(marker)
bytes = read_exact(2, marker_error(marker, "length"))
length = (bytes.getbyte(0) << 8) | bytes.getbyte(1)
raise InvalidJPEG, format("invalid FF%02X marker length %d", marker, length) if length < 2
length - 2
end
end
class Estimator
# ITU-T T.81 Annex K tables, in natural (row-major) order.
LUMINANCE_BASE = [
16, 11, 10, 16, 24, 40, 51, 61,
12, 12, 14, 19, 26, 58, 60, 55,
14, 13, 16, 24, 40, 57, 69, 56,
14, 17, 22, 29, 51, 87, 80, 62,
18, 22, 37, 56, 68, 109, 103, 77,
24, 35, 55, 64, 81, 104, 113, 92,
49, 64, 78, 87, 103, 121, 120, 101,
72, 92, 95, 98, 112, 100, 103, 99,
].freeze
CHROMINANCE_BASE = [
17, 18, 24, 47, 99, 99, 99, 99,
18, 21, 26, 66, 99, 99, 99, 99,
24, 26, 56, 99, 99, 99, 99, 99,
47, 66, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
].freeze
ZIGZAG_ORDER = [
0, 1, 8, 16, 9, 2, 3, 10,
17, 24, 32, 25, 18, 11, 4, 5,
12, 19, 26, 33, 40, 48, 41, 34,
27, 20, 13, 6, 7, 14, 21, 28,
35, 42, 49, 56, 57, 50, 43, 36,
29, 22, 15, 23, 30, 37, 44, 51,
58, 59, 52, 45, 38, 31, 39, 46,
53, 60, 61, 54, 47, 55, 62, 63,
].freeze
BASE_TABLES = [LUMINANCE_BASE, CHROMINANCE_BASE].freeze
MAX_APPROXIMATE_LOG_RMSE = Math.log(1.20)
def initialize(parsed_jpeg)
@parsed_jpeg = parsed_jpeg
end
def estimate
return unknown("lossless JPEG processes do not use DQT quality") if @parsed_jpeg.lossless
tables = active_tables
return unknown("no DQT tables were found before the first scan") if tables.empty?
missing_ids = @parsed_jpeg.used_table_ids - @parsed_jpeg.tables.keys
unless missing_ids.empty?
return unknown("frame references undefined DQT table#{"s" if missing_ids.length > 1} #{missing_ids.join(", ")}")
end
exact_quality = (1..100).find { |quality| tables.all? { |table| exact_match?(table, quality) } }
return result(:exact, exact_quality, 0.0, tables) if exact_quality
quality, score = (1..100).map { |candidate| [candidate, score(tables, candidate)] }.min_by(&:last)
if score <= MAX_APPROXIMATE_LOG_RMSE
result(:approximate, quality, Math.exp(score) - 1, tables)
else
unknown("DQT tables are not close to a single IJG/libjpeg quality", tables)
end
end
private
def active_tables
ids = @parsed_jpeg.used_table_ids
ids = @parsed_jpeg.tables.keys if ids.empty?
ids.uniq.filter_map { |id| @parsed_jpeg.tables[id] }
end
def candidate_tables(quality)
BASE_TABLES.flat_map do |base|
[scaled_table(base, quality, 255), scaled_table(base, quality, 32_767)]
end.uniq
end
def exact_match?(table, quality)
candidate_tables(quality).include?(table.values)
end
def result(status, quality, deviation, tables)
Estimate.new(
status: status,
quality: quality,
deviation: deviation,
table_count: tables.length,
precisions: tables.map(&:precision).uniq.sort,
)
end
def scaled_table(base, quality, maximum)
scale = quality < 50 ? 5000 / quality : 200 - (quality * 2)
natural = base.map { |coefficient| ((coefficient * scale + 50) / 100).clamp(1, maximum) }
ZIGZAG_ORDER.map { |index| natural[index] }
end
def score(tables, quality)
candidates = candidate_tables(quality)
total =
tables.sum do |table|
candidates
.map do |candidate|
table.values.zip(candidate).sum do |actual, expected|
Math.log(actual.to_f / expected)**2
end / 64.0
end
.min
end
Math.sqrt(total / tables.length)
end
def unknown(reason, tables = [])
Estimate.new(
status: :unknown,
table_count: tables.length,
precisions: tables.map(&:precision).uniq.sort,
reason: reason,
)
end
end
def self.estimate(source)
io = source.respond_to?(:read) ? source : File.open(source, "rb")
Estimator.new(Parser.new(io).parse).estimate
ensure
io&.close unless source.respond_to?(:read)
end
def self.run_cli(arguments, stdout: $stdout, stderr: $stderr)
if arguments.empty?
stderr.puts USAGE
stderr.puts "Estimates IJG/libjpeg DQT quality; it does not measure perceived image quality."
return 2
end
if arguments == ["-h"] || arguments == ["--help"]
stdout.puts USAGE
stdout.puts "Reports exact, approximate, or unknown IJG/libjpeg DQT quality estimates."
return 0
end
exit_status = 0
arguments.each do |filename|
estimate = estimate(filename)
details = "#{estimate.table_count} table#{"s" unless estimate.table_count == 1}"
unless estimate.precisions.empty?
details += ", #{estimate.precisions.map { |precision| "#{precision}-bit" }.join("/")} DQT"
end
case estimate.status
when :exact
stdout.puts "#{filename}: quality #{estimate.quality} (exact IJG/libjpeg match; #{details})"
when :approximate
deviation = format("%.1f%%", estimate.deviation * 100)
stdout.puts "#{filename}: quality ~#{estimate.quality} (approximate IJG/libjpeg match; #{deviation} deviation; #{details})"
when :unknown
stdout.puts "#{filename}: quality unknown (#{estimate.reason})"
end
rescue Error, IOError, SystemCallError => error
stderr.puts "#{filename}: error: #{error.message}"
exit_status = 1
end
exit_status
end
end
exit JPEGQuality.run_cli(ARGV) if $PROGRAM_NAME == __FILE__
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment