mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 13:02:25 +00:00
jpeg: arithmetic scans take zeros at a marker without limit
libjpeg's encoder drops trailing zero bytes, so a flat tail of a few thousand blocks decodes from zeros supplied at EOI. Damage is data that runs to the end of the file or a lost restart marker. whitetail-ari.jpg (cjpeg -arithmetic) and its Huffman transcode cover it.
This commit is contained in:
1 parent
35bd2f3891
commit
e96bf9552b
9 files changed
+86
-29
No files matched your search
@@ -57,7 +57,10 @@ if img.damaged { /* warn: part of the image is missing */ }
|
||||
- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had
|
||||
decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of
|
||||
that scan, and every scan past the budget, is left out; those blocks keep what earlier scans
|
||||
decoded. A stream with no decodable image data is an error.
|
||||
decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops
|
||||
early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo
|
||||
treats it the same way), so for arithmetic scans only data that runs to the end of the file
|
||||
and a missing restart marker count as damage.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -105,6 +108,8 @@ SOF11 (lossless) falls in that range and returns `Unsupported`.
|
||||
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
|
||||
the derived files were made: `tests/data/SOURCE.md`):
|
||||
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
|
||||
- `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros
|
||||
supplied at EOI) decodes undamaged and equal to its Huffman transcode.
|
||||
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
|
||||
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
|
||||
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
+18
-17
@@ -595,45 +595,45 @@ const QE: [(u32, u8, u8, bool); 113] = [
|
||||
/// Qe index in bits 0–6 and the MPS in bit 7.
|
||||
struct Arith<'a> {
|
||||
d: &'a [u8],
|
||||
/// `d` ends at a marker rather than at the end of the file.
|
||||
at_marker: bool,
|
||||
pos: usize,
|
||||
c: u32,
|
||||
a: u32,
|
||||
ct: u32,
|
||||
/// Zero bytes fed in since the start or the last restart because the data had ended.
|
||||
pad: u32,
|
||||
/// The decoder needed bytes past the end of the file.
|
||||
eof: bool,
|
||||
/// A restart was due but its marker was missing.
|
||||
lost: bool,
|
||||
}
|
||||
|
||||
impl<'a> Arith<'a> {
|
||||
/// The most zero bytes a scan or restart interval may consume past the end of its data.
|
||||
/// The decoder reads two bytes ahead (INITDEC), and encoders drop trailing zero bytes
|
||||
/// (libjpeg's `finish_pass` does, as D.1.8 allows), so a valid stream consumes a few.
|
||||
/// Past that the data was cut short, and the decoder would only decode the zeros.
|
||||
const MAX_PAD: u32 = 8;
|
||||
|
||||
fn new(d: &'a [u8]) -> Self {
|
||||
let mut ar = Arith { d, pos: 0, c: 0, a: 0, ct: 0, pad: 0, lost: false };
|
||||
fn new(d: &'a [u8], at_marker: bool) -> Self {
|
||||
let mut ar = Arith { d, at_marker, pos: 0, c: 0, a: 0, ct: 0, eof: false, lost: false };
|
||||
ar.init();
|
||||
ar
|
||||
}
|
||||
|
||||
/// At a marker the decoder is fed zeros for as long as it asks (D.2.6): encoders drop
|
||||
/// trailing zero bytes (libjpeg's `finish_pass` does, as D.1.8 allows), and a flat tail of
|
||||
/// a few thousand blocks can decode entirely from those zeros. Running out at the end of
|
||||
/// the file is damage.
|
||||
fn byte_in(&mut self) {
|
||||
let v = next_byte(self.d, &mut self.pos).unwrap_or_else(|| {
|
||||
self.pad = self.pad.saturating_add(1);
|
||||
let at_restart = matches!(self.d.get(self.pos + 1), Some(0xD0..=0xD7));
|
||||
self.eof |= !at_restart && !self.at_marker;
|
||||
0
|
||||
});
|
||||
self.c = self.c.wrapping_add(u32::from(v) << 8);
|
||||
}
|
||||
|
||||
/// The scan's data ran out, or a restart marker is missing.
|
||||
/// The data ended without a marker, or a restart marker is missing.
|
||||
fn exhausted(&self) -> bool {
|
||||
self.lost || self.pad > Self::MAX_PAD
|
||||
self.lost || self.eof
|
||||
}
|
||||
|
||||
/// INITDEC (D.2.7).
|
||||
fn init(&mut self) {
|
||||
self.pad = 0;
|
||||
self.a = 0x10000;
|
||||
self.c = 0;
|
||||
self.byte_in();
|
||||
@@ -1037,7 +1037,8 @@ fn parse_scan(seg: &[u8], frame: &Frame) -> Result<Scan, Error> {
|
||||
}
|
||||
|
||||
/// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`.
|
||||
fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), Error> {
|
||||
/// `at_marker` says whether a marker follows `d` (otherwise it runs to the end of the file).
|
||||
fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], at_marker: bool, done: &mut usize) -> Result<(), Error> {
|
||||
for &(ci, _, _) in &scan.comps {
|
||||
let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?;
|
||||
if comp.quant.is_none() {
|
||||
@@ -1054,7 +1055,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done:
|
||||
(true, _, _) => ArithMode::AcRefine,
|
||||
};
|
||||
let mut dec = ArithScan {
|
||||
ar: Arith::new(d),
|
||||
ar: Arith::new(d, at_marker),
|
||||
mode,
|
||||
ss: scan.ss,
|
||||
se: scan.se,
|
||||
@@ -1154,7 +1155,7 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool
|
||||
let end = scan_end(b, i);
|
||||
let data = b.get(i..end).unwrap_or_default();
|
||||
scans += 1;
|
||||
if let Err(e) = decode_scan(f, &tables, &scan, data, &mut mcus) {
|
||||
if let Err(e) = decode_scan(f, &tables, &scan, data, end < b.len(), &mut mcus) {
|
||||
if mcus == 0 {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -688,8 +688,9 @@ fn huffman_scan_out_of_data_is_not_clean() {
|
||||
assert!(decode(&cut, &Limits::default(), None).unwrap().damaged);
|
||||
}
|
||||
|
||||
/// Arithmetic data cut short before a valid EOI is reported damaged, and the blocks before the
|
||||
/// cut are kept: past the end the decoder is fed zeros, and a restart marker goes missing.
|
||||
/// Arithmetic data that runs to the end of the file, or loses a restart marker, is reported
|
||||
/// damaged, and the blocks before the cut are kept. Data cut short before EOI with no restart
|
||||
/// interval reads as dropped trailing zeros and can't be detected.
|
||||
#[test]
|
||||
fn arithmetic_scan_out_of_data_is_damaged() {
|
||||
let blocks = coefficients(64, 11, 500);
|
||||
@@ -698,16 +699,29 @@ fn arithmetic_scan_out_of_data_is_damaged() {
|
||||
assert_eq!(decoded_blocks(&full), blocks, "ri={ri}");
|
||||
let data = last_scan_data(&full);
|
||||
let end = full.len() - 2;
|
||||
for cut in [data + (end - data) / 2, data] {
|
||||
for cut in [data + (end - data) / 2, data + 1] {
|
||||
let mut s = full[..cut].to_vec();
|
||||
if s.last() == Some(&0xFF) {
|
||||
s.pop();
|
||||
}
|
||||
s.extend([0xFF, 0xD9]);
|
||||
let (frame, _, damaged) = decode_coefficients(&s, &Limits::default()).unwrap();
|
||||
assert!(damaged, "ri={ri} cut at {}", cut - data);
|
||||
if cut > data {
|
||||
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri}");
|
||||
for eoi in [false, true] {
|
||||
if eoi && ri == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut s = s.clone();
|
||||
if eoi {
|
||||
s.extend([0xFF, 0xD9]);
|
||||
}
|
||||
// With one byte of data the first MCU fails, which is an error, not damage.
|
||||
match decode_coefficients(&s, &Limits::default()) {
|
||||
Ok((frame, _, damaged)) => {
|
||||
assert!(damaged, "ri={ri} eoi={eoi} cut at {}", cut - data);
|
||||
if cut > data + 1 {
|
||||
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri} eoi={eoi}");
|
||||
}
|
||||
}
|
||||
Err(_) => assert_eq!(cut, data + 1, "ri={ri} eoi={eoi}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,18 @@ fn fixture(name: &str) -> Vec<u8> {
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn decode_fixture(name: &str) -> Decoded {
|
||||
fn decode_clean(name: &str) -> Decoded {
|
||||
let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
|
||||
assert!(!img.damaged, "{name}");
|
||||
img
|
||||
}
|
||||
|
||||
fn decode_fixture(name: &str) -> Decoded {
|
||||
let img = decode_clean(name);
|
||||
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
|
||||
img
|
||||
}
|
||||
|
||||
/// Mean and maximum absolute difference.
|
||||
fn diff(a: impl Iterator<Item = f64>, b: impl Iterator<Item = f64>) -> (f64, f64) {
|
||||
let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize);
|
||||
@@ -36,6 +41,16 @@ fn arithmetic_coded_image_matches_its_huffman_transcode() {
|
||||
assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg");
|
||||
}
|
||||
|
||||
/// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower
|
||||
/// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode
|
||||
/// whole and match its Huffman transcode.
|
||||
#[test]
|
||||
fn arithmetic_white_tail_decodes_from_implied_zeros() {
|
||||
let arithmetic = decode_clean("whitetail-ari.jpg");
|
||||
assert_eq!((arithmetic.width, arithmetic.height, arithmetic.components), (512, 512, 1));
|
||||
assert!(decode_clean("whitetail-huffman.jpg").samples == arithmetic.samples, "whitetail-ari.jpg decodes differently from whitetail-huffman.jpg");
|
||||
}
|
||||
|
||||
/// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is
|
||||
/// float against integer IDCT and color conversion.
|
||||
#[test]
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg
|
||||
34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg
|
||||
62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm
|
||||
0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg
|
||||
0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
|
||||
(https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License
|
||||
(README.ijg in this directory). The other two files are derived from them as described below.
|
||||
(README.ijg in this directory). The other files are derived from them, or made with
|
||||
libjpeg-turbo's tools, as described below.
|
||||
`SHA256SUMS` lists every file.
|
||||
|
||||
| File | What it is |
|
||||
@@ -12,6 +13,8 @@ The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
|
||||
| `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
|
||||
| `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) |
|
||||
| `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 |
|
||||
| `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below |
|
||||
| `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
|
||||
|
||||
`testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients:
|
||||
libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the
|
||||
@@ -43,4 +46,21 @@ with open(dst, "wb") as f:
|
||||
|
||||
`djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file.
|
||||
|
||||
`whitetail-ari.jpg` and `whitetail-huffman.jpg`, with cjpeg and jpegtran from libjpeg-turbo 3.1.3
|
||||
(Alpine 3.24, `libjpeg-turbo-utils`). libjpeg's arithmetic encoder drops the trailing zero bytes
|
||||
of a scan, so the white rows decode almost entirely from zeros supplied at the EOI marker.
|
||||
`python3 whitetail.py whitetail.pgm` writes the source image:
|
||||
|
||||
```python
|
||||
import sys
|
||||
w, h, top = 512, 512, 16
|
||||
px = bytes(((x * 3 + y * 5) ^ (x * y)) & 0xFF if y < top else 255 for y in range(h) for x in range(w))
|
||||
open(sys.argv[1], "wb").write(b"P5\n%d %d\n255\n" % (w, h) + px)
|
||||
```
|
||||
|
||||
```sh
|
||||
cjpeg -arithmetic -quality 75 whitetail.pgm > whitetail-ari.jpg
|
||||
jpegtran -copy none whitetail-ari.jpg > whitetail-huffman.jpg
|
||||
```
|
||||
|
||||
"This software is based in part on the work of the Independent JPEG Group."
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Reference in new issue
Block a user