diff --git a/download_collection.py b/download_collection.py
new file mode 100644
index 0000000..e66e948
--- /dev/null
+++ b/download_collection.py
@@ -0,0 +1,722 @@
+# Script to download Scanned Objects by Google Research dataset and Stanford models
+# Distributed by CC-BY 4.0 License
+
+# Dataset page:
+# https://app.gazebosim.org/GoogleResearch/fuel/collections/Scanned%20Objects%20by%20Google%20Research
+
+import sys, argparse, subprocess
+from pathlib import Path
+import requests
+import zipfile, tarfile, gzip
+import csv, json
+import math
+import numpy as np
+
+try:
+    import cv2 as cv
+except ImportError:
+    raise ImportError("Failed to import OpenCV, please check that cv2 library is in PYTHONPATH env variable, "
+                      "for example use the path {opencv_build_dir}/lib/python3/")
+
+if sys.version_info[0] < 3 or sys.version_info[1] < 5:
+    raise Exception("Python 3.5 or greater is required. Try running `python3 download_collection.py`")
+
+# global params
+verbose = False
+
+class ModelData:
+    def __init__(self, name : str, description : str, filesize : int, thumb_url : str, categories ) -> None:
+        self.name = name
+        self.description = description
+        self.filesize = filesize
+        self.thumb_url = thumb_url
+        self.categories = set(categories)
+
+def print_size(num):
+    if num < 1024:
+        return str(num) + " B"
+    elif num < 1 << 20:
+        return "%.3f KiB" % (num / 1024)
+    elif num < 1 << 30:
+        return "%.3f MiB" % (num / (1 << 20))
+    else:
+        return "%.3f GiB" % (num / (1 << 30))
+
+collection_name = "Scanned Objects by Google Research"
+owner_name = "GoogleResearch"
+
+base_url ='https://fuel.gazebosim.org/'
+fuel_version = '1.0'
+
+def download_model(model_name, dir):
+    if verbose:
+        print()
+        print("{}: {}".format(model.name, model.description))
+        print("Categories: [", ", ".join(model.categories), "]")
+        print("Size:", print_size(model.filesize))
+
+    download_url = base_url + fuel_version + '/{}/models/'.format(owner_name) + model_name + '.zip'
+
+    archive_path = Path(dir) / Path(model_name+'.zip')
+    tmp_archive_path   = Path(dir) / Path(model_name+'.zip.tmp')
+    mesh_path     = Path(dir) / Path(model_name+'.obj')
+    tmp_mesh_path = Path(dir) / Path(model_name+'.obj.tmp')
+    mtl_path     = Path(dir) / Path('model.mtl')
+    tmp_mtl_path = Path(dir) / Path('model.mtl.tmp')
+    texture_path     = Path(dir) / Path('texture.png')
+    tmp_texture_path = Path(dir) / Path('texture.png.tmp')
+
+    for tmp in [tmp_archive_path, tmp_mesh_path, tmp_mtl_path, tmp_texture_path]:
+        tmp.unlink(missing_ok=True)
+
+    if archive_path.exists():
+        if verbose:
+            print("Archive exists")
+    else:
+        print("URL:", download_url)
+        attempt = 1
+        while True:
+            print("download attempt "+str(attempt)+"...", end="")
+            try:
+                download = requests.get(download_url, stream=True, timeout=5.0)
+                break
+            except requests.exceptions.Timeout:
+                print("Timed out")
+                attempt = attempt + 1
+        with open(tmp_archive_path, 'wb') as fd:
+            for chunk in download.iter_content(chunk_size=1024*1024):
+                fd.write(chunk)
+                print(".", end="")
+        tmp_archive_path.rename(archive_path)
+        print("..downloaded")
+
+    with zipfile.ZipFile(archive_path) as z:
+        if mesh_path.exists():
+            if verbose:
+                print("OBJ exists")
+        else:
+            with open(tmp_mesh_path, 'wb') as f:
+                f.write(z.read("meshes/model.obj"))
+            tmp_mesh_path.rename(mesh_path)
+            print("OBJ unpacked")
+        if texture_path.exists():
+            if verbose:
+                print("Texture exists")
+        else:
+            with open(tmp_texture_path, 'wb') as f:
+                f.write(z.read("materials/textures/texture.png"))
+            tmp_texture_path.rename(texture_path)
+            print("Texture unpacked")
+
+    if mtl_path.exists():
+        if verbose:
+            print("Material exists")
+    else:
+        mtlFile = """
+# Copyright 2020 Google LLC.
+# 
+# This work is licensed under the Creative Commons Attribution 4.0
+# International License. To view a copy of this license, visit
+# http://creativecommons.org/licenses/by/4.0/ or send a letter
+# to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
+newmtl material_0
+# shader_type beckmann
+map_Kd texture.png
+
+# Kd: Diffuse reflectivity.
+Kd 1.000000 1.000000 1.000000
+"""
+        with open(tmp_mtl_path, 'xt') as f:
+            f.writelines(mtlFile)
+        tmp_mtl_path.rename(mtl_path)
+        print("Material written")
+    return mesh_path, texture_path
+
+
+def get_thumb(model : ModelData, dir):
+    if verbose:
+        print(model.name)
+    img_url = base_url + fuel_version + model.thumb_url
+    img_path = Path(dir) / Path(model.name+'.jpg')
+    tmp_path = Path(dir) / Path(model.name+'.jpg.tmp')
+    tmp_path.unlink(missing_ok=True)
+    if img_path.exists():
+        if verbose:
+            print("...exists")
+    else:
+        print("URL:", img_url)
+        attempt = 1
+        while True:
+            print("download attempt "+str(attempt)+"...")
+            try:
+                download = requests.get(img_url, stream=True, timeout=5.0)
+                break
+            except requests.exceptions.Timeout:
+                print("Timed out")
+                attempt = attempt + 1
+        with open(tmp_path, 'wb') as fd:
+            for chunk in download.iter_content(chunk_size=1024*1024):
+                fd.write(chunk)
+                print(".", end="")
+        tmp_path.rename(img_path)
+        print("..downloaded")
+
+
+def get_content(content_file):
+    # Getting model names and URLs
+    models_json = []
+
+    # Iterate over the pages
+    page = 0
+    while True:
+        page = page + 1
+        next_url = '/models?page={}&per_page=100&q=collections:{}'.format(page,collection_name)
+        page_url = base_url + fuel_version + next_url
+
+        print("Gettting page %d..." % page)
+        r = requests.get(page_url)
+
+        if not r or not r.text:
+            break
+
+        # Convert to JSON
+        models_page = json.loads(r.text)
+
+        if not models_page:
+            break
+
+        models_json.extend(models_page)
+
+        print(len(models_json), " models")
+
+    json_object = json.dumps(models_json, indent=4)
+    with open(content_file, "w") as outfile:
+        outfile.write(json_object)
+
+    return models_json
+
+
+# let's use different chunk sizes to get rid of timeouts
+stanford_models = [
+["http://graphics.stanford.edu/pub/3Dscanrep/bunny.tar.gz", 1, "bunny/reconstruction/bun_zipper.ply"],
+["http://graphics.stanford.edu/pub/3Dscanrep/happy/happy_recon.tar.gz", 1024, "happy_recon/happy_vrip.ply"],
+["http://graphics.stanford.edu/pub/3Dscanrep/dragon/dragon_recon.tar.gz", 1024, "dragon_recon/dragon_vrip.ply"],
+["http://graphics.stanford.edu/pub/3Dscanrep/armadillo/Armadillo.ply.gz", 64, ""],
+["http://graphics.stanford.edu/data/3Dscanrep/lucy.tar.gz", 1024, "lucy.ply"],
+["http://graphics.stanford.edu/data/3Dscanrep/xyzrgb/xyzrgb_dragon.ply.gz", 1024, ""],
+["http://graphics.stanford.edu/data/3Dscanrep/xyzrgb/xyzrgb_manuscript.ply.gz", 1024, ""],
+["http://graphics.stanford.edu/data/3Dscanrep/xyzrgb/xyzrgb_statuette.ply.gz", 1024, ""],
+]
+
+def get_stanford_model(url : str, name : str, ext: str, dir : str, chunk_size : int, internal_path : str):
+    archive_path     = Path(dir) / Path(name+'.'+ext)
+    tmp_archive_path = Path(dir) / Path(name+'.'+ext+'.tmp')
+    model_path = Path(dir) / Path(name+'.ply')
+    tmp_model_path = Path(dir) / Path(name+'.ply.tmp')
+
+    for tmp in [tmp_archive_path, tmp_model_path]:
+        tmp.unlink(missing_ok=True)
+
+    if archive_path.exists():
+        if verbose:
+            print("Archive exists")
+    else:
+        print("URL:", url)
+        attempt = 1
+        while True:
+            print("download attempt "+str(attempt)+"...", end="")
+            try:
+                download = requests.get(url, stream=True, timeout=5.0)
+                break
+            except requests.exceptions.Timeout:
+                print("Timed out")
+                attempt = attempt + 1
+        with open(tmp_archive_path, 'wb') as fd:
+            for chunk in download.iter_content(chunk_size=chunk_size*1024):
+                fd.write(chunk)
+                print(".", end="")
+        tmp_archive_path.rename(archive_path)
+        print("..downloaded")
+
+    if model_path.exists():
+        if verbose:
+            print("Model exists")
+    else:
+        # to reduce memory footprint for big models
+        max_size = 1024*1024*16
+        print("Extracting..", end="")
+        with open(tmp_model_path, 'xb') as of:
+            if ext=="tar.gz":
+                tar_obj = tarfile.open(archive_path, 'r', encoding='utf-8', errors='surrogateescape')
+                try:
+                    reader = tar_obj.extractfile(internal_path)
+                    while buf := reader.read(max_size):
+                        of.write(buf)
+                        print(".", end="")
+                except Exception as err:
+                    print(err)
+                tar_obj.close()
+            elif ext=="ply.gz":
+                with gzip.open(archive_path) as gz:
+                    while buf := gz.read(max_size):
+                        of.write(buf)
+                        print(".", end="")
+        tmp_model_path.rename(model_path)
+        print("done")
+    return model_path, ""
+
+
+def lookAtMatrixCal(position, lookat, upVector):
+    tmp = position - lookat
+    norm = np.linalg.norm(tmp)
+    w = tmp / norm
+    tmp = np.cross(upVector, w)
+    norm = np.linalg.norm(tmp)
+    u = tmp / norm
+    v = np.cross(w, u)
+    res = np.array([
+        [u[0], u[1], u[2],   0],
+        [v[0], v[1], v[2],   0],
+        [w[0], w[1], w[2],   0],
+        [0,    0,    0,   1.0]
+    ], dtype=np.float32)
+    translate = np.array([
+        [1.0,   0,   0, -position[0]],
+        [0, 1.0,   0, -position[1]],
+        [0,   0, 1.0, -position[2]],
+        [0,   0,   0,          1.0]
+    ], dtype=np.float32)
+    res = np.matmul(res, translate)
+    return res
+
+
+# walk over all triangles, collect per-vertex normals
+def generateNormals(points, indices):
+    success = False
+    maxTri = 8
+    while not success and maxTri < 64:
+        preNormals = np.zeros((points.shape[0], maxTri, 3), dtype=np.float32)
+        nNormals = np.zeros((points.shape[0],), dtype=np.int)
+
+        pArr = [ np.take(points, indices[:, j], axis=0) for j in range(3) ]
+
+        crossArr = np.cross(pArr[1] - pArr[0], pArr[2] - pArr[0])
+        # normalizing, fixing div by zero
+        crossNorm = np.linalg.norm(crossArr, axis=1)
+        crossNorm[abs(crossNorm) < 1e-6 ] = 1
+        crossNorm3 = np.vstack([crossNorm, crossNorm, crossNorm]).T
+        crossArr = crossArr * (1.0 / crossNorm3)
+
+        needMoreTri = False
+        for i in range(crossArr.shape[0]):
+            tri = indices[i, :]
+            cross = crossArr[i, :]
+
+            for j in range(3):
+                idx = tri[j]
+                n = nNormals[idx]
+                if n == maxTri:
+                    print(maxTri, " triangles per vertex is not enough, adding 8 more")
+                    needMoreTri = True
+                    break
+                nNormals[idx] = n+1
+                preNormals[idx, n, :] = cross
+            if needMoreTri:
+                break
+
+        if needMoreTri:
+            maxTri += 8
+        else:
+            normals = np.sum(preNormals, axis=1)
+            # normalizing, fixing div by zero
+            norm = np.linalg.norm(normals, axis=1)
+            norm[abs(norm) < 1e-6 ] = 1
+            norm3 = np.vstack([norm, norm, norm]).T
+            normals = normals * (1.0 / norm3)
+            success = True
+
+    if success:
+        print ("normals generated")
+        return normals
+    else:
+        raise RuntimeError("too much triangle per vertex")
+
+
+def writeCsv(path, stat_data):
+    with open(path, 'w', newline='') as csvfile:
+        fieldnames = [ "model", "normL2Rgb", "normInfRgb", "nzDepthDiff",
+                    "normL2Depth", "normInfDepth"]
+        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+        writer.writeheader()
+        for name, sd in stat_data.items():
+            # dict merge operator | is not supported until 3.9
+            sdname = sd
+            sdname["model"] = name
+            writer.writerow(sdname)
+
+# ==================================================
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(prog="bench3d", description="3D algorithm benchmark for OpenCV",
+                                     formatter_class=argparse.RawTextHelpFormatter,
+                                     epilog="The tool works with the latest OpenCV 5.x, "
+                                     "a proper path to OpenCV library should be provided in PYTHONPATH env. variable, "
+                                     "usually it is {opencv_build_dir}/lib/python3/\n"
+                                     "The script first downloads dataset contents to {work_dir}/contents.json "
+                                     "then downloads and unpacks the models and their thumbnails. "
+                                     "If this process is interrupted, you can start the script again and "
+                                     "it will continue from the same point.\n"
+                                     "Next, the benchmark for triangleRasterize() starts: each model is "
+                                     "rendered by both OpenCV and OpenGL reference tool, results are compared.\n"
+                                     "The statistics is saved to {work_dir}/stat.json and {work_dir}/stat.csv "
+                                     "after each model processed.\n"
+                                     "NOTE: statistics is erased at each script start\n"
+                                     "NOTE #2: stanford_* models are quite big, may require several minutes "
+                                     "and up to 12 Gb RAM per each")
+    parser.add_argument("--work_dir", required=True, help="a directory to store downloaded dataset and generated files")
+    parser.add_argument("--verbose", action="store_true",
+                        help="indicates whether to display more information to the console or not")
+    parser.add_argument("--renderer_path", required=True,
+                        help="path to OpenGL tool to render models, usually it is "
+                        "{opencv_build_dir}/bin/example_opengl_opengl_testdata_generator")
+    parser.add_argument("--debug_pics_dir",
+                        help="optional, a directory to store temporary images "
+                        "showing in realtime what is being rendered right now")
+
+    args = parser.parse_args()
+
+    if args.verbose:
+        verbose = True
+
+    dirname = args.work_dir
+
+    debug_pics_dir = ""
+    if args.debug_pics_dir:
+        debug_pics_dir = args.debug_pics_dir
+
+    renderer_path = args.renderer_path
+
+    all_models = []
+
+    print("Getting Google Research models")
+
+    content_file = Path(dirname) / Path("content.json")
+    if content_file.exists():
+        with open(content_file, "r") as openfile:
+            models_json = json.load(openfile)
+    else:
+        Path(dirname).mkdir(parents=True, exist_ok=True)
+        models_json = get_content(content_file)
+
+    models = []
+    for model in models_json:
+        model_name = model['name']
+        desc  = model['description']
+        fsize = model['filesize']
+        thumb_url  = model['thumbnail_url']
+        if 'categories' in model:
+            categories = model['categories']
+        else:
+            categories = [ ]
+        models.append(ModelData(model_name, desc, fsize, thumb_url, categories))
+
+    print("Getting thumbnail images")
+    for model in models:
+        get_thumb(model, dirname)
+
+    print("Downloading models from the {}/{} collection.".format(owner_name, collection_name))
+
+    for model in models:
+        model_dir = Path(dirname) / Path(model.name)
+        Path(model_dir).mkdir(parents=True, exist_ok=True)
+        model_path, texture_path = download_model(model.name, model_dir)
+        all_models.append((model.name, model_path, texture_path))
+
+    print('Done.')
+
+    categories = set()
+    for model in models:
+        for cat in model.categories:
+            categories.add(cat)
+    print("Categories:", categories)
+    # 'Consumer Goods', 'Bag', 'Car Seat', 'Keyboard', 'Media Cases', 'Toys',
+    # 'Action Figures', 'Bottles and Cans and Cups', 'Shoe', 'Legos', 'Hat',
+    # 'Mouse', 'Headphones', 'Stuffed Toys', 'Board Games', 'Camera'
+
+    print("\nGetting Stanford models")
+
+    for m in stanford_models:
+        url, chunk_size, internal_path = m
+
+        s = url.split("/")[-1].split(".")
+        name = "stanford_"+s[0]
+        ext = s[1]+"."+s[2]
+
+        if verbose:
+            print(name + ":")
+        model_dir = Path(dirname) / Path(name)
+        Path(model_dir).mkdir(parents=True, exist_ok=True)
+        model_path, texture_path = get_stanford_model(url, name, ext, model_dir, chunk_size, internal_path)
+        all_models.append((name, model_path, texture_path))
+
+    print("\ntriangleRasterize() test")
+
+    width, height = 640, 480
+    fovDegrees = 45.0
+    fovY = fovDegrees * math.pi / 180.0
+
+    stat_data = {}
+
+    stat_file = Path(dirname) / Path("stat.json")
+    csv_file  = Path(dirname) / Path("stat.csv")
+
+    for file in [stat_file, csv_file]:
+        file.unlink(missing_ok=True)
+
+    if stat_file.exists():
+        with open(stat_file, "r") as openfile:
+            stat_data = json.load(openfile)
+        writeCsv(csv_file, stat_data)
+
+    for model_name, model_fname, texture_fname in all_models:
+        print(model_name)
+
+        colvert_path = Path(model_fname).parent / Path("colvert.ply")
+        
+        remap_path = Path(debug_pics_dir) / Path("remap.png")
+        debug_color_path = Path(debug_pics_dir) / Path("color_raster.png")
+        debug_depth_path = Path(debug_pics_dir) / Path("depth_raster.png")
+
+        color_raster_path = Path(model_fname).parent / Path("color_raster.png")
+        depth_raster_path = Path(model_fname).parent / Path("depth_raster.png")
+
+        rgb_gl_path   = Path(model_fname).parent / Path("color_gl.png")
+        depth_gl_path = Path(model_fname).parent / Path("depth_gl.png")
+
+        color_diff_path = Path(model_fname).parent / Path("color_diff.png")
+
+        depth_diff_path  = Path(model_fname).parent / Path("depth_diff.png")
+        mask_gl_path     = Path(model_fname).parent / Path("mask_gl.png")
+        mask_raster_path = Path(model_fname).parent / Path("mask_raster.png")
+
+        for file in [ colvert_path, remap_path,
+                      color_raster_path, depth_raster_path,
+                      rgb_gl_path, depth_gl_path,
+                      color_diff_path, depth_diff_path, mask_gl_path, mask_raster_path ]:
+            file.unlink(missing_ok=True)
+
+        if verbose:
+            print("temp files removed")
+
+        verts, list_indices, normals, colors, texCoords = cv.loadMesh(model_fname)
+        if verbose:
+            print("loading finished")
+
+        if texture_fname:
+            texture = cv.imread(texture_fname) / 255.0
+
+        verts = verts.squeeze()
+
+        # list_indices is a tuple of 1x3 arrays of dtype=int32
+        if not list_indices:
+            raise ValueError("empty index list")
+        for ind in list_indices:
+            if ind.shape != (1, 3):
+                raise ValueError("wrong index shape")
+
+        indices = np.array(list_indices, dtype=np.int32).squeeze()
+
+        if len(indices.shape) != 2 or (indices.shape[1] != 3):
+            raise ValueError("wrong index shape")
+
+        if normals is not None:
+            normals = normals.squeeze()
+
+        if colors is not None:
+            colors = colors.squeeze()
+
+        minx, miny, minz = np.min(verts[:, 0]), np.min(verts[:, 1]), np.min(verts[:, 2])
+        maxx, maxy, maxz = np.max(verts[:, 0]), np.max(verts[:, 1]), np.max(verts[:, 2])
+
+        if verbose:
+            print("verts: ", verts.shape)
+            print("indices: ", indices.shape)
+            print("normals: ", (normals.shape if normals is not None else 0))
+            print("colors: ", (colors.shape if colors is not None else 0))
+            print("texCoords: ", (texCoords.shape if texCoords is not None else 0))
+            print("bounding box: [%f...%f, %f...%f, %f...%f]" % (minx, maxx, miny, maxy, minz, maxz))
+
+        nverts = verts.shape[0]
+
+        # this could be used for slow and dirty texturing
+        doRemap = False
+        remapCoords = np.ones((nverts, 3), dtype=np.float32)
+
+        if colors is None:
+            if texCoords is not None:
+                # sample vertex color from texture
+                if doRemap:
+                    colors = np.ones((nverts, 3), dtype=np.float32)
+                    for i in range(nverts):
+                        remapCoords[i, :] = [texCoords[i, 0], 1.0-texCoords[i, 1], 0]
+
+                uArr = (       texCoords[:, 0]  * texture.shape[1] - 0.5).astype(np.int)
+                vArr = ((1.0 - texCoords[:, 1]) * texture.shape[0] - 0.5).astype(np.int)
+                colors = (texture[vArr, uArr, :]).astype(np.float32)
+
+            else:
+                if nverts < 1_000_000:
+                    # generate normals and set colors to normals
+                    normals = generateNormals(verts, indices)
+                    colors = abs(normals)
+                else:
+                    t = np.arange(0, nverts, dtype=np.float32)
+                    colors = 0.5 + 0.5 * np.vstack([np.cos(t * (2 * math.pi / nverts)),
+                                                    np.sin(t * (3 * math.pi / nverts)),
+                                                    np.sin(t * (2 * math.pi / nverts))]).astype(np.float32).T
+                    if verbose:
+                        print("colors generated")
+
+        # save mesh for OpenGL renderer
+
+        vertsToSave = np.expand_dims(verts, axis=0)
+        colorsToSave = np.expand_dims(colors, axis=0)
+        colorsToSave = colorsToSave[:, :, ::-1]
+        indicesToSave = []
+        for i in range(indices.shape[0]):
+            ix = indices[i, :]
+            indicesToSave.append(ix)
+
+        cv.saveMesh(colvert_path, vertsToSave, indicesToSave, None, colorsToSave)
+
+        if verbose:
+            print("mesh saved for OpenGL renderer")
+
+        # rasterize
+
+        ctgY = 1./math.tan(fovY / 2.0)
+        ctgX = ctgY / width * height
+        zat = maxz + max([abs(maxy) * ctgY,
+                          abs(miny) * ctgY,
+                          abs(maxx) * ctgX,
+                          abs(minx) * ctgX])
+
+        zNear = zat - maxz
+        zFar = zat - minz
+        position = np.array([0.0, 0.0, zat], dtype=np.float32)
+        lookat   = np.array([0.0, 0.0, 0.0], dtype=np.float32)
+        upVector = np.array([0.0, 1.0, 0.0], dtype=np.float32)
+        cameraPose = lookAtMatrixCal(position, lookat, upVector)
+        scaleCoeff = 65535.0 / zFar
+
+        depth_buf = np.ones((height, width), dtype=np.float32) * zFar
+        color_buf = np.zeros((height, width, 3), dtype=np.float32)
+
+        settings = cv.TriangleRasterizeSettings().setShadingType(cv.RASTERIZE_SHADING_SHADED)
+        settings = settings.setCullingMode(cv.RASTERIZE_CULLING_NONE)
+
+        color_buf, depth_buf = cv.triangleRasterize(verts, indices, colors, color_buf, depth_buf,
+                                                    cameraPose, fovY, zNear, zFar, settings)
+
+        if verbose:
+            print("rasterized")
+
+        if doRemap:
+            remap_color_buf, _ = cv.triangleRasterizeColor(verts, indices, remapCoords, color_buf, depth_buf,
+                                                           cameraPose, fovY, zNear, zFar, settings)
+            mapx = remap_color_buf[:, :, 0] * texture.shape[1] - 0.5
+            mapy = remap_color_buf[:, :, 1] * texture.shape[0] - 0.5
+            remapped = cv.remap(texture, mapx, mapy, cv.INTER_LINEAR)
+            cv.imwrite(remap_path, remapped * 255.0)
+
+        colorRasterize = color_buf
+        depthRasterize = (depth_buf * scaleCoeff)
+
+        if debug_pics_dir:
+            cv.imwrite(debug_color_path, color_buf * 255.0)
+            cv.imwrite(debug_depth_path, depthRasterize.astype(np.ushort))
+
+        cv.imwrite(color_raster_path, color_buf * 255.0)
+        cv.imwrite(depth_raster_path, depthRasterize.astype(np.ushort))
+
+        # send mesh to OpenGL rasterizer
+
+        renderer_args = [renderer_path] + [
+                "--modelPath="+str(colvert_path),
+                "--custom",
+                "--fov="+str(fovDegrees),
+                "--posx="+str(position[0]),
+                "--posy="+str(position[1]),
+                "--posz="+str(position[2]),
+                "--lookatx="+str(lookat[0]),
+                "--lookaty="+str(lookat[1]),
+                "--lookatz="+str(lookat[2]),
+                "--upx="+str(upVector[0]),
+                "--upy="+str(upVector[1]),
+                "--upz="+str(upVector[2]),
+                "--resx="+str(width),
+                "--resy="+str(height),
+                "--zNear="+str(zNear),
+                "--zFar="+str(zFar),
+                "--scaleCoeff="+str(scaleCoeff),
+                # white/flat/shaded
+                "--shading=shaded",
+                # none/cw/ccw
+                "--culling=cw",
+                "--colorPath="+str(rgb_gl_path),
+                "--depthPath="+str(depth_gl_path),
+            ]
+
+        if verbose:
+            print(renderer_args)
+        p = subprocess.run(renderer_args)
+
+        # compare results
+
+        colorGl = cv.imread(rgb_gl_path)
+        colorGl = colorGl.astype(np.float32) * (1.0/255.0)
+        colorDiff = np.ravel(colorGl - colorRasterize)
+        normInfRgb = np.linalg.norm(colorDiff, ord=np.inf)
+        normL2Rgb = np.linalg.norm(colorDiff, ord=2) / (width * height)
+        print("rgb L2: %f Inf: %f" % (normL2Rgb, normInfRgb))
+
+        cv.imwrite(color_diff_path, (colorDiff.reshape((height, width, 3)) + 1) * 0.5 * 255.0)
+
+        depthGl = cv.imread(depth_gl_path, cv.IMREAD_GRAYSCALE | cv.IMREAD_ANYDEPTH).astype(np.float32)
+        depthGl /= scaleCoeff
+        depthRasterize /= scaleCoeff
+        depthDiff = depthGl - depthRasterize
+
+        maskGl = abs(depthGl - zFar) > 1e-6
+        maskRaster = abs(depthRasterize - zFar) > 1e-6
+        maskDiff = maskGl != maskRaster
+        nzDepthDiff = np.count_nonzero(maskDiff)
+        print("depth nzdiff: %d" % (nzDepthDiff))
+
+        jointMask = maskRaster & maskGl
+        nzJointMask = np.count_nonzero(jointMask)
+        if nzJointMask:
+            maskedDiff = np.ravel(depthDiff[jointMask])
+            normInfDepth = np.linalg.norm(maskedDiff, ord=np.inf)
+            normL2Depth = np.linalg.norm(maskedDiff, ord=2) / nzJointMask
+        else:
+            normInfDepth = math.nan
+            normL2Depth = math.nan
+        print("depth L2: %f Inf: %f" % (normL2Depth, normInfDepth))
+
+        cv.imwrite(depth_diff_path, ((depthDiff) + (1 << 15)).astype(np.ushort))
+        cv.imwrite(mask_gl_path, maskGl.astype(np.ubyte) * 255)
+        cv.imwrite(mask_raster_path, maskRaster.astype(np.ubyte) * 255)
+
+        stat_data[str(model_name)] = {
+            "normL2Rgb"    : str(normL2Rgb),
+            "normInfRgb"   : str(normInfRgb),
+            "nzDepthDiff"  : str(nzDepthDiff),
+            "normL2Depth"  : str(normL2Depth),
+            "normInfDepth" : str(normInfDepth)
+        }
+
+        stat_json = json.dumps(stat_data, indent=4)
+        with open(stat_file, "w") as outfile:
+            outfile.write(stat_json)
+        writeCsv(csv_file, stat_data)
\ No newline at end of file