-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
thermopile-occupancy.service.ts
214 lines (194 loc) · 6.4 KB
/
thermopile-occupancy.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import { Pixel } from './pixel';
import * as math from 'mathjs';
import { Cluster } from './cluster';
import * as _ from 'lodash';
import { HeatmapOptions } from './thermopile-occupancy.config';
import { rotate } from '2d-array-rotation';
import type { createCanvas, Canvas, CanvasRenderingContext2D } from 'canvas';
export type RotationOption = 0 | 90 | 180 | 270;
let nodeCanvas: { createCanvas: typeof createCanvas };
try {
nodeCanvas = require('canvas');
} catch (e) {
nodeCanvas = undefined;
}
export abstract class ThermopileOccupancyService {
/**
* Gets all temperatures in the sensor field of view.
*
* @returns Matrix of temperatures
*/
abstract getPixelTemperatures(): Promise<number[][]>;
/**
* Calculates the coordinates with human presence based on the temperatures.
*
* @param temperatures - Matrix of temperatures to analyze
* @param deltaThreshold - Minimum difference between avg and pixel temperature to consider it human
* @returns [x, y] coordinates of humans in field of view
*/
async getCoordinates(
temperatures: number[][],
deltaThreshold: number
): Promise<number[][]> {
const relevantPixels = this.findRelevantPixels(
temperatures as number[][],
deltaThreshold
);
const clusters = this.clusterPixels(relevantPixels);
return clusters.map((cluster) => [cluster.center.x, cluster.center.y]);
}
/**
* Filters out the relevant pixels for presence detection.
*
* @param data - Matrix of all temperatures in FOV
* @param deltaThreshold - Minimum difference between avg and pixel temperature to consider it human
* @returns Array of relevant pixels
*/
findRelevantPixels(data: number[][], deltaThreshold: number): Pixel[] {
const mean = math.mean(data);
const threshold = mean + deltaThreshold;
const relevantPixels: Pixel[] = [];
for (const [y, row] of data.entries()) {
for (const [x, value] of row.entries()) {
if (value >= threshold) {
relevantPixels.push(new Pixel(x, y, value));
}
}
}
return relevantPixels;
}
/**
* Clusters a list of pixels by their locations. Neighbors are grouped together.
*
* @param pixels - Array of pixels to be clustered
* @returns Calculated clusters containing all input pixels
*/
clusterPixels(pixels: Pixel[]): Cluster[] {
const clusters: Cluster[] = [];
pixels.forEach((pixel) => {
const neighbor = clusters.find((cluster) =>
cluster.isNeighboredTo(pixel)
);
if (neighbor === undefined) {
clusters.push(new Cluster([pixel]));
} else {
neighbor.pixels.push(pixel);
}
});
return clusters;
}
/**
* Checks if a heatmap can be generated.
*
* @returns Whether dependencies for generation are met or not
*/
isHeatmapAvailable(): boolean {
return nodeCanvas !== undefined;
}
/**
* Generates a blue to red heatmap image of the given temperatures.
*
* @param temperatures - Matrix of temperatures to be visualized
* @param options - Options for tuning the output
* @param width - Width of the output image in px
* @param height - Height of the output image in px
* @returns Buffer of JPEG image data
*/
async generateHeatmap(
temperatures: number[][],
options = new HeatmapOptions(),
width = 280,
height = 280
): Promise<Buffer> {
if (!this.isHeatmapAvailable()) {
throw new Error(
'Generating a heatmap requires the canvas optional dependency'
);
}
const segmentHeight = Math.round(height / temperatures.length);
const segmentWidth = Math.round(width / temperatures[0].length);
const canvas: Canvas = [90, 270].includes(options.rotation)
? nodeCanvas.createCanvas(height, width)
: nodeCanvas.createCanvas(width, height);
const ctx = canvas.getContext('2d');
let normed = math.divide(
math.subtract(temperatures, options.minTemperature),
options.maxTemperature - options.minTemperature
) as number[][];
normed = rotate(normed, options.rotation);
const rotatedTemperatures = rotate(temperatures, options.rotation);
for (const [y, row] of normed.entries()) {
for (const [x, value] of row.entries()) {
const pixelXOrigin = x * segmentWidth;
const pixelXCenter = pixelXOrigin + segmentWidth / 2;
const pixelYOrigin = y * segmentHeight;
const pixelYCenter = pixelYOrigin + segmentHeight / 2;
ThermopileOccupancyService.drawPixel(
ctx,
pixelXOrigin,
pixelYOrigin,
segmentWidth,
segmentHeight,
value
);
if (options.drawTemperatures) {
ThermopileOccupancyService.drawTemperature(
ctx,
pixelXCenter,
pixelYCenter,
segmentWidth,
rotatedTemperatures[y][x]
);
}
}
}
return canvas.toBuffer('image/png');
}
/**
* Draws a rectangle representing the pixel temperature with a color.
*
* @param ctx - 2d context of the canvas to draw on
* @param xOrigin - x coordinate of the pixel top-left origin
* @param yOrigin - y coordinate of the pixel top-left origin
* @param width - width of the pixel
* @param height - height of the pixel
* @param normedValue - value representing the temperature within a normed range (between 0 and 1)
*/
private static drawPixel(
ctx: CanvasRenderingContext2D,
xOrigin: number,
yOrigin: number,
width: number,
height: number,
normedValue: number
): void {
const h = (1 - _.clamp(normedValue, 0, 1)) * 240;
ctx.fillStyle = `hsl(${h}, 100%, 50%)`;
ctx.fillRect(xOrigin, yOrigin, width, height);
}
/**
* Draws a text representing the temperature onto a pixel.
*
* @param ctx - 2d context of the canvas to draw on
* @param xCenter - x coordinate of the pixel center
* @param yCenter - y coordinate of the pixel center
* @param width - width of the pixel
* @param temperature - temperature value to draw
*/
private static drawTemperature(
ctx: CanvasRenderingContext2D,
xCenter: number,
yCenter: number,
width: number,
temperature: number
): void {
ctx.save();
ctx.font = `${Math.round(width / 2.7)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'black';
ctx.translate(xCenter, yCenter);
ctx.fillText(temperature.toFixed(1), 0, 0);
ctx.restore();
}
}