inkmap 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.
inkmap/__init__.py ADDED
@@ -0,0 +1,271 @@
1
+ """inkmap -- per-pixel foreground coverage for photographed marks.
2
+
3
+ Given a photograph of a printed mark and a *model* of its shape, say for every pixel
4
+ how much of it is foreground (the mark) and how much is background (the substrate) --
5
+ as a continuous fraction, not a boolean.
6
+
7
+ The model is the seam. QR symbols, barcodes, printed letters and arbitrary artwork all
8
+ go through one pipeline, because all a model has to do is answer "is this point, in my
9
+ own coordinate system, foreground?"::
10
+
11
+ import inkmap
12
+ from inkmap import models
13
+
14
+ inkmap.find(photo) # auto-detect QR/barcode
15
+ inkmap.find(photo, model=models.text_model("ABC"), quad=q) # a printed word
16
+ inkmap.find(photo, model=models.RasterModel(art), quad=q) # anything you can draw
17
+
18
+ The simplest call::
19
+
20
+ coverage = inkmap.foreground(photo) # (H, W) float32 in [0, 1]
21
+
22
+ With geometry::
23
+
24
+ ink = inkmap.find(photo) # an InkMap, or None
25
+ ink.placement.quad # (4, 2) corners -- a quadrilateral, not a
26
+ # rectangle: perspective is expected
27
+ ink.coverage # (H, W) float32 area coverage
28
+ ink.region # OUTSIDE / MARGIN / BACKGROUND / FOREGROUND
29
+ ink.weight # (H, W) float32 safe-to-measure weighting
30
+
31
+ Measuring::
32
+
33
+ mask = inkmap.measurement_mask(ink) # pure pixels, eroded clear of edges
34
+ result = inkmap.measure(photo, ink)
35
+ result.contrast # foreground minus background, linearised
36
+ result.measurability.ok # False if the image cannot support this
37
+
38
+ Terminology
39
+ -----------
40
+ The two classes are **foreground** and **background**, never dark and light: dark/light
41
+ describe appearance, and appearance inverts (white-on-black print, reflectance-reversed
42
+ symbols, inverted imaging), so an appearance name would mean the mark in one image and
43
+ the substrate in the next. :class:`Polarity` carries that mapping explicitly. The
44
+ continuous value is **coverage**, after ISO 12647-2:2013 3.1 *area coverage*. See
45
+ ``docs/terminology.md``.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import cv2
51
+ import numpy as np
52
+
53
+ from . import config, coverage as _coverage, geometry, models, photometry
54
+ from .coverage import confidence, coverage_map, measurement_weight, observed_coverage
55
+ from .locate import LOCATORS, locate, place
56
+ from .models import Model
57
+ from .photometry import (
58
+ Measurability,
59
+ Measurement,
60
+ measurability,
61
+ measure,
62
+ measurement_mask,
63
+ srgb_to_linear,
64
+ )
65
+ from .types import InkMap, Placement, Polarity, Region
66
+
67
+
68
+ def _version() -> str:
69
+ """Read the installed version, so it cannot drift from pyproject.toml."""
70
+ from importlib.metadata import PackageNotFoundError, version
71
+
72
+ try:
73
+ return version("inkmap")
74
+ except PackageNotFoundError: # running from a source tree, not installed
75
+ return "0.0.0.dev0"
76
+
77
+
78
+ __version__ = _version()
79
+
80
+ __all__ = [
81
+ # facade
82
+ "foreground",
83
+ "find",
84
+ "find_all",
85
+ "place",
86
+ # types
87
+ "InkMap",
88
+ "Placement",
89
+ "Region",
90
+ "Polarity",
91
+ "Model",
92
+ "Measurement",
93
+ "Measurability",
94
+ # measurement
95
+ "measure",
96
+ "measurement_mask",
97
+ "measurability",
98
+ "srgb_to_linear",
99
+ # pieces
100
+ "coverage_map",
101
+ "observed_coverage",
102
+ "confidence",
103
+ "measurement_weight",
104
+ "locate",
105
+ "LOCATORS",
106
+ "models",
107
+ "geometry",
108
+ "photometry",
109
+ "config",
110
+ ]
111
+
112
+
113
+ def _as_bgr(image) -> np.ndarray:
114
+ """Accept a path, a grayscale array, or a BGR/BGRA array; return BGR uint8."""
115
+ if not isinstance(image, np.ndarray):
116
+ img = cv2.imread(str(image), cv2.IMREAD_COLOR)
117
+ if img is None:
118
+ raise FileNotFoundError(f"could not read image: {image}")
119
+ return img
120
+ if image.ndim == 2:
121
+ return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
122
+ if image.shape[2] == 4:
123
+ return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
124
+ return image
125
+
126
+
127
+ def analyze(
128
+ image,
129
+ placements,
130
+ *,
131
+ polarity: Polarity = Polarity.FOREGROUND_IS_DARK,
132
+ samples: int = config.SUPERSAMPLES,
133
+ source: str = "modelled",
134
+ refine: bool = True,
135
+ estimate_blur: bool = True,
136
+ margin_units: float = config.MARGIN_UNITS,
137
+ ) -> list[InkMap]:
138
+ """Turn located placements into ink maps. The half of the pipeline after locating.
139
+
140
+ Separated from :func:`find_all` so that a placement obtained any other way -- from
141
+ :func:`place`, from your own detector, from a previous run -- gets exactly the same
142
+ treatment.
143
+ """
144
+ bgr = _as_bgr(image)
145
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
146
+
147
+ out: list[InkMap] = []
148
+ for placement in placements:
149
+ if refine:
150
+ placement = _refine(gray, placement)
151
+ cov = _coverage_for(
152
+ gray, placement, source=source, samples=samples, polarity=polarity
153
+ )
154
+ sigma = (
155
+ _coverage.estimate_blur_sigma(gray, placement) if estimate_blur else None
156
+ )
157
+ out.append(
158
+ InkMap(
159
+ coverage=cov,
160
+ region=_coverage.region_map(placement, cov, margin_units=margin_units),
161
+ placement=placement,
162
+ polarity=polarity,
163
+ weight=_coverage.measurement_weight(
164
+ cov,
165
+ blur_sigma=sigma
166
+ if sigma is not None
167
+ else config.DEFAULT_BLUR_SIGMA,
168
+ kind="foreground",
169
+ ),
170
+ blur_sigma=sigma,
171
+ )
172
+ )
173
+ return out
174
+
175
+
176
+ def find_all(
177
+ image,
178
+ *,
179
+ model: Model | None = None,
180
+ quad=None,
181
+ locators=config.DEFAULT_LOCATORS,
182
+ **kwargs,
183
+ ) -> list[InkMap]:
184
+ """Find every mark in ``image`` and compute its foreground coverage.
185
+
186
+ With ``model`` and ``quad``, skips detection entirely and analyses that one
187
+ placement -- the path for marks no detector knows about. With neither, runs the
188
+ locator cascade (QR and linear barcodes out of the box).
189
+
190
+ ``refine`` (default on) aligns the rendered model to the photograph, pinning the
191
+ transform to sub-pixel accuracy; it is far more accurate than locator corners alone
192
+ and costs a few milliseconds.
193
+
194
+ ``source`` picks where coverage comes from: ``"modelled"`` (default) computes it
195
+ from the model -- exact and lighting-independent, answering "where should the
196
+ foreground be". ``"observed"`` reads it from the photograph, answering "where is
197
+ the foreground actually", and so is the one that can see a printing defect.
198
+ """
199
+ if model is not None:
200
+ if quad is None:
201
+ raise ValueError(
202
+ "pass quad= alongside model= (four image points, model's corner order); "
203
+ "use inkmap.place() directly if you want the Placement itself"
204
+ )
205
+ placements = [place(model, quad)]
206
+ elif quad is not None:
207
+ raise ValueError("quad= needs model=")
208
+ else:
209
+ bgr = _as_bgr(image)
210
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
211
+ placements = locate(bgr, gray, locators=locators)
212
+
213
+ return analyze(image, placements, **kwargs)
214
+
215
+
216
+ def find(image, **kwargs) -> InkMap | None:
217
+ """The largest mark in ``image``, or ``None``. See :func:`find_all`."""
218
+ maps = find_all(image, **kwargs)
219
+ if not maps:
220
+ return None
221
+ return max(maps, key=lambda m: cv2.contourArea(m.placement.quad.astype(np.float32)))
222
+
223
+
224
+ def foreground(image, **kwargs) -> np.ndarray:
225
+ """Foreground coverage of the largest mark, ``(H, W) float32`` in ``[0, 1]``.
226
+
227
+ All-zero if nothing is found -- the one-liner never raises on a miss, so it can be
228
+ mapped over a directory. Use :func:`find` to tell "no mark" from "a mark with no
229
+ foreground".
230
+ """
231
+ ink = find(image, **kwargs)
232
+ if ink is None:
233
+ return np.zeros(_as_bgr(image).shape[:2], dtype=np.float32)
234
+ return ink.coverage
235
+
236
+
237
+ def _refine(gray: np.ndarray, placement: Placement) -> Placement:
238
+ """Sub-pixel-refine a placement's transform, keeping the original on failure."""
239
+ from dataclasses import replace
240
+
241
+ transform, score = geometry.refine_transform(
242
+ gray, placement.model, placement.transform
243
+ )
244
+ return replace(
245
+ placement,
246
+ transform=transform,
247
+ quad=geometry.transform_to_quad(transform, placement.model).astype(np.float32),
248
+ refined=bool(np.isfinite(score) and score >= config.ECC_MIN_CORRELATION),
249
+ score=None if not np.isfinite(score) else float(score),
250
+ )
251
+
252
+
253
+ def _coverage_for(
254
+ gray: np.ndarray,
255
+ placement: Placement,
256
+ *,
257
+ source: str,
258
+ samples: int,
259
+ polarity: Polarity,
260
+ ) -> np.ndarray:
261
+ if source == "modelled":
262
+ cov = coverage_map(
263
+ placement.model, placement.transform, gray.shape[:2], samples=samples
264
+ )
265
+ if polarity is Polarity.FOREGROUND_IS_LIGHT:
266
+ inside = _coverage._inside(placement, gray.shape[:2])
267
+ cov = np.where(inside, 1.0 - cov, 0.0).astype(np.float32)
268
+ return cov
269
+ if source == "observed":
270
+ return observed_coverage(gray, placement, polarity=polarity)
271
+ raise ValueError(f"source must be 'modelled' or 'observed', got {source!r}")