pelutils 4.2.1__cp315-cp315-win_amd64.whl
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.
- _pelutils_c.cp315-win_amd64.pyd +0 -0
- pelutils/__init__.py +3 -0
- pelutils/__version__.py +2 -0
- pelutils/_c/__init__.py +29 -0
- pelutils/_entry_points/__init__.py +0 -0
- pelutils/_entry_points/linecounter.py +149 -0
- pelutils/array/__init__.py +48 -0
- pelutils/array/_blob.py +173 -0
- pelutils/array/_unique.py +77 -0
- pelutils/array/_unique.pyi +80 -0
- pelutils/array/_utils.py +31 -0
- pelutils/job_parser/__init__.py +61 -0
- pelutils/job_parser/_job_parser.py +362 -0
- pelutils/job_parser/_structs.py +268 -0
- pelutils/logging/__init__.py +38 -0
- pelutils/logging/_logger.py +331 -0
- pelutils/logging/_rich_string.py +49 -0
- pelutils/logging/_rotate.py +154 -0
- pelutils/misc/__init__.py +29 -0
- pelutils/misc/_conditional_import.py +21 -0
- pelutils/misc/_files.py +60 -0
- pelutils/misc/_git.py +31 -0
- pelutils/misc/_misc.py +13 -0
- pelutils/misc/_platform.py +74 -0
- pelutils/misc/_table.py +113 -0
- pelutils/plots/__init__.py +50 -0
- pelutils/plots/_figure.py +116 -0
- pelutils/plots/_histogram.py +50 -0
- pelutils/plots/_utils.py +36 -0
- pelutils/py.typed +0 -0
- pelutils/serialization/__init__.py +51 -0
- pelutils/serialization/_jsonl.py +38 -0
- pelutils/serialization/_pretty_json.py +274 -0
- pelutils/serialization/_universal_json_model.py +54 -0
- pelutils/stats/__init__.py +53 -0
- pelutils/stats/_distributions.py +93 -0
- pelutils/stats/_stats.py +26 -0
- pelutils/tests/__init__.py +32 -0
- pelutils/tests/_tests.py +88 -0
- pelutils/ticktock/__init__.py +42 -0
- pelutils/ticktock/_ticktock.py +450 -0
- pelutils/types/__init__.py +25 -0
- pelutils/types/_types.py +17 -0
- pelutils-4.2.1.dist-info/METADATA +387 -0
- pelutils-4.2.1.dist-info/RECORD +49 -0
- pelutils-4.2.1.dist-info/WHEEL +5 -0
- pelutils-4.2.1.dist-info/entry_points.txt +2 -0
- pelutils-4.2.1.dist-info/licenses/pelutils/_c/hashmap/LICENSE +20 -0
- pelutils-4.2.1.dist-info/top_level.txt +2 -0
|
Binary file
|
pelutils/__init__.py
ADDED
pelutils/__version__.py
ADDED
pelutils/_c/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Utilities for pelutils C api, including a helpful way of passing numpy arrays and torch tensors to C."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
|
|
8
|
+
from pelutils.misc._conditional_import import import_torch
|
|
9
|
+
from pelutils.types import AnyArray as AnyArray
|
|
10
|
+
|
|
11
|
+
torch = import_torch()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Data pointer, num dims, dimensions pointer, strides pointer
|
|
15
|
+
class ArrayArgs:
|
|
16
|
+
def __init__(self, arr: npt.ArrayLike):
|
|
17
|
+
if torch is not None and isinstance(arr, torch.Tensor):
|
|
18
|
+
arr = arr.numpy()
|
|
19
|
+
if not isinstance(arr, np.ndarray):
|
|
20
|
+
raise TypeError(f"Array cannot be of type {type(arr)}")
|
|
21
|
+
# Store attributes on self to prevent garbage collector cleaning them up until the object no longer exists
|
|
22
|
+
self._arr = arr
|
|
23
|
+
self._dims = np.array(self._arr.shape, dtype=np.uint64)
|
|
24
|
+
self._strides = np.array(self._arr.strides, dtype=np.uint64)
|
|
25
|
+
|
|
26
|
+
self.array_ptr = self._arr.ctypes.data
|
|
27
|
+
self.ndim = len(self._dims)
|
|
28
|
+
self.dims_ptr = self._dims.ctypes.data
|
|
29
|
+
self.strides_ptr = self._strides.ctypes.data
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from argparse import ArgumentParser
|
|
3
|
+
|
|
4
|
+
import git
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from pelutils.misc import git_repo_info
|
|
10
|
+
from pelutils.plots import Figure, get_dateticks
|
|
11
|
+
from pelutils.types import FloatArray, IntArray
|
|
12
|
+
|
|
13
|
+
_default_extensions = ", ".join((
|
|
14
|
+
".py",
|
|
15
|
+
".pyw",
|
|
16
|
+
".c",
|
|
17
|
+
".cpp",
|
|
18
|
+
".cc",
|
|
19
|
+
".h",
|
|
20
|
+
".hpp",
|
|
21
|
+
".tex",
|
|
22
|
+
".html",
|
|
23
|
+
".css",
|
|
24
|
+
".js",
|
|
25
|
+
".jsx",
|
|
26
|
+
".ts",
|
|
27
|
+
".java",
|
|
28
|
+
".kt",
|
|
29
|
+
".dart",
|
|
30
|
+
".swift",
|
|
31
|
+
".rs",
|
|
32
|
+
".go",
|
|
33
|
+
".r",
|
|
34
|
+
".m",
|
|
35
|
+
".sh",
|
|
36
|
+
".zsh",
|
|
37
|
+
".bash",
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _count(repo: git.Repo, branch: git.Head, exts: list[str]) -> tuple[FloatArray, dict[str, IntArray]]:
|
|
42
|
+
"""Count lines of all files recursively in the current working directory.
|
|
43
|
+
|
|
44
|
+
Return an array of commit epoch times and a dict mapping file extensions to line counts
|
|
45
|
+
"""
|
|
46
|
+
commits = list(reversed(list(repo.iter_commits()))) # List of commit from oldest to newest
|
|
47
|
+
times = np.array([c.committed_date for c in commits])
|
|
48
|
+
lines = {ext: np.zeros_like(times) for ext in exts}
|
|
49
|
+
try:
|
|
50
|
+
for i, commit in enumerate(tqdm(commits)):
|
|
51
|
+
try:
|
|
52
|
+
repo.git.checkout(str(commit))
|
|
53
|
+
except git.GitCommandError as e:
|
|
54
|
+
# This error happens if there is a file that has been moved in and out of .gitignore
|
|
55
|
+
# For safety, the commit is ignored
|
|
56
|
+
if "error: The following untracked working tree files would be overwritten by checkout:" in str(e):
|
|
57
|
+
continue
|
|
58
|
+
raise
|
|
59
|
+
for path, __ in repo.index.entries:
|
|
60
|
+
try:
|
|
61
|
+
ext = next(e for e in exts if path.endswith(e) and os.path.isfile(path)) # pyright: ignore[reportAttributeAccessIssue]
|
|
62
|
+
except StopIteration:
|
|
63
|
+
continue
|
|
64
|
+
with open(path) as f:
|
|
65
|
+
lines[ext][i] += len([line for line in f if line.strip()])
|
|
66
|
+
finally:
|
|
67
|
+
repo.git.checkout(branch)
|
|
68
|
+
|
|
69
|
+
return times, lines
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fuse_times(all_times: list[FloatArray]) -> FloatArray:
|
|
73
|
+
"""Merge all time arrays together into a single array that is also sorted."""
|
|
74
|
+
n = sum(times.size for times in all_times)
|
|
75
|
+
times = np.empty(n, dtype=int)
|
|
76
|
+
idcs = [0] * len(all_times)
|
|
77
|
+
for i in range(n):
|
|
78
|
+
current_times = [time[idx] if idx < len(time) else float("inf") for time, idx in zip(all_times, idcs, strict=True)]
|
|
79
|
+
min_time = np.argmin(current_times)
|
|
80
|
+
times[i] = current_times[min_time]
|
|
81
|
+
idcs[min_time] += 1
|
|
82
|
+
return times
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _last_initial_zero(values: IntArray) -> int:
|
|
86
|
+
if values[0] != 0:
|
|
87
|
+
return 0
|
|
88
|
+
return np.where(values != 0)[0][0] - 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def linecounter(repos: list[str], output: str, extensions: str, date_format: str, no_repo_name: bool):
|
|
92
|
+
file_extensions = [x.strip() for x in extensions.split(",")]
|
|
93
|
+
file_extensions = [x if x.startswith(".") else "." + x for x in file_extensions]
|
|
94
|
+
wd = os.getcwd()
|
|
95
|
+
repo_names, all_times, all_counts = list(), list(), list()
|
|
96
|
+
for possible_repo_path in repos:
|
|
97
|
+
repo_path, __ = git_repo_info(possible_repo_path)
|
|
98
|
+
if repo_path is None:
|
|
99
|
+
raise ValueError(f"{repo_path} is not a git repository")
|
|
100
|
+
os.chdir(repo_path)
|
|
101
|
+
|
|
102
|
+
repo = git.Repo()
|
|
103
|
+
branch = repo.active_branch
|
|
104
|
+
try:
|
|
105
|
+
times, lines = _count(repo, branch, file_extensions)
|
|
106
|
+
finally:
|
|
107
|
+
# Reset repo
|
|
108
|
+
repo.git.checkout(branch)
|
|
109
|
+
|
|
110
|
+
repo_names.append(os.path.split(repo_path)[-1])
|
|
111
|
+
all_times.append(times)
|
|
112
|
+
all_counts.append(lines)
|
|
113
|
+
os.chdir(wd)
|
|
114
|
+
|
|
115
|
+
with Figure(output):
|
|
116
|
+
repo_name: str | None = None
|
|
117
|
+
for _i, (repo_name, times, counts) in enumerate(zip(repo_names, all_times, all_counts, strict=True)):
|
|
118
|
+
for ext, line_counts in counts.items():
|
|
119
|
+
if not line_counts.any():
|
|
120
|
+
continue
|
|
121
|
+
non_zero = line_counts != 0
|
|
122
|
+
non_zero[_last_initial_zero(line_counts)] = 1
|
|
123
|
+
lab = ext + (f" ({repo_name})" if len(repo_names) > 1 and not no_repo_name else "")
|
|
124
|
+
plt.plot(times[non_zero], line_counts[non_zero], marker=".", ms=8, lw=2, label=lab)
|
|
125
|
+
|
|
126
|
+
fused_times = _fuse_times(all_times)
|
|
127
|
+
plt.xticks(*get_dateticks(fused_times, date_format=date_format))
|
|
128
|
+
title = "Line count over time" + (f" ({repo_name})" if len(repo_names) == 1 and not no_repo_name else "")
|
|
129
|
+
plt.title(title)
|
|
130
|
+
plt.xlabel("Date of commit")
|
|
131
|
+
plt.ylabel("Number of non-empty lines")
|
|
132
|
+
plt.legend(loc=2)
|
|
133
|
+
plt.grid()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run():
|
|
137
|
+
parser = ArgumentParser()
|
|
138
|
+
parser.add_argument("repos", nargs="+")
|
|
139
|
+
parser.add_argument("-o", "--output", default="linecount.png", help="Path to produce output figure to.")
|
|
140
|
+
parser.add_argument("-e", "--extensions", default=_default_extensions, help="Comma separated list of file extensions to look for.")
|
|
141
|
+
parser.add_argument("-d", "--date-format", default="%y-%m-%d", help="How to format axis labels.")
|
|
142
|
+
parser.add_argument("-n", "--no-repo-name", action="store_true", help="Disable repository name from the produced figure.")
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
linecounter(args.repos, args.output, args.extensions, args.date_format, args.no_repo_name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
run()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Utilities and various algorithms for working with numpy arrays.
|
|
2
|
+
|
|
3
|
+
The flagship is :func:`unique`, a drop-in for ``numpy.unique`` that runs in linear time
|
|
4
|
+
(rather than sorting), making it dramatically faster on large arrays.
|
|
5
|
+
|
|
6
|
+
Quick start
|
|
7
|
+
-----------
|
|
8
|
+
|
|
9
|
+
.. code-block:: python
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from pelutils.array import unique
|
|
13
|
+
|
|
14
|
+
x = np.random.randint(0, 100, size=10_000_000)
|
|
15
|
+
values = unique(x)
|
|
16
|
+
values, index, inverse, counts = unique(
|
|
17
|
+
x, return_index=True, return_inverse=True, return_counts=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
:class:`SparseGridBlobDetection` is used for detecting blobs in a sparse, N-dimensional grid.
|
|
21
|
+
It also works for dense grids (effetively boolean numpy arrays), which can trivially be converted
|
|
22
|
+
to sparse grids using ``np.where`` and ``np.column_stack``, as shown in the example.
|
|
23
|
+
|
|
24
|
+
A blob is defined as an area of touching grid entries which are all ``True``. An obvious example
|
|
25
|
+
is finding areas that are left in an image after thresholding on pixel values.
|
|
26
|
+
|
|
27
|
+
.. code-block:: python
|
|
28
|
+
|
|
29
|
+
greyscale_image = ... # Numpy array of shape height x width
|
|
30
|
+
thresholded = greyscale_image > 100
|
|
31
|
+
# Find the indices in the thresholded image which are True and stack them into an n x 2 array
|
|
32
|
+
coords_above_threshold = np.column_stack(np.where(thresholded))
|
|
33
|
+
|
|
34
|
+
blob_detector = SparseGridBlobDetection(coords_above_threshold)
|
|
35
|
+
blobs = blob_detector.find_all_blobs()
|
|
36
|
+
|
|
37
|
+
# The pixel coordinates in the first blob are fetched with
|
|
38
|
+
coords_above_threshold[blobs[0]]
|
|
39
|
+
|
|
40
|
+
Both :func:`unique` and :class:`SparseGridBlobDetection` are implemented in C, making them
|
|
41
|
+
blazingly fast compared to what you usually expect from Python.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from ._blob import SparseGridBlobDetection
|
|
45
|
+
from ._unique import unique
|
|
46
|
+
from ._utils import array_bytes, array_ptr
|
|
47
|
+
|
|
48
|
+
__all__ = ("SparseGridBlobDetection", "array_bytes", "array_ptr", "unique")
|
pelutils/array/_blob.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import _pelutils_c as _c
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
import pelutils._c as c_utils
|
|
7
|
+
from pelutils.types import BoolArray, IntArray
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SparseGridBlobDetection:
|
|
11
|
+
"""Detect blobs (continuous regions) in a sparse grid, implemented in C for high performance.
|
|
12
|
+
|
|
13
|
+
The grids can have an arbitrary number of dimensions, but a common usecase might be in image analysis.
|
|
14
|
+
Imaging thresholding an image based on pixel values. This class offers blazingly fast detection of all
|
|
15
|
+
blobs in the resulting boolean image.
|
|
16
|
+
|
|
17
|
+
While images are a prime use case, this class works for any dimensionality of arrays.
|
|
18
|
+
It provides two methods: ``find_all_blobs`` and ``find_single_blob``. The first, as demonstrated above, detects,
|
|
19
|
+
for each blobs, all pixels belonging to that blob. The second finds only a single blob and optionally accepts a
|
|
20
|
+
starting pixel.
|
|
21
|
+
|
|
22
|
+
Adjacency is defined by a plus-shaped kernel (generalised to the dimensions of the array), not a solid square-shaped
|
|
23
|
+
kernel. To get the behaviour of a square-shaped kernel, run a dilation with a square kernel over the array first.
|
|
24
|
+
|
|
25
|
+
Note that this class is stateful, meaning that for each call to either of the afforementioned methods, trying to
|
|
26
|
+
detected an already detected blob will raise a ``RuntimeError``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
grid_coords : IntArray
|
|
31
|
+
Coordinates in the grid that are part of any blob. For an ``d``-dimensional grid, it has shape ``n x d`` where
|
|
32
|
+
``n`` is the number of nodes in the grid belonging to any blob. For a grid representated by a boolean numpy
|
|
33
|
+
array, the coordinates can be gotten with ``np.column_stack(np.where(grid))``.
|
|
34
|
+
|
|
35
|
+
Note that even though ``grid_coords`` commonly would represent axis coordinates in a numpy array,
|
|
36
|
+
negative coordinates are fully supported.
|
|
37
|
+
|
|
38
|
+
wrap_axes : dict[int, int] | None, optional
|
|
39
|
+
A dictionary mapping axis numbers to their corresponding axis lengths
|
|
40
|
+
in the grid. Values will be wrapped on that axis. Negative axis numbers are supported.
|
|
41
|
+
|
|
42
|
+
As an example, if ``wrap_axes = {2: 10}``, the third column in ``grid_coords`` will be wrapped to
|
|
43
|
+
the range (0, 9), and the coordinates 0 and 9 will be considered next to each other. This is
|
|
44
|
+
effectively the same as doing ``grid_coords[:, 2] %= 10``, and pretending the ends touch each other.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, grid_coords: IntArray, wrap_axes: dict[int, int] | None = None):
|
|
48
|
+
"""Initialise the blob detection by building necessary data structures."""
|
|
49
|
+
self._validate_grid_coords(grid_coords)
|
|
50
|
+
|
|
51
|
+
# Ensure correct dtype and make a copy to prevent both inside and outside changes to the array unintentionally causing a ruckus
|
|
52
|
+
self._grid_coords = grid_coords.astype(np.int64).copy()
|
|
53
|
+
# Boolean array of visited nodes
|
|
54
|
+
# After each call to `find_blob`, the found indices are set to True
|
|
55
|
+
self._visited = np.zeros(self._grid_coords.shape[0], dtype=bool)
|
|
56
|
+
# Next row in _grid_coords from which to start a search
|
|
57
|
+
self._next_index = 0
|
|
58
|
+
# For each axes that should be wrapped, the size of the axis in the grid is stored
|
|
59
|
+
# For axes which should not be wrapped, 0 is used
|
|
60
|
+
self._wrap_axis_sizes = np.zeros(self._grid_coords.shape[1], dtype=np.uint64)
|
|
61
|
+
wrap_axes = wrap_axes or dict()
|
|
62
|
+
for axis, axis_size in wrap_axes.items():
|
|
63
|
+
self._wrap_axis_sizes[axis] = axis_size
|
|
64
|
+
|
|
65
|
+
# The pointers array is used to store pointers from objects allocated in the C code and pass them between calls
|
|
66
|
+
# This is very ugly and hacky but it works and is arguably simpler that using the Python C API correctly in this
|
|
67
|
+
# use case - at least I'm lazy enough to assume so
|
|
68
|
+
# This first element is the pointer to the coord-to-index hashmap
|
|
69
|
+
self._pointers = np.empty(1, dtype=np.uint64)
|
|
70
|
+
|
|
71
|
+
self._index_args = c_utils.ArrayArgs(self._grid_coords)
|
|
72
|
+
self._pointer_args = c_utils.ArrayArgs(self._pointers)
|
|
73
|
+
self._wrap_axis_sizes_args = c_utils.ArrayArgs(self._wrap_axis_sizes)
|
|
74
|
+
_c.build_lookup_table(
|
|
75
|
+
self._pointer_args.array_ptr,
|
|
76
|
+
self._wrap_axis_sizes_args.array_ptr,
|
|
77
|
+
self._index_args.array_ptr,
|
|
78
|
+
self._grid_coords.shape[0],
|
|
79
|
+
self._grid_coords.shape[1],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def done(self) -> bool:
|
|
84
|
+
"""True if all coordinates have been explored and there are no more blobs to discover."""
|
|
85
|
+
return self._next_index == len(self._grid_coords)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def next_index(self) -> int:
|
|
89
|
+
"""Return the index of the first unvisited coordinate.
|
|
90
|
+
|
|
91
|
+
This starts at ``0`` and advances as blobs are detected, whether by :meth:`find_single_blob` or :meth:`find_all_blobs`.
|
|
92
|
+
It reaches ``len(grid_coords)`` when all coordinates have been visited, and there are no more blobs to be found.
|
|
93
|
+
This is equivalent to :meth:`done` being ``True``.
|
|
94
|
+
|
|
95
|
+
When a blob is detected, if the current value is part of that blob, it will increase to the index of the first coordinate
|
|
96
|
+
which has not been visited.
|
|
97
|
+
"""
|
|
98
|
+
return self._next_index
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def visited_coords(self) -> BoolArray:
|
|
102
|
+
"""Return 1D boolean array of length ``len(grid_coords)`` indicating which grid coordinates have been visited."""
|
|
103
|
+
visited_coords = self._visited.view()
|
|
104
|
+
visited_coords.flags.writeable = False
|
|
105
|
+
return visited_coords
|
|
106
|
+
|
|
107
|
+
def _validate_grid_coords(self, grid_coords: IntArray):
|
|
108
|
+
"""Raise an exception if given ``grid_coords`` are invalid."""
|
|
109
|
+
if not np.issubdtype(grid_coords.dtype, np.integer):
|
|
110
|
+
raise TypeError(f"`grid_coords` must have an integer dtype, not {grid_coords.dtype}")
|
|
111
|
+
if len(grid_coords.shape) != 2:
|
|
112
|
+
raise ValueError(f"`grid_coords` must have shape n x d but has {len(grid_coords.shape)} axes")
|
|
113
|
+
if grid_coords.shape[1] == 0:
|
|
114
|
+
raise ValueError("Coordinates cannot be 0-dimensional")
|
|
115
|
+
|
|
116
|
+
def _mark_visited(self, indices: IntArray):
|
|
117
|
+
self._visited[indices] = True
|
|
118
|
+
while self._next_index < len(self._visited) and self._visited[self._next_index]:
|
|
119
|
+
self._next_index += 1
|
|
120
|
+
|
|
121
|
+
def find_single_blob(self, init_index: int | None = None) -> IntArray:
|
|
122
|
+
"""Detect one blob starting at ``grid_coords[init_index]``, or ``grid_coords[self.next_index]`` if ``init_index`` is not provided.
|
|
123
|
+
|
|
124
|
+
Return the index of each row in ``grid_coords`` which is part of the blob.
|
|
125
|
+
"""
|
|
126
|
+
if init_index is None:
|
|
127
|
+
init_index = self.next_index
|
|
128
|
+
if init_index >= len(self._grid_coords):
|
|
129
|
+
raise IndexError(f"Cannot start blob search from index {init_index} as there are only {len(self._grid_coords)} coordinates")
|
|
130
|
+
if self._visited[init_index]:
|
|
131
|
+
raise RuntimeError(f"Blob at index {init_index} ({self._grid_coords[init_index]}) has already been visited")
|
|
132
|
+
blob_indices: list[int] = list()
|
|
133
|
+
_c.find_blob(
|
|
134
|
+
self._pointer_args.array_ptr,
|
|
135
|
+
self._wrap_axis_sizes_args.array_ptr,
|
|
136
|
+
self._index_args.array_ptr,
|
|
137
|
+
init_index,
|
|
138
|
+
self._grid_coords.shape[0],
|
|
139
|
+
self._grid_coords.shape[1],
|
|
140
|
+
blob_indices,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
blob_indices_arr = np.array(blob_indices)
|
|
144
|
+
self._mark_visited(blob_indices_arr)
|
|
145
|
+
return blob_indices_arr
|
|
146
|
+
|
|
147
|
+
def find_all_blobs(self) -> list[IntArray]:
|
|
148
|
+
"""Detect all blobs in ``grid_coords``.
|
|
149
|
+
|
|
150
|
+
For each detected blob, an array is returned containing the rows in ``grid_coords`` which are part of that blob.
|
|
151
|
+
"""
|
|
152
|
+
if len(self._grid_coords) == 0:
|
|
153
|
+
return list()
|
|
154
|
+
if self.done:
|
|
155
|
+
raise RuntimeError("Blob detection has already been run")
|
|
156
|
+
|
|
157
|
+
indices = np.array([], dtype=np.int64)
|
|
158
|
+
blobs: list[IntArray] = list()
|
|
159
|
+
while not self.done:
|
|
160
|
+
indices = self.find_single_blob()
|
|
161
|
+
blobs.append(indices)
|
|
162
|
+
self._mark_visited(indices)
|
|
163
|
+
|
|
164
|
+
return blobs
|
|
165
|
+
|
|
166
|
+
def __del__(self):
|
|
167
|
+
"""Free the lookup table when the instance is no longer referenced to prevent memory leak."""
|
|
168
|
+
try:
|
|
169
|
+
_c.free_lookup_table(self._pointer_args.array_ptr)
|
|
170
|
+
except AttributeError:
|
|
171
|
+
# If the error happens early in init (e.g. wrong call to __init__), _pointer_args has not yet been defined,
|
|
172
|
+
# and the hashmap has not been created
|
|
173
|
+
pass
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
import _pelutils_c as _c
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
|
|
9
|
+
import pelutils._c as c_utils
|
|
10
|
+
from pelutils.types import AnyArray
|
|
11
|
+
|
|
12
|
+
_ArrayT = TypeVar("_ArrayT", bound=AnyArray)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def unique(
|
|
16
|
+
array: npt.ArrayLike,
|
|
17
|
+
*,
|
|
18
|
+
return_index: bool = False,
|
|
19
|
+
return_inverse: bool = False,
|
|
20
|
+
return_counts: bool = False,
|
|
21
|
+
axis: int = 0,
|
|
22
|
+
):
|
|
23
|
+
"""Return unique elements in a given numpy array.
|
|
24
|
+
|
|
25
|
+
This function works very similar to ``np.unique``, but it runs in linear time, making it significantly faster
|
|
26
|
+
for large arrays. Unlike with ``np.unique``, the returned unique values are unsorted. Because of this, it can
|
|
27
|
+
also be used for detecting uniqueness along axes when ordering along the respective axes matters.
|
|
28
|
+
"""
|
|
29
|
+
array = np.asarray(array)
|
|
30
|
+
if np.issubdtype(array.dtype, np.object_):
|
|
31
|
+
raise TypeError(f"Unsupported array dtype {array.dtype}")
|
|
32
|
+
|
|
33
|
+
if array.ndim == 0:
|
|
34
|
+
raise ValueError("unique does not work for shapeless arrays")
|
|
35
|
+
|
|
36
|
+
if axis:
|
|
37
|
+
axes = list(range(len(array.shape)))
|
|
38
|
+
axes[0] = axis
|
|
39
|
+
axes[axis] = 0
|
|
40
|
+
array = array.transpose(axes)
|
|
41
|
+
else:
|
|
42
|
+
axes = None
|
|
43
|
+
if not array.flags["C_CONTIGUOUS"]:
|
|
44
|
+
array = np.ascontiguousarray(array)
|
|
45
|
+
|
|
46
|
+
index = np.empty(len(array), dtype=np.int64)
|
|
47
|
+
inverse = np.empty(len(array), dtype=np.int64) if return_inverse else None
|
|
48
|
+
counts = np.empty(len(array), dtype=np.int64) if return_counts else None
|
|
49
|
+
|
|
50
|
+
array_args = c_utils.ArrayArgs(array)
|
|
51
|
+
c = _c.unique(
|
|
52
|
+
array_args.array_ptr,
|
|
53
|
+
array_args.ndim,
|
|
54
|
+
array_args.dims_ptr,
|
|
55
|
+
array_args.strides_ptr,
|
|
56
|
+
index.ctypes.data,
|
|
57
|
+
inverse.ctypes.data if inverse is not None else 0,
|
|
58
|
+
counts.ctypes.data if counts is not None else 0,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
index = index[:c]
|
|
62
|
+
if axis:
|
|
63
|
+
array = array[index]
|
|
64
|
+
array = np.ascontiguousarray(array.transpose(axes))
|
|
65
|
+
else:
|
|
66
|
+
array = array[index]
|
|
67
|
+
ret = [array]
|
|
68
|
+
if return_index:
|
|
69
|
+
ret.append(index)
|
|
70
|
+
if return_inverse:
|
|
71
|
+
assert inverse is not None
|
|
72
|
+
ret.append(inverse)
|
|
73
|
+
if return_counts:
|
|
74
|
+
assert counts is not None
|
|
75
|
+
ret.append(counts[index])
|
|
76
|
+
|
|
77
|
+
return tuple(ret) if len(ret) > 1 else ret[0]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, overload
|
|
4
|
+
|
|
5
|
+
import numpy.typing as npt
|
|
6
|
+
|
|
7
|
+
from pelutils.types import AnyArray, IntArray
|
|
8
|
+
|
|
9
|
+
@overload
|
|
10
|
+
def unique(
|
|
11
|
+
array: npt.ArrayLike,
|
|
12
|
+
*,
|
|
13
|
+
return_index: Literal[False] = False,
|
|
14
|
+
return_inverse: Literal[False] = False,
|
|
15
|
+
return_counts: Literal[False] = False,
|
|
16
|
+
axis: int = 0,
|
|
17
|
+
) -> AnyArray: ...
|
|
18
|
+
@overload
|
|
19
|
+
def unique(
|
|
20
|
+
array: npt.ArrayLike,
|
|
21
|
+
*,
|
|
22
|
+
return_index: Literal[True],
|
|
23
|
+
return_inverse: Literal[False] = False,
|
|
24
|
+
return_counts: Literal[False] = False,
|
|
25
|
+
axis: int = 0,
|
|
26
|
+
) -> tuple[AnyArray, IntArray]: ...
|
|
27
|
+
@overload
|
|
28
|
+
def unique(
|
|
29
|
+
array: npt.ArrayLike,
|
|
30
|
+
*,
|
|
31
|
+
return_index: Literal[False] = False,
|
|
32
|
+
return_inverse: Literal[True],
|
|
33
|
+
return_counts: Literal[False] = False,
|
|
34
|
+
axis: int = 0,
|
|
35
|
+
) -> tuple[AnyArray, IntArray]: ...
|
|
36
|
+
@overload
|
|
37
|
+
def unique(
|
|
38
|
+
array: npt.ArrayLike,
|
|
39
|
+
*,
|
|
40
|
+
return_index: Literal[True],
|
|
41
|
+
return_inverse: Literal[True],
|
|
42
|
+
return_counts: Literal[False] = False,
|
|
43
|
+
axis: int = 0,
|
|
44
|
+
) -> tuple[AnyArray, IntArray, IntArray]: ...
|
|
45
|
+
@overload
|
|
46
|
+
def unique(
|
|
47
|
+
array: npt.ArrayLike,
|
|
48
|
+
*,
|
|
49
|
+
return_index: Literal[False] = False,
|
|
50
|
+
return_inverse: Literal[False] = False,
|
|
51
|
+
return_counts: Literal[True],
|
|
52
|
+
axis: int = 0,
|
|
53
|
+
) -> tuple[AnyArray, IntArray]: ...
|
|
54
|
+
@overload
|
|
55
|
+
def unique(
|
|
56
|
+
array: npt.ArrayLike,
|
|
57
|
+
*,
|
|
58
|
+
return_index: Literal[True],
|
|
59
|
+
return_inverse: Literal[False] = False,
|
|
60
|
+
return_counts: Literal[True],
|
|
61
|
+
axis: int = 0,
|
|
62
|
+
) -> tuple[AnyArray, IntArray, IntArray]: ...
|
|
63
|
+
@overload
|
|
64
|
+
def unique(
|
|
65
|
+
array: npt.ArrayLike,
|
|
66
|
+
*,
|
|
67
|
+
return_index: Literal[False] = False,
|
|
68
|
+
return_inverse: Literal[True],
|
|
69
|
+
return_counts: Literal[True],
|
|
70
|
+
axis: int = 0,
|
|
71
|
+
) -> tuple[AnyArray, IntArray, IntArray]: ...
|
|
72
|
+
@overload
|
|
73
|
+
def unique(
|
|
74
|
+
array: npt.ArrayLike,
|
|
75
|
+
*,
|
|
76
|
+
return_index: Literal[True],
|
|
77
|
+
return_inverse: Literal[True],
|
|
78
|
+
return_counts: Literal[True],
|
|
79
|
+
axis: int = 0,
|
|
80
|
+
) -> tuple[AnyArray, IntArray, IntArray, IntArray]: ...
|
pelutils/array/_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import numpy.typing as npt
|
|
5
|
+
|
|
6
|
+
from pelutils.misc._conditional_import import import_torch
|
|
7
|
+
|
|
8
|
+
torch = import_torch()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def array_ptr(arr: npt.ArrayLike) -> ctypes.c_void_p:
|
|
12
|
+
"""Return a pointer to a numpy array or torch tensor which can be used to interact with it in low-level languages like C/C++/Rust.
|
|
13
|
+
|
|
14
|
+
This function is mostly useful when not using Python's C api and instead interfacing with .so files directly with ctypes.
|
|
15
|
+
"""
|
|
16
|
+
if torch is not None and isinstance(arr, torch.Tensor):
|
|
17
|
+
return ctypes.c_void_p(arr.data_ptr())
|
|
18
|
+
if not isinstance(arr, np.ndarray):
|
|
19
|
+
raise TypeError(f"Array should be of type np.ndarray or torch.Tensor, not {type(arr)}")
|
|
20
|
+
if not arr.flags.c_contiguous:
|
|
21
|
+
raise ValueError("Array must be C-contiguous")
|
|
22
|
+
return ctypes.c_void_p(arr.ctypes.data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def array_bytes(x: npt.ArrayLike) -> int:
|
|
26
|
+
"""Calculate the size of a numpy array or torch tensor in bytes."""
|
|
27
|
+
if torch is not None and isinstance(x, torch.Tensor):
|
|
28
|
+
x = x.numpy()
|
|
29
|
+
if isinstance(x, np.ndarray):
|
|
30
|
+
return x.nbytes
|
|
31
|
+
raise TypeError(f"`x` of type {type(x)} is not a numpy array or torch tensor")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed argument and configuration parsing that treats a run as a *job*.
|
|
2
|
+
|
|
3
|
+
``argparse`` handles command-line flags but knows nothing about config files, so the
|
|
4
|
+
moment you want to save a run's settings, reproduce it later, or launch a sweep of
|
|
5
|
+
several runs, you end up hand-rolling a config file parser, merging it with the CLI args by
|
|
6
|
+
hand, and writing your own record of what was actually run. :class:`JobParser` does all
|
|
7
|
+
of that: you declare your arguments once with a type, and it resolves values from the
|
|
8
|
+
command line *and* an INI config file into one or more :class:`JobDescription` objects —
|
|
9
|
+
CLI values overriding config values, which override declared defaults. A single config
|
|
10
|
+
file can describe many named jobs, and every resolved job can write out an exact,
|
|
11
|
+
human-readable record of how it was invoked.
|
|
12
|
+
|
|
13
|
+
Quick start
|
|
14
|
+
-----------
|
|
15
|
+
|
|
16
|
+
.. code-block:: python
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from pelutils.job_parser import Flag, JobParser, OptionalArg, RequiredArg
|
|
20
|
+
|
|
21
|
+
parser = JobParser(
|
|
22
|
+
RequiredArg("data-path", help="Training data directory"),
|
|
23
|
+
OptionalArg("learning-rate", default=1e-4, type=float, help="Optimizer learning rate"),
|
|
24
|
+
Flag("fp16", help="Use mixed precision"),
|
|
25
|
+
multiple_jobs=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
for job in parser.parse_jobs():
|
|
29
|
+
# Names are --kebab-case on the CLI and snake_case attributes on the job
|
|
30
|
+
print(job.name, job.data_path, job.learning_rate, job.fp16)
|
|
31
|
+
job.write_documentation(Path("runs") / job.name / "arguments.ini")
|
|
32
|
+
... # Run your application with the resolved job values
|
|
33
|
+
|
|
34
|
+
Declare :class:`RequiredArg` for values every job must supply, :class:`OptionalArg` for
|
|
35
|
+
values with a default, and :class:`Flag` for booleans. Point the parser at a config file
|
|
36
|
+
with ``--config-file config.ini``; a file may contain a shared ``[DEFAULT]`` section plus
|
|
37
|
+
named sections that each become a job. Target a single section directly with
|
|
38
|
+
``--config-file config.ini:section-name``. A config file for the parser above might look
|
|
39
|
+
like:
|
|
40
|
+
|
|
41
|
+
.. code-block:: ini
|
|
42
|
+
|
|
43
|
+
[DEFAULT]
|
|
44
|
+
data-path = /data/train # Shared by every job below
|
|
45
|
+
|
|
46
|
+
[baseline]
|
|
47
|
+
# Inherits the default learning rate; fp16 as a bare key means True
|
|
48
|
+
fp16
|
|
49
|
+
|
|
50
|
+
[high-lr]
|
|
51
|
+
learning-rate = 5e-4
|
|
52
|
+
|
|
53
|
+
For the common single-job case, drop ``multiple_jobs=True`` and call :meth:`JobParser.parse_job`
|
|
54
|
+
to get one :class:`JobDescription` back. See :class:`JobParser` for the full API and
|
|
55
|
+
:meth:`JobDescription.write_documentation` for auto-documenting a run.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from ._job_parser import JobParser
|
|
59
|
+
from ._structs import ArgumentTypes, ConfigError, Flag, JobDescription, JobParserError, OptionalArg, RequiredArg
|
|
60
|
+
|
|
61
|
+
__all__ = ("ArgumentTypes", "ConfigError", "Flag", "JobDescription", "JobParser", "JobParserError", "OptionalArg", "RequiredArg")
|