pure_jpeg 0.2.0 → 0.3.1
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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +24 -9
- data/lib/pure_jpeg/decoder.rb +55 -19
- data/lib/pure_jpeg/encoder.rb +168 -58
- data/lib/pure_jpeg/huffman/encoder.rb +73 -45
- data/lib/pure_jpeg/huffman/tables.rb +91 -0
- data/lib/pure_jpeg/image.rb +6 -1
- data/lib/pure_jpeg/info.rb +6 -0
- data/lib/pure_jpeg/jfif_reader.rb +32 -3
- data/lib/pure_jpeg/source/raw_source.rb +1 -2
- data/lib/pure_jpeg/version.rb +1 -1
- data/lib/pure_jpeg.rb +27 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a0015f811a2250264bfa73727aa37af15fee1af10e4897243045f2f4f54ae07
|
|
4
|
+
data.tar.gz: 5085ab8c4bd1d9941c94e116b39ea7ca38f658fdf0e48523cae65fc913f765d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b507962b2ec9650e743b365b8ace5ddb2a9c1d04de126206ac6062c9da46001dc3e8a1f04df356187b3a2458f3383625d864fe12ee0b3c5e3df589755aa540aa
|
|
7
|
+
data.tar.gz: 7bf019ea4702bbd7379ad3a1d295acaadd47b185815461b810c08c33299248235b326e9d0cdcfbebe23646f32014487c55212517a9e8a246d4fd5d40891eb62f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
Fixes:
|
|
6
|
+
|
|
7
|
+
- Fixed shared `Pixel` instance bug in decoder that could corrupt pixel data
|
|
8
|
+
- Encoder validates return values from `quantization_modifier` blocks
|
|
9
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
New features:
|
|
13
|
+
|
|
14
|
+
- `PureJPEG.info` for reading dimensions and metadata without full decode
|
|
15
|
+
- ICC color profile extraction (available on `Info` and `Image`)
|
|
16
|
+
- Optional image-specific optimized Huffman tables (`optimize_huffman: true`)
|
|
17
|
+
|
|
18
|
+
Fixes:
|
|
19
|
+
|
|
20
|
+
- Decoder validates Huffman table, quantization table, and component references with clear error messages
|
|
21
|
+
- Color decoding looks up Y/Cb/Cr components by ID instead of assuming SOF array order
|
|
22
|
+
- Support for non-standard component IDs (e.g. 0, 1, 2 as used by some Adobe tools)
|
|
23
|
+
- Explicit error for unsupported component counts (e.g. CMYK)
|
|
24
|
+
- Encoder no longer holds file handle open during encoding
|
|
25
|
+
|
|
3
26
|
## 0.2.0
|
|
4
27
|
|
|
5
28
|
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
|
|
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,14 +194,17 @@ 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
|
-
On a 1024x1024 image (Ruby 4.0.1 on my
|
|
201
|
+
On a 1024x1024 image (Ruby 4.0.1 on my M5):
|
|
188
202
|
|
|
189
203
|
| Operation | Time |
|
|
190
204
|
|-----------|------|
|
|
191
|
-
| Encode (color, q85) | ~1.
|
|
192
|
-
| Decode (
|
|
205
|
+
| Encode (color, q85) | ~1.2s |
|
|
206
|
+
| Decode (baseline) | ~1.2s |
|
|
207
|
+
| Decode (progressive) | ~1.3s |
|
|
193
208
|
|
|
194
209
|
Both the encoder and decoder use a separable DCT with a precomputed cosine matrix and reuse all per-block buffers to minimize GC pressure. Pixel data is stored as packed integers internally to avoid per-pixel object allocation.
|
|
195
210
|
|
|
@@ -198,17 +213,17 @@ Both the encoder and decoder use a separable DCT with a precomputed cosine matri
|
|
|
198
213
|
```
|
|
199
214
|
bundle install
|
|
200
215
|
rake test # run the test suite
|
|
201
|
-
rake benchmark # benchmark encoding (3 runs
|
|
216
|
+
rake benchmark # benchmark encoding and decoding (3 runs each)
|
|
202
217
|
rake profile # CPU profile with StackProf (requires the stackprof gem)
|
|
203
218
|
```
|
|
204
219
|
|
|
205
220
|
## AI Disclosure
|
|
206
221
|
|
|
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 :-)
|
|
222
|
+
**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
223
|
|
|
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.
|
|
224
|
+
**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
225
|
|
|
211
|
-
**CC needed a lot of guidance.** Its initial JPEG algorithm was somewhat naive and output odd looking JPEGs akin to those of my
|
|
226
|
+
**CC needed a lot of guidance.** Its initial JPEG algorithm was somewhat naive and output odd looking JPEGs akin to those of my [Casio QV-10 digital camera](https://medium.com/people-gadgets/the-gadget-we-miss-the-casio-qv-10-digital-camera-c25ab786ce49) from the late 1990s. 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
227
|
|
|
213
228
|
**CC is lazy.** The initial implementation was VERY SLOW. It took 15 seconds to turn a 1024x1024 PNG into a JPEG, so we went down the profiling rabbit hole and found many optimizations to make it ~6x faster. CC is poor at considering the role of Ruby's GC when implementing low level algorithms and needs some prodding to make the correct optimizations. CC is also lazy to the point of recommending that you just use another language (e.g. Go or Rust) rather than do a pure Ruby version of something - despite it being possible with some extra work.
|
|
214
229
|
|
data/lib/pure_jpeg/decoder.rb
CHANGED
|
@@ -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
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
data/lib/pure_jpeg/encoder.rb
CHANGED
|
@@ -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.
|
|
63
|
+
File.binwrite(path, to_bytes)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
# Return the encoded JPEG as a binary string.
|
|
@@ -70,17 +76,27 @@ module PureJPEG
|
|
|
70
76
|
|
|
71
77
|
def build_lum_qtable
|
|
72
78
|
table = @luminance_table || Quantization.scale_table(Quantization::LUMINANCE_BASE, quality)
|
|
73
|
-
table =
|
|
79
|
+
table = apply_quantization_modifier(table, :luminance) if @quantization_modifier
|
|
74
80
|
table
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
def build_chr_qtable
|
|
78
84
|
table = @chrominance_table || Quantization.scale_table(Quantization::CHROMINANCE_BASE, @chroma_quality)
|
|
79
|
-
table =
|
|
85
|
+
table = apply_quantization_modifier(table, :chrominance) if @quantization_modifier
|
|
80
86
|
table
|
|
81
87
|
end
|
|
82
88
|
|
|
89
|
+
def apply_quantization_modifier(table, channel)
|
|
90
|
+
modified = @quantization_modifier.call(table, channel)
|
|
91
|
+
validate_qtable!(modified, "quantization_modifier result for #{channel}")
|
|
92
|
+
modified
|
|
93
|
+
end
|
|
94
|
+
|
|
83
95
|
def validate_qtable!(table, name)
|
|
96
|
+
unless table.respond_to?(:length) && table.respond_to?(:all?)
|
|
97
|
+
raise ArgumentError, "#{name} must be a 64-element array of integers between 1 and 255"
|
|
98
|
+
end
|
|
99
|
+
|
|
84
100
|
raise ArgumentError, "#{name} must have exactly 64 elements (got #{table.length})" unless table.length == 64
|
|
85
101
|
unless table.all? { |v| v.is_a?(Integer) && v >= 1 && v <= 255 }
|
|
86
102
|
raise ArgumentError, "#{name} elements must be integers between 1 and 255"
|
|
@@ -91,61 +107,127 @@ module PureJPEG
|
|
|
91
107
|
width = source.width
|
|
92
108
|
height = source.height
|
|
93
109
|
|
|
110
|
+
raise ArgumentError, "Width must be a positive integer (got #{width.inspect})" unless width.is_a?(Integer) && width > 0
|
|
111
|
+
raise ArgumentError, "Height must be a positive integer (got #{height.inspect})" unless height.is_a?(Integer) && height > 0
|
|
112
|
+
raise ArgumentError, "Width #{width} exceeds maximum of #{MAX_DIMENSION}" if width > MAX_DIMENSION
|
|
113
|
+
raise ArgumentError, "Height #{height} exceeds maximum of #{MAX_DIMENSION}" if height > MAX_DIMENSION
|
|
114
|
+
|
|
94
115
|
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
116
|
|
|
99
117
|
if grayscale
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
y_data = extract_luminance(width, height)
|
|
119
|
+
lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values =
|
|
120
|
+
if optimize_huffman
|
|
121
|
+
counter = collect_grayscale_frequencies(y_data, width, height, lum_qtable)
|
|
122
|
+
dc_bits, dc_values = Huffman.optimize_table(counter.dc_frequencies)
|
|
123
|
+
ac_bits, ac_values = Huffman.optimize_table(counter.ac_frequencies)
|
|
124
|
+
[dc_bits, dc_values, ac_bits, ac_values]
|
|
125
|
+
else
|
|
126
|
+
[Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
|
|
127
|
+
Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
lum_huff = Huffman::Encoder.new(
|
|
131
|
+
Huffman.build_table(lum_dc_bits, lum_dc_values),
|
|
132
|
+
Huffman.build_table(lum_ac_bits, lum_ac_values)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
scan_data = encode_grayscale_data(y_data, width, height, lum_qtable, lum_huff)
|
|
136
|
+
write_grayscale_jfif(io, width, height, lum_qtable, scan_data,
|
|
137
|
+
lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values)
|
|
102
138
|
else
|
|
103
139
|
chr_qtable = build_chr_qtable
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
140
|
+
y_data, cb_data, cr_data = extract_ycbcr(width, height)
|
|
141
|
+
sub_w = (width + 1) / 2
|
|
142
|
+
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
|
+
|
|
146
|
+
lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
|
|
147
|
+
chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values =
|
|
148
|
+
if optimize_huffman
|
|
149
|
+
lum_counter, chr_counter = collect_color_frequencies(
|
|
150
|
+
y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable
|
|
151
|
+
)
|
|
152
|
+
dc_bits, dc_values = Huffman.optimize_table(lum_counter.dc_frequencies)
|
|
153
|
+
ac_bits, ac_values = Huffman.optimize_table(lum_counter.ac_frequencies)
|
|
154
|
+
chr_dc_bits, chr_dc_values = Huffman.optimize_table(chr_counter.dc_frequencies)
|
|
155
|
+
chr_ac_bits, chr_ac_values = Huffman.optimize_table(chr_counter.ac_frequencies)
|
|
156
|
+
[dc_bits, dc_values, ac_bits, ac_values, chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values]
|
|
157
|
+
else
|
|
158
|
+
[Huffman::DC_LUMINANCE_BITS, Huffman::DC_LUMINANCE_VALUES,
|
|
159
|
+
Huffman::AC_LUMINANCE_BITS, Huffman::AC_LUMINANCE_VALUES,
|
|
160
|
+
Huffman::DC_CHROMINANCE_BITS, Huffman::DC_CHROMINANCE_VALUES,
|
|
161
|
+
Huffman::AC_CHROMINANCE_BITS, Huffman::AC_CHROMINANCE_VALUES]
|
|
162
|
+
end
|
|
107
163
|
|
|
108
|
-
|
|
109
|
-
|
|
164
|
+
lum_huff = Huffman::Encoder.new(
|
|
165
|
+
Huffman.build_table(lum_dc_bits, lum_dc_values),
|
|
166
|
+
Huffman.build_table(lum_ac_bits, lum_ac_values)
|
|
167
|
+
)
|
|
168
|
+
chr_huff = Huffman::Encoder.new(
|
|
169
|
+
Huffman.build_table(chr_dc_bits, chr_dc_values),
|
|
170
|
+
Huffman.build_table(chr_ac_bits, chr_ac_values)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
scan_data = encode_color_data(
|
|
174
|
+
y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qtable, chr_qtable, lum_huff, chr_huff
|
|
175
|
+
)
|
|
176
|
+
write_color_jfif(io, width, height, lum_qtable, chr_qtable, scan_data,
|
|
177
|
+
lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
|
|
178
|
+
chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
|
|
110
179
|
end
|
|
111
180
|
end
|
|
112
181
|
|
|
113
182
|
# --- Grayscale encoding ---
|
|
114
183
|
|
|
115
|
-
def
|
|
116
|
-
|
|
184
|
+
def collect_grayscale_frequencies(y_data, width, height, qtable)
|
|
185
|
+
counter = Huffman::FrequencyCounter.new
|
|
186
|
+
each_grayscale_block(y_data, width, height, qtable) do |zbuf|
|
|
187
|
+
counter.observe_block(zbuf, :y)
|
|
188
|
+
end
|
|
189
|
+
counter
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def encode_grayscale_data(y_data, width, height, qtable, huff)
|
|
193
|
+
bit_writer = BitWriter.new
|
|
194
|
+
prev_dc = 0
|
|
195
|
+
|
|
196
|
+
each_grayscale_block(y_data, width, height, qtable) do |zbuf|
|
|
197
|
+
prev_dc = huff.encode_block(zbuf, prev_dc, bit_writer)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
bit_writer.flush
|
|
201
|
+
bit_writer.bytes
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def each_grayscale_block(y_data, width, height, qtable)
|
|
117
205
|
padded_w = (width + 7) & ~7
|
|
118
206
|
padded_h = (height + 7) & ~7
|
|
119
207
|
|
|
120
|
-
# Reusable buffers
|
|
121
208
|
block = Array.new(64, 0.0)
|
|
122
209
|
temp = Array.new(64, 0.0)
|
|
123
210
|
dct = Array.new(64, 0.0)
|
|
124
211
|
qbuf = Array.new(64, 0)
|
|
125
212
|
zbuf = Array.new(64, 0)
|
|
126
213
|
|
|
127
|
-
bit_writer = BitWriter.new
|
|
128
|
-
prev_dc = 0
|
|
129
|
-
|
|
130
214
|
(0...padded_h).step(8) do |by|
|
|
131
215
|
(0...padded_w).step(8) do |bx|
|
|
132
216
|
extract_block_into(y_data, width, height, bx, by, block)
|
|
133
|
-
|
|
217
|
+
transform_block(block, temp, dct, qbuf, zbuf, qtable)
|
|
218
|
+
yield zbuf
|
|
134
219
|
end
|
|
135
220
|
end
|
|
136
|
-
|
|
137
|
-
bit_writer.flush
|
|
138
|
-
bit_writer.bytes
|
|
139
221
|
end
|
|
140
222
|
|
|
141
|
-
def write_grayscale_jfif(io, width, height, qtable, scan_data)
|
|
223
|
+
def write_grayscale_jfif(io, width, height, qtable, scan_data, dc_bits, dc_values, ac_bits, ac_values)
|
|
142
224
|
jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
|
|
143
225
|
jfif.write_soi
|
|
144
226
|
jfif.write_app0
|
|
145
227
|
jfif.write_dqt(qtable, 0)
|
|
146
228
|
jfif.write_sof0(width, height, [[1, 1, 1, 0]])
|
|
147
|
-
jfif.write_dht(0, 0,
|
|
148
|
-
jfif.write_dht(1, 0,
|
|
229
|
+
jfif.write_dht(0, 0, dc_bits, dc_values)
|
|
230
|
+
jfif.write_dht(1, 0, ac_bits, ac_values)
|
|
149
231
|
jfif.write_sos([[1, 0, 0]])
|
|
150
232
|
jfif.write_scan_data(scan_data)
|
|
151
233
|
jfif.write_eoi
|
|
@@ -153,69 +235,97 @@ module PureJPEG
|
|
|
153
235
|
|
|
154
236
|
# --- Color encoding (YCbCr 4:2:0) ---
|
|
155
237
|
|
|
156
|
-
def
|
|
157
|
-
|
|
238
|
+
def collect_color_frequencies(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
|
|
239
|
+
lum_counter = Huffman::FrequencyCounter.new
|
|
240
|
+
chr_counter = Huffman::FrequencyCounter.new
|
|
241
|
+
|
|
242
|
+
each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
|
|
243
|
+
case component
|
|
244
|
+
when :y
|
|
245
|
+
lum_counter.observe_block(zbuf, :y)
|
|
246
|
+
when :cb
|
|
247
|
+
chr_counter.observe_block(zbuf, :cb)
|
|
248
|
+
when :cr
|
|
249
|
+
chr_counter.observe_block(zbuf, :cr)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
158
252
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
253
|
+
[lum_counter, chr_counter]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def encode_color_data(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt, lum_huff, chr_huff)
|
|
257
|
+
bit_writer = BitWriter.new
|
|
258
|
+
prev_dc_y = 0
|
|
259
|
+
prev_dc_cb = 0
|
|
260
|
+
prev_dc_cr = 0
|
|
261
|
+
|
|
262
|
+
each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt) do |component, zbuf|
|
|
263
|
+
case component
|
|
264
|
+
when :y
|
|
265
|
+
prev_dc_y = lum_huff.encode_block(zbuf, prev_dc_y, bit_writer)
|
|
266
|
+
when :cb
|
|
267
|
+
prev_dc_cb = chr_huff.encode_block(zbuf, prev_dc_cb, bit_writer)
|
|
268
|
+
when :cr
|
|
269
|
+
prev_dc_cr = chr_huff.encode_block(zbuf, prev_dc_cr, bit_writer)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
163
272
|
|
|
273
|
+
bit_writer.flush
|
|
274
|
+
bit_writer.bytes
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def each_color_block(y_data, cb_sub, cr_sub, width, height, sub_w, sub_h, lum_qt, chr_qt)
|
|
164
278
|
mcu_w = (width + 15) & ~15
|
|
165
279
|
mcu_h = (height + 15) & ~15
|
|
166
280
|
|
|
167
|
-
# Reusable buffers
|
|
168
281
|
block = Array.new(64, 0.0)
|
|
169
282
|
temp = Array.new(64, 0.0)
|
|
170
283
|
dct = Array.new(64, 0.0)
|
|
171
284
|
qbuf = Array.new(64, 0)
|
|
172
285
|
zbuf = Array.new(64, 0)
|
|
173
286
|
|
|
174
|
-
bit_writer = BitWriter.new
|
|
175
|
-
prev_dc_y = 0
|
|
176
|
-
prev_dc_cb = 0
|
|
177
|
-
prev_dc_cr = 0
|
|
178
|
-
|
|
179
287
|
(0...mcu_h).step(16) do |my|
|
|
180
288
|
(0...mcu_w).step(16) do |mx|
|
|
181
|
-
# 4 luminance blocks
|
|
182
289
|
extract_block_into(y_data, width, height, mx, my, block)
|
|
183
|
-
|
|
290
|
+
transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
|
|
291
|
+
yield :y, zbuf
|
|
184
292
|
|
|
185
293
|
extract_block_into(y_data, width, height, mx + 8, my, block)
|
|
186
|
-
|
|
294
|
+
transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
|
|
295
|
+
yield :y, zbuf
|
|
187
296
|
|
|
188
297
|
extract_block_into(y_data, width, height, mx, my + 8, block)
|
|
189
|
-
|
|
298
|
+
transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
|
|
299
|
+
yield :y, zbuf
|
|
190
300
|
|
|
191
301
|
extract_block_into(y_data, width, height, mx + 8, my + 8, block)
|
|
192
|
-
|
|
302
|
+
transform_block(block, temp, dct, qbuf, zbuf, lum_qt)
|
|
303
|
+
yield :y, zbuf
|
|
193
304
|
|
|
194
|
-
# 1 Cb block
|
|
195
305
|
extract_block_into(cb_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
|
|
196
|
-
|
|
306
|
+
transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
|
|
307
|
+
yield :cb, zbuf
|
|
197
308
|
|
|
198
|
-
# 1 Cr block
|
|
199
309
|
extract_block_into(cr_sub, sub_w, sub_h, mx >> 1, my >> 1, block)
|
|
200
|
-
|
|
310
|
+
transform_block(block, temp, dct, qbuf, zbuf, chr_qt)
|
|
311
|
+
yield :cr, zbuf
|
|
201
312
|
end
|
|
202
313
|
end
|
|
203
|
-
|
|
204
|
-
bit_writer.flush
|
|
205
|
-
bit_writer.bytes
|
|
206
314
|
end
|
|
207
315
|
|
|
208
|
-
def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data
|
|
316
|
+
def write_color_jfif(io, width, height, lum_qt, chr_qt, scan_data,
|
|
317
|
+
lum_dc_bits, lum_dc_values, lum_ac_bits, lum_ac_values,
|
|
318
|
+
chr_dc_bits, chr_dc_values, chr_ac_bits, chr_ac_values)
|
|
209
319
|
jfif = JFIFWriter.new(io, scramble_quantization: @scramble_quantization)
|
|
210
320
|
jfif.write_soi
|
|
211
321
|
jfif.write_app0
|
|
212
322
|
jfif.write_dqt(lum_qt, 0)
|
|
213
323
|
jfif.write_dqt(chr_qt, 1)
|
|
214
324
|
jfif.write_sof0(width, height, [[1, 2, 2, 0], [2, 1, 1, 1], [3, 1, 1, 1]])
|
|
215
|
-
jfif.write_dht(0, 0,
|
|
216
|
-
jfif.write_dht(1, 0,
|
|
217
|
-
jfif.write_dht(0, 1,
|
|
218
|
-
jfif.write_dht(1, 1,
|
|
325
|
+
jfif.write_dht(0, 0, lum_dc_bits, lum_dc_values)
|
|
326
|
+
jfif.write_dht(1, 0, lum_ac_bits, lum_ac_values)
|
|
327
|
+
jfif.write_dht(0, 1, chr_dc_bits, chr_dc_values)
|
|
328
|
+
jfif.write_dht(1, 1, chr_ac_bits, chr_ac_values)
|
|
219
329
|
jfif.write_sos([[1, 0, 0], [2, 1, 1], [3, 1, 1]])
|
|
220
330
|
jfif.write_scan_data(scan_data)
|
|
221
331
|
jfif.write_eoi
|
|
@@ -223,11 +333,11 @@ module PureJPEG
|
|
|
223
333
|
|
|
224
334
|
# --- Shared block pipeline (all buffers pre-allocated) ---
|
|
225
335
|
|
|
226
|
-
def
|
|
336
|
+
def transform_block(block, temp, dct, qbuf, zbuf, qtable)
|
|
227
337
|
DCT.forward!(block, temp, dct)
|
|
228
338
|
Quantization.quantize!(dct, qtable, qbuf)
|
|
229
339
|
Zigzag.reorder!(qbuf, zbuf)
|
|
230
|
-
|
|
340
|
+
zbuf
|
|
231
341
|
end
|
|
232
342
|
|
|
233
343
|
# --- 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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
data/lib/pure_jpeg/image.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
@@ -27,8 +27,7 @@ module PureJPEG
|
|
|
27
27
|
def initialize(width, height, &block)
|
|
28
28
|
@width = width
|
|
29
29
|
@height = height
|
|
30
|
-
|
|
31
|
-
@pixels = Array.new(width * height, black)
|
|
30
|
+
@pixels = Array.new(width * height) { Pixel.new(0, 0, 0) }
|
|
32
31
|
|
|
33
32
|
if block
|
|
34
33
|
height.times do |y|
|
data/lib/pure_jpeg/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.1
|
|
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
|
|
@@ -85,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
86
|
- !ruby/object:Gem::Version
|
|
86
87
|
version: '0'
|
|
87
88
|
requirements: []
|
|
88
|
-
rubygems_version:
|
|
89
|
+
rubygems_version: 4.0.3
|
|
89
90
|
specification_version: 4
|
|
90
91
|
summary: Pure Ruby JPEG encoder and decoder
|
|
91
92
|
test_files: []
|