File size: 16,407 Bytes
a1dd5ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""The object channel: every subject in play, discovered β€” never named.

User-set design rules, enforced here:
  - NOTHING predefined. FastSAM runs in prompt-free segment-everything
    mode: the frame goes in, class-agnostic region masks come out. No
    text prompts, no class lists, no dataset words β€” a hard rule, because
    this is a general store where anyone uploads anything.
  - NESTED sub-subjects. SAM proposes masks at several granularities
    (a person AND their shirt AND their hat). Containment builds the
    hierarchy: region A is a child of B when most of A's box lies inside
    B's (IoA > 0.75). Children are kept as their own rows β€” a query about
    the shirt matches the shirt crop, not the whole person.
  - MOTION integrated per subject. Each region carries a motion score:
    mean |pixel delta| inside its box between the keyframe and a nearby
    frame, normalized per frame. The moving subject is the one acting.

Each region crop is embedded with the SAME SigLIP space queries live in,
so retrieval is one matmul: score(query, event) = max over that event's
region embeddings. This is the multi-vector layout at the OBJECT level β€”
"cloth" matches the cloth crop even when the scene is cluttered.
"""
from __future__ import annotations

import time
import uuid

import numpy as np
import pyarrow as pa
import pyarrow.compute as pc

_FSAM = {}


def _fastsam():
    if "m" not in _FSAM:
        from ultralytics import FastSAM
        _FSAM["m"] = FastSAM("FastSAM-s.pt")
    return _FSAM["m"]


def _regions(frame_u8, max_regions=24, min_area=0.002, conf=0.4):
    """Prompt-free region proposals -> [(box xyxy, area_frac, parent)].
    Parent index (-1 = top-level) comes from box containment (IoA)."""
    m = _fastsam()
    H, W = frame_u8.shape[:2]
    res = m(frame_u8, device="mps", retina_masks=False, imgsz=448,
            conf=conf, iou=0.9, verbose=False)
    if not res or res[0].boxes is None or len(res[0].boxes) == 0:
        return []
    boxes = res[0].boxes.xyxy.cpu().numpy()
    areas = ((boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
             / (H * W))
    keep = np.where((areas > min_area) & (areas < 0.9))[0]
    keep = keep[np.argsort(-areas[keep])][:max_regions]
    boxes, areas = boxes[keep], areas[keep]
    parents = np.full(len(boxes), -1, np.int32)
    for i in range(len(boxes)):          # sorted big->small: parents first
        xi0, yi0, xi1, yi1 = boxes[i]
        ai = max((xi1 - xi0) * (yi1 - yi0), 1e-6)
        for j in range(i):               # smallest enclosing earlier box
            xj0, yj0, xj1, yj1 = boxes[j]
            ix = max(0, min(xi1, xj1) - max(xi0, xj0))
            iy = max(0, min(yi1, yj1) - max(yi0, yj0))
            if ix * iy / ai > 0.75:
                parents[i] = j
        # keep the SMALLEST enclosing parent (deepest nesting)
        best = -1
        for j in range(i):
            xj0, yj0, xj1, yj1 = boxes[j]
            ix = max(0, min(xi1, xj1) - max(xi0, xj0))
            iy = max(0, min(yi1, yj1) - max(yi0, yj0))
            if ix * iy / ai > 0.75 and (
                    best < 0 or areas[j] < areas[best]):
                best = j
        parents[i] = best
    return [(boxes[i], float(areas[i]), int(parents[i]))
            for i in range(len(boxes))]


def _motion_energy(frame_a, frame_b, box):
    x0, y0, x1, y1 = (int(v) for v in box)
    a = frame_a[y0:y1, x0:x1].astype(np.float32)
    b = frame_b[y0:y1, x0:x1].astype(np.float32)
    if a.size == 0 or a.shape != b.shape:
        return 0.0
    return float(np.abs(a - b).mean() / 255.0)


def index_objects(store, keyframes_per_recording=2, max_regions=24,
                  verbose=True):
    """Materialize `object_vectors`: one row per discovered region.
    Columns: ts/t1 (recording span), stream, vector (SigLIP crop), area,
    parent (row offset within the same keyframe, -1 top-level), motion."""
    from PIL import Image

    from .embeddings import _embed_images
    from .video import FrameSet
    t_start = time.time()

    try:
        ep = store.table("episodes").scan()
        recs = list(zip(ep.column("stream").to_pylist(),
                        (int(v) for v in ep.column("ts").to_pylist()),
                        (int(v) for v in ep.column("t1").to_pylist())))
    except Exception:
        recs = []
    frames_tbl = store.table("frames").scan()
    if not recs:
        for s in sorted(set(frames_tbl.column("stream").to_pylist())):
            sel = frames_tbl.filter(pc.equal(frames_tbl.column("stream"), s))
            ts = sorted(int(v) for v in sel.column("ts").to_pylist())
            recs.append((s, ts[0], ts[-1]))

    rows = {"ts": [], "t1": [], "stream": [], "area": [], "parent": [],
            "motion": [], "kf": []}
    crops = []
    n_frames = 0
    for ri, (s, a, b) in enumerate(recs):
        sel = frames_tbl.filter(pc.and_(
            pc.equal(frames_tbl.column("stream"), s),
            pc.and_(pc.greater_equal(frames_tbl.column("ts"), a),
                    pc.less_equal(frames_tbl.column("ts"), b))))
        if len(sel) < 3:
            continue
        pick = np.linspace(0, len(sel) - 2,
                           min(keyframes_per_recording, len(sel) - 1)) \
            .round().astype(int)
        # decode each keyframe AND its next frame for motion energy
        want = sorted({int(p) for p in pick} | {int(p) + 1 for p in pick})
        dec = FrameSet(store, "frames",
                       sel.take(np.array(want))).decode(width=448)
        if len(dec) < 2:
            continue
        by_pos = {want[i]: d[1] for i, d in enumerate(sorted(dec))
                  if i < len(want)}
        n_frames += len(pick)
        for p in pick:
            fr, nxt = by_pos.get(int(p)), by_pos.get(int(p) + 1)
            if fr is None:
                continue
            base_row = len(rows["ts"])
            for box, area, parent in _regions(fr, max_regions=max_regions):
                x0, y0, x1, y1 = (int(v) for v in box)
                crops.append(Image.fromarray(fr[y0:y1, x0:x1]))
                rows["ts"].append(a)
                rows["t1"].append(b)
                rows["stream"].append(s)
                rows["area"].append(area)
                rows["parent"].append(parent if parent < 0
                                      else base_row + parent)
                rows["motion"].append(
                    _motion_energy(fr, nxt, box) if nxt is not None else 0.0)
                rows["kf"].append(int(p))
        if verbose and (ri + 1) % 200 == 0:
            print(f"  {ri + 1}/{len(recs)} recordings, "
                  f"{len(crops)} regions (t={time.time() - t_start:.0f}s)",
                  flush=True)
    if not crops:
        return {"regions": 0}

    # crops MUST live in the same space as the query text tower
    # (DEFAULT_MODEL) β€” the 'fast' 224 checkpoint is a different space and
    # its cosines would be meaningless against q_full
    from .embeddings import DEFAULT_MODEL
    vecs = []
    B = 64
    for i in range(0, len(crops), B):
        vecs.append(_embed_images(crops[i:i + B], DEFAULT_MODEL))
    vecs = np.concatenate(vecs).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-8

    dim = vecs.shape[1]
    order = np.argsort(np.array(rows["ts"]), kind="stable")
    flat = np.ascontiguousarray(vecs[order]).reshape(-1)
    tbl = pa.table({
        "ts": pa.array([rows["ts"][i] for i in order], pa.int64()),
        "t1": pa.array([rows["t1"][i] for i in order], pa.int64()),
        "stream": pa.array([rows["stream"][i] for i in order]),
        "vector": pa.FixedSizeListArray.from_arrays(pa.array(flat), dim),
        "area": pa.array([rows["area"][i] for i in order], pa.float32()),
        "parent": pa.array([rows["parent"][i] for i in order], pa.int32()),
        "motion": pa.array([rows["motion"][i] for i in order],
                           pa.float32()),
    })
    tab = store.table("object_vectors")
    meta = {"model": "fastsam-s + siglip crops", "dim": dim,
            "keyframes_per_recording": keyframes_per_recording,
            "max_regions": max_regions, "nested": True}
    try:
        existing = tab.state().files
    except Exception:
        existing = []
    if existing:
        from .log import FileEntry
        from .store import write_parquet
        fname = f"part-{uuid.uuid4().hex[:12]}.parquet"
        path = tab.dir / fname
        write_parquet(tbl, path)
        tsv = tbl.column("ts")
        version = tab.log.commit(
            op="replace", kind="embeddings", schema=str(tbl.schema),
            add=[FileEntry(fname, len(tbl), path.stat().st_size,
                           tsv[0].as_py(), tsv[-1].as_py())],
            remove=[f.path for f in existing], meta=meta)
    else:
        version = tab.append(tbl, kind="embeddings", meta=meta)
    return {"regions": len(tbl), "recordings": len(recs),
            "keyframes": n_frames, "dim": dim, "version": version,
            "seconds": round(time.time() - t_start, 1)}


_OBJ_IDX = {}


def object_lookup(store, qv):
    """lookup(stream, t0, t1) -> (best region cosine, its motion score)
    for the recording containing the span; (nan, 0) when absent.
    Per-stream span index cached per version; one matmul per query."""
    from .embeddings import _vec_table
    ver = store.table("object_vectors").state().version
    key = (str(store.dir), ver)
    if key not in _OBJ_IDX:
        tbl, _ = _vec_table(store, "object_vectors")
        ss = np.asarray(tbl.column("stream").to_pylist())
        sa = np.asarray([int(v) for v in tbl.column("ts").to_pylist()])
        sb = np.asarray([int(v) for v in tbl.column("t1").to_pylist()])
        mo = np.asarray(tbl.column("motion").to_pylist(), np.float32)
        idx = {}
        for s in np.unique(ss):
            m = np.where(ss == s)[0]
            o = np.argsort(sa[m], kind="stable")
            idx[s] = (sa[m][o], sb[m][o], m[o])
        if len(_OBJ_IDX) > 8:
            _OBJ_IDX.clear()
        _OBJ_IDX[key] = (idx, mo)
    idx, mo = _OBJ_IDX[key]
    _, vecs = _vec_table(store, "object_vectors")
    # CONJUNCTION OVER REGIONS β€” the channel's whole point. `qv` may be a
    # matrix of clause/atom embeddings: a compound sentence scored against
    # a small crop is a bag-of-concepts mismatch (measured: green-binding
    # unmoved with whole-sentence scoring), but per-atom max over regions
    # then soft-AND across atoms asks the right question β€” does this
    # recording contain a green-toy region AND a drawer region?
    Q = np.atleast_2d(np.asarray(qv, np.float32))
    S = vecs @ Q.T                                   # (rows, atoms)

    def lookup(s, a, b):
        if s not in idx:
            return float("nan"), 0.0
        t0s, t1s, rows = idx[s]
        lo = int(np.searchsorted(t0s, a, side="right"))
        j0 = lo - 1
        if j0 < 0 or b > int(t1s[j0]) + 1:
            return float("nan"), 0.0
        # all rows of this recording share (t0, t1): walk the run
        j = j0
        while j >= 0 and t0s[j] == t0s[j0]:
            j -= 1
        run = rows[j + 1:lo]
        if len(run) == 0:
            return float("nan"), 0.0
        per_atom = S[run].max(axis=0)                # best region per atom
        which = int(np.argmax(S[run][:, 0]))
        # CONJUNCTION = the WEAKEST required object. The mean let one
        # strong crop carry a recording whose other object barely matched
        # (attributed live: 'a vessel' matched nothing, so any fork with a
        # good 'lid'-ish crop won; drawer crops are everywhere, so 'green'
        # never had to be real). min() makes every named object earn it.
        return float(per_atom.min()), float(mo[run[which]])
    return lookup


def object_candidates(store, qv, top=24):
    """Top recordings by best motion-weighted region match β€” the object
    channel's own recall: a small matching crop finds a recording whose
    whole-frame embedding never would."""
    from .embeddings import _vec_table
    tbl, vecs = _vec_table(store, "object_vectors")
    ver = store.table("object_vectors").state().version
    key = (str(store.dir), ver)
    if key not in _OBJ_IDX:
        object_lookup(store, qv)           # builds the cache
    idx, mo = _OBJ_IDX[key]
    Q = np.atleast_2d(np.asarray(qv, np.float32))
    S = (vecs @ Q.T) * (1.0 + mo)[:, None]
    # per-recording soft-AND: mean over atoms of best region per atom
    ss = tbl.column("stream").to_pylist()
    sa = tbl.column("ts").to_pylist()
    sb = tbl.column("t1").to_pylist()
    rec_score = {}
    for s, (t0s, t1s, rows) in idx.items():
        start = 0
        while start < len(rows):
            end = start
            while end < len(rows) and t0s[end] == t0s[start]:
                end += 1
            run = rows[start:end]
            r0 = int(run[0])
            rec_score[(s, int(sa[r0]), int(sb[r0]))] = \
                float(S[run].max(axis=0).min())
            start = end
    out = sorted(rec_score.items(), key=lambda kv: -kv[1])[:top]
    return [(*k, v) for k, v in out]


def bind_lookup(store, qv_mover, qv_landmark):
    """ROLE-AWARE binding: does the MOVER move and the LANDMARK stay?

    The obj channel asks "are both objects present" (min over atoms).
    That is why "put the eggplant into the drawer" scores every episode
    holding an eggplant and a drawer, and why the binding queries sit at
    ~11% precision while direction queries reach 86%.

    A relation has ROLES. In "put X into Y", X is the theme and moves; Y
    is the landmark and largely does not. That asymmetry is already in
    the store - object_vectors carries a per-crop `motion` scalar - it
    was simply never used to tell the two roles apart. This scores:

        min(sim_X, sim_Y)              both objects must really be there
      * relu(motion_X - motion_Y)      and X must be the one that moved

    so an episode where the drawer moves and the eggplant sits still
    scores zero, which is exactly the confusion the flat conjunction
    could not express. No new ingest: pure arithmetic over crops that
    are already embedded.

    MEASURED 2026-07-28 - THIS DOES NOT WORK, AND THE REASON MATTERS.
    Wired as a channel it left the binding queries where it found them
    (q09 best true rank 799 of 1122, q10 rank 441). The role logic is
    not what fails: crop-level object identification is at noise. Across
    all 51,101 crops the best cosine to "an eggplant" is 0.184 (mean
    0.048), and in the two episodes that genuinely contain one the best
    crop scores 0.147 and 0.161 - ranked 110th and 75th corpus-wide. The
    detector cannot find the object, so nothing built on top of it can
    bind it. Fixing binding needs better object GROUNDING (higher-res
    crops, or an open-vocabulary detector), not better relation
    reasoning. Kept, unwired, as the measurement behind that claim.

    Returns lookup(stream, t0, t1) -> float (nan when the recording has
    no crops)."""
    from .embeddings import _vec_table
    ver = store.table("object_vectors").state().version
    key = (str(store.dir), ver)
    if key not in _OBJ_IDX:
        object_lookup(store, np.atleast_2d(np.asarray(qv_mover, np.float32)))
    idx, mo = _OBJ_IDX[key]
    _, vecs = _vec_table(store, "object_vectors")
    m = np.asarray(qv_mover, np.float32).reshape(-1)
    l = np.asarray(qv_landmark, np.float32).reshape(-1)
    m /= np.linalg.norm(m) + 1e-8
    l /= np.linalg.norm(l) + 1e-8
    Sm = vecs @ m
    Sl = vecs @ l

    def lookup(s, a, b):
        if s not in idx:
            return float("nan")
        t0s, t1s, rows = idx[s]
        lo = int(np.searchsorted(t0s, a, side="right"))
        j0 = lo - 1
        if j0 < 0 or b > int(t1s[j0]) + 1:
            return float("nan")
        j = j0
        while j >= 0 and t0s[j] == t0s[j0]:
            j -= 1
        run = rows[j + 1:lo]
        if len(run) == 0:
            return float("nan")
        im = int(np.argmax(Sm[run]))
        il = int(np.argmax(Sl[run]))
        if im == il:
            return 0.0          # one crop cannot play both roles
        present = float(min(Sm[run][im], Sl[run][il]))
        # the mover must out-move the landmark; ties and reversals are
        # evidence AGAINST the relation, not neutral
        delta = float(mo[run[im]] - mo[run[il]])
        return present * max(delta, 0.0)
    return lookup