Skip to content

Commit 03bb80c

Browse files
wayfarer3130claude
andcommitted
fix: decode the last sample of a scan that ends on a byte boundary
An entropy coded segment whose final Huffman code lands exactly on a byte boundary needs no padding bits (T.81 B.1.1.2 pads only an incomplete final byte), so EOI follows the last code immediately. `getHuffmanValue` and `getn` treated that as having read into the marker and abandoned the scan one sample early, leaving the frame's last sample 0. `temp` holds `index` unconsumed bits. Once the 0xFF that introduces a marker has been shifted in, its 8 bits are not data, so `index - 8` real bits are left and consuming the last of them leaves `index === 8` - still a valid decode. The guards tested `index < markerIndex` (9), which rejects it. `readPastEntropyData` puts the boundary at `index < 8` and names what it is testing; `markerIndex` keeps its 9 purely as the "marker seen" sentinel that the other call sites already treat it as. This replaces the `!isLastPixel()` special case in `getn`, which was covering the same off-by-one for one of the three guard sites. Reproduced with the single fragment of CTImage.dcm_JPEGProcess14SV1TransferSyntax_1.2.840.10008.1.2.4.70.dcm, written by DCMTK 3.6.1: 512x512 16 bit signed, ending in a long run of the image minimum whose two-bit zero-difference codes tile the final byte exactly. Its last sample decoded as 0 instead of -2000; every other sample was already correct. Added as tests/data/jpeg_lossless_sel1-byte-aligned-end.jpg, checked against the crc32 of the uncompressed CTImage.dcm pixel data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ea0eaa0 commit 03bb80c

3 files changed

Lines changed: 98 additions & 10 deletions

File tree

