Action Recognition: new task, model ABC, and workflow block - #2854
Action Recognition: new task, model ABC, and workflow block#2854leeclemnet wants to merge 87 commits into
Conversation
|
👋 Thanks for the pull request! Here is how automated Claude review works here, so you spend credits (and reviewer time) wisely. 🚦 This PR is marked Ready for review, so automated Claude review will run — and every pass spends real credits. Warning 💸 The Claude reviewer bills in credits, not vibesAutomated review spins up a real agent that reads real code and spends real credits on every pass. It is glad to help — but it is not a rubber duck, a linter you poke in a loop, or a substitute for reading the contributing guide. Treat it like an expensive senior reviewer whose time you booked, and show up prepared. Draft when unsure, Ready when you mean it:
However you get there, arrive prepared:
Reviews are not free. A draft costs nothing to review; a Ready PR is a promise that it is worth reviewing.
|
9ef7ef4 to
2f3768f
Compare
…zation Register cosmos-3-edge under the new video-multi-label-classification task and add Cosmos3EdgeReasoner.temporal_localization: one prompt per clip requesting JSON events in seconds, parsed into sampled-frame index ranges (floor start, ceil end). The parser accepts numeric seconds, mm:ss.ff strings, and the prompt guide's caption key. An env-gated integration test is the Phase 0 prompt-format gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the model-agnostic streaming video classification block (roboflow_core/video_classification_model@v1, Cosmos3 by default) with its tensor sibling, the video_multi_label_classification_prediction kind backed by the typed VideoIntervalClassification entity, and serializer/deserializer/loader wiring. The block samples tumbling windows (seconds x fps, tracker-style fps fallback), calls temporal_localization per window, and emits a cumulative timeline with provisional open ranges plus per-frame active classes. Tail-window classification at stream end needs an execution-engine end-of-stream signal and ships separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prompt for integer frame indices instead of seconds, per review: the platform contract is frame-indexed and unit conversion in the parser adds failure modes before any observed need. An entry survives only when both boundaries are plain JSON integers inside the sampled range; timestamps, floats, numeric strings, and the caption key all drop the entry. The Phase 0 gate tests this format first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match the prior action_recogntion_block v2 conventions per Peter: rename window_size_seconds/sampling_fps to window_seconds/sample_fps, replace tumbling windows with a rolling buffer classified from the first frame onward at a configurable stride_seconds (default window_seconds / 2 for 50 percent overlap), and surface model-call failures through a new error_status output instead of raising, so a stream survives transient failures. Fire time is stamped before the call so a failed call does not retry on every frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Future video classification models will be purpose-built, not VLM wrappers, so nothing outside Cosmos3-specific code may depend on temporal_localization. Add the VideoClassificationModel ABC and the frozen VideoSegmentClassification result dataclass under models/base (single abstract infer; the backend pre/forward/post split does not fit API-wrapping models), and Cosmos3VideoSegmentClassification, which implements infer by wrapping the reasoner. The registry's video-multi-label-classification entry points at the wrapper; the block types against the ABC and wraps a bare reasoner only for the hosted cosmos-3-edge id, whose platform task type is vlm. Naming follows the annotation tooling: Segment, not Interval, throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cosmos3EdgeReasoner is a pure VLM again (prompt / prompt_video only). Cosmos3VideoSegmentClassification owns the temporal-localization prompt template and the strict parser, which now returns VideoSegmentClassification dataclasses directly, and calls the reasoner's generic prompt_video. The Phase 0 integration gate targets the segment classifier's infer(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply the Segment nomenclature to the base module and ABC: models/base/video_segment_classification.py defines VideoSegmentClassificationModel. The workflow block class name is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
By analogy with ClassificationPrediction: the model-layer dataclass and the workflows entity are both named VideoSegmentClassificationPrediction. The ABC and the Cosmos3 class names are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complete the Segment nomenclature sweep: the registry task becomes video-segment-classification, and the kind and its serializers follow (video_segment_classification_prediction). The pre-existing multi-label-classification image task is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serialize_/deserialize_video_segment_classification_prediction_kind now match the video_segment_classification_prediction kind, following the kind-named serializer convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation Match the Cosmos3Edge* family naming (Cosmos3EdgeReasoner, Cosmos3EdgeWorldModel) and the segment nomenclature in the module name: cosmos3_video_segment_classification.py defines Cosmos3EdgeVideoSegmentClassification. Test module renamed to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The block completes the Segment nomenclature sweep: roboflow_core/video_segment_classification_model@v1, display name "Video Segment Classification Model", class VideoSegmentClassificationModelBlockV1, package models/roboflow/video_segment_classification/. The type string is free to change while unreleased. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A naming audit found stragglers the class renames missed: the block's model_id description (which named Cosmos3's mechanism, not the task), the GPU restriction note, the unsupported-model error, the tensor sibling docstring, the changelog feature name, and the integration test name. Search keywords keep "video classification" for discoverability. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per Lee's staging UI test: model_id becomes the generic-model-block required RoboflowModelField; class_names becomes optional and moves to Additional Properties, with vocabulary resolved block param -> model-provided (fine-tunes read class_names from the package's model_config.json) -> fully open (new open-vocabulary prompt, class_id -1); active_classes now emits the standard multi-label classification prediction shape so Classification Label Visualization accepts it (and no longer accepts the timeline kind). Both prompt templates cite NVIDIA's temporal-localization cookbook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow the sibling base-class convention: VideoSegmentClassificationModel declares class_names as an abstract property; implementations provide it (Cosmos3 returns its stored vocabulary, None when zero-shot). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
class_names becomes class_filter, matching the core model blocks: for fine-tuned models it restricts output to a subset of the model's classes; for zero-shot models it forms the prompt vocabulary; empty accepts all classes (open vocabulary). class_id indexes the model's own class list when one exists (stable ids under filtering, as with detection class filters), else the filter list, else -1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found via instrumented WebRTC UI runs: the reset signature included the resolved source fps, and live streams re-estimate measured_fps continuously, so every frame looked like a config change — state wiped per frame, single-frame windows, empty timelines, and one ~7 s model call per frame. Resolve fps once per video and store it in the bookkeeping; the reset signature carries user configuration only. The sam3 video blocks are immune because they never consume fps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Observed live: a fractional sampling stride (7.5 at 30 fps / 4 sample fps) places samples alternately 7 and 8 frames apart, so adjacent windows' reports sit ceil(stride) apart and the float tolerance missed the merge by under one frame, fragmenting a continuous event into window-aligned pieces. Regression test reproduces the observed geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give Cosmos3 exactly what it was trained on (per the technical report and the cookbook processor sources): prompt_video accepts video_fps and passes VideoMetadata with do_sample_frames=False, so the processor's injected per-frame '<X.X seconds>' markers carry the real clock instead of the assumed 24 fps; the segment classifier prompts for decimal seconds at 0.1 s granularity (the trained localization format) and converts to sampled-frame indices in the parser (floor start, ceil end). The public frame-index contract is unchanged at every boundary. The block documents the pixel/temporal trade: the model spreads a fixed pixel budget across a call's frames. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
do_sample_frames=False leaves the Edge processor's sampled-frame indices unset and its per-frame timestamp step crashes on None. Passing num_frames equal to the frame count makes the sampler an identity linspace, keeping indices populated and timestamps correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The checkpoint's processor config merges fps=2 into every call, which collides with the identity num_frames; an explicit fps=None wins the merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live finding on the full vehicles clip: window-spanning assertions smear boundaries and bridge two distinct same-class events into one range. Both prompt templates now instruct precise boundaries (start at visibility, end when the event stops, not at clip edges) and separate events per distinct occurrence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit b2a0a8f.
Two live-path fixes from full-clip testing on the WebRTC UI runs: The blind 30 fps fallback on a 25 fps stream skews the model's whole time axis by 20 percent (windows, strides, and every reported second). When the stream provides no fps, the block now pins a one-shot estimate from the median of the first eight frame-timestamp deltas (clamped 1-120; the median shrugs off delivery stalls during model calls; degenerate timestamps keep the 30 fallback). Sticky like the existing pin; resets clear it. A segment counted as open only when its end exactly touched the window's last sampled frame, but the model rounds boundaries by roughly 10-20 percent of its window - ongoing events kept closing and active_classes flickered. Openness is now a band: max(0.15 x window frames, sampling stride). Recorded endpoints are unchanged; absent classes still close immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
measured_fps reports the pipeline's arrival rate. WebRTC ACK windows pace delivery to processing, so after the first-frame model call it tracks model latency (~0.05 fps observed). Pinning it collapsed stride_frames to 1 and fired the model on every frame, which kept arrival slow — a self- reinforcing loop. Only declared fps pins directly; otherwise the frame- timestamp median estimates the source rate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every supported source declares its FPS: cv2-backed files, RTSP, and webcams via CAP_PROP_FPS, and WebRTC uploads via the worker's ffprobe detection. The only fps-less path observed was a dev image without ffprobe, where frames arrive ACK-paced and timestamps carry no source clock — there the estimator measured the delivery pipe (77.62 on a 25 fps clip). With no valid consumer left, declared fps pins and the 30 FPS warning fallback covers the rest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lettered prompt is gone, so every description that told a caller to pass a vocabulary for zero-shot was wrong. class_filter now reads as what it is: a fine-tune's class subset, which zero-shot ignores. The changelog entry only named the task. It now states the contract that travels with a model, what each mode answers with, and when a package is rejected outright. The vocabulary warning test asserted through caplog, which passed in the container and failed locally because that logger does not propagate the same way in both. It now asserts on the call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base64 grows a clip by a third and the request is held whole in memory before the clip reaches disk, so a gateway rejects a large one. Nothing said so, and base64 reads as the equal of a URL in every description. The SDK already defaults to url; the docstring now says why. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wrapper carried the answer grammar inline: fourteen state constants, a transition function, the constrained decoder and the parser, none of which concern prompting or calling a model. prefix_allowed_tokens_fn now meets a named class across a module boundary. Grammar and parser move together because they describe one format in two directions. A change to what training emits lands in one file, and drift between what we constrain and what we read back stays visible. Pure refactor: the same 68 tests pass, split to mirror the modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The class was named for a task, InferenceModelsActionRecognitionAdapter, but sat in the cosmos3 family directory, where adapters are named for a family instead. Its body is task-generic: it serves any model meeting the ActionRecognitionModel contract. It now sits beside the five other task adapters, and only the two cosmos3-shaped helpers name that family. It also skipped resolve_roboflow_model_alias, which every sibling calls. No alias targets an action recognition model today, so nothing was broken, but the first one registered would not have resolved. The call runs before the task suffix is stripped, so it sees the id the caller gave. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch added it for the two-stage zero-shot design, where a second text-only call mapped captions onto a vocabulary or condensed them into labels. Both stages are gone, and nothing calls it. prompt() does not route through it, so the vlm path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zero-shot answered every clip with one summary segment. Three trained values were reaching it, and each one alone is enough to cause that. The frame budget: whole-video planning capped at window_seconds x sample_fps, which is a fine-tune's trained budget. On an 18.8 s clip it cut 75 frames to 64 and dragged the rate to 3.4 fps. max_frames is now declared rather than derived, and stays unset for a model that never trained on one. The rate: the window reported count / duration instead of the rate it sampled at, so 4.0 arrived as 3.9964. The model builds a timestamp per frame from that number, and off-rate stamps are values training never produced. 4.0 gives 19 segments on NVIDIA's demo clip; 3.9964 gives 1, on identical frames, greedy, five runs each. The frame side: the adapter resized to 360, which is what the SFT package records. 360 and 480 both give one segment where native gives 19, so max_frame_side is optional too and absent means native. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
max_frame_side fell back to 360 when a package left it out. That is the guess this branch has been removing: a value the model never declared, applied as though it had. roboflow-train always writes the key, so the fallback only ever guessed for a package shape that does not exist. Both limits now read the same way, and absent means absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three planning tests duplicated others: two ran the same inputs for a subset of the same assertions, and one asserted a rate the neighbouring test now asserts alongside the step it checks. Three wrapper tests went for a different reason. One repeated an outcome stated more generally elsewhere. The other two pinned implementation rather than behaviour: mode caching against a tokenizer mutated mid-run, which nothing does, and a property returning its constructor argument. Also renamed a block test that said 'default stride' while passing one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
They ran three times the length of every neighbouring entry, describing mechanism that belongs in the code. What each one now states is the public surface, the contract, and what a caller has to know. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
72af91b to
30ae1e4
Compare
The entry said class tokens make a package a fine-tune. The class list has to resolve to those tokens, which is a different claim: the list names the classes and the tokens say the model can emit the span format. A package carrying one without the other is rejected, not routed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
30ae1e4 to
abb2cf5
Compare
Eight cleanups from the review, none changing behaviour. read_frame_windows walks a clip once for every window. Reading each window on its own capture re-decoded everything before it, so a 30 minute clip decoded 3,036,656 frames to deliver 7,168. load_action_recognition_model is now the one load path. The block skipped resolve_roboflow_model_alias and the trust flags the adapter passes, so a model id resolved differently by entry point. The constrained decoder sliced the prompt off after crossing to host. It now slices the tensor, so a 10k token prompt stops being copied once per generated token. One pydantic segment model, declared in the entities layer and imported by the workflow kind. merge_window_segments replaces the window-to-clip mapping each caller had written out, which had already drifted on stride. Also: the backend helper that keeps ordering hash-seed independent, a disjunct the check above already covered, and the URL error translation video fetching had dropped, so an SSRF rejection surfaces as InputImageLoadError rather than raw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The method hard-coded the v1 route, so select_api_v0 was silently ignored. Serverless serves v0, and model-eval reaches the task through this client, so v0 was the version that mattered most. It now dispatches on client mode like infer() does: a URL rides in the image query parameter, base64 rides as the body, and the class list goes on the query string. Both versions answer with the same shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six fixes from the bot review, five of them verified defects. The grid was the real one. Windows tiled in frames where training slices in time, positions rounded where training takes the first frame at or after each timestamp, short clips floored where training rounds, and clips under the frame floor were refused where training clamps up to it. At 29.97 fps that started a window (0, 7, 15, 22, 30) against training's (0, 8, 15, 23, 30), and a 4.2 s clip read 16 frames at 4.0 against 17 at 4.0157. All three now match. Two grids exist because two trainings do. A model that recorded a frame budget was trained by the platform and reads count / duration. A model that recorded nothing has only its pretraining, which used a plain rate, and reading it at anything else collapses the answer to a summary. Also: a span the model places outside the clip no longer clamps into a one-frame event on the nearest edge; windows_classified counts calls rather than plans; merge_segment builds a replacement rather than assigning to a frozen dataclass; a sampling value we cannot honour is rejected instead of quietly becoming a default; and the slow integration test no longer asserts a vocabulary that zero-shot ignores by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It read as a per-frame classification and is not one. Users expecting a standard classification output would have found a keep-alive list of classes whose ranges can still merge, which answers a question about the timeline rather than about the frame. The state that only fed it goes with it: the keep-alive window, the bookkeeping field, and both builders. The tensor sibling now overrides frame extraction alone, which is all a tensor block has to change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two divergences remained after the last pass, both reproduced. A source slower than the recorded rate was capped to the source. Training picks the count from the recorded rate alone and repeats frames the source cannot supply distinctly, so 20 frames at 2 fps became 20 samples at 2 fps here against 40 at 4 fps there. The cap now applies only to a model that recorded nothing, where there is no training to reproduce and a repeat buys nothing. Timestamps were built by multiplying floats. (0.1 * 3) * 30 lands just above 9.0, so a fourth frame read 10 where training read 9. Windows and stamps are whole microseconds now, and a frame comes from one multiplication rather than a chain. Verified against the trainer's algorithm on six shapes, including a source slower than the contract, the drift case, 29.97 fps, and the lee-2 clip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A window holds tens of frames, and each one crossed on its own with its own synchronisation. They now stack and cross together. Ragged input, which cannot stack, keeps the per-frame path. Same bytes and same values either way, which the new test asserts against the per-frame conversion directly. The live fine-tune answer and its timings are unchanged. The frames still cross at source resolution and are resized on the host afterwards. Resizing on the device first would move far less, but interpolate is not bit-identical to INTER_AREA, and this model has already shown it answers differently on smaller input changes than that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Claude review started at commit New commits are not auto-reviewed. Add the |
What does this PR do?
Adds Action Recognition: a video task that labels frame ranges. Output is
(start_frame_idx, end_frame_idx, class)tuples, which can overlap.The temporal contract travels with the model.
VideoSamplingstates the window, the sample rate, and the limits training set, so a caller sends a clip and nothing else. No caller picks a window length or a frame rate, because those are training decisions. This is the idea the rest of the diff follows from.Two kinds of checkpoint share one output type, so nothing downstream tells them apart:
prefix_allowed_tokens_fnstate machine that admits only tokens continuing a valid line, so the answer always parses. Runs on a stream and over HTTP.Four surfaces reach the task: the block
roboflow_core/roboflow_action_recognition_model@v1, both HTTP routes (/infer/action_recognition, and the legacy/{model}/{version}that serverless serves today), andclient.infer_action_recognitionin the SDK. The server samples, so a request carries a clip and the response covers that clip.Type of Change
Testing
Test details:
CI
Local inference server build on L4 VM
Details
{ "timeline": [ { "start_frame_idx": 0, "end_frame_idx": 15, "class": "The robot's arm scoops popcorn from the machine and begins lifting it toward the cup.", "class_id": -1 }, { "start_frame_idx": 15, "end_frame_idx": 45, "class": "The robot continues to lift the popcorn scoop, moving it closer to the cup while maintaining a steady grip.", "class_id": -1 }, { "start_frame_idx": 45, "end_frame_idx": 75, "class": "The robot's arm positions the popcorn scoop directly above the cup, preparing to release the popcorn.", "class_id": -1 }, { "start_frame_idx": 75, "end_frame_idx": 105, "class": "The robot releases popcorn into the cup, with the arm slightly adjusting its angle for precision.", "class_id": -1 }, { "start_frame_idx": 105, "end_frame_idx": 135, "class": "The robot's arm retracts slightly after releasing popcorn, readying for the next scoop.", "class_id": -1 }, { "start_frame_idx": 135, "end_frame_idx": 165, "class": "The robot scoops another portion of popcorn from the machine, lifting it toward the cup.", "class_id": -1 }, { "start_frame_idx": 165, "end_frame_idx": 195, "class": "The robot positions the popcorn scoop above the cup again, ensuring accurate placement.", "class_id": -1 }, { "start_frame_idx": 195, "end_frame_idx": 225, "class": "The robot releases popcorn into the cup, with the arm making minor adjustments for consistency.", "class_id": -1 }, { "start_frame_idx": 225, "end_frame_idx": 255, "class": "The robot's arm retracts slightly, preparing for the next cycle of scooping.", "class_id": -1 }, { "start_frame_idx": 255, "end_frame_idx": 285, "class": "The robot scoops another portion of popcorn, lifting it toward the cup.", "class_id": -1 }, { "start_frame_idx": 285, "end_frame_idx": 315, "class": "The robot positions the popcorn scoop above the cup, ensuring alignment for release.", "class_id": -1 }, { "start_frame_idx": 315, "end_frame_idx": 345, "class": "The robot releases popcorn into the cup, with the arm making slight adjustments for precision.", "class_id": -1 }, { "start_frame_idx": 345, "end_frame_idx": 375, "class": "The robot's arm retracts slightly, readying for the next scoop.", "class_id": -1 }, { "start_frame_idx": 375, "end_frame_idx": 405, "class": "The robot scoops another portion of popcorn, lifting it toward the cup.", "class_id": -1 }, { "start_frame_idx": 405, "end_frame_idx": 435, "class": "The robot positions the popcorn scoop above the cup, ensuring alignment for release.", "class_id": -1 }, { "start_frame_idx": 435, "end_frame_idx": 465, "class": "The robot releases popcorn into the cup, with the arm making minor adjustments for consistency.", "class_id": -1 }, { "start_frame_idx": 465, "end_frame_idx": 495, "class": "The robot's arm retracts slightly, preparing for the next cycle of scooping.", "class_id": -1 }, { "start_frame_idx": 495, "end_frame_idx": 525, "class": "The robot scoops another portion of popcorn, lifting it toward the cup.", "class_id": -1 }, { "start_frame_idx": 525, "end_frame_idx": 555, "class": "The robot positions the popcorn scoop above the cup, ensuring alignment for release.", "class_id": -1 } ], "source_fps": 30.0, "frame_count": 563, "windows_classified": 1 }Reproduce NVIDIA's zero-shot temporal localization demo via v1 API
Fine-tuned model test via v0+v1 API
peter-robicheaux/video-7-cosmos3-edge-t6(lee-2.mov, in the train split)peter-robicheaux/video-7-cosmos3-edge-t6(lee-2.mov again, stride=15.5s to ensure inference triggers before the 16s stream ends)note the frame ranges are similar but the class assignments flip vs the HTTP endpoint. I could not find a bug that explains this, so I believe the fine-tuned model most likely is not confident in the in vs out class.
Checklist
Additional Context
CI stays red until an
inference-modelsrelease carries the new modules andrequirements/pins it. The branch was developed against a source overlay.ACTION_RECOGNITION_ENABLEDgates the v1 route. It currently defaults to on; flip the default before merge if this needs to land dark.The prompt and decode settings come from measurement against NVIDIA's own demo assets. Zero-shot needs its reasoning pass to localize, which costs 12–80 s per clip depending on length. That is affordable for a clip and not for a stream, which is why the two modes divide the way they do.
🤖 Generated with Claude Code