From 1760f5c7f73b03caccb3d5039713dac41a02a910 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Mon, 21 Nov 2022 17:00:53 +0100 Subject: Unifying and cleaning AreaOfInterest classes. --- src/argaze/AreaOfInterest/AOI2DScene.py | 27 +++-- src/argaze/AreaOfInterest/AOI3DScene.py | 43 +++++--- src/argaze/AreaOfInterest/AOIFeatures.py | 172 +++++++++++++++++++------------ 3 files changed, 144 insertions(+), 98 deletions(-) diff --git a/src/argaze/AreaOfInterest/AOI2DScene.py b/src/argaze/AreaOfInterest/AOI2DScene.py index 99bf8a9..f648803 100644 --- a/src/argaze/AreaOfInterest/AOI2DScene.py +++ b/src/argaze/AreaOfInterest/AOI2DScene.py @@ -12,12 +12,9 @@ import numpy class AOI2DScene(AOIFeatures.AOIScene): """Define AOI 2D scene.""" - def __init__(self, **aois_2d): + def __init__(self, aois_2d = None): - super().__init__(**aois_2d) - - # set dimension member - self.dimension = 2 + super().__init__(2, aois_2d) def draw(self, frame, exclude=[], color=(0, 255, 255)): """Draw AOI polygons on frame.""" @@ -29,7 +26,7 @@ class AOI2DScene(AOIFeatures.AOIScene): aoi.draw(frame, color) - def raycast(self, gaze_position) -> Tuple[str, "AOIFeatures.AreaOfInterest", bool]: + def raycast(self, gaze_position: GazeFeatures.GazePosition) -> Tuple[str, "AOIFeatures.AreaOfInterest", bool]: """Iterate over aoi to know which aoi is looked considering only gaze position value. * **Returns:** - aoi name @@ -39,11 +36,11 @@ class AOI2DScene(AOIFeatures.AOIScene): for name, aoi in self.items(): - looked = aoi.looked(gaze_position) + looked = aoi.contains_point(gaze_position.value) yield name, aoi, looked - def draw_raycast(self, frame, gaze_position, exclude=[], base_color=(0, 0, 255), looked_color=(0, 255, 0)): + def draw_raycast(self, frame, gaze_position: GazeFeatures.GazePosition, exclude=[], base_color=(0, 0, 255), looked_color=(0, 255, 0)): """Draw AOIs with their looked status.""" for name, aoi, looked in self.raycast(gaze_position): @@ -61,26 +58,26 @@ class AOI2DScene(AOIFeatures.AOIScene): # Draw form aoi.draw(frame, color) - def regioncast(self, gaze_position) -> Tuple[str, "AOIFeatures.AreaOfInterest", numpy.array, float, float]: + def circlecast(self, gaze_position: GazeFeatures.GazePosition) -> Tuple[str, "AOIFeatures.AreaOfInterest", numpy.array, float, float]: """Iterate over areas to know which aoi is looked considering gaze position value and its accuracy. * **Returns:** - aoi name - aoi object - looked region points - - ratio of looked region relatively to aoi - - ratio of looked region relatively to gaze position accuracy + - ratio of looked region area relatively to aoi area + - ratio of looked region area relatively to gaze position circle accuracy """ for name, aoi in self.items(): - looked_region, aoi_ratio, gaze_ratio = aoi.looked_region(gaze_position) + looked_region, aoi_ratio, gaze_ratio = aoi.circle_intersection(gaze_position.value, gaze_position.accuracy) yield name, aoi, looked_region, aoi_ratio, gaze_ratio - def draw_regioncast(self, frame, gaze_position, exclude=[], base_color=(0, 0, 255), looked_color=(0, 255, 0)): - """Draw AOIs with their looked status and thei looked region.""" + def draw_circlecast(self, frame, gaze_position: GazeFeatures.GazePosition, exclude=[], base_color=(0, 0, 255), looked_color=(0, 255, 0)): + """Draw AOIs with their looked status and looked region.""" - for name, aoi, looked_region, aoi_ratio, gaze_ratio in self.regioncast(gaze_position): + for name, aoi, looked_region, aoi_ratio, gaze_ratio in self.circlecast(gaze_position): if name in exclude: continue diff --git a/src/argaze/AreaOfInterest/AOI3DScene.py b/src/argaze/AreaOfInterest/AOI3DScene.py index ba0c18c..c5ee265 100644 --- a/src/argaze/AreaOfInterest/AOI3DScene.py +++ b/src/argaze/AreaOfInterest/AOI3DScene.py @@ -1,7 +1,6 @@ #!/usr/bin/env python from typing import TypeVar, Tuple -from dataclasses import dataclass, field import math import re @@ -13,16 +12,16 @@ import cv2 as cv T0 = numpy.array([0., 0., 0.]) -"""Define defaut translation vector.""" +"""Define no translation vector.""" R0 = numpy.array([0., 0., 0.]) -"""Define defaut rotation vector.""" +"""Define no rotation vector.""" -K0 = numpy.array([[1., 0., 1.], [0., 1., 1.], [0., 0., 1.]]) -"""Define defaut optical parameter.""" +K0 = numpy.array([[1., 0., 0.], [0., 1., 0.], [0., 0., 0.]]) +"""Define default camera intrinsic parameters matrix.""" D0 = numpy.array([0.0, 0.0, 0.0, 0.0, 0.0]) -"""Define a zero distorsion matrix.""" +"""Define default camera distorsion coefficients vector.""" AOI3DSceneType = TypeVar('AOI3DScene', bound="AOI3DScene") # Type definition for type annotation convenience @@ -30,14 +29,12 @@ AOI3DSceneType = TypeVar('AOI3DScene', bound="AOI3DScene") AOI2DSceneType = TypeVar('AOI2DScene', bound="AOI2DScene") # Type definition for type annotation convenience -@dataclass class AOI3DScene(AOIFeatures.AOIScene): """Define AOI 3D scene.""" - def __post_init__(self, **aois): + def __init__(self, aois_3d = None): - # set dimension member - self.dimension = 3 + super().__init__(3, aois_3d) def load(self, obj_filepath: str): """Load AOI3D scene from .obj file.""" @@ -104,7 +101,7 @@ class AOI3DScene(AOIFeatures.AOIScene): # retreive all aoi3D vertices for name, face in faces.items(): - aoi3D = numpy.array([ vertices[i-1] for i in face ]).astype(numpy.float32).view(AOIFeatures.AreaOfInterest) + aoi3D = AOIFeatures.AreaOfInterest([ vertices[i-1] for i in face ]) self[name] = aoi3D except IOError: @@ -181,23 +178,35 @@ class AOI3DScene(AOIFeatures.AOIScene): return aoi3D_scene_inside, aoi3D_scene_outside - def project(self, T=T0, R=R0, K=K0, D=D0) -> AOI2DScene: - """Project 3D scene onto 2D scene according translation, rotation and optical parameters.""" + def project(self, T: numpy.array = T0, R: numpy.array = R0, K: numpy.array = K0, D: numpy.array = D0) -> AOI2DSceneType: + """Project 3D scene onto 2D scene according translation, rotation and optical parameters. + + * **Arguments:** + - translation vector + - [axis-angle](https://en.wikipedia.org/wiki/Axis%E2%80%93angle_representation) rotation vector + - camera intrinsic parameters matrix + - camera distorsion coefficients vector + """ aoi2D_scene = AOI2DScene.AOI2DScene() for name, aoi3D in self.items(): - vertices_2D, J = cv.projectPoints(aoi3D, R, T, K, D) + vertices_2D, J = cv.projectPoints(aoi3D.astype(numpy.float32), R, T, K, D) - aoi2D = vertices_2D.reshape((len(vertices_2D), 2)).astype(numpy.float32).view(AOIFeatures.AreaOfInterest) + aoi2D = vertices_2D.reshape((len(vertices_2D), 2)).view(AOIFeatures.AreaOfInterest) aoi2D_scene[name] = aoi2D return aoi2D_scene - def transform(self, T=T0, R=D0) -> AOI3DSceneType: - """Translate and/or rotate 3D scene.""" + def transform(self, T: numpy.array = T0, R: numpy.array = R0) -> AOI3DSceneType: + """Translate and/or rotate 3D scene. + + * **Arguments:** + - translation vector + - [axis-angle](https://en.wikipedia.org/wiki/Axis%E2%80%93angle_representation) rotation vector + """ aoi3D_scene = AOI3DScene() diff --git a/src/argaze/AreaOfInterest/AOIFeatures.py b/src/argaze/AreaOfInterest/AOIFeatures.py index b3812bc..24e19e1 100644 --- a/src/argaze/AreaOfInterest/AOIFeatures.py +++ b/src/argaze/AreaOfInterest/AOIFeatures.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -from dataclasses import dataclass, field from typing import TypeVar, Tuple +import json from argaze import DataStructures @@ -14,9 +14,18 @@ from shapely.geometry.point import Point AreaOfInterestType = TypeVar('AreaOfInterest', bound="AreaOfInterest") # Type definition for type annotation convenience -@dataclass class AreaOfInterest(numpy.ndarray): - """Define 2D/3D Area Of Interest as an array of points.""" + """Define Area Of Interest as an array of points of any dimension.""" + + def __new__(cls, points: numpy.ndarray) -> AreaOfInterestType: + """View casting inheritance.""" + + return numpy.array(points).view(AreaOfInterest) + + def __repr__(self): + """String representation""" + + return repr(self.tolist()) @property def dimension(self) -> int: @@ -25,13 +34,10 @@ class AreaOfInterest(numpy.ndarray): return self.shape[1] @property - def bounding_box(self) -> numpy.array: - """Get area's bounding box.""" - - min_x, min_y = numpy.min(self, axis=0) - max_x, max_y = numpy.max(self, axis=0) - - return numpy.array([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]) + def size(self) -> int: + """Number of points defining the area.""" + + return self.shape[0] @property def center(self) -> numpy.array: @@ -39,8 +45,22 @@ class AreaOfInterest(numpy.ndarray): return self.mean(axis=0) + @property + def bounding_box(self) -> numpy.array: + """Get area's bounding box. + .. warning:: + Available for 2D AOI only.""" + + assert(self.size > 1) + assert(self.dimension == 2) + + min_x, min_y = numpy.min(self, axis=0) + max_x, max_y = numpy.max(self, axis=0) + + return numpy.array([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]) + def clockwise(self) -> AreaOfInterestType: - """Get area points in clocwise order. + """Get area points in clockwise order. .. warning:: Available for 2D AOI only.""" @@ -52,74 +72,81 @@ class AreaOfInterest(numpy.ndarray): return self[numpy.argsort(angles)] - def looked(self, gaze_position) -> bool: - """Is gaze position inside area? + def contains_point(self, point: tuple) -> bool: + """Is a point inside area? .. warning:: - Available for 2D AOI only.""" + Available for 2D AOI only. + .. danger:: + The AOI points must be sorted in clockwise order.""" assert(self.dimension == 2) + assert(len(point) == self.dimension) - return mpath.Path(self).contains_points([tuple(gaze_position)])[0] + return mpath.Path(self).contains_points([point])[0] - def look_at(self, pixel_position) -> numpy.array: - """Get where the area is looked using perpespective transformation. + def inner_axis(self, point: tuple) -> tuple: + """Transform the coordinates from the global axis to the AOI's axis. .. warning:: - Available for 2D AOI only.""" + Available for 2D AOI only. + .. danger:: + The AOI points must be sorted in clockwise order.""" assert(self.dimension == 2) - Src = self.clockwise() + Src = self Src_origin = Src[0] - Src = (Src - Src_origin).reshape((len(Src)), 2) + Src = (Src - Src_origin).reshape((len(Src)), 2).astype(numpy.float32) Dst = numpy.array([[0., 0.], [1., 0.], [1., 1.], [0., 1.]]).astype(numpy.float32) P = cv.getPerspectiveTransform(Src, Dst) - X = numpy.append(numpy.array(pixel_position - Src_origin), [1.0]).astype(numpy.float32) + X = numpy.append(numpy.array(numpy.array(point) - Src_origin), [1.0]).astype(numpy.float32) Y = numpy.dot(P, X) La = (Y/Y[2])[:-1] - return numpy.around(La, 4).tolist() + return tuple(numpy.around(La, 4)) - def looked_pixel(self, look_at) -> numpy.array: - """Get which pixel is looked inside 2D AOI. + def outter_axis(self, point: tuple) -> tuple: + """Transform the coordinates from the AOI's axis to the global axis. .. warning:: - Available for 2D AOI only.""" + Available for 2D AOI only. + .. danger:: + The AOI points must be sorted in clockwise order.""" assert(self.dimension == 2) Src = numpy.array([[0., 0.], [1., 0.], [1., 1.], [0., 1.]]).astype(numpy.float32) - Dst = self.clockwise() + Dst = self.astype(numpy.float32) Dst_origin = Dst[0] Dst = (Dst - Dst_origin).reshape((len(Dst)), 2) P = cv.getPerspectiveTransform(Src, Dst) - X = numpy.array([look_at[0], look_at[1], 1.0]).astype(numpy.float32) + X = numpy.array([point[0], point[1], 1.0]).astype(numpy.float32) Y = numpy.dot(P, X) Lp = Dst_origin + (Y/Y[2])[:-1] - return numpy.rint(Lp).astype(int).tolist() + return tuple(numpy.rint(Lp).astype(int)) - def looked_region(self, gaze_position) -> Tuple[numpy.array, float, float]: - """Get intersection shape with gaze accuracy circle as the looked area, (looked area / AOI area) and (looked area / gaze accuracy circle area). + def circle_intersection(self, center: tuple, radius: float) -> Tuple[numpy.array, float, float]: + """Get intersection shape with a circle, intersection area / AOI area ration and intersection area / circle area ration. .. warning:: Available for 2D AOI only.""" assert(self.dimension == 2) self_polygon = Polygon(self) - gaze_circle = Point(gaze_position).buffer(gaze_position.accuracy) + args_circle = Point(center).buffer(radius) - if self_polygon.intersects(gaze_circle): + if self_polygon.intersects(args_circle): - intersection = self_polygon.intersection(gaze_circle) + intersection = self_polygon.intersection(args_circle) intersection_array = numpy.array([list(xy) for xy in intersection.exterior.coords[:]]).astype(numpy.float32).view(AreaOfInterest) - return intersection_array, intersection.area / self_polygon.area, intersection.area / gaze_circle.area + return intersection_array, intersection.area / self_polygon.area, intersection.area / args_circle.area else: @@ -149,41 +176,62 @@ class AreaOfInterest(numpy.ndarray): AOISceneType = TypeVar('AOIScene', bound="AOIScene") # Type definition for type annotation convenience -@dataclass class AOIScene(): - """Define 2D/3D AOI scene.""" + """Define AOI scene as a dictionary of AOI.""" - dimension: int = field(init=False, repr=False, default=None) - """Dimension of the AOIs in scene.""" + def __init__(self, dimension: int, areas: dict = None): + """Initialisation.""" - areas: dict = field(init=False, default_factory=dict) - """All aois in the scene.""" + assert(dimension > 0) + + self.__dimension = dimension + + # NEVER USE {} as default function argument + if areas == None: + self.__areas = {} + else: + self.__areas = areas def __getitem__(self, name) -> AreaOfInterest: - """Get an aoi from the scene.""" + """Get an AOI from the scene.""" - return numpy.array(self.areas[name]).astype(numpy.float32).view(AreaOfInterest) + return AreaOfInterest(self.__areas[name]) #.astype(numpy.float32).view(AreaOfInterest) def __setitem__(self, name, aoi: AreaOfInterest): - """Add an aoi to the scene.""" + """Add an AOI to the scene.""" - self.areas[name] = aoi.tolist() + assert(aoi.dimension == self.__dimension) + + self.__areas[name] = AreaOfInterest(aoi) #.tolist() def __delitem__(self, key): - """Remove an aoi from the scene.""" + """Remove an AOI from the scene.""" + + del self.__areas[key] - del self.areas[key] + def __repr__(self): + """String representation""" + + return str(self.__areas) def items(self) -> Tuple[str, AreaOfInterest]: """Iterate over areas.""" - for name, area in self.areas.items(): - yield name, numpy.array(area).astype(numpy.float32).view(AreaOfInterest) + return self.__areas.items() + + #for name, area in self.__areas.items(): + #yield name, AreaOfInterest(area) #.astype(numpy.float32).view(AreaOfInterest) def keys(self) -> list[str]: """Get areas name.""" - return self.areas.keys() + return self.__areas.keys() + + @property + def dimension(self) -> int: + """Dimension of the AOIs in scene.""" + + return self.__dimension @property def bounds(self) -> numpy.array: @@ -191,11 +239,11 @@ class AOIScene(): all_vertices = [] - for area in self.areas.values(): + for area in self.__areas.values(): for vertice in area: all_vertices.append(vertice) - all_vertices = numpy.array(all_vertices).astype(numpy.float32) + all_vertices = numpy.array(all_vertices) #.astype(numpy.float32) min_bounds = numpy.min(all_vertices, axis=0) max_bounds = numpy.max(all_vertices, axis=0) @@ -206,7 +254,7 @@ class AOIScene(): def center(self) -> numpy.array: """Get scene's center point.""" - min_bounds, max_bounds = self.bounds() + min_bounds, max_bounds = self.bounds return (min_bounds + max_bounds) / 2 @@ -214,38 +262,30 @@ class AOIScene(): def size(self) -> numpy.array: """Get scene size.""" - min_bounds, max_bounds = self.bounds() + min_bounds, max_bounds = self.bounds return max_bounds - min_bounds def copy(self, exclude=[]) -> AOISceneType: - """Copy scene partly excluding aoi by name.""" + """Copy scene partly excluding AOI by name.""" - scene_copy = type(self)() + scene_copy = AOIScene(self.__dimension) - for name, area in self.areas.items(): + for name, area in self.__areas.items(): if name not in exclude: - scene_copy[name] = numpy.array(area).astype(numpy.float32).view(AreaOfInterest) + scene_copy[name] = AreaOfInterest(area) #.astype(numpy.float32).view(AreaOfInterest) return scene_copy -class EmptyAOIScene(AOIScene): - """Empty aoi scene.""" - - def __init__(self): - - self.dimension = 0 - self.areas = {} - class TimeStampedAOIScenes(DataStructures.TimeStampedBuffer): """Define timestamped buffer to store AOI scenes in time.""" def __setitem__(self, ts, scene): """Force value to inherit from AOIScene.""" - assert(type(scene).__bases__[0] == AOIScene) + assert(type(scene) == AOIScene) # .__bases__[0] super().__setitem__(ts, scene) -- cgit v1.1