├── LICENSE ├── .gitignore ├── screenshots ├── gui_launcher.png ├── gui_screen_gtk_1.png ├── gui_screen_gtk_2.png ├── gui_screen_gtk_3.png ├── gui_screen_gtk_4.png ├── gui_screen_gtk_5.png └── gui_screen_gtk_6.png ├── pkg ├── hu.irl.cameractrls.desktop ├── hu.irl.cameractrls.svg └── hu.irl.cameractrls.metainfo.xml ├── cameractrlsd.py ├── cameraptzspnav.py ├── README.md ├── cameraptzgame.py ├── CHANGELOG.md ├── cameraptzmidi.py ├── cameractrlsgtk4.py ├── cameractrlsgtk.py └── cameraview.py /LICENSE: -------------------------------------------------------------------------------- 1 | LGPL-3.0-or-later -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | rust 3 | -------------------------------------------------------------------------------- /screenshots/gui_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_launcher.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_1.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_2.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_3.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_4.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_5.png -------------------------------------------------------------------------------- /screenshots/gui_screen_gtk_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soyersoyer/cameractrls/HEAD/screenshots/gui_screen_gtk_6.png -------------------------------------------------------------------------------- /pkg/hu.irl.cameractrls.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Version=1.0 4 | Name=Cameractrls 5 | Comment=Camera controls for Linux 6 | Exec=cameractrlsgtk.py 7 | Icon=hu.irl.cameractrls 8 | Terminal=false 9 | Categories=Settings;AudioVideo;Video;Office;Utility;GNOME;GTK; 10 | Keywords=picture;photos;camera;webcam; 11 | -------------------------------------------------------------------------------- /cameractrlsd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, os, ctypes, ctypes.util, logging, getopt, time 4 | from collections import namedtuple 5 | from struct import unpack_from, calcsize 6 | from cameractrls import CameraCtrls, find_symlink_in, get_configfilename 7 | 8 | clib = ctypes.util.find_library('c') 9 | if clib is None: 10 | logging.error('libc not found, please install the libc package!') 11 | sys.exit(2) 12 | c = ctypes.CDLL(clib) 13 | 14 | logging.getLogger().setLevel(logging.INFO) 15 | 16 | inotify_init1 = c.inotify_init1 17 | inotify_init1.restype = ctypes.c_int 18 | inotify_init1.argtypes = [ctypes.c_int] 19 | # int inotify_init1(int flags); 20 | 21 | inotify_add_watch = c.inotify_add_watch 22 | inotify_add_watch.restype = ctypes.c_int 23 | inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32] 24 | # int inotify_add_watch(int fd, const char *pathname, uint32_t mask); 25 | 26 | inotify_rm_watch = c.inotify_rm_watch 27 | inotify_rm_watch.restypes = ctypes.c_int 28 | inotify_rm_watch.argtypes = [ctypes.c_int, ctypes.c_int] 29 | # int inotify_rm_watch(int fd, int wd); 30 | 31 | IN_CLOEXEC = os.O_CLOEXEC 32 | IN_NONBLOCK = os.O_NONBLOCK 33 | 34 | IN_ACCESS = 0x00000001 # File was accessed 35 | IN_MODIFY = 0x00000002 # File was modified 36 | IN_ATTRIB = 0x00000004 # Metadata changed 37 | IN_CLOSE_WRITE = 0x00000008 # Writable file was closed 38 | IN_CLOSE_NOWRITE = 0x00000010 # Unwritable file closed 39 | IN_OPEN = 0x00000020 # File was opened 40 | IN_MOVED_FROM = 0x00000040 # File was moved from X 41 | IN_MOVED_TO = 0x00000080 # File was moved to Y 42 | IN_CREATE = 0x00000100 # Subfile was created 43 | IN_DELETE = 0x00000200 # Subfile was deleted 44 | IN_DELETE_SELF = 0x00000400 # Self was deleted 45 | IN_MOVE_SELF = 0x00000800 # Self was moved 46 | 47 | IN_ALL_EVENTS = ( \ 48 | IN_ACCESS | IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | \ 49 | IN_CLOSE_NOWRITE | IN_OPEN | IN_MOVED_FROM | \ 50 | IN_MOVED_TO | IN_DELETE | IN_CREATE | IN_DELETE_SELF | \ 51 | IN_MOVE_SELF \ 52 | ) 53 | 54 | IN_UNMOUNT = 0x00002000 # Backing fs was unmounted 55 | IN_Q_OVERFLOW = 0x00004000 # Event queued overflowed 56 | IN_IGNORED = 0x00008000 # File was ignored 57 | 58 | IN_CLOSE = (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE) 59 | IN_MOVE = (IN_MOVED_FROM | IN_MOVED_TO) 60 | 61 | IN_ONLYDIR = 0x01000000 # only watch the path if it is a directory 62 | IN_DONT_FOLLOW = 0x02000000 # don't follow a sym link 63 | IN_EXCL_UNLINK = 0x04000000 # exclude events on unlinked objects 64 | IN_MASK_CREATE = 0x10000000 # only create watches 65 | IN_MASK_ADD = 0x20000000 # add to the mask of an already existing watch 66 | IN_ISDIR = 0x40000000 # event occurred against dir 67 | IN_ONESHOT = 0x80000000 # only send event once 68 | 69 | NAME_MAX = 255 70 | 71 | def usage(): 72 | print(f'usage: {sys.argv[0]} [--help]\n') 73 | print(f'optional arguments:') 74 | print(f' -h, --help show this help message and exit') 75 | 76 | def preset_device(device): 77 | logging.debug(f'trying to preset_device: {device}') 78 | 79 | configfile = get_configfilename(device) 80 | 81 | # if config file does not exists, we should not open the device 82 | if not os.path.exists(configfile): 83 | logging.debug(f'preset_device: {configfile} does not exists') 84 | return 85 | 86 | logging.info(f'preset_device: {device}') 87 | 88 | try: 89 | fd = os.open(device, os.O_RDWR, 0) 90 | except Exception as e: 91 | logging.warning(f'os.open({device}, os.O_RDWR, 0) failed: {e}') 92 | return 93 | 94 | errs = [] 95 | 96 | camera_ctrls = CameraCtrls(device, fd) 97 | camera_ctrls.setup_ctrls({'preset': 'load_1'}, errs) 98 | if errs: 99 | logging.warning(f'preset_device: failed to load_1: {errs}') 100 | 101 | os.close(fd) 102 | 103 | Event = namedtuple('Event', ['wd', 'mask', 'cookie', 'namesize', 'name']) 104 | EVENT_FMT = 'iIII' 105 | EVENT_SIZE = calcsize(EVENT_FMT) 106 | 107 | def parse_events(data): 108 | pos = 0 109 | events = [] 110 | while pos < len(data): 111 | wd, mask, cookie, namesize = unpack_from(EVENT_FMT, data, pos) 112 | pos += EVENT_SIZE + namesize 113 | name = data[pos - namesize : pos].split(b'\x00', 1)[0] 114 | events.append(Event(wd, mask, cookie, namesize, name.decode())) 115 | return events 116 | 117 | def main(): 118 | try: 119 | arguments, values = getopt.getopt(sys.argv[1:], 'h', ['help']) 120 | except getopt.error as err: 121 | print(err) 122 | usage() 123 | return 2 124 | 125 | for current_argument, current_value in arguments: 126 | if current_argument in ('-h', '--help'): 127 | usage() 128 | return 0 129 | 130 | dev_path = '/dev' 131 | v4l_paths = ['/dev/v4l/by-id/', '/dev/v4l/by-path/'] 132 | 133 | for v4l_path in v4l_paths: 134 | for dirpath, dirs, files in os.walk(v4l_path): 135 | for device in files: 136 | preset_device(os.path.join(v4l_path, device)) 137 | 138 | fd = inotify_init1(0) 139 | if fd == -1: 140 | logging.error(f'inotify_init1 failed') 141 | return 1 142 | 143 | wd = inotify_add_watch(fd, dev_path.encode(), IN_CREATE) 144 | if wd == -1: 145 | logging.error(f'inotify_add_watch failed {dev_path}') 146 | return 1 147 | 148 | while True: 149 | data = os.read(fd, EVENT_SIZE + NAME_MAX + 1) 150 | for e in parse_events(data): 151 | logging.debug(f'event: {e}') 152 | if e.name.startswith('video'): 153 | time.sleep(2) # waiting for udev to create dirs 154 | path = find_symlink_in(os.path.join(dev_path, e.name), v4l_paths) 155 | if path is None: 156 | logging.warning(f'can\'t find {e.name} in {v4l_paths}') 157 | continue 158 | preset_device(path.path) 159 | 160 | if __name__ == '__main__': 161 | sys.exit(main()) 162 | -------------------------------------------------------------------------------- /cameraptzspnav.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, os, ctypes, ctypes.util, logging, getopt, select, signal 4 | from cameractrls import CameraCtrls, PTZController 5 | 6 | spnavlib = ctypes.util.find_library('spnav') 7 | if spnavlib is None: 8 | logging.error('spnav not found, please install the libspnav package!') 9 | sys.exit(2) 10 | spnav = ctypes.CDLL(spnavlib) 11 | 12 | #logging.getLogger().setLevel(logging.INFO) 13 | 14 | enum = ctypes.c_uint 15 | 16 | spnav_event_type = enum 17 | ( 18 | SPNAV_EVENT_ANY, # used by spnav_remove_events() 19 | SPNAV_EVENT_MOTION, 20 | SPNAV_EVENT_BUTTON, # includes both press and release 21 | ) = range(3) 22 | 23 | class spnav_event_motion(ctypes.Structure): 24 | _fields_ = [ 25 | ('type', ctypes.c_int), 26 | ('x', ctypes.c_int), 27 | ('y', ctypes.c_int), 28 | ('z', ctypes.c_int), 29 | ('rx', ctypes.c_int), 30 | ('ry', ctypes.c_int), 31 | ('rz', ctypes.c_int), 32 | ('period', ctypes.c_uint), 33 | ('data', ctypes.POINTER(ctypes.c_int)), 34 | ] 35 | 36 | class spnav_event_button(ctypes.Structure): 37 | _fields_ = [ 38 | ('type', ctypes.c_int), 39 | ('press', ctypes.c_int), 40 | ('bnum', ctypes.c_int), 41 | ] 42 | 43 | class spnav_event(ctypes.Union): 44 | _fields_ = [ 45 | ('type', ctypes.c_int), 46 | ('motion', spnav_event_motion), 47 | ('button', spnav_event_button), 48 | ] 49 | 50 | spnav_open = spnav.spnav_open 51 | spnav_open.restype = ctypes.c_int 52 | spnav_open.argtypes = [] 53 | # int spnav_open(void); 54 | 55 | spnav_dev_name = spnav.spnav_dev_name 56 | spnav_dev_name.restype = ctypes.c_int 57 | spnav_dev_name.argtypes = [ctypes.c_char_p, ctypes.c_int] 58 | # int spnav_dev_name(char *buf, int bufsz); 59 | 60 | spnav_fd = spnav.spnav_fd 61 | spnav_fd.restype = ctypes.c_int 62 | spnav_fd.argtypes = [] 63 | # int spnav_fd(void); 64 | 65 | spnav_poll_event = spnav.spnav_poll_event 66 | spnav_poll_event.restype = ctypes.c_int 67 | spnav_poll_event.argtypes = [ctypes.POINTER(spnav_event)] 68 | # int spnav_poll_event(spnav_event *event); 69 | 70 | spnav_close = spnav.spnav_close 71 | spnav_close.restype = ctypes.c_int 72 | spnav_close.argtypes = [] 73 | # int spnav_close(void); 74 | 75 | th = 133 76 | 77 | def check_step(cb, value): 78 | if -th < value < th: 79 | return 0 80 | value = round(value/128) 81 | return cb(value, []) 82 | 83 | def check_speed(cb, value): 84 | if -th < value < th: 85 | value = 0 86 | value = round(value/128) 87 | return cb(value, []) 88 | 89 | def usage(): 90 | print(f'usage: {sys.argv[0]} [-h] [-l] [-c] [-d DEVICE]\n') 91 | print(f'optional arguments:') 92 | print(f' -h, --help show this help message and exit') 93 | print(f' -l, --list list space navigators') 94 | print(f' -c, --controller use space navigator (0..n), default first') 95 | print(f' -d DEVICE use DEVICE, default /dev/video0') 96 | print() 97 | print(f'example:') 98 | print(f' {sys.argv[0]} -d /dev/video4') 99 | 100 | def main(): 101 | try: 102 | arguments, values = getopt.getopt(sys.argv[1:], 'hlc:d:', ['help', 'list', 'controller', 'device']) 103 | except getopt.error as err: 104 | print(err) 105 | usage() 106 | sys.exit(2) 107 | 108 | list_ctrls = False 109 | device = '/dev/video0' 110 | 111 | for current_argument, current_value in arguments: 112 | if current_argument in ('-h', '--help'): 113 | usage() 114 | sys.exit(0) 115 | elif current_argument in ('-l', '--list'): 116 | list_ctrls = True 117 | elif current_argument in ('-d', '--device'): 118 | device = current_value 119 | 120 | if spnav_open() == -1: 121 | logging.error(f'spnav_open failed') 122 | sys.exit(1) 123 | 124 | if list_ctrls: 125 | # only one device is supported by the libspnav 126 | name = ctypes.create_string_buffer(64) 127 | if spnav_dev_name(name, 64) < 0: 128 | logging.warning(f'spnav_dev_name failed') 129 | else: 130 | n = name.raw.decode().strip('\0') 131 | print(f'{n}:0') 132 | sys.exit(0) 133 | 134 | try: 135 | fd = os.open(device, os.O_RDWR, 0) 136 | except Exception as e: 137 | logging.error(f'os.open({device}, os.O_RDWR, 0) failed: {e}') 138 | sys.exit(2) 139 | 140 | camera_ctrls = CameraCtrls(device, fd) 141 | if not camera_ctrls.has_ptz(): 142 | logging.error(f'camera {device} cannot do PTZ') 143 | sys.exit(1) 144 | 145 | ptz = PTZController(camera_ctrls) 146 | 147 | epoll = select.epoll() 148 | epoll.register(spnav_fd(), select.POLLIN | select.POLLERR | select.POLLNVAL) 149 | 150 | signal.signal(signal.SIGINT, lambda signum, frame: epoll.close()) 151 | event = spnav_event() 152 | while not epoll.closed: 153 | try: 154 | p = epoll.poll() 155 | except OSError: 156 | break 157 | 158 | if len(p) == 0: 159 | continue 160 | (fd , v) = p[0] 161 | if v == select.POLLERR: 162 | logging.warning(f'POLLERR') 163 | break 164 | 165 | if v == select.POLLNVAL: 166 | logging.warning(f'POLLNVAL') 167 | break 168 | 169 | if spnav_poll_event(event) == 0: 170 | logging.warning(f'spnav_poll_event failed') 171 | continue 172 | 173 | if event.type == SPNAV_EVENT_MOTION: 174 | check_step(ptz.do_zoom_step, event.motion.z) 175 | check_step(ptz.do_pan_step, event.motion.x) 176 | check_step(ptz.do_tilt_step, event.motion.y) 177 | check_speed(ptz.do_pan_speed, -event.motion.ry) 178 | check_speed(ptz.do_tilt_speed, event.motion.rx) 179 | elif event.type == SPNAV_EVENT_BUTTON: 180 | if event.button.bnum == 1 and event.button.press == 0: 181 | ptz.do_reset([]) 182 | 183 | spnav_close() 184 | 185 | if __name__ == '__main__': 186 | sys.exit(main()) 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cameractrls 2 | 3 | 4 | 5 | Camera controls for Linux 6 | 7 | It's a standalone Python CLI and GUI (GTK3, GTK4) and camera Viewer (SDL) to set the camera controls in Linux. It can set the V4L2 controls and it is extendable with the non standard controls. Currently it has a Logitech extension (LED mode, LED frequency, BRIO FoV, Relative Pan/Tilt, PTZ presets), Kiyo Pro extension (HDR, HDR mode, FoV, AF mode, Save), Dell UltraSharp WB7022 extension, AnkerWork C310 extension, Preset extension (Save and restore controls), Control Restore Daemon (to restore presets at device connection). 8 | 9 | # Installation 10 | 11 | ## From Flathub 12 | 13 | Download on Flathub 14 | 15 | ``` 16 | flatpak install flathub hu.irl.cameractrls 17 | ``` 18 | 19 | ## From Arch package repository 20 | 21 | ``` 22 | pacman -S cameractrls 23 | ``` 24 | 25 | ## From Manjaro package repository 26 | 27 | ``` 28 | pamac install cameractrls 29 | ``` 30 | 31 | ## Git Install method 32 | 33 | Install the dependencies via apt: 34 | ```shell 35 | sudo apt install git libsdl2-2.0-0 libturbojpeg 36 | ``` 37 | 38 | or via dnf: 39 | ```shell 40 | sudo dnf install git SDL2 turbojpeg 41 | ``` 42 | 43 | Clone the repo 44 | ```shell 45 | git clone https://github.com/soyersoyer/cameractrls.git 46 | cd cameractrls 47 | ``` 48 | 49 | # cameractrlsgtk 50 | GTK3 GUI for the Camera controls 51 | 52 | cameractrls launcher 53 | 54 |
55 | cameractrls crop screen 56 | cameractrls image screen 57 | cameractrls exposure screen 58 | cameractrls advanced screen 59 | cameractrls capture screen 60 | cameractrls PTZ controls 61 |
62 | 63 | ### GTK3 GUI install 64 | 65 | Add desktop file to the launcher 66 | ```shell 67 | desktop-file-install --dir="$HOME/.local/share/applications" \ 68 | --set-key=Exec --set-value="$PWD/cameractrlsgtk.py" \ 69 | --set-key=Path --set-value="$PWD" \ 70 | --set-key=Icon --set-value="$PWD/pkg/hu.irl.cameractrls.svg" \ 71 | pkg/hu.irl.cameractrls.desktop 72 | ``` 73 | 74 | Run from the launcher or from the shell 75 | ```shell 76 | ./cameractrlsgtk.py 77 | ``` 78 | 79 | ### GTK4 GUI install 80 | 81 | Add desktop file to the launcher 82 | ```shell 83 | desktop-file-install --dir="$HOME/.local/share/applications" \ 84 | --set-key=Exec --set-value="$PWD/cameractrlsgtk4.py" \ 85 | --set-key=Path --set-value="$PWD" \ 86 | --set-key=Icon --set-value="$PWD/pkg/hu.irl.cameractrls.svg" \ 87 | pkg/hu.irl.cameractrls.desktop 88 | ``` 89 | 90 | Run from the launcher or from the shell 91 | ```shell 92 | ./cameractrlsgtk4.py 93 | ``` 94 | 95 | # cameractrls.py 96 | 97 | The CLI. 98 | 99 | Run the cameractrls 100 | ```shell 101 | ./cameractrls.py 102 | ``` 103 | ``` 104 | usage: ./cameractrls.py [--help] [-d DEVICE] [--list] [-c CONTROLS] 105 | 106 | optional arguments: 107 | -h, --help show this help message and exit 108 | -d DEVICE use DEVICE, default /dev/video0 109 | -l, --list list the controls and values 110 | -L, --list-devices list capture devices 111 | -c CONTROLS set CONTROLS (eg.: hdr=on,fov=wide) 112 | 113 | example: 114 | ./cameractrls.py -c brightness=128,kiyo_pro_hdr=on,kiyo_pro_fov=wide 115 | ``` 116 | 117 | # cameractrlsd.py 118 | 119 | The control restore daemon. 120 | 121 | Add it to SystemD/Desktop portal with the GUI/CLI. 122 | 123 | # cameraview.py 124 | 125 | The camera viewer. 126 | 127 | ```shell 128 | ./cameraview.py -h 129 | ``` 130 | ``` 131 | usage: ./cameraview.py [--help] [-d DEVICE] [-s SIZE] [-r ANGLE] [-m FLIP] [-c COLORMAP] 132 | 133 | optional arguments: 134 | -h, --help show this help message and exit 135 | -d DEVICE use DEVICE, default /dev/video0 136 | -s SIZE put window inside SIZE rectangle (wxh), default unset 137 | -r ANGLE rotate the image by ANGLE, default 0 138 | -m FLIP mirror the image by FLIP, default no, (no, h, v, hv) 139 | -c COLORMAP set colormap, default none 140 | (none, grayscale, inferno, viridis, ironblack, rainbow) 141 | 142 | example: 143 | ./cameraview.py -d /dev/video2 144 | 145 | shortcuts: 146 | f: toggle fullscreen 147 | r: ANGLE +90 (shift+r -90) 148 | m: FLIP next (shift+m prev) 149 | c: COLORMAP next (shift+c prev) 150 | ``` 151 | 152 | # PTZ controls 153 | 154 | ## Keyboard 155 | 156 | Control your PTZ camera with the arrow keys/keypad keys/wasd/Home/End/PageUp/PageDown/+/-/Ctrl++/Ctrl+- of your keyboard while one of the PTZ control is in focus or in the cameraview window. 157 | 158 | Use Alt+PresetNum to select a preset for logitech_pantilt_preset. 159 | 160 | ## 3Dconnexion SpaceMouse 161 | 162 | Control your camera with your 6DoF [SpaceMouse](https://3dconnexion.com/product/spacemouse-compact/). 163 | 164 | ``` 165 | Z => zoom_absolute 166 | X => pan_absolute 167 | Y => tilt_absolute 168 | RY => pan_speed 169 | RX => tilt_speed 170 | BTN1 => PTZ reset 171 | ``` 172 | 173 | It requires spacenavd and libspnav. (optional, only if you have a SpaceMouse) 174 | 175 | ```shell 176 | sudo apt install spacenavd libspnav0 177 | sudo cp /usr/share/doc/spacenavd/examples/example-spnavrc /etc/spnavrc 178 | ``` 179 | 180 | or via dnf: 181 | ```shell 182 | sudo dnf install spacenavd libspnav 183 | sudo cp /usr/share/doc/spacenavd/example-spnavrc /etc/spnavrc 184 | ``` 185 | 186 | tip: set `led = auto` in /etc/spnavrc 187 | 188 | ## Game Controllers 189 | 190 | Control you camera with your Game Controller ([PS5 DualSense](https://www.playstation.com/accessories/dualsense-wireless-controller/)/[Xbox controller](https://www.xbox.com/accessories/controllers/xbox-wireless-controller)/etc) 191 | 192 | ``` 193 | Left Stick => pan_speed/tilt_speed or pan_absolute/tilt_absolute 194 | Right Stick => pan_absolute/tilt_absolute 195 | DPAD => pan_absolute/tilt_absolute 196 | Left/Right Trigger => zoom_absolute 197 | South/East/West/North/Left Shoulder/Right Shoulder/Back/Start => PTZ Presets 1-8 198 | Guide => PTZ Reset 199 | ``` 200 | 201 | ## MIDI Controllers 202 | 203 | Control you camera with your MIDI Controller (e.g. [MPK Mini](https://www.akaipro.com/mpk-mini-mk3.html) or [Arturias](https://www.arturia.com/products?categories=hybrid-synths) or any with configurable knobs/joys) 204 | 205 | Configure with [SysEx Controls](https://github.com/soyersoyer/sysex-controls) or [MPK3 Settings](https://github.com/tsmetana/mpk3-settings) your MIDI Controller as follows: 206 | 207 | ``` 208 | With joystick: 209 | CC78 => pan_speed/pan_absolute 210 | CC79 => tilt_speed/tilt_absolute 211 | 212 | With absolute knobs (knob values: 0-127): 213 | CC71 => pan_absolute 214 | CC72 => tilt_absolute 215 | CC73 => zoom_absolute 216 | 217 | With relative knobs (knob values: INC:1 DEC:127): 218 | CC70 => pan_speed 219 | CC74 => tilt_speed 220 | CC75 => pan_absolute 221 | CC76 => tilt_absolute 222 | CC77 => zoom_absolute 223 | 224 | CC121 => PTZ reset 225 | PGM0-7 => PTZ presets 1-8 226 | ``` 227 | 228 | # Update cameractrls 229 | 230 | ```shell 231 | git pull 232 | ``` 233 | 234 | # Update from 0.5.x -> 0.6.x 235 | 236 | Disable, stop and delete the old systemd paths, services: 237 | ```shell 238 | cd ~/.config/systemd/user 239 | systemctl --user disable --now cameractrls-* 240 | rm cameractrls-* 241 | ``` 242 | 243 | # Delete cameractrls 244 | 245 | Disable, stop and delete the systemd service: 246 | ```shell 247 | cd ~/.config/systemd/user 248 | systemctl --user disable --now cameractrlsd.service 249 | rm cameractrlsd.service 250 | ``` 251 | 252 | Remove launcher shortcut 253 | ```shell 254 | rm ~/.local/share/applications/hu.irl.cameractrls.desktop 255 | ``` 256 | 257 | Delete the cameractrls: 258 | ```shell 259 | rm -rf cameractrls 260 | ``` 261 | -------------------------------------------------------------------------------- /pkg/hu.irl.cameractrls.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 36 | 43 | 47 | 50 | 51 | 54 | 58 | 62 | 63 | 70 | 72 | 75 | 76 | 78 | 81 | 82 | 88 | 93 | 94 | 96 | 99 | 104 | 105 | 106 | 113 | 117 | 122 | 123 | 125 | 129 | 130 | 137 | 141 | 146 | 150 | 154 | 158 | 162 | 166 | 170 | 171 | 174 | 178 | 181 | 184 | 188 | 192 | 193 | 194 | 195 | 199 | 203 | 204 | -------------------------------------------------------------------------------- /cameraptzgame.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, os, ctypes, ctypes.util, logging, getopt, signal, atexit, time 4 | from cameractrls import CameraCtrls, PTZController 5 | 6 | sdl2lib = ctypes.util.find_library('SDL2-2.0') 7 | if sdl2lib is None: 8 | logging.error('libSDL2 not found, please install the libsdl2-2.0 package!') 9 | sys.exit(2) 10 | sdl2 = ctypes.CDLL(sdl2lib) 11 | 12 | #logging.getLogger().setLevel(logging.INFO) 13 | 14 | enum = ctypes.c_uint 15 | 16 | SDL_Init = sdl2.SDL_Init 17 | SDL_Init.restype = ctypes.c_int 18 | SDL_Init.argtypes = [ctypes.c_uint32] 19 | # int SDL_Init(Uint32 flags); 20 | 21 | SDL_GetError = sdl2.SDL_GetError 22 | SDL_GetError.restype = ctypes.c_char_p 23 | SDL_GetError.argtypes = [] 24 | # const char* SDL_GetError(void); 25 | 26 | SDL_NumJoysticks = sdl2.SDL_NumJoysticks 27 | SDL_NumJoysticks.restype = ctypes.c_int 28 | # int SDL_NumJoysticks(void) 29 | 30 | SDL_IsGameController = sdl2.SDL_IsGameController 31 | SDL_IsGameController.restype = ctypes.c_bool 32 | SDL_IsGameController.argtypes = [ctypes.c_int] 33 | # SDL_bool SDL_IsGameController(int joystick_index); 34 | 35 | SDL_GameControllerNameForIndex = sdl2.SDL_GameControllerNameForIndex 36 | SDL_GameControllerNameForIndex.restype = ctypes.c_char_p 37 | SDL_GameControllerNameForIndex.argtypes = [ctypes.c_int] 38 | # const char* SDL_GameControllerNameForIndex(int joystick_index); 39 | 40 | SDL_JoystickID = ctypes.c_int32 41 | 42 | SDL_GameControllerOpen = sdl2.SDL_GameControllerOpen 43 | SDL_GameControllerOpen.restype = ctypes.c_void_p 44 | SDL_GameControllerOpen.argtypes = [ctypes.c_int] 45 | # SDL_GameController* SDL_GameControllerOpen(int joystick_index); 46 | 47 | SDL_GameControllerRumble = sdl2.SDL_GameControllerRumble 48 | SDL_GameControllerRumble.restype = ctypes.c_int 49 | SDL_GameControllerRumble.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_uint16, ctypes.c_uint32] 50 | # int SDL_GameControllerRumble(SDL_GameController *gamecontroller, Uint16 low_frequency_rumble, Uint16 high_frequency_rumble, Uint32 duration_ms); 51 | 52 | SDL_GameControllerAxis = enum 53 | SDL_CONTROLLER_AXIS_LEFTX = 0 54 | SDL_CONTROLLER_AXIS_LEFTY = 1 55 | SDL_CONTROLLER_AXIS_RIGHTX = 2 56 | SDL_CONTROLLER_AXIS_RIGHTY = 3 57 | SDL_CONTROLLER_AXIS_TRIGGERLEFT = 4 58 | SDL_CONTROLLER_AXIS_TRIGGERRIGHT = 5 59 | 60 | SDL_GameControllerButton = enum 61 | SDL_CONTROLLER_BUTTON_A = 0 62 | SDL_CONTROLLER_BUTTON_B = 1 63 | SDL_CONTROLLER_BUTTON_X = 2 64 | SDL_CONTROLLER_BUTTON_Y = 3 65 | SDL_CONTROLLER_BUTTON_BACK = 4 66 | SDL_CONTROLLER_BUTTON_GUIDE = 5 67 | SDL_CONTROLLER_BUTTON_START = 6 68 | SDL_CONTROLLER_BUTTON_LEFTSTICK = 7 69 | SDL_CONTROLLER_BUTTON_RIGHTSTICK = 8 70 | SDL_CONTROLLER_BUTTON_LEFTSHOULDER = 9 71 | SDL_CONTROLLER_BUTTON_RIGHTSHOULDER = 10 72 | SDL_CONTROLLER_BUTTON_DPAD_UP = 11 73 | SDL_CONTROLLER_BUTTON_DPAD_DOWN = 12 74 | SDL_CONTROLLER_BUTTON_DPAD_LEFT = 13 75 | SDL_CONTROLLER_BUTTON_DPAD_RIGHT = 14 76 | 77 | SDL_GameControllerGetAxis = sdl2.SDL_GameControllerGetAxis 78 | SDL_GameControllerGetAxis.restype = ctypes.c_int16 79 | SDL_GameControllerGetAxis.argtypes = [ctypes.c_void_p, SDL_GameControllerAxis] 80 | # Sint16 SDL_GameControllerGetAxis(SDL_GameController *gamecontroller, SDL_GameControllerAxis axis); 81 | 82 | SDL_GameControllerGetButton = sdl2.SDL_GameControllerGetButton 83 | SDL_GameControllerGetButton.restype = ctypes.c_int8 84 | SDL_GameControllerGetButton.argtypes = [ctypes.c_void_p, SDL_GameControllerButton] 85 | # Uint8 SDL_GameControllerGetButton(SDL_GameController *gamecontroller, SDL_GameControllerButton button); 86 | 87 | SDL_GameControllerClose = sdl2.SDL_GameControllerClose 88 | SDL_GameControllerClose.argtypes = [ctypes.c_void_p] 89 | # void SDL_GameControllerClose(SDL_GameController *gamecontroller); 90 | 91 | SDL_PollEvent = sdl2.SDL_PollEvent 92 | SDL_PollEvent.restype = ctypes.c_int 93 | SDL_PollEvent.argtypes = [ctypes.c_void_p] 94 | # int SDL_PollEvent(SDL_Event * event); 95 | 96 | SDL_Quit = sdl2.SDL_Quit 97 | # void SDL_Quit(void); 98 | 99 | SDL_INIT_GAMECONTROLLER = 0x00002000 100 | SDL_INIT_EVENTS = 0x00004000 101 | 102 | SDL_QUIT = 0x100 103 | SDL_CONTROLLERDEVICEADDED = 0x653 104 | 105 | _event_pad_size = 56 if ctypes.sizeof(ctypes.c_void_p) <= 8 else 64 106 | 107 | class SDL_ControllerDeviceEvent(ctypes.Structure): 108 | _fields_ = [ 109 | ('type', ctypes.c_uint32), 110 | ('timestamp', ctypes.c_uint32), 111 | ('which', SDL_JoystickID), 112 | ] 113 | 114 | class SDL_Event(ctypes.Union): 115 | _fields_ = [ 116 | ('type', ctypes.c_uint32), 117 | ('cdevice', SDL_ControllerDeviceEvent), 118 | ('padding', (ctypes.c_uint8 * _event_pad_size)), 119 | ] 120 | 121 | SDL_QuitEvent = SDL_Event() 122 | SDL_QuitEvent.type = SDL_QUIT 123 | 124 | SDL_PushEvent = sdl2.SDL_PushEvent 125 | SDL_PushEvent.restype = ctypes.c_int 126 | SDL_PushEvent.argtypes = [ctypes.POINTER(SDL_Event)] 127 | #int SDL_PushEvent(SDL_Event * event); 128 | 129 | def usage(): 130 | print(f'usage: {sys.argv[0]} [-h] [-l] [-c] [-d DEVICE]\n') 131 | print(f'optional arguments:') 132 | print(f' -h, --help show this help message and exit') 133 | print(f' -l, --list list gamecontrollers') 134 | print(f' -c, --controller use controller id (0..n), default first') 135 | print(f' -d DEVICE use DEVICE, default /dev/video0') 136 | print() 137 | print(f'example:') 138 | print(f' {sys.argv[0]} -d /dev/video4') 139 | 140 | th = 4096 141 | 142 | def rumble_simple(controller): 143 | SDL_GameControllerRumble(controller, 0x0a00, 0x0aff, 50) 144 | 145 | def check_zoom(controller, axis, cb, scale=1, rumble=rumble_simple): 146 | value = SDL_GameControllerGetAxis(controller, axis) 147 | if value == 0: 148 | return 0 149 | if cb(round(value / 2048) * scale, []): 150 | rumble(controller) 151 | 152 | def check_axis(controller, axis, cb, scale=1, rumble=rumble_simple): 153 | value = SDL_GameControllerGetAxis(controller, axis) 154 | if -th < value < th: 155 | return cb(0, []) 156 | if cb(round(value / 8192) * scale, []): 157 | rumble(controller) 158 | 159 | def check_axis_abs(controller, axis, cb, scale=1, rumble=rumble_simple): 160 | value = SDL_GameControllerGetAxis(controller, axis) 161 | if -th < value < th: 162 | return 0 163 | if cb(round(value / 8192) * scale, []): 164 | rumble(controller) 165 | 166 | def check_button_v(controller, button, cb, scale=1, rumble=rumble_simple): 167 | value = SDL_GameControllerGetButton(controller, button) 168 | if value == 0: 169 | return 0 170 | if cb(value * scale, []): 171 | rumble(controller) 172 | 173 | def check_button(controller, button, cb, rumble=rumble_simple): 174 | value = SDL_GameControllerGetButton(controller, button) 175 | if value == 0: 176 | return 0 177 | if cb([]): 178 | rumble(controller) 179 | 180 | def main(): 181 | try: 182 | arguments, values = getopt.getopt(sys.argv[1:], 'hlc:d:', ['help', 'list', 'controller', 'device']) 183 | except getopt.error as err: 184 | print(err) 185 | usage() 186 | sys.exit(2) 187 | 188 | list_ctrls = False 189 | controller_id = None 190 | device = '/dev/video0' 191 | 192 | controller = None 193 | ptz = None 194 | 195 | controllers = [] 196 | 197 | for current_argument, current_value in arguments: 198 | if current_argument in ('-h', '--help'): 199 | usage() 200 | sys.exit(0) 201 | elif current_argument in ('-l', '--list'): 202 | list_ctrls = True 203 | elif current_argument in ('-c', '--controller'): 204 | controller_id = int(current_value.split(':')[-1]) 205 | elif current_argument in ('-d', '--device'): 206 | device = current_value 207 | 208 | if SDL_Init(SDL_INIT_GAMECONTROLLER | SDL_INIT_EVENTS) != 0: 209 | logging.error(f'SDL_Init failed: {SDL_GetError()}') 210 | sys.exit(1) 211 | 212 | signal.signal(signal.SIGINT, lambda signum, frame: SDL_PushEvent(SDL_QuitEvent)) 213 | atexit.register(SDL_Quit) 214 | 215 | for i in range(SDL_NumJoysticks()): 216 | if SDL_IsGameController(i): 217 | controllers.append((SDL_GameControllerNameForIndex(i).decode(), i)) 218 | 219 | if list_ctrls: 220 | for c in controllers: 221 | print(f'{c[0]}:{c[1]}') 222 | sys.exit(0) 223 | 224 | if controller_id is None and controllers: 225 | controller_id = controllers[0][1] 226 | 227 | if controller_id is None: 228 | logging.warning(f'controller not found, waiting to appear one..') 229 | 230 | if controller_id is not None and not SDL_IsGameController(controller_id): 231 | logging.error(f'controller with id "{controller_id}" is not a game controller') 232 | sys.exit(1) 233 | 234 | try: 235 | fd = os.open(device, os.O_RDWR, 0) 236 | except Exception as e: 237 | logging.error(f'os.open({device}, os.O_RDWR, 0) failed: {e}') 238 | sys.exit(2) 239 | 240 | camera_ctrls = CameraCtrls(device, fd) 241 | if not camera_ctrls.has_ptz(): 242 | logging.error(f'camera {device} cannot do PTZ') 243 | sys.exit(1) 244 | 245 | ptz = PTZController(camera_ctrls) 246 | 247 | running = True 248 | event = SDL_Event() 249 | while running: 250 | while SDL_PollEvent(ctypes.byref(event)): 251 | if event.type == SDL_QUIT: 252 | running = False 253 | if event.type == SDL_CONTROLLERDEVICEADDED: 254 | if controller_id is None: 255 | controller_id = event.cdevice.which 256 | if event.cdevice.which == controller_id: 257 | if controller is not None: 258 | SDL_GameControllerClose(controller) 259 | controller = None 260 | controller = SDL_GameControllerOpen(controller_id) 261 | if controller is None: 262 | logging.error(f'SDL_GameControllerOpen failed: {SDL_GetError()}') 263 | sys.exit(1) 264 | 265 | SDL_GameControllerRumble(controller, 0x00ff, 0xffff, 250) 266 | 267 | if ptz.has_pantilt_speed: 268 | check_axis(controller, SDL_CONTROLLER_AXIS_LEFTX, ptz.do_pan_speed) 269 | check_axis(controller, SDL_CONTROLLER_AXIS_LEFTY, ptz.do_tilt_speed, -1) 270 | elif ptz.has_pantilt_absolute: 271 | check_axis(controller, SDL_CONTROLLER_AXIS_LEFTX, ptz.do_pan_step) 272 | check_axis(controller, SDL_CONTROLLER_AXIS_LEFTY, ptz.do_tilt_step, -1) 273 | 274 | check_axis_abs(controller, SDL_CONTROLLER_AXIS_RIGHTX, ptz.do_pan_step) 275 | check_axis_abs(controller, SDL_CONTROLLER_AXIS_RIGHTY, ptz.do_tilt_step, -1) 276 | 277 | check_zoom(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT, ptz.do_zoom_step, -1) 278 | check_zoom(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT, ptz.do_zoom_step) 279 | 280 | check_button_v(controller, SDL_CONTROLLER_BUTTON_DPAD_LEFT, ptz.do_pan_step, -1) 281 | check_button_v(controller, SDL_CONTROLLER_BUTTON_DPAD_RIGHT, ptz.do_pan_step) 282 | check_button_v(controller, SDL_CONTROLLER_BUTTON_DPAD_UP, ptz.do_tilt_step) 283 | check_button_v(controller, SDL_CONTROLLER_BUTTON_DPAD_DOWN, ptz.do_tilt_step, -1) 284 | 285 | check_button(controller, SDL_CONTROLLER_BUTTON_GUIDE, ptz.do_reset) 286 | 287 | check_button_v(controller, SDL_CONTROLLER_BUTTON_A, ptz.do_preset, 1) 288 | check_button_v(controller, SDL_CONTROLLER_BUTTON_B, ptz.do_preset, 2) 289 | check_button_v(controller, SDL_CONTROLLER_BUTTON_X, ptz.do_preset, 3) 290 | check_button_v(controller, SDL_CONTROLLER_BUTTON_Y, ptz.do_preset, 4) 291 | check_button_v(controller, SDL_CONTROLLER_BUTTON_LEFTSHOULDER, ptz.do_preset, 5) 292 | check_button_v(controller, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, ptz.do_preset, 6) 293 | check_button_v(controller, SDL_CONTROLLER_BUTTON_BACK, ptz.do_preset, 7) 294 | check_button_v(controller, SDL_CONTROLLER_BUTTON_START, ptz.do_preset, 8) 295 | 296 | time.sleep(0.050) 297 | 298 | SDL_GameControllerClose(controller) 299 | 300 | if __name__ == '__main__': 301 | sys.exit(main()) 302 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.6.10] - 2025-12-11 6 | 7 | ### Fixed 8 | - Fixed GTK related issues regarding the AnkerWorks C310 webcam. 9 | - Fixed specific buttons not working for AnkerWorks C310 webcam. 10 | - As of today, this camera is nearly feature complete with the Windows version. The only sections that still need some work are HDR support (that is implemented in code following the Windows app, but it doesn't work on Linux for some reason) and exposure compensation on a specific area (the Windows app allows you to set a square to always keep exposed). The packet stream is documented in the source, in case someone wants to complete the work. 11 | 12 | ## [0.6.9] - 2025-11-14 13 | 14 | ### Fixed 15 | - Fixed a GTK3 GSignal warning 16 | - Fixed a bug where the actual buffer size was not used 17 | - Fixed a bug where auto white balance was reporting errors in the GUI 18 | 19 | ## [0.6.8] - 2025-10-22 20 | 21 | ### Added 22 | - AnkerWork C310 extension (thx RodoMa92) 23 | - Workaround for VIDIOC_QUERYCTRL -EIO return value (thx lbrooney) 24 | 25 | ### Fixed 26 | - GTK4 scale layout 27 | - GTK4 GtkGrid instead of GtkFlowBox 28 | - GTK4 GUI is no longer experimental 29 | 30 | ## [0.6.7] - 2024-09-02 31 | 32 | ### Added 33 | - Dell UltraSharp WB7022 Extension (thx Sensape) 34 | - Add SpinButtons next to Scales 35 | - Allow PageUp/PageDown buttons to work with scales and spin buttons 36 | - Use Alt+n shortcuts to switch between pages 37 | 38 | ### Removed 39 | - Remove Alt+n shortcuts for Logitech PTZ presets 40 | 41 | ## [0.6.6] - 2024-06-11 42 | 43 | ### Fixed 44 | 45 | - Fixes typo, which prevents from saving any config (thx morbno) 46 | 47 | ## [0.6.5] - 2024-06-04 48 | 49 | ### Added 50 | - Gray out the readonly controls (thx Daniel Schaefer @ Framework) 51 | 52 | ### Fixed 53 | - Fix GKT4 scale layout for version 4.14.2+ 54 | 55 | ## [0.6.4] - 2024-05-28 56 | 57 | ### Fixed 58 | - Fix config location when XDG_CONFIG_DIR is set (thx Daniel Fox) 59 | 60 | ## [0.6.3] - 2024-04-11 61 | 62 | ### Added 63 | - Added scrollbar to the control panel 64 | 65 | ### Fixed 66 | - Cameractrlsd can start even if there is no /dev/v4l directory 67 | - Preset save writes the config file based on v4l ID 68 | 69 | ## [0.6.2] - 2024-04-07 70 | 71 | ### Added 72 | - Add tooltips to preset controls 73 | 74 | ### Changed 75 | - Sort resolutions in descendng order 76 | - Use only 1 screenshot in metainfo to make the Flatpak image less bloated 77 | 78 | ## [0.6.1] - 2024-04-03 79 | 80 | ### Added 81 | - Set dark mode based on color-scheme 82 | - Enable DesktopPortal launcer for snap as well 83 | 84 | ### Changed 85 | - Check if systemctl enable is successfull 86 | - Set cameractrlsd loglevel to INFO 87 | 88 | ## [0.6.0] - 2024-04-01 89 | 90 | ### Added 91 | - Control Presets 92 | - Cameractrlsd, a deamon which restores the controls at device connection 93 | - Starter for Cameractrlsd with SystemD and DesktopPortal 94 | - MX Brio FoV support (thx wanderboessenkool) 95 | - PTZ control keys in cameraview 96 | 97 | ### Changed 98 | - use GPLv3 License 99 | 100 | ### Fixed 101 | - Various camera opening fixes 102 | - Better relative MIDI control support 103 | - Added Zoom Continuous to zeroers to work 104 | 105 | ### Removed 106 | - removed cameractrlstk 107 | 108 | ## [0.5.15] - 2024-02-17 109 | 110 | ### Fixed 111 | - Do not use PEP-701 as older pythons do not support it 112 | 113 | ## [0.5.14] - 2024-02-17 114 | 115 | ### Added 116 | - 3DConnexion SpaceMouse support to control PTZ cameras 117 | - Game Controllers (PS DualSense/Xbox/etc) support to control PTZ cameras 118 | - MIDI Controllers (MPK Mini or any configurable) support to control PTZ cameras 119 | - Use Page Up/Page Down for Zoom also (PTZ) 120 | - Keyboard controls for Absolute PTZ 121 | - Alt+PresetNum shortcuts for PTZ presets 122 | - Tooltips for headerbar icons 123 | 124 | ### Changed 125 | - Replaced Hamburger menu with About icon 126 | 127 | ### Fixed 128 | - Fix Ctrl detection in PTZ controls 129 | - Eliminating GLib warnings on app closing 130 | 131 | ## [0.5.13] - 2023-12-22 132 | 133 | ### Added 134 | - Logitech PTZ presets 135 | - Better keyboard control for PTZ 136 | - Bigger steps for Zoom with Ctrl+Dir, Page Up/Page Down 137 | 138 | ## [0.5.12] - 2023-12-08 139 | 140 | ### Added 141 | - Brio 505 FoV support (thx squiddity) 142 | 143 | ### Fixed 144 | - fixed 'list more' button size in the GTK4 app 145 | - fixed release events of scale widget in the GTK4 app 146 | 147 | ## [0.5.11] - 2023-10-13 148 | 149 | ### Fixed 150 | - Handle invalid menu control value 151 | 152 | ## [0.5.10] - 2023-08-03 153 | 154 | ### Added 155 | - Listen for pixelformat, resolution, FPS changes from other processes 156 | - Show warnings about invalid FPS values 157 | 158 | ### Changed 159 | - Preview calls S_PARM to make uninitialized cameras work 160 | 161 | ## [0.5.9] - 2023-07-07 162 | 163 | ### Added 164 | - V4L2_CID_HDR_SENSOR_MODE, V4L2_CID_IMAGE_[SOURCE|PROC]_CLASS descriptions 165 | 166 | ### Fixed 167 | - Shortcuts in cameraview 168 | - Float FPS handling 169 | 170 | ### Changed 171 | - Adjust the window size based on rotation 172 | - cameraview calls VIDIOC_S_FMT only for Kiyo Pro (it doesn't work without) 173 | 174 | ## [0.5.8] - 2023-06-26 175 | 176 | ### Added 177 | - Colormaps for all pixel formats (some thermal cameras use YUYV for thermal imaging) 178 | 179 | ## [0.5.7] - 2023-06-24 180 | 181 | ### Fixed 182 | - Fixed rotation in preview 183 | - Clamp percent control values for fewer warnings when using presets 184 | 185 | ### Changed 186 | - Use the GTK bundled view-more icon instead of camera-switch 187 | 188 | ## [0.5.6] - 2023-06-15 189 | 190 | ### Fixed 191 | - Fixed ctrl+q quit by closing the camera before 192 | 193 | ## [0.5.5] - 2023-06-10 194 | 195 | ### Added 196 | - Color presets 197 | - Listen for controls changes from other processes 198 | - 'default' or percent values can also be set 199 | - Improved error reporting 200 | - Exposure time now in µs in the GTK GUIs 201 | - Exposure time, Gain scales has dark-to-light background 202 | 203 | ### Changed 204 | - Removed header buttons border 205 | 206 | ## [0.5.4] - 2023-05-21 207 | 208 | ### Changed 209 | - Limit the initial size of the preview window to control window so it can be placed next to each other 210 | 211 | ## [0.5.3] - 2023-05-21 212 | 213 | ### Added 214 | - Display warnings on the GUIs as well 215 | 216 | ### Fixed 217 | - Fixed device listbox margin in GTK app 218 | 219 | ## [0.5.2] - 2023-05-20 220 | 221 | ### Added 222 | - Two more colormaps 223 | - Capture - Info category with camera informations 224 | 225 | ### Changed 226 | - Show devices by name, not by the long v4l path 227 | - Move device combobox to headerbar 228 | - Add refresh button to headerbar 229 | - Limit the size of the preview to fit next to the window 230 | - Redesigned Zero camera page with snap instructions 231 | 232 | ## [0.5.1] - 2023-05-17 233 | 234 | ### Added 235 | - New Icon (thx Jorge Toledo eldelacajita) 236 | - Rotate, mirror the preview image 237 | - Colormaps (inferno, ironblack) for Thermal/ToF camera GREY previews 238 | - RGB565 format support 239 | 240 | ### Changed 241 | - Use edit-undo-symbolic icon instead of ⟳ in default buttons 242 | - Various GTK/GTK4 fixes 243 | - Breaking: pkg/icon.png -> pkg/hu.irl.cameractrls.svg 244 | 245 | ## [0.5.0] - 2023-04-29 246 | 247 | ### Added 248 | - Brio 501 FoV support (thx Monkatraz) 249 | - Colorized White Balance scale 250 | - GTK4 GUI (experimental) 251 | 252 | ### Changed 253 | - Simpler looking scales 254 | - Icon now comes from the Window Manager 255 | - Breaking: The desktop filename have to be hu.irl.cameractrls.desktop 256 | - Breaking: The desktop file moved to pkg dir 257 | - Breaking: The icon should be installed also 258 | 259 | ## [0.4.14] - 2023-03-05 260 | 261 | ### Added 262 | - Brio 4K Stream Edition FoV support (thx chrishoage) 263 | 264 | ## [0.4.13] - 2023-03-02 265 | 266 | ### Added 267 | - Brio 500 FoV support (thx crabmanX) 268 | 269 | ## [0.4.12] - 2022-12-04 270 | 271 | ### Changed 272 | - Improved error handling and logging 273 | - The icon has been given some bloom to make it visible even on a dark background (thx nekohayo for the suggestion) 274 | 275 | ### Fixed 276 | - Fixed Dynex 1.3MP Webcam preview and fps control (thx dln949 for testing) 277 | 278 | ## [0.4.11] - 2022-10-19 279 | 280 | ### Added 281 | - Pan/Tilt relative and reset controls for some Logitech PTZ cameras (like bcc950) 282 | - LED and focus controls for some old Logitech cameras (like QuickCam Pro 9000) 283 | - V4L2 buttons 284 | - Controls also work with keyboard 285 | - Pan/Tilt speed controls stop when the key or button released 286 | - Highlight focused controls in the TK app 287 | - Gray out the inactive controls 288 | - Quit with Primary+q 289 | - New compression page with the Codec and JPEG categories 290 | - Fullscreen with double-click in the cameraview 291 | - Support YVYU, UYVY, NV21, YV12, RGB24, BGR24, RX24 formats in the cameraview 292 | 293 | ### Changed 294 | - Limit the combobox width in the GTK app 295 | - Controls fill the width in the GTK app 296 | 297 | ## [0.4.10] - 2022-10-07 298 | 299 | ### Added 300 | - Color Balance category 301 | - Tooltips for JPEG controls 302 | - Support cameras with YU12 format 303 | - Support IR cameras with GREY format 304 | 305 | ### Changed 306 | - Advanced/Color Effects moved to Color/Effects 307 | - Basic/Crop/Privacy moved to Advanced/Privacy 308 | - Merge Compression page into Advanced page 309 | 310 | ### Fixed 311 | - Retain aspect ratio in the cameraview's fullscreen mode 312 | 313 | ## [0.4.9] - 2022-08-20 314 | 315 | ### Added 316 | - Control tooltips 317 | 318 | ### Changed 319 | - Reordered pages 320 | 321 | ## [0.4.8] - 2022-08-19 322 | 323 | ### Changed 324 | - Cameractrls, GTK: Crop, Image, Exposure pages for better navigation 325 | 326 | 327 | ## [0.4.7] - 2022-08-19 328 | 329 | ### Added 330 | - Cameractrls: add Logitech BRIO FoV control 331 | 332 | ## [0.4.6] - 2022-07-01 333 | 334 | ### Changed 335 | - Cameraview: use esc to exit 336 | - GTK, TK: close all windows at exit 337 | 338 | ## [0.4.5] - 2022-06-30 339 | 340 | ### Added 341 | - AppData for better flatpak integration 342 | 343 | ## [0.4.4] - 2022-06-29 344 | 345 | ### Fixed 346 | - SystemdSaver: Don't show systemd save, if it is not available 347 | - GTK: show the open camera button properly 348 | - GTK: suppress warnings or silent exits while changing the capture settings 349 | 350 | ## [0.4.3] - 2022-06-23 351 | 352 | ### Fixed 353 | - Fixed systemd saving when systemd user directory doesn't exist 354 | - Fixed cameraview starting, when it's not in the current directory 355 | 356 | ## [0.4.2] - 2022-06-23 357 | 358 | ### Added 359 | - Added JPEG support for the cameraview 360 | 361 | ### Fixed 362 | - Handling cameras that return zero fps 363 | 364 | ## [0.4.1] - 2022-06-23 365 | 366 | ### Added 367 | - Added MJPG support for the cameraview 368 | 369 | ## [0.4.0] - 2022-06-22 370 | 371 | ### Added 372 | - Ability to view the camera (only in YUYV or NV12 format yet) 373 | - Pixelformat, resolution, fps controls 374 | 375 | ### Changed 376 | - LogitechCtrls: removed the (not) default values 377 | - SystemdSaver: don't save the inactive controls and save the controls without default values too. 378 | - Adding gamma to Basic.Image 379 | 380 | ## [0.3.1] - 2022-06-17 381 | 382 | ### Changed 383 | - TK: better ordering for the controls 384 | - GTK, TK: load the icon from an absolute path (script relative) 385 | 386 | ## [0.3.0] - 2022-06-16 387 | 388 | ### Added 389 | - Systemd setting saver, systemd path (inotify watcher) and a systemd service for restoring the controls 390 | 391 | ### Changed 392 | - TK: move the reset button next to the label 393 | - GTK: place the settings savers in the footer 394 | - CLI: show pages and categories in the list of controls too 395 | 396 | ## [0.2.3] - 2022-06-11 397 | 398 | ### Added 399 | - Treat bool like integer V4l2 controls as bool 400 | 401 | ### Fixed 402 | - String to bool converting in the cameractrls CLI 403 | 404 | ### Changed 405 | - Added Hue to Basic.Image 406 | - Reorder Gain and Backligh Compensation in Basic.Exposure 407 | 408 | ## [0.2.2] - 2022-06-10 409 | 410 | ### Added 411 | - Button control type 412 | - Kiyo Pro save control 413 | 414 | ### Fixed 415 | - Kiyo Pro controls shouldn't always save on every change 416 | 417 | ## [0.2.1] - 2022-06-10 418 | 419 | ### Changed 420 | - New icon 421 | 422 | ## [0.2.0] - 2022-06-09 423 | 424 | ### Added 425 | - GTK GUI 426 | - Split controls to pages 427 | 428 | ## [0.1.2] - 2022-06-08 429 | 430 | ### Added 431 | - Hide the default buttons when the values are the defaults 432 | 433 | ## [0.1.1] - 2022-06-07 434 | 435 | ### Added 436 | - When the menu control is too long using Combobox instead of radiobuttons 437 | - Improved device discovery, added /dev/by-path/\*, /dev/video\* 438 | 439 | ## [0.1.0] - 2022-06-03 440 | 441 | ### Added 442 | - CLI script 443 | - V4L2 controls 444 | - Logitech LED controls 445 | - Kiyo Pro controls 446 | - GUI 447 | -------------------------------------------------------------------------------- /cameraptzmidi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, os, ctypes, ctypes.util, logging, getopt, select, asyncio, signal 4 | from cameractrls import CameraCtrls, PTZController 5 | 6 | asoundlib = ctypes.util.find_library('asound') 7 | if asoundlib is None: 8 | logging.error('libasound not found, please install the alsa-lib package!') 9 | sys.exit(2) 10 | asound = ctypes.CDLL(asoundlib) 11 | 12 | #logging.getLogger().setLevel(logging.INFO) 13 | 14 | SND_SEQ_OPEN_INPUT = 2 15 | 16 | SND_SEQ_PORT_CAP_READ = (1<<0) 17 | SND_SEQ_PORT_CAP_WRITE = (1<<1) 18 | SND_SEQ_PORT_CAP_SUBS_READ = (1<<5) 19 | SND_SEQ_PORT_CAP_SUBS_WRITE = (1<<6) 20 | 21 | SND_SEQ_PORT_TYPE_HARDWARE = (1<<16) 22 | SND_SEQ_PORT_TYPE_APPLICATION = (1<<20) 23 | 24 | SND_SEQ_EVENT_CONTROLLER = 10 25 | SND_SEQ_EVENT_PGMCHANGE = 11 26 | SND_SEQ_EVENT_PORT_UNSUBSCRIBED = 67 27 | 28 | snd_seq_p = ctypes.c_void_p 29 | 30 | snd_seq_client_type_t = ctypes.c_uint 31 | snd_seq_event_type_t = ctypes.c_ubyte 32 | snd_seq_tick_time_t = ctypes.c_uint 33 | 34 | class snd_seq_client_info(ctypes.Structure): 35 | _fields_ = [ 36 | ('client', ctypes.c_int), 37 | ('type', snd_seq_client_type_t), 38 | ('name', ctypes.c_char * 64), 39 | ('filter', ctypes.c_uint), 40 | ('multicast_filter', ctypes.c_ubyte * 8), 41 | ('num_ports', ctypes.c_int), 42 | ('event_lost', ctypes.c_int), 43 | ('card', ctypes.c_int), 44 | ('pid', ctypes.c_int), 45 | ('midi_version', ctypes.c_uint), 46 | ('group_filter', ctypes.c_uint), 47 | ('reserved', ctypes.c_char * 48), 48 | ] 49 | 50 | class snd_seq_addr(ctypes.Structure): 51 | _fields_ = [ 52 | ('client', ctypes.c_ubyte), 53 | ('port', ctypes.c_ubyte), 54 | ] 55 | 56 | class snd_seq_port_info(ctypes.Structure): 57 | _fields_ = [ 58 | ('addr', snd_seq_addr), 59 | ('name', ctypes.c_char * 64), 60 | ('capability', ctypes.c_uint), 61 | ('type', ctypes.c_uint), 62 | ('midi_channels', ctypes.c_int), 63 | ('midi_voices', ctypes.c_int), 64 | ('synth_voices', ctypes.c_int), 65 | ('read_use', ctypes.c_int), 66 | ('write_use', ctypes.c_int), 67 | ('kernel', ctypes.c_void_p), 68 | ('flags', ctypes.c_uint), 69 | ('time_queue', ctypes.c_ubyte), 70 | ('direction', ctypes.c_ubyte), 71 | ('ump_group', ctypes.c_ubyte), 72 | ('reserved', ctypes.c_char * 57), 73 | ] 74 | 75 | class snd_seq_real_time(ctypes.Structure): 76 | _fields_ = [ 77 | ('tv_sec', ctypes.c_uint), 78 | ('tv_nsec', ctypes.c_uint), 79 | ] 80 | 81 | class snd_seq_timestamp(ctypes.Union): 82 | _fields_ = [ 83 | ('tick', snd_seq_tick_time_t), 84 | ('time', snd_seq_real_time), 85 | ] 86 | 87 | class snd_seq_ev_ctrl(ctypes.Structure): 88 | _fields_ = [ 89 | ('channel', ctypes.c_ubyte), 90 | ('unused1', ctypes.c_ubyte), 91 | ('unused2', ctypes.c_ubyte), 92 | ('unused3', ctypes.c_ubyte), 93 | ('param', ctypes.c_uint), 94 | ('value', ctypes.c_int), 95 | ] 96 | 97 | class snd_seq_event_data(ctypes.Union): 98 | _fields_ = [ 99 | ('control', snd_seq_ev_ctrl), 100 | ] 101 | 102 | class snd_seq_event(ctypes.Structure): 103 | _fields_ = [ 104 | ('type', snd_seq_event_type_t), 105 | ('flags', ctypes.c_ubyte), 106 | ('tag', ctypes.c_char), 107 | ('queue', ctypes.c_ubyte), 108 | ('time', snd_seq_timestamp), 109 | ('source', snd_seq_addr), 110 | ('dest', snd_seq_addr), 111 | ('data', snd_seq_event_data), 112 | ] 113 | 114 | class pollfd(ctypes.Structure): 115 | _fields_ = [ 116 | ('fd', ctypes.c_int), 117 | ('events', ctypes.c_short), 118 | ('revents', ctypes.c_short), 119 | ] 120 | 121 | snd_seq_client_info_p = ctypes.POINTER(snd_seq_client_info) 122 | snd_seq_port_info_p = ctypes.POINTER(snd_seq_port_info) 123 | snd_seq_event_p = ctypes.POINTER(snd_seq_event) 124 | 125 | snd_seq_open = asound.snd_seq_open 126 | snd_seq_open.restype = ctypes.c_int 127 | snd_seq_open.argtypes = [ctypes.POINTER(snd_seq_p), ctypes.c_char_p, ctypes.c_int, ctypes.c_int] 128 | # int snd_seq_open(snd_seq_t **seq, const char *name, int streams, int mode); 129 | 130 | snd_seq_set_client_name = asound.snd_seq_set_client_name 131 | snd_seq_set_client_name.restype = ctypes.c_int 132 | snd_seq_set_client_name.argtypes = [snd_seq_p, ctypes.c_char_p] 133 | # int snd_seq_set_client_name(snd_seq_t *seq, const char *name); 134 | 135 | snd_seq_create_simple_port = asound.snd_seq_create_simple_port 136 | snd_seq_create_simple_port.restype = ctypes.c_int 137 | snd_seq_create_simple_port.argtypes = [snd_seq_p, ctypes.c_char_p, ctypes.c_uint, ctypes.c_uint] 138 | # int snd_seq_create_simple_port(snd_seq_t *seq, const char *name, unsigned int caps, unsigned int type); 139 | 140 | snd_seq_client_info_set_client = asound.snd_seq_client_info_set_client 141 | snd_seq_client_info_set_client.restype = None 142 | snd_seq_client_info_set_client.argtypes = [snd_seq_client_info_p, ctypes.c_int] 143 | # void snd_seq_client_info_set_client(snd_seq_client_info_t *info, int client); 144 | 145 | snd_seq_query_next_client = asound.snd_seq_query_next_client 146 | snd_seq_query_next_client.restype = ctypes.c_int 147 | snd_seq_query_next_client.argtypes = [snd_seq_p, snd_seq_client_info_p] 148 | # int snd_seq_query_next_client(snd_seq_t *handle, snd_seq_client_info_t *info); 149 | 150 | snd_seq_client_info_get_client = asound.snd_seq_client_info_get_client 151 | snd_seq_client_info_get_client.restype = ctypes.c_int 152 | snd_seq_client_info_get_client.argtypes = [snd_seq_client_info_p] 153 | # int snd_seq_client_info_get_client(const snd_seq_client_info_t *info); 154 | 155 | snd_seq_port_info_set_client = asound.snd_seq_port_info_set_client 156 | snd_seq_port_info_set_client.restype = None 157 | snd_seq_port_info_set_client.argtypes = [snd_seq_port_info_p, ctypes.c_int] 158 | # void snd_seq_port_info_set_client(snd_seq_port_info_t *info, int client); 159 | 160 | snd_seq_port_info_set_port = asound.snd_seq_port_info_set_port 161 | snd_seq_port_info_set_port.restype = None 162 | snd_seq_port_info_set_port.argtypes = [snd_seq_port_info_p, ctypes.c_int] 163 | # void snd_seq_port_info_set_port(snd_seq_port_info_t *info, int port); 164 | 165 | snd_seq_query_next_port = asound.snd_seq_query_next_port 166 | snd_seq_query_next_port.restype = ctypes.c_int 167 | snd_seq_query_next_port.argtypes = [snd_seq_p, snd_seq_port_info_p] 168 | # int snd_seq_query_next_port(snd_seq_t *handle, snd_seq_port_info_t *info); 169 | 170 | snd_seq_port_info_get_capability = asound.snd_seq_port_info_get_capability 171 | snd_seq_port_info_get_capability.restype = ctypes.c_uint 172 | snd_seq_port_info_get_capability.argtypes = [snd_seq_port_info_p] 173 | # unsigned int snd_seq_port_info_get_capability(const snd_seq_port_info_t *info); 174 | 175 | snd_seq_port_info_get_type = asound.snd_seq_port_info_get_type 176 | snd_seq_port_info_get_type.restype = ctypes.c_uint 177 | snd_seq_port_info_get_type.argtypes = [snd_seq_port_info_p] 178 | # unsigned int snd_seq_port_info_get_type(const snd_seq_port_info_t *info); 179 | 180 | snd_seq_port_info_get_port = asound.snd_seq_port_info_get_port 181 | snd_seq_port_info_get_port.restype = ctypes.c_int 182 | snd_seq_port_info_get_port.argtypes = [snd_seq_port_info_p] 183 | # int snd_seq_port_info_get_port(const snd_seq_port_info_t *info); 184 | 185 | snd_seq_port_info_get_name = asound.snd_seq_port_info_get_name 186 | snd_seq_port_info_get_name.restype = ctypes.c_char_p 187 | snd_seq_port_info_get_name.argtypes = [snd_seq_port_info_p] 188 | # const char *snd_seq_port_info_get_name(const snd_seq_port_info_t *info) 189 | 190 | snd_seq_set_client_event_filter = asound.snd_seq_set_client_event_filter 191 | snd_seq_set_client_event_filter.restype = ctypes.c_int 192 | snd_seq_set_client_event_filter.argtypes = [snd_seq_p, ctypes.c_int] 193 | # void snd_seq_set_client_event_filter(snd_seq_t *seq, int event_type); 194 | 195 | snd_seq_connect_from = asound.snd_seq_connect_from 196 | snd_seq_connect_from.restype = ctypes.c_int 197 | snd_seq_connect_from.argtypes = [snd_seq_p, ctypes.c_int, ctypes.c_int, ctypes.c_int] 198 | # int snd_seq_connect_from(snd_seq_t *seq, int my_port, int src_client, int src_port); 199 | 200 | snd_seq_poll_descriptors_count = asound.snd_seq_poll_descriptors_count 201 | snd_seq_poll_descriptors_count.restype = ctypes.c_int 202 | snd_seq_poll_descriptors_count.argtypes = [snd_seq_p, ctypes.c_short] 203 | # int snd_seq_poll_descriptors_count(snd_seq_t *handle, short events); 204 | 205 | snd_seq_poll_descriptors = asound.snd_seq_poll_descriptors 206 | snd_seq_poll_descriptors.restype = ctypes.c_int 207 | snd_seq_poll_descriptors.argtypes = [snd_seq_p, ctypes.POINTER(pollfd), ctypes.c_uint, ctypes.c_short] 208 | # int snd_seq_poll_descriptors(snd_seq_t *handle, struct pollfd *pfds, unsigned int space, short events); 209 | 210 | snd_seq_event_input_pending = asound.snd_seq_event_input_pending 211 | snd_seq_event_input_pending.restype = ctypes.c_int 212 | snd_seq_event_input_pending.argtypes = [snd_seq_p, ctypes.c_int] 213 | # int snd_seq_event_input_pending(snd_seq_t *seq, int fetch_sequencer); 214 | 215 | snd_seq_event_input = asound.snd_seq_event_input 216 | snd_seq_event_input.restype = ctypes.c_int 217 | snd_seq_event_input.argtypes = [snd_seq_p, ctypes.POINTER(snd_seq_event_p)] 218 | # int snd_seq_event_input(snd_seq_t *handle, snd_seq_event_t **ev); 219 | 220 | def get_hw_client_ports(seq): 221 | cinfo = snd_seq_client_info() 222 | pinfo = snd_seq_port_info() 223 | ret = [] 224 | 225 | snd_seq_client_info_set_client(cinfo, -1) 226 | while snd_seq_query_next_client(seq, cinfo) >= 0: 227 | client = snd_seq_client_info_get_client(cinfo) 228 | 229 | snd_seq_port_info_set_client(pinfo, client) 230 | snd_seq_port_info_set_port(pinfo, -1) 231 | while snd_seq_query_next_port(seq, pinfo) >= 0: 232 | if (snd_seq_port_info_get_capability(pinfo) 233 | & (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ)) \ 234 | != (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ): 235 | continue 236 | 237 | if not(snd_seq_port_info_get_type(pinfo) & SND_SEQ_PORT_TYPE_HARDWARE): 238 | continue 239 | 240 | port = snd_seq_port_info_get_port(pinfo) 241 | name = snd_seq_port_info_get_name(pinfo) 242 | ret.append((name.decode(), client, port)) 243 | return ret 244 | 245 | def check_cc(ev, cc, cb): 246 | if ev.data.control.param != cc or ev.data.control.value != 0: 247 | return 248 | 249 | cb([]) 250 | 251 | def check_abs_knob(ev, cc, cb): 252 | if ev.data.control.param != cc: 253 | return 254 | 255 | cb(ev.data.control.value / 127, []) 256 | 257 | def check_rel_knob(ev, cc, cb, loop=None): 258 | if ev.data.control.param != cc: 259 | return 260 | 261 | if ev.data.control.value in [127, 126, 125]: 262 | step = ev.data.control.value - 128 #-1, -2, -3 263 | elif ev.data.control.value in [1, 2, 3]: 264 | step = ev.data.control.value 265 | else: 266 | return 267 | 268 | cb(step, []) 269 | 270 | if loop is None: 271 | return 272 | 273 | # rel knobs emit 1, 2, 3 or 127, 126, 125 only, set to 0 after a while 274 | if cc in loop.ctx: 275 | loop.ctx[cc].cancel() 276 | loop.ctx[cc] = loop.call_later(0.1, cb, 0, []) 277 | 278 | def check_joy(ev, cc, cb, loop=None): 279 | if ev.data.control.param != cc: 280 | return 281 | 282 | value = round((ev.data.control.value - 64) / 16) 283 | cb(value, []) 284 | 285 | if loop is None: 286 | return 287 | 288 | # joystick only emits the changes, so it stays eg. 99 if we hold, and doesn't 289 | # generate new events, repeat the events when it is not 0 290 | if cc in loop.ctx: 291 | loop.ctx[cc].cancel() 292 | if value != 0: 293 | loop.ctx[cc] = loop.call_later(0.05, check_joy, ev, cc, cb, loop) 294 | 295 | def process_midi(seq, ptz, loop): 296 | evp = snd_seq_event_p() 297 | if snd_seq_event_input(seq, evp) < 0: 298 | return 299 | 300 | # deep copy 301 | ev = type(evp.contents)() 302 | ctypes.pointer(ev)[0] = evp.contents 303 | 304 | if ev.type == SND_SEQ_EVENT_CONTROLLER: 305 | check_abs_knob(ev, 71, ptz.do_pan_percent) 306 | check_abs_knob(ev, 72, ptz.do_tilt_percent) 307 | check_abs_knob(ev, 73, ptz.do_zoom_percent) 308 | 309 | check_rel_knob(ev, 75, ptz.do_pan_step) 310 | check_rel_knob(ev, 76, ptz.do_tilt_step) 311 | check_rel_knob(ev, 77, ptz.do_zoom_step) 312 | 313 | check_rel_knob(ev, 70, ptz.do_pan_speed, loop) 314 | check_rel_knob(ev, 74, ptz.do_tilt_speed, loop) 315 | 316 | if ptz.has_pantilt_speed: 317 | check_joy(ev, 78, ptz.do_pan_speed) 318 | check_joy(ev, 79, ptz.do_tilt_speed) 319 | elif ptz.has_pantilt_absolute: 320 | check_joy(ev, 78, ptz.do_pan_step, loop) 321 | check_joy(ev, 79, ptz.do_tilt_step, loop) 322 | 323 | check_cc(ev, 121, ptz.do_reset) 324 | 325 | elif ev.type == SND_SEQ_EVENT_PGMCHANGE: 326 | ptz.do_preset(ev.data.control.value + 1, []) 327 | 328 | elif ev.type == SND_SEQ_EVENT_PORT_UNSUBSCRIBED: 329 | logging.error(f'midi port unsubscribed') 330 | sys.exit(1) 331 | 332 | def usage(): 333 | print(f'usage: {sys.argv[0]} [-h] [-l] [-c] [-d DEVICE]\n') 334 | print(f'optional arguments:') 335 | print(f' -h, --help show this help message and exit') 336 | print(f' -l, --list list midi controllers') 337 | print(f' -c, --controller use controller (client:port), default first') 338 | print(f' -d DEVICE use DEVICE, default /dev/video0') 339 | print() 340 | print(f'example:') 341 | print(f' {sys.argv[0]} -d /dev/video4') 342 | 343 | def main(): 344 | try: 345 | arguments, values = getopt.getopt(sys.argv[1:], 'hlc:d:', ['help', 'list', 'controller', 'device']) 346 | except getopt.error as err: 347 | print(err) 348 | usage() 349 | sys.exit(2) 350 | 351 | list_ctrls = False 352 | controller_id = None 353 | device = '/dev/video0' 354 | 355 | for current_argument, current_value in arguments: 356 | if current_argument in ('-h', '--help'): 357 | usage() 358 | sys.exit(0) 359 | elif current_argument in ('-l', '--list'): 360 | list_ctrls = True 361 | elif current_argument in ('-c', '--controller'): 362 | controller_id = current_value 363 | elif current_argument in ('-d', '--device'): 364 | device = current_value 365 | 366 | seq = snd_seq_p() 367 | 368 | if snd_seq_open(seq, b"default", SND_SEQ_OPEN_INPUT, 0) < 0: 369 | logging.error("Could not open sequencer") 370 | sys.exit(2) 371 | 372 | if snd_seq_set_client_name(seq, b"Midi Listener") < 0: 373 | logging.error("Could not set client name") 374 | sys.exit(2) 375 | 376 | if snd_seq_create_simple_port(seq, b"listen:in", 377 | SND_SEQ_PORT_CAP_WRITE|SND_SEQ_PORT_CAP_SUBS_WRITE, 378 | SND_SEQ_PORT_TYPE_APPLICATION) < 0: 379 | logging.error("Could not open port") 380 | sys.exit(2) 381 | 382 | ports = get_hw_client_ports(seq) 383 | if not ports: 384 | logging.error("Couldn't find hw devices") 385 | sys.exit(2) 386 | 387 | if list_ctrls: 388 | for p in ports: 389 | print(f'{p[0]}:{p[1]}:{p[2]}') 390 | sys.exit(0) 391 | 392 | client, port = ports[0][1], ports[0][2] 393 | 394 | if controller_id is not None: 395 | spl = controller_id.split(':') 396 | if len(spl) < 2: 397 | logging.error(f'invalid controller id: {controller_id}') 398 | sys.exit(2) 399 | client, port = int(spl[-2]), int(spl[-1]) 400 | 401 | logging.info(f'using port: {client}:{port}') 402 | 403 | snd_seq_set_client_event_filter(seq, SND_SEQ_EVENT_CONTROLLER) 404 | snd_seq_set_client_event_filter(seq, SND_SEQ_EVENT_PGMCHANGE) 405 | snd_seq_set_client_event_filter(seq, SND_SEQ_EVENT_PORT_UNSUBSCRIBED) 406 | 407 | if snd_seq_connect_from(seq, 0, client, port) < 0: 408 | logging.error("Cannot connect from port") 409 | sys.exit(2) 410 | 411 | npfd = snd_seq_poll_descriptors_count(seq, select.POLLIN) 412 | pfd = (pollfd * npfd)() 413 | snd_seq_poll_descriptors(seq, pfd, npfd, select.POLLIN) 414 | 415 | loop = asyncio.new_event_loop() 416 | loop.ctx = {} 417 | 418 | loop.add_signal_handler(signal.SIGINT, loop.stop) 419 | 420 | try: 421 | fd = os.open(device, os.O_RDWR, 0) 422 | except Exception as e: 423 | logging.error(f'os.open({device}, os.O_RDWR, 0) failed: {e}') 424 | sys.exit(2) 425 | 426 | camera_ctrls = CameraCtrls(device, fd) 427 | if not camera_ctrls.has_ptz(): 428 | logging.error(f'camera {device} cannot do PTZ') 429 | sys.exit(1) 430 | 431 | ptz = PTZController(camera_ctrls) 432 | 433 | for i in range(npfd): 434 | loop.add_reader(pfd[i].fd, process_midi, seq, ptz, loop) 435 | 436 | try: 437 | loop.run_forever() 438 | finally: 439 | loop.close() 440 | 441 | if __name__ == '__main__': 442 | sys.exit(main()) 443 | -------------------------------------------------------------------------------- /pkg/hu.irl.cameractrls.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | hu.irl.cameractrls 4 | hu.irl.cameractrls.desktop 5 | Cameractrls 6 | 7 | Gergo Koteles 8 | 9 | Camera controls for Linux 10 | 11 |

