brisc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. brisc-0.1.0/LICENSE +21 -0
  2. brisc-0.1.0/PKG-INFO +107 -0
  3. brisc-0.1.0/README.md +86 -0
  4. brisc-0.1.0/brisc/__init__.py +64 -0
  5. brisc-0.1.0/brisc/colorspacious.py +280 -0
  6. brisc-0.1.0/brisc/concatenate.py +123 -0
  7. brisc-0.1.0/brisc/de.py +1571 -0
  8. brisc-0.1.0/brisc/pseudobulk.py +6669 -0
  9. brisc-0.1.0/brisc/single_cell.py +19876 -0
  10. brisc-0.1.0/brisc/sparse.py +1175 -0
  11. brisc-0.1.0/brisc/type_aliases.py +32 -0
  12. brisc-0.1.0/brisc/utils.py +1330 -0
  13. brisc-0.1.0/brisc/validated_dict.py +549 -0
  14. brisc-0.1.0/brisc/x86_64_v2/__init__.py +0 -0
  15. brisc-0.1.0/brisc/x86_64_v2/cysparse.pyx +1336 -0
  16. brisc-0.1.0/brisc/x86_64_v2/cyutils.pyx +1276 -0
  17. brisc-0.1.0/brisc/x86_64_v2/doublets.pyx +581 -0
  18. brisc-0.1.0/brisc/x86_64_v2/embed.pyx +1743 -0
  19. brisc-0.1.0/brisc/x86_64_v2/harmonize.pyx +1298 -0
  20. brisc-0.1.0/brisc/x86_64_v2/hdf5.pyx +633 -0
  21. brisc-0.1.0/brisc/x86_64_v2/hvg.pyx +531 -0
  22. brisc-0.1.0/brisc/x86_64_v2/kmeans.pyx +1433 -0
  23. brisc-0.1.0/brisc/x86_64_v2/knn.pyx +973 -0
  24. brisc-0.1.0/brisc/x86_64_v2/labels.pyx +671 -0
  25. brisc-0.1.0/brisc/x86_64_v2/leiden.pyx +1674 -0
  26. brisc-0.1.0/brisc/x86_64_v2/norm_factors.pyx +322 -0
  27. brisc-0.1.0/brisc/x86_64_v2/normalize.pyx +451 -0
  28. brisc-0.1.0/brisc/x86_64_v2/pca.pyx +1257 -0
  29. brisc-0.1.0/brisc/x86_64_v2/pseudobulk_and_markers.pyx +759 -0
  30. brisc-0.1.0/brisc/x86_64_v2/qc.pyx +378 -0
  31. brisc-0.1.0/brisc/x86_64_v2/snn.pyx +489 -0
  32. brisc-0.1.0/brisc/x86_64_v3/__init__.py +0 -0
  33. brisc-0.1.0/brisc/x86_64_v3/cysparse.pyx +1336 -0
  34. brisc-0.1.0/brisc/x86_64_v3/cyutils.pyx +1276 -0
  35. brisc-0.1.0/brisc/x86_64_v3/doublets.pyx +581 -0
  36. brisc-0.1.0/brisc/x86_64_v3/embed.pyx +1743 -0
  37. brisc-0.1.0/brisc/x86_64_v3/harmonize.pyx +1298 -0
  38. brisc-0.1.0/brisc/x86_64_v3/hdf5.pyx +633 -0
  39. brisc-0.1.0/brisc/x86_64_v3/hvg.pyx +531 -0
  40. brisc-0.1.0/brisc/x86_64_v3/kmeans.pyx +1433 -0
  41. brisc-0.1.0/brisc/x86_64_v3/knn.pyx +973 -0
  42. brisc-0.1.0/brisc/x86_64_v3/labels.pyx +671 -0
  43. brisc-0.1.0/brisc/x86_64_v3/leiden.pyx +1674 -0
  44. brisc-0.1.0/brisc/x86_64_v3/norm_factors.pyx +322 -0
  45. brisc-0.1.0/brisc/x86_64_v3/normalize.pyx +451 -0
  46. brisc-0.1.0/brisc/x86_64_v3/pca.pyx +1257 -0
  47. brisc-0.1.0/brisc/x86_64_v3/pseudobulk_and_markers.pyx +759 -0
  48. brisc-0.1.0/brisc/x86_64_v3/qc.pyx +378 -0
  49. brisc-0.1.0/brisc/x86_64_v3/snn.pyx +489 -0
  50. brisc-0.1.0/brisc/x86_64_v4/__init__.py +0 -0
  51. brisc-0.1.0/brisc/x86_64_v4/cysparse.pyx +1336 -0
  52. brisc-0.1.0/brisc/x86_64_v4/cyutils.pyx +1276 -0
  53. brisc-0.1.0/brisc/x86_64_v4/doublets.pyx +581 -0
  54. brisc-0.1.0/brisc/x86_64_v4/embed.pyx +1743 -0
  55. brisc-0.1.0/brisc/x86_64_v4/harmonize.pyx +1298 -0
  56. brisc-0.1.0/brisc/x86_64_v4/hdf5.pyx +633 -0
  57. brisc-0.1.0/brisc/x86_64_v4/hvg.pyx +531 -0
  58. brisc-0.1.0/brisc/x86_64_v4/kmeans.pyx +1433 -0
  59. brisc-0.1.0/brisc/x86_64_v4/knn.pyx +973 -0
  60. brisc-0.1.0/brisc/x86_64_v4/labels.pyx +671 -0
  61. brisc-0.1.0/brisc/x86_64_v4/leiden.pyx +1674 -0
  62. brisc-0.1.0/brisc/x86_64_v4/norm_factors.pyx +322 -0
  63. brisc-0.1.0/brisc/x86_64_v4/normalize.pyx +451 -0
  64. brisc-0.1.0/brisc/x86_64_v4/pca.pyx +1257 -0
  65. brisc-0.1.0/brisc/x86_64_v4/pseudobulk_and_markers.pyx +759 -0
  66. brisc-0.1.0/brisc/x86_64_v4/qc.pyx +378 -0
  67. brisc-0.1.0/brisc/x86_64_v4/snn.pyx +489 -0
  68. brisc-0.1.0/brisc.egg-info/PKG-INFO +107 -0
  69. brisc-0.1.0/brisc.egg-info/SOURCES.txt +84 -0
  70. brisc-0.1.0/brisc.egg-info/dependency_links.txt +1 -0
  71. brisc-0.1.0/brisc.egg-info/requires.txt +11 -0
  72. brisc-0.1.0/brisc.egg-info/top_level.txt +1 -0
  73. brisc-0.1.0/pyproject.toml +58 -0
  74. brisc-0.1.0/setup.cfg +4 -0
  75. brisc-0.1.0/setup.py +164 -0
  76. brisc-0.1.0/tests/test_cluster.py +24 -0
  77. brisc-0.1.0/tests/test_de.py +19 -0
  78. brisc-0.1.0/tests/test_embedding.py +51 -0
  79. brisc-0.1.0/tests/test_hvg.py +23 -0
  80. brisc-0.1.0/tests/test_integration.py +58 -0
  81. brisc-0.1.0/tests/test_markers.py +32 -0
  82. brisc-0.1.0/tests/test_neighbors.py +49 -0
  83. brisc-0.1.0/tests/test_normalize.py +33 -0
  84. brisc-0.1.0/tests/test_pca.py +25 -0
  85. brisc-0.1.0/tests/test_pseudobulk.py +20 -0
  86. brisc-0.1.0/tests/test_qc.py +108 -0
