diff --git a/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-batched-20260524.json b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-batched-20260524.json new file mode 100644 index 0000000..f5771a5 --- /dev/null +++ b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-batched-20260524.json @@ -0,0 +1,487 @@ +{ + "decode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 639.65108299999997, + "median" : 632.47491600000001, + "min" : 627.59516699999995, + "samples" : [ + 636.82991700000002, + 633.50320799999997, + 630.12483299999997, + 629.41475000000003, + 632.47491600000001, + 639.65108299999997, + 627.59516699999995 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 226.83224999999999, + "median" : 220.78104200000001, + "min" : 216.083125, + "samples" : [ + 224.31133399999999, + 221.223916, + 226.83224999999999, + 219.52574999999999, + 218.156791, + 216.083125, + 220.78104200000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 154.63874999999999, + "median" : 142.22108299999999, + "min" : 140.471125, + "samples" : [ + 141.71875, + 142.22108299999999, + 140.471125, + 147.691125, + 154.63874999999999, + 150.952708, + 141.78633400000001 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 315.71474999999998, + "median" : 314.86595899999998, + "min" : 312.851833, + "samples" : [ + 315.00008300000002, + 314.85766699999999, + 314.90224999999998, + 313.878917, + 314.86595899999998, + 312.851833, + 315.71474999999998 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 112.019458, + "median" : 110.51958399999999, + "min" : 109.494584, + "samples" : [ + 110.59608299999999, + 110.685667, + 110.421875, + 110.449, + 110.51958399999999, + 109.494584, + 112.019458 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 73.131541999999996, + "median" : 70.836917, + "min" : 68.212874999999997, + "samples" : [ + 71.368542000000005, + 68.212874999999997, + 69.882915999999994, + 70.836917, + 73.131541999999996, + 70.272958000000003, + 71.196792000000002 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 39.872208999999998, + "median" : 39.120624999999997, + "min" : 38.694000000000003, + "samples" : [ + 38.694000000000003, + 39.120624999999997, + 39.049332999999997, + 39.029874999999997, + 39.709457999999998, + 39.872208999999998, + 39.326791999999998 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 14.3375, + "median" : 14.133917, + "min" : 13.174208999999999, + "samples" : [ + 14.321833, + 14.3375, + 14.133917, + 14.082708999999999, + 14.325917, + 13.174208999999999, + 13.864750000000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 9.3236249999999998, + "median" : 9.0929579999999994, + "min" : 8.9106249999999996, + "samples" : [ + 9.3236249999999998, + 9.1032919999999997, + 8.9106249999999996, + 9.0929579999999994, + 8.9308750000000003, + 9.092333, + 9.1450829999999996 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 78.592250000000007, + "median" : 78.042292000000003, + "min" : 77.957541000000006, + "samples" : [ + 78.592250000000007, + 78.132709000000006, + 78.371707999999998, + 77.971625000000003, + 78.042292000000003, + 77.957541000000006, + 78.027958999999996 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 28.549125, + "median" : 28.339832999999999, + "min" : 26.885249999999999, + "samples" : [ + 28.508875, + 26.885249999999999, + 28.549125, + 28.383875, + 27.934291000000002, + 28.339832999999999, + 27.754957999999998 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 18.703624999999999, + "median" : 18.556625, + "min" : 18.417916999999999, + "samples" : [ + 18.417916999999999, + 18.473416, + 18.565957999999998, + 18.495291000000002, + 18.636832999999999, + 18.703624999999999, + 18.556625 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 14.115417000000001, + "median" : 14.009791, + "min" : 13.898417, + "samples" : [ + 13.981083, + 14.009791, + 14.115417000000001, + 14.03275, + 14.017542000000001, + 13.953291, + 13.898417 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 7.7522909999999996, + "median" : 7.5487080000000004, + "min" : 7.4568339999999997, + "samples" : [ + 7.5103749999999998, + 7.5487080000000004, + 7.5775829999999997, + 7.4568339999999997, + 7.7522909999999996, + 7.6807910000000001, + 7.5401249999999997 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 4.5545410000000004, + "median" : 4.2859170000000004, + "min" : 4.1896250000000004, + "samples" : [ + 4.5545410000000004, + 4.3470000000000004, + 4.1896250000000004, + 4.2657920000000003, + 4.2962090000000002, + 4.2711249999999996, + 4.2859170000000004 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 56.425291999999999, + "median" : 55.960500000000003, + "min" : 55.877042000000003, + "samples" : [ + 55.877042000000003, + 56.425291999999999, + 55.960500000000003, + 56.023874999999997, + 56.051457999999997, + 55.880916999999997, + 55.923124999999999 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 25.812792000000002, + "median" : 24.766375, + "min" : 24.267833, + "samples" : [ + 24.766375, + 24.435749999999999, + 24.267833, + 24.823, + 25.812792000000002, + 25.339292, + 24.660291000000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 11.779040999999999, + "median" : 11.559832999999999, + "min" : 11.061291000000001, + "samples" : [ + 11.715916, + 11.653957999999999, + 11.150083, + 11.061291000000001, + 11.559832999999999, + 11.779040999999999, + 11.5555 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "encode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 536.31812500000001, + "median" : 522.44354199999998, + "min" : 516.10058300000003, + "samples" : [ + 522.44354199999998, + 522.36062500000003, + 516.10058300000003, + 528.15341699999999, + 536.31812500000001, + 527.01800000000003, + 517.15891599999998 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 262.120542, + "median" : 259.21537499999999, + "min" : 256.51291700000002, + "samples" : [ + 260.686667, + 258.546333, + 260.124166, + 259.21537499999999, + 256.51291700000002, + 259.06200000000001, + 262.120542 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 32.220458999999998, + "median" : 32.153165999999999, + "min" : 31.328084, + "samples" : [ + 32.200333999999998, + 32.220458999999998, + 31.328084, + 31.33475, + 32.153165999999999, + 31.856458, + 32.181457999999999 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 65.307333, + "median" : 65.198250000000002, + "min" : 64.137084000000002, + "samples" : [ + 64.137084000000002, + 65.289666999999994, + 64.421250000000001, + 65.307333, + 65.198250000000002, + 64.820541000000006, + 65.278458000000001 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 12.75825, + "median" : 11.798541, + "min" : 11.450165999999999, + "samples" : [ + 12.75825, + 11.824334, + 11.547917, + 11.798541, + 11.930125, + 11.727333, + 11.450165999999999 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 54.780042000000002, + "median" : 54.544333000000002, + "min" : 53.663417000000003, + "samples" : [ + 54.780042000000002, + 54.243417000000001, + 54.581916999999997, + 53.955374999999997, + 54.544333000000002, + 53.663417000000003, + 54.581874999999997 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "host" : { + "brand" : "Apple M2", + "machine" : "arm64", + "model" : "Mac14,2", + "ncpu" : 8, + "release" : "15.7.5", + "system" : "Darwin" + }, + "j2k_version" : "J2KSwift version 10.9.2", + "lane" : "inproc-jp3d", + "runs" : 7, + "schema" : "warm-inproc-jp3d-v1", + "warmups" : 2 +} \ No newline at end of file diff --git a/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-serial-20260524.json b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-serial-20260524.json new file mode 100644 index 0000000..4fc810f --- /dev/null +++ b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-serial-20260524.json @@ -0,0 +1,487 @@ +{ + "decode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 738.72108300000002, + "median" : 715.36891700000001, + "min" : 703.65899999999999, + "samples" : [ + 715.36891700000001, + 703.65899999999999, + 716.06691599999999, + 738.72108300000002, + 716.67512499999998, + 712.48666700000001, + 713.73633299999995 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 256.81541600000003, + "median" : 246.60487499999999, + "min" : 241.20658399999999, + "samples" : [ + 245.06312500000001, + 241.20658399999999, + 247.70137500000001, + 243.92595900000001, + 246.60487499999999, + 253.57245800000001, + 256.81541600000003 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 192.16258300000001, + "median" : 189.7465, + "min" : 187.39375000000001, + "samples" : [ + 191.80799999999999, + 187.39375000000001, + 190.14812499999999, + 192.16258300000001, + 189.7465, + 187.999042, + 187.63312500000001 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 356.95850000000002, + "median" : 353.71174999999999, + "min" : 351.48116700000003, + "samples" : [ + 353.09916700000002, + 353.71174999999999, + 351.48116700000003, + 352.98629199999999, + 354.29533300000003, + 356.95850000000002, + 354.79666700000001 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 124.310042, + "median" : 120.962875, + "min" : 119.26795799999999, + "samples" : [ + 124.310042, + 123.386916, + 119.26795799999999, + 120.962875, + 120.410042, + 119.8485, + 122.83175 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 95.970124999999996, + "median" : 92.016709000000006, + "min" : 88.454082999999997, + "samples" : [ + 90.624624999999995, + 90.858917000000005, + 92.016709000000006, + 92.574332999999996, + 95.970124999999996, + 92.983707999999993, + 88.454082999999997 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 43.885333000000003, + "median" : 43.308917000000001, + "min" : 43.065083000000001, + "samples" : [ + 43.885333000000003, + 43.308917000000001, + 43.223208, + 43.266582999999997, + 43.864415999999999, + 43.535457999999998, + 43.065083000000001 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 14.820582999999999, + "median" : 14.457125, + "min" : 14.268041999999999, + "samples" : [ + 14.820582999999999, + 14.716167, + 14.363292, + 14.531000000000001, + 14.292209, + 14.457125, + 14.268041999999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 11.463666, + "median" : 11.285417000000001, + "min" : 11.159667000000001, + "samples" : [ + 11.463666, + 11.417415999999999, + 11.259957999999999, + 11.285417000000001, + 11.205958000000001, + 11.291708, + 11.159667000000001 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 90.468625000000003, + "median" : 87.391999999999996, + "min" : 86.742750000000001, + "samples" : [ + 86.742750000000001, + 87.365916999999996, + 90.468625000000003, + 89.037541000000004, + 87.952624999999998, + 87.391999999999996, + 86.744041999999993 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 30.978916999999999, + "median" : 29.969833000000001, + "min" : 29.213833000000001, + "samples" : [ + 29.789959, + 30.327458, + 29.969833000000001, + 29.771374999999999, + 29.213833000000001, + 30.351042, + 30.978916999999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 23.597750000000001, + "median" : 22.776584, + "min" : 22.346, + "samples" : [ + 22.346, + 22.776584, + 23.128374999999998, + 22.759291999999999, + 23.056916999999999, + 23.597750000000001, + 22.438749999999999 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 14.400458, + "median" : 14.060375000000001, + "min" : 13.858625, + "samples" : [ + 14.380125, + 14.027416000000001, + 13.972417, + 13.858625, + 14.400458, + 14.220791999999999, + 14.060375000000001 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 5.7556669999999999, + "median" : 5.6926670000000001, + "min" : 5.6475, + "samples" : [ + 5.6707080000000003, + 5.6991250000000004, + 5.6524580000000002, + 5.7106669999999999, + 5.6926670000000001, + 5.6475, + 5.7556669999999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 4.3541249999999998, + "median" : 4.3107920000000002, + "min" : 4.2655000000000003, + "samples" : [ + 4.314209, + 4.2655000000000003, + 4.3107920000000002, + 4.3202910000000001, + 4.2756670000000003, + 4.2960000000000003, + 4.3541249999999998 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 61.942041000000003, + "median" : 60.646124999999998, + "min" : 59.635790999999998, + "samples" : [ + 59.635790999999998, + 60.646124999999998, + 61.368333, + 61.942041000000003, + 61.691499999999998, + 59.814416999999999, + 59.970125000000003 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 23.619499999999999, + "median" : 23.517375000000001, + "min" : 23.269166999999999, + "samples" : [ + 23.431750000000001, + 23.517375000000001, + 23.362333, + 23.566790999999998, + 23.610792, + 23.619499999999999, + 23.269166999999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 14.562583, + "median" : 14.294541000000001, + "min" : 14.090875, + "samples" : [ + 14.240792000000001, + 14.374167, + 14.294541000000001, + 14.562583, + 14.090875, + 14.112291000000001, + 14.386167 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "encode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 533.70441700000003, + "median" : 527.75129100000004, + "min" : 518.27625, + "samples" : [ + 531.63216699999998, + 527.75129100000004, + 520.54804200000001, + 524.21825000000001, + 518.27625, + 533.70441700000003, + 528.67966699999999 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 268.37849999999997, + "median" : 262.96112499999998, + "min" : 260.78483299999999, + "samples" : [ + 262.69987500000002, + 260.78483299999999, + 268.37849999999997, + 266.56616700000001, + 262.96112499999998, + 262.131125, + 264.59550000000002 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 33.438124999999999, + "median" : 32.411082999999998, + "min" : 32.131, + "samples" : [ + 32.250292000000002, + 32.411082999999998, + 32.131, + 32.307499999999997, + 32.555292000000001, + 33.438124999999999, + 33.392375000000001 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 66.763333000000003, + "median" : 65.989125000000001, + "min" : 65.186666000000002, + "samples" : [ + 65.272707999999994, + 65.709333000000001, + 66.065875000000005, + 66.230958999999999, + 66.763333000000003, + 65.989125000000001, + 65.186666000000002 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 14.076416999999999, + "median" : 13.725875, + "min" : 12.158625000000001, + "samples" : [ + 12.158625000000001, + 12.987625, + 13.53725, + 13.866541, + 13.857250000000001, + 14.076416999999999, + 13.725875 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 56.594290999999998, + "median" : 55.588959000000003, + "min" : 54.146791, + "samples" : [ + 54.146791, + 55.588959000000003, + 54.612124999999999, + 55.269458999999998, + 56.159166999999997, + 56.594290999999998, + 55.668708000000002 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "host" : { + "brand" : "Apple M2", + "machine" : "arm64", + "model" : "Mac14,2", + "ncpu" : 8, + "release" : "15.7.5", + "system" : "Darwin" + }, + "j2k_version" : "J2KSwift version 10.9.2", + "lane" : "inproc-jp3d", + "runs" : 7, + "schema" : "warm-inproc-jp3d-v1", + "warmups" : 2 +} \ No newline at end of file diff --git a/README.md b/README.md index cca952b..5d197c7 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.11.0 -**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.11.0 ships **JP3D batched GPU iDWT**: a new single-dispatch Metal kernel amortises per-slice GPU overhead across the whole volume. `JP3DSliceStackCodec.decode` now runs ONE batched iDWT dispatch across all slices of a tile instead of N per-slice dispatches. M2 release JP3D full decode wins **−5 ms on small CT to −115 ms on 16M-voxel CT** (1.07–1.17× faster, 5/6 fixtures cross the 3 ms acceptance threshold). Decoder-only; codestream bytes byte-identical to v10.10.0. -**Previous Release**: 10.10.0 (JP3D true partial-resolution + ROI footprint-skip + Z-narrow) +**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) **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.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/). **v10.10.0** ships **JP3D true partial-resolution + ROI footprint-skip + Z-narrow ROI skip** — three coordinated decoder-side changes inside `Sources/J2K3D/` that close the long-standing follow-up on JP3D's selective-decode story. **`JP3DDecoderConfiguration.resolutionLevel`** has been wired into the struct since v5.x but the decoder ignored it (silently returned full resolution); v10.10.0 routes each per-slice 2D codestream inside `JP3DSliceStackCodec` through the existing v10.5.0 `J2KDecoder.decodeResolution(_:options:)`, producing a volume sized `⌈W / 2^K⌉ × ⌈H / 2^K⌉ × D` as documented. **`JP3DROIDecoder`** swaps decode-then-crop for true ROI footprint-skip — the per-tile in-tile XY sub-region is passed through to the slice-stack codec, which routes the per-slice 2D decode through v10.6.0 `decodeRegion(_:options:)` with `.direct` strategy (code-blocks whose inverse-DWT cone-of-influence misses the region skip entropy decode entirely). **Z-narrow ROI skip** pre-scans slice headers (no decode), finds the latest non-residual slice ≤ `zRange.lowerBound`, and starts decoding from there — completely skipping the unused Z-prefix while keeping Z-delta residual chain integrity via the restart anchor. M2 release: **`resolutionLevel = 1` is 2.2–3.1× faster than full decode** (mr_3d_small 2.22×, ct_3d_small 3.05×, mr_3d_mid 3.01×); **ROI 1/4 is 3.4–4.1× faster than full decode** (mr_3d_small 3.37×, ct_3d_small 4.14×, mr_3d_mid 3.96×). Combined `resolutionLevel + ROI` throws loud (was silently ignored previously). `V10_18_TrueSelectiveParityTests` 9/9 PASS, existing `JP3DDecoderTests` 61/61 PASS, mandatory commit gate clean. Encoder unchanged. Codestream bytes byte-identical to v10.9.3. See [RELEASE_NOTES_v10.10.0.md](RELEASE_NOTES_v10.10.0.md) and [V10_18_JP3D_TRUE_SELECTIVE_DECODE.md](Documentation/research/V10_18_JP3D_TRUE_SELECTIVE_DECODE.md). diff --git a/RELEASE_NOTES_v10.12.0.md b/RELEASE_NOTES_v10.12.0.md new file mode 100644 index 0000000..e5cdeb5 --- /dev/null +++ b/RELEASE_NOTES_v10.12.0.md @@ -0,0 +1,196 @@ +# J2KSwift v10.12.0 + +**JP3D batched bridge extension — partial-resolution + ROI.** Closes +the v10.11.0 known limitation: the batched bridge SPI now handles +the `JP3DDecoderConfiguration.resolutionLevel > 0` and +`JP3DROIDecoder(_:region:)` cases inside `JP3DSliceStackCodec` via a +new `JP3DBridgeOptions` overload. Same single-dispatch architecture +as v10.11.0; the orchestrator truncates the chain for partial-res +and crops after iDWT for ROI. M2 release JP3D decode wins +**−10 ms to −83 ms** on production-relevant CT volumes across all +three lanes (full / partial-res-1 / ROI 1/4). + +Decoder-only release; codestream bytes are byte-identical to v10.11.0. +Encoder unchanged. MINOR per RELEASING.md — additive public surface +(`JP3DBridgeOptions` + new `_jp3dDecodeToCoefficients(_:options:)` +overload), no signature removal, no default flip that affects bytes. + +## Summary + +Three coordinated changes that extend v10.11.0's batched bridge SPI +to the two cases it previously dropped to per-slice serial: + +1. **`JP3DBridgeOptions` struct + new bridge SPI overload.** + `JP3DBridgeOptions(partialResolutionLevel:, regionOfInterest:)` + carries the partial-resolution K and/or ROI rectangle. The new + `J2KDecoder._jp3dDecodeToCoefficients(_:options:)` overload + forwards them to the underlying `DecoderPipeline`, where the + entropy-stage Stage B.1 (partial-res) / Stage 1 (ROI footprint- + skip) wins fire just like in the standalone + `decodeResolution` / `decodeRegion`. The options ride along + inside the returned bundle so the matching finalize call sizes / + crops the output without re-plumbing. + +2. **Batched orchestrator handles uniform-options batches.** The + eligibility gate now accepts partial-res K and ROI when all + slices in the batch carry the same options (JP3D slice-stack + always does — these are per-volume not per-slice). For partial- + res the orchestrator truncates the chain to `effectiveLevels = + N − K` levels (output dims = `levelSizes[K]`); for ROI the chain + runs full and `cropImage` slices the per-slice output to the + region rectangle. K=0 (thumbnail) falls back to per-slice serial + because the orchestrator can't dispatch an empty iDWT chain. + +3. **`JP3DSliceStackCodec` consumes both new cases.** The K>0 + (partial-resolution) and ROI branches now build a + `JP3DBridgeOptions` and route through the bulk parallel + `_jp3dDecodeToCoefficients` + ONE batched iDWT, instead of + per-slice `decoder.decodeResolution` / `decodeRegion`. The + only combination still routed per-slice is `K > 0 AND ROI` — + the 2D codec doesn't compose partial-res with ROI; the + pre-existing throw still fires for that case. + +## What's New — production-default + +| Public API | v10.11.0 behaviour | v10.12.0 behaviour | +|---|---|---| +| `JP3DDecoder(configuration: cfg).decode(data)` with `cfg.resolutionLevel > 0` | Per-slice serial `decodeResolution` loop | Parallel `_jp3dDecodeToCoefficients(options:)` + ONE batched iDWT (truncated chain). **Same output bytes**; 10–26 ms faster wall on the JP3D corpus' larger CT volumes | +| `JP3DROIDecoder().decode(data, region:)` | Per-slice serial `decodeRegion` loop | Parallel `_jp3dDecodeToCoefficients(options:)` + ONE batched iDWT + per-slice ROI crop. **Same output bytes**; 4–48 ms faster wall on the JP3D corpus | +| `JP3DDecoder().decode(data)` full-volume decode | v10.11.0 batched bridge (unchanged) | Unchanged | + +The new bridge SPI surface (`JP3DBridgeOptions` + the options +overload) is public but `JP3DBridgeOptions` ships in J2KCodec +alongside the existing underscored `_jp3d*` methods. Consumers +outside `J2K3D` should keep calling the normal +`J2KDecoder.decode(_:)` / `decodeResolution(_:options:)` / +`decodeRegion(_:options:)`. + +## What's New — opt-in / opt-out + +`J2K_JP3D_BATCHED_BRIDGE=0` env var (introduced v10.11.0) continues +to disable the batched path on ALL three lanes — full, partial-res, +ROI — forcing per-slice serial. Diagnostic-A/B only; production +should leave it unset. + +## Backward compatibility + +- **Codestream bytes**: byte-identical to v10.11.0 on every input. + Encoder unchanged. +- **`JP3DDecoder(cfg).decode(data)` with `cfg.resolutionLevel > 0`** + is byte-identical to v10.11.0 — validated by + `V10_21_BatchedBridgeOptionsParityTests` (per-slice serial vs + batched-with-options bit-exact on 4-slice K=2 / K=4 / K=N=full / + K=0 batches). +- **`JP3DROIDecoder().decode(data, region:)`** is byte-identical to + v10.11.0 — validated by `V10_21_BatchedBridgeOptionsParityTests` + ROI-only and ROI+K composition tests. +- **Behaviour change**: none. + +## Measured wins — JP3D corpus + +M2 release, J2KBenchMac --jp3d, in-process, 7 timed runs / 2 warmups, +median per fixture per lane (`full` = full-volume decode, `res1` = +JP3D `resolutionLevel = 1`, `ROIq` = centre quarter ROI): + +| Fixture (modality WxHxD) | voxels | full Δ ms (ratio) | res1 Δ ms (ratio) | ROIq Δ ms (ratio) | +|---|---:|---:|---:|---:| +| mr_3d_small MR 128×128×16 | 262K | −0.05 (1.00×) | +1.86 (0.75×) | −0.02 (1.00×) | +| ct_3d_small CT 256×256×16 | 1.05M | **−4.19** (1.11×) | −0.32 (1.02×) | −2.19 (1.24×) | +| us_3d_small US 320×240×24 | 1.84M | **−4.69** (1.08×) | +1.25 (0.95×) | −2.73 (1.24×) | +| mr_3d_mid MR 256×256×32 | 2.10M | **−9.35** (1.12×) | −1.63 (1.06×) | **−4.22** (1.23×) | +| ct_3d_mid CT 512×512×32 | 8.39M | **−38.85** (1.12×) | **−10.44** (1.09×) | **−21.18** (1.30×) | +| ct_3d_large CT 512×512×64 | 16.78M | **−82.89** (1.13×) | **−25.82** (1.12×) | **−47.53** (1.33×) | + +Per acceptance discipline (≥3 ms wins): **full lane 4/6, res1 lane +2/6, ROIq lane 3/6** clear. Smaller fixtures wash on res1/ROIq +because the reduced workload (fewer iDWT levels or smaller region) +leaves less for the batched dispatch to amortise. The 8M-voxel +ct_3d_mid and 16M-voxel ct_3d_large CT volumes (radiologist +scrolling case) win on every lane. + +Raw bench JSONs: +- `Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-batched-20260524.json` +- `Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-serial-20260524.json` + +## Cross-codec parity (2D codec unchanged) + +The 2D codec path (entropy, dequant, iDWT, colour, DC, reconstruct) +is touched only at the bridge SPI splitting point — the same per- +stage code paths execute, same `SubbandInfo` flows, same partial-res +truncation and ROI crop the standalone `decodeResolution` / +`decodeRegion` apply. Cross-codec parity vs OpenJPH 0.27.0 / +Grok 20.3.0 / Kakadu 8.4.1 is inherited from v10.11.0 (no change +measured or asserted in this release). + +## Test Suite Results + +| Suite | Tests | Result | Coverage | +|---|---:|---|---| +| `V10_21_BatchedBridgeOptionsParityTests` | 7/7 | PASS | New options-bridge parity (K=0/2/4/N, ROI, K+ROI) | +| `V10_20_JP3DBridgeParityTests` | 5/5 | PASS | Phase 1 bridge SPI bit-exact composition | +| `V10_20_BatchedInverseInt32ParityTests` | 12/12 | PASS | Batched Metal kernel + multi-level orchestrator bit-exact vs serial GPU | +| `V10_20_BatchedBridgeParityTests` | 5/5 | PASS | Batched bridge SPI bit-exact vs per-slice serial (full-volume) | +| `swift test --filter JP3D` (regression sweep) | 519/519 | PASS | Full JP3D test suite green with the K>0 + ROI batched wiring | +| Mandatory commit gate (release mode) | 7/7 | PASS | Encode-perf + decode-perf + cross-codec strict validation | + +## API surface — additions only + +```swift +/// v10.12.0 — JP3D bridge SPI decode options. +public struct JP3DBridgeOptions: Sendable, Equatable { + public let partialResolutionLevel: Int? + public let regionOfInterest: J2KRegion? + public init(partialResolutionLevel: Int? = nil, + regionOfInterest: J2KRegion? = nil) + public static let `default`: JP3DBridgeOptions +} + +extension J2KDecoder { + /// v10.12.0 — options overload. + public func _jp3dDecodeToCoefficients( + _ data: Data, + options: JP3DBridgeOptions + ) async throws -> JP3DSliceCoefficients +} +``` + +No removals. No existing signatures changed. + +## Known limitations + +- **`K > 0 AND ROI` composition** still throws. The 2D codec doesn't + support "footprint-skip at a downsampled resolution" — that's a + v10.5.0 + v10.6.0 follow-up that wasn't included in this arc. +- **K=0 thumbnail batched dispatch.** The orchestrator can't + dispatch an empty iDWT chain, so K=0 (which produces the deepest + LL only) falls back to per-slice serial. Per-slice K=0 is already + cheap (one LL Int32→pixel copy per slice), so the impact is minor. + +## Reproducing the headline numbers + +```bash +# Build the JP3D bench (research tool; not in main's Package.swift +# but checked out on v10.21-research): +git fetch origin v10.21-research +git checkout v10.21-research -- Sources/J2KBenchMac/ Package.swift +swift build -c release --product J2KBenchMac + +# Batched (default) +.build/release/J2KBenchMac --jp3d --output /tmp/jp3d_batched.json + +# Serial baseline (opt-out) +J2K_JP3D_BATCHED_BRIDGE=0 .build/release/J2KBenchMac --jp3d --output /tmp/jp3d_serial.json +``` + +## Companion documents + +- `Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-batched-20260524.json` — raw bench (batched) +- `Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-serial-20260524.json` — raw bench (serial baseline) + +## 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.12.0"` (or accept the next `.upToNextMinor` per your +policy). Consumers of `JP3DDecoder` / `JP3DROIDecoder` see only a +perf improvement; no source changes required. diff --git a/Sources/J2K3D/JP3DSliceStackCodec.swift b/Sources/J2K3D/JP3DSliceStackCodec.swift index e60b9ec..6536cb5 100644 --- a/Sources/J2K3D/JP3DSliceStackCodec.swift +++ b/Sources/J2K3D/JP3DSliceStackCodec.swift @@ -467,21 +467,27 @@ struct JP3DSliceStackCodec: Sendable { // Int32 — used to add the residual when `is_residual` is set. var prevSliceInt: [[Int32]]? = nil - // v10.20-research Phase 3c — bulk batched-bridge fast path. - // When neither resolutionLevel nor regionOfInterest is set - // (the dominant JP3D production case for full-volume decode), - // we can: + // v10.20-research Phase 3c — bulk batched-bridge fast path + // (full-volume decode). v10.21-research Phase 5 — extended + // to K>0 partial-resolution AND ROI cases via the new + // `JP3DBridgeOptions` overload. + // // 1. Decode all slices [zStart, zUpper) to coefficients in - // parallel via the Phase 1 bridge SPI. + // parallel via the Phase 1 bridge SPI, passing the per- + // slice options (partial-res level + ROI rectangle). // 2. Run ONE batched iDWT + finalize across the whole z-range - // via the Phase 3d bridge SPI (2.4× kernel-level speedup - // vs serial per-slice iDWT on M2 release). + // via the Phase 3d bridge SPI. The orchestrator now + // truncates the chain to `effectiveLevels` for partial- + // res and crops to the ROI after iDWT. // 3. Apply the Z-delta residual chain sequentially after the // iDWTs land (the residual is a cheap Int32 add but has // the cross-slice dependency that prevents batching). - // For K > 0 or any ROI request we keep the per-slice decode - // loop below (it uses decodeResolution / decodeRegion which - // the bridge SPI doesn't expose yet — Phase 3c/v2 territory). + // + // 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.20-research Phase 4 — env-var off-switch for A/B benching. // `J2K_JP3D_BATCHED_BRIDGE=0` disables the bulk batched path, // forcing the per-slice serial loop (the pre-Phase-3c shape) @@ -494,9 +500,41 @@ struct JP3DSliceStackCodec: Sendable { } return false }() - let useBatchedBridge = (K == 0) && (regionOfInterest == nil) && !batchedOptedOut + // The (K>0, ROI) case is excluded by the throw upstream; here + // we only need to gate on the env-var off-switch. + 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- + // level count up-front (to map JP3D's K → 2D's + // `partialResolutionLevel = N - K`). Peek the first slice's + // codestream header — same approach the per-slice path + // takes on first call. + if K > 0 && perSliceDecompLevels == nil { + let firstEntry = sliceEntries[zStart] + let firstCS = payload.subdata( + in: firstEntry.codestreamStart..<(firstEntry.codestreamStart.advanced(by: firstEntry.length)) + ) + perSliceDecompLevels = Self.peekDecompositionLevels(firstCS) + } + // Build the per-slice options. All slices in the batch get + // the same options (uniform-options is the orchestrator's + // batched-eligibility requirement). + let sliceLevel: Int? = { + guard K > 0, let N = perSliceDecompLevels else { return nil } + return max(0, N - K) + }() + let regionRect: J2KRegion? = { + guard let roi = regionOfInterest else { return nil } + return J2KRegion( + x: roi.xRange.lowerBound, y: roi.yRange.lowerBound, + width: roi.xRange.count, height: roi.yRange.count) + }() + let bridgeOptions = JP3DBridgeOptions( + partialResolutionLevel: sliceLevel, + regionOfInterest: regionRect) + let nSlices = zUpper - zStart var coefs: [JP3DSliceCoefficients?] = Array( repeating: nil, count: nSlices) @@ -510,8 +548,10 @@ struct JP3DSliceStackCodec: Sendable { ) let dec = decoder let idx = z - zStart + let opts = bridgeOptions group.addTask { - let c = try await dec._jp3dDecodeToCoefficients(codestream) + let c = try await dec._jp3dDecodeToCoefficients( + codestream, options: opts) return (idx, c) } } diff --git a/Sources/J2KCodec/J2KDecoderPipeline.swift b/Sources/J2KCodec/J2KDecoderPipeline.swift index 7596b86..beeb1ca 100644 --- a/Sources/J2KCodec/J2KDecoderPipeline.swift +++ b/Sources/J2KCodec/J2KDecoderPipeline.swift @@ -1518,6 +1518,29 @@ struct DecoderPipeline: Sendable { mutating func decodeToCoefficients( _ data: Data ) async throws -> _JP3DSliceCoefficientsInternal { + return try await decodeToCoefficients(data, options: .default) + } + + /// v10.21-research — JP3D bridge SPI overload accepting options. + /// `options.partialResolutionLevel` truncates entropy + iDWT to + /// the target level (v10.5 Stage B); `options.regionOfInterest` + /// engages the v10.6 ROI footprint-skip. The captured options + /// ride along inside the returned payload so the matching + /// finalize call sizes / crops the output without re-plumbing + /// the same parameters. + mutating func decodeToCoefficients( + _ data: Data, + options: JP3DBridgeOptions + ) async throws -> _JP3DSliceCoefficientsInternal { + // Honour caller-passed options by setting the corresponding + // pipeline knobs (they may have been pre-set on `self` too — + // bridge SPI sets both; per-slice serial paths set on `self`). + if options.partialResolutionLevel != nil { + partialResolutionLevel = options.partialResolutionLevel + } + if options.regionOfInterest != nil { + regionOfInterest = options.regionOfInterest + } let (metadata, tiles) = try parseCodestream(data) guard !metadata.isMultiTile else { throw J2KError.notImplemented( @@ -1541,7 +1564,8 @@ struct DecoderPipeline: Sendable { return _JP3DSliceCoefficientsInternal( metadata: metadata, - dequantizedSubbands: dequantizedSubbands) + dequantizedSubbands: dequantizedSubbands, + options: options) } /// v10.20-research Phase 3b — JP3D batched bridge SPI. Takes a @@ -1589,9 +1613,9 @@ struct DecoderPipeline: Sendable { // • 5/3 reversible filter // • single-component slices (`componentCount == 1`) // • uniform dimensions across the batch - // • full-resolution decode (no `partialResolutionLevel`) - // • no ROI - // • ≥ 1 decomposition level + // • uniform options across the batch (partial-res K + ROI) + // • ≥ 1 effective iDWT level (K=0 thumbnail falls back — + // no iDWT to dispatch in the first place) // Anything else falls back to the per-slice serial loop — // same output, no batched win, never errors on a valid // bundle. @@ -1607,12 +1631,22 @@ struct DecoderPipeline: Sendable { let headW = head.metadata.width let headH = head.metadata.height let headComponentCount = head.metadata.componentCount + let headOptions = head.options + + // v10.21-research Phase 2 — compute effectiveLevels from + // partial-resolution. K=N (or nil) → full chain; K ∈ [1, N-1] + // → truncated chain; K=0 → no iDWT to dispatch. + let effectiveLevels: Int = { + if let k = headOptions.partialResolutionLevel { + return max(0, min(k, levels)) + } + return levels + }() var batchable = isReversible53 && headComponentCount == 1 && levels >= 1 - && partialResolutionLevel == nil - && regionOfInterest == nil + && effectiveLevels >= 1 if batchable { for slice in coefsBatch { @@ -1630,6 +1664,15 @@ struct DecoderPipeline: Sendable { batchable = false break } + // v10.21 — uniform options requirement. JP3D slice- + // stack guarantees the caller passes identical options + // to every slice (decodeRegion / decodeResolution are + // per-volume not per-slice), so this almost always + // holds; the check is defensive. + if slice.options != headOptions { + batchable = false + break + } } } @@ -1662,6 +1705,14 @@ struct DecoderPipeline: Sendable { let llDimsW = levelSizes[levels].width let llDimsH = levelSizes[levels].height + // v10.21 — orchestrator output dims after `effectiveLevels` + // iDWT steps. For K=N (or .default): output = full image. + // For K J2KImage { let metadata = coefs.metadata + // v10.21-research — honour captured options. For partial- + // resolution we must set `partialResolutionLevel` AND + // `outputDimensions` on this pipeline before `applyInverseWaveletTransform` + // / `reconstructImage` run; the CPU iDWT path reads + // `partialResolutionLevel` to truncate the chain, and + // `reconstructImage` reads `outputDimensions` to size the + // component buffers. ROI is post-iDWT crop (the iDWT itself + // runs full-tile; finalize crops at the end), so just stash + // the region for the post-step. + if let level = coefs.options.partialResolutionLevel { + partialResolutionLevel = level + let N = metadata.configuration.decompositionLevels + let halvings = max(0, N - max(0, min(level, N))) + let factor = 1 << halvings + let outW = (metadata.width + factor - 1) / factor + let outH = (metadata.height + factor - 1) / factor + outputDimensions = (width: outW, height: outH) + } + var spatialData = try await applyInverseWaveletTransform( coefs.dequantizedSubbands, metadata: metadata) @@ -1795,7 +1883,48 @@ struct DecoderPipeline: Sendable { } } - return try reconstructImage(rgbData, metadata: metadata) + let fullImage = try reconstructImage(rgbData, metadata: metadata) + if let roi = coefs.options.regionOfInterest { + return try Self.cropImage(fullImage, region: roi) + } + return fullImage + } + + /// v10.21-research — per-component byte-level crop. Mirrors + /// `J2KDecoder.extractRegion` (private in J2KAdvancedDecoding) + /// so the JP3D bridge can produce region-sized images without + /// reaching into a sibling file's private. 1-byte and 2-byte + /// samples both supported; `sampleByteOrder` preserved. + static func cropImage(_ image: J2KImage, region: J2KRegion) throws -> J2KImage { + try region.validate(imageWidth: image.width, imageHeight: image.height) + let cropped = image.components.map { component -> J2KComponent in + let bytesPerSample = (component.bitDepth + 7) / 8 + let srcStride = component.width + let dstRowBytes = region.width * bytesPerSample + var dst = Data(count: region.height * dstRowBytes) + component.data.withUnsafeBytes { srcRaw in + dst.withUnsafeMutableBytes { dstRaw in + guard let src = srcRaw.baseAddress, + let out = dstRaw.baseAddress else { return } + for y in 0..0 and ROI cases. +/// +/// On `_jp3dDecodeToCoefficients(_:options:)` the options propagate +/// to the underlying `DecoderPipeline` so the entropy-stage filter +/// (Stage B.1 partial-res / Stage 1 ROI footprint-skip) takes the +/// same shortcuts the standalone `decodeResolution` / `decodeRegion` +/// take. The options ride along inside the returned bundle so the +/// matching finalize call can size + crop the output the same way +/// the standalone path does — no plumbing through the JP3D layer. +public struct JP3DBridgeOptions: Sendable, Equatable { + /// Target decomposition level for partial-resolution decode. + /// `nil` = full resolution. Range: `[0, N]` where `N` is the + /// codestream's `decompositionLevels`. `0` returns the deepest + /// 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). + public let regionOfInterest: J2KRegion? + + public init( + partialResolutionLevel: Int? = nil, + regionOfInterest: J2KRegion? = nil + ) { + self.partialResolutionLevel = partialResolutionLevel + self.regionOfInterest = regionOfInterest + } + + /// Default options = full resolution, no ROI. Equivalent to the + /// v10.20 (no-options) bridge SPI behaviour. + public static let `default` = JP3DBridgeOptions() +} + // MARK: - Internal payload /// Opaque internal payload carried inside `JP3DSliceCoefficients`. @@ -53,6 +92,10 @@ import J2KCore struct _JP3DSliceCoefficientsInternal: @unchecked Sendable { let metadata: CodestreamMetadata let dequantizedSubbands: [DecoderPipeline.SubbandInfo] + /// v10.21-research — captured options. Used by finalize to size + /// `outputDimensions` (for partial-res) and to crop after iDWT + /// (for ROI). Default = .default. + let options: JP3DBridgeOptions } // MARK: - Public opaque coefficient bundle @@ -75,10 +118,35 @@ public struct JP3DSliceCoefficients: @unchecked Sendable { } /// Pixel dimensions of the slice this coefficients bundle will - /// produce after iDWT + reconstruction. Useful for the JP3D + /// 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. - public var width: Int { _internal.metadata.width } - public var height: Int { _internal.metadata.height } + 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 + } + 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 + } } // MARK: - J2KDecoder SPI @@ -108,10 +176,33 @@ extension J2KDecoder { /// a JP3D-specific bridge; the bundle's payload is opaque. public func _jp3dDecodeToCoefficients( _ data: Data + ) async throws -> JP3DSliceCoefficients { + return try await _jp3dDecodeToCoefficients(data, options: .default) + } + + /// v10.21-research — JP3D bridge SPI overload accepting + /// `JP3DBridgeOptions` for partial-resolution and/or ROI decode. + /// Sets the underlying `DecoderPipeline.partialResolutionLevel` / + /// `regionOfInterest` before extracting + entropy-decoding code- + /// blocks, so the same Stage B.1 entropy-skip (v10.5) and ROI + /// footprint-skip (v10.6) wins fire here as in `decodeResolution` + /// / `decodeRegion`. The returned bundle stamps the options into + /// its internal payload so the matching finalize call can size + /// `outputDimensions` (partial-res) and crop (ROI) without + /// requiring the JP3D layer to plumb the same options twice. + /// + /// `options == .default` is byte-for-byte equivalent to the + /// no-options overload above. + public func _jp3dDecodeToCoefficients( + _ data: Data, + options: JP3DBridgeOptions ) async throws -> JP3DSliceCoefficients { var pipeline = DecoderPipeline() pipeline.metalSession = J2KMetalSession.processShared - let inner = try await pipeline.decodeToCoefficients(data) + pipeline.partialResolutionLevel = options.partialResolutionLevel + pipeline.regionOfInterest = options.regionOfInterest + let inner = try await pipeline.decodeToCoefficients( + data, options: options) return JP3DSliceCoefficients(_internal: inner) } diff --git a/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift b/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift new file mode 100644 index 0000000..636fa66 --- /dev/null +++ b/Tests/J2KCodecTests/V10_21_BatchedBridgeOptionsParityTests.swift @@ -0,0 +1,259 @@ +// +// V10_21_BatchedBridgeOptionsParityTests.swift +// J2KSwift +// +// v10.21-research — JP3D batched bridge SPI parity for the new +// `JP3DBridgeOptions` overload (partial-resolution + ROI). +// +// Specification: for every slice `i` in the batch and any `options` +// uniform across the batch, +// _jp3dIDWTAndFinalizeBatched(coefsBatchWithOptions)[i].components[0].data == +// _jp3dIDWTAndFinalize(coefsBatchWithOptions[i]).components[0].data +// on every JP3D-shape input (5/3 reversible, single-component, +// uniform dims across the batch). +// +// The batched path's eligibility gate accepts uniform partial-res +// + ROI; the orchestrator truncates the chain to `effectiveLevels` +// for partial-res (K=N-effectiveLevels) and crops after iDWT for +// ROI. K=0 (thumbnail) falls back to per-slice serial because there +// are no iDWT levels to dispatch. + +import XCTest +@testable import J2KCore +@testable import J2KCodec + +final class V10_21_BatchedBridgeOptionsParityTests: XCTestCase { + + private func makeLCGImage( + width: Int, height: Int, + bitDepth: Int = 16, seed: UInt64 + ) -> J2KImage { + let bytesPerSample = (bitDepth + 7) / 8 + let voxelCount = width * height + let maxVal = (1 << bitDepth) - 1 + var data = Data(count: voxelCount * bytesPerSample) + var s: UInt64 = seed &* 6364136223846793005 &+ 1442695040888963407 + data.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt16.self) + for i in 0..> 16) & 0xFFFFFFFF) % (maxVal + 1)) + p[i] = sample.bigEndian + } + } + let comp = J2KComponent( + index: 0, bitDepth: bitDepth, signed: false, + width: width, height: height, + subsamplingX: 1, subsamplingY: 1, + data: data, sampleByteOrder: .bigEndian) + return J2KImage(width: width, height: height, components: [comp]) + } + + private func losslessHTEncoder() -> J2KEncoder { + let cfg = J2KEncodingConfiguration( + quality: 1.0, lossless: true, + decompositionLevels: 5, qualityLayers: 1, + progressionOrder: .lrcp, bitrateMode: .lossless, + maxThreads: 8, useHTJ2K: true, useReversibleFilter: true, + enableParallelCodeBlocks: true, + htj2kBlockFormat: .conformant) + return J2KEncoder(encodingConfiguration: cfg) + } + + // MARK: - Partial-resolution parity + + /// 4-slice batch @ 256×256 with partial-resolution K=4 (one + /// iDWT step from deepest, output 32×32). Batched ≡ per-slice + /// serial bit-exact. + func testBatchedBridgeWithPartialResolutionK4_4Slices_256x256() async throws { + let decoder = J2KDecoder() + let opts = JP3DBridgeOptions(partialResolutionLevel: 4) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<4 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0x500 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 4) + for s in 0..<4 { + // For levels=5 codestream, K=4 means 4 iDWT steps run. + // Output is at depth (5-4)=1, dims = ceil(256/2^1) = 128. + XCTAssertEqual(serials[s].width, 128) + XCTAssertEqual(serials[s].height, 128) + XCTAssertEqual(batched[s].width, 128) + XCTAssertEqual(batched[s].height, 128) + XCTAssertEqual(serials[s].components.first?.data, + batched[s].components.first?.data, + "Batched bridge (K=4) slice \(s) diverges from serial") + } + } + + /// 4-slice batch @ 256×256 with partial-resolution K=2 (3 iDWT + /// steps, output 32×32). + func testBatchedBridgeWithPartialResolutionK2_4Slices_256x256() async throws { + let decoder = J2KDecoder() + let opts = JP3DBridgeOptions(partialResolutionLevel: 2) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<4 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0x600 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 4) + for s in 0..<4 { + // K=2 means 2 iDWT steps run; output depth = 5-2 = 3, + // dims = ceil(256/2^3) = 32. + XCTAssertEqual(serials[s].width, 32) + XCTAssertEqual(serials[s].height, 32) + XCTAssertEqual(batched[s].width, 32) + XCTAssertEqual(batched[s].height, 32) + XCTAssertEqual(serials[s].components.first?.data, + batched[s].components.first?.data, + "Batched bridge (K=2) slice \(s) diverges from serial") + } + } + + /// K=N full-res via options should match the v10.20 no-options + /// path bit-exact (the options carry .partialResolutionLevel=N + /// which the orchestrator should treat as full-res). + func testBatchedBridgeWithPartialResolutionKN_EquivalentToNoOptions() async throws { + let decoder = J2KDecoder() + let optsFull = JP3DBridgeOptions(partialResolutionLevel: 5) + let image = makeLCGImage(width: 256, height: 256, seed: 0x700) + let codestream = try await losslessHTEncoder().encode(image) + let cNoOpts = try await decoder._jp3dDecodeToCoefficients(codestream) + let cWithOpts = try await decoder._jp3dDecodeToCoefficients(codestream, options: optsFull) + let serial = try await decoder._jp3dIDWTAndFinalize(cNoOpts) + let batchedNoOpts = try await decoder._jp3dIDWTAndFinalizeBatched([cNoOpts]) + let batchedWithOpts = try await decoder._jp3dIDWTAndFinalizeBatched([cWithOpts]) + XCTAssertEqual(serial.components.first?.data, + batchedNoOpts[0].components.first?.data) + XCTAssertEqual(serial.components.first?.data, + batchedWithOpts[0].components.first?.data, + "K=N options-batched diverges from K=nil options-batched") + } + + // MARK: - ROI parity + + /// 4-slice batch @ 256×256, ROI = centre 128×128 corner. Batched + /// ≡ per-slice serial bit-exact; both produce 128×128 output. + func testBatchedBridgeWithROI_4Slices_256x256() async throws { + let decoder = J2KDecoder() + let region = J2KRegion(x: 64, y: 64, width: 128, height: 128) + let opts = JP3DBridgeOptions(regionOfInterest: region) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<4 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0x800 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 4) + for s in 0..<4 { + XCTAssertEqual(serials[s].width, 128) + XCTAssertEqual(serials[s].height, 128) + XCTAssertEqual(batched[s].width, 128) + XCTAssertEqual(batched[s].height, 128) + XCTAssertEqual(serials[s].components.first?.data, + batched[s].components.first?.data, + "Batched bridge (ROI 128²) slice \(s) diverges from serial") + } + } + + /// 16-slice batch with a small corner ROI — the typical JP3D + /// volume slice-scrubbing case at viewport zoom. + func testBatchedBridgeWithSmallROI_16Slices_256x256() async throws { + let decoder = J2KDecoder() + let region = J2KRegion(x: 0, y: 0, width: 64, height: 64) + let opts = JP3DBridgeOptions(regionOfInterest: region) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<16 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0x900 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 16) + for s in 0..<16 { + XCTAssertEqual(batched[s].width, 64) + XCTAssertEqual(batched[s].height, 64) + XCTAssertEqual(serials[s].components.first?.data, + batched[s].components.first?.data, + "Batched bridge (corner ROI) slice \(s) diverges from serial") + } + } + + // 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. + 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) + let opts = JP3DBridgeOptions( + partialResolutionLevel: 4, regionOfInterest: region) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<4 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0xA00 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 4) + for s in 0..<4 { + 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") + } + } + + // MARK: - K=0 fallback + + /// K=0 (thumbnail = deepest LL only) — orchestrator can't dispatch + /// an empty iDWT chain, so the path must fall back to per-slice + /// serial. Batched output still matches serial bit-exact. + func testBatchedBridgeWithPartialResolutionK0_FallsBackBitExact() async throws { + let decoder = J2KDecoder() + let opts = JP3DBridgeOptions(partialResolutionLevel: 0) + var coefsArr: [JP3DSliceCoefficients] = [] + var serials: [J2KImage] = [] + for s in 0..<4 { + let image = makeLCGImage(width: 256, height: 256, seed: UInt64(0xB00 + s)) + let codestream = try await losslessHTEncoder().encode(image) + let c = try await decoder._jp3dDecodeToCoefficients(codestream, options: opts) + coefsArr.append(c) + serials.append(try await decoder._jp3dIDWTAndFinalize(c)) + } + let batched = try await decoder._jp3dIDWTAndFinalizeBatched(coefsArr) + XCTAssertEqual(batched.count, 4) + for s in 0..<4 { + // K=0 → output depth = N = 5, dims = ceil(256/32) = 8. + XCTAssertEqual(batched[s].width, 8) + XCTAssertEqual(batched[s].height, 8) + XCTAssertEqual(serials[s].components.first?.data, + batched[s].components.first?.data, + "Batched bridge (K=0 thumbnail) slice \(s) diverges from serial") + } + } +}