Commit 98a3fe5c authored by André Anjos's avatar André Anjos 💬
Browse files

Initial commit

This diff is collapsed.
include README.rst buildout.cfg requirements.txt version.txt
recursive-include doc *.py *.rst *.png *.ico
recursive-include bob/ip/annotator/data *.jpg
Image Key Point Annotation Tool
A small TkInter-based keypoint annotation tool for images written in Python.
Follow our `binary installation
<>`_ instructions. Then,
using the Python interpreter inside that distribution, bootstrap and buildout
this package::
$ python
$ ./bin/buildout
Annotation format
The annotation file, result of annotating an image, contains points in the
``(y,x)`` (Bob-style) format. Every line corresponds to one point annotated on
the original image.
To start annotating, just pass a single image to the ``./bin/``
program. A window will open and allow you to annotate a variable number of
points in it. The output of the annotation will be placed on the current
directory, with the same filename as the original input file::
$ bin/ example/image.jpg
After a successful annotation session, the file ``image.txt`` should be
available with all the points you have touched during the annotation process.
Available keyboard shortcuts
During the annotation process, on the image window, you can use the following
keyboard shortcuts to optimise your work.
* ``?``: this help message
* ``x``: places keypoint under mouse cursor
* ``d``: deletes the last annotation
* ``s``: saves current annotations
* ``q``: quits the application, saving annotations
* ``D``: deletes annotations for the current image
* ``<Esc>`` Quits the application, does not save anything
You can use the mouse to either drag-and-drop keypoints or move the closest
keypoint to the clicked location.
# see
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
# see
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
HELP_MESSAGE = """A TkInter-based keypoint annotation tool for images
This tool treats any number of image inputs, one after the other. As images are
loaded, annotations for the previous image are saved on a text file, following
a simple format (y,x) keeping the order in which they where inserted.
Keyboard shortcuts
?: this help message
a: places new annotation under mouse cursor
d: deletes the last annotation
D: deletes all annotations
n: moves to the next image
p: moves to the previous image
s: saves current annotations
q: quits the application, saving annotations for the current image
<Esc>: Quits the application, does not save anything
Annotation displacement
h | Left: move last annotation by 1 pixel to the left
l | Right: move last annotation by 1 pixel to the right
k | Up: move last annotation by 1 pixel up
j | Down: move last annotation by 1 pixel down
Shift + movement: move last annotation by 5 pixels on that direction
import os
import sys
import time
from operator import itemgetter
import Tkinter as tkinter
from PIL import Image, ImageTk
import numpy.linalg
from . import io as annotator_io
import logging
logger = logging.getLogger()
COLOR_ACTIVE = "yellow"
SHIFT = 0x0001
def _uniq(seq, idfun=None):
'''Order preserving uniq for lists
if idfun is None:
def idfun(x): return x
seen = {}
result = []
for item in seq:
marker = idfun(item)
if marker in seen: continue
seen[marker] = 1
return result
class HelpDialog(tkinter.Toplevel):
def __init__(self, parent, message):
tkinter.Toplevel.__init__(self, parent)
self.parent = parent
self.result = None
body = tkinter.Frame(self, width=100, height=400)
# Now build the dialog geometry
buttonbox = tkinter.Frame(body, height=20)
w = tkinter.Button(buttonbox, text="Dismiss", command=self.on_dismiss,
self.bind("<Return>", self.on_dismiss)
self.bind("<Escape>", self.on_dismiss)
textbox = tkinter.Frame(body, height=380)
self.initial_focus = t = tkinter.Text(textbox)
t.insert(tkinter.INSERT, message) #fill in contents
scrollbar = tkinter.Scrollbar(textbox)
scrollbar.pack(side=tkinter.RIGHT, fill=tkinter.Y)
body.pack(padx=5, pady=5)
if not self.initial_focus: self.initial_focus = self
self.protocol("WM_DELETE_WINDOW", self.on_dismiss)
self.geometry("+%d+%d" % (parent.winfo_rootx()+50,
def on_dismiss(self, event=None):
# put focus back to the parent window
class AnnotatorApp(tkinter.Tk):
"""A wrapper for the annotation application
filelist (list): A list of paths to images that will be annotated
outputdir (str): A path that will be used as the base directory for the
recording the annotations. If this directory does not exist, it will be
zoom (float, Optional): The zoom level for the image. In case it is greater
than 1.0, then the image will be zoomed in. Otherwise, it will be zoomed
out. Annotations will be take relatively to the original image.
marker_radius (int, Optional): The number of pixels in the original image
each annotation marker will occupy.
pixel_skip (int, Optional): The number of pixels skipped every time the
user uses a motion key with the Shift key pressed.
def __init__(self, filelist, outputdir, zoom=1.0, marker_radius=5,
pixel_skip=5, *args, **kwargs):
tkinter.Tk.__init__(self, *args, **kwargs)
# setup parameters
filelist = _uniq(filelist)
if len(filelist) == 1:
self.basedir = os.path.dirname(filelist[0])
self.basedir = os.path.commonprefix(filelist)
self.filelist = filelist
self.outputdir = outputdir
self.zoom = zoom
self.marker_radius = marker_radius
self.skip_factor = pixel_skip
# setup internal variables
self.curr_pos = 0 #the current position in the file list we're treating
self.curr_image = None #the actual pixels of the image in a PIL Image
self.curr_photo = None #the Tk widget containing the image
self.annotations = [] #points to the current frame
self.annotation_widgets = [] #the widgets related to the annotations
self.canvas = None #the main canvas
def zoom_compensated(self):
"""Returns zoom-compensated annotations"""
def rounded(x, y, z):
return (int(round(float(x)/z)), int(round(float(y)/z)))
return [rounded(x,y,self.zoom) for k in self.annotations for x,y in k]
def update_image(self):
"""Updates the image displayed"""
# loads the current image, creates the image
self.image = Image.fromarray([self.curr_pos]))
if self.canvas is None:
# creates the image canvas
self.canvas = tkinter.Canvas(self, width=self.image.width,
# creates the status bar - bellow the image canvas
self.bottom_frame = tkinter.Frame(self)
self.bottom_frame.pack(side=tkinter.BOTTOM, fill=tkinter.BOTH)
self.text_status = tkinter.StringVar()
self.label_status = tkinter.Label(self.bottom_frame,
# resizes the canvas
# set or replace the current frame image
self.curr_photo = ImageTk.PhotoImage(self.image, Image.ANTIALIAS)
self.canvas.itemconfig(self.curr_frame, image=self.curr_photo)
# show keypoints if they exist
candidate_annotation = os.path.join(self.outputdir,
if os.path.exists(candidate_annotation):
self.annotations = annotator_io.load(candidate_annotation)
self.annotations = []
# resize all dialog boxes by default to be 200px wide
self.option_add("*Dialog.msg.wrapLength", "200p")
# some keyboard and mouse bindings
# Capture closing the app -> use to save the file
self.protocol("WM_DELETE_WINDOW", self.on_quit)
self.bind("q", self.on_quit)
self.bind("<Escape>", self.on_quit_no_saving)
self.bind("D", self.remove_all_annotations)
def update_status_bar(self):
# updates the status bar
if not self.annotations:
annotated = '(no annotations)'
annotated = '(%d annotations loaded)' % len(self.annotations)
self.text_status.set('%s (%03d/%03d) - %s - press ? for help' % \
(path, self.curr_pos+1, len(self.filelist), annotated))
def cross(y, x, text):
"""Defines a cross + number in terms of a center and a radius"""
#points = (x, y-r, x, y+r, x, y, x-r, y, x+r, y, x, y)
w = self.marker_radius
w3 = 3*w;
points = (
x-w, y-w3,
x+w, y-w3,
x+w, y-w,
x+w3, y-w,
x+w3, y+w,
x+w, y+w,
x+w, y+w3,
x-w, y+w3,
x-w, y+w,
x-w3, y+w,
x-w3, y-w,
x-w, y-w,
# text - not modifiable for the color
t = self.canvas.create_text((x-w3, y-w3), anchor=tkinter.SE,
fill='black', tags="keypoint", state=tkinter.NORMAL,
justify=tkinter.RIGHT, text=text)
bbox = self.canvas.bbox(t)
self.canvas.itemconfig(t, state=tkinter.HIDDEN)
# background drop shadow
s = self.canvas.create_rectangle(bbox, fill=COLOR_INACTIVE,
tags="annotation", state=tkinter.HIDDEN)
# text on the top of the drop shadow
poly = self.canvas.create_polygon(points, outline='black',
fill=COLOR_INACTIVE, tags="keypoint", width=1.0, state=tkinter.HIDDEN)
# [0]: (polygon) activate-able (not hidden);
# [1]: (shadow) activate-able (hidden);
# [2]: (text) not activate-able (hidden);
return (poly, s, t)
def create_annotation_widgets(self):
"""Creates the annotation widgets (displays them as well)"""
self.annotation_widgets = []
for i, (y, x) in enumerate(self.annotations):
self.annotation_widgets.append(cross(y, x, str(i)))
def save(self, *args, **kwargs):
"""Action executed when we need to save the current annotations"""
stem = os.path.relpath(self.filelist[self.curr_pos], self.basedir)
output_path = os.path.join(self.outputdir, stem)
output_dir = os.path.dirname(output_path)
if not os.path.exists(output_dir):
if self.annotations:
file_save(self.zoom_compensated(), self.output_path, backup=True)
self.text_status.set('Saved to `%s\'' % output_path)
self.text_status.set('No annotations to save')
def on_quit_no_saving(self, *args, **kwargs):
"""On quit we either dump the output to screen or to a file."""
if self.annotations:
logger.warn("Lost annotations for %s", )
def on_quit(self, *args, **kwargs):
"""On quit we either dump the output to screen or to a file."""*args, **kwargs)
self.on_quit_no_saving(*args, **kwargs)
def on_help(self, event):
"""Creates a help dialog box with the currently enabled commands"""
dialog = HelpDialog(self, HELP_MESSAGE)
def change_frame(self, event):
"""Advances to the next or rewinds to the previous frame"""
move = 0
if event.keysym in ('n', 'N') or event.num in (4,):
self.curr_pos += 1
if self.curr_pos >= len(self.filelist):
self.curr_pos = len(self.filelist) - 1
self.text_status.set('[warning] cannot go beyond end')
if event.keysym in ('p', 'P') or event.num in (5,):
self.curr_pos -= 1
if self.curr_pos < 0:
self.text_status.set('[warning] cannot go beyond first image')
self.curr_pos = 0
def add_annotation(self, event):
"""Adds the given annotation position immediately"""
self.annotations.append((event.y, event.x))
self.annotation_widgets.append(self.cross(event.y, event.x,
def remove_last_annotation(self, event):
"""Removes the last annotation"""
for k in self.annotation_widgets.pop(): k.destroy()
def remove_all_annotations(self, event):
"""Delete current frame annotations and reset the view"""
while self.annotations: self.remove_last_annotation()
def on_show_labels(self, event):
"""Shows labels"""
for widgets in self.annotation_widgets:
for k in widgets[1:]:
self.canvas.itemconfig(k, state=tkinter.NORMAL)
def on_hide_labels(self, event):
"""Hide labels"""
for widgets in self.annotation_widgets:
for k in widgets[1:]:
self.canvas.itemconfig(o, state=tkinter.HIDDEN)
def move_last_annotation(self, event):
"""Moves the last annotated keypoint using the keyboard"""
# move the object the appropriate amount
dx, dy = (0, 0)
if event.keysym in ('Right', 'l', 'L'): dx = 1
elif event.keysym in ('Left', 'h', 'H'): dx = -1
elif event.keysym in ('Up', 'k', 'K'): dy = -1
elif event.keysym in ('Down', 'j', 'J'): dy = 1
if event.state & SHIFT: dx *= self.skip_factor; dy *= self.skip_factor
for k in self.annotation_widgets[-1]: self.canvas.move(k, dx, dy)
self.annotations[-1][1] += dy
self.annotations[-1][0] += dx
def add_keyboard_bindings(self):
"""Adds mouse bindings to the given widget"""
# add a given annotation (marked in white)
self.bind("a", self.add_annotation)
self.bind("d", self.remove_annotation)
self.bind("D", self.remove_all_annotations)
self.bind("n", self.change_frame)
self.bind("p", self.change_frame)
self.bind("N", self.change_frame)
self.bind("P", self.change_frame)
# motion keys - move frame or keypoint depending on keypoint focus
self.bind("<Right>", self.move_last_annotation)
self.bind("<Shift-Right>", self.move_last_annotation)
self.bind("<Left>", self.move_last_annotation)
self.bind("<Shift-Left>", self.move_last_annotation)
self.bind("<Up>", self.move_last_annotation)
self.bind("<Shift-Up>", self.move_last_annotation)
self.bind("<Down>", self.move_last_annotation)
self.bind("<Shift-Down>", self.move_last_annotation)
self.bind("h", self.move_last_annotation)
self.bind("H", self.move_last_annotation)
self.bind("l", self.move_last_annotation)
self.bind("L", self.move_last_annotation)
self.bind("k", self.move_last_annotation)
self.bind("K", self.move_last_annotation)
self.bind("j", self.move_last_annotation)
self.bind("J", self.move_last_annotation)
# show text labels with Alt pressed
self.bind("<KeyPress-Alt_L>", self.on_show_labels)
self.bind("<KeyRelease-Alt_L>", self.on_hide_labels)
self.bind("?", self.on_help)
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
# Andre Anjos <>
# Fri 29 Jun 2012 13:42:57 CEST
"""A set of utilities and library functions to handle keypoint annotations."""
import os
import six
def save(data, fp, backup=False):
"""Saves a given data set to a file
data (numpy.ndarray): A dictionary where the keys are frame numbers and the
values are lists of tuples indicating each of the keypoints in (x, y)
fp (File, str): The name of a file, with full path, to be used for recording
the data or an already opened file-like object, that accepts the "write()"
backup (boolean, Optional): If set, backs-up a possibly existing file path
before overriding it. Note this is not valid in case 'fp' above points to
an opened file.
if isinstance(fp, six.string_types):
if backup and os.path.exists(fp):
bname = fp + '~'
if os.path.exists(bname): os.unlink(bname)
os.rename(fp, bname)
fp = open(fp, 'wt')
return numpy.savetxt(fp, data, fmt='%d')
def load(fp):
"""Loads a given data set from file
fp (File, str): The name of a file, with full path, to be used for
reading the data or an already opened file-like object, that accepts
the "read()" call.
numpy.ndarray: Containing the matrix of loaded annotations, one per row, in
the format (y, x). That is Bob's style.