brisc-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Wainberg and Keon Arbabi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
brisc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: brisc
3
+ Version: 0.1.0
4
+ Summary: Fast single-cell toolkit
5
+ Author-email: Michael Wainberg <m.wainberg@utoronto.ca>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: anndata
10
+ Requires-Dist: h5py
11
+ Requires-Dist: matplotlib>=3.5.0
12
+ Requires-Dist: numpy>=1.17.0
13
+ Requires-Dist: pandas
14
+ Requires-Dist: polars>=1.41.0
15
+ Requires-Dist: pyarrow>=0.17.0
16
+ Requires-Dist: ryp
17
+ Requires-Dist: scipy>=0.16.0
18
+ Requires-Dist: scikit-misc
19
+ Requires-Dist: threadpoolctl
20
+ Dynamic: license-file
21
+
22
+ <p align="center">
23
+ <picture>
24
+ <source media="(prefers-color-scheme: dark)" srcset="https://brisc.run/_static/images/runner_title_wide_dark.svg">
25
+ <source media="(prefers-color-scheme: light)" srcset="https://brisc.run/_static/images/runner_title_wide_light.svg">
26
+ <img alt="brisc" src="https://brisc.run/_static/images/runner_title_wide_light.svg" width="500">
27
+ </picture>
28
+ </p>
29
+
30
+ brisc is a high-performance library for analyzing single-cell data at scale. It prioritizes running as fast as possible on multi-core CPU systems, strict reproducibility, and a clean, user-friendly interface. On datasets of 1 to 20 million cells, it cuts the runtime of common workflows from hours to minutes.
31
+
32
+ Full documentation is available at **[brisc.run](https://brisc.run)**.
33
+
34
+ ## Why brisc?
35
+
36
+ - **Blazing fast** — ground-up optimization of core algorithms and effective parallelism.
37
+ - **Deterministic** — every step gives floating-point identical results between runs, regardless of the number of threads used.
38
+ - **Complete toolkit** — preprocessing, dimensionality reduction, harmonization, label transfer, clustering, embedding, pseudobulk differential expression, and plotting.
39
+ - **Interoperable** — reads and writes `.h5ad`, `.rds`, `.h5Seurat`, and 10x files, and supports interleaving Python and R analyses via [ryp](https://github.com/Wainberg/ryp) without intermediate writes to disk.
40
+ - **Memory-efficient** — ~2× lower peak memory than Scanpy by tabulating which cells pass QC, instead of subsetting to them.
41
+ - **User-friendly** — sensible defaults, strict type-checking, and solution-focused error messages.
42
+
43
+ ## Installation
44
+
45
+ brisc supports Linux, macOS, and Windows on Python 3.9+.
46
+
47
+ **conda (recommended)**
48
+
49
+ ```bash
50
+ conda install -c conda-forge brisc
51
+ ```
52
+
53
+ **pip**
54
+
55
+ ```bash
56
+ pip install brisc
57
+ ```
58
+
59
+ conda is recommended because it sets up the fast MKL BLAS and some of the R packages brisc uses. With pip, you'll need to handle those yourself: see the [installation guide](https://brisc.run/installation.html) for details, including optional R integration (for differential expression, Seurat, and SingleCellExperiment support) via [ryp](https://github.com/Wainberg/ryp).
60
+
61
+ ## Quick start
62
+
63
+ ### Basic workflow
64
+
65
+ ```python
66
+ from brisc import SingleCell
67
+
68
+ sc = SingleCell('data.h5ad')\
69
+ .qc()\
70
+ .hvg(batch_column='donor')\
71
+ .normalize()\
72
+ .pca()\
73
+ .neighbors()\
74
+ .shared_neighbors()\
75
+ .cluster(resolution=[0.25, 0.5, 1, 1.5, 2])\
76
+ .pacmap()
77
+ ```
78
+
79
+ ### Label transfer
80
+
81
+ ```python
82
+ from brisc import SingleCell
83
+
84
+ sc_ref = SingleCell('data_ref.h5ad').qc()
85
+ sc_query = SingleCell('data_query.h5ad').qc()
86
+ sc_ref, sc_query = sc_ref.hvg(sc_query)
87
+ sc_ref = sc_ref.normalize()
88
+ sc_query = sc_query.normalize()
89
+ sc_ref, sc_query = sc_ref.pca(sc_query)
90
+ sc_ref, sc_query = sc_ref.harmonize(sc_query)
91
+ sc_query = sc_query.label_transfer_from(
92
+ sc_ref, 'cell_type')
93
+ ```
94
+
95
+ ### Pseudobulk differential expression
96
+
97
+ ```python
98
+ from brisc import SingleCell
99
+
100
+ pb = SingleCell('data.h5ad')\
101
+ .qc()\
102
+ .pseudobulk('sample', 'cell_type')
103
+ de = pb\
104
+ .qc('condition')\
105
+ .library_size()\
106
+ .de('~ condition + sex + pmi')
107
+ ```
brisc-0.1.0/README.md ADDED
@@ -0,0 +1,86 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://brisc.run/_static/images/runner_title_wide_dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://brisc.run/_static/images/runner_title_wide_light.svg">
5
+ <img alt="brisc" src="https://brisc.run/_static/images/runner_title_wide_light.svg" width="500">
6
+ </picture>
7
+ </p>
8
+
9
+ brisc is a high-performance library for analyzing single-cell data at scale. It prioritizes running as fast as possible on multi-core CPU systems, strict reproducibility, and a clean, user-friendly interface. On datasets of 1 to 20 million cells, it cuts the runtime of common workflows from hours to minutes.
10
+
11
+ Full documentation is available at **[brisc.run](https://brisc.run)**.
12
+
13
+ ## Why brisc?
14
+
15
+ - **Blazing fast** — ground-up optimization of core algorithms and effective parallelism.
16
+ - **Deterministic** — every step gives floating-point identical results between runs, regardless of the number of threads used.
17
+ - **Complete toolkit** — preprocessing, dimensionality reduction, harmonization, label transfer, clustering, embedding, pseudobulk differential expression, and plotting.
18
+ - **Interoperable** — reads and writes `.h5ad`, `.rds`, `.h5Seurat`, and 10x files, and supports interleaving Python and R analyses via [ryp](https://github.com/Wainberg/ryp) without intermediate writes to disk.
19
+ - **Memory-efficient** — ~2× lower peak memory than Scanpy by tabulating which cells pass QC, instead of subsetting to them.
20
+ - **User-friendly** — sensible defaults, strict type-checking, and solution-focused error messages.
21
+
22
+ ## Installation
23
+
24
+ brisc supports Linux, macOS, and Windows on Python 3.9+.
25
+
26
+ **conda (recommended)**
27
+
28
+ ```bash
29
+ conda install -c conda-forge brisc
30
+ ```
31
+
32
+ **pip**
33
+
34
+ ```bash
35
+ pip install brisc
36
+ ```
37
+
38
+ conda is recommended because it sets up the fast MKL BLAS and some of the R packages brisc uses. With pip, you'll need to handle those yourself: see the [installation guide](https://brisc.run/installation.html) for details, including optional R integration (for differential expression, Seurat, and SingleCellExperiment support) via [ryp](https://github.com/Wainberg/ryp).
39
+
40
+ ## Quick start
41
+
42
+ ### Basic workflow
43
+
44
+ ```python
45
+ from brisc import SingleCell
46
+
47
+ sc = SingleCell('data.h5ad')\
48
+ .qc()\
49
+ .hvg(batch_column='donor')\
50
+ .normalize()\
51
+ .pca()\
52
+ .neighbors()\
53
+ .shared_neighbors()\
54
+ .cluster(resolution=[0.25, 0.5, 1, 1.5, 2])\
55
+ .pacmap()
56
+ ```
57
+
58
+ ### Label transfer
59
+
60
+ ```python
61
+ from brisc import SingleCell
62
+
63
+ sc_ref = SingleCell('data_ref.h5ad').qc()
64
+ sc_query = SingleCell('data_query.h5ad').qc()
65
+ sc_ref, sc_query = sc_ref.hvg(sc_query)
66
+ sc_ref = sc_ref.normalize()
67
+ sc_query = sc_query.normalize()
68
+ sc_ref, sc_query = sc_ref.pca(sc_query)
69
+ sc_ref, sc_query = sc_ref.harmonize(sc_query)
70
+ sc_query = sc_query.label_transfer_from(
71
+ sc_ref, 'cell_type')
72
+ ```
73
+
74
+ ### Pseudobulk differential expression
75
+
76
+ ```python
77
+ from brisc import SingleCell
78
+
79
+ pb = SingleCell('data.h5ad')\
80
+ .qc()\
81
+ .pseudobulk('sample', 'cell_type')
82
+ de = pb\
83
+ .qc('condition')\
84
+ .library_size()\
85
+ .de('~ condition + sex + pmi')
86
+ ```
@@ -0,0 +1,64 @@
1
+ import os
2
+ import signal
3
+ import sys
4
+
5
+ # Disable HDF5 file locking, which can cause issues when loading
6
+ os.environ['HDF5_USE_FILE_LOCKING'] = 'FALSE'
7
+
8
+ # Python's `logging` module calls `os.register_at_fork()` to handle aspects of
9
+ # its internal locking. Unfortunately, this can cause KeyboardInterrupts to be
10
+ # repeatedly ignored in multiprocessing-based HDF5 loading with errors like:
11
+ # Exception ignored in: <function _releaseLock at 0x7fb85797c5e0>
12
+ # Traceback (most recent call last):
13
+ # File ".../logging/__init__.py", line 243, in _releaseLock
14
+ # def _releaseLock():
15
+ # To get around this bug, temporarily monkeypatch `os.register_at_fork()` to be
16
+ # a null-op, then `import logging`. Unfortunately, there's no way to unregister
17
+ # a callback created with `os.register_at_fork()`, so this "fix" only works
18
+ # when the `logging` module has not been imported yet.
19
+ if 'logging' not in sys.modules and hasattr(os, 'register_at_fork'):
20
+ original_register_at_fork = os.register_at_fork
21
+ os.register_at_fork = lambda *args, **kwargs: None
22
+ try:
23
+ import logging
24
+ finally:
25
+ os.register_at_fork = original_register_at_fork
26
+
27
+ # Ignore Ctrl + C when importing certain modules, to avoid errors due to
28
+ # incomplete imports
29
+ original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
30
+ try:
31
+ import h5py
32
+ import numpy as np
33
+ import polars as pl
34
+ import pyarrow as pa
35
+ from scipy import sparse
36
+ from scipy.sparse._compressed import _cs_matrix
37
+ from scipy.special import stdtrit
38
+ finally:
39
+ signal.signal(signal.SIGINT, original_sigint_handler)
40
+
41
+ # On Linux with thread pinning enabled, MKL BLAS narrows the thread affinity
42
+ # mask to one core when first executed, which would make all of brisc run
43
+ # single-threaded if not fixed. Fix this now by running a BLAS function, then
44
+ # re-expanding the mask to all available cores. (This works even if cgroups
45
+ # limit the available cores, e.g. on Slurm: the OS will take the intersection
46
+ # of what we asked for and what the cgroup allows.)
47
+ if hasattr(os, 'sched_setaffinity'):
48
+ np.linalg.svd([[0]])
49
+ os.sched_setaffinity(0, range(os.cpu_count()))
50
+
51
+ # Ignore harmless warnings about libiomp/libomp mismatch on Windows, which come
52
+ # from compiling with /openmp:llvm but calling BLAS functions compiled with
53
+ # Intel OpenMP
54
+ if sys.platform == 'win32':
55
+ import warnings
56
+ warnings.filterwarnings(action='ignore', module='threadpoolctl',
57
+ category=RuntimeWarning)
58
+
59
+ # Expose the public API
60
+ from .single_cell import SingleCell
61
+ from .pseudobulk import Pseudobulk
62
+ from .de import DE
63
+ from .concatenate import concat_obs, concat_var
64
+ __all__ = 'SingleCell', 'Pseudobulk', 'DE', 'concat_obs', 'concat_var'
@@ -0,0 +1,280 @@
1
+ """
2
+ A standalone replacement for `colorspacious.cspace_convert()`, covering only
3
+ the conversions used by `generate_palette()`:
4
+
5
+ * ('sRGB1', 'CAM02-UCS')
6
+ * ('sRGB255', 'CAM02-UCS')
7
+ * ('CAM02-UCS', 'JCh')
8
+ * ('CAM02-UCS', 'sRGB1')
9
+
10
+ It is a faithful port of the relevant pieces of colorspacious
11
+ (github.com/njsmith/colorspacious, MIT license, (C) Nathaniel J. Smith),
12
+ using the fixed sRGB viewing conditions and the CAM02-UCS parameters that
13
+ colorspacious uses for these named color spaces. It reproduces colorspacious's
14
+ numeric output for these routes, but does not support any other conversion.
15
+ """
16
+
17
+ import numpy as np
18
+
19
+ __all__ = ['cspace_convert']
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # sRGB <-> linear-sRGB <-> XYZ100 (from colorspacious/basics.py)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ # Exact matrix from IEC 61966-2-1:1999
26
+ _XYZ100_to_sRGB1_matrix = np.array([
27
+ [ 3.2406, -1.5372, -0.4986],
28
+ [-0.9689, 1.8758, 0.0415],
29
+ [ 0.0557, -0.2040, 1.0570]])
30
+ _sRGB1_to_XYZ100_matrix = np.linalg.inv(_XYZ100_to_sRGB1_matrix)
31
+
32
+
33
+ def _srgb_to_linear(c):
34
+ c = np.asarray(c, dtype=float)
35
+ out = np.empty(c.shape, dtype=float)
36
+ lin = c < 0.04045
37
+ a = 0.055
38
+ out[lin] = c[lin] / 12.92
39
+ out[~lin] = ((c[~lin] + a) / (a + 1)) ** 2.4
40
+ return out
41
+
42
+
43
+ def _linear_to_srgb(c):
44
+ c = np.asarray(c, dtype=float)
45
+ out = np.empty(c.shape, dtype=float)
46
+ lin = c <= 0.0031308
47
+ a = 0.055
48
+ out[lin] = c[lin] * 12.92
49
+ out[~lin] = (1 + a) * c[~lin] ** (1 / 2.4) - a
50
+ return out
51
+
52
+
53
+ def _matvec(mat, vecs):
54
+ # mat @ each trailing-3 vector of `vecs`
55
+ return np.einsum('...ij,...j->...i', mat, vecs)
56
+
57
+
58
+ def _sRGB1_to_XYZ100(sRGB1):
59
+ return _matvec(_sRGB1_to_XYZ100_matrix, _srgb_to_linear(sRGB1)) * 100.0
60
+
61
+
62
+ def _XYZ100_to_sRGB1(XYZ100):
63
+ lin = _matvec(_XYZ100_to_sRGB1_matrix, np.asarray(XYZ100, float) / 100.0)
64
+ return _linear_to_srgb(lin)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # CIECAM02 viewing conditions (colorspacious's CIECAM02Space.sRGB)
69
+ # XYZ100_w = D65, Y_b = 20, L_A = (64/pi)/5, average surround
70
+ # ---------------------------------------------------------------------------
71
+
72
+ _M_CAT02 = np.array([[ 0.7328, 0.4296, -0.1624],
73
+ [-0.7036, 1.6975, 0.0061],
74
+ [ 0.0030, 0.0136, 0.9834]])
75
+ _M_HPE = np.array([[ 0.38971, 0.68898, -0.07868],
76
+ [-0.22981, 1.18340, 0.04641],
77
+ [ 0.00000, 0.00000, 1.00000]])
78
+ _M_CAT02_inv = np.linalg.inv(_M_CAT02)
79
+ _M_HPE_M_CAT02_inv = _M_HPE @ _M_CAT02_inv
80
+ _M_CAT02_M_HPE_inv = _M_CAT02 @ np.linalg.inv(_M_HPE)
81
+
82
+
83
+ class _VC:
84
+ """Precomputed CIECAM02 viewing-condition constants (sRGB)."""
85
+ def __init__(self):
86
+ XYZ_w = np.array([95.047, 100.0, 108.883]) # D65
87
+ Y_b = 20.0
88
+ L_A = (64.0 / np.pi) / 5.0
89
+ F, c, N_c = 1.0, 0.69, 1.0 # average surround
90
+
91
+ self.c = c
92
+ self.N_c = N_c
93
+
94
+ RGB_w = _M_CAT02 @ XYZ_w
95
+ D = F * (1 - (1 / 3.6) * np.exp((-L_A - 42) / 92))
96
+ D = np.clip(D, 0, 1)
97
+ self.D_RGB = D * XYZ_w[1] / RGB_w + 1 - D
98
+
99
+ k = 1 / (5 * L_A + 1)
100
+ self.F_L = (0.2 * k ** 4 * (5 * L_A)
101
+ + 0.1 * (1 - k ** 4) ** 2 * (5 * L_A) ** (1 / 3))
102
+ self.n = Y_b / XYZ_w[1]
103
+ self.z = 1.48 + np.sqrt(self.n)
104
+ self.N_bb = 0.725 * (1 / self.n) ** 0.2
105
+ self.N_cb = self.N_bb
106
+
107
+ RGB_wc = self.D_RGB * RGB_w
108
+ RGBp_w = _M_HPE_M_CAT02_inv @ RGB_wc
109
+ tmp = ((self.F_L * RGBp_w) / 100) ** 0.42
110
+ RGBp_aw = 400 * (tmp / (tmp + 27.13)) + 0.1
111
+ self.A_w = (np.dot([2, 1, 1 / 20], RGBp_aw) - 0.305) * self.N_bb
112
+
113
+
114
+ _VC = _VC()
115
+
116
+
117
+ def _XYZ100_to_JCh_M(XYZ100):
118
+ """Forward CIECAM02. Returns (J, C, h, M)."""
119
+ vc = _VC
120
+ XYZ100 = np.asarray(XYZ100, dtype=float)
121
+
122
+ RGB = np.inner(XYZ100, _M_CAT02)
123
+ RGB_C = vc.D_RGB * RGB
124
+ RGBp = np.inner(RGB_C, _M_HPE_M_CAT02_inv)
125
+
126
+ signs = np.sign(RGBp)
127
+ tmp = (vc.F_L * signs * RGBp / 100) ** 0.42
128
+ RGBp_a = signs * 400 * (tmp / (tmp + 27.13)) + 0.1
129
+
130
+ a = np.inner(RGBp_a, [1, -12 / 11, 1 / 11])
131
+ b = np.inner(RGBp_a, [1 / 9, 1 / 9, -2 / 9])
132
+ h_rad = np.arctan2(b, a)
133
+ h = np.rad2deg(h_rad) % 360
134
+
135
+ A = (np.inner(RGBp_a, [2, 1, 1 / 20]) - 0.305) * vc.N_bb
136
+ if np.any(A < 0):
137
+ error_message = 'achromatic signal A was negative'
138
+ raise ValueError(error_message)
139
+
140
+ J = 100 * (A / vc.A_w) ** (vc.c * vc.z)
141
+ e = (12500 / 13) * vc.N_c * vc.N_cb * (np.cos(h_rad + 2) + 3.8)
142
+ t = (e * np.sqrt(a ** 2 + b ** 2)) / np.inner(RGBp_a, [1, 1, 21 / 20])
143
+ C = t ** 0.9 * (J / 100) ** 0.5 * (1.64 - 0.29 ** vc.n) ** 0.73
144
+ M = C * vc.F_L ** 0.25
145
+ return J, C, h, M
146
+
147
+
148
+ def _JMh_to_XYZ100(J, M, h):
149
+ """Inverse CIECAM02 from (J, M, h). Port of the J/M/h path only."""
150
+ vc = _VC
151
+ J = np.asarray(J, dtype=float)
152
+ M = np.asarray(M, dtype=float)
153
+ h = np.asarray(h, dtype=float)
154
+
155
+ C = M / vc.F_L ** 0.25
156
+
157
+ J, C, h = np.broadcast_arrays(J, C, h)
158
+ target_shape = J.shape
159
+ if J.ndim == 0:
160
+ J = np.atleast_1d(J)
161
+ C = np.atleast_1d(C)
162
+ h = np.atleast_1d(h)
163
+
164
+ t = (C / (np.sqrt(J / 100)
165
+ * (1.64 - 0.29 ** vc.n) ** 0.73)) ** (1 / 0.9)
166
+ e_t = 0.25 * (np.cos(np.deg2rad(h) + 2) + 3.8)
167
+ A = vc.A_w * (J / 100) ** (1 / (vc.c * vc.z))
168
+
169
+ with np.errstate(divide='ignore', invalid='ignore'):
170
+ one_over_t = 1 / t
171
+ one_over_t = np.select([np.isnan(one_over_t), True],
172
+ [np.inf, one_over_t])
173
+
174
+ p_1 = (50000 / 13) * vc.N_c * vc.N_cb * e_t * one_over_t
175
+ p_2 = A / vc.N_bb + 0.305
176
+ p_3 = 21 / 20
177
+
178
+ sin_h = np.sin(np.deg2rad(h))
179
+ cos_h = np.cos(np.deg2rad(h))
180
+
181
+ num = p_2 * (2 + p_3) * (460 / 1403)
182
+ denom_part2 = (2 + p_3) * (220 / 1403)
183
+ denom_part3 = (-27 / 1403) + p_3 * (6300 / 1403)
184
+
185
+ a = np.empty_like(h)
186
+ b = np.empty_like(h)
187
+ small_cos = np.abs(sin_h) >= np.abs(cos_h)
188
+
189
+ b[small_cos] = (num[small_cos]
190
+ / (p_1[small_cos] / sin_h[small_cos]
191
+ + denom_part2 * cos_h[small_cos] / sin_h[small_cos]
192
+ + denom_part3))
193
+ a[small_cos] = b[small_cos] * cos_h[small_cos] / sin_h[small_cos]
194
+
195
+ a[~small_cos] = (num[~small_cos]
196
+ / (p_1[~small_cos] / cos_h[~small_cos]
197
+ + denom_part2
198
+ + denom_part3 * sin_h[~small_cos] / cos_h[~small_cos]))
199
+ b[~small_cos] = a[~small_cos] * sin_h[~small_cos] / cos_h[~small_cos]
200
+
201
+ p2ab = np.stack([p_2, a, b], axis=-1)
202
+ RGBp_a_matrix = (1 / 1403) * np.array([[460, 451, 288],
203
+ [460, -891, -261],
204
+ [460, -220, -6300]], dtype=float)
205
+ RGBp_a = np.inner(p2ab, RGBp_a_matrix)
206
+
207
+ RGBp = (np.sign(RGBp_a - 0.1)
208
+ * (100 / vc.F_L)
209
+ * ((27.13 * np.abs(RGBp_a - 0.1))
210
+ / (400 - np.abs(RGBp_a - 0.1))) ** (1 / 0.42))
211
+
212
+ RGB_C = np.inner(RGBp, _M_CAT02_M_HPE_inv)
213
+ RGB = RGB_C / vc.D_RGB
214
+ XYZ100 = np.inner(RGB, _M_CAT02_inv)
215
+ return XYZ100.reshape(target_shape + (3,))
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # CAM02-UCS (Luo et al. 2006) J'a'b' <-> JMh (KL=1.00, c1=0.007, c2=0.0228)
220
+ # ---------------------------------------------------------------------------
221
+
222
+ _UCS_KL, _UCS_C1, _UCS_C2 = 1.00, 0.007, 0.0228
223
+
224
+
225
+ def _JMh_to_Jpapbp(JMh):
226
+ JMh = np.asarray(JMh, dtype=float)
227
+ J = JMh[..., 0]
228
+ M = JMh[..., 1]
229
+ h = JMh[..., 2]
230
+ Jp = (1 + 100 * _UCS_C1) * J / (1 + _UCS_C1 * J) / _UCS_KL
231
+ Mp = (1 / _UCS_C2) * np.log(1 + _UCS_C2 * M)
232
+ h_rad = np.deg2rad(h)
233
+ ap = Mp * np.cos(h_rad)
234
+ bp = Mp * np.sin(h_rad)
235
+ return np.stack([Jp, ap, bp], axis=-1)
236
+
237
+
238
+ def _Jpapbp_to_JMh(Jpapbp):
239
+ Jpapbp = np.asarray(Jpapbp, dtype=float)
240
+ Jp = Jpapbp[..., 0] * _UCS_KL
241
+ ap = Jpapbp[..., 1]
242
+ bp = Jpapbp[..., 2]
243
+ J = -Jp / (_UCS_C1 * Jp - 100 * _UCS_C1 - 1)
244
+ Mp = np.hypot(ap, bp)
245
+ h = np.rad2deg(np.arctan2(bp, ap)) % 360
246
+ M = (np.exp(_UCS_C2 * Mp) - 1) / _UCS_C2
247
+ return np.stack([J, M, h], axis=-1)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Public entry point
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def cspace_convert(arr, start, end):
255
+ """Drop-in replacement for `colorspacious.cspace_convert()`, restricted to
256
+ the start-end pairs used by `generate_palette()`. Raises for anything
257
+ else."""
258
+ route = (start, end)
259
+
260
+ if route == ('sRGB1', 'CAM02-UCS'):
261
+ J, C, h, M = _XYZ100_to_JCh_M(_sRGB1_to_XYZ100(arr))
262
+ return _JMh_to_Jpapbp(np.stack([J, M, h], axis=-1))
263
+
264
+ if route == ('sRGB255', 'CAM02-UCS'):
265
+ sRGB1 = np.asarray(arr, dtype=float) / 255.0
266
+ J, C, h, M = _XYZ100_to_JCh_M(_sRGB1_to_XYZ100(sRGB1))
267
+ return _JMh_to_Jpapbp(np.stack([J, M, h], axis=-1))
268
+
269
+ if route == ('CAM02-UCS', 'JCh'):
270
+ JMh = _Jpapbp_to_JMh(arr)
271
+ XYZ100 = _JMh_to_XYZ100(JMh[..., 0], JMh[..., 1], JMh[..., 2])
272
+ J, C, h, M = _XYZ100_to_JCh_M(XYZ100)
273
+ return np.stack([J, C, h], axis=-1)
274
+
275
+ if route == ('CAM02-UCS', 'sRGB1'):
276
+ JMh = _Jpapbp_to_JMh(arr)
277
+ XYZ100 = _JMh_to_XYZ100(JMh[..., 0], JMh[..., 1], JMh[..., 2])
278
+ return _XYZ100_to_sRGB1(XYZ100)
279
+
280
+ raise NotImplementedError
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+ from typing import Iterable
3
+ from .single_cell import SingleCell
4
+ from .pseudobulk import Pseudobulk
5
+ from .utils import check_type, to_tuple
6
+
7
+
8
+ def concat_obs(datasets: SingleCell | Iterable[SingleCell] |
9
+ Pseudobulk | Iterable[Pseudobulk],
10
+ *more_datasets: SingleCell | Pseudobulk,
11
+ dataset_column: str | None = None,
12
+ dataset_labels: Iterable[str] | None = None,
13
+ flexible: bool = False,
14
+ num_threads: int | np.integer | None = None) -> \
15
+ SingleCell | Pseudobulk:
16
+ """
17
+ Concatenate multiple SingleCell datasets cell-wise, or multiple Pseudobulk
18
+ datasets sample-wise.
19
+
20
+ Delegates to `SingleCell.concat_obs()` or `Pseudobulk.concat_obs()`,
21
+ depending on whether the datasets are SingleCell or Pseudobulk.
22
+
23
+ Args:
24
+ datasets: one or more SingleCell or Pseudobulk datasets to concatenate
25
+ *more_datasets: additional SingleCell or Pseudobulk datasets to
26
+ concatenate with this one, specified as positional
27
+ arguments
28
+ dataset_column: the name of an Enum column to be added to the
29
+ concatenated dataset's `obs` labeling which dataset
30
+ each cell came from. The labels themselves are
31
+ determined by the `dataset_labels` argument.
32
+ dataset_labels: a sequence of labels for each dataset, used to populate
33
+ `dataset_column`. There must be one label per dataset
34
+ being concatenated. If `dataset_labels` is not
35
+ specified, the labels default to `{dataset_column}_0`,
36
+ `{dataset_column}_1`, ..., `{dataset_column}_{N - 1}`.
37
+ Can only be specified when `dataset_column` is not
38
+ `None`.
39
+ flexible: whether to subset to genes, columns of `obs` and `var`, and
40
+ (for SingleCell datasets) keys of `obsm`, `varm` and `uns`
41
+ common to all datasets before concatenating, rather than
42
+ raising an error on any mismatches
43
+ num_threads: the number of threads to use when concatenating. Does not
44
+ affect the concatenated SingleCell or Pseudobulk dataset's
45
+ `num_threads`; this will always be the same as the first
46
+ dataset's `num_threads`.
47
+
48
+ Returns:
49
+ The concatenated SingleCell or Pseudobulk dataset.
50
+ """
51
+ if isinstance(datasets, (SingleCell, Pseudobulk)):
52
+ datasets = (datasets,) + more_datasets
53
+ else:
54
+ datasets = tuple(datasets) + more_datasets
55
+ check_type(datasets[0], 'the first dataset', (SingleCell, Pseudobulk),
56
+ 'a SingleCell or Pseudobulk dataset')
57
+ if isinstance(datasets[0], SingleCell):
58
+ return datasets[0].concat_obs(
59
+ datasets[1:], flexible=flexible, dataset_column=dataset_column,
60
+ dataset_labels=dataset_labels, num_threads=num_threads)
61
+ else:
62
+ return datasets[0].concat_obs(
63
+ datasets[1:], flexible=flexible, dataset_column=dataset_column,
64
+ dataset_labels=dataset_labels)
65
+
66
+
67
+ def concat_var(datasets: SingleCell | Iterable[SingleCell] |
68
+ Pseudobulk | Iterable[Pseudobulk],
69
+ *more_datasets: SingleCell | Pseudobulk,
70
+ dataset_column: str | None = None,
71
+ dataset_labels: Iterable[str] | None = None,
72
+ flexible: bool = False,
73
+ num_threads: int | np.integer | None = None) -> \
74
+ SingleCell | Pseudobulk:
75
+ """
76
+ Concatenate multiple SingleCell datasets or multiple Pseudobulk datasets,
77
+ gene-wise. This is much less common than the cell- or sample-wise
78
+ concatenation provided by `concat_obs()`.
79
+
80
+ Delegates to `SingleCell.concat_var()` or `Pseudobulk.concat_var()`,
81
+ depending on whether the datasets are SingleCell or Pseudobulk.
82
+
83
+ Args:
84
+ datasets: one or more SingleCell or Pseudobulk datasets to concatenate
85
+ *more_datasets: additional Pseudobulk datasets to concatenate with this
86
+ one, specified as positional arguments
87
+ dataset_column: the name of an Enum column to be added to the
88
+ concatenated dataset's `var` labeling which dataset
89
+ each cell came from. The labels themselves are
90
+ determined by the `dataset_labels` argument.
91
+ dataset_labels: a sequence of labels for each dataset, used to populate
92
+ `dataset_column`. There must be one label per dataset
93
+ being concatenated. If `dataset_labels` is not
94
+ specified, the labels default to `{dataset_column}_0`,
95
+ `{dataset_column}_1`, ..., `{dataset_column}_{N - 1}`.
96
+ Can only be specified when `dataset_column` is not
97
+ `None`.
98
+ flexible: whether to subset to cells/samples, columns of `obs` and
99
+ `var`, and (for SingleCell datasets) keys of `obsm`, `varm`
100
+ and `uns` common to all datasets before concatenating, rather
101
+ than raising an error on any mismatches
102
+ num_threads: the number of threads to use when concatenating. Does not
103
+ affect the concatenated SingleCell/Pseudobulk dataset's
104
+ `num_threads`; this will always be the same as the first
105
+ dataset's `num_threads`.
106
+
107
+ Returns:
108
+ The concatenated SingleCell or Pseudobulk dataset.
109
+ """
110
+ if isinstance(datasets, (SingleCell, Pseudobulk)):
111
+ datasets = (datasets,) + more_datasets
112
+ else:
113
+ datasets = tuple(datasets) + more_datasets
114
+ check_type(datasets[0], 'the first dataset', (SingleCell, Pseudobulk),
115
+ 'a SingleCell or Pseudobulk dataset')
116
+ if isinstance(datasets[0], SingleCell):
117
+ return datasets[0].concat_var(
118
+ datasets[1:], flexible=flexible, dataset_column=dataset_column,
119
+ dataset_labels=dataset_labels, num_threads=num_threads)
120
+ else:
121
+ return datasets[0].concat_var(
122
+ datasets[1:], flexible=flexible, dataset_column=dataset_column,
123
+ dataset_labels=dataset_labels)