Skip to content

Commit

Permalink
Merge pull request #7 from radoslavirha/return-rain-image
Browse files Browse the repository at this point in the history
feat: ✨ new /rain/image endpoint
  • Loading branch information
radoslavirha authored Jun 5, 2023
2 parents 71a36ab + ca0e231 commit 5b57bca
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 76 deletions.
9 changes: 9 additions & 0 deletions laskakit-data-feeder/.README.j2
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ Downloads precipitation image from ČHMÚ, process and send data to LaskaKit

#### Query parameters

- `pixelBuffer` (default 0) - a pixel buffer around the city, used to determine if it is raining at a given location. The resulting value is the maximum R,G,B value of the selected pixels
- E.g. `{laskakit_url}?pixelBuffer=2`

### GET /rain/image

Downloads precipitation image from ČHMÚ, creates current image and returns joined image

#### Query parameters

- `pixelBuffer` (default 0) - a pixel buffer around the city, used to determine if it is raining at a given location. The resulting value is the maximum R,G,B value of the selected pixels
- E.g. `{laskakit_url}?pixelBuffer=2`

Expand Down
108 changes: 35 additions & 73 deletions laskakit-data-feeder/lib/handlers/rain.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,31 @@
import Boom from '@hapi/boom';
import { AxiosError } from 'axios';
import Jimp from 'jimp';
import joinImages from 'join-images';

import Cities from '../data/cities.json';
import { BBOX } from '../types/common';
import { IRouteGetRainRefs } from '../types/routes/rain';
import { IRouteGetRainImageRefs, IRouteGetRainRefs } from '../types/routes/rain';
import { HapiHandler } from '../types/server/handler';
import { getCHMIUTCDate } from '../util';
import { getCityRGBA } from '../util/city';
import { cityValidator } from '../validators/schemas';

