Skip to content

Commit cac306a

Browse files
committed
Adding Joystick for ESP32 STage Manager, Updating ESPSerialCamera
1 parent c30a5c7 commit cac306a

File tree

9 files changed

+344
-8
lines changed

9 files changed

+344
-8
lines changed

imswitch/imcommon/view/guitools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .BetterPushButton import BetterPushButton
2+
from .joystick import Joystick
23
from .BetterSlider import BetterSlider
34
from .CheckableComboBox import CheckableComboBox
45
from .FloatSlider import FloatSlider
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import sys
2+
import math
3+
4+
from PyQt5.QtWidgets import QApplication, QWidget
5+
from PyQt5.QtWidgets import QLabel
6+
from PyQt5.QtGui import QPainter, QBrush, QPen
7+
from PyQt5.QtCore import Qt
8+
from qtpy import QtCore
9+
10+
class Joystick(QWidget):
11+
floatValueChanged = QtCore.Signal(float)
12+
''' based on https://github.com/bsiyoung/PyQt5-Joystick/ '''
13+
14+
def __init__(self, window_min_size = [200, 200], callbackFct=None):
15+
super().__init__()
16+
17+
self.window_title = 'Joystick'
18+
self.window_min_size = window_min_size
19+
self.wnd_fit_size = 400
20+
self.window_size = [self.wnd_fit_size, self.wnd_fit_size]
21+
22+
self.circle_margin_ratio = 0.1
23+
self.circle_diameter = int(self.window_size[0] * (1 - self.circle_margin_ratio * 2))
24+
25+
self.stick_diameter_ratio = 0.1
26+
self.stick_diameter = int(self.circle_diameter * self.stick_diameter_ratio)
27+
self.is_mouse_down = False
28+
self.stick_pos = [0, 0]
29+
self.strength = 0
30+
31+
self.stat_label_margin = 10
32+
self.stat_label = QLabel(self)
33+
34+
self.callbackFct = callbackFct
35+
36+
self.init_ui()
37+
38+
def init_ui(self):
39+
self.setWindowTitle(self.window_title)
40+
41+
self.setMinimumSize(self.window_min_size[0], self.window_min_size[1])
42+
self.resize(self.window_size[0], self.window_size[1])
43+
44+
self.stat_label.setAlignment(Qt.AlignLeft)
45+
self.stat_label.setGeometry(self.stat_label_margin, self.stat_label_margin,
46+
self.window_min_size[0] - self.stat_label_margin * 2,
47+
self.window_min_size[0] - self.stat_label_margin * 2)
48+
font = self.stat_label.font()
49+
font.setPointSize(10)
50+
51+
self.setMouseTracking(True)
52+
53+
self.show()
54+
55+
def resizeEvent(self, event):
56+
self.wnd_fit_size = min(self.width(), self.height())
57+
58+
self.circle_diameter = int(self.wnd_fit_size * (1 - self.circle_margin_ratio * 2))
59+
self.stick_diameter = int(self.circle_diameter * self.stick_diameter_ratio)
60+
61+
def _draw_outer_circle(self, painter):
62+
painter.setPen(QPen(Qt.black, 2, Qt.SolidLine))
63+
64+
circle_margin = int(self.wnd_fit_size * self.circle_margin_ratio)
65+
painter.drawEllipse(circle_margin, circle_margin,
66+
self.circle_diameter, self.circle_diameter)
67+
68+
def _draw_sub_lines(self, painter):
69+
painter.setRenderHint(QPainter.Antialiasing)
70+
painter.setPen(QPen(Qt.lightGray, 1, Qt.DashLine))
71+
72+
num_sub_line = 6
73+
for i in range(num_sub_line):
74+
theta = math.pi / 2 - math.pi * i / num_sub_line
75+
x0 = int(self.wnd_fit_size / 2 - self.circle_diameter / 2 * math.cos(theta))
76+
y0 = int(self.wnd_fit_size / 2 - self.circle_diameter / 2 * math.sin(theta))
77+
x1 = int(self.wnd_fit_size / 2 - self.circle_diameter / 2 * math.cos(theta + math.pi))
78+
y1 = int(self.wnd_fit_size / 2 - self.circle_diameter / 2 * math.sin(theta + math.pi))
79+
painter.drawLine(x0, y0, x1, y1)
80+
81+
def _draw_sub_circles(self, painter):
82+
painter.setPen(QPen(Qt.lightGray, 1, Qt.DashLine))
83+
84+
num_sub_circle = 4
85+
for i in range(num_sub_circle):
86+
sub_radius = int(self.circle_diameter / 2 * (i + 1) / (num_sub_circle + 1))
87+
sub_margin = int(self.wnd_fit_size / 2 - sub_radius)
88+
painter.drawEllipse(sub_margin, sub_margin, sub_radius * 2, sub_radius * 2)
89+
90+
# Draw Inner(Joystick) Circle
91+
painter.setBrush(QBrush(Qt.black, Qt.SolidPattern))
92+
stick_margin = [int(self.wnd_fit_size / 2 + self.stick_pos[0] - self.stick_diameter / 2),
93+
int(self.wnd_fit_size / 2 - self.stick_pos[1] - self.stick_diameter / 2)]
94+
painter.drawEllipse(stick_margin[0], stick_margin[1], self.stick_diameter, self.stick_diameter)
95+
96+
def paintEvent(self, event):
97+
painter = QPainter(self)
98+
99+
# Draw Outer(Main) Circle
100+
self._draw_outer_circle(painter)
101+
102+
# Draw Sub Lines
103+
self._draw_sub_lines(painter)
104+
105+
# Draw Sub Circles
106+
self._draw_sub_circles(painter)
107+
108+
# Change Status Label Text (Angle In Degree)
109+
strength = self.get_strength()
110+
angle = self.get_angle(in_deg=True)
111+
if angle < 0:
112+
angle += 360
113+
#self.stat_label.setText('Strength : {:.2f} \nDirection : {:.2f}°'.format(strength, angle))
114+
115+
def mouseMoveEvent(self, event):
116+
# Move Stick Only When Mouse Left Button Pressed
117+
if self.is_mouse_down is False:
118+
return
119+
120+
# Window Coordinate To Cartesian Coordinate
121+
pos = event.pos()
122+
stick_pos_buf = [pos.x() - self.wnd_fit_size / 2, self.wnd_fit_size / 2 - pos.y()]
123+
124+
# If Cursor Is Not In Available Range, Correct It
125+
if self._get_strength(stick_pos_buf) > 1.0:
126+
theta = math.atan2(stick_pos_buf[1], stick_pos_buf[0])
127+
radius = (self.circle_diameter - self.stick_diameter) / 2
128+
stick_pos_buf[0] = radius * math.cos(theta)
129+
stick_pos_buf[1] = radius * math.sin(theta)
130+
131+
# Emit signal #TODO: Not sure if this is the right way to do it
132+
if self.callbackFct is not None:
133+
self.callbackFct(stick_pos_buf[0], stick_pos_buf[1])
134+
135+
self.stick_pos = stick_pos_buf
136+
self.repaint()
137+
138+
def mousePressEvent(self, event):
139+
if event.button() == Qt.LeftButton:
140+
self.is_mouse_down = True
141+
142+
def mouseReleaseEvent(self, event):
143+
if event.button() == Qt.LeftButton:
144+
self.is_mouse_down = False
145+
self.stick_pos = [0, 0]
146+
self.repaint()
147+
148+
# Get Strength With Argument
149+
def _get_strength(self, stick_pos):
150+
max_distance = (self.circle_diameter - self.stick_diameter) / 2
151+
distance = math.sqrt(stick_pos[0] * stick_pos[0] + stick_pos[1] * stick_pos[1])
152+
153+
return distance / max_distance
154+
155+
# Get Strength With Current Stick Position
156+
def get_strength(self):
157+
max_distance = (self.circle_diameter - self.stick_diameter) / 2
158+
distance = math.sqrt(self.stick_pos[0] * self.stick_pos[0] + self.stick_pos[1] * self.stick_pos[1])
159+
160+
return distance / max_distance
161+
162+
def get_angle(self, in_deg=False):
163+
angle = math.atan2(self.stick_pos[1], self.stick_pos[0])
164+
if in_deg is True:
165+
angle = angle * 180 / math.pi
166+
167+
return angle
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
2+
from imswitch.imcommon.framework import Signal, Thread, Worker, Mutex
3+
from imswitch.imcontrol.view import guitools
4+
from imswitch.imcommon.model import initLogger
5+
from ..basecontrollers import LiveUpdatedController
6+
7+
8+
class JoystickController(LiveUpdatedController):
9+
""" Linked to JoystickWidget."""
10+
11+
def __init__(self, *args, **kwargs):
12+
super().__init__(*args, **kwargs)
13+
14+
# scaler
15+
self.scaler = 1
16+
17+
# initialize the positioner
18+
self.positioner_name = self._master.positionersManager.getAllDeviceNames()[0]
19+
self.positioner = self._master.positionersManager[self.positioner_name]
20+
21+
self._widget.sigJoystickXY.connect(self.moveXY)
22+
self._widget.sigJoystickZA.connect(self.moveZA)
23+
24+
def moveXY(self, x, y):
25+
if abs(x)>0 or abs(y) >0:
26+
self.positioner.moveForever(speed=(0, x, y, 0), is_stop=False)
27+
else:
28+
self.stop_x()
29+
self.stop_y()
30+
return x, y
31+
32+
def moveZA(self, a, z):
33+
if abs(a)>0 or abs(z) >0:
34+
self.positioner.moveForever(speed=(a, 0, 0, z), is_stop=False)
35+
else:
36+
self.stop_a()
37+
self.stop_z()
38+
return a, z
39+
40+
41+
42+
43+
44+
45+
46+
# Copyright (C) 2020-2021 ImSwitch developers
47+
# This file is part of ImSwitch.
48+
#
49+
# ImSwitch is free software: you can redistribute it and/or modify
50+
# it under the terms of the GNU General Public License as published by
51+
# the Free Software Foundation, either version 3 of the License, or
52+
# (at your option) any later version.
53+
#
54+
# ImSwitch is distributed in the hope that it will be useful,
55+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
56+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
57+
# GNU General Public License for more details.
58+
#
59+
# You should have received a copy of the GNU General Public License
60+
# along with this program. If not, see <https://www.gnu.org/licenses/>.

