From ab6aafa42e8b82bbc9c5e2ab032e1f1c2cb90a7b Mon Sep 17 00:00:00 2001 From: Q Date: Sun, 7 Nov 2021 10:26:31 +0200 Subject: [PATCH] object format of the program --- tsmark/__init__.py | 2 +- tsmark/video_annotator.py | 638 +++++++++++++++++++++----------------- 2 files changed, 360 insertions(+), 280 deletions(-) diff --git a/tsmark/__init__.py b/tsmark/__init__.py index ec13cce..aa08269 100644 --- a/tsmark/__init__.py +++ b/tsmark/__init__.py @@ -1,3 +1,3 @@ from tsmark.video_annotator import main -VERSION = "0.1" +VERSION = "0.2" diff --git a/tsmark/video_annotator.py b/tsmark/video_annotator.py index 8c7a944..2e3f4ef 100755 --- a/tsmark/video_annotator.py +++ b/tsmark/video_annotator.py @@ -5,322 +5,402 @@ import time import argparse -def draw_time(frame, nr, fps, paused): - left = 10 - bottom = 30 - - formatted = "{} {}".format( - format_time(nr, fps), - "||" if paused else "", - ) - shadow_text(frame, formatted, (left, bottom), 1.1, 2, (255, 255, 255)) - - -def shadow_text(frame, text, pos, size, thicc, color): - font = cv2.FONT_HERSHEY_SIMPLEX - cv2.putText( - frame, - text, - pos, - font, - size, - (0, 0, 0), - 2 * thicc, - cv2.LINE_AA, - ) - cv2.putText( - frame, - text, - pos, - font, - size, - color, - thicc, - cv2.LINE_AA, - ) - - -def draw_bar(frame, nr, frames, fps, stamps): - - position = nr / frames - bar_start = int(frame.shape[1] * 0.05) - bar_end = int(frame.shape[1] * 0.95) - bar_position = int(bar_start + position * (bar_end - bar_start)) - top = int(frame.shape[0] * 0.90) - bottom = int(frame.shape[0] * 0.95) - - cv2.rectangle(frame, (bar_start, top), (bar_end, bottom), (255, 255, 255), 2) - - for ts in stamps: - ts_pos = int(bar_start + ts / frames * (bar_end - bar_start)) - cv2.line(frame, (ts_pos, top), (ts_pos, bottom), (32, 32, 32), 3) - cv2.line(frame, (ts_pos, top), (ts_pos, bottom), (84, 255, 63), 1) - # cv2.line(frame, (bar_position, top), (bar_position, bottom), (32,32,32), 3) - cv2.line(frame, (bar_position, top), (bar_position, bottom), (63, 84, 255), 1) - - shadow_text(frame, "1", (bar_start - 7, bottom + 20), 0.7, 2, (255, 255, 255)) - end_frame = format_time(frames - 1, fps) - (text_width, text_height) = cv2.getTextSize( - end_frame, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2 - )[0] - shadow_text( - frame, end_frame, (bar_end - text_width, bottom + 20), 0.7, 2, (255, 255, 255) - ) - - -def draw_label(frame, nr, stamps): - - if not nr in stamps: - return - - text = "{} #{}".format(nr + 1, stamps.index(nr) + 1) - bottom = 60 - left = 10 - shadow_text(frame, text, (left, bottom), 1, 2, (63, 84, 255)) - - -def calculate_step(step, last_move, video_length): - - now = time.time() - last_move = [x for x in last_move if x[1] > now - 3] - if len(last_move) == 0: - return 1, [] - lefts = sum([1 for x in last_move if x[0] == "l"]) - rights = sum([1 for x in last_move if x[0] == "r"]) - if lefts > 0 and rights > 0: - return 1, [] - count = max(lefts, rights) - if count < 5: - step = 1 - else: - # x2 poly from 5:5 -> 15:180 - step = 45 - 16.5 * count + 1.7 * count * count - step = min(step, 0.1 * video_length) - return int(step), last_move - - -def format_time(nframe, fps): - - seconds = int(nframe / fps) - frame = nframe % fps - parts = int(100 * (frame / fps)) - return time.strftime("%H:%M:%S", time.gmtime(seconds)) + ".%02d" % (parts) - - -def parse_time(timestr, fps): - """return frames""" - - colon_count = len(timestr.split(":")) - 1 - if colon_count == 0: - secs = float(timestr) - return int(secs * fps) - if colon_count == 1: - mins, secstr = timestr.split(":", 1) - sec = float(secstr) - return int(fps * (int(mins) * 60 + sec)) - if colon_count == 2: - hours, mins, secstr = timestr.split(":", 2) - sec = float(secstr) - return int(fps * (int(hours) * 3600 + int(mins) * 60 + sec)) - raise ValueError("Cannot parse time definition {}".format(timestr)) - - -def print_timestamps(stamps, fps, filename): - stamps.sort() - print("# Timestamps:") - for i, ts in enumerate(stamps): - print("# {}: {} / {}".format(i + 1, format_time(ts, fps), ts + 1)) - if len(stamps) > 0: - print( - "ffmpeg -i '{}' -ss {} -to {} -c copy trimmed.mp4".format( - filename, - format_time(stamps[0], fps), - format_time(stamps[-1], fps), - ) - ) - - -def get_options(): - - parser = argparse.ArgumentParser(description="Video timestamping tool") - parser.add_argument( - "--ts", - action="store", - dest="timestamps", - default=None, - required=False, - help="Comma separated list of predefined timestamps, in frame numbers, or HH:MM:SS.FF", - ) - parser.add_argument(action="store", dest="video") - return parser.parse_args() - - -def calculate_res(max_res, video_reader): - - video_res = [ - int(video_reader.get(cv2.CAP_PROP_FRAME_WIDTH)), - int(video_reader.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ] - video_aspect = video_res[0] / video_res[1] - if video_res[0] > max_res[0]: - video_res[0] = int(max_res[0]) - video_res[1] = int(video_res[0] / video_aspect) - - if video_res[1] > max_res[1]: - video_res[1] = int(max_res[1]) - video_res[0] = int(video_res[1] * video_aspect) - return tuple(video_res) - - -def print_help(): - print( - """Keyboard help: - -Arrows left and right, Home, End - jump in video. Tap frequently to increase time step -, and . move one frame at a time -z and c move to previous or next mark -x mark frame -space pause -i toggle HUD -q quit -""" - ) - - -def main(): - try: - opts = get_options() - if not os.path.exists(opts.video): +class Marker: + def __init__(self): + self.get_options() + if not os.path.exists(self.opts.video): raise FileNotFoundError("Video file missing!") - max_res = (1280, 720) - video_reader = cv2.VideoCapture(opts.video) - frames = int(video_reader.get(cv2.CAP_PROP_FRAME_COUNT)) - fps = video_reader.get(cv2.CAP_PROP_FPS) - spf = 1.0 / fps - video_res = calculate_res(max_res, video_reader) - if opts.timestamps: - stamps = sorted( - [parse_time(ts.strip(), fps) for ts in opts.timestamps.split(",")] - ) - stamps = [x for x in stamps if 0 <= x < frames] - nr = stamps[0] + self.paused = False + self.read_next = False + self.show_info = True + self.auto_step = True + self.font = cv2.FONT_HERSHEY_SIMPLEX + self.frame_visu = [] + self.max_res = (1280, 720) + + try: + self.open() + self.calculate_res() + self.parse_timestamps() + self.loop() + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + print(exc_type, fname, exc_tb.tb_lineno) + raise e + + def open(self): + + self.video_reader = cv2.VideoCapture(self.opts.video) + self.frames = int(self.video_reader.get(cv2.CAP_PROP_FRAME_COUNT)) + self.fps = self.video_reader.get(cv2.CAP_PROP_FPS) + self.spf = 1 / self.fps + self.video_length = self.frames * self.fps + + def calculate_res(self): + + self.video_res = [ + int(self.video_reader.get(cv2.CAP_PROP_FRAME_WIDTH)), + int(self.video_reader.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ] + video_aspect = self.video_res[0] / self.video_res[1] + if self.video_res[0] > self.max_res[0]: + self.video_res[0] = int(self.max_res[0]) + self.video_res[1] = int(self.video_res[0] / video_aspect) + + if self.video_res[1] > self.max_res[1]: + self.video_res[1] = int(self.max_res[1]) + self.video_res[0] = int(self.video_res[1] * video_aspect) + self.video_res = tuple(self.video_res) + self.bar_start = int(self.video_res[0] * 0.05) + self.bar_end = int(self.video_res[0] * 0.95) + self.bar_top = int(self.video_res[1] * 0.90) + self.bar_bottom = int(self.video_res[1] * 0.95) + + def calculate_step(self): + + now = time.time() + self.last_move = [x for x in self.last_move if x[1] > now - 3] + if len(self.last_move) == 0: + self.step = 1 + self.last_move = [] + return + lefts = sum([1 for x in self.last_move if x[0] == "l"]) + rights = sum([1 for x in self.last_move if x[0] == "r"]) + if lefts > 0 and rights > 0: + self.step = 1 + self.last_move = [] + return + count = max(lefts, rights) + if count < 5: + self.step = 1 else: - stamps = [] - nr = 0 + # x2 poly from 5:5 -> 15:180 + self.step = 45 - 16.5 * count + 1.7 * count * count + self.step = min(self.step, 0.1 * self.video_length) + self.step = int(self.step) - paused = False - read_next = False - show_info = True - frame_visu = [] - step = 1 - last_move = [] - video_length = frames * fps - auto_step = True - video_reader.set(cv2.CAP_PROP_POS_FRAMES, nr) + def draw_bar(self, frame): - print_help() + position = self.nr / self.frames + bar_position = int(self.bar_start + position * (self.bar_end - self.bar_start)) - while video_reader.isOpened(): + cv2.rectangle( + frame, + (self.bar_start, self.bar_top), + (self.bar_end, self.bar_bottom), + (255, 255, 255), + 2, + ) + + for ts in self.stamps: + ts_pos = int( + self.bar_start + ts / self.frames * (self.bar_end - self.bar_start) + ) + cv2.line( + frame, + (ts_pos, self.bar_top), + (ts_pos, self.bar_bottom), + (32, 32, 32), + 3, + ) + cv2.line( + frame, + (ts_pos, self.bar_top), + (ts_pos, self.bar_bottom), + (84, 255, 63), + 1, + ) + # cv2.line(frame, (bar_position, top), (bar_position, bottom), (32,32,32), 3) + cv2.line( + frame, + (bar_position, self.bar_top), + (bar_position, self.bar_bottom), + (63, 84, 255), + 1, + ) + + self.shadow_text( + frame, + "1", + (self.bar_start - 7, self.bar_bottom + 20), + 0.7, + 2, + (255, 255, 255), + ) + end_frame = self.format_time(self.frames - 1) + (text_width, text_height) = cv2.getTextSize( + end_frame, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2 + )[0] + self.shadow_text( + frame, + end_frame, + (self.bar_end - text_width, self.bar_bottom + 20), + 0.7, + 2, + (255, 255, 255), + ) + + def draw_label(self, frame): + + if not self.nr in self.stamps: + return + + text = "{} #{}".format(self.nr + 1, self.stamps.index(self.nr) + 1) + bottom = 60 + left = 10 + self.shadow_text(frame, text, (left, bottom), 1, 2, (63, 84, 255)) + + def draw_time(self, frame): + left = 10 + bottom = 30 + + formatted = "{} {}".format( + self.format_time(self.nr), + "||" if self.paused else "", + ) + self.shadow_text(frame, formatted, (left, bottom), 1.1, 2, (255, 255, 255)) + + def format_time(self, nframe): + + seconds = int(nframe / self.fps) + frame = nframe % self.fps + parts = int(100 * (frame / self.fps)) + return time.strftime("%H:%M:%S", time.gmtime(seconds)) + ".%02d" % (parts) + + def get_options(self): + + parser = argparse.ArgumentParser(description="Video timestamping tool") + parser.add_argument( + "--ts", + action="store", + dest="timestamps", + default=None, + required=False, + help="Comma separated list of predefined timestamps, in frame numbers, or HH:MM:SS.FF", + ) + parser.add_argument(action="store", dest="video") + self.opts = parser.parse_args() + + def mouse_click(self, event, x, y, flags, param): + + in_bar = all( + ( + x < self.bar_end, + x > self.bar_start, + y < self.bar_bottom, + y > self.bar_top, + ) + ) + if event == cv2.EVENT_LBUTTONDOWN: + if in_bar: + click_relative = (x - self.bar_start) / (self.bar_end - self.bar_start) + self.nr = int(click_relative * self.frames) + self.read_next = True + + if event == cv2.EVENT_LBUTTONDBLCLK: + if not in_bar: + self.toggle_stamp() + # doubleclick (toggle?) + # ~ print("double", x, y) + + def parse_time(self, timestr): + """return frames""" + + colon_count = len(timestr.split(":")) - 1 + if colon_count == 0: + secs = float(timestr) + return int(secs * self.fps) + if colon_count == 1: + mins, secstr = timestr.split(":", 1) + sec = float(secstr) + return int(self.fps * (int(mins) * 60 + sec)) + if colon_count == 2: + hours, mins, secstr = timestr.split(":", 2) + sec = float(secstr) + return int(self.fps * (int(hours) * 3600 + int(mins) * 60 + sec)) + raise ValueError("Cannot parse time definition {}".format(timestr)) + + def parse_timestamps(self): + if self.opts.timestamps: + self.stamps = sorted( + [self.parse_time(ts.strip()) for ts in self.opts.timestamps.split(",")] + ) + self.stamps = [x for x in self.stamps if 0 <= x < self.frames] + self.nr = self.stamps[0] + else: + self.stamps = [] + self.nr = 0 + + def print_help(self): + print( + """Keyboard help: + (Note: after mouse click, arrows stop working due to unknown bug: use j,k) + Arrows left and right, Home, End or click mouse in position bar + j and k + jump in video. Tap frequently to increase time step + , and . move one frame at a time + z and c move to previous or next mark + x or double click in the video + mark frame + space pause + i toggle HUD + q quit + """ + ) + + def print_timestamps(self): + self.stamps.sort() + print("# Timestamps:") + for i, ts in enumerate(self.stamps): + print("# {}: {} / {}".format(i + 1, self.format_time(ts), ts + 1)) + if len(self.stamps) > 0: + print( + "ffmpeg -i '{}' -ss {} -to {} -c copy trimmed.mp4".format( + self.opts.video, + self.format_time(self.stamps[0]), + self.format_time(self.stamps[-1]), + ) + ) + + def shadow_text(self, frame, text, pos, size, thicc, color): + + cv2.putText( + frame, + text, + pos, + self.font, + size, + (0, 0, 0), + 2 * thicc, + cv2.LINE_AA, + ) + cv2.putText( + frame, + text, + pos, + self.font, + size, + color, + thicc, + cv2.LINE_AA, + ) + + def toggle_stamp(self): + if self.nr in self.stamps: + self.stamps.remove(self.nr) + else: + self.stamps.append(self.nr) + self.stamps.sort() + + def loop(self): + + self.step = 1 + self.last_move = [] + self.video_reader.set(cv2.CAP_PROP_POS_FRAMES, self.nr) + + self.print_help() + cv2.namedWindow("tsmark") + cv2.setMouseCallback("tsmark", self.mouse_click) + while self.video_reader.isOpened(): show_time = time.time() - if (not paused) or read_next: - ret, frame = video_reader.read() + if (not self.paused) or self.read_next: + ret, frame = self.video_reader.read() if ret == True: - if paused: + if self.paused: draw_wait = 200 else: draw_wait = 1 - if (not paused) or read_next: - read_next = False - frame_visu = cv2.resize(frame.copy(), video_res) - nr_time = nr / fps - if show_info: - draw_time(frame_visu, nr, fps, paused) - draw_bar(frame_visu, nr, frames, fps, stamps) - draw_label(frame_visu, nr, stamps) + if (not self.paused) or self.read_next: + self.read_next = False + frame_visu = cv2.resize(frame.copy(), self.video_res) + nr_time = self.nr / self.fps + if self.show_info: + self.draw_time(frame_visu) + self.draw_bar(frame_visu) + self.draw_label(frame_visu) cv2.imshow("tsmark", frame_visu) k = cv2.waitKey(draw_wait) if k & 0xFF == ord("q") or k & 0xFF == 27: break if k & 0xFF == 32: # space - paused = not paused + self.paused = not self.paused if k & 0xFF == 80: # home key - nr = 0 + self.nr = -1 + self.read_next = True if k & 0xFF == 87: # end key - nr = frames - 1 - paused = True + self.nr = self.frames - 1 + self.paused = True + self.read_next = True - if k & 0xFF == 83: # right arrow - last_move.append(("r", time.time())) - if auto_step: - step, last_move = calculate_step(step, last_move, video_length) - nr = int((nr_time + step) * fps) - 1 - read_next = True + if k & 0xFF == 83 or k & 0xFF == ord("k"): # right arrow + self.last_move.append(("r", time.time())) + if self.auto_step: + self.calculate_step() + self.nr = int((nr_time + self.step) * self.fps) - 1 + self.read_next = True if k & 0xFF == ord("."): - paused = True - read_next = True - if k & 0xFF == 81: # left arrow - last_move.append(("l", time.time())) - if auto_step: - step, last_move = calculate_step(step, last_move, video_length) - nr = int((nr_time - step) * fps) - 1 - read_next = True + self.paused = True + self.read_next = True + if k & 0xFF == 81 or k & 0xFF == ord("j"): # left arrow + self.last_move.append(("l", time.time())) + if self.auto_step: + self.calculate_step() + self.nr = int((nr_time - self.step) * self.fps) - 1 + self.read_next = True if k & 0xFF == ord(","): - paused = True - nr -= 2 - read_next = True + self.paused = True + self.nr -= 2 + self.read_next = True if k & 0xFF == ord("z"): # move to previous ts - for ts in reversed(sorted(stamps)): - if ts < nr - 1: - nr = ts - 1 - read_next = True + for ts in reversed(sorted(self.stamps)): + if ts < self.nr - 1: + self.nr = ts - 1 + self.read_next = True break if k & 0xFF == ord("c"): # move to previous ts - for ts in sorted(stamps): - if ts > nr: - nr = ts - 1 - read_next = True + for ts in sorted(self.stamps): + if ts > self.nr: + self.nr = ts - 1 + self.read_next = True break if k & 0xFF == ord("x"): # toggle ts - if nr in stamps: - stamps.remove(nr) - else: - stamps.append(nr) - stamps.sort() + self.toggle_stamp() + if k & 0xFF == ord("i"): - show_info = not show_info + self.show_info = not self.show_info if k & 0xFF == ord("h"): - print_help() + self.print_help() - if (not paused) or read_next: - nr += 1 - if nr < 0: - nr = 0 - if nr >= frames: - nr = frames - 1 - paused = True - if read_next: - video_reader.set(cv2.CAP_PROP_POS_FRAMES, nr) + if (not self.paused) or self.read_next: + self.nr += 1 + if self.nr < 0: + self.nr = 0 + if self.nr >= self.frames: + self.nr = self.frames - 1 + self.paused = True + if self.read_next: + self.video_reader.set(cv2.CAP_PROP_POS_FRAMES, self.nr) - time_to_wait = spf - time.time() + show_time + time_to_wait = self.spf - time.time() + show_time if time_to_wait > 0: time.sleep(time_to_wait) else: - nr = frames - 2 - video_reader.set(cv2.CAP_PROP_POS_FRAMES, nr) - paused = True - read_next = True + self.nr = self.frames - 2 + self.video_reader.set(cv2.CAP_PROP_POS_FRAMES, self.nr) + self.paused = True + self.read_next = True - video_reader.release() - print_timestamps(stamps, fps, opts.video) - except Exception: - exc_type, exc_obj, exc_tb = sys.exc_info() - fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] - print(exc_type, fname, exc_tb.tb_lineno) + self.video_reader.release() + self.print_timestamps() + + +def main(): + mark = Marker()