piu_app 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. #!/usr/bin/python3
  2. #
  3. # Copyright (c) 2021 Clementine Computing LLC.
  4. #
  5. # This file is part of PopuFare.
  6. #
  7. # PopuFare is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU Affero General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # PopuFare is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Affero General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Affero General Public License
  18. # along with PopuFare. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. # This is the front end interface to the PIU that will run as
  21. # a Python application.
  22. # It displays the video stream and overlays the barcode, if it finds one.
  23. # It scans in the directory:
  24. #
  25. # /home/bus/queue
  26. #
  27. # for messages. If found, it then moves them to:
  28. #
  29. # /home/bus/log/messages
  30. #
  31. # Only the last message (after filename sorting) is chosen.
  32. # The first line in the message file is the status message.
  33. # The second line is the error message.
  34. # Either or both lines can be blank without consequence.
  35. #
  36. import sys
  37. import os
  38. import tkinter as tk
  39. from PIL import Image, ImageTk
  40. import cv2
  41. import signal
  42. import tkinter.font as tkfont
  43. import numpy as np
  44. from pyzbar import pyzbar
  45. import datetime
  46. import imutils
  47. import time
  48. from imutils.video.pivideostream import PiVideoStream
  49. from imutils.video import FPS
  50. from picamera.array import PiRGBArray
  51. from picamera import PiCamera
  52. # signal handlers to capture ctrl-c etc. for "graceful" exit
  53. #
  54. def handler(ev):
  55. root.destroy()
  56. def check():
  57. root.after(500, check)
  58. class MainWindow():
  59. def __init__(self, window, pistream):
  60. # variables relating to display messages,
  61. # how long to display them for, when to
  62. # remove them from display and what to
  63. # display when idle.
  64. #
  65. self.display_msg = ""
  66. self.display_msg_error = ""
  67. self.display_msg_idle = "READY"
  68. self.display_msg_t = 10000
  69. self.display_msg_start = 0
  70. self.display_msg_end = 0
  71. # log files and locations of communication files
  72. #
  73. self.message_dir = "/home/bus/queue"
  74. self.processed_message_dir = "/home/bus/log/message"
  75. self.log_dir = "/home/bus/log"
  76. self.barcode_ofn = os.path.join(self.log_dir, "qrcode.log")
  77. self.watchdog_ofn = os.path.join(self.log_dir, "piu_app.watchdog")
  78. # watchdog rate limit variables
  79. #
  80. self.watchdog_rate_ms = 2000
  81. self.watchdog_next = 0
  82. # our camera stream
  83. #
  84. self.pistream = pistream
  85. pad = 0
  86. self.disp_pad = pad
  87. self._orig_geom = "{0}x{1}+0+0".format( window.winfo_screenwidth()-pad, window.winfo_screenheight()-pad )
  88. self.screen_width = window.winfo_screenwidth()
  89. self.screen_height = window.winfo_screenheight()
  90. window.attributes("-fullscreen", True)
  91. self.cap_width = 400
  92. self.cap_height = 300
  93. self.window = window
  94. # Camera processing and graphical display rate, in milliseconds.
  95. # This is optimisitic and it looks like Python, or whatever else,
  96. # is not going to really acheive sub 100ms.
  97. #
  98. self.interval = 50
  99. self.canvas = tk.Canvas(self.window, width=self.screen_width, height=self.screen_height, bg='#000', bd=0, highlightthickness=0, relief='ridge')
  100. self.canvas.grid(row=0, column=0)
  101. # variables for bar/qr code scanning.
  102. # * rate limiting to no log multiple quick reads as independent
  103. # * save last token read so we aren't limited by the rate limit
  104. # * `t_prv` and `t_now` for rate limiting caluclations
  105. #
  106. self.RATE_LIMIT_MS = 1500.0
  107. self.LAST_TOK = ""
  108. self.t_prv = time.time()*1000.0
  109. self.t_now = self.t_prv
  110. # kick off our main loop
  111. #
  112. self.update_loop()
  113. def log_barcode(self, data):
  114. with open( self.barcode_ofn, "a" ) as ofp:
  115. ofp.write( data + "\n")
  116. ofp.flush()
  117. # Look for new messages in the `message` directory
  118. # (stored in `self.message_dir`) for new messages.
  119. # Once read, move to the `self.processed_message_dir`
  120. # directory.
  121. #
  122. def message_scan(self):
  123. msg_fns = []
  124. for rootdir, dirs, fns in os.walk( self.message_dir ):
  125. for fn in fns:
  126. msg_fns.append(fn)
  127. msg_fns.sort()
  128. for msg_fn in msg_fns:
  129. src_fq_msg_fn = os.path.join(self.message_dir, msg_fn)
  130. dst_fq_msg_fn = os.path.join(self.processed_message_dir, msg_fn)
  131. with open(src_fq_msg_fn) as fp:
  132. msg_a = fp.read().split("\n")
  133. self.display_msg = msg_a[0][0:10]
  134. if len(msg_a) > 1:
  135. self.display_msg_error = msg_a[1][0:10]
  136. self.display_msg_start = time.time() * 1000.0
  137. self.display_msg_end = self.display_msg_start + self.display_msg_t
  138. os.replace(src_fq_msg_fn, dst_fq_msg_fn)
  139. def message_update(self):
  140. t = time.time() * 1000.0
  141. # Clear the current message if we've exceeded
  142. # the `self.display_msg_end` time.
  143. # Put in the idle message.
  144. #
  145. if t > self.display_msg_end:
  146. self.display_msg = ""
  147. self.display_msg_error = ""
  148. self.display_msg = self.display_msg_idle
  149. # @rite to watchdog file with current time (in seconds, since
  150. # epoch) at a rate of `self.watchdog_rate_ms` (in milliseconds).
  151. # Monitoring processes can check this file to make sure the
  152. # piu_app is still running.
  153. #
  154. def watchdog(self):
  155. t = time.time() * 1000.0
  156. if t > self.watchdog_next:
  157. with open( self.watchdog_ofn, "w" ) as ofp:
  158. ofp.write( "{}".format(int(t)) + "\n" )
  159. ofp.flush()
  160. self.watchdog_next = t + self.watchdog_rate_ms
  161. def update_loop(self):
  162. self.watchdog()
  163. # scan for PIU messages to display on-screen
  164. #
  165. self.message_scan()
  166. self.message_update()
  167. # grab our camera and resize apporpriately
  168. #
  169. img_cv2 = None
  170. img_cv2_o = self.pistream.read()
  171. img_cv2 = imutils.resize(img_cv2_o, width=self.cap_width)
  172. # keep oriignal image size to process barcodes, resize
  173. # for display and keep rescale factor to properly overla
  174. # the rectangle and other indicator text.
  175. #
  176. _r = float(self.cap_width) / float(img_cv2_o.shape[1])
  177. ## overlay barcode rectanges and text on cv2 image
  178. ##
  179. barcodes = pyzbar.decode(img_cv2_o)
  180. show_text = None
  181. for barcode in barcodes:
  182. # Show overlay of scanned barcode with text information
  183. # on what information was scanned.
  184. #
  185. (x_o,y_o,w_o,h_o) = barcode.rect
  186. (x,y,w,h) = ( int(float(x_o)*_r), int(float(y_o)*_r), int(float(w_o)*_r), int(float(h_o)*_r) )
  187. cv2.rectangle(img_cv2, (x,y), (x+w, y+h), (0, 0, 255), 2)
  188. barcode_data = barcode.data.decode("utf-8")
  189. barcode_type = barcode.type
  190. show_text = "{} ({})".format(barcode_data, barcode_type)
  191. # we flip the image (below), so we need to transform the x
  192. # coordinate
  193. #
  194. _w = img_cv2.shape[1]
  195. cam_img_txt_org = (_w - x - w, y - 10)
  196. # We will be sensing bar/qr codes continuiously (50ms ideally,
  197. # though probably slower due to Python inefficiencies), so
  198. # we only consider bar/qr code data if the scanned code is
  199. # different from our last scan _or_ if we've waited past
  200. # our 'RATE_LIMIT_MS' limit.
  201. # Codes scanned are logged to the barcode file, which another
  202. # process should pickup and process.
  203. #
  204. self.t_now = time.time()*1000.0
  205. if self.LAST_TOK != barcode_data:
  206. self.log_barcode( str(int(time.time())) + ": " + barcode_data )
  207. self.t_prv = self.t_now
  208. self.LAST_TOK = barcode_data
  209. elif (self.t_now - self.t_prv) >= self.RATE_LIMIT_MS:
  210. self.log_barcode( str(int(time.time())) + ": " + barcode_data )
  211. self.t_prv = self.t_now
  212. self.LAST_TOK = barcode_data
  213. else:
  214. pass
  215. # We need to convert from whatever native format opencv returns
  216. # to the format that tkinter expects.
  217. # We also flip the image so that the camera is much more like
  218. # a "mirror", for ease of media placement under/in front of the
  219. # camera.
  220. #
  221. self.image = cv2.cvtColor(img_cv2, cv2.COLOR_BGR2RGB)
  222. self.image = cv2.flip(self.image, 1)
  223. # to get the barcode text to not be reversed, we overlay
  224. #the bar/qr code text here.
  225. #
  226. if show_text is not None:
  227. _w = self.image.shape[1]
  228. cv2.putText(self.image, show_text, cam_img_txt_org,
  229. cv2.FONT_HERSHEY_SIMPLEX, 0.5, (235, 116, 108), 2 )
  230. self.image = Image.fromarray(self.image)
  231. self.image = ImageTk.PhotoImage(self.image)
  232. disp_cam_dy = int(self.screen_height - self.cap_height)
  233. disp_cam_dx = int((self.screen_width - self.cap_width)/2)
  234. disp_cam_dx -= 1
  235. if disp_cam_dx<0: disp_cam_dx=0
  236. midx = int(self.screen_width/2)
  237. # Do some formatting of date-time.
  238. # "Blink" the ':' each second.
  239. #
  240. _now = datetime.datetime.now()
  241. yyyymmdd = _now.strftime("%Y-%m-%d")
  242. time_str = _now.strftime("%a %I") + [":", " "][int(_now.strftime("%S"))%2] + _now.strftime("%M%p")
  243. # text_msg are the messages to be displayed.
  244. # The position in the array is the vertical position and 'type' of message
  245. #
  246. # 0-1: date-time
  247. # 2: status
  248. # 3-4: error
  249. #
  250. text_msg = [ yyyymmdd, time_str, self.display_msg, self.display_msg_error ]
  251. text_msg_dy = [ 60, 80, 80, 60 , 0 ]
  252. self.canvas.delete("all")
  253. # txt_dy is the amount to shift the text down by
  254. # (txt_x, txt_y) is the current (center) position of displayed text
  255. #
  256. txt_dy = 80
  257. txt_x = midx
  258. txt_y = int(txt_dy/2)
  259. for idx,msg in enumerate(text_msg):
  260. font = None
  261. if idx==0:
  262. font = tkfont.Font(family="monospace", weight="bold", size=60)
  263. else:
  264. font = tkfont.Font(family="monospace", weight="bold", size=50)
  265. # change color depending on position
  266. # top two rows are normal text (date and time)
  267. # third row is status message (blue)
  268. # fourth and fifth row are error messages
  269. #
  270. color = "#aaa"
  271. if idx == 2: color = "#6194fa"
  272. elif idx > 2: color = "#fa6171"
  273. self.canvas.create_text(txt_x, txt_y, anchor=tk.CENTER, text=msg, font=font, fill=color)
  274. txt_y += text_msg_dy[idx]
  275. # draw our built up tk canvas and setupt the interval callback
  276. #
  277. self.canvas.create_image(disp_cam_dx, disp_cam_dy, anchor=tk.NW, image=self.image)
  278. self.window.after(self.interval, self.update_loop)
  279. if __name__ == "__main__":
  280. root = tk.Tk()
  281. pistream = PiVideoStream().start()
  282. time.sleep(2.0)
  283. MainWindow(root, pistream)
  284. # catch signals so we can gracefully-ish exit on a ctrl-c
  285. #
  286. signal.signal(signal.SIGINT, lambda x,y : print('terminal ^C') or handler(None))
  287. root.after(500, check)
  288. root.bind_all('<Control-c>', handler)
  289. root.configure(bg='#000')
  290. root.mainloop()