Hackster is hosting Hackster Holidays, Ep. 5: Livestream & Giveaway Drawing. Watch previous episodes or stream live on Friday!Stream Hackster Holidays, Ep. 5 on Friday!
Don Mitchinson
Published © GPL3+

Keen Eye Curling Counter

OpenCV and Raspberry Pi to process camera video of curling rings and scoreboard to reliably count rocks in rings and interpret scoreboard..

IntermediateWork in progress2 days113
Keen Eye Curling Counter

Things used in this project

Hardware components

Raspberry Pi 4 Model B
Raspberry Pi 4 Model B
×1
1080p HD Bullet Security Camera
×1
HD Multiplexer
×1
1080P MINI VGA to HDMI-compatible Converter
×1
VGA male to male extension cable
×1
AIXXCO 4K Video USB capture HDMI card
×1
Security Camera Cable 50'
×1
Security Camera Cable 150'
×1
Camera Module V2
Raspberry Pi Camera Module V2
Cheaper option to use this and a Pi to read scoreboard instead of security camera
×1

Software apps and online services

OpenCV
OpenCV
Raspbian
Raspberry Pi Raspbian
Tesseract OCR

Story

Read more

Code

Google Colab code that estimates the scoring rocks

Python
A Google Colab Notebook that was used to test on uploaded ring images to see how it functioned with fixed images
from math import dist

import numpy as np
import cv2
from google.colab import drive
from google.colab.patches import cv2_imshow

cv2.destroyAllWindows()

# Next step is make sure measured rocks are touching rings
# distance between ((cCircle to cRock) - rRock) should be less than rCircle
# rRings = 72"; rRock = 5.7" (different for rinks and even rocks)
# rRockInPixels = rRingsPixels/72.0 * 5.7

# Accessing My Google Drive
drive.mount('/content/drive')
path = "/content/drive/My Drive/OpenCV/CurlingRingsFilled06.jpg" # Add the path of the image here

rockRatio = 5.70/72.0 # ratio of 12' ring radius vs rock radius in inches

# Read in colored image
image = cv2.imread(path, cv2.IMREAD_COLOR) # reads in as BGR
imgCircles= image.copy()

def rockInRings(rockDistance, ringRadius):
  print('Rock Distance', rockDistance, '  Ring Radius:', ringRadius)
  rockEdge = rockDistance - (ringRadius*rockRatio)
  print('Edge of Rock:', rockEdge)
  if int(rockEdge) <= ringRadius:
    return True
  else:
    return False

def measureDistance(x1, y1, cx, cy):
    pt1 = (x1,y1)
    pt2 = (cx,cy)
    return dist(pt1,pt2)

# Courtesy https://stackoverflow.com/a/59890380/2419128
def colorOfRock(image):

  hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)

  # Red rocks
  # Range for lower red
  red_lower = np.array([0,120,70])
  red_upper = np.array([10,255,255])
  mask_red1 = cv2.inRange(hsv, red_lower, red_upper)

  # Range for upper range
  red_lower = np.array([170,120,70])
  red_upper = np.array([180,255,255])
  mask_red2 = cv2.inRange(hsv, red_lower, red_upper)

  mask_red = mask_red1 + mask_red2

  red_output = cv2.bitwise_and(image, image, mask=mask_red)
  red_ratio=(cv2.countNonZero(mask_red))/(image.size/3)
  red_ratio = np.round(red_ratio*100, 0)
  #print("Red in image", red_ratio)
  if red_ratio < 20: red_ratio = 0

  # Yellow rocks
  # Range for upper range
  yellow_lower = np.array([20, 100, 100])
  yellow_upper = np.array([30, 255, 255])
  mask_yellow = cv2.inRange(hsv, yellow_lower, yellow_upper)

  yellow_output = cv2.bitwise_and(image, image, mask=mask_yellow)
  yellow_ratio = (cv2.countNonZero(mask_yellow))/(image.size/3)
  yellow_ratio = np.round(yellow_ratio*100, 0)
  #print("Yellow in image", yellow_ratio)
  if int(yellow_ratio) < 20: yellow_ratio = 0

  if (int(yellow_ratio) == int(red_ratio) == 0): return None
  elif (red_ratio > yellow_ratio): return 'red'
  else: return 'yellow'

