Files
linuxcncAllWasm/lib/python/qtvcp/widgets/camview_widget.py
2026-08-20 06:17:39 +08:00

769 lines
26 KiB
Python

#!/usr/bin/env python3
# Qtvcp camview
#
# Copyright (c) 2017 Chris Morley <chrisinnanaimo@hotmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# use open cv to do camera alignment
import os
import _thread as Thread
import hal
from qtpy import QtWidgets, QtCore
from qtpy.QtGui import QColor, QFont, QPainter, QPen, QImage
from qtvcp.widgets.widget_baseclass import _HalWidgetBase
from qtvcp import logger
# Instiniate the libraries with global reference
# STATUS gives us status messages from linuxcnc
# LOG is for running code logging
if __name__ != '__main__': # This avoids segfault when testing directly in python
from qtvcp.core import Status, Info
STATUS = Status()
INFO = Info()
LOG = logger.getLogger(__name__)
#LOG.setLevel(logger.VERBOSE) # One of VERBOSE, DEBUG, INFO, WARNING, ERROR, CRITICAL
# Suppress cryptic messages when checking for useable ports
os.environ["OPENCV_LOG_LEVEL"]="FATAL"
# If the library is missing don't crash the GUI
# send an error and just make a blank widget.
LIB_GOOD = True
try:
import cv2 as CV
DEFAULT_API = CV.CAP_ANY
except:
LOG.error('Qtvcp Error with camview - is python3-opencv installed?')
LIB_GOOD = False
DEFAULT_API = 0 # CV.CAP_ANY
import numpy as np
class CamView(QtWidgets.QWidget, _HalWidgetBase):
def __init__(self, parent=None):
super(CamView, self).__init__(parent)
self._qImageFormat=QImage.Format_RGB888
self.video = None
self.grabbed = None
self.frame = None
self._camNum = 0
self.api = DEFAULT_API
self.resolution=None
self.diameter = 20
self.rotation = 0
self.rotationIncrement = .5
self.scale = 1
self.scaleX = 1.0
self.scaleY = 1.0
self.gap = 5
self._noRotate = False
self.setWindowTitle('Cam View')
self.setGeometry(100, 100, 200, 200)
self.text_color = QColor(255, 255, 255)
self.circle_color = QtCore.Qt.red
self.cross_color = QtCore.Qt.yellow
self.cross_pointer_color = QtCore.Qt.white
self.font = QFont("arial,helvetica", 40)
self.SUB = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
if LIB_GOOD:
self.text = 'No Image'
else:
self.text = 'Missing\npython-opencv\nLibrary'
self.pix = None
self.degree = str("\N{DEGREE SIGN}")
# trap so can run script directly to test
try:
if INFO.PROGRAM_PREFIX is not None:
self.user_path = os.path.expanduser(INFO.PROGRAM_PREFIX)
except:
self.user_path = (os.path.join(os.path.expanduser('~'), 'linuxcnc/nc_files'))
#self.blobInit()
def _hal_init(self):
try:
self.pin_ = self.HAL_GCOMP_.newpin('cam-rotation',hal.HAL_FLOAT, hal.HAL_OUT)
except:
pass
if LIB_GOOD:
STATUS.connect('periodic', self.nextFrameSlot)
##################################
# no button scroll = circle diameter
# left button scroll = zoom
# right button scroll = cross hair rotation
##################################
def wheelEvent(self, event):
super(CamView, self).wheelEvent(event)
mouse_state = QtWidgets.QApplication.mouseButtons()
if event.angleDelta().y() < 0:
if mouse_state == QtCore.Qt.NoButton:
self.diameter -= self.rotationIncrement
if mouse_state == QtCore.Qt.LeftButton:
self.scale -= .1
if mouse_state == QtCore.Qt.RightButton:
if not self._noRotate:
self.rotation += self.rotationIncrement
else:
if mouse_state == QtCore.Qt.NoButton:
self.diameter += self.rotationIncrement
if mouse_state == QtCore.Qt.LeftButton:
self.scale += .1
if mouse_state == QtCore.Qt.RightButton:
if not self._noRotate:
self.rotation -= self.rotationIncrement
self.limitChecks()
def mousePressEvent(self, event):
if event.button() & QtCore.Qt.LeftButton:
if not self._noRotate:
self.rotation += self.rotationIncrement
self.limitChecks()
elif event.button() & QtCore.Qt.RightButton:
if not self._noRotate:
self.rotation -= self.rotationIncrement
self.limitChecks()
elif event.button() & QtCore.Qt.MiddleButton:
self.rotation_increments_changed()
def mouseDoubleClickEvent(self, event):
if event.button() & QtCore.Qt.LeftButton:
self.scale = 1
elif event.button() & QtCore.Qt.RightButton:
self.rotation = 0
elif event.button() & QtCore.Qt.MiddleButton:
self.diameter = 20
def zoom_in(self):
if self.scale >= 5:
return
self.scale += 0.1
def zoom_out(self, event):
if self.scale <= 1:
return
self.scale -= 0.1
def limitChecks(self):
w = self.size().width()
if self.diameter < 2: self.diameter = 2
if self.diameter > w: self.diameter = w
if self.rotation > 360 - self.rotationIncrement: self.rotation = 0
if self.rotation < 0: self.rotation = 360 - self.rotationIncrement
if self.scale < 1: self.scale = 1
if self.scale > 5: self.scale = 5
def nextFrameSlot(self, w):
if not self.video: return
if not self.isVisible(): return
############################
# capture a freme from cam
############################
ret, frame = self.video.read()
if not ret: return
# set digital zoom
frame = self.zoom(frame, self.scale)
# invert image
frame = self.flip(frame)
# make a Q image
self.pix = self.makeImage(frame, self._qImageFormat)
# make a CV window for testing
#self.makeCVImage(self.canny(self.convertToGray(frame)))
# repaint the window
self.update()
try:
self.pin_.set(self.rotation)
except:
pass
def convertToRGB(self, img):
return CV.cvtColor(img, CV.COLOR_BGR2RGB)
# new qt 5.13 + for QImage.Format_Grayscale16
def convertToGray(self, img):
return CV.cvtColor(img, CV.COLOR_BGR2GRAY)
def blur(self, img, B=7):
return CV.GaussingBlur(img, (B,B), CV.BORDER_DEFAULT)
def canny(self, img, x=125, y=175):
return CV.Canny(img , x, y)
def makeImage(self, image, qFormat=QImage.Format_RGB888):
img = self.convertToRGB(image)
return QImage(img, img.shape[1], img.shape[0], img.strides[0], qFormat)
# openCV Window
def makeCVImage(self, frame):
CV.imshow('CV Image',frame)
def rescaleFrame(self, frame, scale=1, scale_x=1.0, scale_y=1.0):
x = abs(scale_x * scale)
y = abs(scale_y * scale)
return CV.resize(frame, None, fx = x, fy = y, interpolation=CV.INTER_CUBIC)
def zoom(self, frame, scale):
# get original size of image
(oh, ow) = frame.shape[:2]
#############################
# scale image
#############################
frame = self.rescaleFrame(frame, scale,self.scaleX,self.scaleY)
##########################
# crop to the original size of the frame
# measure from center, so we zoom on center
# ch = center of current height
# coh = center of original height
##########################
(h, w) = frame.shape[:2]
ch = int(h/2)
cw = int(w/2)
coh = int(oh/2)
cow = int(ow/2)
# NOTE: its img[y: y + h, x: x + w]
return frame[ch-coh:ch+coh, cw-cow:cw+cow]
# flip the image based on if scaleX and scaleY are negative
def flip(self, image):
if self.scaleX > 0 and self.scaleY > 0:
return image
elif self.scaleX < 0 and self.scaleY < 0:
flip = -1
elif self.scaleX < 0:
flip = 0
else:
flip = 1
return CV.flip(image, flip)
# draw a circle around small holes
#
def findCircles(self,frame):
# Our operations on the frame come here
gray = CV.cvtColor(frame, CV.COLOR_BGR2GRAY)
# Display the resulting frame
circles = CV.HoughCircles(gray,CV.cv.CV_HOUGH_GRADIENT,1,20,param1=50,param2=30,minRadius=10,maxRadius=15)
# print circles
if circles is not None:
#print circles
circles = np.uint16(np.around(circles))
for i in circles[0,:]:
# draw the outer circle
CV.circle(gray,(i[0],i[1]),i[2],(246,11,11),1)
# draw the center of the circle
CV.circle(gray,(i[0],i[1]),2,(246,11,11),1)
CV.imshow('Circles',gray)
def blobInit(self):
# Setup BlobDetector
detector = CV.SimpleBlobDetector()
params = CV.SimpleBlobDetector_Params()
# Filter by Area.
params.filterByArea = True
params.minArea = 20000
params.maxArea = 40000
# Filter by Circularity
params.filterByCircularity = True
params.minCircularity = 0.5
# Filter by Convexity
params.filterByConvexity = False
#params.minConvexity = 0.87
# Filter by Inertia
params.filterByInertia = True
params.minInertiaRatio = 0.8
# Distance Between Blobs
params.minDistBetweenBlobs = 200
# Create a detector with the parameters
self.detector = CV.SimpleBlobDetector(params)
# find circles overlay and draw a cross
# https://www.kurokesu.com/main/2016/07/25/advanced-opencv-3-python-hole-detection/
def findBlob(self, image):
overlay = image.copy()
keypoints = self.detector.detect(image)
for k in keypoints:
CV.circle(overlay, (int(k.pt[0]), int(k.pt[1])), int(k.size/2), (0, 0, 255), -1)
CV.line(overlay, (int(k.pt[0])-20, int(k.pt[1])), (int(k.pt[0])+20, int(k.pt[1])), (0,0,0), 3)
CV.line(overlay, (int(k.pt[0]), int(k.pt[1])-20), (int(k.pt[0]), int(k.pt[1])+20), (0,0,0), 3)
opacity = 0.5
CV.addWeighted(overlay, opacity, image, 1 - opacity, 0, image)
# Uncomment to resize to fit output window if needed
#image = CV.resize(im, None,fx=0.5, fy=0.5, interpolation = CV.INTER_CUBIC)
CV.imshow("Output", image)
def showEvent(self, event):
if LIB_GOOD:
if self.video is None:
try:
self.video = WebcamVideoStream(src=self._camNum, api=self.api, res = self.resolution )
if not self.video.isOpened():
p = self.video.list_ports(api=self.api)[1]
self.text = 'Error with video {}\nAvailable ports:\n{}'.format(self._camNum, p)
else:
self.video.start()
except Exception as e:
LOG.error('Video capture error: {}'.format(e))
else:
self.video.start()
def hideEvent(self, event):
if LIB_GOOD:
try:
self.video.stop()
except:
pass
def resizeEvent(self, event):
# Create a square base size of 10x10 and scale it to the new size
# maintaining aspect ratio.
new_size = QtCore.QSize(10, 10)
new_size.scale(event.size(), QtCore.Qt.KeepAspectRatio)
self.resize(new_size)
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
if self.pix:
qp.drawImage(self.rect(), self.pix)
self.drawText(event, qp)
self.drawCircle(event, qp)
self.drawCrossHair(event, qp)
qp.end()
def drawText(self, event, qp):
size = self.size()
w = size.width()
h = size.height()
qp.setPen(self.text_color)
qp.setFont(self.font)
if self._noRotate:
inc =''
else:
inc = str(self.rotationIncrement).translate(self.SUB)
if self.pix:
qp.drawText(self.rect(), QtCore.Qt.AlignTop, '{:.1f}{} {:>}'.format(self.rotation,self.degree,
inc))
else:
qp.drawText(self.rect(), QtCore.Qt.AlignCenter, self.text)
def drawCircle(self, event, gp):
size = self.size()
w = size.width()
h = size.height()
radx = self.diameter/2
rady = self.diameter/2
# draw red circles
gp.setPen(self.circle_color)
center = QtCore.QPoint(w//2, h//2)
gp.drawEllipse(center, radx, rady)
def drawCrossHair(self, event, gp):
size = self.size()
w = size.width()//2
h = size.height()//2
pen0 = QPen(self.cross_pointer_color, 1, QtCore.Qt.SolidLine)
pen = QPen(self.cross_color, 1, QtCore.Qt.SolidLine)
gp.translate(w, h)
gp.rotate(-self.rotation)
gp.setPen(pen0)
gp.drawLine(0, 0-self.gap, 0, -h)
gp.setPen(pen)
gp.drawLine(-w, 0, 0-self.gap, 0)
gp.drawLine(0+self.gap, 0, w, 0)
gp.drawLine(0, 0+self.gap, 0, h)
def rotation_increments_changed(self,w=None):
if self.rotationIncrement == 5.00:
self.rotationIncrement = 1
elif self.rotationIncrement == 1:
self.rotationIncrement = 0.5
elif self.rotationIncrement == 0.5:
self.rotationIncrement = 0.1
else:
self.rotationIncrement = 5
def setCircleColor(self, color):
self.circle_color = color
def setCrossColor(self, color):
self.cross_color = color
def setPointerColor(self, color):
self.cross_pointer_color = color
def saveImage(self):
filepath = '{}/camImage.png'.format(self.user_path)
self.video.writeFrame(filepath)
# if data is a string or int, search for name/int in library and use it.
def setAPI(self, data):
if not LIB_GOOD:
return
camera_backends = CV.videoio_registry.getCameraBackends()
if isinstance(data,str):
# should we use any backend option?
if data in ('','ANY','DEFAULT'):
self.api = 0
return
# is it a string of a number?
if data.isdigit():
if int(data) in camera_backends:
self.api = int(data)
else:
LOG.warning(f'Backend {data} not found.')
return
# ok it's a name
for backend in camera_backends:
if CV.videoio_registry.getBackendName(backend) == data:
self.api = backend
break
else:
LOG.warning(f'Backend {data} not found.')
elif isinstance(data,int):
if data in camera_backends:
self.api = data
else:
LOG.warning(f'Backend {data} not found.')
# returns the api backend name from backend int
def getAPIName(self, num):
return CV.videoio_registry.getBackendName(self.api)
# sets the resolution tuple (width,height) from a string 'width,height'
def setResolution(self, data):
if isinstance(data,str):
if not data.upper() in('','DEFAULT','NONE'):
data = tuple(int(x) for x in data.split(','))
self.resolution = data
#########################################################################
# This is how designer can interact with our widget properties.
# designer will show the Property properties in the editor
# it will use the get set and reset calls to do those actions
#
# These can also be set as WIDGET.setProperty('property_name', data)
########################################################################
def set_wheel_rotation(self, value):
self._noRotate = value
def get_wheel_rotation(self):
return self._noRotate
def reset_wheel_rotation(self):
self._noRotate = False
def set_camnum(self, value):
self._camNum = value
def get_camnum(self):
return self._camNum
def reset_camnum(self):
self._camNum = 0
# designer will show these properties in this order:
block_wheel_rotation = QtCore.Property(bool, get_wheel_rotation, set_wheel_rotation, reset_wheel_rotation)
camera_number = QtCore.Property(int, get_camnum, set_camnum, reset_camnum)
class WebcamVideoStream:
def __init__(self, src=0, api=DEFAULT_API, res=None):
self.possible_resolutions = []
# initialize the video camera stream and read the first frame
# from the stream
flag = True
camera_backends = CV.videoio_registry.getCameraBackends()
for backend in camera_backends:
if backend == api: flag = False
LOG.verbose('Available backend library:{}'.format(CV.videoio_registry.getBackendName(backend)))
if flag and api != CV.CAP_ANY:
LOG.error('OpenCV backend library {} not available'.format(CV.videoio_registry.getBackendName(api)))
LOG.debug("Using {} backend".format(CV.videoio_registry.getBackendName(api)))
self.stream = self.openStream(src, api, res)
if not (self.stream.isOpened()):
self.stream.release()
LOG.error('Could not open video device {}'.format(src))
# get a list of ports
plist = self.list_ports(api)[1]
if src not in plist:
LOG.error('port number {}, is not a working port- trying: {}'.format(src,plist[0]))
# try again with a hopefully functioning port
if plist != []:
self.stream = self.openStream(plist[0], api, res)
width = int(self.stream.get(CV.CAP_PROP_FRAME_WIDTH))
height = int(self.stream.get(CV.CAP_PROP_FRAME_HEIGHT))
LOG.info(f"Current camera resolution: {width}x{height}")
# initialize the variable used to indicate if the thread should
# be stopped
self.stopped = False
self.grabbed = None
self.frame = None
def isOpened(self):
try:
return self.stream.isOpened()
except:
return False
def openStream(self, src, api, res=None):
# initialize the video camera stream and read the first frame
# from the stream
try:
stream = CV.VideoCapture(src, api)
except Exception as e:
log.debug(e)
# try using an older function signature
stream = CV.VideoCapture(src)
# seems only val2 is successful changing resolutions
if not api == CV.CAP_ANY:
w,h = self.getBestResolution(stream)
# look for requested resolution
if not res is None and res in self.possible_resolutions:
w,h=res
stream.set(CV.CAP_PROP_FRAME_WIDTH, w)
stream.set(CV.CAP_PROP_FRAME_HEIGHT, h)
# last ditch - sometimes the default resolution can not be changed
# if the height is 0 - failed, so just try default
actual_height = int(stream.get(CV.CAP_PROP_FRAME_HEIGHT))
if actual_height == 0:
stream.release()
stream = CV.VideoCapture(src, api)
return stream
def getBestResolution(self, stream):
resolutions_to_test = [
(320, 240), (640, 480), (800, 600), (1024, 768),
(1280, 720), (1920, 1080)
]
start_width = int(stream.get(CV.CAP_PROP_FRAME_WIDTH))
start_height = int(stream.get(CV.CAP_PROP_FRAME_HEIGHT))
for width, height in resolutions_to_test:
stream.set(CV.CAP_PROP_FRAME_WIDTH, width)
stream.set(CV.CAP_PROP_FRAME_HEIGHT, height)
actual_width = int(stream.get(CV.CAP_PROP_FRAME_WIDTH))
actual_height = int(stream.get(CV.CAP_PROP_FRAME_HEIGHT))
if actual_width == width and actual_height == height:
if not (actual_width,actual_height) in self.possible_resolutions:
self.possible_resolutions.append((actual_width,actual_height))
elif actual_width !=0 and actual_height !=0:
self.possible_resolutions.append((actual_width,actual_height))
else:
LOG.verbose(f"Resolution {width}x{height} is NOT supported (actual: {actual_width}x{actual_height}).")
LOG.verbose("All possible resolutions found:")
for res in self.possible_resolutions:
LOG.verbose(res)
try:
best = self.possible_resolutions[len(self.possible_resolutions)-1]
LOG.debug('Best resolution:{}'.format(best))
except:
return (start_width,start_height)
return best
def start(self):
# start the thread to read frames from the video stream
self.stopped = False
Thread.start_new_thread(self._update, ())
return self
def _update(self):
# keep looping infinitely until the thread is stopped
while True:
# if the thread indicator variable is set, stop the thread
if self.stopped:
return
# otherwise, read the next frame from the stream
(self.grabbed, self.frame) = self.stream.read()
def read(self):
# return the frame most recently read
return (self.grabbed, self.frame)
def stop(self):
# indicate that the thread should be stopped
self.stopped = True
# TODO path checking
def writeFrame(self, filepath):
CV.imwrite(filepath, self.frame)
print('saved camview image to: {}'.format(filepath))
def list_ports(self,api):
"""
Test the ports and returns a tuple with the available ports and the ones that are working.
"""
non_working_ports = []
dev_port = 0
working_ports = []
available_ports = []
while len(non_working_ports) < 6: # if there are more than 5 non working ports stop the testing.
camera = CV.VideoCapture(dev_port, api)
if not camera.isOpened():
non_working_ports.append(dev_port)
LOG.debug("Port %s is not working." %dev_port)
else:
is_reading, img = camera.read()
w = camera.get(3)
h = camera.get(4)
if is_reading:
LOG.debug("Port %s is working and reads images (%s x %s)" %(dev_port,w,h))
working_ports.append(dev_port)
else:
LOG.debug("Port %s for camera ( %s x %s) is present but does not read." %(dev_port,w,h))
available_ports.append(dev_port)
dev_port +=1
camera.release()
return available_ports,working_ports,non_working_ports
class CamAngle(CamView):
def __init__(self, parent=None):
super(CamAngle, self).__init__(parent)
def mouseDoubleClickEvent(self, event):
if event.button() & QtCore.Qt.LeftButton:
# zoom
self.scale = 1
elif event.button() & QtCore.Qt.MiddleButton:
self.diameter = 40
def wheelEvent(self, event):
mouse_state = QtWidgets.QApplication.mouseButtons()
size = self.size()
w = size.width()
if event.angleDelta().y() < 0:
if mouse_state == QtCore.Qt.NoButton:
self.diameter -= 2
if mouse_state == QtCore.Qt.LeftButton:
self.scale -= .1
else:
if mouse_state == QtCore.Qt.NoButton:
self.diameter += 2
if mouse_state == QtCore.Qt.LeftButton:
self.scale += .1
if self.diameter < 2: self.diameter = 2
if self.diameter > w: self.diameter = w
if self.scale < 1: self.scale = 1
if self.scale > 5: self.scale = 5
def drawText(self, event, qp):
size = self.size()
w = size.width()
h = size.height()
qp.setPen(self.text_color)
qp.setFont(self.font)
if self.pix:
angle = 0.0 if self.rotation == 0 else 360 - self.rotation
qp.drawText(self.rect(), QtCore.Qt.AlignTop, '{:0.3f}{}'.format(angle, self.degree))
else:
qp.drawText(self.rect(), QtCore.Qt.AlignCenter, self.text)
if __name__ == '__main__':
import sys
from qtpy.QtWidgets import (QLabel, QSlider, QDial,QWidget, QVBoxLayout,
QHBoxLayout)
from qtpy.QtCore import (Qt)
def hDialMoved():
print("Dial value = %i" % (hdial.value()))
capture.scaleX = hdial.value()/100
def vDialMoved():
print("VDial value = %i" % (vdial.value()))
capture.scaleY = vdial.value()/100
def setScale():
print("slider value = %i" % (slider.value()))
capture.scale = slider.value()/100
app = QtWidgets.QApplication(sys.argv)
w = QWidget()
w.setGeometry(100, 100, 400, 200)
w.setWindowTitle('Bar widget')
layout = QVBoxLayout(w)
hlyt = QHBoxLayout()
capture = CamAngle()
slider = QSlider(Qt.Horizontal)
slider.setMinimum(100)
slider.setMaximum(400)
slider.setSingleStep(10)
slider.setPageStep(100)
slider.valueChanged.connect(setScale)
slider.setValue(100)
hdial = QDial()
hdial.setMinimum(100)
hdial.setMaximum(200)
hdial.setValue(100)
hdial.valueChanged.connect(hDialMoved)
vdial = QDial()
vdial.setMinimum(100)
vdial.setMaximum(200)
vdial.setValue(100)
vdial.valueChanged.connect(vDialMoved)
hlyt.addWidget(capture)
hlyt.addWidget(hdial)
hlyt.addWidget(vdial)
layout.addLayout(hlyt)
layout.addWidget(slider)
def jump():
capture.nextFrameSlot(None)
timer = QtCore.QTimer()
timer.timeout.connect(jump)
timer.start(100)
w.show()
sys.exit(app.exec())