pure_jpeg 0.2.0 → 0.3.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: 6eacb8a616f95a52625f6f5acb3c8c137306c5dcf3636e93e2e715287b429655
4
- data.tar.gz: dade5c2d3b9603bb7089635977ea2e38827da1a3912c820152367f3ca643c5f9
3
+ metadata.gz: 66b5d6fe1b663128f62aae8111b55a9b2ddbe9739d501fcf1146c459286433da
4
+ data.tar.gz: 02ae6cdc25f520221fee4adfe9d6070c4a975602e5de2b34a0b9ab8b4e005829
5
5
  SHA512:
6
- metadata.gz: ccf7a06b88c08f14ca70d944ddcc753795217e3bfbca00874484ee6f3c2a360d8cede768c4469f5b92d2789bf7bcc71d61b22f67d76fb98774af28f88132c248
7
- data.tar.gz: d868a5d4f7db3b20a504bc09f9908169fd35c269b511e619e083957fd6b4bf90a3cf22985a5e6d81289a02306ba75677b94b8ff9f6d741acc7b2047979d0f2c6
6
+ metadata.gz: f476b8fec25f1f0402f297d534f52887aed90778ddf1217668a0889755856436dae8a0d8e4e9d648bc2a33879165b32fa0560e5b506549467c21a648fd6ecf29
7
+ data.tar.gz: 5438c161519149458fad8cd28dea1f9f073a2646c5f91f619a357d375b77456ed5f366dd3ed78e75f5c0d2cbe4156f4ec8a5a6dbaa9e19d86198dcd8a2fcacfc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ New features:
6
+
7
+ - `PureJPEG.info` for reading dimensions and metadata without full decode
8
+ - ICC color profile extraction (available on `Info` and `Image`)
9
+ - Optional image-specific optimized Huffman tables (`optimize_huffman: true`)
10
+
11
+ Fixes:
12
+
13
+ - Decoder validates Huffman table, quantization table, and component references with clear error messages
14
+ - Color decoding looks up Y/Cb/Cr components by ID instead of assuming SOF array order
15
+ - Support for non-standard component IDs (e.g. 0, 1, 2 as used by some Adobe tools)
16
+ - Explicit error for unsupported component counts (e.g. CMYK)
17
+ - Encoder no longer holds file handle open during encoding
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  New features:
data/README.md CHANGED
@@ -79,7 +79,8 @@ PureJPEG.encode(source,
79
79
  luminance_table: nil, # custom 64-element quantization table for Y
80
80
  chrominance_table: nil, # custom 64-element quantization table for Cb/Cr
81
81
  quantization_modifier: nil, # proc(table, :luminance/:chrominance) -> modified table
82
- scramble_quantization: false # intentionally misordered quant tables (creative effect)
82
+ scramble_quantization: false, # intentionally misordered quant tables (creative effect)
83
+ optimize_huffman: false # slower 2-pass encode, usually smaller files
83
84
  )
84
85
  ```
85
86
 
@@ -135,6 +136,16 @@ pixel.b # => 97
135
136
  image = PureJPEG.read(jpeg_bytes)
136
137
  ```
137
138
 
139
+ ### Read dimensions and metadata only
140
+
141
+ ```ruby
142
+ info = PureJPEG.info("photo.jpg")
143
+ info.width # => 1024
144
+ info.height # => 768
145
+ info.component_count # => 3
146
+ info.progressive # => false
147
+ ```
148
+
138
149
  ### Iterating pixels
139
150
 
