1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
//! Detection of prior JPEG compression via stochastic block sampling.
//!
//! A PNG that was produced by decoding a JPEG and re-encoding it losslessly is
//! not a clean container. The quantised DCT coefficients leave a footprint in
//! the pixel domain, and that footprint is one of the first things a steganalyst
//! looks for: any residual the embedder adds on top of it stands out against a
//! signal whose statistics are already known.
//!
//! # What is measured
//!
//! JPEG encodes the image as independent 8x8 blocks. Each block is quantised on
//! its own, so the reconstruction error does not agree across a block edge and
//! the decoded image carries a step at every eighth column and every eighth row
//! that the original scene never had. Inside a block there is no such step: the
//! inverse DCT is smooth there by construction.
//!
//! The detector compares those two populations of adjacent-pixel differences:
//!
//! ```text
//! ratio = mean |p(x, y) - p(x + 1, y)| over pairs straddling the 8x8 grid
//! ────────────────────────────────────────────────────────────────
//! mean |p(x, y) - p(x + 1, y)| over pairs strictly inside a block
//! ```
//!
//! An image that has never been through a lossy codec has no privileged
//! position: a pair on the grid line is statistically the same as a pair one
//! column over, and the ratio sits at about `1.0`. An image carrying JPEG
//! artifacts has a numerator inflated by the block edges, and the ratio climbs
//! well above it — measured at `1.77` for quality 90 and `10.4` for quality 50.
//!
//! The grid phase matters, and it is the whole signal: the measurement is only
//! meaningful because both sides know where the block edges are. That is also
//! why the detector cannot be fooled by a rescaled JPEG, and why it does not
//! claim to catch one.
//!
//! # Limitations
//!
//! The measurement is a ratio, so its sensitivity falls as the image's own
//! high-frequency energy rises: the codec's block edges have to be visible
//! against whatever the content already puts between neighbouring pixels. Two
//! cases were measured and do not trip the threshold.
//!
//! - Grain-dominated images at high quality. A container that is mostly noise
//! of amplitude 30 scores `0.98` after a quality-90 round trip and only
//! reaches `1.91` at quality 25. Quantisation that gentle barely moves a
//! signal that random, so there is little to detect.
//! - Content built from very high-contrast axis-aligned edges. Steps of 180
//! levels from the scene itself enter both populations and swamp a block edge
//! worth a handful of levels.
//!
//! Both are the same limitation seen twice, and neither is a false *positive*:
//! the detector stays silent rather than rejecting a clean image. What it means
//! is that the gate is a filter and not a proof — it catches the laundered
//! JPEGs a user is likely to reach for by accident, not every one that exists.
//!
//! # Why the blocks are sampled and not scanned
//!
//! The verdict is a ratio of two means, and a mean converges long before the
//! whole image has been visited. Five per cent of the blocks — a few thousand
//! for a typical container — puts the standard error of the estimate far below
//! the distance between the two populations, at a fraction of the cost of a
//! full scan. The sample is drawn from a seed derived from the image itself, so
//! it is not a source of nondeterminism: the same image always yields the same
//! blocks and the same verdict.
use StdRng;
use index;
use SeedableRng;
use *;
use ;
use crateColorSpace;
use crateluminance;
/// Side length of the DCT grid JPEG quantises over, in pixels.
const BLOCK_SIZE: usize = 8;
/// Number of leading sample bytes hashed into the sampling seed.
const SEED_SAMPLE_LEN: usize = 1024;
/// Percentage of the blocks in the image that gets analysed.
const SAMPLE_PERCENT: usize = 5;
/// Lower bound on the number of sampled blocks.
const MIN_SAMPLES: usize = 50;
/// Upper bound on the number of sampled blocks.
const MAX_SAMPLES: usize = 10_000;
/// Ratio above which an image is reported as carrying JPEG artifacts.
///
/// A never-compressed image scores about `1.0`; the lowest JPEG quality that
/// still counts as a plausible container, 90, already scores `1.77`, and the
/// score grows from there as quality drops. The threshold sits deliberately
/// close to the clean end of a very wide gap, because the two failure modes are
/// not symmetric in cost — but not at `1.0` either: an image whose content
/// happens to carry strong axis-aligned edges can raise the numerator on its
/// own, without any codec involved, and `1.3` leaves room for that.
///
/// A false positive costs the user a container they were entitled to use, and
/// they can simply pick another one. A false negative hands a steganalyst an
/// image whose statistics they already know how to model.
const ARTIFACT_THRESHOLD: f32 = 1.3;
/// Guard against dividing by an interior energy of zero.
///
/// Deliberately the only protection against a flat denominator, and no minimum
/// interior activity is required on top of it. Heavy quantisation is exactly
/// what flattens the inside of a block while leaving the step at its edge
/// intact, so a sample with no interior texture and measurable boundaries is
/// not a sample that failed to produce evidence — it is the strongest evidence
/// of blocking there is, and the ratio it yields should be large. An image that
/// is flat everywhere, boundaries included, still scores zero and is accepted.
const DIVISION_EPSILON: f32 = 1e-6;
/// Absolute differences between neighbouring pixels, split by whether the pair
/// straddles a block boundary of the 8x8 grid.
///
/// Sums and counts are kept apart rather than pre-averaged so that the totals
/// of many blocks can be pooled into a single ratio. Pooling is what makes the
/// estimate robust: a per-block ratio would divide by the interior energy of
/// one 8x8 patch, which is zero on any flat patch and tiny on many, and the
/// mean of those ratios would be dominated by whichever block happened to be
/// smoothest rather than by the evidence.
/// Derives the block-sampling seed from the head of the sample buffer.
///
/// Hashing the image rather than drawing from the system entropy pool is what
/// makes the detector auditable: a rejected container can be re-tested and will
/// fail on exactly the same blocks. The leading bytes are enough because the
/// seed only has to vary between images, not to resist an adversary — nothing
/// downstream is protected by it.
/// Converts the whole image to a row-major plane of BT.601 luma values.
///
/// Parallelised over pixels because this is the only part of the detector whose
/// cost scales with the image rather than with the sample: the block analysis
/// touches at most [`MAX_SAMPLES`] blocks, this touches every pixel.
/// Collecting an indexed parallel iterator preserves order, so the resulting plane
/// is laid out exactly as the sequential version would lay it out.
///
/// Shared with [`crate::cost::hill`], which runs its convolutions over the same
/// plane. Like [`luminance`] itself, it exists once so that the analyses of the
/// image cannot disagree about what the brightness of a pixel is.
pub
/// Splits the adjacent-pixel steps of one 8x8 block into the two populations.
///
/// Each row of the block contributes seven interior steps and one step across
/// the vertical grid line at the right edge; each column contributes the same
/// down to the horizontal grid line at the bottom edge. Both directions are
/// measured because the JPEG grid is two-dimensional, and an image whose
/// texture runs in columns would hide its horizontal edges from a
/// one-directional scan.
///
/// The pair that straddles the right edge reaches into the neighbouring block
/// by one pixel. On a block sitting against the right or bottom border of the
/// image that pixel does not exist, and the step is dropped: the counts are
/// returned alongside the sums precisely so that a truncated block does not
/// weigh as much as a complete one.
/// Estimates how strongly an image shows traces of a previous JPEG encoding.
///
/// Returns `Some(ratio)` when blocking artifacts are detected, where `ratio` is
/// the mean absolute difference between neighbouring pixels that straddle the
/// 8x8 grid, divided by the same mean taken strictly inside blocks, pooled over
/// the sampled blocks. Returns `None` when the image looks like it has never
/// been through a lossy codec, which is the case whenever that ratio stays at
/// or below the rejection threshold of `1.3`.
///
/// A value close to `1.0` means the grid lines are no sharper than the pixels
/// around them and the image carries no 8x8 block structure; the larger the
/// returned value, the stronger the structure.
///
/// `pixels` must hold `width * height * color_space.bytes_per_pixel()` bytes in
/// row-major order. `None` is returned for a buffer shorter than that and for
/// an image with no complete 8x8 block: in both cases there is nothing to
/// measure, and reporting a clean image is the answer that keeps the caller
/// from acting on a number that was never computed.