/**
* GET /rain?pixelBuffer={number}
*/
export const rain: HapiHandler<IRouteGetRainRefs> = async (request, h) => {
const { axios, logger } = request.server.app,
const { axios, logger, rainService } = request.server.app,
query = request.query,
{ IMAGE_TO_CONSOLE } = request.server.app.options;

try {
// correct bbox definition
const bbox: BBOX = {
left: 11.267,
bottom: 48.048,
right: 20.770,
top: 52.167
};
const image = await rainService.getRainImage();
const emptyImage = new Jimp(image.getWidth(), image.getHeight());

const url = `https://www.chmi.cz/files/portal/docs/meteo/rad/inca-cz/data/czrad-z_max3d_masked/pacz2gmaps3.z_max3d.${getCHMIUTCDate()}.0.png`;

logger.debug(`Downloading rain image from ${url}`);

const reponse = await axios<Buffer>({ method: 'GET', url, responseType: 'arraybuffer' });

const image = await Jimp.read(reponse.data);

if (!image.hasAlpha()) {
throw new Error(`Image does not have alpha channel!`);
}

const imageToCompare = new Jimp(image.getWidth(), image.getHeight());

const citiesCount = Cities.length;

if (citiesCount !== 72) {
throw new Error(`Found only ${citiesCount} cities!`);
}

const rainyCities: {id: number; r: number; g: number; b: number }[] = [];

for (let i = 0; i < citiesCount; i++) {
try {
cityValidator.parse(Cities[i]);
} catch (error) {
logger.warn(`${Cities[i].city} failed validation!`);
continue;
}

const { id, city } = Cities[i];

const { pixels } = await getCityRGBA(Cities[i], bbox, image, query.pixelBuffer);

const r = Math.max(...pixels.map(p => p.r)),
g = Math.max(...pixels.map(p => p.g)),
b = Math.max(...pixels.map(p => p.b)),
a = Math.max(...pixels.map(p => p.a ?? 255));

if (r + g + b > 0) {
logger.debug(`${city} is rainy. RGB: ${r}, ${g}, ${b}, ${a}`);

rainyCities.push({ id, r, g, b });

for (const pixel of pixels) {
imageToCompare.bitmap.data[pixel.idx] = r;
imageToCompare.bitmap.data[pixel.idx + 1] = g;
imageToCompare.bitmap.data[pixel.idx + 2] = b;
imageToCompare.bitmap.data[pixel.idx + 3] = a;
}
}
}
const { rainyCities, rainyImage } = await rainService.processCities(image, emptyImage, query.pixelBuffer);

if (IMAGE_TO_CONSOLE) {
try {
const visualResponse = await joinImages([
await imageToCompare.getBufferAsync(Jimp.MIME_PNG),
await image.getBufferAsync(Jimp.MIME_PNG)
], { direction: 'horizontal' });
const visualResponse = await rainService.getJoinedImagesBuffer(rainyImage, image);

const terminal = (await import('terminal-image')).default;
// eslint-disable-next-line no-console
console.log(await terminal.buffer(await visualResponse.png().toBuffer(), { height: '25%', preserveAspectRatio: false }));
console.log(await terminal.buffer(await visualResponse, { height: '25%', preserveAspectRatio: false }));
} catch (error) {
logger.debug(`Failed to render images to console!`);
}
Expand Down Expand Up @@ -125,6 +60,33 @@ export const rain: HapiHandler<IRouteGetRainRefs> = async (request, h) => {
return h.response('OK').code(200);
};

/**
* GET /rain/image?pixelBuffer={number}
*/
export const rainImage: HapiHandler<IRouteGetRainImageRefs> = async (request, h) => {
const { rainService, logger } = request.server.app,
query = request.query;

let visualResponse;

try {
const image = await rainService.getRainImage();
const emptyImage = new Jimp(image.getWidth(), image.getHeight());

const { rainyImage } = await rainService.processCities(image, emptyImage, query.pixelBuffer);

visualResponse = await rainService.getJoinedImagesBuffer(rainyImage, image);
} catch (error) {
logger.warn((error as AxiosError).message);
return Boom.badRequest((error as AxiosError).message);
}

return h.response(visualResponse)
.type(Jimp.MIME_PNG)
.header('Cache-Control', 'public, max-age=31536000');
};

export default {
rain
rain,
rainImage
};
116 changes: 116 additions & 0 deletions laskakit-data-feeder/lib/plugins/rain-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Hapi from '@hapi/hapi';
import Jimp from 'jimp';
import joinImages from 'join-images';

import Cities from '../data/cities.json';
import { BBOX } from '../types/common';
import { type HapiPluginWithApplicationState } from '../types/server/plugin';
import { IBaseServerApplicationState } from '../types/server/server';
import { getCHMIUTCDate } from '../util';
import { getCityRGBA } from '../util/city';
import { cityValidator } from '../validators/schemas';

// correct bbox definition
const bbox: BBOX = {
left: 11.267,
bottom: 48.048,
right: 20.770,
top: 52.167
};

export class RainService {
private _app: IBaseServerApplicationState;

constructor(app: IBaseServerApplicationState) {
this._app = app;
}

private getCities(): { id: number; city: string; latitude: number; longitude: number }[] {
const citiesCount = Cities.length;

if (citiesCount !== 72) {
throw new Error(`Found only ${citiesCount} cities!`);
}

for (let i = 0; i < citiesCount; i++) {
try {
cityValidator.parse(Cities[i]);
} catch (error) {
const err = `${Cities[i].city} failed validation!`;
this.app.logger.warn(err);
throw new Error(err);
}
}

return Cities;
}

async getRainImage(): Promise<Jimp> {
const url = `https://www.chmi.cz/files/portal/docs/meteo/rad/inca-cz/data/czrad-z_max3d_masked/pacz2gmaps3.z_max3d.${getCHMIUTCDate()}.0.png`;

this.app.logger.debug(`Downloading rain image from ${url}`);

const reponse = await this.app.axios<Buffer>({ method: 'GET', url, responseType: 'arraybuffer' });

const image = await Jimp.read(reponse.data);

if (!image.hasAlpha()) {
throw new Error(`Image does not have alpha channel!`);
}

return image;
}

async getJoinedImagesBuffer(left: Jimp, right: Jimp): Promise<Buffer> {
const joined = await joinImages([
await left.getBufferAsync(Jimp.MIME_PNG),
await right.getBufferAsync(Jimp.MIME_PNG)
], { direction: 'horizontal' });

return joined.png().toBuffer();
}

async processCities(image: Jimp, emptyImage: Jimp, pixelBuffer: number): Promise<{ rainyCities: {id: number; r: number; g: number; b: number }[]; rainyImage: Jimp }> {
const rainyCities: {id: number; r: number; g: number; b: number }[] = [];

const cities = this.getCities();

for (let i = 0; i < cities.length; i++) {
const { id, city } = Cities[i];

const { pixels } = await getCityRGBA(Cities[i], bbox, image, pixelBuffer);

const r = Math.max(...pixels.map(p => p.r)),
g = Math.max(...pixels.map(p => p.g)),
b = Math.max(...pixels.map(p => p.b)),
a = Math.max(...pixels.map(p => p.a ?? 255));

if (r + g + b > 0) {
this.app.logger.debug(`${city} is rainy. RGB: ${r}, ${g}, ${b}, ${a}`);

rainyCities.push({ id, r, g, b });

for (const pixel of pixels) {
emptyImage.bitmap.data[pixel.idx] = r;
emptyImage.bitmap.data[pixel.idx + 1] = g;
emptyImage.bitmap.data[pixel.idx + 2] = b;
emptyImage.bitmap.data[pixel.idx + 3] = a;
}
}
}

return { rainyCities, rainyImage: emptyImage };
}

get app(): IBaseServerApplicationState { return this._app; }
}

const plugin: HapiPluginWithApplicationState<null> = {
name: 'rain-service',
register: async (server): Promise<void> => {

server.app.rainService = new RainService(server.app);
}
};

export default plugin as Hapi.Plugin<null>;
31 changes: 28 additions & 3 deletions laskakit-data-feeder/lib/routes/rain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Controller from '../handlers/rain';
import { IRouteGetRainRefs, IRouteGetRainValidation } from '../types/routes/rain';
import { IRouteGetRainImageRefs, IRouteGetRainImageValidation, IRouteGetRainRefs, IRouteGetRainValidation } from '../types/routes/rain';
import { HapiServerRoute } from '../types/server/route';
import { getRainQueryValidator } from '../validators/routes/rain';

Expand All @@ -9,7 +9,7 @@ const rain: HapiServerRoute<IRouteGetRainRefs, IRouteGetRainValidation> = {
handler: Controller.rain,
options: {
auth: false,
description: 'Rain',
description: 'Send rain data to LaskaKit',
notes: [],
plugins: {
'hapi-swagger': {
Expand All @@ -26,6 +26,31 @@ const rain: HapiServerRoute<IRouteGetRainRefs, IRouteGetRainValidation> = {
}
};

const rainImage: HapiServerRoute<IRouteGetRainImageRefs, IRouteGetRainImageValidation> = {
method: 'GET',
path: `/rain/image`,
handler: Controller.rainImage,
options: {
auth: false,
description: 'Returns radar and current conditions image',
notes: [],
plugins: {
'hapi-swagger': {
responses: {
200: { description: 'Radar and current conditions image', content: { 'image/png': { schema: { type: 'string', format: 'binary' } } } },
400: { description: 'Bad Request' }
},
produces: [ 'application/json', 'image/png' ]
}
},
tags: [ 'api' ],
validate: {
query: getRainQueryValidator
}
}
};

export default [
rain
rain,
rainImage
];
11 changes: 11 additions & 0 deletions laskakit-data-feeder/lib/types/routes/rain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,15 @@ export interface IRouteGetRainRefs {

export interface IRouteGetRainValidation {
query: typeof getRainQueryValidator;
}

/**
* GET /rain/image?pixelBuffer={number}
*/
export interface IRouteGetRainImageRefs {
Query: RainQuery;
}

export interface IRouteGetRainImageValidation {
query: typeof getRainQueryValidator;
}
3 changes: 3 additions & 0 deletions laskakit-data-feeder/lib/types/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Hapi from '@hapi/hapi';
import { AxiosResponse, RawAxiosRequestConfig } from 'axios';
import type Winston from 'winston';

import { RainService } from '../../plugins/rain-service';

export interface IBaseServerApplicationState {
axios: <T = unknown>(request: RawAxiosRequestConfig<T>, retries?: number) => Promise<AxiosResponse<T>>;
logger: Winston.Logger;
Expand All @@ -11,6 +13,7 @@ export interface IBaseServerApplicationState {
LOG_LEVEL: string;
};
version: string;
rainService: RainService;
}

export class BaseServerWithState extends Hapi.Server<IBaseServerApplicationState> {}

0 comments on commit 5b57bca

Please sign in to comment.