imswitch/imcontrol/controller/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .EtSTEDController import EtSTEDController
99
from .FFTController import FFTController
1010
from .HoloController import HoloController
11+
from .JoystickController import JoystickController
1112
from .HistogrammController import HistogrammController
1213
from .STORMReconController import STORMReconController
1314
from .HoliSheetController import HoliSheetController

imswitch/imcontrol/model/interfaces/CameraESP32CamSerial.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import base64
77
from PIL import Image
88
import io
9+
import math
910

1011
class CameraESP32CamSerial:
1112
def __init__(self, port=None):
@@ -36,6 +37,9 @@ def __init__(self, port=None):
3637
self.newCommand = ""
3738
self.exposureTime = -1
3839
self.gain = -1
40+
41+
self.waitForNextFrame = True
42+
3943

4044
self.serialdevice = self.connect_to_usb_device()
4145

@@ -48,19 +52,34 @@ def connect_to_usb_device(self):
4852
except:
4953
pass
5054

51-
5255
ports = serial.tools.list_ports.comports()
5356
for port in ports:
5457
if port.manufacturer == self.manufacturer or port.manufacturer=="Microsoft":
5558
try:
5659
ser = serial.Serial(port.device, baudrate=2000000, timeout=1)
60+
ser.write_timeout = 1
5761
print(f"Connected to device: {port.description}")
5862
return ser
5963
except serial.SerialException:
6064
print(f"Failed to connect to device: {port.description}")
6165
print("No matching USB device found.")
6266
return None
6367