140
151
  ```ruby
@@ -171,7 +182,8 @@ Encoding:
171
182
  - 8-bit precision
172
183
  - Grayscale (1 component) and YCbCr color (3 components)
173
184
  - 4:2:0 chroma subsampling (color) or no subsampling (grayscale)
174
- - Standard Huffman tables (Annex K)
185
+ - Standard Huffman tables (Annex K) by default
186
+ - Optional image-specific optimized Huffman tables
175
187
 
176
188
  Decoding:
177
189
  - Baseline DCT (SOF0) and Progressive DCT (SOF2)
@@ -182,6 +194,8 @@ Decoding:
182
194
 
183
195
  Not supported: arithmetic coding, 12-bit precision, EXIF/ICC profile preservation, adding a default background for transparent sources (see what happens above!). 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!
184
196
 
197
+ Possible future improvements: AAN/fixed-point DCT (but it's a LOT of work), ICC profile rendering/conversion.
198
+
185
199
  ## Performance
186
200
 
187
201
  On a 1024x1024 image (Ruby 4.0.1 on my M1 Max):
@@ -204,9 +218,9 @@ rake profile # CPU profile with StackProf (requires the stackprof gem)
204
218
 
205
219
  ## AI Disclosure
206
220
 
207
- **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 :-)
221
+ **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.*
208
222
 
209
- **I have read all of the code produced.** The algorithms are above my paygrade, but I'm OK with what has been produced, and I manually fixed a variety of stylistic things along the way. For example, CC seems to like wrapping entire functions in `if` statements rather than bailing on the opposite condition.
223
+ **I have read all of the code produced up to v0.2.0.** The algorithms are above my paygrade, but I'm OK with what has been produced, and I manually fixed a variety of stylistic things along the way. For example, CC seems to like wrapping entire functions in `if` statements rather than bailing on the opposite condition. *Later update: I have not read the ICC and optimized Huffman code yet, but it is heavily tested.*
210
224
 
211
225
  **CC needed a lot of guidance.** Its initial JPEG algorithm was somewhat naive and output odd looking JPEGs akin to those of my Kodak digital camera from 2001. After some back and forth and image comparisons, we figured out it was doing the quantization entirely wrong (specifically not using the zigzag approach during quanitization but just going in raster order). I *like* this aesthetic, but fixed it up so that it works as a generally usable JPEG library, while adding ways to customize things so you can recreate the effect, if preferred (see `CREATIVE.md` for more on that).
212
226
 
@@ -27,6 +27,8 @@ module PureJPEG
27
27
 
28
28
  def decode
29
29
  jfif = JFIFReader.new(@data)
30
+ @icc_profile = jfif.icc_profile
31
+ validate_dimensions!(jfif.width, jfif.height)
30
32
  return decode_progressive(jfif) if jfif.progressive
31
33
 
32
34
  width = jfif.width
@@ -90,10 +92,8 @@ module PureJPEG
90
92
  end
91
93
 
92
94
  jfif.scan_components.each do |sc|
93
- comp = comp_info[sc.id]
94
- dc_tab = dc_tables[sc.dc_table_id]
95
- ac_tab = ac_tables[sc.ac_table_id]
96
- qt = jfif.quant_tables[comp.qt_id]
95
+ comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables)
96
+ qt = fetch_quant_table!(jfif, comp)
97
97
  ch = channels[comp.id]
98
98
 
99
99
  comp.v_sampling.times do |bv|
@@ -122,13 +122,20 @@ module PureJPEG
122
122
  num_components = jfif.components.length
123
123
  if num_components == 1
124
124
  assemble_grayscale(width, height, channels, jfif.components[0])
125
- else
125
+ elsif num_components == 3
126
126
  assemble_color(width, height, channels, jfif.components, max_h, max_v)
127
+ else
128
+ raise DecodeError, "Unsupported number of components: #{num_components}"
127
129
  end
128
130
  end
129
131
 
130
132
  private
131
133
 
134
+ def validate_dimensions!(width, height)
135
+ raise DecodeError, "Invalid image dimensions: #{width}x#{height}" if width <= 0 || height <= 0
136
+ raise DecodeError, "Image too large: #{width}x#{height} (max #{MAX_DIMENSION}x#{MAX_DIMENSION})" if width > MAX_DIMENSION || height > MAX_DIMENSION
137
+ end
138
+
132
139
  # --- Progressive JPEG decoding ---
133
140
 
134
141
  def decode_progressive(jfif)
@@ -203,7 +210,7 @@ module PureJPEG
203
210
  spatial = Array.new(64, 0.0)
204
211
 
205
212
  jfif.components.each do |c|
206
- qt = jfif.quant_tables[c.qt_id]
213
+ qt = fetch_quant_table!(jfif, c)
207
214
  ch = channels[c.id]
208
215
  coeff_buf = coeffs[c.id]
209
216
  bx_count, by_count = comp_blocks[c.id]
@@ -224,17 +231,17 @@ module PureJPEG
224
231
  num_components = jfif.components.length
225
232
  if num_components == 1
226
233
  assemble_grayscale(width, height, channels, jfif.components[0])
227
- else
234
+ elsif num_components == 3
228
235
  assemble_color(width, height, channels, jfif.components, max_h, max_v)
236
+ else
237
+ raise DecodeError, "Unsupported number of components: #{num_components}"
229
238
  end
230
239
  end
231
240
 
232
241
  def prog_scan_non_interleaved(reader, scan, comp_info, dc_tables, ac_tables,
233
242
  coeffs, comp_blocks, restart_interval, ss, se, ah, al)
234
243
  sc = scan.components[0]
235
- comp = comp_info[sc.id]
236
- dc_tab = dc_tables[sc.dc_table_id]
237
- ac_tab = ac_tables[sc.ac_table_id]
244
+ comp, dc_tab, ac_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: ss > 0)
238
245
  coeff_buf = coeffs[comp.id]
239
246
  bx_count, by_count = comp_blocks[comp.id]
240
247
 
@@ -284,8 +291,7 @@ module PureJPEG
284
291
  end
285
292
 
286
293
  scan.components.each do |sc|
287
- comp = comp_info[sc.id]
288
- dc_tab = dc_tables[sc.dc_table_id]
294
+ comp, dc_tab = resolve_scan_references!(sc, comp_info, dc_tables, ac_tables, require_ac: false)
289
295
  coeff_buf = coeffs[comp.id]
290
296
  bx_count = comp_blocks[comp.id][0]
291
297
 
@@ -463,6 +469,28 @@ module PureJPEG
463
469
  end
464
470
  end
465
471
 
472
+ def resolve_scan_references!(scan_component, comp_info, dc_tables, ac_tables, require_ac: true)
473
+ comp = comp_info[scan_component.id]
474
+ raise DecodeError, "Scan references unknown component id #{scan_component.id}" unless comp
475
+
476
+ dc_tab = dc_tables[scan_component.dc_table_id]
477
+ raise DecodeError, "Component #{scan_component.id} references missing DC Huffman table #{scan_component.dc_table_id}" unless dc_tab
478
+
479
+ if require_ac
480
+ ac_tab = ac_tables[scan_component.ac_table_id]
481
+ raise DecodeError, "Component #{scan_component.id} references missing AC Huffman table #{scan_component.ac_table_id}" unless ac_tab
482
+ end
483
+
484
+ [comp, dc_tab, ac_tab]
485
+ end
486
+
487
+ def fetch_quant_table!(jfif, comp)
488
+ qt = jfif.quant_tables[comp.qt_id]
489
+ raise DecodeError, "Component #{comp.id} references missing quantization table #{comp.qt_id}" unless qt
490
+
491
+ qt
492
+ end
493
+
466
494
  def assemble_grayscale(width, height, channels, comp)
467
495
  ch = channels[comp.id]
468
496
  pixels = Array.new(width * height)
@@ -474,17 +502,16 @@ module PureJPEG
474
502
  pixels[dst_row + x] = (v << 16) | (v << 8) | v
475
503
  end
476
504
  end
477
- Image.new(width, height, pixels)
505
+ Image.new(width, height, pixels, icc_profile: @icc_profile)
478
506
  end
479
507
 
480
508
  def assemble_color(width, height, channels, components, max_h, max_v)
481
509
  # Upsample chroma channels if needed and convert YCbCr to RGB
482
- y_ch = channels[components[0].id]
483
- cb_ch = channels[components[1].id]
484
- cr_ch = channels[components[2].id]
510
+ y_comp, cb_comp, cr_comp = resolve_color_components(components)
485
511
 
486
- cb_comp = components[1]
487
- cr_comp = components[2]
512
+ y_ch = channels[y_comp.id]
513
+ cb_ch = channels[cb_comp.id]
514
+ cr_ch = channels[cr_comp.id]
488
515
 
489
516
  pixels = Array.new(width * height)
490
517
 
@@ -518,7 +545,16 @@ module PureJPEG
518
545
  end
519
546
  end
520
547
 
521
- Image.new(width, height, pixels)
548
+ Image.new(width, height, pixels, icc_profile: @icc_profile)
549
+ end
550
+
551
+ def resolve_color_components(components)
552
+ by_id = components.each_with_object({}) { |comp, memo| memo[comp.id] = comp }
553
+ if by_id[1] && by_id[2] && by_id[3]
554
+ [by_id[1], by_id[2], by_id[3]]
555
+ else
556
+ components
557
+ end
522
558
  end
523
559
  end
524
560
  end
@@ -15,6 +15,8 @@ module PureJPEG
15
15
  attr_reader :quality
16
16
  # @return [Boolean] whether grayscale mode is enabled
17
17
  attr_reader :grayscale
18
+ # @return [Boolean] whether image-specific Huffman tables are generated
19
+ attr_reader :optimize_huffman
18
20
 
19
21
  # Create a new encoder for the given pixel source.
20
22
  #
@@ -34,12 +36,16 @@ module PureJPEG
34
36
  # @param scramble_quantization [Boolean] write quantization tables in raster
35
37
  # order instead of zigzag (non-spec-compliant; recreates the "early digicam"
36
38
  # artifact look when decoded by standard viewers)
39
+ # @param optimize_huffman [Boolean] build image-specific Huffman tables with
40
+ # an additional analysis pass (default false)
37
41
  def initialize(source, quality: 85, grayscale: false, chroma_quality: nil,
38
42
  luminance_table: nil, chrominance_table: nil,
39
- quantization_modifier: nil, scramble_quantization: false)
43
+ quantization_modifier: nil, scramble_quantization: false,
44
+ optimize_huffman: false)
40
45
  @source = source
41
46
  @quality = quality
42
47
  @grayscale = grayscale
48
+ @optimize_huffman = optimize_huffman
43
49
  @chroma_quality = chroma_quality || quality
44
50
  validate_qtable!(luminance_table, "luminance_table") if luminance_table
45
51
  validate_qtable!(chrominance_table, "chrominance_table") if chrominance_table
@@ -54,7 +60,7 @@ module PureJPEG
54
60
  # @param path [String] output file path
55
61
  # @return [void]
56
62
  def write(path)
57
- File.open(path, "wb") { |f| encode(f) }
63
+ File.binwrite(path, to_bytes)
58
64
  end
59
65
 
60
66
  # Return the encoded JPEG as a binary string.
@@ -91,61 +97,127 @@ module PureJPEG
91
97
  width = source.width
92
98
  height = source.height
93
99
 
100
+ raise ArgumentError, "Width must be a positive integer (got #{width.inspect})" unless width.is_a?(Integer) && width > 0
101
+ raise ArgumentError, "Height must be a positive integer (got #{height.inspect})" unless height.is_a?(Integer) && height > 0
102
+ raise ArgumentError, "Width #{width} exceeds maximum of #{MAX_DIMENSION}" if width > MAX_DIMENSION
103
+ raise ArgumentError, "Height #{height} exceeds maximum of #{MAX_DIMENSION}" if height > MAX_DIMENSION
104
+
94
105
  lum_qtable = build_lum_qtable
95
- lum_dc = Huffman.build_table(Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
96
- lum_ac = Huffman.build_table(Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
97
- lum_huff = Huffman::Encoder.new(lum_dc, lum_ac)
98
106
 
99
107
  if grayscale
100
- scan_data = encode_grayscale(width, height, lum_qtable, lum_huff)
101
- write_grayscale_jfif(io, width, height, lum_qtable, scan_data)
108
+ y_data = extract_luminance(width, height)
109
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values =
110
+ if optimize_huffman
111
+ counter = collect_grayscale_frequencies(y_data, width, height, lum_qtable)
112
+ dc_bits, dc_values = Huffman.optimize_table(counter.dc_frequencies)
113
+ ac_bits, ac_values = Huffman.optimize_table(counter.ac_frequencies)
114
+ [dc_bits, dc_values, ac_bits, ac_values]
115
+ else
116
+ [Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
117
+ Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES]
118
+ end
119
+
120
+ lum_huff = Huffman::Encoder.new(
121
+ Huffman.build_table(lum_dc_bits, lum_dc_values),
122
+ Huffman.build_table(lum_ac_bits, lum_ac_values)
123
+ )
124
+
125
+ scan_data = encode_grayscale_data(y_data, width, height, lum_qtable, lum_huff)
126
+ write_grayscale_jfif(io, width, height, lum_qtable, scan_data,
127
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values)
102
128
  else
103
129
  chr_qtable = build_chr_qtable
104
- chr_dc = Huffman.build_table(Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES)
105
- chr_ac = Huffman.build_table(Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES)
106
- chr_huff = Huffman::Encoder.new(chr_dc, chr_ac)
130
+ y_data, cb_data, cr_data = extract_ycbcr(width, height)
131
+ sub_w = (width + 1) / 2
132
+ sub_h = (height + 1) / 2
133
+ cb_sub = downsample(cb_data, width, height, sub_w, sub_h)
134
+ cr_sub = downsample(cr_data, width, height, sub_w, sub_h)
135
+
136
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
137
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values =
138
+ if optimize_huffman
139
+ lum_counter, chr_counter = collect_color_frequencies(
140
+ y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable
141
+ )
142
+ dc_bits, dc_values = Huffman.optimize_table(lum_counter.dc_frequencies)
143
+ ac_bits, ac_values = Huffman.optimize_table(lum_counter.ac_frequencies)
144
+ chr_dc_bits, chr_dc_values = Huffman.optimize_table(chr_counter.dc_frequencies)
145
+ chr_ac_bits, chr_ac_values = Huffman.optimize_table(chr_counter.ac_frequencies)
146
+ [dc_bits, dc_values, ac_bits, ac_values, chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values]
147
+ else
148
+ [Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
149
+ Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES,
150
+ Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES,
151
+ Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES]
152
+ end
107
153
 
108
- scan_data = encode_color(width, height, lum_qtable, chr_qtable, lum_huff, chr_huff)
109
- write_color_jfif(io, width, height, lum_qtable, chr_qtable, scan_data)
154
+ lum_huff = Huffman::Encoder.new(
155
+ Huffman.build_table(lum_dc_bits, lum_dc_values),
156
+ Huffman.build_table(lum_ac_bits, lum_ac_values)
157
+ )
158
+ chr_huff = Huffman::Encoder.new(
159
+ Huffman.build_table(chr_dc_bits, chr_dc_values),
160
+ Huffman.build_table(chr_ac_bits, chr_ac_values)
161
+ )
162
+
163
+ scan_data = encode_color_data(
164
+ y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable, lum_huff, chr_huff
165
+ )
166
+ write_color_jfif(io, width, height, lum_qtable, chr_qtable, scan_data,
167
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
168
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
110
169
  end
111
170
  end
112
171
 
113
172
  # --- Grayscale encoding ---
114
173
 
115
- def encode_grayscale(width, height, qtable, huff)
116
- y_data = extract_luminance(width, height)
174
+ def collect_grayscale_frequencies(y_data, width, height, qtable)
175
+ counter = Huffman::FrequencyCounter.new
176
+ each_grayscale_block(y_data, width, height, qtable) do |zbuf|
177
+ counter.observe_block(zbuf, :y)
178
+ end
179
+ counter
180
+ end
181
+
182
+ def encode_grayscale_data(y_data, width, height, qtable, huff)
183
+ bit_writer = BitWriter.new
184
+ prev_dc = 0
185
+
186
+ each_grayscale_block(y_data, width, height, qtable) do |zbuf|
187
+ prev_dc = huff.encode_block(zbuf, prev_dc, bit_writer)
188
+ end
189
+
190
+ bit_writer.flush
191
+ bit_writer.bytes
192
+ end
193
+
194
+ def each_grayscale_block(y_data, width, height, qtable)
117
195
  padded_w = (width + 7) & ~7
118
196
  padded_h = (height + 7) & ~7
119
197
 
120
- # Reusable buffers
121
198
  block = Array.new(64, 0.0)
122
199
  temp = Array.new(64, 0.0)
123
200
  dct = Array.new(64, 0.0)
124
201
  qbuf = Array.new(64, 0)
125
202
  zbuf = Array.new(64, 0)
126
203
 
127
- bit_writer = BitWriter.new
128
- prev_dc = 0
129
-
130
204
  (0...padded_h).step(8) do |by|
131
205
  (0...padded_w).step(8) do |bx|
132
206
  extract_block_into(y_data, width, height, bx, by, block)
133
- prev_dc = encode_block(block, temp, dct, qbuf, zbuf, qtable, huff, prev_dc, bit_writer)
207
+ transform_block(block, temp, dct, qbuf, zbuf, qtable)
208
+ yield zbuf
134
209
  end
135
210
  end
136
-
137
- bit_writer.flush
138
- bit_writer.bytes
139
211
  end
140
212
 
141
- def write_grayscale_jfif(io, width, height, qtable, scan_data)
213
+ def write_grayscale_jfif(io, width, height, qtable, scan_data, dc_bits, dc_values, ac_bits, ac_values)
142
214
  jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
143
215
  jfif.write_soi
144
216
  jfif.write_app0
145
217
  jfif.write_dqt(qtable, 0)
146
218
  jfif.write_sof0(width, height, [[1, 1, 1, 0]])
147
- jfif.write_dht(0, 0, Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
148
- jfif.write_dht(1, 0, Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
219
+ jfif.write_dht(0, 0, dc_bits, dc_values)
220
+ jfif.write_dht(1, 0, ac_bits, ac_values)
149
221
  jfif.write_sos([[1, 0, 0]])
150
222
  jfif.write_scan_data(scan_data)
151
223
  jfif.write_eoi
@@ -153,69 +225,97 @@ module PureJPEG
153
225
 
154
226
  # --- Color encoding (YCbCr 4:2:0) ---
155
227
 
156
- def encode_color(width, height, lum_qt, chr_qt, lum_huff, chr_huff)
157
- y_data, cb_data, cr_data = extract_ycbcr(width, height)
228
+ def collect_color_frequencies(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
229
+ lum_counter = Huffman::FrequencyCounter.new
230
+ chr_counter = Huffman::FrequencyCounter.new
231
+
232
+ each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
233
+ case component
234
+ when :y
235
+ lum_counter.observe_block(zbuf, :y)
236
+ when :cb
237
+ chr_counter.observe_block(zbuf, :cb)
238
+ when :cr
239
+ chr_counter.observe_block(zbuf, :cr)
240
+ end
241
+ end
242
+
243
+ [lum_counter, chr_counter]
244
+ end
245
+
246
+ def encode_color_data(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt, lum_huff, chr_huff)
247
+ bit_writer = BitWriter.new
248
+ prev_dc_y = 0
249
+ prev_dc_cb = 0
250
+ prev_dc_cr = 0
158
251
 
159
- sub_w = (width + 1) / 2
160
- sub_h = (height + 1) / 2
161
- cb_sub = downsample(cb_data, width, height, sub_w, sub_h)
162
- cr_sub = downsample(cr_data, width, height, sub_w, sub_h)
252
+ each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
253
+ case component
254
+ when :y
255
+ prev_dc_y = lum_huff.encode_block(zbuf, prev_dc_y, bit_writer)
256
+ when :cb
257
+ prev_dc_cb = chr_huff.encode_block(zbuf, prev_dc_cb, bit_writer)
258
+ when :cr
259
+ prev_dc_cr = chr_huff.encode_block(zbuf, prev_dc_cr, bit_writer)
260
+ end
261
+ end
163
262
 
263
+ bit_writer.flush
264
+ bit_writer.bytes
265
+ end
266
+
267
+ def each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
164
268
  mcu_w = (width + 15) & ~15
165
269
  mcu_h = (height + 15) & ~15
166
270
 
167
- # Reusable buffers
168
271
  block = Array.new(64, 0.0)
169
272
  temp = Array.new(64, 0.0)
170
273
  dct = Array.new(64, 0.0)
171
274
  qbuf = Array.new(64, 0)
172
275
  zbuf = Array.new(64, 0)
173
276
 
174
- bit_writer = BitWriter.new
175
- prev_dc_y = 0
176
- prev_dc_cb = 0
177
- prev_dc_cr = 0
178
-
179
277
  (0...mcu_h).step(16) do |my|
180
278
  (0...mcu_w).step(16) do |mx|
181
- # 4 luminance blocks
182
279
  extract_block_into(y_data, width, height, mx, my, block)
183
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
280
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
281
+ yield :y, zbuf
184
282
 
185
283
  extract_block_into(y_data, width, height, mx + 8, my, block)
186
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
284
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
285
+ yield :y, zbuf
187
286
 
188
287
  extract_block_into(y_data, width, height, mx, my + 8, block)
189
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
288
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
289
+ yield :y, zbuf
190
290
 
191
291
  extract_block_into(y_data, width, height, mx + 8, my + 8, block)
192
- prev_dc_y = encode_block(block, temp, dct, qbuf, zbuf, lum_qt, lum_huff, prev_dc_y, bit_writer)
292
+ transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
293
+ yield :y, zbuf
193
294
 
194
- # 1 Cb block
195
295
  extract_block_into(cb_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
196
- prev_dc_cb = encode_block(block, temp, dct, qbuf, zbuf, chr_qt, chr_huff, prev_dc_cb, bit_writer)
296
+ transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
297
+ yield :cb, zbuf
197
298
 
198
- # 1 Cr block
199
299
  extract_block_into(cr_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
200
- prev_dc_cr = encode_block(block, temp, dct, qbuf, zbuf, chr_qt, chr_huff, prev_dc_cr, bit_writer)
300
+ transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
301
+ yield :cr, zbuf
201
302
  end
202
303
  end
203
-
204
- bit_writer.flush
205
- bit_writer.bytes
206
304
  end
207
305
 
208
- def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data)
306
+ def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data,
307
+ lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
308
+ chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
209
309
  jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
210
310
  jfif.write_soi
211
311
  jfif.write_app0
212
312
  jfif.write_dqt(lum_qt, 0)
213
313
  jfif.write_dqt(chr_qt, 1)
214
314
  jfif.write_sof0(width, height, [[1, 2, 2, 0], [2, 1, 1, 1], [3, 1, 1, 1]])
215
- jfif.write_dht(0, 0, Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES)
216
- jfif.write_dht(1, 0, Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES)
217
- jfif.write_dht(0, 1, Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES)
218
- jfif.write_dht(1, 1, Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES)
315
+ jfif.write_dht(0, 0, lum_dc_bits, lum_dc_values)
316
+ jfif.write_dht(1, 0, lum_ac_bits, lum_ac_values)
317
+ jfif.write_dht(0, 1, chr_dc_bits, chr_dc_values)
318
+ jfif.write_dht(1, 1, chr_ac_bits, chr_ac_values)
219
319
  jfif.write_sos([[1, 0, 0], [2, 1, 1], [3, 1, 1]])
220
320
  jfif.write_scan_data(scan_data)
221
321
  jfif.write_eoi
@@ -223,11 +323,11 @@ module PureJPEG
223
323
 
224
324
  # --- Shared block pipeline (all buffers pre-allocated) ---
225
325
 
226
- def encode_block(block, temp, dct, qbuf, zbuf, qtable, huff, prev_dc, bit_writer)
326
+ def transform_block(block, temp, dct, qbuf, zbuf, qtable)
227
327
  DCT.forward!(block, temp, dct)
228
328
  Quantization.quantize!(dct, qtable, qbuf)
229
329
  Zigzag.reorder!(qbuf, zbuf)
230
- huff.encode_block(zbuf, prev_dc, bit_writer)
330
+ zbuf
231
331
  end
232
332
 
233
333
  # --- Pixel extraction ---
@@ -3,6 +3,56 @@
3
3
  module PureJPEG
4
4
  module Huffman
5
5
  class Encoder
6
+ def self.category_and_bits(value)
7
+ return [0, 0] if value == 0
8
+ abs_val = value.abs
9
+ cat = 0
10
+ v = abs_val
11
+ while v > 0
12
+ cat += 1
13
+ v >>= 1
14
+ end
15
+ bits = value > 0 ? value : value + (1 << cat) - 1
16
+ [cat, bits]
17
+ end
18
+
19
+ def self.each_ac_item(zigzag)
20
+ last_nonzero = 63
21
+ last_nonzero -= 1 while last_nonzero > 0 && zigzag[last_nonzero] == 0
22
+
23
+ if last_nonzero == 0
24
+ yield 0x00, 0
25
+ return
26
+ end
27
+
28
+ i = 1
29
+ while i <= last_nonzero
30
+ run = 0
31
+ while i <= last_nonzero && zigzag[i] == 0
32
+ run += 1
33
+ i += 1
34
+ end
35
+
36
+ while run >= 16
37
+ yield 0xF0, 0
38
+ run -= 16
39
+ end
40
+
41
+ value = zigzag[i]
42
+ cat, = category_and_bits(value)
43
+ yield (run << 4) | cat, value
44
+ i += 1
45
+ end
46
+
47
+ yield 0x00, 0 if last_nonzero < 63
48
+ end
49
+
50
+ def self.each_ac_symbol(zigzag)
51
+ each_ac_item(zigzag) do |symbol, _value|
52
+ yield symbol
53
+ end
54
+ end
55
+
6
56
  def initialize(dc_table, ac_table)
7
57
  @dc_table = dc_table
8
58
  @ac_table = ac_table
@@ -23,65 +73,43 @@ module PureJPEG
23
73
  private
24
74
 
25
75
  def encode_dc(diff, writer)
26
- cat, bits = category_and_bits(diff)
76
+ cat, bits = self.class.category_and_bits(diff)
27
77
  code, length = @dc_table[cat]
28
78
  writer.write_bits(code, length)
29
79
  writer.write_bits(bits, cat) if cat > 0
30
80
  end
31
81
 
32
82
  def encode_ac(zigzag, writer)
33
- last_nonzero = 63
34
- last_nonzero -= 1 while last_nonzero > 0 && zigzag[last_nonzero] == 0
83
+ self.class.each_ac_item(zigzag) do |symbol, value|
84
+ code, length = @ac_table[symbol]
85
+ writer.write_bits(code, length)
86
+ next if symbol == 0x00 || symbol == 0xF0
35
87
 
36
- if last_nonzero == 0
37
- # All AC coefficients are zero (AC starts at index 1)
38
- eob = @ac_table[0x00]
39
- writer.write_bits(eob[0], eob[1])
40
- return
88
+ cat, bits = self.class.category_and_bits(value)
89
+ writer.write_bits(bits, cat)
41
90
  end
91
+ end
92
+ end
42
93
 
43
- i = 1
44
- while i <= last_nonzero
45
- run = 0
46
- while i <= last_nonzero && zigzag[i] == 0
47
- run += 1
48
- i += 1
49
- end
94
+ class FrequencyCounter
95
+ attr_reader :dc_frequencies, :ac_frequencies
50
96
 
51
- # Emit ZRL (16 zeros) symbols as needed
52
- while run >= 16
53
- zrl = @ac_table[0xF0]
54
- writer.write_bits(zrl[0], zrl[1])
55
- run -= 16
56
- end
97
+ def initialize
98
+ @dc_frequencies = Array.new(256, 0)
99
+ @ac_frequencies = Array.new(256, 0)
100
+ @prev_dc = Hash.new(0)
101
+ end
57
102
 
58
- cat, bits = category_and_bits(zigzag[i])
59
- symbol = (run << 4) | cat
60
- code, length = @ac_table[symbol]
61
- writer.write_bits(code, length)
62
- writer.write_bits(bits, cat) if cat > 0
63
- i += 1
64
- end
103
+ def observe_block(zigzag, state_key)
104
+ diff = zigzag[0] - @prev_dc[state_key]
105
+ @prev_dc[state_key] = zigzag[0]
65
106
 
66
- # EOB if we didn't reach position 63
67
- if last_nonzero < 63
68
- eob = @ac_table[0x00]
69
- writer.write_bits(eob[0], eob[1])
70
- end
71
- end
107
+ cat, = Encoder.category_and_bits(diff)
108
+ @dc_frequencies[cat] += 1
72
109
 
73
- # Returns [category, encoded_bits] for a coefficient value.
74
- def category_and_bits(value)
75
- return [0, 0] if value == 0
76
- abs_val = value.abs
77
- cat = 0
78
- v = abs_val
79
- while v > 0
80
- cat += 1
81
- v >>= 1
110
+ Encoder.each_ac_symbol(zigzag) do |symbol|
111
+ @ac_frequencies[symbol] += 1
82
112
  end
83
- bits = value > 0 ? value : value + (1 << cat) - 1
84
- [cat, bits]
85
113
  end
86
114
  end
87
115
  end
@@ -81,5 +81,96 @@ module PureJPEG
81
81
 
82
82
  table
83
83
  end
84
+
85
+ # Build a JPEG canonical Huffman table definition from symbol frequencies.
86
+ # Returns [bits, values], where bits has 16 entries for code lengths 1..16.
87
+ def self.optimize_table(frequencies)
88
+ lengths = build_code_lengths(frequencies)
89
+ counts = length_counts(lengths)
90
+ trim_counts_to_jpeg_limit!(counts)
91
+
92
+ symbols = (0...256).select { |symbol| frequencies[symbol].positive? }
93
+ symbols.sort_by! { |symbol| [-frequencies[symbol], symbol] }
94
+
95
+ bits = Array.new(16, 0)
96
+ values = []
97
+ index = 0
98
+
99
+ 1.upto(16) do |length|
100
+ count = counts[length]
101
+ bits[length - 1] = count
102
+ count.times do
103
+ values << symbols[index]
104
+ index += 1
105
+ end
106
+ end
107
+
108
+ [bits.freeze, values.freeze]
109
+ end
110
+
111
+ def self.build_code_lengths(frequencies)
112
+ nodes = []
113
+ 256.times do |symbol|
114
+ freq = frequencies[symbol]
115
+ nodes << { freq: freq, symbol: symbol } if freq.positive?
116
+ end
117
+ nodes << { freq: 1, symbol: 256 }
118
+
119
+ while nodes.length > 1
120
+ nodes.sort_by! do |node|
121
+ [node[:freq], node[:symbol] || 257]
122
+ end
123
+ left = nodes.shift
124
+ right = nodes.shift
125
+ nodes << { freq: left[:freq] + right[:freq], left: left, right: right }
126
+ end
127
+
128
+ lengths = Array.new(257, 0)
129
+ assign_code_lengths(nodes.first, 0, lengths)
130
+ lengths
131
+ end
132
+ private_class_method :build_code_lengths
133
+
134
+ def self.assign_code_lengths(node, depth, lengths)
135
+ if node[:symbol]
136
+ lengths[node[:symbol]] = depth.zero? ? 1 : depth
137
+ return
138
+ end
139
+
140
+ assign_code_lengths(node[:left], depth + 1, lengths)
141
+ assign_code_lengths(node[:right], depth + 1, lengths)
142
+ end
143
+ private_class_method :assign_code_lengths
144
+
145
+ def self.length_counts(lengths)
146
+ counts = Array.new([lengths.max + 1, 33].max, 0)
147
+ lengths.each do |length|
148
+ counts[length] += 1 if length.positive?
149
+ end
150
+ counts
151
+ end
152
+ private_class_method :length_counts
153
+
154
+ def self.trim_counts_to_jpeg_limit!(counts)
155
+ max_length = counts.length - 1
156
+ while max_length > 16
157
+ while counts[max_length].positive?
158
+ j = max_length - 2
159
+ j -= 1 while j.positive? && counts[j].zero?
160
+ raise ArgumentError, "Unable to limit Huffman code lengths" unless j.positive?
161
+
162
+ counts[max_length] -= 2
163
+ counts[max_length - 1] += 1
164
+ counts[j + 1] += 2
165
+ counts[j] -= 1
166
+ end
167
+ max_length -= 1
168
+ end
169
+
170
+ max_length = 16
171
+ max_length -= 1 while max_length.positive? && counts[max_length].zero?
172
+ counts[max_length] -= 1
173
+ end
174
+ private_class_method :trim_counts_to_jpeg_limit!
84
175
  end
85
176
  end
@@ -17,14 +17,19 @@ module PureJPEG
17
17
  # Format: +(r << 16) | (g << 8) | b+.
18
18
  attr_reader :packed_pixels
19
19
 
20
+ # @return [String, nil] raw ICC color profile data, if present in the source JPEG
21
+ attr_reader :icc_profile
22
+
20
23
  # @param width [Integer]
21
24
  # @param height [Integer]
22
25
  # @param packed_pixels [Array<Integer>] flat row-major array of packed RGB
23
26
  # integers in the format +(r << 16) | (g << 8) | b+
24
- def initialize(width, height, packed_pixels)
27
+ # @param icc_profile [String, nil] raw ICC profile bytes
28
+ def initialize(width, height, packed_pixels, icc_profile: nil)
25
29
  @width = width
26
30
  @height = height
27
31
  @packed_pixels = packed_pixels
32
+ @icc_profile = icc_profile
28
33
  end
29
34
 
30
35
  # Retrieve a pixel by coordinate.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureJPEG
4
+ # Lightweight metadata returned by {.info}.
5
+ Info = Struct.new(:width, :height, :component_count, :progressive, :icc_profile, keyword_init: true)
6
+ end
@@ -3,14 +3,15 @@
3
3
  module PureJPEG
4
4
  class JFIFReader
5
5
  attr_reader :width, :height, :components, :quant_tables, :huffman_tables,
6
- :restart_interval, :progressive, :scans
6
+ :restart_interval, :progressive, :scans, :icc_profile
7
7
 
8
8
  Component = Struct.new(:id, :h_sampling, :v_sampling, :qt_id)
9
9
  ScanComponent = Struct.new(:id, :dc_table_id, :ac_table_id)
10
10
  Scan = Struct.new(:components, :spectral_start, :spectral_end, :successive_high, :successive_low, :data, :huffman_tables)
11
11
 
12
- def initialize(data)
12
+ def initialize(data, stop_after_frame: false)
13
13
  @data = data.b
14
+ @stop_after_frame = stop_after_frame
14
15
  @pos = 0
15
16
  @quant_tables = {}
16
17
  @huffman_tables = {}
@@ -18,7 +19,9 @@ module PureJPEG
18
19
  @restart_interval = 0
19
20
  @progressive = false
20
21
  @scans = []
22
+ @icc_chunks = {}
21
23
  parse
24
+ assemble_icc_profile
22
25
  end
23
26
 
24
27
  def scan_components
@@ -37,7 +40,9 @@ module PureJPEG
37
40
  loop do
38
41
  marker = read_marker
39
42
  case marker
40
- when 0xE0..0xEF # APP0-APP15
43
+ when 0xE2 # APP2 (may contain ICC profile)
44
+ parse_app2
45
+ when 0xE0, 0xE1, 0xE3..0xEF # APP0, APP1, APP3-APP15
41
46
  skip_segment
42
47
  when 0xDB # DQT
43
48
  parse_dqt
@@ -45,9 +50,11 @@ module PureJPEG
45
50
  parse_dht
46
51
  when 0xC0 # SOF0 (baseline)
47
52
  parse_sof0
53
+ return if @stop_after_frame
48
54
  when 0xC2 # SOF2 (progressive)
49
55
  parse_sof0
50
56
  @progressive = true
57
+ return if @stop_after_frame
51
58
  when 0xDA # SOS
52
59
  scan = parse_sos
53
60
  scan.data = extract_scan_data
@@ -91,6 +98,28 @@ module PureJPEG
91
98
  raise PureJPEG::DecodeError, "Expected marker 0x#{expected.to_s(16)}, got 0x#{marker.to_s(16)}" unless marker == expected
92
99
  end
93
100
 
101
+ ICC_PROFILE_SIG = "ICC_PROFILE\0".b
102
+
103
+ def parse_app2
104
+ length = read_u16
105
+ end_pos = @pos + length - 2
106
+
107
+ if length >= 16 && @data[@pos, 12] == ICC_PROFILE_SIG
108
+ @pos += 12
109
+ seq_no = read_byte
110
+ _total = read_byte
111
+ @icc_chunks[seq_no] = @data[@pos, end_pos - @pos]
112
+ end
113
+
114
+ @pos = end_pos
115
+ end
116
+
117
+ def assemble_icc_profile
118
+ return if @icc_chunks.empty?
119
+
120
+ @icc_profile = @icc_chunks.sort_by(&:first).map(&:last).join.b
121
+ end
122
+
94
123
  def skip_segment
95
124
  length = read_u16
96
125
  @pos += length - 2
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PureJPEG
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pure_jpeg.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "pure_jpeg/huffman/encoder"
16
16
  require_relative "pure_jpeg/huffman/decoder"
17
17
  require_relative "pure_jpeg/jfif_writer"
18
18
  require_relative "pure_jpeg/jfif_reader"
19
+ require_relative "pure_jpeg/info"
19
20
  require_relative "pure_jpeg/image"
20
21
  require_relative "pure_jpeg/encoder"
21
22
  require_relative "pure_jpeg/decoder"
@@ -28,6 +29,9 @@ module PureJPEG
28
29
  # Raised when decoding invalid or unsupported JPEG data.
29
30
  class DecodeError < StandardError; end
30
31
 
32
+ # Maximum image dimension (width or height) allowed for encoding and decoding.
33
+ MAX_DIMENSION = 8192
34
+
31
35
  # Encode a pixel source as a JPEG.
32
36
  #
33
37
  # @param source [#width, #height, #[]] any object responding to +width+,
@@ -60,4 +64,27 @@ module PureJPEG
60
64
  def self.read(path_or_data)
61
65
  Decoder.decode(path_or_data)
62
66
  end
67
+
68
+ # Read JPEG dimensions and basic frame metadata without decoding scan data.
69
+ #
70
+ # @param path_or_data [String] a file path or raw JPEG bytes
71
+ # @return [Info] image metadata parsed from the frame header
72
+ def self.info(path_or_data)
73
+ data = if path_or_data.is_a?(String) && !path_or_data.start_with?("\xFF\xD8".b) && File.exist?(path_or_data)
74
+ File.binread(path_or_data)
75
+ else
76
+ path_or_data.b
77
+ end
78
+
79
+ jfif = JFIFReader.new(data, stop_after_frame: true)
80
+ raise DecodeError, "JPEG frame header not found" unless jfif.width && jfif.height
81
+
82
+ Info.new(
83
+ width: jfif.width,
84
+ height: jfif.height,
85
+ component_count: jfif.components.length,
86
+ progressive: jfif.progressive,
87
+ icc_profile: jfif.icc_profile
88
+ )
89
+ end
63
90
  end
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper
@@ -57,6 +57,7 @@ files:
57
57
  - lib/pure_jpeg/huffman/encoder.rb
58
58
  - lib/pure_jpeg/huffman/tables.rb
59
59
  - lib/pure_jpeg/image.rb
60
+ - lib/pure_jpeg/info.rb
60
61
  - lib/pure_jpeg/jfif_reader.rb
61
62
  - lib/pure_jpeg/jfif_writer.rb
62
63
  - lib/pure_jpeg/quantization.rb