From d2ad9bff036ca2b62a152b421724d89c23754f11 Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Sat, 30 May 2020 16:59:04 +0200 Subject: [PATCH] feat: draw temperature values on heatmaps The feature can be disabled with a new config option. --- docs/integrations/grid-eye.md | 11 +-- docs/integrations/omron-d6t.md | 11 +-- .../thermopile/thermopile-occupancy.config.ts | 1 + .../thermopile-occupancy.service.spec.ts | 41 ++++++--- .../thermopile-occupancy.service.ts | 86 +++++++++++++++++-- 5 files changed, 118 insertions(+), 32 deletions(-) diff --git a/docs/integrations/grid-eye.md b/docs/integrations/grid-eye.md index 5eb70d5..d99958e 100644 --- a/docs/integrations/grid-eye.md +++ b/docs/integrations/grid-eye.md @@ -74,11 +74,12 @@ When placing your sensor you need to consider a few factors to get reliable resu ### Heatmap -| Name | Type | Default | Description | -| ---------------- | ------ | ------- | ------------------------------------------------------------ | -| `minTemperature` | Number | `16` | Temperature that will be considered the lower bound for the color scale in °C. | -| `maxTemperature` | Number | `30` | Temperature that will be considered the upper bound for the color scale in °C. | -| `rotation` | Number | `0` | The amount of degrees that the heatmap output image should be rotated. Only `0`, `90`, `180` or `270` are supported as values. | +| Name | Type | Default | Description | +| ------------------ | ------- | ------- | ------------------------------------------------------------ | +| `minTemperature` | Number | `16` | Temperature that will be considered the lower bound for the color scale in °C. | +| `maxTemperature` | Number | `30` | Temperature that will be considered the upper bound for the color scale in °C. | +| `rotation` | Number | `0` | The amount of degrees that the heatmap output image should be rotated. Only `0`, `90`, `180` or `270` are supported as values. | +| `drawTemperatures` | Boolean | `true` | Whether the actual temperature values should be drawn on the heatmap or not. | ::: details Example Config diff --git a/docs/integrations/omron-d6t.md b/docs/integrations/omron-d6t.md index ccc7080..c7f3112 100644 --- a/docs/integrations/omron-d6t.md +++ b/docs/integrations/omron-d6t.md @@ -87,11 +87,12 @@ When placing your sensor you need to consider a few factors to get reliable resu ### Heatmap -| Name | Type | Default | Description | -| ---------------- | ------ | ------- | ------------------------------------------------------------ | -| `minTemperature` | Number | `16` | Temperature that will be considered the lower bound for the color scale in °C. | -| `maxTemperature` | Number | `30` | Temperature that will be considered the upper bound for the color scale in °C. | -| `rotation` | Number | `0` | The amount of degrees that the heatmap output image should be rotated. Only `0`, `90`, `180` or `270` are supported as values. | +| Name | Type | Default | Description | +| ------------------ | ------- | ------- | ------------------------------------------------------------ | +| `minTemperature` | Number | `16` | Temperature that will be considered the lower bound for the color scale in °C. | +| `maxTemperature` | Number | `30` | Temperature that will be considered the upper bound for the color scale in °C. | +| `rotation` | Number | `0` | The amount of degrees that the heatmap output image should be rotated. Only `0`, `90`, `180` or `270` are supported as values. | +| `drawTemperatures` | Boolean | `true` | Whether the actual temperature values should be drawn on the heatmap or not. | ::: details Example Config diff --git a/src/integrations/thermopile/thermopile-occupancy.config.ts b/src/integrations/thermopile/thermopile-occupancy.config.ts index 9af9a23..2856c77 100644 --- a/src/integrations/thermopile/thermopile-occupancy.config.ts +++ b/src/integrations/thermopile/thermopile-occupancy.config.ts @@ -4,4 +4,5 @@ export class HeatmapOptions { minTemperature = 16; maxTemperature = 30; rotation: RotationOption = 0; + drawTemperatures = true; } diff --git a/src/integrations/thermopile/thermopile-occupancy.service.spec.ts b/src/integrations/thermopile/thermopile-occupancy.service.spec.ts index ed4edc5..5f6e5a2 100644 --- a/src/integrations/thermopile/thermopile-occupancy.service.spec.ts +++ b/src/integrations/thermopile/thermopile-occupancy.service.spec.ts @@ -1,8 +1,14 @@ +import { HeatmapOptions } from './thermopile-occupancy.config'; + const mockContext = { rotate: jest.fn(), translate: jest.fn(), fillRect: jest.fn(), + fillText: jest.fn(), + save: jest.fn(), + restore: jest.fn(), fillStyle: '', + fontStyle: '', }; const mockCanvas = { getContext: jest.fn().mockReturnValue(mockContext), @@ -104,14 +110,32 @@ describe('ThermopileOccupancyService', () => { expect(mockCanvasModule.createCanvas).toHaveBeenCalledWith(250, 250); }); - it('should create rectangles for each pixel', async () => { - await service.generateHeatmap(PRESENCE_TEMPERATURES); + it('should create rectangles with fonts for each pixel', async () => { + await service.generateHeatmap(PRESENCE_TEMPERATURES, undefined, 150, 150); expect(mockContext.translate).toHaveBeenCalledWith(75, 75); expect(mockContext.fillRect).toHaveBeenCalledTimes(16); expect(mockContext.fillRect).toHaveBeenCalledWith(-75, -75, 38, 38); expect(mockContext.fillRect).toHaveBeenCalledWith(39, 1, 38, 38); - expect(mockContext.fillStyle).toEqual('hsl(111.42857142857143, 100%, 50%)'); + expect(mockContext.fillText).toHaveBeenCalledTimes(16); + expect(mockContext.fillText).toHaveBeenCalledWith( + PRESENCE_TEMPERATURES[0][0].toFixed(1), + 0, + 0 + ); + }); + + it('should not draw the temperature text if option is disabled', async () => { + const heatmapOptions = new HeatmapOptions(); + heatmapOptions.drawTemperatures = false; + await service.generateHeatmap( + PRESENCE_TEMPERATURES, + heatmapOptions, + 150, + 150 + ); + + expect(mockContext.fillText).not.toHaveBeenCalled(); }); it('should output a jpeg image buffer', async () => { @@ -130,6 +154,7 @@ describe('ThermopileOccupancyService', () => { rotation: 90, minTemperature: 16, maxTemperature: 30, + drawTemperatures: true, }, 100, 200 @@ -138,14 +163,4 @@ describe('ThermopileOccupancyService', () => { expect(mockCanvasModule.createCanvas).toHaveBeenCalledWith(200, 100); expect(mockContext.rotate).toHaveBeenCalledWith(1.5707963267948966); }); - - it('should adapt the pixel coloring based on min and max temperatures', async () => { - await service.generateHeatmap(PRESENCE_TEMPERATURES, { - rotation: 0, - minTemperature: 10, - maxTemperature: 40, - }); - - expect(mockContext.fillStyle).toEqual('hsl(132, 100%, 50%)'); - }); }); diff --git a/src/integrations/thermopile/thermopile-occupancy.service.ts b/src/integrations/thermopile/thermopile-occupancy.service.ts index daa275b..411f4ec 100644 --- a/src/integrations/thermopile/thermopile-occupancy.service.ts +++ b/src/integrations/thermopile/thermopile-occupancy.service.ts @@ -109,8 +109,8 @@ export abstract class ThermopileOccupancyService { async generateHeatmap( temperatures: number[][], options = new HeatmapOptions(), - width = 150, - height = 150 + width = 280, + height = 280 ): Promise { if (!this.isHeatmapAvailable()) { throw new Error( @@ -125,7 +125,8 @@ export abstract class ThermopileOccupancyService { : nodeCanvas.createCanvas(width, height); const ctx = canvas.getContext('2d'); ctx.translate(canvas.width / 2, canvas.height / 2); - ctx.rotate((options.rotation * Math.PI) / 180); + const rotationRad = (options.rotation * Math.PI) / 180; + ctx.rotate(rotationRad); const normed = math.divide( math.subtract(temperatures, options.minTemperature), @@ -134,14 +135,29 @@ export abstract class ThermopileOccupancyService { for (const [y, row] of normed.entries()) { for (const [x, value] of row.entries()) { - const h = (1 - _.clamp(value, 0, 1)) * 240; - ctx.fillStyle = `hsl(${h}, 100%, 50%)`; - ctx.fillRect( - -canvas.width / 2 + x * segmentWidth, - -canvas.height / 2 + y * segmentHeight, + const pixelXOrigin = -canvas.width / 2 + x * segmentWidth; + const pixelXCenter = pixelXOrigin + segmentWidth / 2; + const pixelYOrigin = -canvas.height / 2 + y * segmentHeight; + const pixelYCenter = pixelYOrigin + segmentHeight / 2; + + ThermopileOccupancyService.drawPixel( + ctx, + pixelXOrigin, + pixelYOrigin, segmentWidth, - segmentHeight + segmentHeight, + value ); + if (options.drawTemperatures) { + ThermopileOccupancyService.drawTemperature( + ctx, + pixelXCenter, + pixelYCenter, + segmentWidth, + rotationRad, + temperatures[x][y] + ); + } } } @@ -149,4 +165,56 @@ export abstract class ThermopileOccupancyService { quality: 1, }); } + + /** + * 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 rotationRad - rotation that is used for the context in rad + * @param temperature - temperature value to draw + */ + private static drawTemperature( + ctx: CanvasRenderingContext2D, + xCenter: number, + yCenter: number, + width: number, + rotationRad: 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.rotate(-rotationRad); + ctx.fillText(temperature.toFixed(1), 0, 0); + ctx.restore(); + } }