Repository: ryanhausen/fitsmap (language: python)

Issue:
max_catalog_zoom not being set correctly as the maxNativeZoom in the JS

adjust_pixels makes an additional copy of the array that is not required
https://github.com/ryanhausen/fitsmap/blob/0e5c863e81245881087ec0ee4b508582745e340e/fitsmap/convert.py#L320
adjust_pixels makes an additional copy of the array that is not required
https://github.com/ryanhausen/fitsmap/blob/0e5c863e81245881087ec0ee4b508582745e340e/fitsmap/convert.py#L320
max_catalog_zoom not being set correctly as the maxNativeZoom in the JS

Creating a FitsMap when the catalog data exists throws an error.
when I run create_fitsmap.py and there are existing catalogs in map/ the code crashes and complains that the .column file is missing.

If I try to create a fitsmap with catalogs using multiple cores, it always fails



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 9ba954f..c8e2dff 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -3,4 +3,5 @@ omit =
     fitsmap/tests/*
     fitsmap/__*__.py
     fitsmap/kdbush.py
-    fitsmap/supercluster.py
\ No newline at end of file
+    fitsmap/supercluster.py
+show_missing=True
\ No newline at end of file
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
new file mode 100644
index 0000000..fea500b
--- /dev/null
+++ b/.github/workflows/black.yml
@@ -0,0 +1,14 @@
+name: Lint
+
+on:
+  push:
+    branches: [ "push-jwst-hotfixes" ]
+  pull_request:
+    branches: [ "master" ]
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: psf/black@stable
\ No newline at end of file
diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml
new file mode 100644
index 0000000..ce45fdb
--- /dev/null
+++ b/.github/workflows/build-linux.yml
@@ -0,0 +1,43 @@
+# Runs the testing matrix and code coverage
+
+name: Linux
+
+on:
+  push:
+    branches: [ "push-jwst-hotfixes" ]
+  pull_request:
+    branches: [ "master" ]
+
+  # allows running manually
+  workflow_dispatch:
+jobs:
+
+  build:
+    strategy:
+      matrix:
+        os: [ ubuntu-latest ] #, windows-latest, macos-latest]
+        python-version: ["3.7"] # TODO: decide how many versions to support
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      # check out the repo
+      - uses: actions/checkout@v3
+
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r requirements.txt
+          python -m pip install pytest pytest-cov codecov
+          python -m pip install .
+
+      - name: Run tests
+        run: pytest --cov=fitsmap -vv
+
+      - name: Run coverage
+        run: codecov
\ No newline at end of file
diff --git a/.github/workflows/build-osx.yml b/.github/workflows/build-osx.yml
new file mode 100644
index 0000000..a9524e9
--- /dev/null
+++ b/.github/workflows/build-osx.yml
@@ -0,0 +1,40 @@
+# Runs the testing matrix and code coverage
+
+name: OSX
+
+on:
+  push:
+    branches: [ "push-jwst-hotfixes" ]
+  pull_request:
+    branches: [ "master" ]
+
+  # allows running manually
+  workflow_dispatch:
+jobs:
+
+  build:
+    strategy:
+      matrix:
+        os: [ macos-latest ] #, windows-latest, macos-latest]
+        python-version: ["3.7"] # TODO: decide how many versions to support
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      # check out the repo
+      - uses: actions/checkout@v3
+
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r requirements.txt
+          python -m pip install pytest pytest-cov codecov
+          python -m pip install .
+
+      - name: Run tests
+        run: pytest --cov=fitsmap -vv
\ No newline at end of file
diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
new file mode 100644
index 0000000..611fdc9
--- /dev/null
+++ b/.github/workflows/build-windows.yml
@@ -0,0 +1,40 @@
+# Runs the testing matrix and code coverage
+
+name: Windows
+
+on:
+  push:
+    branches: [ "push-jwst-hotfixes" ]
+  pull_request:
+    branches: [ "master" ]
+
+  # allows running manually
+  workflow_dispatch:
+jobs:
+
+  build:
+    strategy:
+      matrix:
+        os: [ windows-latest ] #, windows-latest, macos-latest]
+        python-version: ["3.7"] # TODO: decide how many versions to support
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      # check out the repo
+      - uses: actions/checkout@v3
+
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r requirements.txt
+          python -m pip install pytest pytest-cov codecov
+          python -m pip install .
+
+      - name: Run tests
+        run: pytest --cov=fitsmap -vv
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..ea0d6cf
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,13 @@
+name: Deploy to PyPI
+
+on:
+  push:
+    tags:
+      - "*"
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: testing
+        run: echo "Will hopefully deploy soon!"
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ca65c49..a23d139 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,8 @@ fitsmap.egg-info/**
 
 fitsmap/tests/__pycache__/
 
-.coverage
+.coverage*
 
-testing_tmp/
\ No newline at end of file
+testing_tmp/
+
+fitsmap/tests/data/test.columns
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 00abc79..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-language: python
-python:
-  - "3.7.9"
-install:
-  - python -m pip install -r requirements.txt
-  - python -m pip install .
-  - python -m pip install pytest
-  - python -m pip install pytest-cov
-  - python -m pip install codecov
-  - python -m pip install twine
-script:
-  - pytest --cov=fitsmap -vv
-after_success:
-  - codecov
\ No newline at end of file
diff --git a/README.rst b/README.rst
index 632eef2..66968e7 100644
--- a/README.rst
+++ b/README.rst
@@ -10,8 +10,14 @@
 FitsMap
 =======
 
-.. image:: https://travis-ci.com/ryanhausen/fitsmap.svg?branch=master
-    :target: https://app.travis-ci.com/ryanhausen/fitsmap
+.. image:: https://github.com/ryanhausen/fitsmap/actions/workflows/build-linux.yml/badge.svg
+    :target: https://github.com/ryanhausen/fitsmap/actions/workflows/build-linux.yml
+
+.. image:: https://github.com/ryanhausen/fitsmap/actions/workflows/build-osx.yml/badge.svg
+    :target: https://github.com/ryanhausen/fitsmap/actions/workflows/build-osx.yml
+
+.. image:: https://github.com/ryanhausen/fitsmap/actions/workflows/build-windows.yml/badge.svg
+    :target: https://github.com/ryanhausen/fitsmap/actions/workflows/build-windows.yml
 
 .. image:: https://readthedocs.org/projects/fitsmap/badge/?version=latest
     :target: https://fitsmap.readthedocs.io
@@ -29,8 +35,10 @@ FitsMap
     :target: https://doi.org/10.1016/j.ascom.2022.100586
 
 
-FitsMap is a tool for displaying astronomical images and their associated
-catalogs, powered by `LeafletJS <https://leafletjs.com>`_.
+FitsMap is a tool developed in the `Computational Astrophysics Research Group
+<https://robertson.sites.ucsc.edu/research/>`_ at UC Santa Cruz for displaying
+astronomical images and their associated catalogs, powered by `LeafletJS
+<https://leafletjs.com>`_.
 
 Survey images can have dimensions in the tens of thousands of pixels in multiple
 bands. Examining images of this size can be difficult, especially in multiple
@@ -67,16 +75,15 @@ Installation
 Requirements:
 
 - ``astropy``
-- ``imageio``
-- ``numpy``
+- ``cbor2``
+- ``click``
+- ``mapbox_vector_tile``
 - ``matplotlib``
+- ``numpy``
 - ``pillow``
 - ``scikit-image``
-- ``sharedmem``
+- ``ray``
 - ``tqdm``
-- ``click``
-- ``mapbox_vector_tile``
-- ``cbor2``
 
 Use ``pip`` to install
 
@@ -143,7 +150,7 @@ in a new subdirectory called ``map`` within ``path/to/data``. The argument
 parse any catalog files and convert them into map markers. The ``norm_kwargs``
 argument should be a dictionary of kwargs that get passed to
 `astropy.visulization.simple_norm
-<https://docs.astropy.org/en/stable/api/astropy.visualization.mpl_normalize.simple_norm.html>`
+<https://docs.astropy.org/en/stable/api/astropy.visualization.mpl_normalize.simple_norm.html>`_
 which is used to scale the FITS files before rendering.
 
 Equivalently, using the FitsMap command line interface:
@@ -170,16 +177,17 @@ Once FitsMap is finished, the following will have been generated:
     - catalog/
     - css/
     - catalog_assets/
+    - imgs/
     - js/
     - index.html
 
 The directories ``F125W``, ``F160W``, ``RGB``, ``catalog`` contain tiled
 versions of the input fits files. The ``css`` directory contains some supporting
-CSS files for clustering the markers and rendering pixels. The ``js`` directory
-contains supporting JavaScript for the map. ``catalog_assets`` contains JSON
-files for each source in each that are rendered when the marker associated with
-that source is clicked. Finally, ``index.html`` is the webpage that contains the
-map.
+CSS files for clustering the markers and rendering pixels. The ``imgs``
+directory contains supporting images. The ``js`` directory contains supporting
+JavaScript for the map. ``catalog_assets`` contains JSON files for each source
+in each that are rendered when the marker associated with that source is
+clicked. Finally, ``index.html`` is the webpage that contains the map.
 
 To use the map, run ``fitsmap serve`` in the same directory as ``index.html``
 
@@ -223,11 +231,11 @@ You can search the catalogs by the ``id`` column from the catalog and FitsMap
 will locate and pan to the source in the map.
 
 
-Parallelization *(Linux Only)*
+Parallelization
 **********************************
 
-FitsMap supports the parallelization(via ``multiprocessing``/``sharedmem``) of
-map creation in two ways:
+FitsMap supports the parallelization(via `ray <ray.io>`_) of map creation in two
+ways:
 
 - splitting images/catalogs into parallel tasks
 - parallel tiling of an image
@@ -239,33 +247,18 @@ The settings for parallelization are set using the following keyword arguments:
   time.
 - ``task_procs``: How many processes can work on a single task.
 
-Note that if you use parallelization you need to wrap your code like so:
-
-.. code-block:: python
-
-    from fitsmap import convert
-
-    if __name__=="__main__:
-        convert.dir_to_map.(
-            "path/to/data",
-            out_dir="path/to/data/map",
-            cat_wcs_fits_file="path/to/header_file.fits",
-            proces_per_task=2,
-            task_procs=2,
-        )
-
-For an explanation on why this is necessary, see the
-`Python Programming Guidelines
-<https://docs.python.org/3/library/multiprocessing.html#multiprocessing-programming>`_
-
 You can use both keyword arguments at the same time, but keep in mind the number
 of CPUs available. For example, if ``procs_per_task=2`` and ``task_procs=2``
 then that will generate 6 new processes, 2 new processes for each task, and each
 of those will generate 2 new processes to tile an image in parallel.
 
-Parallelization offers a significant speed up, so if there are cores available
+Parallelization can offer a significant speed up, so if there are cores available
 it makes sense to use them.
 
+**NOTE: ray's support for Windows is currently in beta, so you may experience
+some bugs running in parallel on Windows machines. Feel free to submit an issue
+if you run into any problems.**
+
 Notes
 *****
 
@@ -286,13 +279,13 @@ converted into transparent pixels.
 Notes on Catalog Conversion
 ---------------------------
 
-Catalogs should be delimited text files with the first line
-containing the column names, and the following lines containing values.
-Catalogs need to have an ``id`` column with a unique value for each row. It
-also needs to have coordinates for each source, which can be one of the
-following pairs of columns (``ra``/``dec``) or (``x``/``y``). **Note fitsmap
-assumes that the origin of the image starts at (1,1), which is a common
-convention in catalogs.**
+Catalogs should be delimited text files with the first line containing the
+column names, and the following lines containing values. Catalogs need to have
+an ``id`` column with a unique value for each row. It also needs to have
+coordinates for each source, which can be one of the following pairs of columns
+(``ra`` / ``dec``) or (``x`` / ``y``). **Note fitsmap assumes that the origin of
+the image starts at (1,1), but this can be changed to (0,0) by setting the
+kwarg** ``catalog_starts_at_one=False``.
 
 Some catalogs have many columns for each row, which will create very tall
 pop-ups when the markers are clicked. To avoid this, you can pass an integer
@@ -303,6 +296,16 @@ Catalog pop-ups are rendered as a simple HTML table, so you can put any HTML
 friendly things, for example <img> tags, in the catalog and they should be
 rendered appropriately.
 
+FitsMap will render your markers as Ellipses if you have the following columns
+in your catalog: ``a``, ``b``, and ``theta``. Where ``a`` is the major axis
+radius in **pixels**, ``b`` is the minor axis radius in **pixels**, and theta
+is the rotation of the ellipse in units of degrees starting from the negative
+x-axis and moving counter-clockwise.
+
+.. image:: docs/ellipse_fig.png
+    :alt: EllipseOrientaton
+    :align: center
+
 ----
 
 If you use FitsMap in your research please cite it using the following (also in
diff --git a/docs/conf.py b/docs/conf.py
index 460c435..a31963c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -17,11 +17,10 @@
 sys.path.insert(0, os.path.abspath("../"))
 sys.path.insert(0, os.path.abspath("../fitsmap"))
 
-
 # -- Project information -----------------------------------------------------
 
 project = "FitsMap"
-copyright = "2019, Ryan Hausen"
+copyright = "2022, Ryan Hausen and Contributors"
 author = "Ryan Hausen"
 
 # The full version, including alpha/beta/rc tags
diff --git a/docs/ellipse_fig.png b/docs/ellipse_fig.png
new file mode 100644
index 0000000..35aaa30
Binary files /dev/null and b/docs/ellipse_fig.png differ
diff --git a/docs/ellipse_fig.svg b/docs/ellipse_fig.svg
new file mode 100644
index 0000000..f7db22d
--- /dev/null
+++ b/docs/ellipse_fig.svg
@@ -0,0 +1,157 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="52.792423mm"
+   height="33.209038mm"
+   viewBox="0 0 52.792423 33.209038"
+   version="1.1"
+   id="svg5"
+   inkscape:version="1.1.2 (0a00cf5, 2022-02-04)"
+   sodipodi:docname="ellipse_fig.svg"
+   inkscape:export-filename="/home/ryanhausen/repos/fitsmap/docs/ellipse_fig.png"
+   inkscape:export-xdpi="599.64398"
+   inkscape:export-ydpi="599.64398"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview7"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.81176471"
+     inkscape:pagecheckerboard="true"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:document-units="mm"
+     showgrid="false"
+     showguides="false"
+     inkscape:zoom="4.2835116"
+     inkscape:cx="46.924117"
+     inkscape:cy="59.530596"
+     inkscape:window-width="3766"
+     inkscape:window-height="2107"
+     inkscape:window-x="74"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1"
+     inkscape:pageshadow="2" />
+  <defs
+     id="defs2">
+    <marker
+       style="overflow:visible"
+       id="TriangleStart-3"
+       refX="0"
+       refY="0"
+       orient="auto-start-reverse"
+       inkscape:stockid="TriangleStart"
+       markerWidth="2.729075"
+       markerHeight="3.155"
+       viewBox="0 0 5.3244081 6.1553851"
+       inkscape:isstock="true"
+       inkscape:collect="always"
+       preserveAspectRatio="xMidYMid">
+      <path
+         transform="scale(0.5)"
+         style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
+         d="M 5.77,0 -2.88,5 V -5 Z"
+         id="path135-6" />
+    </marker>
+    <marker
+       style="overflow:visible"
+       id="TriangleStart-9"
+       refX="0"
+       refY="0"
+       orient="auto-start-reverse"
+       inkscape:stockid="TriangleStart"
+       markerWidth="0.999075"
+       markerHeight="1.155"
+       viewBox="0 0 5.3244081 6.1553851"
+       inkscape:isstock="true"
+       inkscape:collect="always"
+       preserveAspectRatio="xMidYMid"
+       markerUnits="userSpaceOnUse">
+      <path
+         transform="scale(0.5)"
+         style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
+         d="M 5.77,0 -2.88,5 V -5 Z"
+         id="path135-1" />
+    </marker>
+  </defs>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-23.644613,-48.66162)">
+    <g
+       id="g8410-2"
+       transform="rotate(-17.74479,81.182126,256.47091)">
+      <ellipse
+         style="fill:#81b4ff;fill-opacity:1;stroke:#0744aa;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;paint-order:stroke fill markers;stop-color:#000000"
+         id="path234-7"
+         cx="110.01833"
+         cy="64.019218"
+         rx="18.985565"
+         ry="7.6236887" />
+      <path
+         style="fill:none;stroke:#ffffff;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;marker-start:url(#TriangleStart-9);marker-end:url(#TriangleStart-9)"
+         d="m 90.79411,63.96119 h 19.21933 v -7.843663"
+         id="path8108-0"
+         sodipodi:nodetypes="ccc" />
+      <text
+         xml:space="preserve"
+         style="font-size:3.52778px;line-height:1.25;font-family:Helvetica;-inkscape-font-specification:Helvetica;letter-spacing:0px;stroke-width:0.264583"
+         x="99.769592"
+         y="67.926262"
+         id="text8319-9"><tspan
+           sodipodi:role="line"
+           id="tspan8317-3"
+           style="font-size:3.52778px;fill:#ffffff;stroke-width:0.264583"
+           x="99.769592"
+           y="67.926262">a</tspan></text>
+      <text
+         xml:space="preserve"
+         style="font-size:3.52778px;line-height:1.25;font-family:Helvetica;-inkscape-font-specification:Helvetica;letter-spacing:0px;stroke-width:0.264583"
+         x="112.18715"
+         y="61.235874"
+         id="text8319-6-6"><tspan
+           sodipodi:role="line"
+           id="tspan8317-2-0"
+           style="font-size:3.52778px;fill:#ffffff;stroke-width:0.264583"
+           x="112.18715"
+           y="61.235874">b</tspan></text>
+    </g>
+    <g
+       id="g9988"
+       transform="matrix(1.4020139,0,0,1.3284217,-20.117104,-21.102013)"
+       style="opacity:0.484481">
+      <path
+         style="fill:none;stroke:#000000;stroke-width:0.246937;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:0.246937, 0.246937;stroke-dashoffset:0;stroke-opacity:1;marker-start:url(#TriangleStart-3);marker-end:url(#TriangleStart-3)"
+         d="M 50.040824,52.965639 V 77.065588"
+         id="path2413"
+         sodipodi:nodetypes="cc" />
+      <path
+         style="fill:none;stroke:#000000;stroke-width:0.246937;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:0.246937, 0.246937;stroke-dashoffset:0;stroke-opacity:1;marker-start:url(#TriangleStart-3);marker-end:url(#TriangleStart-3)"
+         d="M 68.418718,64.252806 H 31.662928"
+         id="path2413-7" />
+    </g>
+    <text
+       xml:space="preserve"
+       style="font-size:2.11667px;line-height:1.25;font-family:Helvetica;-inkscape-font-specification:Helvetica;letter-spacing:0px;fill:#000000;stroke-width:0.264583"
+       x="36.604084"
+       y="66.649117"
+       id="text13398"><tspan
+         sodipodi:role="line"
+         id="tspan13396"
+         style="font-size:2.11667px;fill:#000000;stroke-width:0.264583"
+         x="36.604084"
+         y="66.649117">theta</tspan></text>
+    <path
+       style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#TriangleStart-3)"
+       d="m 35.46671,64.421154 c 0,0 -1.497873,2.058057 0.26306,3.839216"
+       id="path13477"
+       sodipodi:nodetypes="cc" />
+  </g>
+</svg>
diff --git a/fitsmap/__main__.py b/fitsmap/__main__.py
index aba410a..30c3202 100644
--- a/fitsmap/__main__.py
+++ b/fitsmap/__main__.py
@@ -128,7 +128,10 @@ def f():
         # https://docs.python.org/3.8/library/http.server.html
         server = http.server.HTTPServer(
             ("", port),
-            functools.partial(http.server.SimpleHTTPRequestHandler, directory=out_dir,),
+            functools.partial(
+                http.server.SimpleHTTPRequestHandler,
+                directory=out_dir,
+            ),
         )
         server.serve_forever()
 
diff --git a/fitsmap/cartographer.py b/fitsmap/cartographer.py
index 3718fc6..368a9bb 100644
--- a/fitsmap/cartographer.py
+++ b/fitsmap/cartographer.py
@@ -1,5 +1,5 @@
 # MIT License
-# Copyright 2021 Ryan Hausen and contributors
+# Copyright 2022 Ryan Hausen and contributers
 
 # Permission is hereby granted, free of charge, to any person obtaining a copy of
 # this software and associated documentation files (the "Software"), to deal in
@@ -57,16 +57,37 @@ def chart(
     """
     # convert layer names into a single javascript string
     layer_zooms = lambda l: list(map(int, os.listdir(os.path.join(out_dir, l))))
-    zooms = reduce(lambda x, y: x + y, list(map(layer_zooms, img_layer_names)))
-    zooms = [0] if len(zooms) == 0 else zooms
-    convert_layer_name_func = partial(
-        layer_name_to_dict, out_dir, min(zooms), max(zooms)
+    img_zooms = reduce(lambda x, y: x + y, list(map(layer_zooms, img_layer_names)), [0])
+    cat_zooms = reduce(
+        lambda x, y: x + y, list(map(layer_zooms, marker_layer_names)), [0]
     )
+    # be able to zoom in 5 levels further than the native zoom
+    # this seems to work well in general, but could become a parameter.
+    max_overall_zoom = max(img_zooms + cat_zooms) + 5
+
+    convert_layer_name_func = partial(layer_name_to_dict, out_dir, max_overall_zoom)
     img_layer_dicts = list(
-        starmap(convert_layer_name_func, zip(img_layer_names, repeat(None)))
+        starmap(
+            convert_layer_name_func,
+            zip(
+                repeat(min(img_zooms)),
+                repeat(max(img_zooms)),
+                img_layer_names,
+                repeat(None),
+            ),
+        )
     )
+
     cat_layer_dicts = list(
-        starmap(convert_layer_name_func, zip(marker_layer_names, get_colors()))
+        starmap(
+            convert_layer_name_func,
+            zip(
+                repeat(min(cat_zooms)),
+                repeat(max(cat_zooms)),
+                marker_layer_names,
+                get_colors(),
+            ),
+        )
     )
 
     # generated javascript =====================================================
@@ -92,26 +113,29 @@ def chart(
 
 
 def layer_name_to_dict(
-    out_dir: str, min_zoom: int, max_zoom: int, name: str, color: str,
+    out_dir: str,
+    max_zoom: int,
+    min_zoom: int,
+    max_native_zoom: int,
+    name: str,
+    color: str,
 ) -> dict:
     """Convert layer name to dict for conversion."""
 
     layer_dict = dict(
-        directory=name + "/{z}/{y}/{x}.png",
+        directory=name + "/{z}/{y}/{x}." + ("pbf" if color else "png"),
         name=name,
         min_zoom=min_zoom,
-        max_zoom=max_zoom + 5,
-        max_native_zoom=max_zoom,
+        max_zoom=max_zoom,
+        max_native_zoom=max_native_zoom,
     )
     if color:
         layer_dict["color"] = color
-        layer_dict["directory"] = layer_dict["directory"].replace("png", "pbf")
 
-        cat_col_path = os.path.join(out_dir, f"{name}.columns")
+        cat_col_path = "/".join([out_dir, f"{name}.columns"])
         with open(cat_col_path, "r") as f:
             columns = f.readline().strip().split(",")
             layer_dict["columns"] = [f'"{c}"' for c in columns]
-        os.remove(cat_col_path)
 
     return layer_dict
 
@@ -142,9 +166,12 @@ def cat_layer_dict_to_str(layer: dict, rows_per_column: int) -> str:
         " = L.gridLayer.tiledMarkers(",
         "{ ",
         'tileURL:"' + layer["directory"] + '", ',
+        "radius: 10, ",
         'color: "' + layer["color"] + '", ',
+        "fillOpacity: 0.2, ",
+        "strokeOpacity: 1.0, ",
         f"rowsPerColumn: {rpc_str}, ",
-        f'catalogColumns: [{",".join(layer["columns"])}],',
+        f'catalogColumns: [{",".join(layer["columns"])}], ',
         "minZoom: " + str(layer["min_zoom"]) + ", ",
         "maxZoom: " + str(layer["max_zoom"]) + ", ",
         "maxNativeZoom: " + str(layer["max_native_zoom"]) + " ",
@@ -174,7 +201,7 @@ def get_colors() -> Iterable[str]:
 def leaflet_crs_js(tile_layers: List[dict]) -> str:
     max_zoom = max(map(lambda t: t["max_native_zoom"], tile_layers))
 
-    scale_factor = int(2 ** max_zoom)
+    scale_factor = int(2**max_zoom)
 
     js = [
         "L.CRS.FitsMap = L.extend({}, L.CRS.Simple, {",
@@ -269,7 +296,7 @@ def build_conditional_css(out_dir: str) -> str:
     return "\n".join(map(lambda s: css_string.format(s), [search_css] + local_css))
 
 
-def build_conditional_js(out_dir: str, include_markerjs:bool) -> str:
+def build_conditional_js(out_dir: str, include_markerjs: bool) -> str:
 
     support_dir = os.path.join(os.path.dirname(__file__), "support")
     out_js_dir = os.path.join(out_dir, "js")
@@ -294,7 +321,8 @@ def build_conditional_js(out_dir: str, include_markerjs:bool) -> str:
     # sucessfully run. Other files get placed after index.js so that the map
     # can load first and then those will load in the background
     pre_index_files = [
-        "https://cdnjs.cloudflare.com/ajax/libs/leaflet-search/3.0.2/leaflet-search.src.min.js" * include_markerjs,
+        "https://cdnjs.cloudflare.com/ajax/libs/leaflet-search/3.0.2/leaflet-search.src.min.js"
+        * include_markerjs,
         "js/customSearch.min.js" * include_markerjs,
         "js/tiledMarkers.min.js" * include_markerjs,
         "js/urlCoords.js",
@@ -310,14 +338,18 @@ def build_conditional_js(out_dir: str, include_markerjs:bool) -> str:
 
     js_string = "    <script defer src='{}'></script>"
     js_tags = list(
-        map(lambda s: js_string.format(s), filter(lambda x: x, pre_index_files + post_index_files))
+        map(
+            lambda s: js_string.format(s),
+            filter(lambda x: x, pre_index_files + post_index_files),
+        )
     )
 
     return "\n".join(js_tags)
 
 
 def leaflet_layer_control_declaration(
-    img_layer_dicts: List[Dict], cat_layer_dicts: List[Dict],
+    img_layer_dicts: List[Dict],
+    cat_layer_dicts: List[Dict],
 ) -> str:
     img_layer_label_pairs = ",".join(
         list(map(lambda l: '"{0}":{0}'.format(l["name"]), img_layer_dicts))
@@ -337,12 +369,14 @@ def leaflet_layer_control_declaration(
     return "\n".join(control_js)
 
 
-def leaflet_search_control_declaration(cat_layer_dicts: List[Dict],) -> str:
+def leaflet_search_control_declaration(
+    cat_layer_dicts: List[Dict],
+) -> str:
     search_js = [
         "const catalogPaths = [",
         *list(
             map(
-                lambda s: f'    "{os.path.join("catalog_assets", s)}/",',
+                lambda s: f'    "{"/".join(["catalog_assets", s])}/",',
                 map(lambda l: l["name"], cat_layer_dicts),
             )
         ),
@@ -355,6 +389,25 @@ def leaflet_search_control_declaration(cat_layer_dicts: List[Dict],) -> str:
     return "\n".join(search_js) if cat_layer_dicts else ""
 
 
+def extract_cd_matrix_as_string(wcs: WCS) -> str:
+    if hasattr(wcs.wcs, "cd"):
+        return str(wcs.wcs.cd.tolist())
+    else:
+        # Manual "CD" matrix
+        delta = wcs.all_pix2world(
+            [
+                wcs.wcs.crpix,
+                wcs.wcs.crpix + np.array([1, 0]),
+                wcs.wcs.crpix + np.array([0, 1]),
+            ],
+            0,
+        )
+
+        _cd = np.array([delta[1, :] - delta[0, :], delta[2, :] - delta[0, :]])
+        _cd[0, :] *= np.cos(wcs.wcs.crval[1] / 180 * np.pi)
+        return str(_cd.tolist())
+
+
 def build_urlCoords_js(img_wcs: WCS) -> str:
 
     wcs_js_file = os.path.join(os.path.dirname(__file__), "support", "urlCoords.js.tmp")
@@ -366,23 +419,7 @@ def build_urlCoords_js(img_wcs: WCS) -> str:
         wcs_js = wcs_js.replace("_IS_RA_DEC", "1")
         wcs_js = wcs_js.replace("_CRPIX", str(img_wcs.wcs.crpix.tolist()))
         wcs_js = wcs_js.replace("_CRVAL", str(img_wcs.wcs.crval.tolist()))
-
-        if hasattr(img_wcs.wcs, "cd"):
-            wcs_js = wcs_js.replace("_CD", str(img_wcs.wcs.cd.tolist()))
-        else:
-            # Manual "CD" matrix
-            delta = img_wcs.all_pix2world(
-                [
-                    img_wcs.wcs.crpix,
-                    img_wcs.wcs.crpix + np.array([1, 0]),
-                    img_wcs.wcs.crpix + np.array([0, 1]),
-                ],
-                0,
-            )
-
-            _cd = np.array([delta[1, :] - delta[0, :], delta[2, :] - delta[0, :]])
-            _cd[0, :] *= np.cos(img_wcs.wcs.crval[1] / 180 * np.pi)
-            wcs_js = wcs_js.replace("_CD", str(_cd.tolist()))
+        wcs_js = wcs_js.replace("_CD", extract_cd_matrix_as_string(img_wcs))
     else:
         wcs_js = wcs_js.replace("_IS_RA_DEC", "0")
         wcs_js = wcs_js.replace("_CRPIX", "[1, 1]")
@@ -426,7 +463,8 @@ def build_index_js(
             "",
             "// Map event setup =============================================================",
             loading_screen_js(image_layer_dicts),
-            "" 'map.on("moveend", updateLocationBar);',
+            "",
+            'map.on("moveend", updateLocationBar);',
             'map.on("zoomend", updateLocationBar);',
             "",
             'if (urlParam("zoom")==null) {',
diff --git a/fitsmap/convert.py b/fitsmap/convert.py
index 81e8efe..09a08da 100644
--- a/fitsmap/convert.py
+++ b/fitsmap/convert.py
@@ -1,5 +1,5 @@
 # MIT License
-# Copyright 2021 Ryan Hausen and contributers
+# Copyright 2022 Ryan Hausen and contributers
 
 # Permission is hereby granted, free of charge, to any person obtaining a copy of
 # this software and associated documentation files (the "Software"), to deal in
@@ -22,33 +22,41 @@
 
 import copy
 import csv
-import json
-import multiprocessing as mp
+import io
+import logging
 import os
-import shutil
 import sys
 from functools import partial
-from itertools import chain, count, filterfalse, islice, product, repeat
-from multiprocessing import JoinableQueue, Pool, Process
-from queue import Empty, Queue
-from typing import Any, Callable, Dict, Iterable, List, Tuple
+from itertools import (
+    chain,
+    filterfalse,
+    groupby,
+    islice,
+    product,
+    repeat,
+    starmap,
+)
+from typing import Any, Callable, Dict, Iterable, List, Tuple, Union
 
 import cbor2
 import mapbox_vector_tile as mvt
 import matplotlib as mpl
+
+mpl.use("Agg")  # need to use this for processes safe matplotlib
 import matplotlib.pyplot as plt
 import numpy as np
-import sharedmem
+import ray
+import ray.util.queue as queue
 from astropy.io import fits
 from astropy.wcs import WCS
 from astropy.visualization import simple_norm
-from imageio import imread
 from PIL import Image
-from fitsmap.supercluster import Supercluster
-from tqdm import tqdm
 
 import fitsmap.utils as utils
 import fitsmap.cartographer as cartographer
+import fitsmap.padded_array as pa
+from fitsmap.output_manager import OutputManager
+from fitsmap.supercluster import Supercluster
 
 # https://github.com/zimeon/iiif/issues/11#issuecomment-131129062
 Image.MAX_IMAGE_PIXELS = sys.maxsize
@@ -132,19 +140,12 @@ def balance_array(array: np.ndarray) -> np.ndarray:
     dim0, dim1 = array.shape[0], array.shape[1]
 
     exp_val = np.ceil(np.log2(max(dim0, dim1)))
-    total_size = 2 ** exp_val
+    total_size = 2**exp_val
     pad_dim0 = int(total_size - dim0)
     pad_dim1 = int(total_size - dim1)
 
     if pad_dim0 > 0 or pad_dim1 > 0:
-        if len(array.shape) == 3:
-            padding = [[0, pad_dim0], [0, pad_dim1], [0, 0]]
-        else:
-            padding = [[0, pad_dim0], [0, pad_dim1]]
-
-        return np.pad(
-            array.astype(np.float32), padding, mode="constant", constant_values=np.nan
-        )
+        return pa.PaddedArray(array, (pad_dim0, pad_dim1))
     else:
         return array
 
@@ -161,18 +162,12 @@ def get_array(file_location: str) -> np.ndarray:
     _, ext = os.path.splitext(file_location)
 
     if ext == ".fits":
-        array = fits.getdata(file_location)
+        array = fits.getdata(file_location).astype(np.float32)
         shape = array.shape
         if len(shape) != 2:
             raise ValueError("FitsMap only supports 2D FITS files.")
     else:
-        array = np.flipud(imread(file_location))
-        # array = imread(file_location)
-
-        if len(array.shape) == 3:
-            shape = array.shape[:-1]
-        elif len(array.shape) == 2:
-            shape = array.shape
+        array = np.flipud(Image.open(file_location)).astype(np.float32)
 
     return balance_array(array)
 
@@ -216,7 +211,7 @@ def make_dirs(out_dir: str, min_zoom: int, max_zoom: int) -> None:
         None
     """
 
-    row_count = lambda z: int(np.sqrt(4 ** z))
+    row_count = lambda z: int(np.sqrt(4**z))
 
     def build_z_ys(z, ys):
         list(
@@ -264,34 +259,31 @@ def get_total_tiles(min_zoom: int, max_zoom: int) -> int:
         The total number of tiles that will be generated
     """
 
-    return int(sum([4 ** i for i in range(min_zoom, max_zoom + 1)]))
+    return int(sum([4**i for i in range(min_zoom, max_zoom + 1)]))
 
 
-def make_tile_mpl(
-    out_dir: str, array: np.ndarray, job: Tuple[int, int, int, slice, slice],
-) -> None:
-    """Extracts a tile from ``array`` and saves it at the proper place in ``out_dir`` using Matplotlib.
-
+def imread_default(path: str, default: np.ndarray) -> np.ndarray:
+    """Opens an image if it exists, if not returns a tranparent image.
     Args:
-        out_dir (str): The directory to save tile in
-        array (np.ndarray): Array to extract a slice from
-        job (Tuple[int, int, int, slice, slice]): A tuple containing z, y, x,
-                                                  dim0_slices, dim1_slices. Where
-                                                  (z, y, x) define the zoom and
-                                                  the coordinates, and (dim0_slices,
-                                                  and dim1_slices) are slice
-                                                  objects that extract the tile.
+        path (str): Image file location
+        size (int, optional): The image size, assumed square. Defaults to 256.
     Returns:
-        None
+        np.ndarray: the image if it exists. if not, a transparent image of
+                    size (size, size, 4).
     """
-    z, y, x, slice_ys, slice_xs = job
+    try:
+        return np.flipud(Image.open(path))
+    except FileNotFoundError:
+        return default
 
-    img_path = build_path(z, y, x, out_dir)
 
-    tile = array[slice_ys, slice_xs].copy()
-
-    if np.all(np.isnan(tile)):
-        return
+def make_tile_mpl(tile: np.ndarray) -> np.ndarray:
+    """Converts array data into an image using matplotlib
+    Args:
+        tile (np.ndarray): The array data
+    Returns:
+        np.ndarray: The array data converted into an image using Matplotlib
+    """
 
     global mpl_f
     global mpl_img
@@ -301,11 +293,8 @@ def make_tile_mpl(
     if mpl_f:
         # this is a singleton and starts out as null
         mpl_img.set_data(mpl_alpha_f(tile))  # pylint: disable=not-callable
-        mpl_f.savefig(
-            img_path, dpi=256, bbox_inches=0, facecolor=(0, 0, 0, 0),
-        )
     else:
-        if len(array.shape) == 2:
+        if len(tile.shape) == 2:
             cmap = copy.copy(mpl.cm.get_cmap(MPL_CMAP))
             cmap.set_bad(color=(0, 0, 0, 0))
 
@@ -318,38 +307,75 @@ def make_tile_mpl(
             img_kwargs = dict(interpolation="nearest", origin="lower", norm=mpl_norm)
 
             def adjust_pixels(arr):
-                img = arr.copy()
-                if img.shape[2] == 3:
-                    img = np.concatenate(
+                tmp = arr
+                if tmp.shape[2] == 3:
+                    tmp = np.concatenate(
                         (
-                            img,
-                            np.ones(list(img.shape[:-1]) + [1], dtype=np.float32) * 255,
+                            tmp,
+                            np.ones(list(tmp.shape[:-1]) + [1], dtype=np.float32) * 255,
                         ),
                         axis=2,
                     )
 
-                ys, xs = np.where(np.isnan(img[:, :, 0]))
-                img[ys, xs, :] = np.array([0, 0, 0, 0], dtype=np.float32)
+                ys, xs = np.where(np.isnan(tmp[:, :, 0]))
+                tmp[ys, xs, :] = np.array([0, 0, 0, 0], dtype=np.float32)
 
-                return img.astype(np.uint8)
+                return tmp.astype(np.uint8)
 
             mpl_alpha_f = lambda arr: adjust_pixels(arr)
 
         mpl_f = plt.figure(dpi=256)
         mpl_f.set_size_inches([256 / 256, 256 / 256])
-        mpl_img = plt.imshow(mpl_alpha_f(tile), **img_kwargs)
+        t = mpl_alpha_f(tile)
+        mpl_img = plt.imshow(t, **img_kwargs)
         plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
         plt.axis("off")
-        mpl_f.savefig(
-            img_path, dpi=256, bbox_inches=0, facecolor=(0, 0, 0, 0),
+
+    buf = io.BytesIO()
+    mpl_f.savefig(
+        buf,
+        dpi=256,
+        bbox_inches=0,
+        facecolor=(0, 0, 0, 0),
+    )
+    buf.seek(0)
+    return Image.open(buf)
+
+
+def make_tile_pil(tile: np.ndarray) -> np.ndarray:
+    """Converts the input array into an image using PIL
+    Args:
+        tile (np.ndarray): The array data to be converted
+    Returns:
+        np.ndarray: an RGBA version of the input data
+    """
+
+    if len(tile.shape) < 3:
+        img_tile = np.dstack([tile, tile, tile, np.ones_like(tile) * 255])
+    elif tile.shape[2] == 3:
+        img_tile = np.concatenate(
+            (tile, np.ones(list(tile.shape[:-1]) + [1], dtype=np.float32) * 255),
+            axis=2,
         )
+    else:
+        img_tile = tile.copy()
+
+    ys, xs = np.where(np.isnan(tile[:, :, 0]))
+    img_tile[ys, xs, :] = np.array([0, 0, 0, 0], dtype=np.float32)
+    img = Image.fromarray(np.flipud(img_tile).astype(np.uint8))
 
+    return img
 
-def make_tile_pil(
-    out_dir: str, array: np.ndarray, job: Tuple[int, int, int, slice, slice]
+
+def mem_safe_make_tile(
+    out_dir: str,
+    img_engine: str,
+    array: np.ndarray,
+    job: Union[
+        Tuple[int, int, int, slice, slice], List[Tuple[int, int, int, slice, slice]]
+    ],
 ) -> None:
     """Extracts a tile from ``array`` and saves it at the proper place in ``out_dir`` using PIL.
-
     Args:
         out_dir (str): The directory to save tile in
         array (np.ndarray): Array to extract a slice from
@@ -359,46 +385,66 @@ def make_tile_pil(
                                                   the coordinates, and (dim0_slices,
                                                   and dim1_slices) are slice
                                                   objects that extract the tile.
-
     Returns:
         None
     """
-    z, y, x, slice_ys, slice_xs = job
-
-    img_path = build_path(z, y, x, out_dir)
 
-    tile = array[slice_ys, slice_xs].copy()
+    default_array = np.zeros([256, 256, 4], dtype=np.uint8)
+    get_img = partial(imread_default, default=default_array)
 
-    if np.all(np.isnan(tile)):
-        return
+    # If this is being run in parallel it will be a List of Tuples if its being
+    # run serially it will be a single Tuple, wrap the Tuple in a list to keep
+    # it simple
+    jobs = [job] if isinstance(job, tuple) else job
 
-    if len(tile.shape) < 3:
-        tile = np.dstack([tile, tile, tile, np.ones_like(tile) * 255])
-    elif tile.shape[2] == 3:
-        tile = np.concatenate(
-            (tile, np.ones(list(tile.shape[:-1]) + [1], dtype=np.float32) * 255),
-            axis=2,
-        )
-
-    ys, xs = np.where(np.isnan(tile[:, :, 0]))
-    tile[ys, xs, :] = np.array([0, 0, 0, 0], dtype=np.float32)
-    img = Image.fromarray(np.flipud(tile).astype(np.uint8))
-    del tile
-
-    img.thumbnail([256, 256], Image.LANCZOS)
-    img.convert("RGBA").save(img_path, "PNG")
-    del img
+    for z, y, x, slice_ys, slice_xs in jobs:
+        try:
+            img_path = build_path(z, y, x, out_dir)
+
+            with_out_dir = partial(os.path.join, out_dir)
+
+            if os.path.exists(with_out_dir(f"{z+1}")):
+                top_left = get_img(with_out_dir(f"{z+1}", f"{2*y+1}", f"{2*x}.png"))
+                top_right = get_img(with_out_dir(f"{z+1}", f"{2*y+1}", f"{2*x+1}.png"))
+                bottom_left = get_img(with_out_dir(f"{z+1}", f"{2*y}", f"{2*x}.png"))
+                bottom_right = get_img(with_out_dir(f"{z+1}", f"{2*y}", f"{2*x+1}.png"))
+
+                tile = np.concatenate(
+                    [
+                        np.concatenate([top_left, top_right], axis=1),
+                        np.concatenate([bottom_left, bottom_right], axis=1),
+                    ],
+                    axis=0,
+                )
+                img = Image.fromarray(np.flipud(tile))
+            else:
+                tile = array[slice_ys, slice_xs]
+                if img_engine == IMG_ENGINE_PIL:
+                    img = make_tile_pil(tile)
+                else:
+                    img = make_tile_mpl(tile)
+
+            # if the tile is all transparent, don't save to disk
+            if np.any(np.array(img)[..., -1]):
+                img.thumbnail([256, 256], Image.Resampling.LANCZOS)
+                img.convert("RGBA").save(img_path, "PNG")
+            else:
+                pass
+        except Exception as e:
+            print("Tile creation failed for tile:", z, y, x, slice_ys, slice_xs)
+            print(e)
 
 
 def tile_img(
     file_location: str,
-    pbar_loc: int,
+    pbar_ref: Tuple[int, queue.Queue],
     tile_size: Shape = [256, 256],
     min_zoom: int = 0,
     image_engine: str = IMG_ENGINE_MPL,
     out_dir: str = ".",
     mp_procs: int = 0,
     norm_kwargs: dict = {},
+    batch_size: int = 500,
 ) -> None:
     """Extracts tiles from the array at ``file_location``.
 
@@ -420,6 +466,8 @@ def tile_img(
                             `astropy.visualization.simple_norm`. The default is
                             linear scaling using min/max values. See documentation
                             for more information: https://docs.astropy.org/en/stable/api/astropy.visualization.mpl_normalize.simple_norm.html
+        batch_size (int): The number of tiles to process at a time, when tiling
+                          in parallel. The default is 500.
 
     Returns:
         None
@@ -427,7 +475,7 @@ def tile_img(
 
     _, fname = os.path.split(file_location)
     if get_map_layer_name(file_location) in os.listdir(out_dir):
-        print(f"{fname} already tiled. Skipping tiling.")
+        OutputManager.write(pbar_ref, f"{fname} already tiled. Skipping tiling.")
         return
 
     # reset mpl vars just in case they have been set by another img
@@ -441,7 +489,9 @@ def tile_img(
 
     # get image
     array = get_array(file_location)
-    mpl_norm = simple_norm(array, **norm_kwargs)
+
+    if image_engine == IMG_ENGINE_MPL and norm_kwargs:
+        mpl_norm = simple_norm(array[:], **norm_kwargs)
 
     zooms = get_zoom_range(array.shape, tile_size)
     min_zoom = max(min_zoom, zooms[0])
@@ -457,53 +507,93 @@ def tile_img(
 
     tile_params = chain.from_iterable(
         [
-            slice_idx_generator(array.shape, z, 256 * (2 ** i))
+            slice_idx_generator(array.shape, z, 256 * (2**i))
             for (i, z) in enumerate(range(max_zoom, min_zoom - 1, -1), start=0)
         ]
     )
 
-    # tile the image
     total_tiles = get_total_tiles(min_zoom, max_zoom)
 
-    if image_engine == IMG_ENGINE_MPL:
-        make_tile = partial(make_tile_mpl, tile_dir)
-    else:
-        make_tile = partial(make_tile_pil, tile_dir)
-
     if mp_procs:
-        mp_array = sharedmem.empty_like(array)
-        mp_array[:] = array[:]
-        work = partial(make_tile, mp_array)
-        with Pool(mp_procs) as p:
-            any(
-                p.imap_unordered(
-                    work,
-                    tqdm(
-                        tile_params,
-                        desc="Converting " + name,
-                        position=pbar_loc,
-                        total=total_tiles,
-                        unit="tile",
-                        disable=bool(os.getenv("DISBALE_TQDM", False)),
+        # We need to process batches to offset the cost of spinning up a process
+        def batch_params(iter, batch_size):
+            while True:
+                batch = list(islice(iter, batch_size))
+                if batch:
+                    yield batch
+                else:
+                    break
+
+        # Put the array in the ray object store. After it's there remove the
+        # reference to it so that it can be garbage collected.
+        arr_obj_id = ray.put(array)
+        del array
+
+        make_tile_f = ray.remote(num_cpus=1)(mem_safe_make_tile)
+
+        OutputManager.set_description(pbar_ref, f"Converting {name}")
+        OutputManager.set_units_total(pbar_ref, unit="tile", total=total_tiles)
+
+        # in parallel we have to go one zoom level at a time because the images
+        # at lower zoom levels are dependent on the tiles are higher zoom levels
+        for zoom, jobs in groupby(tile_params, lambda z: z[0]):
+            if zoom == max_zoom:
+                utils.backpressure_queue_ray(
+                    make_tile_f.remote,
+                    list(
+                        zip(
+                            repeat(tile_dir),
+                            repeat(image_engine),
+                            repeat(arr_obj_id),
+                            batch_params(jobs, batch_size),
+                        )
                     ),
+                    pbar_ref,
+                    mp_procs,
+                    batch_size,
+                )
+            else:
+                if "arr_obj_id" in locals():
+                    del arr_obj_id
+                work = list(
+                    zip(
+                        repeat(tile_dir),
+                        repeat(image_engine),
+                        repeat(None),
+                        batch_params(jobs, batch_size),
+                    )
                 )
-            )
-    else:
-        work = partial(make_tile, array)
-        any(
-            map(
-                work,
-                tqdm(
-                    tile_params,
-                    desc="Converting " + name,
-                    total=total_tiles,
-                    unit="tile",
-                    position=pbar_loc,
-                    disable=bool(os.getenv("DISBALE_TQDM", False)),
-                ),
-            )
-        )
 
+                utils.backpressure_queue_ray(
+                    make_tile_f.remote,
+                    work,
+                    pbar_ref,
+                    mp_procs,
+                    batch_size,
+                )
+    else:
+        work = partial(mem_safe_make_tile, tile_dir, image_engine, array)
+        jobs = tile_params
+        OutputManager.set_description(pbar_ref, f"Converting {name}")
+        OutputManager.set_units_total(pbar_ref, "tile", total_tiles)
+
+        for job in jobs:
+            if job[0] == max_zoom:
+                work(job)
+                OutputManager.update(pbar_ref, 1)
+            else:
+                break
+
+        del array
+        work = partial(mem_safe_make_tile, tile_dir, image_engine, None)
+        work(job)
+        OutputManager.update(pbar_ref, 1)
+
+        for job in jobs:
+            work(job)
+            OutputManager.update(pbar_ref, 1)
+
+    OutputManager.update_done(pbar_ref)
     if image_engine == IMG_ENGINE_MPL:
         plt.close("all")
 
@@ -566,7 +656,12 @@ def line_to_cols(raw_col_vals: str) -> List[str]:
     ]
 
     # make ra and dec lowercase for ease of access
-    raw_cols = list(map(lambda s: s.lower() if s in change_case else s, raw_col_vals,))
+    raw_cols = list(
+        map(
+            lambda s: s.lower() if s in change_case else s,
+            raw_col_vals,
+        )
+    )
 
     # if header line starts with a '#' exclude it
     if raw_cols[0] == "#":
@@ -576,7 +671,11 @@ def line_to_cols(raw_col_vals: str) -> List[str]:
 
 
 def line_to_json(
-    wcs: WCS, columns: List[str], catalog_assets_path: str, src_vals: List[str],
+    wcs: WCS,
+    columns: List[str],
+    catalog_assets_path: str,
+    src_vals: List[str],
+    catalog_starts_at_one: bool = 1,
 ) -> Dict[str, Any]:
     """Transform a raw text line attribute values into a JSON marker
 
@@ -594,8 +693,7 @@ def line_to_json(
         ra = float(src_vals[columns.index("ra")])
         dec = float(src_vals[columns.index("dec")])
 
-        # We assume the origins of the images for catalog conversion start at (1,1).
-        [[img_x, img_y]] = wcs.wcs_world2pix([[ra, dec]], 1)
+        [[img_x, img_y]] = wcs.wcs_world2pix([[ra, dec]], int(catalog_starts_at_one))
 
     if "a" in columns and "b" in columns and "theta" in columns:
         a = float(src_vals[columns.index("a")])
@@ -612,34 +710,23 @@ def line_to_json(
         b = -1
         theta = -1
 
-    # The default catalog convention is that the lower left corner is (1, 1)
-    # The default leaflet convention is that the lower left corner is (0, 0)
+    # Leaflet is 0 indexed, so if the catalog starts at 1 we need to offset
     # The convention for leaflet is to place markers at the lower left corner
     # of a pixel, to center the marker on the pixel, we subtract 1 to bring it
     # to leaflet convention and add 0.5 to move it to the center of the pixel.
-    x = (img_x - 1) + 0.5
-    y = (img_y - 1) + 0.5
-    # y = img_y
-    # x = img_x
-
-    # src_desc = {k: v for k, v in zip(columns, src_vals)}
-
-    # src_desc["fm_y"] = y
-    # src_desc["fm_x"] = x
-    # src_desc["fm_cat"] = catalog_assets_path.split(os.sep)[-1]
+    x = (img_x - int(catalog_starts_at_one)) + 0.5
+    y = (img_y - int(catalog_starts_at_one)) + 0.5
 
     src_vals += [y, x, catalog_assets_path.split(os.sep)[-1]]
 
-    # src_json = os.path.join(catalog_assets_path, f"{src_id}.json")
-    # with open(src_json, "w") as f:
-    #     json.dump(dict(id=src_id,v=src_vals), f, separators=(",", ":"))  # no whitespace
-
     src_json = os.path.join(catalog_assets_path, f"{src_id}.cbor")
     with open(src_json, "wb") as f:
         cbor2.dump(dict(id=src_id, v=src_vals), f)
 
     return dict(
-        geometry=dict(coordinates=[x, y],),
+        geometry=dict(
+            coordinates=[x, y],
+        ),
         tags=dict(
             a=a,
             b=b,
@@ -651,7 +738,12 @@ def line_to_json(
 
 
 def process_catalog_file_chunk(
-    process_f: Callable, fname: str, delimiter: str, start: int, end: int
+    process_f: Callable,
+    fname: str,
+    delimiter: str,
+    q: queue.Queue,
+    start: int,
+    end: int,
 ) -> List[dict]:
     # newline="" for csv reader, see
     # https://docs.python.org/3/library/csv.html#csv.reader
@@ -674,22 +766,16 @@ def process_catalog_file_chunk(
         current = f.tell()
         count += 1
         if count % update_every == 0:
-            process_catalog_file_chunk.q.put(count)
+            q.put(count)
             count = 1
 
     if count > 1:
-        process_catalog_file_chunk.q.put(count)
+        q.put(count)
 
     f.close()
     return json_lines
 
 
-# This allows the queue reference to be shared between processes
-# https://stackoverflow.com/a/3843313/2691018
-def process_catalog_file_chunk_init(q):
-    process_catalog_file_chunk.q = q
-
-
 def _simplify_mixed_ws(catalog_fname: str) -> None:
     with open(catalog_fname, "r") as f:
         lines = [l.strip() for l in f.readlines()]
@@ -699,49 +785,36 @@ def _simplify_mixed_ws(catalog_fname: str) -> None:
             f.write(" ".join([token.strip() for token in line.split()]) + "\n")
 
 
-def procbar_listener(q: Queue, bar: tqdm) -> None:
-    while True:
-        update = q.get()
-        if update:
-            bar.update(n=update)
-        else:
-            break
-
-
 def make_marker_tile(
-    cluster: Supercluster, out_dir: str, zyx: Tuple[int, Tuple[int, int]],
+    cluster: Supercluster,
+    out_dir: str,
+    job: Union[Tuple[int, Tuple[int, int]], List[Tuple[int, Tuple[int, int]]]],
 ) -> None:
-    z, (y, x) = zyx
-
-    if not os.path.exists(os.path.join(out_dir, str(z))):
-        os.mkdir(os.path.join(out_dir, str(z)))
+    jobs = [job] if isinstance(job, tuple) else job
 
-    if not os.path.exists(os.path.join(out_dir, str(z), str(y))):
-        os.mkdir(os.path.join(out_dir, str(z), str(y)))
+    for z, (y, x) in jobs:
+        if not os.path.exists(os.path.join(out_dir, str(z))):
+            os.mkdir(os.path.join(out_dir, str(z)))
 
-    out_path = os.path.join(out_dir, str(z), str(y), f"{x}.pbf")
+        if not os.path.exists(os.path.join(out_dir, str(z), str(y))):
+            os.mkdir(os.path.join(out_dir, str(z), str(y)))
 
-    tile_sources = cluster.get_tile(z, x, y)
+        out_path = os.path.join(out_dir, str(z), str(y), f"{x}.pbf")
 
-    if tile_sources:
-        tile_sources["name"] = "Points"
+        tile_sources = cluster.get_tile(z, x, y)
 
-        for i in range(len(tile_sources["features"])):
-            # tile_sources["features"][i]["geometry"] = "POINT({} {})".format(
-            #     *list(map(
-            #         int,
-            #         tile_sources["features"][i]["geometry"]
-            #     ))
-            # )
-            tile_sources["features"][i]["geometry"] = "POINT(0 0)"  # we dont' use this
+        if tile_sources:
+            tile_sources["name"] = "Points"
 
-        # with open(out_path.replace("pbf", "json"), "w") as f:
-        #     json.dump(tile_sources, f, indent=2)
+            for i in range(len(tile_sources["features"])):
+                tile_sources["features"][i][
+                    "geometry"
+                ] = "POINT(0 0)"  # we dont' use this
 
-        encoded_tile = mvt.encode([tile_sources], extents=256)
+            encoded_tile = mvt.encode([tile_sources], extents=256)
 
-        with open(out_path, "wb") as f:
-            f.write(encoded_tile)
+            with open(out_path, "wb") as f:
+                f.write(encoded_tile)
 
 
 def tile_markers(
@@ -755,14 +828,16 @@ def tile_markers(
     tile_size: int,
     max_x: int,
     max_y: int,
+    catalog_starts_at_one: bool,
     catalog_file: str,
-    pbar_loc: int,
+    pbar_ref: Tuple[int, queue.Queue],
+    batch_size: int = 500,
 ) -> None:
     _, fname = os.path.split(catalog_file)
 
     catalog_layer_name = get_map_layer_name(catalog_file)
     if catalog_layer_name in os.listdir(out_dir):
-        print(f"{fname} already tiled. Skipping tiling.")
+        OutputManager.write(pbar_ref, f"{fname} already tiled. Skipping tiling.")
         return
     else:
         os.mkdir(os.path.join(out_dir, catalog_layer_name))
@@ -779,20 +854,13 @@ def tile_markers(
     x_y_coords = "x" in columns and "y" in columns
     use_xy = (not ra_dec_coords) or (prefer_xy)
 
-    if (not ra_dec_coords and not x_y_coords) or "id" not in columns:
-        err_msg = " ".join(
-            [
-                catalog_file + " is missing coordinate columns (ra/dec, xy),",
-                "an 'id' column, or all of the above",
-            ]
-        )
-        raise ValueError(err_msg)
-
-    if (not use_xy) and (wcs_file is None):
-        err_msg = " ".join(
-            [catalog_file + " uses ra/dec coords, but a WCS file wasn't", "provided."]
-        )
-        raise ValueError(err_msg)
+    assert ra_dec_coords or x_y_coords, (
+        catalog_file + " is missing coordinate columns (ra/dec, xy),"
+    )
+    assert "id" in columns, catalog_file + " missing 'id' column"
+    assert use_xy or (wcs_file is not None), (
+        catalog_file + " uses ra/dec coords, but a WCS file wasn't provided."
+    )
 
     wcs = WCS(wcs_file) if wcs_file else None
 
@@ -807,67 +875,63 @@ def tile_markers(
     if catalog_layer_name not in os.listdir(catalog_assets_parent_path):
         os.mkdir(catalog_assets_path)
 
-    line_func = partial(line_to_json, wcs, columns, catalog_assets_path,)
-
-    bar = tqdm(
-        position=pbar_loc,
-        desc="Parsing " + catalog_file,
-        disable=bool(os.getenv("DISBALE_TQDM", False)),
+    line_func = partial(
+        line_to_json,
+        wcs,
+        columns,
+        catalog_assets_path,
+        catalog_starts_at_one=catalog_starts_at_one,
     )
 
+    OutputManager.set_description(pbar_ref, f"Parsing {catalog_file}")
+
     catalog_file_size = os.path.getsize(catalog_file)
 
+    q = queue.Queue()
     process_f = partial(
-        process_catalog_file_chunk, line_func, catalog_file, catalog_delim,
+        process_catalog_file_chunk, line_func, catalog_file, catalog_delim, q
     )
 
     if mp_procs > 1:
-        # this queue manages a proc bar instance that can be shared among multiple
-        # processes, i have a feeling there is a better way to do this
-        q = mp.Queue()
-        monitor = mp.Process(target=procbar_listener, args=(q, bar))
-        monitor.start()
-
-        # split the file by splitting the byte size of the file into even sized
-        # chunks. We always read to the end of the first line so its ok to get
-        # dropped into the middle of a link
+        process_f = ray.remote(num_cpus=1)(process_catalog_file_chunk)
         boundaries = np.linspace(0, catalog_file_size, mp_procs + 1, dtype=np.int64)
-        file_chunk_pairs = list(zip(boundaries[:-1], boundaries[1:]))
 
-        # To keep the progress bar up to date we need an extra process that
-        # montiors the queue for updates from the workers. The extra process
-        # doesn't do much and so it shouldn't be a strain to add it.
-
-        with Pool(mp_procs, process_catalog_file_chunk_init, [q]) as p:
-            catalog_values = list(
-                chain.from_iterable(p.starmap(process_f, file_chunk_pairs))
+        remote_val_ids = list(
+            starmap(
+                process_f.remote,
+                zip(
+                    repeat(line_func),
+                    repeat(catalog_file),
+                    repeat(catalog_delim),
+                    repeat(q),
+                    boundaries[:-1],
+                    boundaries[1:],
+                ),
             )
+        )
 
-            # this kills the iter in the monitor process
-
-        q.put(None)
-        monitor.join()
-        del monitor
-
+        _, remaining = ray.wait(remote_val_ids, timeout=0.01, fetch_local=False)
+        while remaining:
+            try:
+                lines_processed = sum([q.get(timeout=0.0001) for _ in range(mp_procs)])
+                OutputManager.update(pbar_ref, lines_processed)
+            except queue.Empty:
+                pass
+            _, remaining = ray.wait(remaining, timeout=0.01, fetch_local=False)
+        q.shutdown()
+        catalog_values = list(chain.from_iterable(list(ray.get(remote_val_ids))))
+        del q
     else:
-        process_catalog_file_chunk_init(utils.MockQueue(bar))
         catalog_values = process_f(0, catalog_file_size)
+        q.shutdown()
+        del q
 
-    # TEMP FIX FOR DREAM
-    # max_zoom += 2
-    bar = tqdm(
-        position=pbar_loc,
-        desc="Clustering " + catalog_file + "(THIS MAY TAKE A WHILE)",
-        disable=bool(os.getenv("DISBALE_TQDM", False)),
-        total=max_zoom + 1 - min_zoom,
+    OutputManager.set_description(
+        pbar_ref, f"Clustering {catalog_file}(THIS MAY TAKE A WHILE)"
+    )
+    OutputManager.set_units_total(
+        pbar_ref, unit="zoom levels", total=max_zoom + 1 - min_zoom
     )
-
-    if mp_procs > 1:
-        q = mp.Queue()
-        monitor = mp.Process(target=procbar_listener, args=(q, bar))
-        monitor.start()
-    else:
-        q = utils.MockQueue(bar)
 
     # cluster the parsed sources
     # need to get super cluster stuff in here
@@ -878,66 +942,60 @@ def tile_markers(
         radius=max(max(max_x, max_y) / tile_size, 40),
         node_size=np.log2(len(catalog_values)) * 2,
         alternate_CRS=(max_x, max_y),
-        update_f=lambda: q.put(1),
+        update_f=lambda: OutputManager.update(pbar_ref, 1),
         log=True,
     ).load(catalog_values)
 
-    if mp_procs > 1:
-        # this kills the iter in the monitor process
-        q.put(None)
-        monitor.join()
-        del monitor
+    # Don't push any updates from the clustering algorithm after clustering
+    # has been completed
+    clusterer.update_f = None
+    clusterer.log = False
 
     # tile the sources and save using protobuf
     zs = range(min_zoom, max_zoom + 1)
-    ys = [range(2 ** z) for z in zs]
-    xs = [range(2 ** z) for z in zs]
+    ys = [range(2**z) for z in zs]
+    xs = [range(2**z) for z in zs]
     tile_idxs = list(
         chain.from_iterable(
             [zip(repeat(zs[i]), product(ys[i], xs[i])) for i in range(len(zs))]
         )
     )
 
-    clusterer.update_f = None
-    clusterer.log = False
+    catalog_layer_dir = os.path.join(out_dir, catalog_layer_name)
 
-    tile_f = partial(
-        make_marker_tile, clusterer, os.path.join(out_dir, catalog_layer_name),
-    )
-
-    tracked_collection = tqdm(
-        tile_idxs,
-        position=pbar_loc,
-        desc="Tiling " + catalog_file,
-        disable=bool(os.getenv("DISBALE_TQDM", False)),
-    )
+    OutputManager.set_description(pbar_ref, f"Tiling {catalog_file}")
+    OutputManager.set_units_total(pbar_ref, unit="tile", total=len(tile_idxs))
 
     if mp_procs > 1:
-        with Pool(mp_procs) as p:
-            list(p.imap_unordered(tile_f, tracked_collection, chunksize=100))
+        # We need to process batches to offset the cost of spinning up a process
+        def batch_params(iter, batch_size):
+            while True:
+                batch = list(islice(iter, batch_size))
+                if batch:
+                    yield batch
+                else:
+                    break
+
+        tile_f = ray.remote(num_cpus=1)(make_marker_tile)
+        cluster_remote_id = ray.put(clusterer)
+
+        utils.backpressure_queue_ray(
+            tile_f.remote,
+            list(
+                zip(
+                    repeat(cluster_remote_id),
+                    repeat(catalog_layer_dir),
+                    batch_params(iter(tile_idxs), batch_size),
+                )
+            ),
+            pbar_ref,
+            mp_procs,
+            batch_size,
+        )
     else:
-        list(map(tile_f, tracked_collection))
-
-
-def async_worker(q: JoinableQueue) -> None:
-    """Function for async task processesing.
-
-    Args:
-        q (JoinableQueue): Queue to retrieve tasks (func, args) from
-
-    Returns:
-        None
-    """
-    BLOCK = True
-    TIMEOUT = 5
-
-    while True:
-        try:
-            f, args = q.get(BLOCK, TIMEOUT)
-            f(*args)
-            q.task_done()
-        except Empty:
-            break
+        for zyx in tile_idxs:
+            make_marker_tile(clusterer, catalog_layer_dir, zyx)
+            OutputManager.update(pbar_ref, 1)
 
 
 def files_to_map(
@@ -951,10 +1009,12 @@ def files_to_map(
     cat_wcs_fits_file: str = None,
     max_catalog_zoom: int = -1,
     tile_size: Tuple[int, int] = [256, 256],
-    image_engine: str = IMG_ENGINE_MPL,
+    image_engine: str = IMG_ENGINE_PIL,
     norm_kwargs: dict = {},
     rows_per_column: int = np.inf,
     prefer_xy: bool = False,
+    catalog_starts_at_one: bool = True,
+    img_tile_batch_size: int = 1000,
 ) -> None:
     """Converts a list of files into a LeafletJS map.
 
@@ -965,8 +1025,8 @@ def files_to_map(
                        subdirectories
         min_zoom (int): The minimum zoom to create tiles for. The default value
                         is 0, but if it can be helpful to set it to a value
-                        greater than zero if your running out of memory as the
-                        lowest zoom images can be the most memory intensive.
+                        greater than zero if don't want the map to be able
+                        to zoom all the way out.
         title (str): The title to placed on the webpage
         task_procs (int): The number of tasks to run in parallel
         procs_per_task (int): The number of tiles to process in parallel
@@ -998,16 +1058,20 @@ def files_to_map(
                                column. Setting this value can make it easier to
                                work with catalogs that have a lot of values for
                                each object.
+        prefer_xy (bool): If True x/y coordinates should be preferred if both
+                          ra/dec and x/y are present in a catalog
+        catalog_starts_at_one (bool): True if the catalog is 1 indexed, False if
+                                      the catalog is 0 indexed
+        img_tile_batch_size (int): The number of image tiles to process in
+                                   parallel when task_procs > 1
     Returns:
         None
     """
 
-    if len(files) == 0:
-        raise ValueError("No files provided `files` is an empty list")
+    assert len(files) > 0, "No files provided `files` is an empty list"
 
     unlocatable_files = list(filterfalse(os.path.exists, files))
-    if len(unlocatable_files) > 0:
-        raise FileNotFoundError(unlocatable_files)
+    assert len(unlocatable_files) == 0, f"Files not found:{unlocatable_files}"
 
     if not os.path.exists(out_dir):
         os.makedirs(out_dir)
@@ -1018,6 +1082,10 @@ def files_to_map(
     if "css" not in os.listdir(out_dir):
         os.mkdir(os.path.join(out_dir, "css"))
 
+    # build image tasks
+    img_files = filter_on_extension(files, IMG_FORMATS)
+    img_layer_names = list(map(get_map_layer_name, img_files))
+
     img_f_kwargs = dict(
         tile_size=tile_size,
         min_zoom=min_zoom,
@@ -1025,12 +1093,24 @@ def files_to_map(
         out_dir=out_dir,
         mp_procs=procs_per_task,
         norm_kwargs=norm_kwargs,
+        batch_size=img_tile_batch_size,
     )
 
-    img_files = filter_on_extension(files, IMG_FORMATS)
-    img_layer_names = list(map(get_map_layer_name, img_files))
-    img_job_f = partial(tile_img, **img_f_kwargs)
+    # we want to init ray in such a way that it doesn't print any output
+    # to the console. These should be changed during development
+    ray.init(
+        include_dashboard=True,  # during dev == True
+        configure_logging=False,  # during dev == False
+        logging_level=logging.INFO,  # during dev == logging.INFO, test == logging.CRITICAL
+        log_to_driver=True,  # during dev = True
+    )
 
+    if task_procs > 1:
+        img_task_f = ray.remote(num_cpus=1)(tile_img).remote
+    else:
+        img_task_f = tile_img
+
+    # build catalog tasks
     cat_files = filter_on_extension(files, CAT_FORMAT)
     cat_layer_names = list(map(get_map_layer_name, cat_files))
 
@@ -1039,47 +1119,101 @@ def files_to_map(
     if len(cat_files) > 0:
         # get highlevel image info for catalogging function
         max_zoom = int(np.log2(2 ** np.ceil(np.log2(max_dim)) / tile_size[0]))
-        max_dim = 2 ** max_zoom * tile_size[0]
+        max_dim = 2**max_zoom * tile_size[0]
         if max_catalog_zoom == -1:
             max_zoom = int(np.log2(2 ** np.ceil(np.log2(max_dim)) / tile_size[0]))
         else:
             max_zoom = max_catalog_zoom
 
-        cat_job_f = partial(
-            tile_markers,
-            cat_wcs_fits_file,
-            out_dir,
-            catalog_delim,
-            procs_per_task,
-            prefer_xy,
-            min_zoom,
-            max_zoom,
-            tile_size[0],
-            max_dim,
-            max_dim,
+        if task_procs > 1:
+            cat_task_f = ray.remote(num_cpus=1)(tile_markers).remote
+        else:
+            cat_task_f = tile_markers
+
+        cat_f_kwargs = dict(
+            wcs_file=cat_wcs_fits_file,
+            out_dir=out_dir,
+            catalog_delim=catalog_delim,
+            mp_procs=procs_per_task,
+            prefer_xy=prefer_xy,
+            min_zoom=min_zoom,
+            max_zoom=max_zoom,
+            tile_size=tile_size[0],
+            max_x=max_dim,
+            max_y=max_dim,
+            catalog_starts_at_one=catalog_starts_at_one,
         )
     else:
-        cat_job_f = None
+        cat_task_f = None
+        cat_f_kwargs = dict()
+
+    output_manager = OutputManager()
 
-    pbar_locations = count(0)
+    img_tasks = zip(
+        repeat(img_task_f),
+        map(
+            lambda x: dict(**x[0], **x[1]),
+            zip(
+                repeat(img_f_kwargs),
+                starmap(
+                    lambda x, y: dict(file_location=x, pbar_ref=y),
+                    zip(img_files, output_manager.make_bar()),
+                ),
+            ),
+        ),
+    )
+
+    cat_tasks = zip(
+        repeat(cat_task_f),
+        map(
+            lambda x: dict(**x[0], **x[1]),
+            zip(
+                repeat(cat_f_kwargs),
+                starmap(
+                    lambda x, y: dict(catalog_file=x, pbar_ref=y),
+                    zip(cat_files, output_manager.make_bar()),
+                ),
+            ),
+        ),
+    )
 
-    img_tasks = zip(repeat(img_job_f), zip(img_files, pbar_locations))
-    cat_tasks = zip(repeat(cat_job_f), zip(cat_files, pbar_locations))
     tasks = chain(img_tasks, cat_tasks)
 
     if task_procs:
-        q = JoinableQueue()
-        any(map(lambda t: q.put(t), tasks))
-
-        workers = [Process(target=async_worker, args=[q]) for _ in range(task_procs)]
-        [w.start() for w in workers]  # can use any-map if this returns None
+        # start runnning task_procs number of tasks
+        in_progress = list(
+            starmap(
+                lambda i, func_kwargs: func_kwargs[0](**func_kwargs[1]),
+                zip(range(task_procs), tasks),
+            )
+        )
 
-        q.join()
+        while in_progress:
+            _, in_progress = ray.wait(in_progress, timeout=0.003)
+            output_manager.check_for_updates()
+            if len(in_progress) < task_procs:
+                try:
+                    # try to get a task with kwargs from the iterator
+                    func, kwargs = next(tasks)
+                    in_progress.append(func(**kwargs))
+                except StopIteration:
+                    # all of the tasks are in progress or completed
+                    if not in_progress:
+                        # we're done!
+                        break
+                    else:
+                        # the tasks 'in_progress' are still running
+                        pass
     else:
-        any(map(lambda func_args: func_args[0](*func_args[1]), tasks))
 
-    ns = "\n" * (next(pbar_locations) - 1)
-    print(ns + "Building index.html")
+        def f(func_args):
+            func_args[0](**func_args[1])
+
+        any(map(f, tasks))
+
+    output_manager.close_up()
+    ray.shutdown()
+    print("Building index.html")
 
     if cat_wcs_fits_file is not None:
         cat_wcs = WCS(cat_wcs_fits_file)
@@ -1110,9 +1244,12 @@ def dir_to_map(
     cat_wcs_fits_file: str = None,
     max_catalog_zoom: int = -1,
     tile_size: Shape = [256, 256],
-    image_engine: str = IMG_ENGINE_MPL,
+    image_engine: str = IMG_ENGINE_PIL,
     norm_kwargs: dict = {},
     rows_per_column: int = np.inf,
+    prefer_xy: bool = False,
+    catalog_starts_at_one: bool = True,
+    img_tile_batch_size: int = 1000,
 ) -> None:
     """Converts a list of files into a LeafletJS map.
 
@@ -1127,8 +1264,8 @@ def dir_to_map(
                                       processed
         min_zoom (int): The minimum zoom to create tiles for. The default value
                         is 0, but if it can be helpful to set it to a value
-                        greater than zero if your running out of memory as the
-                        lowest zoom images can be the most memory intensive.
+                        greater than zero if don't want the map to be able
+                        to zoom all the way out.
         title (str): The title to placed on the webpage
         task_procs (int): The number of tasks to run in parallel
         procs_per_task (int): The number of tiles to process in parallel
@@ -1164,6 +1301,12 @@ def dir_to_map(
                                column. Setting this value can make it easier to
                                work with catalogs that have a lot of values for
                                each object.
+        prefer_xy (bool): If True x/y coordinates should be preferred if both
+                          ra/dec and x/y are present in a catalog
+        catalog_starts_at_one (bool): True if the catalog is 1 indexed, False if
+                                      the catalog is 0 indexed
+        img_tile_batch_size (int): The number of image tiles to process in
+                                   parallel when task_procs > 1
     Returns:
         None
 
@@ -1174,14 +1317,16 @@ def dir_to_map(
     dir_files = list(
         map(
             lambda d: os.path.join(directory, d),
-            filterfalse(exclude_predicate, os.listdir(directory),),
+            filterfalse(
+                exclude_predicate,
+                os.listdir(directory),
+            ),
         )
     )
 
-    if len(dir_files) == 0:
-        raise ValueError(
-            "No files in `directory` or `exclude_predicate` excludes everything"
-        )
+    assert (
+        len(dir_files) > 0
+    ), "No files in `directory` or `exclude_predicate` excludes everything"
 
     files_to_map(
         sorted(dir_files),
@@ -1197,4 +1342,7 @@ def dir_to_map(
         image_engine=image_engine,
         norm_kwargs=norm_kwargs,
         rows_per_column=rows_per_column,
+        prefer_xy=prefer_xy,
+        catalog_starts_at_one=catalog_starts_at_one,
+        img_tile_batch_size=img_tile_batch_size,
     )
diff --git a/fitsmap/kdbush.py b/fitsmap/kdbush.py
index 9cf9e42..36878ee 100644
--- a/fitsmap/kdbush.py
+++ b/fitsmap/kdbush.py
@@ -51,12 +51,19 @@ def __init__(
         _sort(self.ids, self.coords, self.node_size, 0, n_points - 1, 0)
 
     def range(
-        self, min_x: int, min_y: int, max_x: int, max_y: int,
+        self,
+        min_x: int,
+        min_y: int,
+        max_x: int,
+        max_y: int,
     ):
         return _range(self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size)
 
     def within(
-        self, x: int, y: int, r: int,
+        self,
+        x: int,
+        y: int,
+        r: int,
     ):
         return _within(self.ids, self.coords, x, y, r, self.node_size)
 
@@ -149,7 +156,11 @@ def _swap_item(
 
 # based on:
 # https://github.com/mourner/kdbush/blob/ea3a81d272e1a87df3efe8c404021435dfa6cbfd/src/sort.js#L64
-def _swap(arr: np.ndarray, i: int, j: int,) -> None:  # updated in place
+def _swap(
+    arr: np.ndarray,
+    i: int,
+    j: int,
+) -> None:  # updated in place
     tmp = arr[i]
     arr[i] = arr[j]
     arr[j] = tmp
@@ -219,7 +230,12 @@ def _range(
 # based on:
 # https://github.com/mourner/kdbush/blob/ea3a81d272e1a87df3efe8c404021435dfa6cbfd/src/within.js#L2
 def _within(
-    ids: np.ndarray, coords: np.ndarray, qx: int, qy: int, r: int, node_size: int,
+    ids: np.ndarray,
+    coords: np.ndarray,
+    qx: int,
+    qy: int,
+    r: int,
+    node_size: int,
 ) -> List[int]:
     stack = [0, len(ids) - 1, 0]
     result = []
diff --git a/fitsmap/output_manager.py b/fitsmap/output_manager.py
new file mode 100644
index 0000000..0c2d382
--- /dev/null
+++ b/fitsmap/output_manager.py
@@ -0,0 +1,129 @@
+# MIT License
+# Copyright 2022 Ryan Hausen and contributers
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy of
+# this software and associated documentation files (the "Software"), to deal in
+# the Software without restriction, including without limitation the rights to
+# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+# the Software, and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+import os
+from itertools import count
+from typing import Tuple
+
+import ray.util.queue as queue
+from tqdm import tqdm
+
+
+class OutputManager:
+    """Manages all FitsMap console output for tasks."""
+
+    SENTINEL = -1
+
+    __instance = None
+
+    @staticmethod
+    def pbar_disabled():
+        return bool(os.getenv("DISBALE_TQDM", False))
+
+    def check_for_updates(func):
+        def f(*args, **kwargs):
+            func(*args, **kwargs)
+            if OutputManager.__instance and not OutputManager.pbar_disabled():
+                OutputManager.__instance.check_for_updates()
+
+        return f
+
+    @staticmethod
+    @check_for_updates
+    def write(pbar_ref: Tuple[int, queue.Queue], message: str) -> None:
+        idx, q = pbar_ref
+
+        def write(pbar):
+            pbar.clear()
+            pbar.display(message)
+
+        q.put([idx, write])
+
+    @staticmethod
+    @check_for_updates
+    def update(pbar_ref: Tuple[int, queue.Queue], value: int) -> None:
+        idx, q = pbar_ref
+        q.put([idx, lambda pbar: pbar.update(value)])
+
+    @staticmethod
+    @check_for_updates
+    def update_done(pbar_ref: Tuple[int, queue.Queue]) -> None:
+        OutputManager.update(pbar_ref, OutputManager.SENTINEL)
+
+    @staticmethod
+    @check_for_updates
+    def set_description(pbar_ref: Tuple[int, queue.Queue], desc: str) -> None:
+        idx, q = pbar_ref
+
+        def write(pbar):
+            pbar.clear()
+            pbar.set_description(desc)
+
+        q.put([idx, write])
+
+    @staticmethod
+    @check_for_updates
+    def set_units_total(
+        pbar_ref: Tuple[int, queue.Queue], unit: str, total: int
+    ) -> None:
+        idx, q = pbar_ref
+
+        def setup(pbar):
+            pbar.unit = unit
+            pbar.reset(total=total)
+
+        q.put([idx, setup])
+
+    def __init__(self):
+        self.progress_bars = dict()
+        self.in_progress = dict()
+        self.q = queue.Queue()
+        self.idx = count()
+        OutputManager.__instance = self
+
+    def make_bar(self) -> Tuple[int, queue.Queue]:
+        for idx in self.idx:
+            self.progress_bars[idx] = tqdm(
+                position=idx, disable=OutputManager.pbar_disabled(), leave=True
+            )
+            if not OutputManager.pbar_disabled():
+                self.progress_bars[idx].display("Preparing...")
+            self.in_progress[idx] = True
+            yield tuple([idx, self.q])
+
+    def check_for_updates(self):
+        if not self.q.empty():
+            idx, f = self.q.get(block=True)
+            running = not f == OutputManager.SENTINEL
+            self.in_progress[idx] = running
+
+            if running and not OutputManager.pbar_disabled():
+                f(self.progress_bars[idx])
+
+    def close_up(self):
+        list(
+            map(
+                lambda key: self.progress_bars[key].close(),
+                sorted(self.progress_bars.keys()),
+            )
+        )
+
+    @property
+    def jobs_running(self):
+        return any(self.in_progress.values())
diff --git a/fitsmap/padded_array.py b/fitsmap/padded_array.py
new file mode 100644
index 0000000..069d935
--- /dev/null
+++ b/fitsmap/padded_array.py
@@ -0,0 +1,93 @@
+# MIT License
+# Copyright 2022 Ryan Hausen and Contributers
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy of
+# this software and associated documentation files (the "Software"), to deal in
+# the Software without restriction, including without limitation the rights to
+# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+# the Software, and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+from typing import Any, Tuple, Union
+
+import numpy as np
+
+
+class PaddedArray:
+    def __init__(self, array: np.ndarray, pad: Tuple[int, int], tile_size=(256, 256)):
+        self.array = array
+        self.pad = pad
+
+        shape = [array.shape[0] + pad[0], array.shape[1] + pad[1]]
+        empty_tile_shape = list(tile_size)
+        if len(array.shape) == 3:
+            shape += [array.shape[2]]
+            empty_tile_shape += [array.shape[2]]
+
+        self.empty_tile = np.full(empty_tile_shape, np.nan, dtype=np.float32)
+        self.shape = tuple(shape)
+
+    def __get_internal_array(self, ys: slice, xs: slice) -> np.ndarray:
+        return self.array[ys, xs]
+
+    # def __get_padding(self, ys: slice, xs: slice) -> np.ndarray:
+    #     shape = [ys.stop - ys.start, xs.stop - xs.start]
+    #     if len(self.shape) == 3:
+    #         shape += [self.shape[2]]
+    #     return np.full(shape, np.nan, dtype=np.float32)
+
+    def __get_mixed(self, ys: slice, xs: slice) -> np.ndarray:
+        start_y, stop_y = ys.start, ys.stop
+        start_x, stop_x = xs.start, xs.stop
+
+        pad_y = max(0, stop_y - self.array.shape[0])
+        pad_x = max(0, stop_x - self.array.shape[1])
+
+        padding = [[0, pad_y], [0, pad_x]]
+        if len(self.array.shape) == 3:
+            padding += [[0, 0]]
+
+        slice_ys = slice(start_y, min(self.array.shape[0], stop_y))
+        slice_xs = slice(start_x, min(self.array.shape[1], stop_x))
+
+        return np.pad(
+            self.array[slice_ys, slice_xs],
+            padding,
+            mode="constant",
+            constant_values=np.nan,
+        )
+
+    def __getitem__(self, axis_slices: Tuple[slice, slice]) -> np.ndarray:
+        if type(axis_slices) is not tuple:
+            axis_slices = [axis_slices, slice(None)]
+
+        ys, xs = axis_slices
+        start_y, stop_y = ys.start or 0, ys.stop or self.shape[0]
+        start_x, stop_x = xs.start or 0, xs.stop or self.shape[1]
+
+        slice_ys = slice(start_y, stop_y)
+        slice_xs = slice(start_x, stop_x)
+
+        if stop_y < self.array.shape[0] and stop_x < self.array.shape[1]:
+            return self.__get_internal_array(slice_ys, slice_xs)
+        elif start_y > self.array.shape[0] or start_x > self.array.shape[1]:
+            # this is a hack because in our use case we know we always
+            # want the same sized thing. In other use cases the commented out
+            # function should be called
+            return self.empty_tile
+            # return self.__get_padding(slice_ys, slice_xs)
+        else:
+            return self.__get_mixed(slice_ys, slice_xs)
+
+    def __reduce__(self) -> Union[str, Tuple[Any, ...]]:
+        return PaddedArray, (self.array, self.pad)
diff --git a/fitsmap/supercluster.py b/fitsmap/supercluster.py
index 885ccca..2d58444 100644
--- a/fitsmap/supercluster.py
+++ b/fitsmap/supercluster.py
@@ -207,7 +207,7 @@ def get_leaves(self, cluster_id, limit, offset):
     # https://github.com/ryanhausen/supercluster/blob/97dbc5687fe0c6c3e63bafc15ad3d942bbd316b6/index.js#L151
     def get_tile(self, z, x, y):
         tree = self.trees[self._limit_zoom(z)]
-        z2 = 2 ** z
+        z2 = 2**z
         p = self.radius / self.extent
         top = (y - p) / z2
         bottom = (y + 1 + p) / z2
@@ -223,12 +223,22 @@ def get_tile(self, z, x, y):
 
         if x == 0:
             tile = self._add_tile_features(
-                tree.range(1 - p / z2, top, 1, bottom), tree.points, z2, y, z2, tile,
+                tree.range(1 - p / z2, top, 1, bottom),
+                tree.points,
+                z2,
+                y,
+                z2,
+                tile,
             )
 
         if x == z2 - 1:
             tile = self._add_tile_features(
-                tree.range(0, top, p / z2, bottom), tree.points, -1, y, z2, tile,
+                tree.range(0, top, p / z2, bottom),
+                tree.points,
+                -1,
+                y,
+                z2,
+                tile,
             )
 
         return tile if len(tile["features"]) else None
@@ -259,7 +269,11 @@ def _append_leaves(self, result, cluster_id, limit, offset, skipped):
                     skipped += props["point_count"]
                 else:
                     skipped = self._append_leaves(
-                        result, props["cluster_id"], limit, offset, skipped,
+                        result,
+                        props["cluster_id"],
+                        limit,
+                        offset,
+                        skipped,
                     )
             elif skipped < offset:
                 skipped += 1
@@ -320,7 +334,7 @@ def _limit_zoom(self, z):
     # https://github.com/mapbox/supercluster/blob/60d13df9c7d96e9ad16b43c8aca897b5aea38ac9/index.js#L246
     def _cluster(self, points, zoom: int):
         clusters = []
-        r = self.radius / (self.extent * 2 ** zoom)
+        r = self.radius / (self.extent * 2**zoom)
 
         for i in range(len(points)):
             p = points[i]
diff --git a/fitsmap/support/l.ellipse.js b/fitsmap/support/l.ellipse.js
index 3eb4c7c..ae284bc 100644
--- a/fitsmap/support/l.ellipse.js
+++ b/fitsmap/support/l.ellipse.js
@@ -1,3 +1,4 @@
+// Forked from below to here: https://github.com/ryanhausen/Leaflet.Ellipse
 /**
  * Copyright 2014 JD Fergason
  *
@@ -13,17 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
- 
- /*
-  * Adapted from the following fork:
-  * https://github.com/SlidEnergy/Leaflet.Ellipse
-  * which fixes the CRS.Simple bug but breaks compatibility with MarkerSearch
-  * so I removed the lines that are compatible with a non CRS.Simple map and 
-  * it works as expected for my use case only. 
-  */
 
 
-L.SVG.include ({
+ L.SVG.include ({
     _updateEllipse: function (layer) {
         var c = layer._point,
             rx = layer._radiusX,
@@ -43,28 +36,27 @@ L.Canvas.include ({
     _updateEllipse: function (layer) {
         if (layer._empty()) { return; }
 
-        var p = layer._point,
+        let p = layer._point,
             ctx = this._ctx,
             r = layer._radiusX,
             s = (layer._radiusY || r) / r;
 
-        this._drawnLayers[layer._leaflet_id] = layer;
+        if (this.hasOwnProperty('_drawnLayers')) {
+            this._drawnlayers[layer._leaflet_id] = layer;
+        } else if (this.hasOwnProperty('_layers')) {
+            this._layers[layer._leaflet_id] = layer;
+        } else {
+            throw new Error("Cannot find property _drawnLayers or _layers");
+        }
 
         ctx.save();
+	    ctx.beginPath();
+	    console.log(p.x, p.y, layer._tilt, layer._tiltDeg);
+        ctx.ellipse(p.x, p.y, layer._radiusX, layer._radiusY, layer._tilt, 0, 2 * Math.PI, true);
 
-        ctx.translate(p.x, p.y);
-        if (layer._tilt !== 0) {
-            ctx.rotate( layer._tilt );
-        }
-        if (s !== 1) {
-            ctx.scale(1, s);
-        }
-
-        ctx.beginPath();
-        ctx.arc(0, 0, r, 0, Math.PI * 2);
         ctx.restore();
 
-        this._fillStroke(ctx, layer);
+        this._fillStroke(ctx, layer)
     },
 });
 
@@ -78,15 +70,21 @@ L.Ellipse = L.Path.extend({
 
     initialize: function (latlng, radii, tilt, options) {
 
+	console.log(latlng, tilt);
         L.setOptions(this, options);
         this._latlng = L.latLng(latlng);
 
         if (tilt) {
-            this._tiltDeg = tilt;
+            // multiply by -1 and add 180 so that degress start from the west
+            // and increase counter clockwise
+            this._tiltDeg = tilt * -1 + 180;
+            this._tilt = Math.PI * this._tiltDeg / 180;
         } else {
             this._tiltDeg = 0;
+            this._tilt = 0;
         }
 
+
         if (radii) {
             this._mRadiusX = radii[0];
             this._mRadiusY = radii[1];
@@ -175,16 +173,20 @@ L.Ellipse = L.Path.extend({
         this._renderer._updateEllipse(this);
     },
 
-    // this is changed to not check for non.CRS.simple
-    // https://raw.githubusercontent.com/SlidEnergy/Leaflet.Ellipse/master/l.ellipse.js
     _getLatRadius: function () {
+      var simpleCrs = !!this._map.options.crs.infinite;
+      if(simpleCrs)
         return this._mRadiusY;
+      else
+        return (this._mRadiusY / 40075017) * 360;
     },
 
-    // this is changed to not check for non.CRS.simple
-    // https://raw.githubusercontent.com/SlidEnergy/Leaflet.Ellipse/master/l.ellipse.js
     _getLngRadius: function () {
+      var simpleCrs = !!this._map.options.crs.infinite;
+      if(simpleCrs)
         return this._mRadiusX;
+      else
+        return ((this._mRadiusX / 40075017) * 360) / Math.cos((Math.PI / 180) * this._latlng.lat);
     },
 
     _centerPointToEndPoint: function () {
diff --git a/fitsmap/support/l.ellipse.min.js b/fitsmap/support/l.ellipse.min.js
index e202c6f..be9c0b1 100644
--- a/fitsmap/support/l.ellipse.min.js
+++ b/fitsmap/support/l.ellipse.min.js
@@ -1,1 +1,1 @@
-L.SVG.include({_updateEllipse:function(t){t._point;var i=t._radiusX,s=t._radiusY,n=t._tiltDeg,a=t._endPointParams,e="M"+a.x0+","+a.y0+"A"+i+","+s+","+n+","+a.largeArc+","+a.sweep+","+a.x1+","+a.y1+" z";this._setPath(t,e)}}),L.Canvas.include({_updateEllipse:function(t){if(!t._empty()){var i=t._point,s=this._ctx,n=t._radiusX,a=(t._radiusY||n)/n;this._drawnLayers[t._leaflet_id]=t,s.save(),s.translate(i.x,i.y),0!==t._tilt&&s.rotate(t._tilt),1!==a&&s.scale(1,a),s.beginPath(),s.arc(0,0,n,0,2*Math.PI),s.restore(),this._fillStroke(s,t)}}}),L.Ellipse=L.Path.extend({options:{fill:!0,startAngle:0,endAngle:359.9},initialize:function(t,i,s,n){L.setOptions(this,n),this._latlng=L.latLng(t),this._tiltDeg=s||0,i&&(this._mRadiusX=i[0],this._mRadiusY=i[1])},setRadius:function(t){return this._mRadiusX=t[0],this._mRadiusY=t[1],this.redraw()},getRadius:function(){return new L.point(this._mRadiusX,this._mRadiusY)},setTilt:function(t){return this._tiltDeg=t,this.redraw()},getBounds:function(){var t=this._getLngRadius(),i=this._getLatRadius(),s=this._latlng;return new L.LatLngBounds([s.lat-i,s.lng-t],[s.lat+i,s.lng+t])},setLatLng:function(t){return this._latlng=L.latLng(t),this.redraw(),this.fire("move",{latlng:this._latlng})},getLatLng:function(){return this._latlng},setStyle:L.Path.prototype.setStyle,_project:function(){var t=this._getLngRadius(),i=this._getLatRadius(),s=this._latlng,n=this._map.latLngToLayerPoint([s.lat,s.lng-t]),a=this._map.latLngToLayerPoint([s.lat-i,s.lng]);this._point=this._map.latLngToLayerPoint(s),this._radiusX=Math.max(this._point.x-n.x,1),this._radiusY=Math.max(a.y-this._point.y,1),this._tilt=Math.PI*this._tiltDeg/180,this._endPointParams=this._centerPointToEndPoint(),this._updateBounds()},_updateBounds:function(){var t=Math.sin(this._tilt),i=Math.cos(this._tilt),s=t*t,n=i*i,a=this._radiusX*this._radiusX,e=this._radiusY*this._radiusY,h=Math.sqrt(a*n+e*s),_=Math.sqrt(a*s+e*n),r=this._clickTolerance(),u=[h+r,_+r];this._pxBounds=new L.Bounds(this._point.subtract(u),this._point.add(u))},_update:function(){this._map&&this._updatePath()},_updatePath:function(){this._renderer._updateEllipse(this)},_getLatRadius:function(){return this._mRadiusY},_getLngRadius:function(){return this._mRadiusX},_centerPointToEndPoint:function(){var t=this._point,i=this._radiusX,s=this._radiusY,n=(this.options.startAngle+this.options.endAngle)*(Math.PI/180),a=this.options.startAngle*(Math.PI/180),e=this.options.endAngle,h=this._tiltDeg*(Math.PI/180),_=t.x+Math.cos(h)*i*Math.cos(a)+Math.sin(-h)*s*Math.sin(a),r=t.y+Math.sin(h)*i*Math.cos(a)+Math.cos(h)*s*Math.sin(a),u=t.x+Math.cos(h)*i*Math.cos(n)+Math.sin(-h)*s*Math.sin(n),o=t.y+Math.sin(h)*i*Math.cos(n)+Math.cos(h)*s*Math.sin(n);return{x0:_,y0:r,tilt:h,largeArc:e>180?1:0,sweep:e>0?1:0,x1:u,y1:o}},_empty:function(){return this._radiusX&&this._radiusY&&!this._renderer._bounds.intersects(this._pxBounds)},_containsPoint:function(t){var i=Math.sin(this._tilt),s=Math.cos(this._tilt),n=t.x-this._point.x,a=t.y-this._point.y,e=s*n+i*a,h=i*n-s*a;return e*e/(this._radiusX*this._radiusX)+h*h/(this._radiusY*this._radiusY)<=1}}),L.ellipse=function(t,i,s,n){return new L.Ellipse(t,i,s,n)};
+L.SVG.include({_updateEllipse:function(t){t._point;var i=t._radiusX,s=t._radiusY,n=t._tiltDeg,a=t._endPointParams,e="M"+a.x0+","+a.y0+"A"+i+","+s+","+n+","+a.largeArc+","+a.sweep+","+a.x1+","+a.y1+" z";this._setPath(t,e)}}),L.Canvas.include({_updateEllipse:function(t){if(t._empty())return;let i=t._point,s=this._ctx,n=t._radiusX;t._radiusY;if(this.hasOwnProperty("_drawnLayers"))this._drawnlayers[t._leaflet_id]=t;else{if(!this.hasOwnProperty("_layers"))throw new Error("Cannot find property _drawnLayers or _layers");this._layers[t._leaflet_id]=t}s.save(),s.beginPath(),console.log(i.x,i.y,t._tilt,t._tiltDeg),s.ellipse(i.x,i.y,t._radiusX,t._radiusY,t._tilt,0,2*Math.PI,!0),s.restore(),this._fillStroke(s,t)}}),L.Ellipse=L.Path.extend({options:{fill:!0,startAngle:0,endAngle:359.9},initialize:function(t,i,s,n){console.log(t,s),L.setOptions(this,n),this._latlng=L.latLng(t),s?(this._tiltDeg=-1*s+180,this._tilt=Math.PI*this._tiltDeg/180):(this._tiltDeg=0,this._tilt=0),i&&(this._mRadiusX=i[0],this._mRadiusY=i[1])},setRadius:function(t){return this._mRadiusX=t[0],this._mRadiusY=t[1],this.redraw()},getRadius:function(){return new L.point(this._mRadiusX,this._mRadiusY)},setTilt:function(t){return this._tiltDeg=t,this.redraw()},getBounds:function(){var t=this._getLngRadius(),i=this._getLatRadius(),s=this._latlng;return new L.LatLngBounds([s.lat-i,s.lng-t],[s.lat+i,s.lng+t])},setLatLng:function(t){return this._latlng=L.latLng(t),this.redraw(),this.fire("move",{latlng:this._latlng})},getLatLng:function(){return this._latlng},setStyle:L.Path.prototype.setStyle,_project:function(){var t=this._getLngRadius(),i=this._getLatRadius(),s=this._latlng,n=this._map.latLngToLayerPoint([s.lat,s.lng-t]),a=this._map.latLngToLayerPoint([s.lat-i,s.lng]);this._point=this._map.latLngToLayerPoint(s),this._radiusX=Math.max(this._point.x-n.x,1),this._radiusY=Math.max(a.y-this._point.y,1),this._tilt=Math.PI*this._tiltDeg/180,this._endPointParams=this._centerPointToEndPoint(),this._updateBounds()},_updateBounds:function(){var t=Math.sin(this._tilt),i=Math.cos(this._tilt),s=t*t,n=i*i,a=this._radiusX*this._radiusX,e=this._radiusY*this._radiusY,h=Math.sqrt(a*n+e*s),r=Math.sqrt(a*s+e*n),_=this._clickTolerance(),o=[h+_,r+_];this._pxBounds=new L.Bounds(this._point.subtract(o),this._point.add(o))},_update:function(){this._map&&this._updatePath()},_updatePath:function(){this._renderer._updateEllipse(this)},_getLatRadius:function(){return!!this._map.options.crs.infinite?this._mRadiusY:this._mRadiusY/40075017*360},_getLngRadius:function(){return!!this._map.options.crs.infinite?this._mRadiusX:this._mRadiusX/40075017*360/Math.cos(Math.PI/180*this._latlng.lat)},_centerPointToEndPoint:function(){var t=this._point,i=this._radiusX,s=this._radiusY,n=(this.options.startAngle+this.options.endAngle)*(Math.PI/180),a=this.options.startAngle*(Math.PI/180),e=this.options.endAngle,h=this._tiltDeg*(Math.PI/180);return{x0:t.x+Math.cos(h)*i*Math.cos(a)+Math.sin(-h)*s*Math.sin(a),y0:t.y+Math.sin(h)*i*Math.cos(a)+Math.cos(h)*s*Math.sin(a),tilt:h,largeArc:e>180?1:0,sweep:e>0?1:0,x1:t.x+Math.cos(h)*i*Math.cos(n)+Math.sin(-h)*s*Math.sin(n),y1:t.y+Math.sin(h)*i*Math.cos(n)+Math.cos(h)*s*Math.sin(n)}},_empty:function(){return this._radiusX&&this._radiusY&&!this._renderer._bounds.intersects(this._pxBounds)},_containsPoint:function(t){var i=Math.sin(this._tilt),s=Math.cos(this._tilt),n=t.x-this._point.x,a=t.y-this._point.y,e=s*n+i*a,h=i*n-s*a;return e*e/(this._radiusX*this._radiusX)+h*h/(this._radiusY*this._radiusY)<=1}}),L.ellipse=function(t,i,s,n){return new L.Ellipse(t,i,s,n)};
\ No newline at end of file
diff --git a/fitsmap/support/tiledMarkers.js b/fitsmap/support/tiledMarkers.js
index e374840..1efee02 100644
--- a/fitsmap/support/tiledMarkers.js
+++ b/fitsmap/support/tiledMarkers.js
@@ -5,6 +5,9 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
     options: {
         tileURL: "",
         color: "#4C72B0",
+        fillOpacity: 0.2,
+        strokeOpacity: 1.0,
+        radius: 10,
         rowsPerCol: Infinity,
         catalogColumns: [],
     },
@@ -23,7 +26,6 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
         const values = json["v"];
 
         let nCols = Math.floor(nItems / (Number.isFinite(rowsPerCol) ? rowsPerCol : nItems));
-        // console.log(nCols);
         if (Number.isFinite(rowsPerCol) && nItems % rowsPerCol > 0) {
             nCols += 1;
         }
@@ -31,9 +33,6 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
         let html = "<span>Catalog Information</span>" +
                     "<table class='catalog-table'>";
 
-        // const display_keys = catalogColumns.filter(k => {return !dontDisplay.includes(k)});
-        // console.log(display_keys);
-
         let colCounter = 0;
         html += "<tr>";
         for (let i = 0; i < nItems; i++){
@@ -73,13 +72,10 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
             console.log("ERROR in Popup Rendering", error);
         });
 
-        // console.log(marker);
-
         // https://stackoverflow.com/a/51749619/2691018
         document.querySelector(".leaflet-popup-pane").addEventListener("load", function(event) {
             const tagName = event.target.tagName,
             popup = map._popup;
-            //console.log("got load event from " + tagName);
             // Also check if flag is already set.
             if (tagName === "IMG" && popup && !popup._updated) {
                 popup._updated = true; // Set flag to prevent looping.
@@ -97,13 +93,17 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
                      .setLatLng(latlng)
                      .setContent((layer) => this.renderPopupContents(this, layer));
 
+            console.log(src);
             if (src.a==-1){
                 return L.circleMarker(latlng, {
                     color: this.options.color,
-                    assetPath: `catalog_assets/${src.cat_path}/${src.catalog_id}.cbor`
+                    assetPath: `catalog_assets/${src.cat_path}/${src.catalog_id}.cbor`,
+                    opacity: this.options.strokeOpacity,
+                    fillOpacity: this.options.fillOpacity,
+                    radius: this.options.radius,
                 }).bindPopup(p);
             } else {
-                return L.ellipse(latlng, [src.a, src.b], (src.theta * (180/Math.PI) * -1), {
+                return L.ellipse(latlng, [src.a, src.b], src.theta, {
                     color: this.options.color,
                     assetPath: `catalog_assets/${src.cat_path}/${src.catalog_id}.cbor`
                 }).bindPopup(p);
@@ -146,9 +146,6 @@ L.GridLayer.TiledMarkers = L.GridLayer.extend({
 
 
     createTile: function (coords, done) {
-        //const bnds = this._pxBoundsToTileRange(this._getTiledPixelBounds(map.getCenter()));
-        //const max_y = bnds.max.y;
-        //const offset_y = max_y - coords.y;
         const offset_y = 2**coords.z - coords.y - 1
         const offset_x = 2**coords.z - coords.x - 1
         const resourceURL = this.options.tileURL
diff --git a/fitsmap/support/tiledMarkers.min.js b/fitsmap/support/tiledMarkers.min.js
index 91a6a7c..7d46a76 100644
--- a/fitsmap/support/tiledMarkers.min.js
+++ b/fitsmap/support/tiledMarkers.min.js
@@ -1,1 +1,1 @@
-"use strict";L.GridLayer.TiledMarkers=L.GridLayer.extend({options:{tileURL:"",color:"#4C72B0",rowsPerCol:1/0,catalogColumns:[]},initialize:function(t){L.setOptions(this,t),this.tilePointCache={}},convertJSONtoHTMLTable:function(t){const e=this.options.catalogColumns.length,o=this.options.rowsPerColumn,n=t.v;let a=Math.floor(e/(Number.isFinite(o)?o:e));Number.isFinite(o)&&e%o>0&&(a+=1);let r="<span>Catalog Information</span><table class='catalog-table'>",i=0;r+="<tr>";for(let t=0;t<e;t++){r+=`<td><b>${this.options.catalogColumns[t]}:<b></td><td>${n[t]}</td>`,i+=1,i==a&&(i=0,r+="</tr>")}return r+="</table>",r},renderPopupContents:function(t,e){const o=e.getPopup();return fetch(e.options.assetPath).then((t=>{if(!t.ok)throw console.log(t),new Error("Failed to fetch JSON",t);return t.arrayBuffer()})).then((t=>cbor.decodeAll(t))).then((e=>{o.setContent(t.convertJSONtoHTMLTable(e[0])).update()})).catch((t=>{console.log("ERROR in Popup Rendering",t)})),document.querySelector(".leaflet-popup-pane").addEventListener("load",(function(t){const e=t.target.tagName,o=map._popup;"IMG"===e&&o&&!o._updated&&(o._updated=!0,o.update())}),!0),"Loading..."},createClusterIcon:function(t){const e=L.latLng(t.global_y,t.global_x);if(!t.cluster){const o=L.popup({maxWidth:"auto"}).setLatLng(e).setContent((t=>this.renderPopupContents(this,t)));return-1==t.a?L.circleMarker(e,{color:this.options.color,assetPath:`catalog_assets/${t.cat_path}/${t.catalog_id}.cbor`}).bindPopup(o):L.ellipse(e,[t.a,t.b],t.theta*(180/Math.PI)*-1,{color:this.options.color,assetPath:`catalog_assets/${t.cat_path}/${t.catalog_id}.cbor`}).bindPopup(o)}const o=t.point_count,n=o<100?"small":o<1e3?"medium":o<1e6?"large":"x-large",a=L.divIcon({html:`<div><span>${t.point_count_abbreviated}</span></div>`,className:`marker-cluster marker-cluster-${n}`,iconSize:L.point(40,40)});return L.marker(e,{icon:a}).bindPopup(`${t.global_y}, ${t.global_x}`)},parseTileResponse:function(t,e){200==e.status&&e.arrayBuffer().then((e=>{this.tilePointCache[t]||(this.tilePointCache[t]=[]);const o=new Pbf(e),n=new VectorTile(o).layers.Points;for(let e=0;e<n.length;e++){let o=n.feature(e);this.tilePointCache[t].push(this.createClusterIcon(o.properties).addTo(map))}})).catch((t=>console.log(t)))},createTile:function(t,e){const o=2**t.z-t.y-1,n=(t.z,t.x,this.options.tileURL.replace("{z}",`${t.z}`).replace("{y}",`${o}`).replace("{x}",`${t.x}`)),a=`${t.z},${t.y},${t.x}`;return fetch(n).then((t=>this.parseTileResponse(a,t))).catch((t=>{console.log(t)})),L.DomUtil.create("canvas","leaflet-tile")},clearItems:function(t){const e=`${t.coords.z},${t.coords.y},${t.coords.x}`;if(this.tilePointCache[e])for(;this.tilePointCache[e].length;){let t=this.tilePointCache[e].pop().remove();t=null}}}),L.gridLayer.tiledMarkers=function(t){const e=new L.GridLayer.TiledMarkers(t);return e.on("tileunload",e.clearItems),e};
\ No newline at end of file
+"use strict";L.GridLayer.TiledMarkers=L.GridLayer.extend({options:{tileURL:"",color:"#4C72B0",fillOpacity:.2,strokeOpacity:1,radius:10,rowsPerCol:1/0,catalogColumns:[]},initialize:function(t){L.setOptions(this,t),this.tilePointCache={}},convertJSONtoHTMLTable:function(t){const e=this.options.catalogColumns.length,o=this.options.rowsPerColumn,a=t.v;let n=Math.floor(e/(Number.isFinite(o)?o:e));Number.isFinite(o)&&e%o>0&&(n+=1);let i="<span>Catalog Information</span><table class='catalog-table'>",s=0;i+="<tr>";for(let t=0;t<e;t++){i+=`<td><b>${this.options.catalogColumns[t]}:<b></td><td>${a[t]}</td>`,s+=1,s==n&&(s=0,i+="</tr>")}return i+="</table>",i},renderPopupContents:function(t,e){const o=e.getPopup();return fetch(e.options.assetPath).then((t=>{if(!t.ok)throw console.log(t),new Error("Failed to fetch JSON",t);return t.arrayBuffer()})).then((t=>cbor.decodeAll(t))).then((e=>{o.setContent(t.convertJSONtoHTMLTable(e[0])).update()})).catch((t=>{console.log("ERROR in Popup Rendering",t)})),document.querySelector(".leaflet-popup-pane").addEventListener("load",(function(t){const e=t.target.tagName,o=map._popup;"IMG"===e&&o&&!o._updated&&(o._updated=!0,o.update())}),!0),"Loading..."},createClusterIcon:function(t){const e=L.latLng(t.global_y,t.global_x);if(!t.cluster){const o=L.popup({maxWidth:"auto"}).setLatLng(e).setContent((t=>this.renderPopupContents(this,t)));return console.log(t),-1==t.a?L.circleMarker(e,{color:this.options.color,assetPath:`catalog_assets/${t.cat_path}/${t.catalog_id}.cbor`,opacity:this.options.strokeOpacity,fillOpacity:this.options.fillOpacity,radius:this.options.radius}).bindPopup(o):L.ellipse(e,[t.a,t.b],t.theta,{color:this.options.color,assetPath:`catalog_assets/${t.cat_path}/${t.catalog_id}.cbor`}).bindPopup(o)}const o=t.point_count,a=o<100?"small":o<1e3?"medium":o<1e6?"large":"x-large",n=L.divIcon({html:`<div><span>${t.point_count_abbreviated}</span></div>`,className:`marker-cluster marker-cluster-${a}`,iconSize:L.point(40,40)});return L.marker(e,{icon:n}).bindPopup(`${t.global_y}, ${t.global_x}`)},parseTileResponse:function(t,e){200==e.status&&e.arrayBuffer().then((e=>{this.tilePointCache[t]||(this.tilePointCache[t]=[]);const o=new Pbf(e),a=new VectorTile(o).layers.Points;for(let e=0;e<a.length;e++){let o=a.feature(e);this.tilePointCache[t].push(this.createClusterIcon(o.properties).addTo(map))}})).catch((t=>console.log(t)))},createTile:function(t,e){const o=2**t.z-t.y-1,a=(t.z,t.x,this.options.tileURL.replace("{z}",`${t.z}`).replace("{y}",`${o}`).replace("{x}",`${t.x}`)),n=`${t.z},${t.y},${t.x}`;return fetch(a).then((t=>this.parseTileResponse(n,t))).catch((t=>{console.log(t)})),L.DomUtil.create("canvas","leaflet-tile")},clearItems:function(t){const e=`${t.coords.z},${t.coords.y},${t.coords.x}`;if(this.tilePointCache[e])for(;this.tilePointCache[e].length;){let t=this.tilePointCache[e].pop().remove();t=null}}}),L.gridLayer.tiledMarkers=function(t){const e=new L.GridLayer.TiledMarkers(t);return e.on("tileunload",e.clearItems),e};
\ No newline at end of file
diff --git a/fitsmap/utils.py b/fitsmap/utils.py
index bd664ca..ec8d3a9 100644
--- a/fitsmap/utils.py
+++ b/fitsmap/utils.py
@@ -19,15 +19,18 @@
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 import os
 import string
-from functools import reduce
+from functools import partial, reduce
 from itertools import chain, filterfalse
-from typing import Iterable, List, Tuple
+from typing import Any, Callable, Iterable, List, Tuple
 
+import ray
+import ray.util.queue as queue
 from astropy.io import fits
 from tqdm import tqdm
 from PIL import Image
 
 import fitsmap
+from fitsmap.output_manager import OutputManager
 
 
 def digit_to_string(digit: int) -> str:
@@ -107,7 +110,10 @@ def peek_image_info(img_file_names: List[str]) -> Tuple[int, int]:
     """
 
     fits_sizes = list(
-        map(get_fits_image_size, filter(lambda f: f.endswith("fits"), img_file_names),)
+        map(
+            get_fits_image_size,
+            filter(lambda f: f.endswith("fits"), img_file_names),
+        )
     )
 
     standard_sizes = list(
@@ -131,6 +137,50 @@ def get_version():
         return f.readline().strip().replace('"', "")
 
 
+def backpressure_queue(
+    wait_f: Callable,
+    work_f: Callable,
+    f_args: List[List[Any]],
+    pbar_ref: Tuple[int, queue.Queue],
+    n_parallel_jobs: int,
+    batch_size: int = 1,
+) -> None:
+    """A queue that will limit things processed in parallel.
+
+    Args:
+        wait_f (Callable): A function that will block until a process is finished
+        work_f (Callable): A function that accepts a single element from args
+        f_args (List[List[Any]]): A list of function args for work_f
+        bar (tqdm): A tqdm progress bar
+        n_parallel_jobs (int): The number of args to process in parallel
+
+    Returns:
+        None
+    """
+    # queue n jobs to be processed by ray
+    in_progress = [
+        work_f(*f_args.pop(0)) for _ in zip(range(n_parallel_jobs), range(len(f_args)))
+    ]
+    # print("in_progress", len(in_progress))
+    while in_progress:
+        # ray.wait blocks until at least one job is done
+        _, in_progress = wait_f(in_progress)
+        OutputManager.update(pbar_ref, batch_size)
+
+        if f_args:
+            # add another job to the queue for ray to work on
+            in_progress.append(work_f(*f_args.pop(0)))
+        elif in_progress:
+            # the only jobs left are already in the queue
+            pass
+        else:
+            # all jobs complete
+            break
+
+
+backpressure_queue_ray = partial(backpressure_queue, ray.wait)
+
+
 class MockQueue:
     def __init__(self, bar):
         self.bar = bar
diff --git a/requirements.txt b/requirements.txt
index cd389a1..70ddde9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,119 +1,11 @@
-alabaster==0.7.12
-appdirs==1.4.3
-astroid==2.3.3
-astropy==4.3.1
-atomicwrites==1.3.0
-attrs==19.3.0
-Babel==2.9.1
-black==19.10b0
-bleach==3.3.0
-cbor2==5.4.2.post1
-certifi==2020.6.20
-cffi==1.14.5
-chardet==4.0.0
-Click==7.0
-cloudpickle==1.6.0
-colorama==0.4.4
-colossus==1.2.19
-coverage==5.3
-cryptography==3.4.7
-cycler==0.10.0
-cytoolz==0.11.0
-dask==2021.10.0
-decorator==4.4.2
-dnspython==1.16.0
-docopt==0.6.2
-docutils==0.17.1
-fett==0.3.2
-fsspec==2021.11.1
-future==0.18.2
-idna==2.10
-imageio==2.6.1
-imagesize==1.2.0
-importlib-metadata==4.6.0
-isort==4.3.21
-jeepney==0.6.0
-Jinja2==3.0.1
-keyring==23.0.1
-kiwisolver==1.1.0
-lazy-object-proxy==1.4.3
-livereload==2.6.3
-locket==0.2.1
-mapbox-vector-tile==1.2.1
-MarkupSafe==2.0.1
-matplotlib==3.1.2
-mccabe==0.6.1
-more-itertools==7.2.0
-mypy-extensions==0.4.3
-networkx==2.5
-numpy==1.17.3
-olefile==0.46
-packaging==20.0
-partd==1.2.0
-pathspec==0.6.0
-Pillow==9.1.0
-pkginfo==1.7.0
-pluggy==0.13.0
-protobuf==3.17.3
-psutil==5.6.6
-py==1.10.0
-pyclipper==1.3.0
-pycparser==2.20
-pyerfa==2.0.0
-Pygments==2.9.0
-pylint==2.4.4
-pymongo==3.11.4
-pyparsing==2.4.5
-PyQt5==5.12.3
-PyQt5_sip==4.19.18
-PyQtWebEngine==5.12.1
-pytest==5.2.3
-pytest-arraydiff==0.3
-pytest-astropy==0.6.0
-pytest-astropy-header==0.1.1
-pytest-cov==2.10.1
-pytest-doctestplus==0.4.0
-pytest-openfiles==0.4.0
-pytest-remotedata==0.3.1
-python-dateutil==2.8.1
-python-jsonrpc-server==0.3.4
-pytz==2021.1
-PyWavelets==1.1.1
-PyYAML==5.4
-readme-renderer==29.0
-regex==2019.11.1
-requests==2.25.1
-requests-toolbelt==0.9.1
-rfc3986==1.5.0
-rstcheck==3.3.1
-scikit-image==0.17.2
-scipy==1.5.2
-SecretStorage==3.3.1
-Shapely==1.7.1
-sharedmem==0.3.8
-six==1.13.0
-snooty-lextudio==1.9.2.dev0
-snowballstemmer==2.1.0
-Sphinx==4.0.2
-sphinx-autobuild==2021.3.14
-sphinxcontrib-applehelp==1.0.2
-sphinxcontrib-devhelp==1.0.2
-sphinxcontrib-htmlhelp==2.0.0
-sphinxcontrib-jsmath==1.0.1
-sphinxcontrib-qthelp==1.0.3
-sphinxcontrib-serializinghtml==1.1.5
-tifffile==2020.10.1
-toml==0.10.0
-toolz==0.11.1
-tornado==6.0.3
-tqdm==4.38.0
-twine==3.4.1
-typed-ast==1.4.0
-typing-extensions==3.7.4.1
-ujson==1.35
-urllib3==1.26.6
-watchdog==1.0.2
-wcwidth==0.1.7
-webencodings==0.5.1
-wrapt==1.11.2
-zipp==0.6.0
+astropy
+cbor2
+click
+protobuf<4.0.0
+mapbox_vector_tile
+matplotlib
+numpy
+pillow
+ray
+scikit-image
+tqdm
\ No newline at end of file
diff --git a/setup.py b/setup.py
index da4ec81..2315eb5 100644
--- a/setup.py
+++ b/setup.py
@@ -37,14 +37,14 @@ def read(fname):
 REQUIRES = [
     "astropy",
     "cbor2",
-    "imageio",
     "numpy",
     "matplotlib",
     "pillow",
+    "ray",
     "scikit-image",
-    "sharedmem",
     "tqdm",
     "click",
+    "protobuf<4.21",  # mapbox-vector-tile requires protobuf<4.21,>=3.0.0
     "mapbox_vector_tile",
 ]
 


Test patch:
diff --git a/fitsmap/tests/data/expected_test_catalog_radec.cat.js b/fitsmap/tests/data/expected_test_catalog_radec.cat.js
deleted file mode 100644
index 43a862a..0000000
--- a/fitsmap/tests/data/expected_test_catalog_radec.cat.js
+++ /dev/null
@@ -1,106 +0,0 @@
-var test_catalog_radec_cat_var_0 = [
-  {
-    "x": 289.87867109328727,
-    "y": 301.2526406693396,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "1",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 356.95558564785233,
-    "y": 283.93480901651037,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "2",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 383.9618691471778,
-    "y": 231.31626230678285,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "3",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 344.90650123599653,
-    "y": 202.92845326810493,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "4",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 416.75721172590784,
-    "y": 180.71651339555683,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "5",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 306.32544668238415,
-    "y": 118.5245116079277,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "6",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 528.1348530511932,
-    "y": 132.45905926137948,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "7",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  },
-  {
-    "x": 453.3956141826716,
-    "y": 77.99035728316721,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "8",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_radec"
-  }
-];
diff --git a/fitsmap/tests/data/expected_test_catalog_xy.cat.js b/fitsmap/tests/data/expected_test_catalog_xy.cat.js
deleted file mode 100644
index b68edd3..0000000
--- a/fitsmap/tests/data/expected_test_catalog_xy.cat.js
+++ /dev/null
@@ -1,106 +0,0 @@
-var test_catalog_xy_cat_var_0 = [
-  {
-    "x": 289.5,
-    "y": 300.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "1",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 356.5,
-    "y": 283.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "2",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 383.5,
-    "y": 229.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "3",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 344.5,
-    "y": 203.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "4",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 417.5,
-    "y": 179.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "5",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 305.5,
-    "y": 119.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "6",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 528.5,
-    "y": 132.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "7",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 453.5,
-    "y": 78.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "8",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  }
-];
diff --git a/fitsmap/tests/data/expected_test_catalog_xy_0.cat.js b/fitsmap/tests/data/expected_test_catalog_xy_0.cat.js
deleted file mode 100644
index 324c92b..0000000
--- a/fitsmap/tests/data/expected_test_catalog_xy_0.cat.js
+++ /dev/null
@@ -1,54 +0,0 @@
-var test_catalog_xy_cat_var_0 = [
-  {
-    "x": 289.5,
-    "y": 300.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "1",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 356.5,
-    "y": 283.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "2",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 383.5,
-    "y": 229.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "3",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 344.5,
-    "y": 203.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "4",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  }
-];
diff --git a/fitsmap/tests/data/expected_test_catalog_xy_1.cat.js b/fitsmap/tests/data/expected_test_catalog_xy_1.cat.js
deleted file mode 100644
index e666a88..0000000
--- a/fitsmap/tests/data/expected_test_catalog_xy_1.cat.js
+++ /dev/null
@@ -1,54 +0,0 @@
-var test_catalog_xy_cat_var_1 = [
-  {
-    "x": 417.5,
-    "y": 179.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "5",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 305.5,
-    "y": 119.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "6",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 528.5,
-    "y": 132.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "7",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  },
-  {
-    "x": 453.5,
-    "y": 78.5,
-    "a": -1,
-    "b": -1,
-    "theta": -1,
-    "catalog_id": "8",
-    "widest_col": 10,
-    "n_rows": 5,
-    "n_cols": 2,
-    "include_img": false,
-    "cat_path": "test_catalog_xy"
-  }
-];
diff --git a/fitsmap/tests/data/expected_test_img_for_map.tar.xz b/fitsmap/tests/data/expected_test_img_for_map.tar.xz
new file mode 100644
index 0000000..9a9a4e7
Binary files /dev/null and b/fitsmap/tests/data/expected_test_img_for_map.tar.xz differ
diff --git a/fitsmap/tests/data/expected_test_tiling_image_mpl.tar.xz b/fitsmap/tests/data/expected_test_tiling_image_mpl.tar.xz
index f93464b..27e8603 100644
Binary files a/fitsmap/tests/data/expected_test_tiling_image_mpl.tar.xz and b/fitsmap/tests/data/expected_test_tiling_image_mpl.tar.xz differ
diff --git a/fitsmap/tests/data/expected_test_tiling_image_pil.tar.xz b/fitsmap/tests/data/expected_test_tiling_image_pil.tar.xz
index cd72c03..7ed598a 100644
Binary files a/fitsmap/tests/data/expected_test_tiling_image_pil.tar.xz and b/fitsmap/tests/data/expected_test_tiling_image_pil.tar.xz differ
diff --git a/fitsmap/tests/data/expected_test_web.tar.xz b/fitsmap/tests/data/expected_test_web.tar.xz
index 1ef910f..8f3f844 100644
Binary files a/fitsmap/tests/data/expected_test_web.tar.xz and b/fitsmap/tests/data/expected_test_web.tar.xz differ
diff --git a/fitsmap/tests/data/expected_test_web_ellipse.tar.xz b/fitsmap/tests/data/expected_test_web_ellipse.tar.xz
new file mode 100644
index 0000000..3bb10a3
Binary files /dev/null and b/fitsmap/tests/data/expected_test_web_ellipse.tar.xz differ
diff --git a/fitsmap/tests/data/expected_test_web_no_marker.tar.xz b/fitsmap/tests/data/expected_test_web_no_marker.tar.xz
index 6f0f98f..29970fb 100644
Binary files a/fitsmap/tests/data/expected_test_web_no_marker.tar.xz and b/fitsmap/tests/data/expected_test_web_no_marker.tar.xz differ
diff --git a/fitsmap/tests/data/test.cat b/fitsmap/tests/data/test.cat
new file mode 100644
index 0000000..5ec2db0
--- /dev/null
+++ b/fitsmap/tests/data/test.cat
@@ -0,0 +1,3 @@
+a b c
+test data stuff
+to test stuff
diff --git a/fitsmap/tests/data/test_catalog_xy_ellipse.cat b/fitsmap/tests/data/test_catalog_xy_ellipse.cat
new file mode 100644
index 0000000..281cf97
--- /dev/null
+++ b/fitsmap/tests/data/test_catalog_xy_ellipse.cat
@@ -0,0 +1,7 @@
+# id x       y       name   filler_val a   b   theta
+  1  50      50      test_1 abc        50  50  0
+  2  226.78  226.78  test_2 def        200 100 45
+  3  580.33  580.33  test_3 ghi        300 100 45
+  4  1036.41 1036.41 test_4 jkl        345 50  45
+  5  100     800     test_5 mno        300 100 90
+  6  800     100     test_6 pqr        350 100 135
diff --git a/fitsmap/tests/data/test_img_for_map.fits b/fitsmap/tests/data/test_img_for_map.fits
new file mode 100644
index 0000000..a10423f
Binary files /dev/null and b/fitsmap/tests/data/test_img_for_map.fits differ
diff --git a/fitsmap/tests/helpers.py b/fitsmap/tests/helpers.py
index bb34318..2c554ed 100644
--- a/fitsmap/tests/helpers.py
+++ b/fitsmap/tests/helpers.py
@@ -1,5 +1,5 @@
 # MIT License
-# Copyright 2019 Ryan Hausen
+# Copyright 2022 Ryan Hausen and contributers
 
 # Permission is hereby granted, free of charge, to any person obtaining a copy of
 # this software and associated documentation files (the "Software"), to deal in
@@ -19,15 +19,15 @@
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 """Helpers for testing"""
 
-import filecmp
 import json
 import os
 import shutil
 import tarfile
-from functools import reduce
-from itertools import chain, product, repeat
+from itertools import product, starmap
 
 import numpy as np
+from PIL import Image
+import ray
 
 TEST_PATH = "./testing_tmp"
 DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
@@ -35,10 +35,42 @@
 
 
 class MockTQDM:
-    def update(self):
+    unit = ""
+
+    def update(self, n: int = 1):
+        pass
+
+    def clear(self):
+        pass
+
+    def display(self, message):
+        pass
+
+    def set_description(self, desc):
+        pass
+
+    def reset(total):
         pass
 
 
+class MockWCS:
+    """Mock WCS object for testing"""
+
+    def __init__(self, include_cd: bool):
+        if include_cd:
+            self.cd = np.array([[1, 0], [0, 1]])
+        else:
+            self.crpix = np.array([1, 1])
+            self.crval = np.array([1, 1])
+
+    def all_pix2world(self, *args, **kwargs):
+        return np.array([[1, 1], [1, 1], [1, 1]]).astype(np.float64)
+
+    @property
+    def wcs(self):
+        return self
+
+
 def setup(with_data=False):
     """Builds testing structure"""
 
@@ -56,8 +88,6 @@ def setup(with_data=False):
             filter(lambda f: f.endswith("tar.xz"), os.listdir(TEST_PATH))
         )
 
-        print()
-
         def extract(f):
             with tarfile.open(with_test_path(f)) as f:
                 f.extractall(TEST_PATH)
@@ -65,12 +95,15 @@ def extract(f):
         any(map(extract, compressed_files))
 
 
-def tear_down():
+def tear_down(include_ray=False):
     """Tears down testing structure"""
 
     if os.path.exists(TEST_PATH):
         shutil.rmtree(TEST_PATH)
 
+    if include_ray:
+        ray.shutdown()
+
 
 def disbale_tqdm():
     os.environ[TQDM_ENV_VAR] = "True"
@@ -93,8 +126,6 @@ def __stable_idx_answer(shape, zoom, tile_size=256):
     dim0_tile_fraction = shape[0] / tile_size
     dim1_tile_fraction = shape[1] / tile_size
 
-    print(shape, dim0_tile_fraction, dim1_tile_fraction, tile_size, zoom)
-
     if dim0_tile_fraction < 1 or dim1_tile_fraction < 1:
         raise StopIteration()
 
@@ -135,20 +166,122 @@ def get_slice_idx_generator_solution(zoom: int):
     return list(__stable_idx_answer((4305, 9791), zoom))
 
 
-def compare_file_directories(dir1, dir2):
-    def recursive_diff(dir_compare):
-        if dir_compare.subdirs:
-            # print(dir_compare.subdirs)
-            sub_match = all(map(recursive_diff, dir_compare.subdirs.values()))
+def compare_file_directories(dir1, dir2) -> bool:
+
+    is_file = lambda x: x.is_file()
+    is_dir = lambda x: x.is_dir()
+    get_name = lambda x: x.name
+    get_path = lambda x: x.path
+
+    def get_file_extension(fname):
+        return os.path.splitext(fname)[1]
+
+    def compare_file_contents(file1, file2) -> bool:
+        f_ext = get_file_extension(file1)
+        if f_ext in [".png", ".tiff", ".ico"]:
+            arr1 = np.array(Image.open(file1))
+            arr2 = np.array(Image.open(file2))
+            same = np.isclose(
+                arr1,
+                arr2,
+                rtol=1e-05,
+                atol=5,  # these are integer images so 15 is a reasonable tolerance
+                equal_nan=True,
+            )
+
+            if not same.all():
+                ys, xs, cs = np.where(~same)
+                print(f"Found {len(ys)} differences in {file1}")
+                print(f"First difference at {ys[0]}, {xs[0]}, {cs[0]}")
+                print(
+                    f"First difference is {arr1[ys[0], xs[0], cs[0]]} vs {arr2[ys[0], xs[0], cs[0]]}"
+                )
+                return False
+
+            return same.all()
         else:
-            sub_match = True
+            mode = "r" + "b" * int(f_ext in [".cbor", ".pbf"])
+            with open(file1, mode) as f1, open(file2, mode) as f2:
 
-        if len(dir_compare.diff_files):
-            print(dir_compare.left, dir_compare.right, dir_compare.diff_files)
+                try:
+                    return f1.readlines() == f2.readlines()
+                except:
+                    print(file1, file2, mode)
 
-        return sub_match and len(dir_compare.diff_files) == 0
+    def compare_subdirs(sub_dir1, sub_dir2) -> bool:
+        dir1_entries = list(os.scandir(sub_dir1))
+        dir2_entries = list(os.scandir(sub_dir2))
+
+        dir1_files = list(sorted(filter(is_file, dir1_entries), key=lambda x: x.name))
+        dir2_files = list(sorted(filter(is_file, dir2_entries), key=lambda x: x.name))
+
+        count_and_names_same = list(map(get_name, dir1_files)) == list(
+            map(get_name, dir2_files)
+        )
+
+        if count_and_names_same:
+            # compare file contents
+            file_pairs = list(zip(map(get_path, dir1_files), map(get_path, dir2_files)))
+            files_comps = list(
+                starmap(
+                    compare_file_contents,
+                    zip(
+                        map(get_path, dir1_files),
+                        map(get_path, dir2_files),
+                    ),
+                )
+            )
+
+            files_same = all(files_comps)
+
+            if files_same:
+                dir1_subdirs = list(
+                    sorted(filter(is_dir, dir1_entries), key=lambda x: x.name)
+                )
+                dir2_subdirs = list(
+                    sorted(filter(is_dir, dir2_entries), key=lambda x: x.name)
+                )
+
+                count_and_names_same = list(map(get_name, dir1_subdirs)) == list(
+                    map(get_name, dir2_subdirs)
+                )
+
+                if count_and_names_same:
+                    # compare sub dirs
+                    subdir_pairs = list(
+                        zip(
+                            map(get_path, dir1_subdirs),
+                            map(get_path, dir2_subdirs),
+                        )
+                    )
+                    subdir_comp = list(starmap(compare_subdirs, subdir_pairs))
+                    subdirs_same = all(subdir_comp)
+
+                    return subdirs_same
+                else:
+                    list(
+                        map(
+                            lambda x: print(x[1], "don't match"),
+                            filter(lambda x: not x[0], zip(subdir_comp, subdir_pairs)),
+                        )
+                    )
+                    return False
+            else:
+                list(
+                    map(
+                        lambda x: print(x[1], "don't match"),
+                        filter(lambda x: not x[0], zip(files_comps, file_pairs)),
+                    )
+                )
+                return False
+        else:
+            missing_files = set(
+                list(map(lambda f: f.name, dir1_files))
+            ).symmetric_difference(set(list(map(lambda f: f.name, dir2_files))))
+            print(missing_files)
+            return False
 
-    return recursive_diff(filecmp.dircmp(dir1, dir2))
+    return compare_subdirs(dir1, dir2)
 
 
 def get_version():
diff --git a/fitsmap/tests/test_cartographer.py b/fitsmap/tests/test_cartographer.py
index 63359ed..b7c18ff 100644
--- a/fitsmap/tests/test_cartographer.py
+++ b/fitsmap/tests/test_cartographer.py
@@ -21,6 +21,8 @@
 
 import filecmp
 import os
+
+import numpy as np
 from astropy.wcs.wcs import WCS
 
 import pytest
@@ -35,18 +37,20 @@ def test_layer_name_to_dict_image():
     """test cartographer.layer_name_to_dict"""
     out_dir = "."
     min_zoom = 0
-    max_zoom = 2
+    max_native_zoom = 2
     name = "test"
     color = ""  # "#4C72B0"
 
-    actual_dict = c.layer_name_to_dict(out_dir, min_zoom, max_zoom, name, color)
+    actual_dict = c.layer_name_to_dict(
+        out_dir, max_native_zoom + 5, min_zoom, max_native_zoom, name, color
+    )
 
     expected_dict = dict(
         directory=name + "/{z}/{y}/{x}.png",
         name=name,
         min_zoom=min_zoom,
-        max_zoom=max_zoom + 5,
-        max_native_zoom=max_zoom,
+        max_zoom=max_native_zoom + 5,
+        max_native_zoom=max_native_zoom,
     )
 
     assert expected_dict == actual_dict
@@ -59,7 +63,7 @@ def test_layer_name_to_dict_catalog():
     helpers.setup()
     out_dir = helpers.DATA_DIR
     min_zoom = 0
-    max_zoom = 2
+    max_native_zoom = 2
     name = "test"
     color = "#4C72B0"
     columns = "a,b,c"
@@ -67,14 +71,16 @@ def test_layer_name_to_dict_catalog():
     with open(os.path.join(out_dir, f"{name}.columns"), "w") as f:
         f.write(columns)
 
-    actual_dict = c.layer_name_to_dict(out_dir, min_zoom, max_zoom, name, color)
+    actual_dict = c.layer_name_to_dict(
+        out_dir, max_native_zoom + 5, min_zoom, max_native_zoom, name, color
+    )
 
     expected_dict = dict(
         directory=name + "/{z}/{y}/{x}.pbf",
         name=name,
         min_zoom=min_zoom,
-        max_zoom=max_zoom + 5,
-        max_native_zoom=max_zoom,
+        max_zoom=max_native_zoom + 5,
+        max_native_zoom=max_native_zoom,
         color=color,
         columns=[f'"{c}"' for c in columns.split(",")],
     )
@@ -149,9 +155,12 @@ def test_cat_layer_dict_to_str():
             " = L.gridLayer.tiledMarkers(",
             "{ ",
             'tileURL:"' + layer_dict["directory"] + '", ',
+            "radius: 10, ",
             'color: "' + layer_dict["color"] + '", ',
+            "fillOpacity: 0.2, ",
+            "strokeOpacity: 1.0, ",
             f"rowsPerColumn: Infinity, ",
-            f'catalogColumns: [{",".join(layer_dict["columns"])}],',
+            f'catalogColumns: [{",".join(layer_dict["columns"])}], ',
             "minZoom: " + str(layer_dict["min_zoom"]) + ", ",
             "maxZoom: " + str(layer_dict["max_zoom"]) + ", ",
             "maxNativeZoom: " + str(layer_dict["max_native_zoom"]) + " ",
@@ -254,6 +263,34 @@ def test_leaflet_crs_js():
     assert actual == expected
 
 
+@pytest.mark.unit
+@pytest.mark.cartographer
+def test_extract_cd_matrix_as_string_with_cd():
+    """test cartographer.extract_cd_matrix_as_string"""
+
+    wcs = helpers.MockWCS(include_cd=True)
+
+    actual = c.extract_cd_matrix_as_string(wcs)
+
+    expected = "[[1, 0], [0, 1]]"
+
+    assert actual == expected
+
+
+@pytest.mark.unit
+@pytest.mark.cartographer
+def test_extract_cd_matrix_as_string_without_cd():
+    """test cartographer.extract_cd_matrix_as_string"""
+
+    wcs = helpers.MockWCS(include_cd=False)
+
+    actual = c.extract_cd_matrix_as_string(wcs)
+
+    expected = "[[0.0, 0.0], [0.0, 0.0]]"
+
+    assert actual == expected
+
+
 @pytest.mark.unit
 @pytest.mark.cartographer
 def test_leaflet_map_js():
@@ -339,6 +376,100 @@ def test_build_conditional_js():
     assert expected_js == acutal_js
 
 
+@pytest.mark.unit
+@pytest.mark.cartographer
+def test_build_index_js():
+    """Tests cartographer.build_index_js"""
+
+    img_layer_dict = [
+        dict(
+            name="img",
+            directory="img/{z}/{y}/{x}.png",
+            min_zoom=0,
+            max_zoom=8,
+            max_native_zoom=3,
+        )
+    ]
+
+    cat_layer_dict = [
+        dict(
+            name="cat",
+            directory="cat/{z}/{y}/{x}.pbf",
+            min_zoom=0,
+            max_zoom=8,
+            max_native_zoom=2,
+            color="blue",
+            columns=['"a"', '"b"', '"c"'],
+        )
+    ]
+
+    rows_per_column = np.inf
+
+    max_xy = (2048, 2048)
+
+    expected_js = "\n".join(
+        [
+            "// Image layers ================================================================",
+            'const img = L.tileLayer("img/{z}/{y}/{x}.png", { attribution:"'
+            + c.LAYER_ATTRIBUTION
+            + '", '
+            + "minZoom: 0, maxZoom: 8, maxNativeZoom: 3 });",
+            "",
+            "// Marker layers ===============================================================",
+            'const cat = L.gridLayer.tiledMarkers({ tileURL:"cat/{z}/{y}/{x}.pbf", radius: 10, color: "blue", fillOpacity: 0.2, strokeOpacity: 1.0, rowsPerColumn: Infinity, catalogColumns: ["a","b","c"], minZoom: 0, maxZoom: 8, maxNativeZoom: 2 });',
+            "",
+            "// Basic map setup =============================================================",
+            "L.CRS.FitsMap = L.extend({}, L.CRS.Simple, {",
+            "    transformation: new L.Transformation(1/8, 0, -1/8, 256)",
+            "});",
+            "",
+            'const map = L.map("map", {',
+            "    crs: L.CRS.FitsMap,",
+            "    minZoom: 0,",
+            "    preferCanvas: true,",
+            "    layers: [img]",
+            "});",
+            "",
+            "const layerControl = L.control.layers(",
+            '    {"img":img},',
+            '    {"cat":cat}',
+            ").addTo(map);",
+            "",
+            "// Search ======================================================================",
+            "const catalogPaths = [",
+            '    "catalog_assets/cat/",',
+            "];",
+            "",
+            "const searchControl = buildCustomSearch(catalogPaths, 2);",
+            "map.addControl(searchControl);",
+            "",
+            "// Map event setup =============================================================",
+            'img.on("load", () => {',
+            '    document.getElementById("loading-screen").style.visibility = "hidden";',
+            '    document.getElementById("map").style.visibility = "visible";',
+            "});",
+            "",
+            'map.on("moveend", updateLocationBar);',
+            'map.on("zoomend", updateLocationBar);',
+            "",
+            'if (urlParam("zoom")==null) {',
+            f"    map.fitBounds(L.latLngBounds([[0, 0], [{max_xy[0]}, {max_xy[1]}]]));",
+            "} else {",
+            "    panFromUrl(map);",
+            "}",
+        ]
+    )
+
+    actual_js = c.build_index_js(
+        img_layer_dict,
+        cat_layer_dict,
+        rows_per_column,
+        max_xy,
+    )
+
+    assert expected_js == actual_js
+
+
 @pytest.mark.unit
 @pytest.mark.cartographer
 def test_move_support_images():
@@ -423,9 +554,18 @@ def test_chart_no_wcs():
     list(
         map(
             lambda r: os.makedirs(os.path.join(out_dir, map_layer_names, str(r))),
+            range(3),
+        )
+    )
+
+    # make mock marker zooms
+    list(
+        map(
+            lambda r: os.makedirs(os.path.join(out_dir, marker_file_names, str(r))),
             range(2),
         )
     )
+
     os.mkdir(os.path.join(out_dir, "js"))
     os.mkdir(os.path.join(out_dir, "css"))
 
@@ -475,9 +615,18 @@ def test_chart_with_wcs():
     with open(os.path.join(out_dir, f"{marker_file_names}.columns"), "w") as f:
         f.write(columns)
 
+    # make mock image zooms
     list(
         map(
             lambda r: os.makedirs(os.path.join(out_dir, map_layer_names, str(r))),
+            range(3),
+        )
+    )
+
+    # make mock marker zooms
+    list(
+        map(
+            lambda r: os.makedirs(os.path.join(out_dir, marker_file_names, str(r))),
             range(2),
         )
     )
diff --git a/fitsmap/tests/test_convert.py b/fitsmap/tests/test_convert.py
index 3965116..9d949d2 100644
--- a/fitsmap/tests/test_convert.py
+++ b/fitsmap/tests/test_convert.py
@@ -20,7 +20,9 @@
 """Tests convert.py"""
 
 import os
+import queue
 import shutil
+import sys
 
 import numpy as np
 import pytest
@@ -201,7 +203,7 @@ def test_balance_array_2d():
     out_array = convert.balance_array(test_array)
 
     assert out_array.shape == expected_shape
-    assert np.isnan(out_array).sum() == expected_num_nans
+    assert np.isnan(out_array[:]).sum() == expected_num_nans
 
 
 @pytest.mark.unit
@@ -218,7 +220,7 @@ def test_balance_array_3d():
     out_array = convert.balance_array(test_array)
 
     assert out_array.shape == expected_shape
-    assert np.isnan(out_array).sum() == expected_num_nans
+    assert np.isnan(out_array[:]).sum() == expected_num_nans
 
 
 @pytest.mark.unit
@@ -241,7 +243,7 @@ def test_get_array_fits():
 
     helpers.tear_down()
 
-    np.testing.assert_equal(expected_array, actual_array)
+    np.testing.assert_equal(expected_array, actual_array[:])
 
 
 @pytest.mark.unit
@@ -371,6 +373,41 @@ def test_get_total_tiles():
     assert expected_number == actual_number
 
 
+@pytest.mark.unit
+@pytest.mark.convert
+def test_imread_default():
+    """Test convert.imread_default() with valid path"""
+
+    helpers.setup(with_data=True)
+
+    test_file = os.path.join(helpers.TEST_PATH, "test_tiling_image.jpg")
+    expected_array = np.flipud(Image.open(test_file))
+    empty_array = np.zeros([256, 256])
+
+    actual_array = convert.imread_default(test_file, empty_array)
+
+    helpers.tear_down()
+
+    np.testing.assert_equal(expected_array, actual_array)
+
+
+@pytest.mark.unit
+@pytest.mark.convert
+def test_imread_default_invalid_path():
+    """Test convert.imread_default() with valid path"""
+
+    helpers.setup(with_data=True)
+
+    test_file = os.path.join(helpers.TEST_PATH, "doesnt_exist.jpg")
+    empty_array = np.zeros([256, 256])
+
+    actual_array = convert.imread_default(test_file, empty_array)
+
+    helpers.tear_down()
+
+    np.testing.assert_equal(empty_array, actual_array)
+
+
 @pytest.mark.unit
 @pytest.mark.convert
 def test_get_map_layer_name():
@@ -442,10 +479,21 @@ def test_line_to_json_xy():
 
     expected_json = dict(
         geometry=dict(coordinates=[9.5, 19.5]),
-        tags=dict(a=-1, b=-1, theta=-1, catalog_id="1", cat_path="catalog_assets",),
+        tags=dict(
+            a=-1,
+            b=-1,
+            theta=-1,
+            catalog_id="1",
+            cat_path="catalog_assets",
+        ),
     )
 
-    actual_json = convert.line_to_json(in_wcs, columns, catalog_assets_path, in_line,)
+    actual_json = convert.line_to_json(
+        in_wcs,
+        columns,
+        catalog_assets_path,
+        in_line,
+    )
 
     helpers.tear_down()
 
@@ -468,55 +516,31 @@ def test_line_to_json_ra_dec():
 
     expected_json = dict(
         geometry=dict(coordinates=[289.87867109328727, 301.2526406693396]),
-        tags=dict(a=-1, b=-1, theta=-1, catalog_id="1", cat_path="catalog_assets",),
+        tags=dict(
+            a=-1,
+            b=-1,
+            theta=-1,
+            catalog_id="1",
+            cat_path="catalog_assets",
+        ),
     )
 
-    actual_json = convert.line_to_json(in_wcs, columns, catalog_assets_path, in_line,)
+    actual_json = convert.line_to_json(
+        in_wcs,
+        columns,
+        catalog_assets_path,
+        in_line,
+    )
 
     helpers.tear_down()
 
-    assert expected_json == actual_json
-
-
-# this needs to be top-level to be pickle-able in test_async_worker_completes
-def mock_f(v1, v2):
-    pass
-
-
-@pytest.mark.unit
-@pytest.mark.convert
-def test_async_worker_completes():
-    """Test convert.async_worker"""
-
-    q = JoinableQueue()
-    q.put((mock_f, ["v1", "v2"]))
-
-    convert.async_worker(q)
-
-    assert True  # if we make it here it works
-
-
-@pytest.mark.unit
-@pytest.mark.convert
-def test_make_tile_mpl():
-    """Test convert.make_tile_mpl"""
-    helpers.setup()
-
-    out_dir = helpers.TEST_PATH
-    test_arr = np.arange(100 * 100).reshape([100, 100])
-    test_job = (0, 0, 0, slice(0, 100), slice(0, 100))
-
-    os.makedirs(os.path.join(out_dir, "0/0/"))
-
-    convert.make_tile_mpl(out_dir, test_arr, test_job)
-
-    actual_img = np.array(Image.open(os.path.join(out_dir, "0/0/0.png")))
-
-    expected_img = test_arr // 256
+    np.testing.assert_allclose(
+        expected_json["geometry"]["coordinates"],
+        actual_json["geometry"]["coordinates"],
+        atol=1e-6,
+    )
 
-    np.array_equal(expected_img, actual_img)
-
-    helpers.tear_down()
+    assert expected_json["tags"] == actual_json["tags"]
 
 
 @pytest.mark.unit
@@ -528,13 +552,13 @@ def test_tile_img_pil_serial():
 
     out_dir = helpers.TEST_PATH
     test_image = os.path.join(out_dir, "test_tiling_image.jpg")
-    pbar_loc = 0
+    pbar_ref = [0, queue.Queue()]
     min_zoom = 0
     image_engine = convert.IMG_ENGINE_PIL
 
     convert.tile_img(
         test_image,
-        pbar_loc,
+        pbar_ref,
         min_zoom=min_zoom,
         image_engine=image_engine,
         out_dir=out_dir,
@@ -560,13 +584,13 @@ def test_tile_img_mpl_serial():
 
     out_dir = helpers.TEST_PATH
     test_image = os.path.join(out_dir, "test_tiling_image.jpg")
-    pbar_loc = 0
+    pbar_ref = [0, queue.Queue()]
     min_zoom = 0
     image_engine = convert.IMG_ENGINE_MPL
 
     convert.tile_img(
         test_image,
-        pbar_loc,
+        pbar_ref,
         min_zoom=min_zoom,
         image_engine=image_engine,
         out_dir=out_dir,
@@ -585,32 +609,67 @@ def test_tile_img_mpl_serial():
 
 @pytest.mark.unit
 @pytest.mark.convert
-def test_tile_img_pil_serial_exists(capsys):
-    """Test convert.tile_img skips tiling"""
+def test_tile_img_mpl_fits_serial():
+    """Test convmax_percentert.tile_img"""
     helpers.disbale_tqdm()
     helpers.setup(with_data=True)
 
     out_dir = helpers.TEST_PATH
-    test_image = os.path.join(out_dir, "test_tiling_image.jpg")
-    pbar_loc = 0
+    test_image = os.path.join(out_dir, "test_img_for_map.fits")
+    pbar_ref = [0, queue.Queue()]
     min_zoom = 0
-    image_engine = convert.IMG_ENGINE_PIL
-
-    os.mkdir(os.path.join(out_dir, "test_tiling_image"))
+    image_engine = convert.IMG_ENGINE_MPL
 
     convert.tile_img(
         test_image,
-        pbar_loc,
+        pbar_ref,
         min_zoom=min_zoom,
         image_engine=image_engine,
         out_dir=out_dir,
+        norm_kwargs=dict(stretch="log", max_percent=99.9),
     )
 
-    captured = capsys.readouterr()
+    expected_dir = os.path.join(out_dir, "expected_test_img_for_map")
+    actual_dir = os.path.join(out_dir, "test_img_for_map")
+
+    dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
+
+    helpers.tear_down()
+    helpers.enable_tqdm()
+
+    assert dirs_match
+
+
+def test_simplify_mixed_ws():
+    """Test convert._simplify_mixed_ws"""
+    helpers.disbale_tqdm()
+    helpers.setup(with_data=True)
+
+    test_lines = [
+        "a b   c\n",
+        "test\tdata stuff\n",
+        "to     test\tstuff\n",
+    ]
+
+    out_file = os.path.join(helpers.DATA_DIR, "test.cat")
+    with open(out_file, "w") as f:
+        f.writelines(test_lines)
+
+    convert._simplify_mixed_ws(out_file)
+
+    expected_test_lines = [
+        "a b c\n",
+        "test data stuff\n",
+        "to test stuff\n",
+    ]
+
+    with open(out_file, "r") as f:
+        actual_lines = f.readlines()
+
     helpers.tear_down()
     helpers.enable_tqdm()
 
-    assert "test_tiling_image.jpg already tiled. Skipping tiling." in captured.out
+    assert expected_test_lines == actual_lines
 
 
 @pytest.mark.unit
@@ -622,13 +681,13 @@ def test_tile_img_pil_parallel():
 
     out_dir = helpers.TEST_PATH
     test_image = os.path.join(out_dir, "test_tiling_image.jpg")
-    pbar_loc = 0
+    pbar_ref = [0, queue.Queue()]
     min_zoom = 0
     image_engine = convert.IMG_ENGINE_PIL
 
     convert.tile_img(
         test_image,
-        pbar_loc,
+        pbar_ref,
         min_zoom=min_zoom,
         image_engine=image_engine,
         out_dir=out_dir,
@@ -640,7 +699,7 @@ def test_tile_img_pil_parallel():
 
     dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
 
-    helpers.tear_down()
+    helpers.tear_down(include_ray=True)
     helpers.enable_tqdm()
 
     assert dirs_match
@@ -655,13 +714,13 @@ def test_tile_img_mpl_parallel():
 
     out_dir = helpers.TEST_PATH
     test_image = os.path.join(out_dir, "test_tiling_image.jpg")
-    pbar_loc = 0
+    pbar_ref = [0, queue.Queue()]
     min_zoom = 0
     image_engine = convert.IMG_ENGINE_MPL
 
     convert.tile_img(
         test_image,
-        pbar_loc,
+        pbar_ref,
         min_zoom=min_zoom,
         image_engine=image_engine,
         out_dir=out_dir,
@@ -673,14 +732,18 @@ def test_tile_img_mpl_parallel():
 
     dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
 
-    helpers.tear_down()
+    helpers.tear_down(include_ray=True)
     helpers.enable_tqdm()
 
     assert dirs_match
 
 
-# @pytest.mark.integration
+@pytest.mark.integration
 @pytest.mark.convert
+@pytest.mark.skipif(
+    condition=not sys.platform.startswith("linux"),
+    reason="temp fix, need osx/windows artififacts for cbor/pbf files",
+)
 @pytest.mark.filterwarnings("ignore:.*:astropy.io.fits.verify.VerifyWarning")
 def test_files_to_map():
     """Integration test for making files into map"""
@@ -714,7 +777,54 @@ def test_files_to_map():
 
     dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
 
-    helpers.tear_down()
+    helpers.tear_down(include_ray=True)
+    helpers.enable_tqdm()
+
+    assert dirs_match
+
+
+@pytest.mark.integration
+@pytest.mark.convert
+@pytest.mark.skipif(
+    condition=not sys.platform.startswith("linux"),
+    reason="temp fix, need osx/windows artififacts for cbor/pbf files",
+)
+@pytest.mark.filterwarnings("ignore:.*:astropy.io.fits.verify.VerifyWarning")
+def test_files_to_map_ellipse_markers():
+    """Integration test for making files into map"""
+    helpers.disbale_tqdm()
+    helpers.setup(with_data=True)
+
+    with_path = lambda f: os.path.join(helpers.TEST_PATH, f)
+    out_dir = with_path("test_web")
+
+    files = [
+        with_path("test_tiling_image.jpg"),
+        with_path("test_catalog_xy_ellipse.cat"),
+    ]
+
+    convert.files_to_map(
+        files,
+        out_dir=out_dir,
+        catalog_delim=" ",
+    )
+
+    expected_dir = with_path("expected_test_web_ellipse")
+
+    # inject current version in to test_index.html
+    version = helpers.get_version()
+    raw_path = os.path.join(expected_dir, "index.html")
+    with open(raw_path, "r") as f:
+        converted = list(map(lambda l: l.replace("VERSION", version), f.readlines()))
+
+    with open(raw_path, "w") as f:
+        f.writelines(converted)
+
+    actual_dir = with_path("test_web")
+
+    dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
+
+    helpers.tear_down(include_ray=True)
     helpers.enable_tqdm()
 
     assert dirs_match
@@ -737,7 +847,7 @@ def test_files_to_map_fails_file_not_found():
         with_path("does_not_exist.txt"),
     ]
 
-    with pytest.raises(FileNotFoundError):
+    with pytest.raises(AssertionError):
         convert.files_to_map(
             files, out_dir=out_dir, cat_wcs_fits_file=with_path("test_image.fits")
         )
@@ -760,7 +870,7 @@ def test_dir_to_map_fails_no_files():
     if not os.path.exists(in_dir):
         os.mkdir(in_dir)
 
-    with pytest.raises(ValueError):
+    with pytest.raises(AssertionError):
         convert.dir_to_map(
             in_dir, out_dir=out_dir, cat_wcs_fits_file=with_path("test_image.fits")
         )
@@ -771,6 +881,10 @@ def test_dir_to_map_fails_no_files():
 
 @pytest.mark.integration
 @pytest.mark.convert
+@pytest.mark.skipif(
+    condition=not sys.platform.startswith("linux"),
+    reason="temp fix, need osx/windows artififacts for cbor/pbf files",
+)
 @pytest.mark.filterwarnings("ignore:.*:astropy.io.fits.verify.VerifyWarning")
 def test_dir_to_map():
     """Integration test for making files into map"""
@@ -813,7 +927,7 @@ def test_dir_to_map():
 
     dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
 
-    helpers.tear_down()
+    helpers.tear_down(include_ray=True)
     helpers.enable_tqdm()
 
     assert dirs_match
@@ -860,7 +974,7 @@ def test_dir_to_map_no_markers():
 
     dirs_match = helpers.compare_file_directories(expected_dir, actual_dir)
 
-    helpers.tear_down()
+    helpers.tear_down(include_ray=True)
     helpers.enable_tqdm()
 
     assert dirs_match
diff --git a/fitsmap/tests/test_utils.py b/fitsmap/tests/test_utils.py
index 65a87d7..5250f61 100644
--- a/fitsmap/tests/test_utils.py
+++ b/fitsmap/tests/test_utils.py
@@ -18,7 +18,9 @@
 # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 """Tests utils.py"""
+from functools import partial
 import os
+from typing import Any, List
 import pytest
 
 import fitsmap.utils as u
@@ -106,8 +108,45 @@ def test_make_fname_js_safe_no_change():
 
 @pytest.mark.unit
 def test_MockQueue():
+    """Test the MockQueue class."""
     bar = tqdm()
 
     q = u.MockQueue(bar)
     q.put(100)
     assert q.bar.n == 100
+
+
+@pytest.mark.unit
+def test_backpressure_queue():
+    """Test backpressure_queue."""
+    helpers.setup()
+
+    pbar_ref = (0, u.MockQueue(helpers.MockTQDM()))
+    n_parallel_jobs = 1
+
+    f_args = [[None], [None], [None]]
+
+    hit_all_queue = [False, False, False]
+
+    wait_one = [True]
+
+    def wait_f(in_progress: List[Any]):
+        still_running = in_progress[1:] if len(in_progress) > 1 else []
+
+        if len(still_running) == 0 and wait_one[0]:
+            wait_one[0] = False
+            return None, in_progress
+
+        return None, still_running
+
+    def work_f(hit_all_queue, *args):
+        hit_all_queue[hit_all_queue.index(False)] = True
+        return args
+
+    work_f_with_check = partial(work_f, hit_all_queue)
+
+    u.backpressure_queue(wait_f, work_f_with_check, f_args, pbar_ref, n_parallel_jobs)
+
+    helpers.tear_down()
+
+    assert all(hit_all_queue)