Skip to content

Commit 56e3ffa

Browse files
authoredJul 2, 2019
Chrome browser plugin deployment example (mobilenet) (#285)
* dist initial commit * wip * wip * working ... removing unnecessary bits * works on click * add images to dist * click to remove previous predictions * cleanup * cleanup * reviewer comments. Move to TF-Hub * removed deployment subdir * fixup * cleanup * remove vestigal deployment dir * Merge branch 'master' into plugin-mobilenet
1 parent d5b6da3 commit 56e3ffa

12 files changed

+1487
-0
lines changed
 

‎chrome-extension/README.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# TensorFlow.js Deployment Example : Browser Extension
2+
3+
This example creates a Chrome extension, enabling users to right-click on images
4+
within a web page, and perform multi-class object detection on them. The
5+
extension will will apply a MobileNetV2 classifier to the image, and then print
6+
the predicted class on top of the image.
7+
8+
To build the extension, use the command:
9+
10+
```sh
11+
yarn
12+
yarn build
13+
```
14+
15+
To install the unpacked extension in chrome, follow the [instructions here](https://developer.chrome.com/extensions/getstarted). Briefly, navigate to `chrome://extensions`, make sure that the `Developer mode` switch is turned on in the upper right, and click `Load Unpacked`. Then select the appropriate directory (the `dist` directory containing `manifest.json`);
16+
17+
If it worked you should see an icon for the `TF.js mobilenet` Chrome extension.
18+
19+
![install page illustration](./install.png "install page")
20+
21+
22+
Using the extension
23+
----
24+
Once the extension is installed, you should be able to classify images in the browser. To do so, navigate to a site with images on it, such as the Google image search page for the term "tiger" used here. Then right click on the image you wish to classify. You should see a menu option for `Classify image with TensorFlow.js`. Clicking that image should cause the extension to execute the model on the image, and then add some text over the image indicating the prediction.
25+
26+
![usage](./usage.png "usage")
27+
28+
29+
Removing the extension
30+
----
31+
To remove the extension, click `Remove` on the extension page, or use the `Remove from Chrome...` menu option when right clicking the icon.
32+
2.75 KB
Loading
495 Bytes
Loading
814 Bytes
Loading
1.2 KB
Loading

‎chrome-extension/dist/manifest.json

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApAR3gTAUuMUf/nAaIip/Vd2xMAR2Xk+9dqlVruWUdsMXeCGiuECchTmOguyPakqdTTA7Mbyd0RyaU86z63iX350cdyYXzfhLUwecQYIZUFh15c7HhGm8YliGj26voZAkczPB8EnaQtnhUIvTkdrys2/TtQy46bCmZlOTuAwM+xQXf0Yo0GkKCU/+bI/S/e7ZkYD+39Riwj/w/Xv+ipdfAH6clPJ/Xs+cOm+MsydKuR7bB3PermsHiv2LKbEnyS7wn7Vev5Q2pdGRRcMQDnXZwYP5YlrEQEp2xdwM2kIvCh2MOk7J0ULniFUpPpdk7Uy2jD72pCZxT4SiiuAUdMLogQIDAQAB",
3+
"name": "TF.js mobilenet in a Chrome extension",
4+
"version": "0.0.0",
5+
"description": "Classify images right in your browser using TensorFlow.js and mobilenet.",
6+
"permissions": [
7+
"<all_urls>",
8+
"activeTab",
9+
"contextMenus",
10+
"storage",
11+
"tabs",
12+
"webRequest",
13+
"webRequestBlocking"
14+
],
15+
"background": {
16+
"scripts": ["src/background.js"],
17+
"persistent": true
18+
},
19+
"content_scripts": [
20+
{
21+
"matches": ["http://*/*", "https://*/*"],
22+
"js": ["src/content.js"],
23+
"all_frames": true,
24+
"run_at": "document_start"
25+
}
26+
],
27+
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
28+
"manifest_version": 2,
29+
"icons": {
30+
"16": "images/get_started16.png",
31+
"32": "images/get_started32.png",
32+
"48": "images/get_started48.png",
33+
"128": "images/get_started128.png"
34+
}
35+
}

‎chrome-extension/install.png

48.5 KB
Loading

‎chrome-extension/package.json

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "tfjs-basic-chrome-extension",
3+
"version": "0.1.0",
4+
"description": "Use tfjs model.predict in a chrome extension",
5+
"scripts": {
6+
"copy": "cp src/content.js dist/src/ && cp src/imagenet_classes.js dist/src/",
7+
"build": "parcel build src/background.js -d dist/src/ -o background --no-minify && npm run copy",
8+
"watch": "npm run copy && parcel watch src/background.js --hmr-hostname localhost -d dist/src/ -o background"
9+
},
10+
"license": "Apache 2.0",
11+
"devDependencies": {
12+
"babel-core": "^6.26.3",
13+
"babel-plugin-transform-runtime": "^6.23.0",
14+
"babel-polyfill": "^6.26.0",
15+
"babel-preset-env": "^1.6.1",
16+
"clang-format": "^1.2.3",
17+
"parcel-bundler": "^1.7.1"
18+
},
19+
"dependencies": {
20+
"@tensorflow/tfjs": "^1.1.0"
21+
}
22+
}

