From 84c2bb71cd35c3b8b72ef2bd53ed53f1b4f0b140 Mon Sep 17 00:00:00 2001 From: Kevin Nielsen Date: Tue, 28 Nov 2023 20:36:14 +0100 Subject: [PATCH] Add new features to production (#19) * chore: add prettier and format * chore: bump version * [Feature] Remove necessity for no / at end of MODEL_DIR_PATH (#18) * feat: add playground for trying out the package * chore: add playground to .npmignore * chore: remove playground * feat: set up filter to remove '/' at end if it exists * test: add test for model_dir_path with/without slash at end * chore: changeset * docs: update readme * chore: format --- .changeset/config.json | 21 ++--- .changeset/heavy-readers-fold.md | 5 + .prettierrc | 2 +- README.md | 26 +++--- src/index.ts | 147 +++++++++++++++--------------- test/classifyImage.test.ts | 135 ++++++++++++++------------- test/classifyImageClassic.test.ts | 130 +++++++++++++------------- tsconfig.json | 20 ++-- tsup.config.ts | 14 +-- 9 files changed, 260 insertions(+), 240 deletions(-) create mode 100644 .changeset/heavy-readers-fold.md diff --git a/.changeset/config.json b/.changeset/config.json index d17d8d2..a1da5bc 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,14 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": [ - "@changesets/changelog-github", - { "repo": "kevinanielsen/tfjs-image-node" } - ], - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": [] + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "kevinanielsen/tfjs-image-node" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] } diff --git a/.changeset/heavy-readers-fold.md b/.changeset/heavy-readers-fold.md new file mode 100644 index 0000000..814b887 --- /dev/null +++ b/.changeset/heavy-readers-fold.md @@ -0,0 +1,5 @@ +--- +"tfjs-image-node": patch +--- + +Add filter to remove necessity for no "/" at end of MODEL_DIR_PATH diff --git a/.prettierrc b/.prettierrc index 28c1e82..3d087ca 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,4 +5,4 @@ "printWidth": 100, "tabWidth": 2, "useTabs": true -} \ No newline at end of file +} diff --git a/README.md b/README.md index 904cfa4..3599cec 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,9 @@ pnpm add tfjs-image-node tfjs-image-node has two different exports. One for the tfjs-node platform and one for the regular tfjs package - one package is preferred for operations in the node runtime, the other is preferred for regular javascript. ```typescript -// Using Node Platform -const classifyImage = require("tfjs-image-node/node"); +const classifyImage = require("tfjs-image-node"); // or -import classifyImage from "tfjs-image-node/node"; - -// Using JS Platform -const classifyImage = require("tfjs-image-node/js"); -// or -import classifyImage from "tfjs-image-node/js"; +import classifyImage from "tfjs-image-node"; ``` ## Example @@ -36,12 +30,18 @@ import classifyImage from "tfjs-image-node/js"; import classifyImage from "tfjs-image-node/node"; const model = "https://teachablemachine.withgoogle.com/models/jAIOHvmge"; -const image = - "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; +const image = "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; + +// With tfjs-node as the platform +(async () => { + const prediction = await classifyImage(model, image); + console.log(prediction[0]); +})(); +// With classic tfjs as the platform (async () => { - const prediction = await classifyImage(model, image); - console.log(prediction[0]); + const prediction = await classifyImage(model, image, "classic"); + console.log(prediction[0]); })(); // expected output: @@ -67,7 +67,7 @@ const image = string - The URL to your AI model (currently only supports teachable machine URLs like "https://teachablemachine.withgoogle.com/models/{model_id}" with no "/" at the end! + The URL to your AI model (currently only supports teachable machine URLs like "https://teachablemachine.withgoogle.com/models/{model_id}". diff --git a/src/index.ts b/src/index.ts index c1b3f31..8c3e082 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,90 +4,89 @@ const tfJs = require("@tensorflow/tfjs"); let tf: any; interface IMetadata extends JSON { - labels: string[]; + labels: string[]; } type ResultType = { - label: string; - probability: string; + label: string; + probability: string; }; type ClassifyImageType = ( - MODEL_DIR_PATH: string, - IMAGE_FILE_PATH: string, - PLATFORM?: "node" | "classic", + MODEL_DIR_PATH: string, + IMAGE_FILE_PATH: string, + PLATFORM?: "node" | "classic" ) => Promise; +const filterInputPath = (inputPath: string) => { + if (inputPath.endsWith("/")) { + return inputPath.slice(0, -1); + } + return inputPath; +}; + const classifyImage: ClassifyImageType = async ( - MODEL_DIR_PATH, - IMAGE_FILE_PATH, - PLATFORM = "node", + MODEL_DIR_PATH, + IMAGE_FILE_PATH, + PLATFORM = "node" ) => { - PLATFORM === "node" ? (tf = tfNode) : (tf = tfJs); - - if (!MODEL_DIR_PATH || !IMAGE_FILE_PATH) { - return new Error("MISSING_PARAMETER"); - } - - const res = await fetch(`${MODEL_DIR_PATH}/metadata.json`); - if (res.status !== 200) { - return new Error("METADATA_NOT_FOUND"); - } - - const METADATA: IMetadata = await res.json(); - - if (METADATA["labels"].length === 0 || METADATA["labels"]! instanceof Array) { - return new Error("NO_METADATA_LABELS"); - } - - let labels: string[] = METADATA["labels"]; - - const model = await tf.loadLayersModel(`${MODEL_DIR_PATH}/model.json`); - - const image = await Jimp.read(IMAGE_FILE_PATH); - image.cover( - 224, - 224, - Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE, - ); - - const NUM_OF_CHANNELS = 3; - let values = new Float32Array(224 * 224 * NUM_OF_CHANNELS); - - let i = 0; - image.scan( - 0, - 0, - image.bitmap.width, - image.bitmap.height, - (x: number, y: number) => { - const pixel = Jimp.intToRGBA(image.getPixelColor(x, y)); - pixel.r = pixel.r / 127.0 - 1; - pixel.g = pixel.g / 127.0 - 1; - pixel.b = pixel.b / 127.0 - 1; - pixel.a = pixel.a / 127.0 - 1; - values[i * NUM_OF_CHANNELS + 0] = pixel.r; - values[i * NUM_OF_CHANNELS + 1] = pixel.g; - values[i * NUM_OF_CHANNELS + 2] = pixel.b; - i++; - }, - ); - - const outShape = [224, 224, NUM_OF_CHANNELS]; - let img_tensor = tf.tensor3d(values, outShape, "float32"); - img_tensor = img_tensor.expandDims(0); - - const predictions = await model.predict(img_tensor).dataSync(); - - let result: ResultType[] = []; - - for (let i = 0; i < predictions.length; i++) { - const label = labels[i]; - const probability = predictions[i]; - result.push({ label: label, probability: probability }); - } - - return result.sort((a, b) => Number(b.probability) - Number(a.probability)); + PLATFORM === "node" ? (tf = tfNode) : (tf = tfJs); + + if (!MODEL_DIR_PATH || !IMAGE_FILE_PATH) { + return new Error("MISSING_PARAMETER"); + } + + MODEL_DIR_PATH = filterInputPath(MODEL_DIR_PATH); + + const res = await fetch(`${MODEL_DIR_PATH}/metadata.json`); + if (res.status !== 200) { + return new Error("METADATA_NOT_FOUND"); + } + + const METADATA: IMetadata = await res.json(); + + if (METADATA["labels"].length === 0 || METADATA["labels"]! instanceof Array) { + return new Error("NO_METADATA_LABELS"); + } + + let labels: string[] = METADATA["labels"]; + + const model = await tf.loadLayersModel(`${MODEL_DIR_PATH}/model.json`); + + const image = await Jimp.read(IMAGE_FILE_PATH); + image.cover(224, 224, Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE); + + const NUM_OF_CHANNELS = 3; + let values = new Float32Array(224 * 224 * NUM_OF_CHANNELS); + + let i = 0; + image.scan(0, 0, image.bitmap.width, image.bitmap.height, (x: number, y: number) => { + const pixel = Jimp.intToRGBA(image.getPixelColor(x, y)); + pixel.r = pixel.r / 127.0 - 1; + pixel.g = pixel.g / 127.0 - 1; + pixel.b = pixel.b / 127.0 - 1; + pixel.a = pixel.a / 127.0 - 1; + values[i * NUM_OF_CHANNELS + 0] = pixel.r; + values[i * NUM_OF_CHANNELS + 1] = pixel.g; + values[i * NUM_OF_CHANNELS + 2] = pixel.b; + i++; + }); + + const outShape = [224, 224, NUM_OF_CHANNELS]; + let img_tensor = tf.tensor3d(values, outShape, "float32"); + img_tensor = img_tensor.expandDims(0); + + const predictions = await model.predict(img_tensor).dataSync(); + + let result: ResultType[] = []; + + for (let i = 0; i < predictions.length; i++) { + const label = labels[i]; + const probability = predictions[i]; + result.push({ label: label, probability: probability }); + } + + return result.sort((a, b) => Number(b.probability) - Number(a.probability)); }; export default classifyImage; diff --git a/test/classifyImage.test.ts b/test/classifyImage.test.ts index 17884e1..a7fa4d9 100644 --- a/test/classifyImage.test.ts +++ b/test/classifyImage.test.ts @@ -2,75 +2,86 @@ import { describe, it, expect } from "vitest"; import classifyImage from "../src"; const model = "https://teachablemachine.withgoogle.com/models/jAIOHvmge"; -const imageHand = - "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; +const imageHand = "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; const imageNoHand = - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Black_colour.jpg/640px-Black_colour.jpg"; + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Black_colour.jpg/640px-Black_colour.jpg"; const imageHandJPEG = "./test/images/hand.jpeg"; const imageHandPNG = "./test/images/hand.png"; describe("classifyImage function - Node", async () => { - describe("Result returns", async () => { - it("returns hand when shown a picture of a hand", async () => { - const result = await classifyImage(model, imageHand); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); + describe("Result returns", async () => { + it("returns hand when shown a picture of a hand", async () => { + const result = await classifyImage(model, imageHand); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); - it("returns 'No hand' when shown a picture not including hand", async () => { - const result = await classifyImage(model, imageNoHand); + it("returns 'No hand' when shown a picture not including hand", async () => { + const result = await classifyImage(model, imageNoHand); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("No hand"); - } - }); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("No hand"); + } + }); - it("returns a probability level", async () => { - const result = await classifyImage(model, imageNoHand); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].probability).not.toBe(null); - } - }); - }); - describe("Error boundries", async () => { - it("returns an error when missing a parameter", async () => { - //@ts-expect-error - const result = await classifyImage(imageNoHand); + it("returns a probability level", async () => { + const result = await classifyImage(model, imageNoHand); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].probability).not.toBe(null); + } + }); - expect(result).toBeInstanceOf(Error); - }); - }); - describe("Image types", async () => { - it("returns a result on url image-input", async () => { - const result = await classifyImage(model, imageHand); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - it("returns a result on JPEG image-input", async () => { - const result = await classifyImage(model, imageHandJPEG); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - it("returns a result on PNG image-input", async () => { - const result = await classifyImage(model, imageHandPNG); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - }); + it("returns when MODEL_DIR_PATH ends with slash", async () => { + const result = await classifyImage(model + "/", imageHand); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + }); + describe("Error boundries", async () => { + it("returns an error when missing a parameter", async () => { + //@ts-expect-error + const result = await classifyImage(imageNoHand); + + expect(result).toBeInstanceOf(Error); + }); + }); + + describe("Image types", async () => { + it("returns a result on url image-input", async () => { + const result = await classifyImage(model, imageHand); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + + it("returns a result on JPEG image-input", async () => { + const result = await classifyImage(model, imageHandJPEG); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + + it("returns a result on PNG image-input", async () => { + const result = await classifyImage(model, imageHandPNG); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + }); }); diff --git a/test/classifyImageClassic.test.ts b/test/classifyImageClassic.test.ts index 321d8b1..8a37ddb 100644 --- a/test/classifyImageClassic.test.ts +++ b/test/classifyImageClassic.test.ts @@ -2,74 +2,82 @@ import { describe, it, expect } from "vitest"; import classifyImage from "../src"; const model = "https://teachablemachine.withgoogle.com/models/jAIOHvmge"; -const imageHand = - "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; +const imageHand = "https://www.stgeorges.nhs.uk/wp-content/uploads/2014/03/hand-2.jpeg"; const imageNoHand = - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Black_colour.jpg/640px-Black_colour.jpg"; + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Black_colour.jpg/640px-Black_colour.jpg"; const imageHandJPEG = "./test/images/hand.jpeg"; const imageHandPNG = "./test/images/hand.png"; describe("classifyImage function - Classic", async () => { - describe("Result returns", async () => { - it("returns hand when shown a picture of a hand", async () => { - const result = await classifyImage(model, imageHand, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); + describe("Result returns", async () => { + it("returns hand when shown a picture of a hand", async () => { + const result = await classifyImage(model, imageHand, "classic"); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); - it("returns 'No hand' when shown a picture not including hand", async () => { - const result = await classifyImage(model, imageNoHand, "classic"); + it("returns 'No hand' when shown a picture not including hand", async () => { + const result = await classifyImage(model, imageNoHand, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("No hand"); - } - }); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("No hand"); + } + }); - it("returns a probability level", async () => { - const result = await classifyImage(model, imageNoHand, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].probability).not.toBe(null); - } - }); - }); - describe("Error boundries", async () => { - it("returns an error when missing a parameter", async () => { - const result = await classifyImage(imageNoHand, "classic"); + it("returns a probability level", async () => { + const result = await classifyImage(model, imageNoHand, "classic"); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].probability).not.toBe(null); + } + }); - expect(result).toBeInstanceOf(Error); - }); - }); - describe("Image types", async () => { - it("returns a result on url image-input", async () => { - const result = await classifyImage(model, imageHand, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - it("returns a result on JPEG image-input", async () => { - const result = await classifyImage(model, imageHandJPEG, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - it("returns a result on PNG image-input", async () => { - const result = await classifyImage(model, imageHandPNG, "classic"); - if (result instanceof Error) { - return new Error(); - } else { - expect(result[0].label).toBe("Hand"); - } - }); - }); + it("returns when MODEL_DIR_PATH ends with slash", async () => { + const result = await classifyImage(model + "/", imageHand); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + }); + describe("Error boundries", async () => { + it("returns an error when missing a parameter", async () => { + const result = await classifyImage(imageNoHand, "classic"); + + expect(result).toBeInstanceOf(Error); + }); + }); + describe("Image types", async () => { + it("returns a result on url image-input", async () => { + const result = await classifyImage(model, imageHand, "classic"); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + it("returns a result on JPEG image-input", async () => { + const result = await classifyImage(model, imageHandJPEG, "classic"); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + it("returns a result on PNG image-input", async () => { + const result = await classifyImage(model, imageHandPNG, "classic"); + if (result instanceof Error) { + return new Error(); + } else { + expect(result[0].label).toBe("Hand"); + } + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index d2de490..2df40f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,12 @@ { - "compilerOptions": { - "target": "es2016", - "module": "commonjs", - "outDir": "dist" /* Specify an output folder for all emitted files. */, - "noEmit": true /* Disable emitting files from a compilation. */, - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - "strict": true /* Enable all strict type-checking options. */, - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "outDir": "dist", + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } } diff --git a/tsup.config.ts b/tsup.config.ts index d7745e7..f5be25e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,11 +1,11 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/**"], - format: ["cjs", "esm"], // Build for commonJS and ESmodules - dts: true, // Generate declaration file (.d.ts) - splitting: false, - sourcemap: true, - clean: true, - minify: true, + entry: ["src/**"], + format: ["cjs", "esm"], // Build for commonJS and ESmodules + dts: true, // Generate declaration file (.d.ts) + splitting: false, + sourcemap: true, + clean: true, + minify: true, });