pure_jpeg 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e09848a734582d7635ff6a8c3d85b166da4f2c5b496d5c4f4e30105af4834e33
4
+ data.tar.gz: 052b8e0a21d58eb9aa9e169e06b7cfbde44f3610775da6fed86e05875dceaea3
5
+ SHA512:
6
+ metadata.gz: 750ea3d65bf2ae6c272998b2ef7b814954eb1b576f6df83036d931543f30b99481281e8a12ef63fc388fdc60db0c54815fc8219b66782415844b3ed8721a5975
7
+ data.tar.gz: 16475c5174b009a7a45eec5ee288799ea7965e4e10ed02ab20d55b15de5ebbdf92e9c0196d58053b6539dfea4601d7e22016c636ee471d2e73300feb86d97f07
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - Baseline DCT encoder (SOF0, 8-bit, Huffman)
8
+ - YCbCr color with 4:2:0 chroma subsampling
9
+ - Grayscale mode
10
+ - Baseline DCT decoder with support for any chroma subsampling factor and restart markers
11
+ - Creative encoding options: independent chroma quality, custom quantization tables, quantization modifier, scrambled quantization
12
+ - Pure Ruby, no native dependencies
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Peter Cooper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # PureJPEG
2
+
3
+ Pure Ruby JPEG encoder and decoder. Implements baseline JPEG (DCT, Huffman, 4:2:0 chroma subsampling) and exposes a variety of encoding options to adjust parts of the JPEG pipeline not normally available (I needed this to recreate the JPEG compression styles of older digital cameras - don't ask..)
4
+
5
+ ## Installation
6
+
7
+ You know the drill:
8
+
9
+ ```ruby
10
+ gem "pure_jpeg"
11
+ ```
12
+
13
+ ```
14
+ gem install pure_jpeg
15
+ ```
16
+
17
+ There are no runtime dependencies. [ChunkyPNG](https://github.com/wvanbergen/chunky_png) is optional and for if you want to use `from_chunky_png`. I have a pure PNG encoder/decoder not far behind this that will ultimately plug in nicely too to get pure Ruby graphical bliss ;-)
18
+
19
+ `examples/` contains some useful example scripts for basic JPEG to PNG and PNG to JPEG conversion if you want to do some quick tests without writing code.
20
+
21
+ ## Encoding (making JPEGs!)
22
+
23
+ ### From ChunkyPNG (easiest to get started)
24
+
25
+ ```ruby
26
+ require "chunky_png"
27
+ require "pure_jpeg"
28
+
29
+ image = ChunkyPNG::Image.from_file("photo.png")
30
+ PureJPEG.from_chunky_png(image, quality: 80).write("photo.jpg")
31
+ ```
32
+
33
+ ### From any pixel source
34
+
35
+ PureJPEG accepts any object that responds to `width`, `height`, and `[x, y]` (returning an object with `.r`, `.g`, `.b` in 0-255):
36
+
37
+ ```ruby
38
+ require "pure_jpeg"
39
+
40
+ encoder = PureJPEG.encode(source, quality: 85)
41
+ encoder.write("output.jpg")
42
+
43
+ # Or get raw bytes
44
+ jpeg_data = encoder.to_bytes
45
+ ```
46
+
47
+ ### From raw pixel data
48
+
49
+ ```ruby
50
+ source = PureJPEG::Source::RawSource.new(width, height) do |x, y|
51
+ [r, g, b] # return RGB values 0-255
52
+ end
53
+
54
+ PureJPEG.encode(source).write("output.jpg")
55
+ ```
56
+
57
+ ### Grayscale
58
+
59
+ ```ruby
60
+ PureJPEG.encode(source, grayscale: true).write("gray.jpg")
61
+ ```
62
+
63
+ ### Encoder options
64
+
65
+ ```ruby
66
+ PureJPEG.encode(source,
67
+ quality: 85, # 1-100, overall compression level
68
+ grayscale: false, # single-channel grayscale mode
69
+ chroma_quality: nil, # 1-100, independent Cb/Cr quality (defaults to quality)
70
+ luminance_table: nil, # custom 64-element quantization table for Y
71
+ chrominance_table: nil, # custom 64-element quantization table for Cb/Cr
72
+ quantization_modifier: nil, # proc(table, :luminance/:chrominance) -> modified table
73
+ scramble_quantization: false # intentionally misordered quant tables (creative effect)
74
+ )
75
+ ```
76
+
77
+ See [CREATIVE.md](CREATIVE.md) for detailed examples of the creative encoding options.
78
+
79
+ Each stage of the JPEG pipeline is a separate module, so individual components (DCT, quantization, Huffman coding) can be replaced or extended independently which is kinda my plan here as I made this to play around with effects.
80
+
81
+ ## Decoding (reading JPEGs!)
82
+
83
+ ### From file
84
+
85
+ ```ruby
86
+ image = PureJPEG.read("photo.jpg")
87
+ image.width # => 1024
88
+ image.height # => 768
89
+ pixel = image[100, 200]
90
+ pixel.r # => 182
91
+ pixel.g # => 140
92
+ pixel.b # => 97
93
+ ```
94
+
95
+ ### From binary data
96
+
97
+ ```ruby
98
+ image = PureJPEG.read(jpeg_bytes)
99
+ ```
100
+
101
+ ### Iterating pixels
102
+
103
+ ```ruby
104
+ image.each_pixel do |x, y, pixel|
105
+ puts "#{x},#{y}: rgb(#{pixel.r}, #{pixel.g}, #{pixel.b})"
106
+ end
107
+ ```
108
+
109
+ ### Re-encoding
110
+
111
+ A decoded `PureJPEG::Image` implements the same pixel source interface, so it can be passed directly back to the encoder:
112
+
113
+ ```ruby
114
+ image = PureJPEG.read("input.jpg")
115
+ PureJPEG.encode(image, quality: 60).write("recompressed.jpg")
116
+ ```
117
+
118
+ ### Converting to PNG (with ChunkyPNG)
119
+
120
+ ```ruby
121
+ image = PureJPEG.read("photo.jpg")
122
+
123
+ png = ChunkyPNG::Image.new(image.width, image.height)
124
+ image.each_pixel do |x, y, pixel|
125
+ png[x, y] = ChunkyPNG::Color.rgb(pixel.r, pixel.g, pixel.b)
126
+ end
127
+ png.save("photo.png")
128
+ ```
129
+
130
+ ## Format support
131
+
132
+ Encoding:
133
+ - Baseline DCT (SOF0)
134
+ - 8-bit precision
135
+ - Grayscale (1 component) and YCbCr color (3 components)
136
+ - 4:2:0 chroma subsampling (color) or no subsampling (grayscale)
137
+ - Standard Huffman tables (Annex K)
138
+
139
+ Decoding:
140
+ - Baseline DCT (SOF0)
141
+ - 8-bit precision
142
+ - 1-component (grayscale) and 3-component (YCbCr) images
143
+ - Any chroma subsampling factor (4:4:4, 4:2:2, 4:2:0, etc.)
144
+ - Restart markers (DRI/RST)
145
+
146
+ Not supported: progressive JPEG (SOF2), arithmetic coding, 12-bit precision, multi-scan, EXIF/ICC profile preservation. Largely because I don't need these, but they are all do-able, especially with how loosely coupled this library is internally. Raise an issue if you really care about them!
147
+
148
+ ## Performance
149
+
150
+ On a 1024x1024 image (Ruby 3.4 on my M1 Max):
151
+
152
+ | Operation | Time |
153
+ |-----------|------|
154
+ | Encode (color, q85) | ~2.8s |
155
+ | Decode (color) | ~12s |
156
+
157
+ The encoder uses a separable DCT with a precomputed cosine matrix and reuses all per-block buffers to minimize GC pressure (more on the optimizations below).
158
+
159
+ ## Some useful `rake` tasks
160
+
161
+ ```
162
+ bundle install
163
+ rake test # run the test suite
164
+ rake benchmark # benchmark encoding (3 runs against examples/a.png)
165
+ rake profile # CPU profile with StackProf (requires the stackprof gem)
166
+ ```
167
+
168
+ ## AI Disclosure
169
+
170
+ Claude Code did the majority of the work. However, it did require a lot of guidance as it was quite naive in its approach at first with its JPEG outputs looking very akin to those of my Kodak digital camera from 2001! It turns out it got something wrong which, amusingly, it seems devices of those era also got wrong (specifically not using the zigzag approach during quanitization).
171
+
172
+ The initial implementation was also VERY SLOW. It took about 15 seconds just to turn a 1024x1024 PNG into a JPEG, so some profiling was necessary which ended up finding a lot of possible optimizations to make it about 6x faster.
173
+
174
+ The tests were also a bit superficial, so I worked on getting them beefed up to tackle a variety of edge cases, although they could still be better. It also didn't do RDoc comments, use Minitest, and a variety of other things I had to coerce it into finishing.
175
+
176
+ I have read all of the code produced. A lot of the internals are above my paygrade but I'm generally OK with what has been produced and fixed a variety of stylistic things along the way.
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ class BitReader
5
+ def initialize(data)
6
+ @data = data
7
+ @pos = 0
8
+ @length = data.bytesize
9
+ @buffer = 0
10
+ @bits_in_buffer = 0
11
+ end
12
+
13
+ def read_bit
14
+ fill_buffer if @bits_in_buffer == 0
15
+ @bits_in_buffer -= 1
16
+ (@buffer >> @bits_in_buffer) & 1
17
+ end
18
+
19
+ def read_bits(n)
20
+ return 0 if n == 0
21
+ value = 0
22
+ n.times { value = (value << 1) | read_bit }
23
+ value
24
+ end
25
+
26
+ # Discard remaining bits in the buffer (for restart marker boundaries).
27
+ def reset
28
+ @bits_in_buffer = 0
29
+ @buffer = 0
30
+ end
31
+
32
+ # Read additional bits and sign-extend per JPEG spec (receive/extend).
33
+ def receive_extend(size)
34
+ return 0 if size == 0
35
+ bits = read_bits(size)
36
+ if bits < (1 << (size - 1))
37
+ bits - (1 << size) + 1
38
+ else
39
+ bits
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def fill_buffer
46
+ raise "Unexpected end of scan data" if @pos >= @length
47
+ byte = @data.getbyte(@pos)
48
+ @pos += 1
49
+ if byte == 0xFF
50
+ next_byte = @data.getbyte(@pos)
51
+ @pos += 1
52
+ # 0xFF 0x00 is a stuffed 0xFF byte
53
+ # Skip restart markers (0xD0-0xD7)
54
+ return fill_buffer if next_byte != 0x00 && next_byte >= 0xD0 && next_byte <= 0xD7
55
+ # For 0x00, the byte is 0xFF (stuffing removed)
56
+ end
57
+ @buffer = byte
58
+ @bits_in_buffer = 8
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ class BitWriter
5
+ def initialize
6
+ @data = []
7
+ @buffer = 0
8
+ @bits_in_buffer = 0
9
+ end
10
+
11
+ # Write `num_bits` of `value` (MSB first) into the output stream.
12
+ def write_bits(value, num_bits)
13
+ return if num_bits == 0
14
+ @buffer = (@buffer << num_bits) | (value & ((1 << num_bits) - 1))
15
+ @bits_in_buffer += num_bits
16
+
17
+ while @bits_in_buffer >= 8
18
+ @bits_in_buffer -= 8
19
+ byte = (@buffer >> @bits_in_buffer) & 0xFF
20
+ @data << byte
21
+ @data << 0x00 if byte == 0xFF # byte stuffing
22
+ end
23
+
24
+ @buffer &= (1 << @bits_in_buffer) - 1
25
+ end
26
+
27
+ # Pad remaining bits with 1s and flush (per JPEG spec).
28
+ def flush
29
+ return unless @bits_in_buffer > 0
30
+ padding = 8 - @bits_in_buffer
31
+ write_bits((1 << padding) - 1, padding)
32
+ end
33
+
34
+ def bytes
35
+ @data.pack("C*")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ module DCT
5
+ # Precomputed 8x8 DCT matrix: A[k][n] = (C(k)/2) * cos((2n+1)*k*pi/16)
6
+ # where C(0) = 1/sqrt(2), C(k) = 1 for k > 0.
7
+ # This lets us do the 2D DCT as two 1D matrix-vector multiplies (separable).
8
+ MATRIX = Array.new(8) { |k|
9
+ ck = k == 0 ? 0.5 / Math.sqrt(2.0) : 0.5
10
+ Array.new(8) { |n|
11
+ ck * Math.cos((2.0 * n + 1.0) * k * Math::PI / 16.0)
12
+ }
13
+ }.freeze
14
+
15
+ # Flatten for faster indexed access
16
+ MATRIX_FLAT = MATRIX.flatten.freeze
17
+
18
+ # Transposed matrix for inverse DCT: A^T[n][k] = A[k][n]
19
+ MATRIX_T_FLAT = Array.new(64) { |i| MATRIX_FLAT[(i % 8) * 8 + i / 8] }.freeze
20
+
21
+ # Separable forward 2D DCT: row pass then column pass.
22
+ # Writes result into `out`. Uses `temp` as scratch space.
23
+ # All three arrays must be pre-allocated with 64 elements.
24
+ def self.forward!(block, temp, out)
25
+ # Row pass: temp[y*8+u] = sum_x A[u][x] * block[y*8+x]
26
+ m = MATRIX_FLAT
27
+ 8.times do |y|
28
+ y8 = y << 3
29
+ b0 = block[y8]; b1 = block[y8|1]; b2 = block[y8|2]; b3 = block[y8|3]
30
+ b4 = block[y8|4]; b5 = block[y8|5]; b6 = block[y8|6]; b7 = block[y8|7]
31
+ 8.times do |u|
32
+ u8 = u << 3
33
+ temp[y8|u] = m[u8]*b0 + m[u8|1]*b1 + m[u8|2]*b2 + m[u8|3]*b3 +
34
+ m[u8|4]*b4 + m[u8|5]*b5 + m[u8|6]*b6 + m[u8|7]*b7
35
+ end
36
+ end
37
+
38
+ # Column pass: out[v*8+u] = sum_y A[v][y] * temp[y*8+u]
39
+ 8.times do |u|
40
+ t0 = temp[u]; t1 = temp[8|u]; t2 = temp[16|u]; t3 = temp[24|u]
41
+ t4 = temp[32|u]; t5 = temp[40|u]; t6 = temp[48|u]; t7 = temp[56|u]
42
+ 8.times do |v|
43
+ v8 = v << 3
44
+ out[v8|u] = m[v8]*t0 + m[v8|1]*t1 + m[v8|2]*t2 + m[v8|3]*t3 +
45
+ m[v8|4]*t4 + m[v8|5]*t5 + m[v8|6]*t6 + m[v8|7]*t7
46
+ end
47
+ end
48
+
49
+ out
50
+ end
51
+
52
+ # Separable inverse 2D DCT: same structure as forward but using A^T.
53
+ # f = A^T * F * A
54
+ def self.inverse!(block, temp, out)
55
+ mt = MATRIX_T_FLAT
56
+
57
+ # Row pass: temp[v*8+x] = sum_u A^T[x][u] * block[v*8+u]
58
+ 8.times do |v|
59
+ v8 = v << 3
60
+ b0 = block[v8]; b1 = block[v8|1]; b2 = block[v8|2]; b3 = block[v8|3]
61
+ b4 = block[v8|4]; b5 = block[v8|5]; b6 = block[v8|6]; b7 = block[v8|7]
62
+ 8.times do |x|
63
+ x8 = x << 3
64
+ temp[v8|x] = mt[x8]*b0 + mt[x8|1]*b1 + mt[x8|2]*b2 + mt[x8|3]*b3 +
65
+ mt[x8|4]*b4 + mt[x8|5]*b5 + mt[x8|6]*b6 + mt[x8|7]*b7
66
+ end
67
+ end
68
+
69
+ # Column pass: out[y*8+x] = sum_v A^T[y][v] * temp[v*8+x]
70
+ 8.times do |x|
71
+ t0 = temp[x]; t1 = temp[8|x]; t2 = temp[16|x]; t3 = temp[24|x]
72
+ t4 = temp[32|x]; t5 = temp[40|x]; t6 = temp[48|x]; t7 = temp[56|x]
73
+ 8.times do |y|
74
+ y8 = y << 3
75
+ out[y8|x] = mt[y8]*t0 + mt[y8|1]*t1 + mt[y8|2]*t2 + mt[y8|3]*t3 +
76
+ mt[y8|4]*t4 + mt[y8|5]*t5 + mt[y8|6]*t6 + mt[y8|7]*t7
77
+ end
78
+ end
79
+
80
+ out
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ # Baseline JPEG decoder.
5
+ #
6
+ # Decodes baseline DCT (SOF0) JPEGs with 1 or 3 components, any chroma
7
+ # subsampling factor, and restart markers.
8
+ #
9
+ # Use {PureJPEG.read} for a convenient entry point.
10
+ class Decoder
11
+ # Decode a JPEG from a file path or binary string.
12
+ #
13
+ # @param path_or_data [String] a file path or raw JPEG bytes
14
+ # @return [Image] decoded image with pixel access
15
+ def self.decode(path_or_data)
16
+ data = if path_or_data.is_a?(String) && !path_or_data.include?("\x00") && File.exist?(path_or_data)
17
+ File.binread(path_or_data)
18
+ else
19
+ path_or_data.b
20
+ end
21
+ new(data).decode
22
+ end
23
+
24
+ def initialize(data)
25
+ @data = data
26
+ end
27
+
28
+ def decode
29
+ jfif = JFIFReader.new(@data)
30
+ width = jfif.width
31
+ height = jfif.height
32
+
33
+ # Build Huffman decode tables
34
+ dc_tables = {}
35
+ ac_tables = {}
36
+ jfif.huffman_tables.each do |(table_class, table_id), info|
37
+ table = Huffman::DecodeTable.new(info[:bits], info[:values])
38
+ if table_class == 0
39
+ dc_tables[table_id] = table
40
+ else
41
+ ac_tables[table_id] = table
42
+ end
43
+ end
44
+
45
+ # Map component IDs to their info
46
+ comp_info = {}
47
+ jfif.components.each { |c| comp_info[c.id] = c }
48
+
49
+ # Determine max sampling factors
50
+ max_h = jfif.components.map(&:h_sampling).max
51
+ max_v = jfif.components.map(&:v_sampling).max
52
+
53
+ # MCU dimensions in pixels
54
+ mcu_px_w = max_h * 8
55
+ mcu_px_h = max_v * 8
56
+ mcus_x = (width + mcu_px_w - 1) / mcu_px_w
57
+ mcus_y = (height + mcu_px_h - 1) / mcu_px_h
58
+
59
+ # Allocate channel buffers (full padded size)
60
+ padded_w = mcus_x * mcu_px_w
61
+ padded_h = mcus_y * mcu_px_h
62
+ channels = {}
63
+ jfif.components.each do |c|
64
+ ch_w = (padded_w * c.h_sampling) / max_h
65
+ ch_h = (padded_h * c.v_sampling) / max_v
66
+ channels[c.id] = { data: Array.new(ch_w * ch_h, 0), width: ch_w, height: ch_h }
67
+ end
68
+
69
+ # Decode scan data
70
+ reader = BitReader.new(jfif.scan_data)
71
+ prev_dc = Hash.new(0)
72
+ restart_interval = jfif.restart_interval
73
+ mcu_count = 0
74
+
75
+ # Reusable buffers
76
+ zigzag = Array.new(64, 0)
77
+ raster = Array.new(64, 0.0)
78
+ dequant = Array.new(64, 0.0)
79
+ temp = Array.new(64, 0.0)
80
+ spatial = Array.new(64, 0.0)
81
+
82
+ mcus_y.times do |mcu_row|
83
+ mcus_x.times do |mcu_col|
84
+ # Handle restart interval
85
+ if restart_interval > 0 && mcu_count > 0 && (mcu_count % restart_interval) == 0
86
+ reader.reset
87
+ prev_dc.clear
88
+ end
89
+
90
+ jfif.scan_components.each do |sc|
91
+ comp = comp_info[sc.id]
92
+ dc_tab = dc_tables[sc.dc_table_id]
93
+ ac_tab = ac_tables[sc.ac_table_id]
94
+ qt = jfif.quant_tables[comp.qt_id]
95
+ ch = channels[comp.id]
96
+
97
+ comp.v_sampling.times do |bv|
98
+ comp.h_sampling.times do |bh|
99
+ # Decode one 8x8 block
100
+ decode_block(reader, dc_tab, ac_tab, prev_dc, sc.id, zigzag)
101
+
102
+ # Inverse pipeline: unzigzag -> dequantize -> IDCT -> level shift
103
+ Zigzag.unreorder!(zigzag, raster)
104
+ Quantization.dequantize!(raster, qt, dequant)
105
+ DCT.inverse!(dequant, temp, spatial)
106
+
107
+ # Write block into channel buffer
108
+ bx = (mcu_col * comp.h_sampling + bh) * 8
109
+ by = (mcu_row * comp.v_sampling + bv) * 8
110
+ write_block(spatial, ch[:data], ch[:width], bx, by)
111
+ end
112
+ end
113
+ end
114
+
115
+ mcu_count += 1
116
+ end
117
+ end
118
+
119
+ # Assemble pixels
120
+ num_components = jfif.components.length
121
+ if num_components == 1
122
+ assemble_grayscale(width, height, channels, jfif.components[0])
123
+ else
124
+ assemble_color(width, height, channels, jfif.components, max_h, max_v)
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def decode_block(reader, dc_tab, ac_tab, prev_dc, comp_id, out)
131
+ # DC coefficient
132
+ dc_cat = dc_tab.decode(reader)
133
+ dc_diff = reader.receive_extend(dc_cat)
134
+ dc_val = prev_dc[comp_id] + dc_diff
135
+ prev_dc[comp_id] = dc_val
136
+ out[0] = dc_val
137
+
138
+ # AC coefficients
139
+ i = 1
140
+ while i < 64
141
+ symbol = ac_tab.decode(reader)
142
+ if symbol == 0x00 # EOB
143
+ while i < 64
144
+ out[i] = 0
145
+ i += 1
146
+ end
147
+ break
148
+ elsif symbol == 0xF0 # ZRL (16 zeros)
149
+ 16.times do
150
+ out[i] = 0
151
+ i += 1
152
+ end
153
+ else
154
+ run = (symbol >> 4) & 0x0F
155
+ size = symbol & 0x0F
156
+ run.times do
157
+ out[i] = 0
158
+ i += 1
159
+ end
160
+ out[i] = reader.receive_extend(size)
161
+ i += 1
162
+ end
163
+ end
164
+
165
+ out
166
+ end
167
+
168
+ # Write an 8x8 spatial block (level-shifted by +128) into a channel buffer.
169
+ def write_block(spatial, channel, ch_width, bx, by)
170
+ 8.times do |row|
171
+ dst_row = (by + row) * ch_width + bx
172
+ row8 = row << 3
173
+ 8.times do |col|
174
+ val = (spatial[row8 | col] + 128.0).round
175
+ channel[dst_row + col] = val < 0 ? 0 : (val > 255 ? 255 : val)
176
+ end
177
+ end
178
+ end
179
+
180
+ def assemble_grayscale(width, height, channels, comp)
181
+ ch = channels[comp.id]
182
+ pixels = Array.new(width * height)
183
+ height.times do |y|
184
+ src_row = y * ch[:width]
185
+ dst_row = y * width
186
+ width.times do |x|
187
+ v = ch[:data][src_row + x]
188
+ pixels[dst_row + x] = Source::Pixel.new(v, v, v)
189
+ end
190
+ end
191
+ Image.new(width, height, pixels)
192
+ end
193
+
194
+ def assemble_color(width, height, channels, components, max_h, max_v)
195
+ # Upsample chroma channels if needed and convert YCbCr to RGB
196
+ y_ch = channels[components[0].id]
197
+ cb_ch = channels[components[1].id]
198
+ cr_ch = channels[components[2].id]
199
+
200
+ cb_comp = components[1]
201
+ cr_comp = components[2]
202
+
203
+ pixels = Array.new(width * height)
204
+
205
+ height.times do |py|
206
+ dst_row = py * width
207
+ y_row = py * y_ch[:width]
208
+
209
+ # Chroma coordinates (nearest-neighbor upsampling)
210
+ cb_y = (py * cb_comp.v_sampling) / max_v
211
+ cr_y = (py * cr_comp.v_sampling) / max_v
212
+ cb_row = cb_y * cb_ch[:width]
213
+ cr_row = cr_y * cr_ch[:width]
214
+
215
+ width.times do |px|
216
+ lum = y_ch[:data][y_row + px]
217
+
218
+ cb_x = (px * cb_comp.h_sampling) / max_h
219
+ cr_x = (px * cr_comp.h_sampling) / max_h
220
+ cb = cb_ch[:data][cb_row + cb_x] - 128.0
221
+ cr = cr_ch[:data][cr_row + cr_x] - 128.0
222
+
223
+ r = (lum + 1.402 * cr).round
224
+ g = (lum - 0.344136 * cb - 0.714136 * cr).round
225
+ b = (lum + 1.772 * cb).round
226
+
227
+ r = r < 0 ? 0 : (r > 255 ? 255 : r)
228
+ g = g < 0 ? 0 : (g > 255 ? 255 : g)
229
+ b = b < 0 ? 0 : (b > 255 ? 255 : b)
230
+
231
+ pixels[dst_row + px] = Source::Pixel.new(r, g, b)
232
+ end
233
+ end
234
+
235
+ Image.new(width, height, pixels)
236
+ end
237
+ end
238
+ end