lica-raw 0.1.1__py3-none-any.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.
- lica_raw/__init__.py +46 -0
- lica_raw/adapters/__init__.py +3 -0
- lica_raw/adapters/exif.py +222 -0
- lica_raw/adapters/factory.py +33 -0
- lica_raw/adapters/fits.py +252 -0
- lica_raw/errors.py +49 -0
- lica_raw/models.py +37 -0
- lica_raw/ports.py +56 -0
- lica_raw/rect.py +109 -0
- lica_raw/types.py +48 -0
- lica_raw/utils.py +45 -0
- lica_raw-0.1.1.dist-info/METADATA +368 -0
- lica_raw-0.1.1.dist-info/RECORD +15 -0
- lica_raw-0.1.1.dist-info/WHEEL +4 -0
- lica_raw-0.1.1.dist-info/licenses/LICENSE +21 -0
lica_raw/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("lica-raw")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.0+dev"
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
MissingImageError,
|
|
18
|
+
MissingMandatoryFitsError,
|
|
19
|
+
OutOfBoundsError,
|
|
20
|
+
ReadMetadataError,
|
|
21
|
+
UnknownDateTimeFormatError,
|
|
22
|
+
UnsupportedCFAError,
|
|
23
|
+
)
|
|
24
|
+
from .models import ImageMetadata
|
|
25
|
+
from .ports import ImageReader, ImageReaderFactory
|
|
26
|
+
from .types import BayerIndex, BayerPattern, HeaderType, ImageType, IntArray2D
|
|
27
|
+
from .rect import Rect, Point
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Rect",
|
|
31
|
+
"Point",
|
|
32
|
+
"ImageReader",
|
|
33
|
+
"ImageMetadata",
|
|
34
|
+
"ImageReaderFactory",
|
|
35
|
+
"BayerPattern",
|
|
36
|
+
"BayerIndex",
|
|
37
|
+
"HeaderType",
|
|
38
|
+
"ImageType",
|
|
39
|
+
"IntArray2D",
|
|
40
|
+
"UnsupportedCFAError",
|
|
41
|
+
"ReadMetadataError",
|
|
42
|
+
"UnknownDateTimeFormatError",
|
|
43
|
+
"MissingImageError",
|
|
44
|
+
"MissingMandatoryFitsError",
|
|
45
|
+
"OutOfBoundsError",
|
|
46
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
|
|
16
|
+
# ---------------------
|
|
17
|
+
# Third party libraries
|
|
18
|
+
# ---------------------
|
|
19
|
+
import exifread
|
|
20
|
+
import numpy as np
|
|
21
|
+
import rawpy
|
|
22
|
+
from rawpy._rawpy import RawPy
|
|
23
|
+
|
|
24
|
+
# ----------------
|
|
25
|
+
# Own dependencies
|
|
26
|
+
# ----------------
|
|
27
|
+
from ..rect import Point, Rect
|
|
28
|
+
from ..types import IntArray2D, BayerIndex, BayerPattern, HeaderType
|
|
29
|
+
|
|
30
|
+
from ..errors import (
|
|
31
|
+
MissingImageError,
|
|
32
|
+
OutOfBoundsError,
|
|
33
|
+
ReadMetadataError,
|
|
34
|
+
UnknownDateTimeFormatError,
|
|
35
|
+
UnsupportedCFAError,
|
|
36
|
+
AlreadyClosedError,
|
|
37
|
+
)
|
|
38
|
+
from ..models import ImageMetadata
|
|
39
|
+
from ..ports import ImageReader, RawPixels
|
|
40
|
+
from ..utils import image_type_by_path
|
|
41
|
+
|
|
42
|
+
# get the root logger
|
|
43
|
+
log = logging.getLogger(__name__.split(".")[-1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_positive_float(value: Any | None) -> float | None:
|
|
47
|
+
if value in (0, "0", None):
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
result = round(float(Fraction(str(value))), 2)
|
|
51
|
+
except (ValueError, ZeroDivisionError, TypeError):
|
|
52
|
+
return None
|
|
53
|
+
return result if result > 0 else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def handle_date_obs(exif: Mapping[str, Any], path: str) -> datetime | None:
|
|
57
|
+
date_obs = None
|
|
58
|
+
for key in ("Image DateTime", "EXIF DateTimeOriginal"):
|
|
59
|
+
date_obs = exif.get(key)
|
|
60
|
+
if date_obs:
|
|
61
|
+
date_obs = str(date_obs)
|
|
62
|
+
break
|
|
63
|
+
if date_obs is None:
|
|
64
|
+
return None
|
|
65
|
+
new_date_obs = None
|
|
66
|
+
for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
|
|
67
|
+
try:
|
|
68
|
+
new_date_obs = datetime.strptime(date_obs, fmt)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
else:
|
|
72
|
+
break
|
|
73
|
+
if new_date_obs is None:
|
|
74
|
+
raise UnknownDateTimeFormatError(date_obs)
|
|
75
|
+
return new_date_obs.replace(tzinfo=timezone.utc)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ExifReader(ImageReader):
|
|
79
|
+
def header_type(self) -> HeaderType:
|
|
80
|
+
return HeaderType.EXIF
|
|
81
|
+
|
|
82
|
+
def read_metadata(self, path: str) -> ImageMetadata:
|
|
83
|
+
with open(path, "rb") as fd:
|
|
84
|
+
exif = exifread.process_file(fd, details=True)
|
|
85
|
+
if not exif:
|
|
86
|
+
raise ReadMetadataError(os.path.basename(path))
|
|
87
|
+
make = exif.get("Image Make")
|
|
88
|
+
make = str(make).strip() if make is not None else None
|
|
89
|
+
model = exif.get("Image Model")
|
|
90
|
+
model = str(model).strip() if model is not None else None
|
|
91
|
+
focal_len = to_positive_float(exif.get("EXIF FocalLength"))
|
|
92
|
+
f_number = to_positive_float(exif.get("EXIF FNumber"))
|
|
93
|
+
iso = exif.get("EXIF ISOSpeedRatings")
|
|
94
|
+
iso = int(float(str(iso))) if iso is not None else None
|
|
95
|
+
exptime = exif.get("EXIF ExposureTime")
|
|
96
|
+
exptime = float(Fraction(str(exptime))) if exptime is not None else None
|
|
97
|
+
timestamp = handle_date_obs(exif, path)
|
|
98
|
+
image_type = image_type_by_path(path)
|
|
99
|
+
with rawpy.imread(path) as img:
|
|
100
|
+
height, width = img.raw_image.shape
|
|
101
|
+
black_levels = img.black_level_per_channel
|
|
102
|
+
black_level = int(round(sum(black_levels) / len(black_levels), 0)) # mean black level
|
|
103
|
+
color_desc = img.color_desc.decode("utf-8")
|
|
104
|
+
if color_desc != "RGBG":
|
|
105
|
+
raise UnsupportedCFAError(color_desc)
|
|
106
|
+
cfa = "".join(
|
|
107
|
+
map(
|
|
108
|
+
lambda c: {"Gr": "G", "Gb": "G"}.get(c, c),
|
|
109
|
+
[BayerIndex(i).name for i in img.raw_pattern.ravel()],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
bayer = BayerPattern(cfa)
|
|
113
|
+
header_type = self.header_type()
|
|
114
|
+
extension = os.path.splitext(path)[1]
|
|
115
|
+
|
|
116
|
+
metadata = ImageMetadata(
|
|
117
|
+
width=width,
|
|
118
|
+
height=height,
|
|
119
|
+
make=make,
|
|
120
|
+
model=model,
|
|
121
|
+
exptime=exptime,
|
|
122
|
+
iso=iso,
|
|
123
|
+
gain=None,
|
|
124
|
+
black_level=black_level,
|
|
125
|
+
bayer=bayer,
|
|
126
|
+
imagetyp=image_type,
|
|
127
|
+
timestamp=timestamp,
|
|
128
|
+
focal_len=focal_len,
|
|
129
|
+
f_number=f_number,
|
|
130
|
+
header_type=header_type,
|
|
131
|
+
extension=extension,
|
|
132
|
+
)
|
|
133
|
+
return metadata
|
|
134
|
+
|
|
135
|
+
def read_pixels(self, path: str) -> RawPixels:
|
|
136
|
+
"""returns an object that implements the RawPixels interface"""
|
|
137
|
+
if not os.path.isfile(path):
|
|
138
|
+
raise MissingImageError(path)
|
|
139
|
+
raw = rawpy.imread(path)
|
|
140
|
+
return ExifRawPixels(raw, path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ExifRawPixels(RawPixels):
|
|
144
|
+
def __init__(self, raw: RawPy, path: str) -> None:
|
|
145
|
+
self.path = path
|
|
146
|
+
self.img = raw
|
|
147
|
+
self.black_levels = raw.black_level_per_channel
|
|
148
|
+
self.shape = raw.raw_image.shape
|
|
149
|
+
self.pattern = raw.raw_pattern
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
if self.img is not None:
|
|
153
|
+
self.img.close()
|
|
154
|
+
self.img = None
|
|
155
|
+
|
|
156
|
+
# No context managers here, we need to keep the internal numpy
|
|
157
|
+
# raw_pixels array alive while statistics are being computed
|
|
158
|
+
def __del__(self) -> None:
|
|
159
|
+
try:
|
|
160
|
+
if self.img is not None:
|
|
161
|
+
self.img.close()
|
|
162
|
+
finally:
|
|
163
|
+
self.img = None
|
|
164
|
+
|
|
165
|
+
def __enter__(self) -> "ExifRawPixels":
|
|
166
|
+
if self.img is None:
|
|
167
|
+
raise AlreadyClosedError(os.path.basename(self.path))
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def __exit__(
|
|
171
|
+
self,
|
|
172
|
+
exc_type: type[BaseException] | None,
|
|
173
|
+
exc_value: BaseException | None,
|
|
174
|
+
traceback: TracebackType | None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
def _section(
|
|
179
|
+
self,
|
|
180
|
+
x: int,
|
|
181
|
+
y: int,
|
|
182
|
+
section: Rect | None,
|
|
183
|
+
) -> IntArray2D:
|
|
184
|
+
screen = Rect.from_points(Point.zero(), Point(x=self.shape[1], y=self.shape[0])) // 2
|
|
185
|
+
if section is None:
|
|
186
|
+
section = screen
|
|
187
|
+
elif not screen.contains(section):
|
|
188
|
+
raise OutOfBoundsError(section)
|
|
189
|
+
section = section * 2
|
|
190
|
+
x1 = x + section.x1
|
|
191
|
+
y1 = y + section.y1
|
|
192
|
+
x2 = section.x2
|
|
193
|
+
y2 = section.y2
|
|
194
|
+
return self.img.raw_image[y1:y2:2, x1:x2:2]
|
|
195
|
+
|
|
196
|
+
def r(self, section: Rect | None) -> IntArray2D:
|
|
197
|
+
y, x = np.argwhere(self.pattern == BayerIndex.R)[0]
|
|
198
|
+
return self._section(x, y, section)
|
|
199
|
+
|
|
200
|
+
def gr(self, section: Rect | None) -> IntArray2D:
|
|
201
|
+
y, x = np.argwhere(self.pattern == BayerIndex.Gr)[0]
|
|
202
|
+
return self._section(x, y, section)
|
|
203
|
+
|
|
204
|
+
def gb(self, section: Rect | None) -> IntArray2D:
|
|
205
|
+
y, x = np.argwhere(self.pattern == BayerIndex.Gb)[0]
|
|
206
|
+
return self._section(x, y, section)
|
|
207
|
+
|
|
208
|
+
def b(self, section: Rect | None) -> IntArray2D:
|
|
209
|
+
y, x = np.argwhere(self.pattern == BayerIndex.B)[0]
|
|
210
|
+
return self._section(x, y, section)
|
|
211
|
+
|
|
212
|
+
def r_black(self) -> int:
|
|
213
|
+
return self.black_levels[BayerIndex.R]
|
|
214
|
+
|
|
215
|
+
def gr_black(self) -> int:
|
|
216
|
+
return self.black_levels[BayerIndex.Gr]
|
|
217
|
+
|
|
218
|
+
def gb_black(self) -> int:
|
|
219
|
+
return self.black_levels[BayerIndex.Gb]
|
|
220
|
+
|
|
221
|
+
def b_black(self) -> int:
|
|
222
|
+
return self.black_levels[BayerIndex.B]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# ----------------
|
|
12
|
+
# Own dependencies
|
|
13
|
+
# ----------------
|
|
14
|
+
from ..errors import UnsupportedImageFormatError
|
|
15
|
+
from ..ports import ImageReader, ImageReaderFactory
|
|
16
|
+
|
|
17
|
+
FITS_EXTENSIONS = (".fit", ".fits", ".fts")
|
|
18
|
+
|
|
19
|
+
RAW_EXTENSIONS = (".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".dng", ".rw2")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ImageReaderFactoryImpl(ImageReaderFactory):
|
|
23
|
+
def get_reader(self, path: str) -> ImageReader:
|
|
24
|
+
ext = os.path.splitext(path)[1].lower()
|
|
25
|
+
if ext in FITS_EXTENSIONS:
|
|
26
|
+
from .fits import FitsReader
|
|
27
|
+
|
|
28
|
+
return FitsReader()
|
|
29
|
+
if ext in RAW_EXTENSIONS:
|
|
30
|
+
from .exif import ExifReader
|
|
31
|
+
|
|
32
|
+
return ExifReader()
|
|
33
|
+
raise UnsupportedImageFormatError(path)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
# ---------------------
|
|
14
|
+
# Third party libraries
|
|
15
|
+
# ---------------------
|
|
16
|
+
from astropy.io import fits
|
|
17
|
+
from astropy.io.fits import HDUList
|
|
18
|
+
|
|
19
|
+
# ----------------
|
|
20
|
+
# Own dependencies
|
|
21
|
+
# ----------------
|
|
22
|
+
from ..rect import Point, Rect
|
|
23
|
+
from ..types import BayerIndex, BayerPattern, HeaderType, IntArray2D
|
|
24
|
+
|
|
25
|
+
from ..errors import (
|
|
26
|
+
MissingImageError,
|
|
27
|
+
MissingMandatoryFitsError,
|
|
28
|
+
OutOfBoundsError,
|
|
29
|
+
UnknownDateTimeFormatError,
|
|
30
|
+
AlreadyClosedError,
|
|
31
|
+
ReadMetadataError,
|
|
32
|
+
)
|
|
33
|
+
from ..models import ImageMetadata
|
|
34
|
+
from ..ports import ImageReader, RawPixels
|
|
35
|
+
|
|
36
|
+
# FITS relevant keywords
|
|
37
|
+
NAXIS1: str = "NAXIS1"
|
|
38
|
+
NAXIS2: str = "NAXIS2"
|
|
39
|
+
HISTORY: str = "HISTORY"
|
|
40
|
+
COMMENT: str = "COMMENT"
|
|
41
|
+
INSTRUME: str = "INSTRUME"
|
|
42
|
+
LOG_GAIN: str = "LOG-GAIN"
|
|
43
|
+
BAYERPAT: str = "BAYERPAT"
|
|
44
|
+
PEDESTAL: str = "PEDESTAL"
|
|
45
|
+
XPIXSIZE: str = "XPIXSZ"
|
|
46
|
+
YPIXSIZE: str = "YPIXSZ"
|
|
47
|
+
IMAGETYP: str = "IMAGETYP"
|
|
48
|
+
FOCALLEN: str = "FOCALLEN"
|
|
49
|
+
APTDIA: str = "APTDIA"
|
|
50
|
+
EXPTIME: str = "EXPTIME"
|
|
51
|
+
DATE_OBS: str = "DATE-OBS"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bayer_layout(pattern: BayerPattern) -> list[tuple[int, int]]:
|
|
55
|
+
"""
|
|
56
|
+
Turns a Bayyer pattern string into a 2x2 Bayer Matrix with the (row, column)
|
|
57
|
+
coordinates of R, Gr, B y Gb.
|
|
58
|
+
The matrix is given as a list, indexed by BayerIndex
|
|
59
|
+
"""
|
|
60
|
+
coords = [(0, 0), (0, 0), (0, 0), (0, 0)]
|
|
61
|
+
p = pattern.value
|
|
62
|
+
# Patrón en orden fila-major:
|
|
63
|
+
# p[0] p[1]
|
|
64
|
+
# p[2] p[3]
|
|
65
|
+
raw = [
|
|
66
|
+
[p[0], p[1]],
|
|
67
|
+
[p[2], p[3]],
|
|
68
|
+
]
|
|
69
|
+
r_coords = next(
|
|
70
|
+
(r, c) for r, row in enumerate(raw) for c, value in enumerate(row) if value == "R"
|
|
71
|
+
)
|
|
72
|
+
b_coords = next(
|
|
73
|
+
(r, c) for r, row in enumerate(raw) for c, value in enumerate(row) if value == "B"
|
|
74
|
+
)
|
|
75
|
+
# El otro G de la fila de R es Gr.
|
|
76
|
+
gr_coords = (r_coords[0], 1 - r_coords[1])
|
|
77
|
+
# El otro G de la fila de B es Gb.
|
|
78
|
+
gb_coords = (b_coords[0], 1 - b_coords[1])
|
|
79
|
+
coords[BayerIndex.R] = r_coords
|
|
80
|
+
coords[BayerIndex.Gr] = gr_coords
|
|
81
|
+
coords[BayerIndex.B] = b_coords
|
|
82
|
+
coords[BayerIndex.Gb] = gb_coords
|
|
83
|
+
return coords
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FitsReader(ImageReader):
|
|
87
|
+
MANDATORY_KEYWORDS = {
|
|
88
|
+
NAXIS1,
|
|
89
|
+
NAXIS2,
|
|
90
|
+
BAYERPAT,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def header_type(self) -> HeaderType:
|
|
94
|
+
return HeaderType.FITS
|
|
95
|
+
|
|
96
|
+
def handle_date_obs(self, date_obs: str) -> datetime:
|
|
97
|
+
new_date_obs = None
|
|
98
|
+
for fmt in (
|
|
99
|
+
"%Y-%m-%d %H:%M:%S",
|
|
100
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
101
|
+
"%Y-%m-%d %H:%M:%S.%f",
|
|
102
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
103
|
+
"%Y-%m-%dT%H:%M:%S:%:z",
|
|
104
|
+
"%Y-%m-%dT%H:%M:%S%Z",
|
|
105
|
+
"%Y-%m-%dT%H:%M:%S.%f%:z",
|
|
106
|
+
"%Y-%m-%dT%H:%M:%S.%f%Z",
|
|
107
|
+
):
|
|
108
|
+
try:
|
|
109
|
+
new_date_obs = datetime.strptime(date_obs, fmt)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
else:
|
|
113
|
+
break
|
|
114
|
+
if new_date_obs is None:
|
|
115
|
+
raise UnknownDateTimeFormatError(date_obs)
|
|
116
|
+
return new_date_obs.replace(tzinfo=timezone.utc)
|
|
117
|
+
|
|
118
|
+
def read_metadata(self, path: str) -> ImageMetadata:
|
|
119
|
+
"""returns an instance of ImageMetadata dataclass"""
|
|
120
|
+
try:
|
|
121
|
+
with fits.open(path) as hdul:
|
|
122
|
+
header = hdul[0].header
|
|
123
|
+
available = set(header.keys())
|
|
124
|
+
if not self.MANDATORY_KEYWORDS < available:
|
|
125
|
+
raise MissingMandatoryFitsError(self.MANDATORY_KEYWORDS - available)
|
|
126
|
+
width = header[NAXIS1]
|
|
127
|
+
height = header[NAXIS2]
|
|
128
|
+
make = None
|
|
129
|
+
model = header.get(INSTRUME)
|
|
130
|
+
exptime = header.get(EXPTIME)
|
|
131
|
+
gain = header.get(LOG_GAIN)
|
|
132
|
+
black_level = header.get(PEDESTAL)
|
|
133
|
+
bayer = header[BAYERPAT]
|
|
134
|
+
image_type = header.get(IMAGETYP)
|
|
135
|
+
timestamp = self.handle_date_obs(header.get(DATE_OBS))
|
|
136
|
+
focal_len = header.get(FOCALLEN)
|
|
137
|
+
f_number = (
|
|
138
|
+
None
|
|
139
|
+
if header.get(APTDIA) is None or focal_len is None
|
|
140
|
+
else round(focal_len / header[APTDIA], 2)
|
|
141
|
+
)
|
|
142
|
+
header_type = self.header_type()
|
|
143
|
+
extension = os.path.splitext(path)[1]
|
|
144
|
+
metadata = ImageMetadata(
|
|
145
|
+
width=width,
|
|
146
|
+
height=height,
|
|
147
|
+
make=make,
|
|
148
|
+
model=model,
|
|
149
|
+
exptime=exptime,
|
|
150
|
+
iso=None,
|
|
151
|
+
gain=gain,
|
|
152
|
+
black_level=black_level,
|
|
153
|
+
bayer=bayer,
|
|
154
|
+
imagetyp=image_type,
|
|
155
|
+
timestamp=timestamp,
|
|
156
|
+
focal_len=focal_len,
|
|
157
|
+
f_number=f_number,
|
|
158
|
+
header_type=header_type,
|
|
159
|
+
extension=extension,
|
|
160
|
+
)
|
|
161
|
+
return metadata
|
|
162
|
+
except OSError:
|
|
163
|
+
raise ReadMetadataError(os.path.basename(path))
|
|
164
|
+
|
|
165
|
+
def read_pixels(self, path: str) -> RawPixels:
|
|
166
|
+
"""returns an object that implements the RawPixels interface"""
|
|
167
|
+
if not os.path.isfile(path):
|
|
168
|
+
raise MissingImageError(path)
|
|
169
|
+
hdul = fits.open(path)
|
|
170
|
+
return FitsRawPixels(hdul, path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class FitsRawPixels(RawPixels):
|
|
174
|
+
def __init__(self, hdul: HDUList, path: str) -> None:
|
|
175
|
+
self.path= path
|
|
176
|
+
self.hdul = hdul
|
|
177
|
+
header = hdul[0].header
|
|
178
|
+
self.black_levels = [header.get(PEDESTAL)] * 4
|
|
179
|
+
self.shape = (header[NAXIS2], header[NAXIS1])
|
|
180
|
+
pattern = BayerPattern(header[BAYERPAT])
|
|
181
|
+
self.coords = bayer_layout(pattern)
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
if self.hdul is not None:
|
|
185
|
+
self.hdul.close()
|
|
186
|
+
self.hdul = None
|
|
187
|
+
|
|
188
|
+
def __del__(self) -> None:
|
|
189
|
+
try:
|
|
190
|
+
self.close()
|
|
191
|
+
finally:
|
|
192
|
+
self.hdul = None
|
|
193
|
+
|
|
194
|
+
def __enter__(self) -> "FitsRawPixels":
|
|
195
|
+
if self.hdul is None:
|
|
196
|
+
raise AlreadyClosedError(os.path.basename(self.path))
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def __exit__(
|
|
200
|
+
self,
|
|
201
|
+
exc_type: type[BaseException] | None,
|
|
202
|
+
exc_value: BaseException | None,
|
|
203
|
+
traceback: TracebackType | None,
|
|
204
|
+
) -> None:
|
|
205
|
+
self.close()
|
|
206
|
+
|
|
207
|
+
def _section(
|
|
208
|
+
self,
|
|
209
|
+
x: int,
|
|
210
|
+
y: int,
|
|
211
|
+
section: Rect | None,
|
|
212
|
+
) -> IntArray2D:
|
|
213
|
+
screen = Rect.from_points(Point.zero(), Point(x=self.shape[1], y=self.shape[0])) // 2
|
|
214
|
+
if section is None:
|
|
215
|
+
section = screen
|
|
216
|
+
elif not screen.contains(section):
|
|
217
|
+
raise OutOfBoundsError(section)
|
|
218
|
+
section = section * 2
|
|
219
|
+
x1 = x + section.x1
|
|
220
|
+
y1 = y + section.y1
|
|
221
|
+
x2 = section.x2
|
|
222
|
+
y2 = section.y2
|
|
223
|
+
img: IntArray2D = self.hdul[0].data
|
|
224
|
+
return img[y1:y2:2, x1:x2:2]
|
|
225
|
+
|
|
226
|
+
def r(self, section: Rect | None) -> IntArray2D:
|
|
227
|
+
y, x = self.coords[BayerIndex.R]
|
|
228
|
+
return self._section(x, y, section)
|
|
229
|
+
|
|
230
|
+
def gr(self, section: Rect | None) -> IntArray2D:
|
|
231
|
+
y, x = self.coords[BayerIndex.Gr]
|
|
232
|
+
return self._section(x, y, section)
|
|
233
|
+
|
|
234
|
+
def gb(self, section: Rect | None) -> IntArray2D:
|
|
235
|
+
y, x = self.coords[BayerIndex.Gb]
|
|
236
|
+
return self._section(x, y, section)
|
|
237
|
+
|
|
238
|
+
def b(self, section: Rect | None) -> IntArray2D:
|
|
239
|
+
y, x = self.coords[BayerIndex.B]
|
|
240
|
+
return self._section(x, y, section)
|
|
241
|
+
|
|
242
|
+
def r_black(self) -> int:
|
|
243
|
+
return self.black_levels[BayerIndex.R]
|
|
244
|
+
|
|
245
|
+
def gr_black(self) -> int:
|
|
246
|
+
return self.black_levels[BayerIndex.Gr]
|
|
247
|
+
|
|
248
|
+
def gb_black(self) -> int:
|
|
249
|
+
return self.black_levels[BayerIndex.Gb]
|
|
250
|
+
|
|
251
|
+
def b_black(self) -> int:
|
|
252
|
+
return self.black_levels[BayerIndex.B]
|
lica_raw/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# ----------------
|
|
8
|
+
# Own dependencies
|
|
9
|
+
# ----------------
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RawBaseError(Exception):
|
|
13
|
+
"""Raw library base error"""
|
|
14
|
+
|
|
15
|
+
def __str__(self):
|
|
16
|
+
msg = self.__doc__
|
|
17
|
+
return f"{msg}: {self.args[0]}." if self.args else f"{msg}."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedImageFormatError(RawBaseError):
|
|
21
|
+
"""Unsupported image format"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AlreadyClosedError(RawBaseError):
|
|
25
|
+
"""Resource already closed"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnsupportedCFAError(RawBaseError):
|
|
29
|
+
"""Unsupported Color Filter Array"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReadMetadataError(RawBaseError):
|
|
33
|
+
"""Could not read image metadata"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnknownDateTimeFormatError(RawBaseError):
|
|
37
|
+
"""unknown observation date format string"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OutOfBoundsError(RawBaseError):
|
|
41
|
+
"""out of bounds rectangle"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MissingImageError(RawBaseError):
|
|
45
|
+
"""Image path does not exists"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingMandatoryFitsError(RawBaseError):
|
|
49
|
+
"""Missing mandatory keywords in FITS image"""
|
lica_raw/models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
# ----------------
|
|
13
|
+
# Own dependencies
|
|
14
|
+
# ----------------
|
|
15
|
+
from .types import BayerPattern, ImageType, HeaderType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ImageMetadata:
|
|
20
|
+
width: int
|
|
21
|
+
height: int
|
|
22
|
+
make: str | None
|
|
23
|
+
model: str | None
|
|
24
|
+
exptime: float | None
|
|
25
|
+
iso: int | None
|
|
26
|
+
gain: float | None
|
|
27
|
+
black_level: int | None
|
|
28
|
+
bayer: BayerPattern
|
|
29
|
+
imagetyp: ImageType | None
|
|
30
|
+
timestamp: datetime | None
|
|
31
|
+
focal_len: float | None
|
|
32
|
+
f_number: float | None
|
|
33
|
+
header_type: HeaderType
|
|
34
|
+
extension: str
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return f"{self.model} {self.width}x{self.height}"
|
lica_raw/ports.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Protocol, Self
|
|
11
|
+
|
|
12
|
+
# ----------------
|
|
13
|
+
# Own dependencies
|
|
14
|
+
# ----------------
|
|
15
|
+
from .models import ImageMetadata
|
|
16
|
+
from .rect import Rect
|
|
17
|
+
from .types import HeaderType, IntArray2D
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RawPixels(Protocol):
|
|
21
|
+
def r(self, section: Rect | None) -> IntArray2D: ...
|
|
22
|
+
def gr(self, section: Rect | None) -> IntArray2D: ...
|
|
23
|
+
def gb(self, section: Rect | None) -> IntArray2D: ...
|
|
24
|
+
def b(self, section: Rect | None) -> IntArray2D: ...
|
|
25
|
+
|
|
26
|
+
def r_black(self) -> int: ...
|
|
27
|
+
def gr_black(self) -> int: ...
|
|
28
|
+
def gb_black(self) -> int: ...
|
|
29
|
+
def b_black(self) -> int: ...
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> Self: ...
|
|
32
|
+
|
|
33
|
+
def __exit__(
|
|
34
|
+
self,
|
|
35
|
+
exc_type: type[BaseException] | None,
|
|
36
|
+
exc_value: BaseException | None,
|
|
37
|
+
traceback: TracebackType | None,
|
|
38
|
+
) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ImageReader(Protocol):
|
|
42
|
+
def header_type(self) -> HeaderType: ...
|
|
43
|
+
|
|
44
|
+
def read_metadata(self, path: str) -> ImageMetadata:
|
|
45
|
+
"""returns an instance of ImageMetadata dataclass"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def read_pixels(self, path: str) -> RawPixels:
|
|
49
|
+
"""returns an object that implements the RawPixels interface"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ImageReaderFactory(Protocol):
|
|
54
|
+
def get_reader(self, path: str) -> ImageReader:
|
|
55
|
+
"""returns an object that implements the ImageReader interface"""
|
|
56
|
+
...
|
lica_raw/rect.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
import re
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
# ----------------
|
|
13
|
+
# Own dependencies
|
|
14
|
+
# ----------------
|
|
15
|
+
from .errors import RawBaseError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidRectStringError(RawBaseError):
|
|
19
|
+
"""Rectangle string incorrectly formatted"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Point(NamedTuple):
|
|
23
|
+
x: int
|
|
24
|
+
y: int
|
|
25
|
+
|
|
26
|
+
def __mul__(self, rhs: int) -> "Point":
|
|
27
|
+
return Point(self.x * rhs, self.y * rhs)
|
|
28
|
+
|
|
29
|
+
def __floordiv__(self, rhs: int) -> "Point":
|
|
30
|
+
return Point(self.x // rhs, self.y // rhs)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def zero(cls) -> "Point":
|
|
34
|
+
return cls(0, 0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PATTERN: re.Pattern[str] = re.compile(r"\[(\d+):(\d+),(\d+):(\d+)\]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Rect(NamedTuple):
|
|
41
|
+
x1: int
|
|
42
|
+
y1: int
|
|
43
|
+
x2: int
|
|
44
|
+
y2: int
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_points(cls, p1: Point, p2: Point) -> "Rect":
|
|
48
|
+
return cls(p1.x, p1.y, p2.x, p2.y)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_string(cls, value: str) -> "Rect":
|
|
52
|
+
match = re.fullmatch(PATTERN, value.strip())
|
|
53
|
+
if match is None:
|
|
54
|
+
raise InvalidRectStringError(f"{value!r}")
|
|
55
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
56
|
+
return cls(x1, y1, x2, y2)
|
|
57
|
+
|
|
58
|
+
def width(self) -> int:
|
|
59
|
+
return self.x2 - self.x1
|
|
60
|
+
|
|
61
|
+
def height(self) -> int:
|
|
62
|
+
return self.y2 - self.y1
|
|
63
|
+
|
|
64
|
+
def dimensions(self) -> tuple[int, int]:
|
|
65
|
+
return self.x2 - self.x1, self.y2 - self.y1
|
|
66
|
+
|
|
67
|
+
def origin(self) -> Point:
|
|
68
|
+
return Point(self.x1, self.y1)
|
|
69
|
+
|
|
70
|
+
def central(self) -> Point:
|
|
71
|
+
return Point((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2)
|
|
72
|
+
|
|
73
|
+
def contains(self, other: "Rect") -> bool:
|
|
74
|
+
return (
|
|
75
|
+
(self.x1 <= other.x1)
|
|
76
|
+
and (self.y1 <= other.y1)
|
|
77
|
+
and (other.x2 <= self.x2)
|
|
78
|
+
and (other.y2 <= self.y2)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def recenter_on(self, screen: "Rect") -> "Rect":
|
|
82
|
+
c_screen = screen.central()
|
|
83
|
+
c_self = self.central()
|
|
84
|
+
dx = c_screen.x - c_self.x
|
|
85
|
+
dy = c_screen.y - c_self.y
|
|
86
|
+
return self + Point(dx, dy)
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
"""as a numpy slice"""
|
|
90
|
+
return f"[{self.y1}:{self.y2},{self.x1}:{self.x2}]"
|
|
91
|
+
|
|
92
|
+
def __add__(self, rhs: Point) -> "Rect":
|
|
93
|
+
return Rect(self.x1 + rhs.x, self.y1 + rhs.y, self.x2 + rhs.x, self.y2 + rhs.y)
|
|
94
|
+
|
|
95
|
+
def __mul__(self, rhs: int) -> "Rect":
|
|
96
|
+
return Rect(self.x1 * rhs, self.y1 * rhs, self.x2 * rhs, self.y2 * rhs)
|
|
97
|
+
|
|
98
|
+
def __floordiv__(self, rhs: int) -> "Rect":
|
|
99
|
+
return Rect(self.x1 // rhs, self.y1 // rhs, self.x2 // rhs, self.y2 // rhs)
|
|
100
|
+
|
|
101
|
+
def __eq__(self, other: object) -> bool:
|
|
102
|
+
if not isinstance(other, Rect):
|
|
103
|
+
return NotImplemented
|
|
104
|
+
return (
|
|
105
|
+
self.x1 == other.x1
|
|
106
|
+
and self.y1 == other.y1
|
|
107
|
+
and self.x2 == other.x2
|
|
108
|
+
and self.y2 == other.y2
|
|
109
|
+
)
|
lica_raw/types.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
from enum import IntEnum, StrEnum
|
|
10
|
+
|
|
11
|
+
# ---------------------
|
|
12
|
+
# Third party libraries
|
|
13
|
+
# ---------------------
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
# type alias
|
|
17
|
+
type IntArray2D = np.ndarray[tuple[int, int], np.dtype[np.uint16]]
|
|
18
|
+
|
|
19
|
+
# -----------------
|
|
20
|
+
# Enumeration types
|
|
21
|
+
# -----------------
|
|
22
|
+
|
|
23
|
+
class HeaderType(StrEnum):
|
|
24
|
+
FITS = "FITS"
|
|
25
|
+
EXIF = "EXIF"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BayerPattern(StrEnum):
|
|
29
|
+
RGGB = "RGGB"
|
|
30
|
+
BGGR = "BGGR"
|
|
31
|
+
GRBG = "GRBG"
|
|
32
|
+
GBRG = "GBRG"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ImageType(StrEnum):
|
|
36
|
+
BIAS = "BIAS"
|
|
37
|
+
DARK = "DARK"
|
|
38
|
+
FLAT = "FLAT"
|
|
39
|
+
LIGHT = "LIGHT"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BayerIndex(IntEnum):
|
|
43
|
+
"""Indexes to bidimensional 2x2 RGB Bayer pattern, black_level_per_channel and white_levels_per_channel"""
|
|
44
|
+
|
|
45
|
+
R = 0
|
|
46
|
+
Gr = 1
|
|
47
|
+
B = 2
|
|
48
|
+
Gb = 3
|
lica_raw/utils.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Rafael Gonzalez.
|
|
3
|
+
#
|
|
4
|
+
# See the LICENSE file for details
|
|
5
|
+
# ----------------------------------------------------------------------
|
|
6
|
+
# ----------------
|
|
7
|
+
# Standard library
|
|
8
|
+
# ----------------
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# ----------------
|
|
13
|
+
# Own dependencies
|
|
14
|
+
# ----------------
|
|
15
|
+
from .types import ImageType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def mk_test_img_type(regexp: re.Pattern[str]):
|
|
19
|
+
def wrapper(path: str):
|
|
20
|
+
def test(name: str):
|
|
21
|
+
matchobj = regexp.search(name.upper())
|
|
22
|
+
return True if matchobj else False
|
|
23
|
+
|
|
24
|
+
filepath = os.path.basename(path)
|
|
25
|
+
dirname = os.path.basename(os.path.dirname(path))
|
|
26
|
+
return test(dirname) or test(filepath)
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
is_flat = mk_test_img_type(re.compile(r"FLAT"))
|
|
31
|
+
is_dark = mk_test_img_type(re.compile(r"(DARK|OSCURO)"))
|
|
32
|
+
is_bias = mk_test_img_type(re.compile(r"BIAS"))
|
|
33
|
+
|
|
34
|
+
def image_type_by_path(path: str) -> ImageType:
|
|
35
|
+
if is_flat(path):
|
|
36
|
+
result = ImageType.FLAT
|
|
37
|
+
elif is_dark(path):
|
|
38
|
+
result = ImageType.DARK
|
|
39
|
+
elif is_bias(path):
|
|
40
|
+
result = ImageType.BIAS
|
|
41
|
+
else:
|
|
42
|
+
result = ImageType.LIGHT
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
__all__ = ["image_type_by_path"]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lica-raw
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LICA RGB Raw loader for DSLR images
|
|
5
|
+
Author: Rafael González
|
|
6
|
+
Author-email: Rafael González <rafael08@ucm.es>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
14
|
+
Requires-Dist: numpy>=2.5
|
|
15
|
+
Requires-Dist: exifread>=3.5 ; extra == 'exif'
|
|
16
|
+
Requires-Dist: rawpy>=0.27 ; extra == 'exif'
|
|
17
|
+
Requires-Dist: astropy>=8.0 ; extra == 'fits'
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Provides-Extra: exif
|
|
20
|
+
Provides-Extra: fits
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# lica-raw
|
|
24
|
+
|
|
25
|
+
## Introduction
|
|
26
|
+
|
|
27
|
+
`lica-raw` is a lightweight utility for reading metadata and raw Bayer pixel data from astronomical camera images. It defines minimal reader interfaces using `typing.Protocol` and provides adapters for FITS images and camera RAW files with EXIF metadata.
|
|
28
|
+
|
|
29
|
+
The package supports Bayer colour-filter-array layouts, per-channel black levels, optional rectangular regions of interest, and automatic selection of a reader from the image file extension. Application code depends only on the `ImageReader`, `ImageReaderFactory`, and `RawPixels` protocols, while the concrete image-format behaviour is supplied by adapters.
|
|
30
|
+
|
|
31
|
+
Objects returned by `read_pixels()` own the underlying image resource. They can be used as context managers, or closed explicitly with `close()` when batch processing makes a `with` block inconvenient.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install `lica-raw` directly from the `lica-ng` Git repository:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install lica-raw
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
or add it to your `pyproject.toml`:
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
[project]
|
|
45
|
+
dependencies = [
|
|
46
|
+
"lica-raw",
|
|
47
|
+
]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For FITS image support, `lica-raw[fits]` installs the needed dependency on `astropy`:
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
[project]
|
|
54
|
+
dependencies = [
|
|
55
|
+
"lica-raw[fits]",
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For camera RAW file support, `lica-raw[exif]` installs the needed dependencies `rawpy`, `exifread`, and `numpy`:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[project]
|
|
63
|
+
dependencies = [
|
|
64
|
+
"lica-raw[exif]",
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Interfaces
|
|
69
|
+
|
|
70
|
+
`lica-raw` defines its image-access contracts using `typing.Protocol`. Adapters do not need to inherit from these protocols explicitly; they only need to provide the required methods and attributes.
|
|
71
|
+
|
|
72
|
+
### Image reader
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from typing import Protocol
|
|
76
|
+
|
|
77
|
+
from lica_raw import HeaderType, ImageMetadata
|
|
78
|
+
from lica_raw.ports import RawPixels
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ImageReader(Protocol):
|
|
82
|
+
def header_type(self) -> HeaderType: ...
|
|
83
|
+
|
|
84
|
+
def read_metadata(self, path: str) -> ImageMetadata: ...
|
|
85
|
+
|
|
86
|
+
def read_pixels(self, path: str) -> RawPixels: ...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Raw pixels
|
|
90
|
+
|
|
91
|
+
`RawPixels` objects support the context-manager protocol. Entering the context returns the pixel-data object; leaving it closes the underlying image resource, even if an exception occurs.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from collections.abc import Generator
|
|
95
|
+
from types import TracebackType
|
|
96
|
+
from typing import Protocol, Self
|
|
97
|
+
|
|
98
|
+
from lica_raw import IntArray2D, Rect
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RawPixels(Protocol):
|
|
102
|
+
def r(self, section: Rect | None) -> IntArray2D: ...
|
|
103
|
+
def gr(self, section: Rect | None) -> IntArray2D: ...
|
|
104
|
+
def gb(self, section: Rect | None) -> IntArray2D: ...
|
|
105
|
+
def b(self, section: Rect | None) -> IntArray2D: ...
|
|
106
|
+
|
|
107
|
+
def r_black(self) -> int: ...
|
|
108
|
+
def gr_black(self) -> int: ...
|
|
109
|
+
def gb_black(self) -> int: ...
|
|
110
|
+
def b_black(self) -> int: ...
|
|
111
|
+
|
|
112
|
+
def close(self) -> None: ...
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> Self: ...
|
|
115
|
+
|
|
116
|
+
def __exit__(
|
|
117
|
+
self,
|
|
118
|
+
exc_type: type[BaseException] | None,
|
|
119
|
+
exc_value: BaseException | None,
|
|
120
|
+
traceback: TracebackType | None,
|
|
121
|
+
) -> None: ...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`close()` is idempotent and can be called explicitly. This is useful in batch-processing loops where wrapping every image in a `with` block would make the client code unnecessarily nested.
|
|
125
|
+
|
|
126
|
+
### Reader factory
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from typing import Protocol
|
|
130
|
+
|
|
131
|
+
from lica_raw.ports import ImageReader
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ImageReaderFactory(Protocol):
|
|
135
|
+
def get_reader(self, path: str) -> ImageReader: ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Adapters
|
|
139
|
+
|
|
140
|
+
| Adapter | Module | Extra | Description |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `FitsReader` | `lica_raw.adapters.fits` | `astropy` | Reads metadata and raw Bayer pixels from FITS images. |
|
|
143
|
+
| `ExifReader` | `lica_raw.adapters.exif` | `rawpy`, `exifread`, `numpy` | Reads metadata and raw Bayer pixels from camera RAW files using EXIF metadata and `rawpy`. |
|
|
144
|
+
| `ImageReaderFactoryImpl` | `lica_raw.adapters` | — | Selects `FitsReader` for FITS extensions and `ExifReader` otherwise. |
|
|
145
|
+
|
|
146
|
+
The factory recognises the following extensions as FITS:
|
|
147
|
+
|
|
148
|
+
- `.fit`
|
|
149
|
+
- `.fits`
|
|
150
|
+
- `.fts`
|
|
151
|
+
|
|
152
|
+
All other files are handled by `ExifReader`.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from lica_raw.adapters import ImageReaderFactoryImpl
|
|
156
|
+
|
|
157
|
+
factory = ImageReaderFactoryImpl()
|
|
158
|
+
reader = factory.get_reader("image_001.fits")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Image metadata
|
|
162
|
+
|
|
163
|
+
`read_metadata()` returns an `ImageMetadata` dataclass:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from datetime import datetime
|
|
167
|
+
|
|
168
|
+
from lica_raw import BayerPattern, HeaderType, ImageType
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class ImageMetadata:
|
|
173
|
+
width: int
|
|
174
|
+
height: int
|
|
175
|
+
make: str | None
|
|
176
|
+
model: str | None
|
|
177
|
+
exptime: float | None
|
|
178
|
+
iso: int | None
|
|
179
|
+
gain: float | None
|
|
180
|
+
black_level: int | None
|
|
181
|
+
bayer: BayerPattern
|
|
182
|
+
imagetyp: ImageType | None
|
|
183
|
+
timestamp: datetime | None
|
|
184
|
+
focal_len: float | None
|
|
185
|
+
f_number: float | None
|
|
186
|
+
header_type: HeaderType
|
|
187
|
+
extension: str
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The supported Bayer patterns are defined in the `BayerPattern` enum as follows:
|
|
191
|
+
|
|
192
|
+
- `RGGB`
|
|
193
|
+
- `BGGR`
|
|
194
|
+
- `GRBG`
|
|
195
|
+
- `GBRG`
|
|
196
|
+
|
|
197
|
+
The supported image types are defined in the `ImageType` enum as follows:
|
|
198
|
+
|
|
199
|
+
- `BIAS`
|
|
200
|
+
- `DARK`
|
|
201
|
+
- `FLAT`
|
|
202
|
+
- `LIGHT`
|
|
203
|
+
|
|
204
|
+
For non-FITS images, `image_type_by_path()` infers the image type from the file name or immediate parent-directory name. Names containing `FLAT`, `DARK` or `OSCURO`, or `BIAS` are classified accordingly; all other images are treated as `LIGHT`.
|
|
205
|
+
|
|
206
|
+
## FITS support
|
|
207
|
+
|
|
208
|
+
`FitsReader` requires the following mandatory FITS keywords:
|
|
209
|
+
|
|
210
|
+
- `NAXIS1`
|
|
211
|
+
- `NAXIS2`
|
|
212
|
+
- `BAYERPAT`
|
|
213
|
+
|
|
214
|
+
It also reads optional metadata from these keywords:
|
|
215
|
+
|
|
216
|
+
| Metadata | FITS keyword |
|
|
217
|
+
|---|---|
|
|
218
|
+
| Instrument or camera model | `INSTRUME` |
|
|
219
|
+
| Exposure time | `EXPTIME` |
|
|
220
|
+
| Gain | `LOG-GAIN` |
|
|
221
|
+
| Black level | `PEDESTAL` |
|
|
222
|
+
| Image type | `IMAGETYP` |
|
|
223
|
+
| Observation date | `DATE-OBS` |
|
|
224
|
+
| Focal length | `FOCALLEN` |
|
|
225
|
+
| Aperture diameter | `APTDIA` |
|
|
226
|
+
|
|
227
|
+
If `FOCALLEN` and `APTDIA` are both present, the focal ratio is calculated as `FOCALLEN / APTDIA`.
|
|
228
|
+
|
|
229
|
+
The observation timestamp is interpreted in UTC. `FitsRawPixels` uses `PEDESTAL` as the black level for all four Bayer channels.
|
|
230
|
+
|
|
231
|
+
## Camera RAW support
|
|
232
|
+
|
|
233
|
+
`ExifReader` reads EXIF metadata with `exifread` and raw image data with `rawpy`. It extracts manufacturer, model, focal length, focal ratio, ISO, exposure time, observation date, image dimensions, black levels, and the Bayer pattern.
|
|
234
|
+
|
|
235
|
+
The adapter currently requires a `RGBG` colour-filter-array description from `rawpy`. Other CFA layouts raise `UnsupportedCFAError`.
|
|
236
|
+
|
|
237
|
+
The observation date is read from `Image DateTime` or `EXIF DateTimeOriginal`, interpreted in UTC, and accepts the following formats:
|
|
238
|
+
|
|
239
|
+
- `%Y:%m:%d %H:%M:%S`
|
|
240
|
+
- `%Y-%m-%dT%H:%M:%S`
|
|
241
|
+
- `%Y-%m-%dT%H:%M:%S%z`
|
|
242
|
+
|
|
243
|
+
## Regions of interest
|
|
244
|
+
|
|
245
|
+
The `r()`, `gr()`, `gb()`, and `b()` methods return a two-dimensional NumPy array containing the pixels of one Bayer colour channel.
|
|
246
|
+
|
|
247
|
+
If `section` is `None`, the full image is used. Otherwise, `section` must be a `Rect` expressed in output-channel coordinates; it is validated against the available image area and converted internally to the corresponding full-resolution Bayer coordinates.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from lica_raw import Rect
|
|
251
|
+
|
|
252
|
+
section = Rect(0, 0, 100, 100)
|
|
253
|
+
|
|
254
|
+
red = pixels.r(section)
|
|
255
|
+
green_red = pixels.gr(section)
|
|
256
|
+
green_blue = pixels.gb(section)
|
|
257
|
+
blue = pixels.b(section)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
A section outside the valid area raises `OutOfBoundsError`.
|
|
261
|
+
|
|
262
|
+
## Resource management
|
|
263
|
+
|
|
264
|
+
`read_pixels()` returns an object that owns an open image resource. Prefer a context manager when the lifetime of the pixels is limited to a local block:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
with reader.read_pixels(path) as pixels:
|
|
268
|
+
red = pixels.r(None)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
For batch processing, explicit closing is equally valid:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
for path in image_paths:
|
|
275
|
+
pixels = reader.read_pixels(path)
|
|
276
|
+
try:
|
|
277
|
+
red = pixels.r(None)
|
|
278
|
+
...
|
|
279
|
+
finally:
|
|
280
|
+
pixels.close()
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
After `close()`, the pixel object must not be used to access image data.
|
|
284
|
+
|
|
285
|
+
## Errors
|
|
286
|
+
|
|
287
|
+
All package-specific errors derive from `RawBaseError`.
|
|
288
|
+
|
|
289
|
+
| Error | Description |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `UnsupportedCFAError` | The camera RAW file uses an unsupported colour-filter-array layout. |
|
|
292
|
+
| `ReadMetadataError` | EXIF metadata could not be read. |
|
|
293
|
+
| `UnknownDateTimeFormatError` | The observation-date string uses an unsupported format. |
|
|
294
|
+
| `OutOfBoundsError` | The requested rectangular section lies outside the image. |
|
|
295
|
+
| `MissingImageError` | The image path does not exist. |
|
|
296
|
+
| `MissingMandatoryFitsError` | One or more mandatory FITS keywords are missing. |
|
|
297
|
+
| `AlreadyClosedError` | Attempt to close an already closed FITS/EXIF image. |
|
|
298
|
+
|
|
299
|
+
## Usage example
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
from lica_raw import ImageReaderFactory, Rect
|
|
303
|
+
from lica_raw.adapters import ImageReaderFactoryImpl
|
|
304
|
+
|
|
305
|
+
path = "light_001.fits"
|
|
306
|
+
|
|
307
|
+
factory: ImageReaderFactory = ImageReaderFactoryImpl()
|
|
308
|
+
reader = factory.get_reader(path)
|
|
309
|
+
|
|
310
|
+
metadata = reader.read_metadata(path)
|
|
311
|
+
print(metadata.model, metadata.width, metadata.height)
|
|
312
|
+
print(metadata.bayer, metadata.exptime, metadata.timestamp)
|
|
313
|
+
|
|
314
|
+
with reader.read_pixels(path) as pixels:
|
|
315
|
+
# Gets the whole channel when section is None.
|
|
316
|
+
red = pixels.r(None)
|
|
317
|
+
green_red = pixels.gr(Rect(0, 0, 256, 256))
|
|
318
|
+
green_blue = pixels.gb(Rect(0, 0, 256, 256))
|
|
319
|
+
blue = pixels.b(Rect(0, 0, 256, 256))
|
|
320
|
+
|
|
321
|
+
print(
|
|
322
|
+
pixels.r_black(),
|
|
323
|
+
pixels.gr_black(),
|
|
324
|
+
pixels.gb_black(),
|
|
325
|
+
pixels.b_black(),
|
|
326
|
+
)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Usage example, with lica-cli package
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
import sys
|
|
333
|
+
from argparse import ArgumentParser, Namespace
|
|
334
|
+
|
|
335
|
+
from lica_cli import parse_args, run_cli
|
|
336
|
+
from lica_raw import ImageReaderFactory
|
|
337
|
+
from lica_raw.adapters import ImageReaderFactoryImpl
|
|
338
|
+
|
|
339
|
+
from myprog import __version__
|
|
340
|
+
|
|
341
|
+
DESCRIPTION = "My program short description"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def add_args(parser: ArgumentParser) -> None:
|
|
345
|
+
parser.add_argument("image", help="FITS or camera RAW image path")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cli_main(args: Namespace) -> None:
|
|
349
|
+
factory: ImageReaderFactory = ImageReaderFactoryImpl()
|
|
350
|
+
reader = factory.get_reader(args.image)
|
|
351
|
+
|
|
352
|
+
metadata = reader.read_metadata(args.image)
|
|
353
|
+
print(metadata)
|
|
354
|
+
|
|
355
|
+
with reader.read_pixels(args.image) as pixels:
|
|
356
|
+
# Gets the whole channel when section is None.
|
|
357
|
+
print("Red channel shape:", pixels.r(None).shape)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main():
|
|
361
|
+
args = parse_args(add_args, __name__, __version__, DESCRIPTION)
|
|
362
|
+
return_code = run_cli(cli_main, args, __name__, __version__)
|
|
363
|
+
sys.exit(return_code)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
main()
|
|
368
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
lica_raw/__init__.py,sha256=cpIcS3uegsUmIArm2-5tNM30z_6ghqPORyjtl1bT8Go,1182
|
|
2
|
+
lica_raw/adapters/__init__.py,sha256=X3bYzLJV8ts7UsQ18E9maduM3HAY6_mhipGshwsPXXw,82
|
|
3
|
+
lica_raw/adapters/exif.py,sha256=e-Wl9MsMI3lnB7XAYTKshXHocyudS0Sh4ZlFhw5hJ_0,7207
|
|
4
|
+
lica_raw/adapters/factory.py,sha256=E8ZXq1xF12g85DZmt6Naolw7VL-haamgGVLVb0XCYh4,992
|
|
5
|
+
lica_raw/adapters/fits.py,sha256=nIroDafrcaYid8bfTEqfeO_RT1HD-dtDOchk13y3jkk,7948
|
|
6
|
+
lica_raw/errors.py,sha256=bMVp31aRzElKHKPnVFkNOW0gOIeke66lFvzunu8Feug,1143
|
|
7
|
+
lica_raw/models.py,sha256=cyHotcZSpCG3EGL27L8K-tNf_nFV-e6WlFsSiAQOV_E,937
|
|
8
|
+
lica_raw/ports.py,sha256=OrwanTRX5zieeyCVt6g75zKh_8gljHbaBMXjb8AvOt4,1636
|
|
9
|
+
lica_raw/rect.py,sha256=X2-9lXl3AXyWX7JCAS1lUOetaHqlnSwhzk8-D_bgDic,3022
|
|
10
|
+
lica_raw/types.py,sha256=hSpUJIzn7x016VKxJYk9hbVf1aOjVIAO-ngfa6R80do,998
|
|
11
|
+
lica_raw/utils.py,sha256=6_qRZMDxb320-oR2mVN1QP7jrKiqjGVuod35fxIQVSc,1221
|
|
12
|
+
lica_raw-0.1.1.dist-info/licenses/LICENSE,sha256=AAxFY4JFsUgATHGcfHy_aFXU7KbvycCxGfszmPiUCjA,1066
|
|
13
|
+
lica_raw-0.1.1.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
|
|
14
|
+
lica_raw-0.1.1.dist-info/METADATA,sha256=cqJb9zSGUBWbBu4uLWMQoTMPgkkWbjz4qX14iRvCSBk,10879
|
|
15
|
+
lica_raw-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guaix UCM
|
|
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.
|