Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,24 @@ roboflow search "tag:reviewed" --limit 100
roboflow search "class:person" --export -f coco -l ./export/
```

Search returns images only unless `--media-types` asks otherwise. Valid values are
`image`, `video`, or both:

```bash
# Native videos only, with a signed video URL on each hit
roboflow search "*" --media-types video --fields id,filename,url

# Images and videos together
roboflow search "tag:reviewed" --media-types image,video

# Scope to one project
roboflow image search "*" -p my-project --media-types video --fields id,url
```

Every hit carries `mediaType`. Video hits add a signed `videoUrl` when you request the
`url` field; `url` itself stays the poster frame, so image-only consumers keep a
thumbnail for every hit. `--media-types` is not accepted with `--export`.

### Browse resources

```bash
Expand Down Expand Up @@ -551,11 +569,11 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
| `workspace` | List and inspect workspaces |
| `project` | List, get, create projects |
| `version` | List, get, download, export dataset versions |
| `image` | Upload, get, search, metadata, tag, delete, annotate images |
| `image` | Upload, get, search, metadata, tag, delete, annotate images and videos |
| `model` | List, get, upload trained models |
| `train` | Start model training |
| `infer` | Run inference on images |
| `search` | Search workspace images (RoboQL), export results |
| `search` | Search workspace images and videos (RoboQL), export results |
| `deployment` | Manage dedicated deployments |
| `device` | List, get, create, and observe RFDM devices (v2 deployment API) |
| `eval` | Inspect model evaluation runs (mAP, confusion matrix, recommendations, ...) |
Expand Down
12 changes: 11 additions & 1 deletion roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from requests_toolbelt.multipart.encoder import MultipartEncoder

from roboflow.config import API_URL, DEFAULT_BATCH_NAME, DEFAULT_JOB_NAME
from roboflow.util.search_utils import normalize_media_types


class RoboflowError(Exception):
Expand Down Expand Up @@ -545,8 +546,10 @@ def workspace_search(
page_size: int = 50,
fields: Optional[List[str]] = None,
continuation_token: Optional[str] = None,
*,
media_types: Optional[List[str]] = None,
) -> dict:
"""Search across all images in a workspace using RoboQL syntax.
"""Search across all media in a workspace using RoboQL syntax.

Args:
api_key: Roboflow API key.
Expand All @@ -555,11 +558,14 @@ def workspace_search(
page_size: Number of results per page (default 50).
fields: Fields to include in each result.
continuation_token: Token for fetching the next page.
media_types: Media types to search: ``["image"]``, ``["video"]`` or
``["image", "video"]``. Omit to search images only (the API default).

Returns:
Parsed JSON response with ``results``, ``total``, and ``continuationToken``.

Raises:
ValueError: If ``media_types`` is not a non-empty list of ``"image"``/``"video"``.
RoboflowError: On non-200 response status codes.
"""
url = f"{API_URL}/{workspace_url}/search/v1?api_key={api_key}"
Expand All @@ -571,6 +577,10 @@ def workspace_search(
payload["fields"] = fields
if continuation_token is not None:
payload["continuationToken"] = continuation_token
# Omitted media_types leaves `mediaTypes` off the body so the API default (images) applies.
normalized_media_types = normalize_media_types(media_types)
if normalized_media_types is not None:
payload["mediaTypes"] = normalized_media_types

response = requests.post(url, json=payload)
if response.status_code != 200:
Expand Down
64 changes: 54 additions & 10 deletions roboflow/cli/handlers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ def search_images(
] = None,
limit: Annotated[int, typer.Option(help="Number of results")] = 50,
cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None,
fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None,
media_types: Annotated[
Optional[str],
typer.Option(
"--media-types",
help="Comma-separated media types to search: image, video, or image,video (default: image)",
),
] = None,
export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False,
format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco",
location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None,
Expand All @@ -107,11 +115,19 @@ def search_images(
name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None,
no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False,
) -> None:
"""Search images in workspace or project.
"""Search media in workspace or project.

Without -p/--project, searches across the entire workspace using RoboQL.
With -p/--project, searches within a specific project.
Use --export to download matching results as a dataset.

Searches images only unless --media-types asks otherwise. Request the 'url' field
to get a signed 'videoUrl' on each native video hit.

Examples:
roboflow image search "tag:review"
roboflow image search "*" --media-types video --fields id,filename,url
roboflow image search "*" -p my-project --media-types image,video
"""
if export:
# Export scopes to a project via the `dataset` (project slug) body param,
Expand All @@ -131,11 +147,21 @@ def search_images(
annotation_group=annotation_group,
name=name,
no_extract=no_extract,
fields=fields,
media_types=media_types,
)
_search(args)
elif project:
# _handle_search scopes by injecting a `project:<slug>` RoboQL filter.
args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor)
args = ctx_to_args(
ctx,
query=query,
project=project,
limit=limit,
cursor=cursor,
fields=fields,
media_types=media_types,
)
_handle_search(args)
else:
# Workspace-level search
Expand All @@ -153,7 +179,8 @@ def search_images(
annotation_group=annotation_group,
name=name,
no_extract=no_extract,
fields=None,
fields=fields,
media_types=media_types,
)
_search(args)

