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.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +621 -259
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +224 -131
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. 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)}}