diff --git a/detection/api/mpf_component_api/__init__.py b/detection/api/mpf_component_api/__init__.py index d11afe4..921e099 100644 --- a/detection/api/mpf_component_api/__init__.py +++ b/detection/api/mpf_component_api/__init__.py @@ -25,3 +25,5 @@ ############################################################################# from .mpf_component_api import * + +from .timing import Timing diff --git a/detection/api/mpf_component_api/mpf_component_api.py b/detection/api/mpf_component_api/mpf_component_api.py index f711397..1b9d96a 100644 --- a/detection/api/mpf_component_api/mpf_component_api.py +++ b/detection/api/mpf_component_api/mpf_component_api.py @@ -140,5 +140,3 @@ def __str__(self): return f'{self.args[0]} (DetectionError.{self.error_code.name})' else: return super().__str__() - - diff --git a/detection/api/mpf_component_api/timing.py b/detection/api/mpf_component_api/timing.py new file mode 100644 index 0000000..53c076b --- /dev/null +++ b/detection/api/mpf_component_api/timing.py @@ -0,0 +1,234 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +from __future__ import annotations + +import contextlib +import functools +import inspect +import json +import logging +import time +import typing +from typing import (Callable, Dict, Iterable, Iterator, Optional, Protocol, + TypedDict, TypeVar, Union) + +T = TypeVar('T') + +JsonValue = Union[float, int, str, bool, None] + +MetricFunc = Callable[[float], JsonValue] + +Metrics = Dict[str, MetricFunc] + +class TimingDict(TypedDict): + name: str + seconds: float + metrics: Dict[str, JsonValue] + + +class Timing: + def __init__(self, reporter: TimingReporter): + self._reporter = reporter + + @classmethod + def with_logger(cls, logger: logging.Logger, level: int = logging.INFO): + return cls(LoggerReporter(logger, level)) + + + @contextlib.contextmanager + def timer_ctx(self, timer_name: str, metrics: Optional[Metrics] = None, started: bool = True): + timer = Timer(timer_name, self._reporter) + if metrics: + timer.add_metrics(metrics) + if started: + timer.start() + try: + yield timer + finally: + timer.pause() + timer.report_timing() + + + @typing.overload + def time_func( + self, + name_or_func: Callable[..., T], + metrics: Optional[Metrics] = None) -> Callable[..., T]: + ... + + @typing.overload + def time_func( + self, + name_or_func: str, + metrics: Optional[Metrics] = None) -> Callable[[Callable[..., T]], Callable[..., T]]: + ... + + def time_func( + self, + name_or_func: Union[str, Callable[..., T]], + metrics: Optional[Metrics] = None): + if callable(name_or_func): + # Handle the case where decorator was applied directly to a function. + func = name_or_func + return typing.cast( + Callable[..., T], + self._decorate_func(func, func.__name__, metrics)) + else: + # Handle the case where parameters were passed to the decorator. + name = name_or_func + return lambda func: self._decorate_func(func, name, metrics) + + def _decorate_func( + self, + func: Callable[..., T], + timer_name: str, + metrics: Optional[Metrics]): + if inspect.isgeneratorfunction(func): + @functools.wraps(func) + def generator_with_timing(*args, **kwargs): + with self.timer_ctx(timer_name, metrics): + return (yield from func(*args, **kwargs)) + return generator_with_timing + else: + @functools.wraps(func) + def func_with_timing(*args, **kwargs): + with self.timer_ctx(timer_name, metrics): + return func(*args, **kwargs) + return func_with_timing + + + def manual_timer(self, timer_name: str) -> Timer: + return Timer(timer_name, self._reporter) + + + def iterator( + self, + timer_name: str, + iterable: Iterable[T], + metrics: Optional[Metrics] = None) -> Iterator[T]: + with self.timer_ctx(timer_name, metrics) as timer: + num_iterations = 0 + for item in iterable: + num_iterations += 1 + yield item + timer.add_metric('iterations/sec', lambda sec: num_iterations / sec) + + + def detailed_iter( + self, + timer_name: str, + iterable: Iterable[T], + metrics: Optional[Metrics] = None): + with self.timer_ctx(timer_name, metrics) as overall_timer: + loop_body_timer = self.manual_timer(timer_name) + num_iterations = 0 + for item in iterable: + num_iterations += 1 + with loop_body_timer.time_segment(): + yield item + + overall_timer.pause() + overall_timer.add_metrics({ + 'iterations/sec': lambda sec: num_iterations / sec, + 'loop body time (s)': lambda _: loop_body_timer.seconds_elapsed, + 'iterator time (s)': lambda sec: sec - loop_body_timer.seconds_elapsed + }) + + +class TimingReporter(Protocol): + def __call__(self, timing_dict: TimingDict): + ... + + +class LoggerReporter(TimingReporter): + def __init__(self, logger: logging.Logger, level: int = logging.INFO): + self._logger = logger + self._level = level + + def __call__(self, timing_dict: TimingDict): + timing_str = json.dumps(timing_dict) + self._logger.log(self._level, 'Timing - %s', timing_str) + + +class PrintReporter(TimingReporter): + def __call__(self, timing_dict: TimingDict): + timing_str = json.dumps(timing_dict) + print(f'Timing - {timing_str}') + + +class Timer: + def __init__(self, name: str, reporter: TimingReporter = PrintReporter()): + self.name = name + self._seconds_elapsed = 0.0 + self._last_start_time = None + self._metrics: Metrics = {} + self._reporter = reporter + + def start(self) -> Timer: + if self._last_start_time is None: + self._last_start_time = time.perf_counter() + return self + + def pause(self) -> float: + self._seconds_elapsed = self.seconds_elapsed + self._last_start_time = None + return self._seconds_elapsed + + @property + def seconds_elapsed(self) -> float: + if self._last_start_time is None: + return self._seconds_elapsed + time_since_start = time.perf_counter() - self._last_start_time + return self._seconds_elapsed + time_since_start + + def add_metric(self, metric_name: str, metric_func: MetricFunc) -> Timer: + self._metrics[metric_name] = metric_func + return self + + def add_metrics(self, metrics: Metrics) -> Timer: + self._metrics.update(metrics) + return self + + def report_timing(self) -> Timer: + self._reporter(self.get_timing_dict()) + return self + + def get_timing_dict(self) -> TimingDict: + seconds_elapsed = self.seconds_elapsed + return { + 'name': self.name, + 'seconds': seconds_elapsed, + 'metrics': {n: f(seconds_elapsed) for n, f in self._metrics.items()} + } + + @contextlib.contextmanager + def time_segment(self): + self.start() + try: + yield self + finally: + self.pause() diff --git a/detection/component_util/mpf_component_util/utils.py b/detection/component_util/mpf_component_util/utils.py index cdf5ecb..c0d0604 100644 --- a/detection/component_util/mpf_component_util/utils.py +++ b/detection/component_util/mpf_component_util/utils.py @@ -164,6 +164,9 @@ def tl(self) -> Point[TNumber]: def empty(self) -> bool: return self.area <= 0 + def __bool__(self) -> bool: + return not self.empty + @property def area(self) -> TNumber: return self.width * self.height @@ -174,7 +177,6 @@ def size(self) -> Size[TNumber]: def union(self, other: _RectLike[TNumber]) -> Rect[TNumber]: other = Rect.__rectify(other) - if self.empty: return other elif other.empty: @@ -184,6 +186,7 @@ def union(self, other: _RectLike[TNumber]) -> Rect[TNumber]: element_wise_op(min, self.tl, other.tl), element_wise_op(max, self.br, other.br)) + __or__ = union def intersection(self, other: _RectLike[TNumber]) -> Rect[TNumber]: other = Rect.__rectify(other) @@ -193,11 +196,12 @@ def intersection(self, other: _RectLike[TNumber]) -> Rect[TNumber]: if top_left.x >= bottom_right.x or top_left.y >= bottom_right.y: if isinstance(self.x, int): - return Rect(0, 0, 0, 0) + return _empty_int_rect else: - return Rect(0.0, 0.0, 0.0, 0.0) + return _empty_float_rect return Rect.from_corners(top_left, bottom_right) + __and__ = intersection @staticmethod def from_corners(point1: _PointLike[TNumber], point2: _PointLike[TNumber]) -> Rect[TNumber]: @@ -229,8 +233,10 @@ def __rectify(obj: _RectLike[TNumber]) -> Rect[TNumber]: return Rect.from_corners(obj1, obj2) if isinstance(obj2, Size): return Rect.from_corner_and_size(obj1, obj2) - raise TypeError('Could not convert argument %s to Rect.' % (obj,)) + raise TypeError(f'Could not convert argument {obj} to Rect.') +_empty_int_rect = Rect(0, 0, 0, 0) +_empty_float_rect = Rect(0.0, 0.0, 0.0, 0.0) _RectLike = Union[ 'Rect[TNumber]', diff --git a/detection/component_util/tests/test_timing.py b/detection/component_util/tests/test_timing.py new file mode 100644 index 0000000..e079554 --- /dev/null +++ b/detection/component_util/tests/test_timing.py @@ -0,0 +1,206 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +import unittest +from unittest import mock +from unittest.mock import Mock + + +import test_util +test_util.add_local_component_libs_to_sys_path() +import mpf_component_api as mpf + +class TestTiming(unittest.TestCase): + + def setUp(self): + clock_patcher = mock.patch('time.perf_counter') + self._mock_clock = clock_patcher.start() + self.addCleanup(clock_patcher.stop) + + self._mock_reporter = Mock() + self._timing = mpf.Timing(self._mock_reporter) + + + def _set_clock_times(self, *times: float): + self._mock_clock.side_effect = [*times, Exception('all times used.')] + + + def test_context_manager(self): + self._set_clock_times(3, 8) + with self._timing.timer_ctx('test-timer') as timer: + timer.add_metric('2x time', lambda sec: 2 * sec) + + self._mock_reporter.assert_called_once_with({ + 'name': 'test-timer', + 'seconds': 5.0, + 'metrics': {'2x time': 10.0} + }) + + + def test_decorator_with_args(self): + + @self._timing.time_func('test-timer', {'3x time': lambda sec: 3 * sec}) + def func_to_time(a, b): + return a + b + + self._set_clock_times(5, 12, 13, 16) + self.assertEqual('ab', func_to_time('a', 'b')) + self.assertEqual('cd', func_to_time('c', 'd')) + self._mock_reporter.assert_has_calls([ + mock.call({ + 'name': 'test-timer', + 'seconds': 7.0, + 'metrics': {'3x time': 21.0} + }), + mock.call({ + 'name': 'test-timer', + 'seconds': 3.0, + 'metrics': {'3x time': 9.0} + }) + ]) + + + def test_decorator_no_args(self): + + @self._timing.time_func + def func_to_time(a, b): + return a + b + + self._set_clock_times(5, 12, 13, 16) + self.assertEqual('ab', func_to_time('a', 'b')) + self.assertEqual('cd', func_to_time('c', 'd')) + self._mock_reporter.assert_has_calls([ + mock.call({ + 'name': 'func_to_time', + 'seconds': 7.0, + 'metrics': {} + }), + mock.call({ + 'name': 'func_to_time', + 'seconds': 3.0, + 'metrics': {} + }) + ]) + + + def test_iterator(self): + self._set_clock_times(120, 125) + expected_values = iter(range(20)) + for value in self._timing.iterator('test-timer', range(20)): + self.assertEqual(value, next(expected_values)) + + self._mock_reporter.assert_called_once_with({ + 'name': 'test-timer', + 'seconds': 5.0, + 'metrics': {'iterations/sec': 4.0} + }) + + + def test_detailed_iterator(self): + start_time = 100 + iter1_start = 108 + iter1_stop = 119 + iter2_start = 121 + iter2_stop = 129 + stop_time = 140 + self._set_clock_times( + start_time, iter1_start, iter1_stop, iter2_start, iter2_stop, stop_time + ) + expected_values = iter(range(2)) + for value in self._timing.detailed_iter('test-timer', range(2)): + self.assertEqual(value, next(expected_values)) + + self._mock_reporter.assert_called_once_with({ + 'name': 'test-timer', + 'seconds': 40.0, + 'metrics': { + 'iterations/sec': 0.05, + 'loop body time (s)': 19.0, + 'iterator time (s)': 21.0 + }}) + + + def test_time_segments(self): + self._set_clock_times(3, 10, 16, 20) + with self._timing.timer_ctx('test-timer', started=False) as timer: + with timer.time_segment(): + pass + with timer.time_segment(): + pass + + self._mock_reporter.assert_called_once_with({ + 'name': 'test-timer', + 'seconds': 11.0, + 'metrics': {} + }) + + + def test_generator_decorator_with_args(self): + @self._timing.time_func('test-timer', metrics={'4x time': lambda sec: sec * 4}) + def generator_func(): + yield 1 + yield 2 + return 3 + + self._set_clock_times(51, 61) + generator = generator_func() + self.assertEqual(1, next(generator)) + self.assertEqual(2, next(generator)) + self._mock_reporter.assert_not_called() + + with self.assertRaises(StopIteration) as cm: + next(generator) + self.assertEqual(3, cm.exception.value) + + self._mock_reporter.assert_called_once_with({ + 'name': 'test-timer', + 'seconds': 10.0, + 'metrics': {'4x time': 40.0} + }) + + + def test_generator_decorator_no_args(self): + @self._timing.time_func + def generator_func(): + yield 3 + yield 4 + return 5 + + self._set_clock_times(34, 40) + generator = generator_func() + self.assertEqual(3, next(generator)) + self.assertEqual(4, next(generator)) + self._mock_reporter.assert_not_called() + + with self.assertRaises(StopIteration) as cm: + next(generator) + self.assertEqual(5, cm.exception.value) + + self._mock_reporter.assert_called_once_with({ + 'name': 'generator_func', + 'seconds': 6.0, + 'metrics': {} + }) diff --git a/subject/api/mpf_subject_api/__init__.py b/subject/api/mpf_subject_api/__init__.py new file mode 100644 index 0000000..c24be9b --- /dev/null +++ b/subject/api/mpf_subject_api/__init__.py @@ -0,0 +1,146 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +from __future__ import annotations + +import collections +import uuid +from dataclasses import dataclass, field +from typing import Collection, Mapping, NamedTuple, NewType, Sequence, TypeVar, Protocol + +import mpf_component_api as mpf + +# Examples: subject, vehicle +EntityType = NewType('EntityType', str) + +TrackId = NewType('TrackId', str) +# Examples: face, person, truck +TrackType = NewType('TrackType', str) + +# Example: proximity +RelationshipType = NewType('RelationshipType', str) + +MediaId = NewType('MediaId', str) + +# Examples FACE, CLASS +DetectionComponentType = NewType('DetectionComponentType', str) + +K = TypeVar('K') +V = TypeVar('V') +Multimap = Mapping[K, Collection[V]] + + +class SubjectTrackingComponent(Protocol): + def get_subjects(self, job: SubjectTrackingJob) -> SubjectTrackingResults: + ... + + +class SubjectTrackingJob(NamedTuple): + job_name: str + job_properties: Mapping[str, str] + + video_jobs: Sequence[VideoDetectionJobResults] + image_jobs: Sequence[ImageDetectionJobResults] + audio_jobs: Sequence[AudioJobResults] + generic_jobs: Sequence[GenericJobResults] + + +class VideoDetectionJobResults(NamedTuple): + data_uri: str + media_id: MediaId + algorithm: str + component_type: DetectionComponentType + job_properties: Mapping[str, str] + media_properties: Mapping[str, str] + # Keys are Hex-encoded hashes + results: Mapping[TrackId, mpf.VideoTrack] + + +class ImageDetectionJobResults(NamedTuple): + data_uri: str + media_id: MediaId + algorithm: str + component_type: DetectionComponentType + job_properties: Mapping[str, str] + media_properties: Mapping[str, str] + results: Mapping[TrackId, mpf.ImageLocation] + +class AudioJobResults(NamedTuple): + data_uri: str + media_id: MediaId + algorithm: str + component_type: DetectionComponentType + job_properties: Mapping[str, str] + media_properties: Mapping[str, str] + results: Mapping[TrackId, mpf.AudioTrack] + + +class GenericJobResults(NamedTuple): + data_uri: str + media_id: MediaId + algorithm: str + component_type: DetectionComponentType + job_properties: Mapping[str, str] + media_properties: Mapping[str, str] + results: Mapping[TrackId, mpf.GenericTrack] + + +def _default_multimap(): + return collections.defaultdict(list) + + +@dataclass +class SubjectTrackingResults: + # Example keys: subject, vehicle + entities: Multimap[EntityType, Entity] = field(default_factory=_default_multimap) + + # Example keys: proximity + relationships: Multimap[RelationshipType, Relationship] = field( + default_factory=_default_multimap) + + properties: Mapping[str, str] = field(default_factory=dict) + + +@dataclass +class Entity: + id: uuid.UUID = field(default_factory=uuid.uuid4) + score: float = -1 + # Example keys: face, person, truck + tracks: Multimap[TrackType, TrackId] = field(default_factory=_default_multimap) + properties: Mapping[str, str] = field(default_factory=dict) + + +@dataclass +class Relationship: + entities: Collection[uuid.UUID] = field(default_factory=list) + frames: Collection[MediaReference] = field(default_factory=list) + properties: Mapping[str, str] = field(default_factory=dict) + + +@dataclass +class MediaReference: + id: MediaId + frames: Collection[int] = field(default_factory=list) diff --git a/subject/api/pyproject.toml b/subject/api/pyproject.toml new file mode 100644 index 0000000..ccc3a37 --- /dev/null +++ b/subject/api/pyproject.toml @@ -0,0 +1,37 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "mpf_subject_api" +version = "9.0" +description = "OpenMPF Python Component API" +dependencies = [ + "mpf_component_api>=9.0" +] diff --git a/subject/examples/PythonSubjectComponent/Dockerfile b/subject/examples/PythonSubjectComponent/Dockerfile new file mode 100644 index 0000000..b80ef7a --- /dev/null +++ b/subject/examples/PythonSubjectComponent/Dockerfile @@ -0,0 +1,38 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +ARG BUILD_REGISTRY +ARG BUILD_TAG=latest +FROM ${BUILD_REGISTRY}openmpf_python_subject_build:${BUILD_TAG} AS build_component + +RUN --mount=target=.,readwrite install-component.sh + + +FROM ${BUILD_REGISTRY}openmpf_python_subject_executor:${BUILD_TAG} + +COPY --from=build_component $COMPONENT_VIRTUALENV $COMPONENT_VIRTUALENV + +COPY --from=build_component $PLUGINS_DIR/PythonSubjectComponent $PLUGINS_DIR/PythonSubjectComponent diff --git a/subject/examples/PythonSubjectComponent/plugin-files/descriptor/descriptor.json b/subject/examples/PythonSubjectComponent/plugin-files/descriptor/descriptor.json new file mode 100644 index 0000000..3843e16 --- /dev/null +++ b/subject/examples/PythonSubjectComponent/plugin-files/descriptor/descriptor.json @@ -0,0 +1,14 @@ +{ + "componentName": "PythonSubjectComponent", + "componentVersion": "9.0", + "sourceLanguage": "python", + "componentLibrary": "PythonSubjectComponent", + "properties": [ + { + "name": "PROP_NAME", + "description" : "Description of PROP_NAME", + "type": "INT", + "defaultValue": "5" + } + ] +} diff --git a/subject/examples/PythonSubjectComponent/pyproject.toml b/subject/examples/PythonSubjectComponent/pyproject.toml new file mode 100644 index 0000000..27c89c4 --- /dev/null +++ b/subject/examples/PythonSubjectComponent/pyproject.toml @@ -0,0 +1,36 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "PythonSubjectComponent" +version = "9.0" + +[project.entry-points."mpf.exported_component"] +component = "subject_component:SubjectExampleComponent" diff --git a/subject/examples/PythonSubjectComponent/subject_component/__init__.py b/subject/examples/PythonSubjectComponent/subject_component/__init__.py new file mode 100644 index 0000000..49f1ab6 --- /dev/null +++ b/subject/examples/PythonSubjectComponent/subject_component/__init__.py @@ -0,0 +1,71 @@ +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +import itertools +import logging +import uuid + +import mpf_subject_api as mpf_sub + +logger = logging.getLogger('SubjectExampleComponent') + +class SubjectExampleComponent: + def __init__(self) -> None: + logger.info('Created instance of SubjectExampleComponent.') + + def get_subjects(self, job: mpf_sub.SubjectTrackingJob) -> mpf_sub.SubjectTrackingResults: + logger.info(f'Received job: {job.job_name}') + jobs = itertools.chain(job.video_jobs, job.image_jobs) + entities = [] + for detection_job in jobs: + for track_id in detection_job.results: + entity = get_single_track_entity(track_id) + entities.append(entity) + + if len(entities) >= 2: + if job.video_jobs: + media_id = job.video_jobs[0].media_id + else: + media_id = job.image_jobs[0].media_id + relationships = [get_relationship(media_id, entities[0], entities[1])] + else: + relationships = () + + logger.info(f'Sending response with {len(entities)} entities.') + return mpf_sub.SubjectTrackingResults( + {mpf_sub.EntityType("example entity type"): entities}, + {mpf_sub.RelationshipType("example relationship"): relationships}, + {"TEST_PROP": "TEST_VAL"}) + + +def get_single_track_entity(track_id: mpf_sub.TrackId) -> mpf_sub.Entity: + return mpf_sub.Entity( + uuid.uuid4(), 1, {mpf_sub.TrackType("example track type"): (track_id,)}) + + +def get_relationship(media_id: mpf_sub.MediaId, *entities: mpf_sub.Entity) -> mpf_sub.Relationship: + entity_ids = [e.id for e in entities] + return mpf_sub.Relationship(entity_ids, (mpf_sub.MediaReference(media_id, (0,)),)) diff --git a/subject/examples/PythonSubjectComponent/tests/test_subject_component.py b/subject/examples/PythonSubjectComponent/tests/test_subject_component.py new file mode 100644 index 0000000..a32d1d1 --- /dev/null +++ b/subject/examples/PythonSubjectComponent/tests/test_subject_component.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +############################################################################# +# NOTICE # +# # +# This software (or technical data) was produced for the U.S. Government # +# under contract, and is subject to the Rights in Data-General Clause # +# 52.227-14, Alt. IV (DEC 2007). # +# # +# Copyright 2024 The MITRE Corporation. All Rights Reserved. # +############################################################################# + +############################################################################# +# Copyright 2024 The MITRE Corporation # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################# + +import unittest +import sys +import pathlib +import logging + +import mpf_subject_api as mpf_sub +import mpf_component_api as mpf + +logging.basicConfig(level=logging.DEBUG) + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) +from subject_component import SubjectExampleComponent + + +class TestSubjectComponent(unittest.TestCase): + + def test_component(self): + face_track = mpf.VideoTrack(0, 100, 0.9, {}, {}) + face_track_id = mpf_sub.TrackId('face_track1') + video_job1 = mpf_sub.VideoDetectionJobResults( + '/path/to/media', + mpf_sub.MediaId('media_id'), + 'Test algorithm', + mpf_sub.DetectionComponentType('FACE'), + {}, + {}, + {face_track_id: face_track}) + + object_track = mpf.VideoTrack(0, 100, 0.8, {}, {'CLASSIFICATION': 'person'}) + object_track_id = mpf_sub.TrackId('object_track_1') + video_job2 = mpf_sub.VideoDetectionJobResults( + '/path/to/media', + mpf_sub.MediaId('media_id'), + 'Test algorithm', + mpf_sub.DetectionComponentType('CLASS'), + {}, + {}, + {object_track_id: object_track}) # tracks + + job = mpf_sub.SubjectTrackingJob('Test job', {}, (video_job1, video_job2), (), (), ()) + component = SubjectExampleComponent() + + subject_results = component.get_subjects(job) + + entities = subject_results.entities[mpf_sub.EntityType('example entity type')] + self.assertEqual(2, len(entities)) + entity1, entity2 = entities + + if track_is_in_entity(face_track_id, entity1): + self.assertTrue(track_is_in_entity(object_track_id, entity2)) + else: + self.assertTrue(track_is_in_entity(object_track_id, entity1)) + self.assertTrue(track_is_in_entity(face_track_id, entity2)) + + +def track_is_in_entity(track_id: mpf_sub.TrackId, entity: mpf_sub.Entity): + return any(track_id in track_group for track_group in entity.tracks.values()) + + +if __name__ == '__main__': + unittest.main(verbosity=2)