From 61478faa1bb38cea1aa08f5c2179978e2ef7662f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 28 Jul 2026 11:55:21 -0600 Subject: [PATCH] Prompt refactoring and optimization --- frigate/api/app.py | 23 ++++ frigate/api/auth.py | 1 + frigate/api/chat.py | 39 ++++++- frigate/genai/prompts.py | 188 ++++++++++++------------------- frigate/util/object_names.py | 209 +++++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 frigate/util/object_names.py diff --git a/frigate/api/app.py b/frigate/api/app.py index 142d4ee0e4..8d4915bd87 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -68,6 +68,7 @@ from frigate.util.config import ( find_config_file, redact_credential, ) +from frigate.util.object_names import get_categorized_object_names from frigate.util.schema import get_config_schema from frigate.util.services import ( get_nvidia_driver_info, @@ -1299,6 +1300,28 @@ def get_sub_labels( return JSONResponse(content=sub_labels) +@router.get( + "/categorized_object_names", + dependencies=[Depends(allow_any_authenticated())], + summary="Get known object names by object type", + description="""Returns the sub labels and attributes this install can attach, + grouped by object type. Unlike /sub_labels, which reflects what has already been + detected, this reads the config and model files, so it covers recognized face + names, named license plates, custom object classification categories, and the + detector attributes of tracked objects.""", +) +def categorized_object_names( + request: Request, + object_type: str | None = None, + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), +): + return JSONResponse( + content=get_categorized_object_names( + request.app.frigate_config, allowed_cameras, object_type + ) + ) + + @router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())]) def get_audio_labels(): labels = load_labels("/audio-labelmap.txt", prefill=521) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 204702e3ac..e8ce747f29 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -83,6 +83,7 @@ def require_admin_by_default(): "/nvinfo", "/labels", "/sub_labels", + "/categorized_object_names", "/plus/models", "/recognized_license_plates", "/timeline", diff --git a/frigate/api/chat.py b/frigate/api/chat.py index fa4510cf14..25fbbb9d42 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -50,6 +50,7 @@ from frigate.jobs.vlm_watch import ( stop_vlm_watch_job, ) from frigate.models import Event +from frigate.util.object_names import get_categorized_object_names logger = logging.getLogger(__name__) @@ -539,6 +540,13 @@ async def execute_tool( if tool_name == "search_objects": return await _execute_search_objects(request, arguments, allowed_cameras) + if tool_name == "get_categorized_object_names": + return JSONResponse( + content=_execute_get_categorized_object_names( + request, arguments, allowed_cameras + ) + ) + if tool_name == "find_similar_objects": result = await _execute_find_similar_objects( request, arguments, allowed_cameras @@ -717,6 +725,29 @@ async def _execute_set_camera_state( return {"success": True, "camera": camera, "feature": feature, "value": value} +def _execute_get_categorized_object_names( + request: Request, + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> dict[str, Any]: + object_type = arguments.get("object_type") or None + names = get_categorized_object_names( + request.app.frigate_config, allowed_cameras, object_type + ) + + if not names: + return { + "names": {}, + "message": ( + f"No names configured for '{object_type}'." + if object_type + else "No names configured; search by label or semantic_query." + ), + } + + return {"names": names} + + async def _execute_tool_internal( tool_name: str, arguments: dict[str, Any], @@ -741,6 +772,10 @@ async def _execute_tool_internal( except (json.JSONDecodeError, AttributeError) as e: logger.warning(f"Failed to extract tool result: {e}") return {"error": "Failed to parse tool result"} + elif tool_name == "get_categorized_object_names": + return _execute_get_categorized_object_names( + request, arguments, allowed_cameras + ) elif tool_name == "find_similar_objects": return await _execute_find_similar_objects(request, arguments, allowed_cameras) elif tool_name == "set_camera_state": @@ -773,8 +808,8 @@ async def _execute_tool_internal( else: logger.error( "Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, " - "get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. " - "Arguments received: %s", + "get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, " + "get_profile_status, get_recap. Arguments received: %s", tool_name, json.dumps(arguments), ) diff --git a/frigate/genai/prompts.py b/frigate/genai/prompts.py index 33045606eb..e350a43245 100644 --- a/frigate/genai/prompts.py +++ b/frigate/genai/prompts.py @@ -262,6 +262,10 @@ def get_tool_definitions( `attribute` parameter is exposed for filtering by their labels. When the embeddings model only understands English (JinaV1), the `semantic_query` description instructs the model to write the query in English. + + Descriptions here stay mechanical: which tool to reach for, and how the + filters relate to each other, is stated once in the system prompt so the + guidance is not paid for twice on every request. """ search_objects_properties: dict[str, Any] = { "camera": { @@ -270,26 +274,13 @@ def get_tool_definitions( }, "label": { "type": "string", - "description": ( - "Generic object class to filter by — one of the tracked detector " - "labels such as 'person', 'package', 'car', 'dog', 'bird'. Use " - "this for broad queries like 'show me all cars today'. Combine " - "with semantic_query when the user also describes appearance or " - "behavior (e.g. label='person', semantic_query='riding a lawn " - "mower')." - ), + "description": "Tracked object class to filter by.", }, "sub_label": { "type": "string", "description": ( - "Filter by a DISCRETE NAMED entity recognized in the detection. " - "Use this for: a known person's name ('John'), a delivery " - "company ('Amazon', 'UPS'), a recognized animal species or " - "breed ('blue jay', 'cardinal', 'golden retriever'), or a " - "license plate string. When filtering by a specific name, set " - "only sub_label and leave label unset. Do NOT use sub_label " - "for descriptions of appearance, clothing, or actions — those " - "belong in semantic_query." + "Name recognized in the detection: a person, delivery company, " + "animal species or breed, or license plate." ), }, "after": { @@ -313,20 +304,11 @@ def get_tool_definitions( } if attribute_classifications: - model_outline = "; ".join( - f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})" - for m in attribute_classifications - ) search_objects_properties["attribute"] = { "type": "string", "description": ( - "Filter by a classification attribute label produced by a " - "configured attribute classification model. Use this INSTEAD " - "of semantic_query when the user's request matches one of " - "these classifications. Configured models: " - f"{model_outline}. " - "Set the value to the attribute label that matches the user's " - "phrasing (case-sensitive)." + "Attribute label produced by a configured classification model " + "(case-sensitive)." ), } @@ -334,29 +316,12 @@ def get_tool_definitions( search_objects_properties["semantic_query"] = { "type": "string", "description": ( - "Optional natural-language description of a PHYSICAL " - "CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, " - "used to semantically narrow results. Only set this when the " - "user describes something beyond what label and sub_label can " - "express on their own.\n" - "USE for descriptive phrases like: 'riding a lawn mower', " - "'wearing a red jacket', 'carrying a package', 'walking a " - "dog', 'on a bicycle', 'holding an umbrella'.\n" - "DO NOT USE for:\n" - "- specific named people, pets, or delivery companies → use sub_label\n" - "- animal species or breed names like 'blue jay', 'cardinal', " - "'golden retriever' → use sub_label\n" - "- license plate strings → use sub_label\n" - "- generic object queries like 'all cars today' or 'every " - "person' → use label alone with no semantic_query\n" - "When set, combine with label/time/camera/zone filters as " - "usual (e.g. label='person', semantic_query='riding a lawn " - "mower', after='2024-05-01T00:00:00Z')." + "Description of an appearance or activity, used to semantically " + "narrow results." + ( - " The configured embeddings model only understands " - "English, so always write semantic_query in English, " - "translating the user's description if they phrased it " - "in another language." + " The configured embeddings model only understands English, so " + "always write this in English, translating the user's " + "description if they phrased it in another language." if embeddings_language == "english" else "" ) @@ -364,26 +329,10 @@ def get_tool_definitions( } search_objects_description = ( - "Search the historical record of detected objects in Frigate. " - "Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', " - "'when was the last car?', 'show me detections from yesterday'. " - "Do NOT use this for monitoring or alerting requests about future events — " - "use start_camera_watch instead for those. " - "An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n" - "Choose filters based on what the user is asking for:\n" - "- Generic class query ('show me all cars today'): set `label` only.\n" - "- Specific NAMED entity (known person, delivery company, animal " - "species/breed like 'blue jay' or 'golden retriever', license " - "plate): set `sub_label` only and leave `label` unset.\n" + "Search the historical record of tracked detections. Use this ONLY for " + "questions about the PAST, e.g. 'did anyone come by today?', 'when was the " + "last car?'. For alerting on future events use start_camera_watch instead." ) - if semantic_search_enabled: - search_objects_description += ( - "- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a " - "discrete name ('person riding a lawn mower', 'someone in a red " - "jacket', 'person carrying a package'): set `semantic_query` with " - "the descriptive phrase, optionally alongside `label` for the " - "object class. Do NOT put descriptive phrases in sub_label." - ) return [ { @@ -398,20 +347,34 @@ def get_tool_definitions( "required": [], }, }, + { + "type": "function", + "function": { + "name": "get_categorized_object_names", + "description": ( + "Every name that can be attached as a sub_label, grouped by object " + "type: recognized faces, named license plates, classification " + "categories, and delivery logos." + ), + "parameters": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "description": "Optional object label (e.g. 'person', 'car'). Omit for all.", + }, + }, + "required": [], + }, + }, + }, { "type": "function", "function": { "name": "find_similar_objects", "description": ( - "Find tracked objects that are visually and semantically similar " - "to a specific past event. Use this when the user references a " - "particular object they have seen and wants to find other " - "sightings of the same or similar one ('that green car', 'the " - "person in the red jacket', 'the package that was delivered'). " - "Prefer this over search_objects whenever the user's intent is " - "'find more like this specific one.' Use search_objects first " - "only if you need to locate the anchor event. Requires semantic " - "search to be enabled." + "Find tracked objects visually and semantically similar to a " + "specific past event. Requires semantic search to be enabled." ), "parameters": { "type": "object", @@ -473,9 +436,8 @@ def get_tool_definitions( "function": { "name": "set_camera_state", "description": ( - "Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). " - "Use camera='*' to apply to all cameras at once. " - "Only call this tool when the user explicitly asks to change a camera setting. " + "Change a camera's feature state, e.g. turn detection on or off. " + "Only call this when the user explicitly asks to change a setting. " "Requires admin privileges." ), "parameters": { @@ -517,7 +479,7 @@ def get_tool_definitions( }, "value": { "type": "string", - "description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.", + "description": "The value to set, as accepted by the chosen feature.", }, }, "required": ["camera", "feature", "value"], @@ -529,11 +491,9 @@ def get_tool_definitions( "function": { "name": "get_live_context", "description": ( - "Get the current live image and detection information for a single camera: objects being tracked, " - "zones, timestamps. Use this to understand what is visible in the live view. " - "Call this when answering questions about what is happening right now on a specific camera. " - "Operates on one camera at a time; call the tool again for each additional camera. " - "Wildcards and empty values are not accepted." + "Current live image and detections (tracked objects, zones, " + "timestamps) for one camera. Use this for questions about what is " + "happening right now. Call it again for each additional camera." ), "parameters": { "type": "object", @@ -541,8 +501,8 @@ def get_tool_definitions( "camera": { "type": "string", "description": ( - "Exact name of a single camera to get live context for. " - "Wildcards (e.g. '*', 'all') and empty strings are not accepted." + "Exact name of a single camera. Wildcards (e.g. '*', " + "'all') and empty strings are not accepted." ), }, }, @@ -555,10 +515,9 @@ def get_tool_definitions( "function": { "name": "start_camera_watch", "description": ( - "Start a continuous VLM watch job that monitors a camera and sends a notification " - "when a specified condition is met. Use this when the user wants to be alerted about " - "a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. " - "Only one watch job can run at a time. Returns a job ID." + "Start a continuous watch job that monitors a camera and notifies " + "the user when a condition is met, e.g. 'tell me when guests " + "arrive'. Only one watch job can run at a time. Returns a job ID." ), "parameters": { "type": "object", @@ -598,10 +557,7 @@ def get_tool_definitions( "type": "function", "function": { "name": "stop_camera_watch", - "description": ( - "Cancel the currently running VLM watch job. Use this when the user wants to " - "stop a previously started watch, e.g. 'stop watching the front door'." - ), + "description": "Cancel the currently running watch job.", "parameters": { "type": "object", "properties": {}, @@ -614,11 +570,9 @@ def get_tool_definitions( "function": { "name": "get_profile_status", "description": ( - "Get the current profile status including the active profile and " - "timestamps of when each profile was last activated. Use this to " - "determine time periods for recap requests — e.g. when the user asks " - "'what happened while I was away?', call this first to find the relevant " - "time window based on profile activation history." + "Get the active profile and when each profile was last activated. " + "Call this before get_recap to derive the time window for requests " + "like 'what happened while I was away?'." ), "parameters": { "type": "object", @@ -632,11 +586,9 @@ def get_tool_definitions( "function": { "name": "get_recap", "description": ( - "Get a recap of all activity (alerts and detections) for a given time period. " - "Use this after calling get_profile_status to retrieve what happened during " - "a specific window — e.g. 'what happened while I was away?'. Returns a " - "chronological list of activity with camera, objects, zones, and GenAI-generated " - "descriptions when available. Summarize the results for the user." + "Get all activity (alerts and detections) for a time period, as a " + "chronological list with camera, objects, zones, and descriptions " + "when available. Summarize the results for the user." ), "parameters": { "type": "object", @@ -723,14 +675,13 @@ def build_chat_system_prompt( ) speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}." - semantic_search_section = "" + filter_routing_section = ( + "\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n" + "- Generic class ('show me all cars today'): set `label` only.\n" + "- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset. Call get_categorized_object_names first and use the exact spelling it returns; a guessed spelling matches nothing. If the name is absent, say it is not configured rather than searching for it." + ) if semantic_search_enabled: - semantic_search_section = ( - "\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n" - "- Generic class ('show me all cars today'): set `label` only.\n" - "- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n" - "- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`." - ) + filter_routing_section += "\n- Physical characteristic, appearance, or activity that is NOT a discrete name ('riding a lawn mower', 'someone in a red jacket'): set `semantic_query` with the descriptive phrase, optionally combined with `label`. Never put descriptive phrases in `sub_label`." attribute_classification_section = "" if attribute_classifications: @@ -739,9 +690,9 @@ def build_chat_system_prompt( for m in attribute_classifications ) attribute_classification_section = ( - "\n\nAttribute classification models are configured for the following object types:\n" + "\n\nConfigured attribute classification models:\n" f"{model_lines}\n" - "When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels." + "When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label (case-sensitive) rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases outside the configured attribute labels." ) return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events. @@ -750,9 +701,6 @@ Current server local date and time: {current_date_str} at {current_time_str} Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly. -Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields. -When users ask about "today", "yesterday", "this week", etc., use the current date above as reference. -When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today). -Always be accurate with time calculations based on the current date provided. +Always present times in the server's local timezone. When tool results include start_time_local and end_time_local, quote those strings exactly; never convert or invent timestamps, and fall back to UTC or ISO format only when a result has no local time fields. Resolve relative dates like "today" or "this week" against the current date above, and pass dates to tools in ISO 8601 (e.g. {current_date_str}T00:00:00Z for the start of today). -When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}""" +When the user refers to a specific object they have seen ("that green car", "the person in the red jacket", "a package left today"), prefer find_similar_objects over search_objects, using search_objects only to locate the anchor event and passing its id along. Keep search_objects for generic queries like "show me all cars today". If a user message begins with [attached_event:], treat that id as the anchor for any similarity or "tell me more" request in the same message.{filter_routing_section}{attribute_classification_section}{cameras_section}{speed_units_section}""" diff --git a/frigate/util/object_names.py b/frigate/util/object_names.py new file mode 100644 index 0000000000..a5129a26ca --- /dev/null +++ b/frigate/util/object_names.py @@ -0,0 +1,209 @@ +"""Aggregation of the known sub label names an object can be tagged with.""" + +import logging +import os + +from pathvalidate import sanitize_filename + +from frigate.config import FrigateConfig +from frigate.config.classification import ObjectClassificationType +from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR +from frigate.util.builtin import load_labels + +logger = logging.getLogger(__name__) + +# subdirectory of FACE_DIR holding unassigned training images, not a face name +FACE_TRAIN_DIR = "train" + +# category used by classification models for "no match", never attached to an object +CLASSIFICATION_NONE_CATEGORY = "none" + + +def get_categorized_object_names( + config: FrigateConfig, + allowed_cameras: list[str], + object_type: str | None = None, +) -> dict[str, list[str]]: + """Collect every sub label name this install can attach, by object type. + + Unlike the database-backed /sub_labels endpoint, this reads the config and + model files, so it also covers names that are configured but have not been + detected yet. Names come from the detector's logo attributes (limited to + objects the allowed cameras actually track), LPR known plate names, + registered face names, and custom object classification categories. + + Structural attributes such as `face` and `license_plate` are excluded: they + describe a part of an object rather than naming it, and are never attached + as a sub label. + + Args: + config: The running Frigate config + allowed_cameras: Cameras the requesting user may see + object_type: Optional object label to restrict the result to + + Returns: + Mapping of object label to its known sub label names, sorted and + deduplicated. Object types with no known names are omitted. + """ + tracked_objects = _get_tracked_objects(config, allowed_cameras) + names: dict[str, set[str]] = {} + logos = set(config.model.all_attribute_logos) + + # 1. detector logo attributes, only for objects that are actually tracked + for label, label_attributes in config.model.attributes_map.items(): + if label not in tracked_objects: + continue + + label_logos = logos.intersection(label_attributes) + + if label_logos: + names.setdefault(label, set()).update(label_logos) + + # 2. LPR known plate names, for objects that can carry a plate + if config.lpr.known_plates and _lpr_enabled(config, allowed_cameras): + known_plates = set(config.lpr.known_plates) + + for label in _objects_with_attribute(config, tracked_objects, "license_plate"): + names.setdefault(label, set()).update(known_plates) + + # 3. registered face names, for objects that can carry a face + if _face_recognition_enabled(config, allowed_cameras): + face_names = _get_face_names() + + if face_names: + for label in _objects_with_attribute(config, tracked_objects, "face"): + names.setdefault(label, set()).update(face_names) + + # 4. custom object classification categories + for model_key, model_config in config.classification.custom.items(): + if not model_config.enabled or model_config.object_config is None: + continue + + if ( + model_config.object_config.classification_type + != ObjectClassificationType.sub_label + ): + continue + + categories = _get_classification_categories(model_key) + + if not categories: + continue + + for label in model_config.object_config.objects: + names.setdefault(label, set()).update(categories) + + return { + label: sorted(label_names) + for label, label_names in sorted(names.items()) + if label_names and (object_type is None or label == object_type) + } + + +def _get_tracked_objects(config: FrigateConfig, allowed_cameras: list[str]) -> set[str]: + """Get the union of objects tracked by the cameras the user can see.""" + tracked: set[str] = set() + + for camera_name in allowed_cameras: + camera_config = config.cameras.get(camera_name) + + if camera_config is None: + continue + + tracked.update(camera_config.objects.track) + + return tracked + + +def _objects_with_attribute( + config: FrigateConfig, tracked_objects: set[str], attribute: str +) -> set[str]: + """Get the tracked objects that a given attribute can be recognized on. + + The attribute may also be tracked as an object in its own right, as + `license_plate` is on a dedicated LPR camera, in which case the name is + attached to that object directly. + """ + objects = { + label + for label, label_attributes in config.model.attributes_map.items() + if attribute in label_attributes and label in tracked_objects + } + + if attribute in tracked_objects: + objects.add(attribute) + + return objects + + +def _lpr_enabled(config: FrigateConfig, allowed_cameras: list[str]) -> bool: + return any( + config.cameras[camera_name].lpr.enabled + for camera_name in allowed_cameras + if camera_name in config.cameras + ) + + +def _face_recognition_enabled( + config: FrigateConfig, allowed_cameras: list[str] +) -> bool: + return any( + config.cameras[camera_name].face_recognition.enabled + for camera_name in allowed_cameras + if camera_name in config.cameras + ) + + +def _get_face_names() -> set[str]: + """Get the names of every registered face collection.""" + if not os.path.exists(FACE_DIR): + return set() + + try: + entries = os.listdir(FACE_DIR) + except OSError: + logger.debug("Failed to read face directory %s", FACE_DIR) + return set() + + return { + name + for name in entries + if name != FACE_TRAIN_DIR and os.path.isdir(os.path.join(FACE_DIR, name)) + } + + +def _get_classification_categories(model_key: str) -> set[str]: + """Get the categories a custom classification model can output. + + The trained labelmap is authoritative, but it only exists once the model + has been trained, so fall back to the dataset directories that will become + the labelmap on the next training run. + """ + safe_key = sanitize_filename(model_key) + categories: set[str] = set() + labelmap_path = os.path.join(MODEL_CACHE_DIR, safe_key, "labelmap.txt") + + if os.path.exists(labelmap_path): + try: + labelmap = load_labels(labelmap_path, prefill=0, indexed=False) + except OSError: + logger.debug("Failed to read labelmap %s", labelmap_path) + labelmap = {} + + categories.update(label for label in labelmap.values() if label) + + dataset_dir = os.path.join(CLIPS_DIR, safe_key, "dataset") + + if os.path.exists(dataset_dir): + try: + entries = os.listdir(dataset_dir) + except OSError: + logger.debug("Failed to read dataset directory %s", dataset_dir) + entries = [] + + categories.update( + name for name in entries if os.path.isdir(os.path.join(dataset_dir, name)) + ) + + categories.discard(CLASSIFICATION_NONE_CATEGORY) + return categories