captchakraken 3.2.0 → 4.0.0
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.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
"""Track the part of a slide captcha that the handle moves, and say where to put the handle next.
|
|
2
|
+
|
|
3
|
+
The model names three points: the handle, a point on what the handle moves, and where that point belongs. This
|
|
4
|
+
module turns them into a closed loop without knowing anything about the vendor: no selector, no handle-to-piece
|
|
5
|
+
ratio, no assumption about whether the handle slides the piece or turns it.
|
|
6
|
+
|
|
7
|
+
1. `segment` finds WHAT moves: optical flow from the shot at the press to the shot after a small probe, kept only
|
|
8
|
+
where it agrees with the flow back, split into connected parts. Flow spreads across flat ground, so one part can
|
|
9
|
+
hold the piece AND the knob below it: each part is cut, under each distinct motion in it, to the pixels that
|
|
10
|
+
motion carries exactly onto the probe, and split into bodies cored on pixels that actually changed. The body
|
|
11
|
+
under the model's point is the object; without one, the largest body that is not the handle. Flow cannot follow a
|
|
12
|
+
piece that looks like the ground it moves over, so around the model's point every small shift is also tested
|
|
13
|
+
directly, and the body the best one explains is a candidate too.
|
|
14
|
+
2. The probe also says HOW the handle drives it: a SLIDE (a shift) or a TURN (about a pivot it fits). A handle does
|
|
15
|
+
one or the other, and fitting both from a short probe lets a half-degree of noise grow into a ten-degree turn by
|
|
16
|
+
the far end of the track.
|
|
17
|
+
3. `measure` finds where the object is now. A piece on a rail is looked for only along its rail through rest, level
|
|
18
|
+
unless the probe rose by more than it can be read to, by the share of its face that lands on its own value, ties
|
|
19
|
+
broken by the prediction; anything else by template matching over its own mask near the prediction. Either way
|
|
20
|
+
the exact warp is then scored to a fraction of a pixel, so a busy board or an animated badge elsewhere cannot
|
|
21
|
+
pull it.
|
|
22
|
+
4. `plan` fits the motion as a straight line in the handle's travel and picks the travel that brings the model's
|
|
23
|
+
point closest to its goal.
|
|
24
|
+
|
|
25
|
+
All coordinates are SHOT pixels; the caller converts to CSS with its own device scale.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
from PIL import Image as PILImage
|
|
36
|
+
from scipy import fft, ndimage
|
|
37
|
+
|
|
38
|
+
Image = Union[str, np.ndarray]
|
|
39
|
+
Point = Tuple[float, float]
|
|
40
|
+
# (theta in degrees, tx, ty) about the object's centroid c0: p' = R(theta)(p - c0) + c0 + t. Positive theta turns
|
|
41
|
+
# clockwise on screen, since image y points down.
|
|
42
|
+
Params = Tuple[float, float, float]
|
|
43
|
+
Offset = Union[float, Sequence[float]]
|
|
44
|
+
|
|
45
|
+
SLIDE = "slide"
|
|
46
|
+
TURN = "turn"
|
|
47
|
+
|
|
48
|
+
# Below this a pixel is still: the flow (`_dense_flow`) on a static textured board stays well under it.
|
|
49
|
+
_MOVING_PX = 0.5
|
|
50
|
+
# Forward-backward disagreement allowed, absolute and as a fraction of the motion: what fails it is ground the
|
|
51
|
+
# object covered or uncovered, which has no counterpart in the other shot.
|
|
52
|
+
_FB_ABS_PX = 0.8
|
|
53
|
+
_FB_REL = 0.25
|
|
54
|
+
# Grey levels within which carrying a pixel with the object's own motion must reproduce the probe for the pixel to
|
|
55
|
+
# be the object's. Dense flow spreads the object's flow several pixels onto the ground around it and the backward flow
|
|
56
|
+
# agrees, so only the pixels can say which side of the edge they are on. Comparing against standing still does not
|
|
57
|
+
# work: a smooth piece hardly changes when it moves, and an edge running along the motion not at all. Both shots
|
|
58
|
+
# are smoothed first, because a page composited at a fraction of a pixel softens the object's sharpest edge.
|
|
59
|
+
# Measured on the test boards: 95-98% of the piece, plus up to 4% featureless ground hugging its edge, which looks
|
|
60
|
+
# the same carried or still and so cannot be told apart by any pixel.
|
|
61
|
+
_CARRY_GREY = 6.0
|
|
62
|
+
_CARRY_SMOOTH = 1.0
|
|
63
|
+
# The dense flow (`_dense_flow`) is fitted on the pyramid level whose short side is the first at or under
|
|
64
|
+
# `_FLOW_SIDE` px, so a device-scale shot is followed at the same detail as a plain one; coarser levels, down to
|
|
65
|
+
# `_FLOW_MIN_SIDE`, catch motion larger than the window. Each pixel's motion is fitted over a Gaussian window
|
|
66
|
+
# (sigma, px of the level) in `_FLOW_PASSES` passes per level, damped by `_FLOW_DAMP` (grey^2 per px^2) so that flat
|
|
67
|
+
# ground, which shows no motion, keeps what the level above gave it.
|
|
68
|
+
_FLOW_SIDE = 200
|
|
69
|
+
_FLOW_MIN_SIDE = 24
|
|
70
|
+
_FLOW_WINDOW = 2.0
|
|
71
|
+
_FLOW_PASSES = 3
|
|
72
|
+
_FLOW_DAMP = 1.0
|
|
73
|
+
# A part smaller than this is flow noise on an edge, not something the handle moves.
|
|
74
|
+
_MIN_PART_PX = 25
|
|
75
|
+
_MIN_PART_FRACTION = 1e-4
|
|
76
|
+
# How far the model's point may sit outside every moving part and still name the nearest one: it reads the piece
|
|
77
|
+
# off a screenshot taken before the press, and some widgets nudge the piece when it is grabbed.
|
|
78
|
+
_SEED_REACH_FRACTION = 0.06
|
|
79
|
+
_SEED_REACH_MIN_PX = 8.0
|
|
80
|
+
# Below this correlation the transform does not describe the object any more: report it lost, never a position.
|
|
81
|
+
_MIN_CORRELATION = 0.6
|
|
82
|
+
# The same for a piece on a rail, read by the share of its face (`_along_rail`) that lands within `_CARRY_GREY` of its
|
|
83
|
+
# own value: measured 0.07-0.12 where a piece left, 0.54-0.97 on it. It says lost, not which of two places: the best
|
|
84
|
+
# point along the rail is the reading, and on a flat face its neighbours a few pixels off score up to 0.85.
|
|
85
|
+
_MIN_SHARE = 0.4
|
|
86
|
+
# The smoothing that share is read through (sigma, px): enough to forgive a page's sub-pixel resampling, not so much
|
|
87
|
+
# that a flat piece's one-pixel rim, its only mark over ground of its own tone, is averaged into that ground.
|
|
88
|
+
_SHARE_SMOOTH = 0.5
|
|
89
|
+
# Peaks along a rail whose share is within this of the best are a tie the picture cannot break - a white piece over
|
|
90
|
+
# a white screen peaks at 0.73-0.76 all along 36 px of it (GeeTest) - and the motion's prediction breaks it instead.
|
|
91
|
+
_SHARE_TIE = 0.05
|
|
92
|
+
# The angle grid a turn is searched on, in degrees; the score's peak is interpolated between its points.
|
|
93
|
+
_TURN_STEP = 1.0
|
|
94
|
+
# The distinct shifts tried within one moving part: histogram bin (px), the share of the strongest peak another must
|
|
95
|
+
# reach to count, and how many are tried.
|
|
96
|
+
_MODE_BIN = 0.5
|
|
97
|
+
_MODE_SHARE = 0.15
|
|
98
|
+
_MODE_MAX = 3
|
|
99
|
+
# A body's core is the pixels that changed AND its motion explains, closed across gaps this wide (px); it keeps the
|
|
100
|
+
# explained ground within this reach of the core (px), the antialiased rim and the featureless ground hugging it.
|
|
101
|
+
_CORE_CLOSE = 5
|
|
102
|
+
_CORE_REACH = 2.0
|
|
103
|
+
# Passes of mask <-> motion refinement at most; each grows the mask by at most the pixel test's 3 px reach.
|
|
104
|
+
_REFINE_MAX = 8
|
|
105
|
+
# A seeded slide's motion searched directly (`_slid_at`): over this share of the shot's short side around the point,
|
|
106
|
+
# for shifts up to this share of it (at least 16 px) - a probe of 8 px at a handle ratio of up to 1.8 is 15.
|
|
107
|
+
_SLID_WINDOW = 0.15
|
|
108
|
+
_SLID_SHIFT = 0.08
|
|
109
|
+
# How well a probe's shift is read (px). A probe that rose or fell by less moved along x as the handle did: GeeTest's
|
|
110
|
+
# 8 px probe read 0.21 px of rise on a level rail, which carried along a probe-angled line put the seat 4.6 px off it
|
|
111
|
+
# by 170 px of travel, where a decoy was taken.
|
|
112
|
+
_PROBE_PRECISION = 0.5
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TrackingError(ValueError):
|
|
116
|
+
"""The object could not be found or followed; the message says which and why."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Segment:
|
|
121
|
+
mask: np.ndarray # uint8 0/1, the object in `first` coordinates
|
|
122
|
+
centroid: Point # c0, the point Params are taken about
|
|
123
|
+
grabbed: bool # the press is ON the object: the pointer carries it, in two dimensions
|
|
124
|
+
kind: str # SLIDE or TURN: how the handle drives it
|
|
125
|
+
pivot: Point # the centre a TURN goes round; the centroid for a SLIDE
|
|
126
|
+
probe: Params # first -> probe, refined
|
|
127
|
+
proven: np.ndarray # uint8 0/1, the part of `mask` the probe proved is the object's: what is matched
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Sample:
|
|
132
|
+
s: float
|
|
133
|
+
params: Params
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── geometry ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _rigid(params: Params, c0: Point) -> np.ndarray:
|
|
140
|
+
"""The 2x3 forward matrix of `params` about `c0`."""
|
|
141
|
+
th, tx, ty = params
|
|
142
|
+
r = math.radians(th)
|
|
143
|
+
rot = np.array([[math.cos(r), -math.sin(r)], [math.sin(r), math.cos(r)]])
|
|
144
|
+
b = np.array(c0) - rot @ np.array(c0) + np.array([tx, ty])
|
|
145
|
+
return np.hstack([rot, b.reshape(2, 1)])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _params_of(m: np.ndarray, c0: Point) -> Params:
|
|
149
|
+
moved = m[:, :2] @ np.array(c0) + m[:, 2]
|
|
150
|
+
return math.degrees(math.atan2(m[1, 0], m[0, 0])), float(moved[0] - c0[0]), float(moved[1] - c0[1])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def transform_point(params: Params, c0: Point, p: Point) -> Point:
|
|
154
|
+
m = _rigid(params, c0)
|
|
155
|
+
q = m[:, :2] @ np.array(p, dtype=float) + m[:, 2]
|
|
156
|
+
return float(q[0]), float(q[1])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _turn_about(theta: float, pivot: Point, c0: Point) -> Params:
|
|
160
|
+
"""A turn of `theta` about `pivot`, written about `c0`."""
|
|
161
|
+
r = math.radians(theta)
|
|
162
|
+
rot = np.array([[math.cos(r), -math.sin(r)], [math.sin(r), math.cos(r)]])
|
|
163
|
+
t = (rot - np.eye(2)) @ (np.array(c0) - np.array(pivot))
|
|
164
|
+
return theta, float(t[0]), float(t[1])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _kabsch(src: np.ndarray, dst: np.ndarray, c0: Point) -> Params:
|
|
168
|
+
"""Best rigid fit of point pairs, as params about `c0`."""
|
|
169
|
+
cs, cd = src.mean(axis=0), dst.mean(axis=0)
|
|
170
|
+
h = (src - cs).T @ (dst - cd)
|
|
171
|
+
th = math.atan2(h[0, 1] - h[1, 0], h[0, 0] + h[1, 1])
|
|
172
|
+
rot = np.array([[math.cos(th), -math.sin(th)], [math.sin(th), math.cos(th)]])
|
|
173
|
+
t = cd - np.array(c0) - rot @ (cs - np.array(c0))
|
|
174
|
+
return math.degrees(th), float(t[0]), float(t[1])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _inverse(m: np.ndarray) -> np.ndarray:
|
|
178
|
+
"""The 2x3 matrix that undoes `m`; a pure shift stays one exactly, which `_warp` reads off slices."""
|
|
179
|
+
(p, q), (u, v) = m[:, :2]
|
|
180
|
+
det = p * v - q * u
|
|
181
|
+
inv = np.array([[v, -q], [-u, p]]) / det
|
|
182
|
+
return np.hstack([inv, -(inv @ m[:, 2])[:, None]])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _from(m: np.ndarray, x0: int, y0: int) -> np.ndarray:
|
|
186
|
+
"""`m` for a crop whose top-left is (x0, y0): its output pixel (x, y) is the shot's (x + x0, y + y0)."""
|
|
187
|
+
out = m.astype(float)
|
|
188
|
+
out[:, 2] += m[:, :2] @ np.array([x0, y0], dtype=float)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── images and masks ─────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _gray(img: Image) -> np.ndarray:
|
|
196
|
+
"""A shot as 8-bit grey: a path is read from disk, an array is taken as BGR, the order the drivers' own
|
|
197
|
+
renders use. Both convert with the same BT.601 weights."""
|
|
198
|
+
if isinstance(img, str):
|
|
199
|
+
try:
|
|
200
|
+
with PILImage.open(img) as f:
|
|
201
|
+
return np.asarray(f.convert("L"), dtype=np.uint8)
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise TrackingError(f"unreadable image: {img}") from exc
|
|
204
|
+
a = np.asarray(img)
|
|
205
|
+
if a.ndim == 3:
|
|
206
|
+
a = np.rint(a[..., :3].astype(np.float32) @ np.float32([0.114, 0.587, 0.299]))
|
|
207
|
+
return np.ascontiguousarray(a, dtype=np.uint8)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _smooth(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
211
|
+
return ndimage.gaussian_filter(img.astype(np.float32), sigma, mode="mirror")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _warp(img: np.ndarray, m: np.ndarray, shape: Tuple[int, int], order: int = 1,
|
|
215
|
+
mode: str = "constant") -> np.ndarray:
|
|
216
|
+
"""`img` sampled at `m` applied to every output pixel: out(x, y) = img(m (x, y, 1)), as float32. Outside the
|
|
217
|
+
image is 0 (`constant`) or the nearest edge pixel (`nearest`); `order` 1 is bilinear, 0 the nearest pixel. A
|
|
218
|
+
third axis, if any, is channels warped alike.
|
|
219
|
+
|
|
220
|
+
Sampled by hand rather than through `scipy.ndimage.affine_transform`, which is several times slower at the
|
|
221
|
+
sizes the loop warps hundreds of times per look.
|
|
222
|
+
"""
|
|
223
|
+
h, w = shape
|
|
224
|
+
hi, wi = img.shape[:2]
|
|
225
|
+
if m[0, 0] == 1 and m[1, 1] == 1 and m[0, 1] == 0 and m[1, 0] == 0:
|
|
226
|
+
tx, ty = (math.floor(t + 0.5) if order == 0 else t for t in m[:, 2])
|
|
227
|
+
ix, iy = math.floor(tx), math.floor(ty)
|
|
228
|
+
if ix >= 0 and iy >= 0 and ix + w + 1 <= wi and iy + h + 1 <= hi:
|
|
229
|
+
# A shift that stays inside the image is a blend of four slices.
|
|
230
|
+
part = img[iy:iy + h + 1, ix:ix + w + 1].astype(np.float32)
|
|
231
|
+
fx, fy = np.float32(tx - ix), np.float32(ty - iy)
|
|
232
|
+
rows = part[:, :w] * (1 - fx) + part[:, 1:] * fx
|
|
233
|
+
return rows[:h] * (1 - fy) + rows[1:] * fy
|
|
234
|
+
xs, ys = np.arange(w, dtype=np.float64), np.arange(h, dtype=np.float64)[:, None]
|
|
235
|
+
x, y = m[0, 0] * xs + m[0, 1] * ys + m[0, 2], m[1, 0] * xs + m[1, 1] * ys + m[1, 2]
|
|
236
|
+
if order == 0:
|
|
237
|
+
xi, yi = np.floor(x + 0.5).astype(np.intp), np.floor(y + 0.5).astype(np.intp)
|
|
238
|
+
out = img[np.clip(yi, 0, hi - 1), np.clip(xi, 0, wi - 1)].astype(np.float32)
|
|
239
|
+
if mode == "constant":
|
|
240
|
+
out[(xi < 0) | (xi >= wi) | (yi < 0) | (yi >= hi)] = 0
|
|
241
|
+
return out
|
|
242
|
+
if mode == "nearest":
|
|
243
|
+
x, y = np.clip(x, 0, wi - 1), np.clip(y, 0, hi - 1)
|
|
244
|
+
else:
|
|
245
|
+
x, y = np.clip(x, -1, wi), np.clip(y, -1, hi)
|
|
246
|
+
x0, y0 = np.floor(x), np.floor(y)
|
|
247
|
+
# Only the part of the image the samples read is copied, with a pixel of border all round (two after) so every
|
|
248
|
+
# corner a sample reads exists: the edge's own value, or 0 past the image.
|
|
249
|
+
cx, cy = max(int(x0.min()), 0), max(int(y0.min()), 0)
|
|
250
|
+
src = img[cy:min(int(y0.max()) + 2, hi), cx:min(int(x0.max()) + 2, wi)].astype(np.float32)
|
|
251
|
+
border = ((1, 2), (1, 2)) + ((0, 0),) * (src.ndim - 2)
|
|
252
|
+
pad = np.pad(src, border, mode="edge") if mode == "nearest" else np.pad(src, border)
|
|
253
|
+
fx, fy = ((v - v0).astype(np.float32).reshape(v.shape + (1,) * (src.ndim - 2)) for v, v0 in ((x, x0), (y, y0)))
|
|
254
|
+
stride = src.shape[1] + 3
|
|
255
|
+
i = (y0.astype(np.intp) - cy + 1) * stride + x0.astype(np.intp) - cx + 1
|
|
256
|
+
flat = pad.reshape((-1,) + src.shape[2:])
|
|
257
|
+
top = flat.take(i, axis=0) * (1 - fx) + flat.take(i + 1, axis=0) * fx
|
|
258
|
+
bottom = flat.take(i + stride, axis=0) * (1 - fx) + flat.take(i + stride + 1, axis=0) * fx
|
|
259
|
+
return top * (1 - fy) + bottom * fy
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Every step that tests pixels near a mask works on the mask's box grown by this (px): past the smoothing's reach
|
|
263
|
+
# (4 sigma of `_CARRY_SMOOTH`) and every closing's, so the crop gives exactly what the whole shot would.
|
|
264
|
+
_MARGIN = 8
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _region(mask: np.ndarray, margin: int = _MARGIN) -> Tuple[slice, slice]:
|
|
268
|
+
"""The rows and columns of `mask`'s bounding box grown by `margin`, within the shot."""
|
|
269
|
+
rows, cols = np.flatnonzero(mask.any(axis=1)), np.flatnonzero(mask.any(axis=0))
|
|
270
|
+
h, w = mask.shape
|
|
271
|
+
return (slice(max(int(rows[0]) - margin, 0), min(int(rows[-1]) + 1 + margin, h)),
|
|
272
|
+
slice(max(int(cols[0]) - margin, 0), min(int(cols[-1]) + 1 + margin, w)))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _paste(crop: np.ndarray, at: Tuple[slice, slice], shape: Tuple[int, int]) -> np.ndarray:
|
|
276
|
+
out = np.zeros(shape, crop.dtype)
|
|
277
|
+
out[at] = crop
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _grow(mask: np.ndarray, size: int) -> np.ndarray:
|
|
282
|
+
"""Dilation by a `size` square; the border adds nothing."""
|
|
283
|
+
return ndimage.maximum_filter(mask.astype(np.uint8), size, mode="nearest")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _shrink(mask: np.ndarray, size: int) -> np.ndarray:
|
|
287
|
+
"""Erosion by a `size` square; the border removes nothing."""
|
|
288
|
+
return ndimage.minimum_filter(mask.astype(np.uint8), size, mode="nearest")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _open(mask: np.ndarray, size: int) -> np.ndarray:
|
|
292
|
+
return _grow(_shrink(mask, size), size)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _close(mask: np.ndarray, size: int) -> np.ndarray:
|
|
296
|
+
return _shrink(_grow(mask, size), size)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
_EIGHT = np.ones((3, 3), bool)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _components(mask: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
303
|
+
"""8-connected parts: the label image and each label's area (label 0 is the background)."""
|
|
304
|
+
labels, _ = ndimage.label(mask, structure=_EIGHT)
|
|
305
|
+
return labels, np.bincount(labels.ravel())
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _within(mask: np.ndarray, radius: float) -> np.ndarray:
|
|
309
|
+
"""The pixels within `radius` (Euclidean, px) of `mask`."""
|
|
310
|
+
r = int(math.floor(radius))
|
|
311
|
+
yy, xx = np.mgrid[-r:r + 1, -r:r + 1]
|
|
312
|
+
return ndimage.binary_dilation(mask.astype(bool), xx * xx + yy * yy <= radius * radius)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _pair(first: Image, other: Image) -> Tuple[np.ndarray, np.ndarray]:
|
|
316
|
+
a, b = _gray(first), _gray(other)
|
|
317
|
+
if a.shape != b.shape:
|
|
318
|
+
raise TrackingError(f"shots differ in size: {a.shape} vs {b.shape}")
|
|
319
|
+
return a, b
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _centroid(mask: np.ndarray) -> Point:
|
|
323
|
+
ys, xs = np.nonzero(mask)
|
|
324
|
+
return float(xs.mean()), float(ys.mean())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _fill(part: np.ndarray) -> np.ndarray:
|
|
328
|
+
"""The part with its holes filled: a turning disc's centre barely moves and a flat patch carries no flow.
|
|
329
|
+
|
|
330
|
+
A hole is ground the part encloses, 4-connected as ground next to an 8-connected part is; labelling it once is
|
|
331
|
+
much faster than `binary_fill_holes`' repeated dilation.
|
|
332
|
+
"""
|
|
333
|
+
ground, _ = ndimage.label(np.pad(~part.astype(bool), 1, constant_values=True))
|
|
334
|
+
return (ground[1:-1, 1:-1] != ground[0, 0]).astype(np.uint8)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _inside(part: np.ndarray, p: Point) -> float:
|
|
338
|
+
"""How far `p` lies inside the outline of `part`'s largest piece (px): negative outside it.
|
|
339
|
+
|
|
340
|
+
The outline runs through the centres of the piece's rim pixels, those with a background pixel beside them.
|
|
341
|
+
"""
|
|
342
|
+
labels, areas = _components(part)
|
|
343
|
+
largest = labels == int(np.argmax(areas[1:])) + 1
|
|
344
|
+
at = _region(largest, 1)
|
|
345
|
+
piece = _fill(largest[at]).astype(bool)
|
|
346
|
+
ys, xs = np.nonzero(piece & ~ndimage.binary_erosion(piece, border_value=1))
|
|
347
|
+
ys, xs = ys + at[0].start, xs + at[1].start
|
|
348
|
+
d = float(np.sqrt(((xs - p[0]) ** 2 + (ys - p[1]) ** 2).min()))
|
|
349
|
+
x, y = int(round(p[0])) - at[1].start, int(round(p[1])) - at[0].start
|
|
350
|
+
on = 0 <= y < piece.shape[0] and 0 <= x < piece.shape[1] and piece[y, x]
|
|
351
|
+
return d if on else -d
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _carried(a: np.ndarray, b: np.ndarray, part: np.ndarray, params: Params, c0: Point,
|
|
355
|
+
min_area: float, along: Optional[Tuple[float, float]] = None) -> np.ndarray:
|
|
356
|
+
"""The pixels around `part` that the object's own motion (`params` about `c0`) carries onto the probe.
|
|
357
|
+
|
|
358
|
+
`along`, a slide's shift: what changed is joined along it (`_spans`) before the unchanged ground is trimmed.
|
|
359
|
+
"""
|
|
360
|
+
shape = a.shape
|
|
361
|
+
at = _region(part, 3 + _MARGIN) # 3: the reach of the 7x7 grow below
|
|
362
|
+
landed = _warp(b, _from(_rigid(params, c0), at[1].start, at[0].start), a[at].shape, mode="nearest")
|
|
363
|
+
a, b, part = a[at], b[at], part[at]
|
|
364
|
+
grow = _grow(part, 7) > 0
|
|
365
|
+
sa = _smooth(a, _CARRY_SMOOTH)
|
|
366
|
+
explained = grow & (np.abs(_smooth(landed, _CARRY_SMOOTH) - sa) < _CARRY_GREY)
|
|
367
|
+
# Ground that happens to match itself one shift along leaves thin threads, never a body.
|
|
368
|
+
labels, areas = _components(_open(explained, 5))
|
|
369
|
+
hits = np.bincount(labels[part.astype(bool)], minlength=len(areas))
|
|
370
|
+
ours = [i for i in range(1, len(areas)) if hits[i] > 0.5 * areas[i]]
|
|
371
|
+
keep = _close(np.isin(labels, ours), 5)
|
|
372
|
+
# The opening also shaves the object's rim, its sharpest edge; take back the rim pixels the motion explains.
|
|
373
|
+
keep = keep | (_grow(keep, 3).astype(bool) & explained).astype(np.uint8)
|
|
374
|
+
# Ground the shot shows unchanged is explained by standing still as well as by the motion: it is the object's
|
|
375
|
+
# only where it lies within reach of what changed, or inside its outline.
|
|
376
|
+
core = keep.astype(bool) & (np.abs(_smooth(b, _CARRY_SMOOTH) - sa) >= _CARRY_GREY)
|
|
377
|
+
if along is not None:
|
|
378
|
+
core = _spans(core, keep, along)
|
|
379
|
+
core = _close(core, _CORE_CLOSE)
|
|
380
|
+
keep = _fill(keep.astype(bool) & _within(core, _CORE_REACH) | core.astype(bool))
|
|
381
|
+
if keep.sum() < min_area:
|
|
382
|
+
raise TrackingError("the moving part has no texture or edge that its own motion explains")
|
|
383
|
+
return _paste(keep, at, shape)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── what moves ───────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _dense_flow(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
390
|
+
"""Where each pixel of `a` went in `b`, as an (h, w, 2) field of (dx, dy) in shot pixels: pyramidal Lucas-Kanade.
|
|
391
|
+
|
|
392
|
+
Each level starts from the motion of the level above, doubled, and refines it by fitting, over a Gaussian
|
|
393
|
+
window, the one shift that best lays `b` back on `a`.
|
|
394
|
+
"""
|
|
395
|
+
pa, pb = [a.astype(np.float32)], [b.astype(np.float32)]
|
|
396
|
+
while min(pa[-1].shape) // 2 >= _FLOW_MIN_SIDE:
|
|
397
|
+
pa.append(_smooth(pa[-1], 1.0)[::2, ::2])
|
|
398
|
+
pb.append(_smooth(pb[-1], 1.0)[::2, ::2])
|
|
399
|
+
finest = next(i for i, level in enumerate(pa) if min(level.shape) <= _FLOW_SIDE or i == len(pa) - 1)
|
|
400
|
+
flow = np.zeros(pa[-1].shape + (2,), np.float32)
|
|
401
|
+
for la, lb in reversed(list(zip(pa, pb))[finest:]):
|
|
402
|
+
flow = _upsampled(flow, la.shape)
|
|
403
|
+
h, w = la.shape
|
|
404
|
+
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
405
|
+
ga_y, ga_x = np.gradient(la)
|
|
406
|
+
for _ in range(_FLOW_PASSES):
|
|
407
|
+
lw = ndimage.map_coordinates(lb, [ys + flow[..., 1], xs + flow[..., 0]], order=1, mode="nearest")
|
|
408
|
+
gw_y, gw_x = np.gradient(lw)
|
|
409
|
+
gx, gy, gt = 0.5 * (ga_x + gw_x), 0.5 * (ga_y + gw_y), lw - la
|
|
410
|
+
sxx, syy = _smooth(gx * gx, _FLOW_WINDOW) + _FLOW_DAMP, _smooth(gy * gy, _FLOW_WINDOW) + _FLOW_DAMP
|
|
411
|
+
sxy, sxt, syt = (_smooth(v, _FLOW_WINDOW) for v in (gx * gy, gx * gt, gy * gt))
|
|
412
|
+
det = sxx * syy - sxy * sxy
|
|
413
|
+
flow[..., 0] -= (syy * sxt - sxy * syt) / det
|
|
414
|
+
flow[..., 1] -= (sxx * syt - sxy * sxt) / det
|
|
415
|
+
return _upsampled(flow, a.shape)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _upsampled(flow: np.ndarray, shape: Tuple[int, int]) -> np.ndarray:
|
|
419
|
+
"""A flow field resampled to `shape`, its vectors scaled with it."""
|
|
420
|
+
if flow.shape[:2] == tuple(shape):
|
|
421
|
+
return flow
|
|
422
|
+
k = shape[1] / flow.shape[1]
|
|
423
|
+
zoom = (shape[0] / flow.shape[0], k)
|
|
424
|
+
return k * np.stack([ndimage.zoom(flow[..., i], zoom, order=1, mode="nearest", grid_mode=True)
|
|
425
|
+
for i in (0, 1)], axis=-1).astype(np.float32)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _near_handle(handle: Optional[Point], h: int, w: int) -> Optional[np.ndarray]:
|
|
429
|
+
"""The few pixels around the press point, or None without one."""
|
|
430
|
+
if handle is None:
|
|
431
|
+
return None
|
|
432
|
+
out = np.zeros((h, w), bool)
|
|
433
|
+
x, y = min(max(int(round(handle[0])), 0), w - 1), min(max(int(round(handle[1])), 0), h - 1)
|
|
434
|
+
out[max(y - 2, 0):y + 3, max(x - 2, 0):x + 3] = True
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _flow_modes(flow: np.ndarray, weight: np.ndarray) -> List[Tuple[float, float]]:
|
|
439
|
+
"""The distinct shifts in a part's flow, strongest first, counted where the picture has an edge to show them.
|
|
440
|
+
|
|
441
|
+
Flat ground carries no evidence of motion, only flow smoothed across it, so it is weighted by its gradient: zero.
|
|
442
|
+
A piece and the knob under it move by different amounts unless the handle drives the piece 1:1, and each shows
|
|
443
|
+
as its own peak.
|
|
444
|
+
"""
|
|
445
|
+
lo = np.floor(flow.min(axis=0)) - _MODE_BIN
|
|
446
|
+
hi = np.ceil(flow.max(axis=0)) + _MODE_BIN
|
|
447
|
+
bins = [max(1, int((hi[i] - lo[i]) / _MODE_BIN)) for i in (0, 1)]
|
|
448
|
+
hist, ex, ey = np.histogram2d(flow[:, 0], flow[:, 1], bins=bins, range=[[lo[0], hi[0]], [lo[1], hi[1]]],
|
|
449
|
+
weights=weight)
|
|
450
|
+
for axis in (0, 1):
|
|
451
|
+
hist = ndimage.correlate1d(hist, [0.25, 0.5, 0.25], axis=axis, mode="mirror")
|
|
452
|
+
peaks = (hist == ndimage.maximum_filter(hist, 5, mode="nearest")) & (hist >= _MODE_SHARE * hist.max())
|
|
453
|
+
ix, iy = np.nonzero(peaks)
|
|
454
|
+
order = np.argsort(-hist[ix, iy])[:_MODE_MAX]
|
|
455
|
+
return [(float((ex[i] + ex[i + 1]) / 2), float((ey[j] + ey[j + 1]) / 2)) for i, j in zip(ix[order], iy[order])]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _spans(core: np.ndarray, cut: np.ndarray, shift: Tuple[float, float]) -> np.ndarray:
|
|
459
|
+
"""`core` joined across the cut along the motion: each run of the cut in the motion's direction filled from its
|
|
460
|
+
first core pixel to its last.
|
|
461
|
+
|
|
462
|
+
Moved along its length, a flat body changes only at its leading and trailing edges, which face each other along
|
|
463
|
+
the motion across its own unchanged middle. Ground bridging two bodies lies across the motion from them instead,
|
|
464
|
+
so this joins the halves of one body and never two bodies.
|
|
465
|
+
"""
|
|
466
|
+
if not cut.any():
|
|
467
|
+
return core.astype(bool)
|
|
468
|
+
at = _region(cut, 1)
|
|
469
|
+
shape = core.shape
|
|
470
|
+
core, cut = core[at], cut[at]
|
|
471
|
+
h, w = core.shape
|
|
472
|
+
# The turn that lays the motion along +x, centred in a square that holds the whole cut at any angle.
|
|
473
|
+
phi = math.atan2(shift[1], shift[0])
|
|
474
|
+
rot = np.array([[math.cos(phi), math.sin(phi)], [-math.sin(phi), math.cos(phi)]])
|
|
475
|
+
side = int(math.ceil(math.hypot(h, w)))
|
|
476
|
+
turn = np.hstack([rot, (np.array([side / 2, side / 2]) - rot @ np.array([w / 2, h / 2]))[:, None]])
|
|
477
|
+
c, k = (_warp(m, _inverse(turn), (side, side), order=0) > 0 for m in (core, cut))
|
|
478
|
+
run = np.cumsum(np.diff(np.pad(k, ((0, 0), (1, 0))).astype(np.int8), axis=1) == 1, axis=1) * k
|
|
479
|
+
out = np.zeros_like(c)
|
|
480
|
+
for y in np.nonzero(c.any(axis=1))[0]:
|
|
481
|
+
for r in np.unique(run[y][c[y]]):
|
|
482
|
+
xs = np.nonzero(c[y] & (run[y] == r))[0]
|
|
483
|
+
out[y, xs[0]:xs[-1] + 1] = True
|
|
484
|
+
back = _warp(out, turn, (h, w), order=0)
|
|
485
|
+
return _paste(core.astype(bool) | ((back > 0) & cut.astype(bool)), at, shape)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _split(a: np.ndarray, b: np.ndarray, cut: np.ndarray, min_area: float,
|
|
489
|
+
shift: Optional[Tuple[float, float]] = None) -> List[np.ndarray]:
|
|
490
|
+
"""The separate bodies in a cut, which flat ground may join.
|
|
491
|
+
|
|
492
|
+
Featureless ground is explained by ANY motion, the object's included, so it passes the pixel test and can bridge
|
|
493
|
+
a piece to the knob under it. Only pixels that changed between the shots are evidence of a body: each body is
|
|
494
|
+
cored on those, joined along a slide's motion (`_spans`), and keeps the explained ground within reach of its own
|
|
495
|
+
core and nearer to it than to any other.
|
|
496
|
+
"""
|
|
497
|
+
at = _region(cut)
|
|
498
|
+
shape = cut.shape
|
|
499
|
+
a, b, cut = a[at], b[at], cut[at]
|
|
500
|
+
changed = np.abs(_smooth(b, _CARRY_SMOOTH) - _smooth(a, _CARRY_SMOOTH)) >= _CARRY_GREY
|
|
501
|
+
core = cut.astype(bool) & changed
|
|
502
|
+
if shift is not None:
|
|
503
|
+
core = _spans(core, cut, shift)
|
|
504
|
+
labels, areas = _components(_fill(_close(core, _CORE_CLOSE)))
|
|
505
|
+
cores = [labels == i for i in range(1, len(areas)) if areas[i] >= min_area]
|
|
506
|
+
if not cores:
|
|
507
|
+
return []
|
|
508
|
+
dist = np.stack([ndimage.distance_transform_edt(~c) for c in cores])
|
|
509
|
+
owner = dist.argmin(axis=0)
|
|
510
|
+
near = dist.min(axis=0) <= _CORE_REACH
|
|
511
|
+
bodies = [_fill((cut.astype(bool) & near & (owner == i)) | cores[i]) for i in range(len(cores))]
|
|
512
|
+
return [_paste(body, at, shape) for body in bodies if body.sum() >= min_area]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _bodies(a: np.ndarray, b: np.ndarray, fwd: np.ndarray, moving: np.ndarray, part: np.ndarray, xs: np.ndarray,
|
|
516
|
+
ys: np.ndarray, min_area: float) -> List[Tuple[np.ndarray, Params, str, Point]]:
|
|
517
|
+
"""Every rigid body in one moving part: `(mask, params about its centroid, SLIDE or TURN, centroid)`.
|
|
518
|
+
|
|
519
|
+
A part is what the FLOW joined, and flow spreads across flat ground: a piece resting just above its track comes
|
|
520
|
+
out joined to the knob below it, though the pixels between them never changed. So each distinct motion in the
|
|
521
|
+
part is tried, the part is cut to the pixels that motion carries exactly onto the probe, and what is left is
|
|
522
|
+
split into the bodies it holds, each then measured on its own.
|
|
523
|
+
"""
|
|
524
|
+
# The flow's own part has leaked onto still ground, and the flow is smoothed toward zero at its edge: the
|
|
525
|
+
# interior alone says how it moved, to a tenth of a pixel.
|
|
526
|
+
inner = _shrink(_fill(part), 9).astype(bool) & (moving > 0)
|
|
527
|
+
if inner.sum() < min_area:
|
|
528
|
+
inner = part & (moving > 0)
|
|
529
|
+
src = np.stack([xs[inner], ys[inner]], axis=1)
|
|
530
|
+
c_part = _centroid(part)
|
|
531
|
+
rough = _kabsch(src, src + fwd[inner], c_part)
|
|
532
|
+
|
|
533
|
+
# How far the turn moves the object's pixels against how far it shifts them: a handle does one or the other,
|
|
534
|
+
# and the probe is the only evidence of which.
|
|
535
|
+
radius = float(np.sqrt(((src - np.array(c_part)) ** 2).sum(axis=1).mean()))
|
|
536
|
+
if radius * abs(math.radians(rough[0])) > math.hypot(rough[1], rough[2]):
|
|
537
|
+
kind, angles, guesses = TURN, _angles(rough[0], 3.0), [rough]
|
|
538
|
+
else:
|
|
539
|
+
g = _smooth(a, 1.0)
|
|
540
|
+
edge = ndimage.sobel(g, axis=1, mode="mirror") ** 2 + ndimage.sobel(g, axis=0, mode="mirror") ** 2
|
|
541
|
+
kind, angles = SLIDE, None
|
|
542
|
+
guesses = [(0.0, tx, ty) for tx, ty in _flow_modes(fwd[inner], edge[inner])]
|
|
543
|
+
return _refined(a, b, part, inner, c_part, guesses, kind, angles, min_area)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _refined(a: np.ndarray, b: np.ndarray, part: np.ndarray, inner: np.ndarray, c_part: Point,
|
|
547
|
+
guesses: Sequence[Params], kind: str, angles: Optional[List[float]],
|
|
548
|
+
min_area: float) -> List[Tuple[np.ndarray, Params, str, Point]]:
|
|
549
|
+
"""The bodies `part` holds under each guessed motion, each mask and motion refined against the other."""
|
|
550
|
+
out: List[Tuple[np.ndarray, Params, str, Point]] = []
|
|
551
|
+
for guess in guesses:
|
|
552
|
+
# The pixel test needs the motion to a fraction of a pixel or every sharp edge fails it; the mask and the
|
|
553
|
+
# motion then sharpen each other.
|
|
554
|
+
try:
|
|
555
|
+
exact, _ = _locate(a, b, inner.astype(np.uint8), c_part, guess, 3.0, angles)
|
|
556
|
+
cut = _carried(a, b, part, exact, c_part, min_area, exact[1:] if kind == SLIDE else None)
|
|
557
|
+
except TrackingError:
|
|
558
|
+
continue
|
|
559
|
+
for mask in _split(a, b, cut, min_area, exact[1:] if kind == SLIDE else None):
|
|
560
|
+
c = _centroid(mask)
|
|
561
|
+
params = _params_of(_rigid(exact, c_part), c)
|
|
562
|
+
try:
|
|
563
|
+
# Each pass reaches a few pixels past the last mask, so a body the flow covered only in part
|
|
564
|
+
# takes several to come out whole; it stops when a pass adds nothing.
|
|
565
|
+
for _ in range(_REFINE_MAX):
|
|
566
|
+
params, _ = _locate(a, b, mask, c, params, 3.0, angles and _angles(params[0], 2.0))
|
|
567
|
+
grown = _carried(a, b, mask, params, c, min_area, params[1:] if kind == SLIDE else None)
|
|
568
|
+
settled = bool((grown == mask).all())
|
|
569
|
+
mask = grown
|
|
570
|
+
if settled:
|
|
571
|
+
break
|
|
572
|
+
except TrackingError:
|
|
573
|
+
continue
|
|
574
|
+
out.append((mask, params, kind, c))
|
|
575
|
+
if not out:
|
|
576
|
+
raise TrackingError("the moving part has no texture or edge that its own motion explains")
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _slid_at(a: np.ndarray, b: np.ndarray, seed: Point, min_area: float) -> List[Tuple[np.ndarray, Params, str, Point]]:
|
|
581
|
+
"""The body under `seed` found by its motion alone: every whole-pixel shift within reach tried around the point,
|
|
582
|
+
the one that explains the most pixels the probe changed taken, and the part it explains refined like a flow part.
|
|
583
|
+
|
|
584
|
+
Dense flow needs the object to look unlike the ground it moves over. A piece cut from the photo it rests on, or
|
|
585
|
+
a pale piece on pale ground, does not: the flow there failed its own forward-backward check almost everywhere
|
|
586
|
+
(Tencent and GeeTest, seed 6), and no part was left at the point. Testing the rigid motion itself asks only that
|
|
587
|
+
the pixels the probe changed be explained, which a piece's edges always are.
|
|
588
|
+
"""
|
|
589
|
+
h, w = a.shape
|
|
590
|
+
reach, reach_t = int(_SLID_WINDOW * min(h, w)), max(16, int(_SLID_SHIFT * min(h, w)))
|
|
591
|
+
x, y = int(round(seed[0])), int(round(seed[1]))
|
|
592
|
+
x0, x1 = max(reach_t, x - reach), min(w - reach_t, x + reach)
|
|
593
|
+
y0, y1 = max(reach_t, y - reach), min(h - reach_t, y + reach)
|
|
594
|
+
if x1 - x0 < 3 or y1 - y0 < 3:
|
|
595
|
+
return []
|
|
596
|
+
sa, sb = _smooth(a, _CARRY_SMOOTH), _smooth(b, _CARRY_SMOOTH)
|
|
597
|
+
here = sa[y0:y1, x0:x1]
|
|
598
|
+
changed = np.abs(sb[y0:y1, x0:x1] - here) >= _CARRY_GREY
|
|
599
|
+
best, shift = 0, (0, 0)
|
|
600
|
+
for ty in range(-reach_t, reach_t + 1):
|
|
601
|
+
for tx in range(-reach_t, reach_t + 1):
|
|
602
|
+
if tx or ty:
|
|
603
|
+
n = int((changed & (np.abs(sb[y0 + ty:y1 + ty, x0 + tx:x1 + tx] - here) < _CARRY_GREY)).sum())
|
|
604
|
+
if n > best:
|
|
605
|
+
best, shift = n, (tx, ty)
|
|
606
|
+
if best < min_area:
|
|
607
|
+
return []
|
|
608
|
+
tx, ty = shift
|
|
609
|
+
explained = np.zeros((h, w), np.uint8)
|
|
610
|
+
explained[y0:y1, x0:x1] = changed & (np.abs(sb[y0 + ty:y1 + ty, x0 + tx:x1 + tx] - here) < _CARRY_GREY)
|
|
611
|
+
labels, areas = _components(_close(explained, _CORE_CLOSE))
|
|
612
|
+
dist = {i: _inside(labels == i, seed) for i in range(1, len(areas)) if areas[i] >= min_area}
|
|
613
|
+
if not dist:
|
|
614
|
+
return []
|
|
615
|
+
part = _fill((labels == max(dist, key=dist.get)).astype(np.uint8)).astype(bool)
|
|
616
|
+
try:
|
|
617
|
+
return _refined(a, b, part, part, _centroid(part), [(0.0, float(tx), float(ty))], SLIDE, None, min_area)
|
|
618
|
+
except TrackingError:
|
|
619
|
+
return []
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def segment(first: Image, probe: Image, seed: Optional[Point] = None, handle: Optional[Point] = None,
|
|
623
|
+
goal: Optional[Point] = None) -> Segment:
|
|
624
|
+
"""The object the probe moved, in `first` coordinates, and how the handle drives it.
|
|
625
|
+
|
|
626
|
+
With `seed`, the body under it (or nearest to it, within reach), from the flow's parts or found by its motion
|
|
627
|
+
alone (`_slid_at`). Without one, the largest body that is not the handle: what a generation-2 answer, which
|
|
628
|
+
names only the goal, has to settle for. That answer still says where the piece rests: the driver slides the
|
|
629
|
+
handle along x, so a piece it moves onto `goal` starts on the goal's row, and with `goal` the bodies are looked
|
|
630
|
+
for along that row too and only one on it is taken.
|
|
631
|
+
"""
|
|
632
|
+
a, b = _pair(first, probe)
|
|
633
|
+
h, w = a.shape
|
|
634
|
+
fwd, bwd = _dense_flow(a, b), _dense_flow(b, a)
|
|
635
|
+
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
636
|
+
at = [ys + fwd[..., 1], xs + fwd[..., 0]]
|
|
637
|
+
back = np.stack([ndimage.map_coordinates(bwd[..., i], at, order=1, mode="nearest") for i in (0, 1)], axis=-1)
|
|
638
|
+
mag = np.hypot(fwd[..., 0], fwd[..., 1])
|
|
639
|
+
fb = np.hypot(fwd[..., 0] + back[..., 0], fwd[..., 1] + back[..., 1])
|
|
640
|
+
moving = _close(_open((mag > _MOVING_PX) & (fb < np.maximum(_FB_ABS_PX, _FB_REL * mag)), 3), 5)
|
|
641
|
+
|
|
642
|
+
labels, areas = _components(moving)
|
|
643
|
+
min_area = max(_MIN_PART_PX, _MIN_PART_FRACTION * h * w)
|
|
644
|
+
parts = [i for i in range(1, len(areas)) if areas[i] >= min_area]
|
|
645
|
+
if not parts and seed is None and goal is None:
|
|
646
|
+
raise TrackingError("nothing moved between the press and the probe")
|
|
647
|
+
|
|
648
|
+
def label_at(p: Optional[Point]) -> int:
|
|
649
|
+
if p is None:
|
|
650
|
+
return 0
|
|
651
|
+
x, y = int(round(p[0])), int(round(p[1]))
|
|
652
|
+
return int(labels[y, x]) if 0 <= x < w and 0 <= y < h else 0
|
|
653
|
+
|
|
654
|
+
reach = max(_SEED_REACH_MIN_PX, _SEED_REACH_FRACTION * min(h, w))
|
|
655
|
+
if seed is not None:
|
|
656
|
+
pick = label_at(seed) if label_at(seed) in parts else 0
|
|
657
|
+
if not pick and parts:
|
|
658
|
+
inside = {i: _inside(labels == i, seed) for i in parts}
|
|
659
|
+
near = max(inside, key=inside.get)
|
|
660
|
+
pick = near if -inside[near] <= reach else 0
|
|
661
|
+
candidates = [pick] if pick else []
|
|
662
|
+
else:
|
|
663
|
+
# Every part, the handle's own included: see `_bodies` for why the piece can share it.
|
|
664
|
+
candidates = sorted(parts, key=lambda i: -areas[i])
|
|
665
|
+
|
|
666
|
+
found: List[Tuple[np.ndarray, Params, str, Point]] = []
|
|
667
|
+
why: Optional[TrackingError] = None
|
|
668
|
+
for pick in candidates:
|
|
669
|
+
try:
|
|
670
|
+
found += _bodies(a, b, fwd, moving, labels == pick, xs, ys, min_area)
|
|
671
|
+
except TrackingError as exc:
|
|
672
|
+
why = why or exc
|
|
673
|
+
held = _near_handle(handle, h, w)
|
|
674
|
+
if seed is not None:
|
|
675
|
+
# Flow cannot follow a piece that looks like the ground it moves over; its motion, tested directly, can.
|
|
676
|
+
found += _slid_at(a, b, seed, min_area)
|
|
677
|
+
if not found:
|
|
678
|
+
raise why or TrackingError(f"nothing moved within {reach:.0f}px of the point on the object "
|
|
679
|
+
f"({seed[0]:.0f}, {seed[1]:.0f})")
|
|
680
|
+
# The body under the model's point, else the nearest: it read the piece off a shot before the press.
|
|
681
|
+
sx, sy = float(seed[0]), float(seed[1])
|
|
682
|
+
mask, exact, kind, c_part = max(found, key=lambda f: _inside(f[0], (sx, sy)))
|
|
683
|
+
off = -_inside(mask, (sx, sy))
|
|
684
|
+
if off > reach:
|
|
685
|
+
raise TrackingError(f"nothing moved within {reach:.0f}px of the point on the object "
|
|
686
|
+
f"({sx:.0f}, {sy:.0f}); the nearest moving body is {off:.0f}px away")
|
|
687
|
+
else:
|
|
688
|
+
if goal is not None:
|
|
689
|
+
# Flow misses a piece that looks like the ground it moves over (see `_slid_at`); its row is known.
|
|
690
|
+
step = max(1, int(_SLID_WINDOW * min(h, w)))
|
|
691
|
+
for x in range(step, w - step + 1, step):
|
|
692
|
+
found += _slid_at(a, b, (float(x), float(goal[1])), min_area)
|
|
693
|
+
carried = [f for f in found if not (held is not None and (f[0].astype(bool) & held).any())]
|
|
694
|
+
if goal is not None:
|
|
695
|
+
carried = [f for f in carried if np.abs(np.nonzero(f[0])[0] - goal[1]).min() <= reach]
|
|
696
|
+
if not carried:
|
|
697
|
+
if why is not None and not found:
|
|
698
|
+
raise why
|
|
699
|
+
raise TrackingError("only the handle moved; nothing it carries was seen" if goal is None else
|
|
700
|
+
f"nothing that moved, but the handle, rests on the goal's row (y {goal[1]:.0f})")
|
|
701
|
+
mask, exact, kind, c_part = max(carried, key=lambda f: int(f[0].sum()))
|
|
702
|
+
c0 = _centroid(mask)
|
|
703
|
+
exact = _params_of(_rigid(exact, c_part), c0)
|
|
704
|
+
if kind == TURN:
|
|
705
|
+
r = math.radians(exact[0])
|
|
706
|
+
rot = np.array([[math.cos(r), -math.sin(r)], [math.sin(r), math.cos(r)]])
|
|
707
|
+
# p' = R(p - c0) + c0 + t turns about the pivot q where (I - R)(q - c0) = t.
|
|
708
|
+
q = np.array(c0) + np.linalg.solve(np.eye(2) - rot, np.array(exact[1:]))
|
|
709
|
+
pivot = (float(q[0]), float(q[1]))
|
|
710
|
+
else:
|
|
711
|
+
pivot = c0
|
|
712
|
+
|
|
713
|
+
grabbed = handle is not None and bool(_grow(mask, 5)[
|
|
714
|
+
min(max(int(round(handle[1])), 0), h - 1), min(max(int(round(handle[0])), 0), w - 1)])
|
|
715
|
+
probe, _ = _locate(a, b, mask, c0, exact, 3.0, _angles(exact[0], 2.0) if kind == TURN else None)
|
|
716
|
+
return Segment(mask=mask, centroid=c0, grabbed=grabbed, kind=kind, pivot=pivot, probe=probe,
|
|
717
|
+
proven=_proven(a, b, mask, probe, c0, min_area))
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _proven(a: np.ndarray, b: np.ndarray, mask: np.ndarray, params: Params, c0: Point, min_area: float) -> np.ndarray:
|
|
721
|
+
"""The pixels of `mask` the probe can tell from the ground: those unlike the pixel one motion along.
|
|
722
|
+
|
|
723
|
+
Where the picture is the same one motion along, the object moving and the ground staying put draw the same probe,
|
|
724
|
+
so the mask's flat rim and any flat ground it swallowed are unproven. They are the object's extent (the fill
|
|
725
|
+
needs them) but not its appearance: matched against a later shot, after the object has left that ground, a
|
|
726
|
+
swallowed patch of dark cow is a template the object no longer carries.
|
|
727
|
+
"""
|
|
728
|
+
at = _region(mask)
|
|
729
|
+
smooth = _smooth(a, _CARRY_SMOOTH)
|
|
730
|
+
along = _warp(smooth, _from(_rigid(params, c0), at[1].start, at[0].start), smooth[at].shape, mode="nearest")
|
|
731
|
+
changed = np.abs(_smooth(b[at], _CARRY_SMOOTH) - smooth[at]) >= _CARRY_GREY
|
|
732
|
+
# The mask's outermost pixel is ground the fill and the reach keep for the object's extent, nearly always; one
|
|
733
|
+
# deeper is the object's own rim, which on a flat face is the only detail it has.
|
|
734
|
+
body = _shrink(mask[at], 3).astype(bool)
|
|
735
|
+
proven = (body & changed & (np.abs(along - smooth[at]) >= _CARRY_GREY)).astype(np.uint8)
|
|
736
|
+
if proven.sum() < min_area:
|
|
737
|
+
raise TrackingError("the probe moved the object too little to show which pixels are its own")
|
|
738
|
+
return _paste(proven, at, a.shape)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ── where it is now ──────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _turns(seg: Segment) -> bool:
|
|
745
|
+
return seg.kind == TURN and not seg.grabbed
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _constrain(seg: Segment, p: Params) -> Params:
|
|
749
|
+
"""`p` reduced to the one motion the handle produces: a SLIDE never turns, a TURN never leaves its pivot."""
|
|
750
|
+
if _turns(seg):
|
|
751
|
+
return _turn_about(p[0], seg.pivot, seg.centroid)
|
|
752
|
+
return 0.0, p[1], p[2]
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _rail(seg: Segment) -> Optional[Tuple[float, float]]:
|
|
756
|
+
"""The direction a SLIDE the handle drives along its track can move, as the probe showed it; None otherwise.
|
|
757
|
+
|
|
758
|
+
A carried piece goes wherever the pointer does, and a turn has no line; a piece on a rail has only this one, so
|
|
759
|
+
a match off it is something else in the picture that resembles it. The driver moves the handle along x, so the
|
|
760
|
+
rail is x unless the probe rose or fell by more than it can be read to: a short probe's own noise, carried down
|
|
761
|
+
the whole track, would put the far end of the rail pixels off the piece.
|
|
762
|
+
"""
|
|
763
|
+
if seg.kind != SLIDE or seg.grabbed:
|
|
764
|
+
return None
|
|
765
|
+
n = math.hypot(seg.probe[1], seg.probe[2])
|
|
766
|
+
if n == 0:
|
|
767
|
+
return None
|
|
768
|
+
if abs(seg.probe[2]) <= _PROBE_PRECISION:
|
|
769
|
+
return (math.copysign(1.0, seg.probe[1]), 0.0)
|
|
770
|
+
return (seg.probe[1] / n, seg.probe[2] / n)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _angles(centre: float, reach: float) -> List[float]:
|
|
774
|
+
n = max(1, int(math.ceil(reach / _TURN_STEP)))
|
|
775
|
+
return [centre + _TURN_STEP * i for i in range(-n, n + 1)]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _peak(res: np.ndarray) -> Tuple[float, float, float]:
|
|
779
|
+
"""The score map's best point to a fraction of a pixel (a parabola through each axis), and its score."""
|
|
780
|
+
y, x = np.unravel_index(int(np.argmax(res)), res.shape)
|
|
781
|
+
score = res[y, x]
|
|
782
|
+
|
|
783
|
+
def vertex(lo: float, mid: float, hi: float) -> float:
|
|
784
|
+
den = lo - 2 * mid + hi
|
|
785
|
+
return 0.0 if den >= 0 else max(-0.5, min(0.5, 0.5 * (lo - hi) / den))
|
|
786
|
+
|
|
787
|
+
dx = vertex(res[y, x - 1], res[y, x], res[y, x + 1]) if 0 < x < res.shape[1] - 1 else 0.0
|
|
788
|
+
dy = vertex(res[y - 1, x], res[y, x], res[y + 1, x]) if 0 < y < res.shape[0] - 1 else 0.0
|
|
789
|
+
return x + dx, y + dy, float(score)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _match(a: np.ndarray, b: np.ndarray, mask: np.ndarray, c0: Point, p: Params,
|
|
793
|
+
reach: int) -> Optional[Tuple[Params, float]]:
|
|
794
|
+
"""The object carried by `p`, matched against `b` within `reach` pixels: the shift that best seats it."""
|
|
795
|
+
h, w = a.shape
|
|
796
|
+
m = _rigid(p, c0)
|
|
797
|
+
bx1, by1, bx2, by2 = _box(_corners(mask) @ m[:, :2].T + m[:, 2], w, h)
|
|
798
|
+
if bx2 <= bx1 or by2 <= by1:
|
|
799
|
+
return None
|
|
800
|
+
# Only the box the carried mask can land in is warped; the template is its tight bounds within that.
|
|
801
|
+
back = _from(_inverse(m), bx1, by1)
|
|
802
|
+
wmask = _warp(mask, back, (by2 - by1, bx2 - bx1), order=0) > 0
|
|
803
|
+
ys, xs = np.nonzero(wmask)
|
|
804
|
+
if len(xs) < _MIN_PART_PX:
|
|
805
|
+
return None
|
|
806
|
+
warped = np.rint(_warp(a, back, wmask.shape))
|
|
807
|
+
xs, ys = xs + bx1, ys + by1
|
|
808
|
+
x1, y1, x2, y2 = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
|
|
809
|
+
sx1, sy1, sx2, sy2 = max(0, x1 - reach), max(0, y1 - reach), min(w, x2 + reach), min(h, y2 + reach)
|
|
810
|
+
if sx2 - sx1 < x2 - x1 or sy2 - sy1 < y2 - y1:
|
|
811
|
+
return None
|
|
812
|
+
tight = (slice(y1 - by1, y2 - by1), slice(x1 - bx1, x2 - bx1))
|
|
813
|
+
px, py, score = _peak(_correlation(b[sy1:sy2, sx1:sx2], warped[tight], wmask[tight]))
|
|
814
|
+
return (float(p[0]), float(p[1] + sx1 + px - x1), float(p[2] + sy1 + py - y1)), float(score)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _correlation(img: np.ndarray, templ: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
|
818
|
+
"""Zero-mean normalized correlation of `templ` over `mask` at every place it fits in `img`; -1 where the window
|
|
819
|
+
is flat and has no correlation.
|
|
820
|
+
|
|
821
|
+
All three sums are of whole grey levels over a 0/1 mask, so they are whole numbers: rounding the FFT's results
|
|
822
|
+
makes them exact, and a flat window's variance exactly zero rather than rounding noise that scores as a match.
|
|
823
|
+
"""
|
|
824
|
+
i, t, m = img.astype(np.float64), templ.astype(np.float64), mask.astype(np.float64)
|
|
825
|
+
n = m.sum()
|
|
826
|
+
(ih, iw), (th, tw) = i.shape, t.shape
|
|
827
|
+
shape = (fft.next_fast_len(ih + th - 1, real=True), fft.next_fast_len(iw + tw - 1, real=True))
|
|
828
|
+
f_i, f_ii, f_m, f_tm = (fft.rfft2(x, shape) for x in (i, i * i, m[::-1, ::-1], (t * m)[::-1, ::-1]))
|
|
829
|
+
|
|
830
|
+
def window(f: np.ndarray) -> np.ndarray:
|
|
831
|
+
return np.rint(fft.irfft2(f, shape)[th - 1:ih, tw - 1:iw])
|
|
832
|
+
|
|
833
|
+
s_i, s_ii, s_ti = window(f_i * f_m), window(f_ii * f_m), window(f_i * f_tm)
|
|
834
|
+
s_t = float((t * m).sum())
|
|
835
|
+
var_t = float((t * t * m).sum()) - s_t * s_t / n
|
|
836
|
+
den = np.sqrt(np.maximum(var_t * (s_ii - s_i * s_i / n), 0.0))
|
|
837
|
+
return np.where(den > 0, (s_ti - s_t * s_i / n) / np.where(den > 0, den, 1.0), -1.0)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _score(a: np.ndarray, b: np.ndarray, mask: np.ndarray, c0: Point, p: Params) -> float:
|
|
841
|
+
"""Zero-mean correlation of the object carried exactly by `p` against `b`, over the object only."""
|
|
842
|
+
h, w = a.shape
|
|
843
|
+
m = _rigid(p, c0)
|
|
844
|
+
x1, y1, x2, y2 = _box(_corners(mask) @ m[:, :2].T + m[:, 2], w, h)
|
|
845
|
+
if x2 - x1 < 3 or y2 - y1 < 3:
|
|
846
|
+
return -1.0
|
|
847
|
+
shift = m.copy()
|
|
848
|
+
shift[:, 2] -= (x1, y1)
|
|
849
|
+
# Only the object's own box is read: outside it the mask is 0, and no pixel there is scored.
|
|
850
|
+
at = _region(mask, 1)
|
|
851
|
+
back = _inverse(shift)
|
|
852
|
+
back[:, 2] -= (at[1].start, at[0].start)
|
|
853
|
+
both = _warp(np.dstack([a[at], mask[at]]), back, (y2 - y1, x2 - x1))
|
|
854
|
+
warped, inside = both[..., 0], both[..., 1] > 0.99
|
|
855
|
+
if inside.sum() < _MIN_PART_PX:
|
|
856
|
+
return -1.0
|
|
857
|
+
u, v = warped[inside], b[y1:y2, x1:x2].astype(np.float32)[inside]
|
|
858
|
+
u, v = u - u.mean(), v - v.mean()
|
|
859
|
+
den = math.sqrt(float((u * u).sum() * (v * v).sum()))
|
|
860
|
+
return float((u * v).sum() / den) if den > 0 else -1.0
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _corners(mask: np.ndarray) -> np.ndarray:
|
|
864
|
+
rows, cols = _region(mask, 0)
|
|
865
|
+
x1, y1, x2, y2 = cols.start, rows.start, cols.stop, rows.stop
|
|
866
|
+
return np.float64([[x1, y1], [x2, y1], [x1, y2], [x2, y2]])
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _box(pts: np.ndarray, w: int, h: int) -> Tuple[int, int, int, int]:
|
|
870
|
+
x1, y1 = np.floor(pts.min(axis=0)).astype(int) - 1
|
|
871
|
+
x2, y2 = np.ceil(pts.max(axis=0)).astype(int) + 2
|
|
872
|
+
return max(0, x1), max(0, y1), min(w, x2), min(h, y2)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _polish(a: np.ndarray, b: np.ndarray, mask: np.ndarray, c0: Point, p: Params,
|
|
876
|
+
pivot: Optional[Point]) -> Tuple[Params, float]:
|
|
877
|
+
"""The last fraction of a pixel (and of a degree), read from the exact warp's own score on a quarter grid.
|
|
878
|
+
|
|
879
|
+
A template match can only shift whole pixels and guess between them; scoring the object warped by sub-pixel
|
|
880
|
+
amounts asks the question directly, one axis at a time, with a parabola through the best quarter.
|
|
881
|
+
"""
|
|
882
|
+
def at(q: List[float]) -> Params:
|
|
883
|
+
return _turn_about(q[0], pivot, c0) if pivot is not None else (q[0], q[1], q[2])
|
|
884
|
+
|
|
885
|
+
q = [p[0], p[1], p[2]]
|
|
886
|
+
axes = [0] if pivot is not None else ([0, 1, 2] if p[0] else [1, 2])
|
|
887
|
+
best = _score(a, b, mask, c0, at(q))
|
|
888
|
+
for _ in range(2):
|
|
889
|
+
for ax in axes:
|
|
890
|
+
step = 0.25
|
|
891
|
+
vals = []
|
|
892
|
+
for k in (-2, -1, 0, 1, 2):
|
|
893
|
+
trial = list(q)
|
|
894
|
+
trial[ax] += k * step
|
|
895
|
+
vals.append(_score(a, b, mask, c0, at(trial)))
|
|
896
|
+
k = int(np.argmax(vals))
|
|
897
|
+
off = (k - 2) * step
|
|
898
|
+
if 0 < k < 4:
|
|
899
|
+
lo, mid, hi = vals[k - 1], vals[k], vals[k + 1]
|
|
900
|
+
den = lo - 2 * mid + hi
|
|
901
|
+
if den < 0:
|
|
902
|
+
off += step * max(-0.5, min(0.5, 0.5 * (lo - hi) / den))
|
|
903
|
+
trial = list(q)
|
|
904
|
+
trial[ax] += off
|
|
905
|
+
got = _score(a, b, mask, c0, at(trial))
|
|
906
|
+
if got >= best:
|
|
907
|
+
q, best = trial, got
|
|
908
|
+
return at(q), best
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _locate(a: np.ndarray, b: np.ndarray, mask: np.ndarray, c0: Point, guess: Params, reach_px: float,
|
|
912
|
+
angles: Optional[Sequence[float]], pivot: Optional[Point] = None) -> Tuple[Params, float]:
|
|
913
|
+
"""Where the object is in `b`: masked template matching near `guess`, to a tenth of a pixel.
|
|
914
|
+
|
|
915
|
+
Matching, not a gradient method: it searches the whole window, so it cannot drift along an edge the way masked
|
|
916
|
+
ECC did (a third of a pixel off the track by its second step). `angles` searches a turn too, about `pivot` when
|
|
917
|
+
given; its sub-degree comes from the scores either side of the best angle. `_polish` then reads the last fraction
|
|
918
|
+
off the exact warp, because the parabola that reads a peak between whole pixels is biased toward the nearest.
|
|
919
|
+
"""
|
|
920
|
+
if angles is None:
|
|
921
|
+
cands = [(0.0, guess[1], guess[2])]
|
|
922
|
+
elif pivot is None:
|
|
923
|
+
cands = [(float(th), guess[1], guess[2]) for th in angles]
|
|
924
|
+
else:
|
|
925
|
+
cands = [_turn_about(float(th), pivot, c0) for th in angles]
|
|
926
|
+
found = [(c, _match(a, b, mask, c0, c, int(math.ceil(reach_px if angles is None or pivot is None else 2))))
|
|
927
|
+
for c in cands]
|
|
928
|
+
scored = [(c, f) for c, f in found if f is not None]
|
|
929
|
+
if not scored:
|
|
930
|
+
raise TrackingError("the object left the shot")
|
|
931
|
+
i = int(np.argmax([f[1] for _, f in scored]))
|
|
932
|
+
best, score = scored[i][1]
|
|
933
|
+
if angles is not None and 0 < i < len(scored) - 1:
|
|
934
|
+
lo, hi = scored[i - 1][1][1], scored[i + 1][1][1]
|
|
935
|
+
den = lo - 2 * score + hi
|
|
936
|
+
if den < 0:
|
|
937
|
+
th = best[0] + _TURN_STEP * max(-0.5, min(0.5, 0.5 * (lo - hi) / den))
|
|
938
|
+
best = _turn_about(th, pivot, c0) if pivot is not None else (th, best[1], best[2])
|
|
939
|
+
best, score = _match(a, b, mask, c0, best, 2) or (best, score)
|
|
940
|
+
again = _match(a, b, mask, c0, best, 2)
|
|
941
|
+
if again is not None:
|
|
942
|
+
best, score = again
|
|
943
|
+
return _polish(a, b, mask, c0, best, pivot)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _share(sa: np.ndarray, sb: np.ndarray, sil: np.ndarray, box: Tuple[int, int, int, int], dx: float,
|
|
947
|
+
dy: float) -> float:
|
|
948
|
+
"""The share of the pixels `sil` (in `box`) that land within `_CARRY_GREY` of `sb` shifted by (dx, dy), in
|
|
949
|
+
smoothed grey; -1 when the shift takes them out of the shot."""
|
|
950
|
+
x1, y1, x2, y2 = box
|
|
951
|
+
h, w = sb.shape
|
|
952
|
+
if x1 + dx < 0 or y1 + dy < 0 or x2 + dx > w or y2 + dy > h:
|
|
953
|
+
return -1.0
|
|
954
|
+
there = _warp(sb, np.array([[1.0, 0.0, x1 + dx], [0.0, 1.0, y1 + dy]]), (y2 - y1, x2 - x1))
|
|
955
|
+
return float((np.abs(there - sa[y1:y2, x1:x2])[sil] < _CARRY_GREY).mean())
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _along_rail(a: np.ndarray, b: np.ndarray, seg: Segment, guess: Params, reach: float,
|
|
959
|
+
rail: Tuple[float, float]) -> Tuple[Params, float]:
|
|
960
|
+
"""Where a piece on a rail is: its face slid along the line, scored by the share of it that lands on its own
|
|
961
|
+
value, then the sub-pixel read off the proven pixels' correlation.
|
|
962
|
+
|
|
963
|
+
The face is the proven pixels and the mask's interior, two pixels clear of its rim, where the smoothing reaches
|
|
964
|
+
no ground. Correlation is the wrong question at range for a piece whose face is flat: with nothing inside to
|
|
965
|
+
vary, the rim decides it, and correlation normalizes away the one thing that tells a flat face from any other
|
|
966
|
+
flat ground, its grey. On GeeTest the seat scored 0.26-0.5 against a plateau of decoys at 0.49 along a white
|
|
967
|
+
phone screen; the share is 0.88 there. Where its peaks tie along a stretch of ground the shade of the face, the
|
|
968
|
+
peak nearest `guess` is taken (`_SHARE_TIE`). Within a pixel of the seat the edges' correlation is the sharper
|
|
969
|
+
of the two.
|
|
970
|
+
"""
|
|
971
|
+
sa, sb = _smooth(a, _SHARE_SMOOTH), _smooth(b, _SHARE_SMOOTH)
|
|
972
|
+
face = seg.proven.astype(bool) | _shrink(seg.mask, 5).astype(bool)
|
|
973
|
+
ys, xs = np.nonzero(face)
|
|
974
|
+
box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
|
|
975
|
+
sil = face[box[1]:box[3], box[0]:box[2]]
|
|
976
|
+
n = int(math.ceil(reach))
|
|
977
|
+
# The rail runs through rest, not through the guess: a guess extrapolated from the probe carries the probe's
|
|
978
|
+
# own error, which on GeeTest's long track put the line 4.5 px above the seat.
|
|
979
|
+
t = guess[1] * rail[0] + guess[2] * rail[1]
|
|
980
|
+
along = [((t + k) * rail[0], (t + k) * rail[1]) for k in range(-n, n + 1)]
|
|
981
|
+
scores = np.array([_share(sa, sb, sil, box, dx, dy) for dx, dy in along])
|
|
982
|
+
if scores.max() < 0:
|
|
983
|
+
raise TrackingError("the object left the shot")
|
|
984
|
+
# A peak is the best place within two pixels of itself; a slope beside the true peak never is.
|
|
985
|
+
peaks = np.nonzero(scores >= np.array([scores[max(0, k - 2):k + 3].max() for k in range(len(scores))]))[0]
|
|
986
|
+
tied = peaks[scores[peaks] >= scores.max() - _SHARE_TIE]
|
|
987
|
+
i = int(tied[np.argmin(np.abs(tied - n))])
|
|
988
|
+
params, _ = _locate(a, b, seg.proven, seg.centroid, (0.0, *along[i]), 1.0, None)
|
|
989
|
+
if math.hypot(params[1] - along[i][0], params[2] - along[i][1]) > 1.5:
|
|
990
|
+
params = (0.0, *along[i])
|
|
991
|
+
return params, max(scores[i], _share(sa, sb, sil, box, params[1], params[2]))
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def measure(first: Image, current: Image, seg: Segment, guess: Optional[Params] = None,
|
|
995
|
+
reach_px: Optional[float] = None, reach_deg: Optional[float] = None) -> Tuple[Params, float]:
|
|
996
|
+
"""The transform `first` -> `current` of the segmented object, and the match's confidence: the correlation, or
|
|
997
|
+
for a piece on a rail the share of it that lands where it was (`_along_rail`).
|
|
998
|
+
|
|
999
|
+
Raises TrackingError below `_MIN_CORRELATION` (`_MIN_SHARE`): the object is gone, covered, or redrawn, and a
|
|
1000
|
+
position read off a poor match would steer the handle by a guess.
|
|
1001
|
+
"""
|
|
1002
|
+
a, b = _pair(first, current)
|
|
1003
|
+
g = _constrain(seg, guess or (0.0, 0.0, 0.0))
|
|
1004
|
+
rp = reach_px if reach_px is not None else max(15.0, 0.3 * math.hypot(g[1], g[2]))
|
|
1005
|
+
rd = reach_deg if reach_deg is not None else max(4.0, 0.3 * abs(g[0]))
|
|
1006
|
+
rail = _rail(seg)
|
|
1007
|
+
if rail is not None:
|
|
1008
|
+
params, share = _along_rail(a, b, seg, g, rp, rail)
|
|
1009
|
+
if share < _MIN_SHARE:
|
|
1010
|
+
raise TrackingError(f"lost the object: at best {share:.0%} of it lands where it was")
|
|
1011
|
+
return params, share
|
|
1012
|
+
params, corr = _locate(a, b, seg.proven, seg.centroid, g, rp, _angles(g[0], rd) if _turns(seg) else None,
|
|
1013
|
+
seg.pivot if _turns(seg) else None)
|
|
1014
|
+
if corr < _MIN_CORRELATION:
|
|
1015
|
+
raise TrackingError(f"lost the object: its best match correlates {corr:.2f}")
|
|
1016
|
+
return params, corr
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ── where to put the handle ──────────────────────────────────────────────────
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _model(samples: Sequence[Sample], seg: Segment) -> Callable[[np.ndarray], List[Params]]:
|
|
1023
|
+
"""The motion as a line in travel s, weighted toward the latest samples: a vendor's ratio can bend, and the
|
|
1024
|
+
newest readings are the ones taken nearest the goal. The press itself (s=0, no motion) is always a sample."""
|
|
1025
|
+
turn = seg.kind == TURN
|
|
1026
|
+
pts = [Sample(0.0, (0.0, 0.0, 0.0)), *samples]
|
|
1027
|
+
s = np.array([p.s for p in pts], dtype=float)
|
|
1028
|
+
q = np.array([[p.params[0]] if turn else list(p.params[1:]) for p in pts], dtype=float)
|
|
1029
|
+
wts = 4.0 ** np.arange(len(pts))
|
|
1030
|
+
if len(pts) == 2 or np.ptp(s) < 1e-6:
|
|
1031
|
+
a0 = np.zeros(q.shape[1])
|
|
1032
|
+
k = (wts[:, None] * s[:, None] * q).sum(0) / max(float((wts * s * s).sum()), 1e-9)
|
|
1033
|
+
else:
|
|
1034
|
+
x = np.stack([np.ones_like(s), s], axis=1) * np.sqrt(wts)[:, None]
|
|
1035
|
+
(a0, k), *_ = np.linalg.lstsq(x, q * np.sqrt(wts)[:, None], rcond=None)
|
|
1036
|
+
|
|
1037
|
+
def at(travel: np.ndarray) -> List[Params]:
|
|
1038
|
+
v = a0[None, :] + np.asarray(travel, dtype=float)[:, None] * k[None, :]
|
|
1039
|
+
if turn:
|
|
1040
|
+
return [_turn_about(float(th), seg.pivot, seg.centroid) for th in v[:, 0]]
|
|
1041
|
+
return [(0.0, float(tx), float(ty)) for tx, ty in v]
|
|
1042
|
+
|
|
1043
|
+
return at
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def predict(samples: Sequence[Sample], seg: Segment, s: float) -> Params:
|
|
1047
|
+
return _model(samples, seg)(np.array([s]))[0]
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def plan(samples: Sequence[Sample], seg: Segment, point: Point, goal: Point, bounds: Tuple[float, float]) -> float:
|
|
1051
|
+
"""The handle travel, within `bounds`, whose predicted motion puts `point` nearest `goal`."""
|
|
1052
|
+
travel = np.arange(float(bounds[0]), float(bounds[1]) + 0.25, 0.25)
|
|
1053
|
+
pos = np.array([transform_point(p, seg.centroid, point) for p in _model(samples, seg)(travel)])
|
|
1054
|
+
return float(travel[int(np.argmin(np.hypot(pos[:, 0] - goal[0], pos[:, 1] - goal[1])))])
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _same(x: Image, y: Image) -> bool:
|
|
1058
|
+
return x is y or (isinstance(x, str) and isinstance(y, str) and x == y)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def slide_step(first: Image, probe: Image, current: Image, *, probe_s: Offset, s: Offset, goal: Point,
|
|
1062
|
+
seed: Optional[Point] = None, handle: Optional[Point] = None,
|
|
1063
|
+
bounds: Optional[Tuple[float, float]] = None, history: Sequence[Dict[str, Any]] = (),
|
|
1064
|
+
tol: float = 2.0) -> Dict[str, Any]:
|
|
1065
|
+
"""One look of the closed loop: where the object's point is now, and where the handle should go next.
|
|
1066
|
+
|
|
1067
|
+
`probe_s` and `s` are the handle's offsets from the press for the probe and the current shot: a number (travel
|
|
1068
|
+
along the track) or, once `grabbed` has come back true, an [dx, dy] pair. `history` is the `observation` each
|
|
1069
|
+
earlier call returned, oldest first, excluding the probe. `done` means stop moving: the point is within `tol`, or
|
|
1070
|
+
no travel inside `bounds` brings it closer; `error` says which. Raises TrackingError when the object cannot be
|
|
1071
|
+
found or followed.
|
|
1072
|
+
"""
|
|
1073
|
+
seg = segment(first, probe, seed, handle, goal)
|
|
1074
|
+
point = seed if seed is not None else seg.centroid
|
|
1075
|
+
a = _gray(first)
|
|
1076
|
+
|
|
1077
|
+
if seg.grabbed:
|
|
1078
|
+
at = np.array(s if isinstance(s, (list, tuple)) else (s, 0.0), dtype=float)
|
|
1079
|
+
# A carried piece follows the pointer, so the pointer's own offset is the guess.
|
|
1080
|
+
params, corr = measure(first, current, seg, guess=(0.0, float(at[0]), float(at[1])))
|
|
1081
|
+
pos = transform_point(params, seg.centroid, point)
|
|
1082
|
+
err = (goal[0] - pos[0], goal[1] - pos[1])
|
|
1083
|
+
dist = math.hypot(*err)
|
|
1084
|
+
return {"pos": list(pos), "params": list(params), "error": dist, "grabbed": True, "kind": SLIDE,
|
|
1085
|
+
"next": [float(at[0] + err[0]), float(at[1] + err[1])], "done": dist <= tol, "correlation": corr,
|
|
1086
|
+
"observation": {"s": [float(v) for v in at], "params": list(params)}}
|
|
1087
|
+
|
|
1088
|
+
ps = float(probe_s[0] if isinstance(probe_s, (list, tuple)) else probe_s)
|
|
1089
|
+
cur = float(s[0] if isinstance(s, (list, tuple)) else s)
|
|
1090
|
+
samples = [Sample(ps, seg.probe)] + [Sample(float(e["s"]), tuple(float(v) for v in e["params"])) for e in history]
|
|
1091
|
+
if _same(current, probe):
|
|
1092
|
+
params, corr = measure(first, probe, seg, guess=seg.probe, reach_px=2.0, reach_deg=1.0)
|
|
1093
|
+
else:
|
|
1094
|
+
params, corr = measure(first, current, seg, guess=predict(samples, seg, cur))
|
|
1095
|
+
samples.append(Sample(cur, params))
|
|
1096
|
+
pos = transform_point(params, seg.centroid, point)
|
|
1097
|
+
dist = math.hypot(goal[0] - pos[0], goal[1] - pos[1])
|
|
1098
|
+
width = float(a.shape[1])
|
|
1099
|
+
nxt = cur if dist <= tol else plan(samples, seg, point, goal, bounds if bounds is not None else (-width, width))
|
|
1100
|
+
return {"pos": list(pos), "params": list(params), "error": dist, "grabbed": False, "kind": seg.kind,
|
|
1101
|
+
"next": nxt, "done": dist <= tol or abs(nxt - cur) < 0.5, "correlation": corr,
|
|
1102
|
+
"observation": {"s": cur, "params": list(params)}}
|