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