jxl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +93 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +90 -0
  5. data/Rakefile +31 -0
  6. data/docs/adr/0001-buffer-representation.md +12 -0
  7. data/docs/bench_history.md +11 -0
  8. data/docs/spec_map.md +16 -0
  9. data/exe/cjxl +21 -0
  10. data/exe/djxl +66 -0
  11. data/exe/jxlinfo +31 -0
  12. data/lib/jxl/basic_info.rb +16 -0
  13. data/lib/jxl/bit/field.rb +126 -0
  14. data/lib/jxl/bit/reader.rb +78 -0
  15. data/lib/jxl/bit/writer.rb +46 -0
  16. data/lib/jxl/codestream/reader.rb +68 -0
  17. data/lib/jxl/color/gamut.rb +80 -0
  18. data/lib/jxl/color/icc_profile.rb +243 -0
  19. data/lib/jxl/color/opsin.rb +28 -0
  20. data/lib/jxl/color/transfer.rb +108 -0
  21. data/lib/jxl/color/ycbcr.rb +63 -0
  22. data/lib/jxl/container/box.rb +7 -0
  23. data/lib/jxl/container/parser.rb +134 -0
  24. data/lib/jxl/container/signature.rb +19 -0
  25. data/lib/jxl/dct.rb +81 -0
  26. data/lib/jxl/decoder.rb +85 -0
  27. data/lib/jxl/encoder.rb +180 -0
  28. data/lib/jxl/entropy/ans_distribution.rb +212 -0
  29. data/lib/jxl/entropy/decoder.rb +226 -0
  30. data/lib/jxl/entropy/encoder.rb +69 -0
  31. data/lib/jxl/entropy/hybrid_uint.rb +55 -0
  32. data/lib/jxl/entropy/icc_stream.rb +293 -0
  33. data/lib/jxl/entropy/permutation.rb +64 -0
  34. data/lib/jxl/entropy/prefix_code.rb +177 -0
  35. data/lib/jxl/errors.rb +12 -0
  36. data/lib/jxl/features/noise.rb +140 -0
  37. data/lib/jxl/features/patches.rb +147 -0
  38. data/lib/jxl/features/splines.rb +206 -0
  39. data/lib/jxl/features/spot_colour.rb +29 -0
  40. data/lib/jxl/features/upsampling.rb +99 -0
  41. data/lib/jxl/features/upsampling_weights.bin +0 -0
  42. data/lib/jxl/filter/epf.rb +119 -0
  43. data/lib/jxl/filter/gaborish.rb +36 -0
  44. data/lib/jxl/frame/toc.rb +31 -0
  45. data/lib/jxl/headers/bit_depth.rb +32 -0
  46. data/lib/jxl/headers/colour_encoding.rb +81 -0
  47. data/lib/jxl/headers/custom_transform.rb +46 -0
  48. data/lib/jxl/headers/frame_header.rb +251 -0
  49. data/lib/jxl/headers/image_metadata.rb +128 -0
  50. data/lib/jxl/headers/size_header.rb +29 -0
  51. data/lib/jxl/image.rb +63 -0
  52. data/lib/jxl/io/npy.rb +82 -0
  53. data/lib/jxl/io/pam.rb +14 -0
  54. data/lib/jxl/io/pfm.rb +21 -0
  55. data/lib/jxl/io/pgx.rb +22 -0
  56. data/lib/jxl/io/png.rb +132 -0
  57. data/lib/jxl/io/ppm.rb +63 -0
  58. data/lib/jxl/modular/decoder.rb +371 -0
  59. data/lib/jxl/modular/group_header.rb +82 -0
  60. data/lib/jxl/modular/ma_tree.rb +95 -0
  61. data/lib/jxl/modular/predictor.rb +107 -0
  62. data/lib/jxl/modular/stream.rb +49 -0
  63. data/lib/jxl/modular/transform.rb +235 -0
  64. data/lib/jxl/modular/weighted.rb +85 -0
  65. data/lib/jxl/num.rb +37 -0
  66. data/lib/jxl/plane.rb +35 -0
  67. data/lib/jxl/render/blender.rb +61 -0
  68. data/lib/jxl/render/orientation.rb +42 -0
  69. data/lib/jxl/trace.rb +36 -0
  70. data/lib/jxl/vardct/ac_strategy.rb +65 -0
  71. data/lib/jxl/vardct/afv_basis.bin +0 -0
  72. data/lib/jxl/vardct/block_context_map.rb +63 -0
  73. data/lib/jxl/vardct/chroma_from_luma.rb +27 -0
  74. data/lib/jxl/vardct/coeff_order.rb +35 -0
  75. data/lib/jxl/vardct/decoder.rb +136 -0
  76. data/lib/jxl/vardct/default_dequant.bin.z +0 -0
  77. data/lib/jxl/vardct/dequant.rb +284 -0
  78. data/lib/jxl/vardct/hf_global.rb +30 -0
  79. data/lib/jxl/vardct/lf_global.rb +43 -0
  80. data/lib/jxl/vardct/lf_group.rb +227 -0
  81. data/lib/jxl/vardct/pass_group.rb +153 -0
  82. data/lib/jxl/vardct/quantizer.rb +27 -0
  83. data/lib/jxl/vardct/reconstructor.rb +294 -0
  84. data/lib/jxl/version.rb +5 -0
  85. data/lib/jxl.rb +216 -0
  86. data/tools/bench.rb +20 -0
  87. data/tools/compare_trace.rb +42 -0
  88. data/tools/conformance_report.rb +23 -0
  89. data/tools/fuzz.rb +51 -0
  90. data/tools/gen_afv_basis.rb +12 -0
  91. data/tools/gen_quant_tables.rb +11 -0
  92. data/tools/gen_upsampling_weights.rb +13 -0
  93. data/tools/libjxl_trace.patch +30 -0
  94. metadata +135 -0