12 | It's a standalone Python CLI and GUI (GTK3, GTK4) and camera Viewer (SDL) to set the camera controls in Linux. 13 | It can set the V4L2 controls and it is extendable with the non standard controls. 14 | Currently it has a Logitech extension (LED mode, LED frequency, BRIO FoV, Relative Pan/Tilt, PTZ presets), Kiyo Pro extension (HDR, HDR mode, FoV, AF mode, Save), Dell UltraSharp WB7022 extension, AnkerWork C310 extension, Preset extension (Save and restore controls), Control Restore Daemon (to restore presets at device connection). 15 |

16 |
17 | FSFAP 18 | LGPL-3.0-or-later 19 | 20 | pointing 21 | keyboard 22 | touch 23 | tablet 24 | gamepad 25 | 26 | 27 | 550 28 | 29 | 30 | https://github.com/soyersoyer/cameractrls 31 | https://github.com/soyersoyer/cameractrls/issues 32 | 33 | 34 | https://github.com/soyersoyer/cameractrls/raw/main/screenshots/gui_screen_gtk_1.png 35 | Basic page 36 | 37 | 38 | 39 | 40 | 41 |
    42 |
  • AnkerWorks C310 related fixes (thx RodoMa92)
  • 43 |
44 |
45 |
46 | 47 | 48 |
    49 |
  • Fixed a GTK3 GSignal warning
  • 50 |
  • Fixed a bug where the actual buffer size was not used
  • 51 |
  • Fixed a bug where auto white balance was reporting errors in the GUI
  • 52 |
