Scoreboards and Video Routing in Python

Scorekeeper

© Photo by Johnny Briggs on Unsplash

© Photo by Johnny Briggs on Unsplash

Article from Issue 273/2023
Author(s):

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).

Figure 1: Video system signal flow. Displays shown in blue can be seen in the control room photo (Figure 2).
Figure 2: The video control room is a converted classroom. From left to right, the first two monitors in the background are the main displays of the NUCs running the scoreboard software. The text window is the output of the Python program, and the scoreboard itself is on the secondary monitor output. The third monitor is routable from the video matrix to select what is shown. In the foreground are the matrix control touchscreen and the camera controller. The tall monitor is the switcher multiview. The NUCs and the matrix are visible at the very bottom of the rack under the table.

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).

Figure 3: The servers, clients, displays, and their associated libraries communicate and interact with each other.
Figure 4: The scorekeeper's scoreboard interface.

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

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • Nerf Target Game

    A cool Nerf gun game for a neighborhood party provides a lesson in Python coding with multiple processors.

  • Gesture-Controlled Book

    Have you found yourself following instructions on a device for repairing equipment or been half-way through a recipe, up to your elbows in grime or ingredients, then needed to turn or scroll down a page? Wouldn't you rather your Raspberry Pi do the honors?

  • ReportLab and Panda3D

    A game of bingo illustrates how to use the ReportLab toolkit and Panda3D real-time 3D engine.

  • Panda3D

    Several free game engines are available for Linux users, but programming with them is often less than intuitive. Panda3D is an easy-to-use engine that is accessible enough for newcomers but still powerful enough for the pros at Disney Studios.

  • Perl: Skydiving Simulation

    Computer game programmers apply physical formulas and special tricks to create realistic animations. Simple DirectMedia Layer (SDL), which is available as a Perl wrapper, provides a powerful framework for creating simple 2D worlds with just a couple of lines of code.

comments powered by Disqus