- serialize face cascade to avoid filesystem
[face-privacy-filter.git] / face_privacy_filter / transform_detect.py
index 656db86..5dc6799 100644 (file)
@@ -8,6 +8,15 @@ import os
 import pandas as pd
 import numpy as np
 from sklearn.base import BaseEstimator, ClassifierMixin
+import base64
+
+import gzip
+import sys
+if sys.version_info[0] < 3:
+    from cStringIO import StringIO as BytesIO
+else:
+    from io import BytesIO as BytesIO
+
 
 class FaceDetectTransform(BaseEstimator, ClassifierMixin):
     '''
@@ -21,16 +30,40 @@ class FaceDetectTransform(BaseEstimator, ClassifierMixin):
     COL_REGION_IDX = 'region'
     COL_IMAGE_IDX = 'image'
     COL_IMAGE_MIME = 'mime_type'
-    COL_IMAGE_DATA = 'binary_stream'
+    COL_IMAGE_DATA = 'image_binary'
     VAL_REGION_IMAGE_ID = -1
 
-    def __init__(self, cascade_path=None, include_image=True):
+    def __init__(self, cascade_path=None, cascade_stream=None, include_image=True):
         self.include_image = include_image    # should output transform include image?
-        self.cascade_path = cascade_path    # abs path outside of module
-        self.cascade_obj = None # late-load this component
+        self.cascade_obj = None  # late-load this component
+        self.cascade_stream = cascade_stream    # compressed binary final for cascade data
+        if self.cascade_stream is None:
+            if cascade_path is None:   # default/included data?
+                pathRoot = os.path.dirname(os.path.abspath(__file__))
+                cascade_path = os.path.join(pathRoot, FaceDetectTransform.CASCADE_DEFAULT_FILE)
+            raw_stream = b""
+            with open(cascade_path, 'rb') as f:
+                raw_stream = f.read()
+                self.cascade_stream = {'name': os.path.basename(cascade_path),
+                                       'data': FaceDetectTransform.string_compress(raw_stream)}
+
+    @staticmethod
+    def string_compress(string_data):
+        out_data = BytesIO()
+        with gzip.GzipFile(fileobj=out_data, mode="wb") as f:
+            f.write(string_data)
+        return out_data.getvalue()
+
+    @staticmethod
+    def string_decompress(compressed_data):
+        in_data = BytesIO(compressed_data)
+        ret_str = None
+        with gzip.GzipFile(fileobj=in_data, mode="rb") as f:
+            ret_str = f.read()
+        return ret_str
 
     def get_params(self, deep=False):
-        return {'include_image': self.include_image}
+        return {'include_image': self.include_image, 'cascade_stream': self.cascade_stream}
 
     @staticmethod
     def generate_in_df(path_image="", bin_stream=b""):
@@ -46,42 +79,33 @@ class FaceDetectTransform(BaseEstimator, ClassifierMixin):
             f.write(row[FaceDetectTransform.COL_IMAGE_DATA][0])
 
     @staticmethod
-    def generate_out_dict(idx=VAL_REGION_IMAGE_ID, x=0, y=0, w=0, h=0, image=0):
-        return {FaceDetectTransform.COL_REGION_IDX: idx, FaceDetectTransform.COL_FACE_X: x,
-                FaceDetectTransform.COL_FACE_Y: y, FaceDetectTransform.COL_FACE_W: w, FaceDetectTransform.COL_FACE_H: h,
-                FaceDetectTransform.COL_IMAGE_IDX: image,
-                FaceDetectTransform.COL_IMAGE_MIME: '', FaceDetectTransform.COL_IMAGE_DATA: ''}
+    def output_names_():
+        return [FaceDetectTransform.COL_IMAGE_IDX, FaceDetectTransform.COL_REGION_IDX,
+                FaceDetectTransform.COL_FACE_X, FaceDetectTransform.COL_FACE_Y,
+                FaceDetectTransform.COL_FACE_W, FaceDetectTransform.COL_FACE_H,
+                FaceDetectTransform.COL_IMAGE_MIME, FaceDetectTransform.COL_IMAGE_DATA]
+
+    @staticmethod
+    def generate_out_dict(idx=VAL_REGION_IMAGE_ID, x=0, y=0, w=0, h=0, image=0, bin_stream=b"", media=""):
+        return dict(zip(FaceDetectTransform.output_names_(), [image, idx, x, y, w, h, media, bin_stream]))
 
     @staticmethod
     def suppress_image(df):
-        keep_col = [FaceDetectTransform.COL_FACE_X, FaceDetectTransform.COL_FACE_Y,
-                    FaceDetectTransform.COL_FACE_W, FaceDetectTransform.COL_FACE_H,
-                    FaceDetectTransform.COL_FACE_W, FaceDetectTransform.COL_FACE_H,
-                    FaceDetectTransform.COL_REGION_IDX, FaceDetectTransform.COL_IMAGE_IDX]
-        blank_cols = [col for col in df.columns if col not in keep_col]
+        blank_cols = [FaceDetectTransform.COL_IMAGE_MIME, FaceDetectTransform.COL_IMAGE_DATA]
         # set columns that aren't in our known column list to empty strings; search where face index==-1 (no face)
-        df.loc[df[FaceDetectTransform.COL_REGION_IDX]==FaceDetectTransform.VAL_REGION_IMAGE_ID,blank_cols] = ""
+        df[blank_cols] = None
         return df
 
     @property
-    def output_names_(self):
-        return [FaceDetectTransform.COL_REGION_IDX, FaceDetectTransform.COL_FACE_X, FaceDetectTransform.COL_FACE_Y,
-                 FaceDetectTransform.COL_FACE_W, FaceDetectTransform.COL_FACE_H,
-                 FaceDetectTransform.COL_IMAGE_IDX, FaceDetectTransform.COL_IMAGE_MIME, FaceDetectTransform.COL_IMAGE_DATA]
-
-    @property
-    def output_types_(self):
-        list_name = self.output_names_
-        list_type = self.classes_
-        return [{list_name[i]:list_type[i]} for i in range(len(list_name))]
+    def _type_in(self):
+        """Custom input type for this processing transformer"""
+        return {FaceDetectTransform.COL_IMAGE_MIME: str, FaceDetectTransform.COL_IMAGE_DATA: bytes}, "FaceImage"
 
     @property
-    def n_outputs_(self):
-        return 8
-
-    @property
-    def classes_(self):
-        return [int, int, int, int, int, int, str, str]
+    def _type_out(self):
+        """Custom input type for this processing transformer"""
+        output_dict = FaceDetectTransform.generate_out_dict()
+        return {k: type(output_dict[k]) for k in output_dict}, "DetectionFrames"
 
     def score(self, X, y=None):
         return 0
@@ -89,48 +113,54 @@ class FaceDetectTransform(BaseEstimator, ClassifierMixin):
     def fit(self, X, y=None):
         return self
 
+    def load_cascade(self):
+        # if no model exists yet, create it; return False for deserialize required
+        if self.cascade_obj is None:
+            if self.cascade_stream is not None:
+                import tempfile
+                with tempfile.TemporaryDirectory() as tdir:
+                    cascade_data = FaceDetectTransform.string_decompress(self.cascade_stream['data'])
+                    cascade_path = os.path.join(tdir, self.cascade_stream['name'])
+                    with open(cascade_path, 'wb') as f:
+                        f.write(cascade_data)
+                    self.cascade_obj = cv2.CascadeClassifier(cascade_path)
+            return False
+        return True
+
     def predict(self, X, y=None):
         """
         Assumes a numpy array of [[mime_type, binary_string] ... ]
-           where mime_type is an image-specifying mime type and binary_string is the raw image bytes       
+           where mime_type is an image-specifying mime type and binary_string is the raw image bytes
         """
-        # if no model exists yet, create it
-        if self.cascade_obj is None:
-            if self.cascade_path is not None:
-                self.cascade_obj = cv2.CascadeClassifier(self.cascade_path)
-            else:   # none provided, load what came with the package
-                pathRoot = os.path.dirname(os.path.abspath(__file__))
-                pathFile = os.path.join(pathRoot, FaceDetectTransform.CASCADE_DEFAULT_FILE)
-                self.cascade_obj = cv2.CascadeClassifier(pathFile)
-
+        self.load_cascade()  # JIT load model
         dfReturn = None
+        listData = []
         for image_idx in range(len(X)):
-            file_bytes = np.asarray(bytearray(X[FaceDetectTransform.COL_IMAGE_DATA][image_idx]), dtype=np.uint8)
+            image_byte = X[FaceDetectTransform.COL_IMAGE_DATA][image_idx]
+            if type(image_byte) == str:
+                image_byte = image_byte.encode()
+                image_byte = base64.b64decode(image_byte)
+            image_byte = bytearray(image_byte)
+            file_bytes = np.asarray(image_byte, dtype=np.uint8)
             img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
             # img = cv2.imread(image_set[1])
             faces = self.detect_faces(img)
 
-            df = pd.DataFrame()  # start with empty DF for this image
             if self.include_image:  # create and append the image if that's requested
-                dict_image = FaceDetectTransform.generate_out_dict(w=img.shape[1], h=img.shape[0], image=image_idx)
-                dict_image[FaceDetectTransform.COL_IMAGE_MIME] = X[FaceDetectTransform.COL_IMAGE_MIME][image_idx]
-                dict_image[FaceDetectTransform.COL_IMAGE_DATA] = X[FaceDetectTransform.COL_IMAGE_DATA][image_idx]
-                df = pd.DataFrame([dict_image])
+                listData.append(FaceDetectTransform.generate_out_dict(w=img.shape[1], h=img.shape[0], image=image_idx,
+                                                                      media=X[FaceDetectTransform.COL_IMAGE_MIME][image_idx],
+                                                                      bin_stream=X[FaceDetectTransform.COL_IMAGE_DATA][image_idx]))
             for idxF in range(len(faces)):  # walk through detected faces
                 face_rect = faces[idxF]
-                df = df.append(pd.DataFrame([FaceDetectTransform.generate_out_dict(idxF, face_rect[0], face_rect[1],
-                                                                    face_rect[2], face_rect[3], image=image_idx)]),
-                               ignore_index=True)
-            if dfReturn is None:  # create an NP container for all image samples + features
-                dfReturn = df.reindex_axis(self.output_names_, axis=1)
-            else:
-                dfReturn = dfReturn.append(df, ignore_index=True)
-            #print("IMAGE {:} found {:} total rows".format(image_idx, len(df)))
+                listData.append(FaceDetectTransform.generate_out_dict(idxF, x=face_rect[0], y=face_rect[1],
+                                                                      w=face_rect[2], h=face_rect[3], image=image_idx))
+            # print("IMAGE {:} found {:} total rows".format(image_idx, len(df)))
 
-        return dfReturn
+        return pd.DataFrame(listData, columns=FaceDetectTransform.output_names_())  # start with empty DF for this image
 
     def detect_faces(self, img):
-        if self.cascade_obj is None: return []
+        if self.cascade_obj is None:
+            return []
         gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
 
         faces = self.cascade_obj.detectMultiScale(
@@ -142,19 +172,6 @@ class FaceDetectTransform(BaseEstimator, ClassifierMixin):
         )
 
         # Draw a rectangle around the faces
-        #for (x, y, w, h) in faces:
+        # for (x, y, w, h) in faces:
         #    cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
         return faces
-
-    #############################################
-    ## helper for i/o
-    @staticmethod
-    def read_byte_arrays(bytearray_string):
-        """Method to recover bytes from pandas read/cast function:
-            inputDf = pd.read_csv(config['input'], converters:{FaceDetectTransform.COL_IMAGE_DATA:FaceDetectTransform.read_byte_arrays})
-           https://stackoverflow.com/a/43024993
-        """
-        from ast import literal_eval
-        if type(bytearray_string)==str and bytearray_string.startswith("b'"):
-            return bytearray(literal_eval(bytearray_string))
-        return bytearray_string