427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""
|
|
Touchscreen calibration module.
|
|
|
|
This module provides calibration functionality to align touchscreen coordinates
|
|
with display pixels. Uses a multi-point calibration approach with affine transformation.
|
|
|
|
The calibration process:
|
|
1. Display calibration targets (circles) at known display positions
|
|
2. User touches each target
|
|
3. Record both display coordinates and touch coordinates
|
|
4. Calculate affine transformation matrix to map touch -> display
|
|
5. Save calibration data for future use
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass, asdict
|
|
from typing import List, Tuple, Optional
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class CalibrationPoint:
|
|
"""
|
|
A single calibration point pair.
|
|
|
|
Attributes:
|
|
display_x: X coordinate on display (pixels)
|
|
display_y: Y coordinate on display (pixels)
|
|
touch_x: Raw X coordinate from touch sensor
|
|
touch_y: Raw Y coordinate from touch sensor
|
|
"""
|
|
display_x: int
|
|
display_y: int
|
|
touch_x: int
|
|
touch_y: int
|
|
|
|
|
|
@dataclass
|
|
class CalibrationData:
|
|
"""
|
|
Complete calibration dataset with transformation matrix.
|
|
|
|
Attributes:
|
|
points: List of calibration point pairs
|
|
matrix: 2x3 affine transformation matrix [a, b, c, d, e, f]
|
|
where: x' = ax + by + c, y' = dx + ey + f
|
|
width: Display width in pixels
|
|
height: Display height in pixels
|
|
rms_error: Root mean square error of calibration (pixels)
|
|
"""
|
|
points: List[CalibrationPoint]
|
|
matrix: List[float] # [a, b, c, d, e, f]
|
|
width: int
|
|
height: int
|
|
rms_error: float = 0.0
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary for JSON serialization."""
|
|
return {
|
|
'points': [asdict(p) for p in self.points],
|
|
'matrix': self.matrix,
|
|
'width': self.width,
|
|
'height': self.height,
|
|
'rms_error': self.rms_error,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> 'CalibrationData':
|
|
"""Create from dictionary (JSON deserialization)."""
|
|
points = [CalibrationPoint(**p) for p in data['points']]
|
|
return cls(
|
|
points=points,
|
|
matrix=data['matrix'],
|
|
width=data['width'],
|
|
height=data['height'],
|
|
rms_error=data.get('rms_error', 0.0),
|
|
)
|
|
|
|
|
|
class TouchCalibration:
|
|
"""
|
|
Touchscreen calibration with affine transformation.
|
|
|
|
This class handles:
|
|
- Generating calibration target positions
|
|
- Computing affine transformation from calibration points
|
|
- Transforming raw touch coordinates to display coordinates
|
|
- Saving/loading calibration data
|
|
|
|
Args:
|
|
width: Display width in pixels
|
|
height: Display height in pixels
|
|
num_points: Number of calibration points (default 9 for 3x3 grid)
|
|
"""
|
|
|
|
def __init__(self, width: int, height: int, num_points: int = 9):
|
|
self.width = width
|
|
self.height = height
|
|
self.num_points = num_points
|
|
self.calibration_data: Optional[CalibrationData] = None
|
|
|
|
def generate_target_positions(self, margin: int = 100, target_radius: int = 10) -> List[Tuple[int, int]]:
|
|
"""
|
|
Generate positions for calibration targets.
|
|
|
|
Creates a grid of targets with margins from edges.
|
|
For 9 points: 3x3 grid (corners, edges, center)
|
|
For 5 points: corners + center
|
|
|
|
Args:
|
|
margin: Distance from screen edges (pixels)
|
|
target_radius: Radius of calibration circles (pixels)
|
|
|
|
Returns:
|
|
List of (x, y) positions for calibration targets
|
|
"""
|
|
if self.num_points == 5:
|
|
# 5-point calibration: corners + center
|
|
return [
|
|
(margin, margin), # Top-left
|
|
(self.width - margin, margin), # Top-right
|
|
(self.width - margin, self.height - margin), # Bottom-right
|
|
(margin, self.height - margin), # Bottom-left
|
|
(self.width // 2, self.height // 2), # Center
|
|
]
|
|
elif self.num_points == 9:
|
|
# 9-point calibration: 3x3 grid
|
|
mid_x = self.width // 2
|
|
mid_y = self.height // 2
|
|
return [
|
|
(margin, margin), # Top-left
|
|
(mid_x, margin), # Top-center
|
|
(self.width - margin, margin), # Top-right
|
|
(margin, mid_y), # Middle-left
|
|
(mid_x, mid_y), # Center
|
|
(self.width - margin, mid_y), # Middle-right
|
|
(margin, self.height - margin), # Bottom-left
|
|
(mid_x, self.height - margin), # Bottom-center
|
|
(self.width - margin, self.height - margin), # Bottom-right
|
|
]
|
|
else:
|
|
# Custom grid based on num_points
|
|
# Create as uniform a grid as possible
|
|
grid_size = int(math.sqrt(self.num_points))
|
|
positions = []
|
|
for i in range(grid_size):
|
|
for j in range(grid_size):
|
|
x = margin + (self.width - 2 * margin) * j // (grid_size - 1)
|
|
y = margin + (self.height - 2 * margin) * i // (grid_size - 1)
|
|
positions.append((x, y))
|
|
return positions[:self.num_points]
|
|
|
|
def add_calibration_point(self, display_x: int, display_y: int,
|
|
touch_x: int, touch_y: int) -> None:
|
|
"""
|
|
Add a calibration point pair.
|
|
|
|
Args:
|
|
display_x: X coordinate on display
|
|
display_y: Y coordinate on display
|
|
touch_x: Raw X from touch sensor
|
|
touch_y: Raw Y from touch sensor
|
|
"""
|
|
if self.calibration_data is None:
|
|
self.calibration_data = CalibrationData(
|
|
points=[],
|
|
matrix=[1, 0, 0, 0, 1, 0], # Identity transform
|
|
width=self.width,
|
|
height=self.height,
|
|
)
|
|
|
|
point = CalibrationPoint(display_x, display_y, touch_x, touch_y)
|
|
self.calibration_data.points.append(point)
|
|
|
|
def compute_calibration(self) -> bool:
|
|
"""
|
|
Compute affine transformation matrix from calibration points.
|
|
|
|
Uses least-squares fitting to find the best affine transformation
|
|
that maps touch coordinates to display coordinates.
|
|
|
|
Affine transformation:
|
|
x_display = a * x_touch + b * y_touch + c
|
|
y_display = d * x_touch + e * y_touch + f
|
|
|
|
Returns:
|
|
True if calibration successful, False otherwise
|
|
"""
|
|
if not self.calibration_data or len(self.calibration_data.points) < 3:
|
|
return False
|
|
|
|
# Build least-squares system
|
|
# For each point: x' = ax + by + c, y' = dx + ey + f
|
|
# We need at least 3 points to solve for 6 unknowns
|
|
|
|
points = self.calibration_data.points
|
|
n = len(points)
|
|
|
|
# Build matrices for least-squares: Ax = b
|
|
# For x': [x1 y1 1] [a] [x'1]
|
|
# [x2 y2 1] [b] = [x'2]
|
|
# [x3 y3 1] [c] [x'3]
|
|
|
|
# Matrix A (n x 3)
|
|
A = [[p.touch_x, p.touch_y, 1] for p in points]
|
|
|
|
# Vectors b_x and b_y
|
|
b_x = [p.display_x for p in points]
|
|
b_y = [p.display_y for p in points]
|
|
|
|
# Solve least-squares for x-transform: [a, b, c]
|
|
abc = self._solve_least_squares(A, b_x)
|
|
if abc is None:
|
|
return False
|
|
|
|
# Solve least-squares for y-transform: [d, e, f]
|
|
def_vals = self._solve_least_squares(A, b_y)
|
|
if def_vals is None:
|
|
return False
|
|
|
|
# Store transformation matrix
|
|
self.calibration_data.matrix = abc + def_vals
|
|
|
|
# Compute RMS error
|
|
self.calibration_data.rms_error = self._compute_rms_error()
|
|
|
|
return True
|
|
|
|
def _solve_least_squares(self, A: List[List[float]], b: List[float]) -> Optional[List[float]]:
|
|
"""
|
|
Solve least-squares problem: A x = b
|
|
|
|
Uses normal equations: (A^T A) x = A^T b
|
|
|
|
Args:
|
|
A: Matrix (n x 3)
|
|
b: Vector (n x 1)
|
|
|
|
Returns:
|
|
Solution vector x (3 x 1) or None if singular
|
|
"""
|
|
# Compute A^T A (3 x 3)
|
|
n = len(A)
|
|
m = len(A[0])
|
|
|
|
ATA = [[0.0] * m for _ in range(m)]
|
|
for i in range(m):
|
|
for j in range(m):
|
|
for k in range(n):
|
|
ATA[i][j] += A[k][i] * A[k][j]
|
|
|
|
# Compute A^T b (3 x 1)
|
|
ATb = [0.0] * m
|
|
for i in range(m):
|
|
for k in range(n):
|
|
ATb[i] += A[k][i] * b[k]
|
|
|
|
# Solve 3x3 system using Gaussian elimination
|
|
return self._solve_3x3(ATA, ATb)
|
|
|
|
def _solve_3x3(self, A: List[List[float]], b: List[float]) -> Optional[List[float]]:
|
|
"""
|
|
Solve 3x3 linear system using Gaussian elimination.
|
|
|
|
Args:
|
|
A: 3x3 matrix
|
|
b: 3x1 vector
|
|
|
|
Returns:
|
|
Solution vector or None if singular
|
|
"""
|
|
# Create augmented matrix
|
|
aug = [A[i][:] + [b[i]] for i in range(3)]
|
|
|
|
# Forward elimination
|
|
for i in range(3):
|
|
# Find pivot
|
|
max_row = i
|
|
for k in range(i + 1, 3):
|
|
if abs(aug[k][i]) > abs(aug[max_row][i]):
|
|
max_row = k
|
|
|
|
# Swap rows
|
|
aug[i], aug[max_row] = aug[max_row], aug[i]
|
|
|
|
# Check for singular matrix
|
|
if abs(aug[i][i]) < 1e-10:
|
|
return None
|
|
|
|
# Eliminate column
|
|
for k in range(i + 1, 3):
|
|
factor = aug[k][i] / aug[i][i]
|
|
for j in range(i, 4):
|
|
aug[k][j] -= factor * aug[i][j]
|
|
|
|
# Back substitution
|
|
x = [0.0] * 3
|
|
for i in range(2, -1, -1):
|
|
x[i] = aug[i][3]
|
|
for j in range(i + 1, 3):
|
|
x[i] -= aug[i][j] * x[j]
|
|
x[i] /= aug[i][i]
|
|
|
|
return x
|
|
|
|
def _compute_rms_error(self) -> float:
|
|
"""
|
|
Compute root mean square error of calibration.
|
|
|
|
Returns:
|
|
RMS error in pixels
|
|
"""
|
|
if not self.calibration_data or not self.calibration_data.points:
|
|
return 0.0
|
|
|
|
total_sq_error = 0.0
|
|
for point in self.calibration_data.points:
|
|
# Transform touch coordinates
|
|
tx, ty = self.transform(point.touch_x, point.touch_y)
|
|
|
|
# Compute error
|
|
dx = tx - point.display_x
|
|
dy = ty - point.display_y
|
|
total_sq_error += dx * dx + dy * dy
|
|
|
|
return math.sqrt(total_sq_error / len(self.calibration_data.points))
|
|
|
|
def transform(self, touch_x: int, touch_y: int) -> Tuple[int, int]:
|
|
"""
|
|
Transform raw touch coordinates to display coordinates.
|
|
|
|
Args:
|
|
touch_x: Raw X from touch sensor
|
|
touch_y: Raw Y from touch sensor
|
|
|
|
Returns:
|
|
(display_x, display_y) tuple
|
|
"""
|
|
if not self.calibration_data:
|
|
# No calibration - return raw coordinates
|
|
return (touch_x, touch_y)
|
|
|
|
m = self.calibration_data.matrix
|
|
a, b, c, d, e, f = m
|
|
|
|
# Apply affine transformation
|
|
x = a * touch_x + b * touch_y + c
|
|
y = d * touch_x + e * touch_y + f
|
|
|
|
# Clamp to display bounds
|
|
x = max(0, min(int(round(x)), self.width - 1))
|
|
y = max(0, min(int(round(y)), self.height - 1))
|
|
|
|
return (x, y)
|
|
|
|
def save(self, filepath: str) -> None:
|
|
"""
|
|
Save calibration data to JSON file.
|
|
|
|
Args:
|
|
filepath: Path to save calibration file
|
|
"""
|
|
if not self.calibration_data:
|
|
raise ValueError("No calibration data to save")
|
|
|
|
path = Path(filepath)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(filepath, 'w') as f:
|
|
json.dump(self.calibration_data.to_dict(), f, indent=2)
|
|
|
|
def load(self, filepath: str) -> bool:
|
|
"""
|
|
Load calibration data from JSON file.
|
|
|
|
Args:
|
|
filepath: Path to calibration file
|
|
|
|
Returns:
|
|
True if loaded successfully, False otherwise
|
|
"""
|
|
try:
|
|
with open(filepath, 'r') as f:
|
|
data = json.load(f)
|
|
|
|
self.calibration_data = CalibrationData.from_dict(data)
|
|
|
|
# Verify dimensions match
|
|
if (self.calibration_data.width != self.width or
|
|
self.calibration_data.height != self.height):
|
|
print(f"Warning: Calibration dimensions ({self.calibration_data.width}x{self.calibration_data.height}) "
|
|
f"don't match display ({self.width}x{self.height})")
|
|
return False
|
|
|
|
return True
|
|
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
print(f"Failed to load calibration: {e}")
|
|
return False
|
|
|
|
def is_calibrated(self) -> bool:
|
|
"""Check if calibration is loaded and valid."""
|
|
return (self.calibration_data is not None and
|
|
len(self.calibration_data.points) >= 3 and
|
|
self.calibration_data.matrix is not None)
|
|
|
|
def get_calibration_quality(self) -> str:
|
|
"""
|
|
Get calibration quality assessment.
|
|
|
|
Returns:
|
|
Quality string: "Excellent", "Good", "Fair", "Poor", or "Uncalibrated"
|
|
"""
|
|
if not self.is_calibrated():
|
|
return "Uncalibrated"
|
|
|
|
error = self.calibration_data.rms_error
|
|
|
|
if error < 5:
|
|
return "Excellent"
|
|
elif error < 10:
|
|
return "Good"
|
|
elif error < 20:
|
|
return "Fair"
|
|
else:
|
|
return "Poor"
|