diff --git a/Package.swift b/Package.swift index 0768bb1..5ccc53b 100644 --- a/Package.swift +++ b/Package.swift @@ -60,6 +60,16 @@ let package = Package( .library( name: "J2KXS", targets: ["J2KXS"]), + // v10.17.0 — DICOM-bridge helpers product. Phase 1 ships the + // Transfer Syntax UID enum + J2KEncodingConfiguration mapping + + // codestream sniffer + PhotometricInterpretation enum mirror. + // ADR-004 compliant: no DICOM library dependency anywhere in + // J2KSwift; this product depends only on J2KCore + J2KCodec. + // Consumers who don't need DICOM ergonomics simply don't import + // J2KDICOMHelpers. + .library( + name: "J2KDICOMHelpers", + targets: ["J2KDICOMHelpers"]), .executable( name: "j2k", targets: ["J2KCLI"]), @@ -173,6 +183,12 @@ let package = Package( .target( name: "J2KXS", dependencies: ["J2KCore"]), + // v10.17.0 — DICOM-bridge helpers. Pure additive surface, + // ADR-004 compliant: depends only on J2KCore + J2KCodec. + // No DICOM library dependency introduced. + .target( + name: "J2KDICOMHelpers", + dependencies: ["J2KCore", "J2KCodec"]), .testTarget( name: "J2KCoreTests", dependencies: ["J2KCore", "J2KFileFormat"]), @@ -207,6 +223,10 @@ let package = Package( .testTarget( name: "J2KXSTests", dependencies: ["J2KXS", "J2KCore"]), + // v10.17.0 — J2KDICOMHelpers Phase 1 test target. + .testTarget( + name: "J2KDICOMHelpersTests", + dependencies: ["J2KDICOMHelpers", "J2KCore", "J2KCodec"]), .testTarget( name: "J2KComplianceTests", dependencies: ["J2K3D", "J2KCore"]), diff --git a/README.md b/README.md index 46a1a68..eb106c3 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.16.0 -**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.16.0 adds **`JP3DDecoder` partial-decode convenience overloads** — `decode(_:resolutionLevel:)`, `decode(_:region:)`, and `decode(_:region:resolutionLevel:)` — surfacing the v10.18-research partial-resolution pipeline + v10.13.0 K+ROI composition behind the canonical `JP3DDecoder` type. Consumers no longer have to know to construct `JP3DDecoderConfiguration(resolutionLevel:)` or switch to `JP3DROIDecoder`: the same speedups are now reachable from `JP3DDecoder` itself — **2.2-3.1× faster** for level-1 thumbnails, **3.4-4.1× faster** for ~1/4-extent ROI decodes (v10.18-research bench). MINOR / additive API — no perf change on existing paths, codestream bytes byte-identical to v10.15.0. -**Previous Release**: 10.15.0 (JP3DDecoder.preWarm discoverable convenience API) +**Current Version**: 10.17.0 +**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.17.0 ships **`J2KDICOMHelpers` (Phase 1)** — a new public SwiftPM library that bridges DICOM Transfer Syntax UIDs to `J2KEncodingConfiguration`. Closes a long-standing product-layer gap: library consumers using their own DICOM parser (DICOMKit, pydicom-via-XPC, etc.) get a Transfer Syntax UID + Pixel Data bytes, and previously had to hand-roll the UID-to-config mapping. Ships `J2KDICOMTransferSyntax` enum (7 DICOM UIDs, round-trip + classification), `J2KDICOMCodestreamDetector.detect(_:)` (SOC + CAP sniff), and `J2KDICOMPhotometricInterpretation` ↔ `J2KColorSpace` mapping. ADR-004 compliant: no DICOM library dependency anywhere; new product depends only on J2KCore + J2KCodec; consumers not importing it are unaffected. MINOR / additive product — no perf change, codestream bytes byte-identical to v10.16.0. +**Previous Release**: 10.16.0 (JP3DDecoder partial-decode discoverable convenience overloads) **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.17.0** ships **`J2KDICOMHelpers` (Phase 1)** — a new public SwiftPM library closing a long-standing product-layer gap. Per [ADR-004](Documentation/ADR/ADR-004-no-dicom-dependency.md), J2KSwift core libraries don't depend on any DICOM library. The CLI ships a `DICOMSupport.swift` for `.dcm` file loading (uses a Python fallback for compressed transfer syntaxes), but the `DICOMTransferSyntax` / `DICOMTransferSyntaxInfo` types there are CLI-private — not surfaced to library consumers. Library consumers using their own DICOM parser (DICOMKit, pydicom-via-XPC, etc.) extract `(Transfer Syntax UID, Pixel Data bytes)` and previously had to hand-roll the UID-to-`J2KEncodingConfiguration` mapping. v10.17.0 lifts the UID-bridge layer into a new public `J2KDICOMHelpers` SwiftPM library: **`J2KDICOMTransferSyntax`** enum (`CaseIterable` over all seven DICOM Part 5 Annex A JPEG 2000 / HTJ2K UIDs — `j2kLossless`, `j2kLossy`, `j2kPart2MulticompLossless`, `j2kPart2Multicomp`, `htj2kLossless`, `htj2kLossyConstant`, `htj2kLossy`) with `init?(uid:)` / `var uid` round-trip (whitespace + trailing-NUL tolerant per PS3.5 §6.2), `isLossless` / `isHTJ2K` / `isPart2` classification flags, and `encodingConfiguration(bitDepth:psnrTarget:)` returning a matching `J2KEncodingConfiguration` (with `htj2kBlockFormat: .conformant` for DICOM interop); **`J2KDICOMCodestreamDetector.detect(_:)`** sniffs SOC + (optional) CAP markers in the first ~256 bytes to classify raw codestreams as `.j2kLossless` (Part 1, no CAP) or `.htj2kLossless` (Part 15, CAP present), returning nil for non-J2K bytes (JPEG SOI, PNG signature, random bytes all correctly reject); and **`J2KDICOMPhotometricInterpretation`** enum mirror (`MONOCHROME1/2`, `RGB`, `YBR_FULL/422`, `YBR_RCT/ICT`) with bidirectional `J2KColorSpace` mapping (greyscale ↔ MONOCHROME2, sRGB ↔ RGB, yCbCr ↔ YBR_FULL; `hdr`/`hdrLinear`/`iccProfile`/`unknown` return nil reverse). ADR-004 compliant: **no DICOM library dependency anywhere in J2KSwift** — the new product depends only on J2KCore + J2KCodec; consumers not importing `J2KDICOMHelpers` are completely unaffected. **Validation**: `V10_29_*` test suites **26/26 PASS** release mode (9 TransferSyntaxRoundTrip + 5 EncodingConfigurationParity + 6 CodestreamDetection + 6 PhotometricInterpretation); end-to-end bit-exact lossless round-trip confirmed for `.j2kLossless` + `.htj2kLossless` via the helpers-built configurations. Full `swift test --filter JP3D` regression sweep **528/528 PASS**; mandatory commit gate 7/7 PASS. Also bumps `getVersion()` 10.16.0 → 10.17.0. Phase 1 deliberately ships the smallest viable shape — UID-and-config bridge only, no DICOM file parsing (Phase 2 territory: extract more of `J2KCLI/DICOMSupport.swift` or ship a DICOMKit adapter as a sibling opt-in product). AsyncSequence progress streams (originally Phase 3) deferred to a dedicated MINOR. See [RELEASE_NOTES_v10.17.0.md](RELEASE_NOTES_v10.17.0.md). + **v10.16.0** adds three **convenience overloads on `JP3DDecoder`** that surface the JP3D partial-decode capabilities behind the canonical type. The underlying pipelines have been production-default since v10.10.0 (true partial-resolution from v10.18-research, K+ROI composition from v10.13.0), but the only way to reach them from `import J2K3D` was to either construct `JP3DDecoderConfiguration(resolutionLevel: K)` (discoverable only by reading docs) or switch to `JP3DROIDecoder` (a separate actor type). v10.16.0 closes the gap with `decode(_:resolutionLevel:)`, `decode(_:region:)`, and `decode(_:region:resolutionLevel:)` overloads — all thin wrappers that construct a transient inner decoder/ROI-decoder with the requested options and delegate. The actor's `configuration` (`tolerateErrors`, `maxQualityLayers`) propagates to the transient inner instances; negative levels are clamped to 0; level 0 is bit-exact equivalent to `decode(_:)`. **Speedups surfaced (existing pipelines, v10.18-research bench)**: level-1 thumbnail decode **2.2-3.1× faster** than full decode on small-mid JP3D fixtures (mr_3d_small 2.22×, ct_3d_small 3.05×, mr_3d_mid 3.01×); ROI ~1/4-extent decode **3.4-4.1× faster** (mr_3d_small 3.37×, ct_3d_small 4.14×, mr_3d_mid 3.96×); K+ROI composition stacks both savings. Decoder-only, codestream bytes byte-identical to v10.15.0, encoder unchanged. **Validation**: `V10_28_JP3DDecoderPartialOverloadsParityTests` **6/6 PASS** (all three overloads byte-identical to existing JP3DDecoderConfiguration + JP3DROIDecoder usage; no-op level (0) bit-exact to `decode(_:)`; negative level clamped; outer-configuration propagation verified); full `swift test --filter JP3D` regression sweep **528/528 PASS** (519 pre-existing + 9 new = 2 V10_27 probe + 6 V10_28 parity + 1 pre-existing skip); mandatory commit gate 7/7 PASS. The natural symmetric ship to v10.15.0 would have been `JP3DEncoder.preWarm`; the V10_27 cold-start probe killed that candidate (leftover encoder-specific cold cost +0.10 / −1.25 ms on 128/256-cube fixtures — `JP3DDecoder.preWarm(includeWarmupDispatch: true)` already amortises encoder Metal init via `J2KMetalSession.processShared`). Also bumps the stale `getVersion()` constant `10.9.2 → 10.16.0` (drifted across 8 releases). See [RELEASE_NOTES_v10.16.0.md](RELEASE_NOTES_v10.16.0.md). **v10.15.0** adds **`JP3DDecoder.preWarm()`** (and `JP3DROIDecoder.preWarm()`) as discoverable convenience wrappers around the existing `J2KDecoder.preWarm()` (v5.28.0). JP3D consumers inherit the one-shot Metal session init amortisation automatically (per-slice `J2KMetalSession.processShared`), but the canonical preWarm entry point lived only in `J2KCodec` — consumers using the JP3D module surface alone had to import `J2KCodec` separately. v10.15.0 closes this discoverability gap with 2-line static wrappers in `Sources/J2K3D/JP3DDecoder.swift` + `JP3DROIDecoder.swift` that delegate to `J2KDecoder.preWarm(includeWarmupDispatch:)`. Same idempotent semantics, same failure handling (silently caught on Linux where Metal is unavailable). **M2 release, mr_3d_small 128×128×16 LCG JP3D fixture**: first decode in process **28.26 ms** (cold; includes Metal init), first after `JP3DDecoder.preWarm()` **14.60 ms**, warm median **14.33 ms** — **−13.93 ms cold-start savings** that clears the 3 ms acceptance threshold. Savings are constant per process (one-shot init cost) so they're most visible on small-volume JP3D decodes; for consumers doing one-shot decodes (e.g., a DICOM viewer opening one study), this is a real-world latency win delivered through a discoverable API. No perf change on warm paths; codestream bytes byte-identical to v10.14.0; decoder-only. Validation: `V10_26_JP3DPreWarmProfile` 1/1 PASS confirms `JP3DDecoder.preWarm` wires through correctly; full `swift test --filter JP3D` regression sweep **520/520 PASS** (519 pre-existing + 1 new V10_26); mandatory commit gate 7/7 PASS. Two preceding research arcs (v10.24 + v10.25) confirmed M2 + Swift release lever-ceiling on the ROI iDWT optimization space — v10.15.0 pivots to additive discoverability and ships honestly. See [RELEASE_NOTES_v10.15.0.md](RELEASE_NOTES_v10.15.0.md). diff --git a/RELEASE_NOTES_v10.17.0.md b/RELEASE_NOTES_v10.17.0.md new file mode 100644 index 0000000..4c48c57 --- /dev/null +++ b/RELEASE_NOTES_v10.17.0.md @@ -0,0 +1,251 @@ +# J2KSwift v10.17.0 + +**`J2KDICOMHelpers` Phase 1 — new SwiftPM product for DICOM-bridge ergonomics.** +A long-standing product-layer gap for library consumers using their own DICOM +parser (DICOMKit, pydicom-via-XPC, etc.): the consumer gets a DICOM Transfer +Syntax UID + Pixel Data bytes, then has to manually map the UID to a +`J2KEncodingConfiguration` to encode through J2KSwift. v10.17.0 ships a +public, ADR-004-compliant `J2KDICOMHelpers` library that closes that gap: + +- `J2KDICOMTransferSyntax` enum — public, `CaseIterable`, mirrors all seven + DICOM Part 5 Annex A JPEG 2000 / HTJ2K UIDs with `init?(uid:)` / + `var uid` round-trip + `isLossless` / `isHTJ2K` / `isPart2` flags. +- `J2KDICOMTransferSyntax.encodingConfiguration(bitDepth:psnrTarget:)` — + builds a matching `J2KEncodingConfiguration` (lossless/HTJ2K flags + + `htj2kBlockFormat: .conformant` for DICOM interop). +- `J2KDICOMCodestreamDetector.detect(_:)` — sniffs SOC + CAP markers to + classify a raw codestream as Part 1 vs HTJ2K (returns `.j2kLossless` + / `.htj2kLossless` respectively when present, `nil` when not J2K). +- `J2KDICOMPhotometricInterpretation` enum mirror — `CaseIterable`, all + seven DICOM PhotometricInterpretation values relevant to J2K-encoded + pixel data, with bidirectional `J2KColorSpace` mapping. + +MINOR per RELEASING.md — entirely additive: new product, zero touch to +existing libraries, codestream bytes byte-identical to v10.15.0, **no DICOM +library dependency anywhere in J2KSwift**. + +## Summary + +Per [ADR-004](Documentation/ADR/ADR-004-no-dicom-dependency.md), J2KSwift +core libraries don't depend on any DICOM library. The CLI ships a +`DICOMSupport.swift` for `.dcm` file loading (uses a Python fallback for +compressed transfer syntaxes), but the `DICOMTransferSyntax` / +`DICOMTransferSyntaxInfo` types there are CLI-private — not surfaced to +library consumers. v10.17.0 lifts the UID-bridge layer into a new public +`J2KDICOMHelpers` SwiftPM library so consumers parsing DICOM with their +own libraries can stop hand-rolling the UID-to-`J2KEncodingConfiguration` +map. + +```swift +import J2KDICOMHelpers +import J2KCodec + +// 1. Consumer reads a DICOM dataset with their own DICOM library and +// extracts (Transfer Syntax UID, Pixel Data bytes). +let uid = dicomDataset.transferSyntaxUID // e.g., "1.2.840.10008.1.2.4.201" +let pixelData = dicomDataset.pixelData + +// 2. Bridge to J2KSwift via the helpers product: +guard let ts = J2KDICOMTransferSyntax(uid: uid) else { + // Not a JPEG 2000 / HTJ2K UID — fall back to whatever non-J2K path + return +} + +// 3. For encode workflows: get a matching configuration. +let cfg = ts.encodingConfiguration(bitDepth: 16) +let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) + +// 4. For detect workflows: sniff a codestream to identify the Transfer Syntax. +if let detected = J2KDICOMCodestreamDetector.detect(codestream) { + print("Codestream classifies as: \(detected.uid)") // e.g., "1.2.840.10008.1.2.4.201" +} +``` + +## What's New — production-default + +| Public API | v10.16.0 | v10.17.0 | +|---|---|---| +| `J2KDICOMHelpers` SwiftPM product | _not present_ | **NEW** — separate library, opt-in import | +| `J2KDICOMTransferSyntax` enum | _not present_ | **NEW** — 7 DICOM UID cases + UID round-trip + classification flags | +| `J2KDICOMTransferSyntax.encodingConfiguration(bitDepth:psnrTarget:)` | _not present_ | **NEW** — produces a `J2KEncodingConfiguration` whose codestream matches the UID's wire format | +| `J2KDICOMCodestreamDetector.detect(_:)` | _not present_ | **NEW** — SOC + CAP sniff returns matching `J2KDICOMTransferSyntax` or nil | +| `J2KDICOMPhotometricInterpretation` enum | _not present_ | **NEW** — 7 DICOM photometric values + bidirectional `J2KColorSpace` mapping | +| `getVersion()` constant | 10.16.0 | 10.17.0 | +| Every other public API | unchanged | unchanged | + +Existing libraries are not modified. The new product depends only on +`J2KCore` + `J2KCodec` (transitively `J2KMetal` for Metal support) — no +new external dependencies introduced. + +## Backward compatibility + +- **Codestream bytes**: byte-identical to v10.16.0 on every input. Encoder + unchanged. +- **Existing libraries**: zero behaviour change. J2KCore, J2KCodec, + J2K3D, J2KROI, JPIP, etc. are bit-equivalent to v10.16.0. +- **API surface**: additive only — new product `J2KDICOMHelpers` ships + alongside the existing 9. Consumers not importing `J2KDICOMHelpers` + are unaffected. +- **ADR-004 compliant**: no DICOM library dependency added anywhere. + The new helpers product depends only on `J2KCore` + `J2KCodec`. + +## Why the scope is intentionally narrow (Phase 1) + +This release **does not** include: + +- DICOM file parsing — the consumer must parse `.dcm` files with their + own library (or use the macOS-only CLI path via `Sources/J2KCLI/DICOMSupport.swift`). +- DICOM pixel-data extraction (encapsulated pixel data demarshalling + from `(7FE0,0010)`). +- Encapsulated-Pixel-Data writer (J2K codestream → DICOM Pixel Data byte + layout). +- DICOM Information Object Definition (IOD) helpers (Photometric + Interpretation auto-detection from sample values, etc.). + +These belong to Phase 2 (likely v10.18.0) and would either extract more +of the CLI's `DICOMSupport.swift` logic into the helpers product, or ship +a thin DICOMKit adapter as an opt-in sibling product so ADR-004 stays +intact for consumers who don't want the DICOM library dep. + +Phase 1 is the **smallest viable shape** of the helpers product — just the +UID-and-config bridge — so the API surface stabilises early and consumers +can start using it before Phase 2 lands. + +## Test Suite Results + +| Suite | Tests | Result | Coverage | +|---|---:|---|---| +| `V10_29_TransferSyntaxRoundTripTests` | 9/9 | PASS | UID round-trip per case + unique UIDs + isLossless/isHTJ2K/isPart2 truth tables + lenient parsing (NUL/whitespace) + unknown UID returns nil + CaseIterable covers all 7 | +| `V10_29_EncodingConfigurationParityTests` | 5/5 | PASS | Lossless UIDs set `lossless = true` + `useReversibleFilter = true` + `quality = 1.0`; lossy UIDs do not; HTJ2K UIDs set `useHTJ2K = true` + `htj2kBlockFormat = .conformant`; Part-1 UIDs do not; end-to-end bit-exact round-trip for `.j2kLossless` + `.htj2kLossless` | +| `V10_29_CodestreamDetectionTests` | 6/6 | PASS | `.j2kLossless` codestream detected as `.j2kLossless` (no CAP); `.htj2kLossless` codestream detected as `.htj2kLossless` (CAP present); empty/truncated data returns nil; JPEG/PNG/random bytes do NOT misdetect as J2K | +| `V10_29_PhotometricInterpretationTests` | 6/6 | PASS | Raw values match DICOM PS3.3 §C.7.6.3.1.2; `.colorSpace` total mapping; reverse from `J2KColorSpace` returns nil for `.hdr`/`.hdrLinear`/`.iccProfile`/`.unknown`; rawValue round-trip | +| **`V10_29_*` total** | **26/26** | **PASS** | | +| `swift test --filter JP3D` (full regression) | 528/528 | PASS | All 519 pre-existing JP3D tests still green + 9 new (V10_27 probe + V10_28 parity from v10.16.0) + 1 pre-existing skip | +| Mandatory commit gate (release mode) | 7/7 | PASS | `J2KMedicalCorpusEncodePerformanceTests` 2/2 + `J2KMedicalCorpusPerformanceTests` 2/2 + `J2KStrictCrossCodecValidationTests` 3/3 | + +## API surface — additions only + +```swift +import J2KDICOMHelpers + +public enum J2KDICOMTransferSyntax: Sendable, Equatable, Hashable, CaseIterable { + case j2kLossless // 1.2.840.10008.1.2.4.90 + case j2kLossy // 1.2.840.10008.1.2.4.91 + case j2kPart2MulticompLossless // 1.2.840.10008.1.2.4.92 (retired in DICOM 2024c) + case j2kPart2Multicomp // 1.2.840.10008.1.2.4.93 (retired in DICOM 2024c) + case htj2kLossless // 1.2.840.10008.1.2.4.201 + case htj2kLossyConstant // 1.2.840.10008.1.2.4.202 + case htj2kLossy // 1.2.840.10008.1.2.4.203 + + public var uid: String { get } + public init?(uid: String) // whitespace + trailing-NUL tolerant + public var isLossless: Bool { get } + public var isHTJ2K: Bool { get } + public var isPart2: Bool { get } + + public func encodingConfiguration( + bitDepth: Int = 16, + psnrTarget: Double = 40.0 + ) -> J2KEncodingConfiguration +} + +public enum J2KDICOMCodestreamDetector { + public static func detect(_ codestream: Data) -> J2KDICOMTransferSyntax? +} + +public enum J2KDICOMPhotometricInterpretation: + String, Sendable, Equatable, Hashable, CaseIterable +{ + case monochrome1, monochrome2 // greyscale (0=white / 0=black) + case rgb // sRGB + case yBrFull, yBrFull422 // full-range YCbCr (no chroma subsampling / 4:2:2) + case yBrRct // YCbCr via JPEG 2000 reversible RCT (lossless 5/3) + case yBrIct // YCbCr via JPEG 2000 irreversible ICT (lossy 9/7) + + public var colorSpace: J2KColorSpace { get } + public init?(colorSpace: J2KColorSpace) // nil for hdr/hdrLinear/iccProfile/unknown +} +``` + +No removals. No existing signatures changed. + +## Recommended usage + +```swift +import J2KDICOMHelpers +import J2KCodec + +// Encode an image to a DICOM-compatible HTJ2K lossless codestream: +let ts = J2KDICOMTransferSyntax.htj2kLossless +let cfg = ts.encodingConfiguration(bitDepth: 16) +let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) +// codestream is byte-compatible with DICOM Transfer Syntax UID 1.2.840.10008.1.2.4.201 + +// Identify an unknown codestream's Transfer Syntax: +let detected = J2KDICOMCodestreamDetector.detect(codestream) +// detected == .htj2kLossless + +// Bridge a PhotometricInterpretation tag from a DICOM dataset to J2KColorSpace: +let pi = J2KDICOMPhotometricInterpretation(rawValue: "MONOCHROME2") +let colorSpace = pi?.colorSpace // .grayscale +``` + +## Known limitations + +- **Sniff ambiguity**: a 5/3 (lossless) codestream is bit-identical + regardless of whether it was tagged with the Part-1 Lossless UID + (`...4.90`) or the Part-1 Lossless-or-Lossy UID (`...4.91`) used in + its lossless mode. Detection alone cannot distinguish — the detector + reports the canonical lossless variant. Consumers with the source + Transfer Syntax UID should prefer `J2KDICOMTransferSyntax(uid:)` over + sniffing. +- **No file parsing**: this Phase 1 does not parse `.dcm` files. CLI + consumers can use `Sources/J2KCLI/DICOMSupport.swift`'s `loadDICOM(_:)` + (macOS, with a Python fallback for compressed transfer syntaxes). + Library consumers should pair `J2KDICOMHelpers` with their own DICOM + parser. +- **Encapsulated pixel data**: Phase 1 doesn't (de)serialise the DICOM + `(7FE0,0010)` Pixel Data Item byte layout (BOT, fragment items, etc.). + Phase 2 territory. +- **Lossy bit-rate control**: `encodingConfiguration(psnrTarget:)` maps + to the encoder's `quality` field via a rough rule of thumb + (`psnrTarget / 50.0`). Consumers wanting a precise R-D operating + point should construct `J2KEncodingConfiguration` directly with + `bitrateMode = .constantBitrate(...)`. + +## Reproducing the test numbers + +```bash +swift test -c release --filter "V10_29" +``` + +26 tests across 4 suites (TransferSyntaxRoundTrip 9 + EncodingConfigurationParity 5 ++ CodestreamDetection 6 + PhotometricInterpretation 6) — all PASS in ~0.06 s +release mode. + +## Backward upgrade + +`swift package update` won't auto-pick this release if your `Package.swift` +pins an exact version; bump the requirement to `from: "10.17.0"`. To use +the new helpers, add `J2KDICOMHelpers` to a target's product list: + +```swift +.target( + name: "YourMedicalApp", + dependencies: [ + .product(name: "J2KCodec", package: "J2KSwift"), + .product(name: "J2KDICOMHelpers", package: "J2KSwift"), // v10.17.0+ + ]) +``` + +Consumers not adding the product are completely unaffected. + +## Companion — AsyncSequence progress streams deferred + +Phase 3 of the original v10.17.0 plan proposed AsyncSequence progress +streams (`progressStream()` extensions) on `JP3DDecoder` / `JP3DROIDecoder` +/ `JP3DEncoder` as a modern alternative to `setProgressCallback(_:)`. +The lifecycle bridging (continuation termination → clear actor-stored +callback; continuation retention semantics; intermixed-with-closure +edge cases) warrants a dedicated MINOR ship rather than being squeezed +into v10.17.0. Deferred to v10.18.0 or later. diff --git a/Sources/J2KCore/J2KCore.swift b/Sources/J2KCore/J2KCore.swift index 58aa9c2..33457be 100644 --- a/Sources/J2KCore/J2KCore.swift +++ b/Sources/J2KCore/J2KCore.swift @@ -888,5 +888,5 @@ public struct J2KConfiguration: Sendable { /// /// - Returns: A string representing the current version in semver format. public func getVersion() -> String { - "10.16.0" + "10.17.0" } diff --git a/Sources/J2KDICOMHelpers/J2KDICOMCodestreamDetector.swift b/Sources/J2KDICOMHelpers/J2KDICOMCodestreamDetector.swift new file mode 100644 index 0000000..b0d6d93 --- /dev/null +++ b/Sources/J2KDICOMHelpers/J2KDICOMCodestreamDetector.swift @@ -0,0 +1,126 @@ +// J2KDICOMCodestreamDetector.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 +// +// Sniffs the leading marker bytes of a JPEG 2000 codestream to classify +// it into one of the DICOM Transfer Syntax categories. Detects: +// +// - SOC marker (0xFF 0x4F) — required first two bytes of any J2K codestream +// - CAP marker (0xFF 0x50) — Extended Capabilities marker; presence implies +// Part 15 (HTJ2K). Absent in Part 1 codestreams. +// - SIZ marker (0xFF 0x51) — image-size marker; required, immediately +// following SOC. Used here to skip past the SIZ segment so the scanner +// can find CAP downstream. +// +// Lossless vs lossy detection looks inside the COD marker (progression / +// quantisation hints) but those aren't deterministically present at sniff +// time (the COD's `qmfbid` field disambiguates 5/3 from 9/7), so the +// detector returns the LOSSLESS variant when CAP is present (HTJ2K) and +// the LOSSLESS Part-1 variant when CAP is absent. Callers needing the +// exact lossy/lossless distinction should inspect the source's DICOM +// Transfer Syntax UID directly (the codestream alone is ambiguous in +// the lossless 5/3 case — a 5/3 codestream is bit-exact lossless but +// can also be a degenerate output of the lossy-tagged UID). + +import Foundation +import J2KCore + +/// Inspects a JPEG 2000 codestream to identify the most likely matching +/// DICOM Transfer Syntax. +public enum J2KDICOMCodestreamDetector { + + /// Scans `codestream` for SOC + (optional) CAP markers and returns + /// the matching DICOM Transfer Syntax category. + /// + /// Returns `nil` when the bytes don't begin with the SOC marker + /// (`0xFF 0x4F`), i.e., the input isn't a raw J2K codestream at all. + /// + /// **Important — sniff ambiguity**: a 5/3 (lossless) codestream can be + /// the output of either a Part 1 lossless-only UID + /// (`1.2.840.10008.1.2.4.90`) or a Part 1 lossy UID + /// (`1.2.840.10008.1.2.4.91`) used in its lossless mode. Without the + /// original DICOM dataset's Transfer Syntax UID, the codestream alone + /// can't distinguish these — the detector reports the LOSSLESS + /// variant (`.j2kLossless` / `.htj2kLossless`) as the canonical + /// guess. Callers who have the source UID should prefer + /// `J2KDICOMTransferSyntax(uid:)` over sniffing. + /// + /// - Parameter codestream: Raw bytes of a J2K codestream (post-`jp2c` / + /// stripped of any container box framing). + /// - Returns: The detected Transfer Syntax, or `nil` if the bytes + /// don't look like a J2K codestream. + public static func detect(_ codestream: Data) -> J2KDICOMTransferSyntax? { + // 1. Verify SOC marker. + guard codestream.count >= 4 else { return nil } + guard readMarker(codestream, at: 0) == J2KMarker.soc.rawValue else { + return nil + } + + // 2. Skip past SIZ (which immediately follows SOC) and check whether + // the next marker is CAP (HTJ2K) or something else (Part 1). + // SIZ has a 2-byte length field at offset 4 (after marker). + guard codestream.count >= 6 else { + // SOC present but truncated — caller has a malformed codestream. + // Conservative classification: assume legacy Part 1 lossless. + return .j2kLossless + } + + // SOC at [0..2], SIZ marker at [2..4], SIZ length at [4..6]. + guard readMarker(codestream, at: 2) == J2KMarker.siz.rawValue else { + // Atypical layout (e.g., COM marker before SIZ); fall back to + // a forward scan in the first 256 bytes. + return scanForCAP(in: codestream, upTo: min(256, codestream.count)) + ? .htj2kLossless + : .j2kLossless + } + let sizLength = readU16BE(codestream, at: 4) + // SIZ marker (2) + length field (2) + payload (sizLength - 2) + // The `length` field reports the bytes from itself to the end of + // the segment payload, inclusive of itself, so the next marker + // starts at offset `2 + 2 + (sizLength)` = `4 + sizLength`. + let postSIZ = 4 + Int(sizLength) + guard postSIZ + 2 <= codestream.count else { return .j2kLossless } + + // 3. The marker immediately after SIZ is typically CAP (HTJ2K) or + // COD/COM/etc. (Part 1). + if readMarker(codestream, at: postSIZ) == J2KMarker.cap.rawValue { + return .htj2kLossless + } + // Some HTJ2K encoders emit COM (private) before CAP — do a brief + // forward scan within the first 256 bytes to confirm. + if scanForCAP(in: codestream, upTo: min(256, codestream.count)) { + return .htj2kLossless + } + return .j2kLossless + } + + // MARK: - Private helpers + + private static func readMarker(_ data: Data, at offset: Int) -> UInt16 { + return readU16BE(data, at: offset) + } + + private static func readU16BE(_ data: Data, at offset: Int) -> UInt16 { + precondition(offset + 2 <= data.count) + let hi = UInt16(data[data.startIndex + offset]) + let lo = UInt16(data[data.startIndex + offset + 1]) + return (hi << 8) | lo + } + + /// Forward-scan for the CAP marker (0xFF 0x50) in the first `limit` + /// bytes of `data`. Returns `true` if found. Bounded scan keeps the + /// sniff cheap — the CAP marker, when present, is always among the + /// first handful of markers in the main header. + private static func scanForCAP(in data: Data, upTo limit: Int) -> Bool { + let end = min(limit, data.count) + guard end >= 2 else { return false } + // Walk byte-by-byte looking for the marker pair. Most J2K + // markers are unique in the prefix so this is overwhelmingly + // accurate within the bounded window. + for i in 0..<(end - 1) { + let m = readU16BE(data, at: i) + if m == J2KMarker.cap.rawValue { return true } + } + return false + } +} diff --git a/Sources/J2KDICOMHelpers/J2KDICOMPhotometricInterpretation.swift b/Sources/J2KDICOMHelpers/J2KDICOMPhotometricInterpretation.swift new file mode 100644 index 0000000..7f4f3f8 --- /dev/null +++ b/Sources/J2KDICOMHelpers/J2KDICOMPhotometricInterpretation.swift @@ -0,0 +1,89 @@ +// J2KDICOMPhotometricInterpretation.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 +// +// Public enum mirror of the DICOM `(0028,0004) PhotometricInterpretation` +// element values that are relevant to JPEG 2000 / HTJ2K-encoded pixel +// data. Provides a mapping to/from `J2KCore.J2KColorSpace`. +// +// ADR-004 compliant: no DICOM library dependency. The string raw values +// are the DICOM-defined constants (PS3.3 §C.7.6.3.1.2). + +import Foundation +import J2KCore + +/// DICOM Photometric Interpretation values relevant to JPEG 2000 / +/// HTJ2K-encoded pixel data. +/// +/// The raw values are the canonical DICOM-defined string constants per +/// DICOM PS3.3 §C.7.6.3.1.2. +public enum J2KDICOMPhotometricInterpretation: String, Sendable, Equatable, Hashable, CaseIterable { + + /// Single-component greyscale where 0 is white (less common). + case monochrome1 = "MONOCHROME1" + + /// Single-component greyscale where 0 is black (the medical-imaging norm). + case monochrome2 = "MONOCHROME2" + + /// Three-component RGB (sRGB by convention). + case rgb = "RGB" + + /// Three-component YCbCr in full-range encoding (BT.601 / BT.709 colour + /// primaries; encoded values span 0–255 / 0–4095, no head/foot room). + case yBrFull = "YBR_FULL" + + /// Three-component YCbCr with 2x1 horizontal chroma subsampling + /// (4:2:2 layout). Common in JPEG-derived pixel data. + case yBrFull422 = "YBR_FULL_422" + + /// YCbCr derived via the JPEG 2000 Reversible Colour Transform (RCT). + /// Pairs with `J2KEncodingConfiguration(lossless: true)` (5/3 + + /// reversible RCT). + case yBrRct = "YBR_RCT" + + /// YCbCr derived via the JPEG 2000 Irreversible Colour Transform (ICT). + /// Pairs with `J2KEncodingConfiguration(lossless: false)` (9/7 + + /// irreversible ICT). + case yBrIct = "YBR_ICT" + + // MARK: - J2KColorSpace bridge + + /// Returns the corresponding `J2KColorSpace`. + /// + /// MONOCHROME1 and MONOCHROME2 both map to `.grayscale`; the inversion + /// semantics of MONOCHROME1 are a display-time concern, not a + /// colour-space distinction at the codec layer. + /// + /// RGB maps to `.sRGB`. All YCbCr variants (full-range, 4:2:2, RCT, + /// ICT) map to `.yCbCr`; the encode-time codec-transform choice + /// (`useReversibleFilter`) is what distinguishes RCT from ICT, not + /// the colour-space enum value. + public var colorSpace: J2KColorSpace { + switch self { + case .monochrome1, .monochrome2: + return .grayscale + case .rgb: + return .sRGB + case .yBrFull, .yBrFull422, .yBrRct, .yBrIct: + return .yCbCr + } + } + + /// Reverse mapping from `J2KColorSpace`. Returns `nil` for + /// colour-space values that don't have an unambiguous DICOM + /// counterpart (`.hdr`, `.hdrLinear`, `.iccProfile(_)`, `.unknown`). + /// + /// For `.grayscale` returns `.monochrome2` (the medical norm). + /// For `.yCbCr` returns `.yBrFull` (the most general DICOM YCbCr + /// variant; callers needing the lossless-RCT or lossy-ICT distinction + /// should construct the enum directly). + public init?(colorSpace: J2KColorSpace) { + switch colorSpace { + case .grayscale: self = .monochrome2 + case .sRGB: self = .rgb + case .yCbCr: self = .yBrFull + case .hdr, .hdrLinear, .iccProfile, .unknown: + return nil + } + } +} diff --git a/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax+EncodingConfiguration.swift b/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax+EncodingConfiguration.swift new file mode 100644 index 0000000..ef9dbe3 --- /dev/null +++ b/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax+EncodingConfiguration.swift @@ -0,0 +1,85 @@ +// J2KDICOMTransferSyntax+EncodingConfiguration.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 +// +// Maps a DICOM Transfer Syntax UID enum to a J2KEncodingConfiguration that, +// when used with `J2KEncoder`, produces a codestream that's compatible with +// the requested UID's wire format (Part 1 vs Part 15 / lossless vs lossy). +// +// Mirrors the pattern in Sources/J2KCLI/DICOMSupport.swift:217-240 but +// lifts it to a public, library-consumer-callable API. + +import Foundation +import J2KCore +import J2KCodec + +extension J2KDICOMTransferSyntax { + + /// Builds a `J2KEncodingConfiguration` whose output codestream matches + /// this DICOM Transfer Syntax's wire format. + /// + /// For lossless UIDs, the returned configuration sets `lossless = true` + /// (which forces `useReversibleFilter = true` per `J2KEncodingConfiguration`'s + /// invariant) and `quality = 1.0`. For lossy UIDs, it sets a default + /// `quality = psnrTarget / 50.0` (clamped to `[0.0, 1.0]`), `lossless = false`, + /// and `useReversibleFilter = false` (selecting the irreversible 9/7 + /// DWT + ICT path). + /// + /// HTJ2K UIDs set `useHTJ2K = true` and `htj2kBlockFormat = .conformant` + /// (so the resulting codestream is interoperable with OpenJPH 0.26+ and + /// other Part-15 decoders — see [J2KEncodingConfiguration.htj2kBlockFormat] + /// docs for the trade-off). + /// + /// Other knobs default to `J2KEncodingConfiguration`'s defaults — callers + /// who need to override (e.g., a specific number of decomposition levels, + /// a different progression order, a tile grid, multi-layer) should + /// construct `J2KEncodingConfiguration` directly with the returned + /// configuration as a starting point. + /// + /// ## Usage + /// + /// ```swift + /// import J2KDICOMHelpers + /// import J2KCodec + /// + /// let ts = J2KDICOMTransferSyntax(uid: "1.2.840.10008.1.2.4.201")! + /// let cfg = ts.encodingConfiguration(bitDepth: 16) + /// let encoder = J2KEncoder(encodingConfiguration: cfg) + /// let codestream = try await encoder.encode(image) + /// ``` + /// + /// - Parameters: + /// - bitDepth: The bit depth of the source image (e.g., 8, 12, 16). + /// Used only as a hint for default code-block sizing; + /// not embedded in the configuration (the image's + /// per-component bit depth is the authoritative value + /// at encode time). + /// - psnrTarget: Target PSNR for lossy UIDs (default 40 dB). + /// Ignored for lossless UIDs. + /// - Returns: A `J2KEncodingConfiguration` matching this Transfer Syntax. + public func encodingConfiguration( + bitDepth: Int = 16, + psnrTarget: Double = 40.0 + ) -> J2KEncodingConfiguration { + let quality: Double + if isLossless { + quality = 1.0 + } else { + // Map a PSNR target into the [0, 1] quality scale that the + // encoder accepts. The mapping is a rough rule-of-thumb + // anchored at 50 dB ↔ quality 1.0 (effectively lossless on + // most natural images). Callers who want a precise R-D + // operating point should construct J2KEncodingConfiguration + // directly and set `bitrateMode = .constantBitrate(...)`. + quality = max(0.0, min(1.0, psnrTarget / 50.0)) + } + _ = bitDepth // reserved for future block-size heuristics + + return J2KEncodingConfiguration( + quality: quality, + lossless: isLossless, + useHTJ2K: isHTJ2K, + useReversibleFilter: isLossless, + htj2kBlockFormat: .conformant) + } +} diff --git a/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax.swift b/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax.swift new file mode 100644 index 0000000..040e60b --- /dev/null +++ b/Sources/J2KDICOMHelpers/J2KDICOMTransferSyntax.swift @@ -0,0 +1,177 @@ +// J2KDICOMTransferSyntax.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 +// +// Public enum mirroring the DICOM Transfer Syntax UIDs that designate +// JPEG 2000 (Part 1) and HTJ2K (Part 15) codestreams as the pixel-data +// encoding. Consumers parsing DICOM files with their own DICOM library +// (DICOMKit, pydicom via XPC, etc.) get a Transfer Syntax UID + Pixel +// Data bytes, and can then bridge to J2KSwift via this enum's +// `encodingConfiguration()` helper (defined in the companion extension). +// +// ADR-004 compliant: no DICOM library dependency anywhere in J2KSwift. +// This module depends only on J2KCore + J2KCodec. Consumers who don't +// need DICOM ergonomics simply don't import J2KDICOMHelpers. + +import Foundation + +/// DICOM Transfer Syntax UIDs that designate JPEG 2000 / HTJ2K codestreams. +/// +/// These are the seven UIDs currently registered with DICOM Standard +/// Part 5 (Annex A) under the JPEG 2000 family. Lossy variants are +/// included for completeness, but note that J2KSwift's primary product +/// target (per [feedback_lossless_only_v5_38]) is exact-reconstruction +/// medical archive — lossy support exists but isn't an active +/// development focus. +/// +/// ## Usage +/// +/// ```swift +/// import J2KDICOMHelpers +/// +/// // From a UID string (typical: read by a DICOM parser) +/// guard let ts = J2KDICOMTransferSyntax(uid: "1.2.840.10008.1.2.4.201") else { +/// // Not a recognised JPEG 2000 / HTJ2K UID +/// return +/// } +/// assert(ts == .htj2kLossless) +/// +/// // Round-trip the UID +/// assert(ts.uid == "1.2.840.10008.1.2.4.201") +/// +/// // Classify +/// if ts.isHTJ2K && ts.isLossless { ... } +/// +/// // Build a matching J2KEncodingConfiguration (see companion extension) +/// let cfg = ts.encodingConfiguration(bitDepth: 16) +/// ``` +public enum J2KDICOMTransferSyntax: Sendable, Equatable, Hashable, CaseIterable { + + /// JPEG 2000 Image Compression (Lossless Only) — Part 1, reversible 5/3 DWT. + /// + /// DICOM UID: `1.2.840.10008.1.2.4.90` + case j2kLossless + + /// JPEG 2000 Image Compression — Part 1, lossy 9/7 DWT (also covers lossless). + /// + /// DICOM UID: `1.2.840.10008.1.2.4.91` + case j2kLossy + + /// JPEG 2000 Part 2 Multi-component Image Compression (Lossless Only). + /// + /// DICOM UID: `1.2.840.10008.1.2.4.92` + /// + /// - Note: Retired in DICOM 2024c. Retained for backward compatibility + /// with archives produced under earlier DICOM editions. + case j2kPart2MulticompLossless + + /// JPEG 2000 Part 2 Multi-component Image Compression (Lossless or Lossy). + /// + /// DICOM UID: `1.2.840.10008.1.2.4.93` + /// + /// - Note: Retired in DICOM 2024c. Retained for backward compatibility. + case j2kPart2Multicomp + + /// High-Throughput JPEG 2000 (HTJ2K) Image Compression (Lossless Only) — + /// Part 15, reversible 5/3 DWT. + /// + /// DICOM UID: `1.2.840.10008.1.2.4.201` + case htj2kLossless + + /// High-Throughput JPEG 2000 (HTJ2K) Image Compression with Reversible + /// Codestream Containment Wavelet (RPCL) — Part 15, with rate-controlled + /// constant-bitrate output. + /// + /// DICOM UID: `1.2.840.10008.1.2.4.202` + case htj2kLossyConstant + + /// High-Throughput JPEG 2000 (HTJ2K) Image Compression — Part 15, + /// lossy 9/7 DWT. + /// + /// DICOM UID: `1.2.840.10008.1.2.4.203` + case htj2kLossy + + // MARK: - UID Round-trip + + /// Returns the canonical DICOM Transfer Syntax UID string. + public var uid: String { + switch self { + case .j2kLossless: return "1.2.840.10008.1.2.4.90" + case .j2kLossy: return "1.2.840.10008.1.2.4.91" + case .j2kPart2MulticompLossless: return "1.2.840.10008.1.2.4.92" + case .j2kPart2Multicomp: return "1.2.840.10008.1.2.4.93" + case .htj2kLossless: return "1.2.840.10008.1.2.4.201" + case .htj2kLossyConstant: return "1.2.840.10008.1.2.4.202" + case .htj2kLossy: return "1.2.840.10008.1.2.4.203" + } + } + + /// Creates a `J2KDICOMTransferSyntax` from a DICOM UID string. + /// + /// Returns `nil` when the UID isn't one of the seven JPEG 2000 / HTJ2K + /// UIDs registered in DICOM Part 5 Annex A. + /// + /// The lookup is whitespace- and trailing-null-tolerant — DICOM Value + /// Representations of UID type may be padded with a trailing NUL byte + /// to maintain even length per PS3.5 §6.2. + public init?(uid: String) { + let trimmed = uid.trimmingCharacters( + in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "\0"))) + switch trimmed { + case "1.2.840.10008.1.2.4.90": self = .j2kLossless + case "1.2.840.10008.1.2.4.91": self = .j2kLossy + case "1.2.840.10008.1.2.4.92": self = .j2kPart2MulticompLossless + case "1.2.840.10008.1.2.4.93": self = .j2kPart2Multicomp + case "1.2.840.10008.1.2.4.201": self = .htj2kLossless + case "1.2.840.10008.1.2.4.202": self = .htj2kLossyConstant + case "1.2.840.10008.1.2.4.203": self = .htj2kLossy + default: return nil + } + } + + // MARK: - Classification + + /// `true` when this Transfer Syntax produces a bit-exact reversible + /// codestream (5/3 wavelet + reversible colour transform). + public var isLossless: Bool { + switch self { + case .j2kLossless, + .j2kPart2MulticompLossless, + .htj2kLossless: + return true + case .j2kLossy, + .j2kPart2Multicomp, + .htj2kLossyConstant, + .htj2kLossy: + return false + } + } + + /// `true` when this Transfer Syntax uses HTJ2K (Part 15) block coding + /// rather than the legacy EBCOT entropy coder of Part 1. + public var isHTJ2K: Bool { + switch self { + case .htj2kLossless, + .htj2kLossyConstant, + .htj2kLossy: + return true + case .j2kLossless, + .j2kLossy, + .j2kPart2MulticompLossless, + .j2kPart2Multicomp: + return false + } + } + + /// `true` when the Transfer Syntax is a JPEG 2000 Part 2 multi-component + /// extension (now retired from DICOM 2024c, but archives may still + /// reference these UIDs). + public var isPart2: Bool { + switch self { + case .j2kPart2MulticompLossless, .j2kPart2Multicomp: + return true + default: + return false + } + } +} diff --git a/Tests/J2KDICOMHelpersTests/V10_29_CodestreamDetectionTests.swift b/Tests/J2KDICOMHelpersTests/V10_29_CodestreamDetectionTests.swift new file mode 100644 index 0000000..946b0a7 --- /dev/null +++ b/Tests/J2KDICOMHelpersTests/V10_29_CodestreamDetectionTests.swift @@ -0,0 +1,97 @@ +// V10_29_CodestreamDetectionTests.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 parity gate. +// +// Encodes a small image via each supported Transfer Syntax's +// configuration, then asserts that `J2KDICOMCodestreamDetector.detect` +// returns the matching Transfer Syntax. This validates that the +// detector's CAP-marker check correctly distinguishes Part-1 vs Part-15 +// codestreams produced by J2KSwift's own encoder. + +import XCTest +import Foundation +@testable import J2KDICOMHelpers +@testable import J2KCore +@testable import J2KCodec + +final class V10_29_CodestreamDetectionTests: XCTestCase { + + private func makeTestImage( + width: Int = 64, height: Int = 64, seed: UInt64 = 0x1234_5678 + ) -> J2KImage { + var bytes = Data(count: width * height * 2) + var s = seed &* 6364136223846793005 &+ 1442695040888963407 + bytes.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + for i in 0..<(width * height) { + s = s &* 6364136223846793005 &+ 1442695040888963407 + p[i] = UInt16(truncatingIfNeeded: Int((s >> 16) & 0xFFFF)) + } + } + let comp = J2KComponent( + index: 0, bitDepth: 16, signed: false, + width: width, height: height, + data: bytes, sampleByteOrder: .bigEndian) + return J2KImage(width: width, height: height, components: [comp]) + } + + // MARK: - Positive detection + + func testDetectJ2KLossless() async throws { + let image = makeTestImage() + let cfg = J2KDICOMTransferSyntax.j2kLossless.encodingConfiguration() + let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) + + let detected = J2KDICOMCodestreamDetector.detect(codestream) + XCTAssertEqual(detected, .j2kLossless, + "Codestream produced by .j2kLossless configuration should detect as .j2kLossless (no CAP marker).") + } + + func testDetectHTJ2KLossless() async throws { + let image = makeTestImage() + let cfg = J2KDICOMTransferSyntax.htj2kLossless.encodingConfiguration() + let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) + + let detected = J2KDICOMCodestreamDetector.detect(codestream) + XCTAssertEqual(detected, .htj2kLossless, + "Codestream produced by .htj2kLossless configuration should detect as .htj2kLossless (CAP marker present).") + } + + // MARK: - Negative — non-J2K bytes + + func testEmptyDataReturnsNil() { + XCTAssertNil(J2KDICOMCodestreamDetector.detect(Data())) + } + + func testTruncatedDataReturnsNil() { + XCTAssertNil(J2KDICOMCodestreamDetector.detect(Data([0xFF]))) + } + + func testNonJ2KBytesReturnNil() { + // JPEG SOI marker — recognisable image format, but not J2K. + let jpegSOI = Data([0xFF, 0xD8, 0xFF, 0xE0]) + XCTAssertNil(J2KDICOMCodestreamDetector.detect(jpegSOI), + "JPEG file (starting with SOI 0xFFD8) must NOT be misdetected as J2K.") + + // PNG signature. + let pngSig = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + XCTAssertNil(J2KDICOMCodestreamDetector.detect(pngSig), + "PNG file must NOT be misdetected as J2K.") + + // Random non-codestream bytes. + let random = Data([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) + XCTAssertNil(J2KDICOMCodestreamDetector.detect(random)) + } + + // MARK: - Boundary + + func testSOCOnlyTruncatedReturnsFallback() { + // SOC marker but truncated immediately after. Conservative + // classification: fall back to legacy Part-1 lossless. + let socOnly = Data([0xFF, 0x4F]) + let detected = J2KDICOMCodestreamDetector.detect(socOnly) + // Either nil (too short) or .j2kLossless (conservative fallback) + // are acceptable. The contract says nil for `count < 4`. + XCTAssertNil(detected) + } +} diff --git a/Tests/J2KDICOMHelpersTests/V10_29_EncodingConfigurationParityTests.swift b/Tests/J2KDICOMHelpersTests/V10_29_EncodingConfigurationParityTests.swift new file mode 100644 index 0000000..192a002 --- /dev/null +++ b/Tests/J2KDICOMHelpersTests/V10_29_EncodingConfigurationParityTests.swift @@ -0,0 +1,109 @@ +// V10_29_EncodingConfigurationParityTests.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 parity gate. +// +// Verifies that for each lossless Transfer Syntax, the configuration +// returned by `encodingConfiguration(...)` actually drives the encoder +// into the right mode (HTJ2K vs Part 1, reversible vs irreversible), +// and produces a codestream that bit-exactly round-trips through the +// decoder. + +import XCTest +import Foundation +@testable import J2KDICOMHelpers +@testable import J2KCore +@testable import J2KCodec + +final class V10_29_EncodingConfigurationParityTests: XCTestCase { + + private func makeTestImage( + width: Int = 64, height: Int = 64, seed: UInt64 = 0xCAFEBABE + ) -> J2KImage { + var bytes = Data(count: width * height * 2) + var s = seed &* 6364136223846793005 &+ 1442695040888963407 + bytes.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + for i in 0..<(width * height) { + s = s &* 6364136223846793005 &+ 1442695040888963407 + p[i] = UInt16(truncatingIfNeeded: Int((s >> 16) & 0xFFFF)) + } + } + let comp = J2KComponent( + index: 0, bitDepth: 16, signed: false, + width: width, height: height, + data: bytes, sampleByteOrder: .bigEndian) + return J2KImage(width: width, height: height, components: [comp]) + } + + // MARK: - Configuration flag parity + + func testLosslessUIDsProduceLosslessConfiguration() { + for ts in J2KDICOMTransferSyntax.allCases where ts.isLossless { + let cfg = ts.encodingConfiguration(bitDepth: 16) + XCTAssertTrue(cfg.lossless, + "Lossless Transfer Syntax \(ts) must produce a lossless configuration.") + XCTAssertTrue(cfg.useReversibleFilter, + "Lossless Transfer Syntax \(ts) must use the reversible 5/3 filter.") + XCTAssertEqual(cfg.quality, 1.0, + "Lossless Transfer Syntax \(ts) must produce maximum quality.") + } + } + + func testLossyUIDsProduceLossyConfiguration() { + for ts in J2KDICOMTransferSyntax.allCases where !ts.isLossless { + let cfg = ts.encodingConfiguration(bitDepth: 16, psnrTarget: 40.0) + XCTAssertFalse(cfg.lossless, + "Lossy Transfer Syntax \(ts) must produce a lossy configuration.") + } + } + + func testHTJ2KUIDsSetHTJ2KFlag() { + for ts in J2KDICOMTransferSyntax.allCases where ts.isHTJ2K { + let cfg = ts.encodingConfiguration(bitDepth: 16) + XCTAssertTrue(cfg.useHTJ2K, + "HTJ2K Transfer Syntax \(ts) must set useHTJ2K = true.") + XCTAssertEqual(cfg.htj2kBlockFormat, .conformant, + "HTJ2K Transfer Syntax \(ts) must use the conformant Part-15 block format for DICOM interop.") + } + } + + func testPart1UIDsDoNotSetHTJ2KFlag() { + for ts in J2KDICOMTransferSyntax.allCases where !ts.isHTJ2K { + let cfg = ts.encodingConfiguration(bitDepth: 16) + XCTAssertFalse(cfg.useHTJ2K, + "Part-1 Transfer Syntax \(ts) must NOT set useHTJ2K (would emit a CAP marker).") + } + } + + // MARK: - End-to-end round-trip parity + + /// For each lossless Transfer Syntax that the codec supports + /// end-to-end (j2kLossless, htj2kLossless), encode-then-decode a + /// synthetic 64×64 16-bit image with the configuration the helper + /// builds, and assert bit-exact reconstruction. + func testLosslessRoundTripBitExact() async throws { + // Restrict to the two production-supported lossless flavours: + // j2kLossless (5/3 + EBCOT) and htj2kLossless (5/3 + HT). + // The Part-2 multi-component variants and the lossy variants + // need additional config knobs not in scope for Phase 1. + let cases: [J2KDICOMTransferSyntax] = [.j2kLossless, .htj2kLossless] + + let image = makeTestImage() + let originalBytes = image.components[0].data + + for ts in cases { + let cfg = ts.encodingConfiguration(bitDepth: 16) + let encoder = J2KEncoder(encodingConfiguration: cfg) + let codestream = try await encoder.encode(image) + XCTAssertGreaterThan(codestream.count, 0, + "\(ts) encode produced empty codestream.") + + let decoder = J2KDecoder() + let decoded = try await decoder.decode(codestream) + XCTAssertEqual(decoded.components.count, 1, + "\(ts) decode lost components.") + XCTAssertEqual(decoded.components[0].data, originalBytes, + "\(ts) lossless round-trip is not bit-exact.") + } + } +} diff --git a/Tests/J2KDICOMHelpersTests/V10_29_PhotometricInterpretationTests.swift b/Tests/J2KDICOMHelpersTests/V10_29_PhotometricInterpretationTests.swift new file mode 100644 index 0000000..b9c7194 --- /dev/null +++ b/Tests/J2KDICOMHelpersTests/V10_29_PhotometricInterpretationTests.swift @@ -0,0 +1,63 @@ +// V10_29_PhotometricInterpretationTests.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 parity gate. +// +// Verifies the J2KDICOMPhotometricInterpretation ↔ J2KColorSpace +// mapping behaves as documented: +// - Raw DICOM string values match PS3.3 §C.7.6.3.1.2 +// - `colorSpace` is total and well-typed +// - reverse init returns nil for non-DICOM-mappable colour spaces +// (hdr, hdrLinear, iccProfile, unknown) +// - all 7 PhotometricInterpretation cases are covered by CaseIterable + +import XCTest +import Foundation +@testable import J2KDICOMHelpers +@testable import J2KCore + +final class V10_29_PhotometricInterpretationTests: XCTestCase { + + func testRawValuesMatchDICOMStandard() { + XCTAssertEqual(J2KDICOMPhotometricInterpretation.monochrome1.rawValue, "MONOCHROME1") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.monochrome2.rawValue, "MONOCHROME2") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.rgb.rawValue, "RGB") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrFull.rawValue, "YBR_FULL") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrFull422.rawValue, "YBR_FULL_422") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrRct.rawValue, "YBR_RCT") + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrIct.rawValue, "YBR_ICT") + } + + func testCaseIterableCoversAllSeven() { + XCTAssertEqual(J2KDICOMPhotometricInterpretation.allCases.count, 7) + } + + func testColorSpaceMapping() { + XCTAssertEqual(J2KDICOMPhotometricInterpretation.monochrome1.colorSpace, .grayscale) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.monochrome2.colorSpace, .grayscale) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.rgb.colorSpace, .sRGB) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrFull.colorSpace, .yCbCr) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrFull422.colorSpace, .yCbCr) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrRct.colorSpace, .yCbCr) + XCTAssertEqual(J2KDICOMPhotometricInterpretation.yBrIct.colorSpace, .yCbCr) + } + + func testReverseFromColorSpaceWhenUnambiguous() { + XCTAssertEqual(J2KDICOMPhotometricInterpretation(colorSpace: .grayscale), .monochrome2) + XCTAssertEqual(J2KDICOMPhotometricInterpretation(colorSpace: .sRGB), .rgb) + XCTAssertEqual(J2KDICOMPhotometricInterpretation(colorSpace: .yCbCr), .yBrFull) + } + + func testReverseFromColorSpaceReturnsNilForUnmappable() { + XCTAssertNil(J2KDICOMPhotometricInterpretation(colorSpace: .hdr)) + XCTAssertNil(J2KDICOMPhotometricInterpretation(colorSpace: .hdrLinear)) + XCTAssertNil(J2KDICOMPhotometricInterpretation(colorSpace: .iccProfile(Data()))) + XCTAssertNil(J2KDICOMPhotometricInterpretation(colorSpace: .unknown)) + } + + func testRawValueRoundTrip() { + for pi in J2KDICOMPhotometricInterpretation.allCases { + let parsed = J2KDICOMPhotometricInterpretation(rawValue: pi.rawValue) + XCTAssertEqual(parsed, pi) + } + } +} diff --git a/Tests/J2KDICOMHelpersTests/V10_29_TransferSyntaxRoundTripTests.swift b/Tests/J2KDICOMHelpersTests/V10_29_TransferSyntaxRoundTripTests.swift new file mode 100644 index 0000000..d7632e5 --- /dev/null +++ b/Tests/J2KDICOMHelpersTests/V10_29_TransferSyntaxRoundTripTests.swift @@ -0,0 +1,96 @@ +// V10_29_TransferSyntaxRoundTripTests.swift +// +// v10.17.0 — J2KDICOMHelpers Phase 1 parity gate. +// +// For each J2KDICOMTransferSyntax case: +// - Constructed-via-UID equals original case +// - `var uid` round-trip +// - `isLossless` / `isHTJ2K` / `isPart2` flags match expected truth table +// - Whitespace + trailing-NUL UID parsing tolerated +// - Unknown UID returns nil + +import XCTest +import Foundation +@testable import J2KDICOMHelpers + +final class V10_29_TransferSyntaxRoundTripTests: XCTestCase { + + // MARK: - Round-trip + + func testUIDRoundTripForEveryCase() { + for ts in J2KDICOMTransferSyntax.allCases { + let uid = ts.uid + XCTAssertFalse(uid.isEmpty, "UID must be non-empty for \(ts)") + let parsed = J2KDICOMTransferSyntax(uid: uid) + XCTAssertEqual(parsed, ts, + "Round-trip via UID failed for \(ts): parsed back as \(String(describing: parsed))") + } + } + + func testEveryCaseHasUniqueUID() { + let uids = J2KDICOMTransferSyntax.allCases.map { $0.uid } + let unique = Set(uids) + XCTAssertEqual(uids.count, unique.count, + "Every Transfer Syntax case must have a unique UID. Found duplicates in: \(uids)") + } + + // MARK: - Classification truth table + + func testIsLosslessTruthTable() { + // Per DICOM PS3.5 Annex A. + XCTAssertTrue(J2KDICOMTransferSyntax.j2kLossless.isLossless) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kLossy.isLossless) + XCTAssertTrue(J2KDICOMTransferSyntax.j2kPart2MulticompLossless.isLossless) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kPart2Multicomp.isLossless) + XCTAssertTrue(J2KDICOMTransferSyntax.htj2kLossless.isLossless) + XCTAssertFalse(J2KDICOMTransferSyntax.htj2kLossyConstant.isLossless) + XCTAssertFalse(J2KDICOMTransferSyntax.htj2kLossy.isLossless) + } + + func testIsHTJ2KTruthTable() { + XCTAssertFalse(J2KDICOMTransferSyntax.j2kLossless.isHTJ2K) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kLossy.isHTJ2K) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kPart2MulticompLossless.isHTJ2K) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kPart2Multicomp.isHTJ2K) + XCTAssertTrue(J2KDICOMTransferSyntax.htj2kLossless.isHTJ2K) + XCTAssertTrue(J2KDICOMTransferSyntax.htj2kLossyConstant.isHTJ2K) + XCTAssertTrue(J2KDICOMTransferSyntax.htj2kLossy.isHTJ2K) + } + + func testIsPart2TruthTable() { + XCTAssertFalse(J2KDICOMTransferSyntax.j2kLossless.isPart2) + XCTAssertFalse(J2KDICOMTransferSyntax.j2kLossy.isPart2) + XCTAssertTrue(J2KDICOMTransferSyntax.j2kPart2MulticompLossless.isPart2) + XCTAssertTrue(J2KDICOMTransferSyntax.j2kPart2Multicomp.isPart2) + XCTAssertFalse(J2KDICOMTransferSyntax.htj2kLossless.isPart2) + XCTAssertFalse(J2KDICOMTransferSyntax.htj2kLossyConstant.isPart2) + XCTAssertFalse(J2KDICOMTransferSyntax.htj2kLossy.isPart2) + } + + // MARK: - Lenient UID parsing + + func testTrailingNULTolerated() { + // DICOM UID VRs may be NUL-padded for even length per PS3.5 §6.2. + let parsed = J2KDICOMTransferSyntax(uid: "1.2.840.10008.1.2.4.201\0") + XCTAssertEqual(parsed, .htj2kLossless) + } + + func testWhitespaceTrimmed() { + let parsed = J2KDICOMTransferSyntax(uid: " 1.2.840.10008.1.2.4.90 ") + XCTAssertEqual(parsed, .j2kLossless) + } + + // MARK: - Negative + + func testUnknownUIDReturnsNil() { + XCTAssertNil(J2KDICOMTransferSyntax(uid: "1.2.840.10008.1.2.1")) // Explicit VR Little Endian — non-J2K + XCTAssertNil(J2KDICOMTransferSyntax(uid: "1.2.840.10008.1.2.4.50")) // JPEG Baseline — non-J2K + XCTAssertNil(J2KDICOMTransferSyntax(uid: "")) + XCTAssertNil(J2KDICOMTransferSyntax(uid: "not-a-uid")) + } + + func testCaseIterableCoversAllSevenUIDs() { + XCTAssertEqual(J2KDICOMTransferSyntax.allCases.count, 7, + "DICOM Part 5 Annex A registers exactly 7 JPEG 2000 / HTJ2K UIDs as of this release.") + } +}