68+
def calculate_base64_length(self, width, height):
69+
"""Calculate the length of a base64 string for an image of given dimensions."""
70+
num_bytes = width * height
71+
base64_length = math.ceil((num_bytes * 4) / 3)
72+
# ensure length is multiple of 4
73+
base64_length = base64_length + (4 - base64_length % 4) % 4
74+
return base64_length
75+
76+
def initCam(self):
77+
"""Initialize the camera."""
78+
# adjust exposure time
79+
self.serialdevice.write(('t10\n').encode())
80+
while(self.serialdevice.read()):
81+
pass
82+
6483
def put_frame(self, frame):
6584
self.frame = frame
6685
return frame
@@ -126,6 +145,10 @@ def startStreamingThread(self):
126145
return
127146
nFrame = 0
128147
nTrial = 0
148+
# Calculate the length of the base64 string
149+
base64_length = self.calculate_base64_length(self.SensorWidth, self.SensorHeight)
150+
lineBreakLength = 2
151+
129152
while self.isRunning:
130153
try:
131154

@@ -141,18 +164,38 @@ def startStreamingThread(self):
141164
time.sleep(.05)
142165
# readline of camera
143166
frame_size = 320 * 240
167+
168+
# Read the base64 string from the serial port
169+
lineBreakLength = 2
170+
base64_image_string = self.serialdevice.read(base64_length+lineBreakLength)
171+
172+
# Decode the base64 string into a 1D numpy array
173+
image_bytes = base64.b64decode(base64_image_string)
174+
image_1d = np.frombuffer(image_bytes, dtype=np.uint8)
175+
176+
# Reshape the 1D array into a 2D image
177+
frame = image_1d.reshape(self.SensorHeight, self.SensorWidth)
178+
144179
frame_bytes = self.serialdevice.read(frame_size)
145180
frame_flat = np.frombuffer(frame_bytes, dtype=np.uint8)
146-
self.frame = frame_flat.reshape((240, 320))
147181