def getRGB(img, x_center, y_center, radius):
  circle_img = np.zeros((img.shape[0],img.shape[1]), np.uint8)
  cv2.circle(circle_img,(x_center,y_center),radius,(255,255,255),-1)
  rgbNum = cv2.mean(img, mask=img)[::-1]

rocks = []
distances = []
confidence=[]

blur = cv2.medianBlur(imgCircles, 5)
blur = cv2.cvtColor(blur, cv2.COLOR_BGR2GRAY)
#hist = cv2.equalizeHist(gray)
#blur = cv2.GaussianBlur(hist, (5,5), cv2.BORDER_DEFAULT)

height, width = blur.shape[:2]

# height and width of the image frame
print(height, width) # 906 1070

# ---- middle 8' rings
# Apply Hough Circle Transform
# param1 - threshold value for the Canny edge detector
# param2 -  accumulator threshold

#minR = round(increment * 2.6)
#maxR = round(increment * 3.25)
#closest = round(increment * 2.5)
increment = height / 9.0
#minR = round(increment * 2.6)
#maxR = round(increment * 3.25)
#closest = round(increment * 2.5)

if height > 1000: # more of an issue with zoomed frame that cuts off outer rings
    minR = round(height / 2.0)
    maxR = round(height / 1.25)
else: # also issue with extra details at bottom
    minR = round(height / 2.5)
    maxR = round(height / 1.5)
closest = minR

print('Looking for Rings (min,max,closest): ', minR, maxR, closest)

circles = cv2.HoughCircles(blur, cv2.HOUGH_GRADIENT, 1, closest, param1=14, param2=25, minRadius=minR, maxRadius=maxR)
if circles is not None:
  circles = np.round(circles[0, :]).astype("int")

  # Draw circles on the original image
  for (cX, cY, cR) in circles:
    cv2.circle(imgCircles, (cX, cY), cR, (0, 255, 0), 1)
    print('circle radius: ' + str(cR))
    print('Detected Ring Center: ',str(cX),str(cY))
    break # exit after first one - it is the best

  # Display the big circles
  cv2_imshow(imgCircles)
else:
  print('No Rings Found')

# --- look for rock shapes
# estimate image-independent parameters for rocks
# 906 1070
# rock limits
increment = increment / 4.0
minR = round(increment)
maxR = round(minR + increment/1.5)
closest = round(minR*1.5)
print('Rocks: ', minR, maxR, closest)

original = image.copy()
bgr = image.copy()

# ---- try for rocks
gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (3,3), cv2.BORDER_DEFAULT)

# Apply Hough Circle Transform
circles = cv2.HoughCircles(blur, cv2.HOUGH_GRADIENT, 1,  closest, param1=15, param2=30, minRadius=minR, maxRadius=maxR)
#circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, 1,  closest, param1=10, param2=30, minRadius=minR, maxRadius=maxR)

TEXT_FACE = cv2.FONT_HERSHEY_DUPLEX
TEXT_SCALE = 0.8
TEXT_THICKNESS = 1

