convert protobuf i/o to flat,row-like data
[face-privacy-filter.git] / web_demo / face-privacy.js
1 /*
2   ===============LICENSE_START=======================================================
3   Acumos Apache-2.0
4   ===================================================================================
5   Copyright (C) 2017-2018 AT&T Intellectual Property & Tech Mahindra. All rights reserved.
6   ===================================================================================
7   This Acumos software file is distributed by AT&T and Tech Mahindra
8   under the Apache License, Version 2.0 (the "License");
9   you may not use this file except in compliance with the License.
10   You may obtain a copy of the License at
11
12   http://www.apache.org/licenses/LICENSE-2.0
13
14   This file is distributed on an "AS IS" BASIS,
15   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   See the License for the specific language governing permissions and
17   limitations under the License.
18   ===============LICENSE_END=========================================================
19 */
20 /**
21  face-privacy.js - send frames to an face privacy service
22
23  Videos or camera are displayed locally and frames are periodically sent to GPU image-net classifier service (developed by Zhu Liu) via http post.
24  For webRTC, See: https://gist.github.com/greenido/6238800
25
26  D. Gibbon 6/3/15
27  D. Gibbon 4/19/17 updated to new getUserMedia api, https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
28  D. Gibbon 8/1/17 adapted for system
29  E. Zavesky 10/19/17 adapted for video+image
30  E. Zavesky 05/05/18 adapted for row-based image and other results
31  */
32
33 "use strict";
34
35 /**
36  * main entry point
37  */
38 $(document).ready(function() {
39     var urlDefault = getUrlParameter('url-image');
40     if (!urlDefault)
41         urlDefault = "http://localhost:8884/transform";
42
43         $(document.body).data('hdparams', {     // store global vars in the body element
44                 classificationServer: urlDefault,
45                 protoObj: null,   // to be back-filled after protobuf load {'root':obj, 'methods':{'xx':{'typeIn':x, 'typeOut':y}} }
46                 protoPayloadInput: null,   //payload for encoded message download (if desired)
47                 protoPayloadOutput: null,   //payload for encoded message download (if desired)
48                 frameCounter: 0,
49                 frameInterval: 500,             // Milliseconds to sleep between sending frames to reduce server load and reduce results updates
50                 frameTimer: -1,         // frame clock for processing
51                 imageIsWaiting: false,  // blocking to prevent too many queued frames
52                 // Objects from DOM elements
53                 srcImgCanvas: document.getElementById('srcImgCanvas'),  // we have a 'src' source image
54                 destImg: document.getElementById('destImg'),    // we have a 'src' source image
55                 video: document.getElementById('srcVideo'),
56         });
57     $(document.body).data('hdparams')['canvasMaxH'] = $(document.body).data('hdparams')['srcImgCanvas'].height;
58     $(document.body).data('hdparams')['canvasMaxW'] = $(document.body).data('hdparams')['srcImgCanvas'].width;
59
60     $("#protoInput").prop("disabled",true).click(downloadBlobIn);
61     $("#protoOutput").prop("disabled",true).click(downloadBlobOut);
62     $("#resultText").hide();
63
64         //add text input tweak
65         $("#serverUrl").change(function() {
66             $(document.body).data('hdparams')['classificationServer'] = $(this).val();
67         updateLink("serverLink");
68         }).val($(document.body).data('hdparams')['classificationServer'])
69         //set launch link at first
70     updateLink("serverLink");
71
72         // add buttons to change video
73         $("#sourceRibbon div").click(function() {
74             var $this = $(this);
75             $this.siblings().removeClass('selected'); //clear other selection
76             $this.addClass('selected');
77             var objImg = $this.children('img')[0];
78             var hd = $(document.body).data('hdparams');
79             if (objImg) {
80                 switchImage(objImg.src);
81             clearInterval(hd.frameTimer);       // stop the processing
82
83             var movieAttr = $(objImg).attr('movie');
84             if (movieAttr) {
85                 // Set the video source based on URL specified in the 'videos' list, or select camera input
86                 $(hd.video).show();
87                 $(srcImgCanvas).hide();
88                 if (movieAttr == "Camera") {
89                     var constraints = {audio: false, video: true};
90                     navigator.mediaDevices.getUserMedia(constraints)
91                         .then(function(mediaStream) {
92                             hd.video.srcObject = mediaStream;
93                             hd.video.play();
94                         })
95                         .catch(function(err) {
96                             console.log(err.name + ": " + err.message);
97                         });
98                 } else {
99                     var mp4 = document.getElementById("mp4");
100                     mp4.setAttribute("src", movieAttr);
101                     hd.video.load();
102                     newVideo();
103                 }
104             }
105             else {
106                 hd.video.pause();
107                 $(hd.video).hide();
108                 $(srcImgCanvas).show();
109             }
110             }
111         });
112
113         //allow user-uploaded images
114     var imageLoader = document.getElementById('imageLoader');
115     imageLoader.addEventListener('change', handleImage, false);
116
117     //if protobuf is enabled, fire load event for it as well
118     $(document.body).data('hdparams').protoObj = {};  //clear from last load
119     protobuf_load("model.pixelate.proto", true);
120     protobuf_load("model.detect.proto");
121
122     //trigger first click
123     $("#sourceRibbon div")[0].click();
124 });
125
126
127 function protobuf_load(pathProto, forceSelect) {
128     protobuf.load(pathProto, function(err, root) {
129         if (err) {
130             console.log("[protobuf]: Error!: "+err);
131             domResult.html("<strong>"+"[protobuf]: Error!: "+err+"</strong>");
132             throw err;
133         }
134         var domSelect = $("#protoMethod");
135         var numMethods = domSelect.children().length;
136         $.each(root.nested, function(namePackage, objPackage) {    // walk all
137             if ('Model' in objPackage && 'methods' in objPackage.Model) {    // walk to model and functions...
138                 var typeSummary = {'root':root, 'methods':{} };
139                 $.each(objPackage.Model.methods, function(nameMethod, objMethod) {  // walk methods
140                     typeSummary['methods'][nameMethod] = {};
141                     typeSummary['methods'][nameMethod]['typeIn'] = namePackage+'.'+objMethod.requestType;
142                     typeSummary['methods'][nameMethod]['typeOut'] = namePackage+'.'+objMethod.responseType;
143                     typeSummary['methods'][nameMethod]['service'] = namePackage+'.'+nameMethod;
144
145                     //create HTML object as well
146                     var namePretty = namePackage+"."+nameMethod;
147                     var domOpt = $("<option />").attr("value", namePretty).text(
148                         nameMethod+ " (input: "+objMethod.requestType
149                         +", output: "+objMethod.responseType+")");
150                     if (numMethods==0) {    // first method discovery
151                         domSelect.append($("<option />").attr("value","").text("(disabled, not loaded)")); //add 'disabled'
152                     }
153                     if (forceSelect) {
154                         domOpt.attr("selected", 1);
155                     }
156                     domSelect.append(domOpt);
157                     numMethods++;
158                 });
159                 $(document.body).data('hdparams').protoObj[namePackage] = typeSummary;   //save new method set
160                 $("#protoContainer").show();
161             }
162         });
163         console.log("[protobuf]: Load successful, found "+numMethods+" model methods.");
164     });
165 }
166
167 /**
168  * Called after a new video has loaded (at least the video metadata has loaded)
169  */
170 function newVideo() {
171         var hd = $(document.body).data('hdparams');
172         hd.frameCounter = 0;
173         hd.imageIsWaiting = false;
174         hd.video.play();
175
176         // set processing canvas size based on source video
177         var pwidth = hd.video.videoWidth;
178         var pheight = hd.video.videoHeight;
179         if (pwidth > hd.maxSrcVideoWidth) {
180                 pwidth = hd.maxSrcVideoWidth;
181                 pheight = Math.floor((pwidth / hd.video.videoWidth) * pheight); // preserve aspect ratio
182         }
183         hd.srcImgCanvas.width = pwidth;
184         hd.srcImgCanvas.height = pheight;
185
186         hd.frameTimer = setInterval(nextFrame, hd.frameInterval); // start the processing
187 }
188
189 /**
190  * process the next video frame
191  */
192 function nextFrame() {
193         var hd = $(document.body).data('hdparams');
194         if (hd.video.ended || hd.video.paused) {
195                 return;
196         }
197     switchImage(hd.video, true);
198 }
199
200 function updateLink(domId, newServer) {
201     var sPageURL = decodeURIComponent(window.location.search.split('?')[0]);
202     if (newServer==undefined) {
203         newServer = $(document.body).data('hdparams')['classificationServer'];
204     }
205     else {
206         $("#serverUrl").val(newServer);
207     }
208     var sNewUrl = sPageURL+"?url-image="+newServer;
209     $("#"+domId).attr('href', sNewUrl);
210 }
211
212 function switchImage(imgSrc, isVideo) {
213     var canvas = $(document.body).data('hdparams')['srcImgCanvas'];
214     if (!isVideo) {
215         var img = new Image();
216         img.onload = function () {
217             var ctx = canvas.getContext('2d');
218             var canvasCopy = document.createElement("canvas");
219             var copyContext = canvasCopy.getContext("2d");
220
221             var ratio = 1;
222
223             //console.log( $(document.body).data('hdparams'));
224             //console.log( [ img.width, img.height]);
225             // https://stackoverflow.com/a/2412606
226             if(img.width > $(document.body).data('hdparams')['canvasMaxW'])
227                 ratio = $(document.body).data('hdparams')['canvasMaxW'] / img.width;
228             if(ratio*img.height > $(document.body).data('hdparams')['canvasMaxH'])
229                 ratio = $(document.body).data('hdparams')['canvasMaxH'] / img.height;
230
231             canvasCopy.width = img.width;
232             canvasCopy.height = img.height;
233             copyContext.drawImage(img, 0, 0);
234
235             canvas.width = img.width * ratio;
236             canvas.height = img.height * ratio;
237             ctx.drawImage(canvasCopy, 0, 0, canvasCopy.width, canvasCopy.height, 0, 0, canvas.width, canvas.height);
238             //document.removeChild(canvasCopy);
239             doPostImage(canvas, '#destImg', canvas.toDataURL());
240         }
241         img.src = imgSrc;  //copy source, let image load
242     }
243     else if (!$(document.body).data('hdparams').imageIsWaiting) {
244         var ctx = canvas.getContext('2d');
245         var canvasCopy = document.createElement("canvas");
246         var copyContext = canvasCopy.getContext("2d");
247         var ratio = 1;
248
249         if(imgSrc.videoWidth > $(document.body).data('hdparams')['canvasMaxW'])
250             ratio = $(document.body).data('hdparams')['canvasMaxW'] / imgSrc.videoWidth;
251         if(ratio*imgSrc.videoHeight > $(document.body).data('hdparams')['canvasMaxH'])
252             ratio = $(document.body).data('hdparams')['canvasMaxH'] / canvasCopy.height;
253
254         //console.log("Canvas Copy:"+canvasCopy.width+"/"+canvasCopy.height);
255         //console.log("Canvas Ratio:"+ratio);
256         //console.log("Video: "+imgSrc.videoWidth+"x"+imgSrc.videoHeight);
257         canvasCopy.width = imgSrc.videoWidth;     //large as possible
258         canvasCopy.height = imgSrc.videoHeight;
259         copyContext.drawImage(imgSrc, 0, 0);
260
261         canvas.width = canvasCopy.width * ratio;
262         canvas.height = canvasCopy.height * ratio;
263         ctx.drawImage(canvasCopy, 0, 0, canvasCopy.width, canvasCopy.height, 0, 0, canvas.width, canvas.height);
264         //document.removeChild(canvasCopy);
265         doPostImage(canvas, '#destImg', canvas.toDataURL());
266     }
267 }
268
269
270 //load image that has been uploaded into a vancas
271 function handleImage(e){
272     var reader = new FileReader();
273     reader.onload = function(event){
274         switchImage(event.target.result);
275     }
276     reader.readAsDataURL(e.target.files[0]);
277 }
278
279
280
281 // https://stackoverflow.com/questions/19491336/get-url-parameter-jquery-or-how-to-get-query-string-values-in-js
282 function getUrlParameter(sParam) {
283     var sPageURL = decodeURIComponent(window.location.search.substring(1)),
284         sURLVariables = sPageURL.split('&'),
285         sParameterName,
286         i;
287
288     for (i = 0; i < sURLVariables.length; i++) {
289         sParameterName = sURLVariables[i].split('=');
290
291         if (sParameterName[0] === sParam) {
292             return sParameterName[1] === undefined ? true : sParameterName[1];
293         }
294     }
295 };
296
297
298 /**
299  * post an image from the canvas to the service
300  */
301 function doPostImage(srcCanvas, dstImg, dataPlaceholder) {
302     var dataURL = srcCanvas.toDataURL('image/jpeg', 1.0);
303     var hd = $(document.body).data('hdparams');
304     var sendPayload = null;
305
306     var nameProtoMethod = $("#protoMethod option:selected").attr('value');
307     var methodKeys = null;
308     if (nameProtoMethod && nameProtoMethod.length) {     //valid protobuf type?
309         var partsURL = hd.classificationServer.split("/");
310         methodKeys = nameProtoMethod.split(".", 2);       //modified for multiple detect/pixelate models
311         partsURL[partsURL.length-1] = methodKeys[1];
312         hd.classificationServer = partsURL.join("/");   //rejoin with new endpoint
313         updateLink("serverLink", hd.classificationServer);
314     }
315
316     var serviceURL = hd.classificationServer;
317     var request = new XMLHttpRequest();     // create request to manipulate
318     request.open("POST", serviceURL, true);
319     var domResult = $("#resultText");
320
321     //console.log("[doPostImage]: Selected method ... '"+typeInput+"'");
322     if (nameProtoMethod && nameProtoMethod.length) {     //valid protobuf type?
323         var blob = dataURItoBlob(dataURL, true);
324
325         // fields from .proto file at time of writing...
326         //    message FaceImage {
327         //      repeated string mime_type = 1;   -> becomes "mimeType" (NOTE repeated type)
328         //      repeated bytes image_binary = 2; -> becomes "imageBinary"
329         //    }
330
331         //TODO: should we always assume this is input? answer: for now, YES, always image input!
332         var inputPayload = {'Images':[{ "mimeType": blob.type, "imageBinary": blob.bytes }]};
333
334         // ---- method for processing from a type ----
335         var msgInput = hd.protoObj[methodKeys[0]]['root'].lookupType(hd.protoObj[methodKeys[0]]['methods'][methodKeys[1]]['typeIn']);
336         // Verify the payload if necessary (i.e. when possibly incomplete or invalid)
337         var errMsg = msgInput.verify(inputPayload);
338         if (errMsg) {
339             var errStr = "[doPostImage]: Error during type verify for object input into protobuf method."+errMsg;
340             console.log(errStr);
341             domResult.html("<strong>"+errStr+"</strong>");
342             throw Error(errMsg);
343         }
344         // Create a new message
345         var msgTransmit = msgInput.create(inputPayload);
346         // Encode a message to an Uint8Array (browser) or Buffer (node)
347         sendPayload = msgInput.encode(msgTransmit).finish();
348
349         // ----------
350
351         /*
352         // ---- method for processing from a service ----
353         var serviceInput = hd.protoObj['root'].lookup(hd.protoObj['methods'][nameProtoMethod]['service']);
354
355         function rpcImpl(method, requestData, callback) {
356             // perform the request using an HTTP request or a WebSocket for example
357             var responseData = ...;
358             // and call the callback with the binary response afterwards:
359             callback(null, responseData);
360         }
361         var serviceCall = serviceInput.create(rpcImpl, false, false); //request dlimited? response delimited?
362
363         serviceCall.sayHello(sendPayload).then(response) {
364             console.log('Greeting:', response.message);
365         });
366         // ---------------------------
367         */
368
369         //downloadBlob(sendPayload, 'protobuf.bin', 'application/octet-stream');
370         // NOTE: TO TEST THIS BINARY BLOB, use some command-line magic like this...
371         //  protoc --decode=mMJuVapnmIbrHlZGKyuuPDXsrkzpGqcr.FaceImage model.proto < protobuf.bin
372         $("#protoInput").prop("disabled",false);
373         hd.protoPayloadInput = sendPayload;
374
375         // append our encoded chunk
376         //console.log(sendPayload);
377         //console.log(typeof(blob.type));
378         // console.log(nameProtoMethod);
379         request.setRequestHeader("Content-type", "text/plain;charset=UTF-8");
380         request.responseType = 'arraybuffer';
381     }
382     else {
383         var blob = dataURItoBlob(dataURL, false);
384         sendPayload = new FormData();
385         sendPayload.append("image_binary", blob);
386         sendPayload.append("mime_type", blob.type);
387     }
388     //$(dstImg).addClaas('workingImage').siblings('.spinner').remove().after($("<span class='spinner'>&nbsp;</span>"));
389     $(document.body).data('hdparams').imageIsWaiting = true;
390     var $dstImg = $(dstImg);
391     if ($dstImg.attr('src')=='') {
392         $dstImg.attr('src', dataPlaceholder);
393         //$(dstImg).addClass('workingImage').attr('src', dataPlaceholder);
394     }
395
396     hd.imageIsWaiting = true;
397     request.onreadystatechange=function() {
398         if (request.readyState==4 && request.status>=200 && request.status<300) {
399             if (methodKeys!=null) {     //valid protobuf type?
400                 //console.log(request);
401                 var bodyEncodedInString = new Uint8Array(request.response);
402                 //console.log(bodyEncodedInString);
403                 //console.log(bodyEncodedInString.length);
404                 $("#protoOutput").prop("disabled",false);
405                 hd.protoPayloadOutput = bodyEncodedInString;
406
407                 // ---- method for processing from a type ----
408                 var msgOutput = hd.protoObj[methodKeys[0]]['root'].lookupType(hd.protoObj[methodKeys[0]]['methods'][methodKeys[1]]['typeOut']);
409                 var objOutput = null;
410                 try {
411                     objOutput = msgOutput.decode(hd.protoPayloadOutput);
412                 }
413                 catch(err) {
414                     var errStr = "Error: Failed to parse protobuf response, was the right method chosen? (err: "+err.message+")";
415                     console.log(errStr);
416                     domResult.html(errStr);
417                     hd.imageIsWaiting = false;
418                     return false;
419                 }
420                 var nameRepeated = null;
421
422                 // NOTE: this code expects one top-level item to be an array of nested results
423                 //  e.g.   ImageSet [ Image{mime_type, image_binary}, .... ]
424                 //  e.g.   DetectionFrameSet [ DetectionFrame{x, y, ...., mime_type, image_binary}, .... ]
425
426                 //try to crawl the fields in the protobuf....
427                 var numFields = 0;
428                 $.each(msgOutput.fields, function(name, val) {           //collect field names
429                     if (val.repeated) {     //indicates it's a repeated field (likely an array)
430                         nameRepeated = name;      //save this as last repeated field (ideally there is just one)
431                     }
432                     numFields += 1;
433                 });
434                 if (numFields > 1) {
435                     var errStr = "Error: Expected array/repeated structure in response, but got non-flat array result ("+numFields+" fields)";
436                     console.log(errStr);
437                     domResult.html(errStr);
438                     hd.imageIsWaiting = false;
439                     return false;
440                 }
441                 var objRecv = objOutput[nameRepeated];
442
443                 //grab the nested array type and print out the fields of interest
444                 var typeNested = methodKeys[0]+"."+msgOutput.fields[nameRepeated].type;
445                 var msgOutputNested = hd.protoObj[methodKeys[0]]['root'].lookupType(typeNested);
446                 //console.log(msgOutputNested);
447                 var domTable = $("<tr />");
448                 var arrNames = [];
449                 $.each(msgOutputNested.fields, function(name, val) {           //collect field names
450                     var nameClean = val.name;
451                     if (nameClean != 'imageBinary') {
452                         domTable.append($("<th />").html(nameClean));
453                         arrNames.push(nameClean);
454                     }
455                 });
456                 domTable = $("<table />").append(domTable);     // create embedded table
457
458                 // loop through all members of array to do two things:
459                 //  (1) find the biggest/best image
460                 //  (2) print out the textual fields
461                 var objBest = null;
462                 $.each(objRecv, function(idx, val) {
463                     if ('imageBinary' in val) {
464                         // at this time, we only support ONE output image, so we will loop through
465                         //  to grab the largest image (old code could grab the one with region == -1)
466                         if (objBest==null || val.imageBinary.length>objBest.imageBinary.length) {
467                             objBest = val;
468                         }
469                     }
470
471                     var domRow = $("<tr />");
472                     $.each(arrNames, function(idx, name) {      //collect data from each column
473                         domRow.append($("<td />").html(val[name]));
474                     });
475                     domTable.append(domRow);
476                 });
477                 domResult.empty().append($("<strong />").html("Results")).show();
478                 domResult.append(domTable);
479
480
481                 //did we find an image? show it now!
482                 if (objBest != null) {
483                     var strImage = btoa(String.fromCharCode.apply(null, objBest.imageBinary));
484                     $dstImg.attr('src', "data:"+objBest.mimeType+";base64,"+strImage).removeClass('workingImage');
485                 }
486                 else {
487                     var errStr = "Error: No valid image data was found, aborting display.";
488                     console.log(errStr);
489                     domResult.html(errStr);
490                     hd.imageIsWaiting = false;
491                     return false;
492                 }
493             }
494             else {       //legacy code where response was in base64 encoded image...
495                 var responseJson = $.parseJSON(request.responseText);
496                 var respImage = responseJson[0];
497                 // https://stackoverflow.com/questions/21227078/convert-base64-to-image-in-javascript-jquery
498                 $dstImg.attr('src', "data:"+respImage['mime_type']+";base64,"+respImage['image_binary']).removeClass('workingImage');
499                 //genClassTable($.parseJSON(request.responseText), dstDiv);
500             }
501             hd.imageIsWaiting = false;
502         }
503         }
504         request.send(sendPayload);
505         $(document.body).data('hdparams').imageIsWaiting = false;
506 }
507
508
509 /**
510  * convert base64/URLEncoded data component to raw binary data held in a string
511  *
512  * Stoive, http://stackoverflow.com/questions/4998908/convert-data-uri-to-file-then-append-to-formdata
513  */
514 function dataURItoBlob(dataURI, wantBytes) {
515     // convert base64/URLEncoded data component to raw binary data held in a string
516     var byteString;
517     if (dataURI.split(',')[0].indexOf('base64') >= 0)
518         byteString = atob(dataURI.split(',')[1]);
519     else
520         byteString = unescape(dataURI.split(',')[1]);
521
522     // separate out the mime component
523     var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
524
525     // write the bytes of the string to a typed array
526     var ia = new Uint8Array(byteString.length);
527     for (var i = 0; i < byteString.length; i++) {
528         ia[i] = byteString.charCodeAt(i);
529     }
530     //added for returning bytes directly
531     if (wantBytes) {
532         return {'bytes':ia, 'type':mimeString};
533     }
534     return new Blob([ia], {type:mimeString});
535 }
536
537 function Uint8ToString(u8a){
538   var CHUNK_SZ = 0x8000;
539   var c = [];
540   for (var i=0; i < u8a.length; i+=CHUNK_SZ) {
541     c.push(String.fromCharCode.apply(null, u8a.subarray(i, i+CHUNK_SZ)));
542   }
543   return c.join("");
544 }
545
546
547 // ----- diagnostic tool to download binary blobs ----
548 function downloadBlobOut() {
549     return downloadBlob($(document.body).data('hdparams').protoPayloadOutput, "protobuf.out.bin");
550 }
551
552 function downloadBlobIn() {
553     return downloadBlob($(document.body).data('hdparams').protoPayloadInput, "protobuf.in.bin");
554 }
555
556 //  https://stackoverflow.com/a/33622881
557 function downloadBlob(data, fileName, mimeType) {
558   //if there is no data, filename, or mime provided, make our own
559   if (!data)
560       data = $(document.body).data('hdparams').protoPayloadInput;
561   if (!fileName)
562       fileName = "protobuf.bin";
563   if (!mimeType)
564       mimeType = "application/octet-stream";
565
566   var blob, url;
567   blob = new Blob([data], {
568     type: mimeType
569   });
570   url = window.URL.createObjectURL(blob);
571   downloadURL(url, fileName, mimeType);
572   setTimeout(function() {
573     return window.URL.revokeObjectURL(url);
574   }, 1000);
575 };
576
577 function downloadURL(data, fileName) {
578   var a;
579   a = document.createElement('a');
580   a.href = data;
581   a.download = fileName;
582   document.body.appendChild(a);
583   a.style = 'display: none';
584   a.click();
585   a.remove();
586 };