asterion-solver 0.4.0__py3-none-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.
- asterion/__init__.py +41 -0
- asterion/_catalogs.py +183 -0
- asterion/_native.py +259 -0
- asterion/_solver.py +512 -0
- asterion/libasterion.dll +0 -0
- asterion/py.typed +0 -0
- asterion_solver-0.4.0.dist-info/METADATA +176 -0
- asterion_solver-0.4.0.dist-info/RECORD +13 -0
- asterion_solver-0.4.0.dist-info/WHEEL +4 -0
- asterion_solver-0.4.0.dist-info/licenses/LICENSE.txt +202 -0
- asterion_solver-0.4.0.dist-info/licenses/third-party/ijg-libjpeg.md +87 -0
- asterion_solver-0.4.0.dist-info/licenses/third-party/openngc.md +21 -0
- asterion_solver-0.4.0.dist-info/licenses/third-party/vizier-nebulae.md +21 -0
asterion/_solver.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# Copyright 2026 the original author or authors.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
"""Solving images: the solver, which keeps the catalogs open, and the results of its solves."""
|
|
15
|
+
import ctypes as c
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import weakref
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from functools import cached_property
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from . import _native
|
|
26
|
+
from ._catalogs import PathLike
|
|
27
|
+
from ._native import AsterionError
|
|
28
|
+
|
|
29
|
+
# The sample types by their names, then by their kinds, as __array_interface__ names them: code, size
|
|
30
|
+
_SAMPLE_TYPES = {"uint8": (_native.UINT8, 1), "uint16": (_native.UINT16, 2), "float32": (_native.FLOAT32, 4)}
|
|
31
|
+
_KINDS = {"u1": "uint8", "u2": "uint16", "f4": "float32"}
|
|
32
|
+
# the formats of the buffer protocol, and their byte orders
|
|
33
|
+
_FORMATS = {"B": "u1", "H": "u2", "f": "f4"}
|
|
34
|
+
_NATIVE = "<" if sys.byteorder == "little" else ">"
|
|
35
|
+
_ORDERS = {"@": _NATIVE, "=": _NATIVE, "|": _NATIVE, "<": "<", ">": ">", "!": ">"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class DeepSkyObject:
|
|
40
|
+
"""
|
|
41
|
+
A deep sky object of an image: a galaxy, a nebula, a cluster...
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
name: the name it's best known by: "M 31", "NGC 7000"
|
|
45
|
+
designation: its designation in its catalog: "NGC 224"
|
|
46
|
+
type: its kind, in words: "galaxy", "HII region"...
|
|
47
|
+
common_name: its common names, separated by commas, or ""
|
|
48
|
+
aliases: its designations in other catalogs, separated by commas, or ""
|
|
49
|
+
messier: its number in the Messier catalog, or None
|
|
50
|
+
ra: the right ascension of its center, J2000, in degrees
|
|
51
|
+
dec: the declination of its center, in degrees
|
|
52
|
+
x: its position in the image, in pixels, which may be outside of it
|
|
53
|
+
y: its position in the image, in pixels
|
|
54
|
+
distance: the distance of its center to the one of the image, in degrees
|
|
55
|
+
major_arcmin: its major axis, in arcminutes, or None
|
|
56
|
+
minor_arcmin: its minor axis, in arcminutes, or None
|
|
57
|
+
angle: the position angle of its major axis, in degrees east of north, or None
|
|
58
|
+
magnitude: its visual magnitude, or None
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
designation: str
|
|
63
|
+
type: str
|
|
64
|
+
common_name: str
|
|
65
|
+
aliases: str
|
|
66
|
+
messier: Optional[int]
|
|
67
|
+
ra: float
|
|
68
|
+
dec: float
|
|
69
|
+
x: float
|
|
70
|
+
y: float
|
|
71
|
+
distance: float
|
|
72
|
+
major_arcmin: Optional[float]
|
|
73
|
+
minor_arcmin: Optional[float]
|
|
74
|
+
angle: Optional[float]
|
|
75
|
+
magnitude: Optional[float]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Match:
|
|
80
|
+
"""
|
|
81
|
+
A star of an image which was matched with the catalog.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
x: its position in the image, in pixels
|
|
85
|
+
y: its position in the image, in pixels
|
|
86
|
+
ra: the right ascension of the catalog star at the date of the image, in degrees
|
|
87
|
+
dec: the declination of the catalog star at the date of the image, in degrees
|
|
88
|
+
residual: the distance between the two according to the solution, in arcseconds
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
x: float
|
|
92
|
+
y: float
|
|
93
|
+
ra: float
|
|
94
|
+
dec: float
|
|
95
|
+
residual: float
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Result:
|
|
99
|
+
"""
|
|
100
|
+
The result of a solve. Without a solution, solved is False and the attributes of the solution are None: the
|
|
101
|
+
stars which were detected, their width and the timings are known still.
|
|
102
|
+
|
|
103
|
+
Pixel positions are counted from 0: the center of the first pixel of the image is (0, 0), whatever the binning
|
|
104
|
+
which Asterion applied to it. The WCS follows the conventions of FITS instead, where it's (1, 1).
|
|
105
|
+
|
|
106
|
+
Results come from Solver.solve, and stay valid once the solver is closed.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
solved: whether the image was solved
|
|
110
|
+
ra: the right ascension of the center of the image, J2000, in degrees
|
|
111
|
+
dec: the declination of the center of the image, J2000, in degrees
|
|
112
|
+
scale: the scale, in arcseconds per pixel
|
|
113
|
+
rotation: the position angle of the Y axis of the image, in degrees east of north
|
|
114
|
+
field_width: the width of the field of view, in degrees
|
|
115
|
+
field_height: the height of the field of view, in degrees
|
|
116
|
+
flipped: whether the image is mirrored
|
|
117
|
+
rms: the residuals of the matched stars, in arcseconds
|
|
118
|
+
log_odds: the confidence in the solution
|
|
119
|
+
matched_stars: the number of stars matched with the catalog
|
|
120
|
+
catalog: the name of the catalog which solved the image, such as "gaia-500"
|
|
121
|
+
focal_length: the focal length, in millimeters, when pixel_size was given
|
|
122
|
+
pixel_size: the size of the pixels used for the focal length, in micrometers
|
|
123
|
+
fwhm_arcsec: the median width of the stars, in arcseconds
|
|
124
|
+
stars: the number of stars detected in the image
|
|
125
|
+
fwhm_pixels: the median width of the stars, in pixels, or None without stars
|
|
126
|
+
read_ms: how long copying the pixels took, in milliseconds
|
|
127
|
+
detect_ms: how long detecting the stars took, in milliseconds
|
|
128
|
+
solve_ms: how long solving took, in milliseconds
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
solved: bool
|
|
132
|
+
ra: Optional[float]
|
|
133
|
+
dec: Optional[float]
|
|
134
|
+
scale: Optional[float]
|
|
135
|
+
rotation: Optional[float]
|
|
136
|
+
field_width: Optional[float]
|
|
137
|
+
field_height: Optional[float]
|
|
138
|
+
flipped: Optional[bool]
|
|
139
|
+
rms: Optional[float]
|
|
140
|
+
log_odds: Optional[float]
|
|
141
|
+
matched_stars: Optional[int]
|
|
142
|
+
catalog: Optional[str]
|
|
143
|
+
focal_length: Optional[float]
|
|
144
|
+
pixel_size: Optional[float]
|
|
145
|
+
fwhm_arcsec: Optional[float]
|
|
146
|
+
stars: int
|
|
147
|
+
fwhm_pixels: Optional[float]
|
|
148
|
+
read_ms: float
|
|
149
|
+
detect_ms: float
|
|
150
|
+
solve_ms: float
|
|
151
|
+
|
|
152
|
+
def __init__(self, solved, solution, handle):
|
|
153
|
+
"""Results come from Solver.solve."""
|
|
154
|
+
self.solved = solved
|
|
155
|
+
|
|
156
|
+
def known(value):
|
|
157
|
+
return _native.optional(value) if solved else None
|
|
158
|
+
|
|
159
|
+
self.ra, self.dec = known(solution.ra), known(solution.dec)
|
|
160
|
+
self.scale, self.rotation = known(solution.scale), known(solution.rotation)
|
|
161
|
+
self.field_width, self.field_height = known(solution.field_width), known(solution.field_height)
|
|
162
|
+
self.flipped = bool(solution.flipped) if solved else None
|
|
163
|
+
self.rms, self.log_odds = known(solution.rms), known(solution.log_odds)
|
|
164
|
+
self.matched_stars = solution.matched_stars if solved else None
|
|
165
|
+
self.catalog = _native.text(solution.index) if solved else None
|
|
166
|
+
self.focal_length, self.pixel_size = known(solution.focal_length), known(solution.pixel_size)
|
|
167
|
+
self.fwhm_arcsec = known(solution.fwhm_arcsec)
|
|
168
|
+
self.stars = solution.stars
|
|
169
|
+
self.fwhm_pixels = _native.optional(solution.fwhm_pixels)
|
|
170
|
+
self.read_ms, self.detect_ms, self.solve_ms = solution.read_ms, solution.detect_ms, solution.solve_ms
|
|
171
|
+
self._handle = handle
|
|
172
|
+
# freed when the result is garbage collected
|
|
173
|
+
weakref.finalize(self, _native.release, "asterion_result_free", handle)
|
|
174
|
+
|
|
175
|
+
def __repr__(self):
|
|
176
|
+
if not self.solved:
|
|
177
|
+
return f"Result(solved=False, stars={self.stars})"
|
|
178
|
+
return (f"Result(solved=True, ra={self.ra:.6f}, dec={self.dec:.6f}, scale={self.scale:.4f}, "
|
|
179
|
+
f"rotation={self.rotation:.2f}, catalog={self.catalog!r})")
|
|
180
|
+
|
|
181
|
+
@cached_property
|
|
182
|
+
def json(self) -> str:
|
|
183
|
+
"""The result as JSON, the output of asterion --json."""
|
|
184
|
+
return _native.string("asterion_result_json", self._handle)
|
|
185
|
+
|
|
186
|
+
@cached_property
|
|
187
|
+
def wcs(self) -> Optional[dict[str, Any]]:
|
|
188
|
+
"""The WCS of the solution, as the keywords of a FITS header: CTYPE1, CRPIX1, CD1_1, A_ORDER, A_2_0...
|
|
189
|
+
astropy.wcs.WCS(result.wcs) makes the WCS of astropy. None without a solution."""
|
|
190
|
+
return json.loads(self.json).get("wcs") if self.solved else None
|
|
191
|
+
|
|
192
|
+
@cached_property
|
|
193
|
+
def objects(self) -> list[DeepSkyObject]:
|
|
194
|
+
"""The deep sky objects of the image, the most prominent first."""
|
|
195
|
+
found = _native.Object()
|
|
196
|
+
objects = []
|
|
197
|
+
for i in range(self._count("asterion_result_object_count")):
|
|
198
|
+
_native.check(_native.call("asterion_result_object", self._handle, i, c.byref(found)))
|
|
199
|
+
objects.append(DeepSkyObject(
|
|
200
|
+
_native.text(found.name), _native.text(found.designation), _native.text(found.type),
|
|
201
|
+
_native.text(found.common_name), _native.text(found.aliases), found.messier or None,
|
|
202
|
+
found.ra, found.dec, found.x, found.y, found.distance, _native.optional(found.major_arcmin),
|
|
203
|
+
_native.optional(found.minor_arcmin), _native.optional(found.angle), _native.optional(found.magnitude)))
|
|
204
|
+
return objects
|
|
205
|
+
|
|
206
|
+
@cached_property
|
|
207
|
+
def matches(self) -> list[Match]:
|
|
208
|
+
"""The stars of the image which were matched with the catalog."""
|
|
209
|
+
found = _native.Match()
|
|
210
|
+
matches = []
|
|
211
|
+
for i in range(self._count("asterion_result_match_count")):
|
|
212
|
+
_native.check(_native.call("asterion_result_match", self._handle, i, c.byref(found)))
|
|
213
|
+
matches.append(Match(found.x, found.y, found.ra, found.dec, found.residual))
|
|
214
|
+
return matches
|
|
215
|
+
|
|
216
|
+
def pixel_to_sky(self, x: float, y: float) -> tuple[float, float]:
|
|
217
|
+
"""The position on the sky of a pixel, in degrees, distortion included.
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
AsterionError: if the image wasn't solved
|
|
221
|
+
"""
|
|
222
|
+
return self._convert("asterion_result_pixel_to_sky", x, y)
|
|
223
|
+
|
|
224
|
+
def sky_to_pixel(self, ra: float, dec: float) -> tuple[float, float]:
|
|
225
|
+
"""The pixel of a position on the sky, in degrees, distortion included.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
AsterionError: if the image wasn't solved, or the position is too far from the image to be projected
|
|
229
|
+
"""
|
|
230
|
+
return self._convert("asterion_result_sky_to_pixel", ra, dec)
|
|
231
|
+
|
|
232
|
+
def _count(self, function):
|
|
233
|
+
return _native.check(_native.call(function, self._handle)) if self.solved else 0
|
|
234
|
+
|
|
235
|
+
def _convert(self, function, a, b):
|
|
236
|
+
first, second = c.c_double(), c.c_double()
|
|
237
|
+
_native.check(_native.call(function, self._handle, a, b, c.byref(first), c.byref(second)))
|
|
238
|
+
return first.value, second.value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Solver:
|
|
242
|
+
"""
|
|
243
|
+
A solver, with the catalogs which are installed in a directory: they stay open between solves, which keeps
|
|
244
|
+
them fast, until the solver is closed. A solver can be used by several threads at once.
|
|
245
|
+
|
|
246
|
+
with asterion.Solver() as solver:
|
|
247
|
+
result = solver.solve(frame)
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, catalog_directory: Optional[PathLike] = None):
|
|
251
|
+
"""
|
|
252
|
+
Opens the catalogs of a directory.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
catalog_directory: the directory of the catalogs, by default the one of the command line tool
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
AsterionError: if no catalog is installed
|
|
259
|
+
"""
|
|
260
|
+
handle = c.c_void_p()
|
|
261
|
+
_native.check(_native.call("asterion_open", _native.path(catalog_directory), c.byref(handle)))
|
|
262
|
+
self._handle = handle
|
|
263
|
+
self._lock = threading.Lock()
|
|
264
|
+
self._solving = 0
|
|
265
|
+
# closed when the solver is garbage collected, if it wasn't
|
|
266
|
+
self._finalizer = weakref.finalize(self, _native.release, "asterion_close", handle)
|
|
267
|
+
|
|
268
|
+
def __enter__(self):
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, *exception):
|
|
272
|
+
self.close()
|
|
273
|
+
|
|
274
|
+
def close(self) -> None:
|
|
275
|
+
"""Closes the solver, if it isn't: its catalogs are unmapped at once, which releases their memory. The results
|
|
276
|
+
stay valid.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
AsterionError: if a solve runs meanwhile
|
|
280
|
+
"""
|
|
281
|
+
with self._lock:
|
|
282
|
+
if self._solving:
|
|
283
|
+
raise AsterionError("The solver can't be closed while it solves")
|
|
284
|
+
if self._handle is not None:
|
|
285
|
+
_native.check(_native.call("asterion_close", self._handle))
|
|
286
|
+
self._handle = None
|
|
287
|
+
self._finalizer.detach()
|
|
288
|
+
|
|
289
|
+
def solve(self, pixels: Any, width: Optional[int] = None, height: Optional[int] = None, *,
|
|
290
|
+
sample_type: Optional[str] = None, row_stride: Optional[int] = None, bayer: bool = False,
|
|
291
|
+
ra: Optional[float] = None, dec: Optional[float] = None, radius: Optional[float] = None,
|
|
292
|
+
fov: Optional[float] = None, scale_low: Optional[float] = None, scale_high: Optional[float] = None,
|
|
293
|
+
pixel_size: Optional[float] = None, epoch: Optional[float] = None, parity: str = "both",
|
|
294
|
+
timeout: Optional[float] = None, sip_order: Optional[int] = None, binning: Optional[int] = None,
|
|
295
|
+
sigma: Optional[float] = None, max_stars: Optional[int] = None, quad_stars: Optional[int] = None) -> Result:
|
|
296
|
+
"""
|
|
297
|
+
Solves an image: its pixels are copied, converted and binned in a single pass, then searched for in the
|
|
298
|
+
catalogs, so that they can be changed as soon as solve returns.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
pixels: the gray levels of the image, one per pixel: unsigned 8 or 16-bit integers, or 32-bit floats, in
|
|
302
|
+
the native byte order. An array of the shape (height, width), which describes itself with
|
|
303
|
+
__array_interface__, such as a NumPy array, whose rows may be apart; a contiguous two-dimensional
|
|
304
|
+
buffer, such as a memoryview; or a one-dimensional buffer, such as bytes or array.array, with the
|
|
305
|
+
width and the height. The pixels of a row must be contiguous.
|
|
306
|
+
width: the width of the image, for one-dimensional buffers
|
|
307
|
+
height: the height of the image, for one-dimensional buffers
|
|
308
|
+
sample_type: "uint8", "uint16" or "float32", for buffers of bytes
|
|
309
|
+
row_stride: the distance between the starts of two rows, in bytes, for one-dimensional buffers whose rows
|
|
310
|
+
are apart
|
|
311
|
+
bayer: whether the image is a raw frame of a color sensor, which is binned 2x2 at least to remove its
|
|
312
|
+
pattern, unless the binning is given
|
|
313
|
+
ra: the right ascension of the center of the region to search, in degrees, with dec and radius: the whole
|
|
314
|
+
sky by default
|
|
315
|
+
dec: the declination of the center of the region to search, in degrees
|
|
316
|
+
radius: the radius of the region to search, in degrees
|
|
317
|
+
fov: the approximate width of the field of view, in degrees: 30% is tolerated
|
|
318
|
+
scale_low: the smallest scale of the image, in arcseconds per pixel
|
|
319
|
+
scale_high: the largest scale of the image, in arcseconds per pixel
|
|
320
|
+
pixel_size: the size of the pixels, in micrometers, binning included, which gives the focal length
|
|
321
|
+
epoch: the date of the image, as a Julian year, for the proper motions of the stars
|
|
322
|
+
parity: "both", "normal" or "flipped", for mirrored images
|
|
323
|
+
timeout: how long to search before giving up, in seconds
|
|
324
|
+
sip_order: the order of the SIP polynomials of the distortion: 0 to disable them, 2 to 5, or -1 to choose
|
|
325
|
+
binning: the binning applied before detecting stars, or 0 to choose
|
|
326
|
+
sigma: the threshold of the detection of stars, in units of background noise
|
|
327
|
+
max_stars: the largest number of stars to detect
|
|
328
|
+
quad_stars: the number of stars, the brightest first, used to search for a match
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
the result, solved or not
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
ValueError: if the pixels can't be read: their type, their byte order, their layout, their size
|
|
335
|
+
AsterionError: if the solver is closed, or the options are out of range
|
|
336
|
+
"""
|
|
337
|
+
options = _options(parity, ra=ra, dec=dec, radius=radius, fov=fov, scale_low=scale_low, scale_high=scale_high,
|
|
338
|
+
pixel_size=pixel_size, epoch=epoch, timeout=timeout, sip_order=sip_order, binning=binning,
|
|
339
|
+
sigma=sigma, max_stars=max_stars, quad_stars=quad_stars)
|
|
340
|
+
image = _Image.of(pixels, width, height, sample_type, row_stride)
|
|
341
|
+
solution = _native.Solution()
|
|
342
|
+
result = c.c_void_p()
|
|
343
|
+
with self._solve() as handle, image.address() as address:
|
|
344
|
+
status = _native.call("asterion_solve", handle, address, image.code, image.width, image.height,
|
|
345
|
+
image.row_stride, int(bayer), c.byref(options), c.byref(solution), c.byref(result))
|
|
346
|
+
_native.check(status)
|
|
347
|
+
return Result(status == _native.SOLVED, solution, result)
|
|
348
|
+
|
|
349
|
+
@contextmanager
|
|
350
|
+
def _solve(self):
|
|
351
|
+
"""The handle of the solver, which can't be closed meanwhile."""
|
|
352
|
+
with self._lock:
|
|
353
|
+
if self._handle is None:
|
|
354
|
+
raise AsterionError("The solver is closed")
|
|
355
|
+
self._solving += 1
|
|
356
|
+
try:
|
|
357
|
+
yield self._handle
|
|
358
|
+
finally:
|
|
359
|
+
with self._lock:
|
|
360
|
+
self._solving -= 1
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _options(parity, **given):
|
|
364
|
+
"""The options of a solve: the defaults of the library, and the ones which are given."""
|
|
365
|
+
if parity not in _native.PARITIES:
|
|
366
|
+
raise ValueError(f"Unknown parity {parity!r}: expected one of {', '.join(_native.PARITIES)}")
|
|
367
|
+
options = _native.Options()
|
|
368
|
+
_native.check(_native.call("asterion_default_options", c.byref(options)))
|
|
369
|
+
options.parity = _native.PARITIES[parity]
|
|
370
|
+
for name, value in given.items():
|
|
371
|
+
if value is not None:
|
|
372
|
+
setattr(options, name, value)
|
|
373
|
+
return options
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class _Image:
|
|
378
|
+
"""The pixels of an image, as the library reads them: their memory, the type and the size of their samples, and
|
|
379
|
+
the layout of their rows. The memory is an address, or an object which exports it with the buffer protocol."""
|
|
380
|
+
|
|
381
|
+
memory: Any
|
|
382
|
+
offset: int
|
|
383
|
+
code: int
|
|
384
|
+
width: int
|
|
385
|
+
height: int
|
|
386
|
+
row_stride: int
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def of(pixels, width, height, sample_type, row_stride):
|
|
390
|
+
interface = getattr(pixels, "__array_interface__", None)
|
|
391
|
+
if interface is not None:
|
|
392
|
+
if width is not None or height is not None or row_stride is not None:
|
|
393
|
+
raise ValueError("The width, the height and the row stride of an array are its own")
|
|
394
|
+
return _Image._array(pixels, interface, sample_type)
|
|
395
|
+
try:
|
|
396
|
+
view = memoryview(pixels)
|
|
397
|
+
except TypeError:
|
|
398
|
+
raise ValueError(f"Expected an array or a buffer of pixels, not {type(pixels).__name__}") from None
|
|
399
|
+
if view.ndim == 2:
|
|
400
|
+
if width is not None or height is not None or row_stride is not None:
|
|
401
|
+
raise ValueError("The width, the height and the row stride of a two-dimensional buffer are its own")
|
|
402
|
+
if not view.c_contiguous:
|
|
403
|
+
raise ValueError("A two-dimensional buffer must be contiguous: pass the array, or a one-dimensional "
|
|
404
|
+
"buffer with its row stride")
|
|
405
|
+
name = _sample_type(*_format(view.format), sample_type)
|
|
406
|
+
height, width = view.shape
|
|
407
|
+
return _Image._of(view, 0, name, width, height, view.strides[0])
|
|
408
|
+
if view.ndim != 1:
|
|
409
|
+
raise ValueError(f"Expected pixels in one or two dimensions, not {view.ndim}")
|
|
410
|
+
if width is None or height is None:
|
|
411
|
+
raise ValueError("The width and the height are required with a one-dimensional buffer")
|
|
412
|
+
if not view.c_contiguous:
|
|
413
|
+
raise ValueError("A one-dimensional buffer must be contiguous")
|
|
414
|
+
# a buffer of bytes needs the type of its samples; a typed one, such as array.array, has its own
|
|
415
|
+
kind = _format(view.format) if view.format not in ("B", "c") else None
|
|
416
|
+
name = _sample_type(*kind, sample_type) if kind else sample_type
|
|
417
|
+
if name is None:
|
|
418
|
+
raise ValueError("The sample type of a buffer of bytes is required: uint8, uint16 or float32")
|
|
419
|
+
return _Image._of(view, 0, name, width, height, row_stride)
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _array(pixels, interface, sample_type):
|
|
423
|
+
"""An array which describes itself with __array_interface__: its data is an address, or an object which
|
|
424
|
+
exports its memory, or the array itself when it's None."""
|
|
425
|
+
shape = interface["shape"]
|
|
426
|
+
if len(shape) != 2:
|
|
427
|
+
raise ValueError(f"Expected an array of the shape (height, width), not {tuple(shape)}")
|
|
428
|
+
height, width = shape
|
|
429
|
+
typestr = interface["typestr"]
|
|
430
|
+
name = _sample_type(typestr[0], typestr[1:], sample_type)
|
|
431
|
+
strides = interface.get("strides")
|
|
432
|
+
row_stride = None
|
|
433
|
+
if strides is not None:
|
|
434
|
+
if strides[1] != _SAMPLE_TYPES[name][1]:
|
|
435
|
+
raise ValueError("The pixels of a row must be contiguous")
|
|
436
|
+
row_stride = strides[0]
|
|
437
|
+
data = interface.get("data")
|
|
438
|
+
if isinstance(data, tuple):
|
|
439
|
+
return _Image._of(data[0], 0, name, width, height, row_stride)
|
|
440
|
+
return _Image._of(memoryview(pixels if data is None else data), interface.get("offset", 0), name, width, height,
|
|
441
|
+
row_stride)
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def _of(memory, offset, name, width, height, row_stride):
|
|
445
|
+
code, size = _SAMPLE_TYPES[name]
|
|
446
|
+
if row_stride is None:
|
|
447
|
+
row_stride = width * size
|
|
448
|
+
if row_stride < width * size:
|
|
449
|
+
raise ValueError(f"The rows of {width} {name} samples can't be {row_stride} bytes apart: arrays whose rows "
|
|
450
|
+
f"are reversed or overlap can be copied with numpy.ascontiguousarray")
|
|
451
|
+
if isinstance(memory, memoryview):
|
|
452
|
+
needed = offset + (height - 1) * row_stride + width * size if height > 0 else 0
|
|
453
|
+
if memory.nbytes < needed:
|
|
454
|
+
raise ValueError(f"The pixels are {memory.nbytes} bytes, {width}x{height} {name} samples need {needed}")
|
|
455
|
+
return _Image(memory, offset, code, width, height, row_stride)
|
|
456
|
+
|
|
457
|
+
@contextmanager
|
|
458
|
+
def address(self):
|
|
459
|
+
"""The address of the pixels, which stay where they are meanwhile."""
|
|
460
|
+
if not isinstance(self.memory, memoryview):
|
|
461
|
+
yield self.memory
|
|
462
|
+
return
|
|
463
|
+
with _pinned(self.memory.cast("B") if self.memory.c_contiguous else self.memory) as address:
|
|
464
|
+
yield address + self.offset
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _format(code):
|
|
468
|
+
"""The byte order and the kind of the samples of a format of the buffer protocol: "<", "u2" for "<H"."""
|
|
469
|
+
order, code = (code[0], code[1:]) if code[:1] in "@=<>!" else ("@", code)
|
|
470
|
+
if code not in _FORMATS:
|
|
471
|
+
raise ValueError(f"Unsupported samples {code!r}: expected unsigned 8 or 16-bit integers, or 32-bit floats")
|
|
472
|
+
return order, _FORMATS[code]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _sample_type(order, kind, given):
|
|
476
|
+
"""The sample type of pixels which tell their byte order and kind, which must be the given one, if any."""
|
|
477
|
+
if kind not in _KINDS:
|
|
478
|
+
raise ValueError(f"Unsupported samples {kind!r}: expected unsigned 8 or 16-bit integers, or 32-bit floats")
|
|
479
|
+
if _ORDERS.get(order) != _NATIVE and kind != "u1":
|
|
480
|
+
raise ValueError("The samples must be in the native byte order: with NumPy, astype(dtype.newbyteorder('='))")
|
|
481
|
+
name = _KINDS[kind]
|
|
482
|
+
if given is not None and given != name:
|
|
483
|
+
raise ValueError(f"The samples are {name}, not {given}")
|
|
484
|
+
return name
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class _Buffer(c.Structure):
|
|
488
|
+
"""Py_buffer, which the buffer protocol fills: the memory of an object, which stays where it is until released."""
|
|
489
|
+
|
|
490
|
+
_fields_ = [("buf", c.c_void_p), ("obj", c.py_object), ("len", c.c_ssize_t), ("itemsize", c.c_ssize_t),
|
|
491
|
+
("readonly", c.c_int), ("ndim", c.c_int), ("format", c.c_char_p), ("shape", c.c_void_p),
|
|
492
|
+
("strides", c.c_void_p), ("suboffsets", c.c_void_p), ("internal", c.c_void_p)]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
_GET_BUFFER = c.pythonapi.PyObject_GetBuffer
|
|
496
|
+
_GET_BUFFER.argtypes = [c.py_object, c.POINTER(_Buffer), c.c_int]
|
|
497
|
+
_GET_BUFFER.restype = c.c_int
|
|
498
|
+
_RELEASE_BUFFER = c.pythonapi.PyBuffer_Release
|
|
499
|
+
_RELEASE_BUFFER.argtypes = [c.POINTER(_Buffer)]
|
|
500
|
+
_RELEASE_BUFFER.restype = None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@contextmanager
|
|
504
|
+
def _pinned(view):
|
|
505
|
+
"""The address of the memory of a contiguous buffer, read-only ones included, which ctypes doesn't give."""
|
|
506
|
+
buffer = _Buffer()
|
|
507
|
+
# PyBUF_SIMPLE: the memory as contiguous bytes. A failure raises the error of Python
|
|
508
|
+
_GET_BUFFER(view, c.byref(buffer), 0)
|
|
509
|
+
try:
|
|
510
|
+
yield buffer.buf
|
|
511
|
+
finally:
|
|
512
|
+
_RELEASE_BUFFER(c.byref(buffer))
|
asterion/libasterion.dll
ADDED
|
Binary file
|
asterion/py.typed
ADDED
|
File without changes
|