data/lib/jxl/io/npy.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module IO
5
+ module NPY
6
+ MAGIC = "\x93NUMPY".b
7
+ FORMATS = {
8
+ "<f4" => "e*",
9
+ "<f8" => "E*",
10
+ "|u1" => "C*",
11
+ "<u2" => "v*",
12
+ "<i4" => "l<*"
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def load(path)
18
+ data = File.binread(path)
19
+ raise FormatError, "invalid NPY signature" unless data.start_with?(MAGIC)
20
+
21
+ major = data.getbyte(6)
22
+ header_offset, header_length = header_location(data, major)
23
+ header = data.byteslice(header_offset, header_length)
24
+ descriptor = header[/['"]descr['"]\s*:\s*['"]([^'"]+)['"]/, 1]
25
+ fortran = header[/['"]fortran_order['"]\s*:\s*(True|False)/, 1]
26
+ raise FormatError, "invalid NPY header" unless descriptor && fortran
27
+ raise UnsupportedFeatureError, "Fortran-order NPY array" if fortran == "True"
28
+
29
+ shape = parse_shape(header)
30
+ format = FORMATS[descriptor] || raise(UnsupportedFeatureError, "NPY dtype #{descriptor}")
31
+ values = data.byteslice((header_offset + header_length)..).unpack(format)
32
+ expected = shape.inject(1, :*)
33
+ unless values.length == expected
34
+ raise CorruptError,
35
+ "NPY shape contains #{expected} values, got #{values.length}"
36
+ end
37
+
38
+ [shape, values]
39
+ end
40
+
41
+ def dump(path, shape, values, descriptor: "<f4")
42
+ format = FORMATS[descriptor] || raise(UnsupportedFeatureError, "NPY dtype #{descriptor}")
43
+ expected = shape.inject(1, :*)
44
+ raise ArgumentError, "shape does not match values" unless expected == values.length
45
+
46
+ shape_text = shape.length == 1 ? "#{shape.first}," : shape.join(", ")
47
+ dictionary = "{'descr': '#{descriptor}', 'fortran_order': False, 'shape': (#{shape_text}), }"
48
+ padding = 16 - ((MAGIC.bytesize + 4 + dictionary.bytesize + 1) % 16)
49
+ header = "#{dictionary}#{' ' * padding}\n"
50
+ File.binwrite(path, MAGIC + [1, 0, header.bytesize].pack("CCv") + header + values.pack(format))
51
+ end
52
+
53
+ def dump_image(path, image)
54
+ frame_planes = image.frames.empty? ? [image.channels] : image.frames.map(&:planes)
55
+ channels = image.num_color_channels + image.metadata.extra_channels.length
56
+ values = frame_planes.flat_map { |planes| planes.first(channels).map(&:data).transpose.flatten }
57
+ dump(path, [frame_planes.length, image.height, image.width, channels], values)
58
+ end
59
+
60
+ def header_location(data, major)
61
+ case major
62
+ when 1 then [10, data.unpack1("v", offset: 8)]
63
+ when 2, 3 then [12, data.unpack1("V", offset: 8)]
64
+ else raise UnsupportedFeatureError, "NPY version #{major}"
65
+ end
66
+ end
67
+ private_class_method :header_location
68
+
69
+ def parse_shape(header)
70
+ text = header[/['"]shape['"]\s*:\s*\(([^)]*)\)/, 1]
71
+ raise FormatError, "invalid NPY shape" unless text
72
+
73
+ text.split(",").filter_map do |part|
74
+ next if part.strip.empty?
75
+
76
+ Integer(part, 10)
77
+ end
78
+ end
79
+ private_class_method :parse_shape
80
+ end
81
+ end
82
+ end
data/lib/jxl/io/pam.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module IO
5
+ module PAM
6
+ module_function
7
+
8
+ def dump(image)
9
+ header = "P7\nWIDTH #{image.width}\nHEIGHT #{image.height}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"
10
+ header.b + image.to_rgba8
11
+ end
12
+ end
13
+ end
14
+ end
data/lib/jxl/io/pfm.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module IO
5
+ module PFM
6
+ module_function
7
+
8
+ def dump(image)
9
+ grayscale = image.num_color_channels == 1
10
+ planes = grayscale ? [image.channels.first] : image.channels.first(3)
11
+ values = []
12
+ (image.height - 1).downto(0) do |y|
13
+ image.width.times do |x|
14
+ planes.each { |plane| values << plane[x, y] }
15
+ end
16
+ end
17
+ "#{grayscale ? 'Pf' : 'PF'}\n#{image.width} #{image.height}\n-1.0\n".b + values.pack("e*")
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/jxl/io/pgx.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module IO
5
+ module PGX
6
+ module_function
7
+
8
+ def dump(image)
9
+ raise UnsupportedFeatureError, "PGX requires grayscale without extra channels" \
10
+ unless image.num_color_channels == 1 && image.extra_channels.empty?
11
+
12
+ bits = image.metadata.bit_depth.bits_per_sample
13
+ raise UnsupportedFeatureError, "PGX supports at most 16 bits" if bits > 16
14
+
15
+ bits = bits <= 8 ? 8 : 16
16
+ maximum = (1 << bits) - 1
17
+ pixels = image.channels.first.data.map { (Num.clamp(_1, 0.0, 1.0) * maximum).round }
18
+ "PG ML + #{bits} #{image.width} #{image.height}\n".b + pixels.pack(bits == 8 ? "C*" : "n*")
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/jxl/io/png.rb ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module JXL
6
+ module IO
7
+ module PNG
8
+ SIGNATURE = "\x89PNG\r\n\x1A\n".b
9
+
10
+ module_function
11
+
12
+ def dump(image) # rubocop:disable Metrics/AbcSize
13
+ planes, colour_type = output_planes(image)
14
+ header = [image.width, image.height, 8, colour_type, 0, 0, 0].pack("N2C5")
15
+ pixels = (image.width * image.height).times.flat_map do |pixel|
16
+ planes.map { (Num.clamp(_1.data[pixel], 0.0, 1.0) * 255).round }
17
+ end
18
+ stride = image.width * planes.length
19
+ rows = pixels.each_slice(stride).map { "\0".b + _1.pack("C*") }.join
20
+ SIGNATURE + chunk("IHDR", header) + chunk("iCCP", icc(image)) +
21
+ chunk("IDAT", Zlib::Deflate.deflate(rows)) + chunk("IEND", "".b)
22
+ end
23
+
24
+ def load(data) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
25
+ raise FormatError, "invalid PNG signature" unless data.start_with?(SIGNATURE)
26
+
27
+ chunks = read_chunks(data)
28
+ width, height, depth, colour, compression, filter, interlace = chunks.fetch("IHDR").first.unpack("N2C5")
29
+ unless depth == 8 && [0, 2, 4, 6].include?(colour) && [compression, filter, interlace].all?(&:zero?)
30
+ raise UnsupportedFeatureError, "only non-interlaced 8-bit PNG input is supported"
31
+ end
32
+
33
+ components = { 0 => 1, 2 => 3, 4 => 2, 6 => 4 }.fetch(colour)
34
+ stride = width * components
35
+ raw = Zlib::Inflate.inflate(chunks.fetch("IDAT").join)
36
+ raise TruncatedError, "PNG pixels are truncated" unless raw.bytesize == height * (stride + 1)
37
+
38
+ previous = Array.new(stride, 0)
39
+ pixels = height.times.flat_map do |row|
40
+ start = row * (stride + 1)
41
+ type = raw.getbyte(start)
42
+ scanline = unfilter(raw.byteslice(start + 1, stride).bytes, previous, components, type)
43
+ previous = scanline
44
+ end
45
+ colour_components = [components, 3].min
46
+ channels = colour_components.times.map do |channel|
47
+ pixels.drop(channel).each_slice(components).map(&:first)
48
+ end
49
+ channels = [channels.first] * 3 if colour_components < 3
50
+ planes = channels.map do |values|
51
+ Plane.new(width, height).tap { |plane| plane.data.replace(values.map { _1 / 255.0 }) }
52
+ end
53
+ Image.new(width:, height:, channels: planes, metadata: Headers::ImageMetadata.defaults)
54
+ rescue KeyError, Zlib::Error => e
55
+ raise FormatError, "invalid PNG: #{e.message}"
56
+ end
57
+
58
+ def chunk(type, data)
59
+ body = type.b + data
60
+ [data.bytesize].pack("N") + body + [Zlib.crc32(body)].pack("N")
61
+ end
62
+ private_class_method :chunk
63
+
64
+ def output_planes(image)
65
+ grayscale = image.num_color_channels == 1
66
+ colour = grayscale ? [image.channels.first] : image.channels.first(3)
67
+ alpha_index = image.metadata.extra_channels.index { _1.type.zero? }
68
+ alpha = image.extra_channels[alpha_index] if alpha_index
69
+ colour_type = if grayscale
70
+ alpha ? 4 : 0
71
+ else
72
+ alpha ? 6 : 2
73
+ end
74
+ [alpha ? colour + [alpha] : colour, colour_type]
75
+ end
76
+ private_class_method :output_planes
77
+
78
+ def read_chunks(data)
79
+ chunks = Hash.new { |hash, key| hash[key] = [] }
80
+ offset = SIGNATURE.bytesize
81
+ while offset < data.bytesize
82
+ raise TruncatedError, "PNG chunk is truncated" if offset + 12 > data.bytesize
83
+
84
+ length = data.unpack1("N", offset:)
85
+ type = data.byteslice(offset + 4, 4)
86
+ payload = data.byteslice(offset + 8, length)
87
+ crc = data.unpack1("N", offset: offset + 8 + length)
88
+ raise TruncatedError, "PNG chunk is truncated" unless payload && crc
89
+ raise FormatError, "invalid PNG checksum" unless Zlib.crc32(type + payload) == crc
90
+
91
+ chunks[type] << payload
92
+ offset += length + 12
93
+ end
94
+ chunks
95
+ end
96
+ private_class_method :read_chunks
97
+
98
+ def unfilter(bytes, previous, components, type) # rubocop:disable Metrics/CyclomaticComplexity
99
+ bytes.each_index do |index|
100
+ left = index >= components ? bytes[index - components] : 0
101
+ top = previous[index]
102
+ top_left = index >= components ? previous[index - components] : 0
103
+ predictor = case type
104
+ when 0 then 0
105
+ when 1 then left
106
+ when 2 then top
107
+ when 3 then (left + top) / 2
108
+ when 4 then paeth(left, top, top_left)
109
+ else raise FormatError, "invalid PNG filter"
110
+ end
111
+ bytes[index] = (bytes[index] + predictor) & 255
112
+ end
113
+ bytes
114
+ end
115
+ private_class_method :unfilter
116
+
117
+ def paeth(left, top, top_left)
118
+ base = left + top - top_left
119
+ distances = [(base - left).abs, (base - top).abs, (base - top_left).abs]
120
+ [left, top, top_left][distances.index(distances.min)]
121
+ end
122
+ private_class_method :paeth
123
+
124
+ def icc(image)
125
+ raise ArgumentError, "image has no ICC profile" unless image.icc_profile
126
+
127
+ "jxl\0\0".b + Zlib::Deflate.deflate(image.icc_profile)
128
+ end
129
+ private_class_method :icc
130
+ end
131
+ end
132
+ end
data/lib/jxl/io/ppm.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module IO
5
+ module PPM
6
+ module_function
7
+
8
+ def dump(image)
9
+ grayscale = image.num_color_channels == 1
10
+ planes = grayscale ? [image.channels.first] : image.channels.first(3)
11
+ pixels = (image.width * image.height).times.flat_map do |pixel|
12
+ planes.map { (Num.clamp(_1.data[pixel], 0.0, 1.0) * 255).round }
13
+ end
14
+ "#{grayscale ? 'P5' : 'P6'}\n#{image.width} #{image.height}\n255\n".b + pixels.pack("C*")
15
+ end
16
+
17
+ def load(data) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
18
+ offset = 0
19
+ magic, offset = token(data, offset)
20
+ width, offset = token(data, offset)
21
+ height, offset = token(data, offset)
22
+ maximum, offset = token(data, offset)
23
+ raise FormatError, "unsupported PNM format" unless %w[P5 P6].include?(magic)
24
+
25
+ width = Integer(width, 10)
26
+ height = Integer(height, 10)
27
+ maximum = Integer(maximum, 10)
28
+ raise FormatError, "unsupported PNM range" unless maximum.between?(1, 65_535)
29
+
30
+ offset += 1
31
+ offset += 1 if data.getbyte(offset - 1) == 13 && data.getbyte(offset) == 10
32
+ count = width * height * (magic == "P6" ? 3 : 1)
33
+ bytes = maximum < 256 ? data.byteslice(offset, count)&.bytes : data.byteslice(offset, count * 2)&.unpack("n*")
34
+ raise TruncatedError, "PNM pixels are truncated" unless bytes&.length == count
35
+
36
+ channels = if magic == "P6"
37
+ 3.times.map { |channel| bytes.drop(channel).each_slice(3).map(&:first) }
38
+ else
39
+ [bytes] * 3
40
+ end
41
+ planes = channels.map do |values|
42
+ Plane.new(width, height).tap { |plane| plane.data.replace(values.map { _1 / maximum.to_f }) }
43
+ end
44
+ Image.new(width:, height:, channels: planes, metadata: Headers::ImageMetadata.defaults)
45
+ end
46
+
47
+ def token(data, offset) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
48
+ loop do
49
+ offset += 1 while data.getbyte(offset)&.chr&.match?(/\s/)
50
+ break unless data.getbyte(offset) == 35
51
+
52
+ offset = data.index("\n", offset) || data.bytesize
53
+ end
54
+ finish = offset
55
+ finish += 1 while data.getbyte(finish) && !data.getbyte(finish).chr.match?(/\s/)
56
+ raise TruncatedError, "PNM header is truncated" if finish == offset
57
+
58
+ [data.byteslice(offset, finish - offset), finish]
59
+ end
60
+ private_class_method :token
61
+ end
62
+ end
63
+ end