Skip to content

fix(jpeg2000): decode subsampled HTJ2K components correctly - #5643

Merged
lgritz merged 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-j2k-subsampling
Nov 10, 2026
Merged

lgritz merged 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-j2k-subsampling

Conversation

@lgritz

@lgritz lgritz commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

The OpenJPH HTJ2K path assumed every component is full resolution, pulling one full-width line per component for each image row. Subsampled components put it out of step with the decoder, and OpenJPH spins indefinitely in codestream::pull(). Found by the fuzzer.

Each component is now pulled at its own reconstructed size, and each sample is replicated over the full-resolution pixels it covers (the same nearest-sample mapping as the OpenJPEG path, but honoring the image offset). The image size comes from the image extent rather than from component 0, which is smaller when subsampled. OpenJPH's pull order is kept: color-transformed files are decoded interleaved, which requires every component to have the same subsampling, so unequal ones are rejected; other files are pulled component by component. Every pulled line is checked for the expected component and width.

A component that is empty despite a nonempty image (offset and extent rounding to the same sample) made pull() spin and is rejected from the header. A tile-component with no columns, from tiles narrower than the component's horizontal subsampling, crashes OpenJPH before 0.27.0, which added a zero-width guard to tile::pull; such files are rejected only when built against an older OpenJPH. Tile rows with no samples are skipped by every version. Keep in mind that our security policy basically doesn't recognize vulnerabilities that are only problems with old versions of dependencies.

The valid subsampled test fixtures come from a committed generator (make_subsampled_htj2k.cpp) that codes a known pattern losslessly, so the expected pixels can be checked independently of the reader; the empty-component fixture is patched by make_malformed_htj2k.py. They cover 4:2:0 at odd sizes, 10-bit, tiles with image and tile offsets, non-power-of-2 factors, a subsampled first component, color transforms with three and five components, four components with their own alpha subsampling, and tiles holding no rows or no columns of a component. The version-dependent outcomes have their own output file and refs.

Assisted-by: Claude Code / Claude Opus 5.5

@lgritz
lgritz force-pushed the lg-j2k-subsampling branch 2 times, most recently from 6dbae55 to b0b387c Compare November 6, 2026 06:04
@lgritz
lgritz requested a balanced review from Copilot November 7, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The decoder can corrupt color-transformed images and hang on zero-height components, while offset and extent calculations lack targeted regression coverage.

3 open issues
What changed in this PR

This PR updates OpenImageIO’s OpenJPH decoder to handle subsampled HTJ2K components without hanging and reconstruct them at full image resolution.

Changes:

  • Decode subsampled components in planar order and expand samples using offset-aware mapping.
  • Derive image dimensions from the image extent rather than component 0.
  • Add subsampling regression cases and document the decoder behavior.
File Description
testsuite/​htj2k/​run.py Adds valid subsampling cases and a corrupt-file hang regression.
testsuite/​htj2k/​ref/​out.txt Records expected dimensions, pixel hashes, and completion output.
src/​jpeg2000.imageio/​jpeg2000input.cpp Implements per-component decoding, sample expansion, and canvas sizing.
.agents/​skills/​oiio-input-security-audit/​references/​format-notes.md Documents the subsampling fix and OpenJPEG fallback limitations.
.agents/​skills/​oiio-input-security-audit/​references/​bug-catalog.md Records the component-size mismatch and its decoding consequences.

Review effort: Balanced

Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved
Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved
Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved
@lgritz
lgritz force-pushed the lg-j2k-subsampling branch from b0b387c to 7a70cbf Compare November 7, 2026 13:35
@lgritz

lgritz commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the copilot comments and pushed an amentment.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The column-map allocation can fail outside the scanline error handler, and the regression test can pass even when the decoder crashes.

2 open issues
3 resolved since last review

Review effort: Balanced

Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved
Comment thread testsuite/htj2k/run.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The tile-layout check incorrectly rejects valid nonempty layouts with more than two tiles when the tile size is smaller than the subsampling factor.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The regressions lack coverage for MCT files with five or more components, and the corrupt-fixture test can mask decoder crashes by hiding the exit status and forcing success.

2 open issues
1 resolved since last review

Review effort: Balanced

