From b9a9af0ad49b23b85114e8dee44bd5568591ffcd Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 15:03:58 +0200 Subject: [PATCH 01/99] first RC of TAMV3 --- .gitignore | 7 +- DuetWebAPI.py | 616 ----- Flowcharts/00 - Main Program Launch.dia | Bin 0 -> 1331 bytes Flowcharts/01 - TAMV GUI Init.dia | Bin 0 -> 4022 bytes Flowcharts/02 - Signals.dia | Bin 0 -> 8331 bytes Flowcharts/02 - Signals.dia~ | Bin 0 -> 8332 bytes Flowcharts/Endstop Calibration | Bin 0 -> 1867 bytes Flowcharts/Endstop Calibration~ | Bin 0 -> 1718 bytes LICENSE | 22 - README.md | 242 -- TAMV.py | 2095 +++++++++++++++ TAMV_GUI.py | 2382 ----------------- ZTATP.py | 257 -- drivers.json | 1 + drivers/API_template.py | 771 ++++++ drivers/DuetWebAPI.py | 1278 +++++++++ install_opencv.sh | 22 - modules/Camera.py | 203 ++ modules/DetectionManager.py | 552 ++++ modules/PrinterManager.py | 451 ++++ modules/SettingsDialog.py | 760 ++++++ modules/StatusTipFilter.py | 8 + modules/__init__.py | 0 plot.py | 203 -- requirements.txt | 14 - resources/TAMV flow.txt | 126 + .../01 - Connect and capture CP twice.txt | 12 + .../02 - Automated CP Capture.txt | 16 + resources/background.png | Bin 0 -> 38747 bytes resources/error.png | Bin 0 -> 13311 bytes jubilee.png => resources/jubilee.png | Bin resources/nozzle.jpg | Bin 0 -> 65885 bytes resources/signals.txt | 49 + resources/standby.jpg | Bin 0 -> 57205 bytes resources/testThreads.py | 117 + video_cap.png => resources/video_cap.png | Bin 36 files changed, 6444 insertions(+), 3760 deletions(-) delete mode 100644 DuetWebAPI.py create mode 100644 Flowcharts/00 - Main Program Launch.dia create mode 100644 Flowcharts/01 - TAMV GUI Init.dia create mode 100644 Flowcharts/02 - Signals.dia create mode 100644 Flowcharts/02 - Signals.dia~ create mode 100644 Flowcharts/Endstop Calibration create mode 100644 Flowcharts/Endstop Calibration~ delete mode 100644 LICENSE delete mode 100644 README.md create mode 100644 TAMV.py delete mode 100755 TAMV_GUI.py delete mode 100755 ZTATP.py create mode 100644 drivers.json create mode 100644 drivers/API_template.py create mode 100644 drivers/DuetWebAPI.py delete mode 100755 install_opencv.sh create mode 100644 modules/Camera.py create mode 100644 modules/DetectionManager.py create mode 100644 modules/PrinterManager.py create mode 100644 modules/SettingsDialog.py create mode 100644 modules/StatusTipFilter.py create mode 100644 modules/__init__.py delete mode 100755 plot.py delete mode 100644 requirements.txt create mode 100644 resources/TAMV flow.txt create mode 100644 resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt create mode 100644 resources/TAMV2.0 Flows/02 - Automated CP Capture.txt create mode 100644 resources/background.png create mode 100644 resources/error.png rename jubilee.png => resources/jubilee.png (100%) create mode 100644 resources/nozzle.jpg create mode 100644 resources/signals.txt create mode 100644 resources/standby.jpg create mode 100644 resources/testThreads.py rename video_cap.png => resources/video_cap.png (100%) diff --git a/.gitignore b/.gitignore index 65aeb0a..3ec061e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ - +*.js *.json +!drivers.json *.pyc **/__pycache__/ *.vscode - *.log +**/log/ +**/config/ +**/exports/ diff --git a/DuetWebAPI.py b/DuetWebAPI.py deleted file mode 100644 index a33af6e..0000000 --- a/DuetWebAPI.py +++ /dev/null @@ -1,616 +0,0 @@ -# Python Script containing a class to send commands to, and query specific information from, -# Duet based printers running either Duet RepRap V2 or V3 firmware. -# -# Does NOT hold open the connection. Use for low-volume requests. -# Does NOT, at this time, support Duet passwords. -# -# Not intended to be a gerneral purpose interface; instead, it contains methods -# to issue commands or return specific information. Feel free to extend with new -# methods for other information; please keep the abstraction for V2 V3 -# -# Copyright (C) 2020 Danal Estes all rights reserved. -# Copyright (C) 2021 Haytham Bennani -# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT -# -# Requires Python3 - -class DuetWebAPI: - import requests - import json - import sys - import time - import datetime - pt = 0 - _base_url = '' - _rrf2 = False - - - def __init__(self,base_url): - self._base_url = base_url - try: - print('Connecting to', base_url, '..') - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=(2,60)) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - j = self.json.loads(r.text) - _=j['coords'] - firmwareName = j['firmwareName'] - - try: - firmwareName = j['firmwareName'] - firmwareVersion = j['firmwareVersion'] - print('Duet Firmware:', firmwareName, '- V'+firmwareVersion) - if firmwareVersion[0] == "2": - self._rrf2 = True - except Exception as e: - self._rrf2 = True - self.pt = 2 - return - except: - try: - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=(2,60)) - j = self.json.loads(r.text) - _=j - self.pt = 3 - return - except: - print(self._base_url," does not appear to be a RRF2 or RRF3 printer", file=self.sys.stderr) - return -#### -# The following methods are a more atomic, reading/writing basic data structures in the printer. -#### - - def printerType(self): - return(self.pt) - - def baseURL(self): - return(self._base_url) - - def getCoords(self): - import time - try: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - if not r.ok: - print('Error in getStatus session: ', r) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - while self.getStatus() not in "idle": - time.sleep(0.5) - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - jc=j['coords']['xyz'] - an=j['axisNames'] - ret=self.json.loads('{}') - for i in range(0,len(jc)): - ret[ an[i] ] = jc[i] - return(ret) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - ja=j['move']['axes'] - ret=self.json.loads('{}') - for i in range(0,len(ja)): - ret[ ja[i]['letter'] ] = ja[i]['userPosition'] - return(ret) - except Exception as e1: - print('Error in getStatus: ',e1 ) - - def getCoordsAbs(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - jc=j['coords']['machine'] - an=j['axisNames'] - ret=self.json.loads('{}') - for i in range(0,len(jc)): - ret[ an[i] ] = jc[i] - return(ret) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - ja=j['move']['axes'] - ret=self.json.loads('{}') - for i in range(0,len(ja)): - ret[ ja[i]['letter'] ] = ja[i]['machinePosition'] - return(ret) - - def getLayer(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=3') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - s = j['currentLayer'] - return (s) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - s = j['job']['layer'] - if (s == None): s=0 - return(s) - - def getG10ToolOffset(self,tool): - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - ja=j['move']['axes'] - jt=j['tools'] - ret=self.json.loads('{}') - to = jt[tool]['offsets'] - for i in range(0,len(to)): - ret[ ja[i]['letter'] ] = to[i] - return(ret) - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - ja=j['axisNames'] - jt=j['tools'] - ret=self.json.loads('{}') - to = jt[tool]['offsets'] - for i in range(0,len(to)): - ret[ ja[i] ] = to[i] - return(ret) - - return({'X':0,'Y':0,'Z':0}) # Dummy for now - - def getNumExtruders(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - jc=j['coords']['extr'] - return(len(jc)) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - return(len(j['move']['extruders'])) - - def getNumTools(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - jc=j['tools'] - return(len(jc)) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - return(len(j['tools'])) - - def getStatus(self): - import time - try: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - if not r.ok: - print('Error in getStatus session: ', r) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - s=j['status'] - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if not self._rrf2: - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - if not r2.ok: - print('Error in getStatus end session: ', r2) - if ('I' in s): return('idle') - if ('P' in s): return('processing') - if ('S' in s): return('paused') - if ('B' in s): return('canceling') - return(s) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: - j = j['result'] - _status = str(j['state']['status']) - return( _status.lower() ) - except Exception as e1: - print('Error in getStatus: ',e1 ) - return 'Error' - - def gCode(self,command): - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - import time - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command) - r = self.requests.get(URL,timeout=8) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/code/') - r = self.requests.post(URL, data=command) - if (r.ok): - return(0) - else: - print("gCode command return code = ",r.status_code) - print(r.reason) - return(r.status_code) - - def gCodeBatch(self,commands): - for command in commands: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - import time - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - time.sleep(0.5) - URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command) - r = self.requests.get(URL,timeout=8) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - json_response = r.json() - buffer_size = int(json_response['buff']) - #print( "Buffer: ", buffer_size ) - #print( command, ' -> ', reply ) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/code/') - r = self.requests.post(URL, data=command) - if not (r.ok): - print("gCode command return code = ",r.status_code) - print(r.reason) - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - return(r.status_code) - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - - def getFilenamed(self,filename): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_download?name='+filename) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/file/'+filename) - r = self.requests.get(URL,timeout=8) - return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' ')) - - def getTemperatures(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - return('Error: getTemperatures not implemented (yet) for RRF V2 printers.') - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - jsa=j['sensors']['analog'] - return(jsa) - - def checkDuet2RRF3(self): - if (self.pt == 2): - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - s=j['firmwareVersion'] - if s == "3.2": - return True - else: - return False - - def getCurrentTool(self): - import time - try: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - if not r.ok: - print('Error in getStatus session: ', r) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - while self.getStatus() not in "idle": - time.sleep(0.5) - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - ret=j['currentTool'] - return(ret) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - ret=j['state']['currentTool'] - return(ret) - except Exception as e1: - print('Error in getStatus: ',e1 ) - - def getHeaters(self): - import time - try: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - if not r.ok: - print('Error in getStatus session: ', r) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - while self.getStatus() not in "idle": - time.sleep(0.5) - URL=(f'{self._base_url}'+'/rr_status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - ret=j['heaters'] - return(ret) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: j = j['result'] - ret=j['heat']['heaters'] - return(ret) - except Exception as e1: - print('Error in getStatus: ',e1 ) - - def isIdle(self): - try: - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - if not r.ok: - print('Error in getStatus session: ', r) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - s=j['status'] - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - if not r2.ok: - print('Error in getStatus end session: ', r2) - return False - if ('I' in s): - return True - else: - return False - - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/status') - r = self.requests.get(URL,timeout=8) - j = self.json.loads(r.text) - if 'result' in j: - j = j['result'] - status = str(j['state']['status']) - if status.upper() == 'IDLE': - return True - else: - return False - except Exception as e1: - print('Error in isIdle(): ',e1 ) - return False -#### -# The following methods provide services built on the atomics above. -#### - - - # Given a line from config g that defines an endstop (N574) or Z probe (M558), - # Return a line that will define the same thing to a "nil" pin, i.e. undefine it - def _nilEndstop(self,configLine): - ret = '' - for each in [word for word in configLine.split()]: ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' ' - return(ret) - - def clearEndstops(self): - c = self.getFilenamed('/sys/config.g') - commandBuffer = [] - for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: - commandBuffer.append(self._nilEndstop(each)) - self.gCodeBatch(commandBuffer) - - - def resetEndstops(self): - import time - c = self.getFilenamed('/sys/config.g') - commandBuffer = [] - for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: - commandBuffer.append(self._nilEndstop(each)) - for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]: - commandBuffer.append(each) - self.gCodeBatch(commandBuffer) - - def resetAxisLimits(self): - c = self.getFilenamed('/sys/config.g') - commandBuffer = [] - for each in [line for line in c if 'M208 ' in line]: - commandBuffer.append(each) - self.gCodeBatch(commandBuffer) - - def resetG10(self): - c = self.getFilenamed('/sys/config.g') - commandBuffer = [] - for each in [line for line in c if 'G10 ' in line]: - commandBuffer.append(each) - self.gCodeBatch(commandBuffer) - - def resetAdvancedMovement(self): - c = self.getFilenamed('/sys/config.g') - commandBuffer = [] - for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]: - commandBuffer.append(each) - self.gCodeBatch(commandBuffer) - - def getTriggerHeight(self): - _errCode = 0 - _errMsg = '' - triggerHeight = 0 - if (self.pt == 2): - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') - r = self.requests.get(sessionURL,timeout=8) - buffer_size = 0 - while buffer_size < 150: - bufferURL = (f'{self._base_url}'+'/rr_gcode') - buffer_request = self.requests.get(bufferURL,timeout=8) - try: - buffer_response = buffer_request.json() - buffer_size = int(buffer_response['buff']) - except: - buffer_size = 149 - if buffer_size < 150: - print('Buffer low: ', buffer_size) - time.sleep(0.6) - URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31') - r = self.requests.get(URL,timeout=8) - replyURL = (f'{self._base_url}'+'/rr_reply') - reply = self.requests.get(replyURL,timeout=8) - # Reply is of the format: - # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0" - start = reply.find('trigger height') - triggerHeight = reply[start+15:] - triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) - if not self._rrf2: - #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking - endsessionURL = (f'{self._base_url}'+'/rr_disconnect') - r2 = self.requests.get(endsessionURL,timeout=8) - if (self.pt == 3): - URL=(f'{self._base_url}'+'/machine/code/') - r = self.requests.post(URL, data='G31') - # Reply is of the format: - # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0" - reply = r.text - start = reply.find('trigger height') - triggerHeight = reply[start+15:] - triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) - if (r.ok): - return (_errCode, _errMsg, triggerHeight ) - else: - _errCode = float(r.status_code) - _errMsg = r.reason - print("getTriggerHeight command return code = ",r.status_code) - print(r.reason) - return (_errCode, _errMsg, None ) - \ No newline at end of file diff --git a/Flowcharts/00 - Main Program Launch.dia b/Flowcharts/00 - Main Program Launch.dia new file mode 100644 index 0000000000000000000000000000000000000000..153fad089b5e4b605dab05af559c1400170fb7bb GIT binary patch literal 1331 zcmV-318gUL%REf| zrTX>$(;HiTe>ZvOVfa@5`5Xp{e1b-YcT066MEG_(-R*W7*+)^UCT5|-ySDV)5Ae0GbT|kdt9Kwxci1h4DNm&EeL=!jWO&_^@y|T7`@Y z4zVagt{Ec;(gaoTEozSwxsXT~J2$Kjqjv+1Q-&$p{PG0CkQS;r6r?h#MTe>~E(k}hzXaTMqZ{P_ItWKP)n zZ{&(}u%8Iqxf{rZ@b9C=-Rkn?7h3~1ux@9XZneV;kbvq1SNE_h+F3^g1uK_vT2vr? zOjc|@PEy4QdrnYu4a|V)9Ip;g8-)FhsKx^AY)isJmxiHhhMv8Q4q%7evgO5f&k9mq zZwK*!38?{*mD9~2q7B=vN;LarLDG1xBC#hZ4}j=4NEfD%5=+Uk32X>GFROvBncYCM+*6iPEzL0Ik{EyntZrCYWl}R+v;mU3 z-=_g8U(jQ`88uN#bw|TIMsT>=Fphsncal2cT$0IE4UJ)>1UWe4)j*={`cPUSwRN&e zZYFA~j=cKFo=%jNo+>M&v$9G(dwxZI1vGtrz_1dDWyEis)*7p(3hFknT3YCe+GqBX ziM$4*S}&hLA@@VbBUubhA|$rqNTWm1=t7&BBaL37(S8^L^u-XyYygTdz$r%<^MOdB z?bt?^c=aqnnD5JfMvfSmBMfsSiJnN}i_Ekhc!(|IJ)XaE%Uwf5;x5Ep=@WR5s6T51vzKC>Mz^? z=xBOH5cSW0*J^czxcF^uO@zaWOQ({)9~J21`4eR$qa{tag2lTR%JEaV6lTE-N**@S zhmGvRptzHh>rp=eZT1z=&Hw>zp9Zus8#1DuIuYGg9gZRWQbRfymBy?|NP}yH^yNa% z>Z_0g`VfVjb6UuO*+7L{+p&#>+^a9-k`1%TZ(siyi@3q|%Z#yz8?1<%_oexa`2eEN zuufGU(1%ogrvp6Wy!t5nUYG0xU1XniZP|C`0NtWD?Y9^}`+<2z`#Jyb({vqY?!Q)+ z;~~275Z!o)uA(OR^$Cmd2;HzHfjyp~8_&=UcZTkA-DLMwH-SNRzcSljU@>If1Zr)7 pwN*-E-SkrHCaX)`wCGwlCGX>(E!|BrziYai{0m|p6`A`y006d_koW)q literal 0 HcmV?d00001 diff --git a/Flowcharts/01 - TAMV GUI Init.dia b/Flowcharts/01 - TAMV GUI Init.dia new file mode 100644 index 0000000000000000000000000000000000000000..b7fbe6322c68f5e70962d8d6a89cb3987dea24e0 GIT binary patch literal 4022 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

*S5aUU4a6l}_xJ znGRm<*Ab0dE4BO*`xms+Tczs7o1!C=VcZ54NhBAUzXC61pWhml9LJaWrRy}$$Tu^> z4(?@yx}3TkQ1W9=s2X3Gs(4Rx(&;CVkI_G0(f+m@ERPU~8?WfwOFmXiy(;I#dX zfx4H&=@aw*$*tr5x=(ZUO)WLH=fkjtW6DnZBZb2z@j7G0A6p{q#w2KNWuB zC79UwEztLQYkO$>8=ZysHNAM-U*}H?deqzgYLu<B{A zu88qn#g+kAG`e|t!6MFe!9fSpOj?yKyd^GLb%ZLK9!p)i9tyio?r&5?I`YO*c1aip z;N!{;C15SsVO~E>A%{%lq^I!2mFN>!fqaJlpTk8LIb;^5e8)ewacPR>e2o23++_4y zZKG3@J+lGfcZMIa>KBCL=ZIY)NLNHH?o9mlsUrVA}+~p)*RCZh~)@0@wr7T}%gz ze{lTw@cko)x+jA^oc4Obg=+k5Rr!j1#+?+DPa|N3SsLDxH0wqEetENgC;IqoXYDXQ zB@+4@4jxAUNhl@;T|w{OBZ;gt4HQgK9)|KE{HVemLS_u&KqWbZOIBs|BQAON38(wg zR(b23>JIBYb^^-qE4N%-zbckiq_jV4-OfQhZUJ6Uv{!2w^p4>PZ{HQuTxG3`l&-Wt z#c=b}KIKkSb~p8%g-Z$f3EOg22x~wL2>U46*iNt=C6-Vm(PIN>(34QUBW=H9zv0w# z4>tuPi2|sDaV^eXt4*?dXKEkKo7@kEVId^|#=g71#?CyqbXR?WGZkvQGrE_hYO>E4^yG$;y^a~q37flrdrm!KD@ zz))mJi68tsdpzxvEr=N@4WL+1N?vK_rIO(uNjX_q`Rwtq!L)C;e-Pr$W%TuWV51F< zDU_fu8a^12yAOk)R)chfgLI8&_SXvJT;K-mFTq%Uc!62a@M70{IweQ4b{^poRRAI2 z1pt`R;TA{MLDq3k;eKsVjP#^emfOvl`ll=XsTRfsr4+Q7!%E^0Q*C1%D>!uT2*cU)e#xR<2etr!FyC5^iG zrH#73k4PeV|ISr1ETQp3M!vBQB=M}tztJP7z0|#Zr&VMxa!kIdc;<1Be}Jk z)2w%DG|@i&oqp`1;;O=((b`B_wN?JzVj+=2oGf@o>y6h{UPSUJK`q8;F?K!^nY63Ip=*ds`l+;}!u;$_KuqU72rwFHKf^JkZ z{5_>XBeWm!y6?I0y3p5WXZ&*4UWD`i-4l`d=KTlkUtWUSuy}h{cSea=3}s668&R6|96g~?SJDTx6OIiy$*0bk}bX( zRmn=>dZ}ZGXYHx1e95g%8*)PRK(fai6NA4?bh!R8ia;=hI1Y`R()OP>5&HmlfFOXf z=5yF{tFdNA9%<-hO9)*r9$db4;6dafN}Dg3pcg}Pi6$Leb15zDF1hEp!oA@uBd?F zHn>LU3)z-i+j)iQA8V(a&rc+ZxpE+$|EPHvZ_T%ika9bl6QUQ4?lkK&K1E>x(iwqhlOAea%iN6JbYB ztqfvBB?!g9V+PU%UdMS8@&XYrdtbc%lrX5pdXF2$67V|srFa}Ds=Xwbq!$woZ1Ou+ z`L<1!`mp7E>zKo>kB3-VsH~RH#Gvyrnn6CMKsooX=kh1pe+?IoT%R9#y!>38Z-vTc zk3W_1b5A~8Jq?eZt%VR`TrdY|Ivk>#@RYqmk47);x%5TG2 z`fr=_?Pd$l>dcm_Tlrt?Gm4mA5yD4@(*GMZtpX(nuo96&Q{xwz;q*U7{v6CM=tbu01~jCYuZ3*F!d>Ats zVYoO@V{WdWD11&zVt@4e3(3mPF8r?qdf*&hN%oXO}{UR@Y6-~ zHGevq9=gLf+d$!{2n{ZQqM43UuPbmUA*yoP*H>`vO?`|-2kXIPwAbUwHsP-e;_78n z@jMync#Nj&bW|FNc4)1oO`sAw*_n+;HsRQ$NC>&)aa%?PXbo`}(1jmHUDOh@(_@t+ zCe_Olye#{I@LuBG{^zc$53CqPi7D!l+V~@;H7M6KwugO(RaB)HUl`T7 zvs$d(IJU)1mqwPwwhR}FYmv|nr+J41ceNOcWey&xjhPIIjJ`;brMF7H!3mZa!eDqsReV(7WHC;Dv1yLhB zPcWxW9!WfZp3+l$+<~6j}KR${r(+6AlFGW!Vy0wCUX0GBShRV1< zW8LGjzJ6Pto|8p#cZZteU`rqFVzzfv5r#s~h&+j^cQ+Xf`P7B7eth(km>I(i9ZHJX zQ56^;$42OAe*N$5&(5-BAtuglOyRP<%;_(JL(5kKMSBf_aDp$27u%@qlUx*&t`orF zH#yW+Up&(#BAc1rB2ii;^s+sl=3IXHf8k*hc(gKWdp?(FGN34xJN%>?#&dNb!j#x! z9}ucG@`~CrG|-A_^UX}Vv{fDv{h{`!`8_A>Lvf2;;pgJMY}sQM9wtK^2XF$aoUg3h z=BK^&*&Lo*GSpruuNF0?08GJx5smdvf5(AJ4rWH>nV3zdf zcYRh7d%U0nqk@o8B~Cux$HAT$6C7v7@)(|#nZNPOO#XDtujKV~yjzrXRTGQW*90Q& zClsY8yLi~K?fmOI#QFpXc^np@ZUBG*AOLF9`7-G%k!WJt zy^X98E41|>G%7se$6Y}l?+mPcr$qe5#BX66^)*29P;5w{3DoVU?1l#fCE4hjM;0W?rh-fZ%}e}F$ahxkq##6ebFC|5`jT7LYdE?{O3~c z_A(m=W|wYu?tKUO4z>nyqmp{k4*2-mk85dz6DP%^_7tMxe7Dat>5;#PL?5u5BrV2% zGYx7n5__0uFs(-w2&Irn$ixrc1T+OiwbYUK0^!KcAr~i&UTn|MCxh_Y5xO&)Zb)~I zt5uhay!0)4dsmqS^vR0s41ypA?^^%1+CHq~A~b?LTmsQa4*MUteUb2iG57$R$Z({J zFD*}LB+bwp(`xaP+ccS{zQ@O5@YJlQXeBJ zf*{V0+nu#ic`+{sFbkya`VXdDq_}8b;>AVj_(@4AeM{sw^8P%bhs+KVLei)C@2Y6d9L#u6WH`;%VyIZIFJ^BjW{ZPuhwjo- zN=^Au8b?j9H+k^or_48k&kqK&JN=lTZw%jTJiH!p5yu@8D91C&7XH@74=YZmlm6bV zOq!d}MNCW1sIN3P$hy+Uzy^{y)j?A(j@KM`etm6-fi3)o%n&aVyvc`jH3P7h#46B* zWYEM{-C}U%D(rM>GHWpuJedKu%74#_?hX<}rL>%!bM~u6p7eX8rkiPNiW83153mU4 z3Zwm@0_GaNR=84b{}IC3O9CIW*;JA*!i@z!W4uFZ1{i`_kI7l3v%nUT)j@ri#&JrB zL)+jzNt}}K33-6-QTuy--%HYSZe{z|6*65gaccu_m$rKjQ?|2e+(l#8e(E6fajeAp4WAVC?bsdfk*_ z^>Jc@*>CS49t$Y;_1WdZ(lxbtY>(r$kDLPG8(-e`J<8tX*9wKaF8|{CaaHR4oyq@( z1}KX|4qtj*dWE&udt=v!&d#mQH|yX{PLYl;=KCpc$roZEBQX`-*`p}|HKQr(K49e zmX-N2s}U&;&5DAGBa~a^tX_tZb1r~Nr4>btvtz<;41E_69wyr2JQabDv*uH=c8|8B z0M*~)B)13s(k3JNsHpe}_MIpM7K{*-X{n&t_$_^^`FFI2#(zdXhE|jaV9J?SQZOw+*Qx2!5DMGX8RpHNa3_;Z;Fu0+pYN8_fJ>~r0Ap)BVwx-Ww;1R{&ax8^-dmP6fsN_X_!#}wS@i+NYyPx& zehZP?-tV&h?=@Mi`@XvKnI$8qoVaM8mVa(C%`S0WD|%`Km8$8B{I;c|9kVgT!xh^q z|BPawnQ&D$)xbZv^$gb#rpsv$^OqU+qLDK=RDtA9@o9?A+M0VtH0bGx@Gz~f!W${~lRMcyiwC&3JynL3Rrd7tQah$0T40f2MMOoOmk{~2JS<oXeBIJH%V14x|DqKEk z#_V&N>GU17(?RHZKpPE^ZuDXZ*%_$~U_7W2-54;`OA5zwMuXCc4@%R=3<&iH zzv?A4f5Bw~P`*p6?E)46)B((ZG$g=IJ`bG8DlwQ)`~}U9CZt3q@FJ>~pNfQZSYcR= z_y3qe@k&&qSKj*f-qYC!Y9>xbeuYl|6MRIH6p#)vRXOoNcNSMpn9psSII6;paq$qW zx?ekevo0`Pvx3Ue(08->%isklJROvZJW_8_nKc(J-o&9R1F#tm7tRpX;CQvw>}3aQiW){2AMeS$@R z@UjWln(kVUPx2H%wx}V2g1vipKHM&vw?lYaK-haYxq&I$b^^9VSLxi>N)KSdq{bRp zj76(L6~BYxgC2>5KnT;R8nN|n-^5iP6fqFL^~5<_9F<5mR>AQ-`4fA4+Vxfpk&0>$ zYZY42;8;Yp^~hc3dTK%FOi?rLdH?RyiXQDbl2M@L?S{On=_6M1+I-5arHtJ>8*m;X zKfX5T4pUnTp!y@q#H?Ck(KqCoudF-h)i_NG%za-|H8NXPSLa>>h-M&RT^fqX>se+t zSNW`p!U}YwDd{yZBB&rF9QkQ8;gMZzf2jJ3ImP!z5%SB$hDF>-TlRH{U5)1V^bpNp zgrL+VXQ&}sT9I$T6{M9NhHbuFz7^;muxl<#MG@u{T3wNS&x?S{l()a<27vD(ja$&|*vl zqnoWxOZr#uq-v!k+%js8@47|YF(!1c@$byNbiI>s|7ntq+VdrJj)>qwqTHYP)PCcl z?}UN6x?v|wB+Xx4Xm~JZ6kSWF7J&(45$&nC+17bI{_010(C_i1&UWVfNx1r3fIGmQ zbL|CWNL*=#S1H6F%u21;Y1r(~l_q-8N8!FE)d;#4=l=fDT79QGMM9(by^eLgghHWg zt^xbgWqRSq6g%Uizg5R=pj2P?anj(sGz@zk`;9)2LY)R1p+SW~RUGFugf)P@tm80& zBDU2<6N1Y^Np$hY1t+(IOk**Z9V=NZYfMRR{#@3vu1l7+mTX$y`*OqFo>PEVVJz8P z>#&#D14~a~>TMN|fdm$%GoJ{l9G1(YhD5M=VVXfwOWp+YZn$xXd8`p8P3nN637IT7)mOEml~ay zs;NJTx9TKk(R+`WF9(rwlU9$tO!mN?kvrFX3uxX>E@px)pV+s<^oIf|!?gB+kd*>f z6`|)tD0xLj1cOlV?T3fcq`YyymG+4qN3p*=%c?sP@#mbhRJJHLSw`t{%K;+03GX!g zB8~F7xXPc;CfrUBrYgQ33Nv3W^HT=}Q}-ou^d^zQFu=91NXe1P}yc&<3*8 z1!DNG=BOg^%MjxBUPr(G0{Q65)TMgtU}8 zq#UdbVu8tgK>ve;s6g{L@z7#rAw>d)Z-)CR?^JX3TfDoRJU zhKMh*6m&}M%|7N&J{Sp~Fe|M)xINi-hHlTOB8iC2~kI<1tr!=!W|Abl6b@-R?q}gY`hezWR8BN0?}uBbThN=IeA`zcTWx%LQ!N zPmJ$$Wfu-Tq`I-(QD||dux(4owclX0S7uj8L8FLpY(&@%1zvtl3*+l> zAC<^t@UZ_Vw8NGDvuOe6E`fCxXy3%Wdw@<{;mLL0jJ%ArykYz$tn|BLXh=pOGZ0R9)zw$DQV literal 0 HcmV?d00001 diff --git a/Flowcharts/Endstop Calibration b/Flowcharts/Endstop Calibration new file mode 100644 index 0000000000000000000000000000000000000000..a9f354614efa2b619c8efea97d13736a4961755c GIT binary patch literal 1867 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 - -

- -_[back to top](#table-of-contents)_ -# TAMV = Tool Alignment (using) Machine Vision -Ever needed to simplify nozzle and tool alignment on a toolchanging 3D printer/machine? - -Well, welcome to the wonderful experience of **automated nozzle alignment** and Z offset setting using machine vision (for X&Y offset alignment) and electrical touch plates (for Z offset alignment). - -This program significantly improves how tool-changing machines using Duet RRF v2/3 on Duet2 / Duet3 controlers by leveraging open-source computer vision algorithms from the OpenCV project to eliminate the guesswork from tool alignment and (nearly) completely automate the process. Let the machines do the work! - -TAMV in its current release is a graphical program that allows you to connect to your printer and automatically calculate tool offsets in XY space using a generic [USB microscope](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/) and a Raspberry Pi (preferably a Pi4 or better). - -You may opt to use a webcam for tool alignment, but this approach (while reliable) will need significantly more work to overcome issues related to insufficient/inappropriate lighting and limited depth-of-field of webcam optics (not to mention more sensor noise artifacts), all of which can throw off the computer vision algorithms and cause headaches. - -_[back to top](#table-of-contents)_ -# Why should I use this version/fork of TAMV? -1. Its now a graphical interface with ZERO command line steps at run-time. -2. Its got a bundled installation script for getting OpenCV 4.5.1 running on a Raspberry Pi (it will take 1.5 hours to install the first time) -3. The program guides you through the alignment process by only enabling the right procedures at the right times. -4. The computer vision functions automatically calibrate your camera output to your machine's movement so there's no need to worry about lens distortion or slightly off-plane images. -5. You may use any USB webcam (no picam yet!) that is compatible with Raspbian (when running TAMV on a Raspberry Pi) -6. You can also use RTSP/network cameras and run the main program on a desktop/laptop if you so prefer (just need Python -- not much benefit over a Raspberry Pi 4 since the computer vision is running some basic blob detection.) -7. TAMV can run repeatability tests for your machine (based on the number of cycles you define) and export/display useful visualizations of how your machine behaves over time to aid in identifying tool drift. -8. TAMV allows you to save your machine address and some advanced parameters to save you some time when running it multiple times -9. **Its completely open-source and community driven.** -10. *Did we mention its a graphical interface now?* - -_[back to top](#table-of-contents)_ -# What's included in this package? -1. **TAMV_GUI.py**: the main interface for automated X/Y offset tool alignment using computer vision -2. **ZTATP.py**: a second program that uses electrical touch plates to physically measure tool Z offsets using your Duet controller's endstop inputs -3. **TAMV.py**: the "original" command line version of TAMV, which also includes data export and repeatability testing - -_[back to top](#table-of-contents)_ -# What do I need to run TAMV? -1. **A fully functional tool changer platform running RepRapFirmware 2.0.5 or 3.2+** - - It has only been tested on machines using either Duet2 (+Duex2 or Duex5) or Duet 3 boards. - - All your toolchanging macros (*tpre, tpost, tfree*) have to be up and running - you can run tool changes cleanly. - - All of your tools are assumed to have reached a **"safe working area"** once the tool is active (after tpost has completed), and your selected "Controlled Point" (the XY position where your microscope is located) can be reached using a large X move followed by a large Y move (be mindful of collisions here!) - - You will need to modify your tool change macros to exclude any extrusion moves temporarily to make sure the nozzles are clear of any filament and can be detected reliably. This is still a manual modification and we're working on automating this in the near future. - - All of your tools must have clean nozzles with no excessive filament deposits surrounding the nozzle. - -2. **A Raspberry Pi (3 or better with at least 2GB of RAM)** - - We prefer a **Raspberry Pi 4 with 4GB of RAM** for a smoother experience, but it will also work on a Model 3 Raspberry Pi. - - OpenCV requires at least 2GB of RAM to be installed, keep this in mind before jumping into installing openCV. It also takes over an hour to compile OpenCV on a Raspberry Pi 4 - -3. **[A generic USB microscope](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/) with the light controls built-in to the USB cable** - - This is a generic mass-manufactured part commonly found at your favorite ecommerce outlet of choice. **Make sure you are using the variant that has a lighting control wheel built-in to the USB cable**, as there are alternative versions that look very similar which lack lighting control and have been found to be incompatible with Raspbian and will not work. - - You may choose to use a webcam you already have at home, but be mindful that computer vision requires a specific type of lighting (mainly soft diffuse light directed perpendicular from the camera to the object/nozzle) to work consistently. Webcams also tend to have wide-angle lens optics and offer a very coarse depth of focus, further complicating things when you need to focus on a 0.4mm nozzle from a 25mm focal distance. Add to that webcam sensors typically exhibit a lot of noise artifacts when forced to zoom in so closely to the nozzles. Overall, it will work, but you'll need to fiddle with it until its just right. We all started using TAMV with Logitech C270 webcams, and then moved to microscopes for the vastly superior experience. - - [Amazon US Link](https://www.amazon.com/gp/product/B07F7T7SC1/) - - [Amazon UK Link](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/) - - -4. **A little dose of patience (since you're reading this and already own a toolchanger, then we're sure you've got this bit covered..)** - - You'll find a great community and ton of help and answers to your questions on the [Jubilee Discord Server](https://discord.gg/XkphRqb) - -_[back to top](#table-of-contents)_ -# What do I need to run ZTATP? -1. Any **tool changer machine**, just like #1 above for TAMV, running RRF -2. A **Raspberry Pi**, just like #2 above for TAMV - - You may also choose to use any computer that can run a Python3 script and communicate with your Duet over the local network. - -3. Some sort of electrically **conductive touch plate** which you're going to connect to an endstop input (ground) on your Duet board. - - This can be as simple as a block of aluminum with an electrical wire screwed into it. As long as its electrically conductive and relatively small (under 3 inches on its longest side), you should be OK (size plays a large part in signal noise, so avoid using your printer's build plate..) - -4. A second electrical wire that you can hook up to your tools (one at a time, ZTATP pauses and prompts you to connect/disconnect everything for each tool). - - We can recommend some electrical wire and an alligator clip that you can connect to your nozzle for each alignment sequence. - -5. A **multimeter** (REQUIRED!) to test for electrical continuity between your tool nozzle and touch plate before each alignement sequence. - - We are talking about moving your tool down into a block of metal on your printer bed with ZTATP, so any collisions will definitely lead to machine/tool damage that will cause frustration and expensive repair. - -6. **Patience, more so than TAMV**, since you want to make sure each tool is ready to be slowly lowered into a block of solid metal, and you definitely don't want to mess this up. - - Don't worry. If you do your checks before each probe move, you won't have any crashes (hopefully). - - **But you are taking full responsibilty and accountability for using ZTATP and we will not be liable for any damages that you may incur.** - -_[back to top](#table-of-contents)_ -# How do I install OpenCV on my Raspberry Pi? -1. Clone this repository into your home folder - - Open a terminal window on your Raspberry pi - - type `cd`, and hit enter - - type `git clone https://github.com/HaythamB/TAMV` and hit enter - -2. type `cd TAMV` and press enter -3. Run `~/TAMV/install_opencv.sh` -4. Wait about 20 minutes for everything to be installed - - Note for Raspberry Pi 3 users: you will probably have to increase the default SWAP size to at least 2GB for the install to run successfully. Here's the commands to run before installing openCV to set up a 2GB swap file. Run a Google search on "how to increase raspberry pi 3 swap size" for more info. - -``` -sudo sed -i 's/CONF_SWAPSIZE=100/CONF_SWAPSIZE=2048/g' /etc/dphys-swapfile -sudo /etc/init.d/dphys-swapfile stop -sudo /etc/init.d/dphys-swapfile start -``` - -_[back to top](#table-of-contents)_ -# How do I run these packages? -## TAMV_GUI -1. Connect your microscope/webcam to a USB port (preferably only connect 1 when starting it for the first time) -2. Open a terminal window *from the Raspbian desktop and NOT AN SSH SESSION* and run `~/TAMV/TAMV_GUI.py` -3. Follow the buttons and prompts on-screen. There are tooltips everywhere to guide you! - - You can always also watch [this video here](https://youtu.be/1nGc_hFzK0s?t=5) to see how a calibration is run. - -TAMV.py = Tool Align Machine Vision - for Duet based tool changing 3D printers. - -* Runs on the Pi that has the USB or Pi camera -* Requires network connection to DUET RepRap V2 or V3 based printer. -* This MAY be, but is not required to be, the Pi in a Duet3+Pi configuration -* Requires OpenCV installed on the Pi. -* MUST run on the graphic console, not SSH. This can be physical, VNC, or any combination of the two. -* Always use soft diffused lighting when running TAMV (a diffused LED ring works great). This is the most important factor to get it to detect nozzles consistently and reliably without any fuss. - -P.S. Reminder: Never NEVER run a graphic app with 'sudo'. It can break your XWindows (graphic) setup. Badly. - -_[back to top](#table-of-contents)_ -## ZTATP -ZTATP.py = Z Tool Align Touch Plate - for Duet based tool changing 3D printers. - -* Requires network connection to DUET RepRap V2 or V3 based printer. -* This MAY be, but is not required to be, the Pi in a Duet3+Pi configuration -* Warning: RRF 2&3 have a **hardcoded offset of 0.7mm applied to Z-probes** which you must over-ride in your config.g file. If you don't fix this issue, your offsets will be calculated 0.7mm too close to your print surface **and this will result in damage to your printer!** A simple G31 Z0 at the end of your config.g file (or whatever Z offset applies to your probe) is sufficient to fix this issue. A later release of ZTATP will automatically handle this issue, but for now, it is a manual fix. -### Installation - - See instructions above for TAMV. It will be in the same directory. - -### Parameters -#### -h, --help -show help message and exit - -#### -duet DUET -Name or IP address of Duet printer. You can use -duet=localhost if you are on the embedded Pi on a Duet3. - -#### -touchplate TOUCHPLATE TOUCHPLATE -x y of center of a 15x15mm touch plate (these can be decimal values) - -#### -pin PIN PIN -input pin to which wires from nozzles are attached (only in RRF3) - -#### -tool TOOL -set a run for an individual tool number - -### Run - - cd TAMV - ./ZTATP.py -touchplate X Y - -NOTE: Requires Wiring! Each nozzle must be wired to the GPIO specified (default is io5.in, can be overriden on command line). The touchplate must be grounded. Recommend about running with finger on power switch, in case a given touch does not stop. - -_[back to top](#table-of-contents)_ -## TAMV (legacy command-line interface) -### Preparation steps -TAMV, ZTATP, and their associated plot functions utilize Python3+, and some additional libraries for GUI elements and processing. If you have some errors while running the code, consider running the following commands to install missing modules. - - sudo apt-get update - sudo apt-get upgrade - sudo apt-get install python3-matplotlib - sudo apt-get install python3-pyqt5 - -### Installation - - cd - git clone https://github.com/HaythamB/TAMV/ - -### Run - usage: TAMV.py [-h] [-duet DUET] [-vidonly] [-camera CAMERA] [-cp CP CP] - [-repeat REPEAT] [-xray] [-loose] [-export] [-alternate] - - Program to allign multiple tools on Duet based printers, using machine vision. - - optional arguments: - -h, --help show this help message and exit - -duet DUET Name or IP address of Duet printer. You can use - -duet=localhost if you are on the embedded Pi on a Duet3. - -vidonly Open video window and do nothing else. - -camera CAMERA Index of /dev/videoN device to be used. Default 0. - -cp CP CP x y that will put 'controlled point' on carriage over - camera. - -repeat REPEAT Repeat entire alignment N times and report statistics - -xray Display edge detection output for troubleshooting. - -loose Run circle detection algorithm with less stringent - parameters to help detect worn nozzles. - -export Export repeat raw data to output.csv when done. - -alternate Try alternative nozzle detection method - - -_[back to top](#table-of-contents)_ - -# TAMV Community Videos -## Danal's Original TAMV Release -You can find the original release of [TAMV in Danal Estes' repository](https://github.com/DanalEstes/TAMV). - -[![Danal's Original TAMV Release](http://img.youtube.com/vi/e_d_XHwGfRM/0.jpg)](https://www.youtube.com/watch?v=e_d_XHwGfRM) -## Walkthrough: Luke's Laboratory -Be sure to check out [Luke's latest releases on his website](https://www.lukeslab.online/). - -[![Luke's Laboratory Walkthrough](http://img.youtube.com/vi/ZjgjIAw_s7E/0.jpg)](https://www.youtube.com/watch?v=ZjgjIAw_s7E) -## Walkthrough: Reefing Ninja -[![Reefing Ninja's Walkthrough](http://img.youtube.com/vi/uPa1ecRxpr4/0.jpg)](https://www.youtube.com/watch?v=uPa1ecRxpr4) - -_[back to top](#table-of-contents)_ diff --git a/TAMV.py b/TAMV.py new file mode 100644 index 0000000..2a95ea2 --- /dev/null +++ b/TAMV.py @@ -0,0 +1,2095 @@ +import argparse, logging, os, sys, traceback, time +from tkinter import E +from json import tool +from logging.handlers import RotatingFileHandler +# openCV imports +from cv2 import cvtColor, COLOR_BGR2RGB +# Qt imports +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QSize, QThread +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QStyle, QWidget, QMenu, QAction, QStatusBar, QLabel, QHBoxLayout, QVBoxLayout, QTextEdit, QPushButton, QApplication, QTabWidget, QButtonGroup, QGridLayout, QFrame, QCheckBox +# Other imports +import json +import numpy as np +import copy +# Custom modules import +from modules.DetectionManager import DetectionManager +from modules.SettingsDialog import SettingsDialog +from modules.PrinterManager import PrinterManager +from modules.StatusTipFilter import StatusTipFilter + +########################################################################### Core application class +class App(QMainWindow): + # Signals + ######## Detection Manager + startVideoSignal = pyqtSignal() + setImagePropertiesSignal = pyqtSignal(object) + getVideoFrameSignal = pyqtSignal() + # Endstop detection signals + toggleEndstopDetectionSignal = pyqtSignal(bool) + toggleEndstopAutoDetectionSignal = pyqtSignal(bool) + # Nozzle detection signals + toggleNozzleDetectionSignal = pyqtSignal(bool) + toggleNozzleAutoDetectionSignal = pyqtSignal(bool) + # Master detection enable/disable signal + toggleDetectionSignal = pyqtSignal(bool) + + ######## Printer Manager + connectSignal = pyqtSignal(object) + disconnectSignal = pyqtSignal(object) + moveRelativeSignal = pyqtSignal(object) + moveAbsoluteSignal = pyqtSignal(object) + callToolSignal = pyqtSignal(int) + pollCoordinatesSignal = pyqtSignal() + pollCurrentToolSignal = pyqtSignal() + setOffsetsSignal = pyqtSignal(object) + limitAxesSignal = pyqtSignal() + flushBufferSignal = pyqtSignal() + saveToFirmwareSignal = pyqtSignal() + # Setting Dialog + resetImageSignal = pyqtSignal() + pushbuttonSize = 38 + # default move speed in feedrate/min + _moveSpeed = 6000 + + ########################################################################### Initialize class + def __init__(self, parent=None): + # send calling to log + _logger.debug('*** calling App.__init__') + + # output greeting to log + _logger.info('Initializing application.. ') + # call QMainWindow init function + super().__init__() + #### class attributes definition + if(True): + # main window size + self.windowWidthOriginal, self.windowHeightOriginal = 800, 600 + # main camera capture size + # self.imageWidthOriginal, self.imageHeightOriginal = 640, 480 + # stylesheets used for interface status updates + self.styleGreen = '* { background-color: green; color: white;} QToolTip { color: black; background-color: #DDDDDD } *:hover{background-color: #003874; color: #ffa000;} *:pressed{background-color: #ffa000; color: #003874;}' + self.styleRed = '* { background-color: red; color: white;} QToolTip { color: black; background-color: #DDDDDD }' + self.styleDisabled = '* { background-color: #cccccc; color: #999999; border-style: solid;} QToolTip { color: black; background-color: #DDDDDD }' + self.styleOrange = '* { background-color: dark-grey; color: #ffa000;} QToolTip { color: black; background-color: #DDDDDD }' + self.styleBlue = '* { background-color: #003874; color: #ffa000;} QToolTip { color: black; background-color: #DDDDDD }' + self.styleDefault = '* { background-color: rgba(0,0,0,0); color: black;} QToolTip { color: black; background-color: #DDDDDD }' + # standby image placeholder + self.standbyImage = QPixmap('./resources/background.png') + self.errorImage = QPixmap('./resources/error.png') + # user-defined cameras array + self.__cameras = [] + # active camera + self.__activeCamera ={} + # user-defined printers array + self.__printers = [] + # active printer + self.__activePrinter = {} + # Control Point + self.__cpCoordinates = {'X': None, 'Y': None, 'Z': None} + self.__currentPosition = {'X': None, 'Y': None, 'Z': None} + self.__stateManualCPCapture = False + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.__restorePosition = None + self.__firstConnection = False + self.state = 0 + # Camera transform matrix + self.transform_matrix = None + self.transform_input = None + self.mpp = None + # Nozzle detection + self.__stateAutoNozzleAlignment = False + self.__stateManualNozzleAlignment = False + + #### setup window properties + if(True): + _logger.debug(' .. setting up window properties..') + self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) + self.setWindowTitle('TAMV') + self.setWindowIcon(QIcon('./resources/jubilee.png')) + screen = QDesktopWidget().availableGeometry() + self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(self.windowWidthOriginal,self.windowHeightOriginal),screen)) + self.setMinimumWidth(self.windowWidthOriginal) + self.setMinimumHeight(self.windowHeightOriginal) + appScreen = self.frameGeometry() + appScreen.moveCenter(screen.center()) + self.move(appScreen.topLeft()) + self.centralWidget = QWidget() + self.setCentralWidget(self.centralWidget) + # create stylehseets + self.setStyleSheet( + '\ + QLabel#instructions_text {\ + background-color: rgba(255,153,0,.4);\ + }\ + QPushButton {\ + border: 1px solid #adadad;\ + border-style: outset;\ + border-radius: 4px;\ + font: 16px;\ + padding: 2px;\ + }\ + QPushButton>QToolTip {\ + color: black;\ + }\ + QPushButton#calibrating:enabled {\ + background-color: orange;\ + color: white;\ + }\ + QPushButton#completed:enabled {\ + background-color: blue;\ + color: white;\ + }\ + QPushButton:hover,QPushButton:enabled:hover,QPushButton:enabled:!checked:hover,QPushButton#completed:enabled:hover {\ + background-color: #003874;\ + color: #ffa000;\ + border: 1px solid #aaaaaa;\ + }\ + QPushButton:pressed,QPushButton:enabled:pressed,QPushButton:enabled:checked,QPushButton#completed:enabled:pressed {\ + background-color: #ffa000;\ + border: 1px solid #aaaaaa;\ + }\ + QPushButton:enabled {\ + background-color: green;\ + color: white;\ + }\ + QLabel#labelPlus {\ + font: 20px;\ + padding: 0px;\ + }\ + QPushButton#plus:enabled {\ + font: 20px;\ + padding: 0px;\ + background-color: #eeeeee;\ + color: #000000;\ + }\ + QPushButton#plus:enabled:hover {\ + font: 20px;\ + padding: 0px;\ + background-color: green;\ + color: #000000;\ + }\ + QPushButton#plus:enabled:pressed {\ + font: 20px;\ + padding: 0px;\ + background-color: #FF0000;\ + color: #222222;\ + }\ + QPushButton#debug,QMessageBox > #debug {\ + background-color: blue;\ + color: white;\ + }\ + QPushButton#debug:hover, QMessageBox > QAbstractButton#debug:hover {\ + background-color: green;\ + color: white;\ + }\ + QPushButton#debug:pressed, QMessageBox > QAbstractButton#debug:pressed {\ + background-color: #ffa000;\ + border-style: inset;\ + color: white;\ + }\ + QPushButton#active, QMessageBox > QAbstractButton#active {\ + background-color: green;\ + color: white;\ + }\ + QPushButton#active:pressed,QMessageBox > QAbstractButton#active:pressed {\ + background-color: #ffa000;\ + }\ + QPushButton#terminate {\ + background-color: red;\ + color: white;\ + }\ + QPushButton#terminate:pressed {\ + background-color: #c0392b;\ + }\ + QPushButton:disabled, QPushButton#terminate:disabled {\ + background-color: #cccccc;\ + color: #999999;\ + }\ + QInputDialog QDialogButtonBox > QPushButton:enabled, QDialog QPushButton:enabled,QPushButton[checkable="true"]:enabled {\ + background-color: none;\ + color: black;\ + border: 1px solid #adadad;\ + border-style: outset;\ + border-radius: 4px;\ + font: 14px;\ + padding: 6px;\ + }\ + QPushButton:enabled:checked {\ + background-color: #ffa000;\ + border: 1px solid #aaaaaa;\ + }\ + QInputDialog QDialogButtonBox > QPushButton:pressed, QDialog QPushButton:pressed {\ + background-color: #ffa000;\ + }\ + QInputDialog QDialogButtonBox > QPushButton:hover:!pressed, QDialog QPushButton:hover:!pressed {\ + background-color: #003874;\ + color: #ffa000;\ + }\ + QToolTip, QLabel > QToolTip, QPushButton > QLabel> QToolTip {\ + color: black;\ + }\ + ' + ) + #### Driver API imports + if(True): + try: + self.__firmwareList = [] + self.__driverList = [] + with open('drivers.json','r') as inputfile: + driverJSON = json.load(inputfile) + _logger.info(' .. loading drivers..') + for driverEntry in driverJSON: + self.__firmwareList.append(driverEntry['firmware']) + self.__driverList.append(driverEntry['filename']) + except: + _logger.critical('Cannot load driver definitions: ' + traceback.format_exc()) + raise SystemExit('Cannot load driver definitions.') + #### Setup components + # load user parameters + if(True): + try: + with open('./config/settings.json','r') as inputfile: + self.__userSettings = json.load(inputfile) + except FileNotFoundError: + try: + # try and see if moving from older build + with open('./settings.json','r') as inputfile: + self.__userSettings = json.load(inputfile) + try: + _logger.info(' .. moving settings file to /config/settings.json.. ') + # create config folder if it doesn't exist + os.makedirs('./config',exist_ok=True) + # move settings.json to new config folder + os.replace('./settings.json','./config/settings.json') + except: + _logger.warning('Cannot rename old settings.json, leaving in place and using new file.') + except FileNotFoundError: + # No settings file defined, create a default file + _logger.info(' .. creating new settings.json..') + # create a camera array + self.__userSettings['camera'] = [ + { + 'video_src': 0, + 'display_width': '640', + 'display_height': '480', + 'default': 1 + } ] + # Create a printer array + self.__userSettings['printer'] = [ + { + 'address': 'http://localhost', + 'password': 'reprap', + 'name': 'My Duet', + 'nickname': 'Default', + 'controller' : 'RRF/Duet', + 'version': '', + 'default': 1, + 'rotated': 0, + 'tools': [ + { + 'number': 0, + 'name': 'Tool 0', + 'nozzleSize': 0.4, + 'offsets': [0,0,0] + } ] + } ] + try: + # set class attributes + self._cameraWidth = int(self.__userSettings['camera'][0]['display_width']) + self._cameraHeight = int(self.__userSettings['camera'][0]['display_height']) + self._videoSrc = self.__userSettings['camera'][0]['video_src'] + # save default settings file + with open('./config/settings.json','w') as outputfile: + json.dump(self.__userSettings, outputfile) + except Exception as e1: + errorMsg = 'Error reading user settings file.' + traceback.format_exc() + _logger.critical(errorMsg) + raise SystemExit(errorMsg) + _logger.info(' .. reading configuration settings..') + # Fetch defined cameras + if(True): + defaultCameraDefined = False + for source in self.__userSettings['camera']: + try: + self.__cameras.append(source) + if(source['default'] == 1 and defaultCameraDefined is False): + self.__activeCamera = source + defaultCameraDefined = True + elif(defaultCameraDefined): + source['default'] = 0 + continue + except KeyError as ke: + source['default'] = 0 + continue + if(defaultCameraDefined is False): + self.__userSettings['camera'][0]['default'] = 1 + self.__activeCamera = self.__userSettings['camera'][0] + self._cameraHeight = int(self.__activeCamera['display_height']) + self._cameraWidth = int(self.__activeCamera['display_width']) + self._videoSrc = self.__activeCamera['video_src'] + if(len(str(self._videoSrc)) == 1 or str(self._videoSrc) == "-1"): + self._videoSrc = int(self._videoSrc) + # Fetch defined machines + if(True): + defaultPrinterDefined = False + for machine in self.__userSettings['printer']: + # Find default printer first + try: + self.__printers.append(machine) + if(machine['default'] == 1): + self.__activePrinter = machine + defaultPrinterDefined = True + except KeyError as ke: + # no default field detected - create a default if not already done + if(defaultPrinterDefined is False): + machine['default'] = 1 + defaultPrinterDefined = True + else: + machine['default'] = 0 + # Check if password doesn't exist + try: + temp = machine['password'] + except KeyError: + machine['password'] = 'reprap' + # Check if nickname doesn't exist + try: + temp = machine['nickname'] + except KeyError: + machine['nickname'] = machine['name'] + # Check if controller doesn't exist + try: + temp = machine['controller'] + except KeyError: + machine['controller'] = 'RRF/Duet' + # Check if version doesn't exist + try: + temp = machine['version'] + except KeyError: + machine['version'] = '' + # Check if rotated kinematics doesn't exist + try: + temp = machine['rotated'] + except KeyError: + machine['rotated'] = 0 + # Check if tools doesn't exist + try: + temp = machine['tools'] + except KeyError: + machine['tools'] = [ { 'number': 0, 'name': 'Tool 0', 'nozzleSize': 0.4, 'offsets': [0,0,0] } ] + # Check if we have no default machine + if(defaultPrinterDefined is False): + self.__activePrinter = self.__userSettings['printer'][0] + (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address']) + if _errCode > 0: + # invalid input + _logger.error('Invalid printer URL detected in settings.json') + _logger.info('Defaulting to \"http://localhost\"...') + self.printerURL = 'http://localhost' + + ##### Settings Dialog + self.__settingsGeometry = None + # Note: settings dialog is created when user clicks the button + + #### Setup interface + ##### Menu bar + self.setupMenu() + ##### Status bar + self.setupStatusbar() + ##### GUI elements + self.setupMainWindow() + + # send exiting to log + _logger.debug('*** exiting App.__init__') + + ########################################################################### Menu setup + def setupMenu(self): + # send calling to log + _logger.debug('*** calling App.setupMenu') + + self.menubar = self.menuBar() + self.menubar.installEventFilter(StatusTipFilter(self)) + #### File menu + fileMenu = QMenu('&File', self) + self.menubar.addMenu(fileMenu) + #### Preferences + self.preferencesAction = QAction(self) + self.preferencesAction.setText('&Preferences..') + self.preferencesAction.triggered.connect(self.displayPreferences) + fileMenu.addAction(self.preferencesAction) + # Quit + self.quitAction = QAction(self) + self.quitAction.setText('&Quit') + self.quitAction.triggered.connect(self.close) + fileMenu.addSeparator() + fileMenu.addAction(self.quitAction) + + # send exiting to log + _logger.debug('*** exiting App.setupMenu') + + ########################################################################### Status bar setup + def setupStatusbar(self): + # send calling to log + _logger.debug('*** calling App.setupStatusbar') + + self.statusBar = QStatusBar() + self.statusBar.showMessage('Welcome.') + self.setStatusBar(self.statusBar) + #### CP coodinate status + self.cpLabel = QLabel('CP: undef') + self.statusBar.addPermanentWidget(self.cpLabel) + self.cpLabel.setStyleSheet(self.styleOrange) + + #### Connection status + self.connectionStatusLabel = QLabel('Disconnected') + self.connectionStatusLabel.setStyleSheet(self.styleOrange) + self.statusBar.addPermanentWidget(self.connectionStatusLabel) + + # send exiting to log + _logger.debug('*** exiting App.setupStatusbar') + + ########################################################################### Main window setup + def setupMainWindow(self): + # send calling to log + _logger.debug('*** calling App.setupMenu') + + self.containerLayout = QVBoxLayout() + self.containerLayout.setSpacing(8) + self.centralWidget.setLayout(self.containerLayout) + #### Main grid layout + if(True): + self.mainLayout = QHBoxLayout() + self.mainLayout.setSpacing(8) + self.containerLayout.addLayout(self.mainLayout) + ##### Left toolbar + if(True): + self.leftToolbarLayout = QVBoxLayout() + self.leftToolbarLayout.setAlignment(Qt.AlignTop) + self.mainLayout.addLayout(self.leftToolbarLayout) + # Connect button + self.connectButton = QPushButton('+') + self.connectButton.setStyleSheet(self.styleDisabled) + self.connectButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize) + self.connectButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize) + self.connectButton.setToolTip('Connect..') + self.connectButton.setDisabled(True) + self.connectButton.clicked.connect(self.connectPrinter) + self.leftToolbarLayout.addWidget(self.connectButton)#, 1, 0, 1, 1, Qt.AlignLeft|Qt.AlignTop) + # Disconnect button + self.disconnectButton = QPushButton('D') + self.disconnectButton.setStyleSheet(self.styleDisabled) + self.disconnectButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize) + self.disconnectButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize) + self.disconnectButton.setToolTip('Disconnect..') + self.disconnectButton.setDisabled(True) + self.disconnectButton.clicked.connect(self.haltPrinterOperation) + self.leftToolbarLayout.addWidget(self.disconnectButton)#, 1, 0, 1, 1, Qt.AlignLeft|Qt.AlignTop) + # Setup Control Point button + self.cpSetupButton = QPushButton('CP') + self.cpSetupButton.setStyleSheet(self.styleDisabled) + self.cpSetupButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize) + self.cpSetupButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize) + self.cpSetupButton.setToolTip('Setup Control Point..') + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.clicked.connect(self.setupCPCapture) + self.leftToolbarLayout.addWidget(self.cpSetupButton) + # CP Automated Capture button + self.cpAutoCaptureButton = QPushButton('Auto') + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + self.cpAutoCaptureButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize) + self.cpAutoCaptureButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize) + self.cpAutoCaptureButton.setToolTip('Automated CP Capture..') + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.clicked.connect(self.setupCPAutoCapture) + self.leftToolbarLayout.addWidget(self.cpAutoCaptureButton) + + ##### Main image preview + if(True): + self.image = QLabel(self) + self.image.resize(self.__activeCamera['display_width'], self.__activeCamera['display_height']) + self.image.setMinimumWidth(self.__activeCamera['display_width']) + self.image.setMinimumHeight(self.__activeCamera['display_height']) + self.image.setAlignment(Qt.AlignLeft) + # self.image.setStyleSheet('text-align:center; border: 1px solid black') + self.image.setPixmap(self.standbyImage) + self.mainLayout.addWidget(self.image)#, 1, 1, 1, -1, Qt.AlignLeft|Qt.AlignTop) + + ##### Right toolbar + if(True): + # Jog Panel + self.tabPanel = QTabWidget() + self.firstTab = QWidget() + self.jogPanel = QGridLayout() + self.jogPanel.setAlignment(Qt.AlignRight|Qt.AlignTop) + self.jogPanel.setSpacing(5) + + # create jogPanel buttons + ## increment size + self.button_1 = QPushButton('1') + self.button_1.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_1.setMaximumHeight(self.pushbuttonSize) + self.button_1.setToolTip('set jog distance to 1 unit') + self.button_01 = QPushButton('.1') + self.button_01.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_01.setMaximumHeight(self.pushbuttonSize) + self.button_01.setToolTip('set jog distance to 0.1 unit') + self.button_001 = QPushButton('.01') + self.button_001.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_001.setMaximumHeight(self.pushbuttonSize) + self.button_001.setToolTip('set jog distance to 0.01 unit') + self.incrementButtonGroup = QButtonGroup() + self.incrementButtonGroup.addButton(self.button_1) + self.incrementButtonGroup.addButton(self.button_01) + self.incrementButtonGroup.addButton(self.button_001) + self.incrementButtonGroup.setExclusive(True) + self.button_1.setCheckable(True) + self.button_01.setCheckable(True) + self.button_001.setCheckable(True) + self.button_1.setChecked(True) + + # horizontal separators + self.incrementLine = QFrame() + self.incrementLine.setFrameShape(QFrame.HLine) + self.incrementLine.setLineWidth(1) + self.incrementLine.setFrameShadow(QFrame.Sunken) + self.keypadLine = QFrame() + self.keypadLine.setFrameShape(QFrame.HLine) + self.keypadLine.setFrameShadow(QFrame.Sunken) + self.keypadLine.setLineWidth(1) + + ## X movement + self.button_x_left = QPushButton('-X', objectName='plus') + self.button_x_left.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_x_left.setMaximumHeight(self.pushbuttonSize) + self.button_x_left.setToolTip('jog X-') + self.button_x_left.clicked.connect(self.xleftClicked) + self.button_x_right = QPushButton('X+', objectName='plus') + self.button_x_right.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_x_right.setMaximumHeight(self.pushbuttonSize) + self.button_x_right.setToolTip('jog X+') + self.button_x_right.clicked.connect(self.xRightClicked) + + ## Y movement + self.button_y_left = QPushButton('-Y', objectName='plus') + self.button_y_left.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_y_left.setMaximumHeight(self.pushbuttonSize) + self.button_y_left.setToolTip('jog Y-') + self.button_y_left.clicked.connect(self.yleftClicked) + self.button_y_right = QPushButton('Y+', objectName='plus') + self.button_y_right.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_y_right.setMaximumHeight(self.pushbuttonSize) + self.button_y_right.setToolTip('jog Y+') + self.button_y_right.clicked.connect(self.yRightClicked) + + ## Z movement + self.button_z_down = QPushButton('-Z', objectName='plus') + self.button_z_down.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_z_down.setMaximumHeight(self.pushbuttonSize) + self.button_z_down.setToolTip('jog Z-') + self.button_z_down.clicked.connect(self.zleftClicked) + self.button_z_up = QPushButton('Z+', objectName='plus') + self.button_z_up.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + self.button_z_up.setMaximumHeight(self.pushbuttonSize) + self.button_z_up.setToolTip('jog Z+') + self.button_z_up.clicked.connect(self.zRightClicked) + + ## layout jogPanel buttons + # add increment buttons + self.jogPanel.addWidget(self.button_001,0,0) + self.jogPanel.addWidget(self.button_01,0,1) + self.jogPanel.addWidget(self.button_1,1,1) + # add separator + self.jogPanel.addWidget(self.incrementLine, 2,0,1,2) + # add X movement buttons + self.jogPanel.addWidget(self.button_x_left,3,0) + self.jogPanel.addWidget(self.button_x_right,3,1) + # add Y movement buttons + self.jogPanel.addWidget(self.button_y_left,4,0) + self.jogPanel.addWidget(self.button_y_right,4,1) + # add Z movement buttons + self.jogPanel.addWidget(self.button_z_down,5,0) + self.jogPanel.addWidget(self.button_z_up,5,1) + # add separator + self.jogPanel.addWidget(self.keypadLine, 6,0,1,2) + + self.tabPanel.setDisabled(True) + self.mainLayout.addWidget(self.tabPanel) + self.firstTab.setLayout(self.jogPanel) + self.tabPanel.addTab(self.firstTab,'Jog') + self.tabPanel.setFixedWidth(95) + self.tabPanel.setTabBarAutoHide(True) + self.tabPanel.setStyleSheet('QTabWidget::pane {\ + margin: -13px -9px -13px -9px;\ + border: 1px solid white;\ + padding: 0px;\ + }') + + #### Footer layout + if(True): + self.footerLayout = QGridLayout() + self.footerLayout.setSpacing(0) + self.containerLayout.addLayout(self.footerLayout) + # Intructions box + self.instructionsBox = QTextEdit() + self.instructionsBox.setReadOnly(True) + self.instructionsBox.setFixedSize(640, 45) + self.footerLayout.addWidget(self.instructionsBox, 0,0,1,-1,Qt.AlignLeft|Qt.AlignVCenter) + + # Manual CP Capture button + self.manualCPCaptureButton = QPushButton('Save CP') + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + self.manualCPCaptureButton.setMinimumSize(self.pushbuttonSize*2,self.pushbuttonSize) + self.manualCPCaptureButton.setMaximumSize(self.pushbuttonSize*2,self.pushbuttonSize) + self.manualCPCaptureButton.setToolTip('Capture current machine coordinates as the Control Point.') + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.clicked.connect(self.manualCPCapture) + self.footerLayout.addWidget(self.manualCPCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton = QPushButton('Capture offset') + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + self.manualToolOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.manualToolOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.manualToolOffsetCaptureButton.setToolTip('Capture current position and calculate tool offset.') + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.clicked.connect(self.manualToolOffsetCapture) + self.footerLayout.addWidget(self.manualToolOffsetCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + + # Start Alignment button + self.alignToolsButton = QPushButton('Align Tools') + self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.alignToolsButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.alignToolsButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.alignToolsButton.setToolTip('Start automated tool offset calibration') + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setVisible(False) + self.alignToolsButton.clicked.connect(self.startAlignTools) + self.footerLayout.addWidget(self.alignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + + #### Set current interface state to disconnected + self.stateDisconnected() + + # send exiting to log + _logger.debug('*** exiting App.setupMenu') + + ########################################################################### Jog Panel functions + def xleftClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'X': str(-1*incrementDistance)}} + self.moveRelativeSignal.emit(params) + + def xRightClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'X': str(incrementDistance)}} + self.moveRelativeSignal.emit(params) + + def yleftClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'Y': str(-1*incrementDistance)}} + self.moveRelativeSignal.emit(params) + + def yRightClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'Y': str(incrementDistance)}} + self.moveRelativeSignal.emit(params) + + def zleftClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'Z': str(-1*incrementDistance)}} + self.moveRelativeSignal.emit(params) + + def zRightClicked(self): + # disable buttons + self.tabPanel.setDisabled(True) + # fetch current increment value + if self.button_1.isChecked(): + incrementDistance = 1 + elif self.button_01.isChecked(): + incrementDistance = 0.1 + elif self.button_001.isChecked(): + incrementDistance = 0.01 + params = {'moveSpeed': self._moveSpeed, 'position':{'Z': str(incrementDistance)}} + self.moveRelativeSignal.emit(params) + + ########################################################################### GUI State functions + def stateDisconnected(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(True) + self.connectButton.setDisabled(False) + self.connectButton.setStyleSheet(self.styleGreen) + # Disconnect button + self.disconnectButton.setVisible(False) + self.disconnectButton.setDisabled(True) + self.disconnectButton.setStyleSheet(self.styleDisabled) + # Setup CP button + self.cpSetupButton.setVisible(False) + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.setStyleSheet(self.styleDisabled) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(False) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(True) + # Delete tool buttons + count = self.jogPanel.count() + for i in range(11,count): + item = self.jogPanel.itemAt(i) + widget = item.widget() + widget.deleteLater() + self.resetCalibration() + + def stateConnected(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('D') + self.disconnectButton.setToolTip('Disconnect..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(False) + self.cpSetupButton.setStyleSheet(self.styleGreen) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(False) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(False) + + # Add tool checkboxes to right panel + self.toolButtons = [] + self.toolCheckboxes = [] + # highest tool number storage + numTools = max(self.__activePrinter['tools'], key= lambda x:int(x['number']))['number'] + _logger.debug('Highest tool number is: ' + str(numTools)) + for tool in range(numTools+1): + # add tool buttons + toolButton = QPushButton('T' + str(tool)) + toolButton.setObjectName('toolButton_'+str(tool)) + toolButton.setFixedSize(self.pushbuttonSize,self.pushbuttonSize) + toolButton.clicked.connect(self.identifyToolButton) + # check if current index exists in tool numbers from machine + if(any(d.get('number', -1) == tool for d in self.__activePrinter['tools'])): + toolButton.setToolTip('Fetch T' + str(tool) + ' to current machine position.') + self.toolButtons.append(toolButton) + # add tool checkboxes + toolCheckbox = QCheckBox() + toolCheckbox.setObjectName('toolCheckbox_' + str(tool)) + toolCheckbox.setToolTip('Add T' + str(tool) + ' to calibration.') + toolCheckbox.setChecked(True) + toolCheckbox.setCheckable(True) + toolCheckbox.setObjectName('toolCheckbox_' + str(tool)) + self.toolCheckboxes.append(toolCheckbox) + + # Display tool buttons + for i,button in enumerate(self.toolButtons): + button.setCheckable(True) + index = button.objectName() + index = int(index[-1:]) + if int(self.__activePrinter['currentTool']) == index: + button.setChecked(True) + else: + button.setChecked(False) + # button.clicked.connect(self.callTool) + self.jogPanel.addWidget(button, (7+i), 0, Qt.AlignCenter|Qt.AlignHCenter) + + # Display tool checkboxes + for i,checkbox in enumerate(self.toolCheckboxes): + checkbox.setCheckable(True) + checkbox.setChecked(True) + # button.clicked.connect(self.callTool) + self.jogPanel.addWidget(checkbox, (7+i), 1, Qt.AlignCenter|Qt.AlignHCenter) + # Alignment/Detection state reset + self.state = 0 + # Endstop calibration state flags + self.__stateManualCPCapture = False + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + # Nozzle calibration state flags + self.__stateManualNozzleAlignment = False + self.__stateAutoNozzleAlignment = False + self.toggleNozzleAutoDetectionSignal.emit(False) + + _logger.debug('Tool data and interface created successfully.') + + def stateCPSetup(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('C') + self.disconnectButton.setToolTip('Cancel..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.setStyleSheet(self.styleDisabled) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(True) + self.cpAutoCaptureButton.setDisabled(False) + self.cpAutoCaptureButton.setStyleSheet(self.styleOrange) + # Manual capture button + self.manualCPCaptureButton.setVisible(True) + self.manualCPCaptureButton.setDisabled(False) + self.manualCPCaptureButton.setStyleSheet(self.styleGreen) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(False) + + def stateCPAuto(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('C') + self.disconnectButton.setToolTip('Cancel..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.setStyleSheet(self.styleDisabled) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(True) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(True) + + def stateCalibrateReady(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('C') + self.disconnectButton.setToolTip('Cancel..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(False) + self.cpSetupButton.setStyleSheet(self.styleBlue) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(False) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(True) + self.alignToolsButton.setDisabled(False) + self.alignToolsButton.setStyleSheet(self.styleGreen) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(False) + # Tool buttons + for button in self.toolButtons: + button.setStyleSheet('') + button.setDisabled(False) + + def stateCalibtrateRunning(self): + # Settings option in menu + self.preferencesAction.setDisabled(True) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('C') + self.disconnectButton.setToolTip('Cancel..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.setStyleSheet(self.styleDisabled) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(False) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(True) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(True) + # Tool selection buttons disable + for button in self.toolButtons: + button.setDisabled(True) + + def stateCalibrateComplete(self): + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(False) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(False) + self.disconnectButton.setStyleSheet(self.styleRed) + self.disconnectButton.setText('D') + self.disconnectButton.setToolTip('Disconnect..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(False) + self.cpSetupButton.setStyleSheet(self.styleBlue) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(False) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(True) + self.alignToolsButton.setDisabled(False) + self.alignToolsButton.setStyleSheet(self.styleGreen) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(False) + # Tool buttons + for button in self.toolButtons: + button.setStyleSheet('') + button.setDisabled(False) + + def stateExiting(self): + # Settings option in menu + self.preferencesAction.setDisabled(True) + # Connect button + self.connectButton.setVisible(True) + self.connectButton.setDisabled(True) + self.connectButton.setStyleSheet(self.styleDisabled) + # Disconnect button + self.disconnectButton.setVisible(True) + self.disconnectButton.setDisabled(True) + self.disconnectButton.setStyleSheet(self.styleDisabled) + self.disconnectButton.setText('-') + self.disconnectButton.setToolTip('Disconnecting..') + # Setup CP button + self.cpSetupButton.setVisible(True) + self.cpSetupButton.setDisabled(True) + self.cpSetupButton.setStyleSheet(self.styleDisabled) + # CP Automated Capture button + self.cpAutoCaptureButton.setVisible(True) + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Manual capture button + self.manualCPCaptureButton.setVisible(False) + self.manualCPCaptureButton.setDisabled(True) + self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) + # Start Alignment button + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setStyleSheet(self.styleGreen) + # Manual Tool offset Capture button + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel tab + self.tabPanel.setDisabled(True) + + ########################################################################### User interactions + def setupCPCapture(self): + self.__stateSetupCPCapture = True + self.toggleEndstopDetectionSignal.emit(True) + # enable detection + self.toggleDetectionSignal.emit(True) + # update machine coordinates + self.pollCoordinatesSignal.emit() + # Get original physical coordinates + self.originalPrinterPosition = self.__currentPosition + self.__restorePosition = copy.deepcopy(self.__currentPosition) + # Setup camera calibration move coordinates + self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] + # guess position used for camera calibration + self.guess_position = [1,1] + # current keypoint location + self.uv = None + # previous keypoint location + self.olduv = self.uv + # detected blob counter + self.detect_count = 0 + # set initial state variable based on camera matrix exists or not + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.space_coordinates = [] + self.camera_coordinates = [] + self.calibration_moves = 0 + self.retries = 0 + self.stateCPSetup() + self.repaint() + + def manualCPCapture(self): + self.__stateSetupCPCapture = False + self.__stateManualCPCapture = True + # update machine coordinates + self.pollCoordinatesSignal.emit() + # stop endstop detection + self.toggleEndstopDetectionSignal.emit(False) + # update GUI + self.stateCalibrateReady() + + def setupCPAutoCapture(self): + # send calling to log + _logger.debug('*** calling App.setupCPAutoCapture') + self.startTime = time.time() + #################################### Camera Calibration + # Update GUI state + self.stateCPAuto() + # set state flag to handle camera calibration + self.__stateEndstopAutoCalibrate = True + self.__stateAutoCPCapture = True + # Start detector in DetectionManager + self.toggleEndstopAutoDetectionSignal.emit(True) + self.uv = [None, None] + self.callTool() + # send exiting to log + _logger.debug('*** exiting App.setupCPAutoCapture') + + def autoCalibrate(self): + self.tabPanel.setDisabled(True) + if(self.uv is not None): + if(self.uv[0] is not None and self.uv[1] is not None): + # First calibration step + if(self.state == 0): + self.updateStatusbarMessage('Calibrating camera step 0..') + self.olduv = self.uv + # Reset space and camera coordinates + self.space_coordinates = [] + self.camera_coordinates = [] + self.calibration_moves = 0 + self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) + self.camera_coordinates.append((self.uv[0], self.uv[1])) + # move carriage for calibration + self.offsetX = self.calibrationCoordinates[0][0] + self.offsetY = self.calibrationCoordinates[0][1] + params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} + self.lastState = 0 + self.state = 1 + self.retries = 0 + self.moveRelativeSignal.emit(params) + return + elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): + if(self.state == self.lastState): + self.offsetX = self.calibrationCoordinates[self.state][0] + self.offsetY = self.calibrationCoordinates[self.state][1] + self.state += 1 + params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} + self.moveRelativeSignal.emit(params) + return + else: + self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') + # check if we've already moved, and calculate mpp value + if self.state == 2: + self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) + # save position as previous position + self.olduv = self.uv + # save machine coordinates for detected nozzle + self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) + # save camera coordinates + self.camera_coordinates.append((self.uv[0],self.uv[1])) + # return carriage to relative center of movement + self.offsetX = (-1*self.offsetX) + self.offsetY = (-1*self.offsetY) + self.lastState = int(self.state) + params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} + self.moveRelativeSignal.emit(params) + return + elif(self.state == len(self.calibrationCoordinates)): + # Camera calibration moves completed. + # Update GUI thread with current status and percentage complete + updateMessage = 'Millimeters per pixel is ' + str(self.mpp) + self.updateStatusbarMessage(updateMessage) + _logger.info(updateMessage) + # save position as previous position + self.olduv = self.uv + # save machine coordinates for detected nozzle + self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) + # save camera coordinates + self.camera_coordinates.append((self.uv[0],self.uv[1])) + # calculate camera transformation matrix + cameraCalibrationTime = np.around(time.time() - self.startTime,1) + _logger.info('Camera calibrated (' + str(cameraCalibrationTime) + 's); aligning..') + self.state = 200 + self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)] + self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input) + # define camera center in machine coordinate space + self.newCenter = self.transform_matrix.T @ np.array([0, 0, 0, 0, 0, 1]) + self.guess_position[0]= np.around(self.newCenter[0],3) + self.guess_position[1]= np.around(self.newCenter[1],3) + _logger.info('Calibration positional guess: ' + str(self.guess_position)) + params = {'position':{'X': self.guess_position[0], 'Y': self.guess_position[1]}} + self.moveAbsoluteSignal.emit(params) + return + elif(self.state == 200): + if(self.__stateEndstopAutoCalibrate): + updateMessage = 'Endstop calibration step ' + str(self.calibration_moves) + '.. (MPP=' + str(self.mpp) +')' + else: + updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibration_moves) + '.. (MPP=' + str(self.mpp) +')' + self.updateStatusbarMessage(updateMessage) + # increment moves counter + self.calibration_moves += 1 + # nozzle detected, frame rotation is set, start + self.cx,self.cy = self.normalize_coords(self.uv) + self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0] + self.offsets = -1*(0.55*self.transform_matrix.T @ self.v) + self.offsets[0] = np.around(self.offsets[0],3) + self.offsets[1] = np.around(self.offsets[1],3) + # Add rounding handling for endstop alignment + if(self.__stateEndstopAutoCalibrate): + if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): + self.offsets[0] = 0.0 + self.offsets[1] = 0.0 + if(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): + params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000} + self.moveRelativeSignal.emit(params) + _logger.debug('Calibration move X{0:-1.3f} Y{1:-1.3f} F1000 '.format(self.offsets[0],self.offsets[1])) + return + else: + # auto calibration complete - end process + if(self.__stateEndstopAutoCalibrate): + # endstop calibration handling + updateMessage = 'Endstop auto-calibrated in ' + str(self.calibration_moves) + ' steps. (MPP=' + str(self.mpp) +')' + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + # disable detection + self.toggleDetectionSignal.emit(False) + self.__cpCoordinates['X'] = np.around(self.__currentPosition['X'],2) + self.__cpCoordinates['Y'] = np.around(self.__currentPosition['Y'],2) + self.__cpCoordinates['Z'] = np.around(self.__currentPosition['Z'],2) + self.cpLabel.setText('CP: ('+ str(self.__cpCoordinates['X']) + ', ' + str(self.__cpCoordinates['Y']) + ')') + self.cpLabel.setStyleSheet(self.styleGreen) + self.stateCalibrateReady() + self.repaint() + elif(self.__stateAutoNozzleAlignment): + updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' has been calibrated.' + self.state = 100 + self.retries = 0 + self.updateStatusbarMessage(updateMessage) + self.pollCoordinatesSignal.emit() + return + elif(self.retries < 3): + self.retries += 1 + # enable detection + self.toggleDetectionSignal.emit(True) + self.pollCoordinatesSignal.emit() + return + else: + self.retries = 0 + if(self.__stateEndstopAutoCalibrate is True): + self.updateStatusbarMessage('Failed to detect endstop.') + _logger.warning('Failed to detect endstop. Cancelled operation.') + if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): + params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} + self.moveAbsoluteSignal.emit(params) + # End calibration + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.pollCoordinatesSignal.emit() + self.haltCPAutoCapture() + elif(self.__stateAutoNozzleAlignment is True): + updateMessage = 'Failed to detect nozzle. Try manual override.' + self.updateStatusbarMessage(updateMessage) + _logger.warning(updateMessage) + self.state = -99 + # End auto calibration + self.__stateAutoNozzleAlignment = False + self.__stateManualNozzleAlignment = True + # calibrating nozzle manual + self.tabPanel.setDisabled(False) + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(True) + self.manualToolOffsetCaptureButton.setDisabled(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleNozzleDetectionSignal.emit(True) + self.toggleDetectionSignal.emit(True) + + def haltCPAutoCapture(self): + self.resetCalibration() + self.statusBar.showMessage('CP calibration cancelled.') + # Reset GUI + self.stateConnected() + + def haltNozzleCapture(self): + self.resetNozzleAlignment() + self.statusBar.showMessage('Nozzle calibration halted.') + # Reset GUI + self.stateCalibrateReady() + + def manualToolOffsetCapture(self): + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # set tool alignment state to trigger offset calculation + self.state = 100 + self.pollCoordinatesSignal.emit() + + def resetCalibration(self): + # Reset program state, and frame capture control to defaults + self.mpp = None + self.transform_matrix = None + self.transform_input = None + self.state = 0 + self.__stateSetupCPCapture = False + self.__stateManualCPCapture = False + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + # self.callTool(-1) + self.getVideoFrameSignal.emit() + + def resetNozzleAlignment(self): + # Reset program state, and frame capture control to defaults + self.__stateEndstopAutoCalibrate = False + self.__stateAutoCPCapture = False + self.__stateSetupCPCapture = False + self.__stateManualNozzleAlignment = False + self.__stateAutoNozzleAlignment = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) + self.retries = 0 + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.calibration_moves = 0 + self.callTool(-1) + self.getVideoFrameSignal.emit() + + def startAlignTools(self): + # send calling to log + _logger.debug('*** calling App.startAlignTools') + self.startTime = time.time() + self.__stateManualNozzleAlignment = True + self.__stateAutoNozzleAlignment = True + self.uv = [None, None] + + # Update GUI state + self.stateCalibtrateRunning() + self.updateStatusbarMessage('Starting tool alignment..') + + # Create array for tools included in alignment process + self.alignmentToolSet = [] + self.workingToolset = None + for checkbox in self.toolCheckboxes: + if(checkbox.isChecked()): + self.alignmentToolSet.append(int(checkbox.objectName()[13:])) + if(len(self.alignmentToolSet) > 0): + self.toggleNozzleAutoDetectionSignal.emit(True) + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.calibrateTools(self.alignmentToolSet) + else: + # tell user no tools selected + errorMsg = 'No tools selected for alignment' + self.updateStatusbarMessage(errorMsg) + _logger.info(errorMsg) + self.haltNozzleCapture() + # send exiting to log + _logger.debug('*** exiting App.startAlignTools') + + def identifyToolButton(self): + sender = self.sender() + for button in self.toolButtons: + if button.objectName() != sender.objectName(): + button.setChecked(False) + toolIndex = int(sender.objectName()[11:]) + self.callTool(toolIndex) + + def calibrateTools(self, toolset=None): + if(toolset is not None): + self.workingToolset = toolset + if(len(self.workingToolset)>0): + toolIndex = self.workingToolset[0] + _logger.info('Calibrating T' + str(toolIndex) +'..') + self.workingToolset.pop(0) + for button in self.toolButtons: + buttonName = 'toolButton_' + str(toolIndex) + if(button.objectName() == buttonName): + button.setStyleSheet(self.styleOrange) + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.retries = 0 + self.calibration_moves = 0 + self.__stateManualNozzleAlignment = True + self.__stateAutoNozzleAlignment = True + self.toggleNozzleAutoDetectionSignal.emit(True) + self.toolTime = time.time() + self.callTool(toolIndex) + else: + calibration_time = np.around(time.time() - self.startTime,1) + _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel') + self.stateCalibrateComplete() + self.repaint() + self.resetNozzleAlignment() + + + ########################################################################### Module interfaces and handlers + ########################################################################### Interface with Detection Manager + def createDetectionManagerThread(self, announce=True): + if(announce): + _logger.info(' .. starting Detection Manager.. ') + # Thread setup + self.detectionThread = QThread() + self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None) + self.detectionManager.moveToThread(self.detectionThread) + self.detectionThread.start(priority=QThread.TimeCriticalPriority) + # Thread management signals and slots + self.detectionManager.errorSignal.connect(self.detectionManagerError) + self.detectionThread.started.connect(self.detectionManager.processFrame) + self.detectionThread.finished.connect(self.detectionManager.quit) + self.detectionThread.finished.connect(self.detectionManager.deleteLater) + self.detectionThread.finished.connect(self.detectionThread.deleteLater) + # Video frame signals and slots + self.detectionManager.detectionManagerNewFrameSignal.connect(self.refreshImage) + self.detectionManager.detectionManagerReadySignal.connect(self.startVideo) + self.getVideoFrameSignal.connect(self.detectionManager.processFrame) + # Camera image properties signals and slots + self.setImagePropertiesSignal.connect(self.detectionManager.relayImageProperties) + self.resetImageSignal.connect(self.detectionManager.relayResetImage) + # Endstop alignment signals and slots + self.toggleEndstopDetectionSignal.connect(self.detectionManager.toggleEndstopDetection) + self.toggleEndstopAutoDetectionSignal.connect(self.detectionManager.toggleEndstopAutoDetection) + # Nozzle alignment signals and slots + self.toggleNozzleDetectionSignal.connect(self.detectionManager.toggleNozzleDetection) + self.toggleNozzleAutoDetectionSignal.connect(self.detectionManager.toggleNozzleAutoDetection) + # Master detection swtich enable/disable + self.toggleDetectionSignal.connect(self.detectionManager.enableDetection) + + @pyqtSlot(object) + def startVideo(self, cameraProperties): + # send calling to log + _logger.debug('*** calling App.startVideo') + # capture camera properties + self.__activeCamera = cameraProperties + self.startVideoSignal.emit() + # send exiting to log + _logger.debug('*** exiting App.startVideo') + + @pyqtSlot(object) + def refreshImage(self, data): + frame = data[0] + uvCoordinates = data[1] + self.image.setPixmap(frame) + if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): + self.uv = uvCoordinates + self.getVideoFrameSignal.emit() + + @pyqtSlot(object) + def updateStatusbarMessage(self, message): + # send calling to log + _logger.debug('*** calling App.updateStatusbarMessage') + try: + self.statusBar.showMessage(message) + self.statusBar.setStyleSheet('') + except: + errorMsg = 'Error sending message to statusbar.' + _logger.error(errorMsg) + app.processEvents() + # send exiting to log + _logger.debug('*** exiting App.updateStatusbarMessage') + + @pyqtSlot(object) + def detectionManagerError(self, message): + self.haltPrinterOperation(silent=True) + try: + self.statusBar.showMessage(message) + self.statusBar.setStyleSheet(self.styleRed) + self.cpLabel.setStyleSheet(self.styleOrange) + self.connectionStatusLabel.setStyleSheet(self.styleOrange) + self.resetCalibration() + # self.moveAbsoluteSignal.emit(self.originalPrinterPosition) + except: + errorMsg = 'Error sending message to statusbar.' + _logger.error(errorMsg) + # Kill thread + self.detectionThread.quit() + self.detectionThread.wait() + self.printerThread.quit() + self.printerThread.wait() + # display error image + self.image.setPixmap(self.errorImage) + + ########################################################################### Interface with Printer Manager + def createPrinterManagerThread(self,announce=True): + if(announce): + _logger.info(' .. starting Printer Manager.. ') + try: + self.printerManager = PrinterManager(parent=None, firmwareList=self.__firmwareList, driverList=self.__driverList) + self.printerThread = QThread() + self.printerManager.moveToThread(self.printerThread) + + self.printerManager.errorSignal.connect(self.printerError) + # self.printerManager.printerDisconnectedSignal.connect(self.printerThread.quit) + # self.printerManager.printerDisconnectedSignal.connect(self.printerManager.deleteLater) + self.printerThread.finished.connect(self.printerManager.quit) + self.printerThread.finished.connect(self.printerManager.deleteLater) + self.printerThread.finished.connect(self.printerThread.deleteLater) + + # PrinterManager object signals + self.printerManager.updateStatusbarSignal.connect(self.updateStatusbarMessage) + self.printerManager.activePrinterSignal.connect(self.printerConnected) + self.printerManager.printerDisconnectedSignal.connect(self.printerDisconnected) + self.connectSignal.connect(self.printerManager.connectPrinter) + self.disconnectSignal.connect(self.printerManager.disconnectPrinter) + + self.moveRelativeSignal.connect(self.printerManager.moveRelative) + self.moveAbsoluteSignal.connect(self.printerManager.moveAbsolute) + self.printerManager.moveCompleteSignal.connect(self.printerMoveComplete) + + self.pollCoordinatesSignal.connect(self.printerManager.getCoordinates) + self.printerManager.coordinatesSignal.connect(self.saveCurrentPosition) + + self.callToolSignal.connect(self.printerManager.callTool) + self.printerManager.toolLoadedSignal.connect(self.toolLoaded) + self.pollCurrentToolSignal.connect(self.printerManager.currentTool) + self.printerManager.toolIndexSignal.connect(self.registerActiveTool) + + self.printerManager.offsetsSetSignal.connect(self.calibrateOffsetsApplied) + self.setOffsetsSignal.connect(self.printerManager.calibrationSetOffset) + + self.printerThread.start(priority=QThread.TimeCriticalPriority) + except Exception as e: + print(e) + errorMsg = 'Failed to start PrinterManager.' + _logger.critical(errorMsg) + self.printerError(errorMsg) + + def connectPrinter(self): + self.connectButton.setStyleSheet(self.styleDisabled) + self.connectButton.setDisabled(True) + self.preferencesAction.setDisabled(True) + self.statusBar.setStyleSheet("") + self.connectionStatusLabel.setText('Connecting..') + self.connectionStatusLabel.setStyleSheet(self.styleBlue) + self.repaint() + try: + if(self.printerThread.isRunning() is False): + self.createPrinterManagerThread(announce=False) + except: self.createPrinterManagerThread(announce=False) + self.connectSignal.emit(self.__activePrinter) + + def haltPrinterOperation(self, **kwargs): + try: + silent = kwargs['silent'] + except: silent = False + # disconnect button + self.disconnectButton.setStyleSheet(self.styleDisabled) + self.disconnectButton.setDisabled(True) + # Cancel CP Capture + if(self.__stateAutoCPCapture is True or self.__stateEndstopAutoCalibrate is True or self.__stateSetupCPCapture is True): + self.haltCPAutoCapture() + return + # Cancel Nozzle Detection + if(self.__stateAutoNozzleAlignment is True or self.__stateManualNozzleAlignment is True): + self.haltNozzleCapture() + return + # Disconnect from printer + # Status label + self.connectionStatusLabel.setText('Disconnecting..') + self.connectionStatusLabel.setStyleSheet(self.styleBlue) + # Setup CP button + self.cpSetupButton.setStyleSheet(self.styleDisabled) + self.cpSetupButton.setDisabled(True) + # CP Automated Capture button + self.cpAutoCaptureButton.setDisabled(True) + self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled) + # Jog panel + self.tabPanel.setDisabled(True) + self.repaint() + params = {} + if(silent): + params['noUpdate'] = True + else: + params['noUpdate'] = False + # restore position + if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): + params['parkPosition'] = self.__cpCoordinates + self.disconnectSignal.emit(params) + elif(self.__restorePosition is not None): + params['parkPosition'] = self.__restorePosition + self.disconnectSignal.emit(params) + else: + self.disconnectSignal.emit(params) + self.updateStatusbarMessage('Printer disconnected.') + + def callTool(self, toolNumber=-1): + # disable detection + self.toggleDetectionSignal.emit(False) + toolNumber = int(toolNumber) + try: + if(toolNumber == int(self.__activePrinter['currentTool'])): + self.callToolSignal.emit(-1) + else: + self.callToolSignal.emit(toolNumber) + except: + errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) + _logger.error(errorMsg) + self.printerError(errorMsg) + + @pyqtSlot() + def toolLoaded(self): + self.pollCurrentToolSignal.emit() + if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): + params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__cpCoordinates['X'], 'Y': self.__cpCoordinates['Y'], 'Z': self.__cpCoordinates['Z']}} + else: + params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__currentPosition['X'], 'Y': self.__currentPosition['Y'], 'Z': self.__cpCoordinates['Z']}} + self.moveAbsoluteSignal.emit(params) + + @pyqtSlot(object) + def printerConnected(self, printerJSON): + # send calling to log + _logger.debug('*** calling App.printerConnected') + self.__activePrinter = printerJSON + #HBHBHBHB TODO: update interface for connected state + # self.statusBar.showMessage('Printer connected.') + # Status label + self.connectionStatusLabel.setText(self.__activePrinter['nickname'] + ' [' + self.__activePrinter['controller'] + ' v' + self.__activePrinter['version'] + ']') + self.connectionStatusLabel.setStyleSheet(self.styleGreen) + # poll for position + self.__firstConnection = True + self.pollCoordinatesSignal.emit() + # Gui state + self.stateConnected() + self.repaint() + # send exiting to log + _logger.debug('*** exiting App.printerConnected') + + @pyqtSlot(object) + def printerDisconnected(self, **kwargs): + try: + message = kwargs['message'] + except: message = None + # send calling to log + _logger.debug('*** calling App.printerDisconnected') + # update status bar + if(message is not None): + self.statusBar.showMessage(message) + self.statusBar.setStyleSheet("") + # CP Label + self.cpLabel.setText('CP: undef') + self.cpLabel.setStyleSheet(self.styleOrange) + # Status label + self.connectionStatusLabel.setText('Disconnected') + self.connectionStatusLabel.setStyleSheet(self.styleOrange) + self.stateDisconnected() + self.repaint() + # send exiting to log + _logger.debug('*** exiting App.printerDisconnected') + + @pyqtSlot(object) + def printerError(self, message): + _logger.debug('Printer Error: ' + message) + if(self.__restorePosition is not None): + params = {'parkPosition': self.__restorePosition} + self.disconnectSignal.emit(params) + else: + self.disconnectSignal.emit(None) + # Kill printer thread + try: + self.printerThread.quit() + self.printerThread.wait() + except: _logger.warning('Printer thread not created yet.') + self.printerDisconnected(message=message) + self.statusBar.setStyleSheet(self.styleRed) + + @pyqtSlot(int) + def registerActiveTool(self, toolIndex): + self.__activePrinter['currentTool'] = toolIndex + for button in self.toolButtons: + if(button.objectName() != ('toolButton_'+str(toolIndex))): + button.setChecked(False) + else: + button.setChecked(True) + + @pyqtSlot(object) + def saveCurrentPosition(self, coordinates): + self.__currentPosition = coordinates + self.toggleDetectionSignal.emit(True) + if(self.__stateManualCPCapture is True): + _logger.debug('saveCurrentPosition: manual CP capture') + self.__cpCoordinates = coordinates + self.__stateManualCPCapture = False + self.cpLabel.setText('CP: ('+ str(self.__cpCoordinates['X']) + ', ' + str(self.__cpCoordinates['Y']) + ')') + self.cpLabel.setStyleSheet(self.styleGreen) + self.updateStatusbarMessage('CP captured.') + self.repaint() + elif(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): + if(self.state != 100): + _logger.debug('saveCurrentPosition: autoCalibrate nozzle for T' + str(int(self.__activePrinter['currentTool']))) + self.autoCalibrate() + else: + _logger.debug('saveCurrentPosition: autoCalibrate nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) + # set initial state variable based on camera matrix exists or not + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.__stateAutoNozzleAlignment = True + self.toggleNozzleAutoDetectionSignal.emit(True) + params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} + self.setOffsetsSignal.emit(params) + elif(self.__stateManualNozzleAlignment is True): + _logger.debug('saveCurrentPosition: manual nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) + # set initial state variable based on camera matrix exists or not + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.__stateAutoNozzleAlignment = True + self.toggleNozzleAutoDetectionSignal.emit(True) + params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} + self.setOffsetsSignal.emit(params) + elif(self.__firstConnection): + _logger.debug('saveCurrentPosition: firstConnection') + self.__restorePosition = copy.deepcopy(self.__currentPosition) + self.__firstConnection = False + + @pyqtSlot() + def printerMoveComplete(self): + self.tabPanel.setDisabled(False) + if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Endstop auto detection active') + # Calibrating camera, go based on state + self.tabPanel.setDisabled(True) + elif(self.__stateAutoNozzleAlignment is True): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Nozzle auto detection active') + # calibrating nozzle auto + self.tabPanel.setDisabled(True) + elif(self.__stateManualNozzleAlignment is True): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Nozzle manual detection active') + # calibrating nozzle manual + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.manualCPCaptureButton.setVisible(True) + return + self.pollCoordinatesSignal.emit() + + @pyqtSlot(object) + def calibrateOffsetsApplied(self, params=None): + try: + offsets = params['offsets'] + except: offsets = None + if(offsets is not None): + toolCalibrationTime = np.around(time.time() - self.toolTime,1) + successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ') -- [' + str(toolCalibrationTime) + 's].' + self.updateStatusbarMessage(successMsg) + _logger.info(successMsg) + self.state = 200 + self.retries = 0 + self.__stateAutoNozzleAlignment = True + self.toggleNozzleAutoDetectionSignal.emit(True) + self.calibrateTools(self.workingToolset) + else: + raise SystemExit('FUCKED!') + + ########################################################################### Interface with Settings Dialog + def displayPreferences(self, event=None, newPrinterFlag=False): + _logger.debug('*** calling App.displayPreferences') + # check if we already have a printer manager thread, and start it + try: + if(self.printerThread.isRunning() is False): + self.createPrinterManagerThread(announce=False) + except: self.createPrinterManagerThread(announce=False) + # Set up settings window + try: + self.settingsDialog = SettingsDialog(parent=self, newPrinter=newPrinterFlag, geometry=self.__settingsGeometry, settings=self.__userSettings, firmwareList=self.__firmwareList, cameraProperties=self.__activeCamera) + # # Signals + # self.settingsDialog.settingsAlignmentPollSignal.connect() + # self.settingsDialog.settingsChangeVideoSrcSignal.connect() + # self.settingsDialog.settingsRequestCameraProperties.connect() + # self.settingsDialog.settingsRequestImageProperties.connect() + self.settingsDialog.settingsResetImageSignal.connect(self.relayResetCameraDefaults) + self.settingsDialog.settingsSetImagePropertiesSignal.connect(self.relayImageParameters) + self.settingsDialog.settingsUpdateSignal.connect(self.updateSettings) + self.settingsDialog.settingsStatusbarSignal.connect(self.updateStatusbarMessage) + self.settingsDialog.settingsGeometrySignal.connect(self.saveSettingsGeometry) + self.settingsDialog.rejected.connect(self.settingsDialog.deleteLater) + self.settingsDialog.accepted.connect(self.settingsDialog.deleteLater) + self.settingsDialog.settingsNewPrinter.connect(self.saveNewPrinter) + + except: + errorMsg = 'Cannot start settings dialog.' + _logger.exception(errorMsg) + return + # self.settingsDialog.update_settings.connect(self.updateSettings) + self.settingsDialog.exec() + + _logger.debug('*** exiting App.displayPreferences') + + @pyqtSlot(object) + def relayImageParameters(self, imageProperties): + self.setImagePropertiesSignal.emit(imageProperties) + + @pyqtSlot() + def relayResetCameraDefaults(self): + self.resetImageSignal.emit() + + @pyqtSlot(object) + def updateSettings(self, settingOptions): + _logger.debug('*** calling App.updateSettings') + self.__userSettings = settingOptions + self.saveUserSettings() + _logger.debug('*** exiting App.updateSettings') + + @pyqtSlot(object) + def saveNewPrinter(self, newSettingsOptions): + self.__userSettings = newSettingsOptions + self.saveUserSettings() + newPrinterIndex = len(newSettingsOptions['printer'])-1 + self.__activePrinter = newSettingsOptions['printer'][newPrinterIndex] + self.connectSignal.emit(self.__activePrinter) + + @pyqtSlot(object) + def saveSettingsGeometry(self, geometry): + self.__settingsGeometry = geometry + + ########################################################################### Utilities + def sanitizeURL(self, inputString='http://localhost'): + _logger.debug('*** calling App.sanitizeURL') + _errCode = 0 + _errMsg = '' + _printerURL = 'http://localhost' + from urllib.parse import urlparse + u = urlparse(inputString) + scheme = u[0] + netlocation = u[1] + if len(scheme) < 4 or scheme.lower() not in ['http']: + _errCode = 1 + _errMsg = 'Invalid scheme. Please only use http connections.' + elif len(netlocation) < 1: + _errCode = 2 + _errMsg = 'Invalid IP/network address.' + elif scheme.lower() in ['https']: + _errCode = 3 + _errMsg = 'Cannot use https connections for Duet controllers' + else: + _printerURL = scheme + '://' + netlocation + _logger.debug('*** exiting App.sanitizeURL') + return(_errCode, _errMsg, _printerURL) + + def saveUserSettings(self): + # save user settings.json + _logger.debug('*** calling App.saveUserSettings') + try: + # get default camera from user settings + cameraSet = False + # new_video_src = 0 + # for camera in self.__userSettings['camera']: + # try: + # if(camera['default'] == 1 and cameraSet is False): + # # if new default, switch feed + # if(self._videoSrc != new_video_src): + # new_video_src = camera['video_src'] + # self.video_thread.changeVideoSrc(newSrc=new_video_src) + # self._videoSrc = new_video_src + # cameraSet = True + # continue + # elif(cameraSet): + # # already have a default, unset other entries + # camera['default'] = 0 + # except KeyError: + # # No default camera defined, add key + # camera['default'] = 0 + # continue + # check if there are no cameras set as default + if(cameraSet is False): + # Set first camera entry to be the default source + self.__userSettings['camera'][0]['default'] = 1 + # try: + # # activate default camera feed + # self.video_thread.changeVideoSrc(newSrc=new_video_src) + # self._videoSrc = new_video_src + # except: + # _logger.critical('Cannot load default camera source.') + # Save settings to file + with open('./config/settings.json','w') as outputfile: + json.dump(self.__userSettings, outputfile) + _logger.info('User preferences saved to settings.json') + self.updateStatusbarMessage('User preferences saved to settings.json') + self.statusBar.setStyleSheet('') + except Exception as e1: + _logger.error('Error saving user settings file.' + str(e1)) + self.statusBar.showMessage('Error saving user settings file.') + self.statusBar.setStyleSheet(self.styleRed) + _logger.debug('*** exiting App.saveUserSettings') + + def getDistance(self, x1, y1, x0, y0): + _logger.debug('*** calling CalibrateNozzles.getDistance') + x1_float = float(x1) + x0_float = float(x0) + y1_float = float(y1) + y0_float = float(y0) + x_dist = (x1_float - x0_float) ** 2 + y_dist = (y1_float - y0_float) ** 2 + retVal = np.sqrt((x_dist + y_dist)) + returnVal = np.around(retVal,3) + _logger.debug('*** exiting CalibrateNozzles.getDistance') + return(returnVal) + + def normalize_coords(self,coords): + xdim, ydim = self._cameraWidth, self._cameraHeight + returnValue = (coords[0] / xdim - 0.5, coords[1] / ydim - 0.5) + return(returnValue) + + def least_square_mapping(self,calibration_points): + # Compute a 2x2 map from displacement vectors in screen space to real space. + n = len(calibration_points) + real_coords, pixel_coords = np.empty((n,2)),np.empty((n,2)) + for i, (r,p) in enumerate(calibration_points): + real_coords[i] = r + pixel_coords[i] = p + x,y = pixel_coords[:,0],pixel_coords[:,1] + A = np.vstack([x**2,y**2,x * y, x,y,np.ones(n)]).T + transform = np.linalg.lstsq(A, real_coords, rcond = None) + return transform[0], transform[1].mean() + + ########################################################################### Close application handler + def closeEvent(self, event): + _logger.debug('*** calling App.closeEvent') + self.stateExiting() + try: + _logger.info('Closing TAMV..') + self.statusBar.showMessage('Shutting down TAMV..') + self.repaint() + try: + self.detectionThread.quit() + self.detectionThread.wait() + except: pass + try: + self.printerThread.quit() + self.printerThread.wait() + except: pass + except Exception: + _logger.critical('Close event error: \n' + traceback.format_exc()) + # Output farewell message + print() + print('Thank you for using TAMV!') + print('Check out www.jubilee3d.com') + _logger.debug('*** exiting App.closeEvent') + super(App, self).closeEvent(event) + + ########################################################################### First run setups + def show(self): + self.createDetectionManagerThread() + self.createPrinterManagerThread() + _logger.info('Initialization complete.') + # Output welcome message + print() + print(' Welcome to TAMV!') + print() + super().show() + +if __name__=='__main__': + ### Setup OS options + # os.putenv("QT_LOGGING_RULES","qt5ct.debug=true") + os.putenv("OPENCV_VIDEOIO_DEBUG", "0") + os.putenv("OPENCV_VIDEOIO_PRIORITY_LIST", "DSHOW,FFMPEG,GSTREAMER") + ### Setup argmument parser + parser = argparse.ArgumentParser(description='Program to allign multiple tools on Duet/klipper based printers, using machine vision.', allow_abbrev=False) + parser.add_argument('-d','--debug',action='store_true',help='Enable debug output to terminal') + # Execute argument parser + args=vars(parser.parse_args()) + + ### Setup logging + _logger = logging.getLogger("TAMV") + _logger.setLevel(logging.DEBUG) + ### # file handler logging + fileFormatter = logging.Formatter('%(asctime)s --- %(levelname)s --- M:%(name)s --- T:%(threadName)s --- F:%(funcName)s --- L:%(lineno)d --- %(message)s') + # migrate logs to /log path + try: + os.makedirs('./log', exist_ok=True) + except: + print() + print('Cannot create \"./log folder.') + print('Please create this folder manually and restart TAMV') + raise SystemExit('Cannot retrieve log path.') + if(os.path.exists('./TAMV.log')): + if(os.path.exists('./log/TAMV.log')): + print() + print('Deleting old log file ./TAMV.log..') + try: + os.remove('./TAMV.log') + except: + print('Cannot delete old log file \"./TAMV.log\", ignoring it..') + print('Log file now located in \"./log/TAMV.log\"') + else: + try: + print() + print('Moving log file to \"./log/TAMV.log\"..') + os.replace('./TAMV.log','./log/TAMV.log') + except: + print('Unknown OS error while moving log file to new folder.') + print('Please move \"./TAMV.log\" to \"./log/TAMV.log\" and restart TAMV.') + raise SystemExit('Cannot move log file.') + fh = RotatingFileHandler('./log/TAMV.log',backupCount=1,maxBytes=1000000) + if(args['debug']): + fh.setLevel(logging.DEBUG) + else: + fh.setLevel(logging.INFO) + fh.setFormatter(fileFormatter) + _logger.addHandler(fh) + ### # console handler logging + consoleFormatter = logging.Formatter(fmt='%(levelname)-9s: %(message)s') + ch = logging.StreamHandler() + if(args['debug']): + ch.setLevel(logging.DEBUG) + else: + ch.setLevel(logging.INFO) + ch.setFormatter(consoleFormatter) + _logger.addHandler(ch) + + ### # log startup messages + print() + _logger.warning('This is an alpha release. Always use only when standing next to your machine and ready to hit EMERGENCY STOP.') + print() + + ### start GUI application + app = QApplication(sys.argv) + a = App() + a.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/TAMV_GUI.py b/TAMV_GUI.py deleted file mode 100755 index eaa044b..0000000 --- a/TAMV_GUI.py +++ /dev/null @@ -1,2382 +0,0 @@ -#!/usr/bin/env python3 - -# TAMV version 2.0RC1 -# Python Script to align multiple tools on Jubilee printer with Duet3d Controller -# Using images from USB camera and finding circles in those images -# -# TAMV originally Copyright (C) 2020 Danal Estes all rights reserved. -# TAMV 2.0 Copyright (C) 2021 Haytham Bennani all rights reserved. -# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT -# -# Requires OpenCV to be installed on Pi -# Requires running via the OpenCV installed python (that is why no shebang) -# Requires network connection to Duet based printer running Duet/RepRap V2 or V3 -# - -# GUI imports -from PyQt5.QtWidgets import ( - QAction, - QApplication, - QCheckBox, - QCheckBox, - QComboBox, - QDesktopWidget, - QDialog, - QDialogButtonBox, - QGridLayout, - QGroupBox, - QHBoxLayout, - QHeaderView, - QInputDialog, - QLabel, - QLineEdit, - QMainWindow, - QMenu, - QMenuBar, - QMessageBox, - QPushButton, - QSlider, - QSpinBox, - QStatusBar, - QStyle, - QTableWidget, - QTableWidgetItem, - QTextEdit, - QVBoxLayout, - QWidget -) -from PyQt5.QtGui import QPixmap, QImage, QPainter, QColor, QIcon -from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QThread, QMutex, QPoint, QSize - -# Core imports -import os -import sys -import cv2 -import numpy as np -import math -import DuetWebAPI as DWA -from time import sleep, time -import datetime -import json -import time - -# graphing imports -import matplotlib -import matplotlib.pyplot as plt -import matplotlib.cm as cm -import matplotlib.patches as patches -from matplotlib.ticker import FormatStrFormatter - -# styles -global style_green, style_red, style_disabled, style_orange -style_green = 'background-color: green; color: white;' -style_red = 'background-color: red; color: white;' -style_disabled = 'background-color: #cccccc; color: #999999; border-style: solid;' -style_orange = 'background-color: dark-grey; color: orange;' - -class CPDialog(QDialog): - def __init__(self, - parent=None, - title='Set Controlled Point', - summary='Instructions:
Jog until controlled point is centered in the window.
Click OK to save and return to main window.', - disabled = False): - super(CPDialog,self).__init__(parent=parent) - self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) - self.setWindowTitle(title) - QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - self.layout = QGridLayout() - self.layout.setSpacing(3) - # add information panel - self.cp_info = QLabel(summary) - # add jogging grid - self.buttons={} - buttons_layout = QGridLayout() - - # X - self.button_x1 = QPushButton('-1') - self.button_x2 = QPushButton('-0.1') - self.button_x3 = QPushButton('-0.01') - self.button_x4 = QPushButton('+0.01') - self.button_x5 = QPushButton('+0.1') - self.button_x6 = QPushButton('+1') - # set X sizes - self.button_x1.setFixedSize(60,60) - self.button_x2.setFixedSize(60,60) - self.button_x3.setFixedSize(60,60) - self.button_x4.setFixedSize(60,60) - self.button_x5.setFixedSize(60,60) - self.button_x6.setFixedSize(60,60) - # attach actions - self.button_x1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-1 G90')) - self.button_x2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-0.1 G90')) - self.button_x3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-0.01 G90')) - self.button_x4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X0.01 G90')) - self.button_x5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X0.1 G90')) - self.button_x6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X1 G90')) - # add buttons to window - x_label = QLabel('X') - buttons_layout.addWidget(x_label,0,0) - buttons_layout.addWidget(self.button_x1,0,1) - buttons_layout.addWidget(self.button_x2,0,2) - buttons_layout.addWidget(self.button_x3,0,3) - buttons_layout.addWidget(self.button_x4,0,4) - buttons_layout.addWidget(self.button_x5,0,5) - buttons_layout.addWidget(self.button_x6,0,6) - - # Y - self.button_y1 = QPushButton('-1') - self.button_y2 = QPushButton('-0.1') - self.button_y3 = QPushButton('-0.01') - self.button_y4 = QPushButton('+0.01') - self.button_y5 = QPushButton('+0.1') - self.button_y6 = QPushButton('+1') - # set X sizes - self.button_y1.setFixedSize(60,60) - self.button_y2.setFixedSize(60,60) - self.button_y3.setFixedSize(60,60) - self.button_y4.setFixedSize(60,60) - self.button_y5.setFixedSize(60,60) - self.button_y6.setFixedSize(60,60) - # attach actions - self.button_y1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-1 G90')) - self.button_y2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-0.1 G90')) - self.button_y3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-0.01 G90')) - self.button_y4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y0.01 G90')) - self.button_y5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y0.1 G90')) - self.button_y6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y1 G90')) - # add buttons to window - y_label = QLabel('Y') - buttons_layout.addWidget(y_label,1,0) - buttons_layout.addWidget(self.button_y1,1,1) - buttons_layout.addWidget(self.button_y2,1,2) - buttons_layout.addWidget(self.button_y3,1,3) - buttons_layout.addWidget(self.button_y4,1,4) - buttons_layout.addWidget(self.button_y5,1,5) - buttons_layout.addWidget(self.button_y6,1,6) - - # Z - self.button_z1 = QPushButton('-1') - self.button_z2 = QPushButton('-0.1') - self.button_z3 = QPushButton('-0.01') - self.button_z4 = QPushButton('+0.01') - self.button_z5 = QPushButton('+0.1') - self.button_z6 = QPushButton('+1') - # set X sizes - self.button_z1.setFixedSize(60,60) - self.button_z2.setFixedSize(60,60) - self.button_z3.setFixedSize(60,60) - self.button_z4.setFixedSize(60,60) - self.button_z5.setFixedSize(60,60) - self.button_z6.setFixedSize(60,60) - # attach actions - self.button_z1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-1 G90')) - self.button_z2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-0.1 G90')) - self.button_z3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-0.01 G90')) - self.button_z4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z0.01 G90')) - self.button_z5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z0.1 G90')) - self.button_z6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z1 G90')) - # add buttons to window - z_label = QLabel('Z') - buttons_layout.addWidget(z_label,2,0) - buttons_layout.addWidget(self.button_z1,2,1) - buttons_layout.addWidget(self.button_z2,2,2) - buttons_layout.addWidget(self.button_z3,2,3) - buttons_layout.addWidget(self.button_z4,2,4) - buttons_layout.addWidget(self.button_z5,2,5) - buttons_layout.addWidget(self.button_z6,2,6) - - #self.macro_field = QLineEdit() - #self.button_macro = QPushButton('Run macro') - #buttons_layout.addWidget(self.button_macro,3,1,2,1) - #buttons_layout.addWidget(self.macro_field,3,2,1,-1) - - - # Set up items on dialog grid - self.layout.addWidget(self.cp_info,0,0,1,-1) - self.layout.addLayout(buttons_layout,1,0,3,7) - # OK/Cancel buttons - self.layout.addWidget(self.buttonBox) - - # apply layout - self.setLayout(self.layout) - - def setSummaryText(self, message): - self.cp_info.setText(message) - -class DebugDialog(QDialog): - def __init__(self,parent=None, message=''): - super(DebugDialog,self).__init__(parent=parent) - self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) - self.setWindowTitle('Debug Information') - # Set layout details - self.layout = QGridLayout() - self.layout.setSpacing(3) - - # text area - self.textarea = QTextEdit() - self.textarea.setAcceptRichText(False) - self.textarea.setReadOnly(True) - self.layout.addWidget(self.textarea,0,0) - # apply layout - self.setLayout(self.layout) - temp_text = '' - try: - if self.parent().video_thread.isRunning(): - temp_text += 'Video thread running\n' - except Exception as e1: - None - if len(message) > 0: - temp_text += '\nCalibration Debug Messages:\n' + message - self.textarea.setText(temp_text) - -class CameraSettingsDialog(QDialog): - def __init__(self,parent=None, message=''): - super(CameraSettingsDialog,self).__init__(parent=parent) - self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) - self.setWindowTitle('Camera Settings') - - #QBtn = QDialogButtonBox.Close - #self.buttonBox = QDialogButtonBox(QBtn) - #self.buttonBox.accepted.connect(self.accept) - #self.buttonBox.rejected.connect(self.reject) - - # Get camera settings from video thread - try: - (brightness_input, contrast_input, saturation_input, hue_input) = self.parent().video_thread.getProperties() - except Exception as set1: - self.updateStatusbar('Error fetching camera parameters.') - print('ERROR: Camera Settings: ' + str(set1)) - - # Set layout details - self.layout = QVBoxLayout() - self.layout.setSpacing(3) - # apply layout - self.setLayout(self.layout) - - # Camera Combobox - self.camera_combo = QComboBox() - camera_description = str(video_src) + ': ' \ - + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) \ - + 'x' + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \ - + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FPS)) + 'fps' - self.camera_combo.addItem(camera_description) - #self.camera_combo.currentIndexChanged.connect(self.parent().video_thread.changeVideoSrc) - # Get cameras button - self.camera_button = QPushButton('Get cameras') - self.camera_button.clicked.connect(self.getCameras) - if self.parent().video_thread.alignment: - self.camera_button.setDisabled(True) - else: self.camera_button.setDisabled(False) - #self.getCameras() - # Brightness slider - self.brightness_slider = QSlider(Qt.Horizontal) - self.brightness_slider.setMinimum(0) - self.brightness_slider.setMaximum(255) - self.brightness_slider.setValue(int(brightness_input)) - self.brightness_slider.valueChanged.connect(self.changeBrightness) - self.brightness_slider.setTickPosition(QSlider.TicksBelow) - self.brightness_slider.setTickInterval(1) - self.brightness_label = QLabel(str(int(brightness_input))) - # Contrast slider - self.contrast_slider = QSlider(Qt.Horizontal) - self.contrast_slider.setMinimum(0) - self.contrast_slider.setMaximum(255) - self.contrast_slider.setValue(int(contrast_input)) - self.contrast_slider.valueChanged.connect(self.changeContrast) - self.contrast_slider.setTickPosition(QSlider.TicksBelow) - self.contrast_slider.setTickInterval(1) - self.contrast_label = QLabel(str(int(contrast_input))) - # Saturation slider - self.saturation_slider = QSlider(Qt.Horizontal) - self.saturation_slider.setMinimum(0) - self.saturation_slider.setMaximum(255) - self.saturation_slider.setValue(int(saturation_input)) - self.saturation_slider.valueChanged.connect(self.changeSaturation) - self.saturation_slider.setTickPosition(QSlider.TicksBelow) - self.saturation_slider.setTickInterval(1) - self.saturation_label = QLabel(str(int(saturation_input))) - # Hue slider - self.hue_slider = QSlider(Qt.Horizontal) - self.hue_slider.setMinimum(0) - self.hue_slider.setMaximum(8) - self.hue_slider.setValue(int(hue_input)) - self.hue_slider.valueChanged.connect(self.changeHue) - self.hue_slider.setTickPosition(QSlider.TicksBelow) - self.hue_slider.setTickInterval(1) - self.hue_label = QLabel(str(int(hue_input))) - # Reset button - self.reset_button = QPushButton("Reset to defaults") - self.reset_button.setToolTip('Reset camera settings to defaults.') - self.reset_button.clicked.connect(self.resetDefaults) - - # Save button - self.save_button = QPushButton('Save and Close') - self.save_button.setToolTip('Save current parameters to settings.json file') - self.save_button.clicked.connect(self.sendUserParameters) - self.save_button.setObjectName('active') - - # Close button - self.close_button = QPushButton('Cancel and close') - self.close_button.setToolTip('Cancel changes and return to main program.') - self.close_button.clicked.connect(self.closeCPWindow) - self.close_button.setObjectName('terminate') - - # Layout objects - # Camera drop-down - self.camera_box = QGroupBox('Camera') - self.layout.addWidget(self.camera_box) - cmbox = QHBoxLayout() - self.camera_box.setLayout(cmbox) - cmbox.addWidget(self.camera_combo) - cmbox.addWidget(self.camera_button) - - # Brightness - self.brightness_box =QGroupBox('Brightness') - self.layout.addWidget(self.brightness_box) - bvbox = QHBoxLayout() - self.brightness_box.setLayout(bvbox) - bvbox.addWidget(self.brightness_slider) - bvbox.addWidget(self.brightness_label) - # Contrast - self.contrast_box =QGroupBox('Contrast') - self.layout.addWidget(self.contrast_box) - cvbox = QHBoxLayout() - self.contrast_box.setLayout(cvbox) - cvbox.addWidget(self.contrast_slider) - cvbox.addWidget(self.contrast_label) - # Saturation - self.saturation_box =QGroupBox('Saturation') - self.layout.addWidget(self.saturation_box) - svbox = QHBoxLayout() - self.saturation_box.setLayout(svbox) - svbox.addWidget(self.saturation_slider) - svbox.addWidget(self.saturation_label) - # Hue - self.hue_box =QGroupBox('Hue') - self.layout.addWidget(self.hue_box) - hvbox = QHBoxLayout() - self.hue_box.setLayout(hvbox) - hvbox.addWidget(self.hue_slider) - hvbox.addWidget(self.hue_label) - # Reset button - self.layout.addWidget(self.reset_button) - self.layout.addWidget(self.save_button) - self.layout.addWidget(self.close_button) - - # OK Cancel buttons - #self.layout.addWidget(self.buttonBox) - - def resetDefaults(self): - self.parent().video_thread.resetProperties() - (brightness_input, contrast_input, saturation_input, hue_input) = self.parent().video_thread.getProperties() - - brightness_input = int(brightness_input) - contrast_input = int(contrast_input) - saturation_input = int(saturation_input) - hue_input = int(hue_input) - self.brightness_slider.setValue(brightness_input) - self.brightness_label.setText(str(brightness_input)) - self.contrast_slider.setValue(contrast_input) - self.contrast_label.setText(str(contrast_input)) - self.saturation_slider.setValue(saturation_input) - self.saturation_label.setText(str(saturation_input)) - self.hue_slider.setValue(hue_input) - self.hue_label.setText(str(hue_input)) - - def changeBrightness(self): - parameter = int(self.brightness_slider.value()) - try: - self.parent().video_thread.setProperty(brightness=parameter) - except: - None - self.brightness_label.setText(str(parameter)) - - def changeContrast(self): - parameter = int(self.contrast_slider.value()) - try: - self.parent().video_thread.setProperty(contrast=parameter) - except: - None - self.contrast_label.setText(str(parameter)) - - def changeSaturation(self): - parameter = int(self.saturation_slider.value()) - try: - self.parent().video_thread.setProperty(saturation=parameter) - except: - None - self.saturation_label.setText(str(parameter)) - - def changeHue(self): - parameter = int(self.hue_slider.value()) - try: - self.parent().video_thread.setProperty(hue=parameter) - except: - None - self.hue_label.setText(str(parameter)) - - def getCameras(self): - # checks the first 6 indexes. - i = 6 - index = 0 - self.camera_combo.clear() - _cameras = [] - original_camera_description = str(video_src) + ': ' \ - + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) \ - + 'x' + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \ - + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FPS)) + 'fps' - _cameras.append(original_camera_description) - while i > 0: - if index != video_src: - tempCap = cv2.VideoCapture(index) - if tempCap.read()[0]: - api = tempCap.getBackendName() - camera_description = str(index) + ': ' \ - + str(tempCap.get(cv2.CAP_PROP_FRAME_WIDTH)) \ - + 'x' + str(tempCap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \ - + str(tempCap.get(cv2.CAP_PROP_FPS)) + 'fps' - _cameras.append(camera_description) - tempCap.release() - index += 1 - i -= 1 - #cameras = [line for line in allOutputs if float(line['propmode']) > -1 ] - _cameras.sort() - for camera in _cameras: - self.camera_combo.addItem(camera) - self.camera_combo.setCurrentText(original_camera_description) - - def sendUserParameters(self): - _tempSrc = self.camera_combo.currentText() - _tempSrc = _tempSrc[:_tempSrc.find(':')] - self.parent().saveUserParameters(cameraSrc=_tempSrc) - self.close() - - def closeCPWindow(self): - self.parent().updateStatusbar('Camera changes discarded.') - self.close() - -class OverlayLabel(QLabel): - def __init__(self): - super(OverlayLabel, self).__init__() - self.display_text = 'Welcome to TAMV. Enter your printer address and click \"Connect..\" to start.' - - def paintEvent(self, event): - super(OverlayLabel, self).paintEvent(event) - pos = QPoint(10, 470) - painter = QPainter(self) - painter.setBrush(QColor(204,204,204,230)) - painter.setPen(QColor(255, 255, 255,0)) - painter.drawRect(0,450,640,50) - painter.setPen(QColor(0, 0, 0)) - painter.drawText(pos, self.display_text) - - def setText(self, textToDisplay): - self.display_text = textToDisplay - -class CalibrateNozzles(QThread): - # Signals - status_update = pyqtSignal(str) - message_update = pyqtSignal(str) - change_pixmap_signal = pyqtSignal(np.ndarray) - calibration_complete = pyqtSignal() - detection_error = pyqtSignal(str) - result_update = pyqtSignal(object) - - alignment = False - _running = False - display_crosshair = False - detection_on = False - - def __init__(self, parent=None, th1=1, th2=50, thstep=1, minArea=600, minCircularity=0.8,numTools=0,cycles=1, align=False): - super(QThread,self).__init__(parent=parent) - # transformation matrix - self.transform_matrix = [] - self.xray = False - self.loose = False - self.detector_changed = False - self.detect_th1 = th1 - self.detect_th2 = th2 - self.detect_thstep = thstep - self.detect_minArea = minArea - self.detect_minCircularity = minCircularity - self.numTools = numTools - self.cycles = cycles - self.alignment = align - self.message_update.emit('Detector created, waiting for tool..') - - # start with detection off - self.display_crosshair = False - self.detection_on = False - - # Video Parameters - self.brightness_default = 0 - self.contrast_default = 0 - self.saturation_default = 0 - self.hue_default = 0 - self.brightness = -1 - self.contrast = -1 - self.saturation = -1 - self.hue = -1 - - # Start Video feed - self.cap = cv2.VideoCapture(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.brightness_default = self.cap.get(cv2.CAP_PROP_BRIGHTNESS) - self.contrast_default = self.cap.get(cv2.CAP_PROP_CONTRAST) - self.saturation_default = self.cap.get(cv2.CAP_PROP_SATURATION) - self.hue_default = self.cap.get(cv2.CAP_PROP_HUE) - - self.ret, self.cv_img = self.cap.read() - if self.ret: - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - else: - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.ret, self.cv_img = self.cap.read() - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - - def toggleXray(self): - if self.xray: - self.xray = False - else: self.xray = True - - def toggleLoose(self): - self.detector_changed = True - if self.loose: - self.loose = False - else: self.loose = True - - def setProperty(self,brightness=-1, contrast=-1, saturation=-1, hue=-1): - try: - if int(brightness) >= 0: - self.brightness = brightness - self.cap.set(cv2.CAP_PROP_BRIGHTNESS,self.brightness) - except Exception as b1: - print('Brightness exception: ', b1 ) - try: - if int(contrast) >= 0: - self.contrast = contrast - self.cap.set(cv2.CAP_PROP_CONTRAST,self.contrast) - except Exception as c1: - print('Contrast exception: ', c1 ) - try: - if int(saturation) >= 0: - self.saturation = saturation - self.cap.set(cv2.CAP_PROP_SATURATION,self.saturation) - except Exception as s1: - print('Saturation exception: ', s1 ) - try: - if int(hue) >= 0: - self.hue = hue - self.cap.set(cv2.CAP_PROP_HUE,self.hue) - except Exception as h1: - print('Hue exception: ', h1 ) - - def getProperties(self): - return (self.brightness_default, self.contrast_default, self.saturation_default,self.hue_default) - - def resetProperties(self): - self.setProperty(brightness=self.brightness_default, contrast = self.contrast_default, saturation=self.saturation_default, hue=self.hue_default) - - def run(self): - self.createDetector() - while True: - if self.detection_on: - if self.alignment: - try: - if self.loose: - self.detect_minCircularity = 0.3 - else: self.detect_minCircularity = 0.8 - if self.detector_changed: - self.createDetector() - self.detector_changed = False - self._running = True - while self._running: - self.cycles = self.parent().cycles - for rep in range(self.cycles): - for tool in range(self.parent().num_tools): - # process GUI events - app.processEvents() - # Update status bar - self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles)) - # Load next tool for calibration - self.parent().printer.gCode('T'+str(tool)) - # Move tool to CP coordinates - self.parent().printer.gCode('G1 X' + str(self.parent().cp_coords['X'])) - self.parent().printer.gCode('G1 Y' + str(self.parent().cp_coords['Y'])) - self.parent().printer.gCode('G1 Z' + str(self.parent().cp_coords['Z'])) - # Wait for moves to complete - while self.parent().printer.getStatus() not in 'idle': - # process GUI events - app.processEvents() - self.ret, self.cv_img = self.cap.read() - if self.ret: - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - else: - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.ret, self.cv_img = self.cap.read() - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - continue - # Update message bar - self.message_update.emit('Searching for nozzle..') - # Process runtime algorithm changes - if self.loose: - self.detect_minCircularity = 0.3 - else: self.detect_minCircularity = 0.8 - if self.detector_changed: - self.createDetector() - self.detector_changed = False - # Analyze frame for blobs - (c, transform, mpp) = self.calibrateTool(tool, rep) - # process GUI events - app.processEvents() - # apply offsets to machine - self.parent().printer.gCode( 'G10 P' + str(tool) + ' X' + str(c['X']) + ' Y' + str(c['Y']) ) - # signal end of execution - self._running = False - # Update status bar - self.status_update.emit('Calibration complete: Resetting machine.') - # HBHBHB - # Update debug window with results - # self.parent().debugString += '\nCalibration output:\n' - self.parent().printer.gCode('T-1') - self.parent().printer.gCode('G1 X' + str(self.parent().cp_coords['X'])) - self.parent().printer.gCode('G1 Y' + str(self.parent().cp_coords['Y'])) - self.parent().printer.gCode('G1 Z' + str(self.parent().cp_coords['Z'])) - self.status_update.emit('Calibration complete: Done.') - self.alignment = False - self.detection_on = False - self.display_crosshair = False - self._running = False - self.calibration_complete.emit() - except Exception as mn1: - self.alignment = False - self.detection_on = False - self.display_crosshair = False - self._running = False - self.detection_error.emit(str(mn1)) - self.cap.release() - else: - # don't run alignment - fetch frames and detect only - try: - if self.loose: - self.detect_minCircularity = 0.3 - else: self.detect_minCircularity = 0.8 - self._running = True - # transformation matrix - #self.transform_matrix = [] - while self._running and self.detection_on: - # Update status bar - #self.status_update.emit('Detection mode: ON') - # Process runtime algorithm changes - if self.loose: - self.detect_minCircularity = 0.3 - else: self.detect_minCircularity = 0.8 - if self.detector_changed: - self.createDetector() - self.detector_changed = False - # Run detection and update output - self.analyzeFrame() - # process GUI events - app.processEvents() - except Exception as mn1: - self._running = False - self.detection_error.emit(str(mn1)) - self.cap.release() - else: - while not self.detection_on: - try: - self.ret, self.cv_img = self.cap.read() - if self.ret: - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - else: - # reset capture - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.ret, self.cv_img = self.cap.read() - if self.ret: - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - continue - app.processEvents() - except Exception as mn2: - self.status_update( 'Error: ' + str(mn2) ) - print('Error: ' + str(mn2)) - self.cap.release() - self.detection_on = False - self._running = False - exit() - app.processEvents() - app.processEvents() - continue - self.cap.release() - - def analyzeFrame(self): - # Placeholder coordinates - xy = [0,0] - # Counter of frames with no circle. - nocircle = 0 - # Random time offset - rd = int(round(time.time()*1000)) - # reset capture - #self.cap.open(video_src) - #self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - #self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - #self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - - while True and self.detection_on: - app.processEvents() - self.ret, self.frame = self.cap.read() - if not self.ret: - # reset capture - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - continue - if self.alignment: - try: - # capture tool location in machine space before processing - toolCoordinates = self.parent().printer.getCoords() - except Exception as c1: - toolCoordinates = None - # capture first clean frame for display - cleanFrame = self.frame - # apply nozzle detection algorithm - # Detection algorithm 1: - # gamma correction -> use Y channel from YUV -> GaussianBlur (7,7),6 -> adaptive threshold - gammaInput = 1.2 - self.frame = self.adjust_gamma(image=self.frame, gamma=gammaInput) - yuv = cv2.cvtColor(self.frame, cv2.COLOR_BGR2YUV) - yuvPlanes = cv2.split(yuv) - yuvPlanes[0] = cv2.GaussianBlur(yuvPlanes[0],(7,7),6) - yuvPlanes[0] = cv2.adaptiveThreshold(yuvPlanes[0],255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,35,1) - self.frame = cv2.cvtColor(yuvPlanes[0],cv2.COLOR_GRAY2BGR) - target = [int(np.around(self.frame.shape[1]/2)),int(np.around(self.frame.shape[0]/2))] - # Process runtime algorithm changes - if self.loose: - self.detect_minCircularity = 0.3 - else: self.detect_minCircularity = 0.8 - if self.detector_changed: - self.createDetector() - self.detector_changed = False - # run nozzle detection for keypoints - keypoints = self.detector.detect(self.frame) - # draw the timestamp on the frame AFTER the circle detector! Otherwise it finds the circles in the numbers. - if self.xray: - cleanFrame = self.frame - # check if we are displaying a crosshair - if self.display_crosshair: - self.frame = cv2.line(cleanFrame, (target[0], target[1]-25), (target[0], target[1]+25), (0, 255, 0), 1) - self.frame = cv2.line(self.frame, (target[0]-25, target[1] ), (target[0]+25, target[1] ), (0, 255, 0), 1) - else: self.frame = cleanFrame - # update image - local_img = self.frame - self.change_pixmap_signal.emit(local_img) - if(nocircle> 25): - self.message_update.emit( 'Error in detecting nozzle.' ) - nocircle = 0 - continue - num_keypoints=len(keypoints) - if (num_keypoints == 0): - if (25 < (int(round(time.time() * 1000)) - rd)): - nocircle += 1 - self.frame = self.putText(self.frame,'No circles found',offsety=3) - self.message_update.emit( 'No circles found.' ) - local_img = self.frame - self.change_pixmap_signal.emit(local_img) - continue - if (num_keypoints > 1): - if (25 < (int(round(time.time() * 1000)) - rd)): - self.message_update.emit( 'Too many circles found. Please stop and clean the nozzle.' ) - self.frame = self.putText(self.frame,'Too many circles found '+str(num_keypoints),offsety=3, color=(255,255,255)) - self.frame = cv2.drawKeypoints(self.frame, keypoints, np.array([]), (255,255,255), cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS) - local_img = self.frame - self.change_pixmap_signal.emit(local_img) - continue - # Found one and only one circle. Put it on the frame. - nocircle = 0 - xy = np.around(keypoints[0].pt) - r = np.around(keypoints[0].size/2) - # draw the blobs that look circular - self.frame = cv2.drawKeypoints(self.frame, keypoints, np.array([]), (0,0,255), cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS) - # Note its radius and position - ts = 'U{0:3.0f} V{1:3.0f} R{2:2.0f}'.format(xy[0],xy[1],r) - xy = np.uint16(xy) - #self.frame = self.putText(self.frame, ts, offsety=2, color=(0, 255, 0), stroke=2) - self.message_update.emit(ts) - # show the frame - local_img = self.frame - self.change_pixmap_signal.emit(local_img) - rd = int(round(time.time() * 1000)) - #end the loop - break - # and tell our parent. - if self.detection_on: - return (xy, target, toolCoordinates, r) - else: - return - - def calibrateTool(self, tool, rep): - # timestamp for caluclating tool calibration runtime - self.startTime = time.time() - # average location of keypoints in frame - self.average_location=[0,0] - # current location - self.current_location = {'X':0,'Y':0} - # guess position used for camera calibration - self.guess_position = [1,1] - # current keypoint location - self.xy = [0,0] - # previous keypoint location - self.oldxy = self.xy - # Tracker flag to set which state algorithm is running in - self.state = 0 - # detected blob counter - self.detect_count = 0 - # Save CP coordinates to local class - self.cp_coordinates = self.parent().cp_coords - # number of average position loops - self.position_iterations = 5 - # calibration move set (0.5mm radius circle over 10 moves) - self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] - - # Check if camera calibration matrix is already defined - if len(self.transform_matrix) > 1: - # set state flag to Step 2: nozzle alignment stage - self.state = 200 - self.parent().debugString += '\nCalibrating T'+str(tool)+':C'+str(rep)+': ' - - # Space coordinates - self.space_coordinates = [] - self.camera_coordinates = [] - self.calibration_moves = 0 - - while True: - (self.xy, self.target, self.tool_coordinates, self.radius) = self.analyzeFrame() - # analyzeFrame has returned our target coordinates, average its location and process according to state - self.average_location[0] += self.xy[0] - self.average_location[1] += self.xy[1] - - self.detect_count += 1 - - # check if we've reached our number of detections for average positioning - if self.detect_count >= self.position_iterations: - # calculate average X Y position from detection - self.average_location[0] /= self.detect_count - self.average_location[1] /= self.detect_count - # round to 3 decimal places - self.average_location = np.around(self.average_location,3) - # get another detection validated - (self.xy, self.target, self.tool_coordinates, self.radius) = self.analyzeFrame() - - #### Step 1: camera calibration and transformation matrix calculation - if self.state == 0: - self.parent().debugString += 'Calibrating camera...\n' - # Update GUI thread with current status and percentage complete - self.status_update.emit('Calibrating camera..') - self.message_update.emit('Calibrating rotation.. (10%)') - # Save position as previous location - self.oldxy = self.xy - # Reset space and camera coordinates - self.space_coordinates = [] - self.camera_coordinates = [] - # save machine coordinates for detected nozzle - self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) ) - # save camera coordinates - self.camera_coordinates.append( (self.xy[0],self.xy[1]) ) - # move carriage for calibration - self.offsetX = self.calibrationCoordinates[0][0] - self.offsetY = self.calibrationCoordinates[0][1] - self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ') - # Update state tracker to second nozzle calibration move - self.state = 1 - continue - # Check if camera is still being calibrated - elif self.state >= 1 and self.state < len(self.calibrationCoordinates): - # Update GUI thread with current status and percentage complete - self.status_update.emit('Calibrating camera..') - self.message_update.emit('Calibrating rotation.. (' + str(self.state*10) + '%)') - # check if we've already moved, and calculate mpp value - if self.state == 1: - self.mpp = np.around(0.5/self.getDistance(self.oldxy[0],self.oldxy[1],self.xy[0],self.xy[1]),4) - # save position as previous position - self.oldxy = self.xy - # save machine coordinates for detected nozzle - self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) ) - # save camera coordinates - self.camera_coordinates.append( (self.xy[0],self.xy[1]) ) - # return carriage to relative center of movement - self.offsetX = -1*self.offsetX - self.offsetY = -1*self.offsetY - self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ') - # move carriage a random amount in X&Y to collect datapoints for transform matrix - self.offsetX = self.calibrationCoordinates[self.state][0] - self.offsetY = self.calibrationCoordinates[self.state][1] - self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ') - # increment state tracker to next calibration move - self.state += 1 - continue - # check if final calibration move has been completed - elif self.state == len(self.calibrationCoordinates): - calibration_time = np.around(time.time() - self.startTime,1) - self.parent().debugString += 'Camera calibration completed in ' + str(calibration_time) + ' seconds.\n' - self.parent().debugString += 'Millimeters per pixel: ' + str(self.mpp) + '\n\n' - print('Millimeters per pixel: ' + str(self.mpp)) - print('Camera calibration completed in ' + str(calibration_time) + ' seconds.') - # Update GUI thread with current status and percentage complete - self.message_update.emit('Calibrating rotation.. (100%) - MPP = ' + str(self.mpp)) - self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles)) - # save position as previous position - self.oldxy = self.xy - # save machine coordinates for detected nozzle - self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) ) - # save camera coordinates - self.camera_coordinates.append( (self.xy[0],self.xy[1]) ) - # calculate camera transformation matrix - self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)] - self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input) - # define camera center in machine coordinate space - self.newCenter = self.transform_matrix.T @ np.array([0, 0, 0, 0, 0, 1]) - self.guess_position[0]= np.around(self.newCenter[0],3) - self.guess_position[1]= np.around(self.newCenter[1],3) - self.parent().printer.gCode('G90 G1 X{0:-1.3f} Y{1:-1.3f} F1000 G90 '.format(self.guess_position[0],self.guess_position[1])) - # update state tracker to next phase - self.state = 200 - # start tool calibration timer - self.startTime = time.time() - self.parent().debugString += '\nCalibrating T'+str(tool)+':C'+str(rep)+': ' - continue - #### Step 2: nozzle alignment stage - elif self.state == 200: - # Update GUI thread with current status and percentage complete - self.message_update.emit('Tool calibration move #' + str(self.calibration_moves)) - self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles)) - # increment moves counter - self.calibration_moves += 1 - # nozzle detected, frame rotation is set, start - self.cx,self.cy = self.normalize_coords(self.xy) - self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0] - self.offsets = -1*(0.55*self.transform_matrix.T @ self.v) - self.offsets[0] = np.around(self.offsets[0],3) - self.offsets[1] = np.around(self.offsets[1],3) - # Move it a bit - self.parent().printer.gCode( 'M564 S1' ) - self.parent().printer.gCode( 'G91 G1 X{0:-1.3f} Y{1:-1.3f} F1000 G90 '.format(self.offsets[0],self.offsets[1]) ) - # save position as previous position - self.oldxy = self.xy - if ( self.offsets[0] == 0.0 and self.offsets[1] == 0.0 ): - self.parent().debugString += str(self.calibration_moves) + ' moves.\n' - self.parent().printer.gCode( 'G1 F13200' ) - # Update GUI with progress - # calculate final offsets and return results - self.tool_offsets = self.parent().printer.getG10ToolOffset(tool) - final_x = np.around( (self.cp_coordinates['X'] + self.tool_offsets['X']) - self.tool_coordinates['X'], 3 ) - final_y = np.around( (self.cp_coordinates['Y'] + self.tool_offsets['Y']) - self.tool_coordinates['Y'], 3 ) - string_final_x = "{:.3f}".format(final_x) - string_final_y = "{:.3f}".format(final_y) - # Save offset to output variable - # HBHBHBHB - _return = {} - _return['X'] = final_x - _return['Y'] = final_y - _return['MPP'] = self.mpp - _return['time'] = np.around(time.time() - self.startTime,1) - self.message_update.emit('Nozzle calibrated: offset coordinates X' + str(_return['X']) + ' Y' + str(_return['Y']) ) - self.parent().debugString += 'T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.\n' - print('T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.') - self.message_update.emit('T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.') - self.parent().printer.gCode( 'G1 F13200' ) - - self.parent().debugString += 'G10 P' + str(tool) + ' X' + string_final_x + ' Y' + string_final_y + '\n' - x_tableitem = QTableWidgetItem(string_final_x) - x_tableitem.setBackground(QColor(100,255,100,255)) - y_tableitem = QTableWidgetItem(string_final_y) - y_tableitem.setBackground(QColor(100,255,100,255)) - self.parent().offsets_table.setItem(tool,0,x_tableitem) - self.parent().offsets_table.setItem(tool,1,y_tableitem) - self.result_update.emit({ - 'tool': str(tool), - 'cycle': str(rep), - 'mpp': str(self.mpp), - 'X': string_final_x, - 'Y': string_final_y - }) - return(_return, self.transform_matrix, self.mpp) - else: - self.state = 200 - continue - self.avg = [0,0] - self.location = {'X':0,'Y':0} - self.count = 0 - - def normalize_coords(self,coords): - xdim, ydim = camera_width, camera_height - return (coords[0] / xdim - 0.5, coords[1] / ydim - 0.5) - - def least_square_mapping(self,calibration_points): - # Compute a 2x2 map from displacement vectors in screen space to real space. - n = len(calibration_points) - real_coords, pixel_coords = np.empty((n,2)),np.empty((n,2)) - for i, (r,p) in enumerate(calibration_points): - real_coords[i] = r - pixel_coords[i] = p - x,y = pixel_coords[:,0],pixel_coords[:,1] - A = np.vstack([x**2,y**2,x * y, x,y,np.ones(n)]).T - transform = np.linalg.lstsq(A, real_coords, rcond = None) - return transform[0], transform[1].mean() - - def getDistance(self, x1, y1, x0, y0 ): - x1_float = float(x1) - x0_float = float(x0) - y1_float = float(y1) - y0_float = float(y0) - x_dist = (x1_float - x0_float) ** 2 - y_dist = (y1_float - y0_float) ** 2 - retVal = np.sqrt((x_dist + y_dist)) - return np.around(retVal,3) - - def stop(self): - self._running = False - self.detection_on = False - try: - tempCoords = self.printer.getCoords() - if self.printer.isIdle(): - self.parent().printer.gCode('T-1') - self.parent().printer.gCode('G1 X' + str(tempCoords['X']) + ' Y' + str(tempCoords['Y'])) - while self.parent().printer.getStatus() not in 'idle': - time.sleep(1) - except: None - self.cap.release() - self.exit() - - def createDetector(self): - # Setup SimpleBlobDetector parameters. - params = cv2.SimpleBlobDetector_Params() - # Thresholds - params.minThreshold = self.detect_th1 - params.maxThreshold = self.detect_th2 - params.thresholdStep = self.detect_thstep - - # Area - params.filterByArea = True # Filter by Area. - params.minArea = self.detect_minArea - - # Circularity - params.filterByCircularity = True # Filter by Circularity - params.minCircularity = self.detect_minCircularity - params.maxCircularity= 1 - - # Convexity - params.filterByConvexity = True # Filter by Convexity - params.minConvexity = 0.3 - params.maxConvexity = 1 - - # Inertia - params.filterByInertia = True # Filter by Inertia - params.minInertiaRatio = 0.3 - - # create detector - self.detector = cv2.SimpleBlobDetector_create(params) - - def adjust_gamma(self, image, gamma=1.2): - # build a lookup table mapping the pixel values [0, 255] to - # their adjusted gamma values - invGamma = 1.0 / gamma - table = np.array([((i / 255.0) ** invGamma) * 255 - for i in np.arange(0, 256)]).astype('uint8') - # apply gamma correction using the lookup table - return cv2.LUT(image, table) - - def putText(self, frame,text,color=(0, 0, 255),offsetx=0,offsety=0,stroke=1): # Offsets are in character box size in pixels. - if (text == 'timestamp'): text = datetime.datetime.now().strftime('%m-%d-%Y %H:%M:%S') - fontScale = 1 - if (frame.shape[1] > 640): fontScale = stroke = 2 - if (frame.shape[1] < 640): - fontScale = 0.8 - stroke = 1 - offpix = cv2.getTextSize('A', cv2.FONT_HERSHEY_SIMPLEX ,fontScale, stroke) - textpix = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX ,fontScale, stroke) - offsety=max(offsety, (-frame.shape[0]/2 + offpix[0][1])/offpix[0][1]) # Let offsety -99 be top row - offsetx=max(offsetx, (-frame.shape[1]/2 + offpix[0][0])/offpix[0][0]) # Let offsetx -99 be left edge - offsety=min(offsety, (frame.shape[0]/2 - offpix[0][1])/offpix[0][1]) # Let offsety 99 be bottom row. - offsetx=min(offsetx, (frame.shape[1]/2 - offpix[0][0])/offpix[0][0]) # Let offsetx 99 be right edge. - cv2.putText(frame, text, - (int(offsetx * offpix[0][0]) + int(frame.shape[1]/2) - int(textpix[0][0]/2) - ,int(offsety * offpix[0][1]) + int(frame.shape[0]/2) + int(textpix[0][1]/2)), - cv2.FONT_HERSHEY_SIMPLEX, fontScale, color, stroke) - return(frame) - - def changeVideoSrc(self, newSrc=-1): - self.cap.release() - video_src = newSrc - # Start Video feed - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.brightness_default = self.cap.get(cv2.CAP_PROP_BRIGHTNESS) - self.contrast_default = self.cap.get(cv2.CAP_PROP_CONTRAST) - self.saturation_default = self.cap.get(cv2.CAP_PROP_SATURATION) - self.hue_default = self.cap.get(cv2.CAP_PROP_HUE) - - self.ret, self.cv_img = self.cap.read() - if self.ret: - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - else: - self.cap.open(video_src) - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) - #self.cap.set(cv2.CAP_PROP_FPS,25) - self.ret, self.cv_img = self.cap.read() - local_img = self.cv_img - self.change_pixmap_signal.emit(local_img) - -class App(QMainWindow): - cp_coords = {} - numTools = 0 - current_frame = np.ndarray - mutex = QMutex() - debugString = '' - calibrationResults = [] - - def __init__(self, parent=None): - super().__init__() - self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) - self.setWindowTitle('TAMV') - self.setWindowIcon(QIcon('jubilee.png')) - global display_width, display_height - screen = QDesktopWidget().availableGeometry() - self.small_display = False - # HANDLE DIFFERENT DISPLAY SIZES - # 800x600 display - fullscreen app - if int(screen.width()) >= 800 and int(screen.height()) >= 550 and int(screen.height() < 600): - self.small_display = True - print('800x600 desktop detected') - display_width = 512 - display_height = 384 - self.setWindowFlag(Qt.FramelessWindowHint) - self.showFullScreen() - self.setGeometry(0,0,700,500) - app_screen = self.frameGeometry() - # 848x480 display - fullscreen app - elif int(screen.width()) >= 800 and int(screen.height()) < 550: - self.small_display = True - print('848x480 desktop detected') - display_width = 448 - display_height = 336 - self.setWindowFlag(Qt.FramelessWindowHint) - self.showFullScreen() - self.setGeometry(0,0,700,400) - app_screen = self.frameGeometry() - # larger displays - normal window - else: - self.small_display = False - display_width = 640 - display_height = 480 - self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(800,600),screen)) - app_screen = self.frameGeometry() - app_screen.moveCenter(screen.center()) - self.move(app_screen.topLeft()) - # SET UP STYLESHEETS FOR GUI ELEMENTS - self.setStyleSheet( - '\ - QPushButton {\ - border: 1px solid #adadad;\ - border-style: outset;\ - border-radius: 4px;\ - font: 14px;\ - padding: 6px;\ - }\ - QPushButton:hover,QPushButton:enabled:hover,QPushButton:enabled:!checked:hover {\ - background-color: #27ae60;\ - border: 1px solid #aaaaaa;\ - }\ - QPushButton:pressed,QPushButton:enabled:pressed,QPushButton:enabled:checked {\ - background-color: #ae2776;\ - border: 1px solid #aaaaaa;\ - }\ - QPushButton:enabled {\ - background-color: green;\ - color: white;\ - }\ - QPushButton#debug,QMessageBox > #debug {\ - background-color: blue;\ - color: white;\ - }\ - QPushButton#debug:hover, QMessageBox > QAbstractButton#debug:hover {\ - background-color: green;\ - color: white;\ - }\ - QPushButton#debug:pressed, QMessageBox > QAbstractButton#debug:pressed {\ - background-color: #ae2776;\ - border-style: inset;\ - color: white;\ - }\ - QPushButton#active, QMessageBox > QAbstractButton#active {\ - background-color: green;\ - color: white;\ - }\ - QPushButton#active:pressed,QMessageBox > QAbstractButton#active:pressed {\ - background-color: #ae2776;\ - }\ - QPushButton#terminate {\ - background-color: red;\ - color: white;\ - }\ - QPushButton#terminate:pressed {\ - background-color: #c0392b;\ - }\ - QPushButton:disabled, QPushButton#terminate:disabled {\ - background-color: #cccccc;\ - color: #999999;\ - }\ - QInputDialog QDialogButtonBox > QPushButton:enabled, QDialog QPushButton:enabled,QPushButton[checkable="true"]:enabled {\ - background-color: none;\ - color: black;\ - border: 1px solid #adadad;\ - border-style: outset;\ - border-radius: 4px;\ - font: 14px;\ - padding: 6px;\ - }\ - QPushButton:enabled:checked {\ - background-color: #ae2776;\ - border: 1px solid #aaaaaa;\ - }\ - QInputDialog QDialogButtonBox > QPushButton:pressed, QDialog QPushButton:pressed {\ - background-color: #ae2776;\ - }\ - QInputDialog QDialogButtonBox > QPushButton:hover:!pressed, QDialog QPushButton:hover:!pressed {\ - background-color: #27ae60;\ - }\ - ' - ) - # LOAD USER SAVED PARAMETERS OR CREATE DEFAULTS - self.loadUserParameters() - # GUI ELEMENTS DEFINITION - # Menubar - if not self.small_display: - self._createActions() - self._createMenuBar() - self._connectActions() - self.centralWidget = QWidget() - self.setCentralWidget(self.centralWidget) - # create the label that holds the image - self.image_label = OverlayLabel() - self.image_label.setFixedSize( display_width, display_height ) - pixmap = QPixmap( display_width, display_height ) - self.image_label.setPixmap(pixmap) - # create a status bar - self.statusBar = QStatusBar() - self.statusBar.showMessage('Loading up video feed and libraries..',5000) - self.setStatusBar( self.statusBar ) - # CP location on statusbar - self.cp_label = QLabel('CP: undef') - self.statusBar.addPermanentWidget(self.cp_label) - self.cp_label.setStyleSheet(style_red) - # Connection status on statusbar - self.connection_status = QLabel('Disconnected') - self.connection_status.setStyleSheet(style_red) - self.statusBar.addPermanentWidget(self.connection_status) - # BUTTONS - # Connect - self.connection_button = QPushButton('Connect..') - self.connection_button.setToolTip('Connect to a Duet machine..') - self.connection_button.clicked.connect(self.connectToPrinter) - self.connection_button.setFixedWidth(170) - # Disconnect - self.disconnection_button = QPushButton('STOP / DISCONNECT') - self.disconnection_button.setToolTip('End current operation,\nunload tools, and return carriage to CP\nthen disconnect.') - self.disconnection_button.clicked.connect(self.disconnectFromPrinter) - self.disconnection_button.setFixedWidth(170) - self.disconnection_button.setObjectName('terminate') - self.disconnection_button.setDisabled(True) - # Controlled point - self.cp_button = QPushButton('Set Controlled Point..') - self.cp_button.setToolTip('Define your origin point\nto calculate all tool offsets from.') - self.cp_button.clicked.connect(self.controlledPoint) - self.cp_button.setFixedWidth(170) - #self.cp_button.setStyleSheet(style_disabled) - self.cp_button.setDisabled(True) - # Calibration - self.calibration_button = QPushButton('Start Tool Alignment') - self.calibration_button.setToolTip('Start alignment process.\nMAKE SURE YOUR CARRIAGE IS CLEAR TO MOVE ABOUT WITHOUT COLLISIONS!') - self.calibration_button.clicked.connect(self.runCalibration) - #self.calibration_button.setStyleSheet(style_disabled) - self.calibration_button.setDisabled(True) - self.calibration_button.setFixedWidth(170) - # Jog Panel - self.jogpanel_button = QPushButton('Jog Panel') - self.jogpanel_button.setToolTip('Open a control panel to move carriage.') - self.jogpanel_button.clicked.connect(self.displayJogPanel) - self.jogpanel_button.setDisabled(True) - self.jogpanel_button.setFixedWidth(170) - # Debug Info - self.debug_button = QPushButton('Debug Information') - self.debug_button.setToolTip('Display current debug info for troubleshooting\nand to display final G10 commands') - self.debug_button.clicked.connect(self.displayDebug) - self.debug_button.setFixedWidth(170) - self.debug_button.setObjectName('debug') - # Exit - self.exit_button = QPushButton('Quit') - self.exit_button.setToolTip('Unload tools, disconnect, and quit TAMV.') - self.exit_button.clicked.connect(self.close) - self.exit_button.setFixedWidth(170) - - # OTHER ELEMENTS - # Repeat spinbox - self.repeat_label = QLabel('Cycles: ') - self.repeat_label.setAlignment(Qt.AlignRight|Qt.AlignVCenter) - self.repeatSpinBox = QSpinBox() - self.repeatSpinBox.setValue(1) - self.repeatSpinBox.setMinimum(1) - self.repeatSpinBox.setSingleStep(1) - self.repeatSpinBox.setDisabled(True) - # Offsets table - self.offsets_box = QGroupBox("Tool Offsets") - self.offsets_box.setMaximumWidth(170) - self.offsets_table = QTableWidget() - self.offsets_table.setColumnCount(2) - self.offsets_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.offsets_table.setColumnWidth(0,50) - self.offsets_table.setColumnWidth(1,50) - self.offsets_table.setHorizontalHeaderItem(0, QTableWidgetItem("X")) - self.offsets_table.setHorizontalHeaderItem(1, QTableWidgetItem("Y")) - self.offsets_table.resizeRowsToContents() - vbox = QVBoxLayout() - vbox.setSpacing(1) - self.offsets_box.setLayout(vbox) - vbox.addWidget(self.offsets_table) - self.offsets_box.setVisible(False) - # Tool buttons table - self.toolBoxLayout = QHBoxLayout() - self.toolBoxLayout.setSpacing(1) - self.toolBox = QGroupBox() - self.toolBoxLayout.setContentsMargins(0,0,0,0) - self.toolBox.setLayout(self.toolBoxLayout) - self.toolBox.setVisible(False) - self.toolButtons = [] - # Xray checkbox - self.xray_box = QCheckBox('X-ray') - self.xray_box.setChecked(False) - self.xray_box.stateChanged.connect(self.toggle_xray) - self.xray_box.setDisabled(True) - self.xray_box.setVisible(False) - # Loose checkbox - self.loose_box = QCheckBox('Loose detection') - self.loose_box.setChecked(False) - self.loose_box.stateChanged.connect(self.toggle_loose) - self.loose_box.setDisabled(True) - self.loose_box.setVisible(False) - # Detection checkbox - self.detect_box = QCheckBox('Detect ON') - self.detect_box.setChecked(False) - self.detect_box.stateChanged.connect(self.toggle_detect) - # create a grid box layout - grid = QGridLayout() - grid.setSpacing(3) - # add elements to grid - # FIRST ROW - grid.addWidget(self.connection_button,1,1,Qt.AlignLeft) - grid.addWidget(self.detect_box,1,2,1,1) - grid.addWidget(self.xray_box,1,3,1,1) - grid.addWidget(self.loose_box,1,4,1,1) - grid.addWidget(self.toolBox,1,5,1,1) - grid.addWidget(self.disconnection_button,1,7,1,-1,Qt.AlignLeft) - # SECOND ROW - - # THIRD ROW - # main image viewer - grid.addWidget(self.image_label,3,1,4,6) - grid.addWidget(self.jogpanel_button,3,7,1,1) - grid.addWidget(self.offsets_box,4,7,1,1) - if self.small_display: - grid.addWidget(self.exit_button,5,7,1,1) - grid.addWidget(self.debug_button,6,7,1,1) - # FOURTH ROW - grid.addWidget(self.cp_button,7,1,1,1) - grid.addWidget(self.calibration_button,7,2,1,1) - grid.addWidget(self.repeat_label,7,3,1,1) - grid.addWidget(self.repeatSpinBox,7,4,1,1) - # set the grid layout as the widgets layout - self.centralWidget.setLayout(grid) - # start video feed - self.startVideo() - # flag to draw circle - self.crosshair = False - - def toggle_detect(self): - self.video_thread.display_crosshair = not self.video_thread.display_crosshair - self.video_thread.detection_on = not self.video_thread.detection_on - if self.video_thread.detection_on: - self.xray_box.setDisabled(False) - self.xray_box.setVisible(True) - self.loose_box.setDisabled(False) - self.loose_box.setVisible(True) - else: - self.xray_box.setDisabled(True) - self.xray_box.setVisible(False) - self.loose_box.setDisabled(True) - self.loose_box.setVisible(False) - self.updateStatusbar('Detection: OFF') - - def cleanPrinterURL(self, inputString='http://localhost'): - _errCode = 0 - _errMsg = '' - _printerURL = 'http://localhost' - from urllib.parse import urlparse - u = urlparse(inputString) - scheme = u[0] - netlocation = u[1] - if len(scheme) < 4 or scheme.lower() not in ['http']: - _errCode = 1 - _errMsg = 'Invalid scheme. Please only use http connections.' - elif len(netlocation) < 1: - _errCode = 2 - _errMsg = 'Invalid IP/network address.' - elif scheme.lower() in ['https']: - _errCode = 3 - _errMsg = 'Cannot use https connections for Duet controllers' - else: - _printerURL = scheme + '://' + netlocation - return( _errCode, _errMsg, _printerURL ) - - def loadUserParameters(self): - global camera_width, camera_height, video_src - try: - with open('settings.json','r') as inputfile: - options = json.load(inputfile) - camera_settings = options['camera'][0] - camera_height = int( camera_settings['display_height'] ) - camera_width = int( camera_settings['display_width'] ) - video_src = camera_settings['video_src'] - if len(str(video_src)) == 1: video_src = int(video_src) - printer_settings = options['printer'][0] - tempURL = printer_settings['address'] - ( _errCode, _errMsg, self.printerURL ) = self.cleanPrinterURL(tempURL) - if _errCode > 0: - # invalid input - print('Invalid printer URL detected in settings.json!') - print('Defaulting to \"http://localhost\"...') - self.printerURL = 'http://localhost' - except FileNotFoundError: - # create parameter file with standard parameters - options = {} - options['camera'] = [] - options['camera'].append( { - 'video_src': 1, - 'display_width': '640', - 'display_height': '480' - } ) - options['printer'] = [] - options['printer'].append( { - 'address': 'http://localhost', - 'name': 'Localhost' - } ) - try: - camera_width = 640 - camera_height = 480 - video_src = 1 - with open('settings.json','w') as outputfile: - json.dump(options, outputfile) - except Exception as e1: - print('Error writing user settings file.') - print(e1) - - def saveUserParameters(self, cameraSrc=-2): - global camera_width, camera_height, video_src - cameraSrc = int(cameraSrc) - try: - if cameraSrc > -2: - video_src = cameraSrc - options = {} - options['camera'] = [] - options['camera'].append( { - 'video_src': video_src, - 'display_width': camera_width, - 'display_height': camera_height - } ) - options['printer'] = [] - options['printer'].append( { - 'address': self.printerURL, - 'name': 'Default printer' - } ) - with open('settings.json','w') as outputfile: - json.dump(options, outputfile) - except Exception as e1: - print('Error saving user settings file.') - print(e1) - if int(video_src) != cameraSrc: - self.video_thread.changeVideoSrc(newSrc=cameraSrc) - self.updateStatusbar('Current profile saved to settings.json') - - def _createMenuBar(self): - menuBar = self.menuBar() - # Creating menus using a QMenu object - fileMenu = QMenu('&File', self) - menuBar.addMenu(fileMenu) - fileMenu.addAction(self.debugAction) - fileMenu.addAction(self.cameraAction) - fileMenu.addSeparator() - fileMenu.addAction(self.saveAction) - fileMenu.addSeparator() - fileMenu.addAction(self.quitAction) - - self.analysisMenu = QMenu('&Analyze',self) - menuBar.addMenu(self.analysisMenu) - self.analysisMenu.addAction(self.graphAction) - self.analysisMenu.addAction(self.exportAction) - self.analysisMenu.setDisabled(True) - - def _createActions(self): - # Creating action using the first constructor - self.debugAction = QAction(self) - self.debugAction.setText('&Debug info') - self.cameraAction = QAction(self) - self.cameraAction.setText('&Camera settings') - self.quitAction = QAction(self) - self.quitAction.setText('&Quit') - self.saveAction = QAction(self) - self.saveAction.setText('&Save current settings') - self.graphAction = QAction(self) - self.graphAction.setText('&Graph calibration data..') - self.exportAction = QAction(self) - self.exportAction.setText('&Export to output.json') - - def _connectActions(self): - # Connect File actions - self.debugAction.triggered.connect(self.displayDebug) - self.cameraAction.triggered.connect(self.displayCameraSettings) - self.quitAction.triggered.connect(self.close) - self.saveAction.triggered.connect(self.saveUserParameters) - - self.graphAction.triggered.connect(lambda: self.analyzeResults(graph=True)) - self.exportAction.triggered.connect(lambda: self.analyzeResults(export=True)) - - def displayCameraSettings(self): - self.camera_dialog = CameraSettingsDialog(parent=self) - self.camera_dialog.exec_() - - def displayDebug(self): - dbg = DebugDialog(parent=self,message=self.debugString) - if dbg.exec_(): - None - - def displayJogPanel(self): - try: - local_status = self.printer.getStatus() - if local_status == 'idle': - jogPanel = CPDialog(parent=self,summary='Control printer movement using this panel.',title='Jog Control') - if jogPanel.exec_(): - None - except Exception as e1: self.statusBar.showMessage('Printer is not available or is busy. ') - - def startVideo(self): - # create the video capture thread - self.video_thread = CalibrateNozzles(parent=self,numTools=0, cycles=1,minArea=600, align=False) - # connect its signal to the update_image slot - # connect its signal to the update_image slot - self.video_thread.detection_error.connect(self.updateStatusbar) - self.video_thread.status_update.connect(self.updateStatusbar) - self.video_thread.message_update.connect(self.updateMessagebar) - self.video_thread.change_pixmap_signal.connect(self.update_image) - self.video_thread.calibration_complete.connect(self.applyCalibration) - self.video_thread.result_update.connect(self.addCalibrationResult) - - # start the thread - self.video_thread.start() - - def stopVideo(self): - try: - if self.video_thread.isRunning(): - self.video_thread.stop() - except Exception as vs2: - self.updateStatusbar('ERROR: cannot stop video') - print('ERROR: cannot stop video') - print(vs2) - - def closeEvent(self, event): - try: - if self.printer.isIdle(): - tempCoords = self.printer.getCoords() - self.printer.gCode('T-1') - self.printer.gCode('G1 X' + str(tempCoords['X']) + ' Y' + str(tempCoords['Y'])) - except Exception as ce1: None # no printer connected usually. - print() - print('Thank you for using TAMV!') - print('Check out www.jubilee3d.com') - event.accept() - - def connectToPrinter(self): - # temporarily suspend GUI and display status message - self.image_label.setText('Waiting to connect..') - self.updateStatusbar('Please enter machine IP address or name prefixed with http(s)://') - self.connection_button.setDisabled(True) - self.disconnection_button.setDisabled(True) - self.calibration_button.setDisabled(True) - self.cp_button.setDisabled(True) - self.jogpanel_button.setDisabled(True) - self.offsets_box.setVisible(False) - self.connection_status.setText('Connecting..') - self.connection_status.setStyleSheet(style_orange) - self.cp_label.setText('CP: undef') - self.cp_label.setStyleSheet(style_orange) - self.repeatSpinBox.setDisabled(True) - self.xray_box.setDisabled(True) - self.xray_box.setChecked(False) - self.xray_box.setVisible(False) - self.loose_box.setDisabled(True) - self.loose_box.setChecked(False) - self.loose_box.setVisible(False) - self.repaint() - try: - # check if printerURL has already been defined (user reconnecting) - if len(self.printerURL) > 0: - None - except Exception: - # printerURL initalization to defaults - self.printerURL = 'http://localhost' - # Prompt user for machine connection address - text, ok = QInputDialog.getText(self, 'Machine URL','Machine IP address or hostname: ', QLineEdit.Normal, self.printerURL) - # Handle clicking OK/Connect - if ok and text != '' and len(text) > 5: - ( _errCode, _errMsg, tempURL ) = self.cleanPrinterURL(text) - while _errCode != 0: - # Invalid URL detected, pop-up window to correct this - text, ok = QInputDialog.getText(self, 'Machine URL', _errMsg + '\nMachine IP address or hostname: ', QLineEdit.Normal, text) - if ok: - ( _errCode, _errMsg, tempURL ) = self.cleanPrinterURL(text) - else: - self.updateStatusbar('Connection request cancelled.') - self.resetConnectInterface() - return - # input has been parsed and is clean, proceed - self.printerURL = tempURL - # Handle clicking cancel - elif not ok: - self.updateStatusbar('Connection request cancelled.') - self.resetConnectInterface() - return - # Handle invalid input - elif len(text) < 6 or text[:4] not in ['http']: - self.updateStatusbar('Invalid IP address or hostname: \"' + text +'\". Add http(s):// to try again.') - self.resetConnectInterface() - return - # Update user with new state - self.statusBar.showMessage('Attempting to connect to: ' + self.printerURL ) - # Attempt connecting to the Duet controller - try: - self.printer = DWA.DuetWebAPI(self.printerURL) - if not self.printer.printerType(): - # connection failed for some reason - self.updateStatusbar('Device at '+self.printerURL+' either did not respond or is not a Duet V2 or V3 printer.') - self.resetConnectInterface() - return - else: - # connection succeeded, update objects accordingly - self._connected_flag = True - self.num_tools = self.printer.getNumTools() - self.video_thread.numTools = self.num_tools - # UPDATE OFFSET INFORMATION - self.offsets_box.setVisible(True) - self.offsets_table.setRowCount(self.num_tools) - for i in range(self.num_tools): - current_tool = self.printer.getG10ToolOffset(i) - offset_x = "{:.3f}".format(current_tool['X']) - offset_y = "{:.3f}".format(current_tool['Y']) - x_tableitem = QTableWidgetItem(offset_x) - y_tableitem = QTableWidgetItem(offset_y) - x_tableitem.setBackground(QColor(255,255,255,255)) - y_tableitem.setBackground(QColor(255,255,255,255)) - self.offsets_table.setVerticalHeaderItem(i,QTableWidgetItem('T'+str(i))) - self.offsets_table.setItem(i,0,x_tableitem) - self.offsets_table.setItem(i,1,y_tableitem) - # add tool buttons - toolButton = QPushButton('T'+str(i)) - toolButton.setToolTip('Fetch T' + str(i) + ' to current machine position.') - self.toolButtons.append(toolButton) - except Exception as conn1: - self.updateStatusbar('Cannot connect to: ' + self.printerURL ) - print('Duet Connection exception: ', conn1) - self.resetConnectInterface() - return - # Get active tool - _active = self.printer.getCurrentTool() - # Display toolbox - for i,button in enumerate(self.toolButtons): - button.setCheckable(True) - if int(_active) == i: - button.setChecked(True) - else: - button.setChecked(False) - button.clicked.connect(self.callTool) - self.toolBoxLayout.addWidget(button) - self.toolBox.setVisible(True) - # Connection succeeded, update GUI first - self.updateStatusbar('Connected to a Duet V'+str(self.printer.printerType())) - self.connection_button.setText('Online: ' + self.printerURL[self.printerURL.rfind('/')+1:]) - self.statusBar.showMessage('Connected to printer at ' + self.printerURL, 5000) - self.connection_status.setText('Connected.') - self.image_label.setText('Set your Controlled Point to continue.') - # enable/disable buttons - self.connection_button.setDisabled(True) - self.calibration_button.setDisabled(True) - self.disconnection_button.setDisabled(False) - self.cp_button.setDisabled(False) - self.jogpanel_button.setDisabled(False) - self.analysisMenu.setDisabled(True) - # update connection status indicator to green - self.connection_status.setStyleSheet(style_green) - self.cp_label.setStyleSheet(style_red) - - def callTool(self): - # handle scenario where machine is busy and user tries to select a tool. - if not self.printer.isIdle(): - self.updateStatusbar('Machine is not idle, cannot select tool.') - return - - # get current active tool - _active = self.printer.getCurrentTool() - - # get requested tool number - sender = self.sender() - - # update buttons to new status - for button in self.toolButtons: - button.setChecked(False) - self.toolButtons[int(self.sender().text()[1:])].setChecked(True) - - # handle tool already active on printer - if int(_active) == int(sender.text()[1:]): - msg = QMessageBox() - status = msg.question( self, 'Unload ' + sender.text(), 'Unload ' + sender.text() + ' and return carriage to the current position?',QMessageBox.Yes | QMessageBox.No ) - if status == QMessageBox.Yes: - self.toolButtons[int(self.sender().text()[1:])].setChecked(False) - if len(self.cp_coords) > 0: - self.printer.gCode('T-1') - self.printer.gCode('G1 X' + str(self.cp_coords['X'])) - self.printer.gCode('G1 Y' + str(self.cp_coords['Y'])) - self.printer.gCode('G1 Z' + str(self.cp_coords['Z'])) - else: - tempCoords = self.printer.getCoords() - self.printer.gCode('T-1') - self.printer.gCode('G1 X' + str(tempCoords['X'])) - self.printer.gCode('G1 Y' + str(tempCoords['Y'])) - self.printer.gCode('G1 Z' + str(tempCoords['Z'])) - # End video threads and restart default thread - self.video_thread.alignment = False - - # Update GUI for unloading carriage - self.calibration_button.setDisabled(False) - self.cp_button.setDisabled(False) - self.updateMessagebar('Ready.') - self.updateStatusbar('Ready.') - else: - # User cancelled, do nothing - return - else: - # Requested tool is different from active tool - msg = QMessageBox() - status = msg.question( self, 'Confirm loading ' + sender.text(), 'Load ' + sender.text() + ' and move to current position?',QMessageBox.Yes | QMessageBox.No ) - - if status == QMessageBox.Yes: - # return carriage to controlled point position - if len(self.cp_coords) > 0: - self.printer.gCode('T-1') - self.printer.gCode(sender.text()) - self.printer.gCode('G1 X' + str(self.cp_coords['X'])) - self.printer.gCode('G1 Y' + str(self.cp_coords['Y'])) - self.printer.gCode('G1 Z' + str(self.cp_coords['Z'])) - else: - tempCoords = self.printer.getCoords() - self.printer.gCode('T-1') - self.printer.gCode(self.sender().text()) - self.printer.gCode('G1 X' + str(tempCoords['X'])) - self.printer.gCode('G1 Y' + str(tempCoords['Y'])) - self.printer.gCode('G1 Z' + str(tempCoords['Z'])) - # START DETECTION THREAD HANDLING - # close camera settings dialog so it doesn't crash - try: - if self.camera_dialog.isVisible(): - self.camera_dialog.reject() - except: None - # update GUI - self.cp_button.setDisabled(True) - self.jogpanel_button.setDisabled(False) - self.calibration_button.setDisabled(True) - self.repeatSpinBox.setDisabled(True) - - else: - self.toolButtons[int(self.sender().text()[1:])].setChecked(False) - - def resetConnectInterface(self): - self.connection_button.setDisabled(False) - self.disconnection_button.setDisabled(True) - self.calibration_button.setDisabled(True) - self.cp_button.setDisabled(True) - self.jogpanel_button.setDisabled(True) - self.offsets_box.setVisible(False) - self.connection_status.setText('Disconnected') - self.connection_status.setStyleSheet(style_red) - self.cp_label.setText('CP: undef') - self.cp_label.setStyleSheet(style_red) - self.repeatSpinBox.setDisabled(True) - self.analysisMenu.setDisabled(True) - self.detect_box.setChecked(False) - self.detect_box.setDisabled(False) - self.xray_box.setDisabled(True) - self.xray_box.setChecked(False) - self.xray_box.setVisible(False) - self.loose_box.setDisabled(True) - self.loose_box.setChecked(False) - self.loose_box.setVisible(False) - self.video_thread.detection_on = False - self.video_thread.loose = False - self.video_thread.xray = False - self.video_thread.alignment = False - - index = self.toolBoxLayout.count()-1 - while index >= 0: - curWidget = self.toolBoxLayout.itemAt(index).widget() - curWidget.setParent(None) - index -= 1 - self.toolBox.setVisible(False) - self.toolButtons = [] - self.repaint() - - def controlledPoint(self): - # handle scenario where machine is busy and user tries to select a tool. - if not self.printer.isIdle(): - self.updateStatusbar('Machine is not idle, cannot select tool.') - return - # display crosshair on video feed at center of image - self.crosshair = True - self.calibration_button.setDisabled(True) - - if len(self.cp_coords) > 0: - self.printer.gCode('T-1') - self.printer.gCode('G90 G1 X'+ str(self.cp_coords['X']) + ' Y' + str(self.cp_coords['Y']) + ' Z' + str(self.cp_coords['Z']) ) - dlg = CPDialog(parent=self) - if dlg.exec_(): - self.cp_coords = self.printer.getCoords() - self.cp_string = '(' + str(self.cp_coords['X']) + ', ' + str(self.cp_coords['Y']) + ')' - self.readyToCalibrate() - else: - self.statusBar.showMessage('CP Setup cancelled.') - self.crosshair = False - - def readyToCalibrate(self): - self.statusBar.showMessage('Controlled Point coordinates saved.',3000) - self.image_label.setText('Controlled Point set. Click \"Start Tool Alignment\" to calibrate..') - self.cp_button.setText('Reset CP ') - self.cp_label.setText('CP: ' + self.cp_string) - self.cp_label.setStyleSheet(style_green) - self.detect_box.setChecked(False) - self.detect_box.setDisabled(False) - self.detect_box.setVisible(True) - self.xray_box.setDisabled(True) - self.xray_box.setChecked(False) - self.xray_box.setVisible(False) - self.loose_box.setDisabled(True) - self.loose_box.setChecked(False) - self.loose_box.setVisible(False) - self.video_thread.detection_on = False - self.video_thread.loose = False - self.video_thread.xray = False - self.video_thread.alignment = False - self.calibration_button.setDisabled(False) - self.cp_button.setDisabled(False) - - self.toolBox.setVisible(True) - self.repeatSpinBox.setDisabled(False) - - if len(self.calibrationResults) > 1: - self.analysisMenu.setDisabled(False) - else: - self.analysisMenu.setDisabled(True) - - def applyCalibration(self): - # update GUI - self.readyToCalibrate() - # close camera settings dialog so it doesn't crash - try: - if self.camera_dialog.isVisible(): - self.camera_dialog.reject() - except: None - # prompt for user to apply results - msgBox = QMessageBox(parent=self) - msgBox.setIcon(QMessageBox.Information) - msgBox.setText('Do you want to save the new offsets to your machine?') - msgBox.setWindowTitle('Calibration Results') - yes_button = msgBox.addButton('Apply offsets and save (M500)',QMessageBox.ApplyRole) - yes_button.setStyleSheet(style_green) - cancel_button = msgBox.addButton('Apply offsets',QMessageBox.NoRole) - - # Update debug string - self.debugString += '\nCalibration results:\n' - for result in self.calibrationResults: - calibrationCode = 'G10 P' + str(result['tool']) + ' X' + str(result['X']) + ' Y' + str(result['Y']) - self.debugString += calibrationCode + '\n' - - # Prompt user - returnValue = msgBox.exec() - if msgBox.clickedButton() == yes_button: - for result in self.calibrationResults: - calibrationCode = 'G10 P' + str(result['tool']) + ' X' + str(result['X']) + ' Y' + str(result['Y']) - self.printer.gCode(calibrationCode) - self.printer.gCode('M500 P10') # because of Rene. - self.statusBar.showMessage('Offsets applied and stored using M500.') - print('Offsets applied and stored using M500.') - else: - self.statusBar.showMessage('Temporary offsets applied. You must manually save these offsets.') - # Clean up threads and detection - self.video_thread.alignment = False - self.video_thread.detect_on = False - self.video_thread.display_crosshair = False - # run stats - self.analyzeResults() - - def analyzeResults(self, graph=False, export=False): - if len(self.calibrationResults) < 1: - self.updateStatusbar('No calibration data found.') - return - if graph or export: - # get data as 3 dimensional array [tool][axis][datapoints] normalized around mean of each axis - (numTools, totalRuns, toolData) = self.parseData(self.calibrationResults) - else: - # display stats to terminal - self.stats() - if graph: - matplotlib.use('Qt5Agg',force=True) - # set up color and colormap arrays - colorMap = ["Greens","Oranges","Blues", "Reds"] #["Blues", "Reds","Greens","Oranges"] - colors = ['blue','red','green','orange'] - # initiate graph data - 1 tool per column - # Row 0: scatter plot with standard deviation box - # Row 1: histogram of X axis data - # Row 2: histogram of Y axis data - - # Set backend (if needed) - #plt.switch_backend('Qt4Agg') - - fig, axes = plt.subplots(ncols=3,nrows=numTools,constrained_layout=False) - - for i, data in enumerate(toolData): - # create a color array the length of the number of tools in the data - color = np.arange(len(data[0])) - - # Axis formatting - # Major ticks - axes[i][0].xaxis.set_major_formatter(FormatStrFormatter('%.3f')) - axes[i][0].yaxis.set_major_formatter(FormatStrFormatter('%.3f')) - # Minor ticks - axes[i][0].xaxis.set_minor_formatter(FormatStrFormatter('%.3f')) - axes[i][0].yaxis.set_minor_formatter(FormatStrFormatter('%.3f')) - # Draw 0,0 lines - axes[i][0].axhline() - axes[i][0].axvline() - # x&y std deviation box - x_sigma = np.around(np.std(data[0]),3) - y_sigma = np.around(np.std(data[1]),3) - axes[i][0].add_patch(patches.Rectangle((-1*x_sigma,-1*y_sigma), 2*x_sigma, 2*y_sigma, color="green",fill=False, linestyle='dotted')) - axes[i][0].add_patch(patches.Rectangle((-2*x_sigma,-2*y_sigma), 4*x_sigma, 4*y_sigma, color="red",fill=False, linestyle='-.')) - - # scatter plot for tool data - axes[i][0].scatter(data[0], data[1], c=color, cmap=colorMap[i]) - axes[i][0].autoscale = True - - # Histogram data setup - # Calculate number of bins per axis - x_intervals = int(np.around(math.sqrt(len(data[0])),0)+1) - y_intervals = int(np.around(math.sqrt(len(data[1])),0)+1) - - # plot histograms - x_kwargs = dict(alpha=0.5, bins=x_intervals,rwidth=.92, density=True) - n, bins, hist_patches = axes[i][1].hist([data[0],data[1]],**x_kwargs, color=[colors[0],colors[1]], label=['X','Y']) - axes[i][2].hist2d(data[0], data[1], bins=x_intervals, cmap='Blues') - axes[i][1].legend() - - - # add a 'best fit' line - # calculate mean and std deviation per axis - x_mean = np.mean(data[0]) - y_mean = np.mean(data[1]) - x_sigma = np.around(np.std(data[0]),3) - y_sigma = np.around(np.std(data[1]),3) - # calculate function lines for best fit - x_best = ((1 / (np.sqrt(2 * np.pi) * x_sigma)) * - np.exp(-0.5 * (1 / x_sigma * (bins - x_mean))**2)) - y_best = ((1 / (np.sqrt(2 * np.pi) * y_sigma)) * - np.exp(-0.5 * (1 / y_sigma * (bins - y_mean))**2)) - # add best fit line to plots - axes[i][1].plot(bins, x_best, '-.',color=colors[0]) - axes[i][1].plot(bins, y_best, '--',color=colors[1]) - - x_count = int(sum( p == True for p in ((data[0] >= (x_mean - x_sigma)) & (data[0] <= (x_mean + x_sigma))) )/len(data[0])*100) - y_count = int(sum( p == True for p in ((data[1] >= (y_mean - y_sigma)) & (data[1] <= (y_mean + y_sigma))) )/len(data[1])*100) - # annotate std dev values - annotation_text = "Xσ: " + str(x_sigma) + " ("+str(x_count) + "%)" - if x_count < 68: - x_count = int(sum( p == True for p in ((data[0] >= (x_mean - 2*x_sigma)) & (data[0] <= (x_mean + 2*x_sigma))) )/len(data[0])*100) - annotation_text += " --> 2σ: " + str(x_count) + "%" - if x_count < 95 and x_sigma*2 > 0.1: - annotation_text += " -- check axis!" - else: annotation_text += " -- OK" - annotation_text += "\nYσ: " + str(y_sigma) + " ("+str(y_count) + "%)" - if y_count < 68: - y_count = int(sum( p == True for p in ((data[1] >= (y_mean - 2*y_sigma)) & (data[1] <= (y_mean + 2*y_sigma))) )/len(data[1])*100) - annotation_text += " --> 2σ: " + str(y_count) + "%" - if y_count < 95 and y_sigma*2 > 0.1: - annotation_text += " -- check axis!" - else: annotation_text += " -- OK" - axes[i][0].annotate(annotation_text, (10,10),xycoords='axes pixels') - axes[i][0].annotate('σ',(1.1*x_sigma,-1.1*y_sigma),xycoords='data',color='green') - axes[i][0].annotate('2σ',(1.1*2*x_sigma,-1.1*2*y_sigma),xycoords='data',color='red') - # # place title for graph - axes[i][0].set_ylabel("Tool " + str(i) + "\nY") - axes[i][0].set_xlabel("X") - axes[i][2].set_ylabel("Y") - axes[i][2].set_xlabel("X") - - if i == 0: - axes[i][0].set_title('Scatter Plot') - axes[i][1].set_title('Histogram') - axes[i][2].set_title('2D Histogram') - plt.tight_layout() - figManager = plt.get_current_fig_manager() - figManager.window.showMaximized() - plt.ion() - plt.show() - - if export: - # export JSON data to file - try: - with open('output.json','w') as outputfile: - json.dump(self.calibrationResults, outputfile) - except Exception as e1: - print('Error exporting data:') - print(e1) - self.updateStatusbar('Error exporting data, please check terminal for details.') - - def stats(self): - ################################################################################### - # Report on repeated executions - ################################################################################### - print('') - print('Repeatability statistics for '+str(self.cycles)+' repeats:') - print('+-------------------------------------------------------------------------------------------------------+') - print('| | X | Y |') - print('| T | Avg | Max | Min | StdDev | Range | Avg | Max | Min | StdDev | Range |') - for index in range(self.num_tools): - # create array of results for current tool - _rawCalibrationData = [line for line in self.calibrationResults if line['tool'] == str(index)] - x_array = [float(line['X']) for line in _rawCalibrationData] - y_array = [float(line['Y']) for line in _rawCalibrationData] - mpp_value = np.average([float(line['mpp']) for line in _rawCalibrationData]) - cycles = np.max( - [float(line['cycle']) for line in _rawCalibrationData] - ) - x_avg = np.average(x_array) - y_avg = np.average(y_array) - x_min = np.min(x_array) - y_min = np.min(y_array) - x_max = np.max(x_array) - y_max = np.max(y_array) - x_std = np.std(x_array) - y_std = np.std(y_array) - x_ran = x_max - x_min - y_ran = y_max - y_min - print('| {0:1.0f} '.format(index) - + '| {0:7.3f} '.format(x_avg) - + '| {0:7.3f} '.format(x_max) - + '| {0:7.3f} '.format(x_min) - + '| {0:7.3f} '.format(x_std) - + '| {0:7.3f} '.format(x_ran) - + '| {0:7.3f} '.format(y_avg) - + '| {0:7.3f} '.format(y_max) - + '| {0:7.3f} '.format(y_min) - + '| {0:7.3f} '.format(y_std) - + '| {0:7.3f} '.format(y_ran) - + '|' - ) - print('+-------------------------------------------------------------------------------------------------------+') - print('Note: Repeatability cannot be better than one pixel (MPP=' + str(mpp_value) + ').') - - def parseData( self, rawData ): - # create empty output array - toolDataResult = [] - # get number of tools - _numTools = np.max([ int(line['tool']) for line in rawData ]) + 1 - _cycles = np.max([ int(line['cycle']) for line in rawData ]) - - for i in range(_numTools): - x = [float(line['X']) for line in rawData if int(line['tool']) == i] - y = [float(line['Y']) for line in rawData if int(line['tool']) == i] - # variable to hold return data coordinates per tool formatted as a 2D array [x_value, y_value] - tempPairs = [] - - # calculate stats - # mean values - x_mean = np.around(np.mean(x),3) - y_mean = np.around(np.mean(y),3) - # median values - x_median = np.around(np.median(x),3) - y_median = np.around(np.median(y),3) - # ranges (max - min per axis) - x_range = np.around(np.max(x) - np.min(x),3) - y_range = np.around(np.max(y) - np.min(y),3) - # standard deviations - x_sig = np.around(np.std(x),3) - y_sig = np.around(np.std(y),3) - - # normalize data around mean - x -= x_mean - y -= y_mean - - # temporary object to append coordinate pairs into return value - tempPairs.append(x) - tempPairs.append(y) - - # add data to return object - toolDataResult.append(tempPairs) - # return dataset - return ( _numTools, _cycles, toolDataResult ) - - def disconnectFromPrinter(self): - # temporarily suspend GUI and display status message - self.image_label.setText('Restoring machine to initial state..') - self.updateStatusbar('Restoring machine and disconnecting...') - self.connection_button.setText('Pending..') - self.connection_button.setDisabled(True) - self.disconnection_button.setDisabled(True) - self.calibration_button.setDisabled(True) - self.cp_button.setDisabled(True) - self.cp_button.setText('Pending..') - self.jogpanel_button.setDisabled(True) - self.offsets_box.setVisible(False) - self.connection_status.setText('Disconnecting..') - self.connection_status.setStyleSheet(style_orange) - self.cp_label.setText('CP: undef') - self.cp_label.setStyleSheet(style_orange) - self.repeatSpinBox.setDisabled(True) - self.xray_box.setDisabled(True) - self.xray_box.setChecked(False) - self.loose_box.setDisabled(True) - self.toolBox.setVisible(False) - self.repaint() - # End video threads and restart default thread - # Clean up threads and detection - self.video_thread.alignment = False - self.video_thread.detect_on = False - self.video_thread.display_crosshair = False - self.detect_box.setChecked(False) - self.detect_box.setVisible(True) - - # update status - self.updateStatusbar('Unloading tools and disconnecting from machine..') - # Wait for printer to stop moving and unload tools - _ret_error = self.printer.gCode('M400') - if self.printer.isIdle(): - tempCoords = self.printer.getCoords() - _ret_error += self.printer.gCode('T-1') - # return carriage to controlled point position - if len(self.cp_coords) > 0: - _ret_error += self.printer.gCode('G1 X' + str(self.cp_coords['X'])) - _ret_error += self.printer.gCode('G1 Y' + str(self.cp_coords['Y'])) - _ret_error += self.printer.gCode('G1 Z' + str(self.cp_coords['Z'])) - else: - _ret_error += self.printer.gCode('G1 X' + str(tempCoords['X'])) - _ret_error += self.printer.gCode('G1 Y' + str(tempCoords['Y'])) - _ret_error += self.printer.gCode('G1 Z' + str(tempCoords['Z'])) - # update status with disconnection state - if _ret_error == 0: - self.updateStatusbar('Disconnected.') - self.image_label.setText('Disconnected.') - else: - # handle unforeseen disconnection error (power loss?) - self.statusBar.showMessage('Disconnect: error communicating with machine.') - self.statusBar.setStyleSheet(style_red) - # Reinitialize printer object - self.printer = None - - # Tools unloaded, reset GUI - self.image_label.setText('Welcome to TAMV. Enter your printer address and click \"Connect..\" to start.') - self.connection_button.setText('Connect..') - self.connection_button.setDisabled(False) - self.disconnection_button.setDisabled(True) - self.calibration_button.setDisabled(True) - self.cp_button.setDisabled(True) - self.cp_button.setText('Set Controlled Point..') - self.jogpanel_button.setDisabled(True) - self.offsets_box.setVisible(False) - self.connection_status.setText('Disconnected.') - self.connection_status.setStyleSheet(style_red) - self.cp_label.setText('CP: undef') - self.cp_label.setStyleSheet(style_red) - self.repeatSpinBox.setDisabled(True) - self.xray_box.setDisabled(True) - self.loose_box.setDisabled(True) - self.resetConnectInterface() - - def runCalibration(self): - # reset debugString - self.debugString = '' - # prompt for user to apply results - msgBox = QMessageBox(parent=self) - msgBox.setIcon(QMessageBox.Information) - msgBox.setText('Do you want to start automated tool alignment?') - msgBox.setWindowTitle('Start Calibration') - yes_button = msgBox.addButton('Start calibration..',QMessageBox.YesRole) - yes_button.setObjectName('active') - yes_button.setStyleSheet(style_green) - no_button = msgBox.addButton('Cancel',QMessageBox.NoRole) - - returnValue = msgBox.exec_() - if msgBox.clickedButton() == no_button: - return - # close camera settings dialog so it doesn't crash - try: - if self.camera_dialog.isVisible(): - self.camera_dialog.reject() - except: None - # update GUI - self.cp_button.setDisabled(True) - self.jogpanel_button.setDisabled(False) - self.calibration_button.setDisabled(True) - self.xray_box.setDisabled(False) - self.xray_box.setChecked(False) - self.xray_box.setVisible(True) - self.loose_box.setDisabled(False) - self.loose_box.setChecked(False) - self.loose_box.setVisible(True) - self.toolBox.setVisible(False) - self.detect_box.setVisible(False) - for i in range(self.num_tools): - current_tool = self.printer.getG10ToolOffset(i) - x_tableitem = QTableWidgetItem("{:.3f}".format(current_tool['X'])) - y_tableitem = QTableWidgetItem("{:.3f}".format(current_tool['Y'])) - x_tableitem.setBackground(QColor(255,255,255,255)) - y_tableitem.setBackground(QColor(255,255,255,255)) - self.offsets_table.setVerticalHeaderItem(i,QTableWidgetItem('T'+str(i))) - self.offsets_table.setItem(i,0,x_tableitem) - self.offsets_table.setItem(i,1,y_tableitem) - # get number of repeat cycles - self.repeatSpinBox.setDisabled(True) - self.cycles = self.repeatSpinBox.value() - - # create the Nozzle detection capture thread - self.video_thread.display_crosshair = True - self.video_thread.detection_on = True - self.video_thread.xray = False - self.video_thread.loose = False - self.video_thread.alignment = True - - def toggle_xray(self): - try: - self.video_thread.toggleXray() - except Exception as e1: - self.updateStatusbar('Detection thread not running.') - print( 'Detection thread error in XRAY: ') - print(e1) - - def toggle_loose(self): - try: - self.video_thread.toggleLoose() - except Exception as e1: - self.updateStatusbar('Detection thread not running.') - print( 'Detection thread error in LOOSE: ') - print(e1) - - @pyqtSlot(str) - def updateStatusbar(self, statusCode ): - self.statusBar.showMessage(statusCode) - - @pyqtSlot(str) - def updateMessagebar(self, statusCode ): - self.image_label.setText(statusCode) - - @pyqtSlot(np.ndarray) - def update_image(self, cv_img): - #self.mutex.lock() - self.current_frame = cv_img - if self.crosshair: - # Draw alignment circle on image - alpha = 0.5 - beta = 1-alpha - center = ( int(camera_width/2), int(camera_height/2) ) - overlay = cv2.circle( - cv_img.copy(), - center, - 6, - (0,255,0), - int( camera_width/1.75 ) - ) - overlay = cv2.circle( - overlay.copy(), - center, - 5, - (0,0,255), - 2 - ) - for i in range(0,8): - overlay = cv2.circle( - overlay.copy(), - center, - 25*i, - (0,0,0), - 1 - ) - overlay = cv2.line(overlay, (center[0],center[1]-int( camera_width/3 )), (center[0],center[1]+int( camera_width/3 )), (128, 128, 128), 1) - overlay = cv2.line(overlay, (center[0]-int( camera_width/3 ),center[1]), (center[0]+int( camera_width/3 ),center[1]), (128, 128, 128), 1) - cv_img = cv2.addWeighted(overlay, beta, cv_img, alpha, 0) - # Updates the image_label with a new opencv image - qt_img = self.convert_cv_qt(cv_img) - self.image_label.setPixmap(qt_img) - #self.mutex.unlock() - - def convert_cv_qt(self, cv_img): - # Convert from an opencv image to QPixmap - rgb_image = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB) - h, w, ch = rgb_image.shape - bytes_per_line = ch * w - convert_to_Qt_format = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888) - p = convert_to_Qt_format.scaled(display_width, display_height, Qt.KeepAspectRatio) - return QPixmap.fromImage(p) - - def addCalibrationResult(self, result={}): - self.calibrationResults.append(result) - -if __name__=='__main__': - os.putenv("QT_LOGGING_RULES","qt5ct.debug=false") - app = QApplication(sys.argv) - a = App() - a.show() - sys.exit(app.exec_()) diff --git a/ZTATP.py b/ZTATP.py deleted file mode 100755 index bd78b10..0000000 --- a/ZTATP.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -# ZTATP = Z (only) Tool Align Touch Plate -# -# Python Script to align Z for multiple tools on Jubilee printer with Duet3d Controller -# Using a touchplate, and and endstop endput wire to every nozzle and that touchplate. -# May also work on other Duet/RepRap based mutli-tool printers. -# -# Copyright (C) 2020 Danal Estes all rights reserved. -# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT -# -# Requires network connection to Duet based printer running Duet/RepRap V2 or V3 -# -# Note: You MUST define Z with a PROBE in config.g. Even if it is really an endstop switch, it is OK to define that as a probe. -# WARNING: RRF defaults to a probe offset of 0.7mm hardcoded in the source files that must be fixed using a G31 Z0 command in your config.g -# Failure to apply this offset to Z=0 will result in tool offsets being incorrectly calculated as 0.7mm too close to the print surface. -# THIS WILL DEFINITELY DAMAGE YOUR BUILD PLATE WHEN YOU ATTEMPT TO PRINT! - -import datetime -import time - -try: - import DuetWebAPI as DWA -except ImportError: - print("Python Library Module 'DuetWebAPI.py' is required. ") - print("Obtain from https://github.com/DanalEstes/DuetWebAPI ") - print("Place in same directory as script, or in Python libpath.") - exit(2) -import numpy as np -import argparse -import time - -def init(): - # parse command line arguments - parser = argparse.ArgumentParser(description='Program to allign multiple tools in Z on Duet based printers, using a touch plate.', allow_abbrev=False) - parser.add_argument('-duet',type=str,nargs=1,default=['localhost'],help='Name or IP address of Duet printer. You can use -duet=localhost if you are on the embedded Pi on a Duet3.') - #parser.add_argument('-camera',type=str,nargs=1,choices=['usb','pi'],default=['usb']) - parser.add_argument('-touchplate',type=float,nargs=2,default=[0.0,0.0],help="x y of center of a 15x15mm touch plate.",required=True) - parser.add_argument('-pin',type=str,nargs=2,default='!io5.in',help='input pin to which wires from nozzles are attached (only in RRF3).') - parser.add_argument('-tool',type=int,nargs=1,default=-1,help='(optional) set a run for an individual tool number referenced by index') - args=vars(parser.parse_args()) - - global duet, camera, tp, pin, tool - duet = args['duet'][0] - tp = args['touchplate'] - pin = args['pin'] - tool = args['tool'] - - - # Get connected to the printer. - print('Attempting to connect to printer at '+duet) - global prt - prt = DWA.DuetWebAPI('http://'+duet) - if (not prt.printerType()): - print('Device at '+duet+' either did not respond or is not a Duet V2 or V3 printer.') - exit(2) - print("Connected to a Duet V"+str(prt.printerType())+" printer at "+prt.baseURL()) - - if( tool is not - 1 ): - tool = tool[0] - if( tool > prt.getNumTools()-1 ): - print() - print('### ERROR IN PARAMETERS ###') - print('You have specified tool ' + str(tool) + ' for probing, but it does not exist.') - print('Your printer has '+str(prt.getNumTools())+' tools defined, so you must enter an index for tools between [0..' + str(prt.getNumTools()-1)+']') - print('Please check your input parameters and try again.') - exit(3) - - print('#########################################################################') - print('# Important: #') - print('# Your printer MUST be capable of mounting and parking every tool with #') - print('# no collisions between tools. #') - print('# #') - print('# Your Z probe must be a probe, not an endstop. It is OK to define a #') - print('# simple switch as a probe. #') - print('# #') - print('# WARNING!! WARNING!! #') - print('# The probing sequence will assume a Z offset of 45mm for every tool. #') - print('# During probing, any existing offsets will be temporarily cleared. #') - print('# Before running this script, make sure all tools will not collide #') - print('# with the build plate/probe plate with this 45 offset applied. #') - print('# #') - print('# Each nozzle MUST be wired to the specified input. Test the wiring by #') - print('# hand before running ZTATP. If the wiring is wrong, probing will not #') - print('# stop, and machine damage could occur. #') - print('#########################################################################') - print('Press enter to proceed') - input() - print() - print("##################################") - print("# Options in force for this run: #") - print("# printer = {0:18s}#".format(duet)) - print("# touchplate = {0:6.2f} {1:6.2f} #".format(tp[0],tp[1])) - if (prt.printerType() == 3): - print("# firmware = V3.x.x #") - print("# pin = {0:18s}#".format(str(pin))) - if (prt.printerType() == 2): - print("# firmware = V2.x.x #") - print("# pin = Z_PROBE_IN #") - print("##################################") - print() - -def probePlate(): - prt.resetEndstops() - # HBHBHB: TODO - insert code to handle 0.7mm offset in RRF here! - commandBuffer = [] - commandBuffer.append('T-1') # Unmount any/all tools - commandBuffer.append('G90 G1 X'+str(tp[0])+' Y'+str(tp[1])+' Z50 ') # The real purpose of this is to move the probe into position with its correct offsets. - commandBuffer.append('M400') # wait for buffer to clear - prt.gCodeBatch(commandBuffer) - # wait for probing setup moves to complete before prompting for probe plate - while prt.getStatus() not in 'idle': - time.sleep(1) - print( 'The toolhead is parked in your designated XY position for probing.' ) - input( 'Please place the probe plate in the correct position on the bed and press ENTER to start.' ) - print( 'Probing touch plate...' ) - commandBuffer = [] - commandBuffer.append('M558 F300') # set probing speed fast - commandBuffer.append('G30') # perform first probe - commandBuffer.append('M558 F50') # set probing speed slow - commandBuffer.append('G30') # perform second probe - commandBuffer.append('G30 S-1') # Now we can probe in such a way that Z is readable. - commandBuffer.append('M400') # wait for buffer to clear - prt.gCodeBatch(commandBuffer) - poffs = 0 - - # wait for probing to complete before fetching offsets - while prt.getStatus() not in 'idle': - time.sleep(1) - - poffs = prt.getCoords()['Z'] # Capture the Z position at initial point of contact - print("Touch plate offset = "+str(poffs)) # Display captured offset to terminal - prt.gCode('G91 G0 Z10 F1000 G90') # Lower bed to avoid collision - return(poffs) - -def probeTool(tn): - commandBuffer=[] - print() - print( 'Probing tool ' + str(tn) + '..') - print( 'ZTATP will prompt you to connect your lead once the tool is positioned over the touch plate.' ) - prt.resetEndstops() # return all endstops to natural state from config.g definitions - prt.gCode('M400') # Wait for planner to empty - while prt.getStatus() not in 'idle': - #print("sleeping.") - time.sleep(1) - commandBuffer.append('G10 P'+str(tn)+' Z0') # Remove z offsets from Tool - commandBuffer.append('G91 G0 Z45 F1000 G90') # Lower bed to avoid collision (move to +45 relative in Z) - commandBuffer.append('T'+str(tn)) # Pick up Tool number 'tn' - prt.gCodeBatch(commandBuffer) - duet2rrf3board = prt.checkDuet2RRF3() - # Z Axis Probe setup - # add code to switch pin based on RRF2 vs RRF3 definitions - RRF3 defaults to the original TAMV code - if (prt.printerType() == 3): - # START -- Code for RRF3 - print('*** RRF3 printer connected.') - prt.gCode('M558 K0 P9 C"nil"') # Undef existing probe - prt.gCode('M558 K0 P5 C"'+pin+'" F200 H50') # Define ( nozzle ) <--> ( probe plate ) as probe - # END -- Code for RRF3 - if (prt.printerType() == 2): - # START -- Code for RRF2 - # check if using a Duet 2 board with 3.2+ firmware - if duet2rrf3board: - print('** Duet 2 RRF 3.2+ board found, changing input pin to Z_PROBE_IN.') - prt.gCode('M558 K0 P9 C"nil"') # Undef existing probe - prt.gCode('M558 K0 P5 C"!^zprobe.in" F200 H50') - else: - # Define a normally-open (closes to ground when ACTIVE/TRIGGERED) probe connected between Z-Probe In and Ground (inverted logic to enable this behavior of NO switch) - prt.gCode('M558 P5 I1 F200 H50') # Define ( nozzle ) <--> ( probe plate ) as probe (Z-Probe In pin connected to nozzle, probe plate is connected to ground) - # END -- Code for RRF2 - - prt.gCode('G0 X'+str(tp[0])+' Y'+str(tp[1])+' F10000') # Move nozzle to spot above flat part of plate - input('Connect probe lead to tool ' + str(tn) + ' and press ENTER to continue.') - prt.gCode('M558 F300') # set probing speed fast - prt.gCode('G30 S-1') # Initiate a probing sequence - # wait for probing to complete before fetching offsets - while prt.getStatus() not in 'idle': - time.sleep(1) - print('First pass offset for tool ' + str(tn) + ': ' + str(prt.getCoords()['Z']) ) - prt.gCode('G91 G1 Z5 G90') # move bed away from probe for second pass - prt.gCode('M558 F50') # set probing speed fast - prt.gCode('G30 S-1') - - # wait for probing to complete before fetching offsets - while prt.getStatus() not in 'idle': - time.sleep(1) - - toffs = prt.getCoords()['Z'] # Fetch current Z coordinate from Duet controller - print("Final offset for tool "+str(tn)+": "+str(toffs)) # Output offset to terminal for user to read - prt.gCode('G91 G0 Z45 F1000 G90') # Lower bed to avoid collision - if (prt.printerType() == 3): - # START -- Code for RRF3 - prt.gCode('M574 Z1 S1 P"nil"') # Undef endstop for Z axis - # END -- Code for RRF3 - if duet2rrf3board: - # START -- Code for RRF3.2 on Duet 2 board - prt.gCode('M574 Z1 S1 P"nil"') - # END -- Code for RRF3 - prt.resetEndstops() # return all endstops to natural state from config.g definitions - input('Please disconnect probe lead to tool ' + str(tn) + ' and press ENTER to continue. Active tool is going to be docked.') - prt.gCode('T-1') # unload tool - prt.gCode('M400') # wait until all movement has finished - return(toffs) # return offsets and end function -# End of probeTool function - -# -# Main -# -init() - -# Get probe plate Z offset by probing using default-defined probe for G30 -poffs = probePlate() - -# initialize output variables -toolCoords = [] -if (tool == -1): - print( 'Running a probing sequence for all tools.') - # start probing sequence for each tool defined on the connected printer - for t in range(prt.getNumTools()): - toolCoords.append(probeTool(t)) -else: - print('Running a probing sequence for only tool '+ str(tool) ) - toolCoords.append(probeTool(tool)) -# restore all endstop definitions to the config.g defaults -prt.resetEndstops() - -# Get probe offsets from machine -(_errCode, _errMsg, probeOffset) = prt.getTriggerHeight() -if _errCode != 0: - probeOffset = 0 - print('Error in retrieving probe offsets. Setting offset to 0 to avoid collisions.') - -# Display Results and actually set G10 offsets -print() -print('########### Probing results') -print("Plate Offset = "+str(poffs)) -if (tool == -1): - for tn in range(len(toolCoords)): - print("Final offset for tool "+str(tn)+": "+str(toolCoords[tn])) - print() - for tn in range(len(toolCoords)): - finalOffset = (poffs-toolCoords[tn])-0.1-probeOffset - print('G10 P'+str(tn)+' Z{:0.3f}'.format(finalOffset)) - # wait for probing to complete before setting offsets - #while prt.getStatus() is 'processing': - # time.sleep(1) - prt.gCode('G10 P'+str(tn)+' Z{:0.3f}'.format(finalOffset)) -else: - print("Final offset for tool "+str(tool)+": "+str(toolCoords[0])) - print() - finalOffset = (poffs-toolCoords[0])-0.1-probeOffset - print('G10 P'+str(tool)+' Z{:0.3f}'.format(finalOffset)) - # wait for probing to complete before setting offsets - #while prt.getStatus() is 'processing': - # time.sleep(1) - prt.gCode('G10 P'+str(tool)+' Z{:0.3f}'.format(finalOffset)) -print() -print("Tool offsets have been applied to the current printer.") -print("Please modify your tool definitions in config.g to reflect these newly measured values for persistent storage.") diff --git a/drivers.json b/drivers.json new file mode 100644 index 0000000..edba82f --- /dev/null +++ b/drivers.json @@ -0,0 +1 @@ +[{"firmware":"RRF","filename":"DuetWebAPI.py"},{"firmware":"klipper","filename":"klipperAPI.py"}] \ No newline at end of file diff --git a/drivers/API_template.py b/drivers/API_template.py new file mode 100644 index 0000000..8ed85e6 --- /dev/null +++ b/drivers/API_template.py @@ -0,0 +1,771 @@ +# API template for TAMV interfaces +# MAIN CLASS NAME MUST BE "printerAPI" - do not change +# Search for "##############*** YOUR CUSTOM CODE #################" to find the sections that need to be modified. +# +# Not intended to be a gerneral purpose interface; instead, it contains methods +# to issue commands or return specific information. Feel free to extend with new +# methods for other information; please keep the abstraction for V2 V3 +# +# Copyright (C) 2020 Danal Estes all rights reserved. +# Copyright (C) 2022 Haytham Bennani +# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT +# +# Requires Python3 +import logging +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +# shared import dependencies +import json +import time +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.DuetWebAPI') + +##############*** YOUR CUSTOM CODE ################# +# Feel free to add whatever imports you may need to get this running in this section +##############*** YOUR CUSTOM CODE ################# + + +################################################################################################################################# +################################################################################################################################# +# Main class for interface +# Rename this to whatever you want, and this needs to be the filename and the file called from TAMV +# For your custom TAMV tests, modify the following 2 statements in the TAMV_GUI.py file: +# - "import DuetWebAPI as DWA" +# - "self.printer = DWA.DuetWebAPI(self.printerURL)" + +class printerAPI: + # Max time to wait for toolchange before raising a timeout exception, in seconds + _toolTimeout = 300 + # Max time to wait for HTTP requests to complete + _requestTimeout = 2 + _responseTimeout = 10 + + ##############*** YOUR CUSTOM CODE ################# + # Here's the section to add your class attributes + ##############*** YOUR CUSTOM CODE ################# + + ################################################################################################################################# + # Instantiate class and connect to controller + # This is your main setup call, which will be returned + # Parameters: + # - baseURL (string): full IP address (not FQDN or alias) in the format 'http://xxx.xxx.xxx.xxx' without trailing '/' + # - optional: nickname (string): short nickname for identifying machine (strictly for TAMV GUI) + # + # Returns: NONE + # + # Raises: + # - UnknownController: if fails to connect + def __init__( self, baseURL, nickname='Default' ): + _logger.debug('Starting API..') + + # Here are the required class attributes. These get saved to settings.json + self._base_url = "whatever you want - this gets called from the dialog box when connecting - required" + self._password = "password to connect to machine (if applicable) - can be empty string" + self._name = "Printer name according to firmware (if applicable) - can be empty string" + self._nickname = "Printer nickname - future use - can be empty string" + self._firmwareName = "Firmware name - can be empty string" + self._firmwareVersion = "Firmware version - can be empty string" + # tools is an array of the Tool class located at the end of this file - read that first. + self.tools = [] + + try: + ##############*** YOUR CUSTOM CODE ################# + # add your code to connect to your controller in this section + ##############*** YOUR CUSTOM CODE ################# + if( you_board_failed_to_connect ): + # The board has failed to connect, return an error state + raise UnknownController('Unknown controller detected.') + except UnknownController as uc: + _logger.critical( "Unknown controller at " + self._base_url ) + raise SystemExit(uc) + except Exception as e: + # Catastrophic error. Bail. + _logger.critical( str(e) ) + raise SystemExit(e) + _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..') + return + + ################################################################################################################################# + # Get firmware version + # Parameters: + # - NONE + # + # Returns: integer + # - returns either 2 or 3 depending on which RRF version is running on the controller + # + # Raises: NONE + def getPrinterType( self ): + _logger.debug('Called getPrinterType') + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + return( some_integer ) + + ################################################################################################################################# + # Get number of defined tools from machine + # Parameters: + # - NONE + # + # Returns: integer + # - Positive integer for number of defined tools on machine + # + # Raises: + # - FailedToolDetection: when cannot determine number of tools on machine + def getNumTools( self ): + _logger.debug('Called getNumTools') + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + return( some_integer ) + + ################################################################################################################################# + # Get index of currently loaded tool + # Tool numbering always starts as 0, 1, 2, .. + # Parameters: + # - NONE + # + # Returns: integer + # - Positive integer for index of current loaded tool + # - '-1' if no tool is loaded on the machine + # + # Raises: + # - FailedToolDetection: when cannot determine number of tools on machine + def getCurrentTool( self ): + _logger.debug('Called getCurrentTool') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + # Unknown condition, raise error + raise FailedToolDetection('Something failed. Baililng.') + except ConnectionError as ce: + _logger.critical('Connection error while polling for current tool') + raise SystemExit(ce) + except FailedToolDetection as fd: + _logger.critical('Failed tool detection.') + raise SystemExit(e1) + except Exception as e1: + _logger.critical('Unhandled exception in getCurrentTool: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Get currently defined offsets for tool referenced by index + # Tool numbering always starts as 0, 1, 2, .. + # Parameters: + # - toolIndex (integer): index of tool to get offsets for + # + # Returns: + # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 } + # + # Raises: + # - FailedOffsetCapture: when cannot determine number of tools on machine + def getToolOffset( self, toolIndex=0 ): + _logger.debug('Called getToolOffset') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + raise FailedOffsetCapture('getG10ToolOffset entered unhandled exception state.') + except FailedOffsetCapture as fd: + _logger.critical(str(fd)) + raise SystemExit(fd) + except ConnectionError as ce: + _logger.critical('Connection error in getToolOffset.') + raise SystemExit(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getToolOffset: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Get machine status, mapping any controller status output into 1 of 3 possible states + # Parameters: + # - NONE + # + # Returns: string of following values ONLY + # - idle + # - processing + # - paused + # + # Raises: + # - StatusException: when cannot determine machine status + # - StatusTimeoutException: when machine takes longer than _toolTimeout seconds to respond + def getStatus( self ): + _logger.debug('Called getStatus') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + # OUTPUT MAPPING LOGIC + # Handle return mapping of status variable "_status" + # Change the conditions as appropriate, but maintain the return values and logging please. + if ( _status == "idle" or _status == "I"): + _logger.debug("Machine is idle.") + return ("idle") + elif ( _status == "paused" or _status == "S" or _status == "pausing" or _status == "D"): + _logger.debug("Machine is paused.") + return ("paused") + else: + _logger.debug("Machine is busy processing something.") + return ("processing") + + # unknown error raise exception + raise StatusException('Unknown error getting machine status') + except StatusException as se: + _logger.critical(str(se)) + raise SystemExit(se) + except ConnectionError as ce: + _logger.critical('Connection error in getStatus') + raise SystemExit(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getStatus: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Get current tool coordinates from machine in XYZ space + # Parameters: + # - NONE + # + # Returns: + # - tuple of 3 decimal places precise floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 } + # + # Raises: + # - CoordinatesException: when cannot determine machine status + def getCoordinates( self ): + _logger.debug('Called getCoordinates') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + if failed: + raise CoordinatesException("Unknown duet controller.") + else: + # NOTE: round results to a maximum of 3 decimals places + return( {'X': 0.000, 'Y': 0.000, 'Z': 0.000 } ) + except CoordinatesException as ce1: + _logger.critical(str(ce1)) + raise SystemExit(ce1) + except ConnectionError as ce: + _logger.critical('Connection error in getCoordinates') + raise SystemExit(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getCoordinates: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Set tool offsets for indexed tool in X, Y, and Z + # Parameters: + # - toolIndex (integer): + # - offsetX (float): + # - offsetY (float): + # - offsetZ (float): + # + # Returns: NONE + # + # Raises: + # - SetOffsetException: when failed to set offsets in controller + def setToolOffsets( self, tool=None, X=None, Y=None, Z=None ): + _logger.debug('Called setToolOffsets') + try: + # Check for invalid tool index, raise exception if needed. + if( tool is None ): + raise SetOffsetException( "No tool index provided.") + # Check that any valid offset has been passed as an argument + elif( X is None and Y is None and Z is None ): + raise SetOffsetException( "Invalid offsets provided." ) + else: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + _logger.debug( "Tool offsets applied.") + except SetOffsetException as se: + _logger.error(se) + return + except Exception as e: + _logger.critical( "setToolOffsets unhandled exception: " + str(e) ) + raise SystemExit( "setToolOffsets unhandled exception: " + str(e) ) + + ################################################################################################################################# + # Helper function to check if machine is idle or not + # Parameters: NONE + # + # Returns: boolean + def isIdle( self ): + _logger.debug("Called isIdle") + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + if( state == "idle" ): + return True + else: + return False + + ################################################################################################################################# + # Helper function to check if machine is homed on all axes for motion + # Parameters: NONE + # + # Returns: boolean + def isHomed( self ): + _logger.debug("Called isHomed") + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + if( homed ): + return True + else: + return False + except Exception as e: + _logger.critical( "Failed to check if machine is homed. " + str(e) ) + raise SystemExit("Failed to check if machine is homed. " + str(e)) + + ################################################################################################################################# + # Load specified tool on machine, and wait until machine is idle + # Tool numbering always starts as 0, 1, 2, .. + # If the toolchange takes longer than the class attribute _toolTimeout, then raise a warning in the log and return. + # + # ATTENTION: + # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs. + # You may change this behavior by modifying the exception handling for ToolTimeoutException. + # + # Parameters: + # - toolIndex (integer): index of tool to load + # + # Returns: NONE + # + # Raises: + # - ToolTimeoutException: machine took too long to load the tool + def loadTool( self, toolIndex = 0 ): + _logger.debug('Called loadTool') + # variable to hold current tool loading "virtual" timer + toolchangeTimer = 0 + + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + # Wait until machine is done loading tool and is idle + while not self.isIdle() and toolchangeTimer <= self._toolTimeout: + self._toolTimeout += 2 + time.sleep(2) + if( toolchangeTimer > self._toolTimeout ): + # Request for toolchange timeout, raise exception + raise ToolTimeoutException( "Request to change to tool T" + str(toolIndex) + " timed out.") + return + except ToolTimeoutException as tte: + _logger.warning(str(tte)) + return + except ConnectionError as ce: + _logger.critical('Connection error in loadTool.') + raise SystemExit(ce) + except Exception as e1: + _logger.critical('Unhandled exception in loadTool: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Unload all tools from machine and wait until machine is idle + # Tool numbering always starts as 0, 1, 2, .. + # If the unload operation takes longer than the class attribute _toolTimeout, then raise a warning in the log and return. + # + # ATTENTION: + # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs. + # You may change this behavior by modifying the exception handling for ToolTimeoutException. + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: + # - ToolTimeoutException: machine took too long to load the tool + def unloadTools( self ): + _logger.debug('Called unloadTools') + # variable to hold current tool loading "virtual" timer + toolchangeTimer = 0 + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + # Wait until machine is done loading tool and is idle + while not self.isIdle() and toolchangeTimer <= self._toolTimeout: + self._toolTimeout += 2 + time.sleep(2) + if( toolchangeTimer > self._toolTimeout ): + # Request for toolchange timeout, raise exception + raise ToolTimeoutException( "Request to unload tools timed out!") + return + except ToolTimeoutException as tte: + _logger.warning(str(tte)) + return + except ConnectionError as ce: + _logger.critical('Connection error in unloadTools') + raise SystemExit(ce) + except Exception as e1: + _logger.critical('Unhandled exception in unloadTools: ' + str(e1)) + raise SystemExit(e1) + + ################################################################################################################################# + # Execute a relative positioning move (G91 in Duet Gcode), and return to absolute positioning. + # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min. + # + # Parameters: + # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode) + # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min) + # - X (float): requested X axis final position + # - Y (float): requested Y axis final position + # - Z (float): requested Z axis final position + # + # Returns: NONE + # + # Raises: + # - HomingException: machine is not homed + def moveRelative( self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None ): + _logger.debug('Called moveRelative') + try: + # check if machine has been homed fully + if( self.isHomed() is False ): + raise HomingException("Machine axes have not been homed properly.") + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + except HomingException as he: + _logger.error( he ) + except Exception as e: + errorString = "Move failed to relative coordinates: (" + if( X is not None ): + errorString += " X" + str(X) + if( Y is not None ): + errorString += " Y" + str(Y) + if( Z is not None ): + errorString += " Z" + str(Z) + errorString += ") at speed: " + str(moveSpeed) + _logger.critical(errorString) + raise SystemExit(errorString + "\n" + str(e)) + return + + ################################################################################################################################# + # Execute an absolute positioning move (G90 in Duet Gcode), and return to absolute positioning. + # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min. + # + # Parameters: + # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode) + # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min) + # - X (float): requested X axis final position + # - Y (float): requested Y axis final position + # - Z (float): requested Z axis final position + # + # Returns: NONE + # + # Raises: NONE + def moveAbsolute( self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None ): + _logger.debug('Called moveAbsolute') + try: + # check if machine has been homed fully + if( self.isHomed() is False ): + raise HomingException("Machine axes have not been homed properly.") + + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + except HomingException as he: + _logger.error( he ) + except Exception as e: + errorString = " move failed to absolute coordinates: (" + if( X is not None ): + errorString += " X" + str(X) + if( Y is not None ): + errorString += " Y" + str(Y) + if( Z is not None ): + errorString += " Z" + str(Z) + errorString += ") at speed: " + str(moveSpeed) + _logger.critical(errorString + str(e) ) + raise SystemExit(errorString + "\n" + str(e)) + return + + ################################################################################################################################# + # Limit machine movement to within predefined boundaries as per machine-specific configuration. + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def limitAxes( self ): + _logger.debug('Called limitAxes') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + _logger.debug("Axes limits enforced successfully.") + except Exception as e: + _logger.error("Failed to limit axes movement: " + str(e)) + raise SystemExit("Failed to limit axes movement: " + str(e)) + return + + ################################################################################################################################# + # Flush controller movement buffer + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def flushMovementBuffer( self ): + _logger.debug('Called flushMovementBuffer') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + + _logger.debug("flushMovementBuffer ran successfully.") + except Exception as e: + _logger.error("Failed to flush movement buffer: " + str(e)) + raise SystemExit("Failed to flush movement buffer: " + str(e)) + return + + ################################################################################################################################# + # Save tool offsets to "firmware" + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def saveOffsetsToFirmware( self ): + _logger.debug('Called saveOffsetsToFirmware') + try: + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + _logger.debug("Tool offsets saved to firmware.") + except Exception as e: + _logger.error("Failed to save offsets: " + str(e)) + raise SystemExit("Failed to save offsets: " + str(e)) + return + + ################################################################################################################################# + ################################################################################################################################# + # Core class functions + # + # These functions handle sending gcode commands to your controller: + # - gCode: send a single line of gcode + # - gCodeBatch: send an array of gcode strings to your controller and execute them sequentially + + def gCode(self,command): + _logger.debug('gCode called') + ##############*** YOUR CUSTOM CODE ################# + ##############*** YOUR CUSTOM CODE ################# + if (ok): + return 0 + else: + _logger.error("Error running gCode command") + raise SystemExit("Error running gCode command") + return -1 + + def gCodeBatch(self,commands): + _logger.debug('gCode called') + ##############*** YOUR CUSTOM CODE ################# + for command in commands: + self.gCode(command) + ##############*** YOUR CUSTOM CODE ################# + if (ok): + return 0 + else: + _logger.error("Error running gCode command") + raise SystemExit("Error running gCode command") + return -1 + + ### DO NOT EDIT BEYOND THIS LINE ### + ################################################################################################################################# + # Output JSON representation of printer + # + # Parameters: NONE + # + # Returns: JSON object for printer class + # + # Raises: NONE + def getJSON( self ): + printerJSON = { + 'address': self._base_url, + 'password': self._password, + 'name': self._name, + 'nickname': self._nickname, + 'controller': self._firmwareName, + 'firmware': self._firmwareVersion, + 'tools': [] + } + for i, currentTool in enumerate(self._tools): + printerJSON['tools'].append(currentTool.getJSON()) + return( printerJSON ) + + ################################################################################################################################# + ################################################################################################################################# + # ZTATP Core atomic class functions + # + # These are critical functions used by ZTATP to set up probes, check for odd RRF versions that have unique syntax requirements, + # and are use to set/rest config file changes for the endstops to ensure correct operation of the ZTATP alignment scripts. + + def getFilenamed(self,filename): + if (self.pt == 2): + URL=(f'{self._base_url}'+'/rr_download?name='+filename) + if (self.pt == 3): + URL=(f'{self._base_url}'+'/machine/file/'+filename) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) ) + return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' ')) + + def checkDuet2RRF3(self): + if (self.pt == 2): + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) ) + j = json.loads(r.text) + s=j['firmwareVersion'] + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout) ) + + if s == "3.2": + return True + else: + return False + + ################################################################################################################################# + # The following methods provide services built on the ZTATP Core atomimc class functions. + + # _nilEndStop + # Given a line from config g that defines an endstop (N574) or Z probe (M558), + # Return a line that will define the same thing to a "nil" pin, i.e. undefine it + def _nilEndstop(self,configLine): + ret = '' + for each in [word for word in configLine.split()]: + ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' ' + return(ret) + + def clearEndstops(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: + commandBuffer.append(self._nilEndstop(each)) + self.gCodeBatch(commandBuffer) + + def resetEndstops(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: + commandBuffer.append(self._nilEndstop(each)) + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetAxisLimits(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if 'M208 ' in line]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetG10(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if 'G10 ' in line]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetAdvancedMovement(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def getTriggerHeight(self): + _errCode = 0 + _errMsg = '' + triggerHeight = 0 + if (self.pt == 2): + if not self._rrf2: + #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking + sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap') + r = self.session.get(sessionURL, timeout=(self._requestTimeout,self._responseTimeout) ) + rawdata = r.json() + rawdata = json.dumps(rawdata) + _logger.debug( 'Response from connect: ' + rawdata ) + buffer_size = 0 + # while buffer_size < 150: + # bufferURL = (f'{self._base_url}'+'/rr_gcode') + # buffer_request = self.session.get(bufferURL, timeout=(self._requestTimeout,self._responseTimeout) ) + # try: + # buffer_response = buffer_request.json() + # buffer_size = int(buffer_response['buff']) + # except: + # buffer_size = 149 + # if buffer_size < 150: + # _logger.debug('Buffer low - adding 0.6s delay before next call: ' + str(buffer_size)) + # time.sleep(0.6) + URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) ) + replyURL = (f'{self._base_url}'+'/rr_reply') + reply = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout) ) + # Reply is of the format: + # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0" + start = reply.find('trigger height') + triggerHeight = reply[start+15:] + triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) + if not self._rrf2: + #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking + endsessionURL = (f'{self._base_url}'+'/rr_disconnect') + r2 = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout) ) + if (self.pt == 3): + URL=(f'{self._base_url}'+'/machine/code/') + r = self.requests.post(URL, data='G31') + # Reply is of the format: + # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0" + reply = r.text + start = reply.find('trigger height') + triggerHeight = reply[start+15:] + triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) + if (r.ok): + return (_errCode, _errMsg, triggerHeight ) + else: + _errCode = float(r.status_code) + _errMsg = r.reason + _logger.error("Bad resposne in getTriggerHeight: " + str(r.status_code) + ' - ' + str(r.reason)) + return (_errCode, _errMsg, None ) + + ################################################################################################################################# +################################################################################################################################# +# Exception Classes +# Do not change this +class Error(Exception): + """Base class for other exceptions""" + pass +class UnknownController(Error): + pass +class FailedToolDetection(Error): + pass +class FailedOffsetCapture(Error): + pass +class StatusException(Error): + pass +class CoordinatesException(Error): + pass +class SetOffsetException(Error): + pass +class ToolTimeoutException(Error): + pass +class HomingException(Error): + pass + +################################################################################################################################# +################################################################################################################################# +# helper class for tool definition +# Do not change this +class Tool: + # class attributes + _number = 0 + _name = "Tool" + _nozzleSize = 0.4 + _offsets = {"X":0, "Y": 0, "Z": 0} + + def __init__( self, number=0, name="Tool", nozzleSize=0.4, offsets={"X":0, "Y": 0, "Z": 0} ): + self._number = number + self._name = name + self._nozzleSize = nozzleSize + self._offsets = offsets + + def getJSON( self ): + return( { + "number": self._number, + "name": self._name, + "nozzleSize": self._nozzleSize, + "offsets": [ self._offsets["X"], self._offsets["Y"], self._offsets["Z"] ] + } ) \ No newline at end of file diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py new file mode 100644 index 0000000..e0ab127 --- /dev/null +++ b/drivers/DuetWebAPI.py @@ -0,0 +1,1278 @@ +# Python Script containing a class to send commands to, and query specific information from, +# Duet based printers running either Duet RepRap V2 or V3 firmware. +# +# Does NOT hold open the connection. Use for low-volume requests. +# Does NOT, at this time, support Duet passwords. +# +# Not intended to be a gerneral purpose interface; instead, it contains methods +# to issue commands or return specific information. Feel free to extend with new +# methods for other information; please keep the abstraction for V2 V3 +# +# Copyright (C) 2020 Danal Estes all rights reserved. +# Copyright (C) 2022 Haytham Bennani +# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT +# +# Requires Python3 +# from csv import excel_tab +# from string import lower +from http.client import * + + +import logging +import requests +from requests.adapters import HTTPAdapter +from urllib3.connection import ConnectTimeoutError +from urllib3.util.retry import Retry +from urllib3.exceptions import * +# import dependencies +import json +import time + +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.DuetWebAPI') +# # enable HTTP requests logging +# import http.client +# http.client.HTTPConnection.debuglevel = 1 + +################################################################################################################################# +################################################################################################################################# +# Exception Classes +class Error(Exception): + """Base class for other exceptions""" + pass +class UnknownController(Error): + pass +class FailedToolDetection(Error): + pass +class FailedOffsetCapture(Error): + pass +class StatusException(Error): + pass +class CoordinatesException(Error): + pass +class SetOffsetException(Error): + pass +class ToolTimeoutException(Error): + pass +class HomingException(Error): + pass + +################################################################################################################################# +################################################################################################################################# +# helper class for tool definition +class Tool: + # class attributes + _number = 0 + _name = "Tool" + _nozzleSize = 0.4 + _offsets = {"X":0, "Y": 0, "Z": 0} + + def __init__(self, number=0, name="Tool", nozzleSize=0.4, offsets={"X":0, "Y": 0, "Z": 0}): + self._number = number + self._name = name + self._nozzleSize = nozzleSize + self._offsets = offsets + + def getJSON(self): + return({ + "number": self._number, + "name": self._name, + "nozzleSize": self._nozzleSize, + "offsets": [ self._offsets["X"], self._offsets["Y"], self._offsets["Z"] ] + }) + +################################################################################################################################# +################################################################################################################################# +# Main class for interface +class printerAPI: + # Any unhandled/general exceptions raised will cause a system exit to prevent machine damage. + + ################################################################################################################################# + ################################################################################################################################# + # Class attributes + # Duet hardware version (Duet 2 or Duet 3 controller) + pt = 0 + # base URL to connect to machine + _base_url = '' + # Machine-specific attributes + _nickname = 'Default' + _firmwareName = 'RRF' + _firmwareVersion = '' + _password = 'reprap' + # special internal flag to handle Duet 2 boards running RRF v3 firmware - only needed for Duet controllers + # you may delete this variable if extending API to other boards + _rrf2 = False + # Max time to wait for toolchange before raising a timeout exception, in seconds + _toolTimeout = 300 + # Max time to wait for HTTP requests to complete + _requestTimeout = 2 + _responseTimeout = 5 + # flag to indicate if machine has been homed or not + _homed = None + # Tools + _tools = [] + + ################################################################################################################################# + # Instantiate class and connect to controller + # Parameters: + # - baseURL (string): full IP address (not FQDN or alias) in the format 'http://xxx.xxx.xxx.xxx' without trailing '/' + # - nickname (string): short nickname for identifying machine (strictly for TAMV GUI) + # + # Returns: NONE + # + # Raises: + # - UnknownController: if fails to connect + def __init__(self, baseURL, nickname='Default', password='reprap'): + _logger.debug('Starting DuetWebAPI..') + # parse input parameters + self._base_url = baseURL + self.password = password + self._nickname = nickname + self._tools = [] + # Name as defined in RRF config.g file + self._name = 'My Duet' + # set up session parameters + self.session = requests.Session() + self.retry = Retry(connect=3, backoff_factor=0.4) + self.adapter = HTTPAdapter(max_retries=self.retry) + self.session.mount('http://', self.adapter) + try: + # check if its a Duet 2 board + # Set up session using password + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + # Get machine name + self._name = j['name'] + # Setup tool definitions + toolData = j['tools'] + for inputTool in toolData: + tempTool = Tool( + number = inputTool['number'], + name = inputTool['name'], + offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]}) + self._tools.append(tempTool) + _logger.debug('Added tool: ' + str(tempTool.getJSON())) + + # Check for firmware version + try: + firmwareName = j['firmwareName'] + # fetch hardware board type from firmware name, character 24 + boardVersion = firmwareName[24] + self._firmwareVersion = j['firmwareVersion'] + # set RRF version based on results + if self._firmwareVersion[0] == "2": + # Duet running RRF v2 + self._rrf2 = True + self.pt = 2 + else: + # Duet 2 hardware running RRF v3 + self._rrf2 = False + self.pt = 2 + _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..') + return + except: + # We're probably dealing with a Duet 3 controller, get required firmware info + try: + _logger.debug('Trying to connect to Duet 3 board..') + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + _logger.debug('Got reply, parsing again..') + j = json.loads(r.text) + _=j + firmwareName = j['boards'][0]['firmwareName'] + firmwareVersion = j['boards'][0]['firmwareVersion'] + self.pt = 3 + + # Setup tool definitions + toolData = j['tools'] + for inputTool in toolData: + tempTool = Tool( + number = inputTool['number'], + name = inputTool['name'], + offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]}) + self._tools.append(tempTool) + + _logger.debug('Duet 3 board detected') + _logger.info(' .. connected to: '+ firmwareName + '- V'+firmwareVersion + '..') + return + except: + # The board is neither a Duet 2 controller using RRF v2/3 nor a Duet 3 controller board, return an error state + raise UnknownController('Unknown controller detected.') + except UnknownController as uc: + errorMsg = 'Unknown controller at " + self._base_url + " - does not appear to be an RRF2 or RRF3 printer' + _logger.error(errorMsg) + raise SystemExit(errorMsg) + except requests.exceptions.ConnectTimeout: + errorMsg = 'Connect operation: Connection timed out.' + _logger.critical(errorMsg) + raise Exception(errorMsg) + except HTTPException as ht: + _logger.error('DuetWebAPIT init: Connection error.') + except Exception as e: + # Catastrophic error. Bail. + _logger.critical('DuetWebAPI Init: ' + str(e)) + raise Exception('DuetWebAPI Init: ' + str(e)) + + ################################################################################################################################# + # Get firmware version + # Parameters: + # - NONE + # + # Returns: integer + # - returns either 2 or 3 depending on which RRF version is running on the controller + # + # Raises: NONE + def getPrinterType(self): + _logger.debug('Called getPrinterType') + return(self.pt) + + ################################################################################################################################# + # Get number of defined tools from machine + # Parameters: + # - NONE + # + # Returns: integer + # - Positive integer for number of defined tools on machine + # + # Raises: + # - FailedToolDetection: when cannot determine number of tools on machine + def getNumTools(self): + _logger.debug('Called getNumTools') + + # try: + # if (self.pt == 2): + # # Duet RRF v2 + # URL=(f'{self._base_url}'+'/rr_status?type=2') + # r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # j = json.loads(r.text) + # jc=j['tools'] + # _logger.debug('Number of tools: ' + str(len(jc))) + + # # Send reply to clear buffer + # replyURL = (f'{self._base_url}'+'/rr_reply') + # r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + # return(len(jc)) + # elif (self.pt == 3): + # # Duet RRF v3 + # URL=(f'{self._base_url}'+'/machine/status') + # r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # j = json.loads(r.text) + # if 'result' in j: j = j['result'] + # _logger.debug('Number of tools: ' + str(len(j['tools']))) + # return(len(j['tools'])) + # # failed to get tool data, raise exception + # raise FailedToolDetection('Cannot determine number of tools on machine') + # except FailedToolDetection as ft: + # _logger.critical('Failed to detect number of tools on machine') + # raise Exception(ft) + # except Exception as e: + # _logger.critical("Connection error: " + str(e)) + # raise Exception(e) + return(len(self._tools)) + + ################################################################################################################################# + # Get index of currently loaded tool + # Tool numbering always starts as 0, 1, 2, .. + # Parameters: + # - NONE + # + # Returns: integer + # - Positive integer for index of current loaded tool + # - '-1' if no tool is loaded on the machine + # + # Raises: + # - FailedToolDetection: when cannot determine number of tools on machine + def getCurrentTool(self): + _logger.debug('Called getCurrentTool') + try: + if (self.pt == 2): + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + # Wait for machine to be in idle state + while self.getStatus() not in "idle": + _logger.debug('Machine not idle, sleeping 0.5 seconds.') + time.sleep(0.5) + # Fetch machine data + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + ret=j['currentTool'] + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + _logger.debug('Found current tool: ' + str(ret)) + return(ret) + elif (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + if 'result' in j: j = j['result'] + ret=j['state']['currentTool'] + _logger.debug('Found current tool: ' + str(ret)) + return(ret) + else: + # Unknown condition, raise error + raise FailedToolDetection('Something failed. Baililng.') + except ConnectTimeoutError: + errorMsg = 'getCurrentTool: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except ConnectionError as ce: + _logger.critical('Connection error while polling for current tool') + raise Exception(ce) + except FailedToolDetection as fd: + _logger.critical('Failed tool detection.') + raise Exception(e1) + except Exception as e1: + _logger.critical('Unhandled exception in getCurrentTool: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Get currently defined offsets for tool referenced by index + # Tool numbering always starts as 0, 1, 2, .. + # Parameters: + # - toolIndex (integer): index of tool to get offsets for + # + # Returns: + # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 } + # + # Raises: + # - FailedOffsetCapture: when cannot determine number of tools on machine + def getToolOffset(self, toolIndex=0): + _logger.debug('Called getToolOffset: ' + str(toolIndex)) + try: + if (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + if 'result' in j: j = j['result'] + ja=j['move']['axes'] + jt=j['tools'] + ret=json.loads('{}') + to = jt[toolIndex]['offsets'] + for i in range(0,len(to)): + ret[ ja[i]['letter'] ] = to[i] + _logger.debug('Tool offset for T' + str(toolIndex) +': ' + str(ret)) + return(ret) + elif (self.pt == 2): + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + ja=j['axisNames'] + jt=j['tools'] + ret=json.loads('{}') + for currentTool in jt: + if(currentTool['number'] == int(toolIndex)): + ret['X'] = currentTool['offsets'][0] + ret['Y'] = currentTool['offsets'][1] + ret['Z'] = currentTool['offsets'][2] + ret['U'] = currentTool['offsets'][3] + else: + continue + # to = jt[toolIndex]['offsets'] + # for i in range(0,len(to)): + # ret[ ja[i] ] = to[i] + _logger.debug('Tool offset for T' + str(toolIndex) +': ' + str(ret)) + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + return(ret) + else: + raise FailedOffsetCapture('getG10ToolOffset entered unhandled exception state.') + except ConnectTimeoutError: + errorMsg = 'getToolOffset: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except FailedOffsetCapture as fd: + _logger.critical('DuetWebAPI getToolOffset: ' + str(fd)) + raise Exception(fd) + except ConnectionError as ce: + _logger.critical('Connection error in getToolOffset.') + raise Exception(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getToolOffset: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Get machine status, mapping any controller status output into 1 of 3 possible states + # Parameters: + # - NONE + # + # Returns: string of following values ONLY + # - idle + # - processing + # - paused + # + # Raises: + # - StatusException: when cannot determine machine status + # - StatusTimeoutException: when machine takes longer than _toolTimeout seconds to respond + def getStatus(self): + _logger.debug('Called getStatus') + try: + if (self.pt == 2): + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + _status=j['status'] + elif (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + if 'result' in j: + j = j['result'] + _status = str(j['state']['status']) + _status = _status.lower() + else: + # unknown error raise exception + raise StatusException('Unknown error getting machine status') + + # Send reply to clear buffer + # replyURL = (f'{self._base_url}'+'/rr_reply') + # r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + # OUTPUT MAPPING LOGIC + # Handle return mapping of status variable "_status" + if (_status == "idle" or _status == "I"): + _logger.debug("Machine is idle.") + return ("idle") + elif (_status == "paused" or _status == "S" or _status == "pausing" or _status == "D"): + _logger.debug("Machine is paused.") + return ("paused") + else: + _logger.debug("Machine is busy processing something.") + return ("processing") + except ConnectTimeoutError: + errorMsg = 'getStatus: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except StatusException as se: + _logger.critical('DuetWebAPI getStatus: ' + str(se)) + raise Exception(se) + except ConnectionError as ce: + _logger.critical('Connection error in getStatus') + raise Exception(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getStatus: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Get current tool coordinates from machine in XYZ space + # Parameters: + # - NONE + # + # Returns: + # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 } + # + # Raises: + # - CoordinatesException: when cannot determine machine status + def getCoordinates(self): + _logger.debug('*** Called getCoordinates') + try: + if (self.pt == 2): + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + # poll machine for coordinates + while self.getStatus() not in "idle": + _logger.debug('moveAbsolute: sleeping 0.3 seconds..') + time.sleep(0.3) + + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + if not r.ok: + raise ConnectionError('Error in getCoordinates session 2: ' + str(r)) + j = json.loads(r.text) + jc=j['coords']['xyz'] + an=j['axisNames'] + ret=json.loads('{}') + for i in range(0,len(jc)): + ret[ an[i] ] = jc[i] + + # Send reply to clear buffer + #replyURL = (f'{self._base_url}'+'/rr_reply') + #r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + _logger.debug('*** exiting getCoordinates') + return(ret) + elif (self.pt == 3): + while self.getStatus() not in "idle": + _logger.debug('XX - printer not idle _SLEEPING_') + time.sleep(0.5) + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + if not r.ok: + raise ConnectionError('Error in getCoordinates session 3: ' + str(r)) + j = json.loads(r.text) + if 'result' in j: j = j['result'] + ja=j['move']['axes'] + ret=json.loads('{}') + for i in range(0,len(ja)): + ret[ ja[i]['letter'] ] = ja[i]['userPosition'] + _logger.debug('Returning coordinates: ' + str(ret)) + _logger.debug('*** exiting getCoordinates') + return(ret) + else: + #unknown error, raise exception + raise CoordinatesException("Unknown duet controller.") + except ConnectTimeoutError: + errorMsg = 'getCoordinates: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except CoordinatesException as ce1: + _logger.critical('DuetWebAPI getCoordinates: ' + str(ce1)) + raise Exception(ce1) + except ConnectionError as ce: + _logger.critical('Connection error in getCoordinates') + raise Exception(ce) + except Exception as e1: + _logger.critical('Unhandled exception in getCoordinates: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Set tool offsets for indexed tool in X, Y, and Z + # Parameters: + # - toolIndex (integer): + # - offsetX (float): + # - offsetY (float): + # - offsetZ (float): + # + # Returns: NONE + # + # Raises: + # - SetOffsetException: when failed to set offsets in controller + def setToolOffsets(self, tool=None, X=None, Y=None, Z=None): + _logger.debug('Called setToolOffsets: ' + str(tool)) + try: + # Check for invalid tool index, raise exception if needed. + if(tool is None): + raise SetOffsetException("No tool index provided.") + # Check that any valid offset has been passed as an argument + elif(X is None and Y is None and Z is None): + raise SetOffsetException("Invalid offsets provided.") + else: + offsetCommand = "G10 P" + str(int(tool)) + if(X is not None): + offsetCommand += " X" + str(X) + if(Y is not None): + offsetCommand += " Y" + str(Y) + if(Z is not None): + offsetCommand += " Z" + str(Z) + _logger.debug(offsetCommand) + self.gCode(offsetCommand) + _logger.debug("Tool offsets applied.") + except ConnectTimeoutError: + errorMsg = 'setToolOffsets: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except SetOffsetException as se: + _logger.error('DuetWebAPI setToolOffsets: ' + str(se)) + return + except Exception as e: + _logger.critical("setToolOffsets unhandled exception: " + str(e)) + raise Exception("setToolOffsets unhandled exception: " + str(e)) + + ################################################################################################################################# + # Helper function to check if machine is idle or not + # Parameters: NONE + # + # Returns: boolean + def isIdle(self): + _logger.debug("Called isIdle") + state = self.getStatus() + if(state == "idle"): + return True + else: + return False + + ################################################################################################################################# + # Helper function to check if machine is homed on all axes for motion + # Parameters: NONE + # + # Returns: boolean + def isHomed(self): + _logger.debug("Called isHomed") + if(self._homed is not None): + return self._homed + machineHomed = True + try: + if (self.pt == 2): + # Duet RRF v2 + + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + axesList=j['coords']['axesHomed'] + for axis in axesList: + if(axis == 0): + machineHomed = False + else: + pass + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + elif (self.pt == 3): + # Duet RRF v3 + + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + if 'result' in j: j = j['result'] + _logger.debug('Number of tools: ' + str(len(j['tools']))) + self._homed = machineHomed + return(self._homed) + except ConnectTimeoutError: + errorMsg = 'isHomed: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except Exception as e: + _logger.critical("Failed to check if machine is homed. " + str(e)) + raise Exception("Failed to check if machine is homed. " + str(e)) + + ################################################################################################################################# + # Load specified tool on machine, and wait until machine is idle + # Tool numbering always starts as 0, 1, 2, .. + # If the toolchange takes longer than the class attribute _toolTimeout, then raise a warning in the log and return. + # + # ATTENTION: + # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs. + # You may change this behavior by modifying the exception handling for ToolTimeoutException. + # + # Parameters: + # - toolIndex (integer): index of tool to load + # + # Returns: NONE + # + # Raises: + # - ToolTimeoutException: machine took too long to load the tool + def loadTool(self, toolIndex = 0): + _logger.debug('Called loadTool: ' + str(toolIndex)) + # variable to hold current tool loading "virtual" timer + toolchangeTimer = 0 + try: + # Send command to controller to load tool specified by parameter + _requestedTool = int(toolIndex) + self.gCode("T" + str(_requestedTool)) + # Wait until machine is done loading tool and is idle + while not self.isIdle() and toolchangeTimer <= self._toolTimeout: + self._toolTimeout += 2 + time.sleep(2) + if(toolchangeTimer > self._toolTimeout): + # Request for toolchange timeout, raise exception + raise ToolTimeoutException("Request to change to tool T" + str(toolIndex) + " timed out.") + return + except ConnectTimeoutError: + errorMsg = 'loadTool: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except ToolTimeoutException as tte: + _logger.warning(str(tte)) + return + except ConnectionError as ce: + _logger.critical('Connection error in loadTool.') + raise Exception(ce) + except Exception as e1: + _logger.critical('Unhandled exception in loadTool: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Unload all tools from machine and wait until machine is idle + # Tool numbering always starts as 0, 1, 2, .. + # If the unload operation takes longer than the class attribute _toolTimeout, then raise a warning in the log and return. + # + # ATTENTION: + # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs. + # You may change this behavior by modifying the exception handling for ToolTimeoutException. + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: + # - ToolTimeoutException: machine took too long to load the tool + def unloadTools(self): + _logger.debug('Called unloadTools') + # variable to hold current tool loading "virtual" timer + toolchangeTimer = 0 + try: + # Send command to controller to unload all tools + self.gCode("T-1") + # Wait until machine is done loading tool and is idle + while not self.isIdle() and toolchangeTimer <= self._toolTimeout: + self._toolTimeout += 2 + time.sleep(2) + if(toolchangeTimer > self._toolTimeout): + # Request for toolchange timeout, raise exception + raise ToolTimeoutException("Request to unload tools timed out!") + return + except ConnectTimeoutError: + errorMsg = 'unloadTools: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except ToolTimeoutException as tte: + _logger.warning(str(tte)) + return + except ConnectionError as ce: + _logger.critical('Connection error in unloadTools') + raise Exception(ce) + except Exception as e1: + _logger.critical('Unhandled exception in unloadTools: ' + str(e1)) + raise Exception(e1) + + ################################################################################################################################# + # Execute a relative positioning move (G91 in Duet Gcode), and return to absolute positioning. + # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min. + # + # Parameters: + # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode) + # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min) + # - X (float): requested X axis final position + # - Y (float): requested Y axis final position + # - Z (float): requested Z axis final position + # + # Returns: NONE + # + # Raises: + # - HomingException: machine is not homed + def moveRelative(self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None): + _logger.debug('Called moveRelative') + try: + # check if machine has been homed fully + if(self.isHomed() is False): + raise HomingException("Machine axes have not been homed properly.") + # Create gcode command, starting with rapid flag (G0 / G1) + if(rapidMove is True): + moveCommand = "G91 G0 " + else: + moveCommand = "G91 G1 " + # Add each axis position according to passed arguments + if(X is not None): + moveCommand += " X" + str(X) + if(Y is not None): + moveCommand += " Y" + str(Y) + if(Z is not None): + moveCommand += " Z" + str(Z) + + # Add move speed to command + moveCommand += " F" + str(moveSpeed) + # Add a return to absolute positioning to finish the command string creation + moveCommand += " G90" + _logger.debug(moveCommand) + # Send command to machine + self.gCode(moveCommand) + while self.getStatus() not in "idle": + _logger.debug('moveRelative: sleeping 0.3 seconds..') + time.sleep(0.3) + except ConnectTimeoutError: + errorMsg = 'moveRelative: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except HomingException as he: + _logger.error(he) + except Exception as e: + if(rapidMove is True): + errorString = "G0 rapid " + else: + errorString = "G1 linear " + errorString += " move failed to relative coordinates: (" + if(X is not None): + errorString += " X" + str(X) + if(Y is not None): + errorString += " Y" + str(Y) + if(Z is not None): + errorString += " Z" + str(Z) + errorString += ") at speed: " + str(moveSpeed) + _logger.critical('DuetWebAPI moveRelative: ' + errorString) + raise Exception(errorString + "\n" + str(e)) + return + + ################################################################################################################################# + # Execute an absolute positioning move (G90 in Duet Gcode), and return to absolute positioning. + # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min. + # + # Parameters: + # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode) + # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min) + # - X (float): requested X axis final position + # - Y (float): requested Y axis final position + # - Z (float): requested Z axis final position + # + # Returns: NONE + # + # Raises: NONE + def moveAbsolute(self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None): + _logger.debug('Called moveAbsolute') + try: + # check if machine has been homed fully + if(self.isHomed() is False): + raise HomingException("Machine axes have not been homed properly.") + # Create gcode command, starting with rapid flag (G0 / G1) + if(rapidMove is True): + moveCommand = "G90 G0" + else: + moveCommand = "G90 G1" + # Add each axis position according to passed arguments + if(X is not None): + moveCommand += " X" + str(X) + if(Y is not None): + moveCommand += " Y" + str(Y) + if(Z is not None): + moveCommand += " Z" + str(Z) + + # Add move speed to command + moveCommand += " F" + str(moveSpeed) + # Add a return to absolute positioning to finish the command string creation + moveCommand += " G90" + _logger.debug(moveCommand) + # Send command to machine + self.gCode(moveCommand) + while self.getStatus() not in "idle": + _logger.debug('moveAbsolute: sleeping 0.3 seconds..') + time.sleep(0.3) + except ConnectTimeoutError: + errorMsg = 'moveAbsolute: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except HomingException as he: + _logger.error(he) + except Exception as e: + if(rapidMove is True): + errorString = "G0 rapid " + else: + errorString = "G1 linear " + errorString += " move failed to absolute coordinates: (" + if(X is not None): + errorString += " X" + str(X) + if(Y is not None): + errorString += " Y" + str(Y) + if(Z is not None): + errorString += " Z" + str(Z) + errorString += ") at speed: " + str(moveSpeed) + _logger.critical('DuetWebAPI moveAbsolute: ' + errorString + str(e)) + raise Exception(errorString + "\n" + str(e)) + return + + ################################################################################################################################# + # Limit machine movement to within predefined boundaries as per machine-specific configuration. + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def limitAxes(self): + _logger.debug('Called limitAxes') + try: + self.gCode("M561 S1") + _logger.debug("Axes limits enforced successfully.") + except ConnectTimeoutError: + errorMsg = 'limitAxes: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except Exception as e: + _logger.error("Failed to limit axes movement") + raise Exception("Failed to limit axes movement") + return + + ################################################################################################################################# + # Flush controller movement buffer + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def flushMovementBuffer(self): + _logger.debug('Called flushMovementBuffer') + try: + self.gCode("M400") + _logger.debug("flushMovementBuffer ran successfully.") + except ConnectTimeoutError: + errorMsg = 'flushBuffer: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except Exception as e: + _logger.error("Failed to flush movement buffer.") + raise Exception("Failed to flush movement buffer") + return + + ################################################################################################################################# + # Save tool offsets to "firmware" + # + # Parameters: NONE + # + # Returns: NONE + # + # Raises: NONE + def saveOffsetsToFirmware(self): + _logger.debug('Called saveOffsetsToFirmware') + try: + self.gCode("M500 P10") + _logger.debug("Tool offsets saved to firmware.") + except ConnectTimeoutError: + errorMsg = 'saveOffsetsToFirmware: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except Exception as e: + _logger.error("Failed to save offsets: " + str(e)) + raise Exception("Failed to save offsets: " + str(e)) + return + + ################################################################################################################################# + # Output JSON representation of printer + # + # Parameters: NONE + # + # Returns: JSON object for printer class + # + # Raises: NONE + def getJSON(self): + printerJSON = { + 'address': self._base_url, + 'password': self._password, + 'name': self._name, + 'nickname': self._nickname, + 'controller': self._firmwareName, + 'version': self._firmwareVersion, + 'tools': [] + } + for i, currentTool in enumerate(self._tools): + printerJSON['tools'].append(currentTool.getJSON()) + + return(printerJSON) + + ################################################################################################################################# + ################################################################################################################################# + # Core class functions + # + # These functions handle sending gcode commands to your controller: + # - gCode: send a single line of gcode + # - gCodeBatch: send an array of gcode strings to your controller and execute them sequentially + + def gCode(self,command): + _logger.debug('gCode called: ' + command) + try: + if (self.pt == 2): + # Start a connection + if(self._password != "reprap"): + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r2 = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + elif (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/code/') + r = self.requests.post(URL, data=command) + if (r.ok): + return 0 + else: + _logger.error("Error running gCode command: return code " + str(r.status_code) + ' - ' + str(r.reason)) + raise Exception("Error running gCode command: return code " + str(r.status_code) + ' - ' + str(r.reason)) + except ConnectTimeoutError: + errorMsg = 'gCode: Connection timed out.' + _logger.error(errorMsg) + raise Exception(errorMsg) + except SystemExit: + errorMsg = 'Failure to run gcode' + _logger.error(errorMsg) + raise Exception(errorMsg) + finally: + return -1 + + def gCodeBatch(self,commands): + if(self.pt == 2): + # Start a connection + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + else: + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + for command in commands: + if (self.pt == 2): + URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + if (self.pt == 3): + URL=(f'{self._base_url}'+'/machine/code/') + r = self.requests.post(URL, data=command) + if not (r.ok): + _logger.Error("Error in gCodeBatch command: " + str(r.status_code) + str(r.reason)) + + if(self.pt == 2): + #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking + endsessionURL = (f'{self._base_url}'+'/rr_disconnect') + r = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout)) + + ################################################################################################################################# + ################################################################################################################################# + # ZTATP Core atomimc class functions + # + # These are critical functions used by ZTATP to set up probes, check for odd RRF versions that have unique syntax requirements, + # and are use to set/rest config file changes for the endstops to ensure correct operation of the ZTATP alignment scripts. + + def getFilenamed(self,filename): + if (self.pt == 2): + # Start a connection + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_download?name='+filename) + + if (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/file/'+filename) + + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' ')) + + def checkDuet2RRF3(self): + if (self.pt == 2): + # Start a connection + _logger.debug('Starting DuetWebAPI session..') + URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + + URL=(f'{self._base_url}'+'/rr_status?type=2') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + j = json.loads(r.text) + s=j['firmwareVersion'] + + # Send reply to clear buffer + replyURL = (f'{self._base_url}'+'/rr_reply') + r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + + if s == "3.2": + return True + else: + return False + + ################################################################################################################################# + # The following methods provide services built on the ZTATP Core atomimc class functions. + + # _nilEndStop + # Given a line from config g that defines an endstop (N574) or Z probe (M558), + # Return a line that will define the same thing to a "nil" pin, i.e. undefine it + def _nilEndstop(self,configLine): + ret = '' + for each in [word for word in configLine.split()]: + ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' ' + return(ret) + + def clearEndstops(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: + commandBuffer.append(self._nilEndstop(each)) + self.gCodeBatch(commandBuffer) + + def resetEndstops(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]: + commandBuffer.append(self._nilEndstop(each)) + for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetAxisLimits(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if 'M208 ' in line]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetG10(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if 'G10 ' in line]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def resetAdvancedMovement(self): + c = self.getFilenamed('/sys/config.g') + commandBuffer = [] + for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]: + commandBuffer.append(each) + self.gCodeBatch(commandBuffer) + + def getTriggerHeight(self): + _errCode = 0 + _errMsg = '' + triggerHeight = 0 + if (self.pt == 2): + if not self._rrf2: + #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking + _logger.debug('Starting DuetWebAPI session..') + sessionURL = (f'{self._base_url}'+'/rr_connect?password=' + self._password) + r = self.session.get(sessionURL, timeout=(self._requestTimeout,self._responseTimeout)) + rawdata = r.json() + rawdata = json.dumps(rawdata) + _logger.debug('Response from connect: ' + rawdata) + buffer_size = 0 + # while buffer_size < 150: + # bufferURL = (f'{self._base_url}'+'/rr_gcode') + # buffer_request = self.session.get(bufferURL, timeout=(self._requestTimeout,self._responseTimeout)) + # try: + # buffer_response = buffer_request.json() + # buffer_size = int(buffer_response['buff']) + # except: + # buffer_size = 149 + # if buffer_size < 150: + # _logger.debug('Buffer low - adding 0.6s delay before next call: ' + str(buffer_size)) + # time.sleep(0.6) + URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + replyURL = (f'{self._base_url}'+'/rr_reply') + reply = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) + # Reply is of the format: + # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0" + start = reply.find('trigger height') + triggerHeight = reply[start+15:] + triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) + if not self._rrf2: + #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking + endsessionURL = (f'{self._base_url}'+'/rr_disconnect') + r2 = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout)) + if (self.pt == 3): + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/code/') + r = self.requests.post(URL, data='G31') + # Reply is of the format: + # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0" + reply = r.text + start = reply.find('trigger height') + triggerHeight = reply[start+15:] + triggerHeight = float(triggerHeight[:triggerHeight.find(',')]) + if (r.ok): + return (_errCode, _errMsg, triggerHeight) + else: + _errCode = float(r.status_code) + _errMsg = r.reason + _logger.error("Bad resposne in getTriggerHeight: " + str(r.status_code) + ' - ' + str(r.reason)) + return (_errCode, _errMsg, None) + \ No newline at end of file diff --git a/install_opencv.sh b/install_opencv.sh deleted file mode 100755 index d0a8e81..0000000 --- a/install_opencv.sh +++ /dev/null @@ -1,22 +0,0 @@ -###################################### -# INSTALL OPENCV ON UBUNTU OR DEBIAN # -###################################### -sudo apt-get -y update -sudo apt-get -y upgrade -sudo apt-get -y dist-upgrade -sudo apt-get -y install python3-dev -sudo apt-get -y install pylint3 -sudo apt-get -y install python3-tk -sudo apt-get -y install python3-numpy -sudo apt-get -y install flake8 -sudo apt-get -y install python3-matplotlib -sudo apt-get -y install python3-pyqt5 -sudo apt-get -y install qtbase5-dev -sudo apt-get -y install curl -cd ~ -curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py -sudo python3 get-pip.py -rm get-pip.py -pip install imutils -sudo apt-get install python3-opencv -sudo apt-get install git diff --git a/modules/Camera.py b/modules/Camera.py new file mode 100644 index 0000000..c2fb2d0 --- /dev/null +++ b/modules/Camera.py @@ -0,0 +1,203 @@ +from distutils.log import debug, error +import logging +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.CaptureManager.Camera') + +import cv2 +import sys +from PyQt5 import QtCore + +class Camera(QtCore.QObject): + # class attributes + __videoSrc = 0 + __width = 640 + __height = 480 + __frame = None + __success = False + cap = None + + # init function + def __init__(self, *args, **kwargs): + super(Camera,self).__init__(parent=kwargs['parent']) + # send calling to log + _logger.debug('*** calling Camera.__init__') + # process arguments + try: + self.__videoSrc = kwargs['videoSrc'] + except KeyError: pass + # frame width + try: + self.__width= int(kwargs['width']) + except KeyError: pass + # frame height + try: + self.__height = int(kwargs['height']) + except KeyError: pass + + if sys.platform.startswith('linux'): # all Linux + self.backend = cv2.CAP_V4L + elif sys.platform.startswith('win'): # MS Windows + self.backend = cv2.CAP_DSHOW + elif sys.platform.startswith('darwin'): # macOS + self.backend = cv2.CAP_AVFOUNDATION + else: + self.backend = cv2.CAP_ANY # auto-detect via OpenCV + + try: + try: + self.cap = cv2.VideoCapture(self.__videoSrc, self.backend) + self.__success = self.cap.grab() + if(not self.__success): + raise Exception + except: + _logger.debug('Camera API primary exploded, using failover cv2.CAP_ANY') + try: + self.cap.release() + except: pass + self.cap = cv2.VideoCapture(self.__videoSrc, cv2.CAP_ANY) + self.__success = self.cap.grab() + if(not self.__success): + _logger.debug('Camera API failover exploded') + raise Exception + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) + # self.cap.setExceptionMode(enable=True) + _logger.debug('Active CV backend: ' + self.cap.getBackendName()) + _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') + try: + backends = cv2.videoio_registry.getCameraBackends() + debugMessage = 'Backend options:' + for backend in backends: + debugMessage += ' ' + cv2.videoio_registry.getBackendName(backend) +',' + debugMessage = debugMessage[:-1] + _logger.debug(debugMessage) + except: _logger.debug('Camera: cannot retrieve list of backends') + except: + errorMsg = 'Failed to start video source ' + str(self.__videoSrc) + ' @ ' + str(self.__width) + 'x' + str(self.__height) + _logger.exception(errorMsg) + raise SystemExit(errorMsg) + + try: + # get startup camera properties + try: + self.brightnessDefault = self.cap.get(cv2.CAP_PROP_BRIGHTNESS) + except: self.brightnessDefault = None + try: + self.contrastDefault = self.cap.get(cv2.CAP_PROP_CONTRAST) + except: self.contrastDefault = None + try: + self.saturationDefault = self.cap.get(cv2.CAP_PROP_SATURATION) + except: self.saturationDefault = None + try: + self.hueDefault = self.cap.get(cv2.CAP_PROP_HUE) + except: self.hueDefault = None + except: pass + + try: + self.__success, self.__frame = self.cap.read() + self.__imageSettings = {'default':1, 'brightness':self.brightnessDefault,'contrast':self.contrastDefault,'saturation':self.saturationDefault,'hue':self.hueDefault} + except Exception as e: + self.cap.release() + raise SystemExit + + # send exiting to log + _logger.debug('*** exiting Camera.__init__') + + def getCurrentImageSettings(self): + return self.__imageSettings + + def quit(self): + # send calling to log + _logger.debug('*** calling Camera.quit') + _logger.info(' .. closing camera..') + self.cap.release() + # send exiting to log + _logger.debug('*** exiting Camera.quit') + + def getFrame(self): + self.__success, self.__frame = self.cap.read() + if(self.__success): + return(self.__frame) + else: + self.cap.release() + raise Exception + + def getImagePropertiesJSON(self): + try: + brightness = self.cap.get(cv2.CAP_PROP_BRIGHTNESS) + except: brightness = None + try: + contrast = self.cap.get(cv2.CAP_PROP_CONTRAST) + except: contrast = None + try: + saturation = self.cap.get(cv2.CAP_PROP_SATURATION) + except: saturation = None + try: + hue = self.cap.get(cv2.CAP_PROP_HUE) + except: hue = None + try: + returnJSON = { + 'default': 0, + 'brightness': str(brightness), + 'contrast': str(contrast), + 'saturation': str(saturation), + 'hue': str(hue) + } + except: + errorMsg = 'Cannot create camera image properties object' + _logger.warning(errorMsg) + returnJSON = None + return(returnJSON) + + def getDefaultImagePropertiesJSON(self): + try: + returnJSON = { + 'default': 1, + 'brightness': str(self.brightnessDefault), + 'contrast': str(self.contrastDefault), + 'saturation': str(self.saturationDefault), + 'hue': str(self.hueDefault) + } + except: + errorMsg = 'Cannot create camera image default properties object' + _logger.warning(errorMsg) + returnJSON = None + return(returnJSON) + + def setImageProperties(self, imageProperties): + try: + brightness = self.cap.set(cv2.CAP_PROP_BRIGHTNESS, imageProperties['brightness']) + except KeyError: pass + except: + errorMsg = 'Failed to set image brightness.' + _logger.warning(errorMsg) + try: + contrast = self.cap.set(cv2.CAP_PROP_CONTRAST, imageProperties['contrast']) + except KeyError: pass + except: + errorMsg = 'Failed to set image contrast.' + _logger.warning(errorMsg) + try: + saturation = self.cap.set(cv2.CAP_PROP_SATURATION, imageProperties['saturation']) + except KeyError: pass + except: + errorMsg = 'Failed to set image saturation.' + _logger.warning(errorMsg) + try: + hue = self.cap.set(cv2.CAP_PROP_HUE, imageProperties['hue']) + except KeyError: pass + except: + errorMsg = 'Failed to set image hue.' + _logger.warning(errorMsg) + + def resetImageDefaults(self): + try: + self.cap.set(cv2.CAP_PROP_BRIGHTNESS, self.brightnessDefault) + self.cap.set(cv2.CAP_PROP_CONTRAST, self.contrastDefault) + self.cap.set(cv2.CAP_PROP_SATURATION, self.saturationDefault) + self.cap.set(cv2.CAP_PROP_HUE, self.hueDefault) + return(True) + except: + errorMsg = 'Cannot set image properties.' + _logger.warning(errorMsg) + return(False) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py new file mode 100644 index 0000000..d014400 --- /dev/null +++ b/modules/DetectionManager.py @@ -0,0 +1,552 @@ +import logging +from sys import stdout +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.DetectionManager') + +from modules.Camera import Camera + +import cv2 +import numpy as np +from PyQt5.QtGui import QPixmap, QImage +from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal +from time import sleep +import copy + +class DetectionManager(QObject): + # class attributes + __videoSource = None + __frameSize = {} + __enableDetection = False + __nozzleDetectionActive = False + __nozzleAutoDetectionActive = False + __endstopDetectionActive = False + __endstopAutomatedDetectionActive = False + __running = True + __uv = None + + # Signals + detectionManagerNewFrameSignal = pyqtSignal(object) + detectionManagerReadySignal = pyqtSignal(object) + detectionManagerImagePropertiesSignal = pyqtSignal(object) + detectionManagerDefaultImagePropertiesSignal = pyqtSignal() + detectionManagerSetImagePropertiesSignal = pyqtSignal(object) + detectionManagerResetImageSignal = pyqtSignal() + errorSignal = pyqtSignal(object) + finishedSignal = pyqtSignal() + + detectionManagerEndstopPosition = pyqtSignal(object) + detectionManagerAutoEndStopSignal = pyqtSignal(object) + detectionManagerArrayFrameSignal = pyqtSignal(object) + + ##### Setup functions + # init function + def __init__(self, *args, **kwargs): + # send calling to log + _logger.debug('*** calling DetectionManager.__init__') + super(DetectionManager, self).__init__(parent=kwargs['parent']) + try: + self.__videoSource = kwargs['videoSrc'] + except KeyError: + self.__videoSource = 0 + try: + self.__frameSize['width'] = kwargs['width'] + except KeyError: + self.__frameSize['width'] = 640 + try: + self.__frameSize['height'] = kwargs['height'] + except KeyError: + self.__frameSize['height'] = 480 + self.startCamera() + self.createDetectors() + self.processFrame() + self.uv = [None, None] + # send exiting to log + _logger.debug('*** exiting DetectionManager.__init__') + + def startCamera(self): + # send calling to log + _logger.debug('*** calling DetectionManager.startCamera') + try: + # Setup camera + self.videoFeed = Camera(videoSrc=self.__videoSource,width=self.__frameSize['width'],height=self.__frameSize['height'],parent=self) + self.__imageSettings = self.videoFeed.getCurrentImageSettings() + if(self.__imageSettings is not None): + self.cameraReady(imageSettings=self.__imageSettings) + else: raise Exception() + except: + _logger.exception('Camera failed to start..') + self.errorSignal.emit('Camera failed to start..') + # send exiting to log + _logger.debug('*** exiting DetectionManager.startCamera') + + def cameraReady(self, imageSettings): + # send calling to log + _logger.debug('*** calling DetectionManager.cameraReady') + # consume JSON + try: + brightness = imageSettings['brightness'] + except KeyError: brightness = None + try: + contrast = imageSettings['contrast'] + except KeyError: contrast = None + try: + staturation = imageSettings['saturation'] + except KeyError: staturation = None + try: + hue = imageSettings['hue'] + except KeyError: hue = None + self.__brightnessDefault = brightness + self.__contrastDefault = contrast + self.__saturationDefault = staturation + self.__hueDefault = hue + cameraProperties = { + 'videoSrc': self.__videoSource, + 'width': self.__frameSize['width'], + 'height': self.__frameSize['height'], + 'image': { + 'default': 1, + 'brightness': self.__brightnessDefault, + 'contrast': self.__contrastDefault, + 'saturation': self.__saturationDefault, + 'hue': self.__hueDefault + } + } + self.detectionManagerReadySignal.emit(cameraProperties) + # send exiting to log + _logger.debug('*** exiting DetectionManager.cameraReady') + + def createDetectors(self): + # Standard Parameters + if(True): + self.standardParams = cv2.SimpleBlobDetector_Params() + # Thresholds + self.standardParams.minThreshold = 1 + self.standardParams.maxThreshold = 50 + self.standardParams.thresholdStep = 1 + # Area + self.standardParams.filterByArea = True + self.standardParams.minArea = 400 + self.standardParams.maxArea = 900 + # Circularity + self.standardParams.filterByCircularity = True + self.standardParams.minCircularity = 0.8 + self.standardParams.maxCircularity= 1 + # Convexity + self.standardParams.filterByConvexity = True + self.standardParams.minConvexity = 0.3 + self.standardParams.maxConvexity = 1 + # Inertia + self.standardParams.filterByInertia = True + self.standardParams.minInertiaRatio = 0.3 + + # Relaxed Parameters + if(True): + self.relaxedParams = cv2.SimpleBlobDetector_Params() + # Thresholds + self.relaxedParams.minThreshold = 1 + self.relaxedParams.maxThreshold = 50 + self.relaxedParams.thresholdStep = 1 + # Area + self.relaxedParams.filterByArea = True + self.relaxedParams.minArea = 600 + self.relaxedParams.maxArea = 15000 + # Circularity + self.relaxedParams.filterByCircularity = True + self.relaxedParams.minCircularity = 0.6 + self.relaxedParams.maxCircularity= 1 + # Convexity + self.relaxedParams.filterByConvexity = True + self.relaxedParams.minConvexity = 0.1 + self.relaxedParams.maxConvexity = 1 + # Inertia + self.relaxedParams.filterByInertia = True + self.relaxedParams.minInertiaRatio = 0.3 + + # Create 2 detectors + self.detector = cv2.SimpleBlobDetector_create(self.standardParams) + self.relaxedDetector = cv2.SimpleBlobDetector_create(self.relaxedParams) + + def quit(self): + # send calling to log + _logger.debug('*** calling DetectionManager.quit') + _logger.info('Shutting down Detection Manager..') + _logger.info(' .. disconnecting video feed..') + self.__running = False + self.videoFeed.quit() + _logger.info('Detection Manager shut down successfully.') + # send exiting to log + _logger.debug('*** exiting DetectionManager.quit') + + # Main processing function + @pyqtSlot() + def processFrame(self): + try: + self.frame = self.videoFeed.getFrame() + if(self.frame is None): + self.errorSignal.emit('Failed to get signal') + elif(self.__enableDetection is True): + if(self.__endstopDetectionActive is True): + if(self.__endstopAutomatedDetectionActive is False): + self.analyzeEndstopFrame() + else: + self.burstEndstopDetection() + elif(self.__nozzleDetectionActive is True): + if(self.__nozzleAutoDetectionActive is False): + self.analyzeNozzleFrame() + else: + self.burstNozzleDetection() + else: + pass + self.receivedFrame(self.frame) + except Exception as e: + _logger.critical('Camera failed to retrieve data.') + _logger.critical(e) + self.errorSignal.emit('Critical camera error. Please restart TAMV.') + + # convert from cv2.mat to QPixmap and return results (frame+keypoint) + def receivedFrame(self, frame): + if(self.__running): + rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + h, w, ch = rgb_image.shape + bytes_per_line = ch * w + convert_to_Qt_format = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888) + qpixmap = QPixmap.fromImage(convert_to_Qt_format) + try: + retObject = [] + retObject.append(qpixmap) + retObject.append(self.__uv) + self.detectionManagerNewFrameSignal.emit(retObject) + except: pass + + @pyqtSlot(bool) + def enableDetection(self, state=False): + self.__enableDetection = state + + + ##### Endstop detection + def analyzeEndstopFrame(self): + detectionCount = 0 + self.uv = [0, 0] + average_location=[0,0] + retries = 0 + while(detectionCount < 3): + # Discard a few frames to get a clean detection due to printer movement + for i in range(3): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + if(self.__uv is not None): + if(self.__uv[0] is not None and self.__uv[1] is not None): + average_location[0] += np.around(self.__uv[0],0) + average_location[1] += np.around(self.__uv[1],0) + detectionCount += 1 + else: + retries += 1 + else: + retries += 1 + if(retries > 3): + average_location[0] = None + average_location[1] = None + break + if(average_location[0] is not None): + # calculate average X Y position from detection + average_location[0] /= detectionCount + average_location[1] /= detectionCount + # round to 0 decimal places + average_location = np.around(average_location,3) + self.__uv = average_location + else: + self.__uv = [None,None] + + @pyqtSlot(int) + def burstEndstopDetection(self): + detectionCount = 0 + self.uv = [0, 0] + average_location=[0,0] + retries = 0 + while(detectionCount < 6): + # Discard a few frames to get a clean detection due to printer movement + for i in range(1): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + if(self.__uv is not None): + if(self.__uv[0] is not None and self.__uv[1] is not None): + average_location[0] += self.__uv[0] + average_location[1] += self.__uv[1] + detectionCount += 1 + else: + retries += 1 + else: + retries += 1 + if(retries > 5): + average_location[0] = None + average_location[1] = None + break + if(average_location[0] is not None): + # calculate average X Y position from detection + average_location[0] /= detectionCount + average_location[1] /= detectionCount + # round to 0 decimal places + average_location = np.around(average_location,0) + self.__uv = average_location + else: + self.__uv = None + + def endstopContourDetection(self, detectFrame): + # apply endstop detection algorithm + usedFrame = copy.deepcopy(detectFrame) + yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) + yuvPlanes = cv2.split(yuv) + still = yuvPlanes[0] + black = np.zeros((still.shape[0],still.shape[1]), np.uint8) + kernel = np.ones((5,5),np.uint8) + img_blur = cv2.GaussianBlur(still, (7, 7), 3) + img_canny = cv2.Canny(img_blur, 50, 190) + img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) + cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) + black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) + cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) + # detectFrame = black + center = (None, None) + if len(cnt2) > 0: + myContours = [] + for k in range(len(cnt2)): + if hierarchy2[0][k][3] > -1: + myContours.append(cnt2[k]) + if len(myContours) > 0: + # return only the biggest detected contour + blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) + contourArea = cv2.contourArea(blobContours) + if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): + M = cv2.moments(blobContours) + center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) + detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) + detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) + detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) + detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) + return(center,detectFrame) + + @pyqtSlot(bool) + def toggleEndstopDetection(self, endstopDetectFlag): + if(endstopDetectFlag is True): + self.__endstopDetectionActive = True + else: + self.__endstopDetectionActive = False + + @pyqtSlot(bool) + def toggleEndstopAutoDetection(self, endstopDetectFlag): + if(endstopDetectFlag is True): + self.__endstopDetectionActive = True + self.__endstopAutomatedDetectionActive = True + else: + self.__endstopDetectionActive = False + self.__endstopAutomatedDetectionActive = False + + + ##### Nozzle detection + def analyzeNozzleFrame(self): + detectionCount = 0 + self.uv = [0, 0] + average_location=[0,0] + retries = 0 + while(detectionCount < 1): + # Discard a few frames to get a clean detection due to printer movement + for i in range(1): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.nozzleDetection() + if(self.__uv is not None): + if(self.__uv[0] is not None and self.__uv[1] is not None): + average_location[0] += np.around(self.__uv[0],0) + average_location[1] += np.around(self.__uv[1],0) + detectionCount += 1 + else: + retries += 1 + else: + retries += 1 + if(retries > 9): + average_location[0] = None + average_location[1] = None + break + if(average_location[0] is not None): + # calculate average X Y position from detection + average_location[0] /= detectionCount + average_location[1] /= detectionCount + # round to 0 decimal places + average_location = np.around(average_location,0) + self.__uv = average_location + else: + self.__uv = [None,None] + + def burstNozzleDetection(self): + detectionCount = 0 + self.uv = [0, 0] + average_location=[0,0] + retries = 0 + while(detectionCount < 5): + # Discard a few frames to get a clean detection due to printer movement + for i in range(1): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.nozzleDetection() + if(self.__uv is not None): + if(self.__uv[0] is not None and self.__uv[1] is not None): + average_location[0] += self.__uv[0] + average_location[1] += self.__uv[1] + detectionCount += 1 + else: + retries += 1 + else: + retries += 1 + if(retries > 5): + average_location[0] = None + average_location[1] = None + break + if(average_location[0] is not None): + # calculate average X Y position from detection + average_location[0] /= detectionCount + average_location[1] /= detectionCount + # round to 0 decimal places + average_location = np.around(average_location,0) + self.__uv = average_location + else: + self.__uv = None + + def nozzleDetection(self): + # working frame object + nozzleDetectFrame = copy.deepcopy(self.frame) + # return value for keypoints + keypoints = None + center = (None, None) + preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) + preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) + + # apply combo 1 (standard detector, preprocessor 0) + keypoints = self.detector.detect(preprocessorImage0) + keypointColor = (0,0,255) + if(len(keypoints) != 1): + # apply combo 2 (standard detector, preprocessor 1) + keypoints = self.detector.detect(preprocessorImage1) + keypointColor = (0,255,0) + if(len(keypoints) != 1): + # apply combo 3 (standard detector, preprocessor 0) + keypoints = self.relaxedDetector.detect(preprocessorImage0) + keypointColor = (255,0,0) + if(len(keypoints) != 1): + # apply combo 4 (standard detector, preprocessor 1) + keypoints = self.relaxedDetector.detect(preprocessorImage1) + keypointColor = (39,127,255) + if(len(keypoints) != 1): + # failed to detect a nozzle, correct return value object + keypoints = None + # process keypoint + if(keypoints is not None): + # create center object + (x,y) = np.around(keypoints[0].pt) + x,y = int(x), int(y) + center = (x,y) + # create radius object + keypointRadius = np.around(keypoints[0].size/2) + keypointRadius = int(keypointRadius) + circleFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius,color=keypointColor,thickness=-1) + nozzleDetectFrame = cv2.addWeighted(circleFrame, 0.4, nozzleDetectFrame, 0.6, 0) + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius, color=(0,0,0), thickness=1) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x-5,y), (x+5, y), (255,255,255), 2) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x,y-5), (x, y+5), (255,255,255), 2) + else: + # no keypoints, draw a 3 outline circle in the middle of the frame + keypointRadius = 17 + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3) + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1) + # draw crosshair + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 3) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 3) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (255,255,255), 1) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (255,255,255), 1) + return(center, nozzleDetectFrame) + + @pyqtSlot(bool) + def toggleNozzleDetection(self, nozzleDetectFlag): + if(nozzleDetectFlag is True): + self.__nozzleDetectionActive = True + else: + self.__nozzleDetectionActive = False + + @pyqtSlot(bool) + def toggleNozzleAutoDetection(self, nozzleDetectFlag): + if(nozzleDetectFlag is True): + self.__nozzleDetectionActive = True + self.__nozzleAutoDetectionActive = True + else: + self.__nozzleDetectionActive = False + self.__nozzleAutoDetectionActive = False + + + ##### Utilities + # adjust image gamma + def adjust_gamma(self, image, gamma=1.2): + # build a lookup table mapping the pixel values [0, 255] to + # their adjusted gamma values + invGamma = 1.0 / gamma + table = np.array([((i / 255.0) ** invGamma) * 255 + for i in np.arange(0, 256)]).astype( 'uint8' ) + # apply gamma correction using the lookup table + return cv2.LUT(image, table) + + # Image detection preprocessors + def preprocessImage(self, frameInput, algorithm=0): + try: + outputFrame = self.adjust_gamma(image=frameInput, gamma=1.2) + except: outputFrame = copy.deepcopy(frameInput) + if(algorithm == 0): + yuv = cv2.cvtColor(outputFrame, cv2.COLOR_BGR2YUV) + yuvPlanes = cv2.split(yuv) + yuvPlanes[0] = cv2.GaussianBlur(yuvPlanes[0],(7,7),6) + yuvPlanes[0] = cv2.adaptiveThreshold(yuvPlanes[0],255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,35,1) + outputFrame = cv2.cvtColor(yuvPlanes[0],cv2.COLOR_GRAY2BGR) + elif(algorithm == 1): + outputFrame = cv2.cvtColor(outputFrame, cv2.COLOR_BGR2GRAY ) + thr_val, outputFrame = cv2.threshold(outputFrame, 127, 255, cv2.THRESH_BINARY|cv2.THRESH_TRIANGLE ) + outputFrame = cv2.GaussianBlur( outputFrame, (7,7), 6 ) + outputFrame = cv2.cvtColor( outputFrame, cv2.COLOR_GRAY2BGR ) + return(outputFrame) + + + ##### Image adjustment properties + @pyqtSlot(object) + def getImageProperties(self, imageSettings): + try: + brightness = imageSettings['brightness'] + except KeyError: brightness = None + try: + contrast = imageSettings['contrast'] + except KeyError: contrast = None + try: + staturation = imageSettings['saturation'] + except KeyError: staturation = None + try: + hue = imageSettings['hue'] + except KeyError: hue = None + if(imageSettings['default']==1): + self.__brightnessDefault = brightness + self.__contrastDefault = contrast + self.__saturationDefault = staturation + self.__hueDefault = hue + else: + self.__brightness = brightness + self.__contrast = contrast + self.__saturation = staturation + self.__hue = hue + + @pyqtSlot(object) + def relayImageProperties(self, imageProperties): + self.videoFeed.setImageProperties(imagePropterties=imageProperties) + + @pyqtSlot() + def relayResetImage(self): + self.videoFeed.resetImageProperties() + + def getPropertiesJSON(self): + self.detectionManagerImagePropertiesSignal.emit() + + def getDefaultPropertiesJSON(self): + self.detectionManagerDefaultImagePropertiesSignal.emit() \ No newline at end of file diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py new file mode 100644 index 0000000..cca305f --- /dev/null +++ b/modules/PrinterManager.py @@ -0,0 +1,451 @@ +import logging +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.PrinterManager') + +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer +import sys, time +import importlib, importlib.util +import numpy as np + +class PrinterManager(QObject): + # class attributes + __printerJSON = None + __activePrinter = None + __status = None + __homed = False + __defaultSpeed = 3000 + __firmwareList = [] + + # signals + updateStatusbarSignal = pyqtSignal(object) + printerDisconnectedSignal = pyqtSignal(object) + printerIdleSignal = pyqtSignal() + printerHomedSignal = pyqtSignal() + moveCompleteSignal = pyqtSignal() + toolLoadedSignal = pyqtSignal() + toolIndexSignal = pyqtSignal(int) + activePrinterSignal = pyqtSignal(object) + coordinatesSignal = pyqtSignal(object) + offsetsSetSignal = pyqtSignal(object) + firmwareSavedSignal = pyqtSignal() + errorSignal = pyqtSignal(object) + + + # init function + def __init__(self, *args, **kwargs): + # send calling to log + _logger.debug('*** calling PrinterManager.__init__') + + + try: + self.__parent = kwargs['parent'] + super(PrinterManager, self).__init__(parent=kwargs['parent']) + except KeyError: + self.__print = None + super().__init__() + try: + self.__firmwareList = kwargs['firmwareList'] + except KeyError: + errorMsg = 'Cannot load master firmware list.' + self.updateStatusbarSignal.emit(errorMsg) + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + try: + self.__driverList = kwargs['driverList'] + except KeyError: + errorMsg = 'Cannot load master driver list.' + self.updateStatusbarSignal.emit(errorMsg) + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + + # send exiting to log + _logger.debug('*** exiting PrinterManager.__init__') + pass + + def quit(self): + # send calling to log + _logger.debug('*** calling PrinterManager.quit') + _logger.info('Shutting down Printer Manager..') + if( self.__printerJSON is not None): + _logger.info(' .. disconnecting printer..') + self.disconnectPrinter({'noUpdate':True}) + if( self.__printerJSON is not None): + _logger.info(' Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') + + _logger.info('Printer Manager shut down successfully.') + # send exiting to log + _logger.debug('*** exiting PrinterManager.quit') + + # Connections + @pyqtSlot(object) + def connectPrinter(self, printer): + # send calling to log + _logger.debug('*** calling PrinterManager.connectPrinter') + if(self.__activePrinter is not None): + self.disconnectPrinter() + # parse input JSON + self.__printerJSON = printer + + # start connect process + self.updateStatusbarSignal.emit('Attempting to connect to: ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') + _logger.info('Connecting to printer at ' + self.__printerJSON['address'] + '..') + + # Attempt connecting to the controller + try: + # get active profile controller + activeDriver = self.__printerJSON['controller'] + _logger.debug("Active controller: " + activeDriver) + _logger.debug(self.__firmwareList) + _logger.debug(self.__driverList) + firmwareSelect = None + driverSelect = None + for i, item in enumerate(self.__firmwareList): + if(item == activeDriver): + firmwareSelect = item + driverSelect = self.__driverList[i] + _logger.debug(' .. active driver: ' + driverSelect) + break + if(firmwareSelect is None or driverSelect is None): + # Firmware not found in master firmware list + errorMsg = 'Cannot load driver for this printer' + raise Exception(errorMsg) + # load active driver + spec = importlib.util.spec_from_file_location("printerAPI","./drivers/"+driverSelect) + driverModule = importlib.util.module_from_spec(spec) + sys.modules[driverSelect[:-3]] = driverModule + spec.loader.exec_module(driverModule) + try: + self.__activePrinter = driverModule.printerAPI(self.__printerJSON['address']) + if(not self.__activePrinter.isHomed()): + # Machine has not been homed yet + errorMsg = 'Printer axis not homed. Rectify and reconnect.' + self.errorSignal.emit(errorMsg) + return + if(not self.__activePrinter.isIdle()): + # connection failed for some reason + errorMsg = 'Device either did not respond or is not a supported controller type.' + self.errorSignal.emit(errorMsg) + return + else: + # connection succeeded, update objects accordingly + _logger.info(' .. fetching tool information..') + # Send printer JSON to parent thread + activePrinter = self.__activePrinter.getJSON() + activePrinter['nickname'] = self.__printerJSON['nickname'] + activePrinter['default'] = self.__printerJSON['default'] + activePrinter['currentTool'] = self.__activePrinter.getCurrentTool() + successMsg = 'Connected to ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' + _logger.info(' ' + successMsg) + self.updateStatusbarSignal.emit(successMsg) + self.activePrinterSignal.emit(activePrinter) + except Exception as e: + # errorMsg = self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' + ' is offline.' + errorMsg = str(e) + self.errorSignal.emit(errorMsg) + return + except Exception as e: + # Cannot connect to printer + _logger.exception(str(e)) + self.errorSignal.emit(str(e)) + return + + # send exiting to log + _logger.debug('*** exiting PrinterManager.connectPrinter') + + @pyqtSlot(object) + def disconnectPrinter(self, kwargs={}): + try: + parkPosition = kwargs['parkPosition'] + except KeyError: + parkPosition = None + try: + noUpdate = kwargs['noUpdate'] + except KeyError: noUpdate = False + # send calling to log + _logger.debug('*** calling PrinterManager.disconnectPrinter') + if(self.__activePrinter is not None): + try: + try: + self.__activePrinter.flushMovementBuffer() + _logger.info(' .. unloading tools..') + self.__activePrinter.unloadTools() + except: + errorMsg = 'Could not unload tools.' + raise Exception(errorMsg) + # move to final park position + if(parkPosition is not None): + _logger.info(' .. restoring position..') + self.complexMoveAbsolute(position=parkPosition) + # delete printer connection + # self.__activePrinter['disconnected'] = True + + if(noUpdate is False): + # notify parent thread + successMsg = 'Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' + _logger.info(successMsg) + self.printerDisconnectedSignal.emit(successMsg) + else: + self.printerDisconnectedSignal.emit(None) + except Exception as e: + _logger.exception(e) + self.errorSignal.emit(str(e)) + return + + # send exiting to log + _logger.debug('*** exiting PrinterManager.disconnectPrinter') + + # Movements and Coordinates + def complexMoveAbsolute(self, position=None, moveSpeed=None): + # send calling to log + _logger.debug('*** calling PrinterManager.complexMoveAbsolute') + if(position is None): + return + if(moveSpeed is None): + moveSpeed = self.__defaultSpeed + # form coorindates from parameter + try: + xPos = position['X'] + except KeyError: xPos = None + try: + yPos = position['Y'] + except KeyError: yPos = None + try: + zPos = position['Z'] + except KeyError: zPos = None + try: + rotated = self.__printerJSON['rotated'] + except KeyError: rotated=0 + try: + if( rotated == 1): + # move Y -> X -> Z + if(yPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Y=yPos) + if(xPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos) + if(zPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Z=zPos) + else: + # move X -> Y -> Z + if(xPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos) + if(yPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Y=yPos) + if(zPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Z=zPos) + except: + errorMsg = 'Error performing printer moves.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + raise Exception(errorMsg) + # send exiting to log + _logger.debug('*** exiting PrinterManager.complexMoveAbsolute') + + def complexMoveRelative(self, position=None, moveSpeed=None): + # send calling to log + _logger.debug('*** calling PrinterManager.complexMoveRelative') + if(position is None): + return + if(moveSpeed is None): + moveSpeed = self.__defaultSpeed + # form coorindates from parameter + try: + xPos = position['X'] + except KeyError: xPos = None + try: + yPos = position['Y'] + except KeyError: yPos = None + try: + zPos = position['Z'] + except KeyError: zPos = None + try: + rotated = self.__printerJSON['rotated'] + except KeyError: rotated=0 + try: + if( rotated == 1): + # move Y -> X -> Z + if(yPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Y=yPos) + if(xPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos) + if(zPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Z=zPos) + else: + # move X -> Y -> Z + if(xPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos) + if(yPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Y=yPos) + if(zPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Z=zPos) + except: + errorMsg = 'Error performing printer moves.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + raise Exception(errorMsg) + # send exiting to log + _logger.debug('*** exiting PrinterManager.complexMoveRelative') + + @pyqtSlot(object) + def moveRelative(self, params=None): + # send calling to log + _logger.debug('*** calling PrinterManager.moveRelative') + _logger.debug('Requesting a move to position: ' + str(params)) + try: + position = params['position'] + except KeyError: position = None + try: + moveSpeed = params['moveSpeed'] + except KeyError: moveSpeed = self.__defaultSpeed + try: + protected = params['protected'] + except KeyError: protected = False + + if(position is None): + errorMsg = 'Move rel: Invalid position.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + # form coorindates from parameter + try: + xPos = position['X'] + except KeyError: xPos = 0 + try: + yPos = position['Y'] + except KeyError: yPos = 0 + try: + zPos = position['Z'] + except KeyError: zPos = 0 + try: + if(protected): + self.complexMoveRelative(moveSpeed=moveSpeed, position={'X':xPos, 'Y': yPos, 'Z': zPos}) + else: + self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos, Y=yPos, Z=zPos) + self.moveCompleteSignal.emit() + except: + errorMsg = 'Error: moveRelative cannot run.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + # send exiting to log + _logger.debug('*** exiting PrinterManager.moveRelative') + + @pyqtSlot(object) + def moveAbsolute(self, params=None): + # send calling to log + _logger.debug('*** calling PrinterManager.moveAbsolute') + _logger.debug('Requesting a move to position: ' + str(params)) + try: + position = params['position'] + except KeyError: position = None + try: + moveSpeed = params['moveSpeed'] + except KeyError: moveSpeed = self.__defaultSpeed + try: + protected = params['protected'] + except KeyError: protected = False + + if(position is None): + errorMsg = 'Move rel: Invalid position.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + + # form coorindates from parameter + try: + xPos = position['X'] + except KeyError: xPos = None + try: + yPos = position['Y'] + except KeyError: yPos = None + try: + zPos = position['Z'] + except KeyError: zPos = None + + try: + if(protected): + self.complexMoveAbsolute(moveSpeed=moveSpeed, position={'X':xPos, 'Y': yPos, 'Z': zPos}) + else: + self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos, Y=yPos, Z=zPos) + self.moveCompleteSignal.emit() + except: + errorMsg = 'Error: moveAbsolute cannot run.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + return + # send exiting to log + _logger.debug('*** exiting PrinterManager.moveAbsolute') + + @pyqtSlot() + def getCoordinates(self): + printerCoordinates = self.__activePrinter.getCoordinates() + self.coordinatesSignal.emit(printerCoordinates) + + # Tools + @pyqtSlot() + def currentTool(self): + loadedToolIndex = self.__activePrinter.getCurrentTool() + self.toolIndexSignal.emit(int(loadedToolIndex)) + + @pyqtSlot(int) + def callTool(self, toolNumber=-1): + toolNumber = int(toolNumber) + try: + if(toolNumber > -1): + self.__activePrinter.loadTool(toolNumber) + else: + self.__activePrinter.unloadTools() + except: + raise Exception('PrinterManager: Unable to load tool: ' + str(toolNumber)) + self.toolLoadedSignal.emit() + + @pyqtSlot(object) + def calibrationSetOffset(self, params=None): + try: + toolIndex = int(params['toolIndex']) + except KeyError: + toolIndex = None + errorMsg = 'setOffset: Error in tool index' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + try: + position = params['position'] + except KeyError: + position = None + errorMsg = 'setOffset: error in tool position' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + try: + cpCoordinates = params['cpCoordinates'] + except KeyError: + errorMsg = 'setOffset: error in cpCoordinates' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + if(toolIndex < 0 or toolIndex is None): + errorMsg = 'Invalid tool selected for offsets.' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + params = {'offsets': None} + self.offsetsSetSignal.emit(params) + return + elif(position is None or position['X'] is None or position['Y'] is None): + errorMsg = 'Invalid offsets passed to printer.' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + params = {'offsets': None} + self.offsetsSetSignal.emit(params) + return + elif(cpCoordinates is None): + errorMsg = 'Invalid CP coordinates passed to printer.' + _logger.error(errorMsg) + self.errorSignal.emit(errorMsg) + params = {'offsets': None} + self.offsetsSetSignal.emit(params) + return + try: + toolOffsets = self.__activePrinter.getToolOffset(toolIndex) + _logger.debug('calibrationSetOffset: Setting offsets for tool: ', toolIndex) + finalOffsets = {} + finalOffsets['X'] = np.around(cpCoordinates['X'] + toolOffsets['X'] - position['X'],3) + finalOffsets['Y'] = np.around(cpCoordinates['Y'] + toolOffsets['Y'] - position['Y'],3) + finalOffsets['Z'] = np.around(toolOffsets['Z'],3) + self.__activePrinter.setToolOffsets(tool=toolIndex, X=finalOffsets['X'], Y=finalOffsets['Y']) + _logger.debug('X: ' + str(finalOffsets['X']) + ' Y:' + str(finalOffsets['Y']) + ' Z:' + str(finalOffsets['X'])) + except Exception as e: + self.errorSignal.emit(str(e)) + _logger.error('Other exception occurred in calibrationSetOffset') + _logger.exception(e) + params = {'offsets': None} + self.offsetsSetSignal.emit(params) + return + params = {'offsets': finalOffsets} + self.offsetsSetSignal.emit(params) \ No newline at end of file diff --git a/modules/SettingsDialog.py b/modules/SettingsDialog.py new file mode 100644 index 0000000..3d47493 --- /dev/null +++ b/modules/SettingsDialog.py @@ -0,0 +1,760 @@ +import logging +from sys import stdout +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.SettingsDialog') + +from cv2 import cvtColor, COLOR_BGR2RGB +from PyQt5.QtWidgets import * #QDialog, QWidget, QVBoxLayout, QTabWidgetQ, QPushButton, QComboBox, QSlider, QLabel +from PyQt5.QtCore import * +import traceback, copy + +# Configuration settings dialog box +class SettingsDialog(QDialog): + # Signals + settingsAlignmentPollSignal = pyqtSignal() + settingsChangeVideoSrcSignal = pyqtSignal(object) + settingsGeometrySignal = pyqtSignal(object) + settingsRequestCameraPropertiesSignal = pyqtSignal() + settingsRequestImagePropertiesSignal = pyqtSignal() + settingsResetImageSignal = pyqtSignal() + settingsSetImagePropertiesSignal = pyqtSignal(object) + settingsRequestDefaultImagePropertiesSignal = pyqtSignal() + settingsStatusbarSignal = pyqtSignal(object) + settingsUpdateSignal = pyqtSignal(object) + settingsNewPrinter = pyqtSignal(object) + + def __init__(self, *args, **kwargs): + # send calling to log + _logger.debug('*** calling SettingsDialog.__init__') + try: + self.__parent = kwargs['parent'] + except KeyError: + self.__parent = None + try: + self.__newPrinter = kwargs['newPrinter'] + except KeyError: + self.__newPrinter = False + try: + self.__geometry = kwargs['geometry'] + except KeyError: + self.__geometry = None + try: + self.__settings = copy.deepcopy(kwargs['settings']) + self.__originalSettingsObject = kwargs['settings'] + except KeyError: + self.__settings = None + self.__originalSettingsObject = None + try: + self.__cameraProperties = kwargs['cameraProperties'] + except KeyError: + self.__cameraProperties = None + try: + self.__firmwareList = kwargs['firmwareList'] + except KeyError: + self.__firmwareList = None + + + # Set up settings window + super(SettingsDialog,self).__init__(parent=self.__parent) + self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) + self.setWindowTitle('TAMV Configuration Settings') + # Restore geometry if available + if(self.__geometry is not None): + self.restoreGeometry(self.__geometry) + # Set layout details + self.layout = QVBoxLayout() + self.layout.setSpacing(3) + ############# TAB SETUP ############# + # Create tabs layout + self.tabs = QTabWidget() + # Tab 1: Settings + self.settingsTab = QWidget() + self.settingsTab.layout = QVBoxLayout() + self.settingsTab.setLayout(self.settingsTab.layout) + # Tab 2: Cameras + self.camerasTab = QWidget() + self.camerasTab.layout = QVBoxLayout() + self.camerasTab.setLayout(self.camerasTab.layout) + # add tabs to tabs layout + self.tabs.addTab(self.settingsTab, 'Machines') + if(self.__newPrinter is False): + self.tabs.addTab(self.camerasTab, 'Cameras') + # Add tabs layout to window + self.layout.addWidget(self.tabs) + # apply layout + self.setLayout(self.layout) + ############# POPULATE TABS + # Create camera items + if(self.__newPrinter is False): + self.createCameraItems() + # Create machine items + self.createMachineItems() + ############# MAIN BUTTONS + # Save button + if(self.__newPrinter is False): + self.save_button = QPushButton('Save') + self.save_button.setToolTip('Save current parameters to settings.json file') + self.save_button.clicked.connect(self.updatePrinterObjects) + else: + self.save_button = QPushButton('Save and connect..') + self.save_button.clicked.connect(self.saveNewPrinter) + self.save_button.setObjectName('active') + # Close button + self.close_button = QPushButton('Cancel') + self.close_button.setToolTip('Cancel changes and return to main program.') + self.close_button.clicked.connect(self.cancelChanges) + self.close_button.setObjectName('terminate') + # WINDOW BUTTONS + self.layout.addWidget(self.save_button) + self.layout.addWidget(self.close_button) + # OK Cancel buttons + #self.layout.addWidget(self.buttonBox) + _logger.debug('*** exiting SettingsDialog.__init__') + + def createCameraItems(self): + _logger.debug('*** calling SettingsDialog.createCameraItems') + ############# CAMERAS TAB ############# + # Get current camera settings from video thread + try: + if(self.__cameraProperties['image'] is not None): + try: + brightness_input = self.__cameraProperties['image']['brightness'] + except KeyError: + brightness_input = None + try: + contrast_input = self.__cameraProperties['image']['contrast'] + except KeyError: + contrast_input = None + try: + saturation_input = self.__cameraProperties['image']['saturation'] + except KeyError: + saturation_input = None + try: + hue_input = self.__cameraProperties['image']['hue'] + except KeyError: + hue_input = None + except KeyError: + (brightness_input, contrast_input, saturation_input, hue_input) = (None, None, None, None) + except Exception: + errorMsg = 'Error fetching camera parameters.' + self.settingsStatusbarSignal.emit(errorMsg) + _logger.error('Error fetching camera parameters.\n' + traceback.format_exc()) + ############# CAMERA TAB: ITEMS + # Camera Combobox + self.camera_combo = QComboBox() + for camera in self.__settings['camera']: + if(camera['default'] == 1): + camera_description = '* ' + str(camera['video_src']) + ': ' + str(camera['display_width']) + 'x' + str(camera['display_height']) + else: + camera_description = str(camera['video_src']) + ': ' + str(camera['display_width']) + 'x' + str(camera['display_height']) + self.camera_combo.addItem(camera_description) + #HBHBHBHB: TODO need to pass actual video source string object from parameter helper function!!! + #self.camera_combo.currentIndexChanged.connect(self.parent().video_thread.changeVideoSrc) + + #HBHBHBHB TODO: write code to fetch cameras + # # Get cameras button + # self.camera_button = QPushButton('Get cameras') + # self.camera_button.clicked.connect(self.getCameras) + # if self.parent().video_thread.alignment: + # self.camera_button.setDisabled(True) + # else: self.camera_button.setDisabled(False) + #self.getCameras() + # cmbox.addWidget(self.camera_button) + #HBHBHBHB TODO: write code to fetch cameras + + # Brightness slider + self.brightness_slider = QSlider(Qt.Horizontal) + self.brightness_slider.setMinimum(0) + self.brightness_slider.setMaximum(255) + self.brightness_slider.valueChanged.connect(self.changeBrightness) + self.brightness_slider.setTickPosition(QSlider.TicksBelow) + self.brightness_slider.setTickInterval(1) + if(brightness_input is not None): + self.brightness_label = QLabel(str(int(brightness_input))) + self.brightness_slider.setValue(int(brightness_input)) + else: + self.brightness_label = QLabel() + self.brightness_slider.setEnabled(False) + # Contrast slider + self.contrast_slider = QSlider(Qt.Horizontal) + self.contrast_slider.setMinimum(0) + self.contrast_slider.setMaximum(255) + self.contrast_slider.valueChanged.connect(self.changeContrast) + self.contrast_slider.setTickPosition(QSlider.TicksBelow) + self.contrast_slider.setTickInterval(1) + if(contrast_input is not None): + self.contrast_label = QLabel(str(int(contrast_input))) + self.contrast_slider.setValue(int(contrast_input)) + else: + self.contrast_label = QLabel() + self.contrast_slider.setEnabled(False) + # Saturation slider + self.saturation_slider = QSlider(Qt.Horizontal) + self.saturation_slider.setMinimum(0) + self.saturation_slider.setMaximum(255) + self.saturation_slider.valueChanged.connect(self.changeSaturation) + self.saturation_slider.setTickPosition(QSlider.TicksBelow) + self.saturation_slider.setTickInterval(1) + if(saturation_input is not None): + self.saturation_label = QLabel(str(int(saturation_input))) + self.saturation_slider.setValue(int(saturation_input)) + else: + self.saturation_label = QLabel() + self.saturation_slider.setEnabled(False) + # Hue slider + self.hue_slider = QSlider(Qt.Horizontal) + self.hue_slider.setMinimum(0) + self.hue_slider.setMaximum(8) + self.hue_slider.valueChanged.connect(self.changeHue) + self.hue_slider.setTickPosition(QSlider.TicksBelow) + self.hue_slider.setTickInterval(1) + if(hue_input is not None): + self.hue_label = QLabel(str(int(hue_input))) + self.hue_slider.setValue(int(hue_input)) + else: + self.hue_label = QLabel() + self.hue_slider.setEnabled(False) + # Reset button + self.reset_button = QPushButton("Reset to defaults") + self.reset_button.setToolTip('Reset camera settings to defaults.') + self.reset_button.clicked.connect(self.resetCameraToDefaults) + if(brightness_input is None and contrast_input is None and saturation_input is None and hue_input is None): + self.reset_button.setDisabled(True) + # Camera drop-down + self.camera_box = QGroupBox('Active camera source') + self.camerasTab.layout.addWidget(self.camera_box) + cmbox = QHBoxLayout() + self.camera_box.setLayout(cmbox) + cmbox.addWidget(self.camera_combo) + # Brightness + self.brightness_box =QGroupBox('Brightness') + self.camerasTab.layout.addWidget(self.brightness_box) + bvbox = QHBoxLayout() + self.brightness_box.setLayout(bvbox) + bvbox.addWidget(self.brightness_slider) + bvbox.addWidget(self.brightness_label) + # Contrast + self.contrast_box =QGroupBox('Contrast') + self.camerasTab.layout.addWidget(self.contrast_box) + cvbox = QHBoxLayout() + self.contrast_box.setLayout(cvbox) + cvbox.addWidget(self.contrast_slider) + cvbox.addWidget(self.contrast_label) + # Saturation + self.saturation_box =QGroupBox('Saturation') + self.camerasTab.layout.addWidget(self.saturation_box) + svbox = QHBoxLayout() + self.saturation_box.setLayout(svbox) + svbox.addWidget(self.saturation_slider) + svbox.addWidget(self.saturation_label) + # Hue + self.hue_box =QGroupBox('Hue') + self.camerasTab.layout.addWidget(self.hue_box) + hvbox = QHBoxLayout() + self.hue_box.setLayout(hvbox) + hvbox.addWidget(self.hue_slider) + hvbox.addWidget(self.hue_label) + _logger.debug('*** exiting SettingsDialog.createCameraItems') + + def createMachineItems(self): + _logger.debug('*** calling SettingsDialog.createMachineItems') + ############# MACHINES TAB ############# + if(self.__newPrinter is False): + # Get machines as defined in the config + # Printer combo box + self.printer_combo = QComboBox() + self.default_printer = None + self.defaultIndex = 0 + for i, device in enumerate(self.__settings['printer']): + if(device['default'] == 1): + printer_description = '(default) ' + device['nickname'] + self.default_printer = device + self.defaultIndex = i + else: + printer_description = device['nickname'] + self.printer_combo.addItem(printer_description) + # set default printer as the selected index + self.printer_combo.setCurrentIndex(self.defaultIndex) + if(self.default_printer is None): + self.default_printer = self.__settings['printer'][0] + # Create a layout for the printer combo box, and the add and delete buttons + topbox = QGroupBox() + toplayout = QHBoxLayout() + topbox.setLayout(toplayout) + toplayout.addWidget(self.printer_combo) + # Add button + self.add_printer_button = QPushButton('+') + self.add_printer_button.setStyleSheet('background-color: green') + self.add_printer_button.clicked.connect(self.addProfile) + self.add_printer_button.setToolTip('Add a new profile..') + self.add_printer_button.setFixedWidth(30) + toplayout.addWidget(self.add_printer_button) + # Delete button + self.delete_printer_button = QPushButton('X') + self.delete_printer_button.setStyleSheet('background-color: red') + self.delete_printer_button.clicked.connect(self.deleteProfile) + self.delete_printer_button.setToolTip('Delete current profile..') + self.delete_printer_button.setFixedWidth(30) + toplayout.addWidget(self.delete_printer_button) + # add printer combo box to layout + self.settingsTab.layout.addWidget(topbox) + # Printer default checkbox + self.printerDefault = QCheckBox("&Default", self) + if(self.__newPrinter is False): + self.printerDefault.setChecked(True) + self.printerDefault.stateChanged.connect(self.checkDefaults) + # Printer nickname + if(self.__newPrinter is False): + self.printerNickname = QLineEdit(self.default_printer['nickname']) + else: + self.printerNickname = QLineEdit() + self.printerNickname.setPlaceholderText('Enter an alias for your printer') + self.printerNickname_label = QLabel('Nickname: ') + self.printerNickname_box =QGroupBox() + self.settingsTab.layout.addWidget(self.printerNickname_box) + nnbox = QHBoxLayout() + self.printerNickname_box.setLayout(nnbox) + nnbox.addWidget(self.printerNickname_label) + nnbox.addWidget(self.printerNickname) + nnbox.addWidget(self.printerDefault) + # Printer address + if(self.__newPrinter is False): + self.printerAddress = QLineEdit(self.default_printer['address']) + else: + self.printerAddress = QLineEdit() + self.printerAddress.setPlaceholderText('Enter printer interface or IP') + self.printerAddress_label = QLabel('Address: ') + self.printerAddress_box =QGroupBox() + self.settingsTab.layout.addWidget(self.printerAddress_box) + adbox = QHBoxLayout() + self.printerAddress_box.setLayout(adbox) + adbox.addWidget(self.printerAddress_label) + adbox.addWidget(self.printerAddress) + # Printer password + if(self.__newPrinter is False): + self.printerPassword = QLineEdit(self.default_printer['password']) + else: + self.printerPassword = QLineEdit() + self.printerPassword.setPlaceholderText('Password') + self.printerPassword.setToolTip('(optional): password used to connect to printer') + self.printerPassword_label = QLabel('Password: ') + adbox.addWidget(self.printerPassword_label) + adbox.addWidget(self.printerPassword) + # Printer controller + self.controllerName = QComboBox() + self.controllerName.setToolTip('Machine firmware family/category') + self.controllerName.setMinimumWidth(180) + # get controller index from master list + for item in self.__firmwareList: + self.controllerName.addItem(item) + if(self.__newPrinter is False): + # get controller index from master list + listIndex = -1 + for i, item in enumerate(self.__firmwareList): + if(item == self.default_printer['controller']): + listIndex = i + break + if(listIndex > -1): + self.controllerName.setCurrentIndex(listIndex) + else: + _logger.error('Controller name not found for combobox.') + else: + # new printer, default to RRF/Duet + self.controllerName.setCurrentIndex(0) + self.controllerName_label = QLabel('Controller Type: ') + self.controllerName_box =QGroupBox() + self.settingsTab.layout.addWidget(self.controllerName_box) + cnbox = QGridLayout() + cnbox.setSpacing(5) + self.controllerName_box.setLayout(cnbox) + cnbox.addWidget(self.controllerName_label, 0, 0, 1, 1, Qt.AlignRight) + cnbox.addWidget(self.controllerName, 0, 1, 1, 2, Qt.AlignLeft) + # Printer with rotated XY kinematics + self.printerRotated = QCheckBox('Rotate XY') + if(self.__newPrinter is True): + self.printerRotated.setChecked(True) + else: + try: + if(self.default_printer['rotated'] == 1): + self.printerRotated.setChecked(True) + else: + self.printerRotated.setChecked(False) + except KeyError: + # rotated key doesn't exist, add to printer model + self.default_printer['rotated'] = 0 + self.printerRotated.setChecked(False) + cnbox.addWidget(self.printerRotated, 0, 3, 1, 1, Qt.AlignRight) + # Printer name + if(self.__newPrinter is False): + self.printerName = QLineEdit(self.default_printer['name']) + else: + self.printerName = QLineEdit() + self.printerName.setPlaceholderText('(pulled from machine..)') + self.printerName.setStyleSheet('font: italic') + self.printerName.setEnabled(False) + self.printerName_label = QLabel('Name: ') + self.printerName_box =QGroupBox() + self.settingsTab.layout.addWidget(self.printerName_box) + if(self.__newPrinter is True): + self.printerName_box.setVisible(False) + pnbox = QHBoxLayout() + self.printerName_box.setLayout(pnbox) + pnbox.addWidget(self.printerName_label) + pnbox.addWidget(self.printerName) + # Printer firmware version identifier + if(self.__newPrinter is False): + self.versionName = QLineEdit(self.default_printer['version']) + else: + self.versionName = QLineEdit() + self.versionName.setPlaceholderText("(pulled from machine..)") + self.versionName.setStyleSheet('font: italic') + self.versionName.setEnabled(False) + self.versionName_label = QLabel('Firmware version: ') + self.versionName_box =QGroupBox() + self.settingsTab.layout.addWidget(self.versionName_box) + if(self.__newPrinter is True): + self.versionName_box.setVisible(False) + fnbox = QHBoxLayout() + self.versionName_box.setLayout(fnbox) + fnbox.addWidget(self.versionName_label) + fnbox.addWidget(self.versionName) + if(self.__newPrinter is False): + # handle selecting a new machine from the dropdown + self.printer_combo.activated.connect(self.refreshPrinters) + self.printerAddress.editingFinished.connect(self.updateAttributes) + self.printerPassword.editingFinished.connect(self.updateAttributes) + self.printerName.editingFinished.connect(self.updateAttributes) + self.printerNickname.editingFinished.connect(self.updateAttributes) + self.controllerName.activated.connect(self.updateAttributes) + self.versionName.editingFinished.connect(self.updateAttributes) + self.printerDefault.stateChanged.connect(self.updateAttributes) + self.printerRotated.stateChanged.connect(self.updateAttributes) + _logger.debug('*** exiting SettingsDialog.createMachineItems') + + def checkDefaults(self): + _logger.debug('*** calling SettingsDialog.checkDefaults') + if(self.printerDefault.isChecked()): + index = self.printer_combo.currentIndex() + for i,machine in enumerate(self.__settings['printer']): + machine['default'] = 0 + self.printer_combo.setItemText(i, self.__settings['printer'][i]['nickname']) + self.__settings['printer'][index]['default']=1 + self.printer_combo.setItemText(index,'(default) ' + self.__settings['printer'][index]['nickname']) + else: + # User de-selected default machine + index = self.printer_combo.currentIndex() + if(index > -1): + self.printer_combo.setItemText(self.printer_combo.currentIndex(),self.__settings['printer'][self.printer_combo.currentIndex()]['nickname']) + _logger.debug('*** exiting SettingsDialog.checkDefaults') + + def addProfile(self): + _logger.debug('*** calling SettingsDialog.addProfile') + # Create a new printer profile object + newPrinter = { + 'address': '', + 'password': 'repap', + 'name': '', + 'nickname': 'New printer..', + 'controller' : 'RRF/Duet', + 'version': '', + 'default': 0, + 'rotated': 0, + 'tools': [ + { + 'number': 0, + 'name': 'Tool 0', + 'nozzleSize': 0.4, + 'offsets': [0,0,0] + } ] + } + # Add new profile to settingsObject list + self.__settings['printer'].append(newPrinter) + # enable all text fields + self.printerDefault.setDisabled(False) + self.printerAddress.setDisabled(False) + self.printerPassword.setDisabled(False) + self.printerNickname.setDisabled(False) + self.controllerName.setDisabled(False) + self.printerRotated.setDisabled(False) + self.delete_printer_button.setDisabled(False) + self.delete_printer_button.setStyleSheet('background-color: red') + # update combobox + self.printer_combo.addItem('New printer..') + self.printer_combo.setCurrentIndex(len(self.__settings['printer'])-1) + self.refreshPrinters(self.printer_combo.currentIndex()) + _logger.debug('*** exiting SettingsDialog.addProfile') + + def deleteProfile(self): + _logger.debug('*** calling SettingsDialog.deleteProfile') + # check if this is the "Add printer.." option which is always at the bottom of the list. + finalIndex = self.printer_combo.count() - 1 + index = self.printer_combo.currentIndex() + if(self.__settings['printer'][index]['default'] == 1): + wasDefault = True + else: + wasDefault = False + del self.__settings['printer'][index] + self.printer_combo.removeItem(index) + index = self.printer_combo.currentIndex() + if(index > -1 and len(self.__settings['printer']) > 0): + if(wasDefault): + self.__settings['printer'][0]['default'] = 1 + self.refreshPrinters(self.printer_combo.currentIndex()) + else: + # no more profiles found, display empty fields + self.printerDefault.setChecked(False) + self.printerAddress.setText('') + self.printerPassword.setText('') + self.printerName.setText('') + self.printerNickname.setText('') + self.controllerName.setCurrentIndex(0) + self.versionName.setText('') + self.printerRotated.setChecked(False) + # disable all fields + self.printerDefault.setDisabled(True) + self.printerAddress.setDisabled(True) + self.printerPassword.setDisabled(True) + self.printerName.setDisabled(True) + self.printerNickname.setDisabled(True) + self.controllerName.setDisabled(True) + self.versionName.setDisabled(True) + self.printerRotated.setDisabled(True) + self.printer_combo.addItem('+++ Add a new profile --->') + self.printer_combo.setCurrentIndex(0) + self.delete_printer_button.setDisabled(True) + self.delete_printer_button.setStyleSheet('background-color: none') + _logger.debug('*** exiting SettingsDialog.deleteProfile') + + def refreshPrinters(self, index): + _logger.debug('*** calling SettingsDialog.refreshPrinters') + if(index >= 0): + if(len(self.__settings['printer'][index]['address']) > 0): + self.printerAddress.setText(self.__settings['printer'][index]['address']) + else: + self.printerAddress.clear() + if(len(self.__settings['printer'][index]['password']) > 0): + self.printerPassword.setText(self.__settings['printer'][index]['password']) + else: + self.printerPassword.clear() + if(len(self.__settings['printer'][index]['name']) > 0): + self.printerName.setText(self.__settings['printer'][index]['name']) + else: + self.printerName.clear() + if(len(self.__settings['printer'][index]['nickname']) > 0): + self.printerNickname.setText(self.__settings['printer'][index]['nickname']) + else: + self.printerNickname.clear() + # get controller index from master list + listIndex = -1 + for i, item in enumerate(self.__firmwareList): + if(item == self.default_printer['controller']): + listIndex = i + break + if(listIndex > -1): + self.controllerName.setCurrentIndex(listIndex) + else: + _logger.error('Controller name not found for combobox.') + if(len(self.__settings['printer'][index]['version']) > 0): + self.versionName.setText(self.__settings['printer'][index]['version']) + else: + self.versionName.clear() + if(self.__settings['printer'][index]['default'] == 1): + self.printerDefault.setChecked(True) + else: + self.printerDefault.setChecked(False) + if(self.__settings['printer'][index]['rotated'] == 1): + self.printerRotated.setChecked(True) + else: + self.printerRotated.setChecked(False) + _logger.debug('*** exiting SettingsDialog.refreshPrinters') + + def updateAttributes(self): + _logger.debug('*** calling SettingsDialog.updateAttributes') + index = self.printer_combo.currentIndex() + if(index > -1): + self.__settings['printer'][index]['address'] = self.printerAddress.text() + self.__settings['printer'][index]['password'] = self.printerPassword.text() + self.__settings['printer'][index]['name'] = self.printerName.text() + self.__settings['printer'][index]['nickname'] = self.printerNickname.text() + self.__settings['printer'][index]['controller'] = self.controllerName.itemText(self.controllerName.currentIndex()) + self.__settings['printer'][index]['version'] = self.versionName.text() + if(self.printerDefault.isChecked()): + self.__settings['printer'][index]['default'] = 1 + else: + self.__settings['printer'][index]['default'] = 0 + if(self.printerRotated.isChecked()): + self.__settings['printer'][index]['rotated'] = 1 + else: + self.__settings['printer'][index]['rotated'] = 0 + _logger.debug('*** exiting SettingsDialog.updateAttributes') + + def resetCameraToDefaults(self): + _logger.debug('*** calling SettingsDialog.resetCameraToDefaults') + self.settingsResetImageSignal.emit() + + _logger.debug('*** exiting SettingsDialog.resetCameraToDefaults') + + def changeBrightness(self): + _logger.debug('*** calling SettingsDialog.changeBrightness') + parameter = int(self.brightness_slider.value()) + message = {'brightness': parameter} + self.settingsSetImagePropertiesSignal.emit(message) + self.brightness_label.setText(str(parameter)) + _logger.debug('*** exiting SettingsDialog.changeBrightness') + + def changeContrast(self): + _logger.debug('*** calling SettingsDialog.changeContrast') + parameter = int(self.contrast_slider.value()) + message = {'contrast': parameter} + self.settingsSetImagePropertiesSignal.emit(message) + self.contrast_label.setText(str(parameter)) + _logger.debug('*** exiting SettingsDialog.changeContrast') + + def changeSaturation(self): + _logger.debug('*** calling SettingsDialog.changeSaturation') + parameter = int(self.saturation_slider.value()) + message = {'saturation': parameter} + self.settingsSetImagePropertiesSignal.emit(message) + self.saturation_label.setText(str(parameter)) + _logger.debug('*** exiting SettingsDialog.changeSaturation') + + def changeHue(self): + _logger.debug('*** calling SettingsDialog.changeHue') + parameter = int(self.hue_slider.value()) + message = {'hue': parameter} + self.settingsSetImagePropertiesSignal.emit(message) + self.hue_label.setText(str(parameter)) + _logger.debug('*** exiting SettingsDialog.changeHue') + + def getCameras(self): + _logger.debug('*** calling SettingsDialog.getCameras') + #HBHBHBHB: TODO handle multiple camera profiles + # # checks the first 6 indexes. + # i = 6 + # index = 0 + # self.camera_combo.clear() + # _cameras = [] + # #HBHBHBHB DEBUG camera description + # original_camera_description = '* ' + str(camera['video_src']) + # _cameras.append(original_camera_description) + # tempCap = cv2.VideoCapture() + # tempCap.setExceptionMode(True) + # while i > 0: + # if index != self.parent()._videoSrc: + # try: + # tempCap.open(index) + # if tempCap.read()[0]: + # api = tempCap.getBackendName() + # camera_description = str(index) + ': ' \ + # + str(int(tempCap.get(cv2.CAP_PROP_FRAME_WIDTH))) \ + # + 'x' + str(int(tempCap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + ' @ ' \ + # + str(int(tempCap.get(cv2.CAP_PROP_FPS))) + 'fps' + # _cameras.append(camera_description) + # tempCap.release() + # except: + # index += 1 + # i -= 1 + # continue + # index += 1 + # i -= 1 + # #cameras = [line for line in allOutputs if float(line['propmode']) > -1 ] + # _cameras.sort() + # for camera in _cameras: + # self.camera_combo.addItem(camera) + # self.camera_combo.setCurrentText(original_camera_description) + _logger.debug('*** exiting SettingsDialog.getCameras') + + def updatePrinterObjects(self): + _logger.debug('*** calling SettingsDialog.updatePrinterObjects') + defaultSet = False + multipleDefaults = False + defaultMessage = "More than one connection is set as the default option.\n\nPlease review the connections for:\n\n" + # Do some data cleaning + for machine in self.__settings['printer']: + # Check if user forgot a nickname, default to printer address + if(machine['nickname'] is None or machine['nickname'] == ""): + machine['nickname'] = machine['address'] + # Do some checking to catch multiple default printers set at the same time + if(machine['default'] == 1): + defaultMessage += " - " + machine['nickname'] + "\n" + if(defaultSet): + multipleDefaults = True + else: + defaultSet = True + # More than one profile is set as the default. Alert user, don't save, and return to the settings screen + if(multipleDefaults): + msgBox = QMessageBox() + msgBox.setIcon(QMessageBox.Warning) + msgBox.setText(defaultMessage) + msgBox.setWindowTitle('ERROR: Too many default connections') + msgBox.setStandardButtons(QMessageBox.Ok) + msgBox.exec() + return + # No default printed was defined, so set first item to default + if(defaultSet is False and len(self.__settings['printer']) > 0): + self.__settings['printer'][0]['default'] = 1 + elif(len(self.__settings['printer']) == 0): + # All profiles have been cleared. Add a dummy template + self.__settings['printer'] = [ + { + 'address': 'http://localhost', + 'password': 'reprap', + 'name': '', + 'nickname': 'Default profile', + 'controller' : 'RRF/Duet', + 'version': '', + 'default': 1, + 'rotated': 0, + 'tools': [ + { + 'number': 0, + 'name': 'Tool 0', + 'nozzleSize': 0.4, + 'offsets': [0,0,0] + } ] + } + ] + pass + self.settingsUpdateSignal.emit(self.__settings) + _logger.debug('*** exiting SettingsDialog.updatePrinterObjects') + self.close() + + def saveNewPrinter(self): + _logger.debug('*** calling SettingsDialog.saveNewPrinter') + _logger.info('Saving printer information..') + newPrinter = { + 'address': self.printerAddress.text(), + 'password': self.printerPassword.text(), + 'name': '', + 'nickname': self.printerNickname.text(), + 'controller' : str(self.controllerName.currentText()), + 'version': '', + 'default': int(self.printerDefault.isChecked()), + 'rotated': int(self.printerRotated.isChecked()), + 'tools': [ + { + 'number': 0, + 'name': 'Tool 0', + 'nozzleSize': 0.4, + 'offsets': [0,0,0] + } ] + } + if(self.printerDefault.isChecked()): + # new default printer, clear other objects + for machine in self.__settings['printer']: + machine['default'] = 0 + self.__settings['printer'].append(newPrinter) + self.settingsNewPrinter.emit(self.__settings) + _logger.debug('*** exiting SettingsDialog.saveNewPrinter') + self.close() + + def cancelChanges(self): + _logger.debug('*** calling SettingsDialog.cancelChanges') + self.settingsStatusbarSignal.emit('Changes to settings discarded.') + self.__settings = self.__originalSettingsObject + self.settingsGeometrySignal.emit(self.saveGeometry()) + self.close() + + def closeEvent(self, event): + self.settingsGeometrySignal.emit(self.saveGeometry()) + super().closeEvent(event) diff --git a/modules/StatusTipFilter.py b/modules/StatusTipFilter.py new file mode 100644 index 0000000..05ced9d --- /dev/null +++ b/modules/StatusTipFilter.py @@ -0,0 +1,8 @@ +from PyQt5 import QtCore, QtGui + + +class StatusTipFilter(QtCore.QObject): + def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + if isinstance(event, QtGui.QStatusTipEvent): + return True + return super().eventFilter(watched, event) \ No newline at end of file diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plot.py b/plot.py deleted file mode 100755 index bee5be5..0000000 --- a/plot.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (C) 2021 Haytham Bennani all rights reserved. -import os -import json -import argparse -import matplotlib.pyplot as plt -import matplotlib.cm as cm -import matplotlib.patches as patches -from matplotlib.ticker import FormatStrFormatter -import matplotlib -import numpy as np -import math - -def init(): - parser = argparse.ArgumentParser(description='Program to allign multiple tools on Duet based printers, using machine vision.', allow_abbrev=False) - parser.add_argument('-filename',type=str,nargs=1,default=['./output.json'],help='Path and filename of data file to process for plotting. Can be a relative or an absolute path. Default is \"./output.json\".') - - args=vars(parser.parse_args()) - inputFilename = args['filename'][0] - matplotlib.use('Qt5Agg',force=True) - return( inputFilename ) - -def loadDataFile( filename="./output.json" ): - try: - with open(filename,"r") as text_file: - data = text_file.read().replace('\n', '') - result = json.loads(data) - print( str(filename) + " has been loaded.") - return result - except OSError: - print( 'Error opening file: \"' + str(filename) + '\"') - return -1 - -def parseData( rawData ): - # create empty output array - toolDataResult = [] - # get number of tools - _numTools = np.max([ int(line['tool']) for line in rawData ]) + 1 - _cycles = np.max([ int(line['cycle']) for line in rawData ]) - - for i in range(_numTools): - x = [float(line['X']) for line in rawData if int(line['tool']) == i] - y = [float(line['Y']) for line in rawData if int(line['tool']) == i] - # variable to hold return data coordinates per tool formatted as a 2D array [x_value, y_value] - tempPairs = [] - - # calculate stats - # mean values - x_mean = np.around(np.mean(x),3) - y_mean = np.around(np.mean(y),3) - # median values - x_median = np.around(np.median(x),3) - y_median = np.around(np.median(y),3) - # ranges (max - min per axis) - x_range = np.around(np.max(x) - np.min(x),3) - y_range = np.around(np.max(y) - np.min(y),3) - # standard deviations - x_sig = np.around(np.std(x),3) - y_sig = np.around(np.std(y),3) - - # normalize data around mean - x -= x_mean - y -= y_mean - - # temporary object to append coordinate pairs into return value - tempPairs.append(x) - tempPairs.append(y) - - # add data to return object - toolDataResult.append(tempPairs) - - # display summary data to terminal - print( 'Summary for Tool '+str(i) +':' ) - print( ' Xmean: {0:6.3f} Ymean: {1:7.3f}'.format(x_mean,y_mean)) - print( ' Xmedian: {0:6.3f} Ymedian: {1:7.3f}'.format(x_median,y_median)) - print( ' Xsigma: {0:7.3f} Ysigma: {1:7.3f}'.format(x_sig,y_sig)) - print( ' Xrange: {0:7.3f} Yrange: {1:7.3f}'.format(x_range,y_range)) - print('') - - # return dataset - return ( _numTools, _cycles, toolDataResult ) - -def main(): - # parse command line arguments - dataFilename = init() - - # set up color and colormap arrays - colorMap = ["Greens","Oranges","Blues", "Reds"] #["Blues", "Reds","Greens","Oranges"] - colors = ['blue','red','green','orange'] - - # attempt to load data file - j=loadDataFile(dataFilename) - # handle file not opened errors and exit - if j == -1: - return - - # get data as 3 dimensional array [tool][axis][datapoints] normalized around mean of each axis - ( numTools, totalRuns, toolData ) = parseData(j) - # data file has been loaded, proceed with processing the data - print('') - print( "Found data for {} tools".format(numTools) + " and " + str(totalRuns) + " alignment cycles.") - print('') - - # initiate graph data - 1 tool per column - # Row 0: scatter plot with standard deviation box - # Row 1: histogram of X axis data - # Row 2: histogram of Y axis data - plt.switch_backend('QT4Agg') - - fig, axes = plt.subplots(ncols=3,nrows=numTools,constrained_layout=False) - - - for i, data in enumerate(toolData): - # create a color array the length of the number of tools in the data - color = np.arange(len(data[0])) - - # Axis formatting - # Major ticks - axes[i][0].xaxis.set_major_formatter(FormatStrFormatter('%.3f')) - axes[i][0].yaxis.set_major_formatter(FormatStrFormatter('%.3f')) - # Minor ticks - axes[i][0].xaxis.set_minor_formatter(FormatStrFormatter('%.3f')) - axes[i][0].yaxis.set_minor_formatter(FormatStrFormatter('%.3f')) - # Draw 0,0 lines - axes[i][0].axhline() - axes[i][0].axvline() - # x&y std deviation box - x_sigma = np.around(np.std(data[0]),3) - y_sigma = np.around(np.std(data[1]),3) - axes[i][0].add_patch(patches.Rectangle((-1*x_sigma,-1*y_sigma), 2*x_sigma, 2*y_sigma, color="green",fill=False, linestyle='dotted')) - axes[i][0].add_patch(patches.Rectangle((-2*x_sigma,-2*y_sigma), 4*x_sigma, 4*y_sigma, color="red",fill=False, linestyle='-.')) - - # scatter plot for tool data - axes[i][0].scatter(data[0], data[1], c=color, cmap=colorMap[i]) - axes[i][0].autoscale = True - - # Histogram data setup - # Calculate number of bins per axis - x_intervals = int(np.around(math.sqrt(len(data[0])),0)+1) - y_intervals = int(np.around(math.sqrt(len(data[1])),0)+1) - - # plot histograms - x_kwargs = dict(alpha=0.5, bins=x_intervals,rwidth=.92, density=True) - n, bins, hist_patches = axes[i][1].hist([data[0],data[1]],**x_kwargs, color=[colors[0],colors[1]], label=['X','Y']) - axes[i][2].hist2d(data[0], data[1], bins=x_intervals, cmap='Blues') - axes[i][1].legend() - - - # add a 'best fit' line - # calculate mean and std deviation per axis - x_mean = np.mean(data[0]) - y_mean = np.mean(data[1]) - x_sigma = np.around(np.std(data[0]),3) - y_sigma = np.around(np.std(data[1]),3) - # calculate function lines for best fit - x_best = ((1 / (np.sqrt(2 * np.pi) * x_sigma)) * - np.exp(-0.5 * (1 / x_sigma * (bins - x_mean))**2)) - y_best = ((1 / (np.sqrt(2 * np.pi) * y_sigma)) * - np.exp(-0.5 * (1 / y_sigma * (bins - y_mean))**2)) - # add best fit line to plots - axes[i][1].plot(bins, x_best, '-.',color=colors[0]) - axes[i][1].plot(bins, y_best, '--',color=colors[1]) - - x_count = int(sum( p == True for p in ((data[0] >= (x_mean - x_sigma)) & (data[0] <= (x_mean + x_sigma))) )/len(data[0])*100) - y_count = int(sum( p == True for p in ((data[1] >= (y_mean - y_sigma)) & (data[1] <= (y_mean + y_sigma))) )/len(data[1])*100) - # annotate std dev values - annotation_text = "Xσ: " + str(x_sigma) + " ("+str(x_count) + "%)" - if x_count < 68: - x_count = int(sum( p == True for p in ((data[0] >= (x_mean - 2*x_sigma)) & (data[0] <= (x_mean + 2*x_sigma))) )/len(data[0])*100) - annotation_text += " --> 2σ: " + str(x_count) + "%" - if x_count < 95 and x_sigma*2 > 0.1: - annotation_text += " -- check axis!" - else: annotation_text += " -- OK" - annotation_text += "\nYσ: " + str(y_sigma) + " ("+str(y_count) + "%)" - if y_count < 68: - y_count = int(sum( p == True for p in ((data[1] >= (y_mean - 2*y_sigma)) & (data[1] <= (y_mean + 2*y_sigma))) )/len(data[1])*100) - annotation_text += " --> 2σ: " + str(y_count) + "%" - if y_count < 95 and y_sigma*2 > 0.1: - annotation_text += " -- check axis!" - else: annotation_text += " -- OK" - axes[i][0].annotate(annotation_text, (10,10),xycoords='axes pixels') - axes[i][0].annotate('σ',(1.1*x_sigma,-1.1*y_sigma),xycoords='data',color='green') - axes[i][0].annotate('2σ',(1.1*2*x_sigma,-1.1*2*y_sigma),xycoords='data',color='red') - # # place title for graph - axes[i][0].set_ylabel("Tool " + str(i) + "\nY") - axes[i][0].set_xlabel("X") - axes[i][2].set_ylabel("Y") - axes[i][2].set_xlabel("X") - - if i == 0: - axes[i][0].set_title('Scatter Plot') - axes[i][1].set_title('Histogram') - axes[i][2].set_title('2D Histogram') - plt.tight_layout() - figManager = plt.get_current_fig_manager() - figManager.window.showMaximized() - plt.show() - - - -if __name__ == "__main__": - main() - exit() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fef1982..0000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -certifi==2020.12.5 -chardet==4.0.0 -idna==2.10 -numpy>=1.21 -opencv-python==4.5.1.48 -PyQt5==5.15.3 -PyQt5-Qt==5.15.2 -PyQt5-sip==12.8.1 -pyqtgraph==0.11.1 -requests==2.25.1 -urllib3>= 1.26.4 -wincertstore==0.2 -imutils>=0.5 -matplotlib>=3.2 diff --git a/resources/TAMV flow.txt b/resources/TAMV flow.txt new file mode 100644 index 0000000..ca621c8 --- /dev/null +++ b/resources/TAMV flow.txt @@ -0,0 +1,126 @@ +## Main program +### Setup global debugging flags for imports +### Setup argmument parser +### Setup logging +### # file handler logging +### # console handler logging +### # log startup messages +### start GUI application + +## GUI application class +### Class attributes +### Initialize class +### # setup class attributes +### # setup window properties +### # handle screen mode based on resolution +### # create stylehseets +### # load user parameters +### # create GUI elements +### ## Menubar +### ### File menu +### ### # Settings.. +### ### # Debug info +### ### # Save current settings +### ### # Quit +### ### Analysis menu +### ### # Graph calibration data.. +### ### # Export analysis.. + +### ## Statusbar +### ## Main interface +### ### image_label (camera display) +### ### connect button +### ### disconnect button +### ### controlPoint button +### ### calibration button +### ### debugInfo button +### ### exit button +### ### autoCalibrateEndstop button +### ### manualAlignment button +### ### cycles spinbox +### ### toolButtons groupbox +### ### xray checkbox +### ### relaxedDetection checkbox +### ### altAlgorithm checkbox +### ### detectOn checkbox +### ### instructionsPanel box +### ### mainSidebar panel +### ### # jogPanel tab +### ### ## setup button properties +### ### ## create jogPanel buttons +### ### ### increment size +### ### ### X movement +### ### ### Y movement +### ### ### Z movement +### ### ## layout jogPanel buttons +### ### # toolInfo tab +### # Layout GUI elements +### # Start video thread +### # Output welcome message + + +### main action functions +### # connect to printer +### # save user settings.json +### # prepare for CP capture +### # start automatic CP capture +### # capture CP coordinates +### # manually capture tool offset +### # run automated tool calibration +### # apply calibration results +### # disconnect from printer + +### Utility functions: +### # check if machine is ready to move +### # load tool into machine +### # convert image to Pixmap datatype +### # add a new offset to calibration results +### # event handler: jog panel click +### # sanitize printer URL +### # display settings dialog +### # display debug dialog +### # analyze calibration results +### # parse raw data for analysis +### # format stats output + +### GUI update helpers +### # reset interface to default state +### # disable CP buttons +### # apply interface ready to calibrate state +### # toggle detection +### # toggle xray output +### # toggle relaxed detection +### # toggle alternative algorithm +### # display standby image +### # display tool number on GUI + +### slot/signal handlers +### # update statusBar +### # update MessageBar +### # switch crosshair +### # switch crosshair for CP +### # update image display +### # update CP label +### # update saved settings.json +### # update active printer URL +### # create a new connection profile from ConnectionSettings event + +### thread control functions +### # start video thread +### # stop video thread +### # stop program and exit + + + + + + + + + + + + + + + diff --git a/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt b/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt new file mode 100644 index 0000000..6c422f5 --- /dev/null +++ b/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt @@ -0,0 +1,12 @@ + App.connectToPrinter + App.setupCP + Jog around + App.captureOffset + App.captureControlPoint + App.readyToCalibrate +second try: + App.captureOffset + App.captureControlPoint + App.readyToCalibrate + +#HBHBHBHB DEBUG: this may cause issues \ No newline at end of file diff --git a/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt b/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt new file mode 100644 index 0000000..ae4fac1 --- /dev/null +++ b/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt @@ -0,0 +1,16 @@ +App.setupCP + App.disableButtonsCP +App.startAutoCPCapture + App.disableButtonsCP + CalibrateNozzles.calibrateTool +# camera calibration started + CalibrateNozzles.analyzeEndstop (xn) + CalibrateNozzles.getDistance + CalibrateNozzles.normalize_coords + CalibrateNozzles.least_square_mapping +# camera calibration ended +# endstop calibration started + CalibrateNozzles.analyzeEndstop +# endstop calibration ended + App.readyToCalibrate + App.updateCrosshairDisplay \ No newline at end of file diff --git a/resources/background.png b/resources/background.png new file mode 100644 index 0000000000000000000000000000000000000000..9f5e4985e07219b75d69d17f76a5098e9df0e439 GIT binary patch literal 38747 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 WMIr24_RR%BwZ$H6!bshTRP7G literal 0 HcmV?d00001 diff --git a/resources/error.png b/resources/error.png new file mode 100644 index 0000000000000000000000000000000000000000..6397e83ef2b8b43db33da95f4b31a10b9d6b57c9 GIT binary patch literal 13311 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
wQIpV>@Q@P1y$b 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

l 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 emit Dialog.settingsSetImagePropertiesSignal (JSON) +TAMV.relayImageParameters + -> emit setImageProperties(JSON) +DetectionManager.relayImageProperties + -> emit detectionManagerSetImageProperties(JSON) +Camera.setImageProperties + -> consume JSON + +# Update statusbar (SettingsDialog) +(SettingsDialog)[init, closeEvent] + -> emit SettingsDialog.settingsStatusbarSignal (JSON) +TAMV.updateStatusBarMessage + -> consume JSON + +# Set camera to ready state (Camera) +(Camera)[run] + -> emit Camera.cameraReadySignal (JSON) +DetectionManager.cameraReady + -> consume JSON + -> create default image properties objects + -> create cameraProperties JSON (local) + -> emit detectionManagerReadySignal (JSON) +TAMV.startVideo + -> consume JSON + -> create self.__cameraProperties + -> emit startVideoSignal () +DetectionManager.triggerFrames + -> consume signal + +# Send frame to display (Camera) +(Camera)[run] + -> emit Camera.cameraFrameSignal (frame) +DetectionManager.receivedFrame + -> convert to QPixmap + -> emit detectionManagerNewFrameSignal (qpixmap) +TAMV.refreshImage -> consume QPixmap + +# Reset default camera settings (SettingsDialog) +(SettingsDialog)resetCameraToDefaults + -> emit settingsRequestDefaultImagePropertiesSignal () +TAMV.relayResetCameraDefaults + -> emit resetImageSignal +DetectionManager.relayResetImage + -> emit detectionManagerResetImageSignal () +Camera.resetImageDefaults + -> consume signal + -> reset image to default settings diff --git a/resources/standby.jpg b/resources/standby.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c236139a6b5a11c7372f03eb2cb2bf27f9a110b7 GIT binary patch literal 57205 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

Nfbl-oCIaOT1Q5|1A-)h`yVpbG%uCUz(jv$O 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 0): + print(counter, flush=True) + counter = counter - 1 + print('Trying to emit error', flush=True) + self.errorSignal.emit('error signal from camera') + print('Trying to emit finished', flush=True) + self.finishedSignal.emit() + + @pyqtSlot() + def quit(self): + print('Camera quitting.',flush=True) + + + +class DetectionManager(QObject): + finishedSignal = pyqtSignal() + error = pyqtSignal(object) + + def __init__(self, *args, **kwargs): + super().__init__() + print('*** detection manager initialized.',flush=True) + + def run(self): + print('*** detection manager running.',flush=True) + self.threadCount = 1 + # while(self.threadCount<5): + print('Starting camera',flush=True) + self.camera = Camera() + self.cameraThread = QThread() + self.camera.moveToThread(self.cameraThread) + self.camera.errorSignal.connect(self.passError) + self.cameraThread.started.connect(self.camera.run) + self.camera.finishedSignal.connect(self.threadComplete) + self.camera.finishedSignal.connect(self.cameraThread.quit) + self.camera.finishedSignal.connect(self.deleteLater) + self.camera.startedSignal.connect(self.cameraStarted) + self.cameraThread.finished.connect(self.cameraThread.deleteLater) + self.cameraThread.start() + + # self.threadCount = self.threadCount+1 + while(self.cameraThread.isRunning()): + print('Sleeping..') + time.sleep(1) + self.finishedSignal.emit() + print('*** detection manager complete.',flush=True) + + @pyqtSlot(object) + def passError(self, message): + print('Detection Manager passError got: ', message, flush=True ) + self.error.emit(message) + + @pyqtSlot() + def cameraStarted(self): + print('************* Camera has been started.',flush=True) + + @pyqtSlot() + def threadComplete(self): + print('Thread counter: ', self.threadCount,flush=True) + +class App(QMainWindow): + def __init__(self, *args, **kwargs): + super().__init__() + self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) + self.setWindowTitle('testing threads') + screen = QDesktopWidget().availableGeometry() + self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(800,600),screen)) + self.setMinimumWidth(800) + self.setMinimumHeight(600) + appScreen = self.frameGeometry() + appScreen.moveCenter(screen.center()) + self.move(appScreen.topLeft()) + self.centralWidget = QWidget() + self.setCentralWidget(self.centralWidget) + self.containerLayout = QVBoxLayout() + self.containerLayout.setSpacing(8) + self.centralWidget.setLayout(self.containerLayout) + self.button = QPushButton('Start threads.') + self.button.clicked.connect(self.startThreads) + self.containerLayout.addWidget(self.button) + + def startThreads(self): + print('Starting threads sequence..') + self.detect = DetectionManager() + self.detectThread = QThread() + self.detect.moveToThread(self.detectThread) + self.detectThread.started.connect(self.detect.run) + self.detect.finishedSignal.connect(self.detectThread.quit) + self.detect.finishedSignal.connect(self.detect.deleteLater) + self.detectThread.finished.connect(self.detectThread.deleteLater) + self.detectThread.start() + +if __name__=='__main__': + app = QApplication(sys.argv) + a = App() + a.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/video_cap.png b/resources/video_cap.png similarity index 100% rename from video_cap.png rename to resources/video_cap.png From 2cb38237a4e5d814ed2e72b9be063c7f256ba0ac Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 15:07:33 +0200 Subject: [PATCH 02/99] adding installcv script from master. --- .gitignore | 1 + install_opencv.sh | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 install_opencv.sh diff --git a/.gitignore b/.gitignore index 3ec061e..1a1ab1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.js *.json +*.old !drivers.json *.pyc **/__pycache__/ diff --git a/install_opencv.sh b/install_opencv.sh new file mode 100644 index 0000000..d0a8e81 --- /dev/null +++ b/install_opencv.sh @@ -0,0 +1,22 @@ +###################################### +# INSTALL OPENCV ON UBUNTU OR DEBIAN # +###################################### +sudo apt-get -y update +sudo apt-get -y upgrade +sudo apt-get -y dist-upgrade +sudo apt-get -y install python3-dev +sudo apt-get -y install pylint3 +sudo apt-get -y install python3-tk +sudo apt-get -y install python3-numpy +sudo apt-get -y install flake8 +sudo apt-get -y install python3-matplotlib +sudo apt-get -y install python3-pyqt5 +sudo apt-get -y install qtbase5-dev +sudo apt-get -y install curl +cd ~ +curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py +sudo python3 get-pip.py +rm get-pip.py +pip install imutils +sudo apt-get install python3-opencv +sudo apt-get install git From 49a26456dd4611fede75cae3e0853b29415a7244 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 15:17:00 +0200 Subject: [PATCH 03/99] Update TAMV.py --- TAMV.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 2a95ea2..4299185 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import argparse, logging, os, sys, traceback, time from tkinter import E from json import tool @@ -2092,4 +2094,4 @@ def show(self): app = QApplication(sys.argv) a = App() a.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) From dc112a5fa92db3151ef2cf4642531f7895332107 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 16:49:27 +0200 Subject: [PATCH 04/99] Update DetectionManager.py --- modules/DetectionManager.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index d014400..44b7a59 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -420,26 +420,27 @@ def nozzleDetection(self): preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) - # apply combo 1 (standard detector, preprocessor 0) - keypoints = self.detector.detect(preprocessorImage0) - keypointColor = (0,0,255) - if(len(keypoints) != 1): - # apply combo 2 (standard detector, preprocessor 1) - keypoints = self.detector.detect(preprocessorImage1) - keypointColor = (0,255,0) + if( self.__nozzleAutoDetectionActive is True ): + # apply combo 1 (standard detector, preprocessor 0) + keypoints = self.detector.detect(preprocessorImage0) + keypointColor = (0,0,255) if(len(keypoints) != 1): - # apply combo 3 (standard detector, preprocessor 0) - keypoints = self.relaxedDetector.detect(preprocessorImage0) - keypointColor = (255,0,0) + # apply combo 2 (standard detector, preprocessor 1) + keypoints = self.detector.detect(preprocessorImage1) + keypointColor = (0,255,0) if(len(keypoints) != 1): - # apply combo 4 (standard detector, preprocessor 1) - keypoints = self.relaxedDetector.detect(preprocessorImage1) - keypointColor = (39,127,255) + # apply combo 3 (standard detector, preprocessor 0) + keypoints = self.relaxedDetector.detect(preprocessorImage0) + keypointColor = (255,0,0) if(len(keypoints) != 1): - # failed to detect a nozzle, correct return value object - keypoints = None + # apply combo 4 (standard detector, preprocessor 1) + keypoints = self.relaxedDetector.detect(preprocessorImage1) + keypointColor = (39,127,255) + if(len(keypoints) != 1): + # failed to detect a nozzle, correct return value object + keypoints = None # process keypoint - if(keypoints is not None): + if(keypoints is not None and): # create center object (x,y) = np.around(keypoints[0].pt) x,y = int(x), int(y) From ed4e057b428bb598adfe133b4d3c1de069c4e478 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 16:50:38 +0200 Subject: [PATCH 05/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 44b7a59..07a7151 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -440,7 +440,7 @@ def nozzleDetection(self): # failed to detect a nozzle, correct return value object keypoints = None # process keypoint - if(keypoints is not None and): + if(keypoints is not None): # create center object (x,y) = np.around(keypoints[0].pt) x,y = int(x), int(y) From fb59e54bd883bb43f1c2d4425d5989cd9fcbd980 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 17:00:22 +0200 Subject: [PATCH 06/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 4299185..67b206c 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1319,8 +1319,8 @@ def autoCalibrate(self): self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False self.toggleEndstopAutoDetectionSignal.emit(False) - self.pollCoordinatesSignal.emit() self.haltCPAutoCapture() + self.pollCoordinatesSignal.emit() elif(self.__stateAutoNozzleAlignment is True): updateMessage = 'Failed to detect nozzle. Try manual override.' self.updateStatusbarMessage(updateMessage) From 1f1fcbcfe894f4ead57fd2e3331ea9e4588a8609 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 5 Oct 2022 21:50:28 +0200 Subject: [PATCH 07/99] fixed TypeError for setting image dimensions --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 67b206c..75736e7 100644 --- a/TAMV.py +++ b/TAMV.py @@ -509,7 +509,7 @@ def setupMainWindow(self): ##### Main image preview if(True): self.image = QLabel(self) - self.image.resize(self.__activeCamera['display_width'], self.__activeCamera['display_height']) + self.image.resize(self._cameraWidth, self._cameraHeight) self.image.setMinimumWidth(self.__activeCamera['display_width']) self.image.setMinimumHeight(self.__activeCamera['display_height']) self.image.setAlignment(Qt.AlignLeft) From 0e52dbc91d19d12a9233ee5a6bb4a4fe1f0bd8ab Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 6 Oct 2022 13:21:23 +0200 Subject: [PATCH 08/99] cleaning up paths --- .gitignore | 2 + TAMV.py | 373 +++++++++--------- resources/TAMV flow.txt | 126 ------ .../01 - Connect and capture CP twice.txt | 12 - .../02 - Automated CP Capture.txt | 16 - resources/signals.txt | 49 --- resources/testThreads.py | 117 ------ 7 files changed, 197 insertions(+), 498 deletions(-) delete mode 100644 resources/TAMV flow.txt delete mode 100644 resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt delete mode 100644 resources/TAMV2.0 Flows/02 - Automated CP Capture.txt delete mode 100644 resources/signals.txt delete mode 100644 resources/testThreads.py diff --git a/.gitignore b/.gitignore index 1a1ab1d..f86492e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ **/log/ **/config/ **/exports/ +**/flowcharts/ +getDict.py \ No newline at end of file diff --git a/TAMV.py b/TAMV.py index 75736e7..69b9d80 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1146,7 +1146,7 @@ def setupCPCapture(self): self.state = 200 self.space_coordinates = [] self.camera_coordinates = [] - self.calibration_moves = 0 + self.calibrationMoves = 0 self.retries = 0 self.stateCPSetup() self.repaint() @@ -1178,6 +1178,146 @@ def setupCPAutoCapture(self): # send exiting to log _logger.debug('*** exiting App.setupCPAutoCapture') + def haltCPAutoCapture(self): + self.resetCalibration() + self.statusBar.showMessage('CP calibration cancelled.') + # Reset GUI + self.stateConnected() + + def haltNozzleCapture(self): + self.resetNozzleAlignment() + self.statusBar.showMessage('Nozzle calibration halted.') + # Reset GUI + self.stateCalibrateReady() + + def manualToolOffsetCapture(self): + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # set tool alignment state to trigger offset calculation + self.state = 100 + self.pollCoordinatesSignal.emit() + + def resetCalibration(self): + # Reset program state, and frame capture control to defaults + self.mpp = None + self.transform_matrix = None + self.transform_input = None + self.state = 0 + self.__stateSetupCPCapture = False + self.__stateManualCPCapture = False + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + # self.callTool(-1) + self.getVideoFrameSignal.emit() + + def resetNozzleAlignment(self): + # Reset program state, and frame capture control to defaults + self.__stateEndstopAutoCalibrate = False + self.__stateAutoCPCapture = False + self.__stateSetupCPCapture = False + self.__stateManualNozzleAlignment = False + self.__stateAutoNozzleAlignment = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) + self.retries = 0 + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.calibrationMoves = 0 + self.callTool(-1) + self.getVideoFrameSignal.emit() + + def startAlignTools(self): + # send calling to log + _logger.debug('*** calling App.startAlignTools') + self.startTime = time.time() + self.__stateManualNozzleAlignment = True + self.__stateAutoNozzleAlignment = True + self.uv = [None, None] + + # Update GUI state + self.stateCalibtrateRunning() + self.updateStatusbarMessage('Starting tool alignment..') + + # Create array for tools included in alignment process + self.alignmentToolSet = [] + self.workingToolset = None + for checkbox in self.toolCheckboxes: + if(checkbox.isChecked()): + self.alignmentToolSet.append(int(checkbox.objectName()[13:])) + if(len(self.alignmentToolSet) > 0): + self.toggleNozzleAutoDetectionSignal.emit(True) + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.calibrateTools(self.alignmentToolSet) + else: + # tell user no tools selected + errorMsg = 'No tools selected for alignment' + self.updateStatusbarMessage(errorMsg) + _logger.info(errorMsg) + self.haltNozzleCapture() + # send exiting to log + _logger.debug('*** exiting App.startAlignTools') + + def identifyToolButton(self): + sender = self.sender() + for button in self.toolButtons: + if button.objectName() != sender.objectName(): + button.setChecked(False) + toolIndex = int(sender.objectName()[11:]) + self.callTool(toolIndex) + + def calibrateTools(self, toolset=None): + # toolset is passed the first time we call the function + # this creates the workingToolset, from which we pop each tool on each cycle + if(toolset is not None): + self.workingToolset = toolset + # check if we still have tools to calibrate in the workingToolset list + if(len(self.workingToolset)>0): + # grab the first item in the list (FIFO) + toolIndex = self.workingToolset[0] + _logger.info('Calibrating T' + str(toolIndex) +'..') + # delete the tool from the list before processing it + self.workingToolset.pop(0) + # update toolButtons GUI to indiciate which tool we're working on + for button in self.toolButtons: + buttonName = 'toolButton_' + str(toolIndex) + if(button.objectName() == buttonName): + button.setStyleSheet(self.styleOrange) + #### Initialize calibration parameters + # self.state to indicate if we need to run a camera alignment + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + # self.retries to reset detection + self.retries = 0 + # self.calibrationMoves for GUI updates + self.calibrationMoves = 0 + # main program state flags + self.__stateManualNozzleAlignment = True + self.__stateAutoNozzleAlignment = True + # DetectionManager state flag + self.toggleNozzleAutoDetectionSignal.emit(True) + # Capture start time of tool calibration run + self.toolTime = time.time() + # Call tool and start calibration + self.callTool(toolIndex) + else: + # entire list has been processed, output results + calibration_time = np.around(time.time() - self.startTime,1) + _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel') + # reset GUI + self.stateCalibrateComplete() + self.repaint() + # reset state flags for main program and Detection Manager + self.resetNozzleAlignment() + def autoCalibrate(self): self.tabPanel.setDisabled(True) if(self.uv is not None): @@ -1189,7 +1329,7 @@ def autoCalibrate(self): # Reset space and camera coordinates self.space_coordinates = [] self.camera_coordinates = [] - self.calibration_moves = 0 + self.calibrationMoves = 0 self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) self.camera_coordinates.append((self.uv[0], self.uv[1])) # move carriage for calibration @@ -1255,12 +1395,12 @@ def autoCalibrate(self): return elif(self.state == 200): if(self.__stateEndstopAutoCalibrate): - updateMessage = 'Endstop calibration step ' + str(self.calibration_moves) + '.. (MPP=' + str(self.mpp) +')' + updateMessage = 'Endstop calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' else: - updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibration_moves) + '.. (MPP=' + str(self.mpp) +')' + updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) # increment moves counter - self.calibration_moves += 1 + self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start self.cx,self.cy = self.normalize_coords(self.uv) self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0] @@ -1281,7 +1421,7 @@ def autoCalibrate(self): # auto calibration complete - end process if(self.__stateEndstopAutoCalibrate): # endstop calibration handling - updateMessage = 'Endstop auto-calibrated in ' + str(self.calibration_moves) + ' steps. (MPP=' + str(self.mpp) +')' + updateMessage = 'Endstop auto-calibrated in ' + str(self.calibrationMoves) + ' steps. (MPP=' + str(self.mpp) +')' self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False self.toggleEndstopAutoDetectionSignal.emit(False) @@ -1340,129 +1480,6 @@ def autoCalibrate(self): self.toggleNozzleDetectionSignal.emit(True) self.toggleDetectionSignal.emit(True) - def haltCPAutoCapture(self): - self.resetCalibration() - self.statusBar.showMessage('CP calibration cancelled.') - # Reset GUI - self.stateConnected() - - def haltNozzleCapture(self): - self.resetNozzleAlignment() - self.statusBar.showMessage('Nozzle calibration halted.') - # Reset GUI - self.stateCalibrateReady() - - def manualToolOffsetCapture(self): - self.manualToolOffsetCaptureButton.setDisabled(True) - self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) - # set tool alignment state to trigger offset calculation - self.state = 100 - self.pollCoordinatesSignal.emit() - - def resetCalibration(self): - # Reset program state, and frame capture control to defaults - self.mpp = None - self.transform_matrix = None - self.transform_input = None - self.state = 0 - self.__stateSetupCPCapture = False - self.__stateManualCPCapture = False - self.__stateAutoCPCapture = False - self.__stateEndstopAutoCalibrate = False - self.toggleEndstopAutoDetectionSignal.emit(False) - # self.callTool(-1) - self.getVideoFrameSignal.emit() - - def resetNozzleAlignment(self): - # Reset program state, and frame capture control to defaults - self.__stateEndstopAutoCalibrate = False - self.__stateAutoCPCapture = False - self.__stateSetupCPCapture = False - self.__stateManualNozzleAlignment = False - self.__stateAutoNozzleAlignment = False - self.toggleEndstopAutoDetectionSignal.emit(False) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleDetectionSignal.emit(False) - self.retries = 0 - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 - self.calibration_moves = 0 - self.callTool(-1) - self.getVideoFrameSignal.emit() - - def startAlignTools(self): - # send calling to log - _logger.debug('*** calling App.startAlignTools') - self.startTime = time.time() - self.__stateManualNozzleAlignment = True - self.__stateAutoNozzleAlignment = True - self.uv = [None, None] - - # Update GUI state - self.stateCalibtrateRunning() - self.updateStatusbarMessage('Starting tool alignment..') - - # Create array for tools included in alignment process - self.alignmentToolSet = [] - self.workingToolset = None - for checkbox in self.toolCheckboxes: - if(checkbox.isChecked()): - self.alignmentToolSet.append(int(checkbox.objectName()[13:])) - if(len(self.alignmentToolSet) > 0): - self.toggleNozzleAutoDetectionSignal.emit(True) - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 - self.calibrateTools(self.alignmentToolSet) - else: - # tell user no tools selected - errorMsg = 'No tools selected for alignment' - self.updateStatusbarMessage(errorMsg) - _logger.info(errorMsg) - self.haltNozzleCapture() - # send exiting to log - _logger.debug('*** exiting App.startAlignTools') - - def identifyToolButton(self): - sender = self.sender() - for button in self.toolButtons: - if button.objectName() != sender.objectName(): - button.setChecked(False) - toolIndex = int(sender.objectName()[11:]) - self.callTool(toolIndex) - - def calibrateTools(self, toolset=None): - if(toolset is not None): - self.workingToolset = toolset - if(len(self.workingToolset)>0): - toolIndex = self.workingToolset[0] - _logger.info('Calibrating T' + str(toolIndex) +'..') - self.workingToolset.pop(0) - for button in self.toolButtons: - buttonName = 'toolButton_' + str(toolIndex) - if(button.objectName() == buttonName): - button.setStyleSheet(self.styleOrange) - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 - self.retries = 0 - self.calibration_moves = 0 - self.__stateManualNozzleAlignment = True - self.__stateAutoNozzleAlignment = True - self.toggleNozzleAutoDetectionSignal.emit(True) - self.toolTime = time.time() - self.callTool(toolIndex) - else: - calibration_time = np.around(time.time() - self.startTime,1) - _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel') - self.stateCalibrateComplete() - self.repaint() - self.resetNozzleAlignment() - ########################################################################### Module interfaces and handlers ########################################################################### Interface with Detection Manager @@ -1653,29 +1670,6 @@ def haltPrinterOperation(self, **kwargs): self.disconnectSignal.emit(params) self.updateStatusbarMessage('Printer disconnected.') - def callTool(self, toolNumber=-1): - # disable detection - self.toggleDetectionSignal.emit(False) - toolNumber = int(toolNumber) - try: - if(toolNumber == int(self.__activePrinter['currentTool'])): - self.callToolSignal.emit(-1) - else: - self.callToolSignal.emit(toolNumber) - except: - errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) - _logger.error(errorMsg) - self.printerError(errorMsg) - - @pyqtSlot() - def toolLoaded(self): - self.pollCurrentToolSignal.emit() - if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): - params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__cpCoordinates['X'], 'Y': self.__cpCoordinates['Y'], 'Z': self.__cpCoordinates['Z']}} - else: - params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__currentPosition['X'], 'Y': self.__currentPosition['Y'], 'Z': self.__cpCoordinates['Z']}} - self.moveAbsoluteSignal.emit(params) - @pyqtSlot(object) def printerConnected(self, printerJSON): # send calling to log @@ -1733,6 +1727,29 @@ def printerError(self, message): self.printerDisconnected(message=message) self.statusBar.setStyleSheet(self.styleRed) + def callTool(self, toolNumber=-1): + # disable detection + self.toggleDetectionSignal.emit(False) + toolNumber = int(toolNumber) + try: + if(toolNumber == int(self.__activePrinter['currentTool'])): + self.callToolSignal.emit(-1) + else: + self.callToolSignal.emit(toolNumber) + except: + errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) + _logger.error(errorMsg) + self.printerError(errorMsg) + + @pyqtSlot() + def toolLoaded(self): + self.pollCurrentToolSignal.emit() + if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): + params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__cpCoordinates['X'], 'Y': self.__cpCoordinates['Y'], 'Z': self.__cpCoordinates['Z']}} + else: + params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__currentPosition['X'], 'Y': self.__currentPosition['Y'], 'Z': self.__currentPosition['Z']}} + self.moveAbsoluteSignal.emit(params) + @pyqtSlot(int) def registerActiveTool(self, toolIndex): self.__activePrinter['currentTool'] = toolIndex @@ -1742,6 +1759,32 @@ def registerActiveTool(self, toolIndex): else: button.setChecked(True) + @pyqtSlot() + def printerMoveComplete(self): + self.tabPanel.setDisabled(False) + if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Endstop auto detection active') + # Calibrating camera, go based on state + self.tabPanel.setDisabled(True) + elif(self.__stateAutoNozzleAlignment is True): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Nozzle auto detection active') + # calibrating nozzle auto + self.tabPanel.setDisabled(True) + elif(self.__stateManualNozzleAlignment is True): + # enable detection + self.toggleDetectionSignal.emit(True) + _logger.debug('Nozzle manual detection active') + # calibrating nozzle manual + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.manualCPCaptureButton.setVisible(True) + return + self.pollCoordinatesSignal.emit() + @pyqtSlot(object) def saveCurrentPosition(self, coordinates): self.__currentPosition = coordinates @@ -1785,32 +1828,6 @@ def saveCurrentPosition(self, coordinates): self.__restorePosition = copy.deepcopy(self.__currentPosition) self.__firstConnection = False - @pyqtSlot() - def printerMoveComplete(self): - self.tabPanel.setDisabled(False) - if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): - # enable detection - self.toggleDetectionSignal.emit(True) - _logger.debug('Endstop auto detection active') - # Calibrating camera, go based on state - self.tabPanel.setDisabled(True) - elif(self.__stateAutoNozzleAlignment is True): - # enable detection - self.toggleDetectionSignal.emit(True) - _logger.debug('Nozzle auto detection active') - # calibrating nozzle auto - self.tabPanel.setDisabled(True) - elif(self.__stateManualNozzleAlignment is True): - # enable detection - self.toggleDetectionSignal.emit(True) - _logger.debug('Nozzle manual detection active') - # calibrating nozzle manual - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.manualCPCaptureButton.setVisible(True) - return - self.pollCoordinatesSignal.emit() - @pyqtSlot(object) def calibrateOffsetsApplied(self, params=None): try: diff --git a/resources/TAMV flow.txt b/resources/TAMV flow.txt deleted file mode 100644 index ca621c8..0000000 --- a/resources/TAMV flow.txt +++ /dev/null @@ -1,126 +0,0 @@ -## Main program -### Setup global debugging flags for imports -### Setup argmument parser -### Setup logging -### # file handler logging -### # console handler logging -### # log startup messages -### start GUI application - -## GUI application class -### Class attributes -### Initialize class -### # setup class attributes -### # setup window properties -### # handle screen mode based on resolution -### # create stylehseets -### # load user parameters -### # create GUI elements -### ## Menubar -### ### File menu -### ### # Settings.. -### ### # Debug info -### ### # Save current settings -### ### # Quit -### ### Analysis menu -### ### # Graph calibration data.. -### ### # Export analysis.. - -### ## Statusbar -### ## Main interface -### ### image_label (camera display) -### ### connect button -### ### disconnect button -### ### controlPoint button -### ### calibration button -### ### debugInfo button -### ### exit button -### ### autoCalibrateEndstop button -### ### manualAlignment button -### ### cycles spinbox -### ### toolButtons groupbox -### ### xray checkbox -### ### relaxedDetection checkbox -### ### altAlgorithm checkbox -### ### detectOn checkbox -### ### instructionsPanel box -### ### mainSidebar panel -### ### # jogPanel tab -### ### ## setup button properties -### ### ## create jogPanel buttons -### ### ### increment size -### ### ### X movement -### ### ### Y movement -### ### ### Z movement -### ### ## layout jogPanel buttons -### ### # toolInfo tab -### # Layout GUI elements -### # Start video thread -### # Output welcome message - - -### main action functions -### # connect to printer -### # save user settings.json -### # prepare for CP capture -### # start automatic CP capture -### # capture CP coordinates -### # manually capture tool offset -### # run automated tool calibration -### # apply calibration results -### # disconnect from printer - -### Utility functions: -### # check if machine is ready to move -### # load tool into machine -### # convert image to Pixmap datatype -### # add a new offset to calibration results -### # event handler: jog panel click -### # sanitize printer URL -### # display settings dialog -### # display debug dialog -### # analyze calibration results -### # parse raw data for analysis -### # format stats output - -### GUI update helpers -### # reset interface to default state -### # disable CP buttons -### # apply interface ready to calibrate state -### # toggle detection -### # toggle xray output -### # toggle relaxed detection -### # toggle alternative algorithm -### # display standby image -### # display tool number on GUI - -### slot/signal handlers -### # update statusBar -### # update MessageBar -### # switch crosshair -### # switch crosshair for CP -### # update image display -### # update CP label -### # update saved settings.json -### # update active printer URL -### # create a new connection profile from ConnectionSettings event - -### thread control functions -### # start video thread -### # stop video thread -### # stop program and exit - - - - - - - - - - - - - - - diff --git a/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt b/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt deleted file mode 100644 index 6c422f5..0000000 --- a/resources/TAMV2.0 Flows/01 - Connect and capture CP twice.txt +++ /dev/null @@ -1,12 +0,0 @@ - App.connectToPrinter - App.setupCP - Jog around - App.captureOffset - App.captureControlPoint - App.readyToCalibrate -second try: - App.captureOffset - App.captureControlPoint - App.readyToCalibrate - -#HBHBHBHB DEBUG: this may cause issues \ No newline at end of file diff --git a/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt b/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt deleted file mode 100644 index ae4fac1..0000000 --- a/resources/TAMV2.0 Flows/02 - Automated CP Capture.txt +++ /dev/null @@ -1,16 +0,0 @@ -App.setupCP - App.disableButtonsCP -App.startAutoCPCapture - App.disableButtonsCP - CalibrateNozzles.calibrateTool -# camera calibration started - CalibrateNozzles.analyzeEndstop (xn) - CalibrateNozzles.getDistance - CalibrateNozzles.normalize_coords - CalibrateNozzles.least_square_mapping -# camera calibration ended -# endstop calibration started - CalibrateNozzles.analyzeEndstop -# endstop calibration ended - App.readyToCalibrate - App.updateCrosshairDisplay \ No newline at end of file diff --git a/resources/signals.txt b/resources/signals.txt deleted file mode 100644 index 5083d1f..0000000 --- a/resources/signals.txt +++ /dev/null @@ -1,49 +0,0 @@ -# Update image properties (SettingsDialog) -(SettingsDialog)[init, changeBrightness, changeContrast, changeSaturation, changeHue] - -> emit Dialog.settingsSetImagePropertiesSignal (JSON) -TAMV.relayImageParameters - -> emit setImageProperties(JSON) -DetectionManager.relayImageProperties - -> emit detectionManagerSetImageProperties(JSON) -Camera.setImageProperties - -> consume JSON - -# Update statusbar (SettingsDialog) -(SettingsDialog)[init, closeEvent] - -> emit SettingsDialog.settingsStatusbarSignal (JSON) -TAMV.updateStatusBarMessage - -> consume JSON - -# Set camera to ready state (Camera) -(Camera)[run] - -> emit Camera.cameraReadySignal (JSON) -DetectionManager.cameraReady - -> consume JSON - -> create default image properties objects - -> create cameraProperties JSON (local) - -> emit detectionManagerReadySignal (JSON) -TAMV.startVideo - -> consume JSON - -> create self.__cameraProperties - -> emit startVideoSignal () -DetectionManager.triggerFrames - -> consume signal - -# Send frame to display (Camera) -(Camera)[run] - -> emit Camera.cameraFrameSignal (frame) -DetectionManager.receivedFrame - -> convert to QPixmap - -> emit detectionManagerNewFrameSignal (qpixmap) -TAMV.refreshImage -> consume QPixmap - -# Reset default camera settings (SettingsDialog) -(SettingsDialog)resetCameraToDefaults - -> emit settingsRequestDefaultImagePropertiesSignal () -TAMV.relayResetCameraDefaults - -> emit resetImageSignal -DetectionManager.relayResetImage - -> emit detectionManagerResetImageSignal () -Camera.resetImageDefaults - -> consume signal - -> reset image to default settings diff --git a/resources/testThreads.py b/resources/testThreads.py deleted file mode 100644 index a9eb9c2..0000000 --- a/resources/testThreads.py +++ /dev/null @@ -1,117 +0,0 @@ -# Qt imports -from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QSize, QThread, QObject -from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QStyle, QWidget, QMenu, QAction, QStatusBar, QLabel, QHBoxLayout, QVBoxLayout, QTextEdit, QPushButton, QApplication -import sys, time - -class Camera(QObject): - startedSignal = pyqtSignal() - finishedSignal = pyqtSignal() - errorSignal = pyqtSignal(object) - - def __init__(self, *args, **kwargs): - super(Camera, self).__init__() - print('Camera init',flush=True) - - def run(self): - # self.startedSignal = pyqtSignal() - # self.finishedSignal = pyqtSignal() - # self.errorSignal = pyqtSignal(object) - print('Camera running', flush=True) - self.startedSignal.emit() - counter = 5 - while (counter > 0): - print(counter, flush=True) - counter = counter - 1 - print('Trying to emit error', flush=True) - self.errorSignal.emit('error signal from camera') - print('Trying to emit finished', flush=True) - self.finishedSignal.emit() - - @pyqtSlot() - def quit(self): - print('Camera quitting.',flush=True) - - - -class DetectionManager(QObject): - finishedSignal = pyqtSignal() - error = pyqtSignal(object) - - def __init__(self, *args, **kwargs): - super().__init__() - print('*** detection manager initialized.',flush=True) - - def run(self): - print('*** detection manager running.',flush=True) - self.threadCount = 1 - # while(self.threadCount<5): - print('Starting camera',flush=True) - self.camera = Camera() - self.cameraThread = QThread() - self.camera.moveToThread(self.cameraThread) - self.camera.errorSignal.connect(self.passError) - self.cameraThread.started.connect(self.camera.run) - self.camera.finishedSignal.connect(self.threadComplete) - self.camera.finishedSignal.connect(self.cameraThread.quit) - self.camera.finishedSignal.connect(self.deleteLater) - self.camera.startedSignal.connect(self.cameraStarted) - self.cameraThread.finished.connect(self.cameraThread.deleteLater) - self.cameraThread.start() - - # self.threadCount = self.threadCount+1 - while(self.cameraThread.isRunning()): - print('Sleeping..') - time.sleep(1) - self.finishedSignal.emit() - print('*** detection manager complete.',flush=True) - - @pyqtSlot(object) - def passError(self, message): - print('Detection Manager passError got: ', message, flush=True ) - self.error.emit(message) - - @pyqtSlot() - def cameraStarted(self): - print('************* Camera has been started.',flush=True) - - @pyqtSlot() - def threadComplete(self): - print('Thread counter: ', self.threadCount,flush=True) - -class App(QMainWindow): - def __init__(self, *args, **kwargs): - super().__init__() - self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) - self.setWindowTitle('testing threads') - screen = QDesktopWidget().availableGeometry() - self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(800,600),screen)) - self.setMinimumWidth(800) - self.setMinimumHeight(600) - appScreen = self.frameGeometry() - appScreen.moveCenter(screen.center()) - self.move(appScreen.topLeft()) - self.centralWidget = QWidget() - self.setCentralWidget(self.centralWidget) - self.containerLayout = QVBoxLayout() - self.containerLayout.setSpacing(8) - self.centralWidget.setLayout(self.containerLayout) - self.button = QPushButton('Start threads.') - self.button.clicked.connect(self.startThreads) - self.containerLayout.addWidget(self.button) - - def startThreads(self): - print('Starting threads sequence..') - self.detect = DetectionManager() - self.detectThread = QThread() - self.detect.moveToThread(self.detectThread) - self.detectThread.started.connect(self.detect.run) - self.detect.finishedSignal.connect(self.detectThread.quit) - self.detect.finishedSignal.connect(self.detect.deleteLater) - self.detectThread.finished.connect(self.detectThread.deleteLater) - self.detectThread.start() - -if __name__=='__main__': - app = QApplication(sys.argv) - a = App() - a.show() - sys.exit(app.exec_()) \ No newline at end of file From 4e6eef7011ba3fd76b5dee5f474f2e08415ec4e1 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Fri, 7 Oct 2022 10:26:29 +0200 Subject: [PATCH 09/99] reading params fixed self.__activeCamera['display_width'] replaced with self._cameraWidth to handle parsing settings json properly as integers and not strings. --- TAMV.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 69b9d80..79cf477 100644 --- a/TAMV.py +++ b/TAMV.py @@ -510,8 +510,8 @@ def setupMainWindow(self): if(True): self.image = QLabel(self) self.image.resize(self._cameraWidth, self._cameraHeight) - self.image.setMinimumWidth(self.__activeCamera['display_width']) - self.image.setMinimumHeight(self.__activeCamera['display_height']) + self.image.setMinimumWidth(self._cameraWidth) + self.image.setMinimumHeight(self._cameraHeight) self.image.setAlignment(Qt.AlignLeft) # self.image.setStyleSheet('text-align:center; border: 1px solid black') self.image.setPixmap(self.standbyImage) From 0d304f4c361f67bc4bf2d5612ff451b67e9e477d Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 00:46:06 +0200 Subject: [PATCH 10/99] small bug fixes adding unload tools as a discrete signal --- TAMV.py | 40 ++++++++++++++++++++++++--------------- modules/PrinterManager.py | 5 +++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/TAMV.py b/TAMV.py index 79cf477..8199803 100644 --- a/TAMV.py +++ b/TAMV.py @@ -42,6 +42,7 @@ class App(QMainWindow): moveRelativeSignal = pyqtSignal(object) moveAbsoluteSignal = pyqtSignal(object) callToolSignal = pyqtSignal(int) + unloadToolSignal = pyqtSignal() pollCoordinatesSignal = pyqtSignal() pollCurrentToolSignal = pyqtSignal() setOffsetsSignal = pyqtSignal(object) @@ -1174,7 +1175,7 @@ def setupCPAutoCapture(self): # Start detector in DetectionManager self.toggleEndstopAutoDetectionSignal.emit(True) self.uv = [None, None] - self.callTool() + self.unloadToolSignal.emit() # send exiting to log _logger.debug('*** exiting App.setupCPAutoCapture') @@ -1199,16 +1200,21 @@ def manualToolOffsetCapture(self): def resetCalibration(self): # Reset program state, and frame capture control to defaults - self.mpp = None - self.transform_matrix = None - self.transform_input = None - self.state = 0 - self.__stateSetupCPCapture = False - self.__stateManualCPCapture = False - self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False + self.__stateAutoCPCapture = False + self.__stateSetupCPCapture = False + self.__stateManualNozzleAlignment = False + self.__stateAutoNozzleAlignment = False self.toggleEndstopAutoDetectionSignal.emit(False) - # self.callTool(-1) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) + self.retries = 0 + if(self.transform_matrix is None or self.mpp is None): + self.state = 0 + else: + self.state = 200 + self.calibrationMoves = 0 + self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def resetNozzleAlignment(self): @@ -1227,7 +1233,7 @@ def resetNozzleAlignment(self): else: self.state = 200 self.calibrationMoves = 0 - self.callTool(-1) + self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def startAlignTools(self): @@ -1555,7 +1561,7 @@ def detectionManagerError(self, message): self.cpLabel.setStyleSheet(self.styleOrange) self.connectionStatusLabel.setStyleSheet(self.styleOrange) self.resetCalibration() - # self.moveAbsoluteSignal.emit(self.originalPrinterPosition) + self.moveAbsoluteSignal.emit(self.originalPrinterPosition) except: errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) @@ -1598,6 +1604,7 @@ def createPrinterManagerThread(self,announce=True): self.printerManager.coordinatesSignal.connect(self.saveCurrentPosition) self.callToolSignal.connect(self.printerManager.callTool) + self.unloadToolSignal.connect(self.printerManager.unloadTools) self.printerManager.toolLoadedSignal.connect(self.toolLoaded) self.pollCurrentToolSignal.connect(self.printerManager.currentTool) self.printerManager.toolIndexSignal.connect(self.registerActiveTool) @@ -1731,11 +1738,14 @@ def callTool(self, toolNumber=-1): # disable detection self.toggleDetectionSignal.emit(False) toolNumber = int(toolNumber) + if(toolNumber == -1): + self.unloadToolSignal.emit() + return try: - if(toolNumber == int(self.__activePrinter['currentTool'])): - self.callToolSignal.emit(-1) - else: - self.callToolSignal.emit(toolNumber) + # if(toolNumber == int(self.__activePrinter['currentTool'])): + # self.unloadToolSignal.emit() + # else: + self.callToolSignal.emit(toolNumber) except: errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) _logger.error(errorMsg) diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index cca305f..9dde1e9 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -388,6 +388,11 @@ def callTool(self, toolNumber=-1): raise Exception('PrinterManager: Unable to load tool: ' + str(toolNumber)) self.toolLoadedSignal.emit() + @pyqtSlot() + def unloadTools(self): + self.__activePrinter.unloadTools() + self.toolLoadedSignal.emit() + @pyqtSlot(object) def calibrationSetOffset(self, params=None): try: From 5e90ef2597cb90f2811adace862e44c23f260b95 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 00:56:56 +0200 Subject: [PATCH 11/99] updating cancel state for endstop --- TAMV.py | 14 ++++++++++++++ modules/DetectionManager.py | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 8199803..1690900 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1207,7 +1207,14 @@ def resetCalibration(self): self.__stateAutoNozzleAlignment = False self.toggleEndstopAutoDetectionSignal.emit(False) self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) + self.uv = [None, None] + self.guess_position = [1,1] + self.olduv = self.uv + self.detect_count = 0 + self.space_coordinates = [] + self.camera_coordinates = [] self.retries = 0 if(self.transform_matrix is None or self.mpp is None): self.state = 0 @@ -1226,7 +1233,14 @@ def resetNozzleAlignment(self): self.__stateAutoNozzleAlignment = False self.toggleEndstopAutoDetectionSignal.emit(False) self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) + self.uv = [None, None] + self.guess_position = [1,1] + self.olduv = self.uv + self.detect_count = 0 + self.space_coordinates = [] + self.camera_coordinates = [] self.retries = 0 if(self.transform_matrix is None or self.mpp is None): self.state = 0 diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 07a7151..e81f06e 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -263,9 +263,9 @@ def burstEndstopDetection(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - while(detectionCount < 6): + while(detectionCount < 5): # Discard a few frames to get a clean detection due to printer movement - for i in range(1): + for i in range(5): self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): From c8cddfeefe4a503446487f8e86c58b3de815ee76 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:00:58 +0200 Subject: [PATCH 12/99] Update TAMV.py --- TAMV.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 1690900..93bdf46 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1510,7 +1510,7 @@ def createDetectionManagerThread(self, announce=True): self.detectionThread = QThread() self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None) self.detectionManager.moveToThread(self.detectionThread) - self.detectionThread.start(priority=QThread.TimeCriticalPriority) + self.detectionThread.start()#priority=QThread.TimeCriticalPriority) # Thread management signals and slots self.detectionManager.errorSignal.connect(self.detectionManagerError) self.detectionThread.started.connect(self.detectionManager.processFrame) @@ -2071,7 +2071,7 @@ def show(self): ### Setup OS options # os.putenv("QT_LOGGING_RULES","qt5ct.debug=true") os.putenv("OPENCV_VIDEOIO_DEBUG", "0") - os.putenv("OPENCV_VIDEOIO_PRIORITY_LIST", "DSHOW,FFMPEG,GSTREAMER") + # os.putenv("OPENCV_VIDEOIO_PRIORITY_LIST", "DSHOW,FFMPEG,GSTREAMER") ### Setup argmument parser parser = argparse.ArgumentParser(description='Program to allign multiple tools on Duet/klipper based printers, using machine vision.', allow_abbrev=False) parser.add_argument('-d','--debug',action='store_true',help='Enable debug output to terminal') From 61b118538548c5ce1bfe725f4a673370b50d826a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:04:14 +0200 Subject: [PATCH 13/99] Update TAMV.py --- TAMV.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 93bdf46..e7a87ac 100644 --- a/TAMV.py +++ b/TAMV.py @@ -796,7 +796,8 @@ def stateDisconnected(self): for i in range(11,count): item = self.jogPanel.itemAt(i) widget = item.widget() - widget.deleteLater() + widget.setVisible(False) + # widget.deleteLater() self.resetCalibration() def stateConnected(self): @@ -841,6 +842,14 @@ def stateConnected(self): # highest tool number storage numTools = max(self.__activePrinter['tools'], key= lambda x:int(x['number']))['number'] _logger.debug('Highest tool number is: ' + str(numTools)) + # Delete old toolbuttons, if they exist + # Delete tool buttons + count = self.jogPanel.count() + for i in range(11,count): + item = self.jogPanel.itemAt(i) + widget = item.widget() + widget.setVisible(False) + # widget.deleteLater() for tool in range(numTools+1): # add tool buttons toolButton = QPushButton('T' + str(tool)) From 0d7f9973f53ee2e6a226462175fe17a858df115a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:05:45 +0200 Subject: [PATCH 14/99] Update TAMV.py --- TAMV.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TAMV.py b/TAMV.py index e7a87ac..1ebf279 100644 --- a/TAMV.py +++ b/TAMV.py @@ -789,8 +789,6 @@ def stateDisconnected(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) - # Jog panel tab - self.tabPanel.setDisabled(True) # Delete tool buttons count = self.jogPanel.count() for i in range(11,count): @@ -799,6 +797,8 @@ def stateDisconnected(self): widget.setVisible(False) # widget.deleteLater() self.resetCalibration() + # Jog panel tab + self.tabPanel.setDisabled(True) def stateConnected(self): # Settings option in menu @@ -848,8 +848,7 @@ def stateConnected(self): for i in range(11,count): item = self.jogPanel.itemAt(i) widget = item.widget() - widget.setVisible(False) - # widget.deleteLater() + widget.deleteLater() for tool in range(numTools+1): # add tool buttons toolButton = QPushButton('T' + str(tool)) From 978ea9f4715a3d76bccdbb3871f9d8d526d91557 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:18:49 +0200 Subject: [PATCH 15/99] Update TAMV.py --- TAMV.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 1ebf279..5d14dfb 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1820,6 +1820,7 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveCurrentPosition(self, coordinates): self.__currentPosition = coordinates + _logger.debug('Coordinates received:', coordinates) self.toggleDetectionSignal.emit(True) if(self.__stateManualCPCapture is True): _logger.debug('saveCurrentPosition: manual CP capture') @@ -1831,7 +1832,10 @@ def saveCurrentPosition(self, coordinates): self.repaint() elif(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): if(self.state != 100): - _logger.debug('saveCurrentPosition: autoCalibrate nozzle for T' + str(int(self.__activePrinter['currentTool']))) + if(int(self.__activePrinter['currentTool']) > -1): + _logger.debug('saveCurrentPosition: autoCalibrate nozzle for T' + str(int(self.__activePrinter['currentTool']))) + else: + _logger.debug('saveCurrentPosition: autoCalibrate endstop.') self.autoCalibrate() else: _logger.debug('saveCurrentPosition: autoCalibrate nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) From 4c7575205eef23c9a40373fb8871dca4f5232a4a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:23:51 +0200 Subject: [PATCH 16/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 5d14dfb..1e35bf5 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1820,7 +1820,7 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveCurrentPosition(self, coordinates): self.__currentPosition = coordinates - _logger.debug('Coordinates received:', coordinates) + _logger.debug('Coordinates received:' + str(coordinates)) self.toggleDetectionSignal.emit(True) if(self.__stateManualCPCapture is True): _logger.debug('saveCurrentPosition: manual CP capture') From 333aea3079d4ca3c8e1a0acaee696a6419b99d64 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:28:40 +0200 Subject: [PATCH 17/99] Update TAMV.py --- TAMV.py | 1 + 1 file changed, 1 insertion(+) diff --git a/TAMV.py b/TAMV.py index 1e35bf5..2467361 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1384,6 +1384,7 @@ def autoCalibrate(self): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) # save position as previous position self.olduv = self.uv + _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) # save machine coordinates for detected nozzle self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) # save camera coordinates From 714262bd9b6ccd12d8a8c47d1141b7b3f57a1b05 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:34:39 +0200 Subject: [PATCH 18/99] updated camera buffer to 1 frame --- modules/Camera.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/Camera.py b/modules/Camera.py index c2fb2d0..4f469e7 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -61,6 +61,7 @@ def __init__(self, *args, **kwargs): raise Exception self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') From 6b37867d10f2522efedaafd44adeab31edfb3de1 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:41:15 +0200 Subject: [PATCH 19/99] Update Camera.py --- modules/Camera.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/Camera.py b/modules/Camera.py index 4f469e7..4616c0c 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -118,8 +118,10 @@ def quit(self): def getFrame(self): self.__success, self.__frame = self.cap.read() if(self.__success): + print('Got frame.') return(self.__frame) else: + print('Frame read failed') self.cap.release() raise Exception From 88e4aa5cc787ec1a4c6d8fceac051c3bd3c94634 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:45:39 +0200 Subject: [PATCH 20/99] Update Camera.py --- modules/Camera.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/Camera.py b/modules/Camera.py index 4616c0c..faa9bd6 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -61,7 +61,7 @@ def __init__(self, *args, **kwargs): raise Exception self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE,2) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') @@ -118,10 +118,8 @@ def quit(self): def getFrame(self): self.__success, self.__frame = self.cap.read() if(self.__success): - print('Got frame.') return(self.__frame) else: - print('Frame read failed') self.cap.release() raise Exception From f5c4ccf5db4334366d3dc5c8e0eeb642a52114f1 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:48:45 +0200 Subject: [PATCH 21/99] Update DetectionManager.py --- modules/DetectionManager.py | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index e81f06e..5dd8900 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -352,30 +352,30 @@ def analyzeNozzleFrame(self): retries = 0 while(detectionCount < 1): # Discard a few frames to get a clean detection due to printer movement - for i in range(1): + for i in range(2): self.frame = self.videoFeed.getFrame() - (self.__uv, self.frame) = self.nozzleDetection() - if(self.__uv is not None): - if(self.__uv[0] is not None and self.__uv[1] is not None): - average_location[0] += np.around(self.__uv[0],0) - average_location[1] += np.around(self.__uv[1],0) - detectionCount += 1 - else: - retries += 1 - else: - retries += 1 - if(retries > 9): - average_location[0] = None - average_location[1] = None - break - if(average_location[0] is not None): - # calculate average X Y position from detection - average_location[0] /= detectionCount - average_location[1] /= detectionCount - # round to 0 decimal places - average_location = np.around(average_location,0) - self.__uv = average_location - else: + # (self.__uv, self.frame) = self.nozzleDetection() + # if(self.__uv is not None): + # if(self.__uv[0] is not None and self.__uv[1] is not None): + # average_location[0] += np.around(self.__uv[0],0) + # average_location[1] += np.around(self.__uv[1],0) + # detectionCount += 1 + # else: + # retries += 1 + # else: + # retries += 1 + # if(retries > 9): + # average_location[0] = None + # average_location[1] = None + # break + # if(average_location[0] is not None): + # # calculate average X Y position from detection + # average_location[0] /= detectionCount + # average_location[1] /= detectionCount + # # round to 0 decimal places + # average_location = np.around(average_location,0) + # self.__uv = average_location + # else: self.__uv = [None,None] def burstNozzleDetection(self): From ba46f23f9773421862fd4f395f789ab39117992a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:52:53 +0200 Subject: [PATCH 22/99] Update DetectionManager.py --- modules/DetectionManager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 5dd8900..98544c7 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -216,7 +216,8 @@ def receivedFrame(self, frame): retObject.append(qpixmap) retObject.append(self.__uv) self.detectionManagerNewFrameSignal.emit(retObject) - except: pass + except: + raise SystemExit('Fatal error in Detection Manager.') @pyqtSlot(bool) def enableDetection(self, state=False): From 18fbf9f1c31c00b2efbb163f273f7e2db3af2b2a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:53:29 +0200 Subject: [PATCH 23/99] Update DetectionManager.py --- modules/DetectionManager.py | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 98544c7..2bb90fb 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -232,30 +232,30 @@ def analyzeEndstopFrame(self): retries = 0 while(detectionCount < 3): # Discard a few frames to get a clean detection due to printer movement - for i in range(3): + for i in range(2): self.frame = self.videoFeed.getFrame() - (self.__uv, self.frame) = self.endstopContourDetection(self.frame) - if(self.__uv is not None): - if(self.__uv[0] is not None and self.__uv[1] is not None): - average_location[0] += np.around(self.__uv[0],0) - average_location[1] += np.around(self.__uv[1],0) - detectionCount += 1 - else: - retries += 1 - else: - retries += 1 - if(retries > 3): - average_location[0] = None - average_location[1] = None - break - if(average_location[0] is not None): - # calculate average X Y position from detection - average_location[0] /= detectionCount - average_location[1] /= detectionCount - # round to 0 decimal places - average_location = np.around(average_location,3) - self.__uv = average_location - else: + # (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + # if(self.__uv is not None): + # if(self.__uv[0] is not None and self.__uv[1] is not None): + # average_location[0] += np.around(self.__uv[0],0) + # average_location[1] += np.around(self.__uv[1],0) + # detectionCount += 1 + # else: + # retries += 1 + # else: + # retries += 1 + # if(retries > 3): + # average_location[0] = None + # average_location[1] = None + # break + # if(average_location[0] is not None): + # # calculate average X Y position from detection + # average_location[0] /= detectionCount + # average_location[1] /= detectionCount + # # round to 0 decimal places + # average_location = np.around(average_location,3) + # self.__uv = average_location + # else: self.__uv = [None,None] @pyqtSlot(int) From ca0095d06e4343c33bb870d9082fffb2087ed68a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:55:16 +0200 Subject: [PATCH 24/99] Update DetectionManager.py --- modules/DetectionManager.py | 70 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 2bb90fb..e84e469 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -234,7 +234,7 @@ def analyzeEndstopFrame(self): # Discard a few frames to get a clean detection due to printer movement for i in range(2): self.frame = self.videoFeed.getFrame() - # (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + (self.__uv, self.frame) = self.endstopContourDetection(self.frame) # if(self.__uv is not None): # if(self.__uv[0] is not None and self.__uv[1] is not None): # average_location[0] += np.around(self.__uv[0],0) @@ -293,39 +293,43 @@ def burstEndstopDetection(self): self.__uv = None def endstopContourDetection(self, detectFrame): - # apply endstop detection algorithm - usedFrame = copy.deepcopy(detectFrame) - yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) - yuvPlanes = cv2.split(yuv) - still = yuvPlanes[0] - black = np.zeros((still.shape[0],still.shape[1]), np.uint8) - kernel = np.ones((5,5),np.uint8) - img_blur = cv2.GaussianBlur(still, (7, 7), 3) - img_canny = cv2.Canny(img_blur, 50, 190) - img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) - cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) - black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) - cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) - # detectFrame = black center = (None, None) - if len(cnt2) > 0: - myContours = [] - for k in range(len(cnt2)): - if hierarchy2[0][k][3] > -1: - myContours.append(cnt2[k]) - if len(myContours) > 0: - # return only the biggest detected contour - blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) - contourArea = cv2.contourArea(blobContours) - if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): - M = cv2.moments(blobContours) - center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) - detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) - detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) - detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) - detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) + if(self.__endstopAutomatedDetectionActive is True): + # apply endstop detection algorithm + usedFrame = copy.deepcopy(detectFrame) + yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) + yuvPlanes = cv2.split(yuv) + still = yuvPlanes[0] + black = np.zeros((still.shape[0],still.shape[1]), np.uint8) + kernel = np.ones((5,5),np.uint8) + img_blur = cv2.GaussianBlur(still, (7, 7), 3) + img_canny = cv2.Canny(img_blur, 50, 190) + img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) + cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) + black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) + cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) + # detectFrame = black + if len(cnt2) > 0: + myContours = [] + for k in range(len(cnt2)): + if hierarchy2[0][k][3] > -1: + myContours.append(cnt2[k]) + if len(myContours) > 0: + # return only the biggest detected contour + blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) + contourArea = cv2.contourArea(blobContours) + if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): + M = cv2.moments(blobContours) + center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) + detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) + detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) + detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) + detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) + else: + detectFrame = cv2.line(self.frame, (320,0), (320,480), (255,255,255), 1) + detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) return(center,detectFrame) @pyqtSlot(bool) From e65e81aa290435f3e3722d72e3d869128d0131a4 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:57:20 +0200 Subject: [PATCH 25/99] Update DetectionManager.py --- modules/DetectionManager.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index e84e469..971b6fb 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -230,11 +230,11 @@ def analyzeEndstopFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - while(detectionCount < 3): - # Discard a few frames to get a clean detection due to printer movement - for i in range(2): - self.frame = self.videoFeed.getFrame() - (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + # while(detectionCount < 3): + # Discard a few frames to get a clean detection due to printer movement + for i in range(2): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.endstopContourDetection(self.frame) # if(self.__uv is not None): # if(self.__uv[0] is not None and self.__uv[1] is not None): # average_location[0] += np.around(self.__uv[0],0) @@ -256,7 +256,7 @@ def analyzeEndstopFrame(self): # average_location = np.around(average_location,3) # self.__uv = average_location # else: - self.__uv = [None,None] + self.__uv = [None,None] @pyqtSlot(int) def burstEndstopDetection(self): @@ -355,10 +355,10 @@ def analyzeNozzleFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - while(detectionCount < 1): + # while(detectionCount < 1): # Discard a few frames to get a clean detection due to printer movement - for i in range(2): - self.frame = self.videoFeed.getFrame() + for i in range(2): + self.frame = self.videoFeed.getFrame() # (self.__uv, self.frame) = self.nozzleDetection() # if(self.__uv is not None): # if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -381,7 +381,7 @@ def analyzeNozzleFrame(self): # average_location = np.around(average_location,0) # self.__uv = average_location # else: - self.__uv = [None,None] + self.__uv = [None,None] def burstNozzleDetection(self): detectionCount = 0 From 6548196f869d240852e0abe3533bdf233b454aa7 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:59:28 +0200 Subject: [PATCH 26/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 971b6fb..2301b13 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -274,7 +274,9 @@ def burstEndstopDetection(self): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 + print('Detected endstop') else: + print('No endstop detected') retries += 1 else: retries += 1 From 21715a90e75983805d6c3a46b093e062fdec875b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 8 Oct 2022 02:03:21 +0200 Subject: [PATCH 27/99] Update DetectionManager.py --- modules/DetectionManager.py | 132 +++++++++++++++++------------------- 1 file changed, 63 insertions(+), 69 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 2301b13..98544c7 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -230,33 +230,33 @@ def analyzeEndstopFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - # while(detectionCount < 3): - # Discard a few frames to get a clean detection due to printer movement - for i in range(2): - self.frame = self.videoFeed.getFrame() - (self.__uv, self.frame) = self.endstopContourDetection(self.frame) - # if(self.__uv is not None): - # if(self.__uv[0] is not None and self.__uv[1] is not None): - # average_location[0] += np.around(self.__uv[0],0) - # average_location[1] += np.around(self.__uv[1],0) - # detectionCount += 1 - # else: - # retries += 1 - # else: - # retries += 1 - # if(retries > 3): - # average_location[0] = None - # average_location[1] = None - # break - # if(average_location[0] is not None): - # # calculate average X Y position from detection - # average_location[0] /= detectionCount - # average_location[1] /= detectionCount - # # round to 0 decimal places - # average_location = np.around(average_location,3) - # self.__uv = average_location - # else: - self.__uv = [None,None] + while(detectionCount < 3): + # Discard a few frames to get a clean detection due to printer movement + for i in range(3): + self.frame = self.videoFeed.getFrame() + (self.__uv, self.frame) = self.endstopContourDetection(self.frame) + if(self.__uv is not None): + if(self.__uv[0] is not None and self.__uv[1] is not None): + average_location[0] += np.around(self.__uv[0],0) + average_location[1] += np.around(self.__uv[1],0) + detectionCount += 1 + else: + retries += 1 + else: + retries += 1 + if(retries > 3): + average_location[0] = None + average_location[1] = None + break + if(average_location[0] is not None): + # calculate average X Y position from detection + average_location[0] /= detectionCount + average_location[1] /= detectionCount + # round to 0 decimal places + average_location = np.around(average_location,3) + self.__uv = average_location + else: + self.__uv = [None,None] @pyqtSlot(int) def burstEndstopDetection(self): @@ -274,9 +274,7 @@ def burstEndstopDetection(self): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 - print('Detected endstop') else: - print('No endstop detected') retries += 1 else: retries += 1 @@ -295,43 +293,39 @@ def burstEndstopDetection(self): self.__uv = None def endstopContourDetection(self, detectFrame): + # apply endstop detection algorithm + usedFrame = copy.deepcopy(detectFrame) + yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) + yuvPlanes = cv2.split(yuv) + still = yuvPlanes[0] + black = np.zeros((still.shape[0],still.shape[1]), np.uint8) + kernel = np.ones((5,5),np.uint8) + img_blur = cv2.GaussianBlur(still, (7, 7), 3) + img_canny = cv2.Canny(img_blur, 50, 190) + img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) + cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) + black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) + cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) + # detectFrame = black center = (None, None) - if(self.__endstopAutomatedDetectionActive is True): - # apply endstop detection algorithm - usedFrame = copy.deepcopy(detectFrame) - yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) - yuvPlanes = cv2.split(yuv) - still = yuvPlanes[0] - black = np.zeros((still.shape[0],still.shape[1]), np.uint8) - kernel = np.ones((5,5),np.uint8) - img_blur = cv2.GaussianBlur(still, (7, 7), 3) - img_canny = cv2.Canny(img_blur, 50, 190) - img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) - cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) - black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) - cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) - # detectFrame = black - if len(cnt2) > 0: - myContours = [] - for k in range(len(cnt2)): - if hierarchy2[0][k][3] > -1: - myContours.append(cnt2[k]) - if len(myContours) > 0: - # return only the biggest detected contour - blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) - contourArea = cv2.contourArea(blobContours) - if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): - M = cv2.moments(blobContours) - center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) - detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) - detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) - detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) - detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) - else: - detectFrame = cv2.line(self.frame, (320,0), (320,480), (255,255,255), 1) - detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) + if len(cnt2) > 0: + myContours = [] + for k in range(len(cnt2)): + if hierarchy2[0][k][3] > -1: + myContours.append(cnt2[k]) + if len(myContours) > 0: + # return only the biggest detected contour + blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) + contourArea = cv2.contourArea(blobContours) + if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): + M = cv2.moments(blobContours) + center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) + detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) + detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) + detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) + detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) return(center,detectFrame) @pyqtSlot(bool) @@ -357,10 +351,10 @@ def analyzeNozzleFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - # while(detectionCount < 1): + while(detectionCount < 1): # Discard a few frames to get a clean detection due to printer movement - for i in range(2): - self.frame = self.videoFeed.getFrame() + for i in range(2): + self.frame = self.videoFeed.getFrame() # (self.__uv, self.frame) = self.nozzleDetection() # if(self.__uv is not None): # if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -383,7 +377,7 @@ def analyzeNozzleFrame(self): # average_location = np.around(average_location,0) # self.__uv = average_location # else: - self.__uv = [None,None] + self.__uv = [None,None] def burstNozzleDetection(self): detectionCount = 0 From 0e67eab41c19ad39d4fecaa77565677ca06f884a Mon Sep 17 00:00:00 2001 From: HaythamB Date: Tue, 11 Oct 2022 14:04:16 +0200 Subject: [PATCH 28/99] Testing frame buffering --- TAMV.py | 121 ++++++++++++++++++++---------------- modules/Camera.py | 36 ++++++++--- modules/DetectionManager.py | 9 ++- 3 files changed, 102 insertions(+), 64 deletions(-) diff --git a/TAMV.py b/TAMV.py index 2467361..58c280d 100644 --- a/TAMV.py +++ b/TAMV.py @@ -55,6 +55,8 @@ class App(QMainWindow): # default move speed in feedrate/min _moveSpeed = 6000 + __counter = 0 + ########################################################################### Initialize class def __init__(self, parent=None): # send calling to log @@ -1184,6 +1186,7 @@ def setupCPAutoCapture(self): self.toggleEndstopAutoDetectionSignal.emit(True) self.uv = [None, None] self.unloadToolSignal.emit() + self.pollCoordinatesSignal.emit() # send exiting to log _logger.debug('*** exiting App.setupCPAutoCapture') @@ -1347,9 +1350,11 @@ def calibrateTools(self, toolset=None): self.resetNozzleAlignment() def autoCalibrate(self): + print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV', self.uv, ' old UV', self.olduv) self.tabPanel.setDisabled(True) if(self.uv is not None): if(self.uv[0] is not None and self.uv[1] is not None): + self.retries = 0 # First calibration step if(self.state == 0): self.updateStatusbarMessage('Calibrating camera step 0..') @@ -1370,29 +1375,29 @@ def autoCalibrate(self): self.moveRelativeSignal.emit(params) return elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): - if(self.state == self.lastState): - self.offsetX = self.calibrationCoordinates[self.state][0] - self.offsetY = self.calibrationCoordinates[self.state][1] - self.state += 1 + if(self.state != self.lastState): + # save position as previous position + self.olduv = self.uv + + # return carriage to relative center of movement + self.offsetX = (-1*self.offsetX) + self.offsetY = (-1*self.offsetY) + self.lastState = self.state params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} self.moveRelativeSignal.emit(params) return else: self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') - # check if we've already moved, and calculate mpp value - if self.state == 2: - self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) - # save position as previous position - self.olduv = self.uv _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) # save machine coordinates for detected nozzle self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) # save camera coordinates self.camera_coordinates.append((self.uv[0],self.uv[1])) - # return carriage to relative center of movement - self.offsetX = (-1*self.offsetX) - self.offsetY = (-1*self.offsetY) + # move carriage to next calibration point + self.offsetX = self.calibrationCoordinates[self.state][0] + self.offsetY = self.calibrationCoordinates[self.state][1] self.lastState = int(self.state) + self.state += 1 params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} self.moveRelativeSignal.emit(params) return @@ -1437,10 +1442,11 @@ def autoCalibrate(self): self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) # Add rounding handling for endstop alignment - if(self.__stateEndstopAutoCalibrate): - if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): - self.offsets[0] = 0.0 - self.offsets[1] = 0.0 + # if(self.__stateEndstopAutoCalibrate): + # if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): + # print('ROUNDING!!! ************************') + # self.offsets[0] = 0.0 + # self.offsets[1] = 0.0 if(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000} self.moveRelativeSignal.emit(params) @@ -1470,44 +1476,49 @@ def autoCalibrate(self): self.updateStatusbarMessage(updateMessage) self.pollCoordinatesSignal.emit() return - elif(self.retries < 3): + elif(self.retries < 100): + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + if(self.retries < 10): + print('Retries:',self.retries) self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() return - else: - self.retries = 0 - if(self.__stateEndstopAutoCalibrate is True): - self.updateStatusbarMessage('Failed to detect endstop.') - _logger.warning('Failed to detect endstop. Cancelled operation.') - if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): - params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} - self.moveAbsoluteSignal.emit(params) - # End calibration - self.__stateAutoCPCapture = False - self.__stateEndstopAutoCalibrate = False - self.toggleEndstopAutoDetectionSignal.emit(False) - self.haltCPAutoCapture() - self.pollCoordinatesSignal.emit() - elif(self.__stateAutoNozzleAlignment is True): - updateMessage = 'Failed to detect nozzle. Try manual override.' - self.updateStatusbarMessage(updateMessage) - _logger.warning(updateMessage) - self.state = -99 - # End auto calibration - self.__stateAutoNozzleAlignment = False - self.__stateManualNozzleAlignment = True - # calibrating nozzle manual - self.tabPanel.setDisabled(False) - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.manualToolOffsetCaptureButton.setVisible(True) - self.manualToolOffsetCaptureButton.setDisabled(False) - self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleNozzleDetectionSignal.emit(True) - self.toggleDetectionSignal.emit(True) + print('Canceling:',self.retries) + self.retries = 0 + if(self.__stateEndstopAutoCalibrate is True): + self.updateStatusbarMessage('Failed to detect endstop.') + _logger.warning('Failed to detect endstop. Cancelled operation.') + # if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): + # params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} + # self.moveAbsoluteSignal.emit(params) + # End calibration + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.haltCPAutoCapture() + self.pollCoordinatesSignal.emit() + elif(self.__stateAutoNozzleAlignment is True): + updateMessage = 'Failed to detect nozzle. Try manual override.' + self.updateStatusbarMessage(updateMessage) + _logger.warning(updateMessage) + self.state = -99 + # End auto calibration + self.__stateAutoNozzleAlignment = False + self.__stateManualNozzleAlignment = True + # calibrating nozzle manual + self.tabPanel.setDisabled(False) + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(True) + self.manualToolOffsetCaptureButton.setDisabled(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleNozzleDetectionSignal.emit(True) + self.toggleDetectionSignal.emit(True) ########################################################################### Module interfaces and handlers @@ -1554,6 +1565,9 @@ def startVideo(self, cameraProperties): @pyqtSlot(object) def refreshImage(self, data): + #print('TAMV:', self.__counter) + self.__lastCounter = self.__counter + self.__counter += 1 frame = data[0] uvCoordinates = data[1] self.image.setPixmap(frame) @@ -1690,10 +1704,10 @@ def haltPrinterOperation(self, **kwargs): else: params['noUpdate'] = False # restore position - if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): - params['parkPosition'] = self.__cpCoordinates - self.disconnectSignal.emit(params) - elif(self.__restorePosition is not None): + # if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): + # params['parkPosition'] = self.__cpCoordinates + # self.disconnectSignal.emit(params) + if(self.__restorePosition is not None): params['parkPosition'] = self.__restorePosition self.disconnectSignal.emit(params) else: @@ -2015,6 +2029,7 @@ def saveUserSettings(self): _logger.debug('*** exiting App.saveUserSettings') def getDistance(self, x1, y1, x0, y0): + print('getDistance: (', x1, ',', y1,') :: (', x0, ',',y0, ')') _logger.debug('*** calling CalibrateNozzles.getDistance') x1_float = float(x1) x0_float = float(x0) diff --git a/modules/Camera.py b/modules/Camera.py index faa9bd6..bb6eeef 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -6,6 +6,7 @@ import cv2 import sys from PyQt5 import QtCore +import threading class Camera(QtCore.QObject): # class attributes @@ -61,7 +62,8 @@ def __init__(self, *args, **kwargs): raise Exception self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,2) + self.cap.set(cv2.CAP_PROP_FPS, 25) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') @@ -100,10 +102,24 @@ def __init__(self, *args, **kwargs): except Exception as e: self.cap.release() raise SystemExit - + self.stopEvent = threading.Event() + self.t = threading.Thread(target=self._reader) + self.t.daemon = True + self.t.start() # send exiting to log _logger.debug('*** exiting Camera.__init__') + def _reader(self): + while True: + ret = self.cap.grab() + if not ret: + break + if self.stopEvent.is_set(): + break + + def stop(self): + self.stopEvent.set() + def getCurrentImageSettings(self): return self.__imageSettings @@ -111,17 +127,21 @@ def quit(self): # send calling to log _logger.debug('*** calling Camera.quit') _logger.info(' .. closing camera..') + self.stop() + self.t.join() self.cap.release() # send exiting to log _logger.debug('*** exiting Camera.quit') def getFrame(self): - self.__success, self.__frame = self.cap.read() - if(self.__success): - return(self.__frame) - else: - self.cap.release() - raise Exception + # self.__success, self.__frame = self.cap.read() + # if(self.__success): + # return(self.__frame) + # else: + # self.cap.release() + # raise Exception + ret, frame = self.cap.retrieve() + return frame def getImagePropertiesJSON(self): try: diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 2301b13..4e7e034 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -23,6 +23,7 @@ class DetectionManager(QObject): __endstopAutomatedDetectionActive = False __running = True __uv = None + __counter = 0 # Signals detectionManagerNewFrameSignal = pyqtSignal(object) @@ -205,6 +206,8 @@ def processFrame(self): # convert from cv2.mat to QPixmap and return results (frame+keypoint) def receivedFrame(self, frame): + #print('Detection:',self.__counter) + self.__counter += 1 if(self.__running): rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb_image.shape @@ -274,9 +277,9 @@ def burstEndstopDetection(self): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 - print('Detected endstop') + #print('Detected endstop') else: - print('No endstop detected') + #print('No endstop detected') retries += 1 else: retries += 1 @@ -557,4 +560,4 @@ def getPropertiesJSON(self): self.detectionManagerImagePropertiesSignal.emit() def getDefaultPropertiesJSON(self): - self.detectionManagerDefaultImagePropertiesSignal.emit() \ No newline at end of file + self.detectionManagerDefaultImagePropertiesSignal.emit() From 927350c83d2b7c62bc29645d8e7ec2223c26481b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 11 Oct 2022 14:17:05 +0200 Subject: [PATCH 29/99] Update DetectionManager.py --- modules/DetectionManager.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index a366f5f..c986245 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -277,13 +277,7 @@ def burstEndstopDetection(self): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 -<<<<<<< HEAD - #print('Detected endstop') else: - #print('No endstop detected') -======= - else: ->>>>>>> 21715a90e75983805d6c3a46b093e062fdec875b retries += 1 else: retries += 1 From 944504b8178fbcc6fff1dcd0d6f981283fa9bb48 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 11 Oct 2022 15:59:01 +0200 Subject: [PATCH 30/99] debugging for Linux --- TAMV.py | 13 ++++++----- modules/Camera.py | 6 ++--- modules/DetectionManager.py | 44 ++++++------------------------------- modules/PrinterManager.py | 3 +-- 4 files changed, 19 insertions(+), 47 deletions(-) diff --git a/TAMV.py b/TAMV.py index 58c280d..e0fc0fb 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1350,13 +1350,13 @@ def calibrateTools(self, toolset=None): self.resetNozzleAlignment() def autoCalibrate(self): - print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV', self.uv, ' old UV', self.olduv) self.tabPanel.setDisabled(True) if(self.uv is not None): if(self.uv[0] is not None and self.uv[1] is not None): self.retries = 0 # First calibration step if(self.state == 0): + print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) self.updateStatusbarMessage('Calibrating camera step 0..') self.olduv = self.uv # Reset space and camera coordinates @@ -1387,6 +1387,7 @@ def autoCalibrate(self): self.moveRelativeSignal.emit(params) return else: + print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) # save machine coordinates for detected nozzle @@ -1704,10 +1705,12 @@ def haltPrinterOperation(self, **kwargs): else: params['noUpdate'] = False # restore position - # if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): - # params['parkPosition'] = self.__cpCoordinates - # self.disconnectSignal.emit(params) - if(self.__restorePosition is not None): + if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None): + _logger.debug('Restoring to CP position..') + params['parkPosition'] = self.__cpCoordinates + self.disconnectSignal.emit(params) + elif(self.__restorePosition is not None): + _logger.debug('Restoring to master restore position..') params['parkPosition'] = self.__restorePosition self.disconnectSignal.emit(params) else: diff --git a/modules/Camera.py b/modules/Camera.py index bb6eeef..60b1172 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -37,8 +37,8 @@ def __init__(self, *args, **kwargs): if sys.platform.startswith('linux'): # all Linux self.backend = cv2.CAP_V4L - elif sys.platform.startswith('win'): # MS Windows - self.backend = cv2.CAP_DSHOW + # elif sys.platform.startswith('win'): # MS Windows + # self.backend = cv2.CAP_DSHOW elif sys.platform.startswith('darwin'): # macOS self.backend = cv2.CAP_AVFOUNDATION else: @@ -63,7 +63,7 @@ def __init__(self, *args, **kwargs): self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) self.cap.set(cv2.CAP_PROP_FPS, 25) - self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) + # self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index c986245..a565f22 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -233,10 +233,9 @@ def analyzeEndstopFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - while(detectionCount < 3): - # Discard a few frames to get a clean detection due to printer movement - for i in range(3): - self.frame = self.videoFeed.getFrame() + while(detectionCount < 5): + # for i in range(10): + # self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -268,9 +267,7 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # Discard a few frames to get a clean detection due to printer movement - for i in range(5): - self.frame = self.videoFeed.getFrame() + self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -354,33 +351,8 @@ def analyzeNozzleFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - while(detectionCount < 1): - # Discard a few frames to get a clean detection due to printer movement - for i in range(2): - self.frame = self.videoFeed.getFrame() - # (self.__uv, self.frame) = self.nozzleDetection() - # if(self.__uv is not None): - # if(self.__uv[0] is not None and self.__uv[1] is not None): - # average_location[0] += np.around(self.__uv[0],0) - # average_location[1] += np.around(self.__uv[1],0) - # detectionCount += 1 - # else: - # retries += 1 - # else: - # retries += 1 - # if(retries > 9): - # average_location[0] = None - # average_location[1] = None - # break - # if(average_location[0] is not None): - # # calculate average X Y position from detection - # average_location[0] /= detectionCount - # average_location[1] /= detectionCount - # # round to 0 decimal places - # average_location = np.around(average_location,0) - # self.__uv = average_location - # else: - self.__uv = [None,None] + self.frame = self.videoFeed.getFrame() + self.__uv = [None,None] def burstNozzleDetection(self): detectionCount = 0 @@ -388,9 +360,7 @@ def burstNozzleDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # Discard a few frames to get a clean detection due to printer movement - for i in range(1): - self.frame = self.videoFeed.getFrame() + self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index 9dde1e9..be76538 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -178,13 +178,12 @@ def disconnectPrinter(self, kwargs={}): if(parkPosition is not None): _logger.info(' .. restoring position..') self.complexMoveAbsolute(position=parkPosition) - # delete printer connection - # self.__activePrinter['disconnected'] = True if(noUpdate is False): # notify parent thread successMsg = 'Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' _logger.info(successMsg) + self.__printerJSON = None self.printerDisconnectedSignal.emit(successMsg) else: self.printerDisconnectedSignal.emit(None) From 2e9e72c17d4b425826fc80eb5c0e63075eea878f Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 11 Oct 2022 16:13:37 +0200 Subject: [PATCH 31/99] more debugging --- TAMV.py | 4 ++-- modules/Camera.py | 2 +- modules/DetectionManager.py | 3 ++- modules/PrinterManager.py | 3 +-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/TAMV.py b/TAMV.py index e0fc0fb..1d42a03 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1377,8 +1377,9 @@ def autoCalibrate(self): elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): if(self.state != self.lastState): # save position as previous position + if(self.state == 2): + self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) self.olduv = self.uv - # return carriage to relative center of movement self.offsetX = (-1*self.offsetX) self.offsetY = (-1*self.offsetY) @@ -2032,7 +2033,6 @@ def saveUserSettings(self): _logger.debug('*** exiting App.saveUserSettings') def getDistance(self, x1, y1, x0, y0): - print('getDistance: (', x1, ',', y1,') :: (', x0, ',',y0, ')') _logger.debug('*** calling CalibrateNozzles.getDistance') x1_float = float(x1) x0_float = float(x0) diff --git a/modules/Camera.py b/modules/Camera.py index 60b1172..14746f0 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -62,7 +62,7 @@ def __init__(self, *args, **kwargs): raise Exception self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) - self.cap.set(cv2.CAP_PROP_FPS, 25) + self.cap.set(cv2.CAP_PROP_FPS, 30) # self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index a565f22..a55e4bd 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -267,7 +267,8 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - self.frame = self.videoFeed.getFrame() + for j in range(3): + self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index be76538..ad59f02 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -73,7 +73,7 @@ def quit(self): self.disconnectPrinter({'noUpdate':True}) if( self.__printerJSON is not None): _logger.info(' Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') - + self.__printerJSON = None _logger.info('Printer Manager shut down successfully.') # send exiting to log _logger.debug('*** exiting PrinterManager.quit') @@ -183,7 +183,6 @@ def disconnectPrinter(self, kwargs={}): # notify parent thread successMsg = 'Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' _logger.info(successMsg) - self.__printerJSON = None self.printerDisconnectedSignal.emit(successMsg) else: self.printerDisconnectedSignal.emit(None) From 3e39098cae3d7f8bb1f741d7ce806bceba19d279 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 11 Oct 2022 16:15:07 +0200 Subject: [PATCH 32/99] Update TAMV.py --- TAMV.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TAMV.py b/TAMV.py index 1d42a03..fc44bcb 100644 --- a/TAMV.py +++ b/TAMV.py @@ -271,6 +271,8 @@ def __init__(self, parent=None): except: _logger.warning('Cannot rename old settings.json, leaving in place and using new file.') except FileNotFoundError: + # create config folder if it doesn't exist + os.makedirs('./config',exist_ok=True) # No settings file defined, create a default file _logger.info(' .. creating new settings.json..') # create a camera array From ee2d3f0b3c2dfd3d26ab361d25dad5bf71445f2a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 11 Oct 2022 16:45:07 +0200 Subject: [PATCH 33/99] more work needed. runs sometimes. --- TAMV.py | 40 ++++++++++++++++++++++++------------- modules/DetectionManager.py | 5 +++++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/TAMV.py b/TAMV.py index fc44bcb..74a0582 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1324,10 +1324,10 @@ def calibrateTools(self, toolset=None): button.setStyleSheet(self.styleOrange) #### Initialize calibration parameters # self.state to indicate if we need to run a camera alignment - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 + if(len(self.transform_matrix) > 1 and self.mpp is not None): + state = 200 else: - self.state = 200 + self.state = 0 # self.retries to reset detection self.retries = 0 # self.calibrationMoves for GUI updates @@ -1421,6 +1421,8 @@ def autoCalibrate(self): cameraCalibrationTime = np.around(time.time() - self.startTime,1) _logger.info('Camera calibrated (' + str(cameraCalibrationTime) + 's); aligning..') self.state = 200 + self.retries = 0 + self.calibrationMoves = 0 self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)] self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input) # define camera center in machine coordinate space @@ -1446,17 +1448,28 @@ def autoCalibrate(self): self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) # Add rounding handling for endstop alignment - # if(self.__stateEndstopAutoCalibrate): - # if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): - # print('ROUNDING!!! ************************') - # self.offsets[0] = 0.0 - # self.offsets[1] = 0.0 - if(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): + if(self.__stateEndstopAutoCalibrate): + if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.04): + self.offsets[0] = 0.0 + self.offsets[1] = 0.0 + + # Start checking calibration status + try: + if(self.toolTime): + pass + except: + self.toolTime = time.time() + # current calibration cycle runtime + runtime = np.around(time.time() - self.toolTime,1) + if(runtime > 60 or self.calibrationMoves > 20): + # too long of a cycle, default to manual override + self.retries = 10 + elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000} self.moveRelativeSignal.emit(params) _logger.debug('Calibration move X{0:-1.3f} Y{1:-1.3f} F1000 '.format(self.offsets[0],self.offsets[1])) return - else: + elif(self.offsets[0] == 0.0 and self.offsets[1] == 0.0): # auto calibration complete - end process if(self.__stateEndstopAutoCalibrate): # endstop calibration handling @@ -1480,18 +1493,17 @@ def autoCalibrate(self): self.updateStatusbarMessage(updateMessage) self.pollCoordinatesSignal.emit() return - elif(self.retries < 100): + elif(self.retries < 100 and runtime <= 15): self.retries += 1 self.pollCoordinatesSignal.emit() return if(self.retries < 10): - print('Retries:',self.retries) self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() return - print('Canceling:',self.retries) + self.retries = 0 if(self.__stateEndstopAutoCalibrate is True): self.updateStatusbarMessage('Failed to detect endstop.') @@ -1534,7 +1546,7 @@ def createDetectionManagerThread(self, announce=True): self.detectionThread = QThread() self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None) self.detectionManager.moveToThread(self.detectionThread) - self.detectionThread.start()#priority=QThread.TimeCriticalPriority) + self.detectionThread.start(priority=QThread.TimeCriticalPriority) # Thread management signals and slots self.detectionManager.errorSignal.connect(self.detectionManagerError) self.detectionThread.started.connect(self.detectionManager.processFrame) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index a55e4bd..177e367 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -354,6 +354,11 @@ def analyzeNozzleFrame(self): retries = 0 self.frame = self.videoFeed.getFrame() self.__uv = [None,None] + # draw crosshair + self.frame = cv2.line(self.frame, (320,0), (320,480), (0,0,0), 3) + self.frame = cv2.line(self.frame, (0,240), (640,240), (0,0,0), 3) + self.frame = cv2.line(self.frame, (320,0), (320,480), (255,255,255), 1) + self.frame = cv2.line(self.frame, (0,240), (640,240), (255,255,255), 1) def burstNozzleDetection(self): detectionCount = 0 From 216343d2d65a37a601f471e32ccf2852ea87597a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:40:49 +0200 Subject: [PATCH 34/99] stable on windows 10 --- TAMV.py | 264 ++++++++++++++++++++++++------------ modules/DetectionManager.py | 132 ++++++++++++------ 2 files changed, 262 insertions(+), 134 deletions(-) diff --git a/TAMV.py b/TAMV.py index 74a0582..02a9f17 100644 --- a/TAMV.py +++ b/TAMV.py @@ -106,6 +106,7 @@ def __init__(self, parent=None): # Nozzle detection self.__stateAutoNozzleAlignment = False self.__stateManualNozzleAlignment = False + self.__displayCrosshair = False #### setup window properties if(True): @@ -492,6 +493,16 @@ def setupMainWindow(self): self.disconnectButton.setDisabled(True) self.disconnectButton.clicked.connect(self.haltPrinterOperation) self.leftToolbarLayout.addWidget(self.disconnectButton)#, 1, 0, 1, 1, Qt.AlignLeft|Qt.AlignTop) + # Crosshair button + self.crosshairDisplayButton = QPushButton('-+-') + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize) + self.crosshairDisplayButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize) + self.crosshairDisplayButton.setToolTip('toggle crosshair on display') + self.crosshairDisplayButton.setDisabled(False) + self.crosshairDisplayButton.setChecked(False) + self.crosshairDisplayButton.clicked.connect(self.toggleCrosshair) + self.leftToolbarLayout.addWidget(self.crosshairDisplayButton) # Setup Control Point button self.cpSetupButton = QPushButton('CP') self.cpSetupButton.setStyleSheet(self.styleDisabled) @@ -793,6 +804,7 @@ def stateDisconnected(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Delete tool buttons count = self.jogPanel.count() for i in range(11,count): @@ -801,6 +813,15 @@ def stateDisconnected(self): widget.setVisible(False) # widget.deleteLater() self.resetCalibration() + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(False) + if(self.__displayCrosshair): + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.crosshairDisplayButton.setChecked(True) + else: + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(True) @@ -901,6 +922,15 @@ def stateConnected(self): self.__stateManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False self.toggleNozzleAutoDetectionSignal.emit(False) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(False) + if(self.__displayCrosshair): + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.crosshairDisplayButton.setChecked(True) + else: + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setChecked(False) _logger.debug('Tool data and interface created successfully.') @@ -937,6 +967,15 @@ def stateCPSetup(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(False) + if(self.__displayCrosshair): + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.crosshairDisplayButton.setChecked(True) + else: + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(False) @@ -973,6 +1012,11 @@ def stateCPAuto(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(True) + self.crosshairDisplayButton.setStyleSheet(self.styleDisabled) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(True) @@ -1009,6 +1053,15 @@ def stateCalibrateReady(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(False) + if(self.__displayCrosshair): + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.crosshairDisplayButton.setChecked(True) + else: + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(False) # Tool buttons @@ -1049,6 +1102,11 @@ def stateCalibtrateRunning(self): self.manualToolOffsetCaptureButton.setVisible(True) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(True) + self.crosshairDisplayButton.setStyleSheet(self.styleDisabled) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(True) # Tool selection buttons disable @@ -1088,6 +1146,15 @@ def stateCalibrateComplete(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(False) + if(self.__displayCrosshair): + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.crosshairDisplayButton.setChecked(True) + else: + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(False) # Tool buttons @@ -1128,6 +1195,11 @@ def stateExiting(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Crosshair display button + self.crosshairDisplayButton.setVisible(True) + self.crosshairDisplayButton.setDisabled(True) + self.crosshairDisplayButton.setStyleSheet(self.styleDisabled) + self.crosshairDisplayButton.setChecked(False) # Jog panel tab self.tabPanel.setDisabled(True) @@ -1137,30 +1209,13 @@ def setupCPCapture(self): self.toggleEndstopDetectionSignal.emit(True) # enable detection self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True # update machine coordinates self.pollCoordinatesSignal.emit() # Get original physical coordinates self.originalPrinterPosition = self.__currentPosition self.__restorePosition = copy.deepcopy(self.__currentPosition) - # Setup camera calibration move coordinates - self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] - # guess position used for camera calibration - self.guess_position = [1,1] - # current keypoint location - self.uv = None - # previous keypoint location - self.olduv = self.uv - # detected blob counter - self.detect_count = 0 - # set initial state variable based on camera matrix exists or not - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 - self.space_coordinates = [] - self.camera_coordinates = [] - self.calibrationMoves = 0 - self.retries = 0 + self.resetCalibrationVariables() self.stateCPSetup() self.repaint() @@ -1222,18 +1277,8 @@ def resetCalibration(self): self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) - self.uv = [None, None] - self.guess_position = [1,1] - self.olduv = self.uv - self.detect_count = 0 - self.space_coordinates = [] - self.camera_coordinates = [] - self.retries = 0 - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 - self.calibrationMoves = 0 + self.__displayCrosshair = False + self.resetCalibrationVariables() self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() @@ -1248,20 +1293,28 @@ def resetNozzleAlignment(self): self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) - self.uv = [None, None] + self.__displayCrosshair = False + self.resetCalibrationVariables() + self.unloadToolSignal.emit() + self.getVideoFrameSignal.emit() + + # Function to reset calibrateTools variables + def resetCalibrationVariables(self): + # Setup camera calibration move coordinates + self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] + # reset all variables self.guess_position = [1,1] + self.uv = [0, 0] self.olduv = self.uv - self.detect_count = 0 - self.space_coordinates = [] - self.camera_coordinates = [] - self.retries = 0 if(self.transform_matrix is None or self.mpp is None): self.state = 0 else: self.state = 200 + self.detect_count = 0 + self.space_coordinates = [] + self.camera_coordinates = [] + self.retries = 0 self.calibrationMoves = 0 - self.unloadToolSignal.emit() - self.getVideoFrameSignal.emit() def startAlignTools(self): # send calling to log @@ -1269,7 +1322,6 @@ def startAlignTools(self): self.startTime = time.time() self.__stateManualNozzleAlignment = True self.__stateAutoNozzleAlignment = True - self.uv = [None, None] # Update GUI state self.stateCalibtrateRunning() @@ -1283,10 +1335,6 @@ def startAlignTools(self): self.alignmentToolSet.append(int(checkbox.objectName()[13:])) if(len(self.alignmentToolSet) > 0): self.toggleNozzleAutoDetectionSignal.emit(True) - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 self.calibrateTools(self.alignmentToolSet) else: # tell user no tools selected @@ -1322,16 +1370,7 @@ def calibrateTools(self, toolset=None): buttonName = 'toolButton_' + str(toolIndex) if(button.objectName() == buttonName): button.setStyleSheet(self.styleOrange) - #### Initialize calibration parameters - # self.state to indicate if we need to run a camera alignment - if(len(self.transform_matrix) > 1 and self.mpp is not None): - state = 200 - else: - self.state = 0 - # self.retries to reset detection - self.retries = 0 - # self.calibrationMoves for GUI updates - self.calibrationMoves = 0 + self.resetCalibrationVariables() # main program state flags self.__stateManualNozzleAlignment = True self.__stateAutoNozzleAlignment = True @@ -1360,28 +1399,37 @@ def autoCalibrate(self): if(self.state == 0): print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) self.updateStatusbarMessage('Calibrating camera step 0..') + self.olduv = self.uv - # Reset space and camera coordinates self.space_coordinates = [] self.camera_coordinates = [] - self.calibrationMoves = 0 + self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) self.camera_coordinates.append((self.uv[0], self.uv[1])) + # move carriage for calibration self.offsetX = self.calibrationCoordinates[0][0] self.offsetY = self.calibrationCoordinates[0][1] params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} self.lastState = 0 self.state = 1 - self.retries = 0 self.moveRelativeSignal.emit(params) return elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): + # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): - # save position as previous position - if(self.state == 2): + # Calculate mpp at first move + if(self.state == 1): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) + # save position as previous position self.olduv = self.uv + # save machine coordinates for detected nozzle + self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) + # save camera coordinates + self.camera_coordinates.append((self.uv[0],self.uv[1])) + + print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) + # return carriage to relative center of movement self.offsetX = (-1*self.offsetX) self.offsetY = (-1*self.offsetY) @@ -1389,14 +1437,10 @@ def autoCalibrate(self): params = {'position':{'X': self.offsetX, 'Y': self.offsetY}} self.moveRelativeSignal.emit(params) return + # otherwise, move the carriage from the original position to the next step and increment state else: - print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) - # save machine coordinates for detected nozzle - self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) - # save camera coordinates - self.camera_coordinates.append((self.uv[0],self.uv[1])) # move carriage to next calibration point self.offsetX = self.calibrationCoordinates[self.state][0] self.offsetY = self.calibrationCoordinates[self.state][1] @@ -1407,38 +1451,49 @@ def autoCalibrate(self): return elif(self.state == len(self.calibrationCoordinates)): # Camera calibration moves completed. + # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) self.updateStatusbarMessage(updateMessage) _logger.info(updateMessage) + # save position as previous position self.olduv = self.uv # save machine coordinates for detected nozzle self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y'])) # save camera coordinates self.camera_coordinates.append((self.uv[0],self.uv[1])) + # calculate camera transformation matrix cameraCalibrationTime = np.around(time.time() - self.startTime,1) _logger.info('Camera calibrated (' + str(cameraCalibrationTime) + 's); aligning..') - self.state = 200 - self.retries = 0 - self.calibrationMoves = 0 + + # Calculate transformation matrix self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)] self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input) + # define camera center in machine coordinate space self.newCenter = self.transform_matrix.T @ np.array([0, 0, 0, 0, 0, 1]) self.guess_position[0]= np.around(self.newCenter[0],3) self.guess_position[1]= np.around(self.newCenter[1],3) _logger.info('Calibration positional guess: ' + str(self.guess_position)) + + # Set next calibration variables state + self.state = 200 + self.retries = 0 + self.calibrationMoves = 0 + params = {'position':{'X': self.guess_position[0], 'Y': self.guess_position[1]}} self.moveAbsoluteSignal.emit(params) return elif(self.state == 200): + # Update GUI with current status if(self.__stateEndstopAutoCalibrate): updateMessage = 'Endstop calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) + # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start @@ -1447,45 +1502,56 @@ def autoCalibrate(self): self.offsets = -1*(0.55*self.transform_matrix.T @ self.v) self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) + # Add rounding handling for endstop alignment if(self.__stateEndstopAutoCalibrate): - if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.04): + if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): self.offsets[0] = 0.0 self.offsets[1] = 0.0 - - # Start checking calibration status + + # Start timer if we're calibrating the CP using the automated endstop detection try: if(self.toolTime): pass except: self.toolTime = time.time() - # current calibration cycle runtime + + # calculate current calibration cycle runtime runtime = np.around(time.time() - self.toolTime,1) + # check if too much time has passed if(runtime > 60 or self.calibrationMoves > 20): - # too long of a cycle, default to manual override self.retries = 10 + # Otherwise, check if we're not aligned to the center elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000} self.moveRelativeSignal.emit(params) _logger.debug('Calibration move X{0:-1.3f} Y{1:-1.3f} F1000 '.format(self.offsets[0],self.offsets[1])) return + # finally, we're aligned to the center, and we should update the tool offsets elif(self.offsets[0] == 0.0 and self.offsets[1] == 0.0): - # auto calibration complete - end process + # endstop calibration wrapping up if(self.__stateEndstopAutoCalibrate): - # endstop calibration handling updateMessage = 'Endstop auto-calibrated in ' + str(self.calibrationMoves) + ' steps. (MPP=' + str(self.mpp) +')' + # update state flags for endstop alignment self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False + self.__stateManualCPCapture = False + # update detection manager state self.toggleEndstopAutoDetectionSignal.emit(False) - # disable detection + # disable detection manager frame analysis self.toggleDetectionSignal.emit(False) + self.__displayCrosshair = False + # Set CP location self.__cpCoordinates['X'] = np.around(self.__currentPosition['X'],2) self.__cpCoordinates['Y'] = np.around(self.__currentPosition['Y'],2) self.__cpCoordinates['Z'] = np.around(self.__currentPosition['Z'],2) + # Update GUI statusbar with CP coordinates and green status self.cpLabel.setText('CP: ('+ str(self.__cpCoordinates['X']) + ', ' + str(self.__cpCoordinates['Y']) + ')') self.cpLabel.setStyleSheet(self.styleGreen) + # Reset entire GUI for next state self.stateCalibrateReady() self.repaint() + # tool calibration wrapping up elif(self.__stateAutoNozzleAlignment): updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' has been calibrated.' self.state = 100 @@ -1501,10 +1567,9 @@ def autoCalibrate(self): self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True self.pollCoordinatesSignal.emit() return - - self.retries = 0 if(self.__stateEndstopAutoCalibrate is True): self.updateStatusbarMessage('Failed to detect endstop.') _logger.warning('Failed to detect endstop. Cancelled operation.') @@ -1535,6 +1600,7 @@ def autoCalibrate(self): self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleNozzleDetectionSignal.emit(True) self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True ########################################################################### Module interfaces and handlers @@ -1737,8 +1803,6 @@ def printerConnected(self, printerJSON): # send calling to log _logger.debug('*** calling App.printerConnected') self.__activePrinter = printerJSON - #HBHBHBHB TODO: update interface for connected state - # self.statusBar.showMessage('Printer connected.') # Status label self.connectionStatusLabel.setText(self.__activePrinter['nickname'] + ' [' + self.__activePrinter['controller'] + ' v' + self.__activePrinter['version'] + ']') self.connectionStatusLabel.setStyleSheet(self.styleGreen) @@ -1792,6 +1856,7 @@ def printerError(self, message): def callTool(self, toolNumber=-1): # disable detection self.toggleDetectionSignal.emit(False) + self.__displayCrosshair = False toolNumber = int(toolNumber) if(toolNumber == -1): self.unloadToolSignal.emit() @@ -1830,18 +1895,21 @@ def printerMoveComplete(self): if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): # enable detection self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True _logger.debug('Endstop auto detection active') # Calibrating camera, go based on state self.tabPanel.setDisabled(True) elif(self.__stateAutoNozzleAlignment is True): # enable detection self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True _logger.debug('Nozzle auto detection active') # calibrating nozzle auto self.tabPanel.setDisabled(True) elif(self.__stateManualNozzleAlignment is True): # enable detection self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True _logger.debug('Nozzle manual detection active') # calibrating nozzle manual self.alignToolsButton.setVisible(False) @@ -1855,6 +1923,7 @@ def saveCurrentPosition(self, coordinates): self.__currentPosition = coordinates _logger.debug('Coordinates received:' + str(coordinates)) self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True if(self.__stateManualCPCapture is True): _logger.debug('saveCurrentPosition: manual CP capture') self.__cpCoordinates = coordinates @@ -1869,29 +1938,25 @@ def saveCurrentPosition(self, coordinates): _logger.debug('saveCurrentPosition: autoCalibrate nozzle for T' + str(int(self.__activePrinter['currentTool']))) else: _logger.debug('saveCurrentPosition: autoCalibrate endstop.') - self.autoCalibrate() - else: + elif(self.state == 100): _logger.debug('saveCurrentPosition: autoCalibrate nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) - # set initial state variable based on camera matrix exists or not - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 + # set state to detect next tool + self.state = 200 self.__stateAutoNozzleAlignment = True self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) + return + self.autoCalibrate() elif(self.__stateManualNozzleAlignment is True): _logger.debug('saveCurrentPosition: manual nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) - # set initial state variable based on camera matrix exists or not - if(self.transform_matrix is None or self.mpp is None): - self.state = 0 - else: - self.state = 200 + # set state to detect next tool + self.state = 200 self.__stateAutoNozzleAlignment = True self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) + return elif(self.__firstConnection): _logger.debug('saveCurrentPosition: firstConnection') self.__restorePosition = copy.deepcopy(self.__currentPosition) @@ -2076,6 +2141,25 @@ def least_square_mapping(self,calibration_points): transform = np.linalg.lstsq(A, real_coords, rcond = None) return transform[0], transform[1].mean() + def toggleCrosshair(self): + if(self.__stateAutoCPCapture is False and self.__stateAutoNozzleAlignment is False and self.__stateEndstopAutoCalibrate is False): + if(self.__displayCrosshair is False): + print('Crosshair on') + self.crosshairDisplayButton.setChecked(True) + self.crosshairDisplayButton.setStyleSheet(self.styleOrange) + self.__displayCrosshair = True + self.toggleNozzleDetectionSignal.emit(True) + self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True + else: + print('Crosshair off') + self.crosshairDisplayButton.setChecked(False) + self.crosshairDisplayButton.setStyleSheet(self.styleBlue) + self.__displayCrosshair = False + self.toggleNozzleDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) + self.__displayCrosshair = False + ########################################################################### Close application handler def closeEvent(self, event): _logger.debug('*** calling App.closeEvent') diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 177e367..5833a3f 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -234,8 +234,6 @@ def analyzeEndstopFrame(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # for i in range(10): - # self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -294,39 +292,60 @@ def burstEndstopDetection(self): self.__uv = None def endstopContourDetection(self, detectFrame): - # apply endstop detection algorithm - usedFrame = copy.deepcopy(detectFrame) - yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) - yuvPlanes = cv2.split(yuv) - still = yuvPlanes[0] - black = np.zeros((still.shape[0],still.shape[1]), np.uint8) - kernel = np.ones((5,5),np.uint8) - img_blur = cv2.GaussianBlur(still, (7, 7), 3) - img_canny = cv2.Canny(img_blur, 50, 190) - img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) - cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) - black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) - cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) - black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) - # detectFrame = black center = (None, None) - if len(cnt2) > 0: - myContours = [] - for k in range(len(cnt2)): - if hierarchy2[0][k][3] > -1: - myContours.append(cnt2[k]) - if len(myContours) > 0: - # return only the biggest detected contour - blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) - contourArea = cv2.contourArea(blobContours) - if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): - M = cv2.moments(blobContours) - center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) - detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5) - detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2) - detectFrame = cv2.line(detectFrame, (320,0), (320,480), (255,255,255), 1) - detectFrame = cv2.line(detectFrame, (0,240), (640,240), (255,255,255), 1) + if(self.__endstopAutomatedDetectionActive is True): + # apply endstop detection algorithm + usedFrame = copy.deepcopy(detectFrame) + yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV) + yuvPlanes = cv2.split(yuv) + still = yuvPlanes[0] + black = np.zeros((still.shape[0],still.shape[1]), np.uint8) + kernel = np.ones((5,5),np.uint8) + img_blur = cv2.GaussianBlur(still, (7, 7), 3) + img_canny = cv2.Canny(img_blur, 50, 190) + img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2) + cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1) + black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2) + cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR) + # detectFrame = black + if len(cnt2) > 0: + myContours = [] + for k in range(len(cnt2)): + if hierarchy2[0][k][3] > -1: + myContours.append(cnt2[k]) + if len(myContours) > 0: + # return only the biggest detected contour + blobContours = max(myContours, key=lambda el: cv2.contourArea(el)) + contourArea = cv2.contourArea(blobContours) + if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000): + M = cv2.moments(blobContours) + center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])) + detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5,lineType=cv2.LINE_AA) + detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2,lineType=cv2.LINE_AA) + + detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320,480), color=(0,0,0), horizontal=False, segmentWidth=4, lineWidth=2) + detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(640,240), color=(0,0,0), horizontal=True, segmentWidth=4, lineWidth=2) + detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320,480), horizontal=False, segmentWidth=4, lineWidth=1) + detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(640,240), horizontal=True, segmentWidth=4, lineWidth=1) + + else: + # draw crosshair + keypointRadius = 57 + width = 4 + detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320, 240-keypointRadius), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(320,240+keypointRadius), end=(320,480), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320, 240-keypointRadius), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(320,240+keypointRadius), end=(320,480), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width) + + detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(320-keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(320+keypointRadius,240), end=(640,240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(320-keypointRadius, 240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) + detectFrame = self.dashedLine(image=detectFrame, start=(320+keypointRadius,240), end=(640,240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) + + detectFrame = cv2.circle(img=detectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) + detectFrame = cv2.circle(img=detectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) return(center,detectFrame) @pyqtSlot(bool) @@ -345,6 +364,21 @@ def toggleEndstopAutoDetection(self, endstopDetectFlag): self.__endstopDetectionActive = False self.__endstopAutomatedDetectionActive = False + def dashedLine(self, image, start, end, color=(255,255,255), segmentWidth=10, horizontal=True, lineWidth=1): + if(horizontal): + segments = int((end[0] - start[0])/segmentWidth) + else: + segments = int((end[1] - start[1])/segmentWidth) + for i in range(segments): + if(horizontal): + segmentStart = (start[0] + segmentWidth*i, start[1]) + segmentEnd = (segmentStart[0]+segmentWidth, segmentStart[1]) + else: + segmentStart = (start[0], start[1] + segmentWidth*i) + segmentEnd = (segmentStart[0], segmentStart[1]+segmentWidth) + if(i%2 == 0): + image = cv2.line( image, segmentStart, segmentEnd, color, lineWidth) + return(image) ##### Nozzle detection def analyzeNozzleFrame(self): @@ -355,10 +389,20 @@ def analyzeNozzleFrame(self): self.frame = self.videoFeed.getFrame() self.__uv = [None,None] # draw crosshair - self.frame = cv2.line(self.frame, (320,0), (320,480), (0,0,0), 3) - self.frame = cv2.line(self.frame, (0,240), (640,240), (0,0,0), 3) - self.frame = cv2.line(self.frame, (320,0), (320,480), (255,255,255), 1) - self.frame = cv2.line(self.frame, (0,240), (640,240), (255,255,255), 1) + keypointRadius = 17 + width = 4 + self.frame = self.dashedLine(image=self.frame, start=(320,0), end=(320, 240-keypointRadius), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320,240+keypointRadius), end=(320,480), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320,0), end=(320, 240-keypointRadius), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320,240+keypointRadius), end=(320,480), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width) + + self.frame = self.dashedLine(image=self.frame, start=(0,240), end=(320-keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320+keypointRadius,240), end=(640,240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(0,240), end=(320-keypointRadius, 240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320+keypointRadius,240), end=(640,240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) + + self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) + self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) def burstNozzleDetection(self): detectionCount = 0 @@ -428,19 +472,19 @@ def nozzleDetection(self): # create radius object keypointRadius = np.around(keypoints[0].size/2) keypointRadius = int(keypointRadius) - circleFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius,color=keypointColor,thickness=-1) + circleFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius,color=keypointColor,thickness=-1,lineType=cv2.LINE_AA) nozzleDetectFrame = cv2.addWeighted(circleFrame, 0.4, nozzleDetectFrame, 0.6, 0) - nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius, color=(0,0,0), thickness=1) + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius, color=(0,0,0), thickness=1,lineType=cv2.LINE_AA) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x-5,y), (x+5, y), (255,255,255), 2) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x,y-5), (x, y+5), (255,255,255), 2) else: # no keypoints, draw a 3 outline circle in the middle of the frame keypointRadius = 17 - nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3) - nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1) + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) + nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) # draw crosshair - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 3) - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 3) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 2) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 2) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (255,255,255), 1) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (255,255,255), 1) return(center, nozzleDetectFrame) From d301136fb181955378e1ae3edec932e37ef1feab Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:43:34 +0200 Subject: [PATCH 35/99] Update TAMV.py --- TAMV.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TAMV.py b/TAMV.py index 02a9f17..93e7312 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1392,6 +1392,11 @@ def calibrateTools(self, toolset=None): def autoCalibrate(self): self.tabPanel.setDisabled(True) + try: + if(runtime): + pass + except: + runtime = time.time() if(self.uv is not None): if(self.uv[0] is not None and self.uv[1] is not None): self.retries = 0 From 39a255837df7ebea9141332538d443b9c3820e11 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 14:18:43 +0200 Subject: [PATCH 36/99] trying adding mutex to fix linux issues --- TAMV.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/TAMV.py b/TAMV.py index 93e7312..84031b4 100644 --- a/TAMV.py +++ b/TAMV.py @@ -7,7 +7,7 @@ # openCV imports from cv2 import cvtColor, COLOR_BGR2RGB # Qt imports -from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QSize, QThread +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QSize, QThread, QMutex from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QStyle, QWidget, QMenu, QAction, QStatusBar, QLabel, QHBoxLayout, QVBoxLayout, QTextEdit, QPushButton, QApplication, QTabWidget, QButtonGroup, QGridLayout, QFrame, QCheckBox # Other imports @@ -22,6 +22,8 @@ ########################################################################### Core application class class App(QMainWindow): + # "Global" mutex + __mutex = QMutex() # Signals ######## Detection Manager startVideoSignal = pyqtSignal() @@ -49,12 +51,11 @@ class App(QMainWindow): limitAxesSignal = pyqtSignal() flushBufferSignal = pyqtSignal() saveToFirmwareSignal = pyqtSignal() - # Setting Dialog + # Settings Dialog resetImageSignal = pyqtSignal() pushbuttonSize = 38 # default move speed in feedrate/min _moveSpeed = 6000 - __counter = 0 ########################################################################### Initialize class @@ -1652,18 +1653,18 @@ def startVideo(self, cameraProperties): @pyqtSlot(object) def refreshImage(self, data): - #print('TAMV:', self.__counter) - self.__lastCounter = self.__counter - self.__counter += 1 + self.__mutex.lock() frame = data[0] uvCoordinates = data[1] self.image.setPixmap(frame) if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): self.uv = uvCoordinates + self.__mutex.unlock() self.getVideoFrameSignal.emit() @pyqtSlot(object) def updateStatusbarMessage(self, message): + self.__mutex.lock() # send calling to log _logger.debug('*** calling App.updateStatusbarMessage') try: @@ -1675,9 +1676,11 @@ def updateStatusbarMessage(self, message): app.processEvents() # send exiting to log _logger.debug('*** exiting App.updateStatusbarMessage') + self.__mutex.unlock() @pyqtSlot(object) def detectionManagerError(self, message): + self.__mutex.lock() self.haltPrinterOperation(silent=True) try: self.statusBar.showMessage(message) @@ -1696,6 +1699,7 @@ def detectionManagerError(self, message): self.printerThread.wait() # display error image self.image.setPixmap(self.errorImage) + self.__mutex.unlock() ########################################################################### Interface with Printer Manager def createPrinterManagerThread(self,announce=True): @@ -1805,6 +1809,7 @@ def haltPrinterOperation(self, **kwargs): @pyqtSlot(object) def printerConnected(self, printerJSON): + self.__mutex.lock() # send calling to log _logger.debug('*** calling App.printerConnected') self.__activePrinter = printerJSON @@ -1819,9 +1824,11 @@ def printerConnected(self, printerJSON): self.repaint() # send exiting to log _logger.debug('*** exiting App.printerConnected') + self.__mutex.unlock() @pyqtSlot(object) def printerDisconnected(self, **kwargs): + self.__mutex.lock() try: message = kwargs['message'] except: message = None @@ -1841,9 +1848,11 @@ def printerDisconnected(self, **kwargs): self.repaint() # send exiting to log _logger.debug('*** exiting App.printerDisconnected') + self.__mutex.unlock() @pyqtSlot(object) def printerError(self, message): + self.__mutex.lock() _logger.debug('Printer Error: ' + message) if(self.__restorePosition is not None): params = {'parkPosition': self.__restorePosition} @@ -1857,6 +1866,7 @@ def printerError(self, message): except: _logger.warning('Printer thread not created yet.') self.printerDisconnected(message=message) self.statusBar.setStyleSheet(self.styleRed) + self.__mutex.unlock() def callTool(self, toolNumber=-1): # disable detection @@ -1887,15 +1897,18 @@ def toolLoaded(self): @pyqtSlot(int) def registerActiveTool(self, toolIndex): + self.__mutex.lock() self.__activePrinter['currentTool'] = toolIndex for button in self.toolButtons: if(button.objectName() != ('toolButton_'+str(toolIndex))): button.setChecked(False) else: button.setChecked(True) + self.__mutex.unlock() @pyqtSlot() def printerMoveComplete(self): + self.__mutex.lock() self.tabPanel.setDisabled(False) if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): # enable detection @@ -1920,11 +1933,14 @@ def printerMoveComplete(self): self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) self.manualCPCaptureButton.setVisible(True) + self.__mutex.unlock() return self.pollCoordinatesSignal.emit() + self.__mutex.unlock() @pyqtSlot(object) def saveCurrentPosition(self, coordinates): + self.__mutex.lock() self.__currentPosition = coordinates _logger.debug('Coordinates received:' + str(coordinates)) self.toggleDetectionSignal.emit(True) @@ -1951,6 +1967,7 @@ def saveCurrentPosition(self, coordinates): self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) + self.__mutex.unlock() return self.autoCalibrate() elif(self.__stateManualNozzleAlignment is True): @@ -1961,14 +1978,17 @@ def saveCurrentPosition(self, coordinates): self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) + self.__mutex.unlock() return elif(self.__firstConnection): _logger.debug('saveCurrentPosition: firstConnection') self.__restorePosition = copy.deepcopy(self.__currentPosition) self.__firstConnection = False + self.__mutex.unlock() @pyqtSlot(object) def calibrateOffsetsApplied(self, params=None): + self.__mutex.lock() try: offsets = params['offsets'] except: offsets = None @@ -1983,7 +2003,9 @@ def calibrateOffsetsApplied(self, params=None): self.toggleNozzleAutoDetectionSignal.emit(True) self.calibrateTools(self.workingToolset) else: + self.__mutex.unlock() raise SystemExit('FUCKED!') + self.__mutex.unlock() ########################################################################### Interface with Settings Dialog def displayPreferences(self, event=None, newPrinterFlag=False): From fed2414882c3576bc207c47a80d77c6e3be932c1 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 14:22:29 +0200 Subject: [PATCH 37/99] Update TAMV.py --- TAMV.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TAMV.py b/TAMV.py index 84031b4..d65e9dd 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1818,13 +1818,13 @@ def printerConnected(self, printerJSON): self.connectionStatusLabel.setStyleSheet(self.styleGreen) # poll for position self.__firstConnection = True + self.__mutex.unlock() self.pollCoordinatesSignal.emit() # Gui state self.stateConnected() self.repaint() # send exiting to log _logger.debug('*** exiting App.printerConnected') - self.__mutex.unlock() @pyqtSlot(object) def printerDisconnected(self, **kwargs): @@ -1844,15 +1844,15 @@ def printerDisconnected(self, **kwargs): # Status label self.connectionStatusLabel.setText('Disconnected') self.connectionStatusLabel.setStyleSheet(self.styleOrange) + self.__mutex.unlock() self.stateDisconnected() self.repaint() # send exiting to log _logger.debug('*** exiting App.printerDisconnected') - self.__mutex.unlock() + @pyqtSlot(object) def printerError(self, message): - self.__mutex.lock() _logger.debug('Printer Error: ' + message) if(self.__restorePosition is not None): params = {'parkPosition': self.__restorePosition} @@ -1866,7 +1866,6 @@ def printerError(self, message): except: _logger.warning('Printer thread not created yet.') self.printerDisconnected(message=message) self.statusBar.setStyleSheet(self.styleRed) - self.__mutex.unlock() def callTool(self, toolNumber=-1): # disable detection From b454f0e9d1dd484542b352e21984e82aa98a36c6 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 14:25:38 +0200 Subject: [PATCH 38/99] Update TAMV.py --- TAMV.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index d65e9dd..e630d02 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1680,8 +1680,8 @@ def updateStatusbarMessage(self, message): @pyqtSlot(object) def detectionManagerError(self, message): - self.__mutex.lock() self.haltPrinterOperation(silent=True) + self.__mutex.lock() try: self.statusBar.showMessage(message) self.statusBar.setStyleSheet(self.styleRed) @@ -1692,6 +1692,7 @@ def detectionManagerError(self, message): except: errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) + self.__mutex.unlock() # Kill thread self.detectionThread.quit() self.detectionThread.wait() @@ -1699,7 +1700,7 @@ def detectionManagerError(self, message): self.printerThread.wait() # display error image self.image.setPixmap(self.errorImage) - self.__mutex.unlock() + ########################################################################### Interface with Printer Manager def createPrinterManagerThread(self,announce=True): From 7a98f6f5d2eb404ec39030cbb0bba44d94520fe3 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 14:30:55 +0200 Subject: [PATCH 39/99] Update TAMV.py --- TAMV.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/TAMV.py b/TAMV.py index e630d02..15097fa 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1673,10 +1673,10 @@ def updateStatusbarMessage(self, message): except: errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) + self.__mutex.unlock() app.processEvents() # send exiting to log _logger.debug('*** exiting App.updateStatusbarMessage') - self.__mutex.unlock() @pyqtSlot(object) def detectionManagerError(self, message): @@ -1688,11 +1688,13 @@ def detectionManagerError(self, message): self.cpLabel.setStyleSheet(self.styleOrange) self.connectionStatusLabel.setStyleSheet(self.styleOrange) self.resetCalibration() + self.__mutex.unlock() self.moveAbsoluteSignal.emit(self.originalPrinterPosition) except: + self.__mutex.unlock() errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) - self.__mutex.unlock() + # Kill thread self.detectionThread.quit() self.detectionThread.wait() @@ -1908,7 +1910,6 @@ def registerActiveTool(self, toolIndex): @pyqtSlot() def printerMoveComplete(self): - self.__mutex.lock() self.tabPanel.setDisabled(False) if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate): # enable detection @@ -1933,15 +1934,14 @@ def printerMoveComplete(self): self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) self.manualCPCaptureButton.setVisible(True) - self.__mutex.unlock() return self.pollCoordinatesSignal.emit() - self.__mutex.unlock() @pyqtSlot(object) def saveCurrentPosition(self, coordinates): self.__mutex.lock() self.__currentPosition = coordinates + self.__mutex.unlock() _logger.debug('Coordinates received:' + str(coordinates)) self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True @@ -1967,7 +1967,6 @@ def saveCurrentPosition(self, coordinates): self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) - self.__mutex.unlock() return self.autoCalibrate() elif(self.__stateManualNozzleAlignment is True): @@ -1978,17 +1977,14 @@ def saveCurrentPosition(self, coordinates): self.toggleNozzleAutoDetectionSignal.emit(True) params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) - self.__mutex.unlock() return elif(self.__firstConnection): _logger.debug('saveCurrentPosition: firstConnection') self.__restorePosition = copy.deepcopy(self.__currentPosition) self.__firstConnection = False - self.__mutex.unlock() @pyqtSlot(object) def calibrateOffsetsApplied(self, params=None): - self.__mutex.lock() try: offsets = params['offsets'] except: offsets = None @@ -2003,9 +1999,7 @@ def calibrateOffsetsApplied(self, params=None): self.toggleNozzleAutoDetectionSignal.emit(True) self.calibrateTools(self.workingToolset) else: - self.__mutex.unlock() raise SystemExit('FUCKED!') - self.__mutex.unlock() ########################################################################### Interface with Settings Dialog def displayPreferences(self, event=None, newPrinterFlag=False): From ae2b475bdbdf12c0c97ab9e3bee80754c62fe139 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 14:33:00 +0200 Subject: [PATCH 40/99] Update TAMV.py --- TAMV.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 15097fa..bd6c359 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1618,7 +1618,7 @@ def createDetectionManagerThread(self, announce=True): self.detectionThread = QThread() self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None) self.detectionManager.moveToThread(self.detectionThread) - self.detectionThread.start(priority=QThread.TimeCriticalPriority) + self.detectionThread.start()#priority=QThread.TimeCriticalPriority) # Thread management signals and slots self.detectionManager.errorSignal.connect(self.detectionManagerError) self.detectionThread.started.connect(self.detectionManager.processFrame) @@ -1743,7 +1743,7 @@ def createPrinterManagerThread(self,announce=True): self.printerManager.offsetsSetSignal.connect(self.calibrateOffsetsApplied) self.setOffsetsSignal.connect(self.printerManager.calibrationSetOffset) - self.printerThread.start(priority=QThread.TimeCriticalPriority) + self.printerThread.start()#priority=QThread.TimeCriticalPriority) except Exception as e: print(e) errorMsg = 'Failed to start PrinterManager.' From 8cc3538ac8ff7e9dd6621354cfd8ec5a4ed84492 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 15:05:34 +0200 Subject: [PATCH 41/99] working on Windows 10 --- TAMV.py | 38 ++++++++++++++++++++++++++----------- modules/DetectionManager.py | 7 ++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/TAMV.py b/TAMV.py index bd6c359..b9e12c2 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1296,6 +1296,7 @@ def resetNozzleAlignment(self): self.toggleDetectionSignal.emit(False) self.__displayCrosshair = False self.resetCalibrationVariables() + self.tabPanel.setDisabled(True) self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() @@ -1347,12 +1348,18 @@ def startAlignTools(self): _logger.debug('*** exiting App.startAlignTools') def identifyToolButton(self): + self.tabPanel.setDisabled(True) sender = self.sender() for button in self.toolButtons: if button.objectName() != sender.objectName(): button.setChecked(False) toolIndex = int(sender.objectName()[11:]) - self.callTool(toolIndex) + if(toolIndex != int(self.__activePrinter['currentTool'])): + self.updateStatusbarMessage('>>> Loading T' + str(toolIndex) + '.. >>>') + self.callTool(toolIndex) + else: + self.updateStatusbarMessage('<<< Unloading tool.. <<<') + self.callTool(-1) def calibrateTools(self, toolset=None): # toolset is passed the first time we call the function @@ -1403,7 +1410,7 @@ def autoCalibrate(self): self.retries = 0 # First calibration step if(self.state == 0): - print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) + _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') self.olduv = self.uv @@ -1434,7 +1441,7 @@ def autoCalibrate(self): # save camera coordinates self.camera_coordinates.append((self.uv[0],self.uv[1])) - print('*** State:', self.state,' Coords:', self.__currentPosition, ' UV:', self.uv, ' old UV:', self.olduv) + _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # return carriage to relative center of movement self.offsetX = (-1*self.offsetX) @@ -1879,9 +1886,6 @@ def callTool(self, toolNumber=-1): self.unloadToolSignal.emit() return try: - # if(toolNumber == int(self.__activePrinter['currentTool'])): - # self.unloadToolSignal.emit() - # else: self.callToolSignal.emit(toolNumber) except: errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) @@ -1915,26 +1919,40 @@ def printerMoveComplete(self): # enable detection self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True - _logger.debug('Endstop auto detection active') + statusMsg = '(Endstop auto detection active.)' + self.updateStatusbarMessage(statusMsg) + _logger.debug(statusMsg) # Calibrating camera, go based on state self.tabPanel.setDisabled(True) elif(self.__stateAutoNozzleAlignment is True): # enable detection self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True - _logger.debug('Nozzle auto detection active') + statusMsg = '(Tool/nozzle auto detection active.)' + self.updateStatusbarMessage(statusMsg) + _logger.debug(statusMsg) # calibrating nozzle auto self.tabPanel.setDisabled(True) elif(self.__stateManualNozzleAlignment is True): # enable detection self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True - _logger.debug('Nozzle manual detection active') + statusMsg = '(Tool/nozzle manual override active.)' + self.updateStatusbarMessage(statusMsg) + _logger.debug(statusMsg) # calibrating nozzle manual self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) self.manualCPCaptureButton.setVisible(True) return + elif(self.__stateManualCPCapture is True): + statusMsg = '(Endstop/CP manual override active.)' + self.updateStatusbarMessage(statusMsg) + _logger.debug(statusMsg) + else: + statusMsg = 'Ready.' + self.updateStatusbarMessage(statusMsg) + _logger.debug(statusMsg) self.pollCoordinatesSignal.emit() @pyqtSlot(object) @@ -2165,7 +2183,6 @@ def least_square_mapping(self,calibration_points): def toggleCrosshair(self): if(self.__stateAutoCPCapture is False and self.__stateAutoNozzleAlignment is False and self.__stateEndstopAutoCalibrate is False): if(self.__displayCrosshair is False): - print('Crosshair on') self.crosshairDisplayButton.setChecked(True) self.crosshairDisplayButton.setStyleSheet(self.styleOrange) self.__displayCrosshair = True @@ -2173,7 +2190,6 @@ def toggleCrosshair(self): self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True else: - print('Crosshair off') self.crosshairDisplayButton.setChecked(False) self.crosshairDisplayButton.setStyleSheet(self.styleBlue) self.__displayCrosshair = False diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 5833a3f..ae35a73 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -265,7 +265,7 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - for j in range(3): + for j in range(10): self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): @@ -386,7 +386,6 @@ def analyzeNozzleFrame(self): self.uv = [0, 0] average_location=[0,0] retries = 0 - self.frame = self.videoFeed.getFrame() self.__uv = [None,None] # draw crosshair keypointRadius = 17 @@ -410,7 +409,9 @@ def burstNozzleDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - self.frame = self.videoFeed.getFrame() + # skip a few frames + for i in range(10): + self.frame = self.videoFeed.getFrame() (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): From f1730717920ac80650f4d12438a9c8e3a3c0e257 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 17:03:44 +0200 Subject: [PATCH 42/99] adding resume alignment button --- TAMV.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index b9e12c2..033d58d 100644 --- a/TAMV.py +++ b/TAMV.py @@ -653,6 +653,7 @@ def setupMainWindow(self): self.instructionsBox = QTextEdit() self.instructionsBox.setReadOnly(True) self.instructionsBox.setFixedSize(640, 45) + self.instructionsBox.setVisible(False) self.footerLayout.addWidget(self.instructionsBox, 0,0,1,-1,Qt.AlignLeft|Qt.AlignVCenter) # Manual CP Capture button @@ -688,6 +689,17 @@ def setupMainWindow(self): self.alignToolsButton.clicked.connect(self.startAlignTools) self.footerLayout.addWidget(self.alignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton = QPushButton('Auto Align') + self.resumeAutoToolAlignmentButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.resumeAutoToolAlignmentButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.resumeAutoToolAlignmentButton.setToolTip('Resume automated calibration') + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.clicked.connect(self.resumeAutoAlignment) + self.footerLayout.addWidget(self.resumeAutoToolAlignmentButton, 0,0,1,1,Qt.AlignRight|Qt.AlignVCenter) + + #### Set current interface state to disconnected self.stateDisconnected() @@ -805,7 +817,10 @@ def stateDisconnected(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) - + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Delete tool buttons count = self.jogPanel.count() for i in range(11,count): @@ -859,6 +874,10 @@ def stateConnected(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Jog panel tab self.tabPanel.setDisabled(False) @@ -968,6 +987,10 @@ def stateCPSetup(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(False) @@ -1013,6 +1036,10 @@ def stateCPAuto(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(True) @@ -1054,6 +1081,10 @@ def stateCalibrateReady(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(False) @@ -1103,6 +1134,10 @@ def stateCalibtrateRunning(self): self.manualToolOffsetCaptureButton.setVisible(True) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(True) @@ -1147,6 +1182,10 @@ def stateCalibrateComplete(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(False) @@ -1196,6 +1235,10 @@ def stateExiting(self): self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + # Resume auto alignment button + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) # Crosshair display button self.crosshairDisplayButton.setVisible(True) self.crosshairDisplayButton.setDisabled(True) @@ -1610,11 +1653,20 @@ def autoCalibrate(self): self.manualToolOffsetCaptureButton.setVisible(True) self.manualToolOffsetCaptureButton.setDisabled(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) + self.resumeAutoToolAlignmentButton.setVisible(True) + self.resumeAutoToolAlignmentButton.setDisabled(False) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleGreen) self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleNozzleDetectionSignal.emit(True) self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True + def resumeAutoAlignment(self): + self.state = 200 + self.retries = 0 + self.__stateAutoNozzleAlignment = True + self.toggleNozzleAutoDetectionSignal.emit(True) + self.pollCoordinatesSignal.emit() ########################################################################### Module interfaces and handlers ########################################################################### Interface with Detection Manager From a265dcb16eaec0d13354b210d7a292b56621a7e4 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 17:05:08 +0200 Subject: [PATCH 43/99] Update TAMV.py --- TAMV.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TAMV.py b/TAMV.py index 033d58d..18ac8ea 100644 --- a/TAMV.py +++ b/TAMV.py @@ -690,9 +690,9 @@ def setupMainWindow(self): self.footerLayout.addWidget(self.alignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) # Resume auto alignment button - self.resumeAutoToolAlignmentButton = QPushButton('Auto Align') - self.resumeAutoToolAlignmentButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) - self.resumeAutoToolAlignmentButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.resumeAutoToolAlignmentButton = QPushButton('Resume Auto Align') + self.resumeAutoToolAlignmentButton.setMinimumSize(self.pushbuttonSize*4,self.pushbuttonSize) + self.resumeAutoToolAlignmentButton.setMaximumSize(self.pushbuttonSize*4,self.pushbuttonSize) self.resumeAutoToolAlignmentButton.setToolTip('Resume automated calibration') self.resumeAutoToolAlignmentButton.setDisabled(True) self.resumeAutoToolAlignmentButton.setVisible(False) From 06055ac0fe309df7c431db3f12461127115fc532 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 21:51:10 +0200 Subject: [PATCH 44/99] Update TAMV.py --- TAMV.py | 1 + 1 file changed, 1 insertion(+) diff --git a/TAMV.py b/TAMV.py index 18ac8ea..f451a52 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1665,6 +1665,7 @@ def resumeAutoAlignment(self): self.state = 200 self.retries = 0 self.__stateAutoNozzleAlignment = True + self.toolTime = time.time() self.toggleNozzleAutoDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() From b95700f29184047ac2c344aa8a27514552756d3c Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 12 Oct 2022 22:39:51 +0200 Subject: [PATCH 45/99] more updates and chasing down bugs. --- TAMV.py | 32 ++++++++++++++++++++++---------- modules/DetectionManager.py | 2 -- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/TAMV.py b/TAMV.py index f451a52..9a2e52c 100644 --- a/TAMV.py +++ b/TAMV.py @@ -101,7 +101,7 @@ def __init__(self, parent=None): self.__firstConnection = False self.state = 0 # Camera transform matrix - self.transform_matrix = None + self.transformMatrix = None self.transform_input = None self.mpp = None # Nozzle detection @@ -1293,6 +1293,7 @@ def setupCPAutoCapture(self): def haltCPAutoCapture(self): self.resetCalibration() + self.resetCalibrationVariables() self.statusBar.showMessage('CP calibration cancelled.') # Reset GUI self.stateConnected() @@ -1348,10 +1349,16 @@ def resetCalibrationVariables(self): # Setup camera calibration move coordinates self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] # reset all variables - self.guess_position = [1,1] + self.guessPosition = [1,1] self.uv = [0, 0] self.olduv = self.uv - if(self.transform_matrix is None or self.mpp is None): + if(self.transformMatrix is None or self.mpp is None): + _logger.debug('Camera calibration matrix reset.') + self.state = 0 + elif(len(self.transformMatrix) < 6): + print('Matrix size: ', len(self.transformMatrix)) + self.transformMatrix = None + self.mpp = None self.state = 0 else: self.state = 200 @@ -1526,20 +1533,20 @@ def autoCalibrate(self): # Calculate transformation matrix self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)] - self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input) + self.transformMatrix, self.transform_residual = self.least_square_mapping(self.transform_input) # define camera center in machine coordinate space - self.newCenter = self.transform_matrix.T @ np.array([0, 0, 0, 0, 0, 1]) - self.guess_position[0]= np.around(self.newCenter[0],3) - self.guess_position[1]= np.around(self.newCenter[1],3) - _logger.info('Calibration positional guess: ' + str(self.guess_position)) + self.newCenter = self.transformMatrix.T @ np.array([0, 0, 0, 0, 0, 1]) + self.guessPosition[0]= np.around(self.newCenter[0],3) + self.guessPosition[1]= np.around(self.newCenter[1],3) + _logger.info('Calibration positional guess: ' + str(self.guessPosition)) # Set next calibration variables state self.state = 200 self.retries = 0 self.calibrationMoves = 0 - params = {'position':{'X': self.guess_position[0], 'Y': self.guess_position[1]}} + params = {'position':{'X': self.guessPosition[0], 'Y': self.guessPosition[1]}} self.moveAbsoluteSignal.emit(params) return elif(self.state == 200): @@ -1555,7 +1562,7 @@ def autoCalibrate(self): # nozzle detected, frame rotation is set, start self.cx,self.cy = self.normalize_coords(self.uv) self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0] - self.offsets = -1*(0.55*self.transform_matrix.T @ self.v) + self.offsets = -1*(0.55*self.transformMatrix.T @ self.v) self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) @@ -1666,6 +1673,10 @@ def resumeAutoAlignment(self): self.retries = 0 self.__stateAutoNozzleAlignment = True self.toolTime = time.time() + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) + self.updateStatusbarMessage('Resuming auto detection of current tool..') self.toggleNozzleAutoDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() @@ -1868,6 +1879,7 @@ def haltPrinterOperation(self, **kwargs): self.disconnectSignal.emit(params) else: self.disconnectSignal.emit(params) + self.resetCalibrationVariables() self.updateStatusbarMessage('Printer disconnected.') @pyqtSlot(object) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index ae35a73..7166cfd 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -506,7 +506,6 @@ def toggleNozzleAutoDetection(self, nozzleDetectFlag): self.__nozzleDetectionActive = False self.__nozzleAutoDetectionActive = False - ##### Utilities # adjust image gamma def adjust_gamma(self, image, gamma=1.2): @@ -536,7 +535,6 @@ def preprocessImage(self, frameInput, algorithm=0): outputFrame = cv2.cvtColor( outputFrame, cv2.COLOR_GRAY2BGR ) return(outputFrame) - ##### Image adjustment properties @pyqtSlot(object) def getImageProperties(self, imageSettings): From 9bfec3dbf61ddc9a129a2a8af3e6e4859c601cb2 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 13 Oct 2022 14:52:32 +0200 Subject: [PATCH 46/99] Update TAMV.py --- TAMV.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/TAMV.py b/TAMV.py index 9a2e52c..9b0c70d 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1356,7 +1356,6 @@ def resetCalibrationVariables(self): _logger.debug('Camera calibration matrix reset.') self.state = 0 elif(len(self.transformMatrix) < 6): - print('Matrix size: ', len(self.transformMatrix)) self.transformMatrix = None self.mpp = None self.state = 0 @@ -1460,7 +1459,7 @@ def autoCalibrate(self): self.retries = 0 # First calibration step if(self.state == 0): - _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') self.olduv = self.uv @@ -1482,7 +1481,7 @@ def autoCalibrate(self): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): # Calculate mpp at first move - if(self.state == 1): + if(self.state == 2): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) # save position as previous position self.olduv = self.uv @@ -1491,7 +1490,7 @@ def autoCalibrate(self): # save camera coordinates self.camera_coordinates.append((self.uv[0],self.uv[1])) - _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # return carriage to relative center of movement self.offsetX = (-1*self.offsetX) @@ -1550,6 +1549,7 @@ def autoCalibrate(self): self.moveAbsoluteSignal.emit(params) return elif(self.state == 200): + _logger.info('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # Update GUI with current status if(self.__stateEndstopAutoCalibrate): updateMessage = 'Endstop calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' @@ -1669,7 +1669,14 @@ def autoCalibrate(self): self.__displayCrosshair = True def resumeAutoAlignment(self): - self.state = 200 + if(self.transformMatrix is None or self.mpp is None): + self.state = 0 + elif(len(self.transformMatrix) < 6): + self.transformMatrix = None + self.mpp = None + self.state = 0 + else: + self.state = 200 self.retries = 0 self.__stateAutoNozzleAlignment = True self.toolTime = time.time() @@ -1773,7 +1780,6 @@ def detectionManagerError(self, message): self.printerThread.wait() # display error image self.image.setPixmap(self.errorImage) - ########################################################################### Interface with Printer Manager def createPrinterManagerThread(self,announce=True): From f9effad04b75a5ebb6805b160f422f34d13197eb Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 13 Oct 2022 14:55:21 +0200 Subject: [PATCH 47/99] Update TAMV.py --- TAMV.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 9b0c70d..f5c67a6 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1480,6 +1480,7 @@ def autoCalibrate(self): elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): + _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # Calculate mpp at first move if(self.state == 2): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) @@ -1490,8 +1491,6 @@ def autoCalibrate(self): # save camera coordinates self.camera_coordinates.append((self.uv[0],self.uv[1])) - _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) - # return carriage to relative center of movement self.offsetX = (-1*self.offsetX) self.offsetY = (-1*self.offsetY) From 1bd92c9ede36e21551b0eee9c8a704ea28a583b3 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 13 Oct 2022 15:33:05 +0200 Subject: [PATCH 48/99] Update DetectionManager.py --- modules/DetectionManager.py | 45 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 7166cfd..ef887b0 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -387,6 +387,7 @@ def analyzeNozzleFrame(self): average_location=[0,0] retries = 0 self.__uv = [None,None] + (self.__uv, self.frame) = self.nozzleDetection() # draw crosshair keypointRadius = 17 width = 4 @@ -445,25 +446,24 @@ def nozzleDetection(self): preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) - if( self.__nozzleAutoDetectionActive is True ): - # apply combo 1 (standard detector, preprocessor 0) - keypoints = self.detector.detect(preprocessorImage0) - keypointColor = (0,0,255) + # apply combo 1 (standard detector, preprocessor 0) + keypoints = self.detector.detect(preprocessorImage0) + keypointColor = (0,0,255) + if(len(keypoints) != 1): + # apply combo 2 (standard detector, preprocessor 1) + keypoints = self.detector.detect(preprocessorImage1) + keypointColor = (0,255,0) if(len(keypoints) != 1): - # apply combo 2 (standard detector, preprocessor 1) - keypoints = self.detector.detect(preprocessorImage1) - keypointColor = (0,255,0) + # apply combo 3 (standard detector, preprocessor 0) + keypoints = self.relaxedDetector.detect(preprocessorImage0) + keypointColor = (255,0,0) if(len(keypoints) != 1): - # apply combo 3 (standard detector, preprocessor 0) - keypoints = self.relaxedDetector.detect(preprocessorImage0) - keypointColor = (255,0,0) + # apply combo 4 (standard detector, preprocessor 1) + keypoints = self.relaxedDetector.detect(preprocessorImage1) + keypointColor = (39,127,255) if(len(keypoints) != 1): - # apply combo 4 (standard detector, preprocessor 1) - keypoints = self.relaxedDetector.detect(preprocessorImage1) - keypointColor = (39,127,255) - if(len(keypoints) != 1): - # failed to detect a nozzle, correct return value object - keypoints = None + # failed to detect a nozzle, correct return value object + keypoints = None # process keypoint if(keypoints is not None): # create center object @@ -478,16 +478,17 @@ def nozzleDetection(self): nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius, color=(0,0,0), thickness=1,lineType=cv2.LINE_AA) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x-5,y), (x+5, y), (255,255,255), 2) nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x,y-5), (x, y+5), (255,255,255), 2) - else: + elif(self.__nozzleAutoDetectionActive is True): # no keypoints, draw a 3 outline circle in the middle of the frame keypointRadius = 17 nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) - # draw crosshair - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 2) - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 2) - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (255,255,255), 1) - nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (255,255,255), 1) + if(self.__nozzleAutoDetectionActive is True): + # draw crosshair + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 2) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 2) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (255,255,255), 1) + nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (255,255,255), 1) return(center, nozzleDetectFrame) @pyqtSlot(bool) From 836aae2f6ef34ad18699584cb200162ce6160364 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 13 Oct 2022 15:39:42 +0200 Subject: [PATCH 49/99] Update TAMV.py --- TAMV.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index f5c67a6..d0c9780 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1581,7 +1581,7 @@ def autoCalibrate(self): # calculate current calibration cycle runtime runtime = np.around(time.time() - self.toolTime,1) # check if too much time has passed - if(runtime > 60 or self.calibrationMoves > 20): + if(runtime > 120 or self.calibrationMoves > 30): self.retries = 10 # Otherwise, check if we're not aligned to the center elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): @@ -1621,7 +1621,7 @@ def autoCalibrate(self): self.updateStatusbarMessage(updateMessage) self.pollCoordinatesSignal.emit() return - elif(self.retries < 100 and runtime <= 15): + elif(self.retries < 100 and runtime <= 120): self.retries += 1 self.pollCoordinatesSignal.emit() return From d1c68dea27ac3bf1d267be506e65d05b243e0bc0 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 13 Oct 2022 15:40:25 +0200 Subject: [PATCH 50/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index d0c9780..631d324 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1625,7 +1625,7 @@ def autoCalibrate(self): self.retries += 1 self.pollCoordinatesSignal.emit() return - if(self.retries < 10): + if(self.retries < 100): self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) From 4e7a90c3e9242ac0a9f67ea9179f90ab125d66a0 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 15 Oct 2022 23:27:09 +0200 Subject: [PATCH 51/99] another set of fixes to stabilize --- TAMV.py | 113 +++++++++++++++++++++++++++--------- modules/Camera.py | 2 +- modules/DetectionManager.py | 7 ++- 3 files changed, 91 insertions(+), 31 deletions(-) diff --git a/TAMV.py b/TAMV.py index 631d324..be16d96 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1350,7 +1350,7 @@ def resetCalibrationVariables(self): self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ] # reset all variables self.guessPosition = [1,1] - self.uv = [0, 0] + self.uv = [None, None] self.olduv = self.uv if(self.transformMatrix is None or self.mpp is None): _logger.debug('Camera calibration matrix reset.') @@ -1366,6 +1366,7 @@ def resetCalibrationVariables(self): self.camera_coordinates = [] self.retries = 0 self.calibrationMoves = 0 + self.repeatCounter = 0 def startAlignTools(self): # send calling to log @@ -1456,12 +1457,24 @@ def autoCalibrate(self): runtime = time.time() if(self.uv is not None): if(self.uv[0] is not None and self.uv[1] is not None): - self.retries = 0 # First calibration step if(self.state == 0): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') - + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 self.olduv = self.uv self.space_coordinates = [] self.camera_coordinates = [] @@ -1481,9 +1494,23 @@ def autoCalibrate(self): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # Calculate mpp at first move - if(self.state == 2): - self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),4) + if(self.state == 1): + self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),3) # save position as previous position self.olduv = self.uv # save machine coordinates for detected nozzle @@ -1512,7 +1539,20 @@ def autoCalibrate(self): return elif(self.state == len(self.calibrationCoordinates)): # Camera calibration moves completed. - + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) self.updateStatusbarMessage(updateMessage) @@ -1548,14 +1588,26 @@ def autoCalibrate(self): self.moveAbsoluteSignal.emit(params) return elif(self.state == 200): - _logger.info('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # Update GUI with current status if(self.__stateEndstopAutoCalibrate): updateMessage = 'Endstop calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) - + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start @@ -1564,7 +1616,7 @@ def autoCalibrate(self): self.offsets = -1*(0.55*self.transformMatrix.T @ self.v) self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) - + _logger.info('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets)) # Add rounding handling for endstop alignment if(self.__stateEndstopAutoCalibrate): if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): @@ -1585,6 +1637,7 @@ def autoCalibrate(self): self.retries = 10 # Otherwise, check if we're not aligned to the center elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): + self.olduv = self.uv params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000} self.moveRelativeSignal.emit(params) _logger.debug('Calibration move X{0:-1.3f} Y{1:-1.3f} F1000 '.format(self.offsets[0],self.offsets[1])) @@ -1648,24 +1701,27 @@ def autoCalibrate(self): updateMessage = 'Failed to detect nozzle. Try manual override.' self.updateStatusbarMessage(updateMessage) _logger.warning(updateMessage) - self.state = -99 - # End auto calibration - self.__stateAutoNozzleAlignment = False - self.__stateManualNozzleAlignment = True - # calibrating nozzle manual - self.tabPanel.setDisabled(False) - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.manualToolOffsetCaptureButton.setVisible(True) - self.manualToolOffsetCaptureButton.setDisabled(False) - self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) - self.resumeAutoToolAlignmentButton.setVisible(True) - self.resumeAutoToolAlignmentButton.setDisabled(False) - self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleGreen) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleNozzleDetectionSignal.emit(True) - self.toggleDetectionSignal.emit(True) - self.__displayCrosshair = True + self.nozzleDetectionFailed() + + def nozzleDetectionFailed(self): + self.state = -99 + # End auto calibration + self.__stateAutoNozzleAlignment = False + self.__stateManualNozzleAlignment = True + # calibrating nozzle manual + self.tabPanel.setDisabled(False) + self.alignToolsButton.setVisible(False) + self.alignToolsButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(True) + self.manualToolOffsetCaptureButton.setDisabled(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) + self.resumeAutoToolAlignmentButton.setVisible(True) + self.resumeAutoToolAlignmentButton.setDisabled(False) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleGreen) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleNozzleDetectionSignal.emit(True) + self.toggleDetectionSignal.emit(True) + self.__displayCrosshair = True def resumeAutoAlignment(self): if(self.transformMatrix is None or self.mpp is None): @@ -1677,6 +1733,9 @@ def resumeAutoAlignment(self): else: self.state = 200 self.retries = 0 + self.repeatCounter = 0 + self.uv = [None, None] + self.olduv = [None, None] self.__stateAutoNozzleAlignment = True self.toolTime = time.time() self.resumeAutoToolAlignmentButton.setVisible(False) diff --git a/modules/Camera.py b/modules/Camera.py index 14746f0..a60dae9 100644 --- a/modules/Camera.py +++ b/modules/Camera.py @@ -63,7 +63,7 @@ def __init__(self, *args, **kwargs): self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height) self.cap.set(cv2.CAP_PROP_FPS, 30) - # self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1) # self.cap.setExceptionMode(enable=True) _logger.debug('Active CV backend: ' + self.cap.getBackendName()) _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..') diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index ef887b0..102ad4d 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -230,7 +230,7 @@ def enableDetection(self, state=False): ##### Endstop detection def analyzeEndstopFrame(self): detectionCount = 0 - self.uv = [0, 0] + self.uv = [None, None] average_location=[0,0] retries = 0 while(detectionCount < 5): @@ -383,7 +383,7 @@ def dashedLine(self, image, start, end, color=(255,255,255), segmentWidth=10, ho ##### Nozzle detection def analyzeNozzleFrame(self): detectionCount = 0 - self.uv = [0, 0] + self.uv = [None, None] average_location=[0,0] retries = 0 self.__uv = [None,None] @@ -406,7 +406,7 @@ def analyzeNozzleFrame(self): def burstNozzleDetection(self): detectionCount = 0 - self.uv = [0, 0] + self.uv = [None, None] average_location=[0,0] retries = 0 while(detectionCount < 5): @@ -483,6 +483,7 @@ def nozzleDetection(self): keypointRadius = 17 nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) + center = (None, None) if(self.__nozzleAutoDetectionActive is True): # draw crosshair nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 2) From 6248f71db78abbffddd2874016b5e1dcafdff355 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sun, 16 Oct 2022 22:03:04 +0200 Subject: [PATCH 52/99] switched camera to an independent process --- TAMV.py | 3 +- modules/DetectionManager.py | 145 +++++++++++++++++++++++++++++------- 2 files changed, 120 insertions(+), 28 deletions(-) diff --git a/TAMV.py b/TAMV.py index be16d96..3c08b9d 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1754,13 +1754,14 @@ def createDetectionManagerThread(self, announce=True): self.detectionThread = QThread() self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None) self.detectionManager.moveToThread(self.detectionThread) - self.detectionThread.start()#priority=QThread.TimeCriticalPriority) + # Thread management signals and slots self.detectionManager.errorSignal.connect(self.detectionManagerError) self.detectionThread.started.connect(self.detectionManager.processFrame) self.detectionThread.finished.connect(self.detectionManager.quit) self.detectionThread.finished.connect(self.detectionManager.deleteLater) self.detectionThread.finished.connect(self.detectionThread.deleteLater) + self.detectionThread.start()#priority=QThread.TimeCriticalPriority) # Video frame signals and slots self.detectionManager.detectionManagerNewFrameSignal.connect(self.refreshImage) self.detectionManager.detectionManagerReadySignal.connect(self.startVideo) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 102ad4d..99eda8f 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -3,14 +3,12 @@ # invoke parent (TAMV) _logger _logger = logging.getLogger('TAMV.DetectionManager') -from modules.Camera import Camera - import cv2 import numpy as np from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal from time import sleep -import copy +import copy, sys, multiprocessing class DetectionManager(QObject): # class attributes @@ -60,6 +58,7 @@ def __init__(self, *args, **kwargs): self.startCamera() self.createDetectors() self.processFrame() + self.uv = [None, None] # send exiting to log _logger.debug('*** exiting DetectionManager.__init__') @@ -67,22 +66,30 @@ def __init__(self, *args, **kwargs): def startCamera(self): # send calling to log _logger.debug('*** calling DetectionManager.startCamera') - try: - # Setup camera - self.videoFeed = Camera(videoSrc=self.__videoSource,width=self.__frameSize['width'],height=self.__frameSize['height'],parent=self) - self.__imageSettings = self.videoFeed.getCurrentImageSettings() - if(self.__imageSettings is not None): - self.cameraReady(imageSettings=self.__imageSettings) - else: raise Exception() - except: - _logger.exception('Camera failed to start..') - self.errorSignal.emit('Camera failed to start..') + + if sys.platform.startswith('linux'): # all Linux + self.backend = cv2.CAP_V4L + elif sys.platform.startswith('win'): # MS Windows + self.backend = cv2.CAP_DSHOW + elif sys.platform.startswith('darwin'): # macOS + self.backend = cv2.CAP_AVFOUNDATION + else: + self.backend = cv2.CAP_ANY # auto-detect via OpenCV + + # create shared event object + self.frameEvent = multiprocessing.Event() + self.stopEvent = multiprocessing.Event() + self.pipeDM, self.pipeQ = multiprocessing.Pipe() + + self.proc = multiprocessing.Process(target=_reader, args=(self.pipeQ, self.frameEvent, self.stopEvent, self.__videoSource, self.__frameSize['height'], self.__frameSize['width'], self.backend)) + self.proc.daemon = True # send exiting to log _logger.debug('*** exiting DetectionManager.startCamera') def cameraReady(self, imageSettings): # send calling to log _logger.debug('*** calling DetectionManager.cameraReady') + print(imageSettings) # consume JSON try: brightness = imageSettings['brightness'] @@ -173,7 +180,8 @@ def quit(self): _logger.info('Shutting down Detection Manager..') _logger.info(' .. disconnecting video feed..') self.__running = False - self.videoFeed.quit() + self.stopEvent.set() + self.proc.join() _logger.info('Detection Manager shut down successfully.') # send exiting to log _logger.debug('*** exiting DetectionManager.quit') @@ -181,10 +189,27 @@ def quit(self): # Main processing function @pyqtSlot() def processFrame(self): + if(not self.proc.is_alive()): + # Start camera process + self.proc.start() + print(self.proc) + # Retrieve camera settings + try: + print('Connecting to camera..') + cameraSettings = self.pipeDM.recv() + print('Connecting to camera..') + self.cameraReady(imageSettings=cameraSettings) + except: + _logger.exception('Camera failed to start..') + self.errorSignal.emit('Camera failed to start..') + try: - self.frame = self.videoFeed.getFrame() - if(self.frame is None): - self.errorSignal.emit('Failed to get signal') + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() + if(len(self.frame)==1): + if(self.frame==-1): + self.errorSignal.emit('Failed to get signal') elif(self.__enableDetection is True): if(self.__endstopDetectionActive is True): if(self.__endstopAutomatedDetectionActive is False): @@ -266,7 +291,9 @@ def burstEndstopDetection(self): retries = 0 while(detectionCount < 5): for j in range(10): - self.frame = self.videoFeed.getFrame() + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -412,7 +439,9 @@ def burstNozzleDetection(self): while(detectionCount < 5): # skip a few frames for i in range(10): - self.frame = self.videoFeed.getFrame() + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -565,14 +594,76 @@ def getImageProperties(self, imageSettings): @pyqtSlot(object) def relayImageProperties(self, imageProperties): - self.videoFeed.setImageProperties(imagePropterties=imageProperties) + self.pipeDM.send(imageProperties) @pyqtSlot() def relayResetImage(self): - self.videoFeed.resetImageProperties() - - def getPropertiesJSON(self): - self.detectionManagerImagePropertiesSignal.emit() - - def getDefaultPropertiesJSON(self): - self.detectionManagerDefaultImagePropertiesSignal.emit() + settings = {'brightness': self.__brightnessDefault, 'contrast': self.__contrastDefault, 'saturation': self.__saturationDefault, 'hue': self.__hueDefault} + self.pipeDM.send(settings) + +# Independent process to run camera grab functions +def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): + print('Starting camera...') + cap = cv2.VideoCapture(videoSrc, backend) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.setExceptionMode(enable=True) + try: + ret = cap.grab() + ret, frame = cap.retrieve() + if(not ret): + cap.release() + raise SystemError('Camera failure.') + # Get camera default settings + brightness = cap.get(cv2.CAP_PROP_BRIGHTNESS) + contrast = cap.get(cv2.CAP_PROP_CONTRAST) + saturation = cap.get(cv2.CAP_PROP_SATURATION) + hue = cap.get(cv2.CAP_PROP_HUE) + cameraSettings = {'default': 1, 'brightness': brightness, 'contrast': contrast, 'saturation': saturation, 'hue': hue} + print(cameraSettings) + # send default settings to queue + q.send(cameraSettings) + except Exception as e: + cap.release() + print('**** Camera failed:',e) + stopEvent.set() + FPS = 1/30 + while True: + try: + ret = cap.grab() + except: break + if not ret: + break + if stopEvent.is_set(): + print('Stopping capture') + break + if frameEvent.is_set(): + ret, frame = cap.retrieve() + q.send(frame) + # check for inputs + if(q.poll(FPS/2)): + settings = q.recv() + try: + brightness = float(settings['brightness']) + except KeyError: pass + try: + contrast = float(settings['contrast']) + except KeyError: pass + try: + saturation = float(settings['saturation']) + except KeyError: pass + try: + hue = float(settings['hue']) + except KeyError: pass + try: + cap.set(cv2.CAP_PROP_BRIGHTNESS, brightness) + cap.set(cv2.CAP_PROP_CONTRAST, contrast) + cap.set(cv2.CAP_PROP_SATURATION, saturation) + cap.set(cv2.CAP_PROP_HUE, hue) + except: + _logger.warning('Failed to set image properties') + sleep(FPS) + cap.release() + q.send(-1) + q.close() \ No newline at end of file From 5b3be6cf49893ffd283ebc640aae5041faa3b3eb Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sun, 16 Oct 2022 22:05:52 +0200 Subject: [PATCH 53/99] Update DetectionManager.py --- modules/DetectionManager.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 99eda8f..ad6167e 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -290,10 +290,10 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - for j in range(10): - self.frameEvent.set() - self.frame = self.pipeDM.recv() - self.frameEvent.clear() + # for j in range(10): + # self.frameEvent.set() + # self.frame = self.pipeDM.recv() + # self.frameEvent.clear() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -437,11 +437,11 @@ def burstNozzleDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # skip a few frames - for i in range(10): - self.frameEvent.set() - self.frame = self.pipeDM.recv() - self.frameEvent.clear() + # # skip a few frames + # for i in range(10): + # self.frameEvent.set() + # self.frame = self.pipeDM.recv() + # self.frameEvent.clear() (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): From f32f2d6ff253cb8a09d129ae73cb5fd1282ecbb9 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sun, 16 Oct 2022 22:10:57 +0200 Subject: [PATCH 54/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index ad6167e..6b7ad9b 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -605,7 +605,7 @@ def relayResetImage(self): def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): print('Starting camera...') cap = cv2.VideoCapture(videoSrc, backend) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.setExceptionMode(enable=True) From 9c2235bbe5e634c2f7c16c1d30dd6d847ea3dfd2 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:28:48 +0200 Subject: [PATCH 55/99] separated UV from XY polls --- TAMV.py | 16 +++++++++++++--- modules/DetectionManager.py | 7 ++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/TAMV.py b/TAMV.py index 3c08b9d..3998db1 100644 --- a/TAMV.py +++ b/TAMV.py @@ -35,6 +35,8 @@ class App(QMainWindow): # Nozzle detection signals toggleNozzleDetectionSignal = pyqtSignal(bool) toggleNozzleAutoDetectionSignal = pyqtSignal(bool) + # UV coordinates update signal + getUVCoordinatesSignal = pyqtSignal() # Master detection enable/disable signal toggleDetectionSignal = pyqtSignal(bool) @@ -1775,6 +1777,9 @@ def createDetectionManagerThread(self, announce=True): # Nozzle alignment signals and slots self.toggleNozzleDetectionSignal.connect(self.detectionManager.toggleNozzleDetection) self.toggleNozzleAutoDetectionSignal.connect(self.detectionManager.toggleNozzleAutoDetection) + # UV coordinates update signals and slots + self.getUVCoordinatesSignal.connect(self.saveUVCoordinates) + self.detectionManager.detectionManagerUVCoordinatesSignal.connect(self.saveUVCoordinates) # Master detection swtich enable/disable self.toggleDetectionSignal.connect(self.detectionManager.enableDetection) @@ -1792,10 +1797,10 @@ def startVideo(self, cameraProperties): def refreshImage(self, data): self.__mutex.lock() frame = data[0] - uvCoordinates = data[1] + # uvCoordinates = data[1] self.image.setPixmap(frame) - if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): - self.uv = uvCoordinates + # if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): + # self.uv = uvCoordinates self.__mutex.unlock() self.getVideoFrameSignal.emit() @@ -2085,6 +2090,11 @@ def printerMoveComplete(self): _logger.debug(statusMsg) self.pollCoordinatesSignal.emit() + @pyqtSlot(object) + def saveUVCoordinates(self, uvCoordinates): + self.uv = uvCoordinates + self.autoCalibrate() + @pyqtSlot(object) def saveCurrentPosition(self, coordinates): self.__mutex.lock() diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 6b7ad9b..67e51de 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -36,6 +36,7 @@ class DetectionManager(QObject): detectionManagerEndstopPosition = pyqtSignal(object) detectionManagerAutoEndStopSignal = pyqtSignal(object) detectionManagerArrayFrameSignal = pyqtSignal(object) + detectionManagerUVCoordinatesSignal = pyqtSignal(object) ##### Setup functions # init function @@ -242,11 +243,15 @@ def receivedFrame(self, frame): try: retObject = [] retObject.append(qpixmap) - retObject.append(self.__uv) + # retObject.append(self.__uv) self.detectionManagerNewFrameSignal.emit(retObject) except: raise SystemExit('Fatal error in Detection Manager.') + @pyqtSlot() + def sendUVCoorindates(self): + self.detectionManagerUVCoordinatesSignal.emit(self.__uv) + @pyqtSlot(bool) def enableDetection(self, state=False): self.__enableDetection = state From 8590dca261210d0d560f9cb372a1c4343d32f2d6 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:30:13 +0200 Subject: [PATCH 56/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 3998db1..cac32eb 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1778,7 +1778,7 @@ def createDetectionManagerThread(self, announce=True): self.toggleNozzleDetectionSignal.connect(self.detectionManager.toggleNozzleDetection) self.toggleNozzleAutoDetectionSignal.connect(self.detectionManager.toggleNozzleAutoDetection) # UV coordinates update signals and slots - self.getUVCoordinatesSignal.connect(self.saveUVCoordinates) + self.getUVCoordinatesSignal.connect(self.detectionManager.sendUVCoorindates) self.detectionManager.detectionManagerUVCoordinatesSignal.connect(self.saveUVCoordinates) # Master detection swtich enable/disable self.toggleDetectionSignal.connect(self.detectionManager.enableDetection) From 823526af808d6e1f0199caf477595e090976685b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:31:24 +0200 Subject: [PATCH 57/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index cac32eb..0a84a5e 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2126,7 +2126,7 @@ def saveCurrentPosition(self, coordinates): params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} self.setOffsetsSignal.emit(params) return - self.autoCalibrate() + self.getUVCoordinatesSignal.emit() elif(self.__stateManualNozzleAlignment is True): _logger.debug('saveCurrentPosition: manual nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) # set state to detect next tool From d2de86626b13d8d405cffe4e8a970561636b7d1b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:37:34 +0200 Subject: [PATCH 58/99] Update TAMV.py --- TAMV.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TAMV.py b/TAMV.py index 0a84a5e..76fc534 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1601,9 +1601,11 @@ def autoCalibrate(self): # print('Repeating detection: ' + str(self.repeatCounter)) self.repeatCounter += 1 if(self.repeatCounter > 10): + print('Failed to detect.') self.nozzleDetectionFailed() return # loop through again + print('Retrying', self.retries) self.retries += 1 self.pollCoordinatesSignal.emit() return @@ -2092,6 +2094,7 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): + print('Received UV:', uvCoordinates) self.uv = uvCoordinates self.autoCalibrate() From 8b23fed10fdbfeec5648fc873df871321d66b8b8 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:40:21 +0200 Subject: [PATCH 59/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 67e51de..5652546 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -610,7 +610,7 @@ def relayResetImage(self): def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): print('Starting camera...') cap = cv2.VideoCapture(videoSrc, backend) - cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.setExceptionMode(enable=True) From 0d6331ebd7a6dddd73f7911ca221db0847b3cfd7 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 09:43:19 +0200 Subject: [PATCH 60/99] Update TAMV.py --- TAMV.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TAMV.py b/TAMV.py index 76fc534..2257df8 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2095,6 +2095,10 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): print('Received UV:', uvCoordinates) + if(uvCoordinates is None): + # failed to detect, poll coordinates again + self.pollCoordinatesSignal.emit() + return self.uv = uvCoordinates self.autoCalibrate() From 75898fe3fa8d328f5976dfce255a34b6c6ac8ea3 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 11:58:51 +0200 Subject: [PATCH 61/99] Update TAMV.py --- TAMV.py | 1 + 1 file changed, 1 insertion(+) diff --git a/TAMV.py b/TAMV.py index 2257df8..ba9369b 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2095,6 +2095,7 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): print('Received UV:', uvCoordinates) + print('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets)) if(uvCoordinates is None): # failed to detect, poll coordinates again self.pollCoordinatesSignal.emit() From 812edefca05aaf2ec307390e521f82c6758971ca Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:00:37 +0200 Subject: [PATCH 62/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index ba9369b..8419419 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2095,7 +2095,7 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): print('Received UV:', uvCoordinates) - print('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets)) + print('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) ) if(uvCoordinates is None): # failed to detect, poll coordinates again self.pollCoordinatesSignal.emit() From 10ceeda4f1d9c633b6a61576e03c72a4a84ea29c Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:05:30 +0200 Subject: [PATCH 63/99] Update DetectionManager.py --- modules/DetectionManager.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 5652546..1a387f9 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -454,14 +454,18 @@ def burstNozzleDetection(self): average_location[1] += self.__uv[1] detectionCount += 1 else: + print('Retrying 1..') retries += 1 else: + print('Retrying 2..') retries += 1 if(retries > 5): + print('Retries full..') average_location[0] = None average_location[1] = None break if(average_location[0] is not None): + print('Found location:', average_location) # calculate average X Y position from detection average_location[0] /= detectionCount average_location[1] /= detectionCount @@ -469,6 +473,7 @@ def burstNozzleDetection(self): average_location = np.around(average_location,0) self.__uv = average_location else: + print('No keypoints!') self.__uv = None def nozzleDetection(self): From 745b6596bd647b8906af1f7015b7dea4381dbda6 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:10:56 +0200 Subject: [PATCH 64/99] Update DetectionManager.py --- modules/DetectionManager.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 1a387f9..ef1391b 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -489,26 +489,31 @@ def nozzleDetection(self): keypoints = self.detector.detect(preprocessorImage0) keypointColor = (0,0,255) if(len(keypoints) != 1): + print('Trying combo 2') # apply combo 2 (standard detector, preprocessor 1) keypoints = self.detector.detect(preprocessorImage1) keypointColor = (0,255,0) if(len(keypoints) != 1): + print('Trying combo 3') # apply combo 3 (standard detector, preprocessor 0) keypoints = self.relaxedDetector.detect(preprocessorImage0) keypointColor = (255,0,0) if(len(keypoints) != 1): + print('Trying combo 4') # apply combo 4 (standard detector, preprocessor 1) keypoints = self.relaxedDetector.detect(preprocessorImage1) keypointColor = (39,127,255) if(len(keypoints) != 1): # failed to detect a nozzle, correct return value object keypoints = None + print('nozzleDetection did not find any keypoints') # process keypoint if(keypoints is not None): # create center object (x,y) = np.around(keypoints[0].pt) x,y = int(x), int(y) center = (x,y) + print('Center:', center) # create radius object keypointRadius = np.around(keypoints[0].size/2) keypointRadius = int(keypointRadius) From 72e61644137dc9629a9053cf43a4e0df03c25cef Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:12:59 +0200 Subject: [PATCH 65/99] Update DetectionManager.py --- modules/DetectionManager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index ef1391b..274e118 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -442,11 +442,11 @@ def burstNozzleDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # # skip a few frames - # for i in range(10): - # self.frameEvent.set() - # self.frame = self.pipeDM.recv() - # self.frameEvent.clear() + # skip a few frames + for i in range(4): + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): From d30c44571c690dd35eb45269ffb9ea80c1ff0bbe Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:27:39 +0200 Subject: [PATCH 66/99] fixing nozzle calibration and limiting algorithm runs --- TAMV.py | 20 +++++------ modules/DetectionManager.py | 72 ++++++++++++++++++++++++------------- 2 files changed, 58 insertions(+), 34 deletions(-) diff --git a/TAMV.py b/TAMV.py index 8419419..647b5b8 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1315,32 +1315,32 @@ def manualToolOffsetCapture(self): def resetCalibration(self): # Reset program state, and frame capture control to defaults + self.toggleDetectionSignal.emit(False) + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) + self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False self.__stateManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False - self.toggleEndstopAutoDetectionSignal.emit(False) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleEndstopDetectionSignal.emit(False) - self.toggleDetectionSignal.emit(False) - self.__displayCrosshair = False self.resetCalibrationVariables() self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def resetNozzleAlignment(self): # Reset program state, and frame capture control to defaults + self.toggleDetectionSignal.emit(False) + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) + self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False self.__stateManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False - self.toggleEndstopAutoDetectionSignal.emit(False) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleEndstopDetectionSignal.emit(False) - self.toggleDetectionSignal.emit(False) - self.__displayCrosshair = False self.resetCalibrationVariables() self.tabPanel.setDisabled(True) self.unloadToolSignal.emit() diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 274e118..6806c93 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -22,6 +22,7 @@ class DetectionManager(QObject): __running = True __uv = None __counter = 0 + __algorithm = None # Signals detectionManagerNewFrameSignal = pyqtSignal(object) @@ -395,6 +396,8 @@ def toggleEndstopAutoDetection(self, endstopDetectFlag): else: self.__endstopDetectionActive = False self.__endstopAutomatedDetectionActive = False + self.__algorithm = None + def dashedLine(self, image, start, end, color=(255,255,255), segmentWidth=10, horizontal=True, lineWidth=1): if(horizontal): @@ -443,7 +446,7 @@ def burstNozzleDetection(self): retries = 0 while(detectionCount < 5): # skip a few frames - for i in range(4): + for i in range(3): self.frameEvent.set() self.frame = self.pipeDM.recv() self.frameEvent.clear() @@ -465,7 +468,6 @@ def burstNozzleDetection(self): average_location[1] = None break if(average_location[0] is not None): - print('Found location:', average_location) # calculate average X Y position from detection average_location[0] /= detectionCount average_location[1] /= detectionCount @@ -482,31 +484,53 @@ def nozzleDetection(self): # return value for keypoints keypoints = None center = (None, None) - preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) - preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) - - # apply combo 1 (standard detector, preprocessor 0) - keypoints = self.detector.detect(preprocessorImage0) - keypointColor = (0,0,255) - if(len(keypoints) != 1): - print('Trying combo 2') - # apply combo 2 (standard detector, preprocessor 1) - keypoints = self.detector.detect(preprocessorImage1) - keypointColor = (0,255,0) + # check which algorithm worked previously + if(self.__algorithm is None): + preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) + preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) + + # apply combo 1 (standard detector, preprocessor 0) + keypoints = self.detector.detect(preprocessorImage0) + keypointColor = (0,0,255) if(len(keypoints) != 1): - print('Trying combo 3') - # apply combo 3 (standard detector, preprocessor 0) - keypoints = self.relaxedDetector.detect(preprocessorImage0) - keypointColor = (255,0,0) + # apply combo 2 (standard detector, preprocessor 1) + keypoints = self.detector.detect(preprocessorImage1) + keypointColor = (0,255,0) if(len(keypoints) != 1): - print('Trying combo 4') - # apply combo 4 (standard detector, preprocessor 1) - keypoints = self.relaxedDetector.detect(preprocessorImage1) - keypointColor = (39,127,255) + # apply combo 3 (standard detector, preprocessor 0) + keypoints = self.relaxedDetector.detect(preprocessorImage0) + keypointColor = (255,0,0) if(len(keypoints) != 1): - # failed to detect a nozzle, correct return value object - keypoints = None - print('nozzleDetection did not find any keypoints') + # apply combo 4 (standard detector, preprocessor 1) + keypoints = self.relaxedDetector.detect(preprocessorImage1) + keypointColor = (39,127,255) + if(len(keypoints) != 1): + # failed to detect a nozzle, correct return value object + keypoints = None + else: + self.__algorithm = 4 + else: + self.__algorithm = 3 + else: + self.__algorithm = 2 + else: + self.__algorithm = 1 + elif(self.__algorithm == 1): + preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) + keypoints = self.detector.detect(preprocessorImage0) + keypointColor = (0,0,255) + elif(self.__algorithm == 2): + preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) + keypoints = self.detector.detect(preprocessorImage1) + keypointColor = (0,255,0) + elif(self.__algorithm == 3): + preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0) + keypoints = self.relaxedDetector.detect(preprocessorImage0) + keypointColor = (255,0,0) + else: + preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1) + keypoints = self.relaxedDetector.detect(preprocessorImage1) + keypointColor = (39,127,255) # process keypoint if(keypoints is not None): # create center object From 5b806f15ffbc8d3072b03c4ef1186878fd884a25 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:35:13 +0200 Subject: [PATCH 67/99] Update TAMV.py --- TAMV.py | 142 +++++++++++++++++++++++++++----------------------------- 1 file changed, 69 insertions(+), 73 deletions(-) diff --git a/TAMV.py b/TAMV.py index 647b5b8..bb88da6 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1315,32 +1315,32 @@ def manualToolOffsetCapture(self): def resetCalibration(self): # Reset program state, and frame capture control to defaults - self.toggleDetectionSignal.emit(False) - self.toggleEndstopAutoDetectionSignal.emit(False) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleEndstopDetectionSignal.emit(False) self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False self.__stateManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) self.resetCalibrationVariables() self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def resetNozzleAlignment(self): # Reset program state, and frame capture control to defaults - self.toggleDetectionSignal.emit(False) - self.toggleEndstopAutoDetectionSignal.emit(False) - self.toggleNozzleAutoDetectionSignal.emit(False) - self.toggleEndstopDetectionSignal.emit(False) self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False self.__stateManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.toggleNozzleAutoDetectionSignal.emit(False) + self.toggleEndstopDetectionSignal.emit(False) + self.toggleDetectionSignal.emit(False) self.resetCalibrationVariables() self.tabPanel.setDisabled(True) self.unloadToolSignal.emit() @@ -1368,7 +1368,7 @@ def resetCalibrationVariables(self): self.camera_coordinates = [] self.retries = 0 self.calibrationMoves = 0 - self.repeatCounter = 0 + # self.repeatCounter = 0 def startAlignTools(self): # send calling to log @@ -1463,20 +1463,20 @@ def autoCalibrate(self): if(self.state == 0): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 10): - self.nozzleDetectionFailed() - return - # loop through again - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 + # if(self.olduv is not None): + # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # # print('Repeating detection: ' + str(self.repeatCounter)) + # self.repeatCounter += 1 + # if(self.repeatCounter > 10): + # self.nozzleDetectionFailed() + # return + # # loop through again + # self.retries += 1 + # self.pollCoordinatesSignal.emit() + # return + # else: + # # print('Took ' + str(self.repeatCounter) + ' attempts.') + # self.repeatCounter = 0 self.olduv = self.uv self.space_coordinates = [] self.camera_coordinates = [] @@ -1496,20 +1496,20 @@ def autoCalibrate(self): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 10): - self.nozzleDetectionFailed() - return - # loop through again - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 + # if(self.olduv is not None): + # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # # print('Repeating detection: ' + str(self.repeatCounter)) + # self.repeatCounter += 1 + # if(self.repeatCounter > 10): + # self.nozzleDetectionFailed() + # return + # # loop through again + # self.retries += 1 + # self.pollCoordinatesSignal.emit() + # return + # else: + # # print('Took ' + str(self.repeatCounter) + ' attempts.') + # self.repeatCounter = 0 # Calculate mpp at first move if(self.state == 1): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),3) @@ -1541,20 +1541,20 @@ def autoCalibrate(self): return elif(self.state == len(self.calibrationCoordinates)): # Camera calibration moves completed. - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 10): - self.nozzleDetectionFailed() - return - # loop through again - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 + # if(self.olduv is not None): + # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # # print('Repeating detection: ' + str(self.repeatCounter)) + # self.repeatCounter += 1 + # if(self.repeatCounter > 10): + # self.nozzleDetectionFailed() + # return + # # loop through again + # self.retries += 1 + # self.pollCoordinatesSignal.emit() + # return + # else: + # # print('Took ' + str(self.repeatCounter) + ' attempts.') + # self.repeatCounter = 0 # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) self.updateStatusbarMessage(updateMessage) @@ -1596,22 +1596,22 @@ def autoCalibrate(self): else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 10): - print('Failed to detect.') - self.nozzleDetectionFailed() - return - # loop through again - print('Retrying', self.retries) - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 + # if(self.olduv is not None): + # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # # print('Repeating detection: ' + str(self.repeatCounter)) + # self.repeatCounter += 1 + # if(self.repeatCounter > 10): + # print('Failed to detect.') + # self.nozzleDetectionFailed() + # return + # # loop through again + # print('Retrying', self.retries) + # self.retries += 1 + # self.pollCoordinatesSignal.emit() + # return + # else: + # # print('Took ' + str(self.repeatCounter) + ' attempts.') + # self.repeatCounter = 0 # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start @@ -1682,7 +1682,7 @@ def autoCalibrate(self): self.retries += 1 self.pollCoordinatesSignal.emit() return - if(self.retries < 100): + if(self.retries < 10): self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) @@ -1692,10 +1692,6 @@ def autoCalibrate(self): if(self.__stateEndstopAutoCalibrate is True): self.updateStatusbarMessage('Failed to detect endstop.') _logger.warning('Failed to detect endstop. Cancelled operation.') - # if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): - # params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} - # self.moveAbsoluteSignal.emit(params) - # End calibration self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False self.toggleEndstopAutoDetectionSignal.emit(False) @@ -1737,7 +1733,7 @@ def resumeAutoAlignment(self): else: self.state = 200 self.retries = 0 - self.repeatCounter = 0 + # self.repeatCounter = 0 self.uv = [None, None] self.olduv = [None, None] self.__stateAutoNozzleAlignment = True From 5cd557b493af1c8886317a6a3219335a6e4d49d4 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:38:54 +0200 Subject: [PATCH 68/99] fixing unknown camera error --- TAMV.py | 58 ------------------------------------- modules/DetectionManager.py | 4 +++ 2 files changed, 4 insertions(+), 58 deletions(-) diff --git a/TAMV.py b/TAMV.py index bb88da6..ecccadc 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1463,20 +1463,6 @@ def autoCalibrate(self): if(self.state == 0): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') - # if(self.olduv is not None): - # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # # print('Repeating detection: ' + str(self.repeatCounter)) - # self.repeatCounter += 1 - # if(self.repeatCounter > 10): - # self.nozzleDetectionFailed() - # return - # # loop through again - # self.retries += 1 - # self.pollCoordinatesSignal.emit() - # return - # else: - # # print('Took ' + str(self.repeatCounter) + ' attempts.') - # self.repeatCounter = 0 self.olduv = self.uv self.space_coordinates = [] self.camera_coordinates = [] @@ -1496,20 +1482,6 @@ def autoCalibrate(self): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) - # if(self.olduv is not None): - # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # # print('Repeating detection: ' + str(self.repeatCounter)) - # self.repeatCounter += 1 - # if(self.repeatCounter > 10): - # self.nozzleDetectionFailed() - # return - # # loop through again - # self.retries += 1 - # self.pollCoordinatesSignal.emit() - # return - # else: - # # print('Took ' + str(self.repeatCounter) + ' attempts.') - # self.repeatCounter = 0 # Calculate mpp at first move if(self.state == 1): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),3) @@ -1541,20 +1513,6 @@ def autoCalibrate(self): return elif(self.state == len(self.calibrationCoordinates)): # Camera calibration moves completed. - # if(self.olduv is not None): - # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # # print('Repeating detection: ' + str(self.repeatCounter)) - # self.repeatCounter += 1 - # if(self.repeatCounter > 10): - # self.nozzleDetectionFailed() - # return - # # loop through again - # self.retries += 1 - # self.pollCoordinatesSignal.emit() - # return - # else: - # # print('Took ' + str(self.repeatCounter) + ' attempts.') - # self.repeatCounter = 0 # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) self.updateStatusbarMessage(updateMessage) @@ -1596,22 +1554,6 @@ def autoCalibrate(self): else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) - # if(self.olduv is not None): - # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # # print('Repeating detection: ' + str(self.repeatCounter)) - # self.repeatCounter += 1 - # if(self.repeatCounter > 10): - # print('Failed to detect.') - # self.nozzleDetectionFailed() - # return - # # loop through again - # print('Retrying', self.retries) - # self.retries += 1 - # self.pollCoordinatesSignal.emit() - # return - # else: - # # print('Took ' + str(self.repeatCounter) + ' attempts.') - # self.repeatCounter = 0 # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 6806c93..f03c9f1 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -209,6 +209,10 @@ def processFrame(self): self.frameEvent.set() self.frame = self.pipeDM.recv() self.frameEvent.clear() + except Exception as e: + _logger.critical('Error in camera process') + _logger.critical(e) + try: if(len(self.frame)==1): if(self.frame==-1): self.errorSignal.emit('Failed to get signal') From ce6e7e016e3c01007bde1ae436ff079ed5cb1dee Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:39:48 +0200 Subject: [PATCH 69/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index f03c9f1..3d0a591 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -448,7 +448,7 @@ def burstNozzleDetection(self): self.uv = [None, None] average_location=[0,0] retries = 0 - while(detectionCount < 5): + while(detectionCount < 3): # skip a few frames for i in range(3): self.frameEvent.set() From a6b6914bc94c1ca351089faf87919d2369d22fe2 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:41:11 +0200 Subject: [PATCH 70/99] Update TAMV.py --- TAMV.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/TAMV.py b/TAMV.py index ecccadc..842d9bd 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1554,6 +1554,22 @@ def autoCalibrate(self): else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 5): + print('Failed to detect.') + self.nozzleDetectionFailed() + return + # loop through again + print('Retrying', self.retries) + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start From 6b1a708c4ef720f779f5e3a903a9d05ce22dae42 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:41:38 +0200 Subject: [PATCH 71/99] Update TAMV.py --- TAMV.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 842d9bd..7793931 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1368,7 +1368,7 @@ def resetCalibrationVariables(self): self.camera_coordinates = [] self.retries = 0 self.calibrationMoves = 0 - # self.repeatCounter = 0 + self.repeatCounter = 0 def startAlignTools(self): # send calling to log @@ -1691,7 +1691,7 @@ def resumeAutoAlignment(self): else: self.state = 200 self.retries = 0 - # self.repeatCounter = 0 + self.repeatCounter = 0 self.uv = [None, None] self.olduv = [None, None] self.__stateAutoNozzleAlignment = True From 2479379d018498ed6b1f4ce0f47101806e3f294b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:48:23 +0200 Subject: [PATCH 72/99] Update DetectionManager.py --- modules/DetectionManager.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 3d0a591..0172b48 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -214,18 +214,23 @@ def processFrame(self): _logger.critical(e) try: if(len(self.frame)==1): + print('cannot retrieve frame') if(self.frame==-1): self.errorSignal.emit('Failed to get signal') elif(self.__enableDetection is True): if(self.__endstopDetectionActive is True): if(self.__endstopAutomatedDetectionActive is False): + print('Endstop manual') self.analyzeEndstopFrame() else: + print('Endstop automatic') self.burstEndstopDetection() elif(self.__nozzleDetectionActive is True): if(self.__nozzleAutoDetectionActive is False): + print('Nozzle manual') self.analyzeNozzleFrame() else: + print('Nozzle automatic') self.burstNozzleDetection() else: pass @@ -461,13 +466,10 @@ def burstNozzleDetection(self): average_location[1] += self.__uv[1] detectionCount += 1 else: - print('Retrying 1..') retries += 1 else: - print('Retrying 2..') retries += 1 if(retries > 5): - print('Retries full..') average_location[0] = None average_location[1] = None break @@ -479,7 +481,6 @@ def burstNozzleDetection(self): average_location = np.around(average_location,0) self.__uv = average_location else: - print('No keypoints!') self.__uv = None def nozzleDetection(self): @@ -576,6 +577,7 @@ def toggleNozzleAutoDetection(self, nozzleDetectFlag): if(nozzleDetectFlag is True): self.__nozzleDetectionActive = True self.__nozzleAutoDetectionActive = True + self.__algorithm = None else: self.__nozzleDetectionActive = False self.__nozzleAutoDetectionActive = False From da9aff6a49b76d6da8a82731dbc628958aa82120 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:55:00 +0200 Subject: [PATCH 73/99] Update DetectionManager.py --- modules/DetectionManager.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 0172b48..b30da28 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -197,9 +197,7 @@ def processFrame(self): print(self.proc) # Retrieve camera settings try: - print('Connecting to camera..') cameraSettings = self.pipeDM.recv() - print('Connecting to camera..') self.cameraReady(imageSettings=cameraSettings) except: _logger.exception('Camera failed to start..') @@ -214,23 +212,18 @@ def processFrame(self): _logger.critical(e) try: if(len(self.frame)==1): - print('cannot retrieve frame') if(self.frame==-1): self.errorSignal.emit('Failed to get signal') elif(self.__enableDetection is True): if(self.__endstopDetectionActive is True): if(self.__endstopAutomatedDetectionActive is False): - print('Endstop manual') self.analyzeEndstopFrame() else: - print('Endstop automatic') self.burstEndstopDetection() elif(self.__nozzleDetectionActive is True): if(self.__nozzleAutoDetectionActive is False): - print('Nozzle manual') self.analyzeNozzleFrame() else: - print('Nozzle automatic') self.burstNozzleDetection() else: pass @@ -253,7 +246,6 @@ def receivedFrame(self, frame): try: retObject = [] retObject.append(qpixmap) - # retObject.append(self.__uv) self.detectionManagerNewFrameSignal.emit(retObject) except: raise SystemExit('Fatal error in Detection Manager.') @@ -453,27 +445,35 @@ def burstNozzleDetection(self): self.uv = [None, None] average_location=[0,0] retries = 0 + print('Starting detection loop') while(detectionCount < 3): # skip a few frames + print(' .. skipping frames..') for i in range(3): self.frameEvent.set() self.frame = self.pipeDM.recv() self.frameEvent.clear() + print(' .. detecting..') (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): + print(' .. keypoints found..') if(self.__uv[0] is not None and self.__uv[1] is not None): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 else: + print(' .. no keypoints..') retries += 1 else: + print(' .. still no keypoints..') retries += 1 if(retries > 5): + print(' .. retries failed..') average_location[0] = None average_location[1] = None break if(average_location[0] is not None): + print(' .. calculating average position..') # calculate average X Y position from detection average_location[0] /= detectionCount average_location[1] /= detectionCount @@ -481,6 +481,7 @@ def burstNozzleDetection(self): average_location = np.around(average_location,0) self.__uv = average_location else: + print(' .. no position found!') self.__uv = None def nozzleDetection(self): @@ -567,6 +568,7 @@ def nozzleDetection(self): @pyqtSlot(bool) def toggleNozzleDetection(self, nozzleDetectFlag): + print('Switch nozzle detection to:', nozzleDetectFlag) if(nozzleDetectFlag is True): self.__nozzleDetectionActive = True else: @@ -574,6 +576,7 @@ def toggleNozzleDetection(self, nozzleDetectFlag): @pyqtSlot(bool) def toggleNozzleAutoDetection(self, nozzleDetectFlag): + print('Switch nozzle auto detection to:', nozzleDetectFlag) if(nozzleDetectFlag is True): self.__nozzleDetectionActive = True self.__nozzleAutoDetectionActive = True @@ -681,7 +684,6 @@ def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): if not ret: break if stopEvent.is_set(): - print('Stopping capture') break if frameEvent.is_set(): ret, frame = cap.retrieve() From 963752983e61394886cdd14f7da60d7082b239a2 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:34:25 +0200 Subject: [PATCH 74/99] Update DetectionManager.py --- modules/DetectionManager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index b30da28..383fdfe 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -538,6 +538,7 @@ def nozzleDetection(self): keypoints = self.relaxedDetector.detect(preprocessorImage1) keypointColor = (39,127,255) # process keypoint + print('Checking keypoints') if(keypoints is not None): # create center object (x,y) = np.around(keypoints[0].pt) From bfe9692dac7db55fc4fa6de61e6cc58e0b66d0f1 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:37:11 +0200 Subject: [PATCH 75/99] Update TAMV.py --- TAMV.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/TAMV.py b/TAMV.py index 7793931..0379e51 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1481,6 +1481,22 @@ def autoCalibrate(self): elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 5): + print('Failed to detect.') + self.nozzleDetectionFailed() + return + # loop through again + print('Retrying', self.retries) + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # Calculate mpp at first move if(self.state == 1): @@ -1502,6 +1518,22 @@ def autoCalibrate(self): # otherwise, move the carriage from the original position to the next step and increment state else: self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 5): + print('Failed to detect.') + self.nozzleDetectionFailed() + return + # loop through again + print('Retrying', self.retries) + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) # move carriage to next calibration point self.offsetX = self.calibrationCoordinates[self.state][0] From e2d3d55d579cf0017d6600a428657cc86d06fe6b Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:41:30 +0200 Subject: [PATCH 76/99] Update DetectionManager.py --- modules/DetectionManager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 383fdfe..9a3be95 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -540,6 +540,7 @@ def nozzleDetection(self): # process keypoint print('Checking keypoints') if(keypoints is not None): + print('Keypoints is not none:', keypoints) # create center object (x,y) = np.around(keypoints[0].pt) x,y = int(x), int(y) From 23aef97979c9f38ede49a66765e305d8a1e47e64 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:42:55 +0200 Subject: [PATCH 77/99] Update DetectionManager.py --- modules/DetectionManager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 9a3be95..f37265b 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -297,10 +297,10 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - # for j in range(10): - # self.frameEvent.set() - # self.frame = self.pipeDM.recv() - # self.frameEvent.clear() + for i in range(3): + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): From 5651dc6db2267eef4666b48893d08bf6f0a28114 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:46:17 +0200 Subject: [PATCH 78/99] adjusting.. --- TAMV.py | 16 ---------------- modules/DetectionManager.py | 10 +++++----- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/TAMV.py b/TAMV.py index 0379e51..4ad2ac7 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1481,22 +1481,6 @@ def autoCalibrate(self): elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 5): - print('Failed to detect.') - self.nozzleDetectionFailed() - return - # loop through again - print('Retrying', self.retries) - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) # Calculate mpp at first move if(self.state == 1): diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index f37265b..816d163 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -297,10 +297,10 @@ def burstEndstopDetection(self): average_location=[0,0] retries = 0 while(detectionCount < 5): - for i in range(3): - self.frameEvent.set() - self.frame = self.pipeDM.recv() - self.frameEvent.clear() + # for j in range(10): + # self.frameEvent.set() + # self.frame = self.pipeDM.recv() + # self.frameEvent.clear() (self.__uv, self.frame) = self.endstopContourDetection(self.frame) if(self.__uv is not None): if(self.__uv[0] is not None and self.__uv[1] is not None): @@ -449,7 +449,7 @@ def burstNozzleDetection(self): while(detectionCount < 3): # skip a few frames print(' .. skipping frames..') - for i in range(3): + for i in range(1): self.frameEvent.set() self.frame = self.pipeDM.recv() self.frameEvent.clear() From 8a34970ea578286e9f63ae22ae35e51c39678a4a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:47:45 +0200 Subject: [PATCH 79/99] Update TAMV.py --- TAMV.py | 70 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/TAMV.py b/TAMV.py index 4ad2ac7..8419419 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1315,7 +1315,6 @@ def manualToolOffsetCapture(self): def resetCalibration(self): # Reset program state, and frame capture control to defaults - self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False @@ -1325,13 +1324,13 @@ def resetCalibration(self): self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) + self.__displayCrosshair = False self.resetCalibrationVariables() self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def resetNozzleAlignment(self): # Reset program state, and frame capture control to defaults - self.__displayCrosshair = False self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False @@ -1341,6 +1340,7 @@ def resetNozzleAlignment(self): self.toggleNozzleAutoDetectionSignal.emit(False) self.toggleEndstopDetectionSignal.emit(False) self.toggleDetectionSignal.emit(False) + self.__displayCrosshair = False self.resetCalibrationVariables() self.tabPanel.setDisabled(True) self.unloadToolSignal.emit() @@ -1463,6 +1463,20 @@ def autoCalibrate(self): if(self.state == 0): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 self.olduv = self.uv self.space_coordinates = [] self.camera_coordinates = [] @@ -1482,6 +1496,20 @@ def autoCalibrate(self): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # Calculate mpp at first move if(self.state == 1): self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),3) @@ -1502,22 +1530,6 @@ def autoCalibrate(self): # otherwise, move the carriage from the original position to the next step and increment state else: self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..') - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 5): - print('Failed to detect.') - self.nozzleDetectionFailed() - return - # loop through again - print('Retrying', self.retries) - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv)) # move carriage to next calibration point self.offsetX = self.calibrationCoordinates[self.state][0] @@ -1529,6 +1541,20 @@ def autoCalibrate(self): return elif(self.state == len(self.calibrationCoordinates)): # Camera calibration moves completed. + if(self.olduv is not None): + if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # print('Repeating detection: ' + str(self.repeatCounter)) + self.repeatCounter += 1 + if(self.repeatCounter > 10): + self.nozzleDetectionFailed() + return + # loop through again + self.retries += 1 + self.pollCoordinatesSignal.emit() + return + else: + # print('Took ' + str(self.repeatCounter) + ' attempts.') + self.repeatCounter = 0 # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) self.updateStatusbarMessage(updateMessage) @@ -1574,7 +1600,7 @@ def autoCalibrate(self): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): # print('Repeating detection: ' + str(self.repeatCounter)) self.repeatCounter += 1 - if(self.repeatCounter > 5): + if(self.repeatCounter > 10): print('Failed to detect.') self.nozzleDetectionFailed() return @@ -1656,7 +1682,7 @@ def autoCalibrate(self): self.retries += 1 self.pollCoordinatesSignal.emit() return - if(self.retries < 10): + if(self.retries < 100): self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) @@ -1666,6 +1692,10 @@ def autoCalibrate(self): if(self.__stateEndstopAutoCalibrate is True): self.updateStatusbarMessage('Failed to detect endstop.') _logger.warning('Failed to detect endstop. Cancelled operation.') + # if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): + # params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} + # self.moveAbsoluteSignal.emit(params) + # End calibration self.__stateAutoCPCapture = False self.__stateEndstopAutoCalibrate = False self.toggleEndstopAutoDetectionSignal.emit(False) From 7eef437897067149cb9796703ae24add46725bdf Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:49:43 +0200 Subject: [PATCH 80/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 816d163..ad52180 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -539,7 +539,7 @@ def nozzleDetection(self): keypointColor = (39,127,255) # process keypoint print('Checking keypoints') - if(keypoints is not None): + if(keypoints is not None and len(keypoints) >= 1): print('Keypoints is not none:', keypoints) # create center object (x,y) = np.around(keypoints[0].pt) From d95aa1595f29095f87670ef94a80139c7bc60baa Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:52:19 +0200 Subject: [PATCH 81/99] Update TAMV.py --- TAMV.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/TAMV.py b/TAMV.py index 8419419..39b3bba 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1596,22 +1596,22 @@ def autoCalibrate(self): else: updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')' self.updateStatusbarMessage(updateMessage) - if(self.olduv is not None): - if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) - self.repeatCounter += 1 - if(self.repeatCounter > 10): - print('Failed to detect.') - self.nozzleDetectionFailed() - return - # loop through again - print('Retrying', self.retries) - self.retries += 1 - self.pollCoordinatesSignal.emit() - return - else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') - self.repeatCounter = 0 + # if(self.olduv is not None): + # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): + # # print('Repeating detection: ' + str(self.repeatCounter)) + # self.repeatCounter += 1 + # if(self.repeatCounter > 10): + # print('Failed to detect.') + # self.nozzleDetectionFailed() + # return + # # loop through again + # print('Retrying', self.retries) + # self.retries += 1 + # self.pollCoordinatesSignal.emit() + # return + # else: + # # print('Took ' + str(self.repeatCounter) + ' attempts.') + # self.repeatCounter = 0 # increment moves counter self.calibrationMoves += 1 # nozzle detected, frame rotation is set, start From 27e08b5687409c7507912fe56cd4b89335672b66 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:57:53 +0200 Subject: [PATCH 82/99] RC is working on Linux --- TAMV.py | 9 +-------- modules/DetectionManager.py | 21 +-------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/TAMV.py b/TAMV.py index 39b3bba..539e4c5 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1465,9 +1465,8 @@ def autoCalibrate(self): self.updateStatusbarMessage('Calibrating camera step 0..') if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) self.repeatCounter += 1 - if(self.repeatCounter > 10): + if(self.repeatCounter > 5): self.nozzleDetectionFailed() return # loop through again @@ -1475,7 +1474,6 @@ def autoCalibrate(self): self.pollCoordinatesSignal.emit() return else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') self.repeatCounter = 0 self.olduv = self.uv self.space_coordinates = [] @@ -1498,7 +1496,6 @@ def autoCalibrate(self): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) self.repeatCounter += 1 if(self.repeatCounter > 10): self.nozzleDetectionFailed() @@ -1508,7 +1505,6 @@ def autoCalibrate(self): self.pollCoordinatesSignal.emit() return else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') self.repeatCounter = 0 # Calculate mpp at first move if(self.state == 1): @@ -1543,7 +1539,6 @@ def autoCalibrate(self): # Camera calibration moves completed. if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): - # print('Repeating detection: ' + str(self.repeatCounter)) self.repeatCounter += 1 if(self.repeatCounter > 10): self.nozzleDetectionFailed() @@ -1553,7 +1548,6 @@ def autoCalibrate(self): self.pollCoordinatesSignal.emit() return else: - # print('Took ' + str(self.repeatCounter) + ' attempts.') self.repeatCounter = 0 # Update GUI thread with current status and percentage complete updateMessage = 'Millimeters per pixel is ' + str(self.mpp) @@ -2094,7 +2088,6 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): - print('Received UV:', uvCoordinates) print('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) ) if(uvCoordinates is None): # failed to detect, poll coordinates again diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index ad52180..b3aba6f 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -91,7 +91,6 @@ def startCamera(self): def cameraReady(self, imageSettings): # send calling to log _logger.debug('*** calling DetectionManager.cameraReady') - print(imageSettings) # consume JSON try: brightness = imageSettings['brightness'] @@ -194,7 +193,6 @@ def processFrame(self): if(not self.proc.is_alive()): # Start camera process self.proc.start() - print(self.proc) # Retrieve camera settings try: cameraSettings = self.pipeDM.recv() @@ -235,7 +233,6 @@ def processFrame(self): # convert from cv2.mat to QPixmap and return results (frame+keypoint) def receivedFrame(self, frame): - #print('Detection:',self.__counter) self.__counter += 1 if(self.__running): rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) @@ -445,35 +442,27 @@ def burstNozzleDetection(self): self.uv = [None, None] average_location=[0,0] retries = 0 - print('Starting detection loop') while(detectionCount < 3): # skip a few frames - print(' .. skipping frames..') for i in range(1): self.frameEvent.set() self.frame = self.pipeDM.recv() self.frameEvent.clear() - print(' .. detecting..') (self.__uv, self.frame) = self.nozzleDetection() if(self.__uv is not None): - print(' .. keypoints found..') if(self.__uv[0] is not None and self.__uv[1] is not None): average_location[0] += self.__uv[0] average_location[1] += self.__uv[1] detectionCount += 1 else: - print(' .. no keypoints..') retries += 1 else: - print(' .. still no keypoints..') retries += 1 if(retries > 5): - print(' .. retries failed..') average_location[0] = None average_location[1] = None break if(average_location[0] is not None): - print(' .. calculating average position..') # calculate average X Y position from detection average_location[0] /= detectionCount average_location[1] /= detectionCount @@ -481,7 +470,6 @@ def burstNozzleDetection(self): average_location = np.around(average_location,0) self.__uv = average_location else: - print(' .. no position found!') self.__uv = None def nozzleDetection(self): @@ -538,14 +526,11 @@ def nozzleDetection(self): keypoints = self.relaxedDetector.detect(preprocessorImage1) keypointColor = (39,127,255) # process keypoint - print('Checking keypoints') if(keypoints is not None and len(keypoints) >= 1): - print('Keypoints is not none:', keypoints) # create center object (x,y) = np.around(keypoints[0].pt) x,y = int(x), int(y) center = (x,y) - print('Center:', center) # create radius object keypointRadius = np.around(keypoints[0].size/2) keypointRadius = int(keypointRadius) @@ -570,7 +555,6 @@ def nozzleDetection(self): @pyqtSlot(bool) def toggleNozzleDetection(self, nozzleDetectFlag): - print('Switch nozzle detection to:', nozzleDetectFlag) if(nozzleDetectFlag is True): self.__nozzleDetectionActive = True else: @@ -578,7 +562,6 @@ def toggleNozzleDetection(self, nozzleDetectFlag): @pyqtSlot(bool) def toggleNozzleAutoDetection(self, nozzleDetectFlag): - print('Switch nozzle auto detection to:', nozzleDetectFlag) if(nozzleDetectFlag is True): self.__nozzleDetectionActive = True self.__nozzleAutoDetectionActive = True @@ -653,7 +636,6 @@ def relayResetImage(self): # Independent process to run camera grab functions def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): - print('Starting camera...') cap = cv2.VideoCapture(videoSrc, backend) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) @@ -671,12 +653,11 @@ def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): saturation = cap.get(cv2.CAP_PROP_SATURATION) hue = cap.get(cv2.CAP_PROP_HUE) cameraSettings = {'default': 1, 'brightness': brightness, 'contrast': contrast, 'saturation': saturation, 'hue': hue} - print(cameraSettings) # send default settings to queue q.send(cameraSettings) except Exception as e: cap.release() - print('**** Camera failed:',e) + _logger.critical('Camera failed:' + str(e)_ stopEvent.set() FPS = 1/30 while True: From 2eaa1d16d809ae4a8606496267d5c155d95b3b46 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 13:59:42 +0200 Subject: [PATCH 83/99] Update DetectionManager.py --- modules/DetectionManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index b3aba6f..f64274a 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -657,7 +657,7 @@ def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend): q.send(cameraSettings) except Exception as e: cap.release() - _logger.critical('Camera failed:' + str(e)_ + _logger.critical('Camera failed:' + str(e)) stopEvent.set() FPS = 1/30 while True: From 24e67e116bff459e82360b3c42fe27f7a5d9454a Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 14:02:41 +0200 Subject: [PATCH 84/99] Update TAMV.py --- TAMV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index 539e4c5..f188397 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1492,7 +1492,7 @@ def autoCalibrate(self): return elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): # capture the UV data when a calibration move has been executed, before returning to original position - if(self.state != self.lastState): + if(self.state != self.lastState and self.state < len(self.calibrationCoordinates)): _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): From efb8c5b24bf51f7dd3aa8a92579a3624c1e4d645 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Mon, 17 Oct 2022 14:04:20 +0200 Subject: [PATCH 85/99] Update TAMV.py --- TAMV.py | 1 - 1 file changed, 1 deletion(-) diff --git a/TAMV.py b/TAMV.py index f188397..fee1d9d 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2088,7 +2088,6 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): - print('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) ) if(uvCoordinates is None): # failed to detect, poll coordinates again self.pollCoordinatesSignal.emit() From 41d899c685c8bd5895b56637d93ee6f0f10feff8 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 00:46:19 +0200 Subject: [PATCH 86/99] fixing legacy TAMV config JSON format --- TAMV.py | 8 ++++++-- modules/DetectionManager.py | 4 ++-- modules/PrinterManager.py | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/TAMV.py b/TAMV.py index fee1d9d..974e4fe 100644 --- a/TAMV.py +++ b/TAMV.py @@ -294,7 +294,7 @@ def __init__(self, parent=None): 'password': 'reprap', 'name': 'My Duet', 'nickname': 'Default', - 'controller' : 'RRF/Duet', + 'controller' : 'RRF', 'version': '', 'default': 1, 'rotated': 0, @@ -373,7 +373,7 @@ def __init__(self, parent=None): try: temp = machine['controller'] except KeyError: - machine['controller'] = 'RRF/Duet' + machine['controller'] = 'RRF' # Check if version doesn't exist try: temp = machine['version'] @@ -389,6 +389,8 @@ def __init__(self, parent=None): temp = machine['tools'] except KeyError: machine['tools'] = [ { 'number': 0, 'name': 'Tool 0', 'nozzleSize': 0.4, 'offsets': [0,0,0] } ] + if(machine['default'] == 1): + self.__activePrinter = machine # Check if we have no default machine if(defaultPrinterDefined is False): self.__activePrinter = self.__userSettings['printer'][0] @@ -1899,6 +1901,8 @@ def connectPrinter(self): if(self.printerThread.isRunning() is False): self.createPrinterManagerThread(announce=False) except: self.createPrinterManagerThread(announce=False) + #HBHBHBHB TODO: add connection popup + self.connectSignal.emit(self.__activePrinter) def haltPrinterOperation(self, **kwargs): diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index f64274a..57c1f70 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -71,8 +71,8 @@ def startCamera(self): if sys.platform.startswith('linux'): # all Linux self.backend = cv2.CAP_V4L - elif sys.platform.startswith('win'): # MS Windows - self.backend = cv2.CAP_DSHOW + # elif sys.platform.startswith('win'): # MS Windows + # self.backend = cv2.CAP_DSHOW elif sys.platform.startswith('darwin'): # macOS self.backend = cv2.CAP_AVFOUNDATION else: diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index ad59f02..79c4d33 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -81,6 +81,7 @@ def quit(self): # Connections @pyqtSlot(object) def connectPrinter(self, printer): + print('Printer:', printer) # send calling to log _logger.debug('*** calling PrinterManager.connectPrinter') if(self.__activePrinter is not None): From cc811cae3e844c68f2f347e1aa72da57cf26917d Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 11:02:17 +0200 Subject: [PATCH 87/99] added IP lookup to improve performance now TAMV supports using any variation of hostname / IPv4 address with or without leading prefix of http/https to connect, and DuetWebAPI performs an IP address lookup to avoid multiple DNS lookups per request. --- TAMV.py | 20 +++++++++++--------- drivers/DuetWebAPI.py | 10 ++++++++++ modules/PrinterManager.py | 1 - 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/TAMV.py b/TAMV.py index 974e4fe..82fa4d0 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 import argparse, logging, os, sys, traceback, time -from tkinter import E -from json import tool from logging.handlers import RotatingFileHandler # openCV imports from cv2 import cvtColor, COLOR_BGR2RGB @@ -14,6 +12,7 @@ import json import numpy as np import copy +import socket # Custom modules import from modules.DetectionManager import DetectionManager from modules.SettingsDialog import SettingsDialog @@ -395,6 +394,7 @@ def __init__(self, parent=None): if(defaultPrinterDefined is False): self.__activePrinter = self.__userSettings['printer'][0] (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address']) + self.__activePrinter['address'] = self.printerURL if _errCode > 0: # invalid input _logger.error('Invalid printer URL detected in settings.json') @@ -2232,19 +2232,21 @@ def sanitizeURL(self, inputString='http://localhost'): _printerURL = 'http://localhost' from urllib.parse import urlparse u = urlparse(inputString) + if(u[0] ==''): + u = u._replace(scheme="http") scheme = u[0] - netlocation = u[1] - if len(scheme) < 4 or scheme.lower() not in ['http']: + if(scheme.lower() not in ['http','https']): _errCode = 1 _errMsg = 'Invalid scheme. Please only use http connections.' - elif len(netlocation) < 1: - _errCode = 2 - _errMsg = 'Invalid IP/network address.' elif scheme.lower() in ['https']: - _errCode = 3 + _errCode = 2 _errMsg = 'Cannot use https connections for Duet controllers' else: - _printerURL = scheme + '://' + netlocation + if(u.netloc == ''): + _printerURL = u.scheme + '://' + u.path + else: + _printerURL = u.geturl() + _logger.debug('Sanitized ' + inputString + ' to address: ' + _printerURL) _logger.debug('*** exiting App.sanitizeURL') return(_errCode, _errMsg, _printerURL) diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py index e0ab127..60b25c0 100644 --- a/drivers/DuetWebAPI.py +++ b/drivers/DuetWebAPI.py @@ -24,6 +24,8 @@ from urllib3.connection import ConnectTimeoutError from urllib3.util.retry import Retry from urllib3.exceptions import * +from urllib.parse import urlparse +import socket # import dependencies import json import time @@ -125,6 +127,14 @@ class printerAPI: def __init__(self, baseURL, nickname='Default', password='reprap'): _logger.debug('Starting DuetWebAPI..') # parse input parameters + + # convert hostname into IP + # fetch IP address + u = urlparse(baseURL) + hostIP = socket.gethostbyname(u.hostname) + baseURL = u.scheme + '://' + hostIP + + # set base parameters self._base_url = baseURL self.password = password self._nickname = nickname diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index 79c4d33..ad59f02 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -81,7 +81,6 @@ def quit(self): # Connections @pyqtSlot(object) def connectPrinter(self, printer): - print('Printer:', printer) # send calling to log _logger.debug('*** calling PrinterManager.connectPrinter') if(self.__activePrinter is not None): From 29c1a2d4b700da1897ffb136bacee0fadf88a304 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 11:05:37 +0200 Subject: [PATCH 88/99] Update TAMV.py --- TAMV.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 82fa4d0..72a2e0a 100644 --- a/TAMV.py +++ b/TAMV.py @@ -393,8 +393,9 @@ def __init__(self, parent=None): # Check if we have no default machine if(defaultPrinterDefined is False): self.__activePrinter = self.__userSettings['printer'][0] - (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address']) - self.__activePrinter['address'] = self.printerURL + if(self.__activePrinter['controller'] == 'RRF'): + (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address']) + self.__activePrinter['address'] = self.printerURL if _errCode > 0: # invalid input _logger.error('Invalid printer URL detected in settings.json') From 0a598021454c6621611a5cda5850cad2e7fad3c9 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 11:09:37 +0200 Subject: [PATCH 89/99] Update TAMV.py --- TAMV.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TAMV.py b/TAMV.py index 72a2e0a..56a89e4 100644 --- a/TAMV.py +++ b/TAMV.py @@ -399,6 +399,7 @@ def __init__(self, parent=None): if _errCode > 0: # invalid input _logger.error('Invalid printer URL detected in settings.json') + _logger.error(_errMsg) _logger.info('Defaulting to \"http://localhost\"...') self.printerURL = 'http://localhost' @@ -2239,9 +2240,9 @@ def sanitizeURL(self, inputString='http://localhost'): if(scheme.lower() not in ['http','https']): _errCode = 1 _errMsg = 'Invalid scheme. Please only use http connections.' - elif scheme.lower() in ['https']: - _errCode = 2 - _errMsg = 'Cannot use https connections for Duet controllers' + # elif scheme.lower() in ['https']: + # _errCode = 2 + # _errMsg = 'Cannot use https connections for Duet controllers' else: if(u.netloc == ''): _printerURL = u.scheme + '://' + u.path From 771bdb24391a57395e65f54275c0583548afc309 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 11:15:46 +0200 Subject: [PATCH 90/99] Update DuetWebAPI.py --- drivers/DuetWebAPI.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py index 60b25c0..b03bff5 100644 --- a/drivers/DuetWebAPI.py +++ b/drivers/DuetWebAPI.py @@ -133,6 +133,8 @@ def __init__(self, baseURL, nickname='Default', password='reprap'): u = urlparse(baseURL) hostIP = socket.gethostbyname(u.hostname) baseURL = u.scheme + '://' + hostIP + if(u.port is not None): + baseURL += ':' + str(u.port) # set base parameters self._base_url = baseURL From 2bd3a9ad05b9d520bc6ac3d29654bc5b70a61533 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Tue, 18 Oct 2022 15:23:45 +0200 Subject: [PATCH 91/99] connection drop down enabled! --- TAMV.py | 56 +++++++++++++----- modules/ConnectionDialog.py | 109 ++++++++++++++++++++++++++++++++++++ modules/PrinterManager.py | 39 +++++++++---- 3 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 modules/ConnectionDialog.py diff --git a/TAMV.py b/TAMV.py index 56a89e4..31bb682 100644 --- a/TAMV.py +++ b/TAMV.py @@ -2,6 +2,7 @@ import argparse, logging, os, sys, traceback, time from logging.handlers import RotatingFileHandler +from urllib.parse import urlparse # openCV imports from cv2 import cvtColor, COLOR_BGR2RGB # Qt imports @@ -12,10 +13,10 @@ import json import numpy as np import copy -import socket # Custom modules import -from modules.DetectionManager import DetectionManager from modules.SettingsDialog import SettingsDialog +from modules.ConnectionDialog import ConnectionDialog +from modules.DetectionManager import DetectionManager from modules.PrinterManager import PrinterManager from modules.StatusTipFilter import StatusTipFilter @@ -52,6 +53,7 @@ class App(QMainWindow): limitAxesSignal = pyqtSignal() flushBufferSignal = pyqtSignal() saveToFirmwareSignal = pyqtSignal() + announceSignal = pyqtSignal(bool) # Settings Dialog resetImageSignal = pyqtSignal() pushbuttonSize = 38 @@ -126,8 +128,7 @@ def __init__(self, parent=None): self.centralWidget = QWidget() self.setCentralWidget(self.centralWidget) # create stylehseets - self.setStyleSheet( - '\ + self.globalStylesheet = '\ QLabel#instructions_text {\ background-color: rgba(255,153,0,.4);\ }\ @@ -239,7 +240,7 @@ def __init__(self, parent=None): color: black;\ }\ ' - ) + self.setStyleSheet(self.globalStylesheet) #### Driver API imports if(True): try: @@ -1330,7 +1331,8 @@ def resetCalibration(self): self.toggleDetectionSignal.emit(False) self.__displayCrosshair = False self.resetCalibrationVariables() - self.unloadToolSignal.emit() + if(self.__firstConnection is False): + self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() def resetNozzleAlignment(self): @@ -1850,7 +1852,7 @@ def createPrinterManagerThread(self,announce=True): if(announce): _logger.info(' .. starting Printer Manager.. ') try: - self.printerManager = PrinterManager(parent=None, firmwareList=self.__firmwareList, driverList=self.__driverList) + self.printerManager = PrinterManager(parent=None, firmwareList=self.__firmwareList, driverList=self.__driverList, announcemode=announce) self.printerThread = QThread() self.printerManager.moveToThread(self.printerThread) @@ -1867,6 +1869,7 @@ def createPrinterManagerThread(self,announce=True): self.printerManager.printerDisconnectedSignal.connect(self.printerDisconnected) self.connectSignal.connect(self.printerManager.connectPrinter) self.disconnectSignal.connect(self.printerManager.disconnectPrinter) + self.announceSignal.connect(self.printerManager.setAnnounceMode) self.moveRelativeSignal.connect(self.printerManager.moveRelative) self.moveAbsoluteSignal.connect(self.printerManager.moveAbsolute) @@ -1899,13 +1902,36 @@ def connectPrinter(self): self.connectionStatusLabel.setText('Connecting..') self.connectionStatusLabel.setStyleSheet(self.styleBlue) self.repaint() - try: - if(self.printerThread.isRunning() is False): - self.createPrinterManagerThread(announce=False) - except: self.createPrinterManagerThread(announce=False) - #HBHBHBHB TODO: add connection popup - self.connectSignal.emit(self.__activePrinter) + self.connection_dialog = ConnectionDialog(parent=self, newPrinter=False, settings=self.__userSettings, stylesheet=self.globalStylesheet) + connectionDialogReturn = self.connection_dialog.exec() + # Destroy connection_dialog window + self.connection_dialog = None + + if(connectionDialogReturn == -2): + # new printer definition + pass + elif(connectionDialogReturn >= 0): + # fetch printer from user objects + self.__activePrinter = self.__userSettings['printer'][connectionDialogReturn] + try: + self.announceSignal.emit(False) + if(self.printerThread.isRunning() is True): + # Printer already running, delete thread and restart + self.printerThread.quit() + self.printerThread.wait() + self.createPrinterManagerThread(announce=False) + except: self.createPrinterManagerThread(announce=False) + self.announceSignal.emit(True) + self.connectSignal.emit(self.__activePrinter) + else: + # user closed window, update GUI back to default state + # Settings option in menu + self.preferencesAction.setDisabled(False) + # Connect button + self.connectButton.setVisible(True) + self.connectButton.setDisabled(False) + self.connectButton.setStyleSheet(self.styleGreen) def haltPrinterOperation(self, **kwargs): try: @@ -1991,6 +2017,8 @@ def printerDisconnected(self, **kwargs): # Status label self.connectionStatusLabel.setText('Disconnected') self.connectionStatusLabel.setStyleSheet(self.styleOrange) + self.__firstConnection = True + self.__restorePosition = None self.__mutex.unlock() self.stateDisconnected() self.repaint() @@ -2232,7 +2260,6 @@ def sanitizeURL(self, inputString='http://localhost'): _errCode = 0 _errMsg = '' _printerURL = 'http://localhost' - from urllib.parse import urlparse u = urlparse(inputString) if(u[0] ==''): u = u._replace(scheme="http") @@ -2289,6 +2316,7 @@ def saveUserSettings(self): # Save settings to file with open('./config/settings.json','w') as outputfile: json.dump(self.__userSettings, outputfile) + _logger.info('User preferences saved to settings.json') self.updateStatusbarMessage('User preferences saved to settings.json') self.statusBar.setStyleSheet('') diff --git a/modules/ConnectionDialog.py b/modules/ConnectionDialog.py new file mode 100644 index 0000000..c85c0a3 --- /dev/null +++ b/modules/ConnectionDialog.py @@ -0,0 +1,109 @@ +import logging +# invoke parent (TAMV) _logger +_logger = logging.getLogger('TAMV.ConnectionDialog') + +from PyQt5.QtWidgets import QComboBox, QDialog, QVBoxLayout, QPushButton, QFrame +from PyQt5.QtCore import Qt +from urllib.parse import urlparse + +########################################################################### Connection dialog box +class ConnectionDialog(QDialog): + def __init__(self, *args, **kwargs): + _logger.debug('*** calling ConnectionDialog.__init__') + + # Parse arguments + try: + self.__parent = kwargs['parent'] + except: self.__parent = None + try: + self.__newPrinter = kwargs['newPrinter'] + except: self.__newPrinter = False + try: + self.__settings = kwargs['settings'] + except: self.__settings = None + try: + self.__stylesheet = kwargs['stylesheet'] + except: self.__stylesheet = None + + # Set up settings window + super(ConnectionDialog, self).__init__(parent=self.__parent) + self.setWindowFlag(Qt.WindowContextHelpButtonHint,False) + self.setWindowTitle( 'Connect to a machine' ) + self.setWindowModality( Qt.ApplicationModal ) + self.setMinimumWidth(300) + # self.setMinimumHeight(150) + + # Set layout details + self.layout = QVBoxLayout() + self.layout.setSpacing(3) + self.setLayout( self.layout ) + if(self.__stylesheet is not None): + self.setStyleSheet(self.__stylesheet) + # Get machines as defined in the config + # Printer combo box + self.printerCombobox = QComboBox() + self.printerCombobox.setFixedWidth(280) + self.printerCombobox.setStyleSheet('font: 16px') + self.defaultPrinter = {} + for i, device in enumerate(self.__settings['printer']): + machineAddress = urlparse(device['address']) + printerDescription = device['nickname'] + ' / ' + machineAddress.hostname + if( device['default'] == 1 ): + self.defaultPrinter = device + self.defaultPrinter['index'] = i + self.printerCombobox.addItem(printerDescription) + # handle selecting a new machine + # set default printer as the selected index + self.printerCombobox.setCurrentIndex(self.defaultPrinter['index']) + # add final option to add a new printer + self.printerCombobox.addItem('+++ Add a new machine..') + self.printerCombobox.currentIndexChanged.connect(self.addPrinter) + # add printer combo box to layout + self.connectButton = QPushButton( 'Connect..') + self.connectButton.clicked.connect(self.startConnection) + self.connectButton.setObjectName( 'active' ) + self.connectButton.setFixedWidth(200) + # Close button + self.cancelButton = QPushButton( 'Cancel' ) + self.cancelButton.setToolTip( 'Cancel changes and return to main program.' ) + self.cancelButton.clicked.connect(self.reject) + self.cancelButton.setObjectName( 'terminate' ) + self.cancelButton.setFixedWidth(200) + # Separator + self.spacerLine = QFrame() + self.spacerLine.setFrameShape(QFrame.HLine) + self.spacerLine.setLineWidth(1) + self.spacerLine.setFrameShadow(QFrame.Sunken) + self.spacerLine.setFixedHeight(25) + self.spacerLine.setFixedWidth(200) + # WINDOW BUTTONS + self.layout.setAlignment(Qt.AlignCenter) + self.layout.addWidget( self.printerCombobox ) + self.layout.addWidget(self.spacerLine) + self.layout.addWidget(self.connectButton) + self.layout.addWidget(self.cancelButton) + self.layout.setAlignment(self.printerCombobox, Qt.AlignHCenter) + self.layout.setAlignment(self.spacerLine, Qt.AlignHCenter) + self.layout.setAlignment(self.connectButton, Qt.AlignHCenter) + self.layout.setAlignment(self.cancelButton, Qt.AlignHCenter) + _logger.debug('*** exiting ConnectionDialog.__init__') + + def startConnection( self ): + _logger.debug('*** calling ConnectionDialog.startConnection') + index = self.printerCombobox.currentIndex() + if( index < len(self.__settings['printer'])): + self.done(index) + else: + self.done(-2) + _logger.debug('*** exiting ConnectionDialog.startConnection') + + def addPrinter( self, index ): + _logger.debug('*** calling ConnectionDialog.addPrinter') + if( index == len(self.__settings['printer'])): + self.connectButton.setText('Create new profile..') + else: + self.connectButton.setText('Connect..') + _logger.debug('*** exiting ConnectionDialog.addPrinter') + + def reject(self): + self.done(-1) \ No newline at end of file diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index ad59f02..f393b4a 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -35,7 +35,6 @@ class PrinterManager(QObject): def __init__(self, *args, **kwargs): # send calling to log _logger.debug('*** calling PrinterManager.__init__') - try: self.__parent = kwargs['parent'] @@ -59,6 +58,9 @@ def __init__(self, *args, **kwargs): _logger.exception(errorMsg) self.errorSignal.emit(errorMsg) return + try: + self.__announce = kwargs['announcemode'] + except KeyError: self.__announce = True # send exiting to log _logger.debug('*** exiting PrinterManager.__init__') @@ -67,14 +69,17 @@ def __init__(self, *args, **kwargs): def quit(self): # send calling to log _logger.debug('*** calling PrinterManager.quit') - _logger.info('Shutting down Printer Manager..') - if( self.__printerJSON is not None): - _logger.info(' .. disconnecting printer..') + if(self.__announce): + _logger.info('Shutting down Printer Manager..') + if( self.__printerJSON is not None): + _logger.info(' .. disconnecting printer..') self.disconnectPrinter({'noUpdate':True}) if( self.__printerJSON is not None): - _logger.info(' Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') + if(self.__announce): + _logger.info(' Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') self.__printerJSON = None - _logger.info('Printer Manager shut down successfully.') + if(self.__announce): + _logger.info('Printer Manager shut down successfully.') # send exiting to log _logger.debug('*** exiting PrinterManager.quit') @@ -90,7 +95,8 @@ def connectPrinter(self, printer): # start connect process self.updateStatusbarSignal.emit('Attempting to connect to: ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')') - _logger.info('Connecting to printer at ' + self.__printerJSON['address'] + '..') + if(self.__announce): + _logger.info('Connecting to printer at ' + self.__printerJSON['address'] + '..') # Attempt connecting to the controller try: @@ -130,14 +136,16 @@ def connectPrinter(self, printer): return else: # connection succeeded, update objects accordingly - _logger.info(' .. fetching tool information..') + if(self.__announce): + _logger.info(' .. fetching tool information..') # Send printer JSON to parent thread activePrinter = self.__activePrinter.getJSON() activePrinter['nickname'] = self.__printerJSON['nickname'] activePrinter['default'] = self.__printerJSON['default'] activePrinter['currentTool'] = self.__activePrinter.getCurrentTool() successMsg = 'Connected to ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' - _logger.info(' ' + successMsg) + if(self.__announce): + _logger.info(' ' + successMsg) self.updateStatusbarSignal.emit(successMsg) self.activePrinterSignal.emit(activePrinter) except Exception as e: @@ -169,20 +177,23 @@ def disconnectPrinter(self, kwargs={}): try: try: self.__activePrinter.flushMovementBuffer() - _logger.info(' .. unloading tools..') + if(self.__announce): + _logger.info(' .. unloading tools..') self.__activePrinter.unloadTools() except: errorMsg = 'Could not unload tools.' raise Exception(errorMsg) # move to final park position if(parkPosition is not None): - _logger.info(' .. restoring position..') + if(self.__announce): + _logger.info(' .. restoring position..') self.complexMoveAbsolute(position=parkPosition) if(noUpdate is False): # notify parent thread successMsg = 'Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' - _logger.info(successMsg) + if(self.__announce): + _logger.info(successMsg) self.printerDisconnectedSignal.emit(successMsg) else: self.printerDisconnectedSignal.emit(None) @@ -194,6 +205,10 @@ def disconnectPrinter(self, kwargs={}): # send exiting to log _logger.debug('*** exiting PrinterManager.disconnectPrinter') + @pyqtSlot(bool) + def setAnnounceMode(self, announceFlag=True): + self.__announce = announceFlag + # Movements and Coordinates def complexMoveAbsolute(self, position=None, moveSpeed=None): # send calling to log From 52852145101bb74002a83cdd4d846b7c95523894 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Fri, 21 Oct 2022 13:45:45 +0200 Subject: [PATCH 92/99] closes #45 enabling manual offset capture --- TAMV.py | 243 +++++++++++++++++++++++++----------- modules/DetectionManager.py | 79 +++++++----- modules/PrinterManager.py | 8 +- 3 files changed, 224 insertions(+), 106 deletions(-) diff --git a/TAMV.py b/TAMV.py index 31bb682..62d4ffc 100644 --- a/TAMV.py +++ b/TAMV.py @@ -13,6 +13,7 @@ import json import numpy as np import copy +import threading # Custom modules import from modules.SettingsDialog import SettingsDialog from modules.ConnectionDialog import ConnectionDialog @@ -22,7 +23,7 @@ ########################################################################### Core application class class App(QMainWindow): - # "Global" mutex + # Global mutex __mutex = QMutex() # Signals ######## Detection Manager @@ -58,8 +59,11 @@ class App(QMainWindow): resetImageSignal = pyqtSignal() pushbuttonSize = 38 # default move speed in feedrate/min - _moveSpeed = 6000 - __counter = 0 + __moveSpeed = 6000 + # Maximum number of retries for detection + __maxRetries = 3 + # Maximum runtime (in seconds) for calibration cycles + __maxRuntime = 120 ########################################################################### Initialize class def __init__(self, parent=None): @@ -110,6 +114,7 @@ def __init__(self, parent=None): # Nozzle detection self.__stateAutoNozzleAlignment = False self.__stateManualNozzleAlignment = False + self.singleToolOffsetsCapture = False self.__displayCrosshair = False #### setup window properties @@ -674,8 +679,19 @@ def setupMainWindow(self): self.manualCPCaptureButton.clicked.connect(self.manualCPCapture) self.footerLayout.addWidget(self.manualCPCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton = QPushButton('Capture offset') + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) + self.overrideManualOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.overrideManualOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.overrideManualOffsetCaptureButton.setToolTip('Capture current position and calculate tool offset.') + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.clicked.connect(self.overrideManualToolOffsetCapture) + self.footerLayout.addWidget(self.overrideManualOffsetCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton = QPushButton('Capture offset') + self.manualToolOffsetCaptureButton = QPushButton('Capture2 offset') self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) self.manualToolOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) self.manualToolOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) @@ -683,7 +699,7 @@ def setupMainWindow(self): self.manualToolOffsetCaptureButton.setDisabled(True) self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.clicked.connect(self.manualToolOffsetCapture) - self.footerLayout.addWidget(self.manualToolOffsetCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + self.footerLayout.addWidget(self.manualToolOffsetCaptureButton, 0,0,1,1,Qt.AlignRight|Qt.AlignVCenter) # Start Alignment button self.alignToolsButton = QPushButton('Align Tools') @@ -724,7 +740,7 @@ def xleftClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'X': str(-1*incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'X': str(-1*incrementDistance)}} self.moveRelativeSignal.emit(params) def xRightClicked(self): @@ -737,7 +753,7 @@ def xRightClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'X': str(incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'X': str(incrementDistance)}} self.moveRelativeSignal.emit(params) def yleftClicked(self): @@ -750,7 +766,7 @@ def yleftClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'Y': str(-1*incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'Y': str(-1*incrementDistance)}} self.moveRelativeSignal.emit(params) def yRightClicked(self): @@ -763,7 +779,7 @@ def yRightClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'Y': str(incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'Y': str(incrementDistance)}} self.moveRelativeSignal.emit(params) def zleftClicked(self): @@ -776,7 +792,7 @@ def zleftClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'Z': str(-1*incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'Z': str(-1*incrementDistance)}} self.moveRelativeSignal.emit(params) def zRightClicked(self): @@ -789,7 +805,7 @@ def zRightClicked(self): incrementDistance = 0.1 elif self.button_001.isChecked(): incrementDistance = 0.01 - params = {'moveSpeed': self._moveSpeed, 'position':{'Z': str(incrementDistance)}} + params = {'moveSpeed': self.__moveSpeed, 'position':{'Z': str(incrementDistance)}} self.moveRelativeSignal.emit(params) ########################################################################### GUI State functions @@ -819,10 +835,15 @@ def stateDisconnected(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -876,10 +897,15 @@ def stateConnected(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -989,10 +1015,15 @@ def stateCPSetup(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1038,10 +1069,15 @@ def stateCPAuto(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleDisabled) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1083,10 +1119,15 @@ def stateCalibrateReady(self): # Start Alignment button self.alignToolsButton.setVisible(True) self.alignToolsButton.setDisabled(False) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleGreen) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1136,10 +1177,15 @@ def stateCalibtrateRunning(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.alignToolsButton.setText('Detecting..') + self.alignToolsButton.setStyleSheet(self.styleOrange+' * {font: italic}') + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(True) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(True) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1184,10 +1230,15 @@ def stateCalibrateComplete(self): # Start Alignment button self.alignToolsButton.setVisible(True) self.alignToolsButton.setDisabled(False) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleGreen) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1237,10 +1288,15 @@ def stateExiting(self): # Start Alignment button self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) + self.alignToolsButton.setText('Align Tools') self.alignToolsButton.setStyleSheet(self.styleGreen) + # Override Manual Tool offset Capture button + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) # Resume auto alignment button self.resumeAutoToolAlignmentButton.setVisible(False) @@ -1311,13 +1367,20 @@ def haltNozzleCapture(self): # Reset GUI self.stateCalibrateReady() - def manualToolOffsetCapture(self): - self.manualToolOffsetCaptureButton.setDisabled(True) - self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + def overrideManualToolOffsetCapture(self): + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) # set tool alignment state to trigger offset calculation self.state = 100 self.pollCoordinatesSignal.emit() + def manualToolOffsetCapture(self): + self.manualToolOffsetCaptureButton.setDisabled(True) + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': self.__currentPosition, 'cpCoordinates': self.__cpCoordinates, 'continue': False} + self.setOffsetsSignal.emit(params) + def resetCalibration(self): # Reset program state, and frame capture control to defaults self.__stateEndstopAutoCalibrate = False @@ -1331,7 +1394,7 @@ def resetCalibration(self): self.toggleDetectionSignal.emit(False) self.__displayCrosshair = False self.resetCalibrationVariables() - if(self.__firstConnection is False): + if(self.__restorePosition is not None): self.unloadToolSignal.emit() self.getVideoFrameSignal.emit() @@ -1429,6 +1492,7 @@ def calibrateTools(self, toolset=None): # grab the first item in the list (FIFO) toolIndex = self.workingToolset[0] _logger.info('Calibrating T' + str(toolIndex) +'..') + self.alignToolsButton.setText('Detecting T'+ str(toolIndex) +'..') # delete the tool from the list before processing it self.workingToolset.pop(0) # update toolButtons GUI to indiciate which tool we're working on @@ -1458,6 +1522,7 @@ def calibrateTools(self, toolset=None): def autoCalibrate(self): self.tabPanel.setDisabled(True) + # create variable for tracking the calibration run time, if not already defined try: if(runtime): pass @@ -1638,6 +1703,7 @@ def autoCalibrate(self): runtime = np.around(time.time() - self.toolTime,1) # check if too much time has passed if(runtime > 120 or self.calibrationMoves > 30): + print('Runtime:', runtime, ' moves: ', self.calibrationMoves) self.retries = 10 # Otherwise, check if we're not aligned to the center elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0): @@ -1678,34 +1744,18 @@ def autoCalibrate(self): self.updateStatusbarMessage(updateMessage) self.pollCoordinatesSignal.emit() return - elif(self.retries < 100 and runtime <= 120): + elif(self.retries < 100 and runtime <= self.__maxRuntime): self.retries += 1 self.pollCoordinatesSignal.emit() return - if(self.retries < 100): + if(self.retries < self.__maxRetries): self.retries += 1 # enable detection self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True self.pollCoordinatesSignal.emit() return - if(self.__stateEndstopAutoCalibrate is True): - self.updateStatusbarMessage('Failed to detect endstop.') - _logger.warning('Failed to detect endstop. Cancelled operation.') - # if(self.originalPrinterPosition['X'] is not None and self.originalPrinterPosition['Y'] is not None): - # params = {'moveSpeed':1000, 'position':{'X':self.originalPrinterPosition['X'],'Y':self.originalPrinterPosition['Y']}} - # self.moveAbsoluteSignal.emit(params) - # End calibration - self.__stateAutoCPCapture = False - self.__stateEndstopAutoCalibrate = False - self.toggleEndstopAutoDetectionSignal.emit(False) - self.haltCPAutoCapture() - self.pollCoordinatesSignal.emit() - elif(self.__stateAutoNozzleAlignment is True): - updateMessage = 'Failed to detect nozzle. Try manual override.' - self.updateStatusbarMessage(updateMessage) - _logger.warning(updateMessage) - self.nozzleDetectionFailed() + # If we've reached this part of the code, we've run over our limit of retries def nozzleDetectionFailed(self): self.state = -99 @@ -1716,9 +1766,9 @@ def nozzleDetectionFailed(self): self.tabPanel.setDisabled(False) self.alignToolsButton.setVisible(False) self.alignToolsButton.setDisabled(True) - self.manualToolOffsetCaptureButton.setVisible(True) - self.manualToolOffsetCaptureButton.setDisabled(False) - self.manualToolOffsetCaptureButton.setStyleSheet(self.styleBlue) + self.overrideManualOffsetCaptureButton.setVisible(True) + self.overrideManualOffsetCaptureButton.setDisabled(False) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleBlue) self.resumeAutoToolAlignmentButton.setVisible(True) self.resumeAutoToolAlignmentButton.setDisabled(False) self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleGreen) @@ -1745,11 +1795,15 @@ def resumeAutoAlignment(self): self.resumeAutoToolAlignmentButton.setVisible(False) self.resumeAutoToolAlignmentButton.setDisabled(True) self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) + self.overrideManualOffsetCaptureButton.setVisible(False) + self.overrideManualOffsetCaptureButton.setDisabled(True) + self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) + self.alignToolsButton.setVisible(True) + self.alignToolsButton.setDisabled(True) self.updateStatusbarMessage('Resuming auto detection of current tool..') self.toggleNozzleAutoDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() - ########################################################################### Module interfaces and handlers ########################################################################### Interface with Detection Manager def createDetectionManagerThread(self, announce=True): if(announce): @@ -1765,7 +1819,7 @@ def createDetectionManagerThread(self, announce=True): self.detectionThread.finished.connect(self.detectionManager.quit) self.detectionThread.finished.connect(self.detectionManager.deleteLater) self.detectionThread.finished.connect(self.detectionThread.deleteLater) - self.detectionThread.start()#priority=QThread.TimeCriticalPriority) + # Video frame signals and slots self.detectionManager.detectionManagerNewFrameSignal.connect(self.refreshImage) self.detectionManager.detectionManagerReadySignal.connect(self.startVideo) @@ -1799,10 +1853,7 @@ def startVideo(self, cameraProperties): def refreshImage(self, data): self.__mutex.lock() frame = data[0] - # uvCoordinates = data[1] self.image.setPixmap(frame) - # if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): - # self.uv = uvCoordinates self.__mutex.unlock() self.getVideoFrameSignal.emit() @@ -1827,23 +1878,28 @@ def detectionManagerError(self, message): self.haltPrinterOperation(silent=True) self.__mutex.lock() try: + self.resetCalibration() + self.stateExiting() self.statusBar.showMessage(message) self.statusBar.setStyleSheet(self.styleRed) self.cpLabel.setStyleSheet(self.styleOrange) + self.cpLabel.setVisible(False) self.connectionStatusLabel.setStyleSheet(self.styleOrange) - self.resetCalibration() + self.connectionStatusLabel.setText('Critical Error') self.__mutex.unlock() - self.moveAbsoluteSignal.emit(self.originalPrinterPosition) - except: + if(self.__restorePosition is not None): + self.moveAbsoluteSignal.emit(self.__restorePosition) + except Exception as e: + print(e) self.__mutex.unlock() errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) - # Kill thread self.detectionThread.quit() self.detectionThread.wait() - self.printerThread.quit() - self.printerThread.wait() + if(self.__restorePosition is not None): + self.printerThread.quit() + self.printerThread.wait() # display error image self.image.setPixmap(self.errorImage) @@ -1886,7 +1942,6 @@ def createPrinterManagerThread(self,announce=True): self.printerManager.offsetsSetSignal.connect(self.calibrateOffsetsApplied) self.setOffsetsSignal.connect(self.printerManager.calibrationSetOffset) - self.printerThread.start()#priority=QThread.TimeCriticalPriority) except Exception as e: print(e) @@ -2024,7 +2079,6 @@ def printerDisconnected(self, **kwargs): self.repaint() # send exiting to log _logger.debug('*** exiting App.printerDisconnected') - @pyqtSlot(object) def printerError(self, message): @@ -2048,9 +2102,11 @@ def callTool(self, toolNumber=-1): self.__displayCrosshair = False toolNumber = int(toolNumber) if(toolNumber == -1): + self.alignToolsButton.setText('Unloading..') self.unloadToolSignal.emit() return try: + self.alignToolsButton.setText('Loading T' +str(toolNumber) + '..') self.callToolSignal.emit(toolNumber) except: errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) @@ -2076,6 +2132,20 @@ def registerActiveTool(self, toolIndex): else: button.setChecked(True) self.__mutex.unlock() + if(self.__stateAutoNozzleAlignment is False and self.__stateManualNozzleAlignment is False and int(toolIndex) != -1): + overrideEnabled = True + else: + overrideEnabled = False + if(self.__cpCoordinates is not None and self.__cpCoordinates['X'] is not None): + self.manualToolOffsetCaptureButton.setDisabled(not overrideEnabled) + self.manualToolOffsetCaptureButton.setVisible(overrideEnabled) + if(overrideEnabled): + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleGreen) + else: + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + if(not overrideEnabled): + self.alignToolsButton.setText('Detecting T' + str(toolIndex) +'..') + self.alignToolsButton.setVisible(not overrideEnabled) @pyqtSlot() def printerMoveComplete(self): @@ -2122,11 +2192,30 @@ def printerMoveComplete(self): @pyqtSlot(object) def saveUVCoordinates(self, uvCoordinates): - if(uvCoordinates is None): - # failed to detect, poll coordinates again - self.pollCoordinatesSignal.emit() - return self.uv = uvCoordinates + if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): + if(uvCoordinates is None): + print('No keypoints found') + # failed to detect, poll coordinates again + self.retries += 1 + print('Retries:', self.retries) + # check if we've exceeded our maxRetries or maxRuntime + if(self.retries > self.__maxRetries): + if(self.__stateEndstopAutoCalibrate is True): + self.updateStatusbarMessage('Failed to detect endstop.') + _logger.warning('Failed to detect endstop. Cancelled operation.') + self.__stateAutoCPCapture = False + self.__stateEndstopAutoCalibrate = False + self.toggleEndstopAutoDetectionSignal.emit(False) + self.haltCPAutoCapture() + elif(self.__stateAutoNozzleAlignment is True): + updateMessage = 'Failed to detect nozzle. Try manual override.' + self.updateStatusbarMessage(updateMessage) + _logger.warning(updateMessage) + self.nozzleDetectionFailed() + return + self.pollCoordinatesSignal.emit() + return self.autoCalibrate() @pyqtSlot(object) @@ -2157,7 +2246,7 @@ def saveCurrentPosition(self, coordinates): self.state = 200 self.__stateAutoNozzleAlignment = True self.toggleNozzleAutoDetectionSignal.emit(True) - params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} + params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates, 'continue': True} self.setOffsetsSignal.emit(params) return self.getUVCoordinatesSignal.emit() @@ -2167,7 +2256,7 @@ def saveCurrentPosition(self, coordinates): self.state = 200 self.__stateAutoNozzleAlignment = True self.toggleNozzleAutoDetectionSignal.emit(True) - params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates} + params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates, 'continue': True} self.setOffsetsSignal.emit(params) return elif(self.__firstConnection): @@ -2180,7 +2269,13 @@ def calibrateOffsetsApplied(self, params=None): try: offsets = params['offsets'] except: offsets = None - if(offsets is not None): + try: + __continue = params['continue'] + except: __continue = True + self.resumeAutoToolAlignmentButton.setDisabled(True) + self.resumeAutoToolAlignmentButton.setVisible(False) + self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) + if(__continue is True): toolCalibrationTime = np.around(time.time() - self.toolTime,1) successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ') -- [' + str(toolCalibrationTime) + 's].' self.updateStatusbarMessage(successMsg) @@ -2188,10 +2283,13 @@ def calibrateOffsetsApplied(self, params=None): self.state = 200 self.retries = 0 self.__stateAutoNozzleAlignment = True + self.__stateManualNozzleAlignment = False self.toggleNozzleAutoDetectionSignal.emit(True) self.calibrateTools(self.workingToolset) else: - raise SystemExit('FUCKED!') + successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ').' + self.updateStatusbarMessage(successMsg) + _logger.info(successMsg) ########################################################################### Interface with Settings Dialog def displayPreferences(self, event=None, newPrinterFlag=False): @@ -2266,10 +2364,7 @@ def sanitizeURL(self, inputString='http://localhost'): scheme = u[0] if(scheme.lower() not in ['http','https']): _errCode = 1 - _errMsg = 'Invalid scheme. Please only use http connections.' - # elif scheme.lower() in ['https']: - # _errCode = 2 - # _errMsg = 'Cannot use https connections for Duet controllers' + _errMsg = 'Invalid scheme. Please only use http/https connections.' else: if(u.netloc == ''): _printerURL = u.scheme + '://' + u.path @@ -2408,6 +2503,10 @@ def show(self): print(' Welcome to TAMV!') print() super().show() + + def startModules(self): + self.detectionThread.start(priority=QThread.TimeCriticalPriority) + self.announceSignal.emit(False) if __name__=='__main__': ### Setup OS options @@ -2477,4 +2576,6 @@ def show(self): app = QApplication(sys.argv) a = App() a.show() + t = threading.Thread(target=a.startModules) + t.start() sys.exit(app.exec()) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index 57c1f70..acf3f66 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -190,46 +190,59 @@ def quit(self): # Main processing function @pyqtSlot() def processFrame(self): - if(not self.proc.is_alive()): + if(not self.proc.is_alive() and not self.stopEvent.is_set()): # Start camera process self.proc.start() # Retrieve camera settings try: cameraSettings = self.pipeDM.recv() + except: + errorMsg = 'Camera process failed to start.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + self.stopEvent.set() + try: self.cameraReady(imageSettings=cameraSettings) except: - _logger.exception('Camera failed to start..') - self.errorSignal.emit('Camera failed to start..') - - try: - self.frameEvent.set() - self.frame = self.pipeDM.recv() - self.frameEvent.clear() - except Exception as e: - _logger.critical('Error in camera process') - _logger.critical(e) - try: - if(len(self.frame)==1): - if(self.frame==-1): - self.errorSignal.emit('Failed to get signal') - elif(self.__enableDetection is True): - if(self.__endstopDetectionActive is True): - if(self.__endstopAutomatedDetectionActive is False): - self.analyzeEndstopFrame() - else: - self.burstEndstopDetection() - elif(self.__nozzleDetectionActive is True): - if(self.__nozzleAutoDetectionActive is False): - self.analyzeNozzleFrame() - else: - self.burstNozzleDetection() - else: - pass - self.receivedFrame(self.frame) - except Exception as e: - _logger.critical('Camera failed to retrieve data.') - _logger.critical(e) - self.errorSignal.emit('Critical camera error. Please restart TAMV.') + errorMsg = 'Failed to read data from camera source.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + self.stopEvent.set() + elif(self.stopEvent.is_set()): + errorMsg = 'Critical error: check camera. Restart TAMV.' + _logger.exception(errorMsg) + self.errorSignal.emit(errorMsg) + else: + try: + self.frameEvent.set() + self.frame = self.pipeDM.recv() + self.frameEvent.clear() + except Exception as e: + _logger.critical('Error in camera process') + _logger.critical(e) + try: + if(len(self.frame)==1): + if(self.frame==-1): + self.errorSignal.emit('Failed to get signal') + elif(self.__enableDetection is True): + if(self.__endstopDetectionActive is True): + if(self.__endstopAutomatedDetectionActive is False): + self.analyzeEndstopFrame() + else: + self.burstEndstopDetection() + elif(self.__nozzleDetectionActive is True): + if(self.__nozzleAutoDetectionActive is False): + self.analyzeNozzleFrame() + else: + self.burstNozzleDetection() + else: + pass + self.receivedFrame(self.frame) + except Exception as e: + _logger.critical('Camera failed to retrieve data.') + _logger.critical(e) + _logger.critical('Critical camera error. Please restart TAMV.') + self.errorSignal.emit('Critical camera error. Please restart TAMV.') # convert from cv2.mat to QPixmap and return results (frame+keypoint) def receivedFrame(self, frame): diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index f393b4a..697986e 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -428,6 +428,10 @@ def calibrationSetOffset(self, params=None): errorMsg = 'setOffset: error in cpCoordinates' _logger.error(errorMsg) self.errorSignal.emit(errorMsg) + try: + __continue = params['continue'] + except: __continue = True + if(toolIndex < 0 or toolIndex is None): errorMsg = 'Invalid tool selected for offsets.' _logger.error(errorMsg) @@ -451,7 +455,7 @@ def calibrationSetOffset(self, params=None): return try: toolOffsets = self.__activePrinter.getToolOffset(toolIndex) - _logger.debug('calibrationSetOffset: Setting offsets for tool: ', toolIndex) + _logger.debug('calibrationSetOffset: Setting offsets for tool: ' + str(toolIndex)) finalOffsets = {} finalOffsets['X'] = np.around(cpCoordinates['X'] + toolOffsets['X'] - position['X'],3) finalOffsets['Y'] = np.around(cpCoordinates['Y'] + toolOffsets['Y'] - position['Y'],3) @@ -465,5 +469,5 @@ def calibrationSetOffset(self, params=None): params = {'offsets': None} self.offsetsSetSignal.emit(params) return - params = {'offsets': finalOffsets} + params = {'offsets': finalOffsets, 'continue': __continue} self.offsetsSetSignal.emit(params) \ No newline at end of file From 64064d88c714c9fe2f8b2d1118157f52afe628ed Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Fri, 21 Oct 2022 15:08:50 +0200 Subject: [PATCH 93/99] updates to #45 --- TAMV.py | 206 +++++++++++++++++++++--------------- modules/DetectionManager.py | 3 + 2 files changed, 122 insertions(+), 87 deletions(-) diff --git a/TAMV.py b/TAMV.py index 62d4ffc..d3c3a5e 100644 --- a/TAMV.py +++ b/TAMV.py @@ -64,6 +64,9 @@ class App(QMainWindow): __maxRetries = 3 # Maximum runtime (in seconds) for calibration cycles __maxRuntime = 120 + # Timeout for Qthread termination (DetectionManager and PrinterManager) + __detectionManagerThreadWaitTime = 20 + __printerManagerThreadWaitTime = 60 ########################################################################### Initialize class def __init__(self, parent=None): @@ -113,7 +116,7 @@ def __init__(self, parent=None): self.mpp = None # Nozzle detection self.__stateAutoNozzleAlignment = False - self.__stateManualNozzleAlignment = False + self.__stateOverrideManualNozzleAlignment = False self.singleToolOffsetsCapture = False self.__displayCrosshair = False @@ -691,7 +694,7 @@ def setupMainWindow(self): self.footerLayout.addWidget(self.overrideManualOffsetCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) # Manual Tool offset Capture button - self.manualToolOffsetCaptureButton = QPushButton('Capture2 offset') + self.manualToolOffsetCaptureButton = QPushButton('Capture offset') self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) self.manualToolOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) self.manualToolOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) @@ -702,15 +705,15 @@ def setupMainWindow(self): self.footerLayout.addWidget(self.manualToolOffsetCaptureButton, 0,0,1,1,Qt.AlignRight|Qt.AlignVCenter) # Start Alignment button - self.alignToolsButton = QPushButton('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleDisabled) - self.alignToolsButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) - self.alignToolsButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) - self.alignToolsButton.setToolTip('Start automated tool offset calibration') - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setVisible(False) - self.alignToolsButton.clicked.connect(self.startAlignTools) - self.footerLayout.addWidget(self.alignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) + self.startAlignToolsButton = QPushButton('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.startAlignToolsButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize) + self.startAlignToolsButton.setToolTip('Start automated tool offset calibration') + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.clicked.connect(self.startAlignTools) + self.footerLayout.addWidget(self.startAlignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter) # Resume auto alignment button self.resumeAutoToolAlignmentButton = QPushButton('Resume Auto Align') @@ -833,10 +836,10 @@ def stateDisconnected(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleDisabled) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -895,10 +898,10 @@ def stateConnected(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleDisabled) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -927,6 +930,7 @@ def stateConnected(self): item = self.jogPanel.itemAt(i) widget = item.widget() widget.deleteLater() + currentTool = 0 for tool in range(numTools+1): # add tool buttons toolButton = QPushButton('T' + str(tool)) @@ -935,7 +939,16 @@ def stateConnected(self): toolButton.clicked.connect(self.identifyToolButton) # check if current index exists in tool numbers from machine if(any(d.get('number', -1) == tool for d in self.__activePrinter['tools'])): - toolButton.setToolTip('Fetch T' + str(tool) + ' to current machine position.') + toolTip = 'Fetch T' + str(tool) + ' to current machine position.' + toolTip += '

'
+                toolTip += 'X: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][0])
+                toolTip += '
' + toolTip += 'Y: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][1]) + toolTip += '
' + toolTip += 'Z: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][2]) + toolTip += '
' + currentTool += 1 + toolButton.setToolTip(toolTip) self.toolButtons.append(toolButton) # add tool checkboxes toolCheckbox = QCheckBox() @@ -972,7 +985,7 @@ def stateConnected(self): self.__stateEndstopAutoCalibrate = False self.toggleEndstopAutoDetectionSignal.emit(False) # Nozzle calibration state flags - self.__stateManualNozzleAlignment = False + self.__stateOverrideManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False self.toggleNozzleAutoDetectionSignal.emit(False) # Crosshair display button @@ -1013,10 +1026,10 @@ def stateCPSetup(self): self.manualCPCaptureButton.setDisabled(False) self.manualCPCaptureButton.setStyleSheet(self.styleGreen) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleDisabled) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1067,10 +1080,10 @@ def stateCPAuto(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleDisabled) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1117,10 +1130,10 @@ def stateCalibrateReady(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(True) - self.alignToolsButton.setDisabled(False) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleGreen) + self.startAlignToolsButton.setVisible(True) + self.startAlignToolsButton.setDisabled(False) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleGreen) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1175,10 +1188,10 @@ def stateCalibtrateRunning(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Detecting..') - self.alignToolsButton.setStyleSheet(self.styleOrange+' * {font: italic}') + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Detecting..') + self.startAlignToolsButton.setStyleSheet(self.styleOrange+' * {font: italic}') # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(True) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1228,10 +1241,10 @@ def stateCalibrateComplete(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(True) - self.alignToolsButton.setDisabled(False) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleGreen) + self.startAlignToolsButton.setVisible(True) + self.startAlignToolsButton.setDisabled(False) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleGreen) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1286,10 +1299,10 @@ def stateExiting(self): self.manualCPCaptureButton.setDisabled(True) self.manualCPCaptureButton.setStyleSheet(self.styleDisabled) # Start Alignment button - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) - self.alignToolsButton.setText('Align Tools') - self.alignToolsButton.setStyleSheet(self.styleGreen) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) + self.startAlignToolsButton.setText('Align Tools') + self.startAlignToolsButton.setStyleSheet(self.styleGreen) # Override Manual Tool offset Capture button self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) @@ -1386,7 +1399,7 @@ def resetCalibration(self): self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False - self.__stateManualNozzleAlignment = False + self.__stateOverrideManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False self.toggleEndstopAutoDetectionSignal.emit(False) self.toggleNozzleAutoDetectionSignal.emit(False) @@ -1403,7 +1416,7 @@ def resetNozzleAlignment(self): self.__stateEndstopAutoCalibrate = False self.__stateAutoCPCapture = False self.__stateSetupCPCapture = False - self.__stateManualNozzleAlignment = False + self.__stateOverrideManualNozzleAlignment = False self.__stateAutoNozzleAlignment = False self.toggleEndstopAutoDetectionSignal.emit(False) self.toggleNozzleAutoDetectionSignal.emit(False) @@ -1443,7 +1456,8 @@ def startAlignTools(self): # send calling to log _logger.debug('*** calling App.startAlignTools') self.startTime = time.time() - self.__stateManualNozzleAlignment = True + # start as automated alignment state + self.__stateOverrideManualNozzleAlignment = False self.__stateAutoNozzleAlignment = True # Update GUI state @@ -1492,7 +1506,7 @@ def calibrateTools(self, toolset=None): # grab the first item in the list (FIFO) toolIndex = self.workingToolset[0] _logger.info('Calibrating T' + str(toolIndex) +'..') - self.alignToolsButton.setText('Detecting T'+ str(toolIndex) +'..') + self.startAlignToolsButton.setText('Detecting T'+ str(toolIndex) +'..') # delete the tool from the list before processing it self.workingToolset.pop(0) # update toolButtons GUI to indiciate which tool we're working on @@ -1502,7 +1516,7 @@ def calibrateTools(self, toolset=None): button.setStyleSheet(self.styleOrange) self.resetCalibrationVariables() # main program state flags - self.__stateManualNozzleAlignment = True + self.__stateOverrideManualNozzleAlignment = False self.__stateAutoNozzleAlignment = True # DetectionManager state flag self.toggleNozzleAutoDetectionSignal.emit(True) @@ -1532,7 +1546,7 @@ def autoCalibrate(self): if(self.uv[0] is not None and self.uv[1] is not None): # First calibration step if(self.state == 0): - _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) self.updateStatusbarMessage('Calibrating camera step 0..') if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): @@ -1564,7 +1578,7 @@ def autoCalibrate(self): elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)): # capture the UV data when a calibration move has been executed, before returning to original position if(self.state != self.lastState and self.state < len(self.calibrationCoordinates)): - _logger.info('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) + _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv)) if(self.olduv is not None): if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]): self.repeatCounter += 1 @@ -1685,7 +1699,7 @@ def autoCalibrate(self): self.offsets = -1*(0.55*self.transformMatrix.T @ self.v) self.offsets[0] = np.around(self.offsets[0],3) self.offsets[1] = np.around(self.offsets[1],3) - _logger.info('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets)) + _logger.debug('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets)) # Add rounding handling for endstop alignment if(self.__stateEndstopAutoCalibrate): if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02): @@ -1761,11 +1775,11 @@ def nozzleDetectionFailed(self): self.state = -99 # End auto calibration self.__stateAutoNozzleAlignment = False - self.__stateManualNozzleAlignment = True + self.__stateOverrideManualNozzleAlignment = True # calibrating nozzle manual self.tabPanel.setDisabled(False) - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) self.overrideManualOffsetCaptureButton.setVisible(True) self.overrideManualOffsetCaptureButton.setDisabled(False) self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleBlue) @@ -1798,8 +1812,8 @@ def resumeAutoAlignment(self): self.overrideManualOffsetCaptureButton.setVisible(False) self.overrideManualOffsetCaptureButton.setDisabled(True) self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled) - self.alignToolsButton.setVisible(True) - self.alignToolsButton.setDisabled(True) + self.startAlignToolsButton.setVisible(True) + self.startAlignToolsButton.setDisabled(True) self.updateStatusbarMessage('Resuming auto detection of current tool..') self.toggleNozzleAutoDetectionSignal.emit(True) self.pollCoordinatesSignal.emit() @@ -1876,7 +1890,7 @@ def updateStatusbarMessage(self, message): @pyqtSlot(object) def detectionManagerError(self, message): self.haltPrinterOperation(silent=True) - self.__mutex.lock() + # self.__mutex.lock() try: self.resetCalibration() self.stateExiting() @@ -1891,15 +1905,15 @@ def detectionManagerError(self, message): self.moveAbsoluteSignal.emit(self.__restorePosition) except Exception as e: print(e) - self.__mutex.unlock() + # self.__mutex.unlock() errorMsg = 'Error sending message to statusbar.' _logger.error(errorMsg) # Kill thread self.detectionThread.quit() - self.detectionThread.wait() + self.detectionThread.wait(self.__detectionManagerThreadWaitTime) if(self.__restorePosition is not None): self.printerThread.quit() - self.printerThread.wait() + self.printerThread.wait(self.__printerManagerThreadWaitTime) # display error image self.image.setPixmap(self.errorImage) @@ -1974,7 +1988,7 @@ def connectPrinter(self): if(self.printerThread.isRunning() is True): # Printer already running, delete thread and restart self.printerThread.quit() - self.printerThread.wait() + self.printerThread.wait(self.__printerManagerThreadWaitTime) self.createPrinterManagerThread(announce=False) except: self.createPrinterManagerThread(announce=False) self.announceSignal.emit(True) @@ -2000,7 +2014,7 @@ def haltPrinterOperation(self, **kwargs): self.haltCPAutoCapture() return # Cancel Nozzle Detection - if(self.__stateAutoNozzleAlignment is True or self.__stateManualNozzleAlignment is True): + if(self.__stateAutoNozzleAlignment is True or self.__stateOverrideManualNozzleAlignment is True): self.haltNozzleCapture() return # Disconnect from printer @@ -2091,7 +2105,7 @@ def printerError(self, message): # Kill printer thread try: self.printerThread.quit() - self.printerThread.wait() + self.printerThread.wait(self.__printerManagerThreadWaitTime) except: _logger.warning('Printer thread not created yet.') self.printerDisconnected(message=message) self.statusBar.setStyleSheet(self.styleRed) @@ -2102,11 +2116,11 @@ def callTool(self, toolNumber=-1): self.__displayCrosshair = False toolNumber = int(toolNumber) if(toolNumber == -1): - self.alignToolsButton.setText('Unloading..') + self.startAlignToolsButton.setText('Unloading..') self.unloadToolSignal.emit() return try: - self.alignToolsButton.setText('Loading T' +str(toolNumber) + '..') + self.startAlignToolsButton.setText('Loading T' +str(toolNumber) + '..') self.callToolSignal.emit(toolNumber) except: errorMsg = 'Unable to call tool from printer: ' + str(toolNumber) @@ -2132,20 +2146,27 @@ def registerActiveTool(self, toolIndex): else: button.setChecked(True) self.__mutex.unlock() - if(self.__stateAutoNozzleAlignment is False and self.__stateManualNozzleAlignment is False and int(toolIndex) != -1): - overrideEnabled = True - else: - overrideEnabled = False - if(self.__cpCoordinates is not None and self.__cpCoordinates['X'] is not None): - self.manualToolOffsetCaptureButton.setDisabled(not overrideEnabled) - self.manualToolOffsetCaptureButton.setVisible(overrideEnabled) - if(overrideEnabled): + if(self.__stateAutoNozzleAlignment is False and self.__stateOverrideManualNozzleAlignment is False and int(toolIndex) != -1): + self.startAlignToolsButton.setVisible(False) + if(self.__cpCoordinates is not None and self.__cpCoordinates['X'] is not None): + self.manualToolOffsetCaptureButton.setEnabled(True) + self.manualToolOffsetCaptureButton.setVisible(True) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleGreen) else: + self.manualToolOffsetCaptureButton.setEnabled(False) + self.manualToolOffsetCaptureButton.setVisible(False) self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) - if(not overrideEnabled): - self.alignToolsButton.setText('Detecting T' + str(toolIndex) +'..') - self.alignToolsButton.setVisible(not overrideEnabled) + elif(int(toolIndex) != -1): + self.manualToolOffsetCaptureButton.setEnabled(False) + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(True) + self.startAlignToolsButton.setText('Detecting T' + str(toolIndex) +'..') + else: + self.manualToolOffsetCaptureButton.setEnabled(False) + self.manualToolOffsetCaptureButton.setVisible(False) + self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled) + self.startAlignToolsButton.setVisible(True) @pyqtSlot() def printerMoveComplete(self): @@ -2168,7 +2189,7 @@ def printerMoveComplete(self): _logger.debug(statusMsg) # calibrating nozzle auto self.tabPanel.setDisabled(True) - elif(self.__stateManualNozzleAlignment is True): + elif(self.__stateOverrideManualNozzleAlignment is True): # enable detection self.toggleDetectionSignal.emit(True) self.__displayCrosshair = True @@ -2176,8 +2197,8 @@ def printerMoveComplete(self): self.updateStatusbarMessage(statusMsg) _logger.debug(statusMsg) # calibrating nozzle manual - self.alignToolsButton.setVisible(False) - self.alignToolsButton.setDisabled(True) + self.startAlignToolsButton.setVisible(False) + self.startAlignToolsButton.setDisabled(True) self.manualCPCaptureButton.setVisible(True) return elif(self.__stateManualCPCapture is True): @@ -2195,10 +2216,9 @@ def saveUVCoordinates(self, uvCoordinates): self.uv = uvCoordinates if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True): if(uvCoordinates is None): - print('No keypoints found') # failed to detect, poll coordinates again self.retries += 1 - print('Retries:', self.retries) + self.startAlignToolsButton.setText('Retrying (' + str(self.retries) + ')..') # check if we've exceeded our maxRetries or maxRuntime if(self.retries > self.__maxRetries): if(self.__stateEndstopAutoCalibrate is True): @@ -2250,7 +2270,7 @@ def saveCurrentPosition(self, coordinates): self.setOffsetsSignal.emit(params) return self.getUVCoordinatesSignal.emit() - elif(self.__stateManualNozzleAlignment is True): + elif(self.__stateOverrideManualNozzleAlignment is True): _logger.debug('saveCurrentPosition: manual nozzle set offsets for T' + str(int(self.__activePrinter['currentTool']))) # set state to detect next tool self.state = 200 @@ -2275,6 +2295,18 @@ def calibrateOffsetsApplied(self, params=None): self.resumeAutoToolAlignmentButton.setDisabled(True) self.resumeAutoToolAlignmentButton.setVisible(False) self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled) + for button in self.toolButtons: + buttonName = 'toolButton_' + str(self.__activePrinter['currentTool']) + if(button.objectName() == buttonName): + toolTip = 'Fetch T' + str(self.__activePrinter['currentTool']) + ' to current machine position.' + toolTip += '
'
+                toolTip += 'X: ' + "{:>9.3f}".format(offsets['X'])
+                toolTip += '
' + toolTip += 'Y: ' + "{:>9.3f}".format(offsets['Y']) + toolTip += '
' + toolTip += 'Z: ' + "{:>9.3f}".format(offsets['Z']) + toolTip += '
' + button.setToolTip(toolTip) if(__continue is True): toolCalibrationTime = np.around(time.time() - self.toolTime,1) successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ') -- [' + str(toolCalibrationTime) + 's].' @@ -2283,7 +2315,7 @@ def calibrateOffsetsApplied(self, params=None): self.state = 200 self.retries = 0 self.__stateAutoNozzleAlignment = True - self.__stateManualNozzleAlignment = False + self.__stateOverrideManualNozzleAlignment = False self.toggleNozzleAutoDetectionSignal.emit(True) self.calibrateTools(self.workingToolset) else: @@ -2478,11 +2510,11 @@ def closeEvent(self, event): self.repaint() try: self.detectionThread.quit() - self.detectionThread.wait() + self.detectionThread.wait(self.__detectionManagerThreadWaitTime) except: pass try: self.printerThread.quit() - self.printerThread.wait() + self.printerThread.wait(self.__printerManagerThreadWaitTime) except: pass except Exception: _logger.critical('Close event error: \n' + traceback.format_exc()) diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py index acf3f66..1ceed11 100644 --- a/modules/DetectionManager.py +++ b/modules/DetectionManager.py @@ -447,6 +447,9 @@ def analyzeNozzleFrame(self): self.frame = self.dashedLine(image=self.frame, start=(0,240), end=(320-keypointRadius, 240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) self.frame = self.dashedLine(image=self.frame, start=(320+keypointRadius,240), end=(640,240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width) + self.frame = self.dashedLine(image=self.frame, start=(320-keypointRadius,240), end=(320+keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=1, segmentWidth=1) + self.frame = self.dashedLine(image=self.frame, start=(320,240-keypointRadius), end=(320, 240+keypointRadius), color=(0,0,0), horizontal=False, lineWidth=1, segmentWidth=1) + self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA) self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA) From 7dc0531789bb29521e2140ef1cdc98420a07d2b4 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 22 Oct 2022 01:50:20 +0200 Subject: [PATCH 94/99] adding save offsets to firmware call at the end of the calibration run --- TAMV.py | 5 +++++ modules/PrinterManager.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/TAMV.py b/TAMV.py index d3c3a5e..91a2376 100644 --- a/TAMV.py +++ b/TAMV.py @@ -1528,6 +1528,9 @@ def calibrateTools(self, toolset=None): # entire list has been processed, output results calibration_time = np.around(time.time() - self.startTime,1) _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel') + # save to firmware + self.saveToFirmwareSignal.emit() + _logger.info('Offsets saved to firmware.') # reset GUI self.stateCalibrateComplete() self.repaint() @@ -1956,6 +1959,8 @@ def createPrinterManagerThread(self,announce=True): self.printerManager.offsetsSetSignal.connect(self.calibrateOffsetsApplied) self.setOffsetsSignal.connect(self.printerManager.calibrationSetOffset) + self.saveToFirmwareSignal.connect(self.printerManager.saveOffsets) + self.printerThread.start()#priority=QThread.TimeCriticalPriority) except Exception as e: print(e) diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py index 697986e..3a4bcd6 100644 --- a/modules/PrinterManager.py +++ b/modules/PrinterManager.py @@ -383,6 +383,10 @@ def getCoordinates(self): printerCoordinates = self.__activePrinter.getCoordinates() self.coordinatesSignal.emit(printerCoordinates) + @pyqtSlot() + def saveOffsets(self): + self.__activePrinter.saveOffsetsToFirmware() + # Tools @pyqtSlot() def currentTool(self): From aaf1807f7a4baf7772f7ea6954d1f7efea6f91b6 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sat, 22 Oct 2022 01:55:30 +0200 Subject: [PATCH 95/99] added file menu option to save offsets. --- TAMV.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TAMV.py b/TAMV.py index 91a2376..25717ff 100644 --- a/TAMV.py +++ b/TAMV.py @@ -442,6 +442,11 @@ def setupMenu(self): self.preferencesAction.setText('&Preferences..') self.preferencesAction.triggered.connect(self.displayPreferences) fileMenu.addAction(self.preferencesAction) + #### Save offsets to firmware + self.saveFirmwareAction = QAction(self) + self.saveFirmwareAction.setText('&Save offsets..') + self.saveFirmwareAction.triggered.connect(self.saveOffsets) + fileMenu.addAction(self.saveFirmwareAction) # Quit self.quitAction = QAction(self) self.quitAction.setText('&Quit') @@ -1529,8 +1534,7 @@ def calibrateTools(self, toolset=None): calibration_time = np.around(time.time() - self.startTime,1) _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel') # save to firmware - self.saveToFirmwareSignal.emit() - _logger.info('Offsets saved to firmware.') + self.saveOffsets() # reset GUI self.stateCalibrateComplete() self.repaint() @@ -2328,6 +2332,10 @@ def calibrateOffsetsApplied(self, params=None): self.updateStatusbarMessage(successMsg) _logger.info(successMsg) + def saveOffsets(self): + self.saveToFirmwareSignal.emit() + _logger.info('Offsets saved to firmware.') + ########################################################################### Interface with Settings Dialog def displayPreferences(self, event=None, newPrinterFlag=False): _logger.debug('*** calling App.displayPreferences') From ce7d4166f1f4515fe1668b82888f8cf34e9de326 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sun, 23 Oct 2022 02:07:51 +0200 Subject: [PATCH 96/99] adding SBC support --- drivers/DuetWebAPI.py | 128 ++++++++++++++++++++++-------------------- 1 file changed, 68 insertions(+), 60 deletions(-) diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py index b03bff5..6c3fb04 100644 --- a/drivers/DuetWebAPI.py +++ b/drivers/DuetWebAPI.py @@ -58,6 +58,8 @@ class ToolTimeoutException(Error): pass class HomingException(Error): pass +class DuetSBCHandler(Error): + pass ################################################################################################################################# ################################################################################################################################# @@ -155,11 +157,15 @@ def __init__(self, baseURL, nickname='Default', password='reprap'): _logger.debug('Starting DuetWebAPI session..') URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - + URL=(f'{self._base_url}'+'/rr_status?type=2') r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - j = json.loads(r.text) - + if(r.ok): + j = json.loads(r.text) + else: + raise DuetSBCHandler + + print('Trying rr_status') # Send reply to clear buffer replyURL = (f'{self._base_url}'+'/rr_reply') r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) @@ -177,58 +183,57 @@ def __init__(self, baseURL, nickname='Default', password='reprap'): _logger.debug('Added tool: ' + str(tempTool.getJSON())) # Check for firmware version + firmwareName = j['firmwareName'] + # fetch hardware board type from firmware name, character 24 + boardVersion = firmwareName[24] + self._firmwareVersion = j['firmwareVersion'] + # set RRF version based on results + if self._firmwareVersion[0] == "2": + # Duet running RRF v2 + self._rrf2 = True + self.pt = 2 + else: + # Duet 2 hardware running RRF v3 + self._rrf2 = False + self.pt = 2 + _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..') + return + except DuetSBCHandler as sbc: + # We're probably dealing with a Duet 3 controller, get required firmware info try: - firmwareName = j['firmwareName'] - # fetch hardware board type from firmware name, character 24 - boardVersion = firmwareName[24] - self._firmwareVersion = j['firmwareVersion'] - # set RRF version based on results - if self._firmwareVersion[0] == "2": - # Duet running RRF v2 - self._rrf2 = True - self.pt = 2 - else: - # Duet 2 hardware running RRF v3 - self._rrf2 = False - self.pt = 2 - _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..') + _logger.debug('Trying to connect to Duet 3 board..') + # Set up session using password + URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + # Get session key + r_obj = json.loads(r.text) + self._sessionKey = r_obj['sessionKey'] + self.session.headers = {'X-Session-Key': self._sessionKey } + + URL=(f'{self._base_url}'+'/machine/status') + r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) + _logger.debug('Got reply, parsing again..') + j = json.loads(r.text) + _=j + firmwareName = j['boards'][0]['firmwareName'] + firmwareVersion = j['boards'][0]['firmwareVersion'] + self.pt = 3 + + # Setup tool definitions + toolData = j['tools'] + for inputTool in toolData: + tempTool = Tool( + number = inputTool['number'], + name = inputTool['name'], + offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]}) + self._tools.append(tempTool) + + _logger.debug('Duet 3 board detected') + _logger.info(' .. connected to: '+ firmwareName + '- V'+firmwareVersion + '..') return except: - # We're probably dealing with a Duet 3 controller, get required firmware info - try: - _logger.debug('Trying to connect to Duet 3 board..') - # Set up session using password - URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) - r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - # Get session key - r_obj = json.loads(r.text) - self._sessionKey = r_obj['sessionKey'] - self.session.headers = {'X-Session-Key': self._sessionKey } - - URL=(f'{self._base_url}'+'/machine/status') - r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - _logger.debug('Got reply, parsing again..') - j = json.loads(r.text) - _=j - firmwareName = j['boards'][0]['firmwareName'] - firmwareVersion = j['boards'][0]['firmwareVersion'] - self.pt = 3 - - # Setup tool definitions - toolData = j['tools'] - for inputTool in toolData: - tempTool = Tool( - number = inputTool['number'], - name = inputTool['name'], - offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]}) - self._tools.append(tempTool) - - _logger.debug('Duet 3 board detected') - _logger.info(' .. connected to: '+ firmwareName + '- V'+firmwareVersion + '..') - return - except: - # The board is neither a Duet 2 controller using RRF v2/3 nor a Duet 3 controller board, return an error state - raise UnknownController('Unknown controller detected.') + # The board is neither a Duet 2 controller using RRF v2/3 nor a Duet 3 controller board, return an error state + raise UnknownController('Unknown controller detected.') except UnknownController as uc: errorMsg = 'Unknown controller at " + self._base_url + " - does not appear to be an RRF2 or RRF3 printer' _logger.error(errorMsg) @@ -237,11 +242,11 @@ def __init__(self, baseURL, nickname='Default', password='reprap'): errorMsg = 'Connect operation: Connection timed out.' _logger.critical(errorMsg) raise Exception(errorMsg) - except HTTPException as ht: - _logger.error('DuetWebAPIT init: Connection error.') + # except HTTPException as ht: + # _logger.error('DuetWebAPIT init: Connection error.') except Exception as e: # Catastrophic error. Bail. - _logger.critical('DuetWebAPI Init: ' + str(e)) + _logger.critical('DuetWebAPI2 Init: ' + str(e)) raise Exception('DuetWebAPI Init: ' + str(e)) ################################################################################################################################# @@ -702,10 +707,8 @@ def isHomed(self): # Send reply to clear buffer replyURL = (f'{self._base_url}'+'/rr_reply') r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) - elif (self.pt == 3): # Duet RRF v3 - # Set up session using password URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password) r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) @@ -717,8 +720,13 @@ def isHomed(self): URL=(f'{self._base_url}'+'/machine/status') r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) j = json.loads(r.text) - if 'result' in j: j = j['result'] - _logger.debug('Number of tools: ' + str(len(j['tools']))) + axesList=j['move']['axes'] + for axis in axesList[0:3]: + if(axis['homed'] is False): + machineHomed = False + else: + pass + self._homed = machineHomed return(self._homed) except ConnectTimeoutError: @@ -1075,7 +1083,7 @@ def gCode(self,command): self.session.headers = {'X-Session-Key': self._sessionKey } URL=(f'{self._base_url}'+'/machine/code/') - r = self.requests.post(URL, data=command) + r = self.session.post(URL, data=command) if (r.ok): return 0 else: From 218063b54833aee7b3f98763f8001fbee931322c Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Sun, 23 Oct 2022 02:08:08 +0200 Subject: [PATCH 97/99] Update DuetWebAPI.py --- drivers/DuetWebAPI.py | 1 - 1 file changed, 1 deletion(-) diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py index 6c3fb04..ad1fc9a 100644 --- a/drivers/DuetWebAPI.py +++ b/drivers/DuetWebAPI.py @@ -165,7 +165,6 @@ def __init__(self, baseURL, nickname='Default', password='reprap'): else: raise DuetSBCHandler - print('Trying rr_status') # Send reply to clear buffer replyURL = (f'{self._base_url}'+'/rr_reply') r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) From 2ab583b96c6f005e1f9f00c961d383cda9709fc6 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:11:41 +0100 Subject: [PATCH 98/99] Update DuetWebAPI.py --- drivers/DuetWebAPI.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py index ad1fc9a..8d72431 100644 --- a/drivers/DuetWebAPI.py +++ b/drivers/DuetWebAPI.py @@ -1161,27 +1161,6 @@ def getFilenamed(self,filename): r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' ')) - - def checkDuet2RRF3(self): - if (self.pt == 2): - # Start a connection - _logger.debug('Starting DuetWebAPI session..') - URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password) - r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - - URL=(f'{self._base_url}'+'/rr_status?type=2') - r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout)) - j = json.loads(r.text) - s=j['firmwareVersion'] - - # Send reply to clear buffer - replyURL = (f'{self._base_url}'+'/rr_reply') - r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout)) - - if s == "3.2": - return True - else: - return False ################################################################################################################################# # The following methods provide services built on the ZTATP Core atomimc class functions. From bc2f12f8f1d615def734017c2e602d9d294867a8 Mon Sep 17 00:00:00 2001 From: HaythamB <49787517+HaythamB@users.noreply.github.com> Date: Wed, 16 Nov 2022 16:28:36 +0100 Subject: [PATCH 99/99] Update TAMV.py --- TAMV.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TAMV.py b/TAMV.py index 25717ff..6fae1b6 100644 --- a/TAMV.py +++ b/TAMV.py @@ -405,12 +405,12 @@ def __init__(self, parent=None): if(self.__activePrinter['controller'] == 'RRF'): (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address']) self.__activePrinter['address'] = self.printerURL - if _errCode > 0: - # invalid input - _logger.error('Invalid printer URL detected in settings.json') - _logger.error(_errMsg) - _logger.info('Defaulting to \"http://localhost\"...') - self.printerURL = 'http://localhost' + if _errCode > 0: + # invalid input + _logger.error('Invalid printer URL detected in settings.json') + _logger.error(_errMsg) + _logger.info('Defaulting to \"http://localhost\"...') + self.printerURL = 'http://localhost' ##### Settings Dialog self.__settingsGeometry = None