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.
- cardvision/__init__.py +4 -0
- cardvision/__main__.py +9 -0
- cardvision/augment/__init__.py +77 -0
- cardvision/augment/components.py +317 -0
- cardvision/augment/crop.py +255 -0
- cardvision/augment/physical.py +175 -0
- cardvision/bank/__init__.py +12 -0
- cardvision/bank/art.py +189 -0
- cardvision/bank/build.py +549 -0
- cardvision/bank/commands.py +405 -0
- cardvision/bank/convert.py +146 -0
- cardvision/bank/documents.py +61 -0
- cardvision/bank/refresh.py +294 -0
- cardvision/bank/release.py +858 -0
- cardvision/bank/suspect.py +236 -0
- cardvision/bank/views.py +135 -0
- cardvision/calibrate/__init__.py +60 -0
- cardvision/calibrate/cli.py +172 -0
- cardvision/calibrate/grid.py +162 -0
- cardvision/calibrate/report.py +308 -0
- cardvision/check_catalog.py +47 -0
- cardvision/cli.py +103 -0
- cardvision/composites/__init__.py +137 -0
- cardvision/composites/board.py +138 -0
- cardvision/composites/broadcast.py +296 -0
- cardvision/composites/cards.py +203 -0
- cardvision/composites/cc0_textures.json +212 -0
- cardvision/composites/closeup.py +130 -0
- cardvision/composites/commands.py +129 -0
- cardvision/composites/dataset.py +242 -0
- cardvision/composites/fetch.py +191 -0
- cardvision/composites/finish.py +130 -0
- cardvision/composites/geometry.py +118 -0
- cardvision/composites/photometric.py +156 -0
- cardvision/composites/scene.py +865 -0
- cardvision/composites/surfaces.py +128 -0
- cardvision/composites/textures.py +451 -0
- cardvision/composites/validate.py +208 -0
- cardvision/core/__init__.py +5 -0
- cardvision/core/bank.py +382 -0
- cardvision/core/bank_binary.py +206 -0
- cardvision/core/catalog.py +664 -0
- cardvision/core/checkout.py +42 -0
- cardvision/core/constants.py +15 -0
- cardvision/core/device.py +66 -0
- cardvision/core/files.py +61 -0
- cardvision/core/images.py +19 -0
- cardvision/core/onnx.py +106 -0
- cardvision/core/preprocess.py +175 -0
- cardvision/core/tls.py +52 -0
- cardvision/data/__init__.py +159 -0
- cardvision/data/commands.py +315 -0
- cardvision/data/dhash.py +80 -0
- cardvision/data/extract.py +654 -0
- cardvision/data/fetch.py +1020 -0
- cardvision/data/geometry.py +184 -0
- cardvision/data/manifest.py +142 -0
- cardvision/data/reachable.py +185 -0
- cardvision/data/roles.py +290 -0
- cardvision/data/schema.py +799 -0
- cardvision/data/sources.py +92 -0
- cardvision/data/targets.py +473 -0
- cardvision/data/validate.py +289 -0
- cardvision/detect/__init__.py +56 -0
- cardvision/detect/decode.py +192 -0
- cardvision/detect/detector.py +84 -0
- cardvision/detect/letterbox.py +69 -0
- cardvision/evaluate/__init__.py +182 -0
- cardvision/evaluate/browser.py +548 -0
- cardvision/evaluate/cli.py +333 -0
- cardvision/evaluate/config.py +315 -0
- cardvision/evaluate/crops.py +209 -0
- cardvision/evaluate/detector.py +532 -0
- cardvision/evaluate/events.py +414 -0
- cardvision/evaluate/evidence.py +148 -0
- cardvision/evaluate/hover.py +235 -0
- cardvision/evaluate/report.py +405 -0
- cardvision/evaluate/rows.py +170 -0
- cardvision/evaluate/scenes.py +177 -0
- cardvision/evaluate/scoring.py +298 -0
- cardvision/evaluate/stats.py +221 -0
- cardvision/export/__init__.py +12 -0
- cardvision/export/checkpoint.py +94 -0
- cardvision/export/cli.py +104 -0
- cardvision/export/detector.py +282 -0
- cardvision/export/onnx_tools.py +67 -0
- cardvision/export/recognizer.py +107 -0
- cardvision/export/torch_export.py +111 -0
- cardvision/fetch_weights.py +261 -0
- cardvision/geometric/__init__.py +35 -0
- cardvision/geometric/commands.py +155 -0
- cardvision/geometric/config.py +315 -0
- cardvision/geometric/features.py +130 -0
- cardvision/geometric/index.py +425 -0
- cardvision/geometric/pack.py +339 -0
- cardvision/geometric/stage.py +406 -0
- cardvision/import_catalog/__init__.py +20 -0
- cardvision/import_catalog/cli.py +100 -0
- cardvision/import_catalog/importer.py +984 -0
- cardvision/infer/__init__.py +56 -0
- cardvision/infer/cli.py +238 -0
- cardvision/infer/contact_sheet.py +252 -0
- cardvision/infer/frames.py +232 -0
- cardvision/infer/pipeline.py +357 -0
- cardvision/infer/prefill.py +83 -0
- cardvision/infer/render.py +94 -0
- cardvision/next_step.py +951 -0
- cardvision/recognize/__init__.py +71 -0
- cardvision/recognize/accept.py +205 -0
- cardvision/recognize/crops.py +58 -0
- cardvision/recognize/looks.py +119 -0
- cardvision/recognize/recognizer.py +342 -0
- cardvision/release_assets.py +32 -0
- cardvision/train_detector/__init__.py +55 -0
- cardvision/train_detector/augment.py +265 -0
- cardvision/train_detector/cli.py +114 -0
- cardvision/train_detector/config.py +412 -0
- cardvision/train_detector/default.toml +87 -0
- cardvision/train_detector/loss.py +185 -0
- cardvision/train_detector/model.py +256 -0
- cardvision/train_detector/sources.py +486 -0
- cardvision/train_detector/trainer.py +258 -0
- cardvision/train_recognizer/__init__.py +33 -0
- cardvision/train_recognizer/cli.py +88 -0
- cardvision/train_recognizer/config.py +503 -0
- cardvision/train_recognizer/dataset.py +537 -0
- cardvision/train_recognizer/default.toml +144 -0
- cardvision/train_recognizer/loop.py +343 -0
- cardvision/train_recognizer/losses.py +117 -0
- cardvision/train_recognizer/model.py +148 -0
- cardvision/train_recognizer/sources.py +699 -0
- cardvision/train_recognizer/validate.py +101 -0
- cardvision/try_game/__init__.py +8 -0
- cardvision/try_game/cli.py +66 -0
- cardvision/try_game/report.py +363 -0
- cardvision/try_game/run.py +651 -0
- cardvision-0.4.0.dist-info/METADATA +176 -0
- cardvision-0.4.0.dist-info/RECORD +146 -0
- cardvision-0.4.0.dist-info/WHEEL +5 -0
- cardvision-0.4.0.dist-info/entry_points.txt +2 -0
- cardvision-0.4.0.dist-info/licenses/LICENSE +201 -0
- cardvision-0.4.0.dist-info/licenses/LICENSE-DATA +449 -0
- cardvision-0.4.0.dist-info/licenses/NOTICE +225 -0
- cardvision-0.4.0.dist-info/licenses/PROVENANCE.md +144 -0
- cardvision-0.4.0.dist-info/licenses/TRADEMARK.md +33 -0
- cardvision-0.4.0.dist-info/top_level.txt +1 -0
cardvision/__init__.py
ADDED
cardvision/__main__.py
ADDED
|
@@ -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)
|