Comment thread src/jpeg2000.imageio/jpeg2000input.cpp Resolved
Comment thread testsuite/htj2k/run.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the subsampled HTJ2K decoding fix.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@lgritz
lgritz force-pushed the lg-j2k-subsampling branch from 88ef76e to 3a709f5 Compare November 10, 2026 07:47
The OpenJPH HTJ2K path assumed every component is full resolution,
pulling one full-width line per component for each image row.
Subsampled components put it out of step with the decoder, and OpenJPH
spins indefinitely in codestream::pull(). Found by the fuzzer.

Each component is now pulled at its own reconstructed size, and each
sample is replicated over the full-resolution pixels it covers (the
same nearest-sample mapping as the OpenJPEG path, but honoring the
image offset). The image size comes from the image extent rather than
from component 0, which is smaller when subsampled. OpenJPH's pull
order is kept: color-transformed files are decoded interleaved, which
requires every component to have the same subsampling, so unequal ones
are rejected; other files are pulled component by component. Every
pulled line is checked for the expected component and width.

A component that is empty despite a nonempty image (offset and extent
rounding to the same sample) made pull() spin and is rejected from the
header. A tile-component with no columns, from tiles narrower than the
component's horizontal subsampling, crashes OpenJPH before 0.27.0,
which added a zero-width guard to tile::pull; such files are rejected
only when built against an older OpenJPH. Tile rows with no samples
are skipped by every version. Keep in mind that our security policy
basically doesn't recognize vulnerabilities that are only problems
with old versions of dependencies.

The valid subsampled test fixtures come from a committed generator
(make_subsampled_htj2k.cpp) that codes a known pattern losslessly, so
the expected pixels can be checked independently of the reader; the
empty-component fixture is patched by make_malformed_htj2k.py. They
cover 4:2:0 at odd sizes, 10-bit, tiles with image and tile offsets,
non-power-of-2 factors, a subsampled first component, color transforms
with three and five components, four components with their own alpha
subsampling, and tiles holding no rows or no columns of a component.
The version-dependent outcomes have their own output file and refs.

Assisted-by: Claude Code / Claude Opus 5.5

Signed-off-by: Larry Gritz <lg@larrygritz.com>
@lgritz
lgritz force-pushed the lg-j2k-subsampling branch from 3a709f5 to 2f19626 Compare November 10, 2026 08:23
@lgritz

lgritz commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Any final objections on this?

@lgritz

lgritz commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

No objections, cleared multiple rounds of tedious robot reviews. Merging.

@lgritz
lgritz merged commit 59c2d55 into AcademySoftwareFoundation:main Nov 10, 2026
30 checks passed
@lgritz
lgritz deleted the lg-j2k-subsampling branch November 10, 2026 23:33
lgritz added a commit to lgritz/OpenImageIO that referenced this pull request Nov 11, 2026
…oftwareFoundation#5643)

The OpenJPH HTJ2K path assumed every component is full resolution,
pulling one full-width line per component for each image row. Subsampled
components put it out of step with the decoder, and OpenJPH spins
indefinitely in codestream::pull(). Found by the fuzzer.

Each component is now pulled at its own reconstructed size, and each
sample is replicated over the full-resolution pixels it covers (the same
nearest-sample mapping as the OpenJPEG path, but honoring the image
offset). The image size comes from the image extent rather than from
component 0, which is smaller when subsampled. OpenJPH's pull order is
kept: color-transformed files are decoded interleaved, which requires
every component to have the same subsampling, so unequal ones are
rejected; other files are pulled component by component. Every pulled
line is checked for the expected component and width.

A component that is empty despite a nonempty image (offset and extent
rounding to the same sample) made pull() spin and is rejected from the
header. A tile-component with no columns, from tiles narrower than the
component's horizontal subsampling, crashes OpenJPH before 0.27.0, which
added a zero-width guard to tile::pull; such files are rejected only
when built against an older OpenJPH. Tile rows with no samples are
skipped by every version. Keep in mind that our security policy
basically doesn't recognize vulnerabilities that are only problems with
old versions of dependencies.

The valid subsampled test fixtures come from a committed generator
(make_subsampled_htj2k.cpp) that codes a known pattern losslessly, so
the expected pixels can be checked independently of the reader; the
empty-component fixture is patched by make_malformed_htj2k.py. They
cover 4:2:0 at odd sizes, 10-bit, tiles with image and tile offsets,
non-power-of-2 factors, a subsampled first component, color transforms
with three and five components, four components with their own alpha
subsampling, and tiles holding no rows or no columns of a component. The
version-dependent outcomes have their own output file and refs.

Assisted-by: Claude Code / Claude Opus 5.5

Signed-off-by: Larry Gritz <lg@larrygritz.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants