Changelog
1.0.0 - 2026-11-01
Added
- The documentation has diagrams of a pipeline run, the two masks and the shared work of the standard configurations (The Pipeline page), and a figure for each filter on the Image Filtering page: a seeded test image and the filter response, with a slider and a play button for the filter settings (
dev/docs_figures.pymakes the figures). A short form such as ROI, FBN or GLCM shows its full name when the pointer is on it. The site uses the modern Zensical look, loads the next page without a full reload, links the previous and the next page at the bottom, and has a sitemap with the page addresses. - The Benchmarks page has interactive charts: a point or a bar gives its times, and a click on a legend entry shows one task. The IBSI 2 Phase 3 page has interactive heat maps: a cell gives the team, the configuration or the scan, the agreement and the number of values. The charts follow the light or dark scheme, and the PNG stays for a browser without JavaScript. The rings and masks tutorials have pictures with a slider: the rings of the
grow_masktable, the rings of touching ROIs with and withoutnearest_roi, the ROIs of a label map, and the thresholds of a probability map. The feature pages link the section of each family in the IBSI reference manual, and the filter page links the IBSI 2 reference manual.
Changed
- Pictologics 1.0.0 is the first stable release (the package classifier is Production/Stable). The code, the tests and the developer scripts are cleaned of old comments and dead code; the old PyRadiomics benchmark is removed from the developer gate. The API does not change.
- The
resegmentandfilter_outlierssteps now change the intensity mask only when a step gives noapply_to, as IBSI re-segmentation does: the morphological mask keeps the ROI. Before, the default was"both", so the morphology features of such a configuration described the voxels in the range; giveapply_to: "both"to keep that. Thelvandcoronarytemplates now giveapply_to: bothin theirresegmentsteps, so their results do not change. The other mask steps keep the default"both".
Fixed
- A SEG whose frames have no position and whose dimension index values count from 0 (DICOM counts from 1) now loads its first frame into the first slice. Before, that frame got slice -1, and numpy wrote it into the last slice.
- A one-voxel image (an image of 1x1x1 voxels) now gives the texture features of a one-voxel ROI in a larger image. The GLDZM map took the image as a plane on each axis and gave the voxel its start distance 4, not 1: the six distance features change. All 13 GLRLM directions stepped out of the image, so the 16 GLRLM features were missing (NaN in a pipeline run).
- Information correlation 2 (JN9H) of a GLCM with independent grey levels (P = p p^T) is now 0. Rounding made the entropy difference slightly negative, and its square root gave NaN (for 23 % of such random GLCMs).
- The GLRLM kernel no longer writes past its run tables for a ROI whose box is one voxel. The tables of such a box hold the run lengths 0 and 1 only, and the kernel wrote a zero into the cell of length 2 outside them. With the compiler off, that raised an IndexError. The features do not change.
- The IVH volume fractions (BC2M, DDTU) of a range with one end now keep that end, as IBSI 4.3 defines a half-open range: the other end is that of the ROI (with FBS bins, the center of the last bin). Before, the given end was dropped and the range of the ROI bins was used. A pipeline FBS IVH now also takes the upper end of the resegment range (IBSI 4.3), as it took its lower end. A filter or normalise step resets both ends.
- The threaded level count of the histogram features (from 524,288 values) used a whole-number check that compiled code does not define for an infinite value, and it took a whole value above 2^63 as not whole. It now uses the check of the one-thread count, so both give the same result.
- With
PICTOLOGICS_NUM_THREADS=1set after numba started more threads, the import warm-up ran on one thread. The kernels that only more threads use then compiled during the first run after a laterset_num_threads. In that case the warm-up now runs on two threads.
Optimized
- A large float64 image (a loaded file or an image in memory) is now cut to the ROI region at the start of each configuration that reads only that region, as an image of another type already was. The configurations without a step before the feature extraction keep the whole image. On a 512x512x200 CT, a lesion run with outlier filtering, resegmentation and FBN takes 0.14 of the time, and
run_roiswith 20 ROIs 0.04. The features are the same, bit for bit. - A volume of a NRRD file with a list axis first (a layer of a
.seg.nrrdfile) gets a column-order copy of its stored type, then the one-pass float64 copy. One layer of a 4-layer 512x512x200.seg.nrrdfile loads in 0.69 of the time with 0.65 of the peak memory. The values are the same, bit for bit. - In
run_rois, agrow_maskstep withnearest_roinow finds the added voxels of a ROI in the box of its grown mask, not in the full image. For 20 small ROIs on a 512x512x200 CT, the step with the intensity features takes 0.12 s instead of 2.3 s at 10 threads, and 0.26 s instead of 2.4 s at 1 thread. The masks and the features are the same, bit for bit. - Moran's I and Geary's C on the FFT path take the ROI values with no 3-D difference array. On an odd FFT grid, they keep only the folded quarter of the kernel spectrum (a quarter of its memory). The two features take 0.84 to 0.97 of the time (0.85 to 0.89 on odd grids). On an odd grid, a value can change in its last bit.
- The FFT filters with a boundary other than periodic (Simoncelli, Riesz, Riesz-Simoncelli) cut the padded border after the inverse FFT of each axis, so the later axes skip it. On a 128x128x128 image, the Riesz filters use 0.83 to 0.86 of the peak memory and the Riesz-Simoncelli filter 0.89 of the time. With SciPy 1.17 the values are the same, bit for bit. SciPy 1.18 rounds the FFT of an axis by its batch, so there a value can change in its last float32 bit (rarely).
- The GLCM features sum the upper triangle of the symmetric matrix (a cell off the diagonal also stands for its mirror), with no copy of a single table. With 256 grey levels, the GLCM family takes 0.76 to 0.93 of the time. The features move in the last digits (up to 6e-14 relative). No IBSI verdict changes.
- The GLSZM and GLDZM zone fill checks only the 13 later neighbours of the first voxel of each zone. The 13 earlier ones come before it in the scan, so they are visited or of another grey level. With 256 grey levels, the two families take 0.82 to 0.85 of the time on a 25x25x25 cube and 0.88 on a 100x100x100 cube; with 32 levels the time is the same. The zones are the same.
- The IVH counts whole float values with no int32 copy: the count checks each value. int32 and int64 values find their range in one pass. The IVH features take 0.42 to 0.63 of the time for 500,000 to 2 million float64 values, and 0.85 to 0.98 for int32 values. The values are the same, bit for bit.
- The NGLDM with a coarseness alpha above 0 counts the dependent neighbours of a row in one loop form that vectorizes. The GLRLM skips the walk of long runs where a row segment has none. GLCM, GLRLM, NGTDM and NGLDM together take 0.83 to 0.96 of the time with alpha 1. The values are the same, bit for bit.
- The NGTDM features come from one compiled kernel. The kernel adds the terms in the order of numpy (pairwise sums), so the features are the same, bit for bit. A call with given matrices of 32 grey levels takes 1.7 µs instead of 19 µs.
calculate_all_texture_featuresof a ROI of 13 to 1,900 voxels takes 0.74 to 0.81 of the time. - The first-order family now takes the mean, the minimum, the maximum and the energy from one pass over the values, in threads from about 500,000 values. The energy, the minimum and the maximum keep their values, bit for bit (only a zero minimum or maximum can change its sign). The mean sums each block of 4,096 values in eight parts, so it can move in the last digits (up to 1.3e-14 relative in the IBSI 2 phase 3 data, for a mean near 0). The variance, the skewness, the kurtosis and the deviations follow the mean (up to 4e-14). No IBSI verdict changes. The family takes 0.73 of the time for 2 million values, 0.53 for 20 million and 0.58 for 100.
- The hull candidates of the morphology family take fewer counting sorts. The voxel candidates come in raster order, so they take 2 sorts, not 9. The mesh candidates take 5, and they are sorted into mesh order only after the plane hulls drop most of them. The values are the same, bit for bit.
- The minimum volume enclosing ellipsoid (MVEE) of the morphology family keeps the Mahalanobis value of each hull point. Each step updates it with the same rank-1 change as the inverse matrix: 3 products for each point, not 10. A step changes the weights through one scale factor, and the search for the extreme points runs in blocks of 64 points. A CT lesion with 690 hull points takes 0.60 of the morphology time, and the work arrays take a third of the memory. The two MVEE densities can move in the last digits: up to 2e-11 relative for lesion-like hulls, and up to 1e-10 for hulls far from the origin. No IBSI verdict changes.
- The rotation-invariant Laws average adds each run of rotations that share one base response in one pass of a compiled kernel in threads. Each value is read once for all its adds and subtractions. From 48x48x48 to 256x256x256 the averages take 0.63 to 0.84 of the time. The values are the same, bit for bit.
- The rotation-invariant wavelet average adds the responses in a compiled kernel in threads, from 2^18 voxels on. Each value is read once for all its adds. The LLL and HHH averages take 0.66 to 0.75 of the time from 64x64x64 to 256x256x256. The values are the same, bit for bit.
- The surface area and the volume of the morphology family now come from the corner cases of the marching cubes, without the mesh: one pass counts the cubes of each case. The volume is exact in voxel units, and the area adds the area of each case. The AABB and OMBB use the hull candidates, which hold the extremes of the mesh. The morphology features take 0.53 to 0.71 of the time on cubes of 25 to 150 voxels and 0.95 to 0.97 on CT lesions. The area moves by up to 6e-14 relative. The old volume of a small ROI far from the origin was off by up to 1e-10; the new volume is exact to rounding.
- The threaded GLDZM distance map and the threaded grey-level volume of the texture features now write every voxel in the threads, with no serial zero fill. The first pass of the distance map is a run length. The distance map takes 0.33 to 0.72 of the time and the volume 0.65 to 0.85 (64^3 to 256^3). The zone fill zeroes its face labels in the threads. The values are the same, bit for bit.
discretise_imagefinds the ROI range of a float64 image with a bool mask in the one fused pass, as with a uint8 mask. On a 512x512x200 CT, the FBN discretisation with a bool lesion mask takes 0.29 of the time. The bins are the same.keep_largest_componentandrun_roiswrite the label comparison straight into the uint8 mask, with no bool temporary. The masks are the same.keep_largest_componentcounts the voxels of each component in a compiled kernel in threads, with no int64 copy of the labels. A 512x512x200 body mask with 27,245 components takes 0.72 of the time and 0.46 of the peak memory; 20 spheres take 0.64 of the time. The mask is the same.load_and_merge_imageswithrelabel_masksloads each unscaled integer NIfTI mask in its stored type and merges it as its label in the merge kernel, with no relabelled copy. Eight uint8 masks of 512x512x200 merge in 0.30 of the time with 0.21 of the peak memory. The labels are the same.load_imagewithsuvscales the float64 array of a single multi-frame PET file in place, as it does for a series. A 256x256x200 file loads in 0.59 of the time with 0.56 of the peak memory. The values are the same, bit for bit.load_segwithcombine_segments=Falsecopies only the slices of the frames of each segment into its row-order mask; the other slices stay 0. A SEG file of 20 segments of 15 slices on a 512x512x152 grid loads in 0.68 of the time. The masks are the same.run_batch()writes the record of a case in one JSON pass. The log entries are ready for JSON, so only the entry of a failed configuration gets the full conversion. A label map case with 200 small ROIs and one intensity configuration takes 17 ms instead of 22 ms. The case files are the same, character for character.save_imagenow writes int16 and uint16 images (stored CT and MR pixels) with the column-order copy and the gzip writer on threads. A 512x512x200 image takes 0.18 s instead of 1.8 s as.nii.gz, and 0.05 s instead of 0.65 s as.nii. The voxel data in the file are the same, bit for bit.visualize_slicesreads the gray range of the volume in threads, assave_slicesdoes. For a 512x512x200 CT, the viewer starts in 0.80 of the time.
Dependencies
- The documentation builds with Zensical, the successor of Material for MkDocs (Material for MkDocs reaches its end of life on May 5, 2027). The dev group installs
zensicalin place ofmkdocsandmkdocs-material, and it namescertifi, whichdev/IBSI2/download_ibsi2_data.pyimports (before, it came withmkdocs-material). CI runszensical build --strict, which stops on a broken page link, a broken anchor or a broken API reference.dev/generate_docs.pybuilds a copy of the documentation in the cache folder, because Zensical writes its.cachefolder next tomkdocs.yml.
Removed
- The unused module constants
OFFSETS_6,OFFSETS_26_TUPLE,DIRECTIONS_13_TUPLEandDIRECTIONS_13_WITH_IDofpictologics.features.textureare removed;OFFSETS_26andDIRECTIONS_13stay.
0.9.0 - 2026-10-23
Changed
- Moran's I and Geary's C choose between the pair loop and the FFT with a fixed count of 4 threads in their time model. Before, the choice used the thread count of the import. So the choice, and thus the values, are now the same on every computer. At 1 thread, for example in the workers of run_batch, a compact ROI of 3,400 voxels takes 0.84 ms instead of 5.9 ms. At 10 threads, some ROIs of 2,000 to 5,000 voxels take up to 0.8 ms more. The values of these ROIs move by at most 4e-15 (relative). The IBSI values stay the same, bit for bit.
calculate_zone_featuresraises aValueErrorwhen its distance map has a value outside [0, 65535] or a NaN. Before, a negative value or a NaN removed the zone from the GLDZM silently.riesz_logwith the zero, nearest or mirror boundary does the LoG and the Riesz transform in one FFT round trip on one padded image. Before, it did two round trips with a float32 cut between them. On a 194x198x193 IBSI 2 phase 3 CT scan, it takes 0.40 of the time at 10 threads and at 1 thread. It needs 0.39 of the peak memory. The values change toward the boundary condition. On that scan the change is 7.4e-5 of the response range with the zero boundary, 6.9e-3 with nearest and 1.7e-2 with mirror. In a comparison with a reference of each boundary, the new values were as close or closer in all 66 measured cases. The periodic boundary (the default) does not change. The IBSI 2 tests 9.a and 9.b.1 pass with the same errors. Asigma_mm,spacing_mmortruncatethat is not a finite positive number now raises a ValueError before the padding.simoncelli_wavelet,riesz_transform,riesz_simoncelliand a 2-Driesz_logwith the zero, nearest or mirror boundary pad each axis a little wider. Each padded axis now has a fast FFT size of the same parity. With the mirror boundary, a Riesz-Simoncelli filter of a 194x198x193 image takes 0.23 of the time at 10 threads and 0.15 at 1 thread. A Simoncelli filter of that image takes 0.64 of the time. A 256^3 or 512x512x200 image takes 0.90 to 0.91 of the time. The peak memory is 0.95 to 0.96 of the old peak. The values with a non-periodic boundary change. On a smooth test image, the change is up to 1.3e-1 of the response range. On an IBSI 2 phase 3 CT scan, it is up to 5.5e-2. Against a reference with a 64-voxel pad, the new values are closer in most cases. The periodic boundary (the default) does not change. In the "Simoncelli Boundary" table of the IBSI 2 phase 3 page, 56.0 % of the mirror values are within 1 % of the range (before: 56.3 %).
Fixed
- The NaN check of a run gives no numpy RuntimeWarning when the sum of the image values is too large for float64 or float32, or when the image has +inf and -inf. Results are unchanged.
- The first-order features of a ROI whose values are all equal now give exactly 0 for the variance, the three absolute deviations and the coefficient of variation, as IBSI defines them. Before, a value that is not exact in binary (0.1 or 1/3, for example) gave rounding noise: a variance of about 1e-34 and a robust mean absolute deviation of up to 3e-13. Equal infinite values do not change.
- With
extract_private_tags=True(default),DicomDatabase.from_folders()left out a file when pydicom could not read one element of its header (for example a US value of 3 bytes). Now the file stays in the database. A private element that pydicom cannot read is not in the private tags of the file. calculate_intensity_featuresgives the same values for a strided array as for its C-order copy. Before, seven features of a strided array were different by up to 3e-13 (relative).format_resultsgives Python numbers for a Series of a nullable type (Int64, Float64, boolean), as the long format did for the values. Before, the wide format gave numpy numbers, and its JSON output of an Int64 or boolean Series raised a TypeError.
Optimized
- A linear resample with
round_intensities=Truenow rounds the values in its kernel. A second pass over the output is not necessary. The values are the same, bit for bit. A 512x512x200 CT to 1 mm takes 12 ms instead of 16 ms on 10 threads. - A mask or an image that
load_imageturns to the voxel order ofreference_imagenow copies on a tiled parallel kernel when numpy's copy reads across the cache. The values are the same bits. A 512x512x200 mask with reversed axes loads in 21 ms instead of 140 ms (uint8, as the pipeline loads it); the turn of a float64 image takes 17 ms instead of 247 ms. With X and Y swapped, the turn of a float64 image takes 17 ms instead of 71 ms. On one thread, only reversed axes of large arrays change: 81 ms instead of 255 ms. - A run without a mask, or with a mask that holds every voxel of its box, no longer copies the image for the intensity, histogram and IVH features: the ROI values are a view of the image. On a 512x512x200 CT at 10 threads,
run()without a mask with discretisation, intensity and histogram features takes 104 ms instead of 169 ms and 260 MB instead of 900 MB. - A source mask for a sentinel value now comes from one numba pass, in parallel from 1.5 million voxels. This applies to
create_source_mask_from_sentineland tosource_mode="auto"in the pipeline. The mask is the same. For a 512x512x200 CT,create_source_mask_from_sentineltakes 3.0 ms instead of 14.9 ms (7.9 ms instead of 14.6 ms on one thread), with half the peak memory. The source mask of a run takes 2.9 ms instead of 11.8 ms. - From 131,072 zones on, the GLSZM and GLDZM tables of the pipeline and of
calculate_all_texture_featuresare counted in threads, one chunk of zones in each thread. The zone pass also adds to the zone roots only the zones that a chunk face joins, not all zones. At 256 grey levels on 10 threads, the GLSZM and GLDZM of a 150^3 ROI cube take 10.2 ms instead of 16.4 ms, and all texture families take 16.8 ms instead of 22.8 ms. Below 131,072 zones and at 1 thread the time stays the same. The values are the same, bit for bit. - In
run_rois, the map of the nearest ROI of agrow_maskstep withnearest_roicomes from the same parallel distance passes asgrow_mask. For 20 ROIs on a 512x512x200 CT, the map takes 21 ms instead of 390 ms. A voxel at the same distance from two ROIs can go to the other ROI than before (15 of 10.8 million map voxels in this case, none of the ring voxels). Such a voxel now goes to the same ROI on every machine, at each number of threads. - The GLCM, GLRLM, GLSZM, GLDZM and NGLDM features come from one compiled kernel for each family. The kernels add the terms in the order of numpy (pairwise sums), so the features are the same, bit for bit. The pass that makes the grey-level volume also gives the texture voxel count and the grey-level span of the GLCM. So the feature functions make no more passes over the ROI. On one thread, a small ROI counts the GLCM in one table, and its tables need no sum over the threads.
calculate_all_texture_featuresof an ROI of 1 to 3,000 voxels takes 0.27 to 0.58 of the time.run_rois()of 200 small ROIs with the texture features takes 35 ms instead of 71 ms, and with all families 76 ms instead of 115 ms. All six families of a large ROI take about 0.91 of the time, and the GLCM alone about 0.88. A two-dimensional GLCM or GLRLM table given to its feature function now counts as one table. Before, it gave an error. - The GLSZM and GLDZM zone pass keeps 12 bytes for each texture voxel instead of 20, and the GLDZM distance map is uint16 instead of int32. The peak memory of the texture features drops by 29 to 31 %. For a 150^3 ROI cube it is 60 MB instead of 87 MB. The time stays the same or drops by up to 10 %. The values are the same, bit for bit.
- The NRRD and MetaImage readers read raw data directly into the array, and inflate gzip and zlib data into the array in one pass. They no longer keep the file bytes and a copy of the values. A 512 x 512 x 200 raw file loads 1.2 times faster. The peak resident memory of a load falls by 8 % to 38 %.
- The NaN check of a run uses the numpy sum below 2,097,152 values (not 524,288) and on fewer than 4 threads. The check of a region of 1 million voxels takes 0.13 ms instead of 0.17 ms on 10 threads and 0.13 ms instead of 0.37 ms on 1 thread. A region of 7 million voxels takes 0.83 ms instead of 2.2 ms on 1 thread. Results are unchanged, bit for bit.
- The ROI minimum and maximum skip the image rows without ROI voxels. One vectorised test of each mask row finds these rows. This range gives the default bounds of FBN and FBS discretisation, the FBS bin count of the pipeline and the GLCM grey-level span. On a 512x512x200 CT with a small lesion, the range takes 0.36 ms instead of 1.7 ms (1.0 ms instead of 13.7 ms on one thread), and
discretise_imagewith FBN 32 takes 47 ms instead of 60 ms on one thread. Results are unchanged, bit for bit. - The ROI values of a large mask box are now gathered in parallel, with 4 threads or more.
apply_maskand the pipeline read a box of 2^20 voxels or more in place, with no bool mask and no copy of the box. The values and their order do not change. On a 512x512x200 CT at 10 threads,apply_maskwith a lesion mask takes 3.4 ms instead of 11.8 ms and 2.4 MB instead of 50 MB. The standard_fbn_32 configuration of an IBSI 2 MRI scan (a box of 10.7 million voxels) takes 84 ms instead of 93 ms. - The Simoncelli and Riesz filters now make the inverse FFT without the temporary copy of the spectrum that SciPy makes. On a 256x256x256 image, a filter call needs 322 MB instead of 451 MB in addition to its input and tables. On a 512x512x200 CT, it needs 211 MB instead of 782 MB. Calls with a cached table are up to 16 % faster. The values are the same, bit for bit.
- The Simoncelli and Riesz transfer tables of 3-D images are now built by parallel numba kernels. A filter call on a new image shape is faster. On a 512x512x200 grid with 10 threads, the Simoncelli table of level 1 takes 16.5 ms instead of 196 ms, and the Riesz table 7.7 ms instead of 92 ms. A Simoncelli filter of level 1 on a new 512x512x200 shape takes 111 ms instead of 295 ms. A Riesz-Simoncelli filter there takes 133 ms instead of 422 ms. The values are the same, bit for bit.
- The
round_intensitiesstep rounds a float64 image of 2^20 voxels or more in threads when more than one thread runs. On a 512x512x200 CT, the step takes about 8 ms instead of 26 ms. On a 200x197x180 grid, it takes about 1.3 ms instead of 3.6 ms, so a run of IBSI 2 phase 2 with a filter is about 3 ms faster. The results are the same, bit for bit. - The cubic resample now copies a column-order float64 image and its source mask with the tiled parallel copy of the loaders. The linear and nearest resamples already did this. A 512×512×200 CT to 1 mm takes 147 ms instead of 385 ms at 10 threads. With a source mask, it takes 281 ms instead of 654 ms. The values are the same, bit for bit.
- The energy and the root mean square of the first-order family now sum their squares in threads from about one million values. Each block of 4,096 values sums in the same order as before, and the block sums add in block order. So the values are the same as in 0.8.0, bit for bit, at every number of threads. The sum of 52 million values takes 1.9 ms instead of 9.6 ms at 10 threads. One thread is 1.5 times faster. The first-order family of a whole 512×512×200 CT grid takes 36 ms instead of 44 ms.
- The filters with a source mask (the
roi_onlyandautosource modes) now use parallel numba kernels. The kernels make the masked image, the weight and the normalized response. Each kernel reads the arrays once. The wavelet filter casts the masked image to float32 in the same copy. On a 232x182x148 image with 30 % invalid voxels at 10 threads, the mean filter takes 13.0 ms instead of 39.1 ms. The LoG takes 30.1 ms instead of 55.8 ms, and the wavelet 4.8 ms instead of 22.5 ms. On one thread, the mean filter takes 63.1 ms instead of 71.4 ms. On a 512x512x200 CT, a pipeline with seven masked filters takes 271 ms instead of 350 ms. A masked wavelet needs 10 % less memory. The values are the same, bit for bit. - The first-order features of a ROI with 524,288 values or more take the moments and the three absolute deviations from one pass in threads, in 64 fixed parts. The first-order features of 2.3 million values (an IBSI 2 CT scan at 1 mm) take 2.5 ms instead of 3.9 ms on 10 threads; one thread is as fast as before. The values do not depend on the number of threads. The variance, skewness, kurtosis, coefficient of variation and the three absolute deviations of these ROIs move by less than 1e-10 (relative; at most 1.3e-11 in the IBSI 2 phase 3 data) and are closer to the exact sums. Smaller ROIs give the same values as before, bit for bit.
- The import now warms the morphology moments and the texture grey-level volume for column-order box crops. These come from a small NIfTI image or another column-order image or uint8 mask, when the ROI box fills the image or cuts only its last axis. The first run on such an input no longer compiles code (0.06 to 0.27 s for each form with an empty numba cache). A warm import takes about 0.04 s more.
- The linear, nearest and source-mask resample kernels now run at every output size. Before, an output of fewer than 1,800 to 25,000 voxels used scipy. The output is the same, bit for bit. The resample of a 12³ image to 1 mm takes 10 µs instead of 42 µs.
run()of a small cropped image is up to 34 % faster. - The sentinel search now counts the 6 default values with one faster numba pass, and a run no longer counts the found value a second time. This applies to
detect_sentinel_valueand tosource_mode="auto"withoutsentinel_value. The result is the same. For a 512x512x200 CT with an ROI, the search takes 7.7 ms instead of 12.0 ms. The search step of a run takes 7.8 ms instead of 24.8 ms, with no temporary array of the image size. With the one-pass source mask, an AUTO run with intensity features on that CT takes 11.6 ms instead of 38.2 ms. DicomDatabase.from_folders()reads the folders with oneos.scandirwalk and gets the real path of each folder once, not of each file. It converts only the private elements of a header, and it computes the projection score without numpy. A serial scan of 6,000 synthetic CT files takes 3.0 s instead of 3.9 s; the database does not change.extract_roiwrites the ROI values and the NaN values in one pass. It does not copy the full image and invert the mask first. On a 512x512x200 CT with a 20 mm lesion, it takes 38 ms instead of 48 ms. Its peak memory is 450 MB instead of 500 MB. A column-order image takes 41 ms instead of 425 ms, and the result keeps the column order. An integer image is cast to float64 in the same pass. The values are the same, bit for bit. A big-endian float image gives the same values in the native byte order.filter_outliersmakes the new mask in one serial pass with no branch when the image has fewer than 2^20 voxels, and it copies the ROI values only when they hold a NaN. On the IBSI 1 configuration D crop (63x61x56 voxels) the step takes 0.14 ms instead of 0.28 ms. On a 1 mm CT lesion crop it takes 0.09 ms instead of 0.18 ms. With one thread, a 512x512x200 CT takes 37 ms instead of 45 ms. The results are the same, bit for bit. When the image and the mask are both in column order, the new mask is in column order too.format_resultsgets the feature names and values of a Series in two calls, not one by one. A wide row of the 6 standard configurations takes 65 µs instead of 99 µs. The values do not change.get_log()copies the log withpicklein place ofcopy.deepcopy. A log of 1,200 entries (200 ROIs and six configurations) takes 3.8 ms instead of 19 ms. The copy is equal to the log and independent of it. A step parameter thatpicklecannot write getscopy.deepcopy, as before.grow_maskcomputes the distances with three parallel passes (one for each axis) instead of scipy's distance transform. A 3 mm ring of a lesion on a 512x512x200 CT takes 3.0 ms instead of 8.9 ms. A grow of a 100^3 ROI by 2 mm takes 3.7 ms instead of 52 ms and needs a fifth of the peak memory. The masks do not change, bit for bit.keep_largest_componentno longer labels a mask that fills its bounding box, because such a mask is one component. When a run has no mask, the pipeline makes a full mask, and this step was slow. On a 512x512x200 CT, the step now takes 7 ms, not 440 ms, and 100 MB of peak memory, not 300 MB. The step also labels a 1-byte copy of the mask, so it takes about 10 % less time on a float64 mask. The results are the same, bit for bit.load_and_merge_imagesmerges each image in one parallel pass (a compiled kernel for float64, uint8, int16 and uint16 arrays in row order from 2^18 voxels) instead of four numpy passes with bool arrays of the image size. Withreposition_to_referenceand a fill value of 0, the merge starts as an array of zeros that the system gives on first use. On a 512x512x200 CT, the merge of 20 sphere masks takes about 0.54 s instead of 1.41 s, with a peak of 850 MB instead of 950 MB; the merge of 20 cropped masks into the reference takes about 20 ms instead of 29 ms. Results are unchanged, bit for bit, with one exception on x86-64: for a voxel with +0 in one image and -0 in the other, the max rule now gives +0 and the min rule -0, as on arm64 (numpy on x86-64 gave the zero of the later image, the same value).load_image()of a DICOM series reads the slices of the next 32-slice slab on a worker thread while the threads of the package convert the current slab. A 200-slice CT (512 x 512) loads in 64 ms instead of 73 ms (133 ms instead of 146 ms at 1 thread). The array is the same, bit for bit. The peak memory is 16 MB more (one more slab).load_image()of one DICOM file reads the SOP class of the header once, not two or three times. A 512 x 512 slice loads in 0.63 ms instead of 0.75 ms, and a PET slice withsuvin 0.57 ms instead of 0.86 ms.load_image(..., apply_rescale=False)of a large DICOM series converts the slices a slab at a time into the stored-type output, as the rescaled load does. A 200-slice CT (512 x 512) loads in 58 ms instead of 86 ms, with a peak of 137 MB instead of 203 MB. The array, its type and its row order are the same.load_imagewithreference_imagestarts the full-size array of a cropped mask as zeros from the system whenfill_valueis 0 (the default), and writes only the box of the mask. A cropped mask that loads as float64 into a 512x512x200 grid takes 16 ms instead of 24 ms. A pipeline run with a float64 cropped mask path on an image in memory takes 18 ms instead of 26 ms. A uint8 mask loads as before. Results are unchanged, bit for bit.load_rtstruct()fills each ROI only in the box of its contour spans. It makes no full-size mask for an ROI and does not scan one for its box. On a 512x512x200 CT with 20 small ROIs, the label image takes 24 ms instead of 73 ms and 51 MB instead of 101 MB of memory. The dict of binary masks takes 60 ms instead of 72 ms. At its return, the process holds 10 MB instead of 1 GB more resident memory, because the zero pages of the masks stay unmapped. The masks are the same, bit for bit.load_segcompares the frames of a DICOM SEG with an integer level. The stored values are integers, so a level of 127.5 is the same as 128. Before, the float level changed each frame to float64 for the compare. The masks are the same, bit for bit. A BINARY SEG of 258 frames (512 x 512, 20 segments) loads in 0.56 of the time as a label map and in 0.80 as a dict of masks, at 10 threads. A FRACTIONAL SEG of 300 frames loads in 0.62 of the time.normalise_imageand thenormalisestep take the statistics of a whole image (no mask, no value range, only finite values, row order) with no gathered copy, and make the map (x - center) / scale in one pass (in threads from 524,288 voxels). On a 512x512x200 CT, a z-score of the whole image takes about 67 ms instead of 138 ms, and its peak memory is 400 MB instead of 850 MB. With a mask, it takes about 32 ms instead of 60 ms. The values do not change, bit for bit.resegment_masknow runs one branch-free kernel for each mask type (bool, integer and float) and at each image size. On a 512x512x200 CT with the range -1000 to 400 HU, it takes 3.1 ms instead of 18.7 ms on 10 threads. On 1 thread, it takes 9.7 ms instead of 110 ms. With an int32 mask, it takes 6.6 ms instead of 129 ms and uses a third less memory. Inrun_rois, a configuration that starts with a resegment step runs about 5 times faster. The masks do not change, bit for bit.save_resultsandformat_resultsfind a NaN or infinite value before they make a JSON text, so they encode the text one time. Before, a NaN made a second encode. A JSON file of 2,000 wide rows with a NaN in the last row takes 1.4 s instead of 2.5 s. The JSON text does not change.save_slicesnow builds each slice as RGB in one pass, mixes the mask colors only into the labelled pixels and scales the gray values in place. For a volume of 8.4 M voxels or more, it finds the gray range in threads. The files are the same byte for byte. On 10 threads, 10 slices of a 512×512×200 CT with a 20-label mask take 17 ms instead of 29 ms (97 ms instead of 119 ms on one thread), with 35 % less peak memory. All 200 slices take 0.22 s instead of 0.30 s.
0.8.0 - 2026-10-21
Changed
- In a resample with a linear (or cubic) mask interpolation, every mask type now takes one threshold rule. It is the rule of a SEG, an RTSTRUCT or an integer array. The resample rounds the interpolated value at the threshold: value + 1 - threshold >= 1. Before, a float64 mask and a NIfTI file mask compared the value itself. The two rules differ only for a value one rounding step below the threshold. So the features of a mask with such a boundary voxel change. This is rare: 7 of the 153 IBSI 2 phase 3 scans change, by up to 14 % on one PET statistic. No IBSI verdict changes.
- The FFT of Moran's I and Geary's C may use half of the memory on every computer, with no 16 GiB ceiling. The workers of
run_batchshare this half, so four workers do not use four halves. Each of the Riesz and Simoncelli table caches keeps at most 2 GiB, and at most a quarter of this half. - The LoG of a float64 image of 131,072 voxels (about 51^3) or more is now one float64 FFT convolution. Before, images below 64^3 voxels ran eight float32 passes. The values of these images move by up to 3e-7 of the largest response, toward the float64 values. At 10 threads such an image takes x0.31 to x0.51 of the time (58x57x58: 1.8 ms instead of 4.0 ms). At 1 thread it takes x0.68 to x0.97.
- The Simoncelli and Riesz transfer-table caches keep the tables of the newest two image shapes. So a cohort of images no longer piles up to 2 GB per process. After 21 scans of the IBSI 2 phase 3 cohort, a worker holds 182 MB of tables instead of 872 MB. Its peak memory drops from 2.5 GB to 1.9 GB. The values are the same, bit for bit.
- The documentation of the FFT filters (Simoncelli, Riesz) now gives the measured accuracy of their pad with a non-periodic boundary. Against a 64-voxel pad, the default pads differ by up to about 1e-1 of the response range. This occurs at the border of a CT scan and at the centre of a large uniform object. The earlier claim (99.9 % of the kernel's L2 energy inside the pad) holds, but the L2 energy is the wrong measure of this error. The periodic boundary, the IBSI default, takes no pad.
- The mean filter and the Laws energy sum each window on its own instead of a running sum. The result is the exact mean of the float32 values, rounded once to double and then to float32. So a value does not depend on where the image starts. Values can move by one float32 unit in rare voxels. The IBSI reference values do not change. In the IBSI 2 phase 3 data, 49 of 24,786 values of the mean filter move by at most 3e-12. The pipeline now filters only the ROI box plus the support for these filters. The mean filter step of a CT run takes 0.5 ms instead of 4.3 ms. A CT run with a rotation-invariant Laws energy takes 30 ms instead of 55 ms. A standalone mean filter of support 15 is 1.2 to 1.6 times slower on an image below 128^3 voxels or at 1 thread.
- The rotation-invariant wavelets with a mixed decomposition (such as LHL) take the tree of shared passes from 2^21 voxels on. Before, the tree started at 2^23 voxels, with average pooling only. Max and min pooling now take it too, and their pooling steps run in threads in fixed chunks of planes. At 10 threads this takes x0.66-0.70 of the time. IBSI 2 phase 3 configuration 6 on a CT scan takes 36 ms instead of 53 ms. A 256^3 image with max pooling takes 95 ms instead of 143 ms. At 1 thread it takes x0.56 of the time, with 27 % less memory. The tree adds the passes in another order. So the last float32 bits change for a mixed decomposition of 2^21 to 2^23 voxels (any pooling), and above that for max or min pooling. In IBSI 2 phase 3, configuration 6 changes on 54 of 153 scans, with no verdict changes.
Fixed
- A DICOM slice whose ImagePositionPatient is not numeric no longer makes
load_imageandget_dicom_phasesfail. Such a series sorts by the instance numbers. Its origin is the position of its first slice, or (0, 0, 0) when that slice has none. - A
binarize_maskstep with its mask values in a NumPy array of two or more values raised an error at the planning of a run. This occurred when a filter, resample or discretise step came before it ('truth value of an array is ambiguous'). It now runs. - A direct texture call with column-order grey levels no longer compiles a second kernel form.
- A run copies a read-only in-memory float64 image or mask once at its start. So no kernel compiles a second form during the run.
- A run of two configurations with the same first steps and a different reach outside the ROI gave wrong values. When the image was not float64, the second configuration took the ROI region of the first. Only the start cut of 0.8.0 had this error. Now each start reach has its own state.
- On Windows, the FFT of Moran's I and Geary's C assumed 8 GiB of memory. So it used at most 4 GiB, whatever the memory of the computer. Larger ROIs then took the slow loop over the voxel pairs. A thin shell of 519,640 voxels took 15 s instead of 0.6 s. A body ROI of a 512x512x200 CT took about 10 hours instead of 0.9 s. One reader now gives the memory of the computer to the whole package. On Windows it reads
GlobalMemoryStatusExand checks its result. On macOS and Linux it readssysconfand checks each number. On Linux it takes at most the memory limit of the cgroup of the process (cgroup v2, then v1). That limit is the smallest limit on its path, for example of a Slurm job. It assumes 8 GiB only when the system gives no valid number. The resample check uses the share of this process: the memory of the computer, divided among therun_batchworkers. Its message gives GiB. - The GLDZM no longer drops zones silently. Before, a texture voxel outside the distance mask got distance 0, and its zone left the matrix. A direct call with such masks now raises a
ValueErrorwith the number of voxels outside. In the pipeline,keep_largest_componentandbinarize_maskwithapply_to="morph"now also keep the intensity mask inside the new morphological mask. In IBSI, the intensity mask lies inside the morphological mask. So the features of a multi-component ROI come from the kept component alone. When the two masks were one array, the intensity mask after the step is the new morphological mask, as withapply_to="both". Else the intersection runs on the box of the new mask: about 2 ms on a 512x512x200 CT, not about 20 ms. - The Gabor filter gives the same values for every number of threads also with SciPy 1.18 or later. From SciPy 1.18, an FFT over a block of slices rounds each slice by the block and by its place in it. So each worker now starts its slices on an even slice, and the blocks of two slices are the same for every number of threads. The speed stays the same, and with SciPy 1.17 the values do not change.
- The NRRD and MetaImage readers give writeable arrays. So the first load of each stored type no longer compiles a second, read-only numba signature (about 0.2 s with a cold cache).
- The convex hull features no longer hide errors: only points in one plane (where Qhull raises) give no hull features. Any other error now reaches the morphology family, which reports it, instead of dropping the maximum 3-D diameter and the MVEE features silently.
- The convex hull volume adds its tetrahedra from a hull vertex, not from the origin. So a region deep in a large grid no longer loses digits. Before,
volume_density_convex_hullwas off by up to 5e-11 (relative) for a region 500 voxels from the origin. For a region 100,000 voxels away, it was off by 2e-3. It is now exact to 3e-15. The IBSI reference values still pass. - The histogram features of float32 (and other non-float64) discretised values sum in float64. Their mean, variance, skewness, kurtosis, coefficient of variation, percentiles and absolute deviations are now those of the same values as float64, bit for bit. Before, they were off by up to 3e-5 (relative).
- The minimum volume enclosing ellipsoid (MVEE) of the morphology features now stops on the Mahalanobis gap, with away steps (Wolfe-Atwood), at eps = 3e-3. The old stop rule left the ellipsoid 0.3 to 0.5 % too small. So
volume_density_mvee_SWZ1andarea_density_mvee_BRI8were 0.2 to 0.5 % too large. Against a tight fit (eps 1e-8) on 34 masks, the new values are within about 0.2 % (SWZ1) and 1.0 % (BRI8). IBSI gives no reference for these two features, so no IBSI result changes. The MVEE of a CT lesion hull of 690 points takes about 2.7 ms instead of about 0.8 ms.run_rois()of 20 small ROIs with the intensity and morphology features takes about 4 % more time than in 0.7.1. The MVEE is half of that run. Hulls below about 150 points fit faster than with the old stop rule. A single ROI with a hull of 200 to 800 points pays 0.1 to 2.6 ms more. - The ten filters that take a
source_maskread a mask of any type assource_mask != 0. Before, a uint8 mask raised anIndexErrorinlaws_filterand aTypeErrorinmean_filterandlaplacian_of_gaussian. calculate_intensity_histogram_featuresraises a clearValueErrorwhen a discretised value is not a finite whole number. Before, the function truncated values such as 1.6 silently. The mode and the entropy came from truncated counts, the mean and the median from the values. A NaN or an infinite value raised an unclear error. The check reads the values once.gabor_filterandlaplacian_of_gaussianraise aValueErrorthat names a bad parameter. A sigma, a wavelength, an aspect ratio, a spacing and a truncation must be finite positive numbers. The Gabor rotation step must be in (0, 2 pi]. Before, some of these gave an all-NaN or a wrong map silently, and others failed with unclear errors.gabor_filterreturns a row-order (C-contiguous) array on every plane, as every other filter does. On the default axial plane it returned a strided view before. Only the axial result is copied. A tiled parallel kernel makes the copy: 5 ms at 256^3, where numpy's copy of the view took 34 ms.intensity_energy_N8CAandroot_mean_square_intensity_5ZWQno longer depend on the memory address of the values. A fixed-order sum of squares replacesnp.dot. The BLAS blocks ofnp.dotgave values one unit in the last place apart for the same values in another buffer. The values move by at most 3e-14 (relative). The first-order family of 643,000 values takes 0.1 ms more.load_imageno longer stops responding on a.nii.gzfile of two or more gzip members whose first member decompresses to more than 16 MB. The read loop did not stop, and the memory use increased without limit. Now nibabel reads such a file.simoncelli_waveletandriesz_simoncelliof a volume with one slice (a singleton axis) now give the 2-D response of the slice. Before, they gave zeros with a RuntimeWarning (a division by zero in the frequency grid).
Optimized
- A filter that computes only the ROI region now keeps that region. The pipeline cuts the image and the masks to it, as after a discretise step. Before, it wrote the region into a zeros array of the whole grid. The case is a 512x512x200 CT with a 20 mm lesion. A 1 mm resample with a LoG filter takes 18 ms instead of 27 ms at 10 threads. At 1 thread it takes 103 ms instead of 112 ms. Its peak memory is 279 MB instead of 400 MB. A LoG without a resample takes 7 ms instead of 21 ms. With a grow_mask step and the local intensity peaks, it takes 25 ms instead of 56 ms. The features are the same, bit for bit.
- A run makes its plan once.
run_rois()computes the deduplication plan, the prefix keys, the configuration snapshots and the serialized step parameters once for all its ROIs. Each result Series takes the Index of its names from a cache.run_rois()with 200 small ROIs and intensity features takes 9.9 ms instead of 15.0 ms at 10 threads. On a 256x256x12 image it takes 9.8 ms instead of 18.3 ms. The values are the same, bit for bit. - Cubic (tricubic spline) resampling of float64 images now runs on parallel numba kernels. The kernels give the output of scipy bit for bit, also in the region form of the pipeline. On x86-64, where the scipy wheels do not fuse the multiply-add as the kernels do, the values lie within one unit in the last place of scipy's. The IBSI 2 CT phantom to 1 mm takes 20 ms instead of 957 ms (161 ms on one thread). A 512x512x200 CT to 1 mm takes 146 ms instead of 5.5 s, with 40 % less peak memory. The same kernels serve a source mask (sentinel voxels). The IBSI 2 CT phantom with a sentinel mask takes 40 ms instead of 1.9 s. The CT takes 257 ms instead of 10.8 s, with 45 % less peak memory.
- Moran's I and Geary's C of a large ROI take half the time and 30 % less memory. The spectrum of the kernel 1 / |r| is real, so the FFT sums take it from the DCT of its half grid. They get the Moran numerator from the spectrum of the centred intensities, with no inverse transform. One padded buffer serves both forward transforms. The CT lesion at 0.5 mm takes 12.6 ms instead of 25.6 ms at 10 threads. A 2.3 million voxel ROI takes 170 ms instead of 347 ms, with a peak of 1.0 GB instead of 1.5 GB. The values move by at most 1e-13 (relative), in Moran's I of a random image near zero. The IBSI values move by at most 4e-15. Below 450 ROI voxels, the pair loop runs on one thread. A tiny ROI then takes 16 to 42 us instead of 115 to 120 us, with the same values, bit for bit.
- Moran's I and Geary's C pick the faster of the pair loop and the FFT by a time model, measured at 1 to 10 threads. The FFT runs on one worker when that is faster (a small grid). A sparse ROI of 15,457 voxels in a 72x72x72 box takes 7.9 ms instead of 13.9 ms. A full 16x16x16 ROI takes 0.56 ms instead of 0.61 ms. When the FFT grid does not fit in half of the memory, the FFT runs on slabs of the ROI box, within the memory. Before, the pair loop ran. A thin shell of 519,640 voxels with a 4 GiB limit takes 1.2 s instead of 15 s. A 512x512x300 CT without a mask with a 16 GiB limit takes 5.1 s (12.9 GB) instead of about 100 hours. The values change by at most about 4e-14 (relative). The IBSI values stay the same, bit for bit. The choice uses the thread count of the import, so the values do not depend on
set_num_threads. The pair loop runs with fast math, so on x86-64 its serial form for small ROIs can differ from the threaded form in the last bits. - The FFT LoG of a float64 image of 2 million padded voxels or more no longer builds the padded image or the float64 response. Each thread pads its slab of planes a chunk at a time and transforms it into the spectrum. The transfer product runs in threads. The inverse transform makes only the planes of the result. A 256^3 image takes 34 ms instead of 47 ms at 10 threads (193 ms instead of 236 ms at 1 thread). Its peak memory over the input is 262 MB instead of 366 MB. A 512x512x200 CT takes 82 ms instead of 119 ms, with 707 MB instead of 968 MB. The values are the same, bit for bit. With SciPy 1.18 or later, the last digits can differ, as they already do with the number of threads.
- The GLCM pairs and the GLRLM runs of the local texture kernel count in passes over the contiguous ROI segments of each row. A segment shorter than 8 voxels goes voxel by voxel, as before. Up to 64 grey levels, the GLCM pairs alternate between two tables. The counts are the same, bit for bit. On ROIs of 0.6-0.7 million voxels, all six texture families take x0.85-0.89 of the time at 10 threads (GLCM + GLRLM x0.69-0.75). At 1 thread they take x0.77-0.79 (GLCM + GLRLM x0.61-0.62).
- The
binarize_maskstep compares only the ROI box of the mask when it leaves out the value 0. It keeps the box of its new mask for the later steps. For one label, it makes one comparison instead ofnp.isin. On a 512x512x200 CT, a run with the step takes 3.3 ms instead of 27.4 ms with a uint8 mask. One label of a 20-label map takes 5.8 ms instead of 33.2 ms.run_roisover 300 small ROIs with an IBSI-like configuration takes 0.68 s instead of 8.1 s. The results are the same, bit for bit. - The convex hull of the morphology features finds its candidate vertices on the voxel grid, not by a pass over all mesh vertices. The candidates are the voxels that end their rows, kept by their 2-D plane hulls, and then the mesh vertices next to them. The hull step of a 150^3 ROI takes 0.45 ms instead of 3.4 ms, with 2.3 MB instead of 6.9 MB of peak memory. The hull step of an STS CT lesion takes 1.6 ms instead of 2.9 ms, with 1.7 MB instead of 3.4 MB. The hull vertices, the maximum 3-D diameter and the MVEE features are the same, bit for bit. The two hull densities move by at most 2e-15 (relative) for a region near the grid origin.
- The histogram features of the discretised image come from two numba kernels. One kernel counts the levels (in threads from 524,288 values). The other computes the 23 features from the counts in the order of numpy's sums, so every value stays the same bit for bit. A 40-voxel ROI takes 4 us instead of 36 us, and 643,000 voxels 0.21 ms instead of 1.3 ms. 2.7 million voxels take 0.41 ms instead of 5.7 ms, with no 20 MB copy. Float64 values that are whole numbers in [1, n_bins] take the same path (643,000 values: 0.23 ms instead of 6.5 ms). Their variance, skewness, kurtosis, coefficient of variation and absolute deviations now add the same terms in the order of the integer path. This moves them by at most 2e-11 (relative). A 2-D array of values raises a clear error.
- The intensity-volume histogram (IVH) counts whole-number float values as it counts integers, from 4,096 values on instead of 131,072. Below 1,048,576 values, the count runs on one core. The IVH of 643,000 whole float64 values takes 0.6 ms instead of 4.0 ms. The IVH of 300,000 rounded CT values takes 0.3 ms instead of 4.3 ms. Its peak memory is half. The search for the intensity at a volume fraction no longer builds an array over the whole value range. Take a bin width of 1 over values from -4e6 to 4e6. The search then takes 6 ms and 12 MB instead of 40 ms and 230 MB. The values are the same, bit for bit.
- The local intensity peaks keep the fast two-stage search when the image crop holds a NaN or an infinite voxel out of reach. No sphere of an ROI voxel reaches such a voxel. The search then runs on a copy with that voxel set to 0, and the peaks are the same, bit for bit. A 643,000-voxel ROI with such a voxel takes 12 ms instead of 60 ms.
- The local intensity peaks run faster, with the same values bit for bit. One numba pass finds the ROI voxels and their runs along z. The sphere sums of a run read the padded row prefix sums in one piece, and a thin ROI sums voxel by voxel. The two-stage search starts at 65,536 (voxel, offset) pairs instead of 16.8 million, and small work runs without threads. A 2.3 million voxel CT ROI takes 34 ms instead of 84 ms. A 10,000 voxel ROI takes 0.30 ms instead of 1.2 ms, and a 10 voxel ROI 0.02 ms instead of 0.11 ms. Peak memory drops by up to 32 % on large ROIs. Values near the float64 limit give inf, as before.
- The marching cubes mesh stores its faces and its work planes as int32. The mesh is the same, bit for bit. The morphology features take 35 % less peak memory. A 150^3 ROI takes 19 MB instead of 30 MB, and an STS CT lesion 9.5 MB instead of 14.7 MB. A 160x430x390 body takes 97 MB instead of 149 MB.
- The mean, Gaussian and Laws filters and the LoG passes run their 1-D passes in numba kernels. The kernels keep the arithmetic of scipy and read the planes and rows in memory order, so the values are the same bits where the scipy build fuses the multiply-add as the kernels do (the arm64 wheels). On x86-64 the values lie within one unit in the last place of scipy's. At 10 threads a 256^3 image takes 2-3 times less time. The times: mean 28 -> 10 ms, Laws rotation invariant 177 -> 68 ms, and with average pooling 253 -> 96 ms. At the CT pipeline shapes the filters are 1.3-1.4 times faster, also at 1 thread (Gaussian 64 -> 50 ms). The pooling steps of the Laws rotation invariance and the adds of the LoG passes run in threads from 6 MB on.
- The mean, Gaussian and Laws filters with a
padding_valueread the constant at the image borders instead of filtering a padded copy. The values are the same bits. The peak memory is about half (232x182x148: 99-109 -> 50-53 MB). The filters are 1.5-1.9 times faster (mean 6.9 -> 3.9 ms, Gaussian 13.5 -> 7.3 ms). A source mask still takes the padded copy. - The morphology features take less time and 15-22 % less peak memory. The moments kernel has no branch, and the mesh vertex statistics use scalar accumulators. The maximum diameter of a hull below 640 vertices runs in one thread. The parallel marching cubes walks its planes in one block per thread. The morphology step copies the ROI box once, with its border. A 150^3 ROI takes 4.0 ms instead of 8.6 ms, and a 1-voxel ROI 0.06 ms instead of 0.17 ms. The MVEE stop rule of this release costs time on hulls of 200 points and more (see that entry). So a CT lesion takes 4.1 ms instead of 2.4 ms. The 20 small ROIs of a CT label map take 14.3 ms instead of 13.5 ms. The kernels of this entry give the same values, bit for bit.
- The percentiles and the median of fewer than 1,000 float64 values come from one partition in numba. The percentile ranks of every size come from a numba kernel. The values are the same, bit for bit. The first-order features of 40 values take 6 us instead of 14 us, and of 500 values 16 us instead of 20 us. From 1,000 to 5,000 values they take 9 to 14 % less time.
- The pipeline now casts an in-memory float32 or integer image to float64 where it reads it. When every configuration reads only the ROI region,
run()andrun_rois()cut the region first and cast the cut, once per run. Some configurations read the whole image: a resample, a filter, an image-wide normalise, a binarize_mask of value 0 or source mode auto. Such a configuration casts the whole image once, as before (x1.02). The values do not change. A large column-order float32 or integer array (for example nibabel'sdataobj) gets its cast and its row order in one pass.run()of a 512x512x200 float32 CT with the intensity features takes 1.0 ms instead of 34 ms (x0.031). Its peak memory is 1.8 MB instead of 400 MB. - The rotation-invariant Laws filter keeps two base responses in flight instead of three. The peak memory is 20 % lower (256^3: 325 -> 261 MB) at the same speed (within 10 %).
- The wavelet filter runs its 1-D passes in numba kernels that keep the arithmetic of scipy. Each line sums in double, the last tap first, with fused multiply-adds. The kernels read each plane once in memory order and skip the zero taps of the a trous kernels of level 2 and above. The values are the same bits where the scipy build fuses the multiply-add (the arm64 wheels), and within one unit in the last place of scipy's on x86-64. At 10 threads the IBSI 2 wavelet configurations run 1.7-3.2 times faster. Phase 3 configuration 7 on a CT scan takes 69 ms instead of 222 ms, and configuration 6 54 ms instead of 119 ms. A 256^3 image takes 7.6 ms instead of 26 ms. At 1 thread the filter is 2 times faster. The tree of shared passes of the rotation-invariant wavelets writes into one buffer for each depth instead of a new array for each chain. A 256^3 rotation-invariant LHL filter takes 108 ms instead of 335 ms, and a 512x512x200 CT 318 ms instead of 431 ms. A NaN or inf voxel now spreads over the non-zero taps of a level-2 kernel only.
- With the default
num_workers,DicomDatabase.from_foldersreads a scan of fewer than 6,000 DICOM files in the calling process. A larger scan starts at most 8 workers. A 2,000-file scan takes 1.2 s instead of 2.7 s. - With the default
num_workers,SRDocument.from_foldersparses SR files of less than 8 MB in total in the calling process. A larger batch starts one worker per 1.5 MB, at most 8. Anum_workersthat you give applies as it is. An explicitnum_workersbelow 1 means 1. 27 files of 2.5 MB take 1.1 s instead of 3.3 s. load_image(stored_type=True)keeps the integer type of a NIfTI mask or label map with no scaling. The pipeline loads its masks and label maps with it. A 512x512x200 uint8 mask loads in less time, with 100 MB instead of 450 MB of peak memory.run_roiswith a uint8 label map file takes a third of the time, with 100 MB instead of 850 MB.load_imagedecompresses a.nii.gzfile ofsave_imageon the threads of the package. It uses the block index in the gzip header (from 4 MB of compressed data). The kernel converts each block to float64 as it comes, so the stored array is not made. A 512x512x200 float64 CT loads in 0.18 s instead of 0.86 s, and a float32 CT in 0.12 s instead of 0.44 s. Other gzip files load as before.resample_image()and the resample step now copy a column-order image into row order with the tiled parallel copy of the loaders. An example is an array from nibabel'sget_fdata(). A bool source mask takes the same copy. A 1 mm resample of a 512x512x200 column-order CT takes 27 ms instead of 252 ms.run()ofstandard_fbn_32on such an image and mask takes 32 ms instead of 376 ms. Row-order arrays and the values do not change.run_roisreads a float64 label map (a NIfTI label map stored as float) in one parallel pass. It casts the map to the smallest integer type that holds its labels. A 512x512x200 map with 20 ROIs takes 25 ms instead of 94 ms (resample, intensity and morphology) and 100 MB instead of 450 MB. The checks and errors are the same.save_imagecompresses a large.nii.gzfile in blocks of about 1 MB on the threads of the package. A 512x512x200 float64 CT saves in 0.8 s instead of 6.8 s at 10 threads. The file is one standard gzip member, and its decompressed bytes are the bytes of nibabel's file. An extra field of its gzip header holds the index of the blocks (other gzip readers ignore it).
0.7.1 - 2026-10-08
Added
DicomDatabase.export_json()has a new parameterindent(default 2, the text as before). Withindent=None, the text is on one line and Python's fast C encoder writes it: a database of 6,000 files exports in 30 ms instead of 145 ms, in 5.5 MB instead of 9.5 MB. The default export is also faster (128 ms instead of 146 ms), with the same bytes.Image.with_source_mask()has a new parametercopy(defaultTrue, the copy as before). Withcopy=False, the new image shares the voxel array: for a 512×512×200 CT the call takes 9.7 ms instead of 26.5 ms, and it needs 50 MB instead of 450 MB.
Changed
- The LoG of a float64 image below 64³ voxels now runs its eight passes in float32, as for a float32 image: x0.85 to x0.98 of the time, with values within about 1e-7 of the largest response. Larger float64 images keep the float64 FFT path.
- The mean, Gaussian, Laws and wavelet filters now run their passes in float32 for every image, also the Laws energy step. A 256³ float64 image takes x0.56 to x0.78 of the time at 10 threads (x0.83 to x0.90 at 1 thread), with a third less memory. The responses change by the float32 rounding of the passes: about 1e-7 of the largest response, up to 6e-7 for plain Laws. The LoG, Simoncelli, Riesz and Gabor filters keep float64 passes for a float64 image, and every IBSI check passes as before.
Fixed
run_rois()raises a clear error for a label map whose largest label is above 1,048,576. Before, scipy'sfind_objectsbuilt a list with one entry for each label up to the largest, so one stray huge label could take all the memory of the machine.
Optimized
- The FFT LoG, Simoncelli and Riesz with a boundary that is not periodic, and constant value padding pad the image with a numba kernel on slabs in threads, not with np.pad in one thread, with the same values, bit for bit. The LoG of a 256³ image takes 51 ms instead of 72 ms at 10 threads (mirror boundary).
- The GLSZM counts its zones in a table of 2^14 cells up to 1,024 grey levels (2^18 above), and the zone labels start the threads once less. The zone matrices of the CT lesion take 7.7 ms instead of 9.2 ms for the six standard configurations at 10 threads, with the same values, bit for bit.
- The Gabor filter over three planes adds the plane responses, divides them and casts the mean on slabs in threads; before, these steps ran in one thread over the strided plane views. A 256³ image takes 304 ms instead of 351 ms, with the same values, bit for bit.
- The Gabor filter pads, multiplies and pools its slices in numba kernels and transforms two slices with one FFT call, with the same values, bit for bit. A 256³ image takes 163 ms instead of 337 ms over three planes at 10 threads, and a 128³ image 30 ms instead of 129 ms. Each worker holds at most 4 MB of products at a time: the orientations go in groups where the slices are large.
- The Laplacian of Gaussian of a float64 image of 64³ voxels or more is now one FFT convolution with the same truncated kernels and boundary. A 256³ image takes 75 ms instead of 139 ms (with the 8 passes below this size), a 128³ image 9.2 ms instead of 16.6 ms, and 26 ms instead of 97 ms at 1 thread. The float32 response stays the same except in very few voxels (3 of 16.8 million at 256³), by one float32 step; the IBSI 2 values move by at most 3.3e-12 (relative).
- The Laplacian of Gaussian runs the plain Gaussian pass along the first axis one time for the terms of the other two axes: 8 one-dimensional passes instead of 9. A 256³ image takes 127 ms instead of 145 ms, and a 128³ image 79 ms instead of 96 ms at 1 thread, with the same values, bit for bit. The call holds one more temporary array of the image size while it runs.
- The MVEE of the convex hull keeps the products of the coordinates of each hull point, so each step reads one short dot product per point: 0.85 ms instead of 2.74 ms for the 632 hull points of a CT lesion, with the same steps. The two MVEE features move by at most 7e-5 (relative), where rounding changes one step.
- The NaN check of a run reads the region that the run reads (the cut ROI region of a large image), not the whole image. The first run of a 512×512×200 CT takes 1.9 ms less at 10 threads and 16 ms less at 1 thread, and
run_rois()with 20 ROIs 15 ms less at 1 thread, with the same results, bit for bit. A repeated run checks its region again (0.2 ms). - The box scan of a mask below 2^20 voxels runs as a serial numba kernel, not as numpy reductions, and the cut to the ROI region copies a large float64 region in threads. The cut and the box scans of
run()take 0.95 ms instead of 1.28 ms at 10 threads, with the same values, bit for bit. - The convex hull gets fewer candidate points: the line ends that are also 2-D hull vertices in their three axis planes (856 instead of 4,950 points for a CT lesion), found with smaller tables. The candidates and Qhull take 2.0 ms instead of 3.3 ms for the lesion and 4.0 ms instead of 6.5 ms for a 150³ ROI. The hull vertices are the same; the hull volume and area move by at most 3e-15 (relative).
- The convex hull of the morphology features is now exact on the lattice of the mesh vertices (a quickhull with integer orientation tests); Qhull runs only for points in one plane. The hull of the 856 candidates of a CT lesion takes 0.18 ms instead of 1.58 ms, with the same hull vertices in the same order; the hull volume and area move by at most 5e-14 (relative). A morphology-only run on the lesion takes 2.7 ms instead of 7.8 ms with the other morphology items of this release.
- The discretise kernels run serially below 160,000 voxels, equal geometry passes with no tolerance check, and a resample keeps its grid for each input. In
run_rois()with 20 spheres the resample and discretise steps take 332 µs per ROI instead of 466 µs, and the run 52.5 ms instead of 55.2 ms, with the same values, bit for bit. - The intensity variance of the first-order features is now the second central moment of the pass that gives the skewness and the kurtosis, so
np.vardoes not read the values again. The percentiles and the median take the key range of their radix select from the smallest and the largest value (one pass less), from 100,000 values on (before 130,000). The first-order features of a CT ROI with 268,000 values take 0.81 ms instead of 1.15 ms, of 110,000 values 0.84 ms instead of 1.50 ms, and of 2 million values 3.3 ms instead of 4.8 ms. The variance and the coefficient of variation move by at most 3e-13 (relative); the other first-order features stay the same, bit for bit. - The local texture kernel (GLCM, GLRLM, NGTDM and NGLDM) runs as a serial copy with one table for a volume below 10,000 voxels, with the same counts. run_rois() with 20 small spheres (257 voxels each) takes x0.92 of the time at 10 threads.
- The local texture kernel (GLCM, GLRLM, NGTDM and NGLDM) takes the sums of the 26 neighbours from passes along the rows, and counts the runs of length 1 and 2 without a branch. The kernel takes 0.47 to 0.85 of its time, the six standard configurations run in 42.6 ms instead of 50.2 ms at 10 threads and 136 ms instead of 196 ms at 1 thread, with the same values, bit for bit.
- The parallel kernels of a small ROI (the mask and intensity moments, the marching cubes counts, the mesh area and volume, the OMBB extents and the three resample kernels) run as serial copies below measured sizes and with one thread, as starting the threads costs about 70 µs per kernel.
run_rois()with 20 small ROIs takes 51 ms instead of 63 ms at 10 threads, and the morphology of a 10³ ROI 0.39 ms instead of 0.94 ms, with the same results, bit for bit. - The percentiles and the median of 1,000 or more float64 values come from a linear select, with the same values, bit for bit. 17,000 values take 35 µs instead of 273 µs, 2 million bimodal values 0.8 ms instead of 10.8 ms, and
run_rois()with 20 spheres 4 ms less. - The rotation-invariant LLL and HHH wavelets compute their 24 rotations as 8 patterns of flipped axes in a tree of shared passes: 14 chains of level kernels instead of 72 passes. A 256³ image takes 0.27 s instead of 0.85 s for HHH at 10 threads; the response changes in the last float32 bits (for a float64 image, only values near zero change).
- The rotation-invariant wavelets with a mixed decomposition (such as LHL) and average pooling run as a tree of shared passes from 2^23 voxels on: 40 passes instead of 72. A 300 × 300 × 100 image takes x0.80 of the time at 10 threads and a 400 × 400 × 80 image x0.65 (x0.52 to x0.65 at 1 thread); the values change in the last float32 bits.
SRDocument.export_csv()andSRBatch.export_combined_csv()write their rows with Python's csv module, not through a pandas DataFrame, with the same bytes. The CSV of a TID 1500 report takes 0.06 ms instead of 0.24 ms, andfrom_folders()with exports of 50 reports takes 95 ms instead of 104 ms.run_rois()finds the box of each label of the label map with one parallel pass over the nonzero box of the map, not with scipy'sfind_objectsin one thread. For a CT map of 20 spheres the boxes take 1.2 ms instead of 15 ms at 10 threads (4.2 ms instead of 16 ms at 1 thread), andrun_rois()without texture features 18 ms instead of 31 ms, with the same boxes.- run_rois() finds the boxes of a label map with up to 131,072 labels in its parallel pass (one table for each thread above 4,096 labels), not with scipy's find_objects, with the same boxes. A CT-size map with 10,000 small labels takes 18 ms instead of 99 ms at 10 threads, and 75 ms instead of 130 ms at 1 thread.
0.7.0 - 2026-10-05
Added
pictologics.set_num_threads()andpictologics.get_num_threads()set and give the number of threads of all parallel parts. The environment variablePICTOLOGICS_NUM_THREADSsets the number before the import, also above the default.
Changed
load_and_merge_images()keeps the common type of the loaded arrays (float64 for NIfTI, NRRD and MetaImage files), gives uint8 withbinarizeand the smallest unsigned type of the labels withrelabel_masks, instead of float64 every time. The values do not change. It also frees each loaded file before it loads the next one. A binarized merge of three 512×512×200 masks takes 202 ms instead of 240 ms, its array is 52 MB instead of 419 MB, and its peak memory is 996 MB instead of 1,311 MB.save_slices()writes each slice at its own size, one pixel per voxel, anddpiis only the resolution tag in the file, which viewers and printers use to scale the image. Before, each slice was enlarged by dpi / 72 with LANCZOS (4.17 times per side at the default 300 dpi). The pixels are those of the display. It also writes the slices in numba's thread count of threads, not in 8 at most. At 300 dpi, 40 slices of a 512×512×200 CT with a mask take 0.07 s instead of 0.60 s, and the files are 9.5 times smaller (8.7 MB instead of 83 MB).
Fixed
- A DICOM file whose ImagePositionPatient holds text no longer stops
DicomDatabase.from_folders()with worker processes (2,000 files or more); it stays in the database without a position. An error in one file skips that file with a log line in worker processes too. - A DICOM series in the Explicit VR Big Endian transfer syntax with 2²⁰ voxels or more loads. Before, its one-pass conversion failed on the byte order.
- A NaN in a float mask shows as background in
save_slices()andvisualize_slices(), without a RuntimeWarning. Before, it got an arbitrary label colour. - A folder with one multi-frame DICOM file loads its frames as
load_image()of the file does, and a series of several multi-frame files raises a clear error. Before, the frames stacked into a garbled volume without an error. - A gzip or bzip2 NRRD file with
byte skip: -1raises a clear error: the NRRD format allows this value with raw encoding only. Before, the loader read the last byte and reported too few bytes. - DICOM SEG frames of one slice whose positions differ by a micrometre stay one slice. Before, this jitter gave a slice step of 0.001 mm and a very large volume when the SEG had no SpacingBetweenSlices.
- Moran's I and Geary's C of a small ROI (the loop over the voxel pairs) changed in their last digits with the number of threads. The kernel now adds its sums in one fixed order, so the values do not depend on the number of threads; they can change once in the last digits.
- With 130,000 values or more, the percentiles and the median could be wrong for a rare spread of the values: the largest value fell one bucket past the count table, and the count wrote past the end of the table. The table now holds every bucket.
DeduplicationPlan.is_stale()tells a mask_values range from a list of the same labels, and reads a numpy array as the list of its values. Before, the configuration hash wrote a tuple and a list as the same text, and an array as its rounded text.DicomDatabase.from_folders()reads each file once when the folders overlap (a folder and its subfolder). Before, it read the files of the subfolder twice.DicomDatabase.get_studies_df()lists the modalities of a study in sorted order. Before, their order changed from one Python process to the next.DicomSeries.get_sorted_instances()keeps the instances with a projection score in score order when some instances have none; those come last, by instance number. Before, one instance without a score sorted the series by instance number, andcheck_completeness()reported false gaps.save_results()raises ValueError for a list that mixes dicts or DataFrames with other items, as its documentation says. Before, it raised TypeError or AttributeError.save_slices()andvisualize_slices()raise ValueError for a window_width of 0 or less, before they write a file or open a window. Before, a width of 0 divided by zero, and a negative width made every pixel white.
Optimized
- A run now scans each mask once for its nonzero box, and
run_rois()gives each run the box of its label. On a 512×512×200 CT, a run takes 0.8 ms less andrun_rois()with 20 ROIs 21 ms less, with the same features, bit for bit. - Pictologics now uses only the fast cores by default, in every thread of the process: the performance cores of Apple silicon (10 threads of 14 on an M4 Pro, 4 % less time), at most the CPU limit of a Linux container, and the fast cores of an ARM chip with more kinds of cores on Linux. Windows and other computers use all logical CPUs, as before. numba cannot add threads after its start, so a number above the default needs
PICTOLOGICS_NUM_THREADSbefore the import;numba.set_num_threads()above the default now raises. - The GLCM features come from two serial passes over the cells that hold counts, with closed forms for the information correlations: 0.98 ms instead of 3.33 ms at 256 grey levels, 0.25 ms instead of 0.30 ms at 8 levels. The values can differ in the last digits (at most 4e-13 relative on the benchmark cube).
- The GLDZM distance map of an ROI box of 2¹⁸ voxels or more now runs in threads, one axis at a time: 0.56 ms instead of 1.17 ms on 75³ voxels, and 1.6 ms instead of 10.3 ms on 150³ voxels, with the same map, bit for bit.
- The GLSZM and GLDZM zones now join across the thread chunks in parallel. The two families take 13 to 19 % less time on ROIs of 50³ to 150³ voxels, with the same matrices, bit for bit.
- The LoG filter keeps its second and third terms in one temporary array: 126 ms instead of 137 ms on a 256³ image, with the same response, bit for bit.
- The NGTDM contrast, busyness and strength come from closed forms over the grey levels, and the complexity from one serial kernel, instead of six level-by-level grids: 0.046 ms instead of 0.398 ms at 256 grey levels. The values can differ in the last digit (at most 8e-16 relative on the benchmark cube).
- The histogram features of integer values (the discretised image) come from their bin counts, in one pass over the values: 0.44 ms instead of 1.23 ms for 268,000 values. The variance, skewness, kurtosis, coefficient of variation and the three absolute deviations add the same terms in another order; they are now closer to the exact values (the kurtosis of 2 million values: 5e-15 instead of 5e-12 relative error).
- The import no longer runs two warm-up calls that warmed nothing (a scipy call, and a second form of the GLDZM distance map call), so a first import with an empty numba cache compiles one kernel less.
- The import now warms the mask scans for masks of each type, in row and column order: the scans read an integer or bool mask as the integer type of its size, and a column-order mask as its transpose. It also warms the texture kernels for the int64 and float64 grey levels of direct calls; direct calls with other input types convert them first. So no numba code compiles after the import. In direct calls of the histogram features with uncommon input types, the moments can change in the last digit.
- The import now warms the parallel sum of the texture tables, so the first texture pass with 182 grey levels or more does not compile it (about 0.2 s with an empty numba cache).
- The marching cubes of a large ROI runs its x planes in threads: 0.46 ms instead of 0.74 ms for the mesh of a lesion box of 82×81×82 voxels, and 1.9 ms instead of 6.0 ms for a 150³ ROI, with the same mesh.
- The percentile search of 130,000 values or more does less work around its buckets: the first-order features of 268,000 values take 1.24 ms instead of 1.76 ms, with the same values, bit for bit.
- The pipeline now checks an image for NaN values once while its array lives: the next run on a 512×512×200 CT saves 2.5 ms. The check assumes that the caller does not change the array in place between runs.
- The rotation-invariant wavelet computes each of the 24 rotations on the image itself, with reversed kernels on the flipped axes, and runs fewer rotations at once on large images, each pass in two threads: 654 ms instead of 975 ms and 470 MB instead of 805 MB for a 256³ image, and 6 to 15 % less time on 32³ to 192³. A response can differ in the last float32 digit (a reversed kernel adds its taps in the other order): the same bits on the synthetic test images; on the IBSI 2 phase 3 images, 403 of 24,786 values moved by at most 1e-9 relative.
- With other feature families in a pass, the morphology convex hull and MVEE now run in a worker thread next to them. A standard configuration on a 512×512×200 CT takes 6.5 ms less, also at 1 thread; the features stay the same, bit for bit. While the worker runs, the other families use one numba thread less when the threads fill the fast cores (1 to 2 % less time).
DicomDatabase.export_csv()looks for lists only in the columns of Python objects: 83 ms instead of 89 ms for a database of 6,000 files, with the same CSV bytes.load_image()converts a DICOM series 32 slices at a time, so the stored values of one slab, not of the whole series, stay in memory next to the output: a 200-slice CT needs 441 MB instead of 528 MB, with the same array.load_image()inflates a gzip NIfTI file in one pass into its stored values: 158 ms instead of 173 ms for a 512×512×200 CT, with the same array, bit for bit.load_image()reads the plain pixel data of a DICOM series straight from its bytes, as pydicom reads them, after pydicom has read the first slice: a 200-slice CT loads in 73 ms instead of 85 ms, with the same array.load_rtstruct()fills each contour only between its edge crossings, and keeps only the box of each mask for the label map: 30 ROIs of 50 contours on a 512×512×200 grid take 216 ms instead of 579 ms and 126 MB instead of 1.6 GB of memory, with the same masks.run_batch()builds its table without a pandas Series for each configuration of a case: a resumed batch of 150 cases with 6 configurations takes 64 ms instead of 95 ms.format_results()now also takes dictionaries of features.run_rois()finds the label boxes inside the nonzero box of the label map only, and skips the negative-label check of an unsigned map. On a 512×512×200 CT with 20 small ROIs, the label boxes take 15 ms instead of 83 ms, with the same results, bit for bit.save_image()gives nibabel a column-order copy of a large array, so nibabel writes whole slices: 76 ms instead of 207 ms for a 512×512×200 float64 CT as .nii, and 52 ms instead of 1.5 s for a float32 response map, with the same file bytes. The copy needs memory of the size of the array while the file is written.save_results()writes a JSON table with a NaN or infinite value in one encode: 80 ms instead of 132 ms for 100 rows of 1,020 features with a NaN in the last row, with the same bytes.
Dependencies
- Matplotlib is now the optional extra
viz:pip install "pictologics[viz]"installs it forvisualize_slices. A plainpip install pictologicsno longer installs Matplotlib and its own packages (about 55 MB). Without Matplotlib,visualize_slicesraises an ImportError that names the extra;save_slicesneeds only Pillow. - Pictologics installs again on Intel Macs and with Intel (x86_64) Python on Apple silicon, for example in 3D Slicer under Rosetta. There it uses numba 0.62.x, the last numba with macOS x86_64 builds (Python 3.12 and 3.13, with NumPy below 2.4). Elsewhere it needs numba 0.62.1 or newer, and 0.63.0 or newer on Python 3.14; before, it needed numba 0.67.x. A new CI job tests an Intel Mac with numba 0.62.1 and NumPy 2.3.5.
- The lowest allowed versions of nibabel (5.2.0), pandas (2.2.2) and PyWavelets (1.6.0) are now the first ones that work with NumPy 2. Before, pip could keep an older nibabel, which fails at the import. The upper limits of SciPy, Pillow, Matplotlib, PyWavelets and tqdm are gone, so Pillow 12 and numba 0.68 install. The tests pass with the lowest versions (NumPy 2.0.2, numba 0.62.1, SciPy 1.16.3, nibabel 5.2.0, pydicom 3.0.1, pandas 2.2.2, Pillow 11.1.0, PyWavelets 1.6.0, Matplotlib 3.10.0, tqdm 4.66.0) and with the newest ones (numba 0.68.0, NumPy 2.5.3, SciPy 1.18.1, pandas 3.0.6, Pillow 12.3.0). A weekly CI job tests the newest numba, NumPy, SciPy, pandas and Pillow.
- With SciPy 1.18 or newer, the Riesz and Simoncelli filters, and Moran's I and Geary's C of large ROIs, can change in their last digits with the number of threads, because the FFT of SciPy 1.18 splits its work by thread (in a test, Moran's I changed by 2.6e-16, relative). SciPy 1.17 gives the same values for every number of threads.
- pandas 3 works:
DicomDatabase.export_csv()no longer gives aPandas4Warning. With pandas 3, a missing text value in a table, for examplepreprocessing_sequenceindescribe_features()orderivationin the SR measurement table, is NaN instead of None, as pandas 3 keeps text in its newstrtype;pd.isna()finds both. - twine is no longer a development dependency: no script or workflow used it (the PyPI upload uses the PyPA publish action).
0.6.0 - 2026-10-04
Added
- A
normalisestep and the functionnormalise_imagemap the intensities of MR images and other images without fixed units: a z-score or a percentile range, with the statistics of the ROI or of the whole image (an optional value range leaves out the background). The step cancels the FBS start, as a filter does, and its log entry records the center and the scale. The function checks that the mask has the grid of the image. - A
run_batch()case can give a label map (rois, and optionallylabels) in place ofmask: it runsrun_rois(), so each ROI gets a row of the result table, with its name inroi, andgrow_masksteps withnearest_roishare the rings between the ROIs. The resume compares the label map and the labels too. - A mask that
load_imageloads withreference_imagecan now hold the grid of the image in another voxel order: axes flipped or swapped, as some converters write NIfTI files. The loader turns the mask to the voxel order of the image. So a DICOM image with a NIfTI mask on the same grid now loads, where it gave "Origin mismatch" before.load_and_merge_imagesandload_segturn their masks in the same way. - Before a resample, the pipeline now checks that the new grid fits in the memory of the computer. A configuration needs at least 24 bytes for each voxel of the new grid. When the grid does not fit, the configuration stops at once, with a
MemoryErrorin its log entry. Before, a PET image at 0.5 mm without a mask filled the memory, and the computer swapped for a long time. Its grid has 4.2 billion voxels and needs at least 94 GB. - DICOM images, SEG masks and RTSTRUCT masks keep their DICOM FrameOfReferenceUID in the new
Image.frame_of_reference_uid. A mask with another UID gives a warning when it loads onto the image, because it can belong to another scan. Images from other formats have no UID and are not checked. - Each log entry now records how its values were made.
config_hashis the SHA-256 of the configuration, the same in every run and session.environmentholds the Python version, the platform, the versions of numpy, scipy, numba, PyWavelets, nibabel, pydicom and python-gdcm, and the numba thread count. Before, the log held only the Pictologics version. - Every filter takes
padding_value, the constant of constant value padding (IBSI 2 Z3VE), with the boundary"constant"(also named"zero"). For example, -1000 pads a CT image with air instead of water. A value other than 0 with another boundary is an error, also as a config problem. The default 0 gives the old values. - The Gabor filter gives the part of its complex response that
responsenames (IBSI 2 5P3T):"modulus"(the default, as before),"angle","real"or"imaginary". Rotation-invariant pooling pools that part over all orientations. The modulus and the real part keep the shortcut over orientations that repeat at θ + π. - The Gaussian filter (IBSI 2 8BC3) smooths the image at the scale
sigma_mm, with the filter size cutofftruncate. Use it asgaussian_filter, or as the filter type"gaussian"in a pipeline. With a source mask, it uses normalized convolution, as the other filters. The capability metadata lists it (schema version 1.1.0). - The texture features take the IBSI 1 distances:
glcm_distance(the GLCM pair distance along each of the 13 directions), andngtdm_distanceandngldm_distance(the Chebyshev distance of the neighbourhood). All are 1 by default. Give them intexture_matrix_params, or to the texture functions. An unknown option or a bad value intexture_matrix_paramsis now a config problem. The matrices equal plain loops over the voxels, and distance 1 gives the old values, bit for bit. - Two templates hold configurations for cardiac CT:
lv(left ventricular myocardium: whole, fat, myocardial tissue and calcium) andcoronary(all, non-calcified, low-attenuation and calcified coronary plaque). Each has 30 configurations with a resegment range for each compartment, FBN and FBS discretisation, and resampling to 0.5 mm. The newRadiomicsPipeline.from_template()loads a template by name. RadiomicsPipeline.get_log()returns a copy of the processing log, with one entry for each configuration run. Each entry now also holds the run time of its configuration (elapsed_seconds). Before, the log was only in the private_loglist, which the documentation and SlicerPictologics read.RadiomicsPipeline.run_batch()runs the configurations on many cases, also in several processes, each with its share of the threads. Each case gets its own result file. So a later call goes on where a stopped batch stopped, and an error of one case does not stop the batch. It returns a table with the status, error, warnings, run time and features of each case. On 28 CT cases, 4 processes did 6.6 cases per second, and one process 3.1.RadiomicsPipeline.run_rois()runs the configurations on each ROI of a label map. It gives the results of onerun()for each label, bit for bit. But it loads the image once, and it makes each mask only inside the box of its label. On a CT with 100 labels and a texture configuration, it took 0.92 s instead of 1.81 s. With intensity features alone, it took 0.29 s instead of 1.21 s.calculate_all_texture_featurestakesfamilies, for example["glcm", "ngtdm"]: it computes the features and the matrices of these families only.grow_maskgrows or shrinks a mask by a distance in mm, or keeps a ring at its edge (for example the fat around a vessel, or the tissue around a lesion), as a function and as a pipeline step. The distances use the spacing of each axis, so the mask changes by the same amount in every direction. Inrun_rois, the step optionnearest_roigives each added voxel to its nearest ROI, so the rings of touching ROIs do not overlap.load_image(..., suv="bw")converts a DICOM PET image to its standardized uptake value by the QIBA vendor-neutral pseudo-code: body weight ("bw"), lean body mass by the Janmahasatian formula ("lbm") or by the James formula ("lbm_james"), or body surface area by the Du Bois formula ("bsa"). On the QIBA FDG-PET/CT digital reference object, it gives the SUV values of the object. A missing or unsupported DICOM attribute raises an error that names it.load_imagenow also takes the DICOM files of one series: a list of file paths, or aDicomPhaseInfofromget_dicom_phases. It then reads only these files. Loading all 20 phases of a 1,000-file cardiac series takes 0.56 s this way, instead of 4.4 s withdataset_index, which reads all files for each phase.load_imagenow reads NRRD files (.nrrd,.nhdrwith a detached data file, and 3D Slicer.seg.nrrd) and MetaImage files (.mha,.mhd), with its own readers. The geometry is in the LPS+ frame, from the RAS, LAS or LPS space of a NRRD file. A 4D file or the layers of a.seg.nrrdfile give the volume ofdataset_index.get_segment_infolists the segments of a.seg.nrrdfile with their label values and layers. An axis of channels (for example colours or vectors) raises a clear error. On 60 files that SimpleITK wrote and on.seg.nrrdfiles of 3D Slicer, the arrays and the geometry are the same as SimpleITK and Slicer give.load_rtstructreads the ROIs of a DICOM RTSTRUCT file onto the grid of a reference image: as one label image (the ROI Number is the label) or as binary masks by ROI name.load_imageand a pipeline run read an RTSTRUCT mask with the image as the reference. A voxel is in an ROI when its center lies inside an odd number of the contours of its slice, so an inner contour cuts a hole.get_segment_infolists the ROIs of an RTSTRUCT file.run()now warns when the mask holds values that are not whole numbers, for example a probability map. Every voxel that is not 0 counts as ROI, so such a mask gives a wrong ROI. The check reads a regular sample of about 65,000 voxels of the ROI box. Its box scan is also the first ROI check of the run. A configuration with abinarize_maskstep chooses its ROI itself, so it gives no warning.run(),run_rois()and the cases ofrun_batch()takeimage_options, theload_imageoptions of an image path: for example a phase of a multi-phase DICOM folder ({"dataset_index": 4}) or a PET series as SUV ({"suv": "bw"}). Before, a run on a folder path always loaded the first phase. The log and the batch records hold the options, and a batch case runs again when they change.save_imagewrites an image, a mask or a response map as a NIfTI file. The geometry goes back to the RAS+ affine of NIfTI, soload_imageand SimpleITK read the same array and LPS+ geometry. A bool mask is saved as uint8, and a 64-bit integer array as int32 when its values fit. A missing folder is made. So an RTSTRUCT, SEG or.seg.nrrdmask can become a NIfTI file.
Changed
- Breaking change: NIfTI images now give their origin and direction in the LPS+ frame, as DICOM images and SimpleITK do. The loader changes the sign of the X and Y rows of the RAS+ affine. Code that reads
Image.originorImage.directionof a NIfTI image as RAS+ must change. An in-memoryImageis in LPS+ too. The warning about mixed NIfTI and DICOM frames is gone, because the frames no longer mix. - Breaking change: every FBS discretisation of the pipeline now starts at the same value in every image, as IBSI strongly recommends. An FBS step without
min_valstarts at the lower bound of an earlierresegmentstep of the intensity mask. Without both,add_configraises an error, and a configuration that skipped the check stops at its discretise step. Before, such bins started at the minimum of each ROI, so one grey level meant another HU range in each image. The standard FBS configurations now start at -1000 HU, the HU of air. This changes the texture, histogram and IVH values of FBS configurations withoutmin_val. An FBSivh_discretisationfollows the same rule. The log records the start of each FBS step (min_val_effective). - Breaking change: the long output of
format_resultsnames its feature columnfeature_key, notfeature_name. The column holds the full feature key with its IBSI code, for examplemean_intensity_Q4LE.describe_features()uses the same name for the same key; itsfeature_namecolumn holds the name without the IBSI code. This applies to the dict, pandas and JSON outputs, and to an empty result. To keep the old name in a script, usedf.rename(columns={"feature_key": "feature_name"}). - Deduplication rules 1.1.0 are the new default. A family signature holds only the
extract_featuresoptions that the family reads. For example, configurations that differ only inivh_paramsnow share the texture, intensity, morphology and histogram features. Rules 1.0.0 stay available and unchanged. - Laws filters without the energy step and wavelet filters without rotation invariance now return float32, as every other filter. Before, they returned float64 for a float64 image, so a full CT map needed twice the memory (629 instead of 315 MB). Their passes still run in float64, so the values move only by the float32 rounding (about 1e-7, relative).
- The FFT filters (Simoncelli and Riesz) and the FFT sums of Moran's I and Geary's C now use numba's thread count. So do the thread pools of the rotation-invariant wavelet, Laws and Gabor filters and of
save_slices. So one setting,NUMBA_NUM_THREADSornumba.set_num_threads(), limits all threads of a process, for example when several processes run at once. By default all cores run, as before; the Laws and Gabor filters used the cores plus 4 threads. Values and speed are unchanged (199 bench rows, two runs). - The centre-of-mass shift (KLMA) is now computed in index units before the spacing, without the image origin, which cancels. The value moves by about 1e-13 (relative), and it no longer depends on where the arrays start in the image.
- With
source_mode="auto"and nosentinel_value, a run now gives one warning about the sentinel search for all its configurations, not one warning for each. The warning names the configurations. The sentinel and NaN warnings also no longer go a second time to the rootlogginglogger. So a run of a cardiac template prints one warning, not 60 lines. - Without
window_centerandwindow_width,save_slicesandvisualize_slicesnow show all slices on one gray scale. The scale is the minimum and maximum of the whole volume, without NaN values. Before, each slice had its own minimum and maximum, so a tissue changed its gray level from slice to slice. A mask shown in gray uses the range of the whole mask. NaN pixels show as black; before, they went through an undefined cast. DicomDatabasenow stores binary private values (for example a 60 KB CSA header) and private sequences as their size, such as<OB, 60000 bytes>, not as text. A scan of 10,000 files, half with such a header, needs 0.27 GB instead of 1.05 GB. Text private values stay as they are.save_image, theexport_csvandexport_jsonmethods ofDicomDatabaseandSRDocument, andSRBatch.export_combined_csvandexport_lognow make a missing folder, assave_results,save_configsandsave_logdo. Before, they raised an error.- The documentation is new: the pipeline guide is three pages (the pipeline, its steps, and the results and logs), the cookbook has seven tested recipes, and new tutorials cover masks from other tools, a study from a DICOM archive, rings, batch studies, cardiac phases, PET in SUV, MR normalisation and speed. The benchmark page now gives the speed of Pictologics alone, in tables and plots.
run()withoutconfig_namesstill runs every configuration, but it now warns when the standard configurations run too. Passconfig_names, or create the pipeline withRadiomicsPipeline(load_standard=False), to run only your own.
Fixed
- A DICOM SEG that stores its plane orientation or pixel spacing in each frame, as the standard allows, now loads with the right geometry. Before, a sagittal SEG of this kind collapsed to one slice.
- A DICOM SEG with more than 255 segments now loads. The combined label image is uint16 when a segment number is above 255. Before, the load failed, or the labels wrapped (300 became 44).
- A DICOM folder with more than one image series, for example two reconstructions of one scan, no longer loads as one mixed image.
load_imageandget_dicom_phasesnow raise an error that lists the series, and their newseries_uidargument chooses one. Files without image pixel data (RTSTRUCT, RTPLAN, SR), SEG files and RT dose grids are not slices, so the loader skips them. It also leaves out images of another orientation or size than the rest of the series (a scout), with a warning. - A DICOM pixel decode error now names the file and the cause.
- A NIfTI mask of the shape of its image, but with another origin, now goes to its place in the image by its origin, as a cropped mask does. Before,
load_image(..., reference_image=...)raised an origin mismatch error. - A FRACTIONAL DICOM SEG now counts a voxel as inside a segment when its value is at least half of the maximum fractional value. The new
fractional_thresholdargument ofload_segsets this fraction. Before, every value above 0 counted as inside. - A Gabor
delta_thetawith few digits, for example 0.785398 for π/4, now gives the whole number of orientations (8, of which 4 are computed). Before, it gave 9 orientations, and the pooled response moved by up to 12 %. - A
binarize_maskstep that keeps mask value 0 after a filter now gets filter values for every voxel. Before, the filter computed only the ROI region, so the voxels outside it held 0 (a mean of 7.4 instead of 112.8). - A bin count of 32.0 from a YAML or JSON file no longer makes all texture features NaN, and an NGLDM alpha of 1.0 no longer compiles a second kernel. Both now work as the whole numbers 32 and 1.
- A configuration with NumPy numbers inside a tuple, for example
new_spacing=(np.float64(0.5), 0.5, 0.5), now saves them as plain numbers. Before, the YAML file held Python object tags thatload_configscannot read, and the JSON file held NumPy integers as text. - A constant ROI now gives skewness and kurtosis 0, as IBSI defines, for the intensity and the intensity histogram features. Before, they were NaN. Equal values whose mean is not exact (for example 0.1) also give 0.
- A filter with a periodic boundary read the wrong image end when the pipeline filtered only the ROI region near an image edge. The LoG, wavelet and Laws features were then wrong, up to 7.5 times off. Such an axis is now filtered whole.
- A tag such as AcquisitionNumber no longer splits one DICOM scan into parts. A step-and-shoot CT can write a new number for each table step, so
load_imageloaded only 4 of 40 slices. A tag now splits a series only when slice positions repeat, and each group holds each position once. A trigger time that drifts from slice to slice (cine MR) now splits by the positions. This also fixes the phases ofget_dicom_phasesand the series ofDicomDatabase. - An unknown boundary name in a direct call of
mean_filter,laplacian_of_gaussian,laws_filterorgabor_filternow raises aValueErrorthat lists the valid names, as the other filters do. Before, it raised a bareKeyError.add_confignow checks the boundary of a filter step, and aBoundaryConditionmember with apadding_valuepasses the check. Before, an unknown boundary failed only at run time, and an enum boundary with a padding value failed the check. - An ROI voxel with a grey level outside [1, n_bins] (for example bin 0, the bin of a NaN voxel) now takes no part in the texture matrices. Before, NGTDM and NGLDM counted it as a neighbour with grey level 256.
- An enhanced multiframe DICOM file with several volumes no longer loads as one mixed image. Its frames sit at repeated positions, and
dataset_indexnow picks one volume by the temporal position index or cardiac phase of the frames. A file with one volume and adataset_indexabove 0 now raises an error. - An error in one feature family no longer makes every feature of its configuration NaN. Only the features of that family are NaN, a warning names the family, and the log entry lists it in
family_errors.run_batchmarks such a case "incomplete". - Colour (RGB) DICOM files now raise a clear error. Before, they loaded with wrong axes.
- DICOM SEG files now load each frame into the slice at its position. Before, the loader took the first dimension index as the slice number, but highdicom puts the segment number there. So each segment collapsed into one slice: in a test, a segment on 4 slices came back on 1, with a quarter of its voxels. The SEG origin and slice spacing now also come from the frame positions, also when the writer leaves out empty frames.
- DICOM images without rescale values now load as float64, like the same image from NIfTI. Many MR series have no rescale values, and some CT series have a slope of 1 and an intercept of 0. Before, these images kept their integer type, so resampling rounded the values, cut off negative values, and ran up to 50 times slower.
apply_rescale=Falsestill gives the stored values in their stored type. - Deduplication no longer copies wrong values between configurations. Before, its reuse check ignored several settings, for example
source_mode,sentinel_value, some preprocessing steps andextract_featuresoptions such asivh_params. Now a feature family is reused only when the source mode, every preprocessing step before extraction and the extraction options are the same. Only a finaldiscretisestep can differ, for families that do not use the binned image. Results withdeduplicate=Truenow equal results withdeduplicate=False, for every rules version, also a pinned"1.0.0". - Every step now counts a voxel as ROI when its mask value is not 0, as the documentation states. Before, the bin limits, the sentinel search, Moran's I, the local intensity peak and the GLDZM distance map counted only values above 0. So negative labels gave another ROI in some families.
- First-order statistics now sum in float64, also for float32 values such as filter responses. Before, float32 values summed in float32, so the results carried float32 rounding errors and depended on numpy's summation order. numpy 2.3 changed that order; now numpy 2.2 and 2.5 give the same values to within 4e-16 (relative). IBSI 2 phase 2 values move by up to 3e-7, phase 3 values by up to 6e-4 (coefficients of variation with a mean near 0). All IBSI checks pass as before, and the phase 3 team agreement is unchanged.
- Gabor filters with γ below 1 use a kernel radius of ceil(6σ/γ), because the envelope is σ/γ long along one axis. Before, the radius ceil(6σ) cut the kernel short. IBSI 2 tests 4.a.1 and 4.a.2 now match their reference maps to 0.000 % of the range (before, 0.27 % and 0.14 %).
- In
roi_onlyandautomode, mask labels of 256 and more now keep their value. Before, the source-mask step cast the mask to uint8, so label 300 became 44, and a laterbinarize_maskfor it found nothing. - JSON output now writes NaN and infinite values as null. Before, it wrote the token NaN, which strict JSON readers reject.
save_resultsnow raises an error for an unknown file extension (a .parquet file got CSV text before), writes tab-separated files for .tsv, and makes a missing output folder. - Label-map DICOM SEG files (Label Map Segmentation Storage, SOP class 1.2.840.10008.5.1.4.1.1.66.7) now load as segmentations. Before,
load_imagesent them to the plain DICOM loader, andload_segread them as binary masks, so every label became 1. Now their pixel values are the segment numbers: the combined mask keeps them, and each separate mask holds the voxels of one number. The background, segment 0, is left out unless you ask for it. - Laws filters without rotation invariance in the
roi_onlyandautosource modes now give the right response. The normalized convolution divides by the fraction of the kernel weight on valid voxels. Before, it divided by the weight itself, so the responses were 0.15 to 0.23 times the right value. - Multi-echo series now split by their echo number. The DICOM loader,
get_dicom_phasesandDicomDatabaseasked for the tagEchoNumber, which does not exist; the keyword isEchoNumbers(0018,0086). So echoes split only when their slices shared positions. The phase tag, the phase label ("Echo 2"),DicomPhaseInfo.split_tagand the database metadata now useEchoNumbers. A multi-valued echo number, for example1\2, is one group value. - NGLDM with an
ngldm_alphaof 1 or more now also counts the neighbours with a lower grey level. Before, an unsigned subtraction counted only the neighbours up to alpha levels above the centre voxel. The defaultngldm_alphaof 0 (IBSI) is not affected. - No feature changes with the number of threads now. Surface area, volume and the intensity-weighted shape features add in fixed blocks, and the NGTDM sums are exact integers per grey level and neighbour count. Before, 19 of 174 features changed in the last digits (up to 5e-11, relative) between machines with other core counts. NGTDM values move by at most 1.3e-14.
- One NaN or infinite voxel in the ROI no longer makes every feature of a configuration NaN. Before feature extraction, each configuration leaves such voxels out of the intensity mask and gives a warning with their number. The morphological mask stays as it is. IBSI marks voxels outside the ROI with NaN, so these voxels have no intensity. The default bin limits also skip infinite values, and FBS puts a +inf voxel into bin 0 (before, an integer cast of infinity).
- Rotation-invariant wavelet filters now give the same result on every run. In 0.5.1, their parallel path (images above 2 million voxels) added the 24 rotations in the order in which the threads finished. So average pooling could change in the last bits from run to run. The rotations are now pooled in a fixed order, as in the sequential path.
- Saving and loading a configuration no longer changes its results. A
binarize_maskrangemask_values: (lo, hi)was saved as a list, which selects only the listed labels, so(1, 3)lost label 2. A range is now saved as{range: [lo, hi]}, which YAML and JSON files can also use, and it loads back as the tuple. ABoundaryConditionwas saved as its scipy mode name, such as"reflect", which loading rejects. It is now saved by its own name, such as"mirror". Deduplication also tells a range from a label list with the same numbers, and these values no longer breakdescribe_features(). - Texture features of a one-slice image use the 4 in-plane directions and the in-plane GLDZM distance map, as for a 2D image. Before, 9 of the 13 directions left the slice: every voxel was a run of length 1 in them, and every zone had the distance 1. A one-slice ROI in a 3D image keeps the 3D rule.
- The DICOM loader now takes the slice spacing from the slice positions when SpacingBetweenSlices or SliceThickness differs from them by more than 1%. It then warns. SliceThickness is the width of a slice, not the step between slices, so overlapping or gapped series got a wrong z spacing. Series whose tags agree with their positions keep the tag value, so their results are unchanged.
- The IBSI 1 page shows the configurations that the check runs and a
run()call that works. The IBSI 2 page no longer shows a local folder path. - The IBSI 2 phase 3 page now uses one range per team, modality, filter and feature. The old page used one range per team and feature over all modalities and filters, which made the agreement too high. Within 1% of the range, CERR now agrees in 89.4% of the values (was 99.3%), and NCT Dresden in 98.2% (was 99.5%). The page also explains why the Simoncelli filters use the periodic boundary, not the mirror boundary of the IBSI 2 manual. With it, 58.1% of the Simoncelli values are within 1% of the range, against 56.3% with the mirror boundary. The page lists the 10 largest mismatches per team instead of all of them (14 KB instead of 524 KB).
- The
mask_thresholdofresample_image, and of the pipeline's linear or cubic mask resampling, now works for every mask type. Before, a uint8 mask was rounded first, so every threshold acted as 0.5. A bool mask kept only the voxels whose eight neighbours were all in the ROI (in a test, 242 voxels instead of 6,945). A uint8 or bool mask now rounds at the threshold, and a float64 mask compares its value with it, as before. The IBSI configurations binarize the mask first, so their results stay the same, bit for bit. Linear mask resampling of larger images also takes 17-62% less time and a third of the memory. - The cache of the Simoncelli and Riesz transfer tables now has a lock. So filters that run in several threads at once no longer clash in it.
- The configuration check no longer warns about the
spatial_intensity_paramsandlocal_intensity_paramsof theextract_featuresstep.run()always accepted them. - The structured report (SR) parser now reads each measurement group from its own container, as TID 1500 stores it. Each lesion keeps its tracking identifier (now also its
group_id), finding, finding site and tracking UID. Each measurement keeps its derivation, for example Mean. Before, every group came out as "default" with none of these values, so the lesions could not be told apart. - The warning about mixed NIfTI and DICOM geometry no longer appears for in-memory images and merged masks, because their source format is unknown.
- The wavelet, Simoncelli and Riesz filters now check their inputs and raise a clear error, also at
add_config. Before, wavelet level 0 returned no response, a decomposition such as "LH" filtered only 2 of the 3 axes, and a Riesz order with 2 values ran on a 3D image. A lowercase decomposition such as "lhl" now works, and whole numbers such as 2.0 work as levels and orders. - When the default start method is forkserver (the Linux default from Python 3.14), the header pool of
DicomDatabaseandSRDocument.from_foldersuses spawn. A forkserver keeps the environment of its first start, so this pool and your own process pools mixed up the warm-up setting. DicomDatabase.from_foldersno longer lists a DICOMDIR file as an image of patient "UNKNOWN". A DICOMDIR only indexes the other files of a DICOM medium, so the scan now skips it.add_confignow checks a configuration and raises one error that lists every problem, with the closest valid name. It checks the step names, the parameters of each step and each filter type, and the feature family names. It also checks the discretise method and bin settings, the new spacing, and that texture features have an earlier discretise step. Before, these mistakes ran silently: a typo dropped a family or a step, and a wrong step gave only NaN values.validate=Falsekeeps the old check;from_dict(validate=True)warns with the same checks, and an unknown source mode in a file now fails at load time.add_confignow keeps its own copy of the steps, so a later edit of the caller's list no longer changes a stored configuration. It also accepts theSourceModeenum.run()takes one configuration name as a string, runs a name given twice only once, and suggests the closest name for an unknown one.add_confignow reports a filter parameter that has no default and that the step leaves out: for example agaussianorlogstep withoutsigma_mm, agaborstep withoutlambda_mm, or arieszstep withoutorder. Before, such a configuration failed only when it ran.format_results(fmt="long")now raisesValueErrorfor an unknownoutput_type, as the wide format does. Before, it returned a DataFrame.get_dicom_phases(recursive=True)now reads the same folder asload_image(recursive=True): the folder with the most DICOM files. Before, it mixed the files of all subfolders. A negativedataset_indexfor DICOM data now raises an error, as it does for NIfTI.load_imagenow warns when the slices of a DICOM series are not evenly spaced, for example when a slice is missing. It also warns when the slice positions move sideways from the slice normal (a gantry tilt). The loader does not correct these cases, so the image is wrong after a gap and sheared with a tilt.load_image,load_and_merge_imagesandRadiomicsPipeline.run()now acceptpathlib.Pathobjects. Before,load_imageraised aValueErrorfor aPathto a NIfTI file, andrun()treated aPathas an image and failed. The type hints ofsave_slices,DicomDatabase.export_csv,DicomDatabase.export_jsonandget_dicom_phasesnow allow aPathtoo, which already worked.export_jsonreturns a string also for aPath.merge_configsnow marks the deduplication plan out of date, asadd_configandremove_configdo. Before,to_dict(),to_json(),to_yaml()andsave_configs()exported the plan of the configurations before the merge.resegment_masknow removes the voxels with a NaN intensity, asfilter_outliersdoes. A NaN value is in no range.run()now converts an in-memory image of another type (for example int16 or float32) to float64, as the loaders do. Before, such an image resampled in its own type: rounded, on one core, and with other feature values. A numpy array instead of anImagenow raises a clear TypeError.save_configs,to_dict,to_jsonandto_yamlnow raise aValueErrorfor an unknown name inconfig_names, with the closest name, asrun()does. Before, they left the name out without an error, so a wrong name gave a file without that configuration. They also take"all_standard"and a single name.save_slicesraisesValueErrorfor a single slice index outside the image, andvisualize_slicesfor aninitial_sliceoutside the image. Before, -1 saved the last slice asslice_-001.png, and an index past the end raised anIndexError. A NumPy integer now selects its slice; before, it selected slice 0.- numpy numbers inside list or tuple parameters, for example a
new_spacingof numpy integers, no longer stoprun()when deduplication is on.
Optimized
- A 4D NIfTI file now loads only the volume that
dataset_indexasks for. A 128 x 128 x 48 x 40 file loads one volume in 42 ms instead of 184 ms, with a peak of 12 MB instead of 480 MB. Before, the returned image also kept the whole 4D array (240 MB) instead of its own 6 MB. The values are the same, bit for bit. - A Gabor filter in the pipeline now cuts each slice to the ROI region, grown by the kernel radius, before its FFT. Before, it filtered whole slices through the region. On a CT of 512 x 512 voxels with an ROI, the axial Gabor filter is 3.5 to 4.3 times faster. The values in the region move only by the float32 rounding of the smaller FFT (up to 4.4e-7 of the largest response).
- A pipeline run does its start-up work once, not once per configuration. This covers the first empty-ROI check, the auto-mode sentinel search, and the source mask of each source setup, kept until its last configuration. The package version is read once per process, and the deduplication plan is reused while the rules and configurations stay the same. Six light configurations in auto, ROI-only and full-image mode take 15-48% less time (a 512×512×200 CT: from 284 ms to 181 ms). Warnings, logs and features are unchanged.
- Configurations of one pipeline run now share identical preprocessing. When several configurations start with the same steps, source mode and sentinel value, these steps run once, and the later configurations continue from their result. The six standard configurations, which all start with the same 0.5 mm resampling, take 29-40% less time. Results and run logs are unchanged.
- DICOM series load faster and with half the memory. The loader now parses each file once, not twice, and reads the pixel data of the chosen phase only when needed. A rescaled series of 2^20 voxels or more now goes through one kernel into the row-order float64 image; each slice is freed once copied. A 512×512×200 CT series loads in 94 ms instead of 204 ms, with a peak of 528 MB instead of 1,054 MB. The default pipeline from those files takes 38% less time and 38% less memory. Values are unchanged, bit for bit.
- DICOM series, multiframe DICOM files and DICOM SEG masks of 2^20 voxels or more now load in row order (C order), as NIfTI images do. The series loader stacks each slice in one piece, and the fast parallel copy to row order now also takes int16, uint16 and uint8 data. On a 512×512×200 CT series, loading takes 36% less time, and the default pipeline 36-38% less, with the same peak memory. A SEG mask no longer comes in column order next to a row-order image; in a test, that mix made the pipeline 2.7-5.8 times slower. Values are unchanged, bit for bit.
- Discretisation uses its parallel kernels from 80,000 voxels, not from 1,048,576 (2^20) voxels. The kernels read a column-order (Fortran) array in its own order, without a copy; arrays in other layouts keep the old limit. On images of 262,000 to 885,000 voxels, FBS binning takes 0.16-0.24 ms instead of 0.46-1.6 ms, and FBN binning 34-55% of the time. Both use a third of the memory. On these images the pipeline runs up to 21% faster, with up to 58% lower peak memory. The bins are unchanged, bit for bit.
- Enhanced multiframe DICOM files load in one pass from the stored pixels to the float64 output. A 512 x 512 x 200 CT file loads in 72 ms instead of 127 ms, with half the memory (0.53 GB instead of 1.03 GB). The values are the same, bit for bit.
- FBN and FBS discretisation without given bin limits now find the ROI minimum and maximum in one parallel pass over the image and the mask. Before, they gathered the ROI values first. This applies to a row-order float64 image with a float64 or uint8 mask. FBN discretisation runs up to 3.4 times faster (a 512×512×200 CT: from 31 ms to 11 ms). The bins are unchanged, bit for bit.
- FBS discretisation in the pipeline gets the ROI minimum and maximum from one fused pass, with no copy of the ROI values. The FBS configuration with IVH runs 33 % faster on a full CT. The bins are the same.
- FIXED_CUTOFFS discretisation uses one parallel pass. An image of 26 million voxels takes 15 ms instead of 243 ms. The bins are the same.
- Gabor filters now use an FFT as long as the padded slice, not as long as the whole linear convolution. The kept part of the response is the same, so the values move only by the float32 rounding (up to 7e-7 of the largest response). Gabor filters are 1.2 to 1.6 times faster.
- In the
roi_onlyandautosource modes, the pipeline keeps one boolean source mask and shares it between the steps, with no type copies. A resampled source mask with no invalid voxel no longer changes the masks. The results are the same. - In the pipeline, an axial Gabor filter (not averaged over planes) now filters only the slices of the region that feature extraction reads. This applies when no later filter or resample step needs the rest of the image. Each slice is still filtered over its whole plane, so the features are unchanged, bit for bit. A Gabor configuration takes 21-45% less time on images with slices outside the ROI region. On a 512×512×200 CT, it goes from 196 ms to 128 ms and from 423 MB to 307 MB.
- Intensity features run faster, and IBSI configuration C runs 2.4 times faster. First-order statistics find their percentiles and median with one partial sort instead of two, and histogram features read them from the bin counts. IVH converts its values to float64 once, and the local intensity peak searches only the ROI box plus the reach of its sphere. Moran's I and Geary's C use FFT convolutions on large ROIs (117,000 voxels: 17 ms instead of 828 ms), and change by at most 1e-14. The FFT needs about 32 bytes per grid point; above 16 GB, or half of the memory, the pair loop runs instead. Then a warning gives the expected run time, and this change leaves all other values the same, bit for bit.
- Linear resampling now runs in parallel kernels that give scipy's output bit for bit, also with rounding, mask thresholds, uint8 masks and source masks. The IBSI configuration C pipeline runs about 2.3 times faster. Plain linear resampling now equals scipy exactly too; before, some edge voxels differed in the last bits (at most 5e-13). Discretisation skips its ROI search when the bin limits are given.
filter_outliersand sentinel detection run in parallel on large images (up to 3.8 and 6.4 times faster on a 512×512×200 CT). All other outputs are unchanged, bit for bit. - Morphology features build their surface mesh with a new marching cubes kernel. It gives the PyMCubes 0.1.6 mesh, with the same vertices and faces in the same order, so all features are unchanged, bit for bit. Morphology takes 22-41% less time on ROIs of 36,000 voxels or more (748,000 voxels: 45 to 27 ms), and 6-9% less on smaller ROIs. The default pipeline takes up to 17% less time.
- Morphology features run faster. Qhull now gets only the possible convex hull vertices: the first and the last mesh vertex of each grid line along each axis. It finds the same hull vertices in the same order, so the maximum 3D diameter and the MVEE features stay the same, bit for bit. The convex hull volume and area change by at most 2e-15 (relative). On large ROIs the convex hull step takes 32-65% less time, and morphology features 11% less on average (up to 21%).
- NIfTI images of 2^20 voxels or more load in one pass. The loader scales the stored values as nibabel's
get_fdata()does and writes them straight into the row-order float64 array. So nibabel's column-order array and its row-order copy are no longer made. Loading takes 8-13% less time and 25-37% less peak memory (a 512×512×200 CT in .nii.gz: 279 to 243 ms, 839 to 524 MB). Values are unchanged, bit for bit. - NIfTI images of 2^20 voxels or more now load in row order (C order), through a fast parallel copy, instead of nibabel's column order. This removes a full-image copy from each discretise, resegment and resample call. With the default configuration, a 512×512×200 CT runs about 1.7 times faster from load to features, with about 15% lower peak memory. Large IBSI 2 validation CT scans run 4-8% faster. Feature values are unchanged, bit for bit.
- Resampling with
round_intensitiesrounds the new array in place. The rounding step uses half the memory. The values are the same. - Resegmentation uses its parallel kernel from 80,000 voxels and sentinel detection from 200,000 voxels, not from 1,048,576 (2^20) voxels. Both kernels read an image and a mask in column order (Fortran) without a copy, and resegmentation keeps that order. On images of 262,000 to 885,000 voxels, resegmentation takes 0.17-0.33 ms instead of 0.19-2.95 ms. Sentinel detection takes 0.30-0.49 ms instead of 0.38-1.25 ms, and it no longer makes a full-size array for each candidate value. The results are unchanged, bit for bit.
- Rotation-invariant Laws filters compute their base responses 3 at a time, each with its share of the threads. They are 1.4 to 2 times faster, with 40 % to 45 % less memory for kernels with 6 bases. Rotation-invariant wavelets keep at most about 2 GB of responses in flight. The values are the same.
- Rotation-invariant Laws filters now pool each base response as soon as it is ready, and free it after its last rotation. Before, they kept every base response plus a signed copy for each of the 24 rotations. Max and min pooling flip signs in place and skip repeated rotations, and the separable passes and the energy image write into their own arrays. The values are the same, bit for bit. With the IBSI 2 phase 3 settings, a 512×512×200 CT takes 1.27 s instead of 1.82 s, and 2.9 GB instead of 5.0 GB. Smaller CT images take 20-41% less time and 42% less memory, and a Laws energy filter without rotations needs 57% less memory.
- Rotation-invariant wavelet filters use threads from 15,000 voxels and Laws filters from 20,000 voxels, not from 2 million voxels. Below 2 million voxels, the 24 rotations ran one after the other, although threads give the same result, bit for bit. With the IBSI 2 phase 3 settings, a Coiflet 3 wavelet on 262,000 to 1.1 million voxels runs 7-9 times faster (1.58 to 0.17 s). The Laws energy filter runs 1.7-2.2 times faster. Each thread holds one working array, so these images need more memory (for example from 35 MB to 133 MB).
- Rotation-invariant wavelets and the FFT filter caches keep less in memory. The parallel path of rotation-invariant wavelets kept all 24 rotation responses until the end; it now drops each response once pooled. The Simoncelli and Riesz transfer-function caches kept up to 32 and 64 full volumes without a byte limit; now each keeps at most 2 GB. On images of 2 to 2.5 million voxels, rotation-invariant wavelets take 10-14% less time and 29-31% less memory. The IBSI 2 phase 3 run uses 22% less memory. Results are unchanged, bit for bit.
- Simoncelli and Riesz filters with a non-periodic boundary return a compact array, not a view of the padded response. They keep 23 % to 52 % less memory at the same speed. The values are the same.
- Slice overlays color all labels in one step, not in one pass per label. A 512 x 512 slice with 117 labels takes 8 ms instead of 122 ms, and with 1 label 5 ms instead of 7 ms. The pixels are the same.
- Texture families that a configuration lists one by one (for example
glcmandglrlm) share one matrix pass, astexturedoes. The features are the same. - Texture features now use smaller matrices that give the same features: in the pipeline, in
calculate_all_texture_featuresand in the feature functions that build a matrix. GLCM and GLRLM keep one table (the sum over the 13 directions) instead of 13, and GLSZM keeps only its non-zero cells. On the largest IBSI 2 validation CT scan, texture features run 1.5-2.2 times faster with 37-56% lower peak memory. The default configuration takes 21% less time there, with 28% lower peak memory.calculate_all_texture_matricesreturns the same matrices as before, and feature values are unchanged, bit for bit. - The GLCM and GLRLM thread tables add up in threads from 32,768 cells, and large tables are zeroed in threads. The texture matrices of 256 levels in 13 directions take 4.3 ms instead of 9.3 ms. The values are the same.
- The GLCM, GLRLM, GLDZM and NGLDM features now add only the rows and columns of their matrix that hold counts. A fixed FBS start, such as the -1000 HU of the standard configurations, leaves many low grey levels empty. Such a configuration is now as fast as one that starts at the ROI minimum; before, it was 4 to 8 % slower. The GLCM information correlations now use exact marginal counts. The features move in the last digits: up to 8e-15 (relative), and the information correlations, which subtract almost equal entropies, up to 4e-12.
- The IVH features of whole-number values come from one count per value instead of a sort. From 131,072 values on, the count runs in threads. It is 1.8 to 10 times faster, with almost no extra memory. Raw values search only the values that occur: 1.3 to 1.4 times faster. The values are the same.
- The JIT warm-up no longer compiles nine strided forms of the ROI box scan and the ROI min/max scans. The package passes row-order arrays to them, so it does not use these forms. A cold first warm-up is 1.7 s shorter.
- The NGTDM and NGLDM neighbour loop of the texture kernel now counts without branches. It sums the neighbours' grey levels as integers, which add exactly in any order. Texture features take 5% less time on average (up to 18% on large ROIs). Feature values are unchanged, bit for bit.
- The Simoncelli and Riesz transfer tables build faster and with less memory. The even Simoncelli table evaluates the band once and copies its mirrored part, and the Riesz table is computed in place. On a 512 x 512 x 200 grid, Simoncelli takes 203 instead of 231 ms and 258 instead of 311 MB, and Riesz takes 282 instead of 345 MB. The table values do not change.
- The Simoncelli and Riesz transfer tables keep only half of their rows, because the other rows mirror them. The products use mirrored views and run in threads, so the values are the same. The cached tables need half the memory and build 1.6 to 2 times faster, and warm calls take 3 % to 20 % less time.
- The Simoncelli filter now uses a real FFT, and the Riesz-Simoncelli filter applies both of its tables in one FFT round trip. The Simoncelli table now holds the even part of the band. For a real image, this part gives the real response that the filter keeps. With a cached table, the Simoncelli filter is 2.2 to 3.2 times faster and the Riesz-Simoncelli filter 3.1 to 3.8 times faster. A first call on a new image shape is 1.2 to 2.5 times faster. The Simoncelli values move by the float32 FFT rounding, up to 1e-6 of the largest response. Riesz-Simoncelli no longer rounds the band to float32 between its two steps. Its values move by up to 1.2e-5 of the largest response and come closer to a float64 reference. With a source mask and the periodic boundary, it still uses two round trips: it sets the band outside the mask to zero between them.
- The
resegment,filter_outliersandkeep_largest_componentsteps withapply_to="both"(the default) now process a shared mask once and keep it shared.binarize_maskdid this already. Before, these steps ran twice and split the masks, so later steps also worked twice. Intensity-weighted morphology also reuses the morphology bounding box when the masks are shared. A pipeline with resegmentation and outlier filtering takes 7-31% less time and about 30% less peak memory. Results are unchanged, bit for bit. - The histogram and the IVH of a configuration share one gather of the ROI values. Configurations with the same masks reuse the GLDZM distance map. The results are the same.
- The local intensity peaks take two stages. Row sums give every ROI voxel a close sphere mean, and the exact sum runs only on the voxels within a strict error bound of the best one and on the brightest voxels. For 3.3 million ROI voxels at 0.6 mm, they take 0.18 s instead of 1.16 s. The values are the same.
- The percentiles and the median of large ROIs (130,000 float values and more) come from a radix select. One parallel pass finds the value range, a parallel count puts the values into 65,536 buckets over that range, and a partition of the few values near the ranks gives the result. For 6.2 million values, the first-order features take 15 to 23 ms instead of 86 to 96 ms, also when the values crowd into a narrow range. The values are the same.
- The pipeline checks a large mask for ROI voxels with the parallel box scan, not in one thread. The result is the same.
- The pipeline filters only the ROI region for more filters. The mean filter and the Laws energy filter the part from the image start to the region end. A Gabor filter over three planes filters only the slices through the region. The source-mask modes also filter the region. The region grows by the local intensity sphere only when the local intensity features are computed. On a 300 × 300 × 160 image with an 80³ ROI, mean takes 35 ms instead of 171 ms. Laws energy takes 42 ms instead of 263 ms. Gabor over three planes takes 100 ms instead of 263 ms, with 93 MB instead of 330 MB. LoG in roi_only mode takes 51 ms instead of 811 ms. The values are the same.
- The pipeline runs faster. Its empty-ROI check reads a shared mask once and no longer runs after discretisation, and
binarize_maskbinarizes a shared mask once. The IVH discretisation bins only the ROI values when the configuration gives the bin limits. On a 512×512×200 CT, the default configuration takes 23% less time. A full-resolution FBS configuration with IVH takes 33% less time and 38% less peak memory, and IBSI configuration C 11-15% less time. Results are unchanged, bit for bit. - The pipeline's LoG, wavelet and Laws filters now filter only the region that feature extraction reads, if no later step needs the rest. The region is the ROI box, grown by the reach of the local intensity sphere and of the filter; the rest is 0. These filters give each voxel the same value wherever the image ends, so all features are unchanged, bit for bit. A source mask, the mean filter, the Laws energy and the FFT filters still filter the whole image. On images larger than their ROI, a LoG configuration takes 28-90% less time, with up to half the peak memory. A wavelet configuration takes 13-84% less time; on a 512×512×200 CT, LoG goes from 2.5 s to 0.26 s.
- The scipy passes of the LoG, mean, Laws and wavelet filters run on slabs in threads for images of 262,144 voxels and more. The passes share one thread pool. Each line stays in one slab, so the values are the same. On 7.1 million voxels, LoG takes 33 ms instead of 182 ms, and mean 7 ms instead of 30 ms. Laws energy takes 19 ms instead of 69 ms, and a level-2 wavelet 53 ms instead of 390 ms. The source-mask forms are 2 times faster.
- The shape features count the marching cubes cells in parallel, write the vertices in physical units at once, and get the bounding box and the centre of the vertices from one pass. The values are the same.
- The source-mask forms of the mean, LoG and Laws filters make no extra copies. On 7.1 million voxels, the peak memory drops from 294 MB to 142 MB (mean) and from 296 MB to 163 MB (LoG). The values are the same.
- The texture matrices come from one zero-padded volume of grey levels, with no bounds checks and no hidden parallel regions. For an ROI of 764,000 voxels they take 21 ms instead of 43 ms; at 0.5 mm (6.2 million voxels) 140 ms instead of 303 ms, with 22 % less memory. The kernels compile once for every number of grey levels up to 65,535 (the new limit). The zone buffers are made for each call, so no buffer pool stays in memory after
run(). The matrices are the same. - The warm-up now compiles every kernel version that a first run needs, also for column-order images, float64 and bool masks, filter responses (float32) and every stored NIfTI type. A first run on a small NIfTI image takes 0.01 s instead of about 12 s of compiles. Kernels that make arrays use prange as their only parallel loop, and the cold warm-up takes 27 s instead of about 48 s.
- Wavelet passes now write into one array: a rotation-invariant wavelet above 2 million voxels needs 46-64% less memory, a plain wavelet 67% less. Simoncelli and Riesz multiply the spectrum in place, and the inverse FFT reuses that array. Simoncelli takes up to 24% less time and 58% less memory (a 512×512×200 CT: 449 to 345 ms, 2,517 to 1,049 MB). Riesz takes up to 18% less time and 17% less memory. Large transfer tables are built in slabs: a first Simoncelli or Riesz filter peaks at 536 or 573 MB, not 2,569 or 1,721 MB. Results are unchanged, bit for bit.
- When no later step reads the image away from the ROI, the discretise step cuts the image and the masks to the ROI box. The cut comes before the binning. Configurations that share an image share its cut. On a 512 × 512 × 200 image with four discretisations, the run takes 80 ms instead of 117 ms. The peak memory is 30 MB instead of 402 MB. The values are the same.
- When no later step reads the new grid away from the ROI, the resample step computes only the region around the ROI box, grown by the local intensity sphere when a later step needs it. The rest of the grid is never made, so a full CT at 0.5 mm needs a fraction of the time and memory. The features are the same, bit for bit.
resample_imagehas a newregionargument for this. - With many grey levels, the GLCM and GLRLM thread tables can hold only the levels that occur. This happens when the full tables would take more than 64 MB and at most half of the levels occur. At 2,048 levels with 400 in the ROI, the peak memory of the texture pass is 100 MB instead of 276 MB. It is also 16 % faster. The matrices and the features are the same.
- YAML configurations and the bundled templates are parsed with PyYAML's libyaml safe loader when it is present, which gives the objects of
yaml.safe_load.from_yamlon the six standard configurations takes 0.38 ms instead of 2.7 ms, andRadiomicsPipeline()0.4 ms instead of 3.0 ms.save_logwrites the JSON file while it encodes it: a 300-entry log peaks at 1.5 MB instead of 6.1 MB. The file is the same, byte for byte. DicomDatabase.from_foldersnow starts worker processes only for large scans: at most one worker per 1,000 DICOM files. The DICOM check of each file (132 bytes) runs in the calling process. Before, the scan startedcpu_count - 1workers twice, whatever the number of files, and each worker imported Pictologics. The workers ofDicomDatabase.from_foldersandSRDocument.from_foldersalso skip the JIT warm-up, as they only read headers. A 200-file series takes 0.06 s instead of 3.4 s with the default settings, and 1.9 s instead of 3.7 s with 4 workers. The database is unchanged.SRDocument.from_foldersnow finds SR files by reading each file only up to its SOP Class UID. Batches below 2.5 MB (SR_POOL_BYTES) are parsed in the calling process, not incpu_count - 1workers that each import Pictologics (about 1 s). From 2.5 MB it starts the requested workers, as before. 200 small reports take 0.53 s instead of 1.67 s, and the same reports among 6,000 CT files 1.12 s instead of 2.42 s. A file that breaks after its SOP Class UID now shows as an error in the processing log, not as a skipped file.format_results(fmt="long")builds the table as columns, not row by row. A pandas table of 200 cases with 1,020 features each takes 0.05 s instead of 0.08 s. The output is the same.format_results(fmt="wide")keeps the column names of the last 64 configurations, so the rows of many images share one copy of each name. 1,000 rows of 1,200 features take 55 MB instead of 142 MB, and 110 ms instead of 140 ms to build. The rows are unchanged.import pictologicstakes about 0.3 s less (0.8 s instead of 1.1 s with a warm cache). The JIT warm-up no longer importsscipy.signalfor an FFT convolution that no filter uses.load_and_merge_imagesmerges each repositioned mask only in its own box of the reference grid, without a full-size copy per mask. Five cropped masks merge into a 512 x 512 x 200 grid in 41 ms instead of 0.78 s, with 0.40 GB instead of 1.75 GB. Masks on the reference grid merge 20 % faster, with 23 % less memory. The results are the same.load_segreads SEG files with pydicom and decodes only the frames of the requested segments, one at a time. A SEG with 2,000 frames of 512 x 512 now needs 0.24 GB instead of 1.10 GB. As separate masks, it loads in 0.5 s instead of 1.0 s.get_segment_inforeads only the header: 2 ms instead of 0.3 s. The masks are the same.run()keeps the state after a shared step only when a later configuration starts from it. This uses up to 24 % less memory when a shared step changes the image. The results are the same.save_slicessaves up to 8 slices at a time in threads, as RGB instead of RGBA, with PNG compression level 3 instead of 6. The alpha channel was always 255, because the mask overlay is mixed into the colors. So the pixels are the same, and JPEG files are the same byte for byte. At 300 dpi, 40 slices of a 512×512×200 CT take 0.64 s instead of 17.6 s, and about 120 MB more peak memory. PNG files are 3% smaller at 300 dpi and 30% larger at 72 dpi, where saving takes 0.12 s instead of 1.4 s. TIFF files are 25% smaller.
Dependencies
- Pictologics now uses numba 0.67 (was 0.62) with llvmlite 0.49, and it supports Python 3.14: CI tests Python 3.12, 3.13 and 3.14. numba 0.67 gives the same IBSI values as 0.62, bit for bit, at the same speed (83 bench rows). On Python 3.14, numpy 2.3.2 or newer is needed.
- PyMCubes is no longer a dependency. Pictologics builds its marching cubes mesh with its own kernel. The tests check that kernel against 32 PyMCubes meshes stored in
tests/data/marching_cubes_pymcubes.npz. - Removed four unused development dependencies: rich, psutil, mkdocs-gen-files and genbadge.
- highdicom is no longer a dependency, also not for the tests. Pictologics reads SEG and SR files with pydicom alone, and the tests read SEG and SR files that highdicom made once. pyjpegls goes with it: it has no Python 3.14 build, so pip had to compile it there. pydicom 3.0.1 is now the lowest version, because the SEG reader needs pydicom 3.
- python-gdcm (3.0.10 or newer) is now a dependency. With it, Pictologics loads DICOM data in every common compression: JPEG Lossless, JPEG-LS, JPEG 2000, baseline JPEG and RLE, also in SEG files. 12-bit lossy JPEG (JPEG Extended) needs pylibjpeg-libjpeg (GPL-3.0), which you can install yourself.
0.5.1 - 2026-07-26
Added
- Added -3024 HU (outside the CT reconstruction field of view) to the pool of sentinel values recognised by automatic sentinel detection, alongside the existing -2048, -1024, -1000, 0 and -32768.
- Added versioned, machine-readable filter capability metadata (
FILTER_CAPABILITIES,get_filter_capabilities,CAPABILITIES_SCHEMA_VERSION) describing input/kernel dimensionality, plane execution and averaging, rotation pooling, supported and effective boundaries, Riesz orders, structure-tensor steering, and anisotropic-spacing behaviour, so compliance tooling no longer has to infer them from signatures. - Every executed pipeline
filterstep now recordsparams_requested(the parameters as supplied) andparams_effective(the arguments actually passed to the filter, including pipeline-injected values such asspacing_mmand the Rieszvariantdispatch) in the run log, so a parameter that is defaulted, substituted, or cannot be applied exactly is always visible. Arrays are recorded as compact shape descriptors rather than raw data. - The FFT-based filters (
simoncelli_wavelet,riesz_transform,riesz_log,riesz_simoncelli) now honour theboundaryparameter through a defined pad-filter-crop procedure instead of always being periodic. Periodic remains the default, so existing results are unchanged;riesz_transform,riesz_logandriesz_simoncelligain aboundaryargument.
Changed
- Corrected the IBSI 2 compliance reporting: test 10.a is no longer described as structure-tensor aligned (it is unsteered), a skipped test can no longer be reported as passing, Phase 1 results now separate the strict 3D total from the broader volumetric total with the four 2D Gabor tests named, and the single Phase 2 feature with no published consensus value (8.B
stat_qcod) is shown explicitly as coverage-only instead of being silently omitted. Both compliance pages now record provenance (package version, IBSI 2 reference manual version, reference dataset source and content hashes) and Phase 1 documents one exact, reproducible named test.
Fixed
- Gabor filtering now scales its 2D kernel using the true in-plane voxel spacing of each plane instead of deriving every scale from the first spacing component. This corrects results for anisotropic voxels when
average_over_planes=True(the planes containing the through-plane axis were previously computed with the wrong physical scale). Isotropic in-plane spacing keeps the existing code path and is bit-for-bit unchanged, so IBSI Gabor results are unaffected. The anisotropic-spacing warning has been removed because the case is now handled correctly rather than approximated. - IBSI 2 Phase 1 verification now requests the padding each test specifies for the Riesz filters (zero padding for 9.a/9.b.1/10.a, nearest for 10.b.1) instead of relying on the periodic default, measurably improving agreement with the published reference response maps.
- The auto-detected sentinel warning no longer rounds a near-total sentinel fraction up to "100.0% of voxels", which wrongly implied that no voxels remained for feature extraction. Fractions above 99.95% are now reported as ">99.9%" (a literal 100.0% is shown only when every voxel is the sentinel), and the message states how many voxels remain valid. The full-precision
sentinel_proportionrecorded in the run log is unchanged. - The pipeline no longer silently discards a requested
boundaryfor the Simoncelli and Riesz filters, and an unsupported boundary value now raises a clear error instead of falling back to mirror padding. Each filter step records both the requested and the effective boundary in the run log.
0.5.0 - 2026-07-12
Optimized
- Cache the Riesz frequency-domain transfer function (depends only on shape and order), making
riesz_transform(and riesz_log/riesz_simoncelli) ~1.9-2.5x faster on repeat same-shape calls with byte-identical output. - Cache the Simoncelli wavelet frequency-domain transfer function (depends only on shape and level), making
simoncelli_wavelet~1.8-2.9x faster on repeat same-shape calls with byte-identical output. - Faster Moran's I and Geary's C spatial-intensity features (about 2x on larger ROIs) by exploiting the symmetry of the inverse-distance weights and letting the O(N^2) inner loop vectorise; byte-identical.
- Faster shared nonzero-bounding-box and ROI min/max scans via single-pass numba kernels (with numpy fallbacks for small or non-float inputs), reused across feature families and memoised per extraction pass; byte-identical.
- Much faster preprocessing via numba kernels and ROI-bounded work: image resampling up to ~35-66x, discretise_image ~5-9x, resegment_mask ~4x, and keep_largest_component ~35x on CT-sized volumes; bit-identical for discrete outputs and within 1e-9 for linear resampling.
- Reduce redundant array recomputation in the GLCM/GLRLM/GLDZM/NGLDM/NGTDM feature calculations (~7% faster per family); byte-identical output.
- Replace the GLDZM distance transform with a numba chamfer kernel (~3-4x faster on that step); distances are byte-identical to the previous scipy result.
- Share the resampled mask between the morphological and intensity masks when they are in sync (and drop a redundant mask copy), speeding multi-config pipeline runs with byte-identical output.
- Speed up texture ROI voxel counting on float masks (~5x) with a dtype-gated nonzero count; results are byte-identical.
- Substantially faster texture feature extraction (GLCM/GLRLM/GLSZM/GLDZM/NGTDM/NGLDM): vectorised the GLSZM matrix build, derive GLCM Ng_eff from the ROI bounding box, crop all texture work to the ROI bounding box to eliminate full-volume float64 mask scans, gate matrix computation to the requested families, and parallelise the zone-labelling kernel. Byte-identical results, roughly 1.4-15x faster on CT-sized and sparse volumes.
0.4.2 - 2026-05-16
Changed
- Extended CI to lint tests, added a separate JIT smoke check outside coverage accounting, and made the MkDocs-published changelog the towncrier source with root changelog sync support.
Fixed
- Fixed IBSI 1 compliance benchmarks that failed due to recent pipeline default changes by explicitly restricting
resegmentandfilter_outlierssteps to theintensitymask in standard compliance configurations (Configs C, D, E). - Fixed compartment-specific pipeline semantics so
resegmentandfilter_outliersupdate morphology masks by default, source masks constrain morphology masks after resampling, deduplication treats mask-narrowing steps as morphology dependencies, anddescribe_features()reports mask usage plus effectiveapply_totargets. Nonzero multi-label mask values are now treated as ROI membership rather than numeric weights across preprocessing, morphology, and texture calculations, whilebinarize_maskremains the explicit label-selection step. Configuration exports and processing logs now include mask semantics, package/schema metadata, configuration snapshots, deduplication settings, and effective source/sentinel details for reproducible runs on other machines. - Fixed deduplication signatures to preserve preprocessing step order, corrected continuous-IVH deduplication dependency detection, applied per-frame DICOM rescale transforms for multiframe files, and propagated DICOM SEG alignment controls through reference-aware loading.
Optimized
- Removed redundant initializations of
min_rotandmax_rotarrays in_ombb_extents_numbato slightly improve morphology calculation efficiency.
0.4.1 - 2026-05-05
Changed
- load_image and load_and_merge_images now accept subvoxel_tolerance, subvoxel_warning_threshold, and min_overlap_fraction parameters for configurable sub-voxel alignment handling when repositioning cropped masks. RadiomicsPipeline.run() exposes the same controls via mask_subvoxel_tolerance, mask_subvoxel_warning_threshold, and mask_min_overlap_fraction. Repositioning settings are recorded in the pipeline run log.
Fixed
- Expanded describe_features() so feature catalog rows record ordered preprocessing metadata, repeated step parameters, source-mode context, and extraction-step parameters in machine-readable columns.
- Fixed multi-configuration deduplication so texture, histogram, and IVH families no longer reuse each other's cached results when they share the same preprocessing signature.
- Fixed physical geometry validation for masks and source masks, including direction-aware cropped repositioning and axis-transpose metadata handling.
- Fixed silent empty-array return when a repositioned mask has no overlap with the reference image. This now raises a ValueError by default (controlled by min_overlap_fraction) to prevent undetected wrong-patient mask loading. Set min_overlap_fraction=0.0 to restore the previous warn-and-continue behaviour.
- Fixed texture subfamily aliases, NIfTI direction normalization, direction-aware geometry updates, and per-slice DICOM rescale handling.
- Updated configuration validation so serialized configs using current resampling, discretisation, and IVH runtime parameters no longer emit false unknown-parameter warnings.
0.4.0 - 2026-04-14
Added
- Added
describe_features()method toRadiomicsPipelinethat returns a DataFrame cataloguing every feature the pipeline will produce, with columns for configuration name, feature identity (name, IBSI code, family, broad family group), discretisation/resampling/filter metadata. Useful for exporting data dictionaries and filtering features before extraction.
Changed
- Pipeline runs now always return a complete, predictable set of feature columns for every configuration. When the ROI is empty after preprocessing (
EmptyROIMaskError), a full NaN-valued Series is returned instead of raising an error, and processing continues to the next configuration. Partial extraction failures (e.g., PCA with ≤3 voxels, mesh computation errors, empty texture matrices) are backfilled with NaN so that no feature keys are silently dropped. A newFEATURE_NAMESregistry inpictologics.featuresenumerates all 174 expected feature names by family. - pipeline.run(subject_id=...) no longer injects subject_id into each configuration's feature Series. The parameter is now used exclusively for the processing log. To include subject identifiers in formatted output, pass them via format_results(meta={"subject_id": ...}).
0.3.5 - 2026-02-19
Changed
- Configuration Loading Behavior:
load_configs(),from_yaml(),from_json(), andfrom_dict()now default to loading only the provided configurations, without including standard predefined configs. Passload_standard=Trueto include standard configs alongside loaded ones.RadiomicsPipeline()default constructor behavior is unchanged.
0.3.4 - 2026-02-14
Fixed
- Memory Exhaustion Issue: Resolved a critical issue where resampled background voxels (value 0) were included in the ROI if they fell within the
resegmentrange. The pipeline now explicitly applies thesource_maskto theintensity_maskafter resampling whensource_mode="auto"or an explicit source mask is used. This prevents memory explosions for small ROIs in large volumes with sentinel backgrounds. - Pipeline Configuration Serialization: Fixed a bug where
source_modeandsentinel_valuewere lost during serialization (to_dict/to_yaml). - Sentinel Detection: Fixed detection logic to correctly handle auto-generated full masks.
Changed
- Documentation: Updated
Data LoadingandPipelineuser guides to clarify the usage ofsource_mode="auto"vs"full_image"and its impact on memory and correctness.
0.3.3 - 2026-02-11
Changed
- Sentinel Value Implementation: Implemented proper handling of sentinel values in the pipeline to assure that they do not influence the feature extraction.
- Complete overhaul of User Guide: Rewrote the user guide to improve clarity and organization.
- Benchmark Methodology Updates: Refined timing methods for benchmarking with optimized measurement techniques, resulting in up to 40% faster execution for PyRadiomics compared to previous implementations. Therefore speed improvements of pictologics are now more modest.
0.3.2 - 2026-02-01
Added
- Feature Deduplication System: Intelligent optimization for multi-configuration pipelines:
- Automatically detects when configurations share preprocessing steps but differ only in discretization
- Computes discretization-independent features (morphology, intensity) once and reuses across configurations
deduplication_statsproperty provides reuse/compute statistics after each run- Hash-based signature comparison using SHA256 for exact parameter matching
- Versioned rules system (
DeduplicationRules) for reproducibility
Changed
- Deduplication enabled by default:
RadiomicsPipeline(deduplicate=True)is now the default behavior - Documentation updated:
- Case examples simplified to reflect default deduplication behavior
- Benchmark page clarifies methodology (raw timing without caching) and notes additional speedups with deduplication
0.3.1 - 2026-01-31
Added
- Pipeline Configuration Serialization: Full YAML/JSON export/import for
RadiomicsPipelineconfigurations:save_configs()/load_configs(): File-based configuration persistenceto_yaml()/from_yaml(): String-based YAML serializationto_json()/from_json(): String-based JSON serializationto_dict()/from_dict(): Dictionary conversion for programmatic use
- Configuration Management Methods:
add_config(): Register custom configurationsget_config(): Retrieve configuration by name (deep copy)remove_config(): Delete configurationslist_configs(): List all registered configuration namesmerge_configs(): Combine configurations from multiple pipelines
- Template System: YAML-based configuration templates in
pictologics/templates/:- Standard configurations now loaded from
standard_configs.yaml - Template loading API:
list_template_files(),load_template_file(),get_standard_templates(),get_all_templates(),get_template_metadata()
- Standard configurations now loaded from
- Schema Versioning: Configuration files include
schema_versionfor forward compatibility and automatic migration - Configuration Validation: Opt-in validation via
validate=Trueparameter logs warnings for unknown steps/parameters - Documentation: New "Predefined Configurations" user guide page with comprehensive examples including end-to-end multi-site study workflow
Changed
- Standard configurations (
standard_fbn_*,standard_fbs_*) now loaded from YAML templates instead of hardcoded dictionaries - Updated pipeline.md documentation with condensed configuration section and cross-references
Dependencies
- Added
pyyaml>=6.0as core dependency for YAML serialization
0.3.0 - 2026-01-25
Added
- IBSI 2 Convolutional Filters: Complete filter module (
pictologics/filters/) with:- Mean filter (3D)
- Laplacian of Gaussian (LoG)
- Laws texture energy filters (3D rotation-invariant)
- Gabor filters (2D per-slice)
- Wavelet decomposition (Haar, Daubechies, Coiflet, Symlet families)
- Simoncelli steerable pyramid
- IBSI 2 Phase 1 Compliance: Filter response map validation against digital phantoms
- IBSI 2 Phase 2 Compliance: Feature extraction from filtered images validated
- IBSI 2 Phase 3 Compliance: Multi-modality reproducibility validation across 51 patients × 3 modalities compared to 9 team submissions
- Filter Pipeline Integration: New
filterstep inRadiomicsPipelinefor seamless filtered feature extraction - Mask Binarization Pipeline Step: New
binarize_maskpreprocessing step with configurablethreshold,mask_values(int/list/range tuple), andapply_totargeting.
Changed
- Updated
mkdocs.ymlwith IBSI 2 Phase 1, 2, 3 navigation - Expanded pipeline documentation with filter usage examples and binarization
Fixed
- IBSI 1 Compliance (Morphology): Achieved passing values for Compactness 2 (
BQWJ) and Asphericity (25C7) in Configs C/D/E and texture matrices in config D by binarizing masks before resampling.
0.2.0 - 2026-01-06
Added
- DICOM Database Utility:
DicomDatabaseclass for parsing complex DICOM folder hierarchies with Patient → Study → Series → Instance traversal, multi-phase detection, and DataFrame/JSON/CSV exports - DICOM SEG Loader:
load_seg()for loading DICOM Segmentation objects with multi-segment handling, geometry alignment, and seamless auto-detection inload_image() - DICOM SR Parser:
SRDocumentclass for parsing Structured Reports with measurement extraction, CSV/JSON export, and batch processing viaSRDocument.from_folders() - DICOM Multi-Phase Support:
load_image()now supports multi-phase DICOM series withdataset_index, plusget_dicom_phases()for phase discovery - Visualization Utility:
visualize_slices()for interactive viewing andsave_slices()for batch export with window/level normalization and colormap options - Cropped Image Repositioning:
load_image()andload_and_merge_images()support repositioning cropped masks into reference volume coordinate space - Intensity Rescaling:
apply_rescaleparameter inload_imageand related functions to toggle DICOM rescale slope/intercept application (default: True) - Sentinel Value Handling: Documentation and examples for handling sentinel values (e.g. -2048 in Siemens DICOMs) using the
resegmentpreprocessing step - Dependencies: Added
highdicom,matplotlib,pillow; updatedpandas>=2.0.0
Optimized
- Morphology Speedup: Implemented bounding box cropping for morphology features (mesh/moments), significantly accelerating extraction for sparse ROIs in large volumes
- Texture Speedup: Added slice-level skipping to texture calculation to ignore empty z-slices, vastly improving performance for disjoint ROIs (e.g. multiple tumors)
Fixed
- DICOM file loading improvements: proper Z-spacing, 3D SEG handling, direction matrix extraction
Changed
DicomDatabaseuses sharedsplit_dicom_phases()for consistent multi-phase detection- Comprehensive documentation updates for all new utilities
0.1.0 - 2025-12-28
Initial commit