Skip to content

Commit 65804d1

Browse files
authored
Merge pull request #222 from bbean23/221-code-feature-tool-for-quickly-annotating-images
221 adding a simple tool to quickly annotate regions in an image
2 parents eb71369 + 50ffb82 commit 65804d1

File tree

8 files changed

+1007
-0
lines changed

8 files changed

+1007
-0
lines changed

contrib/app/SimpleGui/ImageAnnotator.py

Lines changed: 582 additions & 0 deletions
Large diffs are not rendered by default.
879 Bytes
Loading
889 Bytes
Loading
870 Bytes
Loading
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
from abc import ABC, abstractmethod
2+
3+
import tkinter
4+
from typing import Callable, Optional
5+
6+
import opencsp.common.lib.file.SimpleCsv as scsv
7+
import opencsp.common.lib.geometry.Pxy as p2
8+
import opencsp.common.lib.tool.exception_tools as et
9+
import opencsp.common.lib.tool.file_tools as ft
10+
import opencsp.common.lib.tool.log_tools as lt
11+
12+
13+
class AbstractImageAnnotation(ABC):
14+
"""Simple annotations that get displayed on top of images."""
15+
16+
_registered_annotation_classes: set[type["AbstractImageAnnotation"]] = set()
17+
""" Register of all available simple annotation classes. """
18+
19+
def __init__(self, is_preview=False):
20+
"""
21+
Parameters
22+
----------
23+
is_preview : bool, optional
24+
True if this instance is waiting to be finished (such as when
25+
drawing a line and the mouse button hasn't been released yet). False
26+
otherwise. By default False.
27+
"""
28+
self.is_preview = is_preview
29+
"""
30+
True if this instance is waiting to be finished (such as when drawing a
31+
line and the mouse button hasn't been released yet). False otherwise.
32+
"""
33+
self.canvas: tkinter.Canvas = None
34+
""" The canvas instance on which to draw this instance. """
35+
self._canvas_items: list[int] = []
36+
""" Handles to the canvas items used to draw this instance. """
37+
38+
@property
39+
def canvas_items(self) -> list[int]:
40+
"""List of handles to the graphics items on the canvas."""
41+
return self._canvas_items
42+
43+
def clear(self):
44+
"""Removes all graphics representing this instance from the canvas."""
45+
for canvas_item in self.canvas_items:
46+
with et.ignored(Exception):
47+
self.canvas.delete(canvas_item)
48+
self.canvas_items.clear()
49+
50+
@classmethod
51+
def on_mouse_down(
52+
cls, coord_translator: Callable[[p2.Pxy], p2.Pxy], mouse_down_event: tkinter.Event
53+
) -> Optional["AbstractImageAnnotation"]:
54+
"""
55+
Creates an instance of this class when the mouse button is pressed. If
56+
no instance is created, then return None.
57+
58+
Parameters
59+
----------
60+
coord_translator : Callable[[p2.Pxy], p2.Pxy]
61+
Function to translate from event x and y coordinates to image coordinates.
62+
"""
63+
return None
64+
65+
@classmethod
66+
def on_mouse_move(
67+
cls,
68+
coord_translator: Callable[[p2.Pxy], p2.Pxy],
69+
mouse_down_event: tkinter.Event | None,
70+
mouse_move_event: tkinter.Event,
71+
) -> Optional["AbstractImageAnnotation"]:
72+
"""
73+
Creates an instance of this class when the mouse is moved. If no
74+
instance is created, then return None.
75+
76+
Parameters
77+
----------
78+
coord_translator : Callable[[p2.Pxy], p2.Pxy]
79+
Function to translate from event x and y coordinates to image coordinates.
80+
"""
81+
return None
82+
83+
@classmethod
84+
def on_mouse_up(
85+
cls,
86+
coord_translator: Callable[[p2.Pxy], p2.Pxy],
87+
mouse_down_event: tkinter.Event | None,
88+
mouse_up_event: tkinter.Event,
89+
) -> Optional["AbstractImageAnnotation"]:
90+
"""
91+
Creates an instance of this class when the mouse button is pressed. If
92+
no instance is created, then return None.
93+
94+
Parameters
95+
----------
96+
coord_translator : Callable[[p2.Pxy], p2.Pxy]
97+
Function to translate from event x and y coordinates to image coordinates.
98+
"""
99+
return None
100+
101+
@staticmethod
102+
def save_annotations_to_csv(annotations: list["AbstractImageAnnotation"], file_path_name_ext: str, overwrite=False):
103+
"""
104+
Saves the given list of simple annotations to the given CSV file using
105+
each annotations built-in CSV conversion methods.
106+
107+
Parameters
108+
----------
109+
annotations : list[AbstractAnnotation]
110+
The annotations to be saved.
111+
file_path_name_ext : str
112+
The CSV file to be saved to.
113+
overwrite : bool, optional
114+
True to replace the current contents of the CSV file at
115+
file_path_name_ext, by default False
116+
117+
Raises
118+
------
119+
FileExistsError
120+
If file_path_name_ext exists and overwrite is False.
121+
FileNotFoundError
122+
If the directory of file_path_name_ext doesn't exist.
123+
"""
124+
if ft.file_exists(file_path_name_ext):
125+
if not overwrite:
126+
raise FileExistsError
127+
file_path, file_name, file_ext = ft.path_components(file_path_name_ext)
128+
if not ft.directory_exists(file_path):
129+
raise FileNotFoundError
130+
131+
# build the list of columns
132+
columns: list[str] = ["class"]
133+
for annotation in annotations:
134+
for aheader in annotation.csv_columns():
135+
if aheader not in columns:
136+
columns.append(aheader)
137+
header = ",".join(columns)
138+
139+
# add a row for each annotation
140+
rows: list[str] = []
141+
for annotation in annotations:
142+
row = [""] * len(columns)
143+
row[0] = annotation.class_descriptor()
144+
for aheader, sval in zip(annotation.csv_columns(), annotation.csv_values()):
145+
row[columns.index(aheader)] = sval
146+
rows.append(",".join(row))
147+
148+
# save all values to a csv file
149+
lt.info(f"Saving annotations csv {file_name+file_ext}")
150+
with open(file_path_name_ext, "w") as fout:
151+
fout.write(header + "\n")
152+
for row in rows:
153+
fout.write(row + "\n")
154+
155+
@staticmethod
156+
def load_annotations_from_csv(file_path_name_ext: str, is_preview=False) -> list["AbstractImageAnnotation"]:
157+
"""
158+
Loads simple annotations from the given CSV file.
159+
160+
Parameters
161+
----------
162+
file_path_name_ext: str
163+
The CSV file to load the annotations from.
164+
165+
Returns
166+
-------
167+
annotations: list[AbstractAnnotation]
168+
The loaded annotations.
169+
"""
170+
ret: list[AbstractImageAnnotation] = []
171+
file_path, file_name, file_ext = ft.path_components(file_path_name_ext)
172+
173+
parser = scsv.SimpleCsv("annotations csv", file_path, file_name + file_ext)
174+
for row_dict in parser:
175+
descriptor = row_dict["class"]
176+
177+
for aclass in AbstractImageAnnotation._registered_annotation_classes:
178+
if aclass.class_descriptor() == descriptor:
179+
aheaders = aclass.csv_columns()
180+
svals = [row_dict[aheader] for aheader in aheaders]
181+
inst = aclass.from_csv(svals, is_preview)
182+
ret.append(inst)
183+
break
184+
185+
return ret
186+
187+
@abstractmethod
188+
def draw(self, coord_translator: Callable[[p2.Pxy], p2.Pxy], canvas: tkinter.Canvas):
189+
"""
190+
Adds the graphics to represent this instance to the canvas. Modifies self.canvas_items.
191+
192+
Implementations of this class should call super().draw().
193+
194+
Parameters
195+
----------
196+
coord_translator : Callable[[p2.Pxy], p2.Pxy]
197+
Function to translate from image coordinates to screen coordinates.
198+
canvas : tkinter.Canvas
199+
The canvas to draw this instance onto.
200+
"""
201+
self.clear()
202+
self.canvas = canvas
203+
204+
@classmethod
205+
@abstractmethod
206+
def class_descriptor(self) -> str:
207+
"""A string used to identify this class in an annotations file."""
208+
raise NotImplementedError
209+
210+
@classmethod
211+
@abstractmethod
212+
def csv_columns(cls) -> list[str]:
213+
"""
214+
The names of the columns used to represent this annotation.
215+
216+
These column names can potentially be shared with other annotations.
217+
"""
218+
raise NotImplementedError
219+
220+
@abstractmethod
221+
def csv_values(self) -> list[str]:
222+
"""Get the values that represent this instance. Should match the order from csv_columns."""
223+
raise NotImplementedError
224+
225+
@classmethod
226+
@abstractmethod
227+
def from_csv(cls, data: list[str], is_preview=False) -> "AbstractImageAnnotation":
228+
"""Construct an instance of this class from the columns matching the column names for this class."""
229+
raise NotImplementedError()
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import tkinter
2+
from typing import Callable, Optional
3+
4+
from contrib.app.SimpleGui.lib.AbstractImageAnnotation import AbstractImageAnnotation
5+
import opencsp.common.lib.geometry.Pxy as p2
6+
7+
8+
class PointImageAnnotation(AbstractImageAnnotation):
9+
"""
10+
A simple annotation that indicates a pixel point on an image.
11+
"""
12+
13+
def __init__(self, point: p2.Pxy, is_preview=False):
14+
"""
15+
Create a point annotation to be drawn onto a canvas instance.
16+
17+
Parameters
18+
----------
19+
point : p2.Pxy
20+
The pixel location of this instance.
21+
"""
22+
super().__init__(is_preview)
23+
24+
# register inputs
25+
self.point = point
26+
27+
def draw(self, coord_translator: Callable[[p2.Pxy], p2.Pxy], canvas: tkinter.Canvas):
28+
"""Adds the graphics to represent this instance to the canvas. Modifies self.canvas_items."""
29+
super().draw(coord_translator, canvas)
30+
x, y = coord_translator(self.point).astuple()
31+
x0, y0, x1, y1 = x - 3, y - 3, x + 3, y + 3
32+
self.canvas_items.append(self.canvas.create_oval(x0, y0, x1, y1, outline="magenta"))
33+
34+
@classmethod
35+
def on_mouse_down(
36+
cls, coord_translator: Callable[[p2.Pxy], p2.Pxy], mouse_down_event: tkinter.Event
37+
) -> Optional["AbstractImageAnnotation"]:
38+
"""Creates an instance of this class when the mouse is moved. If no instance is created, then return None."""
39+
mouse_down_loc = coord_translator(p2.Pxy((mouse_down_event.x, mouse_down_event.y)))
40+
return cls(mouse_down_loc, is_preview=True)
41+
42+
@classmethod
43+
def on_mouse_move(
44+
cls,
45+
coord_translator: Callable[[p2.Pxy], p2.Pxy],
46+
mouse_down_event: tkinter.Event | None,
47+
mouse_move_event: tkinter.Event,
48+
) -> Optional["AbstractImageAnnotation"]:
49+
"""Creates an instance of this class when the mouse is moved. If no instance is created, then return None."""
50+
mouse_move_loc = coord_translator(p2.Pxy((mouse_move_event.x, mouse_move_event.y)))
51+
return cls(mouse_move_loc, is_preview=True)
52+
53+
@classmethod
54+
def on_mouse_up(
55+
cls,
56+
coord_translator: Callable[[p2.Pxy], p2.Pxy],
57+
mouse_down_event: tkinter.Event | None,
58+
mouse_up_event: tkinter.Event,
59+
) -> Optional["AbstractImageAnnotation"]:
60+
"""Creates an instance of this class when the mouse button is pressed. If no instance is created, then return None."""
61+
mouse_up_loc = coord_translator(p2.Pxy((mouse_up_event.x, mouse_up_event.y)))
62+
return cls(mouse_up_loc, is_preview=False)
63+
64+
@classmethod
65+
def class_descriptor(self) -> str:
66+
return "point"
67+
68+
@classmethod
69+
def csv_columns(cls) -> list[str]:
70+
"""
71+
The names of the columns used to represent this annotation.
72+
73+
These column names can potentially be shared with other annotations.
74+
"""
75+
return ["p1x", "p1y"]
76+
77+
def csv_values(self) -> list[str]:
78+
"""Get the values that represent this instance. Should match the order from csv_columns."""
79+
x, y = self.point.x[0], self.point.y[0]
80+
return [str(x), str(y)]
81+
82+
@classmethod
83+
def from_csv(cls, data: list[str], is_preview=False) -> tuple["AbstractImageAnnotation"]:
84+
"""Construct an instance of this class from the columns matching the column names for this class."""
85+
x, y = float(data[0]), float(data[1])
86+
return cls(p2.Pxy((x, y)), is_preview)
87+
88+
89+
# register this class with AbstractAnnotation, so that it can be created from
90+
# various triggers.
91+
AbstractImageAnnotation._registered_annotation_classes.add(PointImageAnnotation)

0 commit comments

Comments
 (0)