‎src/decoder.ts‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export class Decoder {
3131
static RESTART_MARKER_BEGIN = 0xffd0
3232
static RESTART_MARKER_END = 0xffd7
3333

34+
// Value `markerIndex` takes once the 0xFF that introduces a marker has been
35+
// shifted into `temp`. Only `!== 0` is ever tested - see `readPastEntropyData`
36+
// for what the bit count itself means.
37+
static MARKER_SEEN = 9
38+
39+
// How many bits of `temp` that 0xFF occupies. Bits below it are the marker,
40+
// not entropy coded data.
41+
static MARKER_BITS = 8
42+
3443
buffer: ArrayBuffer | null = null
3544
stream: DataStream | null = null
3645
frame = new FrameHeader()
@@ -505,7 +514,7 @@ export class Decoder {
505514
if (input === 0xff) {
506515
this.marker = this.stream.get8()
507516
if (this.marker !== 0) {
508-
this.markerIndex = 9
517+
this.markerIndex = Decoder.MARKER_SEEN
509518
}
510519
}
511520
temp[0] |= input
@@ -528,7 +537,7 @@ export class Decoder {
528537
if (input === 0xff) {
529538
this.marker = this.stream.get8()
530539
if (this.marker !== 0) {
531-
this.markerIndex = 9
540+
this.markerIndex = Decoder.MARKER_SEEN
532541
}
533542
}
534543

@@ -543,7 +552,7 @@ export class Decoder {
543552
throw new Error('index=' + index[0] + ' temp=' + temp[0] + ' code=' + code + ' in HuffmanValue()')
544553
}
545554

546-
if (index[0] < this.markerIndex) {
555+
if (this.readPastEntropyData(index)) {
547556
this.markerIndex = 0
548557
return 0xff00 | this.marker
549558
}
@@ -575,8 +584,7 @@ export class Decoder {
575584
index[0] -= n
576585

577586
if (index[0] >= 0) {
578-
if (index[0] < this.markerIndex && !this.isLastPixel()) {
579-
// this was corrupting the last pixel in some cases
587+
if (this.readPastEntropyData(index)) {
580588
this.markerIndex = 0
581589
return (0xff00 | this.marker) << 8
582590
}
@@ -590,7 +598,7 @@ export class Decoder {
590598
if (input === 0xff) {
591599
this.marker = this.stream.get8()
592600
if (this.marker !== 0) {
593-
this.markerIndex = 9
601+
this.markerIndex = Decoder.MARKER_SEEN
594602
}
595603
}
596604

@@ -609,7 +617,7 @@ export class Decoder {
609617
if (input === 0xff) {
610618
this.marker = this.stream.get8()
611619
if (this.marker !== 0) {
612-
this.markerIndex = 9
620+
this.markerIndex = Decoder.MARKER_SEEN
613621
}
614622
}
615623

@@ -621,7 +629,7 @@ export class Decoder {
621629
throw new Error('index=' + index[0] + ' in getn()')
622630
}
623631

624-
if (index[0] < this.markerIndex) {
632+
if (this.readPastEntropyData(index)) {
625633
this.markerIndex = 0
626634
return (0xff00 | this.marker) << 8
627635
}
@@ -669,8 +677,26 @@ export class Decoder {
669677
}
670678
}
671679

672-
isLastPixel() {
673-
return this.xLoc === this.xDim - 1 && this.yLoc === this.yDim - 1
680+
/**
681+
* True when the bits just consumed came out of a marker rather than out of
682+
* the entropy coded segment.
683+
*
684+
* `temp` holds `index` unconsumed bits (see the `getHuffmanValue` notes). The
685+
* moment `getHuffmanValue`/`getn` shifts in a byte that turns out to be the
686+
* 0xFF introducing a marker, those 8 bits stop being data: only the
687+
* `index - MARKER_BITS` bits above them are real, and the scan is over once
688+
* they run out.
689+
*
690+
* Consuming the last real bit leaves `index === MARKER_BITS`, and that is
691+
* still a valid decode - it is a scan whose final Huffman code ends exactly
692+
* on a byte boundary, so the encoder had no partial byte left to pad (T.81
693+
* B.1.1.2 pads only an incomplete final byte). Testing `index < markerIndex`
694+
* rejected that case and dropped the frame's last sample, which is what
695+
* DCMTK produces when a frame ends in a long run of one value: the run's
696+
* short codes tile the final byte exactly.
697+
*/
698+
readPastEntropyData(index: number[]): boolean {
699+
return this.markerIndex !== 0 && index[0] < Decoder.MARKER_BITS
674700
}
675701

676702
outputSingle(PRED: number[]) {

‎tests/byte-aligned-end.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import fs from 'fs'
2+
import { describe, it, assert } from 'vitest'
3+
import { Utils, Decoder } from '../src/main.js'
4+
import { toArrayBuffer } from './utils.js'
5+
6+
/**
7+
* A scan whose entropy coded segment ends exactly on a byte boundary.
8+
*
9+
* The frame is the single encapsulated fragment of
10+
* `CTImage.dcm_JPEGProcess14SV1TransferSyntax_1.2.840.10008.1.2.4.70.dcm`
11+
* (cornerstone3D `packages/dicomImageLoader/testImages`): 512x512, 16 bit
12+
* signed, one component, process 14 selection value 1, point transform 0,
13+
* written by DCMTK 3.6.1.
14+
*
15+
* It ends in a long run of the image minimum (-2000, air). A run of equal
16+
* samples is a run of zero differences, and this table codes a zero difference
17+
* in two bits, so the run tiles the last byte exactly and the encoder had no
18+
* partial byte left to pad - EOI follows the final code with no padding bits in
19+
* between. The decoder used to treat that as having read into the marker and
20+
* dropped the last sample, leaving it 0 instead of -2000. Every other sample
21+
* was already correct, so the assertion that matters is the last one; the
22+
* checksum guards the rest of the frame against a regression that shifts it.
23+
*/
24+
const buf = fs.readFileSync('./tests/data/jpeg_lossless_sel1-byte-aligned-end.jpg')
25+
const decoder = new Decoder()
26+
const output = decoder.decode(toArrayBuffer(buf), 0, buf.length)
27+
const samples = new Int16Array(output.buffer, output.byteOffset, output.byteLength / 2)
28+
29+
describe('byte-aligned-end', function () {
30+
it('dimX should equal 512', function () {
31+
assert.equal(512, decoder.frame.dimX)
32+
})
33+
34+
it('dimY should equal 512', function () {
35+
assert.equal(512, decoder.frame.dimY)
36+
})
37+
38+
it('number of components should be 1', function () {
39+
assert.equal(1, decoder.frame.numComp)
40+
})
41+
42+
it('point transform should be 0', function () {
43+
// So this frame is not confused with the point transform handling that is
44+
// its own fix - the last sample is wrong here with Al 0.
45+
assert.equal(0, decoder.scan.al)
46+
})
47+
48+
it('decompressed size should be 524288', function () {
49+
assert.equal(524288, output.byteLength)
50+
})
51+
52+
it('the last sample should be decoded, not left at 0', function () {
53+
assert.equal(-2000, samples[samples.length - 1])
54+
})
55+
56+
it('data checksum should equal 2510355201', function () {
57+
// crc32 of the pixel data of the uncompressed CTImage.dcm this was encoded
58+
// from, so the whole frame is checked against ground truth, not against
59+
// whatever this decoder happens to produce.
60+
assert.equal(Utils.crc32(output.buffer), 2510355201)
61+
})
62+
})
187 KB
Loading

0 commit comments

Comments
 (0)