Scoreboards and Video Routing in Python
Scorekeeper
We look at a broadcast video system network that uses Python code to control a video router and check out another program that creates a scoreboard.
Each year my church hosts a basketball league, and several years ago we wanted to upgrade to digital scoreboards because our existing classic board was showing its age. The first version of this new scoreboard software was written in Python [1] and used the GTK toolkit to create the public display. A web page designed for an iPad allowed courtside control.
The gym is a shared space that also hosts a meeting room, party hall, and general-purpose room. To support these roles, two NUC small-form-factor computers drive the displays (100-inch LCD TVs). Windows was necessary to support all of the display software so that PowerPoint and ProPresenter would run natively.
In general, a video system has a hub-and-spoke-style network wherein each device has a dedicated home that runs to a video switcher (Figure 1). The video switcher, as its name implies, accepts all of the video inputs and allows the operator to pick which one should be displayed, overlaid, or otherwise presented to the final output (Figure 2).
Displays are slightly more complicated because they need to select the switched video program from the video switcher or the scoreboards at any given time, which is accomplished by a matrix router. See the "Matrix Routers" box for more details.
Matrix Routers
In the video world, a router is a really helpful device. It accepts some number of video signals in and can send them to any of its destinations. This example uses a small four-input by four-output version, but other versions are available with up to thousands of I/O points.
Think of the router as a grid with sources (video signals – see the "Video Signals and Conversions" box) coming in on the left and going out along the bottom. Each column (an output or destination) is allowed one connection. Inputs can connect to as many outputs as needed. To say it another way, all of the outputs can display a single input, but each output can only display one thing at a time. When a selection is made, the selected input and output are connected as if they were wired together directly. These connections are easily changed throughout the day as the needs of the basketball game dictate.
Video Signals and Conversions
Many different video formats can carry live video down a wire. You're probably familiar with HDMI because it has been widely adopted since its introduction. The video system discussed here primarily uses a serial digital interface (SDI), which is a digital standard for broadcast video. Hardware converters between these formats also exist, so changing between them is as easy as connecting a cable of each type. The cost for this conversion is a slight delay in the video signal that is normally not noticeable; however, if you have stacked several conversions, the delays can start to add up and become visible. This scenario generally presents itself as audio out of sync with its associated video.
The Scoreboard
In addition to team points, a scoreboard also offers other game information, such as time remaining, period of play, time outs, or player numbers. Figure 3 shows the general flow of the program. At program startup, the graphics initialize and the initial state of the scoreboard renders. Concurrently, the web server starts and the operator brings this page up on a courtside computer (Figure 4).
As the basketball game progresses, the operator uses the web interface to update the game score and other statistics. Each action on the web interface starts as a JavaScript function that runs in the browser, communicating with the CherryPy [2] server through background Ajax calls. When CherryPy receives these commands, it updates the graphics screen to reflect the change on the scoreboard itself and then sends the updated values back to the browser so that the operator's interface is also updated.
Listing 1 shows some of the code behind the scoreboard. (You can download the complete code online [3].) To begin, you must import the external libraries the program needs: The pygame,
graphics library [4] draws the scoreboard; _thread
allows multiple branches of the program to run at the same time, so you can run the scoreboard display and the web control interface at the same time; pymysql
is the MySQL database library for Python; and cherrypy
sets up the CherryPy framework.
Listing 1
scoreboard.py
001 import pygame 002 import _thread 003 import cherrypy 004 import pymysql 005 006 class web: 007 def __init__ ( self , scoreboard ): 008 self.scoreboard = scoreboard 009 self.reconnect() 010 self.dbGameID = None 011 012 def reconnect ( self ): 013 self.db = pymysql.connect( 014 host='DB_HOSTNAME', 015 user='DB_USER', 016 password = "DB_PASSWORD", 017 db='DB_NAME', 018 ) 019 020 def gameEvent ( self , event , value ): 021 if self.dbGameID == None: return 022 023 self.db.ping ( True ) 024 period = self.scoreboard.period.value 025 time = self.scoreboard.clk.seconds 026 027 sql = "INSERT INTO `updates` ( `gameID` , `period` , `gameTime` , `event` , `value` ) VALUES ( " + str ( self.dbGameID ) + " , " + str ( period ) + " , " + str ( time ) + " , '" + event + "' , " + str ( value ) + " );" 028 029 cursor = self.db.cursor ( pymysql.cursors.DictCursor ) 030 cursor.execute ( sql ) 031 self.db.commit() 032 033 @cherrypy.expose 034 def index ( self ): 035 html = """ ... 240 """.format ( 241 self.scoreboard.court.value + " " + self.scoreboard.court.label, 242 self.scoreboard.scoreA.label, 243 self.scoreboard.scoreB.label, 244 self.scoreboard.scoreA.value, 245 self.scoreboard.scoreB.value, 246 self.scoreboard.clk.clockString, 247 self.scoreboard.timeoutA.value, 248 self.scoreboard.timeoutB.value, 249 self.scoreboard.posession.leftString, 250 self.scoreboard.posession.rightString, 251 self.scoreboard.period.value 252 ) 253 254 return html 255 256 @cherrypy.expose 257 def teams ( self ): 258 cursor = self.db.cursor ( pymysql.cursors.DictCursor ) 259 sql = "SELECT t1.teamName AS t1name , t2.teamName AS t2name , games.* FROM `games` LEFT JOIN `teams` t1 ON t1.ID=games.team1 LEFT JOIN `teams` t2 ON t2.ID=games.team2"; 260 cursor.execute ( sql ) 261 262 html = "<select id='gameSelect'>" 263 for game in cursor.fetchall(): 264 html += "<option value='" + str ( game [ 'ID' ] ) + "'>" + str ( game [ 'date' ] ) + " | " + str ( game [ 'time' ] ) + " | " + game [ 't1name' ] + " | " + game [ 't2name' ] + "</option>" 265 html += "</select>" 266 267 return html 268 269 @cherrypy.expose 270 def processGameSelect ( self , gameID ): 271 cursor = self.db.cursor ( pymysql.cursors.DictCursor ) 272 sql = "SELECT t1.teamName AS t1name , t2.teamName AS t2name , games.* FROM `games` LEFT JOIN `teams` t1 ON t1.ID=games.team1 LEFT JOIN `teams` t2 ON t2.ID=games.team2 WHERE games.ID = " + str ( gameID ); 273 cursor.execute ( sql ) 274 game = cursor.fetchone() 275 276 self.dbGameID = gameID 277 278 self.teamName ( "1" , game [ "t1name" ] ) 279 self.teamName ( "2" , game [ "t2name" ] ) 280 281 @cherrypy.expose 282 def teamName ( self , team , name ): 283 if team == "1": self.scoreboard.scoreA.label = name 284 if team == "2": self.scoreboard.scoreB.label = name 285 286 @cherrypy.expose 287 def score ( self , team1 , team2 ): 288 self.scoreboard.scoreA.value += int ( team1 ) 289 self.scoreboard.scoreB.value += int ( team2 ) 290 291 if team1 != "0": self.gameEvent ( "T1SCORE" , team1 ) 292 if team2 != "0": self.gameEvent ( "T2SCORE" , team2 ) 293 294 self.scoreboard.render() 295 return str ( self.scoreboard.scoreA.value ) + ":" + str ( self.scoreboard.scoreB.value ); 296 297 @cherrypy.expose 298 def timeouts ( self , team1 , team2 ): 299 self.scoreboard.timeoutA.value += int ( team1 ) 300 self.scoreboard.timeoutB.value += int ( team2 ) 301 302 if team1 != "0": self.gameEvent ( "T1TIMEOUT" , team1 ) 303 if team2 != "0": self.gameEvent ( "T2TIMEOUT" , team2 ) 304 305 return str ( self.scoreboard.timeoutA.value ) + ":" + str ( self.scoreboard.timeoutB.value ) 306 307 @cherrypy.expose 308 def period ( self , period ): 309 self.gameEvent ( "PERIOD" , period ) 310 311 self.scoreboard.period.value += int ( period ) 312 return str ( self.scoreboard.period.value ) 313 314 @cherrypy.expose 315 def getClock ( self ): 316 return self.scoreboard.clk.clockString 317 318 @cherrypy.expose 319 def clockRun ( self ): 320 self.scoreboard.clk.running = True 321 322 @cherrypy.expose 323 def clockStop ( self ): 324 self.scoreboard.clk.running = False 325 self.gameEvent ( "CLOCKSTOP" , 0 ) 326 327 @cherrypy.expose 328 def clockSet ( self , seconds ): 329 self.scoreboard.clk.seconds = int ( seconds ) + 1 330 self.scoreboard.clk.tick ( manual = True ) 331 return self.scoreboard.clk.clockString 332 333 @cherrypy.expose 334 def posession ( self ): 335 self.scoreboard.posession.toggle() 336 if self.scoreboard.posession.leftString == "<": self.gameEvent ( "POSESSION" , 1 ) 337 if self.scoreboard.posession.rightString == ">": self.gameEvent ( "POSESSION" , 2 ) 338 339 return self.scoreboard.posession.leftString + ":" + self.scoreboard.posession.rightString 340 341 class clock: 342 def __init__ ( self , screen ): 343 self.screen = screen 344 self.width = self.screen.get_width() 345 self.height = 250 346 self.seconds = 6 * 60 347 self.clockSurf = pygame.surface.Surface ( ( self.width , self.height ) ) 348 self.clockFont = pygame.font.Font ( "open24.ttf" , self.height ) 349 self.running = False 350 351 mins = int ( float ( self.seconds ) / 60.0 ) 352 secs = self.seconds % 60 353 354 self.clockString = "{0}:{1:02d}".format ( mins , secs ) 355 356 def tick ( self , manual = False ): 357 if self.seconds > 0 and ( self.running == True or manual == True ): 358 self.seconds -= 1 359 360 mins = int ( float ( self.seconds ) / 60.0 ) 361 secs = self.seconds % 60 362 363 self.clockString = "{0}:{1:02d}".format ( mins , secs ) 364 365 def render ( self ): 366 timeSurf = self.clockFont.render ( self.clockString , True , ( 0 , 255 , 0 ) ) 367 368 x = self.width / 2 - int ( timeSurf.get_width() / 2 ) 369 self.clockSurf.fill ( ( 0 , 0 , 0 ) ) 370 self.clockSurf.blit ( timeSurf , ( x , -25 ) ) 371 return self.clockSurf 372 373 class posession: 374 def __init__ ( self ): 375 self.width = 300 376 self.height = 100 377 self.font = None 378 self.direction = "<" 379 self.posSurf = pygame.surface.Surface ( ( self.width , self.height ) ) 380 self.leftString = "" 381 self.rightString = "" 382 self.makeSurfaces() 383 384 def makeSurfaces ( self ): 385 self.leftSurf = self.font.render ( "<" , True , ( 255 , 0 , 0 ) ) 386 self.rightSurf = self.font.render ( ">" , True , ( 255 , 0 , 0 ) ) 387 388 def toggle ( self ): 389 if self.direction == "<": 390 self.direction = ">" 391 self.leftString = "" 392 self.rightString = ">" 393 else: 394 self.direction = "<" 395 self.leftString = "<" 396 self.rightString = "" 397 398 def render ( self ): 399 self.posSurf.fill ( ( 0 , 0 , 0 ) ) 400 label = self.font.render ( "POSESSION" , True , ( 255 , 0 , 0 ) ) 401 x = self.width / 2 - int ( label.get_width() / 2 ) 402 self.posSurf.blit ( label , ( x , 0 ) ) 403 404 if self.direction == "<": self.posSurf.blit ( self.leftSurf , ( 0 , 0 ) ) 405 else: self.posSurf.blit ( self.rightSurf , ( self.width - self.rightSurf.get_width() , 0 ) ) 406 return self.posSurf 407 408 class label: 409 def __init__ ( self ): 410 self.width = 250 411 self.height = 350 412 self.label = "" 413 self.value = "" 414 self.labelFont = None 415 self.valueFont = None 416 self.labelColor = ( 200 , 200 , 200 ) 417 self.valueColor = ( 200 , 200 , 200 ) 418 self.countSurf = pygame.surface.Surface ( ( self.width , self.height ) ) 419 420 def render ( self ): 421 self.countSurf.fill ( ( 0 , 0 , 0 ) ) 422 valSurf = self.valueFont.render ( str ( self.value ) , True , self.valueColor ) 423 x = self.width / 2 - int ( valSurf.get_width() / 2 ) 424 self.countSurf.blit ( valSurf , ( x , 0 ) ) 425 426 labelSurf = self.labelFont.render ( str ( self.label ) , True , self.labelColor ) 427 x = self.width / 2 - int ( labelSurf.get_width() / 2 ) 428 self.countSurf.blit ( labelSurf , ( x , valSurf.get_height() - 10 ) ) 429 430 return self.countSurf 431 432 class counter: 433 def __init__ ( self ): 434 self.width = 250 435 self.height = 350 436 self.label = "" 437 self.value = 0 438 self.labelFont = None 439 self.valueFont = None 440 self.labelColor = ( 200 , 200 , 200 ) 441 self.valueColor = ( 200 , 200 , 200 ) 442 self.countSurf = pygame.surface.Surface ( ( self.width , self.height ) ) 443 444 def render ( self ): 445 self.countSurf.fill ( ( 0 , 0 , 0 ) ) 446 valSurf = self.valueFont.render ( str ( self.value ) , True , self.valueColor ) 447 x = self.width / 2 - int ( valSurf.get_width() / 2 ) 448 self.countSurf.blit ( valSurf , ( x , 0 ) ) 449 450 labelSurf = self.labelFont.render ( str ( self.label ) , True , self.labelColor ) 451 x = self.width / 2 - int ( labelSurf.get_width() / 2 ) 452 self.countSurf.blit ( labelSurf , ( x , valSurf.get_height() - 10 ) ) 453 454 return self.countSurf 455 456 class board: 457 def __init__ ( self ): 458 pygame.display.init() 459 self.screen = pygame.display.set_mode ( ( 1280 , 720 ) , display = 1 , flags = pygame.FULLSCREEN ) 460 pygame.font.init() 461 self.scoreFont = pygame.font.Font ( "font.ttf" , 200 ) 462 self.labelFont = pygame.font.Font ( "font.ttf" , 50 ) 463 self.timeoutFont = pygame.font.Font ( "font.ttf" , 50 ) 464 self.posFont = pygame.font.Font ( "font2.otf" , 50 ) 465 466 self.logo = pygame.image.load ( "upwardBlack.jpg" ).convert() 467 468 self.clk = clock ( self.screen ) 469 ...
The Web Class
The web
class (lines 6-339) connects to a MySQL database that will be used to retrieve team names, determine which team plays which, and record game history. When the class is instantiated, the __init__
function is called automatically to set up a few things needed later. Line 8 creates a class variable self.scoreboard
and saves the reference to the scoreboard class that's passed in as an argument.
The reconnect
function is really only one call split up across multiple lines for easier readability (lines 12-18). pymysql.connect
creates self.db
, the local reference to the database. Each line supplies the appropriate credential.
The gameEvent
function (lines 20-31) logs any change to the scoreboard. In a future version, this function will allow for recovering the scoreboard after a crash and exporting a game report. To begin, the code checks whether self.dbGameID
(which was initialized to None
in line 10) is set. If not, then you don't have a database ID, so you just return.
The self.db.ping
convenience function, with an argument of True
, checks whether the database connection is live and, if not, reconnects automatically in the next line. The two lines that follow get the game period and time in seconds. These two values pinpoint a unique time within the game.
Line 27 builds a SQL statement. The INSERT INTO
command names the table followed by a list of columns separated by commas. The VALUES
keyword assigns the values for each column, provided in the same order. Line 29 creates a database cursor that interacts with an SQL statement. In this case (line 30), it just executes the SQL line created in line 27, but it has many more capabilities, especially when retrieving records. Line 31 calls commit
to confirm that you want to write the data.
The cherrypy
decorator (lines 33-339) creates a small web server for controlling the scoreboard. Each Python function becomes a web address that returns its designated content to the browser that has called it.
The index
function acts just like index.html in a traditional web server. In the absence of another address, it is the default item returned. Most of this function is a very large multiline string (not shown here, grab the full code online [3]) enclosed by triple quotes ("""
) that instructs Python to ignore any newlines or other special characters until it encounters another triple quote. The enclosed string is the HTML5 [5] and JavaScript of the control web page. After the massive text string, the Python format
command inserts all of the current variables into the web page (lines 240-251).
The @cherrypy.expose
decorator tells CherryPy that it should allow this function to be reachable through its web server. Without this decorator the function remains private from anyone on the web, which allows the program to be structured with additional functions as needed, with only those specifically designed to be web-accessible published. You'll see this decorator before each function in the web
class, so it's only described once here.
The teams
function (lines 257-267) is somewhat of a misnomer in that it generates a game selector. For the purposes of this database, a game is a pair of teams at a specific date and time on a specific court. This function retrieves all of the games and returns an HTML select
widget.
The function begins by creating an SQL cursor and statement and then executing it (as in lines 29-31). However, now it's retrieving records, so the cursor has the results of the query, which is data instead of just a message that the query succeeded.
After starting the HTML select
widget on line 262, the program loops over the SQL results. As the name implies, cursor.fetchall
gives all of the results as an iterator. game
will contain one row from the database for each time through the loop. Each pass creates an HTML option
and lists the game date, time, and teams.
The processGameSelect
function (lines 270-279) is called when the scoreboard operator selects a game from the drop-down just generated. It receives the gameID
argument and uses that to get the game details from the database. Line 272 is an almost identical SQL statement to line 259, but with a WHERE
clause added to the end with the gameID
.
After doing the SQL dance one more time, line 274 calls cursor.fetchone
. The last time, all of the records were retrieved, but this time only one is needed, which is saved into game
. gameID
is saved into a class variable, and self.teamName
sets the names retrieved from the database.
The teamName
function (lines 281-284) accepts a team number and name as arguments. Each of two if
statements determines which team is being named and then saves the new name to a variable in the scoreboard
class.
Housekeeping
The score
, timeouts
, and period
functions (lines 286-312) accept the team1
and team2
arguments (period
only accepts a period number) which are the amounts to change each value. Negative values are allowed so that values can be corrected or reset for the next game or period. Note also that the variables are preceded with int
. All arguments from CherryPy come in as strings, so they have to be converted to integers before they can be used algebraically.
Finally, the two newly adjusted values are returned, separated by a colon, to go back to the JavaScript function and be split into the two scores so that the control screen is updated properly.
The JavaScript versions of these functions are generated in the index
function (not included in the listing). The score
function, for example, creates a JavaScript object and adds obj.team1
and obj.team2
:
function score ( team1 , team2 ) {{ obj = new Object(); obj.team1 = team1 obj.team2 = team2
These are the score deltas (amounts to change) for each team. jQuery [6] (represented by the $
) then posts the object to the address score
(the Python CherryPy function described above):
$.post ( "score" , obj , function ( data ) {{ scoreParts = data.split ( ":" ); $ ( "#team1score" ).html ( scoreParts [ 0 ] ); $ ( "#team2score" ).html ( scoreParts [ 1 ] ); }} );
obj
has the values to send that were set up earlier. When post
finishes and receives a response, function ( data )
is called, where data
is the returned string with the newly updated scores (or timeouts or period). First, the data is split into its two parts with data.split
, and then the score on the control page is updated with jQuery.
Buy this article as PDF
(incl. VAT)