Spaces:
Sleeping
Sleeping
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
|