I've written this as an alternative to xinput_calibrator when it wasn't working for me. It's my first crack at several things: X.org, numpy, and tkinter.

This does indeed work (for me, although I've only tested it with one touchscreen and this has not been tested publicly).

Do you see any obvious bugs or gotchas, in general or specific to the libs being used?

I've just uploaded it to Github, although it's simple enough that it exists completely in one script, shown here:

#!/usr/bin/env python3

# requires python3-tk, python3-numpy

import re
import numpy as np
from subprocess import run, PIPE

prop_name = 'libinput Calibration Matrix'

def xinput(*args):
    return run(args=('/usr/bin/xinput', *args),
               stdout=PIPE, check=True,

def get_devs():
    devs = {int(groups[1]): groups[0] for groups in
            re.findall(r'↳ (\w.+\w)\s+id=(\d+)\D+slave *pointer',
                       xinput('--list', '--short'))}
    if not devs:
        print('No suitable input devices found')
    return devs

def print_devs(devs):
    print('Pointer devices:')
    print('%4s %35s' % ('ID', 'Name'))
    for i, name in sorted(devs.items()):
        print('%4d %35s' % (i, name))

def choose_preferred(devs):
    preferred = [i for (i, n) in devs.items() if 'touch' in n.lower()]
    if preferred:
        return preferred[0]
    return next(iter(devs.keys()))

def choose_dev(devs, preferred):
    while True:
        devstr = input('Device to calibrate [%d]: ' % preferred)
        if not devstr:
            return preferred
            dev = int(devstr)
        except ValueError:
        if dev in devs.keys():
            return dev

def read_cal(dev):
    stdout = xinput('--list-props', str(dev))
    line = re.search(prop_name + r'.*:\s+(\S.+)', stdout)
    if not line:
        print('Cal property not set; is this an xinput device?')
    vals = np.matrix(line.group(1)).reshape(3, 3)

    print('Old calibration:')
    return vals, np.linalg.inv(vals)

def ask(q):
    do = input(q + ' [y]: ')
    return (do or 'y').lower() == 'y'

def choose_points():
    p_min, default = 3, 4

    while True:
        p_str = input('Point count (min %d) [%d]: ' %
                      (p_min, default))
        if not p_str:
            return default
            p = int(p_str)
        except ValueError:

        if p >= p_min:
            return p

def show_tk(n_points, old_cal_inv, new_cal=None):
    from tkinter import Tk, Canvas
    from math import ceil, sqrt

    root = Tk()
    X, Y = None, None
    root.attributes('-fullscreen', True)
    canvas = Canvas(root)

    def resize(event):
        nonlocal X, Y
        X, Y = event.width, event.height
    canvas.bind('<Configure>', resize)
    canvas.pack(expand=True, fill='both')

    point, points = {}, []
    index = -1
    n_cols = int(ceil(sqrt(n_points)))
    n_rows = int(ceil(n_points / n_cols))
    sensitive = False

    def next_point():
        nonlocal point, index, sensitive
        sensitive = True
        index += 1
        if index >= n_points:
            x = 0.1 + 0.8*(index % n_cols)/(n_cols - 1)
            y = 0.1 + 0.8*(index // n_cols)/(n_rows - 1)
            point = {'sx': x, 'sy': y}

            draw_target(point['sx'], point['sy'])

    def cross(px, py, colour):
        x, y = px*X, py*Y
        canvas.create_line(x-10, y, x+10, y, fill=colour)
        canvas.create_line(x, y-10, x, y+10, fill=colour)

    def draw_target(px, py):
        x, y = px*X, py*Y
        canvas.create_oval(x-10, y-10, x+10, y+10, outline='#F00', width=3)
        cross(px, py, '#F00')

    def cancel_cal(_):
        print('Calibration cancelled')
    root.bind('<Escape>', cancel_cal)
    canvas.bind('<Escape>', cancel_cal)

    def transform(x, y, cal):
        p = np.matrix([[x], [y], [1]])
        out = np.matmul(cal, p)
        return out.item(0), out.item(1)

    def indicator(sx, sy, px, py, colour):
        canvas.create_line(X*sx, Y*sy, X*px, Y*py, fill=colour)
        cross(px, py, colour)

    def click(event):
        nonlocal sensitive
        if not sensitive:
        sensitive = False

        sx, sy = point['sx'], point['sy']

        ox, oy = event.x/X, event.y/Y  # old-calibrated
        indicator(sx, sy, ox, oy, '#00F')

        ux, uy = transform(ox, oy, old_cal_inv)  # uncalibrated
        indicator(sx, sy, ux, uy, '#000')

        if new_cal is not None:
            nx, ny = transform(ux, uy, new_cal)  # new-calibrated (test only)
            indicator(sx, sy, nx, ny, '#0F0')

        point.update({'mx': ux, 'my': uy})

        canvas.after(500, next_point)
    canvas.bind('<Button-1>', click)


    return points

def calibrate(points):
    from math import log10

    m_screen = np.matrix([[p['sx'], p['sy'], 1] for p in points])
    m_mouse = np.matrix([[p['mx'], p['my'], 1] for p in points])
    m_transform, residuals, rank, singular = np.linalg.lstsq(m_mouse, m_screen)
    quality = -log10(residuals.sum())

    m_transform = m_transform.getT()
    m_transform[2, :] = [0, 0, 1]
    return m_transform, quality

def use_cal(dev, new_cal):
    cal_array = [str(x)+',' for x in new_cal.flatten().tolist()[0]]
    xinput('--set-prop', str(dev), prop_name, *cal_array)

def main():
    devs = get_devs()
    preferred = choose_preferred(devs)
    dev = choose_dev(devs, preferred)

    old_cal, old_cal_inv = read_cal(dev)

    new_cal = None
    if ask('Calibrate?'):
        n_points = choose_points()

        points = show_tk(n_points, old_cal_inv)
        if points:
            new_cal, quality = calibrate(points)

            print('New calibration:')
            print('Quality (should be at least 3): %.1f' % quality)

    if ask('Test?'):
        n_points = choose_points()
        show_tk(n_points, old_cal_inv, new_cal)

    if new_cal is not None and ask('Use calibration?'):
        use_cal(dev, new_cal)



Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.