Skip to content

Instantly share code, notes, and snippets.

@smitelli
Created May 31, 2022 19:51
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save smitelli/8b5602cea0be3830c6f80802b3c4ff5c to your computer and use it in GitHub Desktop.
Save smitelli/8b5602cea0be3830c6f80802b3c4ff5c to your computer and use it in GitHub Desktop.
Klein Tools TI250 image tool
# 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