Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/label_studio_sdk/label_interface/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,16 @@ def _unique_names_validation(self, config_string):
"Label config contains non-unique names: " + ", ".join(all_names)
)

def _tag_attribute_validation(self):
"""Validate tag-specific attribute values (e.g. Video playback speed)."""
errors: List[str] = []
if self._objects:
for obj in self._objects.values():
if hasattr(obj, "validate_config"):
errors.extend(obj.validate_config())
if errors:
raise LabelStudioValidationErrorSentryIgnored("\n".join(errors))

@property
def is_valid(self):
""" """
Expand Down Expand Up @@ -753,6 +763,7 @@ def validate(self):
self._schema_validation(config_string)
self._unique_names_validation(config_string)
self._to_name_validation(config_string)
self._tag_attribute_validation()

@classmethod
def validate_with_data(cls, config):
Expand Down
49 changes: 48 additions & 1 deletion src/label_studio_sdk/label_interface/object_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import re
import xml.etree.ElementTree
from urllib.parse import urlencode
from typing import Optional
from typing import List, Optional

from .base import LabelStudioTag, get_tag_class

Expand Down Expand Up @@ -154,6 +154,10 @@ def collect_attrs(self):
"value": '$' + self.value if self.value is not None else None
}

def validate_config(self) -> List[str]:
"""Validate tag-specific attribute values. Override in subclasses. Returns list of error messages."""
return []

# and have generate_example in each
def generate_example_value(self, mode="upload", secure_mode=False):
""" """
Expand Down Expand Up @@ -224,14 +228,57 @@ def _generate_example(self, examples, only_urls=False):
return examples.get("TextRaw")


MIN_PLAYBACK_SPEED = 0.05
MAX_PLAYBACK_SPEED = 10
class VideoTag(ObjectTag):
""" """
tag: str = "Video"



def _generate_example(self, examples, only_urls=False):
""" """
return examples.get("Video")

def _parse_speed_attr(self, attr_name: str) -> Optional[float]:
"""Parse a playback speed attribute from tag attrs. Returns None if not present or invalid."""
if not self.attr:
return None
# XML attributes may be stored with original casing
raw = self.attr.get(attr_name) or self.attr.get(attr_name.lower())
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None
try:
return float(raw)
except (TypeError, ValueError):
return None

def validate_config(self) -> List[str]:
"""Validate Video tag playback speed attribute values. Returns list of error messages."""
errors: List[str] = []
default_speed = self._parse_speed_attr("defaultPlaybackSpeed")
min_speed = self._parse_speed_attr("minPlaybackSpeed")

if default_speed is not None:
if default_speed < MIN_PLAYBACK_SPEED or default_speed > MAX_PLAYBACK_SPEED:
errors.append(
f'Video tag "{self.name}": defaultPlaybackSpeed must be '
f"between {MIN_PLAYBACK_SPEED} and {MAX_PLAYBACK_SPEED}, got {default_speed}"
)
if min_speed is not None:
if min_speed < MIN_PLAYBACK_SPEED or min_speed > MAX_PLAYBACK_SPEED:
errors.append(
f'Video tag "{self.name}": minPlaybackSpeed must be '
f"between {MIN_PLAYBACK_SPEED} and {MAX_PLAYBACK_SPEED}, got {min_speed}"
)
if default_speed is not None and min_speed is not None:
if min_speed > default_speed:
errors.append(
f'Video tag "{self.name}": minPlaybackSpeed ({min_speed}) '
f"must not exceed defaultPlaybackSpeed ({default_speed})"
)
return errors


class HyperTextTag(ObjectTag):
""" """
Expand Down
95 changes: 94 additions & 1 deletion tests/custom/test_interface/test_object_tags.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,100 @@
from label_studio_sdk.label_interface.object_tags import ObjectTag
from label_studio_sdk.label_interface.object_tags import (
ObjectTag,
VideoTag,
MIN_PLAYBACK_SPEED,
MAX_PLAYBACK_SPEED,
)
from lxml.etree import Element


def test_object_tag_validate_config_returns_empty():
"""ObjectTag.validate_config() is a no-op and returns empty list."""
tag = Element("tag", {"name": "my_name", "value": "my_value"})
object_tag = ObjectTag.parse_node(tag)
assert object_tag.validate_config() == []


def test_video_tag_validate_config_valid():
"""VideoTag.validate_config() returns no errors for valid playback speed values."""
tag = Element(
"Video",
{
"name": "video",
"value": "$video",
"defaultPlaybackSpeed": "1",
"minPlaybackSpeed": "0.25",
},
)
video_tag = ObjectTag.parse_node(tag)
assert isinstance(video_tag, VideoTag)
assert video_tag.validate_config() == []


def test_video_tag_validate_config_no_playback_attrs():
"""VideoTag without playback speed attributes passes validation."""
tag = Element("Video", {"name": "video", "value": "$video"})
video_tag = ObjectTag.parse_node(tag)
assert video_tag.validate_config() == []


