#!/usr/bin/env python3 # Qtvcp camview # # Copyright (c) 2017 Chris Morley # # 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())