Skip to content

Commit 25c993d

Browse files
feat: add support for multiple images and add smoke test
1 parent c46122a commit 25c993d

3 files changed

Lines changed: 192 additions & 54 deletions

File tree

__tests__/unit/main.test.ts

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it,} from 'vitest';
2-
import { ClassifierSdk, ClassifyImageOptions, ClassifyResponse, ImageFormat } from '../../src/main';
2+
import { ClassificationOutput, ClassifierSdk, ClassifyImageInput, ImageFormat } from '../../src';
33
import fs from 'fs';
44
import { randomUUID } from 'crypto';
55

@@ -31,7 +31,119 @@ describe('classifierHelper', () => {
3131
});
3232

3333
describe('classifyImage function', () => {
34-
it('should classify Steamboat-willie.jpg and return responses (integration smoke test)', async ({expect}) => {
34+
it('should classify 10 images in a single request and return responses (integration smoke test)', async ({expect, annotate}) => {
35+
// This is a smoke test. You must have a running gRPC server at localhost:50051 for this to pass.
36+
// You may want to mock the gRPC client for true unit testing.
37+
const imagePath = __dirname + '/Steamboat-willie.jpg';
38+
const sdk = new ClassifierSdk({
39+
deploymentId: process.env.VITE_ATHENA_DEPLOYMENT_ID,
40+
affiliate: process.env.VITE_ATHENA_AFFILIATE,
41+
authentication: {
42+
issuerUrl: process.env.VITE_OAUTH_ISSUER,
43+
clientId: process.env.VITE_ATHENA_CLIENT_ID,
44+
clientSecret: process.env.VITE_ATHENA_CLIENT_SECRET,
45+
scope: 'manage:classify'
46+
}
47+
});
48+
49+
// Generate 10 unique correlationIds
50+
const correlationIds =
51+
[
52+
randomUUID().toString(),
53+
randomUUID().toString(),
54+
randomUUID().toString()
55+
]
56+
57+
correlationIds.sort((a, b) => a.localeCompare(b));
58+
59+
annotate(`Correlation IDs: ${correlationIds.join(', ')}`);
60+
61+
// Create 10 input objects, each with a new stream and unique correlationId
62+
const inputs: ClassifyImageInput[] = correlationIds.map((correlationId) => ({
63+
imageStream: fs.createReadStream(imagePath),
64+
format: ImageFormat.PNG,
65+
correlationId
66+
}));
67+
68+
// Create a promise to wrap the event emitter event 'data'
69+
const promise = new Promise<ClassificationOutput[]>((resolve, reject) => {
70+
const results:ClassificationOutput[] = [];
71+
72+
sdk.on('data', (data) => {
73+
if (data.globalError)
74+
{
75+
reject(data.globalError);
76+
}
77+
78+
// Check that all correlationIds are present in the outputs
79+
for(const result of data.outputs)
80+
{
81+
if (correlationIds.includes(result.correlationId)) {
82+
results.push(result);
83+
}
84+
}
85+
if (results.length == correlationIds.length) {
86+
resolve(results);
87+
}
88+
});
89+
sdk.once('error', (err) => {
90+
reject(err);
91+
});
92+
});
93+
94+
let error: any = undefined;
95+
96+
await sdk.open();
97+
98+
try {
99+
await sdk.sendClassifyRequest(inputs);
100+
} catch (err) {
101+
error = err;
102+
}
103+
104+
// Wait for classifier to process some data....
105+
const outputs = await promise;
106+
sdk.close();
107+
108+
expect(error).toBeUndefined();
109+
110+
outputs.sort((a, b) => a.correlationId.localeCompare(b.correlationId));
111+
112+
expect(outputs).toBeDefined();
113+
// Check that all correlationIds are present in the outputs
114+
expect(outputs.length).toBe(correlationIds.length);
115+
expect(outputs).toMatchObject([
116+
{
117+
correlationId: correlationIds[0],
118+
classifications: expect.arrayContaining([
119+
{
120+
label: expect.any(String),
121+
weight: expect.any(Number)
122+
}
123+
])
124+
} as ClassificationOutput,
125+
{
126+
correlationId: correlationIds[1],
127+
classifications: expect.arrayContaining([
128+
{
129+
label: expect.any(String),
130+
weight: expect.any(Number)
131+
}
132+
])
133+
} as ClassificationOutput,
134+
{
135+
correlationId: correlationIds[2],
136+
classifications: expect.arrayContaining([
137+
{
138+
label: expect.any(String),
139+
weight: expect.any(Number)
140+
}
141+
])
142+
} as ClassificationOutput
143+
]);
144+
}, 120000);
145+
146+
it('should classify Steamboat-willie.jpg and return responses (integration smoke test)', async ({expect, annotate}) => {
35147
// This is a smoke test. You must have a running gRPC server at localhost:50051 for this to pass.
36148
// You may want to mock the gRPC client for true unit testing.
37149
const imagePath = __dirname + '/Steamboat-willie.jpg';
@@ -48,16 +160,24 @@ describe('classifyImage function', () => {
48160

49161
const correlationId = randomUUID();
50162

163+
annotate(`Correlation IDs: ${correlationId}`);
164+
51165
// Create a promise to wrap the event emitter event 'data'
52-
const promise = new Promise<ClassifyResponse>((resolve, reject) => {
53-
sdk.once('data', (data) => {
166+
const promise = new Promise<ClassificationOutput[]>((resolve, reject) => {
167+
// Add a timeout to reject the promise if no data is received in 30 seconds
168+
const timeout = setTimeout(() => {
169+
reject(new Error('Timeout waiting for classification response'));
170+
}, 30000);
171+
172+
sdk.on('data', (data) => {
54173
const byCorrelationId = data.outputs.filter(o => o.correlationId === correlationId);
55174
if (byCorrelationId.length > 0) {
56-
resolve(data);
175+
clearTimeout(timeout);
176+
resolve(byCorrelationId);
57177
}
58-
sdk.close();
59178
});
60179
sdk.once('error', (err) => {
180+
clearTimeout(timeout);
61181
reject(err);
62182
});
63183
});
@@ -68,9 +188,8 @@ describe('classifyImage function', () => {
68188
await sdk.open();
69189

70190
const imageStream = fs.createReadStream(imagePath);
71-
const options: ClassifyImageOptions = {
191+
const options: ClassifyImageInput = {
72192
imageStream,
73-
format: ImageFormat.PNG,
74193
correlationId
75194
};
76195
try {
@@ -81,12 +200,20 @@ describe('classifyImage function', () => {
81200

82201
// Wait for classifier to process some data....
83202
const first = await promise;
203+
sdk.close();
84204

85205
expect(first).toBeDefined();
86-
87-
const byCorrelationId = first.outputs.filter(o => o.correlationId === correlationId);
88-
89-
expect(byCorrelationId.length).toBeGreaterThan(0);
206+
expect(first).toMatchObject([
207+
{
208+
correlationId,
209+
classifications: expect.arrayContaining([
210+
{
211+
label: expect.any(String),
212+
weight: expect.any(Number)
213+
}
214+
])
215+
} as ClassificationOutput
216+
]);
90217

91218
// Accept either a successful call or a connection error (for CI/dev convenience)
92219
expect(error).toBeUndefined();

src/hashing.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Readable } from 'stream';
22
import crypto from 'crypto';
33
import sharp from 'sharp';
4-
import { RequestEncoding } from './main';
4+
import { ImageFormat, RequestEncoding } from '.';
55
import brotli from 'brotli';
66

77
/**
@@ -13,10 +13,11 @@ import brotli from 'brotli';
1313
export async function computeHashesFromStream(
1414
stream: Readable,
1515
encoding: RequestEncoding = RequestEncoding.UNCOMPRESSED,
16-
): Promise<{ md5: string; sha1: string; data: Buffer }> {
16+
): Promise<{ md5: string; sha1: string; data: Buffer; format: ImageFormat }> {
1717
const md5 = crypto.createHash('md5');
1818
const sha1 = crypto.createHash('sha1');
19-
const resizer = sharp().resize(448, 448).raw({ depth: 'uint' });
19+
20+
const resizer = sharp().resize(448, 448).png();
2021

2122
stream.pipe(md5);
2223
stream.pipe(sha1);
@@ -32,5 +33,6 @@ export async function computeHashesFromStream(
3233
md5: md5.digest('hex'),
3334
sha1: sha1.digest('hex'),
3435
data,
36+
format: ImageFormat.PNG,
3537
};
3638
}

src/main.ts renamed to src/index.ts

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import {
55
ClassificationInput,
66
HashType,
77
RequestEncoding,
8-
ImageFormat,
98
ImageHash,
109
ClassifyResponse,
1110
Deployment,
12-
} from './athena/athena.js';
11+
} from './athena/athena';
1312
import * as grpc from '@grpc/grpc-js';
1413
import {
1514
IClassifierServiceClient,
@@ -32,23 +31,22 @@ import { computeHashesFromStream } from './hashing';
3231
* @property encoding Optional encoding type for the image data.
3332
* @property format Optional image format.
3433
*/
35-
export interface ClassifyImageOptions {
34+
export interface ClassifyImageInput {
3635
affiliate?: string;
3736
correlationId?: string;
3837
imageStream: Readable;
3938
encoding?: ClassifyRequest['inputs'][number]['encoding'];
40-
format?: ClassifyRequest['inputs'][number]['format'];
4139
}
4240

4341
/**
44-
* Options for initializing the ClassifierSdk helper.
42+
* Options for initializing the ClassifierSdk.
4543
* @property keepAliveInterval Optional interval (ms) for keep-alive pings.
4644
* @property grpcAddress Optional gRPC server address.
4745
* @property deploymentId Deployment ID to use for classification.
4846
* @property affiliate Affiliate identifier for requests.
4947
* @property authentication Authentication options for the SDK.
5048
*/
51-
export interface ClassifierHelperOptions {
49+
export interface ClassifierSdkOptions {
5250
keepAliveInterval?: number;
5351
grpcAddress?: string;
5452
deploymentId: string;
@@ -70,7 +68,6 @@ export type ClassifierEvents = {
7068
open: () => void;
7169
};
7270

73-
// export the default endpoint of csam-classification-messages.crispdev.com so library consumers may use it.
7471
export const defaultGrpcAddress =
7572
'csam-classification-messages.crispdev.com:443';
7673

@@ -85,7 +82,7 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
8582
ClassifyRequest,
8683
ClassifyResponse
8784
> | null = null;
88-
private options: ClassifierHelperOptions;
85+
private options: ClassifierSdkOptions;
8986
private auth: AuthenticationManager;
9087
private keepAlive: NodeJS.Timeout;
9188
private metadata?: grpc.Metadata;
@@ -100,7 +97,7 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
10097
deploymentId,
10198
affiliate,
10299
authentication,
103-
}: ClassifierHelperOptions) {
100+
}: ClassifierSdkOptions) {
104101
super();
105102
this.grpcAddress = grpcAddress;
106103
this.client = new ClassifierServiceClient(
@@ -202,46 +199,58 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
202199
* @throws Error if the gRPC stream is not open.
203200
* @returns Promise that resolves when the request is sent.
204201
*/
202+
/**
203+
* Sends one or more classify requests for images to the Athena service.
204+
* @param request Single input or array of image classification request options.
205+
* @throws Error if the gRPC stream is not open.
206+
* @returns Promise that resolves when the request is sent.
207+
*/
205208
public async sendClassifyRequest(
206-
options: ClassifyImageOptions,
209+
request: ClassifyImageInput | ClassifyImageInput[],
207210
): Promise<void> {
208211
if (!this.classifierGrpcCall) {
209212
throw new Error('gRPC stream is not open. Call open() first.');
210213
}
211-
const {
212-
affiliate = this.options.affiliate,
213-
correlationId = randomUUID(),
214-
imageStream,
215-
encoding = RequestEncoding.UNCOMPRESSED,
216-
format = ImageFormat.UNSPECIFIED,
217-
} = options;
218214

219-
const { md5, sha1, data } = await computeHashesFromStream(
220-
imageStream,
221-
encoding,
222-
);
215+
const requests: ClassifyImageInput[] = Array.isArray(request)
216+
? request
217+
: [request];
223218

224-
const hashes: ImageHash[] = [
225-
{ value: md5, type: HashType.MD5 },
226-
{ value: sha1, type: HashType.SHA1 },
227-
];
219+
const processedInputs: ClassificationInput[] = [];
220+
for (const options of requests) {
221+
const {
222+
affiliate = this.options.affiliate,
223+
correlationId = randomUUID(),
224+
imageStream,
225+
encoding = RequestEncoding.UNCOMPRESSED,
226+
} = options;
228227

229-
const input: ClassificationInput = {
230-
affiliate,
231-
correlationId,
232-
encoding,
233-
data,
234-
format,
235-
hashes,
236-
};
228+
const { md5, sha1, data, format } = await computeHashesFromStream(
229+
imageStream,
230+
encoding,
231+
);
232+
const hashes: ImageHash[] = [
233+
{ value: md5, type: HashType.MD5 },
234+
{ value: sha1, type: HashType.SHA1 },
235+
];
236+
237+
processedInputs.push({
238+
affiliate,
239+
correlationId,
240+
encoding,
241+
data,
242+
format,
243+
hashes,
244+
});
245+
}
237246

238-
const request: ClassifyRequest = {
247+
const classifyRequest: ClassifyRequest = {
239248
deploymentId: this.options.deploymentId,
240-
inputs: [input],
249+
inputs: processedInputs,
241250
};
242251

243252
await new Promise<void>((resolve) => {
244-
if (this.classifierGrpcCall.write(request) == false) {
253+
if (this.classifierGrpcCall.write(classifyRequest) == false) {
245254
this.classifierGrpcCall.once('drain', resolve);
246255
} else {
247256
resolve();
@@ -265,6 +274,6 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
265274
}
266275
}
267276

268-
export * from './athena/athena';
269-
export * from './athena/athena.grpc-client';
270-
export * from './hashing';
277+
export * from './athena/athena.js';
278+
export * from './athena/athena.grpc-client.js';
279+
export * from './hashing.js';

0 commit comments

Comments
 (0)