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 +271 -0
- inkmap/assets/commons_qr.json +1514 -0
- inkmap/assets/default_qr.txt +31 -0
- inkmap/config.py +79 -0
- inkmap/coverage.py +343 -0
- inkmap/data.py +124 -0
- inkmap/geometry.py +211 -0
- inkmap/locate/__init__.py +117 -0
- inkmap/locate/barcode.py +159 -0
- inkmap/locate/qr.py +276 -0
- inkmap/models/__init__.py +34 -0
- inkmap/models/base.py +163 -0
- inkmap/models/freeform.py +179 -0
- inkmap/models/grid.py +100 -0
- inkmap/photometry.py +321 -0
- inkmap/synth.py +209 -0
- inkmap/types.py +207 -0
- inkmap/viz.py +158 -0
- inkmap-0.1.1.dist-info/METADATA +242 -0
- inkmap-0.1.1.dist-info/RECORD +22 -0
- inkmap-0.1.1.dist-info/WHEEL +4 -0
- inkmap-0.1.1.dist-info/licenses/LICENSE +21 -0
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}")
|