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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +18 -4
- data/lib/pure_jpeg/decoder.rb +55 -19
- data/lib/pure_jpeg/encoder.rb +156 -56
- 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/version.rb +1 -1
- data/lib/pure_jpeg.rb +27 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66b5d6fe1b663128f62aae8111b55a9b2ddbe9739d501fcf1146c459286433da
|
|
4
|
+
data.tar.gz: 02ae6cdc25f520221fee4adfe9d6070c4a975602e5de2b34a0b9ab8b4e005829
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
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,
|
|
148
|
-
jfif.write_dht(1, 0,
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
216
|
-
jfif.write_dht(1, 0,
|
|
217
|
-
jfif.write_dht(0, 1,
|
|
218
|
-
jfif.write_dht(1, 1,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.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
|