diff --git a/app/fawkes.py b/app/fawkes.py new file mode 100644 index 0000000..a984dd6 --- /dev/null +++ b/app/fawkes.py @@ -0,0 +1,12 @@ +import sys + +if sys.version_info < (3, 0): + # Python 2 + import Tkinter as tk +else: + # Python 3 + import tkinter as tk +root = tk.Tk() +root.title("Sandwich") +tk.Button(root, text="Make me a Sandwich").pack() +tk.mainloop() diff --git a/app/setup.py b/app/setup.py new file mode 100644 index 0000000..5a6453b --- /dev/null +++ b/app/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup + +APP = ['Sandwich.py'] +DATA_FILES = [] +OPTIONS = {'argv_emulation': True} + +setup( + app=APP, + data_files=DATA_FILES, + options={'py2app': OPTIONS}, + setup_requires=['py2app'], +) diff --git a/fawkes/__pycache__/differentiator.cpython-36.pyc b/fawkes/__pycache__/differentiator.cpython-36.pyc index 0b8b35f..0e79165 100644 Binary files a/fawkes/__pycache__/differentiator.cpython-36.pyc and b/fawkes/__pycache__/differentiator.cpython-36.pyc differ diff --git a/fawkes/__pycache__/utils.cpython-36.pyc b/fawkes/__pycache__/utils.cpython-36.pyc index fba5d6f..77639db 100644 Binary files a/fawkes/__pycache__/utils.cpython-36.pyc and b/fawkes/__pycache__/utils.cpython-36.pyc differ diff --git a/fawkes/differentiator.py b/fawkes/differentiator.py index 98a46e0..17e6b2c 100644 --- a/fawkes/differentiator.py +++ b/fawkes/differentiator.py @@ -10,6 +10,7 @@ from decimal import Decimal import numpy as np import tensorflow as tf + from .utils import preprocess, reverse_preprocess @@ -74,6 +75,7 @@ class FawkesMaskGeneration: self.input_shape = tuple([self.batch_size] + self.single_shape) self.bottleneck_shape = tuple([self.batch_size] + self.single_shape) + # self.bottleneck_shape = tuple([self.batch_size, bottleneck_model_ls[0].output_shape[-1]]) # the variable we're going to optimize over @@ -403,9 +405,9 @@ class FawkesMaskGeneration: if all_clear: break - if iteration != 0 and iteration % (self.MAX_ITERATIONS // 2) == 0: - LR = LR / 2 - print("Learning Rate: ", LR) + # if iteration != 0 and iteration % (self.MAX_ITERATIONS // 2) == 0: + # LR = LR / 2 + # print("Learning Rate: ", LR) if iteration % (self.MAX_ITERATIONS // 5) == 0: if self.verbose == 1: diff --git a/fawkes/protection.py b/fawkes/protection.py index da04387..1072807 100644 --- a/fawkes/protection.py +++ b/fawkes/protection.py @@ -7,6 +7,7 @@ import glob import os import random import sys +import time import numpy as np @@ -17,12 +18,10 @@ from .utils import load_extractor, init_gpu, select_target_label, dump_image, re random.seed(12243) np.random.seed(122412) -BATCH_SIZE = 32 - def generate_cloak_images(sess, feature_extractors, image_X, target_emb=None, th=0.01, faces=None, sd=1e9, lr=2, - max_step=500): - batch_size = BATCH_SIZE if len(image_X) > BATCH_SIZE else len(image_X) + max_step=500, batch_size=1): + batch_size = batch_size if len(image_X) > batch_size else len(image_X) differentiator = FawkesMaskGeneration(sess, feature_extractors, batch_size=batch_size, @@ -50,11 +49,11 @@ def check_imgs(imgs): def main(*argv): + start_time = time.time() if not argv: argv = list(sys.argv) - # attach SIGPIPE handler to properly handle broken pipe - try: # sigpipe not available under windows. just ignore in this case + try: import signal signal.signal(signal.SIGPIPE, signal.SIG_DFL) except Exception as e: @@ -78,25 +77,34 @@ def main(*argv): parser.add_argument('--sd', type=int, default=1e9) parser.add_argument('--lr', type=float, default=2) + parser.add_argument('--batch-size', type=int, default=1) parser.add_argument('--separate_target', action='store_true') parser.add_argument('--format', type=str, help="final image format", - default="jpg") + default="png") args = parser.parse_args(argv[1:]) if args.mode == 'low': args.feature_extractor = "high_extract" args.th = 0.003 + args.max_step = 100 + args.lr = 15 elif args.mode == 'mid': args.feature_extractor = "high_extract" args.th = 0.005 + args.max_step = 100 + args.lr = 15 elif args.mode == 'high': args.feature_extractor = "high_extract" args.th = 0.007 + args.max_step = 100 + args.lr = 10 elif args.mode == 'ultra': args.feature_extractor = "high_extract" args.th = 0.01 + args.max_step = 1000 + args.lr = 5 elif args.mode == 'custom': pass else: @@ -116,7 +124,7 @@ def main(*argv): print("No images in the directory") exit(1) - faces = Faces(image_paths, sess) + faces = Faces(image_paths, sess, verbose=1) orginal_images = faces.cropped_faces orginal_images = np.array(orginal_images) @@ -133,7 +141,7 @@ def main(*argv): protected_images = generate_cloak_images(sess, feature_extractors_ls, orginal_images, target_emb=target_embedding, th=args.th, faces=faces, sd=args.sd, - lr=args.lr, max_step=args.max_step) + lr=args.lr, max_step=args.max_step, batch_size=args.batch_size) faces.cloaked_cropped_faces = protected_images @@ -141,9 +149,12 @@ def main(*argv): final_images = faces.merge_faces(cloak_perturbation) for p_img, cloaked_img, path in zip(final_images, protected_images, image_paths): - file_name = "{}_{}_{}_cloaked.{}".format(".".join(path.split(".")[:-1]), args.mode, args.th, args.format) + file_name = "{}_{}_cloaked.{}".format(".".join(path.split(".")[:-1]), args.mode, args.format) dump_image(p_img, file_name, format=args.format) + elapsed_time = time.time() - start_time + print('attack cost %f s' % (elapsed_time)) + if __name__ == '__main__': main(*sys.argv) diff --git a/fawkes/utils.py b/fawkes/utils.py index 6f8f590..c769e31 100644 --- a/fawkes/utils.py +++ b/fawkes/utils.py @@ -4,7 +4,13 @@ import json import os import pickle import random +import shutil import sys +import tarfile +import zipfile + +import six +from six.moves.urllib.error import HTTPError, URLError stderr = sys.stderr sys.stderr = open(os.devnull, 'w') @@ -15,15 +21,40 @@ import keras.backend as K import numpy as np import tensorflow as tf from PIL import Image, ExifTags -# from keras.applications.vgg16 import preprocess_input from keras.layers import Dense, Activation from keras.models import Model from keras.preprocessing import image -from keras.utils import get_file from skimage.transform import resize from sklearn.metrics import pairwise_distances + from .align_face import align, aligner +from six.moves.urllib.request import urlopen + +if sys.version_info[0] == 2: + def urlretrieve(url, filename, reporthook=None, data=None): + def chunk_read(response, chunk_size=8192, reporthook=None): + content_type = response.info().get('Content-Length') + total_size = -1 + if content_type is not None: + total_size = int(content_type.strip()) + count = 0 + while True: + chunk = response.read(chunk_size) + count += 1 + if reporthook is not None: + reporthook(count, chunk_size, total_size) + if chunk: + yield chunk + else: + break + + response = urlopen(url, data) + with open(filename, 'wb') as fd: + for chunk in chunk_read(response, reporthook=reporthook): + fd.write(chunk) +else: + from six.moves.urllib.request import urlretrieve def clip_img(X, preprocessing='raw'): @@ -57,13 +88,16 @@ def load_image(path): class Faces(object): - def __init__(self, image_paths, sess): + def __init__(self, image_paths, sess, verbose=1): + self.verbose = verbose self.aligner = aligner(sess) self.org_faces = [] self.cropped_faces = [] self.cropped_faces_shape = [] self.cropped_index = [] self.callback_idx = [] + if verbose: + print("Identify {} images".format(len(image_paths))) for i, p in enumerate(image_paths): cur_img = load_image(p) self.org_faces.append(cur_img) @@ -73,6 +107,9 @@ class Faces(object): cur_shapes = [f.shape[:-1] for f in cur_faces] cur_faces_square = [] + if verbose: + print("Find {} face(s) in {}".format(len(cur_faces), p.split("/")[-1])) + for img in cur_faces: long_size = max([img.shape[1], img.shape[0]]) base = np.zeros((long_size, long_size, 3)) @@ -270,7 +307,7 @@ def imagenet_reverse_preprocessing(x, data_format=None): def reverse_process_cloaked(x, preprocess='imagenet'): - x = clip_img(x, preprocess) + # x = clip_img(x, preprocess) return reverse_preprocess(x, preprocess) @@ -286,17 +323,18 @@ def load_extractor(name): model_dir = os.path.join(os.path.expanduser('~'), '.fawkes') os.makedirs(model_dir, exist_ok=True) model_file = os.path.join(model_dir, "{}.h5".format(name)) + emb_file = os.path.join(model_dir, "{}_emb.p.gz".format(name)) if os.path.exists(model_file): model = keras.models.load_model(model_file) else: get_file("{}.h5".format(name), "http://sandlab.cs.uchicago.edu/fawkes/files/{}.h5".format(name), cache_dir=model_dir, cache_subdir='') + model = keras.models.load_model(model_file) + if not os.path.exists(emb_file): get_file("{}_emb.p.gz".format(name), "http://sandlab.cs.uchicago.edu/fawkes/files/{}_emb.p.gz".format(name), cache_dir=model_dir, cache_subdir='') - model = keras.models.load_model(model_file) - if hasattr(model.layers[-1], "activation") and model.layers[-1].activation == "softmax": raise Exception( "Given extractor's last layer is softmax, need to remove the top layers to make it into a feature extractor") @@ -404,12 +442,18 @@ def select_target_label(imgs, feature_extractors_ls, feature_extractors_names, m max_id = np.argmax(max_sum) target_data_id = paths[int(max_id)] - image_dir = os.path.join(model_dir, "target_data/{}/*".format(target_data_id)) - if not os.path.exists(image_dir): - get_file("{}.h5".format(name), "http://sandlab.cs.uchicago.edu/fawkes/files/target_images".format(name), - cache_dir=model_dir, cache_subdir='') + image_dir = os.path.join(model_dir, "target_data/{}".format(target_data_id)) + # if not os.path.exists(image_dir): + os.makedirs(os.path.join(model_dir, "target_data"), exist_ok=True) + os.makedirs(image_dir, exist_ok=True) + for i in range(10): + if os.path.exists(os.path.join(model_dir, "target_data/{}/{}.jpg".format(target_data_id, i))): + continue + get_file("{}.jpg".format(i), + "http://sandlab.cs.uchicago.edu/fawkes/files/target_data/{}/{}.jpg".format(target_data_id, i), + cache_dir=model_dir, cache_subdir='target_data/{}/'.format(target_data_id)) - image_paths = glob.glob(image_dir) + image_paths = glob.glob(image_dir + "/*.jpg") target_images = [image.img_to_array(image.load_img(cur_path)) for cur_path in image_paths] @@ -424,6 +468,107 @@ def select_target_label(imgs, feature_extractors_ls, feature_extractors_names, m target_images = random.sample(target_images, len(imgs)) return np.array(target_images) + +def get_file(fname, + origin, + untar=False, + md5_hash=None, + file_hash=None, + cache_subdir='datasets', + hash_algorithm='auto', + extract=False, + archive_format='auto', + cache_dir=None): + if cache_dir is None: + cache_dir = os.path.join(os.path.expanduser('~'), '.keras') + if md5_hash is not None and file_hash is None: + file_hash = md5_hash + hash_algorithm = 'md5' + datadir_base = os.path.expanduser(cache_dir) + if not os.access(datadir_base, os.W_OK): + datadir_base = os.path.join('/tmp', '.keras') + datadir = os.path.join(datadir_base, cache_subdir) + _makedirs_exist_ok(datadir) + + if untar: + untar_fpath = os.path.join(datadir, fname) + fpath = untar_fpath + '.tar.gz' + else: + fpath = os.path.join(datadir, fname) + + download = False + if not os.path.exists(fpath): + download = True + + if download: + error_msg = 'URL fetch failure on {}: {} -- {}' + dl_progress = None + try: + try: + urlretrieve(origin, fpath, dl_progress) + except HTTPError as e: + raise Exception(error_msg.format(origin, e.code, e.msg)) + except URLError as e: + raise Exception(error_msg.format(origin, e.errno, e.reason)) + except (Exception, KeyboardInterrupt) as e: + if os.path.exists(fpath): + os.remove(fpath) + raise + # ProgressTracker.progbar = None + + if untar: + if not os.path.exists(untar_fpath): + _extract_archive(fpath, datadir, archive_format='tar') + return untar_fpath + + if extract: + _extract_archive(fpath, datadir, archive_format) + + return fpath + + +def _extract_archive(file_path, path='.', archive_format='auto'): + if archive_format is None: + return False + if archive_format == 'auto': + archive_format = ['tar', 'zip'] + if isinstance(archive_format, six.string_types): + archive_format = [archive_format] + + for archive_type in archive_format: + if archive_type == 'tar': + open_fn = tarfile.open + is_match_fn = tarfile.is_tarfile + if archive_type == 'zip': + open_fn = zipfile.ZipFile + is_match_fn = zipfile.is_zipfile + + if is_match_fn(file_path): + with open_fn(file_path) as archive: + try: + archive.extractall(path) + except (tarfile.TarError, RuntimeError, KeyboardInterrupt): + if os.path.exists(path): + if os.path.isfile(path): + os.remove(path) + else: + shutil.rmtree(path) + raise + return True + return False + + +def _makedirs_exist_ok(datadir): + if six.PY2: + # Python 2 doesn't have the exist_ok arg, so we try-except here. + try: + os.makedirs(datadir) + except OSError as e: + if e.errno != errno.EEXIST: + raise + else: + os.makedirs(datadir, exist_ok=True) # pylint: disable=unexpected-keyword-arg + # class CloakData(object): # def __init__(self, protect_directory=None, img_shape=(224, 224)): # diff --git a/fawkes_dev/azure.py b/fawkes_dev/azure.py index a0f3149..79312f7 100644 --- a/fawkes_dev/azure.py +++ b/fawkes_dev/azure.py @@ -1,14 +1,28 @@ - -import http.client, urllib.request, urllib.parse, urllib.error +import http.client import json +import random import time +import urllib.error +import urllib.parse +import urllib.request + +import requests + +# Face API Key and Endpoint +f = open('api_key.txt', 'r') +data = f.read().split("\n") +subscription_key = data[0] +uri_base = data[1] + +cloak_image_base = 'http://sandlab.cs.uchicago.edu/fawkes/files/cloak/{}_ultra_cloaked.png' +original_image_base = 'http://sandlab.cs.uchicago.edu/fawkes/files/cloak/{}.png' -#Face API Key and Endpoint -subscription_key = 'e127e26e4d534e2bad6fd9ca06145302' -uri_base = 'eastus.api.cognitive.microsoft.com' -# uri_base = 'https://shawn.cognitiveservices.azure.com/' def detect_face(image_url): + r = requests.get(image_url) + if r.status_code != 200: + return None + headers = { # Request headers 'Content-Type': 'application/json', @@ -32,6 +46,7 @@ def detect_face(image_url): conn.request("POST", "/face/v1.0/detect?%s" % params, body, headers) response = conn.getresponse() data = json.loads(response.read()) + print(data) conn.close() return data[0]["faceId"] @@ -102,12 +117,16 @@ def create_personId(personGroupId, personName): conn.request("POST", "/face/v1.0/persongroups/{}/persons?%s".format(personGroupId) % params, body, headers) response = conn.getresponse() data = json.loads(response.read()) - print(data) + # print(data) conn.close() return data["personId"] def add_persistedFaceId(personGroupId, personId, image_url): + r = requests.get(image_url) + if r.status_code != 200: + return None + headers = { 'Content-Type': 'application/json', 'Ocp-Apim-Subscription-Key': subscription_key, @@ -123,11 +142,14 @@ def add_persistedFaceId(personGroupId, personId, image_url): }) conn = http.client.HTTPSConnection(uri_base) - conn.request("POST", "/face/v1.0/persongroups/{}/persons/{}/persistedFaces?%s".format(personGroupId, personId) % params, body, headers) + conn.request("POST", + "/face/v1.0/persongroups/{}/persons/{}/persistedFaces?%s".format(personGroupId, personId) % params, + body, headers) response = conn.getresponse() data = json.loads(response.read()) - print(data) conn.close() + if "persistedFaceId" not in data: + return None return data["persistedFaceId"] @@ -161,7 +183,8 @@ def get_personGroupPerson(personGroupId, personId): body = json.dumps({}) conn = http.client.HTTPSConnection(uri_base) - conn.request("GET", "/face/v1.0/persongroups/{}/persons/{}?%s".format(personGroupId, personId) % params, body, headers) + conn.request("GET", "/face/v1.0/persongroups/{}/persons/{}?%s".format(personGroupId, personId) % params, body, + headers) response = conn.getresponse() data = json.loads(response.read()) print(data) @@ -208,6 +231,7 @@ def eval(original_faceIds, personGroupId, protect_personId): conn.close() face = data[0] + print(face) if len(face["candidates"]) and face["candidates"][0]["personId"] == protect_personId: return True else: @@ -225,48 +249,20 @@ def delete_personGroupPerson(personGroupId, personId): body = json.dumps({}) conn = http.client.HTTPSConnection(uri_base) - conn.request("DELETE", "/face/v1.0/persongroups/{}/persons/{}?%s".format(personGroupId, personId) % params, body, headers) + conn.request("DELETE", "/face/v1.0/persongroups/{}/persons/{}?%s".format(personGroupId, personId) % params, body, + headers) response = conn.getresponse() data = response.read() print(data) conn.close() -def add_protect_person(personGroupId, name): - personId = create_personId(personGroupId, name) - for idx in range(72): - cloaked_image_url = "https://super.cs.uchicago.edu/~shawn/cloaked/{}_c.png".format(idx) - add_persistedFaceId(personGroupId, personId, cloaked_image_url) - - -def add_sybil_person(personGroupId, name): - personId = create_personId(personGroupId, name) - for idx in range(82): - try: - cloaked_image_url = "https://super.cs.uchicago.edu/~shawn/sybils/{}_c.png".format(idx) - add_persistedFaceId(personGroupId, personId, cloaked_image_url) - except: - print(idx) - - -def add_other_person(personGroupId): - for idx_person in range(65): - personId = create_personId(personGroupId, str(idx_person)) - for idx_image in range(90): - try: - image_url = "https://super.cs.uchicago.edu/~shawn/train/{}/{}.png".format(idx_person, idx_image) - add_persistedFaceId(personGroupId, personId, image_url) - except: - print(idx_person, idx_image) - - def get_trainStatus(personGroupId): headers = { 'Ocp-Apim-Subscription-Key': subscription_key, } - params = urllib.parse.urlencode({ - }) + params = urllib.parse.urlencode({}) body = json.dumps({}) @@ -278,48 +274,75 @@ def get_trainStatus(personGroupId): conn.close() -def test_original(): - personGroupId = 'pubfig' - # create_personGroupId(personGroupId, 'pubfig') - # add protect person - protect_personId = 'd3df3012-6f3f-4c1b-b86d-55e91a352e01' - #protect_personId = create_personId(personGroupId, 'Emily') - #for idx in range(50): - # image_url = "https://super.cs.uchicago.edu/~shawn/cloaked/{}_o.png".format(idx) - # add_persistedFaceId(personGroupId, protect_personId, image_url) +def test_cloak(): + NUM_TRAIN = 10 + total_idx = range(0, 82) + TRAIN_RANGE = random.sample(total_idx, NUM_TRAIN) + + TEST_RANGE = TRAIN_RANGE + + personGroupId = 'all' + + # delete_personGroup(personGroupId) + create_personGroupId(personGroupId, personGroupId) + + with open("protect_personId.txt", 'r') as f: + protect_personId = f.read() + print(protect_personId) + delete_personGroupPerson(personGroupId, protect_personId) + + protect_personId = create_personId(personGroupId, 'Emily') + with open("protect_personId.txt", 'w') as f: + f.write(protect_personId) + + print("Created protect personId: {}".format(protect_personId)) + for idx in TRAIN_RANGE: + image_url = cloak_image_base.format(idx) + r = add_persistedFaceId(personGroupId, protect_personId, image_url) + if r is not None: + print("Added {}".format(idx)) + else: + print("Unable to add {}-th image of protect person".format(idx)) # add other people - #for idx_person in range(65): - # personId = create_personId(personGroupId, str(idx_person)) - # for idx_image in range(50): - # try: - # image_url = "https://super.cs.uchicago.edu/~shawn/train/{}/{}.png".format(idx_person, idx_image) - # add_persistedFaceId(personGroupId, personId, image_url) - # except: - # print(idx_person, idx_image) - + for idx_person in range(500): + personId = create_personId(personGroupId, str(idx_person)) + print("Created personId: {}".format(idx_person)) + for idx_image in range(10): + image_url = "http://sandlab.cs.uchicago.edu/fawkes/files/target_data/{}/{}.jpg".format( + idx_person, idx_image) + r = add_persistedFaceId(personGroupId, personId, image_url) + if r is not None: + print("Added {}".format(idx_image)) + else: + print("Unable to add {}-th image".format(idx_image)) # train model based on personGroup - #train_personGroup(personGroupId) - #time.sleep(3) - #get_trainStatus(personGroupId) - #list_personGroupPerson(personGroupId) + train_personGroup(personGroupId) + time.sleep(4) + get_trainStatus(personGroupId) + # list_personGroupPerson(personGroupId) - idx_range = range(50, 82) + # test original image + idx_range = TEST_RANGE acc = 0. - + tot = 0. for idx in idx_range: - original_image_url = "https://super.cs.uchicago.edu/~shawn/cloaked/{}_o.png".format(idx) + original_image_url = original_image_base.format(idx) faceId = detect_face(original_image_url) + if faceId is None: + print("{} does not exist".format(idx)) + continue original_faceIds = [faceId] # verify res = eval(original_faceIds, personGroupId, protect_personId) if res: acc += 1. + tot += 1. - acc /= len(idx_range) - print(acc) # 1.0 + acc /= tot + print(acc) # 1.0 def list_personGroups(): @@ -358,42 +381,37 @@ def delete_personGroup(personGroupId): conn.close() - def main(): + test_cloak() + # delete_personGroup('cloaking') # delete_personGroup('cloaking-emily') # delete_personGroup('pubfig') # list_personGroups() # exit() - personGroupId = 'cloaking' + # personGroupId = 'cloaking' # create_personGroupId(personGroupId, 'cloaking') - list_personGroups() - exit() - #delete_personGroupPerson(personGroupId, '0ac606cd-24b3-440f-866a-31adf2a1b446') - #add_protect_person(personGroupId, 'Emily') - #personId = create_personId(personGroupId, 'Emily') - #add_sybil_person(personGroupId, 'sybil') - protect_personId = '6c5a71eb-f39a-4570-b3f5-72cca3ab5a6b' - #delete_personGroupPerson(personGroupId, protect_personId) - #add_protect_person(personGroupId, 'Emily') - - # train model based on personGroup - #train_personGroup(personGroupId) - get_trainStatus(personGroupId) - #add_other_person(personGroupId) - #list_personGroupPerson(personGroupId) - #delete_personGroupPerson(personGroupId, '80e32c80-bc69-416a-9dff-c8d42d7a3301') - - idx_range = range(72, 82) - original_faceIds = [] - for idx in idx_range: - original_image_url = "https://super.cs.uchicago.edu/~shawn/cloaked/{}_o.png".format(idx) - faceId = detect_face(original_image_url) - original_faceIds.append(faceId) - - # verify - eval(original_faceIds, personGroupId, protect_personId) + # delete_personGroupPerson(personGroupId, '0ac606cd-24b3-440f-866a-31adf2a1b446') + # add_protect_person(personGroupId, 'Emily') + # protect_personId = create_personId(personGroupId, 'Emily') + # add_sybil_person(personGroupId, 'sybil') + # + # # train model based on personGroup + # train_personGroup(personGroupId) + # get_trainStatus(personGroupId) + # add_other_person(personGroupId) + # list_personGroupPerson(personGroupId) + # + # idx_range = range(72, 82) + # original_faceIds = [] + # for idx in idx_range: + # original_image_url = "https://super.cs.uchicago.edu/~shawn/cloaked/{}_o.png".format(idx) + # faceId = detect_face(original_image_url) + # original_faceIds.append(faceId) + # + # # verify + # eval(original_faceIds, personGroupId, protect_personId) if __name__ == '__main__': - main() \ No newline at end of file + test_cloak()