# Convert the (x, y) coordinates and radius of the circles to integers
if circles is not None:
  circles = np.round(circles[0, :]).astype("int")

  # Draw circles on the original image
  i = 0
  for (x, y, radius) in circles:
    i = i + 1

    cv2.circle(original, (x, y), radius, (0, 255, 0), 1)
    #print('center of rock: ' + str(i), str(x), str(y), str(radius))
    TEXT = "ROCK " + str(i)

    text_size, _ = cv2.getTextSize(TEXT, TEXT_FACE, TEXT_SCALE, TEXT_THICKNESS)
    text_origin = (x+2,y+2) #  offset off from handle
    (b, g, r) = image[y, x]
    #print('rock ' + str(i) + ' radius: ' + str(radius))
    #print("Pixel: Red: {}, Green: {}, Blue: {}".format(r, g, b))
    colorBGR = (int(b), int(g), int(r))

    rectX1 = int(x - radius)
    rectX2 = int(rectX1+2.0*radius)

    if(rectX1 < 0) or (rectX2 > width):
      print(' X coords outside boundary' + str(rectX1), str(rectX2))
      rectX1=rectX1 + 2
      rectX2=rectX2 - 2

    rectY1 = int(y - radius)
    rectY2 = int(rectY1+2.0*radius)
    if(rectY1 < 0) or (rectY2 > height):
      print(' Y coords outside boundary' + str(rectY1), str(rectY2))
      rectY1=rectY1 + 2
      rectY2=rectY2 - 2

    #print(rectX1, rectX2, rectY1, rectY2)
    croppedImg = bgr[rectY1:rectY2, rectX1:rectX2]

    print(TEXT)
    cv2_imshow(croppedImg)

    color = colorOfRock(croppedImg)
    if color is not None:
      print('ROCK COLOR: ' + color + '\n')
      measure = (measureDistance(x, y, cX, cY))
      if rockInRings(measure, cR):
        rocks.append(color)
        distances.append(measure)
      else:
        print('Ignoring Rock outside rings')
    else:
      print('ROCK COLOR N/A')

    imagetxt = cv2.putText(original, TEXT, text_origin, TEXT_FACE, TEXT_SCALE, colorBGR, TEXT_THICKNESS, cv2.LINE_AA)

  # Display the rocks
  cv2_imshow(original)

else:
  print('No Rocks Found')


print('rocks to measure:' + str(len(rocks)))
score = 0
scoringColor = ""
lastRock = ""

# Set confidence level for scoring based on radius of rings in pixels
confidenceLevel100 = cR *.0375
print('Max Confidence Distance: ', confidenceLevel100)

if (len(rocks) > 0):
    distances, rocks = zip(*sorted(zip(distances,rocks)))
    print(distances)
    print(rocks)

    for i in range(len(rocks)):
        if (lastRock == ''):
            scoringColor = rocks[i]
        elif (rocks[i] != lastRock):
            lastNonScoringRock = i
            break
        score = score + 1
        lastRock = rocks[i]
        distanceBetweenRings = abs(cR - distances[i])
        if distanceBetweenRings >= confidenceLevel100:
            scoringConfidence = 1.0
        else:
            scoringConfidence = (1.0 - abs(distanceBetweenRings - confidenceLevel100)/confidenceLevel100)
        confidence.append(scoringConfidence)

# calculate scoring confidence based on distance between rocks
# and distance from scoring circle radius
if (scoringColor != ''):
  print(scoringColor + ' is sitting ' + str(score))
  estimatedScore = 'ESTIMATED that ' + scoringColor + ' is sitting ' + str(score)
  distanceBetweenRocks = distances[lastNonScoringRock] - distances[lastNonScoringRock-1]
  #print('Distance from last scoring rock: ', distanceBetweenRocks)
  if distanceBetweenRocks >= confidenceLevel100:
    scoringConfidence = 1.0
  else:
    # confidence level goes up as distance between grows
    scoringConfidence = (1.0 - abs(distanceBetweenRocks - confidenceLevel100)/confidenceLevel100)
  confidence.append(scoringConfidence)

  if (lastNonScoringRock < len(rocks)): # check next rock in case it's close too
    distanceBetweenRocks = abs(distances[lastNonScoringRock+1] - distances[lastNonScoringRock])
    #print('Distance from next scoring rock: ', distanceBetweenRocks)
    if distanceBetweenRocks >= confidenceLevel100:
      scoringConfidence = 1.0
    else:
      # confidence level goes up as distance between grows
      scoringConfidence = (1.0 - abs(distanceBetweenRocks - confidenceLevel100)/confidenceLevel100)
    confidence.append(scoringConfidence)
  confidenceLevel = ' ' +  "{0:.1%}.format(sum(confidence)/len(confidence))"
else:
  estimatedScore = 'No Rocks Found in House'
  confidenceLevel = ''

print(estimatedScore + confidenceLevel)

Credits

Don Mitchinson

Don Mitchinson

2 projects • 8 followers
Living off-grid on an ocean inlet north of Powell River BC. Consultant, programmer, database developer and Raspberry Pi enthusiast

Comments