‎chrome-extension/src/background.js

+214
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* @license
3+
* Copyright 2019 Google LLC. All Rights Reserved.
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
* =============================================================================
16+
*/
17+
18+
import 'babel-polyfill';
19+
import * as tf from '@tensorflow/tfjs';
20+
import {IMAGENET_CLASSES} from './imagenet_classes';
21+
22+
// Where to load the model from.
23+
const MOBILENET_MODEL_TFHUB_URL =
24+
'https://tfhub.dev/google/imagenet/mobilenet_v2_100_224/classification/2'
25+
// Size of the image expected by mobilenet.
26+
const IMAGE_SIZE = 224;
27+
// The minimum image size to consider classifying. Below this limit the
28+
// extension will refuse to classify the image.
29+
const MIN_IMG_SIZE = 128;
30+
31+
// How many predictions to take.
32+
const TOPK_PREDICTIONS = 2;
33+
const FIVE_SECONDS_IN_MS = 5000;
34+
/**
35+
* What action to take when someone clicks the right-click menu option.
36+
* Here it takes the url of the right-clicked image and the current tabId
37+
* and forwards it to the imageClassifier's analyzeImage method.
38+
*/
39+
function clickMenuCallback(info, tab) {
40+
imageClassifier.analyzeImage(info.srcUrl, tab.id);
41+
}
42+
43+
/**
44+
* Adds a right-click menu option to trigger classifying the image.
45+
* The menu option should only appear when right-clicking an image.
46+
*/
47+
chrome.contextMenus.create({
48+
title: 'Classify image with TensorFlow.js ',
49+
contexts: ['image'],
50+
onclick: clickMenuCallback
51+
});
52+
53+
/**
54+
* Async loads a mobilenet on construction. Subsequently handles
55+
* requests to classify images through the .analyzeImage API.
56+
* Successful requests will post a chrome message with
57+
* 'IMAGE_CLICK_PROCESSED' action, which the content.js can
58+
* hear and use to manipulate the DOM.
59+
*/
60+
class ImageClassifier {
61+
constructor() {
62+
this.loadModel();
63+
}
64+
65+
/**
66+
* Loads mobilenet from URL and keeps a reference to it in the object.
67+
*/
68+
async loadModel() {
69+
console.log('Loading model...');
70+
const startTime = performance.now();
71+
try {
72+
this.model =
73+
await tf.loadGraphModel(MOBILENET_MODEL_TFHUB_URL, {fromTFHub: true});
74+
// Warms up the model by causing intermediate tensor values
75+
// to be built and pushed to GPU.
76+
tf.tidy(() => {
77+
this.model.predict(tf.zeros([1, IMAGE_SIZE, IMAGE_SIZE, 3]));
78+
});
79+
const totalTime = Math.floor(performance.now() - startTime);
80+
console.log(`Model loaded and initialized in ${totalTime} ms...`);
81+
} catch {
82+
console.error(
83+
`Unable to load model from URL: ${MOBILENET_MODEL_TFHUB_URL}`);
84+
}
85+
}
86+
87+
/**
88+
* Triggers the model to make a prediction on the image referenced by url.
89+
* After a successful prediction a IMAGE_CLICK_PROCESSED message when
90+
* complete, for the content.js script to hear and update the DOM with the
91+
* results of the prediction.
92+
*
93+
* @param {string} url url of image to analyze.
94+
* @param {number} tabId which tab the request comes from.
95+
*/
96+
async analyzeImage(url, tabId) {
97+
if (!tabId) {
98+
console.error('No tab. No prediction.');
99+
return;
100+
}
101+
if (!this.model) {
102+
console.log('Waiting for model to load...');
103+
setTimeout(() => {this.analyzeImage(url)}, FIVE_SECONDS_IN_MS);
104+
return;
105+
}
106+
let message;
107+
this.loadImage(url).then(
108+
async (img) => {
109+
if (!img) {
110+
console.error(
111+
'Could not load image. Either too small or unavailable.');
112+
return;
113+
}
114+
const predictions = await this.predict(img);
115+
message = {action: 'IMAGE_CLICK_PROCESSED', url, predictions};
116+
chrome.tabs.sendMessage(tabId, message);
117+
},
118+
(reason) => {
119+
console.error(`Failed to analyze: ${reason}`);
120+
});
121+
}
122+
123+
/**
124+
* Creates a dom element and loads the image pointed to by the provided src.
125+
* @param {string} src URL of the image to load.
126+
*/
127+
async loadImage(src) {
128+
return new Promise((resolve, reject) => {
129+
const img = document.createElement('img');
130+
img.crossOrigin = 'anonymous';
131+
img.onerror = function(e) {
132+
reject(`Could not load image from external source ${src}.`);
133+
};
134+
img.onload = function(e) {
135+
if ((img.height && img.height > MIN_IMG_SIZE) ||
136+
(img.width && img.width > MIN_IMG_SIZE)) {
137+
img.width = IMAGE_SIZE;
138+
img.height = IMAGE_SIZE;
139+
resolve(img);
140+
}
141+
// Fail out if either dimension is less than MIN_IMG_SIZE.
142+
reject(`Image size too small. [${img.height} x ${
143+
img.width}] vs. minimum [${MIN_IMG_SIZE} x ${MIN_IMG_SIZE}]`);
144+
};
145+
img.src = src;
146+
});
147+
}
148+
149+
/**
150+
* Sorts predictions by score and keeps only topK
151+
* @param {Tensor} logits A tensor with one element per predicatable class
152+
* type of mobilenet. Return of executing model.predict on an Image.
153+
* @param {number} topK how many to keep.
154+
*/
155+
async getTopKClasses(logits, topK) {
156+
const {values, indices} = tf.topk(logits, topK, true);
157+
const valuesArr = await values.data();
158+
const indicesArr = await indices.data();
159+
console.log(`indicesArr ${indicesArr}`);
160+
const topClassesAndProbs = [];
161+
for (let i = 0; i < topK; i++) {
162+
topClassesAndProbs.push({
163+
className: IMAGENET_CLASSES[indicesArr[i]],
164+
probability: valuesArr[i]
165+
})
166+
}
167+
return topClassesAndProbs;
168+
}
169+
170+
/**
171+
* Executes the model on the input image, and returns the top predicted
172+
* classes.
173+
* @param {HTMLElement} imgElement HTML element holding the image to predict
174+
* from. Should have the correct size ofr mobilenet.
175+
*/
176+
async predict(imgElement) {
177+
console.log('Predicting...');
178+
// The first start time includes the time it takes to extract the image
179+
// from the HTML and preprocess it, in additon to the predict() call.
180+
const startTime1 = performance.now();
181+
// The second start time excludes the extraction and preprocessing and
182+
// includes only the predict() call.
183+
let startTime2;
184+
const logits = tf.tidy(() => {
185+
// Mobilenet expects images to be normalized between -1 and 1.
186+
const img = tf.browser.fromPixels(imgElement).toFloat();
187+
// const offset = tf.scalar(127.5);
188+
// const normalized = img.sub(offset).div(offset);
189+
const normalized = img.div(tf.scalar(256.0));
190+
const batched = normalized.reshape([1, IMAGE_SIZE, IMAGE_SIZE, 3]);
191+
startTime2 = performance.now();
192+
const output = this.model.predict(batched);
193+
if (output.shape[output.shape.length - 1] === 1001) {
194+
// Remove the very first logit (background noise).
195+
return output.slice([0, 1], [-1, 1000]);
196+
} else if (output.shape[output.shape.length - 1] === 1000) {
197+
return output;
198+
} else {
199+
throw new Error('Unexpected shape...');
200+
}
201+
});
202+
203+
// Convert logits to probabilities and class names.
204+
const classes = await this.getTopKClasses(logits, TOPK_PREDICTIONS);
205+
const totalTime1 = performance.now() - startTime1;
206+
const totalTime2 = performance.now() - startTime2;
207+
console.log(
208+
`Done in ${totalTime1.toFixed(1)} ms ` +
209+
`(not including preprocessing: ${Math.floor(totalTime2)} ms)`);
210+
return classes;
211+
}
212+
}
213+
214+
const imageClassifier = new ImageClassifier();

