Created
May 31, 2022 19:51
-
-
Save smitelli/8b5602cea0be3830c6f80802b3c4ff5c to your computer and use it in GitHub Desktop.
Klein Tools TI250 image tool
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Klein Tools TI250 image tool by Scott Smitelli. Public domain. | |
# Requires at least Python 3.6 (developed and tested on 3.9) | |
# See https://www.scottsmitelli.com/articles/klein-tools-ti250-hidden-worlds | |
import argparse | |
import numpy as np | |
import re | |
import struct | |
from PIL import Image, ImageDraw | |
PALETTE_TABLE = { # encoding is 16-bit 5r:6g:5b, like in the BMP format | |
'gray': [ | |
0, 0, 0, 0, 0, 0, 32, 32, 32, 32, 2113, 2113, 2113, 2113, 2145, 2145, | |
2145, 2145, 4226, 4226, 4226, 4226, 4258, 4258, 4258, 4258, 4290, 6339, | |
6339, 6339, 6371, 6371, 6371, 6371, 6403, 8452, 8452, 8452, 8484, 8484, | |
8484, 8484, 8516, 8516, 10565, 10565, 10597, 10597, 10597, 10597, 10629, | |
10629, 12678, 12678, 12710, 12710, 12710, 12710, 12742, 12742, 12742, | |
14791, 14823, 14823, 14823, 14823, 14855, 14855, 14855, 16904, 16936, | |
16936, 16936, 16936, 16968, 16968, 16968, 16968, 19049, 19049, 19049, | |
19049, 19081, 19081, 19081, 19081, 21162, 21162, 21162, 21162, 21162, | |
21194, 21194, 21194, 21194, 23275, 23275, 23275, 23275, 23307, 23307, | |
23307, 23307, 25388, 25388, 25388, 25388, 25420, 25420, 25420, 25420, | |
27501, 27501, 27501, 27501, 27533, 27533, 27533, 27533, 29614, 29614, | |
29614, 29614, 29646, 29646, 29646, 29646, 31727, 31727, 31727, 31727, | |
31759, 31759, 31759, 31759, 33840, 33840, 33840, 33840, 33872, 33872, | |
33872, 33872, 35953, 35953, 35953, 35953, 35985, 35985, 35985, 35985, | |
38066, 38066, 38066, 38066, 38098, 38098, 38098, 38098, 40179, 40179, | |
40179, 40179, 40211, 40211, 40211, 40211, 42292, 42292, 42292, 42292, | |
42324, 42324, 42324, 42324, 42324, 44405, 44405, 44405, 44405, 44437, | |
44437, 44437, 44437, 46518, 46518, 46518, 46518, 46550, 46550, 46550, | |
46550, 48631, 48631, 48631, 48631, 48663, 48663, 48663, 48663, 50744, | |
50744, 50744, 50744, 50776, 50776, 50776, 50776, 52857, 52857, 52857, | |
52857, 52889, 52889, 52889, 52889, 54970, 54970, 54970, 54970, 55002, | |
55002, 55002, 55002, 57083, 57083, 57083, 57083, 57115, 57115, 57115, | |
57115, 59196, 59196, 59196, 59196, 59228, 59228, 59228, 59228, 61309, | |
61309, 61309, 61309, 61341, 61341, 61341, 61341, 63422, 63422, 63422, | |
63422, 63454, 63454, 63454, 63454], | |
'ironbow': [ | |
3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 10, 10, 11, 11, 12, 12, | |
13, 13, 2061, 2061, 2062, 2062, 4111, 4111, 4111, 6160, 6160, 8208, | |
8208, 10257, 10257, 12305, 12305, 14354, 14354, 16402, 16402, 16402, | |
18450, 18450, 20498, 20498, 20499, 20499, 22547, 22547, 24595, 24595, | |
26643, 26643, 28691, 28691, 28691, 30739, 30739, 32787, 32787, 34835, | |
34835, 36883, 36883, 38931, 38931, 40979, 40979, 43027, 43027, 43027, | |
45074, 45074, 47154, 47154, 49202, 49202, 49233, 49233, 49234, 49234, | |
49265, 49265, 49265, 51344, 51344, 51375, 51375, 51376, 51376, 51407, | |
51407, 53454, 53454, 53455, 53455, 53486, 53486, 53486, 53517, 53517, | |
53548, 53548, 55627, 55627, 55628, 55628, 55658, 55658, 55659, 55659, | |
55659, 55689, 55689, 55719, 55719, 55720, 55720, 57798, 57798, 57828, | |
57828, 57829, 57829, 57859, 57859, 57859, 57890, 57890, 57891, 57891, | |
57922, 57922, 60001, 60001, 60002, 60002, 60033, 60033, 60065, 60065, | |
60065, 60096, 60096, 60128, 60128, 60160, 60160, 62240, 62240, 62272, | |
62272, 62304, 62304, 62304, 62336, 62336, 62368, 62368, 62400, 62400, | |
62432, 62432, 62464, 62464, 62496, 62496, 64576, 64576, 64576, 64608, | |
64608, 64640, 64640, 64672, 64672, 64704, 64704, 64736, 64736, 64768, | |
64768, 64768, 64800, 64800, 64832, 64832, 64864, 64864, 64896, 64896, | |
64928, 64928, 64960, 64960, 64992, 64992, 64992, 65024, 65024, 65056, | |
65056, 65088, 65088, 65120, 65120, 65152, 65152, 65185, 65185, 65217, | |
65217, 65217, 65250, 65250, 65251, 65251, 65284, 65284, 65317, 65317, | |
65318, 65318, 65319, 65319, 65319, 65351, 65351, 65352, 65352, 65353, | |
65353, 65386, 65386, 65387, 65387, 65388, 65388, 65421, 65421, 65421, | |
65422, 65422, 65423, 65423, 65424, 65424, 65425, 65425, 65458, 65458, | |
65459, 65459], | |
'rainbow': [ | |
4195, 4195, 4195, 4195, 4195, 4196, 4196, 4196, 4196, 4197, 4197, 4197, | |
4198, 4198, 4198, 4198, 4199, 4199, 4199, 4199, 4200, 4200, 4200, 4201, | |
4201, 4201, 4201, 4202, 4202, 4202, 4202, 4203, 4203, 4203, 4204, 4204, | |
4204, 4204, 4205, 4205, 4205, 4206, 4206, 4238, 4270, 4302, 4334, 4334, | |
4366, 4398, 4430, 4461, 4493, 4493, 4525, 4557, 4589, 4621, 4621, 4653, | |
4685, 4716, 4748, 4780, 4780, 4812, 4844, 4876, 4908, 4908, 4940, 4971, | |
5003, 5035, 5067, 5067, 5099, 5131, 5163, 5195, 5195, 5226, 5258, 5290, | |
5322, 5354, 5354, 5386, 5418, 5450, 5482, 5513, 5513, 7561, 9609, 11688, | |
13736, 15784, 17864, 17863, 19911, 21991, 24039, 26086, 28134, 30214, | |
32261, 32261, 34341, 36389, 38436, 40516, 42564, 44612, 44611, 46691, | |
48739, 50787, 52866, 54914, 56962, 59041, 59041, 59009, 58977, 58977, | |
58945, 58913, 58881, 58881, 58849, 58817, 58817, 58785, 58753, 58721, | |
58721, 58689, 58657, 58625, 58625, 58593, 58561, 58561, 58529, 58497, | |
58465, 58465, 58433, 58401, 58369, 58369, 58337, 58305, 58305, 58273, | |
58241, 58209, 58209, 58177, 58145, 60161, 60161, 60129, 60097, 60065, | |
60033, 60001, 59969, 59937, 59905, 59873, 59841, 59809, 59777, 59745, | |
59713, 59681, 59649, 59617, 59585, 59554, 59554, 59587, 59587, 59620, | |
59620, 59653, 59653, 59686, 59686, 59719, 59719, 59752, 59752, 59785, | |
59786, 59818, 59819, 59851, 59852, 59884, 59885, 59917, 59918, 59950, | |
59951, 59983, 59984, 60016, 60017, 60050, 60050, 60082, 60114, 60147, | |
60179, 60211, 60244, 60276, 60308, 60340, 60373, 60405, 60437, 60470, | |
60502, 60534, 60566, 60599, 60631, 60663, 60696, 60728, 62776, 62808, | |
62841, 62873, 62905, 62938, 62970, 63002, 63034, 63067, 63099, 63131, | |
63164, 63196, 63228, 63260, 63293, 63325, 63357, 63390, 63422, 63454]} | |
class ThermalImageReader: | |
IMAGE_WIDTH = 320 | |
IMAGE_HEIGHT = 240 | |
PALETTE_SIZE = 256 | |
DATE_MATCHER = re.compile(r'img(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2}).bmp$', re.I) | |
TRAILER_FORMAT = '<BhhHhBIHHHHI' | |
COLOR_MIN = (0, 64, 255) | |
COLOR_MAX = (255, 64, 0) | |
DRAW_SIZE = 5 # distance from center of min/max points | |
def __init__(self): | |
self.trailer_size = struct.calcsize(self.TRAILER_FORMAT) | |
def load(self, filename): | |
self.filename = filename | |
with open(self.filename, 'rb') as f: | |
assert f.read(2) == b'BM', 'is this a BMP file?' | |
bmp_size = int.from_bytes(f.read(4), 'little') | |
f.seek(bmp_size) | |
assert f.read(3) == b'\x00\x00\x00', 'is this a TI250 file?' | |
self.thermal_data = np.ndarray( | |
shape=(self.IMAGE_WIDTH, self.IMAGE_HEIGHT), dtype=np.uint8, | |
buffer=f.read(self.IMAGE_WIDTH * self.IMAGE_HEIGHT)) | |
self.palette_data = np.empty(shape=(self.PALETTE_SIZE, 3), dtype=np.uint8) | |
for i in range(self.PALETTE_SIZE): | |
packed_color = int.from_bytes(f.read(2), 'little') | |
self.palette_data[i] = self.bmp_565_to_rgb(packed_color) | |
( | |
self.display_units, | |
self.scale_hi_temperature, | |
self.scale_lo_temperature, | |
self.highest_thermal_value, | |
self.center_temperature, | |
self.emissivity, | |
padding1, | |
self.min_temperature_y, | |
self.min_temperature_x, | |
self.max_temperature_y, | |
self.max_temperature_x, | |
padding2 | |
) = struct.unpack(self.TRAILER_FORMAT, f.read(self.trailer_size)) | |
assert padding1 == 3 # unknown meaning | |
assert padding2 == 0 | |
assert f.read() == b'', 'why is the file longer than it should be?' | |
def save(self, filename, use_palette=True, draw_min=False, draw_max=False): | |
# Data is captured in PORTRAIT orientation. Once rotated, the origin of | |
# the min/max points is relative to the TOP-RIGHT of the output image. | |
therm_data = np.rot90(self.thermal_data, k=-1) | |
if use_palette: | |
# TODO There is some kind of missing "gamma" correction curve or | |
# something that makes the colors come out a little too hot here. | |
im = Image.fromarray(self.palette_data[therm_data], mode='RGB') | |
else: | |
im = Image.fromarray(therm_data, mode='L').convert(mode='RGB') | |
if draw_min or draw_max: | |
draw = ImageDraw.Draw(im) | |
if draw_min: | |
flip_min_temperature_x = self.IMAGE_WIDTH - 1 - self.min_temperature_x | |
draw.rectangle(( | |
flip_min_temperature_x - self.DRAW_SIZE, | |
self.min_temperature_y - self.DRAW_SIZE, | |
flip_min_temperature_x + self.DRAW_SIZE, | |
self.min_temperature_y + self.DRAW_SIZE | |
), outline=self.COLOR_MIN) | |
if draw_max: | |
flip_max_temperature_x = self.IMAGE_WIDTH - 1 - self.max_temperature_x | |
draw.rectangle(( | |
flip_max_temperature_x - self.DRAW_SIZE, | |
self.max_temperature_y - self.DRAW_SIZE, | |
flip_max_temperature_x + self.DRAW_SIZE, | |
self.max_temperature_y + self.DRAW_SIZE | |
), outline=self.COLOR_MAX) | |
im.save(filename) | |
def sanity_check(self): | |
assert self.thermal_data.min() == 0 | |
assert self.thermal_data.max() == self.highest_thermal_value | |
assert self.identify_palette() is not None | |
assert self.display_units in (0, 1) | |
assert self.scale_lo_temperature <= self.center_temperature <= self.scale_hi_temperature | |
assert self.highest_thermal_value == np.unique(self.thermal_data).size - 1 | |
assert self.highest_thermal_value <= 254 | |
assert 1 <= self.emissivity <= 100 | |
# +1 and +2 below permit out-of-bounds values that were seen in the wild | |
assert 0 <= self.min_temperature_y < self.IMAGE_HEIGHT + 1 | |
assert 0 <= self.min_temperature_x < self.IMAGE_WIDTH + 2 | |
assert 0 <= self.max_temperature_y < self.IMAGE_HEIGHT + 1 | |
assert 0 <= self.max_temperature_x < self.IMAGE_WIDTH | |
def identify_palette(self): | |
for name, table in PALETTE_TABLE.items(): | |
for i in range(self.PALETTE_SIZE): | |
if (self.bmp_565_to_rgb(table[i]) != self.palette_data[i]).any(): | |
break | |
else: | |
return name | |
return None | |
def force_palette(self, palette_name): | |
for i in range(self.PALETTE_SIZE): | |
packed_color = PALETTE_TABLE[palette_name][i] | |
self.palette_data[i] = self.bmp_565_to_rgb(packed_color) | |
def tempfmt(self, attr): | |
tenths_c = getattr(self, attr) | |
if self.display_units == 1: | |
# Truncate-towards-zero the same way the TI250 does it | |
return f'{(np.fix(tenths_c * (9 / 5)) / 10) + 32} F' | |
return f'{tenths_c / 10} C' | |
@property | |
def datefmt(self): | |
d = self.DATE_MATCHER.search(self.filename) | |
if d: | |
return f'20{d[1]}-{d[2]}-{d[3]} {d[4]}:{d[5]}:{d[6]}' # yay Y2.1K | |
return None | |
@property | |
def emisfmt(self): | |
return f'{(self.emissivity / 100):0.2f}' | |
@property | |
def unitfmt(self): | |
return 'Celsius' if self.display_units == 0 else 'Fahrenheit' | |
@staticmethod | |
def bmp_565_to_rgb(value): | |
b = value & 0x1F | |
g = (value >> 5) & 0x3F | |
r = (value >> 11) & 0x1F | |
return (r << 3, g << 2, b << 3) | |
def main(args): | |
r = ThermalImageReader() | |
r.load(args.in_file) | |
print(f'Input filename:\t\t{r.filename}') | |
print(f'Date from filename:\t{r.datefmt}') | |
print(f'Image dimensions:\t{r.IMAGE_WIDTH}x{r.IMAGE_HEIGHT}') | |
print(f'Detected palette:\t{r.identify_palette()} ({r.PALETTE_SIZE} entries)') | |
print(f'Display units:\t\t{r.unitfmt}') | |
print(f'Temperature scale:\t{r.tempfmt("scale_lo_temperature")} - {r.tempfmt("scale_hi_temperature")}') | |
print(f'Center temperature:\t{r.tempfmt("center_temperature")}') | |
print(f'Highest thermal value:\t{r.highest_thermal_value}') | |
print(f'Emissivity:\t\t{r.emisfmt}') | |
print(f'Min temperature point:\t({r.min_temperature_x}, {r.min_temperature_y})') | |
print(f'Max temperature point:\t({r.max_temperature_x}, {r.max_temperature_y})') | |
if args.skip_sanity: | |
print('Sanity check skipped.') | |
else: | |
r.sanity_check() | |
print('Sanity check passed!') | |
if args.force_palette is not None: | |
r.force_palette(args.force_palette) | |
if args.out_file is not None: | |
r.save( | |
args.out_file, use_palette=(not args.raw_output), | |
draw_min=args.draw_min, draw_max=args.draw_max) | |
print(f'Saved {args.out_file}.') | |
if __name__ == '__main__': | |
parser = argparse.ArgumentParser(description='Klein Tools TI250 thermal decoder.') | |
parser.add_argument('in_file', help='path to a .bmp file written by the thermal imager') | |
parser.add_argument('-o', '--out-file', help='the output image filename to (over)write') | |
parser.add_argument( | |
'-f', '--force-palette', choices=PALETTE_TABLE.keys(), | |
help='force a different color palette than the one encoded in the file') | |
parser.add_argument('-s', '--skip-sanity', action='store_true', help='skip checking the file for consistency') | |
parser.add_argument('-r', '--raw-output', action='store_true', help='do not apply palette coloring to output') | |
parser.add_argument('-n', '--draw-min', action='store_true', help='draw marker at the minimum temperature') | |
parser.add_argument('-x', '--draw-max', action='store_true', help='draw marker at the maximum temperature') | |
args = parser.parse_args() | |
main(args) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment