cardvision 0.4.0__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.
Files changed (146) hide show
  1. cardvision/__init__.py +4 -0
  2. cardvision/__main__.py +9 -0
  3. cardvision/augment/__init__.py +77 -0
  4. cardvision/augment/components.py +317 -0
  5. cardvision/augment/crop.py +255 -0
  6. cardvision/augment/physical.py +175 -0
  7. cardvision/bank/__init__.py +12 -0
  8. cardvision/bank/art.py +189 -0
  9. cardvision/bank/build.py +549 -0
  10. cardvision/bank/commands.py +405 -0
  11. cardvision/bank/convert.py +146 -0
  12. cardvision/bank/documents.py +61 -0
  13. cardvision/bank/refresh.py +294 -0
  14. cardvision/bank/release.py +858 -0
  15. cardvision/bank/suspect.py +236 -0
  16. cardvision/bank/views.py +135 -0
  17. cardvision/calibrate/__init__.py +60 -0
  18. cardvision/calibrate/cli.py +172 -0
  19. cardvision/calibrate/grid.py +162 -0
  20. cardvision/calibrate/report.py +308 -0
  21. cardvision/check_catalog.py +47 -0
  22. cardvision/cli.py +103 -0
  23. cardvision/composites/__init__.py +137 -0
  24. cardvision/composites/board.py +138 -0
  25. cardvision/composites/broadcast.py +296 -0
  26. cardvision/composites/cards.py +203 -0
  27. cardvision/composites/cc0_textures.json +212 -0
  28. cardvision/composites/closeup.py +130 -0
  29. cardvision/composites/commands.py +129 -0
  30. cardvision/composites/dataset.py +242 -0
  31. cardvision/composites/fetch.py +191 -0
  32. cardvision/composites/finish.py +130 -0
  33. cardvision/composites/geometry.py +118 -0
  34. cardvision/composites/photometric.py +156 -0
  35. cardvision/composites/scene.py +865 -0
  36. cardvision/composites/surfaces.py +128 -0
  37. cardvision/composites/textures.py +451 -0
  38. cardvision/composites/validate.py +208 -0
  39. cardvision/core/__init__.py +5 -0
  40. cardvision/core/bank.py +382 -0
  41. cardvision/core/bank_binary.py +206 -0
  42. cardvision/core/catalog.py +664 -0
  43. cardvision/core/checkout.py +42 -0
  44. cardvision/core/constants.py +15 -0
  45. cardvision/core/device.py +66 -0
  46. cardvision/core/files.py +61 -0
  47. cardvision/core/images.py +19 -0
  48. cardvision/core/onnx.py +106 -0
  49. cardvision/core/preprocess.py +175 -0
  50. cardvision/core/tls.py +52 -0
  51. cardvision/data/__init__.py +159 -0
  52. cardvision/data/commands.py +315 -0
  53. cardvision/data/dhash.py +80 -0
  54. cardvision/data/extract.py +654 -0
  55. cardvision/data/fetch.py +1020 -0
  56. cardvision/data/geometry.py +184 -0
  57. cardvision/data/manifest.py +142 -0
  58. cardvision/data/reachable.py +185 -0
  59. cardvision/data/roles.py +290 -0
  60. cardvision/data/schema.py +799 -0
  61. cardvision/data/sources.py +92 -0
  62. cardvision/data/targets.py +473 -0
  63. cardvision/data/validate.py +289 -0
  64. cardvision/detect/__init__.py +56 -0
  65. cardvision/detect/decode.py +192 -0
  66. cardvision/detect/detector.py +84 -0
  67. cardvision/detect/letterbox.py +69 -0
  68. cardvision/evaluate/__init__.py +182 -0
  69. cardvision/evaluate/browser.py +548 -0
  70. cardvision/evaluate/cli.py +333 -0
  71. cardvision/evaluate/config.py +315 -0
  72. cardvision/evaluate/crops.py +209 -0
  73. cardvision/evaluate/detector.py +532 -0
  74. cardvision/evaluate/events.py +414 -0
  75. cardvision/evaluate/evidence.py +148 -0
  76. cardvision/evaluate/hover.py +235 -0
  77. cardvision/evaluate/report.py +405 -0
  78. cardvision/evaluate/rows.py +170 -0
  79. cardvision/evaluate/scenes.py +177 -0
  80. cardvision/evaluate/scoring.py +298 -0
  81. cardvision/evaluate/stats.py +221 -0
  82. cardvision/export/__init__.py +12 -0
  83. cardvision/export/checkpoint.py +94 -0
  84. cardvision/export/cli.py +104 -0
  85. cardvision/export/detector.py +282 -0
  86. cardvision/export/onnx_tools.py +67 -0
  87. cardvision/export/recognizer.py +107 -0
  88. cardvision/export/torch_export.py +111 -0
  89. cardvision/fetch_weights.py +261 -0
  90. cardvision/geometric/__init__.py +35 -0
  91. cardvision/geometric/commands.py +155 -0
  92. cardvision/geometric/config.py +315 -0
  93. cardvision/geometric/features.py +130 -0
  94. cardvision/geometric/index.py +425 -0
  95. cardvision/geometric/pack.py +339 -0
  96. cardvision/geometric/stage.py +406 -0
  97. cardvision/import_catalog/__init__.py +20 -0
  98. cardvision/import_catalog/cli.py +100 -0
  99. cardvision/import_catalog/importer.py +984 -0
  100. cardvision/infer/__init__.py +56 -0
  101. cardvision/infer/cli.py +238 -0
  102. cardvision/infer/contact_sheet.py +252 -0
  103. cardvision/infer/frames.py +232 -0
  104. cardvision/infer/pipeline.py +357 -0
  105. cardvision/infer/prefill.py +83 -0
  106. cardvision/infer/render.py +94 -0
  107. cardvision/next_step.py +951 -0
  108. cardvision/recognize/__init__.py +71 -0
  109. cardvision/recognize/accept.py +205 -0
  110. cardvision/recognize/crops.py +58 -0
  111. cardvision/recognize/looks.py +119 -0
  112. cardvision/recognize/recognizer.py +342 -0
  113. cardvision/release_assets.py +32 -0
  114. cardvision/train_detector/__init__.py +55 -0
  115. cardvision/train_detector/augment.py +265 -0
  116. cardvision/train_detector/cli.py +114 -0
  117. cardvision/train_detector/config.py +412 -0
  118. cardvision/train_detector/default.toml +87 -0
  119. cardvision/train_detector/loss.py +185 -0
  120. cardvision/train_detector/model.py +256 -0
  121. cardvision/train_detector/sources.py +486 -0
  122. cardvision/train_detector/trainer.py +258 -0
  123. cardvision/train_recognizer/__init__.py +33 -0
  124. cardvision/train_recognizer/cli.py +88 -0
  125. cardvision/train_recognizer/config.py +503 -0
  126. cardvision/train_recognizer/dataset.py +537 -0
  127. cardvision/train_recognizer/default.toml +144 -0
  128. cardvision/train_recognizer/loop.py +343 -0
  129. cardvision/train_recognizer/losses.py +117 -0
  130. cardvision/train_recognizer/model.py +148 -0
  131. cardvision/train_recognizer/sources.py +699 -0
  132. cardvision/train_recognizer/validate.py +101 -0
  133. cardvision/try_game/__init__.py +8 -0
  134. cardvision/try_game/cli.py +66 -0
  135. cardvision/try_game/report.py +363 -0
  136. cardvision/try_game/run.py +651 -0
  137. cardvision-0.4.0.dist-info/METADATA +176 -0
  138. cardvision-0.4.0.dist-info/RECORD +146 -0
  139. cardvision-0.4.0.dist-info/WHEEL +5 -0
  140. cardvision-0.4.0.dist-info/entry_points.txt +2 -0
  141. cardvision-0.4.0.dist-info/licenses/LICENSE +201 -0
  142. cardvision-0.4.0.dist-info/licenses/LICENSE-DATA +449 -0
  143. cardvision-0.4.0.dist-info/licenses/NOTICE +225 -0
  144. cardvision-0.4.0.dist-info/licenses/PROVENANCE.md +144 -0
  145. cardvision-0.4.0.dist-info/licenses/TRADEMARK.md +33 -0
  146. cardvision-0.4.0.dist-info/top_level.txt +1 -0
