vars-localize 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,34 @@
1
+ """
2
+ Main entry point for the VARS Localize application.
3
+ """
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from PyQt6.QtWidgets import QApplication
9
+
10
+ from vars_localize.ui.AppWindow import AppWindow
11
+ from vars_localize.util.endpoints import DEFAULT_M3_URL
12
+
13
+
14
+ def main():
15
+ """
16
+ Main entry point for the VARS Localize application.
17
+ """
18
+ parser = argparse.ArgumentParser(description="VARS Localize")
19
+ parser.add_argument(
20
+ "-u", "--url", type=str, default=DEFAULT_M3_URL, help="URL of M3 server"
21
+ )
22
+ args = parser.parse_args()
23
+
24
+ app = QApplication(sys.argv)
25
+
26
+ window = AppWindow(args.url)
27
+ window.show()
28
+
29
+ exit_code = app.exec()
30
+ sys.exit(exit_code)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,28 @@
1
+ """
2
+ Helpers for dealing with assets.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Union
7
+
8
+
9
+ ASSETS_DIR = Path(__file__).parent.resolve()
10
+
11
+
12
+ def get_asset_path(rel_path: Union[str, Path]) -> Path:
13
+ """
14
+ Get the absolute path to an asset.
15
+
16
+ Args:
17
+ rel_path (Union[str, Path]): The relative path to the asset.
18
+
19
+ Returns:
20
+ Path: The absolute path to the asset.
21
+
22
+ Raises:
23
+ ValueError: If rel_path is an absolute path.
24
+ """
25
+ rel_path = Path(rel_path)
26
+ if rel_path.is_absolute():
27
+ raise ValueError("rel_path must be a relative path.")
28
+ return ASSETS_DIR / rel_path
@@ -0,0 +1,276 @@
1
+ """
2
+ Main application window.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from PyQt6.QtCore import Qt
8
+ from PyQt6.QtGui import QCloseEvent, QIcon, QAction
9
+ from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QMessageBox, QInputDialog
10
+
11
+ from vars_localize.ui.EntryTree import EntryTreeItem
12
+ from vars_localize.ui.LoginDialog import LoginDialog
13
+ from vars_localize.ui.DisplayPanel import DisplayPanel
14
+ from vars_localize.ui.SearchPanel import SearchPanel
15
+ from vars_localize.util.m3 import (
16
+ check_connection,
17
+ get_all_users,
18
+ get_annotations_by_video_refernce,
19
+ get_imaged_moments_by_image_reference,
20
+ )
21
+ from vars_localize.util.utils import log, split_comma_list
22
+
23
+
24
+ class AppWindow(QMainWindow):
25
+ def __init__(self, m3_url: str, parent=None):
26
+ super(AppWindow, self).__init__(parent)
27
+ self._m3_url = m3_url.rstrip("/")
28
+
29
+ self.setWindowTitle("VARS Localize")
30
+
31
+ log(f"Checking connection to M3 at {self._m3_url}...")
32
+ if not check_connection(self._m3_url):
33
+ log(
34
+ "You are not connected to M3. Check your internet connection and/or VPN.",
35
+ level=2,
36
+ )
37
+ QMessageBox.critical(
38
+ self,
39
+ "No connection to M3",
40
+ "You are not connected to M3. Check your internet connection and/or VPN.",
41
+ )
42
+ exit(1)
43
+ log("Connected.")
44
+
45
+ self.observer = None
46
+ self.observer_role = None
47
+ self.admin_mode = False
48
+
49
+ login_ok = self.login()
50
+ if not login_ok:
51
+ log("You must log in to use this tool.", level=2)
52
+ exit(1)
53
+
54
+ self.central_container = QWidget()
55
+ self.central_container.setLayout(QHBoxLayout())
56
+
57
+ self.search_panel = SearchPanel(parent=self)
58
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.search_panel)
59
+
60
+ self.display_panel = DisplayPanel(parent=self)
61
+ self.central_container.layout().addWidget(self.display_panel)
62
+
63
+ self.setCentralWidget(self.central_container)
64
+
65
+ # Add admin menu if available to user
66
+ if self.observer_role in ("Maint", "Admin"):
67
+ self.add_admin_menu()
68
+
69
+ self.add_search_menu()
70
+ self.add_video_menu()
71
+
72
+ self.display_panel.image_view.observer = self.observer
73
+ self.display_panel.image_view.select_next = self.search_panel.select_next
74
+ self.display_panel.image_view.select_prev = self.search_panel.select_prev
75
+
76
+ self.search_panel.observer = self.observer
77
+
78
+ def load_entry(self, current: EntryTreeItem, previous: EntryTreeItem):
79
+ """
80
+ Load the current entry into the display panel
81
+ :param current: Current selected entry
82
+ :param previous: Previously selected entry
83
+ :return: None
84
+ """
85
+ if current and current.metadata:
86
+ self.display_panel.load_entry(current)
87
+
88
+ def login(self):
89
+ """
90
+ Prompt for observer login
91
+ :return: None
92
+ """
93
+ login_dialog = LoginDialog(parent=self)
94
+ login_dialog._login_form._username_line_edit.setFocus()
95
+ ok = login_dialog.exec()
96
+
97
+ if ok:
98
+ # Get the username/password from the dialog
99
+ username, password = login_dialog.credentials
100
+
101
+ # Set up the M3 configuration, returning False if login fails
102
+ if not self.configure_m3(username, password):
103
+ return False
104
+
105
+ all_valid_users = get_all_users()
106
+ users_dict = {
107
+ user_data["username"]: user_data for user_data in all_valid_users
108
+ }
109
+
110
+ # Set the observer and role
111
+ self.observer = username
112
+ self.observer_role = users_dict[username]["role"]
113
+ else: # Login cancel, return failure
114
+ return False
115
+
116
+ return True # Return success
117
+
118
+ def configure_m3(self, username, password) -> bool:
119
+ """
120
+ Configure endpoints and set up Annosaurus auth
121
+ """
122
+ from vars_localize.util.endpoints import configure as configure_endpoints
123
+ from vars_localize.util.m3 import configure_anno_session
124
+
125
+ try:
126
+ configure_endpoints(self._m3_url, username, password)
127
+ except Exception as e:
128
+ log("Login failed.", level=2)
129
+ log(e, level=2)
130
+ return False
131
+
132
+ configure_anno_session()
133
+
134
+ return True
135
+
136
+ def add_admin_menu(self):
137
+ """
138
+ Add the admin menu for observation modification/deletion
139
+ """
140
+ main_menu = self.menuBar()
141
+ options_menu = main_menu.addMenu("&Options")
142
+
143
+ admin_mode_action = QAction("Admin Mode", options_menu, checkable=True)
144
+
145
+ def set_admin_mode(val):
146
+ if val:
147
+ QMessageBox.warning(
148
+ self,
149
+ "Entering Admin Mode",
150
+ "WARNING: You are now entering administrator mode. This mode allows modification and deletion of observations within VARS.",
151
+ )
152
+ self.admin_mode = val
153
+
154
+ admin_mode_action.toggled.connect(set_admin_mode)
155
+ options_menu.addAction(admin_mode_action)
156
+
157
+ def add_search_menu(self):
158
+ """
159
+ Add the Go menu for non-concept searches
160
+ """
161
+ main_menu = self.menuBar()
162
+ search_menu = main_menu.addMenu("&Search")
163
+
164
+ def search_imaged_moment():
165
+ imaged_moment_uuid_list, ok = QInputDialog.getText(
166
+ self,
167
+ "Imaged Moment UUID Search",
168
+ "Imaged Moment UUID (or comma-separated list)",
169
+ )
170
+ if ok:
171
+ imaged_moment_uuids = split_comma_list(imaged_moment_uuid_list)
172
+ imaged_moment_uuids = list(
173
+ set(imaged_moment_uuids)
174
+ ) # Ensure no duplicates
175
+
176
+ # Set the UUIDs and load the first page
177
+ self.search_panel.set_uuids(imaged_moment_uuids)
178
+ self.search_panel.load_page()
179
+
180
+ search_imaged_moment_action = QAction("Imaged Moment UUID", search_menu)
181
+ search_imaged_moment_action.triggered.connect(search_imaged_moment)
182
+ search_menu.addAction(search_imaged_moment_action)
183
+
184
+ def search_image_reference():
185
+ image_reference_uuid_list, ok = QInputDialog.getText(
186
+ self,
187
+ "Image Reference UUID Search",
188
+ "Image Reference UUID (or comma-separated list)",
189
+ )
190
+ if ok:
191
+ all_image_reference_uuids = split_comma_list(image_reference_uuid_list)
192
+ imaged_moment_uuids = []
193
+ for image_reference_uuid in all_image_reference_uuids:
194
+ res = get_imaged_moments_by_image_reference(image_reference_uuid)
195
+ if res:
196
+ imaged_moment_uuids.extend(
197
+ [item["imaged_moment_uuid"] for item in res]
198
+ )
199
+ imaged_moment_uuids = list(
200
+ set(imaged_moment_uuids)
201
+ ) # Ensure no duplicates
202
+
203
+ # Set the UUIDs and load the first page
204
+ self.search_panel.set_uuids(imaged_moment_uuids)
205
+ self.search_panel.load_page()
206
+
207
+ search_image_reference_action = QAction("Image Reference UUID", search_menu)
208
+ search_image_reference_action.triggered.connect(search_image_reference)
209
+ search_menu.addAction(search_image_reference_action)
210
+
211
+ def search_video_reference():
212
+ video_reference_uuid, ok = QInputDialog.getText(
213
+ self, "Video Reference UUID Search", "Video Reference UUID"
214
+ )
215
+ if ok:
216
+ res = get_annotations_by_video_refernce(video_reference_uuid)
217
+ if res:
218
+ timestamp_uuid_tuples = set()
219
+ for item in res:
220
+ timestamp = datetime.now()
221
+ if "recorded_timestamp" in item:
222
+ try:
223
+ timestamp = datetime.strptime(
224
+ item["recorded_timestamp"], "%Y-%m-%dT%H:%M:%S.%fZ"
225
+ )
226
+ except ValueError:
227
+ timestamp = datetime.strptime(
228
+ item["recorded_timestamp"], "%Y-%m-%dT%H:%M:%SZ"
229
+ )
230
+
231
+ timestamp_uuid_tuples.add(
232
+ (timestamp, item["imaged_moment_uuid"])
233
+ )
234
+
235
+ # Sort by timestamp, then UUID
236
+ timestamp_uuid_tuples = sorted(timestamp_uuid_tuples)
237
+ imaged_moment_uuids = [item[1] for item in timestamp_uuid_tuples]
238
+
239
+ # Set the UUIDs and load the first page
240
+ self.search_panel.set_uuids(imaged_moment_uuids)
241
+ self.search_panel.load_page()
242
+ else:
243
+ # No results, warning dialog
244
+ QMessageBox.warning(
245
+ self,
246
+ "No Results",
247
+ "No results found for video reference UUID: {}".format(
248
+ video_reference_uuid
249
+ ),
250
+ )
251
+
252
+ search_video_reference_action = QAction("Video Reference UUID", search_menu)
253
+ search_video_reference_action.triggered.connect(search_video_reference)
254
+ search_menu.addAction(search_video_reference_action)
255
+
256
+ def add_video_menu(self):
257
+ """
258
+ Add the Video menu for video-level operations
259
+ """
260
+ main_menu = self.menuBar()
261
+ video_menu = main_menu.addMenu("&Video")
262
+
263
+ def open_video():
264
+ self.search_panel.open_video()
265
+
266
+ open_video_action = QAction("Open Video", video_menu)
267
+ open_video_action.triggered.connect(open_video)
268
+ video_menu.addAction(open_video_action)
269
+
270
+ def closeEvent(self, a0: QCloseEvent) -> None:
271
+ """
272
+ Detect window close and tear down components
273
+ :param a0: Close event
274
+ :return: None
275
+ """
276
+ self.deleteLater()
@@ -0,0 +1,243 @@
1
+ """
2
+ Bounding box data structure and manager helper class.
3
+ """
4
+
5
+ import typing
6
+
7
+ from PyQt6.QtCore import Qt, QRectF, QPoint, QSizeF, QRect, QPointF
8
+ from PyQt6.QtGui import QColor, QPainter, QPen, QFont
9
+ from PyQt6.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QWidget
10
+
11
+ from vars_localize.util import m3, utils
12
+
13
+
14
+ class SourceBoundingBox(QRect):
15
+ """Bounding box VARS source data structure"""
16
+
17
+ def __init__(
18
+ self,
19
+ box_json,
20
+ label,
21
+ observer=None,
22
+ observation_uuid=None,
23
+ association_uuid=None,
24
+ part=None,
25
+ ):
26
+ super(SourceBoundingBox, self).__init__(
27
+ box_json["x"], box_json["y"], box_json["width"], box_json["height"]
28
+ )
29
+ self.image_reference_uuid = box_json.get("image_reference_uuid", None)
30
+ self.observation_uuid = observation_uuid
31
+ self.association_uuid = association_uuid
32
+ self.part = part
33
+ self.label = label
34
+ self.observer = observer
35
+
36
+ def set_label(self, label):
37
+ if label in m3.get_all_concepts():
38
+ self.label = label
39
+
40
+ def get_json(self):
41
+ d = {
42
+ "x": self.x(),
43
+ "y": self.y(),
44
+ "width": self.width(),
45
+ "height": self.height(),
46
+ "generator": "vars-localize",
47
+ "image_reference_uuid": self.image_reference_uuid,
48
+ }
49
+
50
+ if self.observer is not None:
51
+ d["observer"] = self.observer
52
+
53
+ return d
54
+
55
+
56
+ class GraphicsBoundingBox(QGraphicsItem):
57
+ """Graphical bounding box representation"""
58
+
59
+ def __init__(self, source: SourceBoundingBox, editable: bool = True):
60
+ super(GraphicsBoundingBox, self).__init__()
61
+
62
+ self.source = source
63
+ self.editable = editable
64
+
65
+ self.width = 0
66
+ self.height = 0
67
+ self.label = None
68
+ self.highlighted = False
69
+ self.color = QColor(0, 0, 0)
70
+
71
+ def set_box(self, x, y, w, h):
72
+ """
73
+ Update box position and dimensions
74
+ :param x: x position
75
+ :param y: y position
76
+ :param w: Width
77
+ :param h: Height
78
+ :return: None
79
+ """
80
+ self.prepareGeometryChange()
81
+ self.setPos(x, y)
82
+ self.width = w
83
+ self.height = h
84
+
85
+ def set_label(self, label):
86
+ """
87
+ Set the label of the bounding box
88
+ :param label: Bounding box label
89
+ :return: None
90
+ """
91
+ self.label = label
92
+ if self.editable:
93
+ self.color.setHsv(*utils.n_split_hash(label, 1), 255, 255)
94
+ else:
95
+ # If not editable, set color to gray with 50% opacity
96
+ self.color.setHsv(0, 0, 128, alpha=128)
97
+
98
+ def set_highlighted(self, highlighted: bool):
99
+ """
100
+ Set the highlight status of the bounding box
101
+ :param highlighted: Highlight on or off
102
+ :return: None
103
+ """
104
+ self.highlighted = highlighted
105
+
106
+ def area(self):
107
+ """
108
+ Compute the area of the box
109
+ :return: Box area
110
+ """
111
+ return self.width * self.height
112
+
113
+ def boundingRect(self) -> QRectF:
114
+ """
115
+ Give the bounding rectangle of the graphics item
116
+ :return: Bounding rectangle of box (not including label)
117
+ """
118
+ return QRectF(QPointF(0, 0), QSizeF(self.width, self.height))
119
+
120
+ def contains(self, pt: QPoint):
121
+ """
122
+ Check if the given point lies within the bounding box
123
+ :param pt: Point to check
124
+ :return: True if point within box, else False
125
+ """
126
+ return (
127
+ self.x() <= pt.x() <= self.x() + self.width
128
+ and self.y() <= pt.y() <= self.y() + self.height
129
+ )
130
+
131
+ def paint(
132
+ self,
133
+ painter: QPainter,
134
+ option: QStyleOptionGraphicsItem,
135
+ widget: typing.Optional[QWidget] = ...,
136
+ ) -> None:
137
+ """
138
+ Paint the item within the scene
139
+ :param painter: Painter object
140
+ :param option: Style object
141
+ :param widget: Optional widget
142
+ :return:
143
+ """
144
+ pen = QPen(self.color.lighter(), 4 if self.highlighted else 2)
145
+ painter.setPen(pen)
146
+ painter.drawRect(0, 0, int(self.width), int(self.height))
147
+
148
+ painter.setFont(QFont("Helvetica", 12, QFont.Weight.Bold))
149
+ draw_text = self.label if self.label else "No label"
150
+ if self.source.part is not None and self.source.part != "self":
151
+ draw_text += " " + self.source.part
152
+ painter.drawText(
153
+ 0,
154
+ int(self.height),
155
+ int(self.width),
156
+ 20,
157
+ Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextDontClip,
158
+ draw_text,
159
+ )
160
+
161
+
162
+ class BoundingBoxManager:
163
+ """Manages a list of graphical bounding box objects"""
164
+
165
+ def __init__(self, bounding_boxes: list = None):
166
+ if bounding_boxes:
167
+ self.bounding_boxes = bounding_boxes
168
+ else:
169
+ self.bounding_boxes = []
170
+
171
+ self.box_click_callback = None
172
+ self.box_right_click_callback = None
173
+
174
+ def make_box(self, x, y, w, h, label, src, editable: bool = True):
175
+ """
176
+ Create a box and add it to the manager
177
+ :param x: x position
178
+ :param y: y position
179
+ :param w: Width
180
+ :param h: Height
181
+ :param label: Bounding box label
182
+ :param src: Source bounding box
183
+ :param editable: Whether the box is editable
184
+ :return: Graphical bounding box item
185
+ """
186
+ box = GraphicsBoundingBox(src, editable=editable)
187
+ box.set_box(x, y, w, h)
188
+ box.set_label(label)
189
+ self.bounding_boxes.append(box)
190
+ return box
191
+
192
+ def set_box_click_callback(self, func):
193
+ """
194
+ Set the callback function for when the box is clicked
195
+ :param func: Callback function
196
+ :return: None
197
+ """
198
+ self.box_click_callback = func
199
+
200
+ def set_box_right_click_callback(self, func):
201
+ """
202
+ Set the callback function for when the box is clicked
203
+ :param func: Callback function
204
+ :return: None
205
+ """
206
+ self.box_right_click_callback = func
207
+
208
+ def check_box_click(self, pt: QPoint, right_click: bool):
209
+ """
210
+ Check managed boxes for point containment, process callbacks
211
+ :param pt: Point to process
212
+ :return: None
213
+ """
214
+ selected_box = None
215
+ for box in self.bounding_boxes:
216
+ if box.contains(pt) and box.editable:
217
+ if not selected_box or box.area() < selected_box.area():
218
+ selected_box = box
219
+ if self.box_click_callback:
220
+ if selected_box:
221
+ if right_click:
222
+ self.box_right_click_callback(selected_box)
223
+ else:
224
+ self.box_click_callback(selected_box)
225
+
226
+ def get_box_hovered(self, pt: QPoint):
227
+ """
228
+ Check managed boxes for point containment, return hovered box if any
229
+ :param pt: Point to process
230
+ :return: Hovered box, if any
231
+ """
232
+ hovered_box = None
233
+ for box in self.bounding_boxes:
234
+ if box.contains(pt) and box.editable:
235
+ if not hovered_box or box.area() < hovered_box.area():
236
+ hovered_box = box
237
+ return hovered_box
238
+
239
+ def boxes(self):
240
+ return self.bounding_boxes
241
+
242
+ def clear(self):
243
+ self.bounding_boxes.clear()
@@ -0,0 +1,33 @@
1
+ """
2
+ Custom QListWidgetItem for displaying concept information.
3
+ """
4
+
5
+ from PyQt6.QtCore import Qt
6
+ from PyQt6.QtGui import QFont
7
+ from PyQt6.QtWidgets import QListWidgetItem
8
+
9
+
10
+ class ConceptEntry(QListWidgetItem):
11
+ def __init__(self, data=None, parent=None):
12
+ super(ConceptEntry, self).__init__(parent)
13
+
14
+ self.setFont(QFont("Courier New"))
15
+ self.setTextAlignment(Qt.AlignmentFlag.AlignHCenter)
16
+
17
+ self.ann_data = data
18
+ if data:
19
+ self.update_message()
20
+
21
+ def set_ann_data(self, data):
22
+ self.ann_data = data
23
+
24
+ def get_data(self):
25
+ return self.ann_data
26
+
27
+ def update_message(self):
28
+ """
29
+ Set message based on annotation data
30
+ :return: None
31
+ """
32
+ str_rep = "{0}: {1}".format(self.ann_data["concept"], self.ann_data["timecode"])
33
+ self.setText(str_rep)
@@ -0,0 +1,29 @@
1
+ """
2
+ Custom QLineEdit widget for searching concepts.
3
+ """
4
+
5
+ from PyQt6.QtCore import Qt, pyqtSignal
6
+ from PyQt6.QtWidgets import QLineEdit, QCompleter
7
+
8
+ from vars_localize.util import m3
9
+
10
+
11
+ class ConceptSearchbar(QLineEdit):
12
+ conceptSelected = pyqtSignal()
13
+
14
+ def __init__(self, parent=None):
15
+ super(ConceptSearchbar, self).__init__(parent)
16
+
17
+ self.setPlaceholderText("Search for concept")
18
+
19
+ self.concept_completer = QCompleter(m3.get_all_concepts())
20
+ self.concept_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
21
+ self.setCompleter(self.concept_completer)
22
+
23
+ def set_callback(self, func):
24
+ """
25
+ Set callback on completer activation (concept selected)
26
+ :param func: Activation callback
27
+ :return: None
28
+ """
29
+ self.concept_completer.activated.connect(func)
@@ -0,0 +1,28 @@
1
+ """
2
+ Container widget used do display images + localizations and process input.
3
+ """
4
+
5
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout
6
+
7
+ from vars_localize.ui.ImageView import ImageView
8
+ from vars_localize.ui.EntryTree import EntryTreeItem
9
+
10
+
11
+ class DisplayPanel(QWidget):
12
+ def __init__(self, parent=None):
13
+ super(DisplayPanel, self).__init__(parent)
14
+
15
+ self.setLayout(QVBoxLayout())
16
+
17
+ self.image_view = ImageView(parent=self)
18
+
19
+ self.layout().addWidget(self.image_view, stretch=1)
20
+
21
+ def load_entry(self, entry: EntryTreeItem):
22
+ """
23
+ Load an entry into the image view, redraw
24
+ :param entry: Concept entry
25
+ :return: None
26
+ """
27
+ self.image_view.set_entry(entry)
28
+ self.image_view.redraw()