‎chrome-extension/src/content.js

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @license
3+
* Copyright 2019 Google LLC. All Rights Reserved.
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
* =============================================================================
16+
*/
17+
18+
// class name for all text nodes added by this script.
19+
const TEXT_DIV_CLASSNAME = 'tfjs_mobilenet_extension_text';
20+
// Thresholds for LOW_CONFIDENCE_THRESHOLD and HIGH_CONFIDENCE_THRESHOLD,
21+
// controlling which messages are printed.
22+
const HIGH_CONFIDENCE_THRESHOLD = 0.5;
23+
const LOW_CONFIDENCE_THRESHOLD = 0.1;
24+
25+
/**
26+
* Produces a short text string summarizing the prediction
27+
* Input prediction should be a list of {className: string, prediction: float}
28+
* objects.
29+
* @param {[{className: string, predictions: number}]} predictions ordered list
30+
* of objects, each with a prediction class and score
31+
*/
32+
function textContentFromPrediction(predictions) {
33+
if (!predictions || predictions.length < 1) {
34+
return `No prediction 🙁`;
35+
}
36+
// Confident.
37+
if (predictions[0].probability >= HIGH_CONFIDENCE_THRESHOLD) {
38+
return `😄 ${predictions[0].className}!`;
39+
}
40+
// Not Confident.
41+
if (predictions[0].probability >= LOW_CONFIDENCE_THRESHOLD &&
42+
predictions[0].probability < HIGH_CONFIDENCE_THRESHOLD) {
43+
return `${predictions[0].className}?...\n Maybe ${
44+
predictions[1].className}?`;
45+
}
46+
// Very not confident.
47+
if (predictions[0].probability < LOW_CONFIDENCE_THRESHOLD) {
48+
return `😕 ${predictions[0].className}????...\n Maybe ${
49+
predictions[1].className}????`;
50+
}
51+
}
52+
53+
/**
54+
* Returns a list of all DOM image elements pointing to the provided srcUrl.
55+
* @param {string} srcUrl which url to search for, including 'http(s)://'
56+
* prefix.
57+
* @returns {HTMLElement[]} all img elements pointing to the provided srcUrl
58+
*/
59+
function getImageElementsWithSrcUrl(srcUrl) {
60+
const imgElArr = Array.from(document.getElementsByTagName('img'));
61+
const filtImgElArr = imgElArr.filter(x => x.src === srcUrl);
62+
return filtImgElArr;
63+
}
64+
65+
/**
66+
* Finds and removes all of the text predictions added by this extension, and
67+
* removes them from the DOM. Note: This does not undo the containerization. A
68+
* cleaner implementation would move the image node back out of the container
69+
* div.
70+
*/
71+
function removeTextElements() {
72+
const textDivs = document.getElementsByClassName(TEXT_DIV_CLASSNAME);
73+
for (const div of textDivs) {
74+
div.parentNode.removeChild(div);
75+
}
76+
}
77+
78+
79+
80+
/**
81+
* Moves the provided imgNode into a container div, and adds a text div as a
82+
* peer. Styles the container div and text div to place the text
83+
* on top of the image.
84+
* @param {HTMLElement} imgNode Which image node to write content on.
85+
* @param {string} textContent What text to write on the image.
86+
*/
87+
function addTextElementToImageNode(imgNode, textContent) {
88+
const originalParent = imgNode.parentElement;
89+
const container = document.createElement('div');
90+
container.style.position = 'relative';
91+
container.style.textAlign = 'center';
92+
container.style.colore = 'white';
93+
const text = document.createElement('div');
94+
text.className = 'tfjs_mobilenet_extension_text';
95+
text.style.position = 'absolute';
96+
text.style.top = '50%';
97+
text.style.left = '50%';
98+
text.style.transform = 'translate(-50%, -50%)';
99+
text.style.fontSize = '34px';
100+
text.style.fontFamily = 'Google Sans,sans-serif';
101+
text.style.fontWeight = '700';
102+
text.style.color = 'white';
103+
text.style.lineHeight = '1em';
104+
text.style['-webkit-text-fill-color'] = 'white';
105+
text.style['-webkit-text-stroke-width'] = '1px';
106+
text.style['-webkit-text-stroke-color'] = 'black';
107+
// Add the containerNode as a peer to the image, right next to the image.
108+
originalParent.insertBefore(container, imgNode);
109+
// Move the imageNode to inside the containerNode;
110+
container.appendChild(imgNode);
111+
// Add the text node right after the image node;
112+
container.appendChild(text);
113+
text.textContent = textContent;
114+
}
115+
116+
// Add a listener to hear from the content.js page when the image is through
117+
// processing. The message should contin an action, a url, and predictions (the
118+
// output of the classifier)
119+
//
120+
// message: {action, url, predictions}
121+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
122+
if (message && message.action === 'IMAGE_CLICK_PROCESSED' && message.url &&
123+
message.predictions) {
124+
// Get the list of images with this srcUrl.
125+
const imgElements = getImageElementsWithSrcUrl(message.url);
126+
for (const imgNode of imgElements) {
127+
const textContent = textContentFromPrediction(message.predictions);
128+
addTextElementToImageNode(imgNode, textContent);
129+
}
130+
}
131+
});
132+
133+
// Set up a listener to remove all annotations if the user clicks
134+
// the left mouse button. Otherwise, they can easily cloud up the
135+
// window.
136+
window.addEventListener('click', clickHandler, false);
137+
/**
138+
* Removes text elements from DOM on a left click.
139+
*/
140+
function clickHandler(mouseEvent) {
141+
if (mouseEvent.button == 0) {
142+
removeTextElements();
143+
}
144+
}

‎chrome-extension/src/imagenet_classes.js

+1,040
Large diffs are not rendered by default.

‎chrome-extension/usage.png

897 KB
Loading

0 commit comments

Comments
 (0)
Please sign in to comment.