pure_jpeg 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 780f932b176fecdb5daab2546909fe2610325e54f7364cf482e3e2652ab614a5
4
- data.tar.gz: 1fbc350f25d09b989ee6262e077bb36847c6637db325fb03c29f2083bb0ec973
3
+ metadata.gz: 81f59b74746cbb7c575aa22e250667b8b126a542613833f2e45b57685fed02c0
4
+ data.tar.gz: 87fb8c2b6dd5035d93c4326a37fac8e43ade2af88f92a3dd1f7761960f675126
5
5
  SHA512:
6
- metadata.gz: a385db40804bf4a992d78253ba619e90b147aa5be7808b341398918f5f36c3593fbd1a979aaad9729ad874f2427fe15d31fc9a0413e5f7ea98ee44e43e125f2d
7
- data.tar.gz: b9f5581f4c4f27f42460961b3b4231fd94b45be130fbdce74a28bc4888f2a2f3de08fd43b9e3efc782ccb6b4f260aa8484ff7794e7bde1362018dc3deb282b26
6
+ metadata.gz: 930f8db2b6ab86080c115e77f56e0918853e455dbcd18129daa5aaca77715df198a502010ae218275799e8b364f115232e11139ffcb42ca981c57478ef12b45b
7
+ data.tar.gz: 54c50b3a2316c6fa0b9db38fcabdd589bcc17d2edfeff653a28ec4054a72c2b5615eef5fd9f123fb31ce56448864495f49942bf63c4536d00c21fd4e7a6eb4f2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ Performance:
6
+
7
+ - Faster Huffman coding and decoding, with scan references resolved once per scan
8
+ - Combined quantization and zigzag passes, including direct reconstruction from progressive coefficient buffers
9
+ - Color encoding downsamples chroma two rows at a time instead of allocating full-resolution chroma images
10
+ - Block-based `RawSource` construction avoids allocating pixels that would immediately be replaced
11
+ - Measured encode/decode times reduced by roughly 12–28% on Ruby 4.0.2 with YJIT; baseline decode allocations fell from 76,994 to 114 on the bundled 1024x1024 benchmark
12
+ - Encoding array storage allocated for that image fell from 29.4 MB to 12.6 MB, with encoded bytes and decoded pixels preserved in equivalence checks
13
+
14
+ Compatibility and testing:
15
+
16
+ - Experimental Spinel compatibility, tested at revision `7f08abd9`, with benchmarks and a `rake test:spinel` proof-of-concept runner
17
+ - Spinel currently passes seven of the eight selected RawSource tests; the remaining test fails because `respond_to?` is unavailable on its pixel struct. See `test/SPINEL.md` for details
18
+ - Added regression tests for quantization rounding, progressive coefficient offsets, and chroma downsampling edges
19
+ - Updated performance comparisons for CRuby, TruffleRuby, JRuby, and Spinel
20
+
21
+ ## 0.3.3
22
+
23
+ New features:
24
+
25
+ - `PureJPEG.from_chunky_png` accepts `background: [r, g, b]` to composite transparent PNG pixels before JPEG encoding
26
+
27
+ Fixes:
28
+
29
+ - Invalid `quality` and `chroma_quality` values now raise clear `ArgumentError`s
30
+ - `Image#[]` and `Image#[]=` now raise `IndexError` for out-of-bounds coordinates
31
+ - Hardened JFIF segment length validation for malformed JPEG input
32
+
3
33
  ## 0.3.2
4
34
 
5
35
  Performance:
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Convert PNG or other pixel data to JPEG. Or the other way! Implements baseline JPEG encoding (DCT, Huffman, 4:2:0 chroma subsampling) and decodes both baseline and progressive JPEGs. 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..)
8
8
 