cardvision/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Card detection and recognition for trading-card game video."""
3
+
4
+ __version__ = "0.4.0"
cardvision/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Run the command line with ``python -m cardvision``."""
3
+
4
+ import sys
5
+
6
+ from cardvision.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Training-time augmentations for card crops and card images.
3
+
4
+ Three groups, each in its own module:
5
+
6
+ ``crop``
7
+ The plain chain for a crop cut from a frame (frame jitter, background swap, cutout,
8
+ resolution jitter, photometric, JPEG, the center-mask input variant), the chain for a clean
9
+ card image (``augment_art``), and ``on_surface``, which gives a card image the form of a
10
+ crop.
11
+ ``physical``
12
+ The physical chain (print effects, sleeve, streaks and foil, tilt, lamp glare, defocus,
13
+ exposure) and ``motion_blur``. It is built on the photometric effects and textures of
14
+ ``cardvision.composites``; which card kinds carry dice or are often foil comes from the
15
+ catalog's ``kinds`` settings.
16
+ ``components``
17
+ Occluders, glare, low light and wider rotation: separately switchable, each with its own
18
+ random stream and each bounded so that the card's name and art stay readable.
19
+
20
+ Everything takes and returns ``(H, W, 3)`` uint8 RGB arrays and an explicit random stream: the
21
+ same stream state always gives the same pixels. Only NumPy and Pillow are needed.
22
+
23
+ The order in which a training recipe applies them is in ``cardvision.train_recognizer.dataset``.
24
+ """
25
+
26
+ from cardvision.augment.components import (
27
+ DEFAULTS,
28
+ glare,
29
+ identity_region,
30
+ low_light,
31
+ occluders,
32
+ rotation,
33
+ row_seed,
34
+ stream_for,
35
+ )
36
+ from cardvision.augment.crop import (
37
+ CropAugment,
38
+ augment_art,
39
+ augment_crop,
40
+ background_swap,
41
+ center_mask,
42
+ cutout,
43
+ frame_jitter,
44
+ jpeg,
45
+ on_surface,
46
+ photometric,
47
+ resolution_jitter,
48
+ )
49
+ from cardvision.augment.physical import PHYSICAL, lamp_glare, motion_blur, physical_chain, rotate, sleeve
50
+
51
+ __all__ = [
52
+ "DEFAULTS",
53
+ "PHYSICAL",
54
+ "CropAugment",
55
+ "augment_art",
56
+ "augment_crop",
57
+ "background_swap",
58
+ "center_mask",
59
+ "cutout",
60
+ "frame_jitter",
61
+ "glare",
62
+ "identity_region",
63
+ "jpeg",
64
+ "lamp_glare",
65
+ "low_light",
66
+ "motion_blur",
67
+ "occluders",
68
+ "on_surface",
69
+ "photometric",
70
+ "physical_chain",
71
+ "resolution_jitter",
72
+ "rotate",
73
+ "rotation",
74
+ "row_seed",
75
+ "sleeve",
76
+ "stream_for",
77
+ ]
@@ -0,0 +1,317 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Targeted augmentation components: occluders, glare, low light and wider rotation.
3
+
4
+ Each one is a named, separately switchable component of a training recipe. Three rules hold for
5
+ all of them:
6
+
7
+ * A component draws from its own random stream, made by ``stream_for`` from the training seed,
8
+ the epoch, the row's index and the component's name. It never draws from the stream of the
9
+ base chain, so switching occluders, glare or low light on or off changes nothing else about
10
+ a row. Rotation changes the size of the crop that is cut, and a few later draws of the base
11
+ chain depend on that size, so with rotation the rest of the row may differ too.
12
+ * A component knows where a card's identity is: the name and the art (``identity_region``).
13
+ No occluder may cover, and no glare may burn out, more than a stated share of it, and low
14
+ light may not darken it below a stated level. A crop that cannot be told apart is not a
15
+ useful training example.
16
+ * Occluders are applied to positives and negatives alike, so that an occluder is never a clue
17
+ to the class. The one exception is stated at ``occluders``.
18
+
19
+ ``DEFAULTS`` holds each component's settings. The functions return ``(image, info)``, where
20
+ ``info`` is None when the component's own probability left the image alone and otherwise
21
+ records what was drawn.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import random
28
+ from collections.abc import Mapping
29
+ from typing import Any
30
+
31
+ import numpy as np
32
+ from PIL import Image, ImageDraw, ImageFilter
33
+
34
+ from cardvision.augment.physical import rotate
35
+
36
+ DEFAULTS: dict[str, dict[str, Any]] = {
37
+ # Something lying over part of the card: another card, fingers, a counter or die, or the
38
+ # edge of a sleeve. "area_max" is the largest share of the crop all occluders of one draw
39
+ # may cover together; "identity_cover_max" the largest share of the identity region.
40
+ "occluders": {"p": 0.35, "types": {"card": 0.40, "fingers": 0.30, "disc": 0.20, "sleeve_edge": 0.10},
41
+ "area_max": 0.35, "identity_cover_max": 0.40, "tries": 6},
42
+ # Light on a glossy or foil surface: a streak, a bloom, a rainbow foil sheen or a washed-out
43
+ # patch. "identity_saturated_max" is the largest share of identity-region pixels that may
44
+ # end up at 250 or more in every channel; a stronger draw is weakened until it fits.
45
+ "glare": {"p": 0.30, "types": {"streak": 0.35, "bloom": 0.25, "foil": 0.25, "washout": 0.15},
46
+ "streak_amp": (0.25, 0.70), "bloom_amp": (0.30, 0.80), "foil_mix": (0.05, 0.15),
47
+ "washout": (0.25, 0.55), "identity_saturated_max": 0.25, "tries": 4},
48
+ # A dim scene: gain, gamma, white balance (warm or cool), a green or magenta tint, and the
49
+ # sensor noise that comes with a dark picture. "identity_mean_min" is the floor on the
50
+ # identity region's mean level (0 to 1) after gain and gamma.
51
+ "low_light": {"p": 0.40, "gain": (0.45, 1.0), "gamma": (0.8, 1.5), "wb": 0.18, "tint": 0.05,
52
+ "noise_base": (1.0, 4.0), "chroma_share": 0.5, "identity_mean_min": 0.10},
53
+ # A tilt beyond the physical chain's: applied to the image with room around the card,
54
+ # before the crop is framed, so the crop is the upright box around the tilted card, as a
55
+ # detector's box is. Quarter turns are not this component's business.
56
+ "rotation": {"p": 0.30, "abs_deg": (15.0, 45.0)},
57
+ }
58
+
59
+ # One arbitrary constant per stream. They keep the streams apart; their values mean nothing,
60
+ # but they are fixed, so that a seed reproduces a run.
61
+ SALT = {"occluders": 0x0C1, "glare": 0x61A, "low_light": 0x10F, "rotation": 0x207, "surface": 0x5F3}
62
+
63
+
64
+ def row_seed(seed: int, epoch: int, index: int) -> int:
65
+ """The seed of a row's base stream: one stream per training seed, epoch and row index."""
66
+ return (seed * 1_000_003 + epoch * 10_007 + index) & 0x7FFFFFFF
67
+
68
+
69
+ def stream_for(seed: int, epoch: int, index: int, name: str) -> random.Random:
70
+ """A component's own stream for one row, fixed by the training seed, the epoch, the row's
71
+ index and the component's name."""
72
+ return random.Random(((seed * 1_000_003 + epoch * 10_007 + index) * 1_315_423_911 + SALT[name]) & 0x7FFFFFFFFFFF)
73
+
74
+
75
+ def identity_region(h: int, w: int) -> tuple[int, int, int, int]:
76
+ """The name and art of an upright card crop, as ``(x0, y0, x1, y1)``.
77
+
78
+ On a portrait card they fill the upper part; on a card printed sideways (a crop clearly
79
+ wider than tall) the art spans the card and the name sits low in the middle.
80
+ """
81
+ if w > h * 1.15:
82
+ return int(0.10 * w), int(0.08 * h), int(0.90 * w), int(0.80 * h)
83
+ return int(0.12 * w), int(0.07 * h), int(0.88 * w), int(0.62 * h)
84
+
85
+
86
+ def _pick(rng: random.Random, weights: Mapping[str, float]) -> str:
87
+ r, acc = rng.random() * sum(weights.values()), 0.0
88
+ for k, v in weights.items():
89
+ acc += v
90
+ if r <= acc:
91
+ return k
92
+ return k
93
+
94
+
95
+ # ------------------------------------------------------------------------------- rotation
96
+ def rotation(img: np.ndarray, inner, rng: random.Random, cfg: Mapping[str, Any] = DEFAULTS["rotation"]):
97
+ """Tilt the image about the card's center by an angle whose size is drawn from
98
+ ``abs_deg``, either way. Returns ``(image, inner, degrees)``; 0.0 degrees when the
99
+ component's probability left the image alone."""
100
+ if rng.random() >= cfg["p"]:
101
+ return img, inner, 0.0
102
+ deg = rng.uniform(*cfg["abs_deg"]) * (1 if rng.random() < 0.5 else -1)
103
+ img, inner = rotate(img, inner, deg)
104
+ return img, inner, deg
105
+
106
+
107
+ # ---------------------------------------------------------------------------------- glare
108
+ def _grid(h: int, w: int):
109
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
110
+ return yy, xx
111
+
112
+
113
+ def _glare_layer(h: int, w: int, kind: str, rng: random.Random, cfg: Mapping[str, Any]):
114
+ """The layer of one glare type, float ``(h, w, 3)`` or ``(h, w, 1)`` in [0, 1], and how it
115
+ is mixed in: ``"add"``, ``"wash"`` or ``("foil", share)``."""
116
+ yy, xx = _grid(h, w)
117
+ rng.getrandbits(32) # a draw that nothing reads: removing it would change every later draw of a seed
118
+ if kind == "streak": # a long thin bright band at an angle
119
+ th = rng.uniform(0, math.pi)
120
+ c, s = math.cos(th), math.sin(th)
121
+ d = (xx - rng.uniform(0.2, 0.8) * w) * s - (yy - rng.uniform(0.2, 0.8) * h) * c
122
+ width = rng.uniform(0.02, 0.10) * max(h, w)
123
+ g = np.exp(-(d / width) ** 2)
124
+ along = (xx - w / 2) * c + (yy - h / 2) * s
125
+ g *= np.exp(-(along / (rng.uniform(0.4, 1.2) * max(h, w))) ** 2)
126
+ col = np.array([1.0, rng.uniform(0.94, 1.0), rng.uniform(0.85, 1.0)], np.float32)
127
+ return g[..., None] * col * rng.uniform(*cfg["streak_amp"]), "add"
128
+ if kind == "bloom": # a lamp's reflection: a bright core with a soft halo
129
+ gx, gy = rng.uniform(0.1, 0.9) * w, rng.uniform(0.1, 0.9) * h
130
+ sig = rng.uniform(0.06, 0.22) * max(h, w)
131
+ dist2 = (xx - gx) ** 2 + (yy - gy) ** 2
132
+ g = np.exp(-dist2 / (2 * sig * sig)) + 0.35 * np.exp(-dist2 / (2 * (2.5 * sig) ** 2))
133
+ return g[..., None] * np.ones(3, np.float32) * rng.uniform(*cfg["bloom_amp"]), "add"
134
+ if kind == "foil": # a rainbow that runs across the card
135
+ th = rng.uniform(0, 2 * math.pi)
136
+ g = ((xx * math.cos(th) + yy * math.sin(th)) / max(h, w)) % 1.0
137
+ hue = (g * rng.uniform(1, 3) + rng.random()) % 1.0
138
+ rain = np.stack([np.clip(np.abs(((hue + o) % 1.0) * 6 - 3) - 1, 0, 1) for o in (0, 2 / 3, 1 / 3)], -1)
139
+ return rain.astype(np.float32), ("foil", rng.uniform(*cfg["foil_mix"]))
140
+ # washout: contrast lost towards a bright haze, as on a glossy sleeve under a lamp
141
+ gx, gy = rng.uniform(0.15, 0.85) * w, rng.uniform(0.15, 0.85) * h
142
+ sig = rng.uniform(0.15, 0.35) * max(h, w)
143
+ g = np.exp(-((xx - gx) ** 2 + (yy - gy) ** 2) / (2 * sig * sig)) * rng.uniform(*cfg["washout"])
144
+ return g[..., None].astype(np.float32), "wash"
145
+
146
+
147
+ def glare(crop: np.ndarray, rng: random.Random, cfg: Mapping[str, Any] = DEFAULTS["glare"]):
148
+ """One glare of a random type over the crop. If it burns out too much of the identity
149
+ region, it is weakened (by 0.6 each time, up to ``tries`` times)."""
150
+ if rng.random() >= cfg["p"]:
151
+ return crop, None
152
+ h, w = crop.shape[:2]
153
+ kind = _pick(rng, cfg["types"])
154
+ layer, mode = _glare_layer(h, w, kind, rng, cfg)
155
+ x0, y0, x1, y1 = identity_region(h, w)
156
+ base = crop.astype(np.float32) / 255.0
157
+ scale = 1.0
158
+ for _ in range(cfg["tries"]):
159
+ if mode == "add":
160
+ out = base + scale * layer
161
+ elif mode == "wash":
162
+ a = scale * layer
163
+ out = base * (1 - a) + (0.5 * base.mean((0, 1), keepdims=True) + 0.5) * a
164
+ else:
165
+ k = scale * mode[1]
166
+ out = base * (1 - k) + layer * k
167
+ out = np.clip(out, 0, 1)
168
+ sat = (out[y0:y1, x0:x1] >= 250 / 255).all(-1).mean() if y1 > y0 and x1 > x0 else 0.0
169
+ if sat <= cfg["identity_saturated_max"]:
170
+ break
171
+ scale *= 0.6
172
+ return np.round(out * 255).astype(np.uint8), {"kind": kind, "scale": round(scale, 3)}
173
+
174
+
175
+ # ------------------------------------------------------------------------------ occluders
176
+ def _skin(rng: random.Random) -> np.ndarray:
177
+ tone = rng.uniform(0, 1) # from light to dark skin, under warm or cool light
178
+ base = np.array([235, 190, 160]) * (1 - tone) + np.array([95, 60, 40]) * tone
179
+ return np.clip(base * rng.uniform(0.6, 1.1), 0, 255)
180
+
181
+
182
+ def _shape_mask(h: int, w: int, kind: str, rng: random.Random, other_art: np.ndarray | None = None):
183
+ """One occluder: ``(mask, fill, shadow)``. ``mask`` is float ``(h, w)`` in [0, 1],
184
+ ``fill`` the occluder's colors as float ``(h, w, 3)`` on the 0..255 scale."""
185
+ m = Image.new("L", (w, h), 0)
186
+ d = ImageDraw.Draw(m)
187
+ fill = np.zeros((h, w, 3), np.float32)
188
+ if kind == "card": # another card sliding in from one edge
189
+ cw, ch = int(w * rng.uniform(0.8, 1.1)), int(h * rng.uniform(0.8, 1.1))
190
+ side = rng.randrange(4)
191
+ depth = rng.uniform(0.12, 0.40)
192
+ if side == 0:
193
+ ox, oy = int(rng.uniform(-0.3, 0.3) * w), int(-ch + depth * h)
194
+ elif side == 1:
195
+ ox, oy = int(rng.uniform(-0.3, 0.3) * w), int(h - depth * h)
196
+ elif side == 2:
197
+ ox, oy = int(-cw + depth * w), int(rng.uniform(-0.3, 0.3) * h)
198
+ else:
199
+ ox, oy = int(w - depth * w), int(rng.uniform(-0.3, 0.3) * h)
200
+ ang = rng.uniform(-20, 20)
201
+ if other_art is not None:
202
+ oa = Image.fromarray(other_art).resize((max(2, cw), max(2, ch)), Image.BILINEAR)
203
+ else:
204
+ oa = Image.new("RGB", (max(2, cw), max(2, ch)), tuple(int(v) for v in np.array([40, 40, 50]) * rng.uniform(0.5, 2)))
205
+ om = Image.new("L", oa.size, 255)
206
+ oa, om = oa.rotate(ang, resample=Image.BILINEAR, expand=True), om.rotate(ang, resample=Image.BILINEAR, expand=True)
207
+ canvas = Image.new("RGB", (w, h))
208
+ canvas.paste(oa, (ox, oy))
209
+ m.paste(om, (ox, oy))
210
+ fill = np.asarray(canvas).astype(np.float32)
211
+ # a thin shadow under the covering card's edge
212
+ sh = np.asarray(m.filter(ImageFilter.GaussianBlur(max(1, w * 0.02)))).astype(np.float32) / 255
213
+ return np.clip(np.asarray(m).astype(np.float32) / 255, 0, 1), fill, sh
214
+ if kind == "fingers": # one to four fingers reaching in from one edge
215
+ side = rng.randrange(4)
216
+ n = rng.randint(1, 4)
217
+ fw = rng.uniform(0.10, 0.17) * min(h, w) * (1.6 if w > h else 1)
218
+ col = _skin(rng)
219
+ start = rng.uniform(0.15, 0.85)
220
+ for k in range(n):
221
+ t = start + (k - (n - 1) / 2) * fw * 1.05 / (w if side in (0, 1) else h)
222
+ L = rng.uniform(0.15, 0.40) * (h if side in (0, 1) else w)
223
+ if side == 0:
224
+ box = (t * w - fw / 2, -fw, t * w + fw / 2, L)
225
+ elif side == 1:
226
+ box = (t * w - fw / 2, h - L, t * w + fw / 2, h + fw)
227
+ elif side == 2:
228
+ box = (-fw, t * h - fw / 2, L, t * h + fw / 2)
229
+ else:
230
+ box = (w - L, t * h - fw / 2, w + fw, t * h + fw / 2)
231
+ d.rounded_rectangle(box, radius=fw / 2, fill=255)
232
+ mk = np.asarray(m.filter(ImageFilter.GaussianBlur(max(0.6, fw * 0.08)))).astype(np.float32) / 255
233
+ soft = np.asarray(m.filter(ImageFilter.GaussianBlur(max(1.0, fw * 0.35)))).astype(np.float32) / 255
234
+ shade = 0.70 + 0.35 * soft # rounded: darker towards a finger's edges
235
+ fill = (col[None, None, :] * shade[..., None]).astype(np.float32)
236
+ return mk, fill, mk
237
+ if kind == "disc": # counters or dice: one to three discs, lit from above
238
+ n = rng.randint(1, 3)
239
+ col = np.array([rng.randint(0, 255) for _ in range(3)], np.float32)
240
+ for _ in range(n):
241
+ r = rng.uniform(0.07, 0.15) * w
242
+ cx, cy = rng.uniform(0, w), rng.uniform(0, h)
243
+ d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=255)
244
+ mk = np.asarray(m).astype(np.float32) / 255
245
+ yy, xx = _grid(h, w)
246
+ fill = np.ones((h, w, 3), np.float32) * col
247
+ fill = fill * (0.8 + 0.3 * (1 - yy / max(h, 1)))[..., None]
248
+ return mk, np.clip(fill, 0, 255), mk
249
+ # sleeve_edge: a colored or glossy band along one or two edges
250
+ col = np.array([rng.randint(0, 255) for _ in range(3)], np.float32)
251
+ for side in rng.sample(range(4), rng.randint(1, 2)):
252
+ b = rng.uniform(0.04, 0.10) * min(h, w)
253
+ box = [(0, 0, w, b), (0, h - b, w, h), (0, 0, b, h), (w - b, 0, w, h)][side]
254
+ d.rectangle(box, fill=int(255 * rng.uniform(0.55, 1.0)))
255
+ mk = np.asarray(m).astype(np.float32) / 255
256
+ return mk, np.ones((h, w, 3), np.float32) * col, mk
257
+
258
+
259
+ def occluders(crop: np.ndarray, rng: random.Random, cfg: Mapping[str, Any] = DEFAULTS["occluders"],
260
+ other_art: np.ndarray | None = None, negative: bool = False):
261
+ """Lay one occluder of a random type over the crop.
262
+
263
+ ``other_art`` is another card's image for the ``card`` type (a dark rectangle stands in
264
+ without one). A draw that covers too much of the crop or of the identity region, or almost
265
+ nothing, is drawn again, up to ``tries`` times; after that the crop is returned as it was
266
+ and ``info`` says ``rejected``.
267
+
268
+ On a negative row (``negative=True``) the ``card`` type is never used: a card face pasted
269
+ onto a negative would teach the model to answer "no card" to card faces.
270
+ """
271
+ if rng.random() >= cfg["p"]:
272
+ return crop, None
273
+ h, w = crop.shape[:2]
274
+ x0, y0, x1, y1 = identity_region(h, w)
275
+ types = {k: v for k, v in cfg["types"].items() if not (negative and k == "card")}
276
+ kind = _pick(rng, types)
277
+ for t in range(cfg["tries"]):
278
+ mk, fill, sh = _shape_mask(h, w, kind, rng, other_art if kind == "card" else None)
279
+ area = float((mk > 0.5).mean())
280
+ idc = float((mk[y0:y1, x0:x1] > 0.5).mean()) if y1 > y0 and x1 > x0 else 0.0
281
+ if area <= cfg["area_max"] and idc <= cfg["identity_cover_max"] and area > 0.01:
282
+ out = crop.astype(np.float32)
283
+ if kind == "card": # the drop shadow first, then the covering card
284
+ out = out * (1 - 0.35 * np.clip(sh - mk, 0, 1))[..., None]
285
+ out = out * (1 - mk[..., None]) + fill * mk[..., None]
286
+ return np.clip(out, 0, 255).astype(np.uint8), {"kind": kind, "area": round(area, 3),
287
+ "identityCover": round(idc, 3), "try": t}
288
+ return crop, {"kind": kind, "rejected": True}
289
+
290
+
291
+ # ------------------------------------------------------------------------------ low light
292
+ def low_light(crop: np.ndarray, rng: random.Random, cfg: Mapping[str, Any] = DEFAULTS["low_light"]):
293
+ """Darken the crop as a dim room does: gamma, gain and white balance, then noise that is
294
+ stronger the lower the gain. The identity region's mean level is not taken below
295
+ ``identity_mean_min``."""
296
+ if rng.random() >= cfg["p"]:
297
+ return crop, None
298
+ f = crop.astype(np.float32) / 255.0
299
+ gain = rng.uniform(*cfg["gain"])
300
+ gamma = rng.uniform(*cfg["gamma"])
301
+ t = rng.uniform(-cfg["wb"], cfg["wb"]) # positive: warm light; negative: cool light
302
+ tint = rng.uniform(-cfg["tint"], cfg["tint"])
303
+ wb = np.array([1 + t, 1 + tint, 1 - t], np.float32)
304
+ f = np.clip(f, 0, 1) ** gamma * gain * wb
305
+ h, w = f.shape[:2]
306
+ x0, y0, x1, y1 = identity_region(h, w)
307
+ m = float(f[y0:y1, x0:x1].mean()) if y1 > y0 and x1 > x0 else float(f.mean())
308
+ if m < cfg["identity_mean_min"] and m > 0:
309
+ gain *= cfg["identity_mean_min"] / m
310
+ f = f * (cfg["identity_mean_min"] / m)
311
+ sigma = rng.uniform(*cfg["noise_base"]) / math.sqrt(max(gain, 0.2)) / 255.0
312
+ nrng = np.random.default_rng(rng.getrandbits(32))
313
+ lum = nrng.normal(0, sigma, f.shape[:2])[..., None]
314
+ chroma = nrng.normal(0, sigma * cfg["chroma_share"], f.shape)
315
+ f = f + lum + chroma
316
+ out = np.clip(np.round(np.clip(f, 0, 1) * 255), 0, 255).astype(np.uint8)
317
+ return out, {"gain": round(gain, 3), "gamma": round(gamma, 3), "wb": round(t, 3), "sigma255": round(sigma * 255, 2)}
@@ -0,0 +1,255 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Crop and card-image augmentations for recognizer training.
3
+
4
+ Every function works on ``(H, W, 3)`` uint8 RGB arrays at the crop's own resolution, before the
5
+ preprocessing contract pools it to the model's input, and draws from the ``random.Random`` it
6
+ is given, so a seed fixes the result. Each can be switched off in ``CropAugment``.
7
+
8
+ What each one stands for in real footage:
9
+
10
+ ``frame_jitter``
11
+ A detector's box is never exactly the card: the crop is cut a little off-center, a little
12
+ too large or small, a little too wide or tall. Needs a crop with room around the card and
13
+ the card's box inside it (``inner``).
14
+ ``resolution_jitter``
15
+ Cards are seen at many sizes; the crop is reduced by a random factor.
16
+ ``background_swap``
17
+ What surrounds and shows around a card varies; the crop's border is replaced by a patch of
18
+ some other background.
19
+ ``cutout``
20
+ Another object covers part of the card: a dark, or sometimes light, rectangle.
21
+ ``jpeg``
22
+ Video compression.
23
+ ``photometric``
24
+ Lighting and color: brightness, contrast, saturation, sometimes blur and noise.
25
+ ``center_mask``
26
+ An input variant, not an augmentation: the border is painted gray. It is applied at
27
+ training and at inference alike when a model is built that way. The ``full128`` contract
28
+ does not use it.
29
+
30
+ A card is not flipped or turned by any of these: orientation is handled outside the model, by
31
+ trying the four quarter turns.
32
+
33
+ ``augment_art`` is the chain for a clean card image, the other half of a training pair.
34
+ ``on_surface`` places a card image on a background with room around it, which gives it the
35
+ form of a crop cut from a frame.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import io
41
+ import random
42
+ from dataclasses import dataclass
43
+
44
+ import numpy as np
45
+ from PIL import Image, ImageEnhance, ImageFilter
46
+
47
+
48
+ @dataclass
49
+ class CropAugment:
50
+ """Which augmentations run, and their ranges.
51
+
52
+ Attributes:
53
+ frame_jitter, res_jitter, bg_swap, cutout, jpeg, photometric: switches.
54
+ center_mask: paint the border gray (an input variant; see the module docstring).
55
+ jitter_translate: the center moves by up to this fraction of the box's size.
56
+ jitter_scale: the box is scaled by a factor in this range.
57
+ jitter_aspect: and its width over height by a factor in this range.
58
+ res_range: the crop is reduced to a fraction of its size in this range.
59
+ bg_swap_p: probability of a background swap.
60
+ bg_keep: the central fraction of the crop, per axis, that a swap keeps.
61
+ cutout_p: probability of a cutout.
62
+ cutout_max: the largest cutout, as a fraction of the crop's area.
63
+ jpeg_p: probability of JPEG compression.
64
+ jpeg_q: its quality range.
65
+ center_keep: the central fraction, per axis, that the center mask keeps.
66
+ brightness: range of the brightness factor.
67
+ cutout_light_p: probability that a cutout is light instead of dark.
68
+ """
69
+
70
+ frame_jitter: bool = True
71
+ res_jitter: bool = True
72
+ bg_swap: bool = True
73
+ cutout: bool = True
74
+ jpeg: bool = True
75
+ photometric: bool = True
76
+ center_mask: bool = False
77
+ jitter_translate: float = 0.15
78
+ jitter_scale: tuple = (0.8, 1.25)
79
+ jitter_aspect: tuple = (0.9, 1.1)
80
+ res_range: tuple = (0.35, 1.0)
81
+ bg_swap_p: float = 0.35
82
+ bg_keep: float = 0.80
83
+ cutout_p: float = 0.30
84
+ cutout_max: float = 0.30
85
+ jpeg_p: float = 0.5
86
+ jpeg_q: tuple = (40, 90)
87
+ center_keep: float = 0.72
88
+ brightness: tuple = (0.8, 1.2)
89
+ cutout_light_p: float = 0.0
90
+
91
+
92
+ def frame_jitter(img: np.ndarray, inner, rng: random.Random, cfg: CropAugment) -> np.ndarray:
93
+ """Cut a jittered card box out of an image that has room around the card.
94
+
95
+ ``inner`` is the card's box ``(x, y, width, height)`` in ``img``. With the jitter off, the
96
+ box itself is cut. The cut is clipped to the image and is at least 2 px on each side.
97
+ """
98
+ H, W = img.shape[:2]
99
+ x, y, w, h = inner
100
+ if cfg.frame_jitter:
101
+ s = rng.uniform(*cfg.jitter_scale)
102
+ a = rng.uniform(*cfg.jitter_aspect)
103
+ nw, nh = w * s * a, h * s / a
104
+ cx = x + w / 2 + rng.uniform(-cfg.jitter_translate, cfg.jitter_translate) * w
105
+ cy = y + h / 2 + rng.uniform(-cfg.jitter_translate, cfg.jitter_translate) * h
106
+ else:
107
+ nw, nh, cx, cy = w, h, x + w / 2, y + h / 2
108
+ x0 = int(np.clip(cx - nw / 2, 0, W - 2))
109
+ y0 = int(np.clip(cy - nh / 2, 0, H - 2))
110
+ x1 = int(np.clip(cx + nw / 2, x0 + 2, W))
111
+ y1 = int(np.clip(cy + nh / 2, y0 + 2, H))
112
+ return img[y0:y1, x0:x1]
113
+
114
+
115
+ def resolution_jitter(crop: np.ndarray, rng: random.Random, cfg: CropAugment) -> np.ndarray:
116
+ """Reduce the crop by a random factor (bilinear), never below 12 px on a side."""
117
+ if not cfg.res_jitter:
118
+ return crop
119
+ s = rng.uniform(*cfg.res_range)
120
+ h, w = crop.shape[:2]
121
+ nw, nh = max(12, int(w * s)), max(12, int(h * s))
122
+ return np.asarray(Image.fromarray(crop).resize((nw, nh), Image.BILINEAR))
123
+
124
+
125
+ def _window(patch: np.ndarray, w: int, h: int, rng: random.Random) -> np.ndarray:
126
+ """A ``(h, w)`` piece of a background patch: a random window where the patch is larger,
127
+ the whole patch resized where it is not."""
128
+ ph, pw = patch.shape[:2]
129
+ px = rng.randint(0, max(0, pw - w)) if pw > w else 0
130
+ py = rng.randint(0, max(0, ph - h)) if ph > h else 0
131
+ canvas = patch[py : py + h, px : px + w]
132
+ if canvas.shape[:2] != (h, w):
133
+ canvas = np.asarray(Image.fromarray(patch).resize((w, h), Image.BILINEAR))
134
+ return canvas.copy()
135
+
136
+
137
+ def background_swap(crop: np.ndarray, patch: np.ndarray | None, rng: random.Random, cfg: CropAugment) -> np.ndarray:
138
+ """Replace the crop's border with another background.
139
+
140
+ The canvas is a piece of ``patch`` of the crop's size, and the central ``bg_keep`` of the
141
+ crop is pasted onto it. Nothing happens without a patch.
142
+ """
143
+ if not cfg.bg_swap or patch is None or rng.random() > cfg.bg_swap_p:
144
+ return crop
145
+ h, w = crop.shape[:2]
146
+ canvas = _window(patch, w, h, rng)
147
+ kx, ky = int(w * (1 - cfg.bg_keep) / 2), int(h * (1 - cfg.bg_keep) / 2)
148
+ canvas[ky : h - ky, kx : w - kx] = crop[ky : h - ky, kx : w - kx]
149
+ return canvas
150
+
151
+
152
+ def cutout(crop: np.ndarray, rng: random.Random, cfg: CropAugment) -> np.ndarray:
153
+ """Paint one rectangle of a flat dark (or, with ``cutout_light_p``, light) gray."""
154
+ if not cfg.cutout or rng.random() > cfg.cutout_p:
155
+ return crop
156
+ h, w = crop.shape[:2]
157
+ area = rng.uniform(0.05, cfg.cutout_max)
158
+ cw = int(w * np.sqrt(area) * rng.uniform(0.7, 1.4))
159
+ ch = int(h * area / max(cw / w, 1e-6) * w) if cw else 0
160
+ cw, ch = min(cw, w - 1), min(max(ch, 4), h - 1)
161
+ if cw < 2 or ch < 2:
162
+ return crop
163
+ x0 = rng.randint(0, w - cw)
164
+ y0 = rng.randint(0, h - ch)
165
+ out = crop.copy()
166
+ fill = rng.randint(190, 240) if rng.random() < cfg.cutout_light_p else rng.randint(10, 60)
167
+ out[y0 : y0 + ch, x0 : x0 + cw] = fill
168
+ return out
169
+
170
+
171
+ def jpeg(crop: np.ndarray, rng: random.Random, cfg: CropAugment) -> np.ndarray:
172
+ """Compress and decode the crop as a JPEG of random quality."""
173
+ if not cfg.jpeg or rng.random() > cfg.jpeg_p:
174
+ return crop
175
+ buf = io.BytesIO()
176
+ Image.fromarray(crop).save(buf, "JPEG", quality=rng.randint(*cfg.jpeg_q))
177
+ buf.seek(0)
178
+ return np.asarray(Image.open(buf).convert("RGB"))
179
+
180
+
181
+ def photometric(crop: np.ndarray, rng: random.Random, cfg: CropAugment) -> np.ndarray:
182
+ """Brightness, contrast and saturation factors, then a Gaussian blur for three crops in
183
+ ten and Gaussian noise (sigma 5) for three in ten."""
184
+ if not cfg.photometric:
185
+ return crop
186
+ im = Image.fromarray(crop)
187
+ im = ImageEnhance.Brightness(im).enhance(rng.uniform(*cfg.brightness))
188
+ im = ImageEnhance.Contrast(im).enhance(rng.uniform(0.8, 1.2))
189
+ im = ImageEnhance.Color(im).enhance(rng.uniform(0.8, 1.2))
190
+ if rng.random() < 0.3:
191
+ im = im.filter(ImageFilter.GaussianBlur(rng.uniform(0.3, 1.2)))
192
+ out = np.asarray(im).astype(np.int16)
193
+ if rng.random() < 0.3:
194
+ out = out + np.random.default_rng(rng.getrandbits(32)).normal(0, 5, out.shape)
195
+ return np.clip(out, 0, 255).astype(np.uint8)
196
+
197
+
198
+ def center_mask(crop: np.ndarray, cfg: CropAugment) -> np.ndarray:
199
+ """Keep the central ``center_keep`` of the crop and paint the rest gray (128). No random
200
+ draw: with the mask on, the same is done to every input at inference."""
201
+ if not cfg.center_mask:
202
+ return crop
203
+ h, w = crop.shape[:2]
204
+ kx, ky = int(w * (1 - cfg.center_keep) / 2), int(h * (1 - cfg.center_keep) / 2)
205
+ out = np.full_like(crop, 128)
206
+ out[ky : h - ky, kx : w - kx] = crop[ky : h - ky, kx : w - kx]
207
+ return out
208
+
209
+
210
+ def augment_crop(img: np.ndarray, inner, patch, rng: random.Random, cfg: CropAugment) -> np.ndarray:
211
+ """The plain chain for a crop: frame jitter, background swap, cutout, resolution jitter,
212
+ photometric, JPEG, center mask, in that order."""
213
+ c = frame_jitter(img, inner, rng, cfg)
214
+ c = background_swap(c, patch, rng, cfg)
215
+ c = cutout(c, rng, cfg)
216
+ c = resolution_jitter(c, rng, cfg)
217
+ c = photometric(c, rng, cfg)
218
+ c = jpeg(c, rng, cfg)
219
+ return center_mask(c, cfg)
220
+
221
+
222
+ def augment_art(art: np.ndarray, rng: random.Random, cfg: CropAugment) -> np.ndarray:
223
+ """The chain for a clean card image: a mild perspective warp (each corner moved inwards by
224
+ up to 6 % of the side, when ``frame_jitter`` is on), then resolution jitter, photometric,
225
+ JPEG and the center mask. The image keeps its size up to the resolution jitter."""
226
+ h, w = art.shape[:2]
227
+ im = Image.fromarray(art)
228
+ if cfg.frame_jitter:
229
+ j = 0.06
230
+ q = [rng.uniform(0, j) * w, rng.uniform(0, j) * h,
231
+ w - rng.uniform(0, j) * w, rng.uniform(0, j) * h,
232
+ w - rng.uniform(0, j) * w, h - rng.uniform(0, j) * h,
233
+ rng.uniform(0, j) * w, h - rng.uniform(0, j) * h]
234
+ im = im.transform((w, h), Image.QUAD, q, Image.BILINEAR)
235
+ c = np.asarray(im)
236
+ c = resolution_jitter(c, rng, cfg)
237
+ c = photometric(c, rng, cfg)
238
+ c = jpeg(c, rng, cfg)
239
+ return center_mask(c, cfg)
240
+
241
+
242
+ def on_surface(art: np.ndarray, patch: np.ndarray | None, margin: float, rng: random.Random):
243
+ """Place a card image on a background, with ``margin`` times its width and height of
244
+ background on each side, as a crop cut from a frame has.
245
+
246
+ Returns ``(image, inner)``: ``inner`` is the card's box ``(x, y, width, height)`` in the
247
+ image. Without a patch, or with a margin of 0, the card image itself is returned.
248
+ """
249
+ h, w = art.shape[:2]
250
+ mx, my = int(round(margin * w)), int(round(margin * h))
251
+ if patch is None or (mx == 0 and my == 0):
252
+ return art, (0, 0, w, h)
253
+ canvas = _window(patch, w + 2 * mx, h + 2 * my, rng)
254
+ canvas[my : my + h, mx : mx + w] = art
255
+ return canvas, (mx, my, w, h)