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 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,3 @@
1
+ from .factory import ImageReaderFactoryImpl
2
+
3
+ __all__ = ["ImageReaderFactoryImpl"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.