def test_video_tag_validate_config_default_above_max():
"""VideoTag.validate_config() returns error when defaultPlaybackSpeed > 10."""
tag = Element(
"Video",
{"name": "video", "value": "$video", "defaultPlaybackSpeed": "15"},
)
video_tag = ObjectTag.parse_node(tag)
errors = video_tag.validate_config()
assert len(errors) == 1
assert "defaultPlaybackSpeed" in errors[0]
assert "15" in errors[0]
assert str(MAX_PLAYBACK_SPEED) in errors[0]


def test_video_tag_validate_config_min_above_max():
"""VideoTag.validate_config() returns error when minPlaybackSpeed > 10."""
tag = Element(
"Video",
{"name": "video", "value": "$video", "minPlaybackSpeed": "12"},
)
video_tag = ObjectTag.parse_node(tag)
errors = video_tag.validate_config()
assert len(errors) == 1
assert "minPlaybackSpeed" in errors[0]
assert "12" in errors[0]


def test_video_tag_validate_config_min_exceeds_default():
"""VideoTag.validate_config() returns error when minPlaybackSpeed > defaultPlaybackSpeed."""
tag = Element(
"Video",
{
"name": "video",
"value": "$video",
"defaultPlaybackSpeed": "5",
"minPlaybackSpeed": "10",
},
)
video_tag = ObjectTag.parse_node(tag)
errors = video_tag.validate_config()
assert len(errors) == 1
assert "minPlaybackSpeed" in errors[0]
assert "must not exceed defaultPlaybackSpeed" in errors[0]


def test_video_tag_validate_config_default_below_min():
"""VideoTag.validate_config() returns error when defaultPlaybackSpeed < 0.05."""
tag = Element(
"Video",
{"name": "video", "value": "$video", "defaultPlaybackSpeed": "0.01"},
)
video_tag = ObjectTag.parse_node(tag)
errors = video_tag.validate_config()
assert len(errors) == 1
assert "defaultPlaybackSpeed" in errors[0]
assert str(MIN_PLAYBACK_SPEED) in errors[0]


def test_parse():
tag = Element("tag", {"name": "my_name", "value": "my_value"})
object_tag = ObjectTag.parse_node(tag)
Expand Down
62 changes: 55 additions & 7 deletions tests/custom/test_interface/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,58 @@ def test_validate_relation():
assert conf.validate_relation(d, [ r1._dict(), r2._dict() ]) == True
assert conf.validate_relation(d, [ r1._dict(), r3._dict() ]) == False
assert conf.validate_relation(d, [ r2._dict(), r3._dict() ]) == False

# def test_validate_with_data():
# """ """

def test_validation_error_messages():
""" """



def test_label_interface_validate_video_config_valid():
"""LabelInterface.validate() passes for valid Video config with playback speed attrs."""
VIDEO_CONF_VALID = """
<View>
<Video name="video" value="$video" framerate="25" defaultPlaybackSpeed="2" minPlaybackSpeed="0.5"/>
<VideoRectangle name="box" toName="video" />
</View>
"""
li = LabelInterface(VIDEO_CONF_VALID)
li.validate() # does not raise


def test_label_interface_validate_video_config_invalid_default_above_max():
"""LabelInterface.validate() raises when defaultPlaybackSpeed > 10."""
VIDEO_CONF_INVALID_DEFAULT_ABOVE_MAX = """
<View>
<Video name="video" value="$video" defaultPlaybackSpeed="15"/>
<VideoRectangle name="box" toName="video" />
</View>
"""
li = LabelInterface(VIDEO_CONF_INVALID_DEFAULT_ABOVE_MAX)
with pytest.raises(LabelStudioValidationErrorSentryIgnored) as exc_info:
li.validate()
assert "defaultPlaybackSpeed" in str(exc_info.value)
assert "15" in str(exc_info.value)


def test_label_interface_validate_video_config_invalid_min_above_default():
"""LabelInterface.validate() raises when minPlaybackSpeed > defaultPlaybackSpeed."""
VIDEO_CONF_INVALID_MIN_ABOVE_DEFAULT = """
<View>
<Video name="video" value="$video" defaultPlaybackSpeed="5" minPlaybackSpeed="10"/>
<VideoRectangle name="box" toName="video" />
</View>
"""
li = LabelInterface(VIDEO_CONF_INVALID_MIN_ABOVE_DEFAULT)
with pytest.raises(LabelStudioValidationErrorSentryIgnored) as exc_info:
li.validate()
assert "minPlaybackSpeed" in str(exc_info.value)
assert "must not exceed defaultPlaybackSpeed" in str(exc_info.value)


def test_label_interface_is_valid_false_for_invalid_video_config():
"""LabelInterface.is_valid returns False for invalid Video playback speed config."""
VIDEO_CONF_INVALID_DEFAULT_ABOVE_MAX = """
<View>
<Video name="video" value="$video" defaultPlaybackSpeed="15"/>
<VideoRectangle name="box" toName="video" />
</View>
"""
li = LabelInterface(VIDEO_CONF_INVALID_DEFAULT_ABOVE_MAX)
assert li.is_valid is False

Loading