53 |
54 |
55 | 56 | 57 |
    58 |
  • AnkerWork C310 extension (thx RodoMa92)
  • 59 |
  • Workaround for VIDIOC_QUERYCTRL -EIO return value (thx lbrooney)
  • 60 |
  • GTK4 scale layout
  • 61 |
  • GTK4 GtkGrid instead of GtkFlowBox
  • 62 |
  • GTK4 GUI is no longer experimental
  • 63 |
64 |
65 |
66 | 67 | 68 |
    69 |
  • Dell UltraSharp WB7022 Extension (thx Sensape)
  • 70 |
  • Add SpinButtons next to Scales
  • 71 |
  • Allow PageUp/PageDown buttons to work with scales and spin buttons
  • 72 |
  • Use Alt+n shortcuts to switch between pages
  • 73 |
  • Remove Alt+n shortcuts for Logitech PTZ presets
  • 74 |
75 |
76 |
77 | 78 | 79 |
    80 |
  • Fixes typo, which prevents from saving any config (thx morbno)
  • 81 |
82 |
83 |
84 | 85 | 86 |
    87 |
  • Gray out the readonly controls (thx Daniel Schaefer @ Framework)
  • 88 |
  • Fix GKT4 scale layout for version 4.14.2+
  • 89 |
90 |
91 |
92 | 93 | 94 |
    95 |
  • Fix config location when XDG_CONFIG_DIR is set (thx Daniel Fox)
  • 96 |
97 |
98 |
99 | 100 | 101 |
    102 |
  • Added scrollbar to the control panel
  • 103 |
  • Cameractrlsd can start even if there is no /dev/v4l directory
  • 104 |
  • Preset save writes the config file based on v4l ID
  • 105 |
106 |
107 |
108 | 109 | 110 |
    111 |
  • Add tooltips to preset controls
  • 112 |
  • Sort resolutions in descendng order
  • 113 |
  • Use only 1 screenshot in metainfo to make the Flatpak image less bloated
  • 114 |
115 |
116 |
117 | 118 | 119 |
    120 |
  • Set dark mode based on color-scheme
  • 121 |
  • Enable DesktopPortal launcer for snap as well
  • 122 |
  • Check if systemctl enable is successfull
  • 123 |
  • Set cameractrlsd loglevel to INFO
  • 124 |
125 |
126 |
127 | 128 | 129 |
    130 |
  • Control Presets
  • 131 |
  • Cameractrlsd, a deamon which restores the controls at device connection
  • 132 |
  • Starter for Cameractrlsd with SystemD and DesktopPortal
  • 133 |
  • MX Brio FoV support (thx wanderboessenkool)
  • 134 |
  • PTZ control keys in cameraview
  • 135 |
  • use GPLv3 License
  • 136 |
  • Various camera opening fixes
  • 137 |
  • Better relative MIDI control support
  • 138 |
  • Added Zoom Continuous to zeroers to work
  • 139 |
140 |
141 |
142 | 143 | 144 |
    145 |
  • Do not use PEP-701 as older pythons do not support it
  • 146 |
147 |
148 |
149 | 150 | 151 |
    152 |
  • 3DConnexion SpaceMouse support to control PTZ cameras
  • 153 |
  • Game Controllers (PS DualSense/Xbox/etc) support to control PTZ cameras
  • 154 |
  • MIDI Controllers (MPK Mini or any configurable) support to control PTZ cameras
  • 155 |
  • Use Page Up/Page Down for Zoom also (PTZ)
  • 156 |
  • Keyboard controls for Absolute PTZ
  • 157 |
  • Alt+PresetNum shortcuts for PTZ presets
  • 158 |
  • Tooltips for headerbar icons
  • 159 |
  • Replaced Hamburger menu with About icon
  • 160 |
  • Fix Ctrl detection in PTZ controls
  • 161 |
  • Eliminating GLib warnings on app closing
  • 162 |
163 |
164 |
165 | 166 | 167 |
    168 |
  • Logitech PTZ presets
  • 169 |
  • Better keyboard control for PTZ
  • 170 |
  • Bigger steps for Zoom with Ctrl+Dir, Page Up/Page Down
  • 171 |
172 |
173 |
174 | 175 | 176 |
    177 |
  • Brio 505 FoV support (thx squiddity)
  • 178 |
  • fixed 'list more' button size in the GTK4 app
  • 179 |
  • fixed release events of scale widget in the GTK4 app
  • 180 |
181 |
182 |
183 | 184 | 185 |
    186 |
  • Handle invalid menu control value
  • 187 |
188 |
189 |
190 | 191 | 192 |
    193 |
  • Listen for pixelformat, resolution, FPS changes from other processes
  • 194 |
  • Show warnings about invalid FPS values
  • 195 |
  • Preview calls S_PARM to make uninitialized cameras work
  • 196 |