Expand Down Expand Up @@ -424,6 +451,7 @@ def _handle_search(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.config import load_roboflow_api_key
from roboflow.util.search_utils import parse_media_types_option

api_key = args.api_key or load_roboflow_api_key(args.workspace)
if not api_key:
Expand All @@ -442,13 +470,29 @@ def _handle_search(args): # noqa: ANN001
if project:
query = f"project:{project} {args.query}"

result = rfapi.workspace_search(
api_key=api_key,
workspace_url=workspace_url,
query=query,
page_size=args.limit,
continuation_token=args.cursor,
)
try:
media_types = parse_media_types_option(getattr(args, "media_types", None))
except ValueError as exc:
output_error(args, str(exc), hint="Valid media types: image, video")
return

fields_raw = getattr(args, "fields", None)
fields = [f.strip() for f in fields_raw.split(",") if f.strip()] if fields_raw else None

try:
result = rfapi.workspace_search(
api_key=api_key,
workspace_url=workspace_url,
query=query,
page_size=args.limit,
fields=fields,
continuation_token=args.cursor,
media_types=media_types,
)
except Exception as exc:
output_error(args, str(exc))
return

output(args, result, text=json.dumps(result, indent=2))


Expand Down
55 changes: 51 additions & 4 deletions roboflow/cli/handlers/search.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Search commands: query workspace images and export search results."""
"""Search commands: query workspace media and export search results."""

from __future__ import annotations

Expand All @@ -19,6 +19,13 @@ def search(
limit: Annotated[int, typer.Option(help="Max results to return")] = 50,
cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None,
fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None,
media_types: Annotated[
Optional[str],
typer.Option(
"--media-types",
help="Comma-separated media types to search: image, video, or image,video (default: image)",
),
] = None,
export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False,
format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco",
location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None,
Expand All @@ -32,13 +39,22 @@ def search(
name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None,
no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False,
) -> None:
"""Search workspace images or export results as a dataset."""
"""Search workspace media or export results as a dataset.

Searches images only unless --media-types asks otherwise.

Examples:
roboflow search "tag:review"
roboflow search "*" --media-types video --fields id,filename,url
roboflow search "tag:review" --media-types image,video
"""
args = ctx_to_args(
ctx,
query=query,
limit=limit,
cursor=cursor,
fields=fields,
media_types=media_types,
export=export,
format=format,
location=location,
Expand All @@ -64,21 +80,38 @@ def _search(args): # noqa: ANN001
return

if args.export:
# `is not None` on purpose: an explicit but invalid value (e.g. --media-types "")
# must not silently fall through to the export route.
if getattr(args, "media_types", None) is not None:
output_error(
args,
"--media-types is not supported with --export",
hint="Drop --media-types to export, or omit --export to search with media type selection",
)
return
_do_export(args, workspace)
else:
_do_search(args, workspace)


def _do_search(args: Any, workspace: Any) -> None:
from roboflow.cli._output import output, output_error
from roboflow.util.search_utils import parse_media_types_option

fields = [field.strip() for field in args.fields.split(",") if field.strip()] if args.fields else None
try:
media_types = parse_media_types_option(getattr(args, "media_types", None))
except ValueError as exc:
output_error(args, str(exc), hint="Valid media types: image, video")
return

fields = args.fields.split(",") if args.fields else None
try:
result = workspace.search(
query=args.query,
page_size=args.limit,
fields=fields,
continuation_token=args.cursor,
media_types=media_types,
)
except Exception as exc:
output_error(args, str(exc))
Expand All @@ -89,18 +122,32 @@ def _do_search(args: Any, workspace: Any) -> None:
token = result.get("continuationToken")

data = {"results": results, "total": total}
if media_types:
data["mediaTypes"] = media_types
if token:
data["cursor"] = token

text_lines = [f"Found {total} result(s)."]
for r in results:
text_lines.append(f" {r.get('filename', r.get('id', ''))}")
text_lines.append(f" {_describe_hit(r)}")
if token:
text_lines.append(f"\nNext page: --cursor {token}")

output(args, data, text="\n".join(text_lines))


def _describe_hit(hit: dict) -> str:
"""One text line per hit: label, media type, and the signed video URL when present."""
label = hit.get("filename") or hit.get("name") or hit.get("id", "")
parts = [str(label)]
media_type = hit.get("mediaType")
if media_type:
parts.append(f"[{media_type}]")
if hit.get("videoUrl"):
parts.append(str(hit["videoUrl"]))
return " ".join(parts)


def _do_export(args: Any, workspace: Any) -> None:
from roboflow.cli._output import output, output_error

Expand Down
Loading
Loading