Skip to content

Commit 4690006

Browse files
feat: add protobuf update instructions to README, enhance classifyImage tests, and improve image processing in hashing
1 parent ecde6a5 commit 4690006

5 files changed

Lines changed: 121 additions & 9 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ Athena is a gRPC-based image classification service designed for CSAM (Child Sex
1414
- **Error Handling**: Comprehensive error codes and detailed error messages
1515
- **Monitoring**: Active deployment tracking and backlog monitoring
1616

17-
## Contributing
17+
# Contributing
18+
19+
## Updating the Protobuf definitions
20+
21+
Protobufs are stored in the [@crispthinking/athena-protobuffs](https://github.com/crispthinking/athena-protobufs) repository.
22+
23+
To update the protobuf definitions for client generation, run:
24+
`git subtree pull --prefix=athena-protobufs https://github.com/crispthinking/athena-protobufs.git <sha> --squash`
1825

1926
## Regenerating the TypeScript gRPC Client
2027

__tests__/unit/main.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,83 @@ describe('classifyImage function', () => {
123123
expect(outputs).toMatchObject(expectedOutputs);
124124
}, 120000);
125125

126+
it('should classify Steamboat-willie.jpg with raw uint8 resize return responses (integration smoke test)', async ({expect, annotate}) => {
127+
// This is a smoke test. You must have a running gRPC server at localhost:50051 for this to pass.
128+
// You may want to mock the gRPC client for true unit testing.
129+
const imagePath = __dirname + '/Steamboat-willie.jpg';
130+
const sdk = new ClassifierSdk({
131+
deploymentId: process.env.VITE_ATHENA_DEPLOYMENT_ID,
132+
affiliate: process.env.VITE_ATHENA_AFFILIATE,
133+
authentication: {
134+
issuerUrl: process.env.VITE_OAUTH_ISSUER,
135+
clientId: process.env.VITE_ATHENA_CLIENT_ID,
136+
clientSecret: process.env.VITE_ATHENA_CLIENT_SECRET,
137+
scope: 'manage:classify'
138+
}
139+
});
140+
141+
const correlationId = randomUUID();
142+
143+
annotate(`Correlation IDs: ${correlationId}`);
144+
145+
// Create a promise to wrap the event emitter event 'data'
146+
const promise = new Promise<ClassificationOutput[]>((resolve, reject) => {
147+
// Add a timeout to reject the promise if no data is received in 30 seconds
148+
const timeout = setTimeout(() => {
149+
reject(new Error('Timeout waiting for classification response'));
150+
}, 30000);
151+
152+
sdk.on('data', (data) => {
153+
const byCorrelationId = data.outputs.filter(o => o.correlationId === correlationId);
154+
if (byCorrelationId.length > 0) {
155+
clearTimeout(timeout);
156+
resolve(byCorrelationId);
157+
}
158+
});
159+
sdk.once('error', (err) => {
160+
clearTimeout(timeout);
161+
reject(err);
162+
});
163+
});
164+
165+
// This will fail if no server is running, but will exercise the code path.
166+
let error: any = undefined;
167+
168+
await sdk.open();
169+
170+
const imageStream = fs.createReadStream(imagePath);
171+
const options: ClassifyImageInput = {
172+
imageStream,
173+
correlationId,
174+
resize: true,
175+
};
176+
try {
177+
await sdk.sendClassifyRequest(options);
178+
} catch (err) {
179+
error = err;
180+
}
181+
182+
// Wait for classifier to process some data....
183+
const first = await promise;
184+
sdk.close();
185+
186+
expect(first).toBeDefined();
187+
expect(first).toMatchObject([
188+
{
189+
correlationId,
190+
classifications: expect.arrayContaining([
191+
{
192+
label: expect.any(String),
193+
weight: expect.any(Number)
194+
}
195+
])
196+
} as ClassificationOutput
197+
]);
198+
199+
// Accept either a successful call or a connection error (for CI/dev convenience)
200+
expect(error).toBeUndefined();
201+
}, 120000);
202+
126203
it('should classify Steamboat-willie.jpg and return responses (integration smoke test)', async ({expect, annotate}) => {
127204
// This is a smoke test. You must have a running gRPC server at localhost:50051 for this to pass.
128205
// You may want to mock the gRPC client for true unit testing.
@@ -170,7 +247,8 @@ describe('classifyImage function', () => {
170247
const imageStream = fs.createReadStream(imagePath);
171248
const options: ClassifyImageInput = {
172249
imageStream,
173-
correlationId
250+
correlationId,
251+
format: ImageFormat.JPEG
174252
};
175253
try {
176254
await sdk.sendClassifyRequest(options);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"test:watch": "vitest unit"
4848
},
4949
"author": "James Abbott <abbottdev@users.noreply.github.com>",
50-
"license": "Apache-2.0",
50+
"license": "MIT",
5151
"dependencies": {
5252
"@bufbuild/protobuf": "^2.7.0",
5353
"@grpc/grpc-js": "^1.13.4",

src/hashing.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from 'crypto';
33
import sharp from 'sharp';
44
import { ImageFormat, RequestEncoding } from '.';
55
import brotli from 'brotli';
6+
import { buffer } from 'stream/consumers';
67

78
/**
89
* Computes MD5 and SHA1 hashes from a readable stream and resizes any image data.
@@ -13,17 +14,25 @@ import brotli from 'brotli';
1314
export async function computeHashesFromStream(
1415
stream: Readable,
1516
encoding: RequestEncoding = RequestEncoding.UNCOMPRESSED,
17+
imageFormat: ImageFormat = ImageFormat.UNSPECIFIED,
18+
resize: boolean = false,
1619
): Promise<{ md5: string; sha1: string; data: Buffer; format: ImageFormat }> {
1720
const md5 = crypto.createHash('md5');
1821
const sha1 = crypto.createHash('sha1');
1922

20-
const resizer = sharp().resize(448, 448).png();
23+
let data: Buffer<ArrayBufferLike>;
2124

2225
stream.pipe(md5);
2326
stream.pipe(sha1);
24-
stream.pipe(resizer);
2527

26-
let data = await resizer.toBuffer();
28+
if (resize) {
29+
const resizer = sharp().resize(448, 448).raw({ depth: 'uint' });
30+
stream.pipe(resizer);
31+
data = await resizer.toBuffer();
32+
imageFormat = ImageFormat.RAW_UINT8;
33+
} else {
34+
data = await buffer(stream);
35+
}
2736

2837
if (encoding === RequestEncoding.BROTLI) {
2938
data = Buffer.from(await brotli.compress(data));
@@ -33,6 +42,6 @@ export async function computeHashesFromStream(
3342
md5: md5.digest('hex'),
3443
sha1: sha1.digest('hex'),
3544
data,
36-
format: ImageFormat.PNG,
45+
format: imageFormat,
3746
};
3847
}

src/index.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ImageHash,
99
ClassifyResponse,
1010
Deployment,
11+
ImageFormat,
1112
} from './athena/athena';
1213
import * as grpc from '@grpc/grpc-js';
1314
import {
@@ -31,12 +32,20 @@ import { computeHashesFromStream } from './hashing';
3132
* @property encoding Optional encoding type for the image data.
3233
* @property format Optional image format.
3334
*/
34-
export interface ClassifyImageInput {
35+
export type ClassifyImageInput = {
3536
affiliate?: string;
3637
correlationId?: string;
3738
imageStream: Readable;
3839
encoding?: ClassifyRequest['inputs'][number]['encoding'];
39-
}
40+
} & (ResizeImageInput | RawImageInput);
41+
42+
export type ResizeImageInput = {
43+
resize: true;
44+
};
45+
46+
export type RawImageInput = {
47+
format: ClassifyRequest['inputs'][number]['format'];
48+
};
4049

4150
/**
4251
* Options for initializing the ClassifierSdk.
@@ -217,6 +226,7 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
217226
: [request];
218227

219228
const processedInputs: ClassificationInput[] = [];
229+
220230
for (const options of requests) {
221231
const {
222232
affiliate = this.options.affiliate,
@@ -225,9 +235,17 @@ export class ClassifierSdk extends (EventEmitter as new () => TypedEmitter<Class
225235
encoding = RequestEncoding.UNCOMPRESSED,
226236
} = options;
227237

238+
let inputFormat: ImageFormat = ImageFormat.UNSPECIFIED;
239+
240+
if ('resize' in options === false) {
241+
inputFormat = options.format;
242+
}
243+
228244
const { md5, sha1, data, format } = await computeHashesFromStream(
229245
imageStream,
230246
encoding,
247+
inputFormat,
248+
'resize' in options,
231249
);
232250
const hashes: ImageHash[] = [
233251
{ value: md5, type: HashType.MD5 },

0 commit comments

Comments
 (0)