197 |
198 |
199 | 200 | 201 |
    202 |
  • Added V4L2_CID_HDR_SENSOR_MODE, V4L2_CID_IMAGE_[SOURCE|PROC]_CLASS descriptions
  • 203 |
  • Fixed shortcuts in cameraview
  • 204 |
  • Fixed float FPS handling
  • 205 |
  • Adjust the window size based on rotation
  • 206 |
  • preview calls VIDIOC_S_FMT only for Kiyo Pro (it doesn't work without)
  • 207 |
208 |
209 |
210 | 211 | 212 |
    213 |
  • Colormaps for all pixel formats (some thermal cameras use YUYV for thermal imaging)
  • 214 |
215 |
216 |
217 | 218 | 219 |
    220 |
  • Fixed rotation in preview
  • 221 |
  • Clamp percent control values for fewer warnings when using presets
  • 222 |
  • Use the GTK bundled view-more icon instead of camera-switch
  • 223 |
224 |
225 |
226 | 227 | 228 |
    229 |
  • Fixed ctrl+q quit by closing the camera before
  • 230 |
231 |
232 |
233 | 234 | 235 |
    236 |
  • Color presets
  • 237 |
  • Listen for controls changes from other processes
  • 238 |
  • 'default' or percent values can also be set
  • 239 |
  • Improved error reporting
  • 240 |
  • Exposure time now in µs in the GTK GUIs
  • 241 |
  • Exposure time, Gain scales has dark-to-light background
  • 242 |
  • Removed header buttons border
  • 243 |
244 |
245 |
246 | 247 | 248 |
    249 |
  • Limit the initial size of the preview window to control window so it can be placed next to each other
  • 250 |
  • Flatpak version now uses Wayland (bundled SDL2, libdecor)
  • 251 |
252 |
253 |
254 | 255 | 256 |
    257 |
  • Display warnings on the GUIs as well
  • 258 |
  • Fixed device listbox margin in GTK app
  • 259 |
260 |
261 |
262 | 263 | 264 |
    265 |
  • Two more colormaps
  • 266 |
  • Capture - Info category with camera informations
  • 267 |
  • Show devices by name, not by the long v4l path
  • 268 |
  • Move device combobox to headerbar
  • 269 |
  • Add refresh button to headerbar
  • 270 |
  • Limit the size of the preview to fit next to the window
  • 271 |
  • Redesigned Zero camera page with snap instructions
  • 272 |
273 |
274 |
275 | 276 | 277 |
    278 |
  • New Icon (thx Jorge Toledo eldelacajita)
  • 279 |
  • Rotate, mirror the preview image
  • 280 |
  • Colormaps (inferno, ironblack) for Thermal/ToF camera GREY previews
  • 281 |
  • RGB565 format support
  • 282 |
  • Use edit-undo-symbolic icon instead of ⟳ in default buttons
  • 283 |
  • Various GTK/GTK4 fixes
  • 284 |
285 |
286 |
287 | 288 | 289 |
    290 |
  • Brio 501 FoV support (thx Monkatraz)
  • 291 |
  • Colorized White Balance scale
  • 292 |
  • Simpler looking scales
  • 293 |
294 |
295 |
296 | 297 | 298 |
    299 |
  • Brio 4K Stream Edition FoV support (thx chrishoage)
  • 300 |
301 |
302 |
303 | 304 | 305 |
    306 |
  • Brio 500 FoV support (thx crabmanX)
  • 307 |
308 |
309 |
310 | 311 | 312 |
    313 |
  • Improved error handling and logging
  • 314 |
  • The icon has been given some bloom to make it visible even on a dark background (thx nekohayo for the suggestion)
  • 315 |
  • Fixed Dynex 1.3MP Webcam preview and fps control (thx dln949 for testing)
  • 316 |
317 |
318 |
319 | 320 | 321 |
    322 |
  • Pan/Tilt relative and reset controls for some Logitech PTZ cameras (like bcc950)
  • 323 |
  • LED and focus controls for some old Logitech cameras (like QuickCam Pro 9000)
  • 324 |
  • V4L2 buttons
  • 325 |
  • Controls also work with keyboard
  • 326 |
  • Pan/Tilt speed controls stop when the key or button released
  • 327 |
  • Highlight focused controls in the TK app
  • 328 |
  • Gray out the inactive controls
  • 329 |
  • Quit with Primary+q
  • 330 |
  • New compression page with the Codec and JPEG categories
  • 331 |
  • Fullscreen with double-click in the cameraview
  • 332 |
  • Support YVYU, UYVY, NV21, YV12, RGB24, BGR24, RX24 formats in the cameraview
  • 333 |
  • Limit the combobox width in the GTK app
  • 334 |
  • Controls fill the width in the GTK app
  • 335 |
336 |
337 |
338 | 339 | 340 |
    341 |
  • Color Balance category
  • 342 |
  • Tooltips for JPEG controls
  • 343 |
  • Support cameras with YU12 format
  • 344 |
  • Support IR cameras with GREY format
  • 345 |
  • Advanced/Color Effects moved to Color/Effects
  • 346 |
  • Basic/Crop/Privacy moved to Advanced/Privacy
  • 347 |
  • Merge Compression page into Advanced page
  • 348 |
  • Retain aspect ratio in the cameraview's fullscreen mode
  • 349 |
350 |
351 |
352 | 353 | 354 |
    355 |
  • Control tooltips
  • 356 |
  • Reordered pages
  • 357 |
358 |
359 |
360 | 361 | 362 |
    363 |
  • Crop, Image, Exposure pages for better navigation
  • 364 |
365 |
366 |
367 | 368 | 369 |
    370 |
  • Add Logitech BRIO FoV control
  • 371 |
372 |
373 |
374 | 375 | 376 |
    377 |
  • Cameraview: use esc to exit
  • 378 |
  • GTK, TK: close all windows at exit
  • 379 |
380 |
381 |
382 | 383 | 384 | 385 |
386 |
387 | -------------------------------------------------------------------------------- /cameractrlsgtk4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys, logging, subprocess 4 | import gi 5 | from cameractrls import CameraCtrls, PTZHWControllers, find_by_text_id, get_devices, v4ldirs, find_idx 6 | from cameractrls import version, ghurl 7 | 8 | gi.require_version('Gtk', '4.0') 9 | from gi.repository import Gtk, Gio, GLib, Pango, Gdk 10 | 11 | #logging.getLogger().setLevel(logging.INFO) 12 | 13 | class CameraCtrlsWindow(Gtk.ApplicationWindow): 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self.devices = [] 17 | 18 | self.fd = 0 19 | self.device = None 20 | self.camera = None 21 | self.listener = None 22 | self.ptz_controllers = None 23 | 24 | self.grid = None 25 | self.frame = None 26 | self.device_dd = None 27 | 28 | self.zoom_absolute_sc = None 29 | self.pan_speed_sc = None 30 | self.tilt_speed_sc = None 31 | self.pan_absolute_sc = None 32 | self.tilt_absolute_sc = None 33 | 34 | self.init_window() 35 | self.refresh_devices() 36 | 37 | def init_window(self): 38 | css_provider = Gtk.CssProvider() 39 | css = ''' 40 | .white-balance-temperature trough { 41 | background-image: linear-gradient(to right, 42 | #89F3FF, #AFF7FF, #DDFCFF, #FFF2AA, #FFDD27, #FFC500, #FFB000, #FF8D00, #FF7A00 43 | ); 44 | } 45 | .white-balance-temperature trough:disabled { 46 | background-blend-mode: color; 47 | } 48 | .dark-to-light trough { 49 | background-image: linear-gradient(to right, 50 | #888888, #dddddd 51 | ); 52 | } 53 | .dark-to-light trough:disabled { 54 | background-blend-mode: color; 55 | } 56 | /* make page stackswitcher gtk3 size */ 57 | stackswitcher button { 58 | padding-left: 10px; 59 | padding-right: 10px; 60 | } 61 | /* scale layout fix */ 62 | scale trough { 63 | margin-top: 6px; 64 | margin-bottom: -6px; 65 | } 66 | ''' 67 | 68 | gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()) 69 | 70 | if gtk_version >= (4, 12, 0): 71 | css_provider.load_from_string(css) 72 | # XXX: remove workaround when merged and pygobject 3.46 is widespread 73 | # https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/231/ 74 | elif gtk_version >= (4, 9, 0): 75 | css_provider.load_from_data(css, -1) 76 | else: 77 | css_provider.load_from_data(css.encode()) 78 | 79 | Gtk.StyleContext.add_provider_for_display( 80 | Gdk.Display.get_default(), css_provider, 81 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 82 | ) 83 | 84 | try: 85 | self.desktop_portal = Gio.DBusProxy.new_for_bus_sync( 86 | Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, 87 | 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', 88 | 'org.freedesktop.portal.Settings', None) 89 | prefer_dark = self.desktop_portal.Read('(ss)', 'org.freedesktop.appearance', 'color-scheme') == 1 90 | self.get_settings().set_property('gtk-application-prefer-dark-theme', prefer_dark) 91 | self.desktop_portal.connect('g-signal', lambda p, sender, signal, params: 92 | signal == 'SettingChanged' and 93 | params[0] == 'org.freedesktop.appearance' and 94 | params[1] == 'color-scheme' and 95 | self.get_settings().set_property('gtk-application-prefer-dark-theme', params[2] == 1) 96 | ) 97 | except Exception as e: 98 | logging.warning(f'desktop_portal failed: {e}') 99 | 100 | about_button = Gtk.Button( 101 | action_name='app.about', 102 | icon_name='open-menu-symbolic', 103 | has_frame=False, 104 | tooltip_text='About', 105 | ) 106 | 107 | self.open_cam_button = Gtk.Button( 108 | action_name='app.open_camera_window', 109 | action_target=GLib.Variant('s', ''), 110 | icon_name='camera-video-symbolic', has_frame=False, 111 | tooltip_text='Show preview window', 112 | ) 113 | 114 | self.ptz_model = Gtk.StringList() 115 | self.ptz_lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) 116 | self.ptz_lb.bind_model(self.ptz_model, lambda name: Gtk.ToggleButton(label=name.get_string())) 117 | 118 | self.ptz_sw = Gtk.MenuButton( 119 | popover=Gtk.Popover(position=Gtk.PositionType.BOTTOM, child=self.ptz_lb), 120 | icon_name='input-gaming-symbolic', 121 | has_frame=False, 122 | tooltip_text='PTZ hardware controls', 123 | ) 124 | 125 | refresh_button = Gtk.Button(icon_name='view-refresh-symbolic', has_frame=False, tooltip_text='Refresh') 126 | refresh_button.connect('clicked', lambda e: self.refresh_devices()) 127 | 128 | headerbar = Gtk.HeaderBar(show_title_buttons=True) 129 | headerbar.pack_start(refresh_button) 130 | headerbar.pack_end(about_button) 131 | headerbar.pack_end(self.open_cam_button) 132 | headerbar.pack_end(self.ptz_sw) 133 | self.set_titlebar(headerbar) 134 | 135 | self.grid = Gtk.Grid() 136 | 137 | self.zero_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True, halign=Gtk.Align.CENTER) 138 | self.zero_box.append(Gtk.Image(icon_name='camera-disabled-symbolic', icon_size=Gtk.IconSize.LARGE, pixel_size=96, 139 | margin_top=30, margin_bottom=10, margin_start=80, margin_end=80)) 140 | self.zero_box.append(Gtk.Label(label='0 camera found', use_markup=True, 141 | margin_top=10, margin_bottom=20)) 142 | if 'SNAP' in os.environ: 143 | self.zero_box.append(Gtk.Label(label='Please permit access with', max_width_chars=30, wrap=True, margin_top=10)) 144 | self.zero_box.append(Gtk.Label(label='snap connect cameractrls:camera', selectable=True, margin_bottom=10)) 145 | 146 | self.device_dd = Gtk.DropDown( 147 | model=Gtk.StringList(), hexpand=True, halign=Gtk.Align.START, show_arrow=False, 148 | tooltip_text='Select other camera', 149 | ) 150 | # use the default factory as list_factory 151 | self.device_dd.set_list_factory(self.device_dd.get_factory()) 152 | self.device_dd.get_first_child().set_has_frame(False) 153 | # then set the icon_factory as factory 154 | icon_factory = Gtk.SignalListItemFactory() 155 | icon_factory.connect('setup', lambda f, item: item.set_child(Gtk.Image(icon_name='view-more-symbolic'))) 156 | self.device_dd.set_factory(icon_factory) 157 | 158 | self.device_dd.connect('notify::selected-item', lambda e, _: self.gui_open_device(self.device_dd.get_selected())) 159 | headerbar.pack_start(self.device_dd) 160 | 161 | self.grid.attach(self.zero_box, 0, 0, 1, 1) 162 | 163 | self._notify_timeout = None 164 | 165 | overlay = Gtk.Overlay() 166 | overlay.set_child(self.grid) 167 | 168 | # Notification overlay widget 169 | self._revealer = Gtk.Revealer(valign=Gtk.Align.END, halign=Gtk.Align.CENTER) 170 | self._notify_label = Gtk.Label(wrap=True, wrap_mode=Pango.WrapMode.CHAR, natural_wrap_mode=Gtk.NaturalWrapMode.NONE, halign=Gtk.Align.FILL) 171 | self._notify_label.add_css_class('app-notification') 172 | self._revealer.set_child(self._notify_label) 173 | overlay.add_overlay(self._revealer) 174 | 175 | self.set_child(overlay) 176 | 177 | self.connect('close-request', lambda w: self.close_device()) 178 | 179 | def refresh_devices(self): 180 | logging.info('refresh_devices') 181 | self.devices = get_devices(v4ldirs) 182 | 183 | if len(self.devices) == 0: 184 | self.close_device() 185 | self.init_gui_device() 186 | 187 | model = self.device_dd.get_model() 188 | model.splice(0, model.get_n_items(), [d.name for d in self.devices]) 189 | 190 | if len(self.devices): 191 | idx = 0 192 | if self.device in self.devices: 193 | idx = self.devices.index(self.device) 194 | self.device_dd.set_selected(idx) 195 | 196 | if len(self.devices) == 0: 197 | self.zero_box.set_visible(True) 198 | self.device_dd.set_visible(False) 199 | self.open_cam_button.set_visible(False) 200 | else: 201 | self.zero_box.set_visible(False) 202 | self.device_dd.set_visible(True) 203 | self.open_cam_button.set_visible(True) 204 | 205 | def gui_open_device(self, id): 206 | logging.info('gui_open_device') 207 | # if the selection is empty 208 | if id == Gtk.INVALID_LIST_POSITION: 209 | return 210 | 211 | self.close_device() 212 | self.open_device(self.devices[id]) 213 | self.init_gui_device() 214 | 215 | def reopen_device(self): 216 | opened_page = self.stack.get_visible_child_name() 217 | device = self.device 218 | self.close_device() 219 | self.open_device(device) 220 | self.init_gui_device() 221 | self.stack.set_visible_child_full(opened_page, Gtk.StackTransitionType.NONE) 222 | 223 | def open_device(self, device): 224 | logging.info(f'opening device: {device.path}') 225 | try: 226 | self.fd = os.open(device.path, os.O_RDWR, 0) 227 | except Exception as e: 228 | logging.error(f'os.open({device.path}, os.O_RDWR, 0) failed: {e}') 229 | 230 | self.camera = CameraCtrls(device.path, self.fd) 231 | self.listener = self.camera.subscribe_events( 232 | lambda c: GLib.idle_add(self.update_ctrl_value, c), 233 | lambda errs: GLib.idle_add(self.notify, '\n'.join(errs)), 234 | ) 235 | self.device = device 236 | self.ptz_controllers = PTZHWControllers(self.device.path, 237 | lambda check, p, i: GLib.timeout_add(300, check, p, i), 238 | lambda err: self.notify(err), 239 | lambda i: self.ptz_lb.get_row_at_index(i).get_child().set_active(False), 240 | ) 241 | self.ptz_model.splice(0, self.ptz_model.get_n_items(), self.ptz_controllers.get_names()) 242 | for i in range(self.ptz_model.get_n_items()): 243 | row = self.ptz_lb.get_row_at_index(i) 244 | row.set_activatable(False) 245 | row.set_focusable(False) 246 | row.get_child().connect('toggled', lambda c, i=i: self.ptz_controllers.set_active(i, c.get_active())) 247 | self.ptz_sw.set_visible(self.camera.has_ptz()) 248 | self.ptz_sw.set_sensitive(self.ptz_model.get_n_items() != 0) 249 | self.open_cam_button.set_action_target_value(GLib.Variant('s', self.device.path)) 250 | 251 | def close_device(self): 252 | if self.fd: 253 | logging.info('close_device') 254 | self.device = None 255 | self.camera = None 256 | self.listener.stop() 257 | self.listener = None 258 | self.ptz_controllers.terminate_all() 259 | self.ptz_controllers = None 260 | os.close(self.fd) 261 | self.fd = 0 262 | 263 | def init_gui_device(self): 264 | if self.frame: 265 | self.grid.remove(self.frame) 266 | self.frame = None 267 | 268 | if self.device is None: 269 | return 270 | 271 | self.frame = Gtk.Grid(hexpand=True, halign=Gtk.Align.FILL) 272 | self.grid.attach(self.frame, 0, 0, 1, 1) 273 | 274 | stack_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True, halign=Gtk.Align.FILL, 275 | margin_bottom=10, margin_top=10, margin_start=10, margin_end=10) 276 | stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, transition_duration=500) 277 | stack_sw = Gtk.StackSwitcher(stack=stack, hexpand=True, halign=Gtk.Align.CENTER) 278 | stack_box.append(stack_sw) 279 | scrolledwindow = Gtk.ScrolledWindow(child=stack, propagate_natural_height=True, hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC) 280 | stack_box.append(scrolledwindow) 281 | 282 | footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=10) 283 | stack_box.append(footer) 284 | 285 | self.frame.attach(stack_box, 0, 1, 1, 1) 286 | self.stack = stack 287 | 288 | for page_n, page in enumerate(self.camera.get_ctrl_pages()): 289 | page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 290 | if page.target == 'main': 291 | stack.add_titled(page_box, str(page_n), page.title) 292 | elif page.target == 'footer': 293 | sep = Gtk.Separator(margin_bottom=10) 294 | footer.append(sep) 295 | footer.append(page_box) 296 | 297 | for cat in page.categories: 298 | if page.target != 'footer': 299 | c_label = Gtk.Label(xalign=0, margin_bottom=10, margin_top=10) 300 | c_label.set_markup(f'{cat.title}') 301 | page_box.append(c_label) 302 | 303 | ctrls_frame = Gtk.Frame() 304 | page_box.append(ctrls_frame) 305 | 306 | ctrls_listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) 307 | ctrls_listbox.set_header_func(lambda row, before: row.set_header(Gtk.Separator()) if before is not None else None) 308 | ctrls_frame.set_child(ctrls_listbox) 309 | 310 | for c in cat.ctrls: 311 | ctrl_row = Gtk.ListBoxRow() 312 | ctrls_listbox.append(ctrl_row) 313 | 314 | ctrl_box = Gtk.Box(margin_start=5, margin_end=5, height_request=45) 315 | ctrl_row.set_child(ctrl_box) 316 | 317 | label = Gtk.Label(label=c.name, xalign=0, margin_end=5) 318 | tooltip_markup = f'{c.text_id}' 319 | if c.kernel_id: 320 | tooltip_markup += f' ({c.kernel_id})' 321 | if c.tooltip: 322 | tooltip_markup += f'\n\n{c.tooltip}' 323 | label.set_tooltip_markup(tooltip_markup) 324 | ctrl_box.append(label) 325 | 326 | c.gui_ctrls = [label] 327 | c.gui_value_set = None 328 | 329 | if c.type == 'integer': 330 | adjustment = Gtk.Adjustment(lower=c.min, upper=c.max, value=c.value, step_increment=1) 331 | adjustment.connect('value-changed', lambda a,c=c: self.update_ctrl(c, a.get_value())) 332 | scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True, halign=Gtk.Align.END, 333 | digits=0, has_origin=False, draw_value=True, value_pos=Gtk.PositionType.LEFT, adjustment=adjustment, width_request=264) 334 | if c.zeroer: 335 | for controller in scale.observe_controllers(): 336 | if isinstance(controller, gi.repository.Gtk.GestureClick): 337 | controller.connect('released', lambda c, n, x, y, sc=scale: sc.set_value(0)) 338 | if isinstance(controller, gi.repository.Gtk.EventControllerKey): 339 | controller.connect('key-pressed', self.handle_ptz_speed_key_pressed) 340 | controller.connect('key-released', self.handle_ptz_speed_key_released) 341 | if c.step and c.step != 1: 342 | adjustment_step = Gtk.Adjustment(lower=c.min, upper=c.max, value=c.value, step_increment=c.step) 343 | adjustment_step.connect('value-changed', lambda a,c=c,a1=adjustment: [a.set_value(a.get_value() - a.get_value() % c.step),a1.set_value(a.get_value())]) 344 | scale.set_adjustment(adjustment_step) 345 | if c.step_big: 346 | scale.get_adjustment().set_page_increment(c.step_big) 347 | if c.scale_class: 348 | scale.add_css_class(c.scale_class) 349 | if c.format_value: 350 | scale.set_format_value_func(c.format_value) 351 | 352 | if c.default is not None: 353 | scale.add_mark(value=c.default, position=Gtk.PositionType.BOTTOM, markup=None) 354 | 355 | if c.text_id == 'zoom_absolute': 356 | self.zoom_absolute_sc = scale 357 | 358 | if c.text_id == 'pan_speed': 359 | self.pan_speed_sc = scale 360 | 361 | if c.text_id == 'tilt_speed': 362 | self.tilt_speed_sc = scale 363 | 364 | if c.text_id == 'pan_absolute': 365 | self.pan_absolute_sc = scale 366 | 367 | if c.text_id == 'tilt_absolute': 368 | self.tilt_absolute_sc = scale 369 | 370 | if c.text_id in ['pan_absolute', 'tilt_absolute']: 371 | for controller in scale.observe_controllers(): 372 | if isinstance(controller, gi.repository.Gtk.EventControllerKey): 373 | controller.connect('key-pressed', self.handle_ptz_absolute_key_pressed) 374 | 375 | refresh = Gtk.Button(icon_name='edit-undo-symbolic', valign=Gtk.Align.CENTER, halign=Gtk.Align.START, has_frame=False) 376 | refresh.connect('clicked', lambda e, c=c, sc=scale: sc.get_adjustment().set_value(c.default)) 377 | ctrl_box.append(refresh) 378 | scale_stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, transition_duration=500) 379 | scale_box = Gtk.Box(halign=Gtk.Align.END) 380 | spin_box = Gtk.Box(halign=Gtk.Align.END) 381 | prev_button = Gtk.Button(icon_name='go-previous-symbolic', has_frame=False, opacity=0.2) 382 | prev_button.connect('clicked', lambda e, st=scale_stack, sc=scale_box: st.set_visible_child(sc)) 383 | next_button = Gtk.Button(icon_name='go-next-symbolic', has_frame=False, opacity=0.2) 384 | next_button.connect('clicked', lambda e, st=scale_stack, sp=spin_box: st.set_visible_child(sp)) 385 | scale_box.append(scale) 386 | scale_box.append(next_button) 387 | spin = Gtk.SpinButton(adjustment=scale.get_adjustment()) 388 | spin_box.append(spin) 389 | spin_box.append(prev_button) 390 | scale_stack.add_child(scale_box) 391 | scale_stack.add_child(spin_box) 392 | ctrl_box.append(scale_stack) 393 | c.gui_value_set = scale.set_value 394 | c.gui_ctrls += [scale, spin, refresh] 395 | c.gui_default_btn = refresh 396 | 397 | elif c.type == 'boolean': 398 | switch = Gtk.Switch(valign=Gtk.Align.CENTER, active=c.value, margin_end=5, hexpand=True, halign=Gtk.Align.END) 399 | switch.connect('state-set', lambda w,state,c=c: self.update_ctrl(c, state)) 400 | refresh = Gtk.Button(icon_name='edit-undo-symbolic', valign=Gtk.Align.CENTER, halign=Gtk.Align.START, has_frame=False) 401 | if c.default is not None: 402 | refresh.connect('clicked', lambda e,switch=switch,c=c: switch.set_active(c.default)) 403 | ctrl_box.append(refresh) 404 | ctrl_box.append(switch) 405 | c.gui_value_set = switch.set_active 406 | c.gui_ctrls += [switch, refresh] 407 | c.gui_default_btn = refresh 408 | 409 | elif c.type == 'button': 410 | filtered_menu = [m for m in c.menu if m.value is not None and not m.gui_hidden] 411 | children_per_line = 4 412 | grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True, column_spacing=4, row_spacing=4, hexpand=True, halign=Gtk.Align.END) 413 | refresh = Gtk.Button(icon_name='edit-undo-symbolic', valign=Gtk.Align.CENTER, halign=Gtk.Align.START, has_frame=False) 414 | ctrl_box.append(refresh) 415 | ctrl_box.append(grid) 416 | for idx, m in enumerate(filtered_menu): 417 | b = Gtk.Button(label=m.name, valign=Gtk.Align.CENTER) 418 | b.connect('clicked', lambda e, c=c, m=m: self.update_ctrl(c, m.text_id)) 419 | if c.child_tooltip: 420 | b.set_tooltip_markup(c.child_tooltip) 421 | if m.lp_text_id is not None: 422 | lp = Gtk.GestureLongPress(propagation_phase=Gtk.PropagationPhase.CAPTURE) 423 | lp.connect('pressed', lambda lp, x, y, c=c, m=m, b=b: [ 424 | lp.set_state(Gtk.EventSequenceState.CLAIMED), 425 | self.update_ctrl(c, m.lp_text_id) 426 | ]) 427 | b.add_controller(lp) 428 | grid.attach(b, idx % children_per_line, idx // children_per_line, 1, 1) 429 | c.gui_ctrls += [b] 430 | if c.default is not None: 431 | refresh.connect('clicked', lambda e,c=c: self.update_ctrl(c, c.default)) 432 | c.gui_ctrls += [refresh] 433 | c.gui_default_btn = refresh 434 | 435 | elif c.type == 'info': 436 | label = Gtk.Label(label=c.value, selectable=True, justify=Gtk.Justification.RIGHT, hexpand=True, 437 | halign=Gtk.Align.END, wrap=True, wrap_mode=Pango.WrapMode.CHAR, 438 | max_width_chars=48, width_chars=32, xalign=1, 439 | natural_wrap_mode=Gtk.NaturalWrapMode.INHERIT) 440 | ctrl_box.append(label) 441 | c.gui_value_set = label.set_label 442 | c.gui_default_btn = None 443 | 444 | elif c.type == 'menu': 445 | if len(c.menu) < 4 and not c.menu_dd: 446 | box = Gtk.Box(valign=Gtk.Align.CENTER, hexpand=True, halign=Gtk.Align.END) 447 | box.add_css_class('linked') 448 | rb = None 449 | for m in c.menu: 450 | rb = Gtk.ToggleButton(group=rb, label=m.name) 451 | rb.set_active(m.text_id == c.value) 452 | rb.connect('toggled', lambda b, c=c, m=m: self.update_ctrl(c, m.text_id) if b.get_active() else None) 453 | box.append(rb) 454 | m.gui_rb = rb 455 | 456 | refresh = Gtk.Button(icon_name='edit-undo-symbolic', valign=Gtk.Align.CENTER, halign=Gtk.Align.START, has_frame=False) 457 | if c.default is not None: 458 | refresh.connect('clicked', lambda e,c=c: find_by_text_id(c.menu, c.default).gui_rb.set_active(True)) 459 | ctrl_box.append(refresh) 460 | ctrl_box.append(box) 461 | c.gui_value_set = lambda ctext, c=c: find_by_text_id(c.menu, ctext).gui_rb.set_active(True) 462 | c.gui_ctrls += [m.gui_rb for m in c.menu] + [refresh] 463 | c.gui_default_btn = refresh 464 | else: 465 | wb_dd = Gtk.DropDown(model=Gtk.StringList(), valign=Gtk.Align.CENTER, hexpand=True, halign=Gtk.Align.END) 466 | for m in c.menu: 467 | wb_dd.get_model().append(m.name) 468 | if c.value: 469 | idx = find_idx(c.menu, lambda m: m.text_id == c.value) 470 | if idx is not None: 471 | wb_dd.set_selected(idx) 472 | else: 473 | wb_dd.set_selected(Gtk.INVALID_LIST_POSITION) 474 | err = f'Control {c.text_id}: Can\'t find {c.value} in {[m.text_id for m in c.menu]}' 475 | logging.warning(err) 476 | self.notify(err) 477 | wb_dd.connect('notify::selected', lambda e,_,c=c: GLib.idle_add(self.update_ctrl, c, c.menu[e.get_selected()].text_id)) 478 | refresh = Gtk.Button(icon_name='edit-undo-symbolic', valign=Gtk.Align.CENTER, halign=Gtk.Align.START, has_frame=False) 479 | if c.default is not None: 480 | refresh.connect('clicked', lambda e,c=c,wb_dd=wb_dd: wb_dd.set_selected(find_idx(c.menu, lambda m: m.text_id == c.default))) 481 | ctrl_box.append(refresh) 482 | ctrl_box.append(wb_dd) 483 | c.gui_value_set = lambda ctext, c=c, wb_dd=wb_dd: wb_dd.set_selected(find_idx(c.menu, lambda m: m.text_id == ctext)) 484 | c.gui_ctrls += [wb_dd, refresh] 485 | c.gui_default_btn = refresh 486 | 487 | self.update_ctrls_state() 488 | _, natsize = self.get_preferred_size() 489 | self.set_default_size(natsize.width, natsize.height) 490 | 491 | def close_notify(self): 492 | self._revealer.set_reveal_child(False) 493 | self._notify_timeout = None 494 | # False removes the timeout 495 | return False 496 | 497 | def notify(self, message, timeout=5): 498 | if self._notify_timeout is not None: 499 | GLib.Source.remove(self._notify_timeout) 500 | 501 | self._notify_label.set_text(message) 502 | self._revealer.set_reveal_child(True) 503 | 504 | if timeout > 0: 505 | self._notify_timeout = GLib.timeout_add_seconds(timeout, self.close_notify) 506 | 507 | def update_ctrl(self, ctrl, value): 508 | # only update if out of sync (when new value comes from the gui) 509 | if ctrl.value != value and not ctrl.inactive: 510 | errs = [] 511 | self.camera.setup_ctrls({ctrl.text_id: value}, errs) 512 | if errs: 513 | self.notify('\n'.join(errs)) 514 | GLib.idle_add(self.update_ctrl_value, ctrl), 515 | return 516 | 517 | self.update_ctrls_state() 518 | if ctrl.reopener: 519 | GLib.idle_add(self.reopen_device) 520 | 521 | def update_ctrls_state(self): 522 | for c in self.camera.get_ctrls(): 523 | self.update_ctrl_state(c) 524 | 525 | def update_ctrl_state(self, c): 526 | for gui_ctrl in c.gui_ctrls: 527 | gui_ctrl.set_sensitive(not c.inactive and not c.readonly) 528 | if c.gui_default_btn is not None: 529 | visible = not c.inactive and not c.readonly and ( 530 | c.default is not None and c.value is not None and c.default != c.value or \ 531 | c.get_default is not None and not c.get_default() 532 | ) 533 | c.gui_default_btn.set_opacity(visible) 534 | c.gui_default_btn.set_can_focus(visible) 535 | 536 | def update_ctrl_value(self, c): 537 | if c.reopener: 538 | self.reopen_device() 539 | return 540 | if c.gui_value_set: 541 | c.gui_value_set(c.value) 542 | self.update_ctrl_state(c) 543 | 544 | def handle_ptz_speed_key_pressed(self, c, keyval, keycode, state): 545 | pan_lower = self.pan_speed_sc.get_adjustment().get_lower() 546 | pan_upper =self.pan_speed_sc.get_adjustment().get_upper() 547 | tilt_lower = self.tilt_speed_sc.get_adjustment().get_lower() 548 | tilt_upper = self.tilt_speed_sc.get_adjustment().get_upper() 549 | 550 | if keyval in [Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_a]: 551 | self.pan_speed_sc.set_value(pan_lower) 552 | elif keyval in [Gdk.KEY_Right, Gdk.KEY_KP_Right, Gdk.KEY_d]: 553 | self.pan_speed_sc.set_value(pan_upper) 554 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up, Gdk.KEY_w]: 555 | self.tilt_speed_sc.set_value(tilt_upper) 556 | elif keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down, Gdk.KEY_s]: 557 | self.tilt_speed_sc.set_value(tilt_lower) 558 | elif keyval == Gdk.KEY_KP_End: 559 | self.pan_speed_sc.set_value(pan_lower) 560 | self.tilt_speed_sc.set_value(tilt_lower) 561 | elif keyval == Gdk.KEY_KP_Page_Down: 562 | self.pan_speed_sc.set_value(pan_upper) 563 | self.tilt_speed_sc.set_value(tilt_lower) 564 | elif keyval == Gdk.KEY_KP_Home: 565 | self.pan_speed_sc.set_value(pan_lower) 566 | self.tilt_speed_sc.set_value(tilt_upper) 567 | elif keyval == Gdk.KEY_KP_Page_Up: 568 | self.pan_speed_sc.set_value(pan_upper) 569 | self.tilt_speed_sc.set_value(tilt_upper) 570 | elif self.zoom_absolute_sc is not None: 571 | return self.handle_ptz_key_pressed_zoom(keyval, state) 572 | else: 573 | return False 574 | return True 575 | 576 | def handle_ptz_absolute_key_pressed(self, c, keyval, keycode, state): 577 | pan_value = self.pan_absolute_sc.get_value() 578 | pan_step = self.pan_absolute_sc.get_adjustment().get_step_increment() 579 | tilt_value = self.tilt_absolute_sc.get_value() 580 | tilt_step = self.tilt_absolute_sc.get_adjustment().get_step_increment() 581 | 582 | if keyval in [Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_a]: 583 | self.pan_absolute_sc.set_value(pan_value - pan_step) 584 | elif keyval in [Gdk.KEY_Right, Gdk.KEY_KP_Right, Gdk.KEY_d]: 585 | self.pan_absolute_sc.set_value(pan_value + pan_step) 586 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up, Gdk.KEY_w]: 587 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 588 | elif keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down, Gdk.KEY_s]: 589 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 590 | elif keyval == Gdk.KEY_KP_End: 591 | self.pan_absolute_sc.set_value(pan_value - pan_step) 592 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 593 | elif keyval == Gdk.KEY_KP_Page_Down: 594 | self.pan_absolute_sc.set_value(pan_value + pan_step) 595 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 596 | elif keyval == Gdk.KEY_KP_Home: 597 | self.pan_absolute_sc.set_value(pan_value - pan_step) 598 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 599 | elif keyval == Gdk.KEY_KP_Page_Up: 600 | self.pan_absolute_sc.set_value(pan_value + pan_step) 601 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 602 | elif keyval == Gdk.KEY_KP_Insert: 603 | self.pan_absolute_sc.set_value(0) 604 | self.tilt_absolute_sc.set_value(0) 605 | elif self.zoom_absolute_sc is not None: 606 | return self.handle_ptz_key_pressed_zoom(keyval, state) 607 | else: 608 | return False 609 | return True 610 | 611 | def handle_ptz_key_pressed_zoom(self, keyval, state): 612 | zoom_value = self.zoom_absolute_sc.get_value() 613 | zoom_step = self.zoom_absolute_sc.get_adjustment().get_step_increment() 614 | zoom_page = self.zoom_absolute_sc.get_adjustment().get_page_increment() 615 | zoom_lower = self.zoom_absolute_sc.get_adjustment().get_lower() 616 | zoom_upper = self.zoom_absolute_sc.get_adjustment().get_upper() 617 | 618 | if keyval in [Gdk.KEY_plus, Gdk.KEY_KP_Add] and state & Gdk.ModifierType.CONTROL_MASK: 619 | self.zoom_absolute_sc.set_value(zoom_value + zoom_page) 620 | elif keyval in [Gdk.KEY_minus, Gdk.KEY_KP_Subtract] and state & Gdk.ModifierType.CONTROL_MASK: 621 | self.zoom_absolute_sc.set_value(zoom_value - zoom_page) 622 | elif keyval in [Gdk.KEY_plus, Gdk.KEY_KP_Add]: 623 | self.zoom_absolute_sc.set_value(zoom_value + zoom_step) 624 | elif keyval in [Gdk.KEY_minus, Gdk.KEY_KP_Subtract]: 625 | self.zoom_absolute_sc.set_value(zoom_value - zoom_step) 626 | elif keyval in [Gdk.KEY_Home]: 627 | self.zoom_absolute_sc.set_value(zoom_lower) 628 | elif keyval in [Gdk.KEY_End]: 629 | self.zoom_absolute_sc.set_value(zoom_upper) 630 | elif keyval in [Gdk.KEY_Page_Up]: 631 | self.zoom_absolute_sc.set_value(zoom_value + zoom_page) 632 | elif keyval in [Gdk.KEY_Page_Down]: 633 | self.zoom_absolute_sc.set_value(zoom_value - zoom_page) 634 | else: 635 | return False 636 | return True 637 | 638 | def handle_ptz_speed_key_released(self, c, keyval, keycode, state): 639 | if keyval in [Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right, Gdk.KEY_a, Gdk.KEY_d]: 640 | self.pan_speed_sc.set_value(0) 641 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_KP_Up, Gdk.KEY_KP_Down, Gdk.KEY_w, Gdk.KEY_s]: 642 | self.tilt_speed_sc.set_value(0) 643 | elif keyval in [Gdk.KEY_KP_End, Gdk.KEY_KP_Page_Down, Gdk.KEY_KP_Home, Gdk.KEY_KP_Page_Up]: 644 | self.pan_speed_sc.set_value(0) 645 | self.tilt_speed_sc.set_value(0) 646 | else: 647 | return False 648 | return True 649 | 650 | class CameraCtrlsApp(Gtk.Application): 651 | def __init__(self, *args, **kwargs): 652 | super().__init__(*args, application_id='hu.irl.cameractrls', **kwargs) 653 | 654 | self.window = None 655 | self.child_processes = [] 656 | 657 | def do_startup(self): 658 | Gtk.Application.do_startup(self) 659 | 660 | icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) 661 | icon_theme.add_search_path(f'{sys.path[0]}/pkg') 662 | 663 | action = Gio.SimpleAction.new('about', None) 664 | action.connect('activate', self.on_about) 665 | self.add_action(action) 666 | 667 | action = Gio.SimpleAction.new('open_camera_window', GLib.VariantType('s')) 668 | action.connect('activate', self.open_camera_window) 669 | self.add_action(action) 670 | 671 | action = Gio.SimpleAction.new('quit', None) 672 | action.connect('activate', self.on_quit) 673 | self.add_action(action) 674 | 675 | action = Gio.SimpleAction.new('alt_n', GLib.VariantType('i')) 676 | action.connect('activate', self.on_alt_n) 677 | self.add_action(action) 678 | 679 | self.set_accels_for_action('app.quit', ["Q"]) 680 | self.set_accels_for_action('app.alt_n(0)', ["1"]) 681 | self.set_accels_for_action('app.alt_n(1)', ["2"]) 682 | self.set_accels_for_action('app.alt_n(2)', ["3"]) 683 | self.set_accels_for_action('app.alt_n(3)', ["4"]) 684 | self.set_accels_for_action('app.alt_n(4)', ["5"]) 685 | self.set_accels_for_action('app.alt_n(5)', ["6"]) 686 | self.set_accels_for_action('app.alt_n(6)', ["7"]) 687 | 688 | def do_activate(self): 689 | self.window = CameraCtrlsWindow(application=self, title='Cameractrls') 690 | self.window.present() 691 | 692 | def on_about(self, action, param): 693 | about = Gtk.AboutDialog(transient_for=self.window, modal=self.window) 694 | about.set_program_name('Cameractrls') 695 | about.set_authors(['Gergo Koteles ']) 696 | about.set_artists(['Jorge Toledo https://lacajita.es']) 697 | about.set_copyright('Copyright © 2022 - 2024 Gergo Koteles') 698 | about.set_license_type(Gtk.License.LGPL_3_0) 699 | about.set_logo_icon_name('hu.irl.cameractrls') 700 | about.set_website(ghurl) 701 | about.set_website_label('GitHub') 702 | about.set_version(version) 703 | about.present() 704 | 705 | def on_quit(self, action, param): 706 | self.window.close() 707 | 708 | def on_alt_n(self, action, param): 709 | page_n = str(param) 710 | if self.window.stack and self.window.stack.get_child_by_name(page_n): 711 | self.window.stack.set_visible_child_name(page_n) 712 | 713 | def check_preview_open(self, p): 714 | # if process returned 715 | if p.poll() is not None: 716 | (stdout, stderr) = p.communicate() 717 | errstr = stderr.decode() 718 | sys.stderr.write(errstr) 719 | if p.returncode != 0: 720 | self.window.notify(errstr.strip()) 721 | # False removes the timeout 722 | return False 723 | return True 724 | 725 | def open_camera_window(self, action, device): 726 | win_width, win_height = self.window.get_default_size() 727 | logging.info(f'open cameraview.py for {device.get_string()} with max size {win_width}x{win_height}') 728 | p = subprocess.Popen([f'{sys.path[0]}/cameraview.py', '-d', device.get_string(), '-s', f'{win_width}x{win_height}'], stderr=subprocess.PIPE) 729 | self.child_processes.append(p) 730 | GLib.timeout_add(300, self.check_preview_open, p) 731 | 732 | def kill_child_processes(self): 733 | for proc in self.child_processes: 734 | proc.kill() 735 | 736 | 737 | if __name__ == '__main__': 738 | app = CameraCtrlsApp() 739 | app.run() 740 | app.kill_child_processes() 741 | -------------------------------------------------------------------------------- /cameractrlsgtk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys, logging, subprocess 4 | import gi 5 | from cameractrls import CameraCtrls, PTZHWControllers, find_by_text_id, get_devices, v4ldirs, find_idx 6 | from cameractrls import version, ghurl 7 | 8 | gi.require_version('Gtk', '3.0') 9 | from gi.repository import Gtk, Gio, GLib, Pango, Gdk, GObject 10 | 11 | class GStr(GObject.GObject): 12 | def __init__(self, text): 13 | GObject.GObject.__init__(self) 14 | self.text = text 15 | def __str__(self): 16 | return self.text 17 | 18 | class FormatScale(Gtk.Scale): 19 | def __init__(self, format_value = None, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | self.format_value = format_value 22 | 23 | def do_format_value(self, v): 24 | if self.format_value: 25 | return self.format_value(self, v) 26 | return f'{v:.0f}' 27 | 28 | class CameraCtrlsWindow(Gtk.ApplicationWindow): 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, type_hint=Gdk.WindowTypeHint.DIALOG, **kwargs) 31 | self.devices = [] 32 | 33 | self.fd = 0 34 | self.device = None 35 | self.camera = None 36 | self.listener = None 37 | self.ptz_controllers = None 38 | 39 | self.grid = None 40 | self.frame = None 41 | self.device_cb = None 42 | 43 | self.zoom_absolute_sc = None 44 | self.pan_speed_sc = None 45 | self.tilt_speed_sc = None 46 | self.pan_absolute_sc = None 47 | self.tilt_absolute_sc = None 48 | 49 | self.init_window() 50 | self.refresh_devices() 51 | 52 | def init_window(self): 53 | css_provider = Gtk.CssProvider() 54 | css_provider.load_from_data(b''' 55 | .white-balance-temperature trough { 56 | background-image: linear-gradient(to right, 57 | #89F3FF, #AFF7FF, #DDFCFF, #FFF2AA, #FFDD27, #FFC500, #FFB000, #FF8D00, #FF7A00 58 | ); 59 | } 60 | .white-balance-temperature trough:disabled { 61 | background-blend-mode: color; 62 | } 63 | .dark-to-light trough { 64 | background-image: linear-gradient(to right, 65 | #888888, #dddddd 66 | ); 67 | } 68 | .dark-to-light trough:disabled { 69 | background-blend-mode: color; 70 | } 71 | ''') 72 | 73 | Gtk.StyleContext.add_provider_for_screen( 74 | Gdk.Screen.get_default(), css_provider, 75 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 76 | ) 77 | 78 | try: 79 | self.desktop_portal = Gio.DBusProxy.new_for_bus_sync( 80 | Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, 81 | 'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop', 82 | 'org.freedesktop.portal.Settings', None) 83 | prefer_dark = self.desktop_portal.Read('(ss)', 'org.freedesktop.appearance', 'color-scheme') == 1 84 | self.get_settings().set_property('gtk-application-prefer-dark-theme', prefer_dark) 85 | self.desktop_portal.connect('g-signal', lambda p, sender, signal, params: 86 | signal == 'SettingChanged' and 87 | params[0] == 'org.freedesktop.appearance' and 88 | params[1] == 'color-scheme' and 89 | self.get_settings().set_property('gtk-application-prefer-dark-theme', params[2] == 1) 90 | ) 91 | except Exception as e: 92 | logging.warning(f'desktop_portal failed: {e}') 93 | 94 | about_button = Gtk.Button( 95 | action_name='app.about', 96 | image=Gtk.Image(icon_name='open-menu-symbolic', icon_size=Gtk.IconSize.MENU), 97 | relief=Gtk.ReliefStyle.NONE, 98 | tooltip_text='About', 99 | ) 100 | 101 | self.open_cam_button = Gtk.Button( 102 | action_name='app.open_camera_window', 103 | action_target=GLib.Variant('s', ''), 104 | image=Gtk.Image(icon_name='camera-video-symbolic', icon_size=Gtk.IconSize.MENU), 105 | relief=Gtk.ReliefStyle.NONE, 106 | tooltip_text='Show preview window', 107 | ) 108 | 109 | self.ptz_model = Gio.ListStore() 110 | self.ptz_lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE, margin=5) 111 | self.ptz_lb.bind_model(self.ptz_model, lambda i: Gtk.ToggleButton(label=i.text, margin=5)) 112 | self.ptz_lb.show_all() 113 | 114 | self.ptz_sw = Gtk.MenuButton( 115 | popover=Gtk.Popover(position=Gtk.PositionType.BOTTOM, child=self.ptz_lb), 116 | image=Gtk.Image(icon_name='input-gaming-symbolic', icon_size=Gtk.IconSize.MENU), 117 | relief=Gtk.ReliefStyle.NONE, 118 | tooltip_text='PTZ hardware controls', 119 | ) 120 | 121 | refresh_button = Gtk.Button( 122 | image=Gtk.Image(icon_name='view-refresh-symbolic', icon_size=Gtk.IconSize.MENU), 123 | relief=Gtk.ReliefStyle.NONE, 124 | tooltip_text='Refresh', 125 | ) 126 | refresh_button.connect('clicked', lambda e: self.refresh_devices()) 127 | 128 | headerbar = Gtk.HeaderBar(title='Cameractrls', show_close_button=True) 129 | headerbar.pack_start(refresh_button) 130 | headerbar.pack_end(about_button) 131 | headerbar.pack_end(self.open_cam_button) 132 | headerbar.pack_end(self.ptz_sw) 133 | 134 | self.grid = Gtk.Grid() 135 | 136 | self.zero_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True, halign=Gtk.Align.CENTER) 137 | self.zero_box.pack_start(Gtk.Image(icon_name='camera-disabled-symbolic', icon_size=Gtk.IconSize.DIALOG, pixel_size=96, 138 | margin_top=30, margin_bottom=10, margin_start=80, margin_end=80), True, True, 0) 139 | self.zero_box.pack_start(Gtk.Label(label='0 camera found', use_markup=True, 140 | margin_top=10, margin_bottom=20), True, True, 0) 141 | if 'SNAP' in os.environ: 142 | self.zero_box.pack_start(Gtk.Label(label='Please permit access with', max_width_chars=30, wrap=True, margin_top=10), True, True, 0) 143 | self.zero_box.pack_start(Gtk.Label(label='snap connect cameractrls:camera', selectable=True, margin_bottom=10), True, True, 0) 144 | self.zero_box.show_all() 145 | self.zero_box.set_no_show_all(True) 146 | 147 | self.model = Gio.ListStore() 148 | self.device_lb = Gtk.ListBox(activate_on_single_click=True, selection_mode=Gtk.SelectionMode.SINGLE, margin=5) 149 | self.device_lb.bind_model(self.model, lambda i: Gtk.Label(label=i.text, margin=10, xalign=0)) 150 | self.device_lb.show_all() 151 | # row-selected fires at every popover open so only row-activated used 152 | self.device_lb.connect('row-activated', lambda lb, r: [ 153 | self.gui_open_device(r.get_index()), 154 | self.device_sw.get_popover().popdown(), 155 | ]) 156 | 157 | self.device_sw = Gtk.MenuButton( 158 | popover=Gtk.Popover(position=Gtk.PositionType.BOTTOM, child=self.device_lb), 159 | image=Gtk.Image(icon_name='view-more-symbolic', icon_size=Gtk.IconSize.MENU), 160 | relief=Gtk.ReliefStyle.NONE, 161 | tooltip_text='Select other camera' 162 | ) 163 | headerbar.pack_start(self.device_sw) 164 | 165 | headerbar.show_all() 166 | self.set_titlebar(headerbar) 167 | 168 | self.grid.attach(self.zero_box, 0, 0, 1, 1) 169 | self.grid.show() 170 | 171 | self._notify_timeout = None 172 | 173 | overlay = Gtk.Overlay() 174 | overlay.add(self.grid) 175 | 176 | # Notification overlay widget 177 | self._revealer = Gtk.Revealer(valign=Gtk.Align.END, halign=Gtk.Align.CENTER) 178 | self._notify_label = Gtk.Label(wrap=True, wrap_mode=Pango.WrapMode.CHAR) 179 | self._notify_label .get_style_context().add_class('app-notification') 180 | self._revealer.add(self._notify_label) 181 | overlay.add_overlay(self._revealer) 182 | overlay.set_overlay_pass_through(self._revealer, True) 183 | overlay.show_all() 184 | 185 | self.add(overlay) 186 | 187 | self.connect('delete-event', lambda w,e: self.close_device()) 188 | 189 | def refresh_devices(self): 190 | logging.info('refresh_devices') 191 | self.devices = get_devices(v4ldirs) 192 | 193 | if len(self.devices) == 0: 194 | self.close_device() 195 | self.init_gui_device() 196 | 197 | self.model.splice(0, self.model.get_n_items(), [GStr(d.name) for d in self.devices]) 198 | 199 | if len(self.devices): 200 | idx = 0 201 | if self.device in self.devices: 202 | idx = self.devices.index(self.device) 203 | self.device_lb.select_row(self.device_lb.get_row_at_index(idx)) 204 | self.gui_open_device(idx) 205 | 206 | if len(self.devices) == 0: 207 | self.zero_box.set_visible(True) 208 | self.device_sw.set_visible(False) 209 | self.open_cam_button.set_visible(False) 210 | else: 211 | self.zero_box.set_visible(False) 212 | self.device_sw.set_visible(True) 213 | self.open_cam_button.set_visible(True) 214 | 215 | def gui_open_device(self, id): 216 | logging.info('gui_open_device') 217 | # if the selection is empty (after remove_all) 218 | if id == -1: 219 | return 220 | 221 | self.close_device() 222 | self.open_device(self.devices[id]) 223 | self.init_gui_device() 224 | 225 | def reopen_device(self): 226 | opened_page = self.stack.get_visible_child_name() 227 | device = self.device 228 | self.close_device() 229 | self.open_device(device) 230 | self.init_gui_device() 231 | self.stack.set_visible_child_full(opened_page, Gtk.StackTransitionType.NONE) 232 | 233 | def open_device(self, device): 234 | logging.info(f'opening device: {device.path}') 235 | try: 236 | self.fd = os.open(device.path, os.O_RDWR, 0) 237 | except Exception as e: 238 | logging.error(f'os.open({device.path}, os.O_RDWR, 0) failed: {e}') 239 | 240 | self.camera = CameraCtrls(device.path, self.fd) 241 | self.listener = self.camera.subscribe_events( 242 | lambda c: GLib.idle_add(self.update_ctrl_value, c), 243 | lambda errs: GLib.idle_add(self.notify, '\n'.join(errs)), 244 | ) 245 | self.device = device 246 | self.ptz_controllers = PTZHWControllers(self.device.path, 247 | lambda check, p, i: GLib.timeout_add(300, check, p, i), 248 | lambda err: self.notify(err), 249 | lambda i: self.ptz_lb.get_row_at_index(i).get_child().set_active(False), 250 | ) 251 | self.ptz_model.splice(0, self.ptz_model.get_n_items(), [GStr(n) for n in self.ptz_controllers.get_names()]) 252 | for i in range(self.ptz_model.get_n_items()): 253 | row = self.ptz_lb.get_row_at_index(i) 254 | row.set_activatable(False) 255 | row.get_child().connect('toggled', lambda c, i=i: self.ptz_controllers.set_active(i, c.get_active())) 256 | self.ptz_sw.set_visible(self.camera.has_ptz()) 257 | self.ptz_sw.set_sensitive(self.ptz_model.get_n_items() != 0) 258 | self.open_cam_button.set_action_target_value(GLib.Variant('s', self.device.path)) 259 | 260 | def close_device(self): 261 | if self.fd: 262 | logging.info('close_device') 263 | self.device = None 264 | self.camera = None 265 | self.listener.stop() 266 | self.listener = None 267 | self.ptz_controllers.terminate_all() 268 | self.ptz_controllers = None 269 | os.close(self.fd) 270 | self.fd = 0 271 | 272 | def init_gui_device(self): 273 | logging.info('init_gui_device') 274 | if self.frame: 275 | self.frame.destroy() 276 | 277 | if self.device is None: 278 | return 279 | 280 | self.frame = Gtk.Grid(hexpand=True, halign=Gtk.Align.FILL) 281 | self.grid.attach(self.frame, 0, 0, 1, 1) 282 | 283 | stack_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin=10, vexpand=True, halign=Gtk.Align.FILL) 284 | stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, transition_duration=500) 285 | stack_sw = Gtk.StackSwitcher(stack=stack, hexpand=True, halign=Gtk.Align.CENTER) 286 | stack_box.pack_start(stack_sw, False, False, 0) 287 | scrolledwindow = Gtk.ScrolledWindow(child=stack, propagate_natural_height=True, hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC) 288 | stack_box.pack_start(scrolledwindow, False, False, 0) 289 | 290 | footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=10) 291 | stack_box.pack_start(footer, False, False, 0) 292 | 293 | self.frame.attach(stack_box, 0, 1, 1, 1) 294 | self.stack = stack 295 | 296 | for page_n, page in enumerate(self.camera.get_ctrl_pages()): 297 | page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 298 | if page.target == 'main': 299 | stack.add_titled(page_box, str(page_n), page.title) 300 | elif page.target == 'footer': 301 | sep = Gtk.Separator(margin_bottom=10) 302 | footer.add(sep) 303 | footer.add(page_box) 304 | 305 | for cat in page.categories: 306 | if page.target != 'footer': 307 | c_label = Gtk.Label(xalign=0, margin_bottom=10, margin_top=10) 308 | c_label.set_markup(f'{cat.title}') 309 | page_box.pack_start(c_label, False, False, 0) 310 | 311 | ctrls_frame = Gtk.Frame() 312 | page_box.pack_start(ctrls_frame, False, False, 0) 313 | 314 | ctrls_listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) 315 | ctrls_listbox.set_header_func(lambda row, before: row.set_header(Gtk.Separator()) if before is not None else None) 316 | ctrls_frame.add(ctrls_listbox) 317 | 318 | for c in cat.ctrls: 319 | ctrl_row = Gtk.ListBoxRow() 320 | ctrls_listbox.add(ctrl_row) 321 | 322 | ctrl_box = Gtk.Box(margin_left=5, margin_right=5, height_request=45) 323 | ctrl_row.add(ctrl_box) 324 | 325 | label = Gtk.Label(label=c.name, xalign=0, margin_right=5) 326 | tooltip_markup = f'{c.text_id}' 327 | if c.kernel_id: 328 | tooltip_markup += f' ({c.kernel_id})' 329 | if c.tooltip: 330 | tooltip_markup += f'\n\n{c.tooltip}' 331 | label.set_tooltip_markup(tooltip_markup) 332 | ctrl_box.pack_start(label, False, False, 0) 333 | 334 | c.gui_ctrls = [label] 335 | c.gui_value_set = None 336 | 337 | if c.type == 'integer': 338 | adjustment = Gtk.Adjustment(lower=c.min, upper=c.max, value=c.value, step_increment=1) 339 | adjustment.connect('value-changed', lambda a,c=c: self.update_ctrl(c, a.get_value())) 340 | scale = FormatScale(c.format_value, orientation=Gtk.Orientation.HORIZONTAL, 341 | digits=0, has_origin=False, value_pos=Gtk.PositionType.LEFT, adjustment=adjustment, width_request=264) 342 | if c.zeroer: 343 | scale.connect('button-release-event', lambda sc, e: sc.set_value(0)) 344 | scale.connect('key-press-event', self.handle_ptz_speed_key_pressed) 345 | scale.connect('key-release-event', self.handle_ptz_speed_key_released) 346 | if c.step and c.step != 1: 347 | adjustment_step = Gtk.Adjustment(lower=c.min, upper=c.max, value=c.value, step_increment=c.step) 348 | adjustment_step.connect('value-changed', lambda a,c=c,a1=adjustment: [a.set_value(a.get_value() - a.get_value() % c.step),a1.set_value(a.get_value())]) 349 | scale.set_adjustment(adjustment_step) 350 | if c.step_big: 351 | scale.get_adjustment().set_page_increment(c.step_big) 352 | if c.scale_class: 353 | scale.get_style_context().add_class(c.scale_class) 354 | 355 | if c.default is not None: 356 | scale.add_mark(value=c.default, position=Gtk.PositionType.BOTTOM, markup=None) 357 | 358 | if c.text_id == 'zoom_absolute': 359 | self.zoom_absolute_sc = scale 360 | 361 | if c.text_id == 'pan_speed': 362 | self.pan_speed_sc = scale 363 | 364 | if c.text_id == 'tilt_speed': 365 | self.tilt_speed_sc = scale 366 | 367 | if c.text_id == 'pan_absolute': 368 | self.pan_absolute_sc = scale 369 | scale.connect('key-press-event', self.handle_ptz_absolute_key_pressed) 370 | 371 | if c.text_id == 'tilt_absolute': 372 | self.tilt_absolute_sc = scale 373 | scale.connect('key-press-event', self.handle_ptz_absolute_key_pressed) 374 | 375 | refresh = Gtk.Button(image=Gtk.Image(icon_name='edit-undo-symbolic', icon_size=Gtk.IconSize.BUTTON), valign=Gtk.Align.CENTER, halign=Gtk.Align.START, relief=Gtk.ReliefStyle.NONE) 376 | refresh.connect('clicked', lambda e, c=c, sc=scale: sc.get_adjustment().set_value(c.default)) 377 | ctrl_box.pack_start(refresh, False, False, 0) 378 | scale_stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, transition_duration=500) 379 | scale_box = Gtk.Box(halign=Gtk.Align.END) 380 | spin_box = Gtk.Box(halign=Gtk.Align.END) 381 | prev_button = Gtk.Button(image=Gtk.Image(icon_name='go-previous-symbolic', icon_size=Gtk.IconSize.BUTTON), relief=Gtk.ReliefStyle.NONE, opacity=0.2) 382 | prev_button.connect('clicked', lambda e, st=scale_stack, sc=scale_box: st.set_visible_child(sc)) 383 | next_button = Gtk.Button(image=Gtk.Image(icon_name='go-next-symbolic', icon_size=Gtk.IconSize.BUTTON), relief=Gtk.ReliefStyle.NONE, opacity=0.2) 384 | next_button.connect('clicked', lambda e, st=scale_stack, sp=spin_box: st.set_visible_child(sp)) 385 | scale_box.pack_start(scale, False, False, 0) 386 | scale_box.pack_start(next_button, False, False, 0) 387 | spin = Gtk.SpinButton(adjustment=scale.get_adjustment()) 388 | spin_box.pack_start(spin, False, False, 0) 389 | spin_box.pack_start(prev_button, False, False, 0) 390 | scale_stack.add(scale_box) 391 | scale_stack.add(spin_box) 392 | ctrl_box.pack_end(scale_stack, False, False, 0) 393 | c.gui_value_set = scale.set_value 394 | c.gui_ctrls += [scale, spin, refresh] 395 | c.gui_default_btn = refresh 396 | 397 | elif c.type == 'boolean': 398 | switch = Gtk.Switch(valign=Gtk.Align.CENTER, active=c.value, margin_right=5) 399 | switch.connect('state-set', lambda w,state,c=c: self.update_ctrl(c, state)) 400 | refresh = Gtk.Button(image=Gtk.Image(icon_name='edit-undo-symbolic', icon_size=Gtk.IconSize.BUTTON), valign=Gtk.Align.CENTER, halign=Gtk.Align.START, relief=Gtk.ReliefStyle.NONE) 401 | if c.default is not None: 402 | refresh.connect('clicked', lambda e,switch=switch,c=c: switch.set_active(c.default)) 403 | ctrl_box.pack_start(refresh, False, False, 0) 404 | ctrl_box.pack_end(switch, False, False, 0) 405 | c.gui_value_set = switch.set_active 406 | c.gui_ctrls += [switch, refresh] 407 | c.gui_default_btn = refresh 408 | 409 | elif c.type == 'button': 410 | filtered_menu = [m for m in c.menu if m.value is not None and not m.gui_hidden] 411 | children_per_line = 4 412 | grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True, column_spacing=4, row_spacing=4) 413 | refresh = Gtk.Button(image=Gtk.Image(icon_name='edit-undo-symbolic', icon_size=Gtk.IconSize.BUTTON), valign=Gtk.Align.CENTER, halign=Gtk.Align.START, relief=Gtk.ReliefStyle.NONE) 414 | ctrl_box.pack_start(refresh, False, False, 0) 415 | ctrl_box.pack_end(grid, False, False, 0) 416 | for idx, m in enumerate(filtered_menu): 417 | b = Gtk.Button(label=m.name, valign=Gtk.Align.CENTER) 418 | b.connect('clicked', lambda e, c=c, m=m: self.update_ctrl(c, m.text_id)) 419 | if c.child_tooltip: 420 | b.set_tooltip_markup(c.child_tooltip) 421 | if m.lp_text_id is not None: 422 | b.gui_lp = Gtk.GestureLongPress(widget=b) 423 | b.gui_lp.connect('pressed', lambda lp, x, y, c=c, m=m: [ 424 | lp.set_state(Gtk.EventSequenceState.CLAIMED), 425 | self.update_ctrl(c, m.lp_text_id) 426 | ]) 427 | grid.attach(b, idx % children_per_line, idx // children_per_line, 1, 1) 428 | c.gui_ctrls += [b] 429 | if c.default is not None: 430 | refresh.connect('clicked', lambda e,c=c: self.update_ctrl(c, c.default)) 431 | c.gui_ctrls += [refresh] 432 | c.gui_default_btn = refresh 433 | 434 | elif c.type == 'info': 435 | label = Gtk.Label(label=c.value, selectable=True, justify=Gtk.Justification.RIGHT, 436 | wrap=True, wrap_mode=Pango.WrapMode.CHAR, 437 | max_width_chars=48, width_chars=32, xalign=1) 438 | ctrl_box.pack_end(label, False, False, 0) 439 | c.gui_value_set = label.set_label 440 | c.gui_default_btn = None 441 | 442 | elif c.type == 'menu': 443 | if len(c.menu) < 4 and not c.menu_dd: 444 | box = Gtk.ButtonBox(valign=Gtk.Align.CENTER) 445 | box.set_layout(Gtk.ButtonBoxStyle.EXPAND) 446 | box.set_homogeneous(False) 447 | rb = None 448 | for m in c.menu: 449 | rb = Gtk.RadioButton(group=rb, label=m.name) 450 | rb.set_mode(False) 451 | rb.set_active(m.text_id == c.value) 452 | rb.connect('toggled', lambda b, c=c, m=m: self.update_ctrl(c, m.text_id) if b.get_active() else None) 453 | box.add(rb) 454 | m.gui_rb = rb 455 | if c.value is None: 456 | rb = Gtk.RadioButton(group=rb, label='Undefined') 457 | rb.set_mode(False) 458 | rb.set_active(True) 459 | rb.set_no_show_all(True) 460 | box.add(rb) 461 | 462 | refresh = Gtk.Button(image=Gtk.Image(icon_name='edit-undo-symbolic', icon_size=Gtk.IconSize.BUTTON), valign=Gtk.Align.CENTER, halign=Gtk.Align.START, relief=Gtk.ReliefStyle.NONE) 463 | if c.default is not None: 464 | refresh.connect('clicked', lambda e,c=c: find_by_text_id(c.menu, c.default).gui_rb.set_active(True)) 465 | ctrl_box.pack_start(refresh, False, False, 0) 466 | ctrl_box.pack_end(box, False, False, 0) 467 | c.gui_value_set = lambda ctext, c=c: find_by_text_id(c.menu, ctext).gui_rb.set_active(True) 468 | c.gui_ctrls += [m.gui_rb for m in c.menu] + [refresh] 469 | c.gui_default_btn = refresh 470 | else: 471 | wb_cb = Gtk.ComboBoxText(valign=Gtk.Align.CENTER) 472 | for m in c.menu: 473 | wb_cb.append_text(m.name) 474 | if c.value: 475 | idx = find_idx(c.menu, lambda m: m.text_id == c.value) 476 | if idx is not None: 477 | wb_cb.set_active(idx) 478 | else: 479 | err = f'Control {c.text_id}: Can\'t find {c.value} in {[m.text_id for m in c.menu]}' 480 | logging.warning(err) 481 | self.notify(err) 482 | wb_cb.connect('changed', lambda e,c=c: GLib.idle_add(self.update_ctrl, c, c.menu[e.get_active()].text_id)) 483 | refresh = Gtk.Button(image=Gtk.Image(icon_name='edit-undo-symbolic', icon_size=Gtk.IconSize.BUTTON), valign=Gtk.Align.CENTER, halign=Gtk.Align.START, relief=Gtk.ReliefStyle.NONE) 484 | if c.default is not None: 485 | refresh.connect('clicked', lambda e,c=c,wb_cb=wb_cb: wb_cb.set_active(find_idx(c.menu, lambda m: m.text_id == c.default))) 486 | ctrl_box.pack_start(refresh, False, False, 0) 487 | ctrl_box.pack_end(wb_cb, False, False, 0) 488 | c.gui_value_set = lambda ctext, c=c, wb_cb=wb_cb: wb_cb.set_active(find_idx(c.menu, lambda m: m.text_id == ctext)) 489 | c.gui_ctrls += [wb_cb, refresh] 490 | c.gui_default_btn = refresh 491 | 492 | self.update_ctrls_state() 493 | self.grid.show_all() 494 | _, natsize = self.grid.get_preferred_size() 495 | self.resize(natsize.width, natsize.height) 496 | 497 | def close_notify(self): 498 | self._revealer.set_reveal_child(False) 499 | self._notify_timeout = None 500 | # False removes the timeout 501 | return False 502 | 503 | def notify(self, message, timeout=5): 504 | if self._notify_timeout is not None: 505 | GLib.Source.remove(self._notify_timeout) 506 | 507 | self._notify_label.set_text(message) 508 | self._revealer.set_reveal_child(True) 509 | 510 | if timeout > 0: 511 | self._notify_timeout = GLib.timeout_add_seconds(timeout, self.close_notify) 512 | 513 | def update_ctrl(self, ctrl, value): 514 | # only update if out of sync (when new value comes from the gui) 515 | if ctrl.value != value and not ctrl.inactive: 516 | errs = [] 517 | self.camera.setup_ctrls({ctrl.text_id: value}, errs) 518 | if errs: 519 | self.notify('\n'.join(errs)) 520 | GLib.idle_add(self.update_ctrl_value, ctrl), 521 | return 522 | 523 | self.update_ctrls_state() 524 | if ctrl.reopener: 525 | GLib.idle_add(self.reopen_device) 526 | 527 | def update_ctrls_state(self): 528 | for c in self.camera.get_ctrls(): 529 | self.update_ctrl_state(c) 530 | 531 | def update_ctrl_state(self, c): 532 | for gui_ctrl in c.gui_ctrls: 533 | gui_ctrl.set_sensitive(not c.inactive and not c.readonly) 534 | if c.gui_default_btn is not None: 535 | visible = not c.inactive and not c.readonly and ( 536 | c.default is not None and c.value is not None and c.default != c.value or \ 537 | c.get_default is not None and not c.get_default() 538 | ) 539 | c.gui_default_btn.set_opacity(visible) 540 | c.gui_default_btn.set_can_focus(visible) 541 | 542 | def update_ctrl_value(self, c): 543 | if c.reopener: 544 | self.reopen_device() 545 | return 546 | if c.gui_value_set: 547 | c.gui_value_set(c.value) 548 | self.update_ctrl_state(c) 549 | 550 | def handle_ptz_speed_key_pressed(self, w, e): 551 | keyval = e.keyval 552 | state = e.state 553 | pan_lower = self.pan_speed_sc.get_adjustment().get_lower() 554 | pan_upper =self.pan_speed_sc.get_adjustment().get_upper() 555 | tilt_lower = self.tilt_speed_sc.get_adjustment().get_lower() 556 | tilt_upper = self.tilt_speed_sc.get_adjustment().get_upper() 557 | 558 | if keyval in [Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_a]: 559 | self.pan_speed_sc.set_value(pan_lower) 560 | elif keyval in [Gdk.KEY_Right, Gdk.KEY_KP_Right, Gdk.KEY_d]: 561 | self.pan_speed_sc.set_value(pan_upper) 562 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up, Gdk.KEY_w]: 563 | self.tilt_speed_sc.set_value(tilt_upper) 564 | elif keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down, Gdk.KEY_s]: 565 | self.tilt_speed_sc.set_value(tilt_lower) 566 | elif keyval == Gdk.KEY_KP_End: 567 | self.pan_speed_sc.set_value(pan_lower) 568 | self.tilt_speed_sc.set_value(tilt_lower) 569 | elif keyval == Gdk.KEY_KP_Page_Down: 570 | self.pan_speed_sc.set_value(pan_upper) 571 | self.tilt_speed_sc.set_value(tilt_lower) 572 | elif keyval == Gdk.KEY_KP_Home: 573 | self.pan_speed_sc.set_value(pan_lower) 574 | self.tilt_speed_sc.set_value(tilt_upper) 575 | elif keyval == Gdk.KEY_KP_Page_Up: 576 | self.pan_speed_sc.set_value(pan_upper) 577 | self.tilt_speed_sc.set_value(tilt_upper) 578 | elif self.zoom_absolute_sc is not None: 579 | return self.handle_ptz_key_pressed_zoom(keyval, state) 580 | else: 581 | return False 582 | return True 583 | 584 | def handle_ptz_absolute_key_pressed(self, w, e): 585 | keyval = e.keyval 586 | state = e.state 587 | pan_value = self.pan_absolute_sc.get_value() 588 | pan_step = self.pan_absolute_sc.get_adjustment().get_step_increment() 589 | tilt_value = self.tilt_absolute_sc.get_value() 590 | tilt_step = self.tilt_absolute_sc.get_adjustment().get_step_increment() 591 | 592 | if keyval in [Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_a]: 593 | self.pan_absolute_sc.set_value(pan_value - pan_step) 594 | elif keyval in [Gdk.KEY_Right, Gdk.KEY_KP_Right, Gdk.KEY_d]: 595 | self.pan_absolute_sc.set_value(pan_value + pan_step) 596 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up, Gdk.KEY_w]: 597 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 598 | elif keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down, Gdk.KEY_s]: 599 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 600 | elif keyval == Gdk.KEY_KP_End: 601 | self.pan_absolute_sc.set_value(pan_value - pan_step) 602 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 603 | elif keyval == Gdk.KEY_KP_Page_Down: 604 | self.pan_absolute_sc.set_value(pan_value + pan_step) 605 | self.tilt_absolute_sc.set_value(tilt_value - tilt_step) 606 | elif keyval == Gdk.KEY_KP_Home: 607 | self.pan_absolute_sc.set_value(pan_value - pan_step) 608 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 609 | elif keyval == Gdk.KEY_KP_Page_Up: 610 | self.pan_absolute_sc.set_value(pan_value + pan_step) 611 | self.tilt_absolute_sc.set_value(tilt_value + tilt_step) 612 | elif keyval == Gdk.KEY_KP_Insert: 613 | self.pan_absolute_sc.set_value(0) 614 | self.tilt_absolute_sc.set_value(0) 615 | elif self.zoom_absolute_sc is not None: 616 | return self.handle_ptz_key_pressed_zoom(keyval, state) 617 | else: 618 | return False 619 | return True 620 | 621 | def handle_ptz_key_pressed_zoom(self, keyval, state): 622 | zoom_value = self.zoom_absolute_sc.get_value() 623 | zoom_step = self.zoom_absolute_sc.get_adjustment().get_step_increment() 624 | zoom_page = self.zoom_absolute_sc.get_adjustment().get_page_increment() 625 | zoom_lower = self.zoom_absolute_sc.get_adjustment().get_lower() 626 | zoom_upper = self.zoom_absolute_sc.get_adjustment().get_upper() 627 | 628 | if keyval in [Gdk.KEY_plus, Gdk.KEY_KP_Add] and state & Gdk.ModifierType.CONTROL_MASK: 629 | self.zoom_absolute_sc.set_value(zoom_value + zoom_page) 630 | elif keyval in [Gdk.KEY_minus, Gdk.KEY_KP_Subtract] and state & Gdk.ModifierType.CONTROL_MASK: 631 | self.zoom_absolute_sc.set_value(zoom_value - zoom_page) 632 | elif keyval in [Gdk.KEY_plus, Gdk.KEY_KP_Add]: 633 | self.zoom_absolute_sc.set_value(zoom_value + zoom_step) 634 | elif keyval in [Gdk.KEY_minus, Gdk.KEY_KP_Subtract]: 635 | self.zoom_absolute_sc.set_value(zoom_value - zoom_step) 636 | elif keyval in [Gdk.KEY_Home]: 637 | self.zoom_absolute_sc.set_value(zoom_lower) 638 | elif keyval in [Gdk.KEY_End]: 639 | self.zoom_absolute_sc.set_value(zoom_upper) 640 | elif keyval in [Gdk.KEY_Page_Up]: 641 | self.zoom_absolute_sc.set_value(zoom_value + zoom_page) 642 | elif keyval in [Gdk.KEY_Page_Down]: 643 | self.zoom_absolute_sc.set_value(zoom_value - zoom_page) 644 | else: 645 | return False 646 | return True 647 | 648 | def handle_ptz_speed_key_released(self, w, e): 649 | keyval = e.keyval 650 | 651 | if keyval in [Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right, Gdk.KEY_a, Gdk.KEY_d]: 652 | self.pan_speed_sc.set_value(0) 653 | elif keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_KP_Up, Gdk.KEY_KP_Down, Gdk.KEY_w, Gdk.KEY_s]: 654 | self.tilt_speed_sc.set_value(0) 655 | elif keyval in [Gdk.KEY_KP_End, Gdk.KEY_KP_Page_Down, Gdk.KEY_KP_Home, Gdk.KEY_KP_Page_Up]: 656 | self.pan_speed_sc.set_value(0) 657 | self.tilt_speed_sc.set_value(0) 658 | else: 659 | return False 660 | return True 661 | 662 | class CameraCtrlsApp(Gtk.Application): 663 | def __init__(self, *args, **kwargs): 664 | super().__init__(*args, application_id='hu.irl.cameractrls', **kwargs) 665 | 666 | self.window = None 667 | self.child_processes = [] 668 | 669 | def do_startup(self): 670 | Gtk.Application.do_startup(self) 671 | 672 | icon_theme = Gtk.IconTheme.get_for_screen(Gdk.Screen.get_default()) 673 | icon_theme.append_search_path(f'{sys.path[0]}/pkg') 674 | 675 | action = Gio.SimpleAction.new('about', None) 676 | action.connect('activate', self.on_about) 677 | self.add_action(action) 678 | 679 | action = Gio.SimpleAction.new('open_camera_window', GLib.VariantType('s')) 680 | action.connect('activate', self.open_camera_window) 681 | self.add_action(action) 682 | 683 | action = Gio.SimpleAction.new('quit', None) 684 | action.connect('activate', self.on_quit) 685 | self.add_action(action) 686 | 687 | action = Gio.SimpleAction.new('alt_n', GLib.VariantType('i')) 688 | action.connect('activate', self.on_alt_n) 689 | self.add_action(action) 690 | 691 | self.set_accels_for_action('app.quit', ["Q"]) 692 | self.set_accels_for_action('app.alt_n(0)', ["1"]) 693 | self.set_accels_for_action('app.alt_n(1)', ["2"]) 694 | self.set_accels_for_action('app.alt_n(2)', ["3"]) 695 | self.set_accels_for_action('app.alt_n(3)', ["4"]) 696 | self.set_accels_for_action('app.alt_n(4)', ["5"]) 697 | self.set_accels_for_action('app.alt_n(5)', ["6"]) 698 | self.set_accels_for_action('app.alt_n(6)', ["7"]) 699 | 700 | def do_activate(self): 701 | self.window = CameraCtrlsWindow(application=self, title='Cameractrls') 702 | self.window.present() 703 | 704 | def on_about(self, action, param): 705 | about = Gtk.AboutDialog(transient_for=self.window, modal=self.window) 706 | about.set_program_name('Cameractrls') 707 | about.set_authors(['Gergo Koteles ']) 708 | about.set_artists(['Jorge Toledo https://lacajita.es']) 709 | about.set_copyright('Copyright © 2022 - 2024 Gergo Koteles') 710 | about.set_license_type(Gtk.License.LGPL_3_0) 711 | about.set_logo_icon_name('hu.irl.cameractrls') 712 | about.set_website(ghurl) 713 | about.set_website_label('GitHub') 714 | about.set_version(version) 715 | about.connect('response', lambda d, r: d.destroy()) 716 | about.present() 717 | 718 | def on_quit(self, action, param): 719 | self.window.close() 720 | 721 | def on_alt_n(self, action, param): 722 | page_n = str(param) 723 | if self.window.stack and self.window.stack.get_child_by_name(page_n): 724 | self.window.stack.set_visible_child_name(page_n) 725 | 726 | def check_preview_open(self, p): 727 | # if process returned 728 | if p.poll() is not None: 729 | (stdout, stderr) = p.communicate() 730 | errstr = stderr.decode() 731 | sys.stderr.write(errstr) 732 | if p.returncode != 0: 733 | self.window.notify(errstr.strip()) 734 | # False removes the timeout 735 | return False 736 | return True 737 | 738 | def open_camera_window(self, action, device): 739 | win_width, win_height = self.window.get_size() 740 | logging.info(f'open cameraview.py for {device.get_string()} with max size {win_width}x{win_height}') 741 | p = subprocess.Popen([f'{sys.path[0]}/cameraview.py', '-d', device.get_string(), '-s', f'{win_width}x{win_height}'], stderr=subprocess.PIPE) 742 | self.child_processes.append(p) 743 | GLib.timeout_add(300, self.check_preview_open, p) 744 | 745 | def kill_child_processes(self): 746 | for proc in self.child_processes: 747 | proc.kill() 748 | 749 | 750 | if __name__ == '__main__': 751 | app = CameraCtrlsApp() 752 | app.run() 753 | app.kill_child_processes() 754 | -------------------------------------------------------------------------------- /cameraview.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys, ctypes, ctypes.util, logging, mmap, struct, getopt, select 4 | from fcntl import ioctl 5 | from threading import Thread 6 | from operator import lt, gt 7 | 8 | from cameractrls import CameraCtrls, PTZController 9 | from cameractrls import v4l2_capability, v4l2_format, v4l2_streamparm, v4l2_requestbuffers, v4l2_buffer 10 | from cameractrls import VIDIOC_QUERYCAP, VIDIOC_G_FMT, VIDIOC_G_PARM, VIDIOC_S_PARM 11 | from cameractrls import VIDIOC_REQBUFS, VIDIOC_QUERYBUF, VIDIOC_QBUF, VIDIOC_DQBUF, VIDIOC_STREAMON, VIDIOC_STREAMOFF 12 | from cameractrls import V4L2_CAP_VIDEO_CAPTURE, V4L2_CAP_STREAMING, V4L2_MEMORY_MMAP, V4L2_BUF_TYPE_VIDEO_CAPTURE 13 | from cameractrls import V4L2_PIX_FMT_YUYV, V4L2_PIX_FMT_YVYU, V4L2_PIX_FMT_UYVY, V4L2_PIX_FMT_YU12, V4L2_PIX_FMT_YV12 14 | from cameractrls import V4L2_PIX_FMT_NV12, V4L2_PIX_FMT_NV21, V4L2_PIX_FMT_GREY 15 | from cameractrls import V4L2_PIX_FMT_RGB565, V4L2_PIX_FMT_RGB24, V4L2_PIX_FMT_BGR24, V4L2_PIX_FMT_RX24 16 | from cameractrls import V4L2_PIX_FMT_MJPEG, V4L2_PIX_FMT_JPEG 17 | 18 | sdl2lib = ctypes.util.find_library('SDL2-2.0') 19 | if sdl2lib is None: 20 | print('libSDL2 not found, please install the libsdl2-2.0 package!') 21 | sys.exit(2) 22 | sdl2 = ctypes.CDLL(sdl2lib) 23 | 24 | turbojpeglib = ctypes.util.find_library('turbojpeg') 25 | if turbojpeglib is None: 26 | print('libturbojpeg not found, please install the libturbojpeg package!') 27 | sys.exit(2) 28 | turbojpeg = ctypes.CDLL(turbojpeglib) 29 | 30 | class SDL_PixelFormat(ctypes.Structure): 31 | _fields_ = [ 32 | ('format', ctypes.c_uint32), 33 | ('palette', ctypes.c_void_p), 34 | ] 35 | 36 | class SDL_Surface(ctypes.Structure): 37 | _fields_ = [ 38 | ('flags', ctypes.c_uint32), 39 | ('format', ctypes.POINTER(SDL_PixelFormat)), 40 | ('w', ctypes.c_int), 41 | ('h', ctypes.c_int), 42 | ('pitch', ctypes.c_int), 43 | ('pixels', ctypes.c_void_p), 44 | ] 45 | 46 | class SDL_Rect(ctypes.Structure): 47 | _fields_ = [ 48 | ('x', ctypes.c_int), 49 | ('y', ctypes.c_int), 50 | ('w', ctypes.c_int), 51 | ('h', ctypes.c_int), 52 | ] 53 | 54 | SDL_Init = sdl2.SDL_Init 55 | SDL_Init.restype = ctypes.c_int 56 | SDL_Init.argtypes = [ctypes.c_uint32] 57 | # int SDL_Init(Uint32 flags); 58 | 59 | SDL_GetError = sdl2.SDL_GetError 60 | SDL_GetError.restype = ctypes.c_char_p 61 | SDL_GetError.argtypes = [] 62 | # const char* SDL_GetError(void); 63 | 64 | SDL_RegisterEvents = sdl2.SDL_RegisterEvents 65 | SDL_RegisterEvents.restype = ctypes.c_uint32 66 | SDL_RegisterEvents.argtypes = [ctypes.c_int] 67 | # Uint32 SDL_RegisterEvents(int numevents); 68 | 69 | SDL_CreateWindow = sdl2.SDL_CreateWindow 70 | SDL_CreateWindow.restype = ctypes.c_void_p 71 | SDL_CreateWindow.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_uint32] 72 | # SDL_Window * SDL_CreateWindow(const char *title, int x, int y, int w, int h, Uint32 flags); 73 | 74 | SDL_CreateRenderer = sdl2.SDL_CreateRenderer 75 | SDL_CreateRenderer.restype = ctypes.c_void_p 76 | SDL_CreateRenderer.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_uint32] 77 | # SDL_Renderer * SDL_CreateRenderer(SDL_Window * window, int index, Uint32 flags); 78 | 79 | SDL_RenderGetLogicalSize = sdl2.SDL_RenderGetLogicalSize 80 | SDL_RenderGetLogicalSize.restype = None 81 | SDL_RenderGetLogicalSize.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)] 82 | # void SDL_RenderGetLogicalSize(SDL_Renderer * renderer, int *w, int *h); 83 | 84 | SDL_RenderSetLogicalSize = sdl2.SDL_RenderSetLogicalSize 85 | SDL_RenderSetLogicalSize.restype = ctypes.c_int 86 | SDL_RenderSetLogicalSize.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] 87 | # int SDL_RenderSetLogicalSize(SDL_Renderer * renderer, int w, int h); 88 | 89 | SDL_GetWindowSize = sdl2.SDL_GetWindowSize 90 | SDL_GetWindowSize.restype = None 91 | SDL_GetWindowSize.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)] 92 | #void SDL_GetWindowSize(SDL_Window * window, int *w, int *h); 93 | 94 | SDL_SetWindowSize = sdl2.SDL_SetWindowSize 95 | SDL_SetWindowSize.restype = None 96 | SDL_SetWindowSize.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] 97 | #void SDL_SetWindowSize(SDL_Window * window, int w, int h); 98 | 99 | SDL_CreateTexture = sdl2.SDL_CreateTexture 100 | SDL_CreateTexture.restype = ctypes.c_void_p 101 | SDL_CreateTexture.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int, ctypes.c_int, ctypes.c_int] 102 | # SDL_Texture * SDL_CreateTexture(SDL_Renderer * renderer, Uint32 format, int access, int w, int h); 103 | 104 | SDL_UpdateTexture = sdl2.SDL_UpdateTexture 105 | SDL_UpdateTexture.restype = ctypes.c_int 106 | SDL_UpdateTexture.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int] 107 | # int SDL_UpdateTexture(SDL_Texture * texture, const SDL_Rect * rect, const void *pixels, int pitch); 108 | 109 | SDL_RenderClear = sdl2.SDL_RenderClear 110 | SDL_RenderClear.restype = ctypes.c_int 111 | SDL_RenderClear.argtypes = [ctypes.c_void_p] 112 | # int SDL_RenderClear(SDL_Renderer * renderer); 113 | 114 | SDL_RenderCopyEx = sdl2.SDL_RenderCopyEx 115 | SDL_RenderCopyEx.restype = ctypes.c_int 116 | SDL_RenderCopyEx.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(SDL_Rect), ctypes.POINTER(SDL_Rect), ctypes.c_double, ctypes.c_void_p, ctypes.c_int] 117 | #int SDL_RenderCopyEx(SDL_Renderer * renderer, SDL_Texture * texture, const SDL_Rect * srcrect, const SDL_Rect * dstrect, 118 | # const double angle, const SDL_Point *center, const SDL_RendererFlip flip); 119 | 120 | SDL_CreateRGBSurfaceFrom = sdl2.SDL_CreateRGBSurfaceFrom 121 | SDL_CreateRGBSurfaceFrom.restype = ctypes.POINTER(SDL_Surface) 122 | SDL_CreateRGBSurfaceFrom.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, 123 | ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32] 124 | #SDL_Surface* SDL_CreateRGBSurfaceFrom(void *pixels, int width, int height, int depth, int pitch, 125 | # Uint32 Rmask, Uint32 Gmask, Uint32 Bmask, Uint32 Amask); 126 | 127 | SDL_ConvertPixels = sdl2.SDL_ConvertPixels 128 | SDL_ConvertPixels.restype = ctypes.c_int 129 | SDL_ConvertPixels.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int] 130 | #int SDL_ConvertPixels(int width, int height, 131 | # Uint32 src_format, 132 | # const void * src, int src_pitch, 133 | # Uint32 dst_format, 134 | # void * dst, int dst_pitch); 135 | 136 | SDL_SetPaletteColors = sdl2.SDL_SetPaletteColors 137 | SDL_SetPaletteColors.restype = ctypes.c_int 138 | SDL_SetPaletteColors.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int] 139 | #int SDL_SetPaletteColors(SDL_Palette * palette, const SDL_Color * colors, int firstcolor, int ncolors); 140 | 141 | SDL_CreateTextureFromSurface = sdl2.SDL_CreateTextureFromSurface 142 | SDL_CreateTextureFromSurface.restype = ctypes.c_void_p 143 | SDL_CreateTextureFromSurface.argtypes = [ctypes.c_void_p, ctypes.c_void_p] 144 | #SDL_Texture* SDL_CreateTextureFromSurface(SDL_Renderer * renderer, SDL_Surface * surface); 145 | 146 | SDL_DestroyTexture = sdl2.SDL_DestroyTexture 147 | SDL_DestroyTexture.restype = None 148 | SDL_DestroyTexture.argtypes = [ctypes.c_void_p] 149 | #void SDL_DestroyTexture(SDL_Texture * texture); 150 | 151 | SDL_RenderPresent = sdl2.SDL_RenderPresent 152 | SDL_RenderPresent.restype = None 153 | SDL_RenderPresent.argtypes = [ctypes.c_void_p] 154 | # void SDL_RenderPresent(SDL_Renderer * renderer); 155 | 156 | SDL_PushEvent = sdl2.SDL_PushEvent 157 | SDL_PushEvent.restype = ctypes.c_int 158 | SDL_PushEvent.argtypes = [ctypes.c_void_p] 159 | #int SDL_PushEvent(SDL_Event * event); 160 | 161 | SDL_WaitEvent = sdl2.SDL_WaitEvent 162 | SDL_WaitEvent.restype = ctypes.c_int 163 | SDL_WaitEvent.argtypes = [ctypes.c_void_p] 164 | # int SDL_WaitEvent(SDL_Event * event); 165 | 166 | SDL_DestroyWindow = sdl2.SDL_DestroyWindow 167 | SDL_DestroyWindow.argtypes = [ctypes.c_void_p] 168 | # void SDL_DestroyWindow(SDL_Window * window); 169 | 170 | SDL_Quit = sdl2.SDL_Quit 171 | # void SDL_Quit(void); 172 | 173 | SDL_SetWindowFullscreen = sdl2.SDL_SetWindowFullscreen 174 | SDL_SetWindowFullscreen.restype = ctypes.c_int 175 | SDL_SetWindowFullscreen.argtypes = [ctypes.c_void_p, ctypes.c_uint32] 176 | # int SDL_SetWindowFullscreen(SDL_Window * window, Uint32 flags); 177 | 178 | SDL_ShowSimpleMessageBox = sdl2.SDL_ShowSimpleMessageBox 179 | SDL_ShowSimpleMessageBox.restype = ctypes.c_int 180 | SDL_ShowSimpleMessageBox.argtypes = [ctypes.c_uint32, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p] 181 | # int SDL_ShowSimpleMessageBox(Uint32 flags, const char *title, const char *message, SDL_Window *window); 182 | 183 | SDL_INIT_VIDEO = 0x00000020 184 | SDL_QUIT = 0x100 185 | SDL_KEYDOWN = 0x300 186 | SDL_KEYUP = 0x301 187 | SDL_MOUSEBUTTONUP = 0x402 188 | SDL_BUTTON_LEFT = 1 189 | SDLK_1 = ord('1') 190 | SDLK_2 = ord('2') 191 | SDLK_3 = ord('3') 192 | SDLK_4 = ord('4') 193 | SDLK_5 = ord('5') 194 | SDLK_6 = ord('6') 195 | SDLK_7 = ord('7') 196 | SDLK_8 = ord('8') 197 | SDLK_c = ord('c') 198 | SDLK_f = ord('f') 199 | SDLK_m = ord('m') 200 | SDLK_q = ord('q') 201 | SDLK_r = ord('r') 202 | SDLK_w = ord('w') 203 | SDLK_a = ord('a') 204 | SDLK_s = ord('s') 205 | SDLK_d = ord('d') 206 | SDLK_PLUS = ord('+') 207 | SDLK_MINUS = ord('-') 208 | SDLK_ESCAPE = 27 209 | SDLK_SCANCODE_MASK = 1<<30 210 | 211 | SDLK_HOME = 74 | SDLK_SCANCODE_MASK 212 | SDLK_PAGEUP = 75 | SDLK_SCANCODE_MASK 213 | SDLK_END = 77 | SDLK_SCANCODE_MASK 214 | SDLK_PAGEDOWN = 78 | SDLK_SCANCODE_MASK 215 | SDLK_RIGHT = 79 | SDLK_SCANCODE_MASK 216 | SDLK_LEFT = 80 | SDLK_SCANCODE_MASK 217 | SDLK_DOWN = 81 | SDLK_SCANCODE_MASK 218 | SDLK_UP = 82 | SDLK_SCANCODE_MASK 219 | SDLK_KP_MINUS = 86 | SDLK_SCANCODE_MASK 220 | SDLK_KP_PLUS = 87 | SDLK_SCANCODE_MASK 221 | SDLK_KP_1 = 89 | SDLK_SCANCODE_MASK 222 | SDLK_KP_2 = 90 | SDLK_SCANCODE_MASK 223 | SDLK_KP_3 = 91 | SDLK_SCANCODE_MASK 224 | SDLK_KP_4 = 92 | SDLK_SCANCODE_MASK 225 | SDLK_KP_5 = 93 | SDLK_SCANCODE_MASK 226 | SDLK_KP_6 = 94 | SDLK_SCANCODE_MASK 227 | SDLK_KP_7 = 95 | SDLK_SCANCODE_MASK 228 | SDLK_KP_8 = 96 | SDLK_SCANCODE_MASK 229 | SDLK_KP_9 = 97 | SDLK_SCANCODE_MASK 230 | SDLK_KP_0 = 98 | SDLK_SCANCODE_MASK 231 | 232 | KMOD_NONE = 0x0000 233 | KMOD_LSHIFT = 0x0001 234 | KMOD_RSHIFT = 0x0002 235 | KMOD_LCTRL = 0x0040 236 | KMOD_RCTRL = 0x0080 237 | KMOD_SHIFT = KMOD_LSHIFT | KMOD_RSHIFT 238 | KMOD_CTRL = KMOD_LCTRL | KMOD_RCTRL 239 | 240 | 241 | SDL_PAL_GRAYSCALE_L = b'\ 242 | \x00\x00\x00\0\x01\x01\x01\0\x02\x02\x02\0\x03\x03\x03\0\x04\x04\x04\0\x05\x05\x05\0\x06\x06\x06\0\x07\x07\x07\0\x08\x08\x08\0\x09\x09\x09\0\x0a\x0a\x0a\0\ 243 | \x0b\x0b\x0b\0\x0c\x0c\x0c\0\x0d\x0d\x0d\0\x0e\x0e\x0e\0\x0f\x0f\x0f\0\x10\x10\x10\0\x11\x11\x11\0\x12\x12\x12\0\x13\x13\x13\0\x14\x14\x14\0\x15\x15\x15\0\ 244 | \x16\x16\x16\0\x17\x17\x17\0\x18\x18\x18\0\x19\x19\x19\0\x1a\x1a\x1a\0\x1b\x1b\x1b\0\x1c\x1c\x1c\0\x1d\x1d\x1d\0\x1e\x1e\x1e\0\x1f\x1f\x1f\0\x20\x20\x20\0\ 245 | \x21\x21\x21\0\x22\x22\x22\0\x23\x23\x23\0\x24\x24\x24\0\x25\x25\x25\0\x26\x26\x26\0\x27\x27\x27\0\x28\x28\x28\0\x29\x29\x29\0\x2a\x2a\x2a\0\x2b\x2b\x2b\0\ 246 | \x2c\x2c\x2c\0\x2d\x2d\x2d\0\x2e\x2e\x2e\0\x2f\x2f\x2f\0\x30\x30\x30\0\x31\x31\x31\0\x32\x32\x32\0\x33\x33\x33\0\x34\x34\x34\0\x35\x35\x35\0\x36\x36\x36\0\ 247 | \x37\x37\x37\0\x38\x38\x38\0\x39\x39\x39\0\x3a\x3a\x3a\0\x3b\x3b\x3b\0\x3c\x3c\x3c\0\x3d\x3d\x3d\0\x3e\x3e\x3e\0\x3f\x3f\x3f\0\x40\x40\x40\0\x41\x41\x41\0\ 248 | \x42\x42\x42\0\x43\x43\x43\0\x44\x44\x44\0\x45\x45\x45\0\x46\x46\x46\0\x47\x47\x47\0\x48\x48\x48\0\x49\x49\x49\0\x4a\x4a\x4a\0\x4b\x4b\x4b\0\x4c\x4c\x4c\0\ 249 | \x4d\x4d\x4d\0\x4e\x4e\x4e\0\x4f\x4f\x4f\0\x50\x50\x50\0\x51\x51\x51\0\x52\x52\x52\0\x53\x53\x53\0\x54\x54\x54\0\x55\x55\x55\0\x56\x56\x56\0\x57\x57\x57\0\ 250 | \x58\x58\x58\0\x59\x59\x59\0\x5a\x5a\x5a\0\x5b\x5b\x5b\0\x5c\x5c\x5c\0\x5d\x5d\x5d\0\x5e\x5e\x5e\0\x5f\x5f\x5f\0\x60\x60\x60\0\x61\x61\x61\0\x62\x62\x62\0\ 251 | \x63\x63\x63\0\x64\x64\x64\0\x65\x65\x65\0\x66\x66\x66\0\x67\x67\x67\0\x68\x68\x68\0\x69\x69\x69\0\x6a\x6a\x6a\0\x6b\x6b\x6b\0\x6c\x6c\x6c\0\x6d\x6d\x6d\0\ 252 | \x6e\x6e\x6e\0\x6f\x6f\x6f\0\x70\x70\x70\0\x71\x71\x71\0\x72\x72\x72\0\x73\x73\x73\0\x74\x74\x74\0\x75\x75\x75\0\x76\x76\x76\0\x77\x77\x77\0\x78\x78\x78\0\ 253 | \x79\x79\x79\0\x7a\x7a\x7a\0\x7b\x7b\x7b\0\x7c\x7c\x7c\0\x7d\x7d\x7d\0\x7e\x7e\x7e\0\x7f\x7f\x7f\0\x80\x80\x80\0\x81\x81\x81\0\x82\x82\x82\0\x83\x83\x83\0\ 254 | \x84\x84\x84\0\x85\x85\x85\0\x86\x86\x86\0\x87\x87\x87\0\x88\x88\x88\0\x89\x89\x89\0\x8a\x8a\x8a\0\x8b\x8b\x8b\0\x8c\x8c\x8c\0\x8d\x8d\x8d\0\x8e\x8e\x8e\0\ 255 | \x8f\x8f\x8f\0\x90\x90\x90\0\x91\x91\x91\0\x92\x92\x92\0\x93\x93\x93\0\x94\x94\x94\0\x95\x95\x95\0\x96\x96\x96\0\x97\x97\x97\0\x98\x98\x98\0\x99\x99\x99\0\ 256 | \x9a\x9a\x9a\0\x9b\x9b\x9b\0\x9c\x9c\x9c\0\x9d\x9d\x9d\0\x9e\x9e\x9e\0\x9f\x9f\x9f\0\xa0\xa0\xa0\0\xa1\xa1\xa1\0\xa2\xa2\xa2\0\xa3\xa3\xa3\0\xa4\xa4\xa4\0\ 257 | \xa5\xa5\xa5\0\xa6\xa6\xa6\0\xa7\xa7\xa7\0\xa8\xa8\xa8\0\xa9\xa9\xa9\0\xaa\xaa\xaa\0\xab\xab\xab\0\xac\xac\xac\0\xad\xad\xad\0\xae\xae\xae\0\xaf\xaf\xaf\0\ 258 | \xb0\xb0\xb0\0\xb1\xb1\xb1\0\xb2\xb2\xb2\0\xb3\xb3\xb3\0\xb4\xb4\xb4\0\xb5\xb5\xb5\0\xb6\xb6\xb6\0\xb7\xb7\xb7\0\xb8\xb8\xb8\0\xb9\xb9\xb9\0\xba\xba\xba\0\ 259 | \xbb\xbb\xbb\0\xbc\xbc\xbc\0\xbd\xbd\xbd\0\xbe\xbe\xbe\0\xbf\xbf\xbf\0\xc0\xc0\xc0\0\xc1\xc1\xc1\0\xc2\xc2\xc2\0\xc3\xc3\xc3\0\xc4\xc4\xc4\0\xc5\xc5\xc5\0\ 260 | \xc6\xc6\xc6\0\xc7\xc7\xc7\0\xc8\xc8\xc8\0\xc9\xc9\xc9\0\xca\xca\xca\0\xcb\xcb\xcb\0\xcc\xcc\xcc\0\xcd\xcd\xcd\0\xce\xce\xce\0\xcf\xcf\xcf\0\xd0\xd0\xd0\0\ 261 | \xd1\xd1\xd1\0\xd2\xd2\xd2\0\xd3\xd3\xd3\0\xd4\xd4\xd4\0\xd5\xd5\xd5\0\xd6\xd6\xd6\0\xd7\xd7\xd7\0\xd8\xd8\xd8\0\xd9\xd9\xd9\0\xda\xda\xda\0\xdb\xdb\xdb\0\ 262 | \xdc\xdc\xdc\0\xdd\xdd\xdd\0\xde\xde\xde\0\xdf\xdf\xdf\0\xe0\xe0\xe0\0\xe1\xe1\xe1\0\xe2\xe2\xe2\0\xe3\xe3\xe3\0\xe4\xe4\xe4\0\xe5\xe5\xe5\0\xe6\xe6\xe6\0\ 263 | \xe7\xe7\xe7\0\xe8\xe8\xe8\0\xe9\xe9\xe9\0\xea\xea\xea\0\xeb\xeb\xeb\0\xec\xec\xec\0\xed\xed\xed\0\xee\xee\xee\0\xef\xef\xef\0\xf0\xf0\xf0\0\xf1\xf1\xf1\0\ 264 | \xf2\xf2\xf2\0\xf3\xf3\xf3\0\xf4\xf4\xf4\0\xf5\xf5\xf5\0\xf6\xf6\xf6\0\xf7\xf7\xf7\0\xf8\xf8\xf8\0\xf9\xf9\xf9\0\xfa\xfa\xfa\0\xfb\xfb\xfb\0\xfc\xfc\xfc\0\ 265 | \xfd\xfd\xfd\0\xfe\xfe\xfe\0\xff\xff\xff\0' 266 | SDL_PAL_GRAYSCALE = (ctypes.c_uint8 * len(SDL_PAL_GRAYSCALE_L))(*SDL_PAL_GRAYSCALE_L) 267 | 268 | # palettes from https://github.com/sciapp/gr/blob/master/lib/gr/cm.h 269 | # print('\\\n'.join(re.findall('.{0,154}', ''.join([f'\\x{hex(x)[2:4]}\\x{hex(x)[4:6]}\\x{hex(x)[6:8]}\\0' for x in pal])))) 270 | SDL_PAL_INFERNO_L = b'\ 271 | \x00\x00\x04\0\x01\x00\x05\0\x01\x01\x06\0\x01\x01\x08\0\x02\x01\x0a\0\x02\x02\x0c\0\x02\x02\x0e\0\x03\x02\x10\0\x04\x03\x12\0\x04\x03\x14\0\x05\x04\x17\0\ 272 | \x06\x04\x19\0\x07\x05\x1b\0\x08\x05\x1d\0\x09\x06\x1f\0\x0a\x07\x22\0\x0b\x07\x24\0\x0c\x08\x26\0\x0d\x08\x29\0\x0e\x09\x2b\0\x10\x09\x2d\0\x11\x0a\x30\0\ 273 | \x12\x0a\x32\0\x14\x0b\x34\0\x15\x0b\x37\0\x16\x0b\x39\0\x18\x0c\x3c\0\x19\x0c\x3e\0\x1b\x0c\x41\0\x1c\x0c\x43\0\x1e\x0c\x45\0\x1f\x0c\x48\0\x21\x0c\x4a\0\ 274 | \x23\x0c\x4c\0\x24\x0c\x4f\0\x26\x0c\x51\0\x28\x0b\x53\0\x29\x0b\x55\0\x2b\x0b\x57\0\x2d\x0b\x59\0\x2f\x0a\x5b\0\x31\x0a\x5c\0\x32\x0a\x5e\0\x34\x0a\x5f\0\ 275 | \x36\x09\x61\0\x38\x09\x62\0\x39\x09\x63\0\x3b\x09\x64\0\x3d\x09\x65\0\x3e\x09\x66\0\x40\x0a\x67\0\x42\x0a\x68\0\x44\x0a\x68\0\x45\x0a\x69\0\x47\x0b\x6a\0\ 276 | \x49\x0b\x6a\0\x4a\x0c\x6b\0\x4c\x0c\x6b\0\x4d\x0d\x6c\0\x4f\x0d\x6c\0\x51\x0e\x6c\0\x52\x0e\x6d\0\x54\x0f\x6d\0\x55\x0f\x6d\0\x57\x10\x6e\0\x59\x10\x6e\0\ 277 | \x5a\x11\x6e\0\x5c\x12\x6e\0\x5d\x12\x6e\0\x5f\x13\x6e\0\x61\x13\x6e\0\x62\x14\x6e\0\x64\x15\x6e\0\x65\x15\x6e\0\x67\x16\x6e\0\x69\x16\x6e\0\x6a\x17\x6e\0\ 278 | \x6c\x18\x6e\0\x6d\x18\x6e\0\x6f\x19\x6e\0\x71\x19\x6e\0\x72\x1a\x6e\0\x74\x1a\x6e\0\x75\x1b\x6e\0\x77\x1c\x6d\0\x78\x1c\x6d\0\x7a\x1d\x6d\0\x7c\x1d\x6d\0\ 279 | \x7d\x1e\x6d\0\x7f\x1e\x6c\0\x80\x1f\x6c\0\x82\x20\x6c\0\x84\x20\x6b\0\x85\x21\x6b\0\x87\x21\x6b\0\x88\x22\x6a\0\x8a\x22\x6a\0\x8c\x23\x69\0\x8d\x23\x69\0\ 280 | \x8f\x24\x69\0\x90\x25\x68\0\x92\x25\x68\0\x93\x26\x67\0\x95\x26\x67\0\x97\x27\x66\0\x98\x27\x66\0\x9a\x28\x65\0\x9b\x29\x64\0\x9d\x29\x64\0\x9f\x2a\x63\0\ 281 | \xa0\x2a\x63\0\xa2\x2b\x62\0\xa3\x2c\x61\0\xa5\x2c\x60\0\xa6\x2d\x60\0\xa8\x2e\x5f\0\xa9\x2e\x5e\0\xab\x2f\x5e\0\xad\x30\x5d\0\xae\x30\x5c\0\xb0\x31\x5b\0\ 282 | \xb1\x32\x5a\0\xb3\x32\x5a\0\xb4\x33\x59\0\xb6\x34\x58\0\xb7\x35\x57\0\xb9\x35\x56\0\xba\x36\x55\0\xbc\x37\x54\0\xbd\x38\x53\0\xbf\x39\x52\0\xc0\x3a\x51\0\ 283 | \xc1\x3a\x50\0\xc3\x3b\x4f\0\xc4\x3c\x4e\0\xc6\x3d\x4d\0\xc7\x3e\x4c\0\xc8\x3f\x4b\0\xca\x40\x4a\0\xcb\x41\x49\0\xcc\x42\x48\0\xce\x43\x47\0\xcf\x44\x46\0\ 284 | \xd0\x45\x45\0\xd2\x46\x44\0\xd3\x47\x43\0\xd4\x48\x42\0\xd5\x4a\x41\0\xd7\x4b\x3f\0\xd8\x4c\x3e\0\xd9\x4d\x3d\0\xda\x4e\x3c\0\xdb\x50\x3b\0\xdd\x51\x3a\0\ 285 | \xde\x52\x38\0\xdf\x53\x37\0\xe0\x55\x36\0\xe1\x56\x35\0\xe2\x57\x34\0\xe3\x59\x33\0\xe4\x5a\x31\0\xe5\x5c\x30\0\xe6\x5d\x2f\0\xe7\x5e\x2e\0\xe8\x60\x2d\0\ 286 | \xe9\x61\x2b\0\xea\x63\x2a\0\xeb\x64\x29\0\xeb\x66\x28\0\xec\x67\x26\0\xed\x69\x25\0\xee\x6a\x24\0\xef\x6c\x23\0\xef\x6e\x21\0\xf0\x6f\x20\0\xf1\x71\x1f\0\ 287 | \xf1\x73\x1d\0\xf2\x74\x1c\0\xf3\x76\x1b\0\xf3\x78\x19\0\xf4\x79\x18\0\xf5\x7b\x17\0\xf5\x7d\x15\0\xf6\x7e\x14\0\xf6\x80\x13\0\xf7\x82\x12\0\xf7\x84\x10\0\ 288 | \xf8\x85\x0f\0\xf8\x87\x0e\0\xf8\x89\x0c\0\xf9\x8b\x0b\0\xf9\x8c\x0a\0\xf9\x8e\x09\0\xfa\x90\x08\0\xfa\x92\x07\0\xfa\x94\x07\0\xfb\x96\x06\0\xfb\x97\x06\0\ 289 | \xfb\x99\x06\0\xfb\x9b\x06\0\xfb\x9d\x07\0\xfc\x9f\x07\0\xfc\xa1\x08\0\xfc\xa3\x09\0\xfc\xa5\x0a\0\xfc\xa6\x0c\0\xfc\xa8\x0d\0\xfc\xaa\x0f\0\xfc\xac\x11\0\ 290 | \xfc\xae\x12\0\xfc\xb0\x14\0\xfc\xb2\x16\0\xfc\xb4\x18\0\xfb\xb6\x1a\0\xfb\xb8\x1d\0\xfb\xba\x1f\0\xfb\xbc\x21\0\xfb\xbe\x23\0\xfa\xc0\x26\0\xfa\xc2\x28\0\ 291 | \xfa\xc4\x2a\0\xfa\xc6\x2d\0\xf9\xc7\x2f\0\xf9\xc9\x32\0\xf9\xcb\x35\0\xf8\xcd\x37\0\xf8\xcf\x3a\0\xf7\xd1\x3d\0\xf7\xd3\x40\0\xf6\xd5\x43\0\xf6\xd7\x46\0\ 292 | \xf5\xd9\x49\0\xf5\xdb\x4c\0\xf4\xdd\x4f\0\xf4\xdf\x53\0\xf4\xe1\x56\0\xf3\xe3\x5a\0\xf3\xe5\x5d\0\xf2\xe6\x61\0\xf2\xe8\x65\0\xf2\xea\x69\0\xf1\xec\x6d\0\ 293 | \xf1\xed\x71\0\xf1\xef\x75\0\xf1\xf1\x79\0\xf2\xf2\x7d\0\xf2\xf4\x82\0\xf3\xf5\x86\0\xf3\xf6\x8a\0\xf4\xf8\x8e\0\xf5\xf9\x92\0\xf6\xfa\x96\0\xf8\xfb\x9a\0\ 294 | \xf9\xfc\x9d\0\xfa\xfd\xa1\0\xfc\xff\xa4\0' 295 | SDL_PAL_INFERNO = (ctypes.c_uint8 * len(SDL_PAL_INFERNO_L))(*SDL_PAL_INFERNO_L) 296 | 297 | SDL_PAL_VIRIDIS_L = b'\ 298 | \x44\x01\x54\0\x44\x02\x56\0\x45\x04\x57\0\x45\x05\x59\0\x46\x07\x5a\0\x46\x08\x5c\0\x46\x0a\x5d\0\x46\x0b\x5e\0\x47\x0d\x60\0\x47\x0e\x61\0\x47\x10\x63\0\ 299 | \x47\x11\x64\0\x47\x13\x65\0\x48\x14\x67\0\x48\x16\x68\0\x48\x17\x69\0\x48\x18\x6a\0\x48\x1a\x6c\0\x48\x1b\x6d\0\x48\x1c\x6e\0\x48\x1d\x6f\0\x48\x1f\x70\0\ 300 | \x48\x20\x71\0\x48\x21\x73\0\x48\x23\x74\0\x48\x24\x75\0\x48\x25\x76\0\x48\x26\x77\0\x48\x28\x78\0\x48\x29\x79\0\x47\x2a\x7a\0\x47\x2c\x7a\0\x47\x2d\x7b\0\ 301 | \x47\x2e\x7c\0\x47\x2f\x7d\0\x46\x30\x7e\0\x46\x32\x7e\0\x46\x33\x7f\0\x46\x34\x80\0\x45\x35\x81\0\x45\x37\x81\0\x45\x38\x82\0\x44\x39\x83\0\x44\x3a\x83\0\ 302 | \x44\x3b\x84\0\x43\x3d\x84\0\x43\x3e\x85\0\x42\x3f\x85\0\x42\x40\x86\0\x42\x41\x86\0\x41\x42\x87\0\x41\x44\x87\0\x40\x45\x88\0\x40\x46\x88\0\x3f\x47\x88\0\ 303 | \x3f\x48\x89\0\x3e\x49\x89\0\x3e\x4a\x89\0\x3e\x4c\x8a\0\x3d\x4d\x8a\0\x3d\x4e\x8a\0\x3c\x4f\x8a\0\x3c\x50\x8b\0\x3b\x51\x8b\0\x3b\x52\x8b\0\x3a\x53\x8b\0\ 304 | \x3a\x54\x8c\0\x39\x55\x8c\0\x39\x56\x8c\0\x38\x58\x8c\0\x38\x59\x8c\0\x37\x5a\x8c\0\x37\x5b\x8d\0\x36\x5c\x8d\0\x36\x5d\x8d\0\x35\x5e\x8d\0\x35\x5f\x8d\0\ 305 | \x34\x60\x8d\0\x34\x61\x8d\0\x33\x62\x8d\0\x33\x63\x8d\0\x32\x64\x8e\0\x32\x65\x8e\0\x31\x66\x8e\0\x31\x67\x8e\0\x31\x68\x8e\0\x30\x69\x8e\0\x30\x6a\x8e\0\ 306 | \x2f\x6b\x8e\0\x2f\x6c\x8e\0\x2e\x6d\x8e\0\x2e\x6e\x8e\0\x2e\x6f\x8e\0\x2d\x70\x8e\0\x2d\x71\x8e\0\x2c\x71\x8e\0\x2c\x72\x8e\0\x2c\x73\x8e\0\x2b\x74\x8e\0\ 307 | \x2b\x75\x8e\0\x2a\x76\x8e\0\x2a\x77\x8e\0\x2a\x78\x8e\0\x29\x79\x8e\0\x29\x7a\x8e\0\x29\x7b\x8e\0\x28\x7c\x8e\0\x28\x7d\x8e\0\x27\x7e\x8e\0\x27\x7f\x8e\0\ 308 | \x27\x80\x8e\0\x26\x81\x8e\0\x26\x82\x8e\0\x26\x82\x8e\0\x25\x83\x8e\0\x25\x84\x8e\0\x25\x85\x8e\0\x24\x86\x8e\0\x24\x87\x8e\0\x23\x88\x8e\0\x23\x89\x8e\0\ 309 | \x23\x8a\x8d\0\x22\x8b\x8d\0\x22\x8c\x8d\0\x22\x8d\x8d\0\x21\x8e\x8d\0\x21\x8f\x8d\0\x21\x90\x8d\0\x21\x91\x8c\0\x20\x92\x8c\0\x20\x92\x8c\0\x20\x93\x8c\0\ 310 | \x1f\x94\x8c\0\x1f\x95\x8b\0\x1f\x96\x8b\0\x1f\x97\x8b\0\x1f\x98\x8b\0\x1f\x99\x8a\0\x1f\x9a\x8a\0\x1e\x9b\x8a\0\x1e\x9c\x89\0\x1e\x9d\x89\0\x1f\x9e\x89\0\ 311 | \x1f\x9f\x88\0\x1f\xa0\x88\0\x1f\xa1\x88\0\x1f\xa1\x87\0\x1f\xa2\x87\0\x20\xa3\x86\0\x20\xa4\x86\0\x21\xa5\x85\0\x21\xa6\x85\0\x22\xa7\x85\0\x22\xa8\x84\0\ 312 | \x23\xa9\x83\0\x24\xaa\x83\0\x25\xab\x82\0\x25\xac\x82\0\x26\xad\x81\0\x27\xad\x81\0\x28\xae\x80\0\x29\xaf\x7f\0\x2a\xb0\x7f\0\x2c\xb1\x7e\0\x2d\xb2\x7d\0\ 313 | \x2e\xb3\x7c\0\x2f\xb4\x7c\0\x31\xb5\x7b\0\x32\xb6\x7a\0\x34\xb6\x79\0\x35\xb7\x79\0\x37\xb8\x78\0\x38\xb9\x77\0\x3a\xba\x76\0\x3b\xbb\x75\0\x3d\xbc\x74\0\ 314 | \x3f\xbc\x73\0\x40\xbd\x72\0\x42\xbe\x71\0\x44\xbf\x70\0\x46\xc0\x6f\0\x48\xc1\x6e\0\x4a\xc1\x6d\0\x4c\xc2\x6c\0\x4e\xc3\x6b\0\x50\xc4\x6a\0\x52\xc5\x69\0\ 315 | \x54\xc5\x68\0\x56\xc6\x67\0\x58\xc7\x65\0\x5a\xc8\x64\0\x5c\xc8\x63\0\x5e\xc9\x62\0\x60\xca\x60\0\x63\xcb\x5f\0\x65\xcb\x5e\0\x67\xcc\x5c\0\x69\xcd\x5b\0\ 316 | \x6c\xcd\x5a\0\x6e\xce\x58\0\x70\xcf\x57\0\x73\xd0\x56\0\x75\xd0\x54\0\x77\xd1\x53\0\x7a\xd1\x51\0\x7c\xd2\x50\0\x7f\xd3\x4e\0\x81\xd3\x4d\0\x84\xd4\x4b\0\ 317 | \x86\xd5\x49\0\x89\xd5\x48\0\x8b\xd6\x46\0\x8e\xd6\x45\0\x90\xd7\x43\0\x93\xd7\x41\0\x95\xd8\x40\0\x98\xd8\x3e\0\x9b\xd9\x3c\0\x9d\xd9\x3b\0\xa0\xda\x39\0\ 318 | \xa2\xda\x37\0\xa5\xdb\x36\0\xa8\xdb\x34\0\xaa\xdc\x32\0\xad\xdc\x30\0\xb0\xdd\x2f\0\xb2\xdd\x2d\0\xb5\xde\x2b\0\xb8\xde\x29\0\xba\xde\x28\0\xbd\xdf\x26\0\ 319 | \xc0\xdf\x25\0\xc2\xdf\x23\0\xc5\xe0\x21\0\xc8\xe0\x20\0\xca\xe1\x1f\0\xcd\xe1\x1d\0\xd0\xe1\x1c\0\xd2\xe2\x1b\0\xd5\xe2\x1a\0\xd8\xe2\x19\0\xda\xe3\x19\0\ 320 | \xdd\xe3\x18\0\xdf\xe3\x18\0\xe2\xe4\x18\0\xe5\xe4\x19\0\xe7\xe4\x19\0\xea\xe5\x1a\0\xec\xe5\x1b\0\xef\xe5\x1c\0\xf1\xe5\x1d\0\xf4\xe6\x1e\0\xf6\xe6\x20\0\ 321 | \xf8\xe6\x21\0\xfb\xe7\x23\0\xfd\xe7\x25\0' 322 | SDL_PAL_VIRIDIS = (ctypes.c_uint8 * len(SDL_PAL_VIRIDIS_L))(*SDL_PAL_VIRIDIS_L) 323 | 324 | # palettes from https://github.com/groupgets/GetThermal/blob/master/src/dataformatter.cpp 325 | # print('\\\n'.join(re.findall('.{0,154}', ''.join([f'\\x{x[0:2]}\\x{x[2:4]}\\x{x[4:6]}\\0' for x in re.findall('......', bytes(a).hex())])))) 326 | SDL_PAL_IRONBLACK_L = b'\ 327 | \xff\xff\xff\0\xfd\xfd\xfd\0\xfb\xfb\xfb\0\xf9\xf9\xf9\0\xf7\xf7\xf7\0\xf5\xf5\xf5\0\xf3\xf3\xf3\0\xf1\xf1\xf1\0\xef\xef\xef\0\xed\xed\xed\0\xeb\xeb\xeb\0\ 328 | \xe9\xe9\xe9\0\xe7\xe7\xe7\0\xe5\xe5\xe5\0\xe3\xe3\xe3\0\xe1\xe1\xe1\0\xdf\xdf\xdf\0\xdd\xdd\xdd\0\xdb\xdb\xdb\0\xd9\xd9\xd9\0\xd7\xd7\xd7\0\xd5\xd5\xd5\0\ 329 | \xd3\xd3\xd3\0\xd1\xd1\xd1\0\xcf\xcf\xcf\0\xcd\xcd\xcd\0\xcb\xcb\xcb\0\xc9\xc9\xc9\0\xc7\xc7\xc7\0\xc5\xc5\xc5\0\xc3\xc3\xc3\0\xc1\xc1\xc1\0\xbf\xbf\xbf\0\ 330 | \xbd\xbd\xbd\0\xbb\xbb\xbb\0\xb9\xb9\xb9\0\xb7\xb7\xb7\0\xb5\xb5\xb5\0\xb3\xb3\xb3\0\xb1\xb1\xb1\0\xaf\xaf\xaf\0\xad\xad\xad\0\xab\xab\xab\0\xa9\xa9\xa9\0\ 331 | \xa7\xa7\xa7\0\xa5\xa5\xa5\0\xa3\xa3\xa3\0\xa1\xa1\xa1\0\x9f\x9f\x9f\0\x9d\x9d\x9d\0\x9b\x9b\x9b\0\x99\x99\x99\0\x97\x97\x97\0\x95\x95\x95\0\x93\x93\x93\0\ 332 | \x91\x91\x91\0\x8f\x8f\x8f\0\x8d\x8d\x8d\0\x8b\x8b\x8b\0\x89\x89\x89\0\x87\x87\x87\0\x85\x85\x85\0\x83\x83\x83\0\x81\x81\x81\0\x7e\x7e\x7e\0\x7c\x7c\x7c\0\ 333 | \x7a\x7a\x7a\0\x78\x78\x78\0\x76\x76\x76\0\x74\x74\x74\0\x72\x72\x72\0\x70\x70\x70\0\x6e\x6e\x6e\0\x6c\x6c\x6c\0\x6a\x6a\x6a\0\x68\x68\x68\0\x66\x66\x66\0\ 334 | \x64\x64\x64\0\x62\x62\x62\0\x60\x60\x60\0\x5e\x5e\x5e\0\x5c\x5c\x5c\0\x5a\x5a\x5a\0\x58\x58\x58\0\x56\x56\x56\0\x54\x54\x54\0\x52\x52\x52\0\x50\x50\x50\0\ 335 | \x4e\x4e\x4e\0\x4c\x4c\x4c\0\x4a\x4a\x4a\0\x48\x48\x48\0\x46\x46\x46\0\x44\x44\x44\0\x42\x42\x42\0\x40\x40\x40\0\x3e\x3e\x3e\0\x3c\x3c\x3c\0\x3a\x3a\x3a\0\ 336 | \x38\x38\x38\0\x36\x36\x36\0\x34\x34\x34\0\x32\x32\x32\0\x30\x30\x30\0\x2e\x2e\x2e\0\x2c\x2c\x2c\0\x2a\x2a\x2a\0\x28\x28\x28\0\x26\x26\x26\0\x24\x24\x24\0\ 337 | \x22\x22\x22\0\x20\x20\x20\0\x1e\x1e\x1e\0\x1c\x1c\x1c\0\x1a\x1a\x1a\0\x18\x18\x18\0\x16\x16\x16\0\x14\x14\x14\0\x12\x12\x12\0\x10\x10\x10\0\x0e\x0e\x0e\0\ 338 | \x0c\x0c\x0c\0\x0a\x0a\x0a\0\x08\x08\x08\0\x06\x06\x06\0\x04\x04\x04\0\x02\x02\x02\0\x00\x00\x00\0\x00\x00\x09\0\x02\x00\x10\0\x04\x00\x18\0\x06\x00\x1f\0\ 339 | \x08\x00\x26\0\x0a\x00\x2d\0\x0c\x00\x35\0\x0e\x00\x3c\0\x11\x00\x43\0\x13\x00\x4a\0\x15\x00\x52\0\x17\x00\x59\0\x19\x00\x60\0\x1b\x00\x67\0\x1d\x00\x6f\0\ 340 | \x1f\x00\x76\0\x24\x00\x78\0\x29\x00\x79\0\x2e\x00\x7a\0\x33\x00\x7b\0\x38\x00\x7c\0\x3d\x00\x7d\0\x42\x00\x7e\0\x47\x00\x7f\0\x4c\x01\x80\0\x51\x01\x81\0\ 341 | \x56\x01\x82\0\x5b\x01\x83\0\x60\x01\x84\0\x65\x01\x85\0\x6a\x01\x86\0\x6f\x01\x87\0\x74\x01\x88\0\x79\x01\x88\0\x7d\x02\x89\0\x82\x02\x89\0\x87\x03\x89\0\ 342 | \x8b\x03\x8a\0\x90\x03\x8a\0\x95\x04\x8a\0\x99\x04\x8b\0\x9e\x05\x8b\0\xa3\x05\x8b\0\xa7\x05\x8c\0\xac\x06\x8c\0\xb1\x06\x8c\0\xb5\x07\x8d\0\xba\x07\x8d\0\ 343 | \xbd\x0a\x89\0\xbf\x0d\x84\0\xc2\x10\x7f\0\xc4\x13\x79\0\xc6\x16\x74\0\xc8\x19\x6f\0\xcb\x1c\x6a\0\xcd\x1f\x65\0\xcf\x22\x5f\0\xd1\x25\x5a\0\xd4\x28\x55\0\ 344 | \xd6\x2b\x50\0\xd8\x2e\x4b\0\xda\x31\x45\0\xdd\x34\x40\0\xdf\x37\x3b\0\xe0\x39\x31\0\xe1\x3c\x2f\0\xe2\x40\x2c\0\xe3\x43\x2a\0\xe4\x47\x27\0\xe5\x4a\x25\0\ 345 | \xe6\x4e\x22\0\xe7\x51\x20\0\xe7\x55\x1d\0\xe8\x58\x1b\0\xe9\x5c\x18\0\xea\x5f\x16\0\xeb\x63\x13\0\xec\x66\x11\0\xed\x6a\x0e\0\xee\x6d\x0c\0\xef\x70\x0c\0\ 346 | \xf0\x74\x0c\0\xf0\x77\x0c\0\xf1\x7b\x0c\0\xf1\x7f\x0c\0\xf2\x82\x0c\0\xf2\x86\x0c\0\xf3\x8a\x0c\0\xf3\x8d\x0d\0\xf4\x91\x0d\0\xf4\x95\x0d\0\xf5\x98\x0d\0\ 347 | \xf5\x9c\x0d\0\xf6\xa0\x0d\0\xf6\xa3\x0d\0\xf7\xa7\x0d\0\xf7\xab\x0d\0\xf8\xaf\x0e\0\xf8\xb2\x0f\0\xf9\xb6\x10\0\xf9\xb9\x12\0\xfa\xbd\x13\0\xfa\xc0\x14\0\ 348 | \xfb\xc4\x15\0\xfb\xc7\x16\0\xfc\xcb\x17\0\xfc\xce\x18\0\xfd\xd2\x19\0\xfd\xd5\x1b\0\xfe\xd9\x1c\0\xfe\xdc\x1d\0\xff\xe0\x1e\0\xff\xe3\x27\0\xff\xe5\x35\0\ 349 | \xff\xe7\x43\0\xff\xe9\x51\0\xff\xea\x5f\0\xff\xec\x6d\0\xff\xee\x7b\0\xff\xf0\x89\0\xff\xf2\x97\0\xff\xf4\xa5\0\xff\xf6\xb3\0\xff\xf8\xc1\0\xff\xf9\xcf\0\ 350 | \xff\xfb\xdd\0\xff\xfd\xeb\0\xff\xff\x18\0' 351 | SDL_PAL_IRONBLACK = (ctypes.c_uint8 * len(SDL_PAL_IRONBLACK_L))(*SDL_PAL_IRONBLACK_L) 352 | 353 | SDL_PAL_RAINBOW_L = b'\ 354 | \x01\x03\x4a\0\x00\x03\x4a\0\x00\x03\x4b\0\x00\x03\x4b\0\x00\x03\x4c\0\x00\x03\x4c\0\x00\x03\x4d\0\x00\x03\x4f\0\x00\x03\x52\0\x00\x05\x55\0\x00\x07\x58\0\ 355 | \x00\x0a\x5b\0\x00\x0e\x5e\0\x00\x13\x62\0\x00\x16\x64\0\x00\x19\x67\0\x00\x1c\x6a\0\x00\x20\x6d\0\x00\x23\x70\0\x00\x26\x74\0\x00\x28\x77\0\x00\x2a\x7b\0\ 356 | \x00\x2d\x80\0\x00\x31\x85\0\x00\x32\x86\0\x00\x33\x88\0\x00\x34\x89\0\x00\x35\x8b\0\x00\x36\x8e\0\x00\x37\x90\0\x00\x38\x91\0\x00\x3a\x95\0\x00\x3d\x9a\0\ 357 | \x00\x3f\x9c\0\x00\x41\x9f\0\x00\x42\xa1\0\x00\x44\xa4\0\x00\x45\xa7\0\x00\x47\xaa\0\x00\x49\xae\0\x00\x4b\xb3\0\x00\x4c\xb5\0\x00\x4e\xb8\0\x00\x4f\xbb\0\ 358 | \x00\x50\xbc\0\x00\x51\xbe\0\x00\x54\xc2\0\x00\x57\xc6\0\x00\x58\xc8\0\x00\x5a\xcb\0\x00\x5c\xcd\0\x00\x5e\xcf\0\x00\x5e\xd0\0\x00\x5f\xd1\0\x00\x60\xd2\0\ 359 | \x00\x61\xd3\0\x00\x63\xd6\0\x00\x66\xd9\0\x00\x67\xda\0\x00\x68\xdb\0\x00\x69\xdc\0\x00\x6b\xdd\0\x00\x6d\xdf\0\x00\x6f\xdf\0\x00\x71\xdf\0\x00\x73\xde\0\ 360 | \x00\x75\xdd\0\x00\x76\xdc\0\x01\x78\xdb\0\x01\x7a\xd9\0\x02\x7c\xd8\0\x02\x7e\xd6\0\x03\x81\xd4\0\x03\x83\xcf\0\x04\x84\xcd\0\x04\x85\xca\0\x04\x86\xc5\0\ 361 | \x05\x88\xc0\0\x06\x8a\xb9\0\x07\x8d\xb2\0\x08\x8e\xac\0\x0a\x90\xa6\0\x0a\x90\xa2\0\x0b\x91\x9e\0\x0c\x92\x99\0\x0d\x93\x95\0\x0f\x95\x8c\0\x11\x97\x84\0\ 362 | \x16\x99\x78\0\x19\x9a\x73\0\x1c\x9c\x6d\0\x22\x9e\x65\0\x28\xa0\x5e\0\x2d\xa2\x56\0\x33\xa4\x4f\0\x3b\xa7\x45\0\x43\xab\x3c\0\x48\xad\x36\0\x4e\xaf\x30\0\ 363 | \x53\xb1\x2b\0\x59\xb3\x27\0\x5d\xb5\x23\0\x62\xb7\x1f\0\x69\xb9\x1a\0\x6d\xbb\x17\0\x71\xbc\x15\0\x76\xbd\x13\0\x7b\xbf\x11\0\x80\xc1\x0e\0\x86\xc3\x0c\0\ 364 | \x8a\xc4\x0a\0\x8e\xc5\x08\0\x92\xc6\x06\0\x97\xc8\x05\0\x9b\xc9\x04\0\xa0\xcb\x03\0\xa4\xcc\x02\0\xa9\xcd\x02\0\xad\xce\x01\0\xaf\xcf\x01\0\xb2\xcf\x01\0\ 365 | \xb8\xd0\x00\0\xbe\xd2\x00\0\xc1\xd3\x00\0\xc4\xd4\x00\0\xc7\xd4\x00\0\xca\xd5\x01\0\xcf\xd6\x02\0\xd4\xd7\x03\0\xd7\xd6\x03\0\xda\xd6\x03\0\xdc\xd5\x03\0\ 366 | \xde\xd5\x04\0\xe0\xd4\x04\0\xe1\xd4\x05\0\xe2\xd4\x05\0\xe5\xd3\x05\0\xe8\xd3\x06\0\xe8\xd3\x06\0\xe9\xd3\x06\0\xea\xd2\x06\0\xeb\xd2\x07\0\xec\xd1\x07\0\ 367 | \xed\xd0\x08\0\xef\xce\x08\0\xf1\xcc\x09\0\xf2\xcb\x09\0\xf4\xca\x0a\0\xf4\xc9\x0a\0\xf5\xc8\x0a\0\xf5\xc7\x0b\0\xf6\xc6\x0b\0\xf7\xc5\x0c\0\xf8\xc2\x0d\0\ 368 | \xf9\xbf\x0e\0\xfa\xbd\x0e\0\xfb\xbb\x0f\0\xfb\xb9\x10\0\xfc\xb7\x11\0\xfc\xb2\x12\0\xfd\xae\x13\0\xfd\xab\x13\0\xfe\xa8\x14\0\xfe\xa5\x15\0\xfe\xa4\x15\0\ 369 | \xff\xa3\x16\0\xff\xa1\x16\0\xff\x9f\x17\0\xff\x9d\x17\0\xff\x9b\x18\0\xff\x95\x19\0\xff\x8f\x1b\0\xff\x8b\x1c\0\xff\x87\x1e\0\xff\x83\x1f\0\xff\x7f\x20\0\ 370 | \xff\x76\x22\0\xff\x6e\x24\0\xff\x68\x25\0\xff\x65\x26\0\xff\x63\x27\0\xff\x5d\x28\0\xff\x58\x2a\0\xfe\x52\x2b\0\xfe\x4d\x2d\0\xfe\x45\x2f\0\xfe\x3e\x31\0\ 371 | \xfd\x39\x32\0\xfd\x35\x34\0\xfc\x31\x35\0\xfc\x2d\x37\0\xfb\x27\x39\0\xfb\x21\x3b\0\xfb\x20\x3c\0\xfb\x1f\x3c\0\xfb\x1e\x3d\0\xfb\x1d\x3d\0\xfb\x1c\x3e\0\ 372 | \xfa\x1b\x3f\0\xfa\x1b\x41\0\xf9\x1a\x42\0\xf9\x1a\x44\0\xf8\x19\x46\0\xf8\x18\x49\0\xf7\x18\x4b\0\xf7\x19\x4d\0\xf7\x19\x4f\0\xf7\x1a\x51\0\xf7\x20\x53\0\ 373 | \xf7\x23\x55\0\xf7\x26\x56\0\xf7\x2a\x58\0\xf7\x2e\x5a\0\xf7\x32\x5c\0\xf8\x37\x5e\0\xf8\x3b\x60\0\xf8\x40\x62\0\xf8\x48\x65\0\xf9\x51\x68\0\xf9\x57\x6a\0\ 374 | \xfa\x5d\x6c\0\xfa\x5f\x6d\0\xfa\x62\x6e\0\xfa\x64\x6f\0\xfb\x65\x70\0\xfb\x66\x71\0\xfb\x6d\x75\0\xfc\x74\x79\0\xfc\x79\x7b\0\xfd\x7e\x7e\0\xfd\x82\x80\0\ 375 | \xfe\x87\x83\0\xfe\x8b\x85\0\xfe\x90\x88\0\xfe\x97\x8c\0\xff\x9e\x90\0\xff\xa3\x92\0\xff\xa8\x95\0\xff\xad\x98\0\xff\xb0\x99\0\xff\xb2\x9b\0\xff\xb8\xa0\0\ 376 | \xff\xbf\xa5\0\xff\xc3\xa8\0\xff\xc7\xac\0\xff\xcb\xaf\0\xff\xcf\xb3\0\xff\xd3\xb6\0\xff\xd8\xb9\0\xff\xda\xbe\0\xff\xdc\xc4\0\xff\xde\xc8\0\xff\xe1\xca\0\ 377 | \xff\xe3\xcc\0\xff\xe6\xce\0\xff\xe9\xd0\0' 378 | SDL_PAL_RAINBOW = (ctypes.c_uint8 * len(SDL_PAL_RAINBOW_L))(*SDL_PAL_RAINBOW_L) 379 | 380 | SDL_PALS = { 381 | 'none': SDL_PAL_GRAYSCALE, 382 | 'grayscale': SDL_PAL_GRAYSCALE, 383 | 'inferno': SDL_PAL_INFERNO, 384 | 'viridis': SDL_PAL_VIRIDIS, 385 | 'ironblack': SDL_PAL_IRONBLACK, 386 | 'rainbow': SDL_PAL_RAINBOW, 387 | } 388 | 389 | SDL_WINDOW_FULLSCREEN = 0x00000001 390 | SDL_WINDOW_RESIZABLE = 0x00000020 391 | SDL_WINDOW_FULLSCREEN_DESKTOP = (SDL_WINDOW_FULLSCREEN | 0x00001000) 392 | SDL_WINDOW_ALLOW_HIGHDPI = 0x00002000 393 | SDL_WINDOWPOS_UNDEFINED = 0x1FFF0000 394 | 395 | SDL_MESSAGEBOX_ERROR = 0x00000010 396 | 397 | def SDL_FOURCC(a, b, c, d): 398 | return (ord(a) << 0) | (ord(b) << 8) | (ord(c) << 16) | (ord(d) << 24) 399 | SDL_PIXELFORMAT_YUY2 = SDL_FOURCC('Y', 'U', 'Y', '2') 400 | SDL_PIXELFORMAT_YV12 = SDL_FOURCC('Y', 'V', '1', '2') 401 | SDL_PIXELFORMAT_YVYU = SDL_FOURCC('Y', 'V', 'Y', 'U') 402 | SDL_PIXELFORMAT_UYVY = SDL_FOURCC('U', 'Y', 'V', 'Y') 403 | SDL_PIXELFORMAT_VYUY = SDL_FOURCC('V', 'Y', 'U', 'Y') 404 | SDL_PIXELFORMAT_NV12 = SDL_FOURCC('N', 'V', '1', '2') 405 | SDL_PIXELFORMAT_NV21 = SDL_FOURCC('N', 'V', '2', '1') 406 | SDL_PIXELFORMAT_IYUV = SDL_FOURCC('I', 'Y', 'U', 'V') 407 | SDL_PIXELFORMAT_RGB24 = 386930691 408 | SDL_PIXELFORMAT_BGR24 = 390076419 409 | SDL_PIXELFORMAT_BGR888 = 374740996 #XBGR8888 410 | SDL_PIXELFORMAT_RGB565 = 353701890 411 | SDL_TEXTUREACCESS_STREAMING = 1 412 | 413 | SDL_Keycode = ctypes.c_int32 414 | SDL_Scancode = ctypes.c_int 415 | _event_pad_size = 56 if ctypes.sizeof(ctypes.c_void_p) <= 8 else 64 416 | 417 | class SDL_Keysym(ctypes.Structure): 418 | _fields_ = [ 419 | ('scancode', SDL_Scancode), 420 | ('sym', SDL_Keycode), 421 | ('mod', ctypes.c_uint16), 422 | ('unused', ctypes.c_uint32), 423 | ] 424 | 425 | class SDL_KeyboardEvent(ctypes.Structure): 426 | _fields_ = [ 427 | ('type', ctypes.c_uint32), 428 | ('timestamp', ctypes.c_uint32), 429 | ('windowID', ctypes.c_uint32), 430 | ('state', ctypes.c_uint8), 431 | ('repeat', ctypes.c_uint8), 432 | ('padding2', ctypes.c_uint8), 433 | ('padding3', ctypes.c_uint8), 434 | ('keysym', SDL_Keysym), 435 | ] 436 | 437 | class SDL_MouseButtonEvent(ctypes.Structure): 438 | _fields_ = [ 439 | ('type', ctypes.c_uint32), 440 | ('timestamp', ctypes.c_uint32), 441 | ('windowID', ctypes.c_uint32), 442 | ('which', ctypes.c_uint32), 443 | ('button', ctypes.c_uint8), 444 | ('state', ctypes.c_uint8), 445 | ('clicks', ctypes.c_uint8), 446 | ('padding1', ctypes.c_uint8), 447 | ('x', ctypes.c_int32), 448 | ('y', ctypes.c_int32), 449 | ] 450 | 451 | class SDL_UserEvent(ctypes.Structure): 452 | _fields_ = [ 453 | ('type', ctypes.c_uint32), 454 | ('timestamp', ctypes.c_uint32), 455 | ('windowID', ctypes.c_uint32), 456 | ('code', ctypes.c_int32), 457 | ('data1', ctypes.c_void_p), 458 | ('data2', ctypes.c_void_p), 459 | ] 460 | 461 | 462 | class SDL_Event(ctypes.Union): 463 | _fields_ = [ 464 | ('type', ctypes.c_uint32), 465 | ('key', SDL_KeyboardEvent), 466 | ('button', SDL_MouseButtonEvent), 467 | ('user', SDL_UserEvent), 468 | ('padding', (ctypes.c_uint8 * _event_pad_size)), 469 | ] 470 | 471 | tj_init_decompress = turbojpeg.tjInitDecompress 472 | tj_init_decompress.restype = ctypes.c_void_p 473 | #tjhandle tjInitDecompress() 474 | 475 | tj_decompress = turbojpeg.tjDecompress2 476 | tj_decompress.argtypes = [ctypes.c_void_p, 477 | ctypes.POINTER(ctypes.c_ubyte), ctypes.c_ulong, 478 | ctypes.POINTER(ctypes.c_ubyte), 479 | ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, 480 | ctypes.c_int] 481 | tj_decompress.restype = ctypes.c_int 482 | #int tjDecompress2(tjhandle handle, 483 | # const unsigned char *jpegBuf, unsigned long jpegSize, 484 | # unsigned char *dstBuf, 485 | # int width, int pitch, int height, int pixelFormat, 486 | # int flags); 487 | 488 | tj_get_error_str = turbojpeg.tjGetErrorStr 489 | tj_get_error_str.restype = ctypes.c_char_p 490 | #char* tjGetErrorStr() 491 | 492 | tj_destroy = turbojpeg.tjDestroy 493 | tj_destroy.argtypes = [ctypes.c_void_p] 494 | tj_destroy.restype = ctypes.c_int 495 | # int tjDestroy(tjhandle handle); 496 | 497 | TJPF_RGB = 0 498 | 499 | class V4L2Camera(Thread): 500 | def __init__(self, device): 501 | super().__init__() 502 | self.device = device 503 | self.width = 0 504 | self.height = 0 505 | self.pixelformat = 0 506 | self.bytesperline = 0 507 | self.stopped = False 508 | self.pipe = None 509 | self.num_cap_bufs = 6 510 | self.cap_bufs = [] 511 | 512 | try: 513 | self.fd = os.open(self.device, os.O_RDWR, 0) 514 | except Exception as e: 515 | logging.error(f'os.open: {e}') 516 | sys.exit(3) 517 | 518 | self.init_device() 519 | self.init_buffers() 520 | 521 | 522 | def init_device(self): 523 | cap = v4l2_capability() 524 | ioctl(self.fd, VIDIOC_QUERYCAP, cap) 525 | 526 | if not (cap.capabilities & V4L2_CAP_VIDEO_CAPTURE): 527 | logging.error(f'{self.device} is not a video capture device') 528 | sys.exit(3) 529 | 530 | if not (cap.capabilities & V4L2_CAP_STREAMING): 531 | logging.error(f'{self.device} does not support streaming i/o') 532 | sys.exit(3) 533 | 534 | fmt = v4l2_format() 535 | fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE 536 | ioctl(self.fd, VIDIOC_G_FMT, fmt) 537 | 538 | parm = v4l2_streamparm() 539 | parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE 540 | 541 | # Razer Kiyo Pro and Microsoft Lifecam HD-3000 need 542 | # to set FPS before first streaming 543 | ioctl(self.fd, VIDIOC_G_PARM, parm) 544 | 545 | try: 546 | ioctl(self.fd, VIDIOC_S_PARM, parm) 547 | except Exception as e: 548 | logging.error(f'VIDIOC_S_PARM failed {self.device}: {e}') 549 | sys.exit(3) 550 | 551 | self.width = fmt.fmt.pix.width 552 | self.height = fmt.fmt.pix.height 553 | self.pixelformat = fmt.fmt.pix.pixelformat 554 | self.bytesperline = fmt.fmt.pix.bytesperline 555 | 556 | 557 | def init_buffers(self): 558 | req = v4l2_requestbuffers() 559 | 560 | req.count = self.num_cap_bufs 561 | req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE 562 | req.memory = V4L2_MEMORY_MMAP 563 | 564 | 565 | try: 566 | ioctl(self.fd, VIDIOC_REQBUFS, req) 567 | except Exception as e: 568 | logging.error(f'Video buffer request failed on {self.device}: {e}') 569 | sys.exit(3) 570 | 571 | if req.count != self.num_cap_bufs: 572 | logging.error(f'Insufficient buffer memory on {self.device}') 573 | sys.exit(3) 574 | 575 | for i in range(req.count): 576 | buf = v4l2_buffer() 577 | buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE 578 | buf.memory = req.memory 579 | buf.index = i 580 | 581 | ioctl(self.fd, VIDIOC_QUERYBUF, buf) 582 | 583 | if req.memory == V4L2_MEMORY_MMAP: 584 | buf.buffer = mmap.mmap(self.fd, buf.length, 585 | flags=mmap.MAP_SHARED | 0x08000, #MAP_POPULATE 586 | prot=mmap.PROT_READ | mmap.PROT_WRITE, 587 | offset=buf.m.offset) 588 | 589 | self.cap_bufs.append(buf) 590 | 591 | def capture_loop(self): 592 | try: 593 | ioctl(self.fd, VIDIOC_STREAMON, struct.pack('I', V4L2_BUF_TYPE_VIDEO_CAPTURE)) 594 | except Exception as e: 595 | logging.error(f'VIDIOC_STREAMON failed {self.device}: {e}') 596 | self.pipe.write_buf(None) 597 | return 598 | 599 | for buf in self.cap_bufs: 600 | ioctl(self.fd, VIDIOC_QBUF, buf) 601 | 602 | qbuf = v4l2_buffer() 603 | qbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE 604 | qbuf.memory = self.cap_bufs[0].memory 605 | 606 | poll = select.poll() 607 | poll.register(self.fd, select.POLLIN) 608 | 609 | timeout = 0 610 | 611 | while not self.stopped: 612 | # DQBUF can block forever, so poll with 1000 ms timeout before 613 | # quit after 5s 614 | if len(poll.poll(1000)) == 0: 615 | logging.warning(f'{self.device}: timeout occured') 616 | timeout += 1 617 | if timeout == 5: 618 | self.pipe.write_buf(None) 619 | return 620 | continue 621 | 622 | try: 623 | ioctl(self.fd, VIDIOC_DQBUF, qbuf) 624 | except Exception as e: 625 | logging.error(f'VIDIOC_DQBUF failed {self.device}: {e}') 626 | self.pipe.write_buf(None) 627 | return 628 | 629 | buf = self.cap_bufs[qbuf.index] 630 | buf.bytesused = qbuf.bytesused 631 | buf.timestamp = qbuf.timestamp 632 | 633 | self.pipe.write_buf(buf) 634 | 635 | ioctl(self.fd, VIDIOC_QBUF, buf) 636 | 637 | try: 638 | ioctl(self.fd, VIDIOC_STREAMOFF, struct.pack('I', V4L2_BUF_TYPE_VIDEO_CAPTURE)) 639 | except Exception as e: 640 | logging.error(f'VIDIOC_STREAMOFF failed {self.device}: {e}') 641 | 642 | def stop_capturing(self): 643 | self.stopped = True 644 | 645 | # thread start 646 | def run(self): 647 | self.capture_loop() 648 | 649 | # thread stop 650 | def stop(self): 651 | self.stop_capturing() 652 | self.join() 653 | 654 | 655 | def V4L2Format2SDL(format): 656 | if format == V4L2_PIX_FMT_YUYV: 657 | return SDL_PIXELFORMAT_YUY2 658 | elif format == V4L2_PIX_FMT_YVYU: 659 | return SDL_PIXELFORMAT_YVYU 660 | elif format == V4L2_PIX_FMT_UYVY: 661 | return SDL_PIXELFORMAT_UYVY 662 | elif format == V4L2_PIX_FMT_NV12: 663 | return SDL_PIXELFORMAT_NV12 664 | elif format == V4L2_PIX_FMT_NV21: 665 | return SDL_PIXELFORMAT_NV21 666 | elif format == V4L2_PIX_FMT_YU12: 667 | return SDL_PIXELFORMAT_IYUV 668 | elif format == V4L2_PIX_FMT_YV12: 669 | return SDL_PIXELFORMAT_YV12 670 | elif format == V4L2_PIX_FMT_RGB565: 671 | return SDL_PIXELFORMAT_RGB565 672 | elif format == V4L2_PIX_FMT_RGB24: 673 | return SDL_PIXELFORMAT_RGB24 674 | elif format == V4L2_PIX_FMT_BGR24: 675 | return SDL_PIXELFORMAT_BGR24 676 | elif format == V4L2_PIX_FMT_RX24: 677 | return SDL_PIXELFORMAT_BGR888 678 | elif format in [V4L2_PIX_FMT_MJPEG, V4L2_PIX_FMT_JPEG]: 679 | return SDL_PIXELFORMAT_RGB24 680 | # handling with surface+palette+texture, not here 681 | #elif format == V4L2_PIX_FMT_GREY: 682 | # return SDL_PIXELFORMAT_INDEX8 683 | 684 | formats = 'Sorry, only YUYV, YVYU, UYVY, NV12, NV21, YU12, RGBP, RGB3, BGR3, RX24, MJPG, JPEG, GREY are supported yet.' 685 | logging.error(f'Invalid pixel format: {formats}') 686 | SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, b'Invalid pixel format', formats.encode(), None) 687 | sys.exit(3) 688 | 689 | class SDLCameraWindow(): 690 | def __init__(self, device, win_width, win_height, angle, flip, colormap): 691 | self.returncode = 0 692 | self.cam = V4L2Camera(device) 693 | self.cam.pipe = self 694 | self.ctrls = CameraCtrls(device, self.cam.fd) 695 | self.ptz = PTZController(self.ctrls) 696 | width = self.cam.width 697 | height = self.cam.height 698 | 699 | rwidth = self.cam.width 700 | rheight = self.cam.height 701 | if angle % 180 != 0: 702 | rwidth = self.cam.height 703 | rheight = self.cam.width 704 | 705 | win_width = rwidth if win_width == 0 else min(int(win_height * (rwidth/rheight)), win_width, rwidth) 706 | win_height = rheight if win_height == 0 else min(int(win_width * (rheight/rwidth)), win_height, rheight) 707 | 708 | self.fullscreen = False 709 | self.tj = None 710 | self.outbuffer = None 711 | self.bytesperline = self.cam.bytesperline 712 | self.surface = None 713 | self.surfbuffer = None 714 | 715 | self.angle = 0 716 | self.flip = 0 717 | self.dstrect = None 718 | self.colormap = None 719 | 720 | if self.cam.pixelformat in [V4L2_PIX_FMT_MJPEG, V4L2_PIX_FMT_JPEG]: 721 | self.tj = tj_init_decompress() 722 | # create rgb buffer 723 | self.outbuffer = (ctypes.c_uint8 * (width * height * 3))() 724 | self.bytesperline = width * 3 725 | 726 | if SDL_Init(SDL_INIT_VIDEO) != 0: 727 | logging.error(f'SDL_Init failed: {SDL_GetError()}') 728 | sys.exit(1) 729 | 730 | # create a new sdl user event type for new image events 731 | self.sdl_new_image_event = SDL_RegisterEvents(1) 732 | self.sdl_new_grey_image_event = SDL_RegisterEvents(1) 733 | self.sdl_camera_error_event = SDL_RegisterEvents(1) 734 | 735 | self.new_image_event = SDL_Event() 736 | self.new_image_event.type = self.sdl_new_image_event 737 | 738 | self.new_grey_image_event = SDL_Event() 739 | self.new_grey_image_event.type = self.sdl_new_grey_image_event 740 | 741 | self.camera_error_event = SDL_Event() 742 | self.camera_error_event.type = self.sdl_camera_error_event 743 | 744 | self.window = SDL_CreateWindow(device.encode(), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, win_width, win_height, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI) 745 | if self.window is None: 746 | logging.error(f'SDL_CreateWindow failed: {SDL_GetError()}') 747 | sys.exit(1) 748 | self.renderer = SDL_CreateRenderer(self.window, -1, 0) 749 | if self.renderer is None: 750 | logging.error(f'SDL_CreateRenderer failed: {SDL_GetError()}') 751 | sys.exit(1) 752 | 753 | self.rotate(angle) 754 | self.mirror(flip) 755 | 756 | if self.cam.pixelformat != V4L2_PIX_FMT_GREY: 757 | self.texture = SDL_CreateTexture(self.renderer, V4L2Format2SDL(self.cam.pixelformat), SDL_TEXTUREACCESS_STREAMING, width, height) 758 | if self.texture is None: 759 | logging.error(f'SDL_CreateTexture failed: {SDL_GetError()}') 760 | sys.exit(1) 761 | 762 | self.surface = SDL_CreateRGBSurfaceFrom(self.surfbuffer, self.cam.width, self.cam.height, 8, self.cam.width, 0, 0, 0, 0) 763 | if not bool(self.surface): 764 | logging.error(f'SDL_CreateRGBSurfaceFrom failed: {SDL_GetError()}') 765 | sys.exit(1) 766 | 767 | self.colormaps = SDL_PALS 768 | if self.cam.pixelformat == V4L2_PIX_FMT_GREY: 769 | self.colormaps = {k: v for k, v in self.colormaps.items() if k != 'grayscale'} 770 | 771 | self.set_colormap(colormap) 772 | 773 | def write_buf(self, buf): 774 | if buf is None: 775 | SDL_PushEvent(ctypes.byref(self.camera_error_event)) 776 | return 777 | 778 | ptr = (ctypes.c_uint8 * buf.bytesused).from_buffer(buf.buffer) 779 | event = self.new_image_event if self.cam.pixelformat != V4L2_PIX_FMT_GREY else self.new_grey_image_event 780 | 781 | if self.cam.pixelformat == V4L2_PIX_FMT_MJPEG or self.cam.pixelformat == V4L2_PIX_FMT_JPEG: 782 | tj_decompress(self.tj, ptr, buf.bytesused, self.outbuffer, self.cam.width, self.bytesperline, self.cam.height, TJPF_RGB, 0) 783 | # ignore decode errors, some cameras only send imperfect frames 784 | ptr = self.outbuffer 785 | 786 | if self.cam.pixelformat != V4L2_PIX_FMT_GREY and self.colormap != 'none': 787 | if self.surfbuffer is None: 788 | # create surface buffer as NV12, but use only the Y 789 | self.surfbuffer = (ctypes.c_uint8 * (self.cam.width * self.cam.height * 2))() 790 | SDL_ConvertPixels(self.cam.width, self.cam.height, V4L2Format2SDL(self.cam.pixelformat), ptr, self.bytesperline, SDL_PIXELFORMAT_NV12, self.surfbuffer, self.cam.width) 791 | ptr = self.surfbuffer 792 | event = self.new_grey_image_event 793 | 794 | event.user.data1 = ctypes.cast(ptr, ctypes.c_void_p) 795 | if SDL_PushEvent(ctypes.byref(event)) < 0: 796 | logging.warning(f'SDL_PushEvent failed: {SDL_GetError()}') 797 | 798 | def event_loop(self): 799 | event = SDL_Event() 800 | while SDL_WaitEvent(ctypes.byref(event)) != 0: 801 | if event.type == SDL_QUIT: 802 | self.stop_capturing() 803 | break 804 | elif event.type == SDL_KEYDOWN and event.key.repeat == 0: 805 | if event.key.keysym.sym == SDLK_q or event.key.keysym.sym == SDLK_ESCAPE: 806 | self.stop_capturing() 807 | break 808 | shift = event.key.keysym.mod & KMOD_SHIFT 809 | if event.key.keysym.sym == SDLK_f: 810 | self.toggle_fullscreen() 811 | elif event.key.keysym.sym == SDLK_r: 812 | self.rotate(90 if not shift else -90) 813 | elif event.key.keysym.sym == SDLK_m: 814 | self.mirror(1 if not shift else -1) 815 | elif event.key.keysym.sym == SDLK_c: 816 | self.step_colormap(1 if not shift else -1) 817 | elif event.type == SDL_MOUSEBUTTONUP and \ 818 | event.button.button == SDL_BUTTON_LEFT and \ 819 | event.button.clicks == 2: 820 | self.toggle_fullscreen() 821 | elif event.type == self.sdl_new_image_event: 822 | if SDL_UpdateTexture(self.texture, None, event.user.data1, self.bytesperline) != 0: 823 | logging.warning(f'SDL_UpdateTexture failed: {SDL_GetError()}') 824 | if SDL_RenderClear(self.renderer) != 0: 825 | logging.warning(f'SDL_RenderClear failed: {SDL_GetError()}') 826 | if SDL_RenderCopyEx(self.renderer, self.texture, None, self.dstrect, self.angle, None, self.flip) != 0: 827 | logging.warning(f'SDL_RenderCopy failed: {SDL_GetError()}') 828 | SDL_RenderPresent(self.renderer) 829 | elif event.type == self.sdl_new_grey_image_event: 830 | self.surface[0].pixels = event.user.data1 831 | texture = SDL_CreateTextureFromSurface(self.renderer, self.surface) 832 | if texture is None: 833 | logging.warning(f'SDL_CreateTextureFromSurface failed: {SDL_GetError()}') 834 | return 835 | if SDL_RenderClear(self.renderer) != 0: 836 | logging.warning(f'SDL_RenderClear failed: {SDL_GetError()}') 837 | if SDL_RenderCopyEx(self.renderer, texture, None, self.dstrect, self.angle, None, self.flip) != 0: 838 | logging.warning(f'SDL_RenderCopy failed: {SDL_GetError()}') 839 | SDL_RenderPresent(self.renderer) 840 | SDL_DestroyTexture(texture) 841 | elif event.type == self.sdl_camera_error_event: 842 | self.stop_capturing() 843 | self.returncode = 4 844 | break 845 | 846 | if event.type == SDL_KEYDOWN: 847 | if self.ptz.has_pantilt_speed: 848 | if event.key.keysym.sym in [SDLK_LEFT, SDLK_KP_4, SDLK_KP_7, SDLK_KP_1, SDLK_a]: 849 | self.ptz.do_pan_speed(-1, []) 850 | if event.key.keysym.sym in [SDLK_RIGHT, SDLK_KP_6, SDLK_KP_9, SDLK_KP_3, SDLK_d]: 851 | self.ptz.do_pan_speed(1, []) 852 | if event.key.keysym.sym in [SDLK_UP, SDLK_KP_8, SDLK_KP_7, SDLK_KP_9, SDLK_w]: 853 | self.ptz.do_tilt_speed(1, []) 854 | if event.key.keysym.sym in [SDLK_DOWN, SDLK_KP_2, SDLK_KP_1, SDLK_KP_3, SDLK_s]: 855 | self.ptz.do_tilt_speed(-1, []) 856 | if event.key.keysym.sym == SDLK_KP_0: 857 | self.ptz.do_reset([]) 858 | elif self.ptz.has_pantilt_absolute: 859 | if event.key.keysym.sym in [SDLK_LEFT, SDLK_KP_4, SDLK_KP_7, SDLK_KP_1, SDLK_a]: 860 | self.ptz.do_pan_step(-1, []) 861 | if event.key.keysym.sym in [SDLK_RIGHT, SDLK_KP_6, SDLK_KP_9, SDLK_KP_3, SDLK_d]: 862 | self.ptz.do_pan_step(1, []) 863 | if event.key.keysym.sym in [SDLK_UP, SDLK_KP_8, SDLK_KP_7, SDLK_KP_9, SDLK_w]: 864 | self.ptz.do_tilt_step(1, []) 865 | if event.key.keysym.sym in [SDLK_DOWN, SDLK_KP_2, SDLK_KP_1, SDLK_KP_3, SDLK_s]: 866 | self.ptz.do_tilt_step(-1, []) 867 | if event.key.keysym.sym == SDLK_KP_0: 868 | self.ptz.do_reset([]) 869 | if self.ptz.has_zoom_absolute: 870 | ctrl = event.key.keysym.mod & KMOD_CTRL 871 | if event.key.keysym.sym in [SDLK_KP_PLUS, SDLK_PLUS] and ctrl: 872 | self.ptz.do_zoom_step_big(1, []) 873 | elif event.key.keysym.sym in [SDLK_KP_MINUS, SDLK_MINUS] and ctrl: 874 | self.ptz.do_zoom_step_big(-1, []) 875 | elif event.key.keysym.sym in [SDLK_KP_PLUS, SDLK_PLUS]: 876 | self.ptz.do_zoom_step(1, []) 877 | elif event.key.keysym.sym in [SDLK_KP_MINUS, SDLK_MINUS]: 878 | self.ptz.do_zoom_step(-1, []) 879 | elif event.key.keysym.sym == SDLK_PAGEUP: 880 | self.ptz.do_zoom_step_big(1, []) 881 | elif event.key.keysym.sym == SDLK_PAGEDOWN: 882 | self.ptz.do_zoom_step_big(-1, []) 883 | elif event.key.keysym.sym == SDLK_HOME: 884 | self.ptz.do_zoom_percent(0, []) 885 | elif event.key.keysym.sym == SDLK_END: 886 | self.ptz.do_zoom_percent(1, []) 887 | if event.key.keysym.sym == SDLK_1: 888 | self.ptz.do_preset(1, []) 889 | elif event.key.keysym.sym == SDLK_2: 890 | self.ptz.do_preset(2, []) 891 | elif event.key.keysym.sym == SDLK_3: 892 | self.ptz.do_preset(3, []) 893 | elif event.key.keysym.sym == SDLK_4: 894 | self.ptz.do_preset(4, []) 895 | elif event.key.keysym.sym == SDLK_5: 896 | self.ptz.do_preset(5, []) 897 | elif event.key.keysym.sym == SDLK_6: 898 | self.ptz.do_preset(6, []) 899 | elif event.key.keysym.sym == SDLK_7: 900 | self.ptz.do_preset(7, []) 901 | elif event.key.keysym.sym == SDLK_8: 902 | self.ptz.do_preset(8, []) 903 | elif event.type == SDL_KEYUP: 904 | if self.ptz.has_pantilt_speed: 905 | if event.key.keysym.sym in [SDLK_LEFT, SDLK_KP_4, SDLK_KP_7, SDLK_KP_1, SDLK_a]: 906 | self.ptz.do_pan_speed(0, []) 907 | if event.key.keysym.sym in [SDLK_RIGHT, SDLK_KP_6, SDLK_KP_9, SDLK_KP_3, SDLK_d]: 908 | self.ptz.do_pan_speed(0, []) 909 | if event.key.keysym.sym in [SDLK_UP, SDLK_KP_8, SDLK_KP_7, SDLK_KP_9, SDLK_w]: 910 | self.ptz.do_tilt_speed(0, []) 911 | if event.key.keysym.sym in [SDLK_DOWN, SDLK_KP_2, SDLK_KP_1, SDLK_KP_3, SDLK_s]: 912 | self.ptz.do_tilt_speed(0, []) 913 | 914 | def toggle_fullscreen(self): 915 | self.fullscreen = not self.fullscreen 916 | SDL_SetWindowFullscreen(self.window, SDL_WINDOW_FULLSCREEN_DESKTOP if self.fullscreen else 0) 917 | self.match_window_to_logical() 918 | 919 | def rotate(self, angle): 920 | self.angle += angle 921 | self.angle %= 360 922 | if self.angle % 180 == 0: 923 | self.dstrect = None 924 | if SDL_RenderSetLogicalSize(self.renderer, self.cam.width, self.cam.height) != 0: 925 | logging.warning(f'SDL_RenderSetlogicalSize failed: {SDL_GetError()}') 926 | else: 927 | self.dstrect = SDL_Rect( 928 | (self.cam.height - self.cam.width)//2, 929 | (self.cam.width - self.cam.height)//2, 930 | self.cam.width, 931 | self.cam.height 932 | ) 933 | if SDL_RenderSetLogicalSize(self.renderer, self.cam.height, self.cam.width) != 0: 934 | logging.warning(f'SDL_RenderSetlogicalSize failed: {SDL_GetError()}') 935 | self.match_window_to_logical() 936 | 937 | def match_window_to_logical(self): 938 | if self.fullscreen: 939 | return 940 | 941 | win_w = ctypes.c_int() 942 | win_h = ctypes.c_int() 943 | SDL_GetWindowSize(self.window, win_w, win_h) 944 | 945 | logical_w = ctypes.c_int() 946 | logical_h = ctypes.c_int() 947 | SDL_RenderGetLogicalSize(self.renderer, logical_w, logical_h) 948 | 949 | if logical_w.value < logical_h.value and win_w.value < win_h.value: 950 | return 951 | if logical_w.value > logical_h.value and win_w.value > win_h.value: 952 | return 953 | 954 | SDL_SetWindowSize(self.window, win_h, win_w) 955 | 956 | def mirror(self, flip): 957 | self.flip += flip 958 | self.flip %= 4 959 | 960 | def set_colormap(self, colormap): 961 | if colormap not in self.colormaps: 962 | logging.warning(f'set_colormap: invalid colormap name ({colormap}) not in {list(SDL_PALS.keys())}') 963 | colormap = 'none' 964 | 965 | pal = self.colormaps.get(colormap) 966 | 967 | self.colormap = colormap 968 | SDL_SetPaletteColors(self.surface[0].format[0].palette, pal, 0, 256) 969 | 970 | def step_colormap(self, step): 971 | cms = list(self.colormaps.keys()) 972 | step = (cms.index(self.colormap) + step) % len(cms) 973 | self.set_colormap(cms[step]) 974 | 975 | def start_capturing(self): 976 | self.cam.start() 977 | self.event_loop() 978 | 979 | def stop_capturing(self): 980 | self.cam.stop() 981 | 982 | def close(self): 983 | tj_destroy(self.tj) 984 | SDL_DestroyWindow(self.window) 985 | SDL_Quit() 986 | return self.returncode 987 | 988 | 989 | def usage(): 990 | print(f'usage: {sys.argv[0]} [--help] [-d DEVICE] [-s SIZE] [-r ANGLE] [-m FLIP] [-c COLORMAP]\n') 991 | print(f'optional arguments:') 992 | print(f' -h, --help show this help message and exit') 993 | print(f' -d DEVICE use DEVICE, default /dev/video0') 994 | print(f' -s SIZE put window inside SIZE rectangle (wxh), default unset') 995 | print(f' -r ANGLE rotate the image by ANGLE, default 0') 996 | print(f' -m FLIP mirror the image by FLIP, default no, (no, h, v, hv)') 997 | print(f' -c COLORMAP set colormap, default none') 998 | print(f' (none, grayscale, inferno, viridis, ironblack, rainbow)') 999 | print() 1000 | print(f'example:') 1001 | print(f' {sys.argv[0]} -d /dev/video2') 1002 | print() 1003 | print(f'shortcuts:') 1004 | print(f' f: toggle fullscreen') 1005 | print(f' r: ANGLE +90 (shift+r -90)') 1006 | print(f' m: FLIP next (shift+m prev)') 1007 | print(f' c: COLORMAP next (shift+c prev)') 1008 | 1009 | 1010 | def main(): 1011 | try: 1012 | arguments, values = getopt.getopt(sys.argv[1:], 'hd:s:r:m:c:', ['help']) 1013 | except getopt.error as err: 1014 | print(err) 1015 | usage() 1016 | sys.exit(2) 1017 | 1018 | device = '/dev/video0' 1019 | width = 0 1020 | height = 0 1021 | angle = 0 1022 | flip = 0 1023 | colormap = 'none' 1024 | 1025 | for current_argument, current_value in arguments: 1026 | if current_argument in ('-h', '--help'): 1027 | usage() 1028 | sys.exit(0) 1029 | elif current_argument == '-d': 1030 | device = current_value 1031 | elif current_argument == '-s': 1032 | args = current_value.split('x') 1033 | if len(args) == 2: 1034 | width = int(args[0]) 1035 | height = int(args[1]) 1036 | else: 1037 | logging.warning(f'invalid size: {current_value}') 1038 | elif current_argument == '-r': 1039 | angle = int(current_value) 1040 | elif current_argument == '-m': 1041 | if current_value == 'no': 1042 | flip = 0 1043 | elif current_value == 'h': 1044 | flip = 1 1045 | elif current_value == 'v': 1046 | flip = 2 1047 | elif current_value == 'hv': 1048 | flip = 3 1049 | else: 1050 | logging.warning(f'invalid FLIP value: {current_value}') 1051 | elif current_argument == '-c': 1052 | colormap = current_value 1053 | 1054 | 1055 | os.environ['SDL_VIDEO_X11_WMCLASS'] = 'hu.irl.cameractrls' 1056 | os.environ['SDL_VIDEO_WAYLAND_WMCLASS'] = 'hu.irl.cameractrls' 1057 | 1058 | win = SDLCameraWindow(device, width, height, angle, flip, colormap) 1059 | win.start_capturing() 1060 | return win.close() 1061 | 1062 | 1063 | if __name__ == '__main__': 1064 | sys.exit(main()) 1065 | --------------------------------------------------------------------------------