diff --git a/README.md b/README.md index 5d197c7..86b8cae 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ A pure Swift 6.2 implementation of JPEG 2000 (ISO/IEC 15444) encoding and decoding with strict concurrency support. -**Current Version**: 10.12.0 -**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.12.0 extends v10.11.0's JP3D batched bridge to the **partial-resolution and ROI lanes** — `JP3DDecoder(cfg).decode` with `cfg.resolutionLevel > 0` and `JP3DROIDecoder().decode(data, region:)` now both run through ONE batched iDWT dispatch instead of N per-slice dispatches. M2 release JP3D wins **−10 ms to −83 ms** on production CT volumes across full / partial-res / ROI lanes (1.07–1.33× faster). Decoder-only; codestream bytes byte-identical to v10.11.0. -**Previous Release**: 10.11.0 (JP3D batched GPU iDWT — full-volume) +**Current Version**: 10.13.0 +**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.13.0 closes the last open cell of the JP3D **{K, ROI}** decode matrix — `JP3DROIDecoder(cfg).decode(data, region:)` with `cfg.resolutionLevel > 0` now returns a downsampled sub-volume (`ceil(roi.w / 2^K) × ceil(roi.h / 2^K) × roi.d`) instead of throwing. v10.12.0 had shipped the K=0+ROI and K>0+nil lanes but left this combination as a known limitation. Decoder-only; codestream bytes byte-identical to v10.12.0. +**Previous Release**: 10.12.0 (JP3D batched bridge — partial-resolution + ROI lanes) **Release process**: see [RELEASING.md](RELEASING.md). Every release MUST update this README (Current Version line + new Release Status paragraph) — see the Release artefacts checklist for the full requirements. ## 📦 Release Status +**v10.13.0** closes the last open cell of the JP3D **{K, ROI}** decode matrix. v10.11.0 shipped the K=0+nil (full-volume) batched bridge; v10.12.0 added K=0+ROI and K>0+nil; v10.13.0 ships K>0+ROI. Both the per-tile throw in `JP3DSliceStackCodec.decode` and the upstream throw in `JP3DROIDecoder.decode` (both fail-loud guards introduced in v10.18 as Phase 5 wiring tripwires) are removed. **`JP3DROIDecoder(cfg).decode(data, region:)`** with `cfg.resolutionLevel > 0` now returns a sub-volume sized `ceil(region.width / 2^K) × ceil(region.height / 2^K) × region.depth`, with voxels bit-identical to `JP3DDecoder(cfg).decode(data)` cropped to the downsampled-mapped region. `JP3DBridgeOptions.regionOfInterest` is standardised on **full-image coordinates** (matching the 2D codec's `decodeRegion` / `decodePartial` convention); the bridge maps the region onto the reduced grid for the crop step via `region / 2^(N − partialResolutionLevel)` (mirrors `decodePartial`'s `J2KAdvancedDecoding.swift:528-543` logic). `JP3DROIDecoder` is now K-aware end-to-end: the output `roiBuffers` is sized for the downsampled output dims when K>0, and the per-tile composite uses downsampled stride for both src (the slice-stack returns downsampled per-slice buffers) and dst (the roiBuffers). Z stays full — JP3D's K is a 2D-only halving; the Z-delta residual chain is per-slice. **`V10_18_TrueSelectiveParityTests.testCombinedResolutionLevelAndROI`** is the new K+ROI parity oracle (was the v10.18 fail-loud tripwire, inverted to a success-branch check); **9/9 PASS**. `V10_21_BatchedBridgeOptionsParityTests` updated to use full-coords ROI for the K=4+ROI composition test — **7/7 PASS**. `V10_20_BatchedBridgeParityTests` 5/5, `V10_20_BatchedInverseInt32ParityTests` 12/12, `V10_20_JP3DBridgeParityTests` 5/5 — all unchanged. Full `swift test --filter JP3D` regression sweep **519/519 PASS**, mandatory commit gate 7/7 PASS. Codestream bytes byte-identical to v10.12.0; encoder unchanged. The full {K, ROI} matrix — (K=0, no ROI), (K=0, ROI), (K>0, no ROI), (K>0, ROI) — is now batched. See [RELEASE_NOTES_v10.13.0.md](RELEASE_NOTES_v10.13.0.md). + **v10.12.0** extends v10.11.0's JP3D batched bridge SPI to the **partial-resolution and ROI lanes** — closes the explicit "Partial-resolution + ROI paths not yet batched" known limitation. A new public `JP3DBridgeOptions(partialResolutionLevel:, regionOfInterest:)` struct + `J2KDecoder._jp3dDecodeToCoefficients(_:options:)` overload thread the options into the bridge SPI; the batched orchestrator truncates the chain to `effectiveLevels = N − K` for partial-res (output dims = `levelSizes[K]`), runs the full chain for ROI and crops the per-slice output via `cropImage`, and composes both (`K>0 + ROI` inside the reduced grid). The eligibility gate now requires uniform options across the batch (which JP3D's per-volume options trivially satisfy). `JP3DSliceStackCodec` removes the per-slice fallback on both branches and now builds a `JP3DBridgeOptions` from `K` + `regionOfInterest`, decodes all slices to coefficients in parallel via the new overload, then runs ONE batched iDWT + finalize. **M2 release, J2KBenchMac --jp3d, in-process, 7 timed runs / 2 warmups, median**: `full` lane ct_3d_large **−82.89 ms** (1.13×), `res1` lane ct_3d_large **−25.82 ms** (1.12×), `ROIq` lane ct_3d_large **−47.53 ms** (1.33×); ct_3d_mid full **−38.85 / res1 −10.44 / ROIq −21.18**. Per acceptance discipline (≥3 ms wins): full 4/6, res1 2/6, ROIq 3/6 fixtures clear; smaller fixtures wash on the lighter lanes (reduced workload leaves less to amortise). K=0 thumbnail (no iDWT chain) falls back to per-slice serial. The only `K > 0 AND ROI` composition still throws (the 2D codec doesn't compose those — separate arc). Opt-out via `J2K_JP3D_BATCHED_BRIDGE=0` (introduced v10.11.0; still works for all three lanes). **`V10_21_BatchedBridgeOptionsParityTests` 7/7 PASS** (K=0/2/4/N, ROI 128² and 64², ROI+K composition), `V10_20_BatchedBridgeParityTests` 5/5 PASS, `V10_20_BatchedInverseInt32ParityTests` 12/12 PASS, `V10_20_JP3DBridgeParityTests` 5/5 PASS, full `swift test --filter JP3D` regression sweep **519/519 PASS**, mandatory commit gate 7/7 PASS. Codestream bytes byte-identical to v10.11.0; encoder unchanged. See [RELEASE_NOTES_v10.12.0.md](RELEASE_NOTES_v10.12.0.md) and the bench JSONs at [`Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-{batched,serial}-20260524.json`](Documentation/Benchmarks/data/). **v10.11.0** ships **JP3D batched GPU iDWT** — the production landing of a multi-week research arc that splits the JP3D per-tile decode pipeline at the dequant↔iDWT boundary and submits one batched Metal dispatch across the whole z-range instead of N per-slice dispatches. A new opaque-payload bridge SPI on `J2KDecoder` (`_jp3dDecodeToCoefficients` / `_jp3dIDWTAndFinalize` / `_jp3dIDWTAndFinalizeBatched`) is the architectural surface; two new Metal kernels (`j2k_dwt_inverse_53_horizontal_int_tiled_batched`, `..._vertical_..._batched`) extend the v10.3 tiled threadgroup-memory kernels with a Z-dim grid axis, processing N slices in one dispatch. `JP3DSliceStackCodec` now runs **parallel `_jp3dDecodeToCoefficients` across `[zStart, zUpper)` via a TaskGroup, then ONE batched iDWT call** before the existing sequential Z-delta residual chain. Per-slice GPU dispatch overhead amortises across the volume; the gain scales with slice count × per-slice work. **M2 release, J2KBenchMac --jp3d, in-process, 7 timed runs / 2 warmups**: ct_3d_small **−5.12 ms** (1.13×), us_3d_small −4.15 ms (1.07×), mr_3d_mid **−9.50 ms** (1.12×), ct_3d_mid **−53.25 ms** (1.17×), **ct_3d_large 16M-voxel CT −114.57 ms** (1.17×). 5/6 fixtures clear the 3 ms acceptance threshold; the wash (mr_3d_small @ 13 ms wall) is the smallest fixture where per-slice overhead dominates anyway. Kernel-level bench (16 slices × 256×256 × 3 levels): **5.93 → 2.06 ms = 2.4× faster GPU dispatch**. Eligibility gate: only `K=0 + no ROI` routes through the batched path; `K>0` keeps the per-slice `decodeResolution` loop and ROI keeps `decodeRegion` (bridging those is future work). Opt-out via `J2K_JP3D_BATCHED_BRIDGE=0`. **`V10_20_BatchedBridgeParityTests` 5/5 PASS, `V10_20_BatchedInverseInt32ParityTests` 12/12 PASS, `V10_20_JP3DBridgeParityTests` 5/5 PASS, full `swift test --filter JP3D` regression sweep 519/519 PASS, mandatory commit gate 7/7 PASS**. Codestream bytes byte-identical to v10.10.0; encoder unchanged. See [RELEASE_NOTES_v10.11.0.md](RELEASE_NOTES_v10.11.0.md) and the bench JSONs in [Documentation/Benchmarks/data/](Documentation/Benchmarks/data/). diff --git a/RELEASE_NOTES_v10.13.0.md b/RELEASE_NOTES_v10.13.0.md new file mode 100644 index 0000000..d6deed8 --- /dev/null +++ b/RELEASE_NOTES_v10.13.0.md @@ -0,0 +1,135 @@ +# J2KSwift v10.13.0 + +**JP3D K>0 + ROI composition closes the {K, ROI} matrix.** v10.12.0 +shipped the batched bridge SPI for the K=0+ROI and K>0+nil lanes but +left K>0+ROI as a known limitation — both `JP3DSliceStackCodec` and +`JP3DROIDecoder` threw on the combination. v10.13.0 removes both +throws and makes `JP3DROIDecoder` K-aware: `JP3DROIDecoder(cfg).decode(data, region:)` +with `cfg.resolutionLevel > 0` now returns a downsampled sub-volume +sized `ceil(region.width / 2^K) × ceil(region.height / 2^K) × region.depth`. + +Decoder-only release; codestream bytes are byte-identical to v10.12.0. +Encoder unchanged. MINOR per RELEASING.md — no public API removed or +signature-changed; semantics of the v10.12.0 `JP3DBridgeOptions.regionOfInterest` +clarified as full-image coordinates. + +## Summary + +Four coordinated changes that close the last open cell of the +{K, ROI} batched-bridge matrix: + +1. **Throws removed.** `JP3DSliceStackCodec.decode` no longer + rejects `regionOfInterest != nil && resolutionLevel > 0`; nor + does `JP3DROIDecoder.decode` reject the same combination + upstream. The v10.18 fail-loud guards were specification + tripwires for the Phase 5 future-work case; v10.13.0 IS that + Phase 5 wiring. + +2. **`JP3DBridgeOptions.regionOfInterest` semantics standardised + on full-image coordinates.** Matches the convention used by the + 2D codec's `decodeRegion` (v10.6) and `decodePartial` (v10.8). + The bridge maps the full-coords region onto the reduced grid + for the crop step via the same formula `decodePartial` uses + (`J2KAdvancedDecoding.swift:528-543`): + `region / 2^(N − partialResolutionLevel)` with ceil-up + width/height + clamp. + +3. **`JP3DSliceStackCodec.outTileWidth/outTileHeight` K-aware.** + When both `K > 0` and a ROI are set, the per-slice 2D codec's + partial-res output is at the reduced grid; the per-slice output + shape is therefore `ceil(roi.width / 2^K) × ceil(roi.height / 2^K)`, + not `roi.width × roi.height`. `outTile*` track that — the + Z-delta residual chain and per-slice composite read the right + buffer shape. + +4. **`JP3DROIDecoder` K-aware composite.** The output `roiBuffers` + is now sized for the downsampled output dims when `K > 0`. The + per-tile composite logic switches to downsampled stride for both + src (the slice-stack returns downsampled per-slice buffers) and + dst (`roiBuffers` sized for downsampled output). Z stays full — + JP3D's `K` is a 2D-only halving; the Z-delta residual chain is + per-slice. + +## What's New — production-default + +| Public API | v10.12.0 behaviour | v10.13.0 behaviour | +|---|---|---| +| `JP3DROIDecoder(configuration: cfg).decode(data, region:)` with `cfg.resolutionLevel > 0` | Threw `decodingError("combining ... is not yet supported. Phase 5 wiring task.")` | Returns sub-volume sized `ceil(region.width / 2^K) × ceil(region.height / 2^K) × region.depth`, with voxels bit-identical to `JP3DDecoder(cfg).decode(data)` cropped to the downsampled-mapped region | +| `JP3DDecoder(configuration: cfg).decode(data)` (no ROI) | v10.12.0 batched (unchanged) | Unchanged | +| `JP3DROIDecoder().decode(data, region:)` (default config, ROI only) | v10.12.0 batched (unchanged) | Unchanged | + +The `JP3DBridgeOptions` struct (v10.12.0) keeps the same surface +but now explicitly documents `regionOfInterest` as full-image +coordinates with bridge-side reduced-grid mapping when partial-res +is also active. + +## What's New — opt-in / opt-out + +`J2K_JP3D_BATCHED_BRIDGE=0` (v10.11.0) still applies — disables the +batched bridge across all four {K, ROI} cells, forcing per-slice +serial. Diagnostic-A/B only. + +## Backward compatibility + +- **Codestream bytes**: byte-identical to v10.12.0 on every input. + Encoder unchanged. +- **Existing decode paths** (full-volume, K-only, ROI-only) are + byte-identical to v10.12.0 — validated by the full + `swift test --filter JP3D` regression sweep (519/519 PASS) and + by `V10_21_BatchedBridgeOptionsParityTests` (7/7 PASS). +- **Behaviour change**: the previously-throwing `K > 0 + ROI` + combination now returns a valid sub-volume instead of throwing. + Code that caught the v10.18 "Phase 5 wiring task" error and + fell back to a workaround should remove the catch. + +## Test Suite Results + +| Suite | Tests | Result | Coverage | +|---|---:|---|---| +| `V10_18_TrueSelectiveParityTests` | 9/9 | PASS | Includes new `testCombinedResolutionLevelAndROI` (K=1 + ROI on 64×64×8 LCG volume, bit-identical to full-partial-then-crop oracle) | +| `V10_21_BatchedBridgeOptionsParityTests` | 7/7 | PASS | Updated K+ROI composition test now uses full-coords ROI | +| `V10_20_BatchedBridgeParityTests` | 5/5 | PASS | v10.11 full-volume batched bridge unchanged | +| `V10_20_BatchedInverseInt32ParityTests` | 12/12 | PASS | v10.11 batched orchestrator unchanged | +| `V10_20_JP3DBridgeParityTests` | 5/5 | PASS | Phase 1 bridge SPI composition unchanged | +| `swift test --filter JP3D` (regression sweep) | 519/519 | PASS | Full JP3D test suite green | +| Mandatory commit gate (release mode) | 7/7 | PASS | Encode-perf + decode-perf + cross-codec strict validation | + +## API surface — no additions, no removals + +`JP3DBridgeOptions.regionOfInterest`'s doc comment updates to spell +out full-image coordinates + the internal reduced-grid mapping +formula. No source-breaking change to consumers — code passing a +region in v10.12.0's "downsampled coords" convention was either +crashing or working accidentally for K=0; same code now works +consistently for any K. + +## Known limitations + +- The bench A/B (`J2KBenchMac --jp3d`) corpus doesn't include a + K>0 + ROI lane in its current `JP3DBench.swift` matrix (only + full / res1 / roiq independently). The bridge SPI parity tests + cover the composition; a future bench update will quantify the + end-to-end win when it lands. + +## Reproducing the K+ROI parity oracle + +The new `V10_18_TrueSelectiveParityTests.testCombinedResolutionLevelAndROI` +test is the canonical reference: + +```bash +swift test --filter "V10_18_TrueSelectiveParityTests/testCombinedResolutionLevelAndROI" +``` + +It encodes a 64×64×8 LCG-noise volume, decodes both +`JP3DDecoder(cfg=resolutionLevel:1)` and +`JP3DROIDecoder(cfg=resolutionLevel:1).decode(data, region:)`, +then asserts the ROI voxels are bit-identical to the full-partial +decode cropped to the downsampled-mapped region. + +## Backward upgrade + +`swift package update` will not auto-pick this release if your +`Package.swift` pins an exact version; bump the requirement to +`from: "10.13.0"`. No source changes required for consumers — the +new behaviour is strictly additive (the throw case is replaced +with a working return). diff --git a/Sources/J2K3D/JP3DROIDecoder.swift b/Sources/J2K3D/JP3DROIDecoder.swift index d4e77ca..d96d2c7 100644 --- a/Sources/J2K3D/JP3DROIDecoder.swift +++ b/Sources/J2K3D/JP3DROIDecoder.swift @@ -99,29 +99,13 @@ public actor JP3DROIDecoder { let codestream = try parser.parse(data) let siz = codestream.siz - // v10.18-research Phase 5 — fail-loud on the unsupported - // combination of resolutionLevel > 0 + sub-region. The full- - // volume case (handled below via JP3DDecoder delegation) still - // honours resolutionLevel because there's no per-tile cropping. - if configuration.resolutionLevel > 0 { - let isFullVolumeRequest = ( - requestedRegion.x.lowerBound <= 0 && - requestedRegion.x.upperBound >= siz.width && - requestedRegion.y.lowerBound <= 0 && - requestedRegion.y.upperBound >= siz.height && - requestedRegion.z.lowerBound <= 0 && - requestedRegion.z.upperBound >= siz.depth) - if !isFullVolumeRequest { - throw J2KError.decodingError( - "JP3DROIDecoder: combining configuration.resolutionLevel > 0 " - + "with a sub-region is not yet supported. " - + "v10.18 Phase 5 wiring task. " - + "Until then, use either JP3DDecoder(configuration: ...) " - + "for partial-resolution decode, OR " - + "JP3DROIDecoder() (default config) for ROI decode — " - + "not both together.") - } - } + // v10.22-research — resolutionLevel + sub-region composition + // is now supported via the batched bridge. JP3DSliceStackCodec + // (Phase 5 wiring) routes K>0 + ROI through the v10.21 + // bridge SPI's K+ROI-aware orchestrator, which truncates the + // iDWT chain AND crops the per-slice output to the + // downsampled-coord ROI. The v10.18 fail-loud throw is gone; + // the per-tile coord translation runs below. // Clamp the requested region to valid volume bounds let clampedXLower = max(0, requestedRegion.x.lowerBound) @@ -193,8 +177,20 @@ public actor JP3DROIDecoder { tilesByIndex[linearIndex] = tile } + // v10.22 — when configuration.resolutionLevel > 0 the output + // sub-volume's XY dims are downsampled by 2^K relative to the + // user-passed ROI (Z is per-slice, not affected). The output + // buffer sizing + per-tile composite below switch to + // downsampled coords accordingly. Z stays full because JP3D's + // K is a 2D-only halving (Z residual chain is per-slice). + let K = configuration.resolutionLevel + let f = K > 0 ? (1 << K) : 1 + let roiW_out = K > 0 ? (roiW + f - 1) / f : roiW + let roiH_out = K > 0 ? (roiH + f - 1) / f : roiH + let roiD_out = roiD + // Allocate output ROI buffers - let roiVoxels = roiW * roiH * roiD + let roiVoxels = roiW_out * roiH_out * roiD_out var roiBuffers = [[Float]]( repeating: [Float](repeating: 0, count: roiVoxels), count: siz.componentCount @@ -259,16 +255,24 @@ public actor JP3DROIDecoder { ) } + // v10.22 — JP3DSliceStackCodec now treats `regionOfInterest` + // in FULL tile-local coords (post-K mapping happens inside + // the bridge SPI). No translation needed here. let inTileXRange = inTileXLower..0). + let regionW_full = inTileXRange.count + let regionH_full = inTileYRange.count + let regionW = K > 0 ? (regionW_full + f - 1) / f : regionW_full + let regionH = K > 0 ? (regionH_full + f - 1) / f : regionH_full // Phase 3 fast path only fires when the in-tile region is - // strictly smaller than the tile (otherwise the full-tile - // decode is identical and bypasses the codec's region- - // setup overhead). - let useRegionPath = (regionW < tw || regionH < th) + // strictly smaller than the tile (full coords). When K>0 + // the slice-stack will still downsample internally. + let useRegionPath = (regionW_full < tw || regionH_full < th) // v10.18-research Phase 5 — wire JP3DDecoderConfiguration. // resolutionLevel through to the slice-stack codec. When @@ -340,40 +344,79 @@ public actor JP3DROIDecoder { _ = returnedSliceCount // bounded by the loop below if useRegionPath { + // v10.22 — composite at DOWNSAMPLED XY coords when + // K>0. The slice-stack returns the per-slice buffer + // sized for the downsampled-in-tile region, so + // src strides and dst offsets are both at the + // reduced scale. Z stays full (per-slice). let srcSliceVoxels = regionW * regionH + // In-tile lower bound in downsampled coords; the + // dst-base accounts for the clampedX/Y lower bound + // also being downsampled (since roiBuffers is + // downsampled-sized). + let inTileXLowerDown = K > 0 + ? inTileXRange.lowerBound / f + : inTileXRange.lowerBound + let inTileYLowerDown = K > 0 + ? inTileYRange.lowerBound / f + : inTileYRange.lowerBound + let x0Down = K > 0 ? x0 / f : x0 + let y0Down = K > 0 ? y0 / f : y0 + let clampedXLowerDown = K > 0 + ? clampedX.lowerBound / f + : clampedX.lowerBound + let clampedYLowerDown = K > 0 + ? clampedY.lowerBound / f + : clampedY.lowerBound for z in intZ0..0, + // so iterate at downsampled stride. + let twDown = K > 0 ? (tw + f - 1) / f : tw + let thDown = K > 0 ? (th + f - 1) / f : th let intX0 = max(x0, clampedX.lowerBound) let intY0 = max(y0, clampedY.lowerBound) let intX1 = min(x0 + tw, clampedX.upperBound) let intY1 = min(y0 + th, clampedY.upperBound) + let intX0Down = K > 0 ? intX0 / f : intX0 + let intY0Down = K > 0 ? intY0 / f : intY0 + let intX1Down = K > 0 ? (intX1 + f - 1) / f : intX1 + let intY1Down = K > 0 ? (intY1 + f - 1) / f : intY1 + let x0Down = K > 0 ? x0 / f : x0 + let y0Down = K > 0 ? y0 / f : y0 + let clampedXLowerDown = K > 0 ? clampedX.lowerBound / f : clampedX.lowerBound + let clampedYLowerDown = K > 0 ? clampedY.lowerBound / f : clampedY.lowerBound for z in intZ0..0. let volumeComponents = assembleROIComponents( - from: roiBuffers, siz: siz, roiW: roiW, roiH: roiH, roiD: roiD + from: roiBuffers, siz: siz, + roiW: roiW_out, roiH: roiH_out, roiD: roiD_out ) - let volume = J2KVolume(width: roiW, height: roiH, depth: roiD, components: volumeComponents) + let volume = J2KVolume(width: roiW_out, height: roiH_out, depth: roiD_out, components: volumeComponents) return JP3DROIDecoderResult( volume: volume, diff --git a/Sources/J2K3D/JP3DSliceStackCodec.swift b/Sources/J2K3D/JP3DSliceStackCodec.swift index 6536cb5..4a8f4f3 100644 --- a/Sources/J2K3D/JP3DSliceStackCodec.swift +++ b/Sources/J2K3D/JP3DSliceStackCodec.swift @@ -354,17 +354,27 @@ struct JP3DSliceStackCodec: Sendable { } // v10.18 Phase 3 — region-of-interest. If set, the output slice - // dims shrink to the region. (The region is expressed in - // *downsampled* tile-local coords if K > 0; callers translate - // before passing.) For K == 0 and ROI nil, this collapses to - // identity = the current behaviour. + // dims shrink to the region. v10.22 — region is now in FULL + // tile-local coords regardless of K (matches v10.6/v10.8/v10.21 + // bridge SPI convention). When K > 0, the per-slice 2D codec's + // partial-res output is at the reduced grid, AND the bridge SPI + // maps the full-coords region onto that reduced grid via + // `region / 2^K` (with ceil-up width/height + clamp) before + // cropping. So `outTileWidth/outTileHeight` here track the + // POST-crop dims = `roi_width / 2^K`. let outTileWidth: Int let outTileHeight: Int let regionXLower: Int let regionYLower: Int if let roi = regionOfInterest { - outTileWidth = roi.xRange.count - outTileHeight = roi.yRange.count + if K > 0 { + let f = 1 << K + outTileWidth = max(1, (roi.xRange.count + f - 1) / f) + outTileHeight = max(1, (roi.yRange.count + f - 1) / f) + } else { + outTileWidth = roi.xRange.count + outTileHeight = roi.yRange.count + } regionXLower = roi.xRange.lowerBound regionYLower = roi.yRange.lowerBound } else { @@ -483,10 +493,16 @@ struct JP3DSliceStackCodec: Sendable { // iDWTs land (the residual is a cheap Int32 add but has // the cross-slice dependency that prevents batching). // - // The single combination still routed per-slice is - // `K > 0 AND regionOfInterest != nil` — the throw above - // already gates that out (the 2D codec doesn't compose - // partial-res with ROI; it's a Phase 5 future task). + // v10.22-research Phase 1 — K>0 + ROI composition now also + // routes through batched bridge. The v10.21 batched bridge + // orchestrator already composes both: chain truncation to + // `effectiveLevels = N − K` + per-slice post-iDWT crop to + // the ROI (verified by V10_21_BatchedBridgeOptionsParityTests + // `testBatchedBridgeWithPartialResolutionAndROI_4Slices_256x256`). + // The JP3DSliceStackCodec convention is that when K>0 the + // ROI is expressed in downsampled tile-local coords, which + // matches what the bridge's crop expects after partial-res + // iDWT — no coordinate translation needed at this layer. // // v10.20-research Phase 4 — env-var off-switch for A/B benching. // `J2K_JP3D_BATCHED_BRIDGE=0` disables the bulk batched path, @@ -500,10 +516,10 @@ struct JP3DSliceStackCodec: Sendable { } return false }() - // The (K>0, ROI) case is excluded by the throw upstream; here - // we only need to gate on the env-var off-switch. + // v10.22 — batched bridge now covers all four cells of the + // {K, ROI} matrix. The only remaining per-slice case is the + // env-var opt-out. let useBatchedBridge = !batchedOptedOut - && !(regionOfInterest != nil && K > 0) var batchedImages: [J2KImage]? = nil if useBatchedBridge { // For K>0 we need the per-slice 2D codec's decomposition- @@ -569,33 +585,32 @@ struct JP3DSliceStackCodec: Sendable { ) // v10.18 — route through decodeResolution / decodeRegion - // as appropriate. Four combinations (composition order: - // ROI ⊕ resolutionLevel for clarity): - // - // (K=0, no ROI) → decoder.decode (current) - // v10.20 Phase 3c: served from batchedImages - // (K=0, ROI) → decoder.decodeRegion (Phase 3) - // (K>0, no ROI) → decoder.decodeResolution (Phase 2) - // (K>0, ROI) → decoder.decodeRegion (Phase 2+3) - // then partial-res must be expressed - // inside the J2KROIDecodingOptions — - // currently the 2D ROI path always - // decodes at full resolution. Falls back - // to (K=0, ROI) for safety and the - // tile-output dims must therefore be in - // FULL tile coords; assert K == 0 when - // ROI is set (the JP3DROIDecoder caller - // today never combines them, and the - // composition is a Phase 5 wiring task). - if regionOfInterest != nil && K > 0 { - throw J2KError.decodingError( - "JP3D slice-stack: combining resolutionLevel > 0 with " - + "regionOfInterest is not yet supported. v10.18 Phase 5 " - + "is the wiring task.") - } + // as appropriate. v10.20-Phase-3c moved the (K=0, no ROI) + // case onto the batched bridge; v10.21 moved (K=0, ROI) + // and (K>0, no ROI); v10.22 closes the matrix by adding + // (K>0, ROI). All four cells now route through + // `batchedImages` unless the env-var off-switch is set. + // The per-slice branches below remain as the off-switch + // fallback only — they execute when + // `J2K_JP3D_BATCHED_BRIDGE=0`. let image: J2KImage if let batched = batchedImages { image = batched[z - zStart] + } else if let roi = regionOfInterest, K > 0 { + // v10.22 — env-var off-switch case for K>0 + ROI. + // Route per-slice through decodePartial, which composes + // partial-res + ROI at the 2D codec level (v10.8). + if perSliceDecompLevels == nil { + perSliceDecompLevels = Self.peekDecompositionLevels(codestream) + } + let N = perSliceDecompLevels ?? 0 + let sliceLevel = max(0, N - K) + let opts = J2KPartialDecodingOptions( + maxResolutionLevel: sliceLevel, + region: J2KRegion( + x: roi.xRange.lowerBound, y: roi.yRange.lowerBound, + width: roi.xRange.count, height: roi.yRange.count)) + image = try await decoder.decodePartial(codestream, options: opts) } else if let roi = regionOfInterest { let opts = J2KROIDecodingOptions( region: J2KRegion( diff --git a/Sources/J2KCodec/J2KDecoderPipeline.swift b/Sources/J2KCodec/J2KDecoderPipeline.swift index beeb1ca..6b00d45 100644 --- a/Sources/J2KCodec/J2KDecoderPipeline.swift +++ b/Sources/J2KCodec/J2KDecoderPipeline.swift @@ -1821,7 +1821,24 @@ struct DecoderPipeline: Sendable { let image = try reconstructImage(spatialData, metadata: slice.metadata) if let roi = headOptions.regionOfInterest { - results.append(try Self.cropImage(image, region: roi)) + // v10.22 — full-coords ROI mapped onto the reduced + // grid when partial-res is active. Same formula as + // the serial bridge SPI's finalize crop, mirrored + // from J2KAdvancedDecoding.swift:528-543. + let f = JP3DSliceCoefficients.reducedFactor( + level: headOptions.partialResolutionLevel, + N: slice.metadata.configuration.decompositionLevels) + let mapped: J2KRegion + if f == 1 { + mapped = roi + } else { + let rx = min(roi.x / f, max(0, image.width - 1)) + let ry = min(roi.y / f, max(0, image.height - 1)) + let rw = max(1, min((roi.width + f - 1) / f, image.width - rx)) + let rh = max(1, min((roi.height + f - 1) / f, image.height - ry)) + mapped = J2KRegion(x: rx, y: ry, width: rw, height: rh) + } + results.append(try Self.cropImage(image, region: mapped)) } else { results.append(image) } @@ -1885,7 +1902,25 @@ struct DecoderPipeline: Sendable { let fullImage = try reconstructImage(rgbData, metadata: metadata) if let roi = coefs.options.regionOfInterest { - return try Self.cropImage(fullImage, region: roi) + // v10.22 — ROI in full-image coords; map onto reduced + // grid when partial-res is also active. Mirror + // decodePartial's mapping (J2KAdvancedDecoding.swift:528-543): + // `factor = 2^(N - partialResolutionLevel)`, region/f + // with ceil-up width/height + clamping. + let f = JP3DSliceCoefficients.reducedFactor( + level: coefs.options.partialResolutionLevel, + N: metadata.configuration.decompositionLevels) + let mapped: J2KRegion + if f == 1 { + mapped = roi + } else { + let rx = min(roi.x / f, max(0, fullImage.width - 1)) + let ry = min(roi.y / f, max(0, fullImage.height - 1)) + let rw = max(1, min((roi.width + f - 1) / f, fullImage.width - rx)) + let rh = max(1, min((roi.height + f - 1) / f, fullImage.height - ry)) + mapped = J2KRegion(x: rx, y: ry, width: rw, height: rh) + } + return try Self.cropImage(fullImage, region: mapped) } return fullImage } diff --git a/Sources/J2KCodec/J2KJP3DBridge.swift b/Sources/J2KCodec/J2KJP3DBridge.swift index ebe7869..4e360f7 100644 --- a/Sources/J2KCodec/J2KJP3DBridge.swift +++ b/Sources/J2KCodec/J2KJP3DBridge.swift @@ -64,10 +64,18 @@ public struct JP3DBridgeOptions: Sendable, Equatable { /// LL only (thumbnail). public let partialResolutionLevel: Int? - /// Region of interest. `nil` = full image. When set, the iDWT - /// still runs full-tile but the finalize stage crops to the - /// region (Stage 1 footprint-skip + Stage 2 tile-skip both - /// engage upstream of the bridge SPI's iDWT). + /// Region of interest in **full-image coordinates** (the same + /// convention as v10.6 `decodeRegion` / v10.8 `decodePartial`). + /// `nil` = full image. When set: + /// • Entropy stage uses the v10.6 footprint-skip block filter + /// (`extractTileData`'s `regionOfInterest`); the filter + /// expects full-image coords for the block-rect overlap check. + /// • Finalize crops the per-slice output to the region. When + /// `partialResolutionLevel` is ALSO set, the bridge maps the + /// full-coords region onto the reduced grid via + /// `region / 2^(N − partialResolutionLevel)` before cropping + /// (mirrors the `decodePartial` crop-on-reduced-grid logic in + /// J2KAdvancedDecoding.swift:528-543). public let regionOfInterest: J2KRegion? public init( @@ -119,33 +127,58 @@ public struct JP3DSliceCoefficients: @unchecked Sendable { /// Pixel dimensions of the slice this coefficients bundle will /// produce after iDWT + reconstruction. Reflects the captured - /// `JP3DBridgeOptions`: partial-resolution shrinks the dims; - /// ROI shrinks the dims to the region; full decode returns - /// `metadata.width × metadata.height`. Useful for the JP3D - /// caller's output-buffer sizing before iDWT runs. + /// `JP3DBridgeOptions`: + /// • full decode (no options): `metadata.width × metadata.height` + /// • partial-res K=L only: `ceil(W / 2^(N-L)) × ceil(H / 2^(N-L))` + /// • ROI only: `roi.width × roi.height` + /// • partial-res + ROI: ROI mapped onto reduced grid via + /// `ceil(roi.width / 2^(N-L))` (matches `decodePartial`'s + /// crop-on-reduced-grid logic). + /// Useful for the JP3D caller's output-buffer sizing before + /// iDWT runs. public var width: Int { - if let roi = _internal.options.regionOfInterest { - return roi.width - } - if let level = _internal.options.partialResolutionLevel { - let N = _internal.metadata.configuration.decompositionLevels - let halvings = max(0, N - max(0, min(level, N))) - let factor = 1 << halvings - return (_internal.metadata.width + factor - 1) / factor - } - return _internal.metadata.width + let mw = _internal.metadata.width + let factor = Self.reducedFactor( + level: _internal.options.partialResolutionLevel, + N: _internal.metadata.configuration.decompositionLevels) + let baseW = (mw + factor - 1) / factor + guard let roi = _internal.options.regionOfInterest else { return baseW } + if factor == 1 { return roi.width } + return Self.mappedRegionWidth(roi: roi, factor: factor, base: baseW) } public var height: Int { - if let roi = _internal.options.regionOfInterest { - return roi.height - } - if let level = _internal.options.partialResolutionLevel { - let N = _internal.metadata.configuration.decompositionLevels - let halvings = max(0, N - max(0, min(level, N))) - let factor = 1 << halvings - return (_internal.metadata.height + factor - 1) / factor - } - return _internal.metadata.height + let mh = _internal.metadata.height + let factor = Self.reducedFactor( + level: _internal.options.partialResolutionLevel, + N: _internal.metadata.configuration.decompositionLevels) + let baseH = (mh + factor - 1) / factor + guard let roi = _internal.options.regionOfInterest else { return baseH } + if factor == 1 { return roi.height } + return Self.mappedRegionHeight(roi: roi, factor: factor, base: baseH) + } + + /// Reduced-grid downsample factor for a given partialResolutionLevel. + /// Matches `decodePartial`'s `resolutionFactor = 1 << (levels - level)`. + @usableFromInline + static func reducedFactor(level: Int?, N: Int) -> Int { + guard let l = level, l >= 0, l < N else { return 1 } + return 1 << (N - l) + } + + /// Maps a full-coords ROI's x-extent onto the reduced grid via + /// the same formula `decodePartial` uses + /// (J2KAdvancedDecoding.swift:536-540): `x/f`, ceil-up width. + @usableFromInline + static func mappedRegionWidth(roi: J2KRegion, factor f: Int, base: Int) -> Int { + let rx = min(roi.x / f, max(0, base - 1)) + let rw = max(1, min((roi.width + f - 1) / f, base - rx)) + return rw + } + @usableFromInline + static func mappedRegionHeight(roi: J2KRegion, factor f: Int, base: Int) -> Int { + let ry = min(roi.y / f, max(0, base - 1)) + let rh = max(1, min((roi.height + f - 1) / f, base - ry)) + return rh } } diff --git a/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift b/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift index 636fa66..cbadc05 100644 --- a/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift +++ b/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift @@ -200,12 +200,19 @@ final class V10_21_BatchedBridgeOptionsParityTests: XCTestCase { // MARK: - Composition parity (partial-res + ROI) - /// 4-slice batch with BOTH partial-res K=4 AND ROI inside the - /// reduced grid. Batched ≡ per-slice serial; output dims = ROI dims. + /// 4-slice batch with BOTH partial-res K=4 (in 2D codec terms, + /// i.e. 4 iDWT steps) AND a full-coords ROI. v10.22 convention: + /// ROI in `JP3DBridgeOptions` is full-image coords; the bridge + /// maps it onto the reduced grid via `region / 2^(N-K)` before + /// cropping. For partialResolutionLevel=4 with N=5, factor = 2, + /// so full ROI(32, 32, 128, 128) → reduced ROI(16, 16, 64, 64) + /// → output 64×64. func testBatchedBridgeWithPartialResolutionAndROI_4Slices_256x256() async throws { let decoder = J2KDecoder() - // K=4 reduces 256→128; ROI inside the 128×128 reduced grid. - let region = J2KRegion(x: 16, y: 16, width: 64, height: 64) + // Full-coords ROI within 256×256. With K=4 (1 iDWT step short + // of full), 2D output is 128×128. Region maps to reduced grid + // via factor = 2^(5-4) = 2. + let region = J2KRegion(x: 32, y: 32, width: 128, height: 128) let opts = JP3DBridgeOptions( partialResolutionLevel: 4, regionOfInterest: region) var coefsArr: [JP3DSliceCoefficients] = [] @@ -220,11 +227,13 @@ final class V10_21_BatchedBridgeOptionsParityTests: XCTestCase { let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) XCTAssertEqual(batched.count, 4) for s in 0..<4 { + // Reduced output: 128×128. ROI mapped: x=16, y=16, + // w=64, h=64. → output 64×64. XCTAssertEqual(batched[s].width, 64) XCTAssertEqual(batched[s].height, 64) XCTAssertEqual(serials[s].components.first?.data, batched[s].components.first?.data, - "Batched bridge (K=4 + ROI 64²) slice \(s) diverges from serial") + "Batched bridge (K=4 + full-coords ROI) slice \(s) diverges from serial") } } diff --git a/Tests/JP3DTests/V10_18_TrueSelectiveParityTests.swift b/Tests/JP3DTests/V10_18_TrueSelectiveParityTests.swift index cecad76..688d68f 100644 --- a/Tests/JP3DTests/V10_18_TrueSelectiveParityTests.swift +++ b/Tests/JP3DTests/V10_18_TrueSelectiveParityTests.swift @@ -356,29 +356,50 @@ final class V10_18_TrueSelectiveParityTests: XCTestCase { } } - /// Composition tripwire — combining `resolutionLevel > 0` with a - /// ROI decoder is the Phase 5 future-work case. JP3DSliceStackCodec - /// throws today rather than silently producing wrong output. When - /// Phase 5 wires the combination through, this test will start - /// passing the (currently absent) success branch — at that point - /// invert the assertion to check the combined output instead. - func testCombinedResolutionLevelAndROIIsPhase5Future() async throws { + /// v10.22-research Phase 1 — composition success path. + /// **Specification**: `JP3DROIDecoder(configuration: cfg).decode(_, region:)` + /// with `cfg.resolutionLevel = K, K > 0` produces a sub-volume + /// whose voxels equal a full partial-resolution decode (at level K) + /// cropped to the region mapped onto the reduced grid. v10.22 wires + /// this through the v10.21 batched bridge SPI's K+ROI orchestrator + /// (chain truncation + per-slice post-iDWT crop at reduced coords). + func testCombinedResolutionLevelAndROI() async throws { let volume = makeLCGVolume(width: 64, height: 64, depth: 8) let codestream = try await encodeLossless(volume) let cfg = JP3DDecoderConfiguration(resolutionLevel: 1) + // Full partial-res decode (K=1 ⇒ output 32×32×8) as the oracle. + let fullPartial = try await JP3DDecoder(configuration: cfg).decode(codestream).volume + + // Sub-region of the full image (full coords). The downsampled + // sub-volume should match `fullPartial` cropped to the + // downsampled sub-region. let region = JP3DRegion(x: 16..<48, y: 16..<48, z: 0..<4) + let combined = try await JP3DROIDecoder(configuration: cfg) + .decode(codestream, region: region).volume + + // Per the K=1 convention, the downsampled region in `fullPartial` + // coords is x: 8..<24, y: 8..<24 (region / 2 each side). + let dRegionX = (region.x.lowerBound >> 1)..<((region.x.upperBound + 1) >> 1) + let dRegionY = (region.y.lowerBound >> 1)..<((region.y.upperBound + 1) >> 1) + XCTAssertEqual(combined.width, dRegionX.count) + XCTAssertEqual(combined.height, dRegionY.count) + XCTAssertEqual(combined.depth, region.z.count) - do { - _ = try await JP3DROIDecoder(configuration: cfg).decode( - codestream, region: region) - XCTFail("Phase 5 has landed — invert this assertion to verify " - + "the combined output instead.") - } catch { - // Expected today: JP3DSliceStackCodec.decode throws when - // both resolutionLevel > 0 AND regionOfInterest are set. - // The check happens per-tile during the ROI decode, so we - // accept any error here. + for (zi, z) in region.z.enumerated() { + for (yi, y) in dRegionY.enumerated() { + for (xi, x) in dRegionX.enumerated() { + let want = voxelValue(in: fullPartial, x: x, y: y, z: z) + let got = voxelValue(in: combined, x: xi, y: yi, z: zi) + if want != got { + XCTFail("K+ROI voxel mismatch at " + + "fullPartial(\(x),\(y),\(z)) " + + "combined(\(xi),\(yi),\(zi)): " + + "want=\(want) got=\(got)") + return + } + } + } } } }