182+
# Display the image
183+
if waitForNextFrame:
184+
waitForNextFrame = False
185+
186+
else:
187+
# publish frame frame
188+
self.frame = frame_flat.reshape((240, 320))
189+
190+
'''
148191
# find 0,1,0,1... pattern to sync
149192
pattern = (0,1,0,1,0,1,0,1,0,1)
150193
window_size = len(pattern)
151194
for i in range(len(frame_flat) - window_size + 1):
152195
# Check if the elements in the current window match the pattern
153196
if np.array_equal(frame_flat[i:i+window_size], pattern):
154197
break
155-
198+
'''
156199

157200
nFrame += 1
158201

@@ -161,11 +204,15 @@ def startStreamingThread(self):
161204
#print(e) # most of the time "incorrect padding of the bytes "
162205
nFrame = 0
163206
nTrial+=1
164-
try:
165-
self.serialdevice.flushInput()
166-
self.serialdevice.flushOutput()
167-
except:
207+
208+
# Clear the serial buffer
209+
while(self.serialdevice.read()):
168210
pass
211+
# Re-initialize the camera
212+
self.initCam()
213+
waitForNextFrame = True
214+
215+
# Attempt a hard reset every 20 errors
169216
if nTrial > 10 and type(e)==serial.serialutil.SerialException:
170217
try:
171218
# close the device - similar to hard reset

imswitch/imcontrol/model/managers/positioners/ESP32StageManager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def setupPIDcontroller(self, PIDactive=1, Kp=100, Ki=10, Kd=1, target=500, PID_u
157157
return self._motor.set_pidcontroller(PIDactive=PIDactive, Kp=Kp, Ki=Ki, Kd=Kd, target=target,
158158
PID_updaterate=PID_updaterate)
159159

160-
def moveForever(self, speed=(0, 0, 0), is_stop=False):
160+
def moveForever(self, speed=(0, 0, 0, 0), is_stop=False):
161161
self._motor.move_forever(speed=speed, is_stop=is_stop)
162162

163163
def setEnabled(self, is_enabled):

imswitch/imcontrol/view/ImConMainView.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(self, options, viewSetupInfo, *args, **kwargs):
9595
'ULenses': _DockInfo(name='uLenses Tool', yPosition=3),
9696
'FFT': _DockInfo(name='FFT Tool', yPosition=3),
9797
'Holo': _DockInfo(name='Holo Tool', yPosition=3),
98+
'Joystick': _DockInfo(name='Joystick Tool', yPosition=3),
9899
'Histogramm': _DockInfo(name='Histogramm Tool', yPosition=3),
99100
'STORMRecon': _DockInfo(name='STORM Recon Tool', yPosition=2),
100101
'HoliSheet': _DockInfo(name='HoliSheet Tool', yPosition=3),

0 commit comments

Comments
 (0)