9
- It works on CRuby 3.0+, TruffleRuby 33.0, and JRuby 10.0.
9
+ It works on CRuby 3.0+, TruffleRuby 33.0, JRuby 10.0, and [Spinel](https://github.com/matz/spinel) (tested at `7f08abd9`). There's *almost* 100% test coverage - I need to find some "broken" JPEGs to do the rest (hit me up if you have any sources..)
10
10
 
11
11
  > [!NOTE]
12
12
  > Rubyists might find the [AI Disclosure](#ai-disclosure) section below of interest.
@@ -23,7 +23,7 @@ gem "pure_jpeg"
23
23
  gem install pure_jpeg
24
24
  ```
25
25
 
26
- There are no runtime dependencies. [ChunkyPNG](https://github.com/wvanbergen/chunky_png) is optional (though quite useful) 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 100% pure Ruby graphical bliss ;-)
26
+ There are no runtime dependencies. [ChunkyPNG](https://github.com/wvanbergen/chunky_png) is optional (though quite useful) if you want to use `from_chunky_png`.
27
27
 
28
28
  `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.
29
29
 
@@ -39,6 +39,13 @@ image = ChunkyPNG::Image.from_file("photo.png")
39
39
  PureJPEG.from_chunky_png(image, quality: 80).write("photo.jpg")
40
40
  ```
41
41
 
42
+ If you want transparent pixels composited against a solid color instead of
43
+ using the PNG's hidden RGB values, pass an RGB background:
44
+
45
+ ```ruby
46
+ PureJPEG.from_chunky_png(image, background: [255, 255, 255], quality: 80).write("photo.jpg")
47
+ ```
48
+
42
49
  ### From any pixel source
43
50
 
44
51
  PureJPEG accepts any object that responds to `width`, `height`, and `[x, y]` (returning an object with `.r`, `.g`, `.b` in 0-255):
@@ -99,7 +106,7 @@ Here's a quick example of sort of the "old digital camera" effect I was looking
99
106
  </tr>
100
107
  </table>
101
108
 
102
- And here's what happens when you convert a PNG with transparency — JPEG doesn't support alpha, so the hidden RGB data behind transparent pixels bleeds through:
109
+ And here's what happens when you convert a PNG with transparency without a `background:` — JPEG doesn't support alpha, so the hidden RGB data behind transparent pixels bleeds through:
103
110
 
104
111
  <table>
105
112
  <tr>
@@ -112,7 +119,7 @@ And here's what happens when you convert a PNG with transparency — JPEG doesn'
112
119
  </tr>
113
120
  </table>
114
121
 
115
- I consider this a feature but you may consider it a deficiency and that a default background of white should be applied. This may be something I'll add if anyone wants it!
122
+ Pass `background: [255, 255, 255]` to composite transparent pixels over white, or any other `[r, g, b]` color you prefer.
116
123
 
117
124
  Note that 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.
118
125
 
@@ -198,25 +205,79 @@ Possible future improvements: ICC profile rendering/conversion.
198
205
 
199
206
  ## Performance
200
207
 
201
- On a 1024x1024 image (Ruby 4.0.2 with YJIT on an M5):
208
+ On the bundled 1024x1024 images (Apple M5, median of 15 runs after 20 seconds of warmup per operation):
209
+
210
+ | Operation | CRuby 4.0.2 (YJIT) | TruffleRuby 34.0.1 | JRuby 9.4.14.0 | Spinel (`7f08abd9`) |
211
+ |-----------|-------------------|-------------------|---------------|--------------------|
212
+ | Encode (color, q85) | ~0.131s | ~0.088s | ~0.577s | ~0.104s |
213
+ | Decode (baseline) | ~0.112s | ~0.027s | ~0.554s | ~0.078s |
214
+ | Decode (progressive) | ~0.140s | ~0.037s | ~0.617s | ~0.092s |
215
+
216
+ Inputs were loaded before timing, and encoded output was returned in memory; file I/O and PNG loading are excluded. Runtimes were benchmarked sequentially. JRuby used its default JIT on OpenJDK 21.0.10. Spinel was measured separately with the same inputs and timing protocol, using a portable harness with PNG pixels preloaded; its encoded JPEG and decoded pixels matched CRuby byte-for-byte. Results vary with image content, garbage collection, and JIT warmup.
217
+
218
+ The encoder and decoder use an integer-scaled AAN (Arai-Agui-Nakajima) DCT with fixed-point arithmetic throughout — no Float operations in the hot path. Color space conversion uses fixed-point integer math, and pixel data is stored as packed integers to avoid per-pixel object allocation. TruffleRuby's Graal JIT compiler can optimize these tight integer loops particularly well once warmed up.
219
+
220
+ ## Example scripts
221
+
222
+ The `examples/` directory contains ready-to-run scripts. All accept JPEG or PNG input (PNG requires the `chunky_png` gem).
223
+
224
+ **`png_to_jpeg.rb`** -- Convert a PNG to JPEG.
225
+
226
+ ```
227
+ ruby examples/png_to_jpeg.rb [--grayscale] INPUT.png OUTPUT.jpg [quality]
228
+ ```
229
+
230
+ **`jpeg_to_png.rb`** -- Convert a JPEG to PNG.
231
+
232
+ ```
233
+ ruby examples/jpeg_to_png.rb INPUT.jpg OUTPUT.png
234
+ ```
235
+
236
+ **`kodak.rb`** -- Apply the "scrambled quantization" effect that recreates the gritty look of early digicams like the Casio QV-10. See `CREATIVE.md` for more on this.
202
237
 
203
- | Operation | Time |
204
- |-----------|------|
205
- | Encode (color, q85) | ~0.16s |
206
- | Decode (baseline) | ~0.14s |
207
- | Decode (progressive) | ~0.18s |
238
+ ```
239
+ ruby examples/kodak.rb INPUT.(jpg|png) [OUTPUT.jpg] [quality]
240
+ ```
241
+
242
+ **`lumacrush.rb`** -- Crush luminance while preserving chrominance. Produces a soft, oil-painting quality where detail is blocky but colors remain accurate.
243
+
244
+ ```
245
+ ruby examples/lumacrush.rb INPUT.(jpg|png) [OUTPUT.jpg] [luma_quality] [chroma_quality]
246
+ ```
247
+
248
+ **`chromacrush.rb`** -- The opposite: sharp detail with collapsed, blocky color patches.
249
+
250
+ ```
251
+ ruby examples/chromacrush.rb INPUT.(jpg|png) [OUTPUT.jpg] [luma_quality] [chroma_quality]
252
+ ```
208
253
 
209
- The encoder and decoder use an integer-scaled AAN (Arai-Agui-Nakajima) DCT with fixed-point arithmetic throughout — no Float operations in the hot path. Color space conversion uses fixed-point integer math, and pixel data is stored as packed integers to avoid per-pixel object allocation.
254
+ **`loopy.rb`** -- Re-encode a JPEG through multiple passes to see how artifacts accumulate.
255
+
256
+ ```
257
+ ruby examples/loopy.rb INPUT.jpg [quality] [iterations]
258
+ ```
210
259
 
211
260
  ## Some useful `rake` tasks
212
261
 
213
262
  ```
214
263
  bundle install
215
264
  rake test # run the test suite
216
- rake benchmark # benchmark encoding and decoding (3 runs each)
265
+ rake benchmark # basic benchmark of encoding and decoding (5 runs after warmup)
217
266
  rake profile # CPU profile with StackProf (requires the stackprof gem)
218
267
  ```
219
268
 
269
+ ## Full benchmark script
270
+
271
+ `benchmark/run.rb` is a more thorough benchmark that exercises the encode and decode paths in several ways. It auto-enables YJIT, warms up before measuring, and reports object allocations, throughput (iterations/second via `benchmark-ips`), best-of-N wall-clock times, and a sustained mixed workload across encode (q85, q95 optimized, grayscale) and decode (baseline and progressive).
272
+
273
+ ```
274
+ ruby benchmark/run.rb # standard run
275
+ ruby benchmark/run.rb --quick # single-shot wall-clock + allocations
276
+ ruby benchmark/run.rb --full # longer run, includes YJIT runtime stats
277
+ ruby benchmark/run.rb --profile # CPU profile with Vernier (writes JSON to /tmp)
278
+ ruby benchmark/run.rb --profile-alloc # retained-object profile with Vernier
279
+ ```
280
+
220
281
  ## AI Disclosure
221
282
 
222
283
  **Claude Code did the majority of the work.** The math of JPEG encoding/decoding is beyond me, except 'getting it' at a high level. I understand it like I understand the engine in my car :-) *Later update: OpenAI Codex is also reviewing and adding features now. It feels stronger in many areas.*
@@ -236,6 +297,7 @@ rake profile # CPU profile with StackProf (requires the stackprof gem)
236
297
  ## Credits
237
298
 
238
299
  - [Ufuk Kayserilioglu](https://github.com/paracycle) - Major performance optimizations including integer-scaled AAN DCT, fixed-point color space conversion, and YJIT-targeted improvements.
300
+ - [Keith R. Bennett](https://github.com/keithrbennett) - Coverage testing and adding SimpleCov to the project.
239
301
 
240
302
  ## License
241
303
 
@@ -78,9 +78,13 @@ module PureJPEG
78
78
 
79
79
  # Reusable buffers
80
80
  zigzag = Array.new(64, 0)
81
- raster = Array.new(64, 0)
82
81
  dequant = Array.new(64, 0)
83
82
 
83
+ scan_components = jfif.scan_components.map do |sc|
84
+ comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables)
85
+ [sc, comp, dc_tab, ac_tab, fetch_quant_table!(jfif, comp), channels[comp.id]]
86
+ end
87
+
84
88
  mcus_y.times do |mcu_row|
85
89
  mcus_x.times do |mcu_col|
86
90
  # Handle restart interval
@@ -89,19 +93,14 @@ module PureJPEG
89
93
  prev_dc.clear
90
94
  end
91
95
 
92
- jfif.scan_components.each do |sc|
93
- comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables)
94
- qt = fetch_quant_table!(jfif, comp)
95
- ch = channels[comp.id]
96
-
96
+ scan_components.each do |sc, comp, dc_tab, ac_tab, qt, ch|
97
97
  comp.v_sampling.times do |bv|
98
98
  comp.h_sampling.times do |bh|
99
99
  # Decode one 8x8 block
100
100
  decode_block(reader, dc_tab, ac_tab, prev_dc, sc.id, zigzag)
101
101
 
102
102
  # Inverse pipeline: unzigzag -> dequantize -> IDCT -> level shift
103
- Zigzag.unreorder!(zigzag, raster)
104
- Quantization.dequantize!(raster, qt, dequant)
103
+ Quantization.dequantize_zigzag!(zigzag, qt, dequant)
105
104
  DCT.inverse!(dequant)
106
105
 
107
106
  # Write block into channel buffer
@@ -191,7 +190,7 @@ module PureJPEG
191
190
  end
192
191
  end
193
192
 
194
- # Reconstruct: unzigzag, dequantize, IDCT, write to channel buffers
193
+ # Reconstruct: unzigzag/dequantize, IDCT, write to channel buffers
195
194
  padded_w = mcus_x * mcu_px_w
196
195
  padded_h = mcus_y * mcu_px_h
197
196
  channels = {}
@@ -201,8 +200,6 @@ module PureJPEG
201
200
  channels[c.id] = { data: Array.new(ch_w * ch_h, 0), width: ch_w, height: ch_h }
202
201
  end
203
202
 
204
- zigzag = Array.new(64, 0)
205
- raster = Array.new(64, 0)
206
203
  dequant = Array.new(64, 0)
207
204
 
208
205
  jfif.components.each do |c|
@@ -214,10 +211,7 @@ module PureJPEG
214
211
  by_count.times do |block_y|
215
212
  bx_count.times do |block_x|
216
213
  offset = (block_y * bx_count + block_x) * 64
217
- 64.times { |i| zigzag[i] = coeff_buf[offset + i] }
218
-
219
- Zigzag.unreorder!(zigzag, raster)
220
- Quantization.dequantize!(raster, qt, dequant)
214
+ Quantization.dequantize_zigzag!(coeff_buf, qt, dequant, offset)
221
215
  DCT.inverse!(dequant)
222
216
  write_block(dequant, ch[:data], ch[:width], block_x * 8, block_y * 8)
223
217
  end
@@ -279,6 +273,11 @@ module PureJPEG
279
273
  prev_dc = Hash.new(0)
280
274
  mcu_count = 0
281
275
 
276
+ scan_components = scan.components.map do |sc|
277
+ comp, dc_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: false)
278
+ [sc, comp, dc_tab, coeffs[comp.id], comp_blocks[comp.id][0]]
279
+ end
280
+
282
281
  mcus_y.times do |mcu_row|
283
282
  mcus_x.times do |mcu_col|
284
283
  if restart_interval > 0 && mcu_count > 0 && (mcu_count % restart_interval) == 0
@@ -286,11 +285,7 @@ module PureJPEG
286
285
  prev_dc.clear
287
286
  end
288
287
 
289
- scan.components.each do |sc|
290
- comp, dc_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: false)
291
- coeff_buf = coeffs[comp.id]
292
- bx_count = comp_blocks[comp.id][0]
293
-
288
+ scan_components.each do |sc, comp, dc_tab, coeff_buf, bx_count|
294
289
  comp.v_sampling.times do |bv|
295
290
  comp.h_sampling.times do |bh|
296
291
  block_x = mcu_col * comp.h_sampling + bh
@@ -312,7 +307,7 @@ module PureJPEG
312
307
  end
313
308
 
314
309
  def prog_dc_first(reader, dc_tab, prev_dc, coeff_buf, offset, al)
315
- cat = dc_tab.decode(reader)
310
+ cat = dc_tab.decode_symbol(reader)
316
311
  diff = reader.receive_extend(cat)
317
312
  dc_val = prev_dc + diff
318
313
  coeff_buf[offset] = dc_val << al
@@ -328,7 +323,7 @@ module PureJPEG
328
323
 
329
324
  k = ss
330
325
  while k <= se
331
- symbol = ac_tab.decode(reader)
326
+ symbol = ac_tab.decode_symbol(reader)
332
327
  run = (symbol >> 4) & 0x0F
333
328
  size = symbol & 0x0F
334
329
 
@@ -364,7 +359,7 @@ module PureJPEG
364
359
 
365
360
  k = ss
366
361
  while k <= se
367
- symbol = ac_tab.decode(reader)
362
+ symbol = ac_tab.decode_symbol(reader)
368
363
  r = (symbol >> 4) & 0x0F
369
364
  s = symbol & 0x0F
370
365
 
@@ -417,7 +412,7 @@ module PureJPEG
417
412
 
418
413
  def decode_block(reader, dc_tab, ac_tab, prev_dc, comp_id, out)
419
414
  # DC coefficient
420
- dc_cat = dc_tab.decode(reader)
415
+ dc_cat = dc_tab.decode_symbol(reader)
421
416
  dc_diff = reader.receive_extend(dc_cat)
422
417
  dc_val = prev_dc[comp_id] + dc_diff
423
418
  prev_dc[comp_id] = dc_val
@@ -426,7 +421,7 @@ module PureJPEG
426
421
  # AC coefficients
427
422
  i = 1
428
423
  while i < 64
429
- symbol = ac_tab.decode(reader)
424
+ symbol = ac_tab.decode_symbol(reader)
430
425
  if symbol == 0x00 # EOB
431
426
  while i < 64
432
427
  out[i] = 0
@@ -42,6 +42,9 @@ module PureJPEG
42
42
  luminance_table: nil, chrominance_table: nil,
43
43
  quantization_modifier: nil, scramble_quantization: false,
44
44
  optimize_huffman: false)
45
+ validate_quality!(quality, "quality")
46
+ validate_quality!(chroma_quality, "chroma_quality") if chroma_quality
47
+
45
48
  @source = source
46
49
  @quality = quality
47
50
  @grayscale = grayscale
@@ -92,6 +95,12 @@ module PureJPEG
92
95
  modified
93
96
  end
94
97
 
98
+ def validate_quality!(value, name)
99
+ unless value.is_a?(Integer) && value.between?(1, 100)
100
+ raise ArgumentError, "#{name} must be an integer between 1 and 100"
101
+ end
102
+ end
103
+
95
104
  def validate_qtable!(table, name)
96
105
  unless table.respond_to?(:length) && table.respond_to?(:all?)
97
106
  raise ArgumentError, "#{name} must be a 64-element array of integers between 1 and 255"
@@ -137,11 +146,9 @@ module PureJPEG
137
146
  lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values)
138
147
  else
139
148
  chr_qtable = build_chr_qtable
140
- y_data, cb_data, cr_data = extract_ycbcr(width, height)
149
+ y_data, cb_sub, cr_sub = extract_ycbcr420(width, height)
141
150
  sub_w = (width + 1) / 2
142
151
  sub_h = (height + 1) / 2
143
- cb_sub = downsample(cb_data, width, height, sub_w, sub_h)
144
- cr_sub = downsample(cr_data, width, height, sub_w, sub_h)
145
152
 
146
153
  lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
147
154
  chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values =
@@ -206,13 +213,12 @@ module PureJPEG
206
213
  padded_h = (height + 7) & ~7
207
214
 
208
215
  block = Array.new(64, 0)
209
- qbuf = Array.new(64, 0)
210
216
  zbuf = Array.new(64, 0)
211
217
 
212
218
  (0...padded_h).step(8) do |by|
213
219
  (0...padded_w).step(8) do |bx|
214
220
  extract_block_into(y_data, width, height, bx, by, block)
215
- yield transform_block(block, qbuf, zbuf, qtable)
221
+ yield transform_block(block, zbuf, qtable)
216
222
  end
217
223
  end
218
224
  end
@@ -276,28 +282,27 @@ module PureJPEG
276
282
  mcu_h = (height + 15) & ~15
277
283
 
278
284
  block = Array.new(64, 0)
279
- qbuf = Array.new(64, 0)
280
285
  zbuf = Array.new(64, 0)
281
286
 
282
287
  (0...mcu_h).step(16) do |my|
283
288
  (0...mcu_w).step(16) do |mx|
284
289
  extract_block_into(y_data, width, height, mx, my, block)
285
- yield :y, transform_block(block, qbuf, zbuf, lum_qt)
290
+ yield :y, transform_block(block, zbuf, lum_qt)
286
291
 
287
292
  extract_block_into(y_data, width, height, mx + 8, my, block)
288
- yield :y, transform_block(block, qbuf, zbuf, lum_qt)
293
+ yield :y, transform_block(block, zbuf, lum_qt)
289
294
 
290
295
  extract_block_into(y_data, width, height, mx, my + 8, block)
291
- yield :y, transform_block(block, qbuf, zbuf, lum_qt)
296
+ yield :y, transform_block(block, zbuf, lum_qt)
292
297
 
293
298
  extract_block_into(y_data, width, height, mx + 8, my + 8, block)
294
- yield :y, transform_block(block, qbuf, zbuf, lum_qt)
299
+ yield :y, transform_block(block, zbuf, lum_qt)
295
300
 
296
301
  extract_block_into(cb_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
297
- yield :cb, transform_block(block, qbuf, zbuf, chr_qt)
302
+ yield :cb, transform_block(block, zbuf, chr_qt)
298
303
 
299
304
  extract_block_into(cr_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
300
- yield :cr, transform_block(block, qbuf, zbuf, chr_qt)
305
+ yield :cr, transform_block(block, zbuf, chr_qt)
301
306
  end
302
307
  end
303
308
  end
@@ -322,10 +327,9 @@ module PureJPEG
322
327
 
323
328
  # --- Shared block pipeline (all buffers pre-allocated) ---
324
329
 
325
- def transform_block(block, qbuf, zbuf, qtable)
330
+ def transform_block(block, zbuf, qtable)
326
331
  DCT.forward!(block)
327
- Quantization.quantize!(block, qtable, qbuf)
328
- Zigzag.reorder!(qbuf, zbuf)
332
+ Quantization.quantize_zigzag!(block, qtable, zbuf)
329
333
  zbuf
330
334
  end
331
335
 
@@ -383,61 +387,65 @@ module PureJPEG
383
387
  luminance
384
388
  end
385
389
 
386
- def extract_ycbcr(width, height)
387
- size = width * height
388
- y_data = Array.new(size)
389
- cb_data = Array.new(size)
390
- cr_data = Array.new(size)
390
+ # Convert RGB in raster order, downsampling chroma after every two rows.
391
+ # Keeping only two full-resolution chroma rows avoids two image-sized arrays.
392
+ def extract_ycbcr420(width, height)
393
+ y_data = Array.new(width * height)
394
+ sub_w = (width + 1) / 2
395
+ sub_h = (height + 1) / 2
396
+ cb_sub = Array.new(sub_w * sub_h)
397
+ cr_sub = Array.new(sub_w * sub_h)
398
+ cb_rows = Array.new(width * 2)
399
+ cr_rows = Array.new(width * 2)
391
400
 
392
401
  if source.respond_to?(:packed_pixels)
393
402
  packed = source.packed_pixels
394
403
  r_shift, g_shift, b_shift = packed_shifts
395
- i = 0
396
- size.times do
397
- color = packed[i]
398
- r = (color >> r_shift) & 0xFF
399
- g = (color >> g_shift) & 0xFF
400
- b = (color >> b_shift) & 0xFF
401
- y_data[i] = clamp255((FP_Y_R * r + FP_Y_G * g + FP_Y_B * b + FP_HALF) >> 16)
402
- cb_data[i] = clamp255((FP_CB_R * r + FP_CB_G * g + FP_CB_B * b + FP_128 + FP_HALF) >> 16)
403
- cr_data[i] = clamp255((FP_CR_R * r + FP_CR_G * g + FP_CR_B * b + FP_128 + FP_HALF) >> 16)
404
- i += 1
405
- end
406
- else
407
- height.times do |py|
408
- row = py * width
404
+ end
405
+
406
+ height.times do |py|
407
+ row = py * width
408
+ chroma_row = (py & 1) * width
409
+ if packed
410
+ width.times do |px|
411
+ color = packed[row + px]
412
+ r = (color >> r_shift) & 0xFF
413
+ g = (color >> g_shift) & 0xFF
414
+ b = (color >> b_shift) & 0xFF
415
+ y_data[row + px] = clamp255((FP_Y_R * r + FP_Y_G * g + FP_Y_B * b + FP_HALF) >> 16)
416
+ cb_rows[chroma_row + px] = clamp255((FP_CB_R * r + FP_CB_G * g + FP_CB_B * b + FP_128 + FP_HALF) >> 16)
417
+ cr_rows[chroma_row + px] = clamp255((FP_CR_R * r + FP_CR_G * g + FP_CR_B * b + FP_128 + FP_HALF) >> 16)
418
+ end
419
+ else
409
420
  width.times do |px|
410
421
  pixel = source[px, py]
411
422
  r = pixel.r; g = pixel.g; b = pixel.b
412
- i = row + px
413
- y_data[i] = clamp255((FP_Y_R * r + FP_Y_G * g + FP_Y_B * b + FP_HALF) >> 16)
414
- cb_data[i] = clamp255((FP_CB_R * r + FP_CB_G * g + FP_CB_B * b + FP_128 + FP_HALF) >> 16)
415
- cr_data[i] = clamp255((FP_CR_R * r + FP_CR_G * g + FP_CR_B * b + FP_128 + FP_HALF) >> 16)
423
+ y_data[row + px] = clamp255((FP_Y_R * r + FP_Y_G * g + FP_Y_B * b + FP_HALF) >> 16)
424
+ cb_rows[chroma_row + px] = clamp255((FP_CB_R * r + FP_CB_G * g + FP_CB_B * b + FP_128 + FP_HALF) >> 16)
425
+ cr_rows[chroma_row + px] = clamp255((FP_CR_R * r + FP_CR_G * g + FP_CR_B * b + FP_128 + FP_HALF) >> 16)
416
426
  end
417
427
  end
428
+
429
+ if py.odd? || py == height - 1
430
+ # For odd image heights, use row zero twice at the bottom edge.
431
+ downsample_chroma_rows(cb_rows, cr_rows, width, chroma_row,
432
+ cb_sub, cr_sub, sub_w, (py >> 1) * sub_w)
433
+ end
418
434
  end
419
435
 
420
- [y_data, cb_data, cr_data]
436
+ [y_data, cb_sub, cr_sub]
421
437
  end
422
438
 
423
- def downsample(data, src_w, src_h, dst_w, dst_h)
424
- out = Array.new(dst_w * dst_h)
425
- max_x = src_w - 1
426
- max_y = src_h - 1
427
- dst_h.times do |dy|
428
- sy = dy << 1
429
- y1 = sy < max_y ? sy + 1 : max_y
430
- row0 = sy * src_w
431
- row1 = y1 * src_w
432
- dst_row = dy * dst_w
433
- dst_w.times do |dx|
434
- sx = dx << 1
435
- x1 = sx < max_x ? sx + 1 : max_x
436
- out[dst_row + dx] = ((data[row0 + sx] + data[row0 + x1] +
437
- data[row1 + sx] + data[row1 + x1]) >> 2)
438
- end
439
+ def downsample_chroma_rows(cb_rows, cr_rows, width, row1, cb_sub, cr_sub, sub_w, dst_row)
440
+ max_x = width - 1
441
+ sub_w.times do |dx|
442
+ sx = dx << 1
443
+ x1 = sx < max_x ? sx + 1 : max_x
444
+ cb_sub[dst_row + dx] = (cb_rows[sx] + cb_rows[x1] +
445
+ cb_rows[row1 + sx] + cb_rows[row1 + x1]) >> 2
446
+ cr_sub[dst_row + dx] = (cr_rows[sx] + cr_rows[x1] +
447
+ cr_rows[row1 + sx] + cr_rows[row1 + x1]) >> 2
439
448
  end
440
- out
441
449
  end
442
450
 
443
451
  # Extract an 8x8 block into a pre-allocated array, level-shifted by -128.
@@ -25,13 +25,15 @@ module PureJPEG
25
25
  end
26
26
 
27
27
  # Decode one Huffman symbol from the bit reader.
28
- def decode(reader)
28
+ def decode_symbol(reader)
29
29
  code = 0
30
- 1.upto(16) do |len|
30
+ len = 1
31
+ while len <= 16
31
32
  code = (code << 1) | reader.read_bit
32
33
  if @max_code[len] >= 0 && code <= @max_code[len]
33
34
  return @values[@val_ptr[len] + code - @min_code[len]]
34
35
  end
36
+ len += 1
35
37
  end
36
38
  raise PureJPEG::DecodeError, "Invalid Huffman code"
37
39
  end
@@ -6,14 +6,7 @@ module PureJPEG
6
6
  # Return the Huffman category (bit length) for a value.
7
7
  # Avoids Array allocation compared to the combined category_and_bits.
8
8
  def self.category(value)
9
- return 0 if value == 0
10
- v = value.abs
11
- cat = 0
12
- while v > 0
13
- cat += 1
14
- v >>= 1
15
- end
16
- cat
9
+ value.abs.bit_length
17
10
  end
18
11
 
19
12
  # Return the extra bits to encode for a value with the given category.
@@ -50,6 +43,7 @@ module PureJPEG
50
43
  end
51
44
 
52
45
  yield 0x00, 0 if last_nonzero < 63
46
+ nil
53
47
  end
54
48
 
55
49
  def self.each_ac_symbol(zigzag)
@@ -85,12 +79,12 @@ module PureJPEG
85
79
  end
86
80
 
87
81
  def encode_ac(zigzag, writer)
88
- self.class.each_ac_item(zigzag) do |symbol, value|
82
+ PureJPEG::Huffman::Encoder.each_ac_item(zigzag) do |symbol, value|
89
83
  code, length = @ac_table[symbol]
90
84
  writer.write_bits(code, length)
91
85
  next if symbol == 0x00 || symbol == 0xF0
92
86
 
93
- cat = self.class.category(value)
87
+ cat = symbol & 0x0F
94
88
  writer.write_bits(self.class.value_bits(value, cat), cat)
95
89
  end
96
90
  end
@@ -38,6 +38,7 @@ module PureJPEG
38
38
  # @param y [Integer] row (0-based)
39
39
  # @return [Source::Pixel] pixel with +.r+, +.g+, +.b+ in 0-255
40
40
  def [](x, y)
41
+ validate_coordinates!(x, y)
41
42
  color = @packed_pixels[y * @width + x]
42
43
  Source::Pixel.new((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)
43
44
  end
@@ -49,6 +50,7 @@ module PureJPEG
49
50
  # @param pixel [Source::Pixel] replacement pixel
50
51
  # @return [Source::Pixel]
51
52
  def []=(x, y, pixel)
53
+ validate_coordinates!(x, y)
52
54
  @packed_pixels[y * @width + x] = (pixel.r << 16) | (pixel.g << 8) | pixel.b
53
55
  pixel
54
56
  end
@@ -85,5 +87,13 @@ module PureJPEG
85
87
  end
86
88
  end
87
89
  end
90
+
91
+ private
92
+
93
+ def validate_coordinates!(x, y)
94
+ unless x.is_a?(Integer) && y.is_a?(Integer) && x >= 0 && y >= 0 && x < @width && y < @height
95
+ raise IndexError, "Pixel coordinate out of bounds: #{x.inspect}, #{y.inspect}"
96
+ end
97
+ end
88
98
  end
89
99
  end
@@ -101,10 +101,9 @@ module PureJPEG
101
101
  ICC_PROFILE_SIG = "ICC_PROFILE\0".b
102
102
 
103
103
  def parse_app2
104
- length = read_u16
105
- end_pos = @pos + length - 2
104
+ end_pos = read_segment_end
106
105
 
107
- if length >= 16 && @data[@pos, 12] == ICC_PROFILE_SIG
106
+ if (end_pos - @pos) >= 14 && @data[@pos, 12] == ICC_PROFILE_SIG
108
107
  @pos += 12
109
108
  seq_no = read_byte
110
109
  _total = read_byte
@@ -121,18 +120,18 @@ module PureJPEG
121
120
  end
122
121
 
123
122
  def skip_segment
124
- length = read_u16
125
- @pos += length - 2
123
+ @pos = read_segment_end
126
124
  end
127
125
 
128
126
  def parse_dqt
129
- length = read_u16
130
- end_pos = @pos + length - 2
127
+ end_pos = read_segment_end
131
128
 
132
129
  while @pos < end_pos
133
130
  info = read_byte
134
131
  precision = (info >> 4) & 0x0F # 0 = 8-bit, 1 = 16-bit
135
132
  table_id = info & 0x0F
133
+ bytes_per_value = precision == 0 ? 1 : 2
134
+ ensure_remaining_in_segment!(end_pos, 64 * bytes_per_value, "DQT")
136
135
 
137
136
  zigzag_table = Array.new(64)
138
137
  64.times do |i|
@@ -146,16 +145,17 @@ module PureJPEG
146
145
  end
147
146
 
148
147
  def parse_dht
149
- length = read_u16
150
- end_pos = @pos + length - 2
148
+ end_pos = read_segment_end
151
149
 
152
150
  while @pos < end_pos
151
+ ensure_remaining_in_segment!(end_pos, 17, "DHT")
153
152
  info = read_byte
154
153
  table_class = (info >> 4) & 0x0F # 0 = DC, 1 = AC
155
154
  table_id = info & 0x0F
156
155
 
157
156
  bits = Array.new(16) { read_byte }
158
157
  total = bits.sum
158
+ ensure_remaining_in_segment!(end_pos, total, "DHT")
159
159
  values = Array.new(total) { read_byte }
160
160
 
161
161
  @huffman_tables[[table_class, table_id]] = { bits: bits, values: values }
@@ -163,11 +163,13 @@ module PureJPEG
163
163
  end
164
164
 
165
165
  def parse_sof0
166
- read_u16 # length
166
+ end_pos = read_segment_end
167
+ ensure_remaining_in_segment!(end_pos, 6, "SOF")
167
168
  read_byte # precision (always 8 for baseline)
168
169
  @height = read_u16
169
170
  @width = read_u16
170
171
  num_components = read_byte
172
+ ensure_remaining_in_segment!(end_pos, num_components * 3, "SOF")
171
173
 
172
174
  @components = Array.new(num_components) do
173
175
  id = read_byte
@@ -177,11 +179,14 @@ module PureJPEG
177
179
  qt_id = read_byte
178
180
  Component.new(id, h, v, qt_id)
179
181
  end
182
+ @pos = end_pos
180
183
  end
181
184
 
182
185
  def parse_sos
183
- read_u16 # length
186
+ end_pos = read_segment_end
187
+ ensure_remaining_in_segment!(end_pos, 1, "SOS")
184
188
  num_components = read_byte
189
+ ensure_remaining_in_segment!(end_pos, num_components * 2 + 3, "SOS")
185
190
 
186
191
  components = Array.new(num_components) do
187
192
  id = read_byte
@@ -196,12 +201,33 @@ module PureJPEG
196
201
  ahl = read_byte # successive approximation
197
202
  ah = (ahl >> 4) & 0x0F
198
203
  al = ahl & 0x0F
204
+ @pos = end_pos
199
205
  Scan.new(components, ss, se, ah, al, nil)
200
206
  end
201
207
 
202
208
  def parse_dri
203
- read_u16 # length
209
+ end_pos = read_segment_end
210
+ ensure_remaining_in_segment!(end_pos, 2, "DRI")
204
211
  @restart_interval = read_u16
212
+ @pos = end_pos
213
+ end
214
+
215
+ def read_segment_end
216
+ length = read_u16
217
+ raise PureJPEG::DecodeError, "Invalid JPEG segment length: #{length}" if length < 2
218
+
219
+ end_pos = @pos + length - 2
220
+ if end_pos > @data.bytesize
221
+ raise PureJPEG::DecodeError, "JPEG segment length exceeds available data"
222
+ end
223
+
224
+ end_pos
225
+ end
226
+
227
+ def ensure_remaining_in_segment!(end_pos, byte_count, segment_name)
228
+ return if @pos + byte_count <= end_pos
229
+
230
+ raise PureJPEG::DecodeError, "Truncated #{segment_name} segment"
205
231
  end
206
232
 
207
233
  # Extract entropy-coded scan data (everything from current position to EOI marker).
@@ -58,5 +58,33 @@ module PureJPEG
58
58
  64.times { |i| out[i] = block[i] * table[i] }
59
59
  out
60
60
  end
61
+
62
+ # Quantize raster-order coefficients directly into zigzag order.
63
+ def self.quantize_zigzag!(block, table, out)
64
+ order = Zigzag::ORDER
65
+ i = 0
66
+ while i < 64
67
+ j = order[i]
68
+ v = block[j]; t = table[j]
69
+ out[i] = if v >= 0
70
+ (v + (t >> 1)) / t
71
+ else
72
+ -((-v + (t >> 1)) / t)
73
+ end
74
+ i += 1
75
+ end
76
+ out
77
+ end
78
+
79
+ # Dequantize zigzag coefficients directly into raster order. The offset
80
+ # allows progressive decoding to read from its full coefficient buffer.
81
+ def self.dequantize_zigzag!(block, table, out, offset = 0)
82
+ order = Zigzag::ORDER
83
+ 64.times do |i|
84
+ j = order[i]
85
+ out[j] = block[offset + i] * table[j]
86
+ end
87
+ out
88
+ end
61
89
  end
62
90
  end
@@ -18,10 +18,16 @@ module PureJPEG
18
18
  attr_reader :height
19
19
 
20
20
  # @param image [ChunkyPNG::Image] the source PNG image
21
- def initialize(image)
21
+ # @param background [Array<Integer>, nil] optional [r, g, b] background
22
+ # color to composite transparent pixels against before encoding
23
+ def initialize(image, background: nil)
22
24
  @width = image.width
23
25
  @height = image.height
24
- @packed_pixels = image.pixels
26
+ @packed_pixels = if background.nil?
27
+ image.pixels
28
+ else
29
+ composite_pixels(image.pixels, background)
30
+ end
25
31
  end
26
32
 
27
33
  # @return [Array<Integer>] flat row-major array of packed RGBA integers
@@ -40,6 +46,37 @@ module PureJPEG
40
46
  (color >> 8) & 0xFF
41
47
  )
42
48
  end
49
+
50
+ private
51
+
52
+ def composite_pixels(pixels, background)
53
+ bg_r, bg_g, bg_b = validate_background!(background)
54
+
55
+ pixels.map do |color|
56
+ alpha = color & 0xFF
57
+ next color if alpha == 255
58
+
59
+ src_r = (color >> 24) & 0xFF
60
+ src_g = (color >> 16) & 0xFF
61
+ src_b = (color >> 8) & 0xFF
62
+ inv_alpha = 255 - alpha
63
+
64
+ r = ((src_r * alpha) + (bg_r * inv_alpha) + 127) / 255
65
+ g = ((src_g * alpha) + (bg_g * inv_alpha) + 127) / 255
66
+ b = ((src_b * alpha) + (bg_b * inv_alpha) + 127) / 255
67
+
68
+ (r << 24) | (g << 16) | (b << 8) | 255
69
+ end
70
+ end
71
+
72
+ def validate_background!(background)
73
+ unless background.respond_to?(:length) && background.length == 3 &&
74
+ background.all? { |v| v.is_a?(Integer) && v.between?(0, 255) }
75
+ raise ArgumentError, "background must be an [r, g, b] array of integers between 0 and 255"
76
+ end
77
+
78
+ background
79
+ end
43
80
  end
44
81
  end
45
82
  end
@@ -27,15 +27,16 @@ module PureJPEG
27
27
  def initialize(width, height, &block)
28
28
  @width = width
29
29
  @height = height
30
- @pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
31
-
32
30
  if block
31
+ @pixels = Array.new(width * height)
33
32
  height.times do |y|
34
33
  width.times do |x|
35
34
  r, g, b = block.call(x, y)
36
35
  @pixels[y * width + x] = Pixel.new(r, g, b)
37
36
  end
38
37
  end
38
+ else
39
+ @pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
39
40
  end
40
41
  end
41
42
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PureJPEG
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/pure_jpeg.rb CHANGED
@@ -50,10 +50,12 @@ module PureJPEG
50
50
  # and passes it to {.encode}.
51
51
  #
52
52
  # @param image [ChunkyPNG::Image] the source image
53
+ # @param background [Array<Integer>, nil] optional [r, g, b] background
54
+ # color to composite transparent pixels against before encoding
53
55
  # @param opts [Hash] encoding options passed to {Encoder#initialize}
54
56
  # @return [Encoder]
55
- def self.from_chunky_png(image, **opts)
56
- source = Source::ChunkyPNGSource.new(image)
57
+ def self.from_chunky_png(image, background: nil, **opts)
58
+ source = Source::ChunkyPNGSource.new(image, background: background)
57
59
  Encoder.new(source, **opts)
58
60
  end
59
61
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pure_jpeg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper
@@ -86,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
86
  - !ruby/object:Gem::Version
87
87
  version: '0'
88
88
  requirements: []
89
- rubygems_version: 3.6.9
89
+ rubygems_version: 4.0.6
90
90
  specification_version: 4
91
91
  summary: Pure Ruby JPEG encoder and decoder
92
92
  test_files: []