Repository: imi-bigpicture/wsidicom (language: python)

Issue:
Update docstrings



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 7ebc977e..48b9426a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,7 @@
-wsidicom.egg-info*
 dist*
 Pipfile.lock*
 .mypy_cache*
 .pytest_cache*
 *__pycache__**
 .vscode
-.ipynb_checkpoints
-*.ipynb
-*.dcm
\ No newline at end of file
+typings
\ No newline at end of file
diff --git a/poetry.lock b/poetry.lock
index 043ff4d1..f3537f99 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -38,7 +38,7 @@ python-versions = "*"
 
 [[package]]
 name = "importlib-metadata"
-version = "4.8.1"
+version = "4.8.2"
 description = "Read metadata from Python packages"
 category = "dev"
 optional = false
@@ -51,7 +51,7 @@ zipp = ">=0.5"
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
 perf = ["ipython"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "iniconfig"
@@ -71,18 +71,18 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "packaging"
-version = "21.0"
+version = "21.2"
 description = "Core utilities for Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.6"
 
 [package.dependencies]
-pyparsing = ">=2.0.2"
+pyparsing = ">=2.0.2,<3"
 
 [[package]]
 name = "pillow"
-version = "8.3.2"
+version = "8.4.0"
 description = "Python Imaging Library (Fork)"
 category = "main"
 optional = false
@@ -105,11 +105,11 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "py"
-version = "1.10.0"
+version = "1.11.0"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "pydicom"
@@ -165,14 +165,14 @@ watchdog = ">=0.6.0"
 
 [[package]]
 name = "shapely"
-version = "1.7.1"
+version = "1.8.0"
 description = "Geometric objects, predicates, and operations"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [package.extras]
-all = ["numpy", "pytest", "pytest-cov"]
+all = ["pytest", "pytest-cov", "numpy"]
 test = ["pytest", "pytest-cov"]
 vectorized = ["numpy"]
 
@@ -186,11 +186,11 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
+version = "4.0.0"
+description = "Backported and Experimental Type Hints for Python 3.6+"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "watchdog"
@@ -245,8 +245,8 @@ docopt = [
     {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
-    {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
+    {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"},
+    {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"},
 ]
 iniconfig = [
     {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
@@ -283,58 +283,59 @@ numpy = [
     {file = "numpy-1.21.1.zip", hash = "sha256:dff4af63638afcc57a3dfb9e4b26d434a7a602d225b42d746ea7fe2edf1342fd"},
 ]
 packaging = [
-    {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
-    {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
+    {file = "packaging-21.2-py3-none-any.whl", hash = "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0"},
+    {file = "packaging-21.2.tar.gz", hash = "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966"},
 ]
 pillow = [
-    {file = "Pillow-8.3.2-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:c691b26283c3a31594683217d746f1dad59a7ae1d4cfc24626d7a064a11197d4"},
-    {file = "Pillow-8.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f514c2717012859ccb349c97862568fdc0479aad85b0270d6b5a6509dbc142e2"},
-    {file = "Pillow-8.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be25cb93442c6d2f8702c599b51184bd3ccd83adebd08886b682173e09ef0c3f"},
-    {file = "Pillow-8.3.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d675a876b295afa114ca8bf42d7f86b5fb1298e1b6bb9a24405a3f6c8338811c"},
-    {file = "Pillow-8.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59697568a0455764a094585b2551fd76bfd6b959c9f92d4bdec9d0e14616303a"},
-    {file = "Pillow-8.3.2-cp310-cp310-win32.whl", hash = "sha256:2d5e9dc0bf1b5d9048a94c48d0813b6c96fccfa4ccf276d9c36308840f40c228"},
-    {file = "Pillow-8.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:11c27e74bab423eb3c9232d97553111cc0be81b74b47165f07ebfdd29d825875"},
-    {file = "Pillow-8.3.2-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:11eb7f98165d56042545c9e6db3ce394ed8b45089a67124298f0473b29cb60b2"},
-    {file = "Pillow-8.3.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f23b2d3079522fdf3c09de6517f625f7a964f916c956527bed805ac043799b8"},
-    {file = "Pillow-8.3.2-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19ec4cfe4b961edc249b0e04b5618666c23a83bc35842dea2bfd5dfa0157f81b"},
-    {file = "Pillow-8.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5a31c07cea5edbaeb4bdba6f2b87db7d3dc0f446f379d907e51cc70ea375629"},
-    {file = "Pillow-8.3.2-cp36-cp36m-win32.whl", hash = "sha256:4abc247b31a98f29e5224f2d31ef15f86a71f79c7f4d2ac345a5d551d6393073"},
-    {file = "Pillow-8.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a048dad5ed6ad1fad338c02c609b862dfaa921fcd065d747194a6805f91f2196"},
-    {file = "Pillow-8.3.2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:06d1adaa284696785375fa80a6a8eb309be722cf4ef8949518beb34487a3df71"},
-    {file = "Pillow-8.3.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a330bf7014ee034046db43ccbb05c766aa9e70b8d6c5260bfc38d73103b0ba"},
-    {file = "Pillow-8.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13654b521fb98abdecec105ea3fb5ba863d1548c9b58831dd5105bb3873569f1"},
-    {file = "Pillow-8.3.2-cp37-cp37m-win32.whl", hash = "sha256:085a90a99404b859a4b6c3daa42afde17cb3ad3115e44a75f0d7b4a32f06a6c9"},
-    {file = "Pillow-8.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:18a07a683805d32826c09acfce44a90bf474e6a66ce482b1c7fcd3757d588df3"},
-    {file = "Pillow-8.3.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4e59e99fd680e2b8b11bbd463f3c9450ab799305d5f2bafb74fefba6ac058616"},
-    {file = "Pillow-8.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4d89a2e9219a526401015153c0e9dd48319ea6ab9fe3b066a20aa9aee23d9fd3"},
-    {file = "Pillow-8.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56fd98c8294f57636084f4b076b75f86c57b2a63a8410c0cd172bc93695ee979"},
-    {file = "Pillow-8.3.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b11c9d310a3522b0fd3c35667914271f570576a0e387701f370eb39d45f08a4"},
-    {file = "Pillow-8.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0412516dcc9de9b0a1e0ae25a280015809de8270f134cc2c1e32c4eeb397cf30"},
-    {file = "Pillow-8.3.2-cp38-cp38-win32.whl", hash = "sha256:ce2e5e04bb86da6187f96d7bab3f93a7877830981b37f0287dd6479e27a10341"},
-    {file = "Pillow-8.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:35d27687f027ad25a8d0ef45dd5208ef044c588003cdcedf05afb00dbc5c2deb"},
-    {file = "Pillow-8.3.2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:04835e68ef12904bc3e1fd002b33eea0779320d4346082bd5b24bec12ad9c3e9"},
-    {file = "Pillow-8.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10e00f7336780ca7d3653cf3ac26f068fa11b5a96894ea29a64d3dc4b810d630"},
-    {file = "Pillow-8.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cde7a4d3687f21cffdf5bb171172070bb95e02af448c4c8b2f223d783214056"},
-    {file = "Pillow-8.3.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c3ff00110835bdda2b1e2b07f4a2548a39744bb7de5946dc8e95517c4fb2ca6"},
-    {file = "Pillow-8.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35d409030bf3bd05fa66fb5fdedc39c521b397f61ad04309c90444e893d05f7d"},
-    {file = "Pillow-8.3.2-cp39-cp39-win32.whl", hash = "sha256:963ebdc5365d748185fdb06daf2ac758116deecb2277ec5ae98139f93844bc09"},
-    {file = "Pillow-8.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:cc9d0dec711c914ed500f1d0d3822868760954dce98dfb0b7382a854aee55d19"},
-    {file = "Pillow-8.3.2-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2c661542c6f71dfd9dc82d9d29a8386287e82813b0375b3a02983feac69ef864"},
-    {file = "Pillow-8.3.2-pp36-pypy36_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:838eb85de6d9307c19c655c726f8d13b8b646f144ca6b3771fa62b711ebf7624"},
-    {file = "Pillow-8.3.2-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:feb5db446e96bfecfec078b943cc07744cc759893cef045aa8b8b6d6aaa8274e"},
-    {file = "Pillow-8.3.2-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:fc0db32f7223b094964e71729c0361f93db43664dd1ec86d3df217853cedda87"},
-    {file = "Pillow-8.3.2-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cb3dd7f23b044b0737317f892d399f9e2f0b3a02b22b2c692851fb8120d82c6"},
-    {file = "Pillow-8.3.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a66566f8a22561fc1a88dc87606c69b84fa9ce724f99522cf922c801ec68f5c1"},
-    {file = "Pillow-8.3.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ce651ca46d0202c302a535d3047c55a0131a720cf554a578fc1b8a2aff0e7d96"},
-    {file = "Pillow-8.3.2.tar.gz", hash = "sha256:dde3f3ed8d00c72631bc19cbfff8ad3b6215062a5eed402381ad365f82f0c18c"},
+    {file = "Pillow-8.4.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d"},
+    {file = "Pillow-8.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6"},
+    {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb9fc393f3c61f9054e1ed26e6fe912c7321af2f41ff49d3f83d05bacf22cc78"},
+    {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d82cdb63100ef5eedb8391732375e6d05993b765f72cb34311fab92103314649"},
+    {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc1afda735a8d109007164714e73771b499768b9bb5afcbbee9d0ff374b43f"},
+    {file = "Pillow-8.4.0-cp310-cp310-win32.whl", hash = "sha256:e3dacecfbeec9a33e932f00c6cd7996e62f53ad46fbe677577394aaa90ee419a"},
+    {file = "Pillow-8.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:620582db2a85b2df5f8a82ddeb52116560d7e5e6b055095f04ad828d1b0baa39"},
+    {file = "Pillow-8.4.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:1bc723b434fbc4ab50bb68e11e93ce5fb69866ad621e3c2c9bdb0cd70e345f55"},
+    {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cbcfd54df6caf85cc35264c77ede902452d6df41166010262374155947460c"},
+    {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70ad9e5c6cb9b8487280a02c0ad8a51581dcbbe8484ce058477692a27c151c0a"},
+    {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a49dc2e2f74e65efaa32b153527fc5ac98508d502fa46e74fa4fd678ed6645"},
+    {file = "Pillow-8.4.0-cp36-cp36m-win32.whl", hash = "sha256:93ce9e955cc95959df98505e4608ad98281fff037350d8c2671c9aa86bcf10a9"},
+    {file = "Pillow-8.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2e4440b8f00f504ee4b53fe30f4e381aae30b0568193be305256b1462216feff"},
+    {file = "Pillow-8.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8c803ac3c28bbc53763e6825746f05cc407b20e4a69d0122e526a582e3b5e153"},
+    {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a17b5d948f4ceeceb66384727dde11b240736fddeda54ca740b9b8b1556b29"},
+    {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1394a6ad5abc838c5cd8a92c5a07535648cdf6d09e8e2d6df916dfa9ea86ead8"},
+    {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792e5c12376594bfcb986ebf3855aa4b7c225754e9a9521298e460e92fb4a488"},
+    {file = "Pillow-8.4.0-cp37-cp37m-win32.whl", hash = "sha256:d99ec152570e4196772e7a8e4ba5320d2d27bf22fdf11743dd882936ed64305b"},
+    {file = "Pillow-8.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7b7017b61bbcdd7f6363aeceb881e23c46583739cb69a3ab39cb384f6ec82e5b"},
+    {file = "Pillow-8.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d89363f02658e253dbd171f7c3716a5d340a24ee82d38aab9183f7fdf0cdca49"},
+    {file = "Pillow-8.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a0956fdc5defc34462bb1c765ee88d933239f9a94bc37d132004775241a7585"},
+    {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b7bb9de00197fb4261825c15551adf7605cf14a80badf1761d61e59da347779"},
+    {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72b9e656e340447f827885b8d7a15fc8c4e68d410dc2297ef6787eec0f0ea409"},
+    {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5a4532a12314149d8b4e4ad8ff09dde7427731fcfa5917ff16d0291f13609df"},
+    {file = "Pillow-8.4.0-cp38-cp38-win32.whl", hash = "sha256:82aafa8d5eb68c8463b6e9baeb4f19043bb31fefc03eb7b216b51e6a9981ae09"},
+    {file = "Pillow-8.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:066f3999cb3b070a95c3652712cffa1a748cd02d60ad7b4e485c3748a04d9d76"},
+    {file = "Pillow-8.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:5503c86916d27c2e101b7f71c2ae2cddba01a2cf55b8395b0255fd33fa4d1f1a"},
+    {file = "Pillow-8.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4acc0985ddf39d1bc969a9220b51d94ed51695d455c228d8ac29fcdb25810e6e"},
+    {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b052a619a8bfcf26bd8b3f48f45283f9e977890263e4571f2393ed8898d331b"},
+    {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:493cb4e415f44cd601fcec11c99836f707bb714ab03f5ed46ac25713baf0ff20"},
+    {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed"},
+    {file = "Pillow-8.4.0-cp39-cp39-win32.whl", hash = "sha256:5e9ac5f66616b87d4da618a20ab0a38324dbe88d8a39b55be8964eb520021e02"},
+    {file = "Pillow-8.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3eb1ce5f65908556c2d8685a8f0a6e989d887ec4057326f6c22b24e8a172c66b"},
+    {file = "Pillow-8.4.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ddc4d832a0f0b4c52fff973a0d44b6c99839a9d016fe4e6a1cb8f3eea96479c2"},
+    {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3e5ddc44c14042f0844b8cf7d2cd455f6cc80fd7f5eefbe657292cf601d9ad"},
+    {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70e94281588ef053ae8998039610dbd71bc509e4acbc77ab59d7d2937b10698"},
+    {file = "Pillow-8.4.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:3862b7256046fcd950618ed22d1d60b842e3a40a48236a5498746f21189afbbc"},
+    {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4901622493f88b1a29bd30ec1a2f683782e57c3c16a2dbc7f2595ba01f639df"},
+    {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c471a734240653a0ec91dec0996696eea227eafe72a33bd06c92697728046b"},
+    {file = "Pillow-8.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc"},
+    {file = "Pillow-8.4.0.tar.gz", hash = "sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed"},
 ]
 pluggy = [
     {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
     {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
 ]
 py = [
-    {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
-    {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+    {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+    {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
 ]
 pydicom = [
     {file = "pydicom-2.2.2-py3-none-any.whl", hash = "sha256:6ecb9c6d56a20b2104099b8ef8fe0f3664d797b08a0e0548fe0311b515b32308"},
@@ -352,38 +353,34 @@ pytest-watch = [
     {file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"},
 ]
 shapely = [
-    {file = "Shapely-1.7.1-1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:46da0ea527da9cf9503e66c18bab6981c5556859e518fe71578b47126e54ca93"},
-    {file = "Shapely-1.7.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4c10f317e379cc404f8fc510cd9982d5d3e7ba13a9cfd39aa251d894c6366798"},
-    {file = "Shapely-1.7.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:17df66e87d0fe0193910aeaa938c99f0b04f67b430edb8adae01e7be557b141b"},
-    {file = "Shapely-1.7.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:da38ed3d65b8091447dc3717e5218cc336d20303b77b0634b261bc5c1aa2bae8"},
-    {file = "Shapely-1.7.1-cp35-cp35m-win32.whl", hash = "sha256:8e7659dd994792a0aad8fb80439f59055a21163e236faf2f9823beb63a380e19"},
-    {file = "Shapely-1.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:791477edb422692e7dc351c5ed6530eb0e949a31b45569946619a0d9cd5f53cb"},
-    {file = "Shapely-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3afccf0437edc108eef1e2bb9cc4c7073e7705924eb4cd0bf7715cd1ef0ce1b"},
-    {file = "Shapely-1.7.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8f15b6ce67dcc05b61f19c689b60f3fe58550ba994290ff8332f711f5aaa9840"},
-    {file = "Shapely-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:60e5b2282619249dbe8dc5266d781cc7d7fb1b27fa49f8241f2167672ad26719"},
-    {file = "Shapely-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:de618e67b64a51a0768d26a9963ecd7d338a2cf6e9e7582d2385f88ad005b3d1"},
-    {file = "Shapely-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:182716ffb500d114b5d1b75d7fd9d14b7d3414cef3c38c0490534cc9ce20981a"},
-    {file = "Shapely-1.7.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4f3c59f6dbf86a9fc293546de492f5e07344e045f9333f3a753f2dda903c45d1"},
-    {file = "Shapely-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:6871acba8fbe744efa4f9f34e726d070bfbf9bffb356a8f6d64557846324232b"},
-    {file = "Shapely-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:35be1c5d869966569d3dfd4ec31832d7c780e9df760e1fe52131105685941891"},
-    {file = "Shapely-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:052eb5b9ba756808a7825e8a8020fb146ec489dd5c919e7d139014775411e688"},
-    {file = "Shapely-1.7.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:90a3e2ae0d6d7d50ff2370ba168fbd416a53e7d8448410758c5d6a5920646c1d"},
-    {file = "Shapely-1.7.1-cp38-cp38-win32.whl", hash = "sha256:a3774516c8a83abfd1ddffb8b6ec1b0935d7fe6ea0ff5c31a18bfdae567b4eba"},
-    {file = "Shapely-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:6593026cd3f5daaea12bcc51ae5c979318070fefee210e7990cb8ac2364e79a1"},
-    {file = "Shapely-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:617bf046a6861d7c6b44d2d9cb9e2311548638e684c2cd071d8945f24a926263"},
-    {file = "Shapely-1.7.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b40cc7bb089ae4aa9ddba1db900b4cd1bce3925d2a4b5837b639e49de054784f"},
-    {file = "Shapely-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2df5260d0f2983309776cb41bfa85c464ec07018d88c0ecfca23d40bfadae2f1"},
-    {file = "Shapely-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:a5c3a50d823c192f32615a2a6920e8c046b09e07a58eba220407335a9cd2e8ea"},
-    {file = "Shapely-1.7.1.tar.gz", hash = "sha256:1641724c1055459a7e2b8bbe47ba25bdc89554582e62aec23cb3f3ca25f9b129"},
+    {file = "Shapely-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c5632cedea6d815b61eb4c264da1c3f24a8ce2ceba2f74e30fba340ca230563"},
+    {file = "Shapely-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ce1f18a0c9bb6b483c73bd7a0eb3a5e90676bcc29b9c27120236e662195c9d"},
+    {file = "Shapely-1.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4e8cdffeec6d0c47ed1eb215ec4e80c024ac05be6ded982061c1e1188034f22f"},
+    {file = "Shapely-1.8.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:83d10f8b47a7568fc90063f72da62cda201dc92ecadf80cc00c015babc48e11f"},
+    {file = "Shapely-1.8.0-cp36-cp36m-win32.whl", hash = "sha256:78b3a46dadd47c27e658d5e8d9006b4b1eb9b7ab947b450059225dcee799a18f"},
+    {file = "Shapely-1.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:0e640d6da59172d679270f0dfd88128b6ae7c57df864a030dd858ff924c307fc"},
+    {file = "Shapely-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:68bdf463f7a609fbed42bbded18fa74c82a5741251984a5597d070060f4286f4"},
+    {file = "Shapely-1.8.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c3cc87e66cbffd00ce0457c03969b64935752824bf43a1cd61f21cf606997d6"},
+    {file = "Shapely-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:bd84d993a0e8e07f5ebb4c67794d5392fdd23ce59a7ccc121900f2080f57989a"},
+    {file = "Shapely-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:796b15a483ac37c2dc757654186d0e064a42fb6f43cb9d1ff65d81cd0c92a84e"},
+    {file = "Shapely-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:622f62d2b2da81dd40841a56db0f78bcf9f9af7a83c7d5f5dc9bcb234aa650ba"},
+    {file = "Shapely-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26b43b69dfeb8a8cb27aacf5597134baf12337845c2bacb01809540c20d3d904"},
+    {file = "Shapely-1.8.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cfb9d72d255af1a484e3859f4c9bb737950faf1d16c21d2b949ffc4ba5f46147"},
+    {file = "Shapely-1.8.0-cp38-cp38-win32.whl", hash = "sha256:f304243b1f4d7bca9b3c9fdeec6565171e1b611fb4a3d6c93efc870c8a75958c"},
+    {file = "Shapely-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:8917a91430126165cfa4bc2b4cf168121e37ff0c8657134e7398c597ca1fe934"},
+    {file = "Shapely-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:19b54cd840883fd71cce98fd94916d1731eed8a32c115eb082b3ed24e631be02"},
+    {file = "Shapely-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13cbb959863cec32d48e2cffdc4bb81828bc3b0fa4256c9b2b32edac5021a0e4"},
+    {file = "Shapely-1.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7e1aebf4f1b2fbef40152fd531216387fcf6fe4ff2d777268381979b63c7c779"},
+    {file = "Shapely-1.8.0-cp39-cp39-win32.whl", hash = "sha256:83145eda2e582c2046d1ecc6a0d7dbfe97f492434311124f65ea60f4e87a6b65"},
+    {file = "Shapely-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b54ebd8fa4b78320f6d87032fe91363c7c1bf0f8d4a30eb93bca6413f787fd5"},
+    {file = "Shapely-1.8.0.tar.gz", hash = "sha256:f5307ee14ba4199f8bbcf6532ca33064661c1433960c432c84f0daa73b47ef9c"},
 ]
 toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
-    {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
-    {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+    {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"},
 ]
 watchdog = [
     {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"},
diff --git a/wsidicom/__init__.py b/wsidicom/__init__.py
index 9d6537b4..4e60f513 100644
--- a/wsidicom/__init__.py
+++ b/wsidicom/__init__.py
@@ -3,4 +3,6 @@
                                     Measurement, Point, PointAnnotationGroup,
                                     Polygon, PolygonAnnotationGroup, Polyline,
                                     PolylineAnnotationGroup)
-from .interface import WsiDicom
+from .instance import WsiDataset, WsiInstance
+from .wsidicom import (WsiDicom, WsiDicomLabels, WsiDicomLevels,
+                       WsiDicomOverviews, ImageData)
diff --git a/wsidicom/conceptcode.py b/wsidicom/conceptcode.py
index 8efa5022..dae8e07f 100644
--- a/wsidicom/conceptcode.py
+++ b/wsidicom/conceptcode.py
@@ -22,7 +22,7 @@ def __init__(
         meaning: str,
         value: str,
         scheme_designator: str,
-        scheme_version: str = None
+        scheme_version: Optional[str] = None
     ):
         self.meaning = meaning
         self.value = value
@@ -37,11 +37,6 @@ def __hash__(self):
             self.scheme_version
         ))
 
-    @classmethod
-    @abstractmethod
-    def from_ds(cls, ds: Dataset):
-        raise NotImplementedError
-
     @classmethod
     @abstractmethod
     def list(cls) -> List[str]:
@@ -56,6 +51,18 @@ def code(self) -> Code:
             scheme_version=self.scheme_version
         )
 
+    @classmethod
+    def from_code(
+        cls,
+        code: Code
+    ) -> 'ConceptCode':
+        return cls(
+            code.value,
+            code.scheme_designator,
+            code.meaning,
+            code.scheme_version
+        )
+
     def to_ds(self) -> Dataset:
         """Codes code into DICOM dataset.
 
@@ -99,7 +106,7 @@ def insert_into_ds(self, ds: Dataset) -> Dataset:
     def _from_ds(
         cls,
         ds: Dataset,
-    ) -> Optional[List[Code]]:
+    ) -> Optional[List['ConceptCode']]:
         """Return list of ConceptCode from sequence in dataset.
 
         Parameters
@@ -109,7 +116,7 @@ def _from_ds(
 
         Returns
         ----------
-        List[ConceptCode]
+        Optional[List['ConceptCode']]
             Codes created from sequence in dataset.
 
         """
@@ -140,7 +147,7 @@ def from_ds(cls, ds: Dataset) -> Optional['ConceptCode']:
 
         Returns
         ----------
-        ConceptCode
+        Optional['ConceptCode']
             Measurement code created from value.
 
         """
@@ -153,7 +160,7 @@ def from_ds(cls, ds: Dataset) -> Optional['ConceptCode']:
 class MultipleConceptCode(ConceptCode):
     """Code for concepts that allow multiple items"""
     @classmethod
-    def from_ds(cls, ds: Dataset) -> Optional[List['ConceptCode']]:
+    def from_ds(cls, ds: Dataset) -> List['ConceptCode']:
         """Return measurement code for value. Value can be a code meaning (str)
         or a DICOM dataset containing the code.
 
@@ -168,19 +175,22 @@ def from_ds(cls, ds: Dataset) -> Optional[List['ConceptCode']]:
             Measurement code created from value.
 
         """
-        return cls._from_ds(ds)
+        codes = cls._from_ds(ds)
+        if codes is None:
+            return []
+        return codes
 
 
-class CidConceptCode(ConceptCode, metaclass=ABCMeta):
+class CidConceptCode(ConceptCode):
     """Code for concepts defined in Context groups"""
     cid: Dict[str, Code]
 
     def __init__(
         self,
         meaning: str,
-        value: str = None,
-        scheme_designator: str = None,
-        scheme_version: str = None
+        value: Optional[str] = None,
+        scheme_designator: Optional[str] = None,
+        scheme_version: Optional[str] = None
     ):
         if value is None or scheme_designator is None:
             code = self._from_cid(meaning)
@@ -235,9 +245,9 @@ class UnitCode(SingleConceptCode):
     def __init__(
         self,
         meaning: str,
-        value: str = None,
-        scheme_designator: str = None,
-        scheme_version: str = None
+        value: Optional[str] = None,
+        scheme_designator: Optional[str] = None,
+        scheme_version: Optional[str] = None
     ):
         if value is None or scheme_designator is None:
             code = self._from_ucum(meaning)
@@ -368,7 +378,7 @@ class IlluminatorCode(CidConceptCode, SingleConceptCode):
     cid = codes.cid8125.concepts  # Microscopy Illuminator Type
 
 
-class ChannelDescriptionCode(CidConceptCode, SingleConceptCode):
+class ChannelDescriptionCode(CidConceptCode, MultipleConceptCode):
     """
     Concept code for channel descriptor.
     From CID 8122 Microscopy Illuminator and Sensor Color
diff --git a/wsidicom/errors.py b/wsidicom/errors.py
index 6ea7e491..29ec4fc8 100644
--- a/wsidicom/errors.py
+++ b/wsidicom/errors.py
@@ -1,5 +1,4 @@
 from pathlib import Path
-from .geometry import Point
 
 
 class WsiDicomError(Exception):
diff --git a/wsidicom/geometry.py b/wsidicom/geometry.py
index f1061710..70c8a84e 100644
--- a/wsidicom/geometry.py
+++ b/wsidicom/geometry.py
@@ -1,6 +1,6 @@
 import math
 from dataclasses import dataclass
-from typing import Tuple, Generator
+from typing import Generator, List, Tuple, Union
 
 
 @dataclass
@@ -8,7 +8,7 @@ class SizeMm:
     width: float
     height: float
 
-    def __mul__(self, factor):
+    def __mul__(self, factor: Union[int, float, 'Size', 'Point']) -> 'SizeMm':
         if isinstance(factor, (int, float)):
             return SizeMm(factor*self.width, factor*self.height)
         elif isinstance(factor, Size):
@@ -17,14 +17,14 @@ def __mul__(self, factor):
             return SizeMm(factor.x*self.width, factor.y*self.height)
         return NotImplemented
 
-    def __truediv__(self, divider):
+    def __truediv__(self, divider: Union[int, float, 'SizeMm']) -> 'SizeMm':
         if isinstance(divider, (int, float)):
             return SizeMm(self.width/divider, self.height/divider)
         elif isinstance(divider, SizeMm):
             return SizeMm(self.width/divider.width, self.height/divider.height)
         return NotImplemented
 
-    def __floordiv__(self, divider):
+    def __floordiv__(self, divider: Union[int, float, 'SizeMm']) -> 'Size':
         if isinstance(divider, (int, float)):
             return Size(
                 int(self.width/divider),
@@ -43,9 +43,15 @@ def to_int_tuple(self) -> Tuple[int, int]:
     def to_tuple(self) -> Tuple[float, float]:
         return self.width, self.height
 
-    @staticmethod
-    def from_tuple(tuple: Tuple[float, float]) -> 'SizeMm':
-        return SizeMm(width=tuple[0], height=tuple[1])
+    @classmethod
+    def from_tuple(
+        cls,
+        input: Union[Tuple[float, float], List[float]]
+    ) -> 'SizeMm':
+        try:
+            return cls(input[0], input[1])
+        except IndexError:
+            raise ValueError("input did not contain two values")
 
 
 @dataclass
@@ -53,7 +59,10 @@ class PointMm:
     x: float
     y: float
 
-    def __truediv__(self, divider):
+    def __truediv__(
+        self,
+        divider: Union[int, float, 'PointMm', 'SizeMm']
+    ) -> 'PointMm':
         if isinstance(divider, (int, float)):
             return PointMm(self.x/divider, self.y/divider)
         elif isinstance(divider, PointMm):
@@ -62,7 +71,10 @@ def __truediv__(self, divider):
             return PointMm(self.x/divider.width, self.y/divider.height)
         return NotImplemented
 
-    def __floordiv__(self, divider):
+    def __floordiv__(
+        self,
+        divider: Union[int, float, 'PointMm', 'SizeMm']
+    ) -> 'Point':
         if isinstance(divider, (int, float)):
             return Point(int(self.x//divider), int(self.y//divider))
         elif isinstance(divider, PointMm):
@@ -74,7 +86,10 @@ def __floordiv__(self, divider):
             )
         return NotImplemented
 
-    def __add__(self, value):
+    def __add__(
+        self,
+        value: Union[int, float, 'PointMm', 'SizeMm']
+    ) -> 'PointMm':
         if isinstance(value, (int, float)):
             return PointMm(self.x + value, self.y + value)
         elif isinstance(value, SizeMm):
@@ -83,7 +98,10 @@ def __add__(self, value):
             return PointMm(self.x + value.x, self.y + value.y)
         return NotImplemented
 
-    def __sub__(self, value):
+    def __sub__(
+        self,
+        value: Union[int, float, 'PointMm', 'SizeMm']
+    ) -> 'PointMm':
         if isinstance(value, (int, float)):
             return PointMm(self.x - value, self.y - value)
         elif isinstance(value, SizeMm):
@@ -92,9 +110,15 @@ def __sub__(self, value):
             return PointMm(self.x - value.x, self.y - value.y)
         return NotImplemented
 
-    @staticmethod
-    def from_tuple(tuple: Tuple[float, float]) -> 'PointMm':
-        return PointMm(x=tuple[0], y=tuple[1])
+    @classmethod
+    def from_tuple(
+        cls,
+        input: Union[Tuple[float, float], List[float]]
+    ) -> 'PointMm':
+        try:
+            return cls(input[0], input[1])
+        except IndexError:
+            raise ValueError("input did not contain two values")
 
     def to_tuple(self) -> Tuple[float, float]:
         return (self.x, self.y)
@@ -105,10 +129,10 @@ class Size:
     width: int
     height: int
 
-    def __neg__(self):
+    def __neg__(self) -> 'Size':
         return Size(-self.width, - self.height)
 
-    def __sub__(self, value):
+    def __sub__(self, value: Union[int, 'Size', 'Point']) -> 'Size':
         if isinstance(value, int):
             return Size(self.width - value, self.height - value)
         elif isinstance(value, Size):
@@ -117,7 +141,7 @@ def __sub__(self, value):
             return Size(self.width - value.x, self.height - value.y)
         return NotImplemented
 
-    def __add__(self, value):
+    def __add__(self, value: Union[int, 'Size', 'Point']) -> 'Size':
         if isinstance(value, int):
             return Size(self.width + value, self.height + value)
         elif isinstance(value, Size):
@@ -126,7 +150,7 @@ def __add__(self, value):
             return Size(self.width + value.x, self.height + value.y)
         return NotImplemented
 
-    def __mul__(self, factor):
+    def __mul__(self, factor: Union[int, float, 'Size', 'Point']) -> 'Size':
         if isinstance(factor, (int, float)):
             return Size(int(factor*self.width), int(factor*self.height))
         elif isinstance(factor, Size):
@@ -135,10 +159,9 @@ def __mul__(self, factor):
             return Size(factor.x*self.width, factor.y*self.height)
         return NotImplemented
 
-    def __floordiv__(self, divider):
+    def __floordiv__(self, divider: Union[int, 'Size', SizeMm]) -> 'Size':
         if isinstance(divider, (int, float)):
             return Size(int(self.width/divider), int(self.height/divider))
-
         elif isinstance(divider, (Size, SizeMm)):
             return Size(
                 int(self.width/divider.width),
@@ -146,7 +169,7 @@ def __floordiv__(self, divider):
             )
         return NotImplemented
 
-    def __truediv__(self, divider):
+    def __truediv__(self, divider: Union[int, 'Size', SizeMm]) -> 'Size':
         if isinstance(divider, (int, float)):
             return Size(
                 math.ceil(self.width/divider),
@@ -159,27 +182,30 @@ def __truediv__(self, divider):
             )
         return NotImplemented
 
-    def __hash__(self):
+    def __hash__(self) -> int:
         return hash((self.width, self.height))
 
-    def __lt__(self, item):
+    def __lt__(self, item: 'Size') -> bool:
         if isinstance(item, Size):
             return self.width < item.width
 
-    @staticmethod
-    def from_points(point_1: 'Point', point_2: 'Point'):
-        return Size(point_2.x-point_1.x, point_2.y-point_1.y)
+    @classmethod
+    def from_points(cls, point_1: 'Point', point_2: 'Point') -> 'Size':
+        return cls(point_2.x-point_1.x, point_2.y-point_1.y)
 
     def to_tuple(self) -> Tuple[int, int]:
         return (self.width, self.height)
 
-    @staticmethod
-    def from_tuple(tuple: Tuple[int, int]) -> 'Size':
-        return Size(width=tuple[0], height=tuple[1])
+    @classmethod
+    def from_tuple(cls, input: Union[Tuple[int, int], List[int]]) -> 'Size':
+        try:
+            return cls(input[0], input[1])
+        except IndexError:
+            raise ValueError("input did not contain two values")
 
-    @staticmethod
-    def max(size_1: 'Size', size_2: 'Size'):
-        return Size(
+    @classmethod
+    def max(cls, size_1: 'Size', size_2: 'Size') -> 'Size':
+        return cls(
             width=max(size_1.width, size_2.width),
             height=max(size_1.height, size_2.height)
         )
@@ -200,13 +226,16 @@ class Point:
     x: int
     y: int
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f'{self.x},{self.y}'
 
     def __hash__(self) -> int:
         return hash((self.x, self.y))
 
-    def __mul__(self, factor):
+    def __mul__(
+        self,
+        factor: Union[int, float, Size, 'Point']
+    ) -> 'Point':
         if isinstance(factor, (int, float)):
             return Point(int(factor*self.x), int(factor*self.y))
         elif isinstance(factor, Size):
@@ -215,7 +244,10 @@ def __mul__(self, factor):
             return Point(factor.x*self.x, factor.y*self.y)
         return NotImplemented
 
-    def __floordiv__(self, divider):
+    def __floordiv__(
+        self,
+        divider: Union[int, float, 'Point', Size, SizeMm]
+    ) -> 'Point':
         if isinstance(divider, (int, float)):
             return Point(int(self.x/divider), int(self.y/divider))
         elif isinstance(divider, Point):
@@ -224,7 +256,7 @@ def __floordiv__(self, divider):
             return Point(int(self.x/divider.width), int(self.y/divider.height))
         return NotImplemented
 
-    def __truediv__(self, divider):
+    def __truediv__(self, divider: Union[int, float, Size, SizeMm]) -> 'Point':
         if isinstance(divider, (int, float)):
             return Point(
                 math.ceil(self.x/divider),
@@ -237,14 +269,14 @@ def __truediv__(self, divider):
             )
         return NotImplemented
 
-    def __mod__(self, divider):
+    def __mod__(self, divider: Union[Size, 'Point']) -> 'Point':
         if isinstance(divider, Size):
             return Point(self.x % divider.width, self.y % divider.height)
         elif isinstance(divider, Point):
             return Point(self.x % divider.x, self.y % divider.y)
         return NotImplemented
 
-    def __add__(self, value):
+    def __add__(self, value: Union[int, float, Size, 'Point']) -> 'Point':
         if isinstance(value, (int, float)):
             return Point(int(self.x + value), int(self.y + value))
         elif isinstance(value, Size):
@@ -253,7 +285,7 @@ def __add__(self, value):
             return Point(self.x + value.x, self.y + value.y)
         return NotImplemented
 
-    def __sub__(self, value):
+    def __sub__(self, value: Union[int, float, Size, 'Point']) -> 'Point':
         if isinstance(value, (int, float)):
             return Point(int(self.x - value), int(self.y - value))
         elif isinstance(value, Size):
@@ -262,20 +294,23 @@ def __sub__(self, value):
             return Point(self.x - value.x, self.y - value.y)
         return NotImplemented
 
-    @staticmethod
-    def max(point_1: 'Point', point_2: 'Point'):
-        return Point(x=max(point_1.x, point_2.x), y=max(point_1.y, point_2.y))
+    @classmethod
+    def max(cls, point_1: 'Point', point_2: 'Point') -> 'Point':
+        return cls(x=max(point_1.x, point_2.x), y=max(point_1.y, point_2.y))
 
-    @staticmethod
-    def min(point_1: 'Point', point_2: 'Point'):
-        return Point(x=min(point_1.x, point_2.x), y=min(point_1.y, point_2.y))
+    @classmethod
+    def min(cls, point_1: 'Point', point_2: 'Point') -> 'Point':
+        return cls(x=min(point_1.x, point_2.x), y=min(point_1.y, point_2.y))
 
     def to_tuple(self) -> Tuple[int, int]:
         return (self.x, self.y)
 
-    @staticmethod
-    def from_tuple(tuple: Tuple[int, int]) -> 'Point':
-        return Point(x=tuple[0], y=tuple[1])
+    @classmethod
+    def from_tuple(cls, input: Union[Tuple[int, int], List[int]]) -> 'Point':
+        try:
+            return cls(input[0], input[1])
+        except IndexError:
+            raise ValueError("input did not contain two values")
 
 
 @dataclass
@@ -292,7 +327,7 @@ def end(self) -> Point:
         end: Point = self.position + self.size
         return end
 
-    def __mul__(self, value):
+    def __mul__(self, value: int) -> 'Region':
         if isinstance(value, int):
             return Region(
                 position=self.position * value,
@@ -308,7 +343,10 @@ def box(self) -> Tuple[int, int, int, int]:
     def box_from_origin(self) -> Tuple[int, int, int, int]:
         return 0, 0, self.size.width, self.size.height
 
-    def iterate_all(self, include_end=False) -> Generator[Point, None, None]:
+    def iterate_all(
+        self,
+        include_end: bool = False
+    ) -> Generator[Point, None, None]:
         offset = 1 if include_end else 0
         return (
             Point(x, y)
@@ -316,9 +354,9 @@ def iterate_all(self, include_end=False) -> Generator[Point, None, None]:
             for x in range(self.start.x, self.end.x + offset)
         )
 
-    @staticmethod
-    def from_points(point_1: 'Point', point_2: 'Point') -> 'Region':
-        return Region(
+    @classmethod
+    def from_points(cls, point_1: 'Point', point_2: 'Point') -> 'Region':
+        return cls(
             position=point_1,
             size=Size(
                 width=point_2.x-point_1.x,
@@ -326,9 +364,9 @@ def from_points(point_1: 'Point', point_2: 'Point') -> 'Region':
             )
         )
 
-    @staticmethod
-    def from_tile(tile: 'Point', size: 'Size'):
-        return Region(
+    @classmethod
+    def from_tile(cls, tile: 'Point', size: 'Size') -> 'Region':
+        return cls(
             position=tile*size,
             size=size
         )
@@ -341,18 +379,56 @@ def is_inside(self, test_region: 'Region') -> bool:
             (self.end.y <= test_region.end.y)
         )
 
-    def crop(self, region: 'Region') -> 'Region':
+    def crop(self, other_region: Union[Size, 'Region']) -> 'Region':
+        """Crop other_region to be inside this region. If the other_region is
+        'Size', a region starting from Point(0, 0) is created.
+
+        Parameters
+        ----------
+        other_region: Union[Size, 'Region']
+            Region to be cropped.
+
+        Returns
+        ----------
+        Region
+            Cropped region.
+        """
+        if isinstance(other_region, Size):
+            other_region = Region(Point(0, 0), other_region)
         start = Point.min(
-            Point.max(region.position, self.position),
+            Point.max(other_region.position, self.position),
             self.end
         )
         end = Point.max(
-            Point.min(region.end, self.end),
+            Point.min(other_region.end, self.end),
             self.position
         )
         size = Size.from_points(start, end)
         return Region(position=start, size=size)
 
+    def inside_crop(self, point: Point, tile_size: Size) -> 'Region':
+        """Crop point to be inside and with the same origin as this region.
+
+        Parameters
+        ----------
+        point: Point
+            Point to be cropped.
+
+        Returns
+        ----------
+        Region
+            Cropped region.
+        """
+        tile_region = Region(
+            position=point * tile_size,
+            size=tile_size
+        )
+        cropped_tile_region = self.crop(tile_region)
+        cropped_tile_region.position = (
+            cropped_tile_region.position % tile_size
+        )
+        return cropped_tile_region
+
 
 @dataclass
 class RegionMm:
@@ -368,16 +444,12 @@ def end(self) -> PointMm:
         end: PointMm = self.position + self.size
         return end
 
-    def __add__(self, value):
+    def __add__(self, value: PointMm) -> 'RegionMm':
         if isinstance(value, PointMm):
             return RegionMm(self.position + value, self.size)
-        elif isinstance(value, SizeMm):
-            return RegionMm(self.position, self.size + value)
         return NotImplemented
 
-    def __sub__(self, value):
+    def __sub__(self, value: PointMm) -> 'RegionMm':
         if isinstance(value, PointMm):
             return RegionMm(self.position - value, self.size)
-        elif isinstance(value, SizeMm):
-            return RegionMm(self.position, self.size - value)
         return NotImplemented
diff --git a/wsidicom/graphical_annotations.py b/wsidicom/graphical_annotations.py
index c184ac99..28f141e2 100644
--- a/wsidicom/graphical_annotations.py
+++ b/wsidicom/graphical_annotations.py
@@ -4,17 +4,23 @@
 from datetime import datetime
 from pathlib import Path
 from typing import (Any, Callable, DefaultDict, Dict, Generator, List,
-                    Optional, Set, Tuple, Union)
+                    Optional, Set, Tuple, Union, Type)
 
 import numpy as np
-import pydicom
 from pydicom import config
-from pydicom.dataset import Dataset
+from pydicom.dataset import (Dataset, FileDataset, FileMetaDataset,
+                             validate_file_meta)
 from pydicom.sequence import Sequence as DicomSequence
-from pydicom.sr.codedict import Code, codes
-
-from .uid import ANN_SOP_CLASS_UID, BaseUids, Uid
+from pydicom.sr.codedict import codes
+from pydicom.sr.coding import Code
+from pydicom.uid import (ExplicitVRBigEndian, ExplicitVRLittleEndian,
+                         ImplicitVRLittleEndian, generate_uid)
+from pydicom.values import convert_numbers
+from pydicom.filewriter import dcmwrite
+from pydicom.filereader import dcmread
 from .geometry import PointMm, RegionMm, SizeMm
+from .uid import ANN_SOP_CLASS_UID, BaseUids, Uid
+
 config.enforce_valid_values = True
 config.future_behavior()
 
@@ -26,7 +32,7 @@ class LabColor:
     b: int
 
 
-def dcm_to_list(item: bytes, type: str) -> List:
+def dcm_to_list(item: bytes, type: str) -> List[Any]:
     """Convert item to list of values using specified type format.
 
     Parameters
@@ -38,10 +44,10 @@ def dcm_to_list(item: bytes, type: str) -> List:
 
     Returns
     ----------
-    List
+    List[Any]
         List of values
     """
-    converted = pydicom.values.convert_numbers(
+    converted = convert_numbers(
         item,
         is_little_endian=True,
         struct_format=type
@@ -506,18 +512,18 @@ def __repr__(self) -> str:
     def from_coords(
         cls,
         coords: Union[Tuple[float, float], List[Tuple[float, float]]]
-    ):
+    ) -> 'Geometry':
         """Return geometry object created from list of coordinates"""
         raise NotImplementedError
 
     @classmethod
     @abstractmethod
-    def from_list(cls, list: List[float]):
+    def from_list(cls, list: List[float]) -> 'Geometry':
         """Return geometry object created from list of floats"""
         raise NotImplementedError
 
     @classmethod
-    def list_to_coords(self, data: List[float]) -> List[Tuple[float, float]]:
+    def list_to_coords(cls, data: List[float]) -> List[Tuple[float, float]]:
         """Return cordinates in list of floats as list of tuple of floats
 
         Parameters
@@ -585,11 +591,11 @@ def from_dict(
         ],
         x: str,
         y: str,
-    ):
+    ) -> 'Geometry':
         raise NotImplementedError
 
     @classmethod
-    def from_shapely_like(cls, object) -> 'Geometry':
+    def from_shapely_like(cls, object: Any) -> 'Geometry':
         """Return Geometry from shapely-like object. Object needs to have
         shapely-like attributes.
 
@@ -643,13 +649,13 @@ def from_geojson(
             points: List[float] = coordinates
             return [Point.from_list(points)]
         elif(annotation_type == 'MultiPoint'):
-            points: List[List[float]] = coordinates
-            return [Point.from_list(point) for point in points]
+            multipoints: List[List[float]] = coordinates
+            return [Point.from_list(point) for point in multipoints]
         elif(annotation_type == 'Polygon'):
-            polylines: List[List[List[float]]] = coordinates
+            polylines: List[List[Tuple[float, float]]] = coordinates
             return [Polygon.from_coords(polylines[0])]
         elif(annotation_type == 'LineString'):
-            polyline: List[List[float]] = coordinates
+            polyline: List[Tuple[float, float]] = coordinates
             return [Polyline.from_coords(polyline)]
         raise NotImplementedError("Not supported geojson geometry")
 
@@ -661,7 +667,7 @@ def __init__(self, x: float, y: float):
         self.x = float(x)
         self.y = float(y)
 
-    def __eq__(self, point) -> bool:
+    def __eq__(self, point: 'Point') -> bool:
         if isinstance(point, Point):
             return (
                 self.x == point.x and self.y == point.y
@@ -719,7 +725,7 @@ def multiple_from_dict(
         y: str,
     ) -> List['Point']:
         coords = cls._coordinates_from_dict(dictionary, x, y)
-        return [cls(*point) for point in coords]
+        return [cls.from_coords(point) for point in coords]
 
     @classmethod
     def from_dict(
@@ -848,7 +854,7 @@ class Annotation:
     def __init__(
         self,
         geometry: Geometry,
-        measurements: List[Measurement] = None
+        measurements: Optional[List[Measurement]] = None
     ):
         """Represents an annotation, with geometry and an optional list of
         measurements.
@@ -857,7 +863,7 @@ def __init__(
         ----------
         geometry: Geometry
             Geometry of the annotation.
-        measurements: List[Measurement]
+        measurements: Optional[List[Measurement]]
             Optional measurements of the annotation.
         """
 
@@ -869,9 +875,9 @@ def __init__(
     def __repr__(self) -> str:
         return f"Annotation({self.geometry}, {self.measurements})"
 
-    def __eq__(self, other) -> bool:
+    def __eq__(self, other: 'Annotation') -> bool:
         if not isinstance(other, Annotation):
-            raise NotImplemented(other)
+            return NotImplemented
         return (
             self.geometry == other.geometry and
             self.measurements == other.measurements
@@ -932,7 +938,7 @@ def get_measurement_values(
 
 
 class AnnotationGroup:
-    _geometry_type: type
+    _geometry_type: Type[Geometry]
 
     def __init__(
         self,
@@ -940,10 +946,10 @@ def __init__(
         label: str,
         categorycode: ConceptCode,
         typecode: ConceptCode,
-        description: str = None,
-        color: LabColor = None,
+        description: Optional[str] = None,
+        color: Optional[LabColor] = None,
         is_double: bool = True,
-        instance: Uid = None
+        instance: Optional[Uid] = None
     ):
         """Represents a group of annotations of the same type.
 
@@ -957,19 +963,20 @@ def __init__(
             Group categorycode.
         typecode: ConceptCode
             Group typecode.
-        instance: Uid
-            Uid this group was created from.
-        description: str
+        description: Optional[str] = None
             Group description.
-        color: LabColor
+        color: Optional[LabColor] = None
             Recommended CIELAB color.
         is_double: bool
             If group is stored with double float
+        instance: Optional[Uid]
+            Uid this group was created from.
+
         """
         self.validate_type(annotations, self._geometry_type)
         self._z_planes: List[float] = []
         self._optical_paths: List[str] = []
-        self._uid = pydicom.uid.generate_uid()
+        self._uid = generate_uid()
         self._categorycode = categorycode
         self._typecode = typecode
         self._is_double = is_double
@@ -994,9 +1001,9 @@ def __repr__(self) -> str:
             f"{self.description}, {self._is_double})"
         )
 
-    def __eq__(self, other) -> bool:
+    def __eq__(self, other: 'AnnotationGroup') -> bool:
         if not isinstance(other, AnnotationGroup):
-            raise NotImplemented(other)
+            return NotImplemented
         return (
             self.annotations == other.annotations and
             self.label == other.label and
@@ -1056,14 +1063,21 @@ def number_of_annotations(self) -> int:
         """
         return len(self.annotations)
 
+    @property
+    def geometry_type(self) -> Type[Geometry]:
+        return self._geometry_type
+
     @property
     @abstractmethod
     def annotation_type(self) -> str:
         raise NotImplementedError
 
-    def __getitem__(self, index: Union[int, List[int]]):
+    def __getitem__(
+        self,
+        index: Union[int, List[int]]
+    ) -> Union[Annotation, List[Annotation]]:
         if isinstance(index, list):
-            return [self[i] for i in index]
+            return [self.annotations[i] for i in index]
         return self.annotations[index]
 
     @classmethod
@@ -1260,7 +1274,7 @@ def _get_coordinates_from_ds(
     def _get_geometries_from_ds(
         cls,
         ds: Dataset
-    ):
+    ) -> List[Geometry]:
         """Abstract method for getting geometries from dataset.
 
         Parameters
@@ -1629,7 +1643,10 @@ def validate_type(
                 )
 
     @classmethod
-    def _get_group_type_by_geometry(cls, geometry_type: type):
+    def _get_group_type_by_geometry(
+        cls,
+        geometry_type: Type[Geometry]
+    ) -> Type['AnnotationGroup']:
         """Return AnnotationGroup class for geometry type.
 
         Parameters
@@ -1722,7 +1739,7 @@ def _get_geometries_from_ds(
 
 class PolylineAnnotationGroupMeta(AnnotationGroup):
     """Meta class for line annotation goup"""
-    _geometry_type: type
+    _geometry_type: Type[Geometry]
 
     @property
     def annotation_type(self) -> str:
@@ -1739,7 +1756,7 @@ def point_index_list(self) -> np.ndarray:
             List of indices in annotation group
         """
         index = 1
-        indices = []
+        indices: List[int] = []
         for annotation in self.annotations:
             indices.append(index)
             index += len(annotation.geometry.data)
@@ -1806,7 +1823,9 @@ def _get_geometries_from_ds(
 
     @staticmethod
     @abstractmethod
-    def _get_line_geometry_from_coords(coords: List[Tuple[float, float]]):
+    def _get_line_geometry_from_coords(
+        coords: List[Tuple[float, float]]
+    ) -> Geometry:
         raise NotImplementedError
 
     def to_ds(self, group_number: int) -> Dataset:
@@ -1893,10 +1912,10 @@ def __repr__(self) -> str:
 
     def save(
         self,
-        path: str,
+        path: Union[str, Path],
         little_endian: bool = True,
         implicit_vr: bool = False,
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid
+        uid_generator: Callable[..., Uid] = generate_uid
     ):
         """Write annotations to DICOM file according to sup 222.
         Note that the file will miss DICOM attributes that has not yet been
@@ -1904,7 +1923,7 @@ def save(
 
         Parameters
         ----------
-        path: Path
+        path: Union[str, Path]
             Path to write DICOM file to
         little_endian: bool
             Write DICOM file as little endian
@@ -1934,22 +1953,22 @@ def save(
         ds.SOPInstanceUID = uid_generator()
         ds.SOPClassUID = ANN_SOP_CLASS_UID
 
-        meta_ds = pydicom.dataset.FileMetaDataset()
+        meta_ds = FileMetaDataset()
         if little_endian and implicit_vr:
-            transfer_syntax = pydicom.uid.ImplicitVRLittleEndian
+            transfer_syntax = ImplicitVRLittleEndian
         elif little_endian and not implicit_vr:
-            transfer_syntax = pydicom.uid.ExplicitVRLittleEndian
+            transfer_syntax = ExplicitVRLittleEndian
         elif not little_endian and not implicit_vr:
-            transfer_syntax = pydicom.uid.ExplicitVRBigEndian
+            transfer_syntax = ExplicitVRBigEndian
         else:
             raise NotImplementedError("Unsupported transfer syntax")
 
         meta_ds.TransferSyntaxUID = transfer_syntax
         meta_ds.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
-        meta_ds.MediaStorageSOPClassUID = ANN_SOP_CLASS_UID
+        meta_ds.MediaStorageSOPClassUID = Uid(ANN_SOP_CLASS_UID)
         meta_ds.FileMetaInformationGroupLength = 0  # Updated on write
-        pydicom.dataset.validate_file_meta(meta_ds)
-        file_ds = pydicom.dataset.FileDataset(
+        validate_file_meta(meta_ds)
+        file_ds = FileDataset(
             preamble=b'\x00' * 128,
             filename_or_obj=path,
             file_meta=meta_ds,
@@ -1957,20 +1976,23 @@ def save(
             is_implicit_VR=implicit_vr,
             is_little_endian=little_endian
         )
-        pydicom.filewriter.dcmwrite(path, file_ds)
+        dcmwrite(path, file_ds)
 
     @classmethod
-    def open(cls, paths: List[Path]) -> List['AnnotationInstance']:
+    def open(
+        cls,
+        paths: Union[List[str], List[Path]]
+    ) -> List['AnnotationInstance']:
         """Read annotations from DICOM file according to sup 222.
 
         Parameters
         ----------
-        paths: List[Path]
+        paths: List[str]
             Paths to DICOM annotation files to read.
         """
         instances: List['AnnotationInstance'] = []
         for path in paths:
-            ds = pydicom.filereader.dcmread(path)
+            ds = dcmread(path)
             instances.append(cls.open_dataset(ds))
 
         return instances
@@ -1990,7 +2012,7 @@ def open_dataset(cls, dataset: Dataset) -> 'AnnotationInstance':
             Annotation groups read from dataset.
         """
         groups: List[AnnotationGroup] = []
-        base_uids: BaseUids = None
+        base_uids: Optional[BaseUids] = None
 
         if dataset.file_meta.MediaStorageSOPClassUID != ANN_SOP_CLASS_UID:
             raise ValueError("SOP Class UID of file is wrong")
@@ -1999,6 +2021,8 @@ def open_dataset(cls, dataset: Dataset) -> 'AnnotationInstance':
             coordinate_type = 'image'
         elif dataset.AnnotationCoordinateType == '3D':
             coordinate_type = 'volume'
+        else:
+            raise ValueError("Unkown coordiante type")
         if coordinate_type == 'volume' and frame_of_reference_uid is None:
             raise ValueError(
                 'volume annotation corrindate type requires frame of reference'
diff --git a/wsidicom/interface.py b/wsidicom/instance.py
similarity index 51%
rename from wsidicom/interface.py
rename to wsidicom/instance.py
index 15d7648e..db10d69e 100644
--- a/wsidicom/interface.py
+++ b/wsidicom/instance.py
@@ -1,35 +1,34 @@
 import io
-import math
-import os
 import threading
 import warnings
 from abc import ABCMeta, abstractmethod
-from collections import defaultdict
 from concurrent.futures import ThreadPoolExecutor
-from copy import deepcopy
 from datetime import datetime
 from pathlib import Path
-from typing import (Callable, DefaultDict, Dict, List, Optional, OrderedDict,
-                    Set, Tuple, Union)
+from typing import (Any, Dict, Generator, List, Optional, OrderedDict, Set,
+                    Tuple, Union)
 
 import numpy as np
 import pydicom
 from PIL import Image
-from pydicom.dataset import Dataset
+from pydicom.dataelem import DataElement
+from pydicom.dataset import Dataset, FileMetaDataset, validate_file_meta
+from pydicom.encaps import itemise_frame
+from pydicom.filebase import DicomFile, DicomFileLike
+from pydicom.filereader import (data_element_offset_to_value,
+                                read_file_meta_info)
+from pydicom.filewriter import write_dataset, write_file_meta_info
+from pydicom.misc import is_dicom
+from pydicom.pixel_data_handlers import pillow_handler
 from pydicom.sequence import Sequence as DicomSequence
-from pydicom.uid import UID as Uid
+from pydicom.tag import ItemTag, SequenceDelimiterTag
+from pydicom.uid import JPEG2000, UID, JPEG2000Lossless, JPEGBaseline8Bit
 
 from wsidicom.errors import (WsiDicomError, WsiDicomFileError,
-                             WsiDicomMatchError, WsiDicomNotFoundError,
-                             WsiDicomOutOfBoundsError,
+                             WsiDicomNotFoundError, WsiDicomOutOfBoundsError,
                              WsiDicomUidDuplicateError)
-from wsidicom.geometry import Point, PointMm, Region, RegionMm, Size, SizeMm
-from wsidicom.graphical_annotations import AnnotationInstance
-from wsidicom.optical import OpticalManager
-from wsidicom.stringprinting import (dict_pretty_str, list_pretty_str,
-                                     str_indent)
-from wsidicom.uid import (ANN_SOP_CLASS_UID, WSI_SOP_CLASS_UID, BaseUids,
-                          FileUids)
+from wsidicom.geometry import Point, Region, Size, SizeMm
+from wsidicom.uid import WSI_SOP_CLASS_UID, BaseUids, FileUids
 
 
 class WsiDataset(Dataset):
@@ -39,7 +38,7 @@ class WsiDataset(Dataset):
     """
     def __init__(self, dataset: Dataset):
         super().__init__(dataset)
-        self._instance_uid = Uid(self.SOPInstanceUID)
+        self._instance_uid = UID(self.SOPInstanceUID)
         self._concatenation_uid = getattr(
             self, 'SOPInstanceUIDOfConcatenationSource', None
         )
@@ -128,7 +127,7 @@ def __str__(self) -> str:
         return f"{type(self).__name__} of dataset {self.instance_uid}"
 
     @classmethod
-    def is_wsi_dicom(self, datset: Dataset) -> bool:
+    def is_wsi_dicom(cls, datset: Dataset) -> bool:
         """Check if dataset is dicom wsi type and that required attributes
         (for the function of the library) is available.
         Warn if attribute listed as requierd in the library or required in the
@@ -254,7 +253,7 @@ def is_wsi_dicom(self, datset: Dataset) -> bool:
 
     @staticmethod
     def check_duplicate_dataset(
-        datasets: List[Dataset],
+        datasets: List['WsiDataset'],
         caller: object
     ) -> None:
         """Check for duplicates in a list of datasets. Datasets are duplicate
@@ -268,10 +267,10 @@ def check_duplicate_dataset(
         caller: Object
             Object that the files belongs to.
         """
-        instance_uids: List[Uid] = []
+        instance_uids: List[UID] = []
 
         for dataset in datasets:
-            instance_uid = Uid(dataset.SOPInstanceUID)
+            instance_uid = UID(dataset.SOPInstanceUID)
             if instance_uid not in instance_uids:
                 instance_uids.append(instance_uid)
             else:
@@ -297,7 +296,11 @@ def matches_instance(self, other_dataset: 'WsiDataset') -> bool:
             self.tile_type == other_dataset.tile_type
         )
 
-    def matches_series(self, uids: BaseUids, tile_size: Size = None) -> bool:
+    def matches_series(
+        self,
+        uids: BaseUids,
+        tile_size: Optional[Size] = None
+    ) -> bool:
         """Check if instance is valid (Uids and tile size match).
         Base uids should match for instances in all types of series,
         tile size should only match for level series.
@@ -308,14 +311,14 @@ def matches_series(self, uids: BaseUids, tile_size: Size = None) -> bool:
 
     def get_supported_wsi_dicom_type(
         self,
-        transfer_syntax_uid: Uid
+        transfer_syntax_uid: UID
     ) -> str:
         """Check image flavor and transfer syntax of dicom dataset.
         Return image flavor if file valid.
 
         Parameters
         ----------
-        transfer_syntax_uid: Uid'
+        transfer_syntax_uid: UID
             Transfer syntax uid for file.
 
         Returns
@@ -331,8 +334,7 @@ def get_supported_wsi_dicom_type(
             warnings.warn(f"Non-supported image type {image_type}")
 
         syntax_supported = (
-            pydicom.pixel_data_handlers.pillow_handler.
-            supports_transfer_syntax(transfer_syntax_uid)
+            pillow_handler.supports_transfer_syntax(transfer_syntax_uid)
         )
         if not syntax_supported:
             warnings.warn(
@@ -349,17 +351,17 @@ def read_optical_path_identifier(self, frame: Dataset) -> str:
         optical_sequence = getattr(
             frame,
             'OpticalPathIdentificationSequence',
-            self.OpticalPathSequence
+            self.optical_path_sequence
         )
         return getattr(optical_sequence[0], 'OpticalPathIdentifier', '0')
 
     @property
-    def instance_uid(self) -> Uid:
+    def instance_uid(self) -> UID:
         """Return instance uid from dataset."""
         return self._instance_uid
 
     @property
-    def concatenation_uid(self) -> Optional[Uid]:
+    def concatenation_uid(self) -> Optional[UID]:
         """Return concatenation uid, if defined, from dataset. An instance that
         is concatenated (split into several files) should have the same
         concatenation uid."""
@@ -542,13 +544,13 @@ def __init__(self, filepath: Path):
         self._filepath = filepath
         self._lock = threading.Lock()
 
-        if not pydicom.misc.is_dicom(self.filepath):
+        if not is_dicom(self.filepath):
             raise WsiDicomFileError(self.filepath, "is not a DICOM file")
 
-        file_meta = pydicom.filereader.read_file_meta_info(self.filepath)
-        self._transfer_syntax_uid = Uid(file_meta.TransferSyntaxUID)
+        file_meta = read_file_meta_info(self.filepath)
+        self._transfer_syntax_uid = UID(file_meta.TransferSyntaxUID)
 
-        self._fp = pydicom.filebase.DicomFile(self.filepath, mode='rb')
+        self._fp = DicomFile(self.filepath, mode='rb')
         self._fp.is_little_endian = self._transfer_syntax_uid.is_little_endian
         self._fp.is_implicit_VR = self._transfer_syntax_uid.is_implicit_VR
 
@@ -599,7 +601,7 @@ def uids(self) -> FileUids:
         return self._uids
 
     @property
-    def transfer_syntax(self) -> Uid:
+    def transfer_syntax(self) -> UID:
         """Return transfer syntax uid"""
         return self._transfer_syntax_uid
 
@@ -621,14 +623,14 @@ def frame_count(self) -> int:
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         return f"File with path: {self.filepath}"
 
     def get_filepointer(
         self,
         frame_index: int
-    ) -> Tuple[pydicom.filebase.DicomFileLike, int, int]:
+    ) -> Tuple[DicomFileLike, int, int]:
         """Return file pointer, frame position, and frame lenght for frame
         number.
 
@@ -639,7 +641,7 @@ def get_filepointer(
 
         Returns
         ----------
-        Tuple[pydicom.filebase.DicomFileLike, int, int]:
+        Tuple[DicomFileLike, int, int]:
             File pointer, frame offset and frame lenght in number of bytes
         """
         frame_index -= self.frame_offset
@@ -658,16 +660,16 @@ def _read_bot(self) -> None:
         BOT by checking the actual frame sequence, we just skip over it.
         """
         # Jump to BOT
-        offset_to_value = pydicom.filereader.data_element_offset_to_value(
+        offset_to_value = data_element_offset_to_value(
             self._fp.is_implicit_VR,
             'OB'
         )
         self._fp.seek(offset_to_value, 1)
         # has_BOT, offset_table = pydicom.encaps.get_frame_offsets(self._fp)
         # Read the BOT lenght and skip over the BOT
-        if(self._fp.read_tag() != pydicom.tag.ItemTag):
+        if(self._fp.read_tag() != ItemTag):
             raise WsiDicomFileError(self.filepath, 'No item tag after BOT')
-        length = self._fp.read_UL()
+        length: int = self._fp.read_UL()
         self._fp.seek(length, 1)
 
     def _read_positions(self) -> List[Tuple[int, int]]:
@@ -690,9 +692,9 @@ def _read_positions(self) -> List[Tuple[int, int]]:
         positions: List[Tuple[int, int]] = []
         frame_position = self._fp.tell()
         # Read items until sequence delimiter
-        while(self._fp.read_tag() == pydicom.tag.ItemTag):
+        while(self._fp.read_tag() == ItemTag):
             # Read item length
-            length = self._fp.read_UL()
+            length: int = self._fp.read_UL()
             if length == 0 or length % 2:
                 raise WsiDicomFileError(self.filepath, 'Invalid frame length')
             # Frame position
@@ -709,7 +711,7 @@ def _read_sequence_delimeter(self):
         """
         TAG_BYTES = 4
         self._fp.seek(-TAG_BYTES, 1)
-        if(self._fp.read_tag() != pydicom.tag.SequenceDelimiterTag):
+        if(self._fp.read_tag() != SequenceDelimiterTag):
             raise WsiDicomFileError(self.filepath, 'No sequence delimeter tag')
 
     def _read_frame_positions(self) -> List[Tuple[int, int]]:
@@ -727,7 +729,7 @@ def _read_frame_positions(self) -> List[Tuple[int, int]]:
         self._fp.seek(self._pixel_data_position, 0)  # Wind back to start
         return positions
 
-    def _read_frame(self, frame_index: int) -> bytes:
+    def read_frame(self, frame_index: int) -> bytes:
         """Return frame data from pixel data by frame index.
 
         Parameters
@@ -768,10 +770,10 @@ def _parse_pixel_data(self) -> List[Tuple[int, int]]:
         return frame_positions
 
     @staticmethod
-    def _filter_files(
+    def filter_files(
         files: List['WsiDicomFile'],
         series_uids: BaseUids,
-        series_tile_size: Size = None
+        series_tile_size: Optional[Size] = None
     ) -> List['WsiDicomFile']:
         """Filter list of wsi dicom files to only include matching uids and
         tile size if defined.
@@ -782,7 +784,7 @@ def _filter_files(
             Wsi files to filter.
         series_uids: Uids
             Uids to check against.
-        series_tile_size: Size
+        series_tile_size: Optional[Size] = None
             Tile size to check against.
 
         Returns
@@ -806,7 +808,7 @@ def _filter_files(
         return valid_files
 
     @classmethod
-    def _group_files(
+    def group_files(
         cls,
         files: List['WsiDicomFile']
     ) -> Dict[str, List['WsiDicomFile']]:
@@ -838,11 +840,18 @@ class ImageData(metaclass=ABCMeta):
     and photometric_interpretation and methods get_tile() and close().
     Additionally properties focal_planes and/or optical_paths should be
     overridden if multiple focal planes or optical paths are implemented."""
-    default_z: float = None
+    _default_z: Optional[float] = None
+    _blank_tile: Optional[Image.Image] = None
+    _encoded_blank_tile: Optional[bytes] = None
+
+    @property
+    @abstractmethod
+    def files(self) -> List[Path]:
+        raise NotImplementedError
 
     @property
     @abstractmethod
-    def transfer_syntax(self) -> Uid:
+    def transfer_syntax(self) -> UID:
         """Should return the uid of the transfer syntax of the image."""
         raise NotImplementedError
 
@@ -879,9 +888,9 @@ def photometric_interpretation(self) -> str:
         raise NotImplementedError
 
     @abstractmethod
-    def get_decoded_tile(
+    def _get_decoded_tile(
         self,
-        tile: Point,
+        tile_point: Point,
         z: float,
         path: str
     ) -> Image.Image:
@@ -890,7 +899,7 @@ def get_decoded_tile(
         raise NotImplementedError
 
     @abstractmethod
-    def get_encoded_tile(
+    def _get_encoded_tile(
         self,
         tile: Point,
         z: float,
@@ -911,6 +920,10 @@ def tiled_size(self) -> Size:
         columns and rows of tiles. Equals (1, 1) if image is not tiled."""
         return self.image_size / self.tile_size
 
+    @property
+    def image_region(self) -> Region:
+        return Region(Point(0, 0), self.image_size)
+
     @property
     def focal_planes(self) -> List[float]:
         """Focal planes avaiable in the image defined in um."""
@@ -925,9 +938,9 @@ def optical_paths(self) -> List[str]:
     def image_mode(self) -> str:
         """Return Pillow image mode (e.g. RGB) for image data"""
         if(self.samples_per_pixel == 1):
-            return "L"
+            return 'L'
         elif(self.samples_per_pixel == 3):
-            return "RGB"
+            return 'RGB'
         raise NotImplementedError
 
     @property
@@ -938,7 +951,7 @@ def blank_color(self) -> Tuple[int, int, int]:
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         return str(self)
 
@@ -970,6 +983,20 @@ def default_path(self) -> str:
     def plane_region(self) -> Region:
         return Region(position=Point(0, 0), size=self.tiled_size - 1)
 
+    @property
+    def blank_tile(self) -> Image.Image:
+        """Return background tile."""
+        if self._blank_tile is None:
+            self._blank_tile = self._create_blank_tile()
+        return self._blank_tile
+
+    @property
+    def blank_encoded_tile(self) -> bytes:
+        """Return encoded background tile."""
+        if self._encoded_blank_tile is None:
+            self._encoded_blank_tile = self.encode(self.blank_tile)
+        return self._encoded_blank_tile
+
     def get_decoded_tiles(
         self,
         tiles: List[Point],
@@ -979,7 +1006,7 @@ def get_decoded_tiles(
         """Return Images for tile defined by tile (x, y), z, and optical
         path."""
         return [
-            self.get_decoded_tile(tile, z, path) for tile in tiles
+            self._get_decoded_tile(tile, z, path) for tile in tiles
         ]
 
     def get_encoded_tiles(
@@ -991,9 +1018,75 @@ def get_encoded_tiles(
         """Return image bytes for tile defined by tile (x, y), z, and optical
         path."""
         return [
-            self.get_encoded_tile(tile, z, path) for tile in tiles
+            self._get_encoded_tile(tile, z, path) for tile in tiles
         ]
 
+    def get_encoded_scaled_tiles(
+        self,
+        scaled_tile_points: List[Point],
+        z: float,
+        path: str,
+        scale: int,
+        image_format: str,
+        image_options: Dict[str, Any]
+    ) -> List[bytes]:
+        scaled_tiles: List[bytes] = []
+        for scaled_tile_point in scaled_tile_points:
+            # For each tile point in the target scale
+            image = Image.new(
+                mode=self.image_mode,  # type: ignore
+                size=(self.tile_size * scale).to_tuple()
+            )
+            # Get decoded tiles for the region covering the scaled tile
+            # in the image data
+            tile_points = Region(
+                scaled_tile_point*scale,
+                Size(1, 1) * scale
+            ).iterate_all()
+            tiles: List[Image.Image] = []
+            for tile_point in tile_points:
+                if (
+                    (tile_point.x < self.tiled_size.width) and
+                    (tile_point.y < self.tiled_size.height)
+                ):
+                    tile = self._get_decoded_tile(
+                        tile_point,
+                        z,
+                        path
+                    )
+                else:
+                    tile = self.blank_tile
+                tiles.append(tile)
+
+            # Paste the unscalled images into a large tile
+            for y in range(scale):
+                for x in range(scale):
+                    tile_index = y*scale + x
+                    tile_point = Point(x, y)
+                    image_coordinate = (
+                        (tile_point * self.tile_size)
+                    )
+                    image.paste(
+                        tiles[tile_index],
+                        image_coordinate.to_tuple()
+                    )
+            # Resize the tile
+            image = image.resize(
+                self.tile_size.to_tuple(),
+                resample=Image.BILINEAR
+            )
+            # Compress the tile and add to created tiles
+            with io.BytesIO() as buffer:
+                image.save(
+                    buffer,
+                    format=image_format,
+                    **image_options
+                )
+                scaled_tiles.append(buffer.getvalue())
+
+        # Return created tiles
+        return scaled_tiles
+
     def valid_tiles(self, region: Region, z: float, path: str) -> bool:
         """Check if tile region is inside tile geometry and z coordinate and
         optical path exists.
@@ -1036,8 +1129,8 @@ def encode(self, image: Image.Image) -> bytes:
 
     @staticmethod
     def _image_settings(
-        transfer_syntax: pydicom.uid
-    ) -> Tuple[str, Dict[str, int]]:
+        transfer_syntax: UID
+    ) -> Tuple[str, Dict[str, Any]]:
         """Return image format and options for creating encoded tiles as in the
         used transfer syntax.
 
@@ -1052,15 +1145,15 @@ def _image_settings(
             image format and image options
 
         """
-        if(transfer_syntax == pydicom.uid.JPEGBaseline8Bit):
+        if(transfer_syntax == JPEGBaseline8Bit):
             image_format = 'jpeg'
             image_options = {'quality': 95}
-        elif(transfer_syntax == pydicom.uid.JPEG2000):
+        elif(transfer_syntax == JPEG2000):
             image_format = 'jpeg2000'
-            image_options = {'irreversible': True}
-        elif(transfer_syntax == pydicom.uid.JPEG2000Lossless):
+            image_options = {"irreversible": True}
+        elif(transfer_syntax == JPEG2000Lossless):
             image_format = 'jpeg2000'
-            image_options = {'irreversible': False}
+            image_options = {"irreversible": False}
         else:
             raise NotImplementedError(
                 "Only supports jpeg and jpeg2000"
@@ -1090,12 +1183,209 @@ def _get_blank_color(
             return (BLACK, BLACK, BLACK)  # Monocrhome2 is black
         return (WHITE, WHITE, WHITE)
 
+    def _create_blank_tile(self) -> Image.Image:
+        """Create blank tile for instance.
+
+        Returns
+        ----------
+        Image.Image
+            Blank tile image
+        """
+        return Image.new(
+            mode=self.image_mode,  # type: ignore
+            size=self.tile_size.to_tuple(),
+            color=self.blank_color[:self.samples_per_pixel]
+        )
+
+    def stitch_tiles(
+        self,
+        region: Region,
+        path: str,
+        z: float
+    ) -> Image.Image:
+        """Stitches tiles together to form requested image.
+
+        Parameters
+        ----------
+        region: Region
+             Pixel region to stitch to image
+        path: str
+            Optical path
+        z: float
+            Z coordinate
+
+        Returns
+        ----------
+        Image.Image
+            Stitched image
+        """
+
+        image = Image.new(
+            mode=self.image_mode,  # type: ignore
+            size=region.size.to_tuple()
+        )
+        stitching_tiles = self.get_tile_range(region, z, path)
+
+        write_index = Point(x=0, y=0)
+        tile = stitching_tiles.position
+        for tile in stitching_tiles.iterate_all(include_end=True):
+            tile_image = self.get_tile(tile, z, path, region)
+            image.paste(tile_image, write_index.to_tuple())
+            write_index = self._write_indexer(
+                write_index,
+                Size.from_tuple(tile_image.size),
+                region.size
+            )
+        return image
+
+    def get_tile_range(
+        self,
+        pixel_region: Region,
+        z: float,
+        path: str
+    ) -> Region:
+        """Return range of tiles to cover pixel region.
+
+        Parameters
+        ----------
+        pixel_region: Region
+            Pixel region of tiles to get
+        z: float
+            Z coordinate of tiles to get
+        path: str
+            Optical path identifier of tiles to get
+
+        Returns
+        ----------
+        Region
+            Region of tiles for stitching image
+        """
+        start = pixel_region.start // self.tile_size
+        end = pixel_region.end / self.tile_size - 1
+        tile_region = Region.from_points(start, end)
+        if not self.valid_tiles(tile_region, z, path):
+            raise WsiDicomOutOfBoundsError(
+                f"Tile region {tile_region}",
+                f"tiled size {self.tiled_size}"
+            )
+        return tile_region
+
+    @staticmethod
+    def _write_indexer(
+        index: Point,
+        previous_size: Size,
+        image_size: Size
+    ) -> Point:
+        """Increment index in x by previous width until index x exceds image
+        size. Then resets index x to 0 and increments index y by previous
+        height. Requires that tiles are scanned row by row.
+
+        Parameters
+        ----------
+        index: Point
+            The last write index position
+        previouis_size: Size
+            The size of the last written last tile
+        image_size: Size
+            The size of the image to be written
+
+        Returns
+        ----------
+        Point
+            The position (upper right) in image to insert the next tile into
+        """
+        index.x += previous_size.width
+        if(index.x >= image_size.width):
+            index.x = 0
+            index.y += previous_size.height
+        return index
+
+    def get_tile(
+        self,
+        tile: Point,
+        z: float,
+        path: str,
+        crop: Union[bool, Region] = True
+    ) -> Image.Image:
+        """Get tile image at tile coordinate x, y. If frame is inside tile
+        geometry but no tile exists in frame data (sparse) returns blank image.
+        Optional crop tile to crop_region.
+
+        Parameters
+        ----------
+        tile: Point
+            Tile x, y coordinate.
+        z: float
+            Z coordinate.
+        path: str
+            Optical path.
+        crop: Union[bool, Region] = True
+            If to crop tile to image size (True, default) or to region.
+
+        Returns
+        ----------
+        Image.Image
+            Tile image.
+        """
+        image = self._get_decoded_tile(tile, z, path)
+        if crop is False:
+            return image
+
+        if isinstance(crop, bool):
+            crop = self.image_region
+        tile_crop = crop.inside_crop(tile, self.tile_size)
+        if tile_crop.size == self.tile_size:
+            return image
+
+        return image.crop(box=tile_crop.box)
+
+    def get_encoded_tile(
+        self,
+        tile: Point,
+        z: float,
+        path: str,
+        crop: Union[bool, Region] = True
+    ) -> bytes:
+        """Get tile bytes at tile coordinate x, y
+        If frame is inside tile geometry but no tile exists in
+        frame data (sparse) returns encoded blank image.
+
+        Parameters
+        ----------
+        tile: Point
+            Tile x, y coordinate.
+        z: float
+            Z coordinate.
+        path: str
+            Optical path.
+        crop: Union[bool, Region] = True
+            If to crop tile to image size (True, default) or to region.
+
+        Returns
+        ----------
+        bytes
+            Tile image as bytes.
+        """
+        tile_frame = self._get_encoded_tile(tile, z, path)
+        if crop is False:
+            return tile_frame
+
+        if isinstance(crop, bool):
+            crop = self.image_region
+        # Check if tile is an edge tile that should be croped
+        cropped_tile_region = crop.inside_crop(tile, self.tile_size)
+        if cropped_tile_region.size != self.tile_size:
+            image = Image.open(io.BytesIO(tile_frame))
+            image.crop(box=cropped_tile_region.box_from_origin)
+            tile_frame = self.encode(image)
+        return tile_frame
+
 
-class DicomImageData(ImageData):
+class WsiDicomImageData(ImageData):
     """Represents image data read from dicom file(s). Image data can
     be sparsly or fully tiled and/or concatenated."""
     def __init__(self, files: Union[WsiDicomFile, List[WsiDicomFile]]) -> None:
-        """Create DicomImageData from frame data in files.
+        """Create WsiDicomImageData from frame data in files.
 
         Parameters
         ----------
@@ -1120,15 +1410,12 @@ def __init__(self, files: Union[WsiDicomFile, List[WsiDicomFile]]) -> None:
 
         self._pixel_spacing = datasets[0].pixel_spacing
         self._transfer_syntax = base_file.transfer_syntax
-        self._default_z: float = None
+        self._default_z: Optional[float] = None
         self._photometric_interpretation = (
             datasets[0].photometric_interpretation
         )
         self._samples_per_pixel = datasets[0].samples_per_pixel
 
-        self._blank_tile = None
-        self._encoded_blank_tile = None
-
     def __repr__(self) -> str:
         return f"{type(self).__name__}({self._files.values()})"
 
@@ -1136,7 +1423,11 @@ def __str__(self) -> str:
         return f"{type(self).__name__} of files {self._files.values()}"
 
     @property
-    def transfer_syntax(self) -> Uid:
+    def files(self) -> List[Path]:
+        return [file.filepath for file in self._files.values()]
+
+    @property
+    def transfer_syntax(self) -> UID:
         """The uid of the transfer syntax of the image."""
         return self._transfer_syntax
 
@@ -1175,33 +1466,19 @@ def samples_per_pixel(self) -> int:
         """Return samples per pixel (1 or 3)."""
         return self._samples_per_pixel
 
-    @property
-    def blank_tile(self) -> Image.Image:
-        """Return background tile."""
-        if self._blank_tile is None:
-            self._blank_tile = self._create_blank_tile()
-        return self._blank_tile
-
-    @property
-    def blank_encoded_tile(self) -> bytes:
-        """Return encoded background tile."""
-        if self._encoded_blank_tile is None:
-            self._encoded_blank_tile = self.encode(self.blank_tile)
-        return self._encoded_blank_tile
-
-    def get_encoded_tile(self, tile: Point, z: float, path: str) -> bytes:
+    def _get_encoded_tile(self, tile: Point, z: float, path: str) -> bytes:
         frame_index = self._get_frame_index(tile, z, path)
         if frame_index == -1:
             return self.blank_encoded_tile
         return self._get_tile_frame(frame_index)
 
-    def get_decoded_tile(
+    def _get_decoded_tile(
         self,
-        tile: Point,
+        tile_point: Point,
         z: float,
         path: str
     ) -> Image.Image:
-        frame_index = self._get_frame_index(tile, z, path)
+        frame_index = self._get_frame_index(tile_point, z, path)
         if frame_index == -1:
             return self.blank_tile
         frame = self._get_tile_frame(frame_index)
@@ -1212,7 +1489,7 @@ def get_filepointer(
         tile: Point,
         z: float,
         path: str
-    ) -> Optional[Tuple[pydicom.filebase.DicomFileLike, int, int]]:
+    ) -> Optional[Tuple[DicomFileLike, int, int]]:
         """Return file pointer, frame position, and frame lenght for tile with
         z and path. If frame is inside tile geometry but no tile exists in
         frame data None is returned.
@@ -1272,7 +1549,7 @@ def _get_tile_frame(self, frame_index: int) -> bytes:
             The frame in bytes
         """
         file = self._get_file(frame_index)
-        tile_frame = file._read_frame(frame_index)
+        tile_frame = file.read_frame(frame_index)
         return tile_frame
 
     def _get_frame_index(self, tile: Point, z: float, path: str) -> int:
@@ -1305,191 +1582,27 @@ def _get_frame_index(self, tile: Point, z: float, path: str) -> int:
     def is_sparse(self, tile: Point, z: float, path: str) -> bool:
         return (self.tiles.get_frame_index(tile, z, path) == -1)
 
-    def _create_blank_tile(self) -> Image.Image:
-        """Create blank tile for instance.
-
-        Returns
-        ----------
-        Image.Image
-            Blank tile image
-        """
-        return Image.new(
-            mode=self.image_mode,
-            size=self.tile_size.to_tuple(),
-            color=self.blank_color[:self.samples_per_pixel]
-        )
-
     def close(self) -> None:
         for file in self._files.values():
             file.close()
 
 
-class DicomWsiFileWriter:
-    def __init__(self, path: Path) -> None:
-        """Return a dicom filepointer.
+class SparseTilePlane:
+    """Hold frame indices for the tiles in a sparse tiled file. Empty (sparse)
+    frames are represented by -1."""
+    def __init__(self, tiled_size: Size):
+        """Create a SparseTilePlane of specified size.
 
         Parameters
         ----------
-        path: Path
-            Path to filepointer.
-
+        tiled_size: Size
+            Size of the tiling
         """
-        self._fp = pydicom.filebase.DicomFile(path, mode='wb')
-        self._fp.is_little_endian = True
-        self._fp.is_implicit_VR = False
+        self._shape = tiled_size
+        self.plane = np.full(tiled_size.to_tuple(), -1, dtype=np.dtype(int))
 
-        self.__enter__()
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        self.close()
-
-    def write_preamble(self) -> None:
-        """Writes file preamble to file."""
-        preamble = b'\x00' * 128
-        self._fp.write(preamble)
-        self._fp.write(b'DICM')
-
-    def write_file_meta(self, uid: Uid, transfer_syntax: Uid) -> None:
-        """Writes file meta dataset to file.
-
-        Parameters
-        ----------
-        uid: Uid
-            SOP instance uid to include in file.
-        transfer_syntax: Uid
-            Transfer syntax used in file.
-        """
-        meta_ds = pydicom.dataset.FileMetaDataset()
-        meta_ds.TransferSyntaxUID = transfer_syntax
-        meta_ds.MediaStorageSOPInstanceUID = uid
-        meta_ds.MediaStorageSOPClassUID = WSI_SOP_CLASS_UID
-        pydicom.dataset.validate_file_meta(meta_ds)
-        pydicom.filewriter.write_file_meta_info(self._fp, meta_ds)
-
-    def write_base(self, dataset: WsiDataset) -> None:
-        """Writes base dataset to file.
-
-        Parameters
-        ----------
-        dataset: WsiDataset
-
-        """
-        now = datetime.now()
-        dataset.ContentDate = datetime.date(now).strftime('%Y%m%d')
-        dataset.ContentTime = datetime.time(now).strftime('%H%M%S.%f')
-        pydicom.filewriter.write_dataset(self._fp, dataset)
-
-    def write_pixel_data_start(self) -> None:
-        """Writes tags starting pixel data."""
-        pixel_data_element = pydicom.dataset.DataElement(
-            0x7FE00010,
-            'OB',
-            0,
-            is_undefined_length=True
-            )
-
-        # Write pixel data tag
-        self._fp.write_tag(pixel_data_element.tag)
-
-        if not self._fp.is_implicit_VR:
-            # Write pixel data VR (OB), two empty bytes (PS3.5 7.1.2)
-            self._fp.write(bytes(pixel_data_element.VR, "iso8859"))
-            self._fp.write_US(0)
-        # Write unspecific length
-        self._fp.write_UL(0xFFFFFFFF)
-
-        # Write item tag and (empty) length for BOT
-        self._fp.write_tag(pydicom.tag.ItemTag)
-        self._fp.write_UL(0)
-
-    def write_pixel_data(
-        self,
-        image_data: ImageData,
-        z: float,
-        path: str,
-        workers: int = os.cpu_count(),
-        chunk_size: int = 100
-    ) -> None:
-        """Writes pixel data to file.
-
-        Parameters
-        ----------
-        image_data: ImageData
-            Image data to read pixel tiles from.
-        z: float
-            Focal plane to write.
-        path: str
-            Optical path to write.
-        workers: int = os.cpu_count()
-            Maximum number of thread workers to use.
-        chunk_size: int = 100
-            Chunk size (number of tiles) to process at a time. Actual chunk
-            size also depends on minimun_chunk_size from image_data.
-        """
-        minimum_chunk_size = getattr(
-            image_data,
-            'suggested_minimum_chunk_size',
-            1
-        )
-        # If chunk_size is less than minimum_chunk_size, use minimum_chunk_size
-        # Otherwise, set chunk_size to highest even multiple of
-        # minimum_chunk_size
-        chunk_size = max(
-            minimum_chunk_size,
-            chunk_size//minimum_chunk_size * minimum_chunk_size
-        )
-
-        # Divide the image tiles up into chunk_size chunks (up to tiled size)
-        chunked_tile_points = (
-            Region(
-                Point(x, y),
-                Size(min(chunk_size, image_data.tiled_size.width - x), 1)
-            ).iterate_all()
-            for y in range(image_data.tiled_size.height)
-            for x in range(0, image_data.tiled_size.width, chunk_size)
-        )
-        with ThreadPoolExecutor(max_workers=workers) as pool:
-            def get_tiles_thread(tile_points: List[Point]) -> List[bytes]:
-                # Thread that takes a chunk of tile points and returns list of
-                # tile bytes
-                return image_data.get_encoded_tiles(tile_points, z, path)
-
-            # Each thread result is a list of tiles that is itemized and writen
-            for thread_result in pool.map(
-                get_tiles_thread,
-                chunked_tile_points
-            ):
-                for tile in thread_result:
-                    for frame in pydicom.encaps.itemize_frame(tile, 1):
-                        self._fp.write(frame)
-
-    def write_pixel_data_end(self) -> None:
-        """Writes tags ending pixel data."""
-        self._fp.write_tag(pydicom.tag.SequenceDelimiterTag)
-        self._fp.write_UL(0)
-
-    def close(self) -> None:
-        self._fp.close()
-
-
-class SparseTilePlane:
-    """Hold frame indices for the tiles in a sparse tiled file. Empty (sparse)
-    frames are represented by -1."""
-    def __init__(self, tiled_size: Size):
-        """Create a SparseTilePlane of specified size.
-
-        Parameters
-        ----------
-        tiled_size: Size
-            Size of the tiling
-        """
-        self.plane = np.full(tiled_size.to_tuple(), -1, dtype=int)
-
-    def __repr__(self) -> str:
-        return f"{type(self).__name__}({Size.from_tuple(self.plane.shape)})"
+    def __repr__(self) -> str:
+        return f"{type(self).__name__}({self._shape})"
 
     def __str__(self) -> str:
         return self.pretty_str()
@@ -1525,7 +1638,7 @@ def __setitem__(self, position: Point, frame_index: int):
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         return "Sparse tile plane"
 
@@ -1597,7 +1710,7 @@ def optical_paths(self) -> List[str]:
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         raise NotImplementedError()
 
@@ -1628,8 +1741,9 @@ def _read_frame_count_from_datasets(
             count += dataset.frame_count
         return count
 
-    @staticmethod
+    @classmethod
     def _read_optical_paths_from_datasets(
+        cls,
         datasets: List[WsiDataset]
     ) -> List[str]:
         """Return list of optical path identifiers from files.
@@ -1647,11 +1761,33 @@ def _read_optical_paths_from_datasets(
         """
         paths: Set[str] = set()
         for dataset in datasets:
-            paths.update(OpticalManager.get_path_identifers(
+            paths.update(cls._get_path_identifers(
                 dataset.optical_path_sequence
             ))
         return list(paths)
 
+    @staticmethod
+    def _get_path_identifers(
+        optical_path_sequence: DicomSequence
+    ) -> List[str]:
+        """Parse optical path sequence and return list of optical path
+        identifiers
+
+        Parameters
+        ----------
+        optical_path_sequence: DicomSequence
+            Optical path sequence.
+
+        Returns
+        ----------
+        List[str]
+            List of optical path identifiers.
+        """
+        return list({
+            str(optical_ds.OpticalPathIdentifier)
+            for optical_ds in optical_path_sequence
+        })
+
 
 class FullTileIndex(TileIndex):
     """Index for mapping tile position to frame number for datasets containing
@@ -1682,7 +1818,7 @@ def __str__(self) -> str:
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         string = (
             f"Full tile index tile size: {self.tile_size}"
@@ -1777,7 +1913,7 @@ def _get_optical_path_index(self, path: str) -> int:
                  if plane_path == path)
             )
         except StopIteration:
-            raise WsiDicomNotFoundError(f"Optical path {path}", self)
+            raise WsiDicomNotFoundError(f"Optical path {path}", str(self))
 
     def _get_focal_plane_index(self, z: float) -> int:
         """Return index of the focal plane of z.
@@ -1796,7 +1932,7 @@ def _get_focal_plane_index(self, z: float) -> int:
             return next(index for index, plane in enumerate(self.focal_planes)
                         if plane == z)
         except StopIteration:
-            raise WsiDicomNotFoundError(f"Z {z} in instance", self)
+            raise WsiDicomNotFoundError(f"Z {z} in instance", str(self))
 
 
 class SparseTileIndex(TileIndex):
@@ -1834,7 +1970,7 @@ def __str__(self) -> str:
     def pretty_str(
         self,
         indent: int = 0,
-        depth: int = None
+        depth: Optional[int] = None
     ) -> str:
         return (
             f"Sparse tile index tile size: {self.tile_size}, "
@@ -1863,7 +1999,7 @@ def get_frame_index(self, tile: Point, z: float, path: str) -> int:
             plane = self._planes[(z, path)]
         except KeyError:
             raise WsiDicomNotFoundError(
-                f"Plane with z {z}, path {path}", self
+                f"Plane with z {z}, path {path}", str(self)
             )
         frame_index = plane[tile]
         return frame_index
@@ -1877,7 +2013,7 @@ def _get_focal_planes(self) -> List[float]:
             Focal planes, specified in um.
         """
         focal_planes: Set[float] = set()
-        for z, path in self._planes.keys():
+        for z, _ in self._planes.keys():
             focal_planes.add(z)
         return list(focal_planes)
 
@@ -1916,7 +2052,7 @@ def _read_planes_from_datasets(
 
     def _read_frame_coordinates(
             self,
-            frame: DicomSequence
+            frame: Dataset
 
     ) -> Tuple[Point, float]:
         """Return frame coordinate (Point(x, y) and float z) of the frame.
@@ -1925,7 +2061,7 @@ def _read_frame_coordinates(
 
         Parameters
         ----------
-        frame: DicomSequence
+        frame: Dataset
             Pydicom frame sequence.
 
         Returns
@@ -1942,96 +2078,283 @@ def _read_frame_coordinates(
         return tile, z
 
 
-class WsiInstance:
-    """Represents a level, label, or overview wsi image, containing image data
-    and datasets with metadata."""
-    def __init__(
-        self,
-        datasets: Union[WsiDataset, List[WsiDataset]],
-        image_data: ImageData
-    ):
-        """Create a WsiInstance from datasets with metadata and image data.
+class WsiDicomFileWriter:
+    def __init__(self, path: Path) -> None:
+        """Return a dicom filepointer.
 
         Parameters
         ----------
-        datasets: Union[WsiDataset, List[WsiDataset]]
-            Single dataset or list of datasets.
-        image_data: ImageData
-            Image data.
+        path: Path
+            Path to filepointer.
+
         """
-        if not isinstance(datasets, list):
-            datasets = [datasets]
-        self._datasets = datasets
-        self._image_data = image_data
-        self._identifier, self._uids = self._validate_instance(self.datasets)
-        self._wsi_type = self.dataset.get_supported_wsi_dicom_type(
-            self.image_data.transfer_syntax
-        )
+        self._fp = DicomFile(path, mode='wb')
+        self._fp.is_little_endian = True
+        self._fp.is_implicit_VR = False
 
-        if self.ext_depth_of_field:
-            if self.ext_depth_of_field_planes is None:
-                raise WsiDicomError("Instance Missing NumberOfFocalPlanes")
-            if self.ext_depth_of_field_plane_distance is None:
-                raise WsiDicomError(
-                    "Instance Missing DistanceBetweenFocalPlanes"
-                )
+        self.__enter__()
 
-    def __repr__(self) -> str:
-        return f"{type(self).__name__}({self.dataset}, {self.image_data})"
+    def __enter__(self):
+        return self
 
-    def __str__(self) -> str:
-        return self.pretty_str()
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close()
 
-    def pretty_str(
-        self,
-        indent: int = 0,
-        depth: int = None
-    ) -> str:
-        string = (
-            f"default z: {self.default_z} "
-            f"default path: { self.default_path}"
+    def write_preamble(self) -> None:
+        """Writes file preamble to file."""
+        preamble = b'\x00' * 128
+        self._fp.write(preamble)
+        self._fp.write(b'DICM')
 
-        )
-        if depth is not None:
-            depth -= 1
-            if(depth < 0):
-                return string
-        string += (
-            ' ImageData ' + self._image_data.pretty_str(indent+1, depth)
-        )
-        return string
+    def write_file_meta(self, uid: UID, transfer_syntax: UID) -> None:
+        """Writes file meta dataset to file.
 
-    @property
-    def wsi_type(self) -> str:
-        """Return wsi type."""
-        return self._wsi_type
+        Parameters
+        ----------
+        uid: UID
+            SOP instance uid to include in file.
+        transfer_syntax: UID
+            Transfer syntax used in file.
+        """
+        meta_ds = FileMetaDataset()
+        meta_ds.TransferSyntaxUID = transfer_syntax
+        meta_ds.MediaStorageSOPInstanceUID = uid
+        meta_ds.MediaStorageSOPClassUID = UID(WSI_SOP_CLASS_UID)
+        validate_file_meta(meta_ds)
+        write_file_meta_info(self._fp, meta_ds)
 
-    @property
-    def datasets(self) -> List[WsiDataset]:
-        return self._datasets
+    def write_base(self, dataset: Dataset) -> None:
+        """Writes base dataset to file.
 
-    @property
-    def dataset(self) -> WsiDataset:
-        return self.datasets[0]
+        Parameters
+        ----------
+        dataset: Dataset
 
-    @property
-    def image_data(self) -> ImageData:
-        return self._image_data
+        """
+        now = datetime.now()
+        dataset.ContentDate = datetime.date(now).strftime('%Y%m%d')
+        dataset.ContentTime = datetime.time(now).strftime('%H%M%S.%f')
+        write_dataset(self._fp, dataset)
 
-    @property
-    def size(self) -> Size:
-        """Return image size in pixels."""
-        return self._image_data.image_size
+    def write_pixel_data_start(self) -> None:
+        """Writes tags starting pixel data."""
+        pixel_data_element = DataElement(
+            0x7FE00010,
+            'OB',
+            0,
+            is_undefined_length=True
+            )
 
-    @property
-    def tile_size(self) -> Size:
-        """Return tile size in pixels."""
-        return self._image_data.tile_size
+        # Write pixel data tag
+        self._fp.write_tag(pixel_data_element.tag)
 
-    @property
-    def mpp(self) -> SizeMm:
-        """Return pixel spacing in um/pixel."""
-        return self.pixel_spacing*1000.0
+        if not self._fp.is_implicit_VR:
+            # Write pixel data VR (OB), two empty bytes (PS3.5 7.1.2)
+            self._fp.write(bytes(pixel_data_element.VR, "iso8859"))
+            self._fp.write_US(0)
+        # Write unspecific length
+        self._fp.write_UL(0xFFFFFFFF)
+
+        # Write item tag and (empty) length for BOT
+        self._fp.write_tag(ItemTag)
+        self._fp.write_UL(0)
+
+    def write_pixel_data(
+        self,
+        image_data: ImageData,
+        z: float,
+        path: str,
+        workers: int,
+        chunk_size: int,
+        scale: int = 1,
+        image_format: str = 'jpeg',
+        image_options: Dict[str, Any] = {'quality': 95}
+    ) -> None:
+        """Writes pixel data to file.
+
+        Parameters
+        ----------
+        image_data: ImageData
+            Image data to read pixel tiles from.
+        z: float
+            Focal plane to write.
+        path: str
+            Optical path to write.
+        workers: int
+            Maximum number of thread workers to use.
+        chunk_size: int
+            Chunk size (number of tiles) to process at a time. Actual chunk
+            size also depends on minimun_chunk_size from image_data.
+        scale: int
+        image_format: str = 'jpeg'
+        image_options: Dict[str, Any] = {'quality': 95}
+        """
+        chunked_tile_points = self._chunk_tile_points(
+            image_data,
+            chunk_size,
+            scale
+        )
+
+        if scale is None:
+            def get_scaled_tiles_thread(
+                scaled_tile_points: List[Point]
+            ) -> List[bytes]:
+                return image_data.get_encoded_scaled_tiles(
+                    scaled_tile_points,
+                    z,
+                    path,
+                    scale,
+                    image_format,
+                    image_options
+                )
+            get_tiles = get_scaled_tiles_thread
+        else:
+            def get_tiles_thread(tile_points: List[Point]) -> List[bytes]:
+                # Thread that takes a chunk of tile points and returns list of
+                # tile bytes
+                return image_data.get_encoded_tiles(tile_points, z, path)
+            get_tiles = get_tiles_thread
+
+        with ThreadPoolExecutor(max_workers=workers) as pool:
+            # Each thread result is a list of tiles that is itemized and writen
+            for thread_result in pool.map(
+                get_tiles,
+                chunked_tile_points
+            ):
+                for tile in thread_result:
+                    for frame in itemise_frame(tile, 1):
+                        self._fp.write(frame)
+
+    def _chunk_tile_points(
+        self,
+        image_data: ImageData,
+        chunk_size: int,
+        scale: int = 1
+    ) -> Generator[Generator[Point, None, None], None, None]:
+        minimum_chunk_size = getattr(
+            image_data,
+            'suggested_minimum_chunk_size',
+            1
+        )
+        # If chunk_size is less than minimum_chunk_size, use minimum_chunk_size
+        # Otherwise, set chunk_size to highest even multiple of
+        # minimum_chunk_size
+        chunk_size = max(
+            minimum_chunk_size,
+            chunk_size//minimum_chunk_size * minimum_chunk_size
+        )
+
+        new_tiled_size = image_data.tiled_size / scale
+        # Divide the image tiles up into chunk_size chunks (up to tiled size)
+        chunked_tile_points = (
+            Region(
+                Point(x, y),
+                Size(min(chunk_size, new_tiled_size.width - x), 1)
+            ).iterate_all()
+            for y in range(new_tiled_size.height)
+            for x in range(0, new_tiled_size.width, chunk_size)
+        )
+        return chunked_tile_points
+
+    def write_pixel_data_end(self) -> None:
+        """Writes tags ending pixel data."""
+        self._fp.write_tag(SequenceDelimiterTag)
+        self._fp.write_UL(0)
+
+    def close(self) -> None:
+        self._fp.close()
+
+
+class WsiInstance:
+    """Represents a level, label, or overview wsi image, containing image data
+    and datasets with metadata."""
+    def __init__(
+        self,
+        datasets: Union[WsiDataset, List[WsiDataset]],
+        image_data: ImageData
+    ):
+        """Create a WsiInstance from datasets with metadata and image data.
+
+        Parameters
+        ----------
+        datasets: Union[WsiDataset, List[WsiDataset]]
+            Single dataset or list of datasets.
+        image_data: ImageData
+            Image data.
+        """
+        if not isinstance(datasets, list):
+            datasets = [datasets]
+        self._datasets = datasets
+        self._image_data = image_data
+        self._identifier, self._uids = self._validate_instance(self.datasets)
+        self._wsi_type = self.dataset.get_supported_wsi_dicom_type(
+            self.image_data.transfer_syntax
+        )
+
+        if self.ext_depth_of_field:
+            if self.ext_depth_of_field_planes is None:
+                raise WsiDicomError("Instance Missing NumberOfFocalPlanes")
+            if self.ext_depth_of_field_plane_distance is None:
+                raise WsiDicomError(
+                    "Instance Missing DistanceBetweenFocalPlanes"
+                )
+
+    def __repr__(self) -> str:
+        return f"{type(self).__name__}({self.dataset}, {self.image_data})"
+
+    def __str__(self) -> str:
+        return self.pretty_str()
+
+    def pretty_str(
+        self,
+        indent: int = 0,
+        depth: Optional[int] = None
+    ) -> str:
+        string = (
+            f"default z: {self.default_z} "
+            f"default path: { self.default_path}"
+
+        )
+        if depth is not None:
+            depth -= 1
+            if(depth < 0):
+                return string
+        string += (
+            ' ImageData ' + self.image_data.pretty_str(indent+1, depth)
+        )
+        return string
+
+    @property
+    def wsi_type(self) -> str:
+        """Return wsi type."""
+        return self._wsi_type
+
+    @property
+    def datasets(self) -> List[WsiDataset]:
+        return self._datasets
+
+    @property
+    def dataset(self) -> WsiDataset:
+        return self.datasets[0]
+
+    @property
+    def image_data(self) -> ImageData:
+        return self._image_data
+
+    @property
+    def size(self) -> Size:
+        """Return image size in pixels."""
+        return self._image_data.image_size
+
+    @property
+    def tile_size(self) -> Size:
+        """Return tile size in pixels."""
+        return self._image_data.tile_size
+
+    @property
+    def mpp(self) -> SizeMm:
+        """Return pixel spacing in um/pixel."""
+        return self.pixel_spacing*1000.0
 
     @property
     def pixel_spacing(self) -> SizeMm:
@@ -2075,14 +2398,14 @@ def ext_depth_of_field_plane_distance(self) -> Optional[float]:
         return self.dataset.ext_depth_of_field_plane_distance
 
     @property
-    def identifier(self) -> Uid:
+    def identifier(self) -> UID:
         """Return identifier (instance uid for single file instance or
         concatenation uid for multiple file instance)."""
         return self._identifier
 
     @property
     def instance_number(self) -> int:
-        return self.dataset.instance_number
+        return int(self.dataset.instance_number)
 
     @property
     def default_z(self) -> float:
@@ -2105,11 +2428,7 @@ def tiled_size(self) -> Size:
         return self._image_data.tiled_size
 
     @property
-    def datasets(self) -> List[WsiDataset]:
-        return self._datasets
-
-    @property
-    def uids(self) -> Optional[BaseUids]:
+    def uids(self) -> BaseUids:
         """Return base uids"""
         return self._uids
 
@@ -2118,7 +2437,7 @@ def open(
         cls,
         files: List[WsiDicomFile],
         series_uids: BaseUids,
-        series_tile_size: Size = None
+        series_tile_size: Optional[Size] = None
     ) -> List['WsiInstance']:
         """Create instances from Dicom files. Only files with matching series
         uid and tile size, if defined, are used. Other files are closed.
@@ -2129,7 +2448,7 @@ def open(
             Files to create instances from.
         series_uids: BaseUids
             Uid to match against.
-        series_tile_size: Size
+        series_tile_size: Optional[Size]
             Tile size to match against (for level instances).
 
         Returns
@@ -2137,379 +2456,82 @@ def open(
         List[WsiInstancece]
             List of created instances.
         """
-        filtered_files = WsiDicomFile._filter_files(
+        filtered_files = WsiDicomFile.filter_files(
             files,
             series_uids,
             series_tile_size
         )
-        files_grouped_by_instance = WsiDicomFile._group_files(filtered_files)
+        files_grouped_by_instance = WsiDicomFile.group_files(filtered_files)
         return [
             cls(
                 [file.dataset for file in instance_files],
-                DicomImageData(instance_files)
+                WsiDicomImageData(instance_files)
             )
             for instance_files in files_grouped_by_instance.values()
         ]
 
-    def stitch_tiles(
-        self,
-        region: Region,
-        path: str = None,
-        z: float = None
-    ) -> Image.Image:
-        """Stitches tiles together to form requested image.
+    @staticmethod
+    def check_duplicate_instance(
+        instances: List['WsiInstance'],
+        self: object
+    ) -> None:
+        """Check for duplicates in list of instances. Instances are duplicate
+        if instance identifier (file instance uid or concatenation uid) match.
+        Stops at first found duplicate and raises WsiDicomUidDuplicateError.
 
         Parameters
         ----------
-        region: Region
-             Pixel region to stitch to image
-        path: str
-            Optical path
-        z: float
-            Z coordinate
+        instances: List['WsiInstance']
+            List of instances to check.
+        caller: Object
+            Object that the instances belongs to.
+        """
+        instance_identifiers: List[str] = []
+        for instance in instances:
+            instance_identifier = instance.identifier
+            if instance_identifier not in instance_identifiers:
+                instance_identifiers.append(instance_identifier)
+            else:
+                raise WsiDicomUidDuplicateError(str(instance), str(self))
+
+    def _validate_instance(
+        self,
+        datasets: List[WsiDataset]
+    ) -> Tuple[UID, BaseUids]:
+        """Check that no files in instance are duplicate, that all files in
+        instance matches (uid, type and size).
+        Raises WsiDicomMatchError otherwise.
+        Returns the matching file uid.
 
         Returns
         ----------
-        Image.Image
-            Stitched image
+        Tuple[UID, BaseUids]
+            Instance identifier uid and base uids
         """
-        if z is None:
-            z = self.default_z
-        if path is None:
-            path = self.default_path
+        WsiDataset.check_duplicate_dataset(datasets, self)
 
-        image = Image.new(
-            mode=self.image_data.image_mode,
-            size=region.size.to_tuple()
+        base_dataset = datasets[0]
+        for dataset in datasets[1:]:
+            if not base_dataset.matches_instance(dataset):
+                raise WsiDicomError("Datasets in instances does not match")
+        return (
+            base_dataset.uids.identifier,
+            base_dataset.uids.base,
         )
-        stitching_tiles = self.get_tile_range(region, z, path)
-
-        write_index = Point(x=0, y=0)
-        tile = stitching_tiles.position
-        for tile in stitching_tiles.iterate_all(include_end=True):
-            tile_image = self.get_tile(tile, z, path)
-            tile_crop = self.crop_tile(tile, region)
-            tile_image = tile_image.crop(box=tile_crop.box)
-            image.paste(tile_image, write_index.to_tuple())
-            write_index = self._write_indexer(
-                write_index,
-                tile_crop.size,
-                region.size
-            )
-        return image
 
-    def get_tile_range(
-        self,
-        pixel_region: Region,
-        z: float,
-        path: str
-    ) -> Region:
-        """Return range of tiles to cover pixel region.
+    def matches(self, other_instance: 'WsiInstance') -> bool:
+        """Return true if other instance is of the same group as self.
 
         Parameters
         ----------
-        pixel_region: Region
-            Pixel region of tiles to get
-        z: float
-            Z coordinate of tiles to get
-        path: str
-            Optical path identifier of tiles to get
+        other_instance: WsiInstance
+            Instance to check.
 
         Returns
         ----------
-        Region
-            Region of tiles for stitching image
-        """
-        start = pixel_region.start // self._image_data.tile_size
-        end = pixel_region.end / self._image_data.tile_size - 1
-        tile_region = Region.from_points(start, end)
-        if not self._image_data.valid_tiles(tile_region, z, path):
-            raise WsiDicomOutOfBoundsError(
-                f"Tile region {tile_region}",
-                f"tiled size {self._image_data.tiled_size}"
-            )
-        return tile_region
-
-    def crop_encoded_tile_to_level(
-        self,
-        tile: Point,
-        tile_frame: bytes
-    ) -> bytes:
-        cropped_tile_region = self.crop_to_level_size(tile)
-        if cropped_tile_region.size != self.tile_size:
-            image = Image.open(io.BytesIO(tile_frame))
-            image.crop(box=cropped_tile_region.box_from_origin)
-            tile_frame = self.encode(image)
-        return tile_frame
-
-    def crop_tile_to_level(
-        self,
-        tile: Point,
-        tile_image: Image.Image
-    ) -> Image.Image:
-        cropped_tile_region = self.crop_to_level_size(tile)
-        if cropped_tile_region.size != self.tile_size:
-            tile_image = tile_image.crop(
-                box=cropped_tile_region.box_from_origin
-            )
-        return tile_image
-
-    def crop_tile(self, tile: Point, stitching: Region) -> Region:
-        """Crop tile at edge of stitching region so that the tile after croping
-        is inside the stitching region.
-
-        Parameters
-        ----------
-        tile: Point
-            Position of tile to crop
-        stitching : Region
-            Region of stitched image
-
-        Returns
-        ----------
-        Region
-            Region of tile inside stitching region
-        """
-        tile_region = Region(
-            position=tile * self.tile_size,
-            size=self.tile_size
-        )
-        cropped_tile_region = stitching.crop(tile_region)
-        cropped_tile_region.position = (
-            cropped_tile_region.position % self.tile_size
-        )
-        return cropped_tile_region
-
-    def crop_to_level_size(self, item: Union[Point, Region]) -> Region:
-        """Crop tile or region so that the tile (Point) or region (Region)
-        after cropping is inside the image size of the level.
-
-        Parameters
-        ----------
-        item: Union[Point, Region]
-            Position of tile or region to crop
-
-        Returns
-        ----------
-        Region
-            Region of tile or region inside level image
-        """
-        level_region = Region(
-            position=Point(x=0, y=0),
-            size=self.size
-        )
-        if isinstance(item, Point):
-            return self.crop_tile(item, level_region)
-        return level_region.crop(item)
-
-    @staticmethod
-    def _write_indexer(
-        index: Point,
-        previous_size: Size,
-        image_size: Size
-    ) -> Point:
-        """Increment index in x by previous width until index x exceds image
-        size. Then resets index x to 0 and increments index y by previous
-        height. Requires that tiles are scanned row by row.
-
-        Parameters
-        ----------
-        index: Point
-            The last write index position
-        previouis_size: Size
-            The size of the last written last tile
-        image_size: Size
-            The size of the image to be written
-
-        Returns
-        ----------
-        Point
-            The position (upper right) in image to insert the next tile into
-        """
-        index.x += previous_size.width
-        if(index.x >= image_size.width):
-            index.x = 0
-            index.y += previous_size.height
-        return index
-
-    @staticmethod
-    def _image_settings(
-        transfer_syntax: pydicom.uid
-    ) -> Tuple[str, Dict[str, int]]:
-        """Return image format and options for creating encoded tiles as in the
-        used transfer syntax.
-
-        Parameters
-        ----------
-        transfer_syntax: pydicom.uid
-            Transfer syntax to match image format and options to
-
-        Returns
-        ----------
-        tuple[str, dict[str, int]]
-            image format and image options
-
-        """
-        if(transfer_syntax == pydicom.uid.JPEGBaseline8Bit):
-            image_format = 'jpeg'
-            image_options = {'quality': 95}
-        elif(transfer_syntax == pydicom.uid.JPEG2000):
-            image_format = 'jpeg2000'
-            image_options = {'irreversible': True}
-        elif(transfer_syntax == pydicom.uid.JPEG2000Lossless):
-            image_format = 'jpeg2000'
-            image_options = {'irreversible': False}
-        else:
-            raise NotImplementedError(
-                "Only supports jpeg and jpeg2000"
-            )
-        return (image_format, image_options)
-
-    def get_tile(
-        self,
-        tile: Point,
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Get tile image at tile coordinate x, y.
-        If frame is inside tile geometry but no tile exists in
-        frame data (sparse) returns blank image.
-
-        Parameters
-        ----------
-        tile: Point
-            Tile x, y coordinate.
-        z: float
-            Z coordinate.
-        path: str
-            Optical path.
-
-        Returns
-        ----------
-        Image.Image
-            Tile image.
-        """
-        if z is None:
-            z = self.default_z
-        if path is None:
-            path = self.default_path
-        image = self.image_data.get_decoded_tile(tile, z, path)
-        return self.crop_tile_to_level(tile, image)
-
-    def get_encoded_tile(
-        self,
-        tile: Point,
-        z: float,
-        path: str
-    ) -> bytes:
-        """Get tile bytes at tile coordinate x, y
-        If frame is inside tile geometry but no tile exists in
-        frame data (sparse) returns encoded blank image.
-
-        Parameters
-        ----------
-        tile: Point
-            Tile x, y coordinate.
-        z: float
-            Z coordinate.
-        path: str
-            Optical path.
-        crop: bool
-            If the tile should be croped to image region.
-
-        Returns
-        ----------
-        bytes
-            Tile image as bytes.
-        """
-        if z is None:
-            z = self.default_z
-        if path is None:
-            path = self.default_path
-        tile_frame = self.image_data.get_encoded_tile(tile, z, path)
-
-        # Check if tile is an edge tile that should be croped
-        cropped_tile_region = self.crop_to_level_size(tile)
-        if cropped_tile_region.size != self.tile_size:
-            image = Image.open(io.BytesIO(tile_frame))
-            image.crop(box=cropped_tile_region.box_from_origin)
-            tile_frame = self.image_data.encode(image)
-        return tile_frame
-
-    @staticmethod
-    def check_duplicate_instance(
-        instances: List['WsiInstance'],
-        self: object
-    ) -> None:
-        """Check for duplicates in list of instances. Instances are duplicate
-        if instance identifier (file instance uid or concatenation uid) match.
-        Stops at first found duplicate and raises WsiDicomUidDuplicateError.
-
-        Parameters
-        ----------
-        instances: List['WsiInstance']
-            List of instances to check.
-        caller: Object
-            Object that the instances belongs to.
-        """
-        instance_identifiers: List[str] = []
-        for instance in instances:
-            instance_identifier = instance.identifier
-            if instance_identifier not in instance_identifiers:
-                instance_identifiers.append(instance_identifier)
-            else:
-                raise WsiDicomUidDuplicateError(str(instance), str(self))
-
-    def _validate_instance(
-        self,
-        datasets: List[WsiDataset]
-    ) -> Tuple[str, BaseUids, str]:
-        """Check that no files in instance are duplicate, that all files in
-        instance matches (uid, type and size).
-        Raises WsiDicomMatchError otherwise.
-        Returns the matching file uid.
-
-
-        Returns
-        ----------
-        Tuple[str, BaseUids, str]
-            Instance identifier uid and base uids
-        """
-        WsiDataset.check_duplicate_dataset(datasets, self)
-
-        base_dataset = datasets[0]
-        for dataset in datasets[1:]:
-            if not base_dataset.matches_instance(dataset):
-                raise WsiDicomError()
-        return (
-            base_dataset.uids.identifier,
-            base_dataset.uids.base,
-        )
-
-    def get_tile_range(
-        self,
-        pixel_region: Region,
-        z: float,
-        path: str
-    ) -> Region:
-        start = pixel_region.start // self.tile_size
-        end = pixel_region.end / self.tile_size - 1
-        tile_region = Region.from_points(start, end)
-        return tile_region
-
-    def matches(self, other_instance: 'WsiInstance') -> bool:
-        """Return true if other instance is of the same group as self.
-
-        Parameters
-        ----------
-        other_instance: WsiInstance
-            Instance to check.
-
-        Returns
-        ----------
-        bool
-            True if instanes are of same group.
-
+        bool
+            True if instanes are of same group.
+
         """
         return (
             self.uids == other_instance.uids and
@@ -2520,1830 +2542,3 @@ def matches(self, other_instance: 'WsiInstance') -> bool:
 
     def close(self) -> None:
         self._image_data.close()
-
-
-class WsiDicomGroup:
-    """Represents a group of instances having the same size, but possibly
-    different z coordinate and/or optical path."""
-    def __init__(
-        self,
-        instances: List[WsiInstance]
-    ):
-        """Create a group of WsiInstances. Instances should match in the common
-        uids, wsi type, and tile size.
-
-        Parameters
-        ----------
-        instances: List[WsiInstance]
-            Instances to build the group.
-        """
-        self._instances = {  # key is identifier (Uid)
-            instance.identifier: instance for instance in instances
-        }
-        self._validate_group()
-
-        base_instance = instances[0]
-        self._wsi_type = base_instance.wsi_type
-        self._uids = base_instance.uids
-
-        self._size = base_instance.size
-        self._pixel_spacing = base_instance.pixel_spacing
-        self._default_instance_uid: str = base_instance.identifier
-
-    def __repr__(self) -> str:
-        return f"{type(self).__name__}({self.instances.values()})"
-
-    def __str__(self) -> str:
-        return self.pretty_str()
-
-    def pretty_str(
-        self,
-        indent: int = 0,
-        depth: int = None
-    ) -> str:
-        string = (
-            f'Image: size: {self.size} px, mpp: {self.mpp} um/px'
-        )
-        if depth is not None:
-            depth -= 1
-            if(depth < 0):
-                return string
-        string += (
-            ' Instances: ' + dict_pretty_str(self.instances, indent, depth)
-        )
-        return string
-
-    def __getitem__(self, index) -> WsiInstance:
-        return self.instances[index]
-
-    @property
-    def uids(self) -> BaseUids:
-        """Return uids"""
-        return self._uids
-
-    @property
-    def wsi_type(self) -> str:
-        """Return wsi type"""
-        return self._wsi_type
-
-    @property
-    def size(self) -> Size:
-        """Return image size in pixels"""
-        return self._size
-
-    @property
-    def mpp(self) -> SizeMm:
-        """Return pixel spacing in um/pixel"""
-        return self.pixel_spacing*1000.0
-
-    @property
-    def pixel_spacing(self) -> SizeMm:
-        """Return pixel spacing in mm/pixel"""
-        return self._pixel_spacing
-
-    @property
-    def instances(self) -> Dict[str, WsiInstance]:
-        """Return contained instances"""
-        return self._instances
-
-    @property
-    def default_instance(self) -> WsiInstance:
-        """Return default instance"""
-        return self.instances[self._default_instance_uid]
-
-    @property
-    def files(self) -> List[WsiDicomFile]:
-        """Return contained files"""
-        instance_files = [
-            instance.files for instance in self.instances.values()
-        ]
-        return [file for sublist in instance_files for file in sublist]
-
-    @property
-    def datasets(self) -> List[Dataset]:
-        """Return contained datasets."""
-        instance_datasets = [
-            instance.datasets for instance in self.instances.values()
-        ]
-        return [
-            dataset for sublist in instance_datasets for dataset in sublist
-        ]
-
-    @property
-    def optical_paths(self) -> List[str]:
-        return list({
-            path
-            for instance in self.instances.values()
-            for path in instance.optical_paths
-        })
-
-    @property
-    def focal_planes(self) -> List[float]:
-        return list({
-            focal_plane
-            for innstance in self.instances.values()
-            for focal_plane in innstance.focal_planes
-        })
-
-    @classmethod
-    def open(
-        cls,
-        instances: List[WsiInstance],
-    ) -> List['WsiDicomGroup']:
-        """Return list of groups created from wsi instances.
-
-        Parameters
-        ----------
-        files: List[WsiInstance]
-            Instances to create groups from.
-
-        Returns
-        ----------
-        List[WsiDicomGroup]
-            List of created groups.
-
-        """
-        groups: List[cls] = []
-
-        grouped_instances = cls._group_instances(instances)
-
-        for group in grouped_instances.values():
-            groups.append(cls(group))
-
-        return groups
-
-    def matches(self, other_group: 'WsiDicomGroup') -> bool:
-        """Check if group is valid (Uids and tile size match).
-        The common Uids should match for all series.
-        """
-        return (
-            other_group.uids == self.uids and
-            other_group.wsi_type == self.wsi_type
-        )
-
-    def valid_pixels(self, region: Region) -> bool:
-        """Check if pixel region is withing the size of the group image size.
-
-        Parameters
-        ----------
-        region: Region
-            Pixel region to check
-
-        Returns
-        ----------
-        bool
-            True if pixel position and size is within image
-        """
-        # Return true if inside pixel plane.
-        image_region = Region(Point(0, 0), self.size)
-        return region.is_inside(image_region)
-
-    def get_instance(
-        self,
-        z: float = None,
-        path: str = None
-    ) -> WsiInstance:
-        """Search for instance fullfilling the parameters.
-        The behavior when z and/or path is none could be made more
-        clear.
-
-        Parameters
-        ----------
-        z: float
-            Z coordinate of the searched instance
-        path: str
-            Optical path of the searched instance
-
-        Returns
-        ----------
-        WsiInstance
-            The instance containing selected path and z coordinate
-        """
-        if z is None and path is None:
-            instance = self.default_instance
-            z = instance.default_z
-            path = instance.default_path
-
-            return self.default_instance
-
-        # Sort instances by number of focal planes (prefer simplest instance)
-        sorted_instances = sorted(
-            list(self._instances.values()),
-            key=lambda i: len(i.focal_planes)
-        )
-        try:
-            if z is None:
-                # Select the instance with selected optical path
-                instance = next(i for i in sorted_instances if
-                                path in i.optical_paths)
-            elif path is None:
-                # Select the instance with selected z
-                instance = next(i for i in sorted_instances
-                                if z in i.focal_planes)
-            else:
-                # Select by both path and z
-                instance = next(i for i in sorted_instances
-                                if (z in i.focal_planes and
-                                    path in i.optical_paths))
-        except StopIteration:
-            raise WsiDicomNotFoundError(
-                f"Instance for path: {path}, z: {z}", "group"
-            )
-        if z is None:
-            z = instance.default_z
-        if path is None:
-            path = instance.default_path
-        return instance
-
-    def get_default_full(self) -> Image.Image:
-        """Read full image using default z coordinate and path.
-
-        Returns
-        ----------
-        Image.Image
-            Full image of the group.
-        """
-        instance = self.default_instance
-        z = instance.default_z
-        path = instance.default_path
-        region = Region(position=Point(x=0, y=0), size=self.size)
-        image = self.get_region(region, z, path)
-        return image
-
-    def get_region(
-        self,
-        region: Region,
-        z: float = None,
-        path: str = None,
-    ) -> Image.Image:
-        """Read region defined by pixels.
-
-        Parameters
-        ----------
-        location: int, int
-            Upper left corner of region in pixels
-        size: int
-            Size of region in pixels
-        z: float
-            Z coordinate, optional
-        path: str
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Region as image
-        """
-
-        instance = self.get_instance(z, path)
-        image = instance.stitch_tiles(region, path, z)
-        return image
-
-    def get_region_mm(
-        self,
-        region: RegionMm,
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read region defined by mm.
-
-        Parameters
-        ----------
-        location: float, float
-            Upper left corner of region in mm
-        size: float
-            Size of region in mm
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Region as image
-        """
-        pixel_region = self.mm_to_pixel(region)
-        image = self.get_region(pixel_region, z, path)
-        return image
-
-    def get_tile(
-        self,
-        tile: Point,
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Return tile at tile coordinate x, y as image.
-
-        Parameters
-        ----------
-        tile: Point
-            Tile x, y coordinate
-        z: float
-            Z coordinate
-        path: str
-            Optical path
-
-        Returns
-        ----------
-        Image.Image
-            The tile as image
-        """
-
-        instance = self.get_instance(z, path)
-        return instance.get_tile(tile, z, path)
-
-    def get_encoded_tile(
-        self,
-        tile: Point,
-        z: float = None,
-        path: str = None
-    ) -> bytes:
-        """Return tile at tile coordinate x, y as bytes.
-
-        Parameters
-        ----------
-        tile: Point
-            Tile x, y coordinate
-        z: float
-            Z coordinate
-        path: str
-            Optical path
-
-        Returns
-        ----------
-        bytes
-            The tile as bytes
-        """
-        instance = self.get_instance(z, path)
-        return instance.get_encoded_tile(tile, z, path)
-
-    def mm_to_pixel(self, region: RegionMm) -> Region:
-        """Convert region in mm to pixel region.
-
-        Parameters
-        ----------
-        region: RegionMm
-            Region in mm
-
-        Returns
-        ----------
-        Region
-            Region in pixels
-        """
-        pixel_region = Region(
-            position=region.position // self.pixel_spacing,
-            size=region.size // self.pixel_spacing
-        )
-        if not self.valid_pixels(pixel_region):
-            raise WsiDicomOutOfBoundsError(
-                f"Region {region}", f"level size {self.size}"
-            )
-        return pixel_region
-
-    def close(self) -> None:
-        """Close all instances on the group."""
-        for instance in self._instances.values():
-            instance.close()
-
-    def _validate_group(self):
-        """Check that no file or instance in group is duplicate, instances in
-        group matches and that the optical manager matches by base uid.
-        Raises WsiDicomMatchError otherwise.
-        """
-        WsiDataset.check_duplicate_dataset(self.datasets, self)
-        instances = list(self.instances.values())
-        base_instance = instances[0]
-        for instance in instances[1:]:
-            if not base_instance.matches(instance):
-                raise WsiDicomMatchError(str(instance), str(self))
-        WsiInstance.check_duplicate_instance(instances, self)
-
-    @classmethod
-    def _group_instances(
-        cls,
-        instances: List[WsiInstance]
-    ) -> Dict[Size, List[WsiInstance]]:
-        """Return instances grouped by image size.
-
-        Parameters
-        ----------
-        instances: List[WsiInstance]
-            Instances to group by image size.
-
-        Returns
-        ----------
-        Dict[Size, List[WsiInstance]]:
-            Instances grouped by size, with size as key.
-
-        """
-        grouped_instances: Dict[Size, List[WsiInstance]] = {}
-        for instance in instances:
-            try:
-                grouped_instances[instance.size].append(instance)
-            except KeyError:
-                grouped_instances[instance.size] = [instance]
-        return grouped_instances
-
-    def _group_instances_to_file(
-        self,
-    ) -> List[List[WsiInstance]]:
-        """Group instances by properties that can't differ in a DICOM-file,
-        i.e. the instances are grouped by output file.
-
-        Returns
-        ----------
-        List[List[WsiInstance]]
-            Instances grouped by common properties.
-        """
-        groups: DefaultDict[Union[str, Uid], List[str]] = defaultdict(list)
-        for instance in self.instances.values():
-            groups[
-                instance.image_data.photometric_interpretation,
-                instance.image_data.transfer_syntax,
-                instance.ext_depth_of_field,
-                instance.ext_depth_of_field_planes,
-                instance.ext_depth_of_field_plane_distance,
-                instance.focus_method,
-                instance.slice_spacing
-            ].append(
-                instance
-            )
-        return list(groups.values())
-
-    @staticmethod
-    def _list_image_data(
-        instances: List[WsiInstance]
-    ) -> Tuple[Tuple[str, float], List[ImageData]]:
-        """List and sort ImageData in instances by optical path and focal
-        plane.
-
-        Parameters
-        ----------
-        instances: List[WsiInstance]
-            List of instances with optical paths and focal planes to list and
-            sort.
-
-        Returns
-        ----------
-        Tuple[Tuple[str, float], List[ImageData]]
-            ImageData listed and sorted by optical path and focal plane.
-        """
-        output: Dict[Tuple[str, float], ImageData] = {}
-        for instance in instances:
-            for optical_path in instance.optical_paths:
-                for z in instance.focal_planes:
-                    if (optical_path, z) not in output:
-                        output[optical_path, z] = instance._image_data
-        return OrderedDict(output).items()
-
-    def save(
-        self,
-        output_path: str,
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid,
-        workers: int = os.cpu_count(),
-        chunk_size: int = 100
-    ) -> List[Path]:
-        """Save a WsiDicomGroup to files in output_path. Instances are grouped
-        by properties that can differ in the same file:
-            - photometric interpretation
-            - transfer syntax
-            - extended depth of field (and planes and distance)
-            - focus method
-            - spacing between slices
-        Other properties are assumed to be equal or to be updated.
-
-        Parameters
-        ----------
-        output_path: str
-            Folder path to save files to.
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid
-            Uid generator to use.
-        workers: int = os.cpu_count()
-            Maximum number of thread workers to use.
-        chunk_size: int = 100
-            Chunk size (number of tiles) to process at a time. Actual chunk
-            size also depends on minimun_chunk_size from image_data.
-
-        Returns
-        ----------
-        List[str]
-            List of paths of created files.
-        """
-        filepaths: List[Path] = []
-        for instances in self._group_instances_to_file():
-            uid = uid_generator()
-            if output_path == os.devnull:
-                filepath = output_path
-            else:
-                filepath = os.path.join(output_path, uid + '.dcm')
-
-            transfer_syntax = instances[0]._image_data.transfer_syntax
-            dataset = deepcopy(instances[0].dataset)
-            with DicomWsiFileWriter(filepath) as wsi_file:
-                wsi_file = DicomWsiFileWriter(filepath)
-                wsi_file.write_preamble()
-                wsi_file.write_file_meta(uid, transfer_syntax)
-                dataset.SOPInstanceUID = uid
-                wsi_file.write_base(dataset)
-                wsi_file.write_pixel_data_start()
-                for (path, z), image_data in self._list_image_data(instances):
-                    wsi_file.write_pixel_data(
-                        image_data,
-                        z,
-                        path,
-                        workers,
-                        chunk_size
-                    )
-                wsi_file.write_pixel_data_end()
-                wsi_file.close()
-            filepaths.append(filepath)
-        return filepaths
-
-
-class WsiDicomLevel(WsiDicomGroup):
-    """Represents a level in the pyramid and contains one or more instances
-    having the same pyramid level index, pixel spacing, and size but possibly
-    different focal planes and/or optical paths.
-    """
-    def __init__(
-        self,
-        instances: List[WsiInstance],
-        base_pixel_spacing: SizeMm
-    ):
-        """Create a level from list of WsiInstances. Asign the pyramid level
-        index from pixel spacing of base level.
-
-        Parameters
-        ----------
-        instances: List[WsiInstance]
-            Instances to build the level.
-        base_pixel_spacing: SizeMm
-            Pixel spacing of base level.
-        """
-        super().__init__(instances)
-        self._base_pixel_spacing = base_pixel_spacing
-        self._level = self._assign_level(self._base_pixel_spacing)
-
-    def __repr__(self) -> str:
-        return (
-            f"{type(self).__name__}({self.instances}, "
-            f"{self._base_pixel_spacing})"
-        )
-
-    def __str__(self) -> str:
-        return self.pretty_str()
-
-    def pretty_str(
-        self,
-        indent: int = 0,
-        depth: int = None
-    ) -> str:
-        string = (
-            f'Level: {self.level}, size: {self.size} px, mpp: {self.mpp} um/px'
-        )
-        if depth is not None:
-            depth -= 1
-            if(depth < 0):
-                return string
-        string += (
-            ' Instances: ' + dict_pretty_str(self.instances, indent, depth)
-        )
-        return string
-
-    @property
-    def pyramid(self) -> str:
-        """Return string representation of the level"""
-        return (
-            f'Level [{self.level}]'
-            f' tiles: {self.default_instance.tiled_size},'
-            f' size: {self.size}, mpp: {self.mpp} um/px'
-        )
-
-    @property
-    def tile_size(self) -> Size:
-        return self.default_instance.tile_size
-
-    @property
-    def level(self) -> int:
-        """Return pyramid level"""
-        return self._level
-
-    @classmethod
-    def open(
-        cls,
-        instances: List[WsiInstance],
-    ) -> List['WsiDicomLevel']:
-        """Return list of levels created wsi files.
-
-        Parameters
-        ----------
-        files: List[WsiInstance]
-            Instances to create levels from.
-
-        Returns
-        ----------
-        List[WsiDicomLevel]
-            List of created levels.
-
-        """
-        levels: List[cls] = []
-        instances_grouped_by_level = cls._group_instances(instances)
-        largest_size = max(instances_grouped_by_level.keys())
-        base_group = instances_grouped_by_level[largest_size]
-        base_pixel_spacing = base_group[0].pixel_spacing
-        for level in instances_grouped_by_level.values():
-            levels.append(cls(level, base_pixel_spacing))
-
-        return levels
-
-    def matches(self, other_group: 'WsiDicomGroup') -> bool:
-        """Check if group is valid (Uids and tile size match).
-        The common Uids should match for all series. For level series the tile
-        size should also match. It is assumed that the instances in the groups
-        are matching each other.
-        """
-        other_instance = other_group.default_instance
-        this_instance = self.default_instance
-        return (
-            other_group.uids == self.uids and
-            other_instance.tile_size == this_instance.tile_size
-        )
-
-    def get_highest_level(self) -> int:
-        """Return highest deep zoom scale that can be produced
-        from the image in the level.
-
-        Returns
-        ----------
-        int
-            Relative level where the pixel size becomes 1x1
-        """
-        return math.ceil(math.log2(max(self.size.width, self.size.height)))
-
-    def get_scaled_tile(
-        self,
-        tile: Point,
-        level: int,
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Return tile in another level by scaling a region.
-        If the tile is an edge tile, the resulting tile is croped
-        to remove part outside of the image (as defiend by level size).
-
-        Parameters
-        ----------
-        tile: Point
-            Non scaled tile coordinate
-        level: int
-            Level to scale from
-        z: float
-            Z coordinate
-        path: str
-            Optical path
-
-        Returns
-        ----------
-        Image.Image
-            A tile image
-        """
-        scale = self.calculate_scale(level)
-        instance = self.get_instance(z, path)
-        scaled_region = Region.from_tile(tile, instance.tile_size) * scale
-        cropped_region = instance.crop_to_level_size(scaled_region)
-        if not self.valid_pixels(cropped_region):
-            raise WsiDicomOutOfBoundsError(
-                f"Region {cropped_region}", f"level size {self.size}"
-            )
-        image = self.get_region(cropped_region, z, path)
-        tile_size = cropped_region.size/scale
-        image = image.resize(
-            tile_size.to_tuple(),
-            resample=Image.BILINEAR
-        )
-        return image
-
-    def get_scaled_encoded_tile(
-        self,
-        tile: Point,
-        scale: int,
-        z: float = None,
-        path: str = None
-    ) -> bytes:
-        """Return encoded tile in another level by scaling a region.
-
-        Parameters
-        ----------
-        tile: Point
-            Non scaled tile coordinate
-        level: int
-           Level to scale from
-        z: float
-            Z coordinate
-        path: str
-            Optical path
-
-        Returns
-        ----------
-        bytes
-            A transfer syntax encoded tile
-        """
-        image = self.get_scaled_tile(tile, scale, z, path)
-        instance = self.get_instance(z, path)
-        return instance.image_data.encode(image)
-
-    def calculate_scale(self, level_to: int) -> int:
-        """Return scaling factor to given level.
-
-        Parameters
-        ----------
-        level_to -- index of level to scale to
-
-        Returns
-        ----------
-        int
-            Scaling factor between this level and given level
-        """
-        return int(2 ** (level_to - self.level))
-
-    def _assign_level(self, base_pixel_spacing: SizeMm) -> int:
-        """Return (2^level scale factor) based on pixel spacing.
-        Will round to closest integer. Raises NotImplementedError if level is
-        to far from integer.
-
-        Parameters
-        ----------
-        base_pixel_spacing: SizeMm
-            The pixel spacing of the base lavel
-
-        Returns
-        ----------
-        int
-            The pyramid order of the level
-        """
-        float_level = math.log2(
-            self.pixel_spacing.width/base_pixel_spacing.width
-        )
-        level = int(round(float_level))
-        TOLERANCE = 1e-2
-        if not math.isclose(float_level, level, rel_tol=TOLERANCE):
-            raise NotImplementedError(f"Levels needs to be integer")
-        return level
-
-
-class WsiDicomSeries(metaclass=ABCMeta):
-    """Represents a series of WsiDicomGroups with the same image flavor, e.g.
-    pyramidal levels, lables, or overviews.
-    """
-
-    def __init__(self, groups: List[WsiDicomGroup]):
-        """Create a WsiDicomSeries from list of WsiDicomGroups.
-
-        Parameters
-        ----------
-        groups: List[WsiDicomGroup]
-            List of groups to include in the series.
-        """
-        self._groups: List[WsiDicomGroup] = groups
-
-        if len(self.groups) != 0 and self.groups[0].uids is not None:
-            self._uids = self._validate_series(self.groups)
-        else:
-            self._uids = None
-
-    def __repr__(self) -> str:
-        return f"{type(self).__name__}({self.groups})"
-
-    def __str__(self) -> str:
-        return f"{type(self).__name__} of groups {self.groups}"
-
-    def __getitem__(self, index: int) -> WsiDicomGroup:
-        """Get group by index.
-
-        Parameters
-        ----------
-        index: int
-            Index in series to get
-
-        Returns
-        ----------
-        WsiDicomGroup
-            The group at index in the series
-        """
-        return self.groups[index]
-
-    def __len__(self) -> int:
-        return len(self.stacks)
-
-    @property
-    @abstractmethod
-    def wsi_type(self) -> str:
-        """Should return the wsi type of the series ('VOLUME', 'LABEL', or
-        'OVERVIEW'"""
-        raise NotImplementedError
-
-    @staticmethod
-    @abstractmethod
-    def group_open(instances: List[WsiInstance]) -> List[WsiDicomGroup]:
-        """Should return WsiDicomGroups created from instances."""
-        raise NotImplementedError
-
-    @property
-    def groups(self) -> List[WsiDicomGroup]:
-        """Return contained groups."""
-        return self._groups
-
-    @property
-    def uids(self) -> Optional[BaseUids]:
-        """Return uids."""
-        return self._uids
-
-    @property
-    def mpps(self) -> List[SizeMm]:
-        """Return contained mpp (um/px)."""
-        return [group.mpp for group in self.groups]
-
-    @property
-    def files(self) -> List[WsiDicomFile]:
-        """Return contained files."""
-        series_files = [series.files for series in self.groups]
-        return [file for sublist in series_files for file in sublist]
-
-    @property
-    def datasets(self) -> List[Dataset]:
-        """Return contained datasets."""
-
-        series_datasets = [
-            series.datasets for series in self.groups
-        ]
-        return [
-            dataset for sublist in series_datasets for dataset in sublist
-        ]
-
-    @property
-    def instances(self) -> List[WsiInstance]:
-        """Return contained instances"""
-        series_instances = [
-            series.instances.values() for series in self.groups
-        ]
-        return [
-            instance for sublist in series_instances for instance in sublist
-        ]
-
-    @classmethod
-    def open(
-        cls,
-        instances: List[WsiInstance]
-    ) -> 'WsiDicomSeries':
-        """Return series created from wsi files.
-
-        Parameters
-        ----------
-        instances: List[WsiInstance]
-            Instances to create series from.
-
-        Returns
-        ----------
-        WsiDicomSeries
-            Created series.
-        """
-        labels = cls.group_open(instances)
-        return cls(labels)
-
-    def _validate_series(
-            self,
-            groups: Union[List[WsiDicomGroup], List[WsiDicomLevel]]
-    ) -> Optional[BaseUids]:
-        """Check that no files or instances in series is duplicate and that
-        all groups in series matches.
-        Raises WsiDicomMatchError otherwise.
-        Returns the matching base uid. If list of groups is empty, return None.
-
-        Parameters
-        ----------
-        groups: Union[List[WsiDicomGroup], List[WsiDicomLevel]]
-            List of groups or levels to check
-
-        Returns
-        ----------
-        Optional[BaseUids]:
-            Matching uids
-        """
-        WsiDataset.check_duplicate_dataset(self.datasets, self)
-        WsiInstance.check_duplicate_instance(self.instances, self)
-
-        try:
-            base_group = groups[0]
-            if base_group.wsi_type != self.wsi_type:
-                raise WsiDicomMatchError(
-                    str(base_group), str(self)
-                )
-            for group in groups[1:]:
-                if not group.matches(base_group):
-                    raise WsiDicomMatchError(
-                        str(group), str(self)
-                    )
-            return base_group.uids
-        except IndexError:
-            return None
-
-    def close(self) -> None:
-        """Close all groups in the series."""
-        for group in self.groups:
-            group.close()
-
-    def save(
-        self,
-        output_path: str,
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid,
-        workers: int = os.cpu_count(),
-        chunk_size: int = 100
-    ) -> List[str]:
-        """Save WsiDicomSeries as DICOM-files in path.
-
-        Parameters
-        ----------
-        output_path: str
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid
-             Function that can gernerate unique identifiers.
-        workers: int = os.cpu_count()
-            Maximum number of thread workers to use.
-        chunk_size: int = 100
-            Chunk size (number of tiles) to process at a time. Actual chunk
-            size also depends on minimun_chunk_size from image_data.
-
-        Returns
-        ----------
-        List[str]
-            List of paths of created files.
-        """
-        filepaths: List[str] = []
-        for group in self.groups:
-            group_file_paths = group.save(
-                output_path,
-                uid_generator,
-                workers,
-                chunk_size
-            )
-            filepaths.extend(group_file_paths)
-        return filepaths
-
-
-class WsiDicomLabels(WsiDicomSeries):
-    """Represents a series of WsiDicomGroups of the label wsi flavor."""
-    @property
-    def wsi_type(self) -> str:
-        return 'LABEL'
-
-    @staticmethod
-    def group_open(instances: List[WsiInstance]) -> List[WsiDicomGroup]:
-        return WsiDicomGroup.open(instances)
-
-
-class WsiDicomOverviews(WsiDicomSeries):
-    """Represents a series of WsiDicomGroups of the overview wsi flavor."""
-    @property
-    def wsi_type(self) -> str:
-        return 'OVERVIEW'
-
-    @staticmethod
-    def group_open(instances: List[WsiInstance]) -> List[WsiDicomGroup]:
-        return WsiDicomGroup.open(instances)
-
-
-class WsiDicomLevels(WsiDicomSeries):
-    """Represents a series of WsiDicomGroups of the volume (e.g. pyramidal
-    level) wsi flavor."""
-    @property
-    def wsi_type(self) -> str:
-        return 'VOLUME'
-
-    @staticmethod
-    def group_open(instances: List[WsiInstance]) -> List[WsiDicomGroup]:
-        return WsiDicomLevel.open(instances)
-
-    def __init__(self, levels: List[WsiDicomLevel]):
-        """Holds a stack of levels.
-
-        Parameters
-        ----------
-        levels: List[WsiDicomLevel]
-            List of levels to include in series
-        """
-        self._levels = OrderedDict(
-            (level.level, level)
-            for level in sorted(levels, key=lambda level: level.level)
-        )
-        if len(self.groups) != 0 and self.groups[0].uids is not None:
-            self._uids = self._validate_series(self.groups)
-        else:
-            self._uids = None
-
-    @property
-    def pyramid(self) -> str:
-        """Return string representation of pyramid"""
-        return (
-            'Pyramid levels in file:\n'
-            + '\n'.join(
-                [str_indent(2) + level.pyramid
-                 for level in self._levels.values()]
-            )
-        )
-
-    @property
-    def groups(self) -> List[WsiDicomGroup]:
-        """Return contained groups"""
-        return list(self._levels.values())
-
-    @property
-    def levels(self) -> List[int]:
-        """Return contained levels"""
-        return list(self._levels.keys())
-
-    @property
-    def highest_level(self) -> int:
-        """Return highest valid pyramid level (which results in a 1x1 image)"""
-        return self.base_level.get_highest_level()
-
-    @property
-    def base_level(self) -> WsiDicomLevel:
-        """Return the base level of the pyramid"""
-        return self._levels[0]
-
-    def valid_level(self, level: int) -> bool:
-        """Check that given level is less or equal to the highest level
-        (1x1 pixel level).
-
-        Parameters
-        ----------
-        level: int
-            The level to check
-
-        Returns
-        ----------
-        bool
-            True if level is valid
-        """
-        return level <= self.highest_level
-
-    def get_level(self, level: int) -> WsiDicomLevel:
-        """Return wsi level.
-
-        Parameters
-        ----------
-        level: int
-            The level of the wsi level to return
-
-        Returns
-        ----------
-        WsiDicomLevel
-            The searched level
-        """
-        try:
-            return self._levels[level]
-        except KeyError:
-            raise WsiDicomNotFoundError(
-                f"Level of {level}", "level series"
-            )
-
-    def get_closest_by_level(self, level: int) -> WsiDicomLevel:
-        """Search for level that is closest to and smaller than the given
-        level.
-
-        Parameters
-        ----------
-        level: int
-            The level to search for
-
-        Returns
-        ----------
-        WsiDicomLevel
-            The level closest to searched level
-        """
-        if not self.valid_level(level):
-            raise WsiDicomOutOfBoundsError(
-                f"Level {level}", f"maximum level {self.highest_level}"
-            )
-        closest_level = 0
-        closest = None
-        for wsi_level in self._levels.values():
-            if((level >= wsi_level.level) and
-               (closest_level <= wsi_level.level)):
-                closest_level = wsi_level.level
-                closest = wsi_level
-        if closest is None:
-            raise WsiDicomNotFoundError(
-                f"Level for {level}", "level series"
-            )
-        return closest
-
-    def get_closest_by_size(self, size: Size) -> WsiDicomLevel:
-        """Search for level that by size is closest to and larger than the
-        given size.
-
-        Parameters
-        ----------
-        size: Size
-            The size to search for
-
-        Returns
-        ----------
-        WsiDicomLevel
-            The level with size closest to searched size
-        """
-        closest_size = self.groups[0].size
-        closest = None
-        for wsi_level in self._levels.values():
-            if((size.width <= wsi_level.size.width) and
-               (wsi_level.size.width <= closest_size.width)):
-                closest_size = wsi_level.size
-                closest = wsi_level
-        if closest is None:
-            raise WsiDicomNotFoundError(
-                f"Level for size {size}", "level series"
-            )
-        return closest
-
-    def get_closest_by_pixel_spacing(
-        self,
-        pixel_spacing: SizeMm
-    ) -> WsiDicomLevel:
-        """Search for level that by pixel spacing is closest to and smaller
-        than the given pixel spacing. Only the spacing in x-axis is used.
-
-        Parameters
-        ----------
-        pixel_spacing: SizeMm
-            Pixel spacing to search for
-
-        Returns
-        ----------
-        WsiDicomLevel
-            The level with pixel spacing closest to searched spacing
-        """
-        closest_pixel_spacing: float = 0
-        closest = None
-        for wsi_level in self._levels.values():
-            if((pixel_spacing.width >= wsi_level.pixel_spacing.width) and
-               (closest_pixel_spacing <= wsi_level.pixel_spacing.width)):
-                closest_pixel_spacing = wsi_level.pixel_spacing.width
-                closest = wsi_level
-        if closest is None:
-            raise WsiDicomNotFoundError(
-                f"Level for pixel spacing {pixel_spacing}", "level series")
-        return closest
-
-
-class WsiDicom:
-    """Represent a wsi slide containing pyramidal levels and optionally
-    labels and/or overviews."""
-    def __init__(
-        self,
-        levels: WsiDicomLevels,
-        labels: WsiDicomLabels,
-        overviews: WsiDicomOverviews,
-        annotations: List[AnnotationInstance] = []
-
-    ):
-        """Holds wsi dicom levels, labels and overviews.
-
-        Parameters
-        ----------
-        levels: WsiDicomLevels
-            Series of pyramidal levels.
-        labels: WsiDicomLabels
-            Series of label images.
-        overviews: WsiDicomOverviews
-            Series of overview images
-        annotations: List[AnnotationInstance] = []
-            Sup-222 annotation instances.
-        """
-
-        self._levels = levels
-        self._labels = labels
-        self._overviews = overviews
-        self.annotations = annotations
-
-        if self.levels.uids is not None:
-            self.uids = self._validate_collection(
-                [self.levels, self.labels, self.overviews],
-            )
-        else:
-            self.uids = None
-
-        self.optical = OpticalManager.open(
-            levels.instances + labels.instances + overviews.instances
-        )
-
-        if self.annotations != []:
-            for annotation in self.annotations:
-                if annotation.base_uids != self.uids:
-                    warnings.warn("Annotations uids does not match")
-
-        self.__enter__()
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        self.close()
-
-    def __repr__(self) -> str:
-        return (
-            f"{type(self).__name__}({self.levels}, {self.labels}"
-            f"{self.overviews}, {self.annotations})"
-        )
-
-    def __str__(self) -> str:
-        return self.pretty_str()
-
-    @property
-    def base_level(self) -> WsiDicomLevel:
-        return self.levels.base_level
-
-    @property
-    def size(self) -> Size:
-        """Return pixel size of base level."""
-        return self.base_level.size
-
-    @property
-    def tile_size(self) -> Size:
-        """Return tile size of levels."""
-        return self.base_level.tile_size
-
-    @property
-    def levels(self) -> WsiDicomLevels:
-        """Return contained levels"""
-        if self._levels is not None:
-            return self._levels
-        raise WsiDicomNotFoundError("levels", str(self))
-
-    @property
-    def labels(self) -> WsiDicomLabels:
-        """Return contained labels"""
-        if self._labels is not None:
-            return self._labels
-        raise WsiDicomNotFoundError("labels", str(self))
-
-    @property
-    def overviews(self) -> WsiDicomOverviews:
-        """Return contained overviews"""
-        if self._overviews is not None:
-            return self._overviews
-        raise WsiDicomNotFoundError("overviews", str(self))
-
-    @property
-    def files(self) -> List[WsiDicomFile]:
-        """Return contained files"""
-        return self.levels.files + self.labels.files + self.overviews.files
-
-    @property
-    def datasets(self) -> List[Dataset]:
-        """Return contained datasets."""
-        return (
-            self.levels.datasets
-            + self.labels.datasets
-            + self.overviews.datasets
-        )
-
-    @property
-    def instances(self) -> List[WsiInstance]:
-        """Return contained instances"""
-        return (
-            self.levels.instances
-            + self.labels.instances
-            + self.overviews.instances
-        )
-
-    @property
-    def frame_of_reference(self) -> Uid:
-        return self.uids.frame_of_reference
-
-    def pretty_str(
-        self,
-        indent: int = 0,
-        depth: int = None
-    ) -> str:
-        string = self.__class__.__name__
-        if depth is not None:
-            depth -= 1
-            if(depth < 0):
-                return string
-        return (
-            string + ' of levels:\n'
-            + list_pretty_str(self.levels.groups, indent, depth, 0, 2)
-        )
-
-    @classmethod
-    def open(cls, path: Union[str, List[str]]) -> 'WsiDicom':
-        """Open valid wsi dicom files in path and return a WsiDicom object.
-        Non-valid files are ignored.
-
-        Parameters
-        ----------
-        paths: List[Path]
-            Path to files to open
-
-        Returns
-        ----------
-        WsiDicom
-            Object created from wsi dicom files in path
-        """
-        filepaths = cls._get_filepaths(path)
-        level_files: List[WsiDicomFile] = []
-        label_files: List[WsiDicomFile] = []
-        overview_files: List[WsiDicomFile] = []
-        annotation_files: List[Path] = []
-
-        for filepath in cls._filter_paths(filepaths):
-            sop_class_uid = cls._get_sop_class_uid(filepath)
-            if sop_class_uid == WSI_SOP_CLASS_UID:
-                wsi_file = WsiDicomFile(filepath)
-                if(wsi_file.wsi_type == 'VOLUME'):
-                    level_files.append(wsi_file)
-                elif(wsi_file.wsi_type == 'LABEL'):
-                    label_files.append(wsi_file)
-                elif(wsi_file.wsi_type == 'OVERVIEW'):
-                    overview_files.append(wsi_file)
-                else:
-                    wsi_file.close()
-            elif sop_class_uid == ANN_SOP_CLASS_UID:
-                annotation_files.append(filepath)
-
-        base_dataset = cls._get_base_dataset(level_files)
-        base_uids = base_dataset.base_uids
-        base_tile_size = base_dataset.tile_size
-        level_instances = WsiInstance.open(
-            level_files,
-            base_uids,
-            base_tile_size
-        )
-        label_instances = WsiInstance.open(label_files, base_uids)
-        overview_instances = WsiInstance.open(overview_files, base_uids)
-
-        levels = WsiDicomLevels.open(level_instances)
-        labels = WsiDicomLabels.open(label_instances)
-        overviews = WsiDicomOverviews.open(overview_instances)
-        annotations = AnnotationInstance.open(annotation_files)
-
-        return cls(levels, labels, overviews, annotations)
-
-    @staticmethod
-    def _get_sop_class_uid(path: Path) -> Uid:
-        metadata: pydicom.dataset.FileMetaDataset = (
-            pydicom.filereader.read_file_meta_info(path)
-        )
-        return metadata.MediaStorageSOPClassUID
-
-    @staticmethod
-    def _get_filepaths(path: Union[str, List[str]]) -> List[Path]:
-        """Return file paths to files in path.
-        If path is folder, return list of folder files in path.
-        If path is single file, return list of that path.
-        If path is list, return list of paths that are files.
-        Raises WsiDicomNotFoundError if no files found
-
-        Parameters
-        ----------
-        path: Union[str, List[str]]
-            Path to folder, file or list of files
-
-        Returns
-        ----------
-        List[Path]
-            List of found file paths
-        """
-        if isinstance(path, str):
-            single_path = Path(path)
-            if single_path.is_dir():
-                return list(single_path.iterdir())
-            elif single_path.is_file():
-                return [single_path]
-        elif isinstance(path, list):
-            multiple_paths = [
-                Path(file_path) for file_path in path
-                if Path(file_path).is_file()
-            ]
-            if multiple_paths != []:
-                return multiple_paths
-
-        raise WsiDicomNotFoundError("No files found", str(path))
-
-    @staticmethod
-    def _get_base_dataset(
-        files: List[WsiDicomFile]
-    ) -> WsiDataset:
-        """Return file with largest image (width) from list of files.
-
-        Parameters
-        ----------
-        files: List[WsiDicomFile]
-           List of files.
-
-        Returns
-        ----------
-        WsiDataset
-            Base layer dataset.
-        """
-        base_size = Size(0, 0)
-        base_dataset: WsiDataset
-        for file in files:
-            if file.dataset.image_size.width > base_size.width:
-                base_dataset = file.dataset
-                base_size = file.dataset.image_size
-        return base_dataset
-
-    @staticmethod
-    def _filter_paths(filepaths: List[Path]) -> List[Path]:
-        """Filter list of paths to only include valid dicom files.
-
-        Parameters
-        ----------
-        filepaths: List[Path]
-            Paths to filter
-
-        Returns
-        ----------
-        List[Path]
-            List of paths with dicom files
-        """
-        return [
-            path for path in filepaths
-            if path.is_file() and pydicom.misc.is_dicom(path)
-        ]
-
-    def _validate_collection(
-        self,
-        series: List[WsiDicomSeries]
-    ) -> BaseUids:
-        """Check that no files or instance in collection is duplicate, that all
-        series have the same base uids. Raises WsiDicomMatchError otherwise.
-        Returns base uid for collection.
-
-        Parameters
-        ----------
-        series: List[WsiDicomSeries]
-            List of series to check
-
-        Returns
-        ----------
-        BaseUids
-            Matching uids
-        """
-        WsiDataset.check_duplicate_dataset(self.datasets, self)
-        WsiInstance.check_duplicate_instance(self.instances, self)
-
-        try:
-            base_uids = next(
-                item.uids for item in series if item.uids is not None
-            )
-        except StopIteration:
-            raise WsiDicomNotFoundError("Valid series", "in collection")
-        for item in series:
-            if item.uids is not None and item.uids != base_uids:
-                raise WsiDicomMatchError(str(item), str(self))
-        return base_uids
-
-    def read_label(self, index: int = 0) -> Image.Image:
-        """Read label image of the whole slide. If several label
-        images are present, index can be used to select a specific image.
-
-        Parameters
-        ----------
-        index: int
-            Index of the label image to read
-
-        Returns
-        ----------
-        Image.Image
-            label as image
-        """
-        try:
-            label = self.labels[index]
-            return label.get_default_full()
-        except IndexError:
-            raise WsiDicomNotFoundError("label", "series")
-
-    def read_overview(self, index: int = 0) -> Image.Image:
-        """Read overview image of the whole slide. If several overview
-        images are present, index can be used to select a specific image.
-
-        Parameters
-        ----------
-        index: int
-            Index of the overview image to read
-
-        Returns
-        ----------
-        Image.Image
-            Overview as image
-        """
-        try:
-            overview = self.overviews[index]
-            return overview.get_default_full()
-        except IndexError:
-            raise WsiDicomNotFoundError("overview", "series")
-
-    def read_thumbnail(
-        self,
-        size: Tuple[int, int],
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read thumbnail image of the whole slide with dimensions
-        no larger than given size.
-
-        Parameters
-        ----------
-        size: int, int
-            Upper size limit for thumbnail
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Thumbnail as image
-        """
-        thumbnail_size = Size.from_tuple(size)
-        level = self.levels.get_closest_by_size(thumbnail_size)
-        region = Region(position=Point(0, 0), size=level.size)
-        image = level.get_region(region, z, path)
-        image.thumbnail((size), resample=Image.BILINEAR)
-        return image
-
-    def read_region(
-        self,
-        location: Tuple[int, int],
-        level: int,
-        size: Tuple[int, int],
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read region defined by pixels.
-
-        Parameters
-        ----------
-        location: int, int
-            Upper left corner of region in pixels
-        level: int
-            Level in pyramid
-        size: int
-            Size of region in pixels
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Region as image
-        """
-        wsi_level = self.levels.get_closest_by_level(level)
-        scale_factor = wsi_level.calculate_scale(level)
-        scaled_region = Region(
-            position=Point.from_tuple(location),
-            size=Size.from_tuple(size)
-        ) * scale_factor
-
-        if not wsi_level.valid_pixels(scaled_region):
-            raise WsiDicomOutOfBoundsError(
-                f"Region {scaled_region}", f"level size {wsi_level.size}"
-            )
-        image = wsi_level.get_region(scaled_region, z, path)
-        if(scale_factor != 1):
-            image = image.resize((size), resample=Image.BILINEAR)
-        return image
-
-    def read_region_mm(
-        self,
-        location: Tuple[float, float],
-        level: int,
-        size: Tuple[float, float],
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read image from region defined in mm.
-
-        Parameters
-        ----------
-        location: float, float
-            Upper left corner of region in mm
-        level: int
-            Level in pyramid
-        size: float
-            Size of region in mm
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Region as image
-        """
-        wsi_level = self.levels.get_closest_by_level(level)
-        scale_factor = wsi_level.calculate_scale(level)
-        region = RegionMm(
-            position=PointMm.from_tuple(location),
-            size=SizeMm.from_tuple(size)
-        )
-        image = wsi_level.get_region_mm(region, z, path)
-        image_size = (
-            Size(width=image.size[0], height=image.size[1]) // scale_factor
-        )
-        return image.resize(image_size.to_tuple(), resample=Image.BILINEAR)
-
-    def read_region_mpp(
-        self,
-        location: Tuple[float, float],
-        mpp: float,
-        size: Tuple[float, float],
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read image from region defined in mm with set pixel spacing.
-
-        Parameters
-        ----------
-        location: float, float
-            Upper left corner of region in mm
-        mpp: float
-            Requested pixel spacing (um/pixel)
-        size: float
-            Size of region in mm
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Region as image
-        """
-        pixel_spacing = mpp/1000.0
-        wsi_level = self.levels.get_closest_by_pixel_spacing(
-            SizeMm(pixel_spacing, pixel_spacing)
-        )
-        region = RegionMm(
-            position=PointMm.from_tuple(location),
-            size=SizeMm.from_tuple(size)
-        )
-        image = wsi_level.get_region_mm(region, z, path)
-        image_size = SizeMm(width=size[0], height=size[1]) // pixel_spacing
-        return image.resize(image_size.to_tuple(), resample=Image.BILINEAR)
-
-    def read_tile(
-        self,
-        level: int,
-        tile: Tuple[int, int],
-        z: float = None,
-        path: str = None
-    ) -> Image.Image:
-        """Read tile in pyramid level as image.
-
-        Parameters
-        ----------
-        level: int
-            Pyramid level
-        tile: int, int
-            tile xy coordinate
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        Image.Image
-            Tile as image
-        """
-        tile_point = Point.from_tuple(tile)
-        try:
-            wsi_level = self.levels.get_level(level)
-            return wsi_level.get_tile(tile_point, z, path)
-        except WsiDicomNotFoundError:
-            # Scale from closest level
-            wsi_level = self.levels.get_closest_by_level(level)
-            return wsi_level.get_scaled_tile(
-                tile_point,
-                level,
-                z,
-                path)
-
-    def read_encoded_tile(
-        self,
-        level: int,
-        tile: Tuple[int, int],
-        z: float = None,
-        path: str = None
-    ) -> bytes:
-        """Read tile in pyramid level as encoded bytes. For non-existing levels
-        the tile is scaled down from a lower level, using the similar encoding.
-
-        Parameters
-        ----------
-        level: int
-            Pyramid level
-        tile: int, int
-            tile xy coordinate
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        bytes
-            Tile in file encoding.
-        """
-        tile_point = Point.from_tuple(tile)
-        try:
-            wsi_level = self.levels.get_level(level)
-            return wsi_level.get_encoded_tile(tile_point, z, path)
-        except WsiDicomNotFoundError:
-            # Scale from closest level
-            wsi_level = self.levels.get_closest_by_level(level)
-            return wsi_level.get_scaled_encoded_tile(
-                tile_point,
-                level,
-                z,
-                path
-            )
-
-    def get_instance(
-        self,
-        level: int,
-        z: float = None,
-        path: str = None
-    ) -> WsiInstance:
-
-        """Return instance fullfilling level, z and/or path.
-
-        Parameters
-        ----------
-        level: int
-            Pyramid level
-        z: float
-            Z coordinate, optional
-        path:
-            optical path, optional
-
-        Returns
-        ----------
-        WsiInstance:
-            Instance
-        """
-        wsi_level = self.levels.get_level(level)
-        return wsi_level.get_instance(z, path)
-
-    def close(self) -> None:
-        """Close all files."""
-        for series in [self.levels, self.overviews, self.labels]:
-            series.close()
-
-    def save(
-        self,
-        output_path: str,
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid,
-        workers: int = os.cpu_count(),
-        chunk_size: int = 100
-    ) -> List[str]:
-        """Save wsi as DICOM-files in path.
-
-        Parameters
-        ----------
-        output_path: str
-        uid_generator: Callable[..., Uid] = pydicom.uid.generate_uid
-             Function that can gernerate unique identifiers.
-        workers: int = os.cpu_count()
-            Maximum number of thread workers to use.
-        chunk_size: int = 100
-            Chunk size (number of tiles) to process at a time. Actual chunk
-            size also depends on minimun_chunk_size from image_data.
-
-        Returns
-        ----------
-        List[str]
-            List of paths of created files.
-        """
-        collections: List[WsiDicomSeries] = [
-            self.levels, self.labels, self.overviews
-        ]
-
-        filepaths: List[str] = []
-        for collection in collections:
-            collection_filepaths = collection.save(
-                output_path,
-                uid_generator,
-                workers,
-                chunk_size
-            )
-            filepaths.extend(collection_filepaths)
-        return filepaths
diff --git a/wsidicom/optical.py b/wsidicom/optical.py
index 813e1bb1..7900a894 100644
--- a/wsidicom/optical.py
+++ b/wsidicom/optical.py
@@ -2,16 +2,19 @@
 import warnings
 from abc import ABCMeta, abstractmethod
 from dataclasses import dataclass
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional
 
 import numpy as np
 from PIL import Image
 from pydicom.dataset import Dataset
 from pydicom.sequence import Sequence as DicomSequence
 
-from .conceptcode import (ChannelDescriptionCode, IlluminationCode,
-                          IlluminationColorCode, IlluminatorCode,
-                          ImagePathFilterCode, LenseCode, LightPathFilterCode)
+from wsidicom.instance import WsiInstance
+
+from .conceptcode import (ChannelDescriptionCode, ConceptCode,
+                          IlluminationCode, IlluminationColorCode,
+                          IlluminatorCode, ImagePathFilterCode, LenseCode,
+                          LightPathFilterCode)
 from .errors import WsiDicomNotFoundError
 
 
@@ -31,12 +34,11 @@ def __init__(self, lut_sequence: DicomSequence):
         length, first, bits = \
             self._lut_item.RedPaletteColorLookupTableDescriptor
         self._length = length
-        self._type: type
         self._bits = bits
         if bits == 8:
-            self._type = np.uint8
+            self._type = np.dtype(np.uint8)
         else:
-            self._type = np.uint16
+            self._type = np.dtype(np.uint16)
         self._byte_format = 'HHH'  # Do we need to set endianess?
         self.table = self._parse_lut(self._lut_item)
 
@@ -75,7 +77,8 @@ def insert_into_ds(self, ds: Dataset) -> Dataset:
             Dataset with object inserted.
 
         """
-        ds.PaletteColorLookupTableSequence = DicomSequence[self._lut_item]
+        ds.PaletteColorLookupTableSequence = DicomSequence([self._lut_item])
+        return ds
 
     @classmethod
     def from_ds(cls, ds: Dataset) -> Optional['Lut']:
@@ -120,12 +123,12 @@ def _parse_color(self, segmented_lut_data: bytes):
                 raise NotImplementedError("Unkown lut segment type")
         return parsed_table
 
-    def _parse_lut(self, lut: DicomSequence) -> np.ndarray:
+    def _parse_lut(self, lut: Dataset) -> np.ndarray:
         """Parse a dicom Palette Color Lookup Table Sequence item.
 
         Parameters
         ----------
-        lut: DicomSequence
+        lut: Dataset
             A Palette Color Lookup Table Sequence item
         """
         tables = [
@@ -206,14 +209,14 @@ def _add_linear(cls, table: np.ndarray, length: int, value: int):
 @dataclass
 class OpticalFilter(metaclass=ABCMeta):
     """Metaclass for filter conditions for optical path"""
-    filters: Optional[Union[LightPathFilterCode, ImagePathFilterCode]]
+    filters: Optional[List[ConceptCode]]
     nominal: Optional[float]
     low_pass: Optional[float]
     high_pass: Optional[float]
 
     @classmethod
     @abstractmethod
-    def from_ds(cls, ds: Dataset):
+    def from_ds(cls, ds: Dataset) -> 'OpticalFilter':
         raise NotImplementedError
 
     def insert_into_ds(self, ds: Dataset) -> Dataset:
@@ -246,7 +249,7 @@ def insert_into_ds(self, ds: Dataset) -> Dataset:
 @dataclass
 class LightPathFilter(OpticalFilter):
     """Set of light path filter conditions for optical path"""
-    filters: Optional[List[LightPathFilterCode]]
+    filters: Optional[List[ConceptCode]]
 
     @classmethod
     def from_ds(cls, ds: Dataset) -> 'LightPathFilter':
@@ -276,7 +279,7 @@ def from_ds(cls, ds: Dataset) -> 'LightPathFilter':
 @dataclass
 class ImagePathFilter(OpticalFilter):
     """Set of image path filter conditions for optical path"""
-    filters: Optional[List[ImagePathFilterCode]]
+    filters: Optional[List[ConceptCode]]
 
     @classmethod
     def from_ds(cls, ds: Dataset) -> 'ImagePathFilter':
@@ -308,10 +311,10 @@ class Illumination:
     """Set of illumination conditions for optical path"""
     def __init__(
         self,
-        illumination_method: List[IlluminationCode],
-        illumination_wavelength: float = None,
-        illumination_color: IlluminationColorCode = None,
-        illuminator: IlluminatorCode = None
+        illumination_method: List[ConceptCode] = [],
+        illumination_wavelength: Optional[float] = None,
+        illumination_color: Optional[ConceptCode] = None,
+        illuminator: Optional[ConceptCode] = None
     ):
         if illumination_color is None and illumination_wavelength is None:
             raise ValueError("Illumination color or wavelenght need to be set")
@@ -373,7 +376,7 @@ def insert_into_ds(self, ds: Dataset) -> Dataset:
 @dataclass
 class Lenses:
     """Set of lens conditions for optical path"""
-    lenses: Optional[List[LenseCode]]
+    lenses: Optional[List[ConceptCode]]
     condenser_power: Optional[float]
     objective_power: Optional[float]
     objective_na: Optional[float]
@@ -435,13 +438,13 @@ def __init__(
         identifier: str,
         illumination: Illumination,
         photometric_interpretation: str,
-        description: str = None,
-        icc_profile: bytes = None,
-        lut: Lut = None,
-        light_path_filter: LightPathFilter = None,
-        image_path_filter: ImagePathFilter = None,
-        channel_description: List[ChannelDescriptionCode] = None,
-        lenses: Lenses = None
+        description: Optional[str] = None,
+        icc_profile: Optional[bytes] = None,
+        lut: Optional[Lut] = None,
+        light_path_filter: Optional[LightPathFilter] = None,
+        image_path_filter: Optional[ImagePathFilter] = None,
+        channel_description: Optional[List[ConceptCode]] = None,
+        lenses: Optional[Lenses] = None
     ):
         """Create a OpticalPath from identifier, illumination, photometric
         interpretation, and optional attributes.
@@ -454,19 +457,19 @@ def __init__(
             The illumination condition used in the optical path.
         photometric_interpretation: str
             The photometric interpretation of the optical path.
-        description: str = None
+        description: Optional[str] = None
             Optional description of the optical path.
-        icc_profile: bytes = None
+        icc_profile: Optional[bytes] = None
             Optional ICC profile for the optical path.
-        lut: Lut = None
+        lut: Optional[Lut] = None
             Optional Look-up table for the optical path.
-        light_path_filter: LightPathFilter = None
+        light_path_filter: Optional[LightPathFilter] = None
             Optional light path filter description for the optical path.
-        image_path_filter: ImagePathFilter = None
+        image_path_filter: Optional[ImagePathFilter] = None
             Optional image path filter description for the optical path.
-        channel_description: List[ChannelDescriptionCode] = None
+        channel_description: Optional[List[ConceptCode]] = None
             Optional channel description for the optical path.
-        lenses: Lenses = None
+        lenses: Optional[Lenses] = None
             Optional lens description for the optical path.
         """
 
@@ -507,7 +510,7 @@ def to_ds(self) -> Dataset:
         if self.light_path_filter is not None:
             ds = self.light_path_filter.insert_into_ds(ds)
         if self.image_path_filter is not None:
-            ds = self.light_path_filter.insert_into_ds(ds)
+            ds = self.image_path_filter.insert_into_ds(ds)
         if self.channel_description is not None:
             for item in self.channel_description:
                 ds = item.insert_into_ds(ds)
@@ -554,13 +557,13 @@ class OpticalManager:
     """Store optical paths loaded from dicom files."""
     def __init__(
         self,
-        optical_paths: List[OpticalPath] = None,
+        optical_paths: List[OpticalPath] = [],
     ):
         """Create a OpticalManager from list of OpticalPaths.
 
         Parameters
         ----------
-        optical_paths: List[OpticalPath] = None
+        optical_paths: List[OpticalPath] = []
             List of OpticalPaths.
         """
         self._optical_paths: Dict[str, OpticalPath] = {
@@ -569,14 +572,14 @@ def __init__(
         }
 
     @classmethod
-    def open(cls, instances: List) -> 'OpticalManager':
-        """Parse optical path sequence in listed files and create an
+    def open(cls, instances: List[WsiInstance]) -> 'OpticalManager':
+        """Parse optical path sequence in listed instances and create an
         OpticalManager out of the found (unique) OpticalPaths.
 
         Parameters
         ----------
-        files: List[WsiDicomFile]
-            List of WsiDicom files to parse
+        files: List[WsiInstance]
+            List of WsiDicom instances to parse
 
         Returns
         ----------
@@ -595,7 +598,7 @@ def open(cls, instances: List) -> 'OpticalManager':
                             dataset.PhotometricInterpretation
                         )
                         optical_paths[identifier] = path
-        return OpticalManager(optical_paths.values())
+        return cls(list(optical_paths.values()))
 
     def insert_into_ds(self, ds: Dataset) -> Dataset:
         """Codes and insert object into dataset.
@@ -627,12 +630,12 @@ def get(self, identifier: str) -> OpticalPath:
                 "optical path manager"
             )
 
-    def apply_lut(self, image: Image, identifier: str) -> Image:
+    def apply_lut(self, image: Image.Image, identifier: str) -> Image.Image:
         """Apply LUT of identifier to image. Converts gray scale image to RGB.
 
         Parameters
         ----------
-        image: Image
+        image: Image.Image
             Pillow image to apply LUT to.
         identifier: str
             The identifier of the LUT to apply
@@ -653,23 +656,3 @@ def apply_lut(self, image: Image, identifier: str) -> Image:
         # if(image.mode == 'L'):
         #     image = image.convert('RGB')
         return image.point(lut.array(image.mode))
-
-    @staticmethod
-    def get_path_identifers(optical_path_sequence: DicomSequence) -> List[str]:
-        """Parse optical path sequence and return list of optical path
-        identifiers
-
-        Parameters
-        ----------
-        optical_path_sequence: DicomSequence
-            Optical path sequence.
-
-        Returns
-        ----------
-        List[str]
-            List of optical path identifiers.
-        """
-        return list({
-            str(optical_ds.OpticalPathIdentifier)
-            for optical_ds in optical_path_sequence
-        })
diff --git a/wsidicom/stringprinting.py b/wsidicom/stringprinting.py
index 0ac7d2f7..4e9c6c10 100644
--- a/wsidicom/stringprinting.py
+++ b/wsidicom/stringprinting.py
@@ -1,4 +1,4 @@
-from typing import Sequence, Dict
+from typing import Any, Dict, Optional, Sequence
 
 
 def str_indent(indent: int = 0) -> str:
@@ -19,9 +19,9 @@ def str_indent(indent: int = 0) -> str:
 
 
 def list_pretty_str(
-    items: Sequence,
+    items: Sequence[Any],
     indent: int = 0,
-    depth: int = None,
+    depth: Optional[int] = None,
     pre_new_lines: int = 0,
     list_new_lines: int = 1,
     space: bool = False
@@ -37,7 +37,7 @@ def list_pretty_str(
         List of items to pretty-print
     indent: int
         Identation for the string and list
-    depth: int
+    depth: Optional[int] = None
         Depth to print, if item contains list
     pre_new_lines: int
         Number of new lines at begining of string
@@ -66,9 +66,9 @@ def list_pretty_str(
 
 
 def dict_pretty_str(
-    items: Dict,
+    items: Dict[Any, Any],
     indent: int = 0,
-    depth: int = None,
+    depth: Optional[int] = None,
     pre_new_lines: int = 0,
     list_new_lines: int = 1,
     space: bool = False
@@ -84,7 +84,7 @@ def dict_pretty_str(
         List of items to pretty-print
     indent: int
         Identation for the string and list
-    depth: int
+    depth: Optional[int] = None
         Depth to print, if item contains list
     pre_new_lines: int
         Number of new lines at begining of string
diff --git a/wsidicom/uid.py b/wsidicom/uid.py
index da7e7d25..17d67a89 100644
--- a/wsidicom/uid.py
+++ b/wsidicom/uid.py
@@ -1,5 +1,6 @@
 from pydicom.uid import UID as Uid
 from dataclasses import dataclass
+from typing import Optional
 
 WSI_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.77.1.6'
 ANN_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.91.1'
@@ -10,7 +11,7 @@ class BaseUids:
     """Represents the UIDs that should be common for all files in the wsi."""
     study_instance: Uid
     series_instance: Uid
-    frame_of_reference: Uid = None
+    frame_of_reference: Optional[Uid] = None
 
     def __str__(self) -> str:
         return (
@@ -19,7 +20,7 @@ def __str__(self) -> str:
             f"frame of reference {self.frame_of_reference}"
         )
 
-    def __eq__(self, other) -> bool:
+    def __eq__(self, other: 'BaseUids') -> bool:
         if isinstance(other, BaseUids):
             return (
                 self.study_instance == other.study_instance and
@@ -37,7 +38,7 @@ def __eq__(self, other) -> bool:
 class FileUids:
     """Represents the UIDs in a DICOM-file."""
     instance: Uid
-    concatenation: Uid
+    concatenation: Optional[Uid]
     base: BaseUids
 
     @property
@@ -54,7 +55,7 @@ def identifier(self) -> Uid:
             return self.concatenation
         return self.instance
 
-    def __eq__(self, other) -> bool:
+    def __eq__(self, other: 'FileUids') -> bool:
         """Return true if concatenation uid is not none, matches other
         concatenation uid and base uids match.
 
diff --git a/wsidicom/wsidicom.py b/wsidicom/wsidicom.py
new file mode 100644
index 00000000..06f45385
--- /dev/null
+++ b/wsidicom/wsidicom.py
@@ -0,0 +1,2013 @@
+import math
+import os
+import warnings
+from abc import ABCMeta, abstractmethod
+from collections import defaultdict
+from copy import deepcopy
+from pathlib import Path
+from typing import (Any, Callable, DefaultDict, Dict, List, Optional,
+                    OrderedDict, Tuple, Union)
+
+from PIL import Image
+from pydicom.dataset import FileMetaDataset
+from pydicom.filereader import read_file_meta_info
+from pydicom.misc import is_dicom
+from pydicom.uid import UID, generate_uid
+
+from wsidicom.errors import (WsiDicomMatchError, WsiDicomNotFoundError,
+                             WsiDicomOutOfBoundsError)
+from wsidicom.geometry import Point, PointMm, Region, RegionMm, Size, SizeMm
+from wsidicom.graphical_annotations import AnnotationInstance
+from wsidicom.instance import (ImageData, WsiDataset, WsiDicomFile,
+                               WsiDicomFileWriter, WsiDicomImageData,
+                               WsiInstance)
+from wsidicom.optical import OpticalManager
+from wsidicom.stringprinting import (dict_pretty_str, list_pretty_str,
+                                     str_indent)
+from wsidicom.uid import ANN_SOP_CLASS_UID, WSI_SOP_CLASS_UID, BaseUids
+
+
+class WsiDicomGroup:
+    """Represents a group of instances having the same size, but possibly
+    different z coordinate and/or optical path."""
+    def __init__(
+        self,
+        instances: List[WsiInstance]
+    ):
+        """Create a group of WsiInstances. Instances should match in the common
+        uids, wsi type, and tile size.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to build the group.
+        """
+        self._instances = {  # key is identifier (Uid)
+            instance.identifier: instance for instance in instances
+        }
+        self._validate_group()
+
+        base_instance = instances[0]
+        self._wsi_type = base_instance.wsi_type
+        self._uids = base_instance.uids
+
+        self._size = base_instance.size
+        self._pixel_spacing = base_instance.pixel_spacing
+        self._default_instance_uid: UID = base_instance.identifier
+
+    def __repr__(self) -> str:
+        return f"{type(self).__name__}({self.instances.values()})"
+
+    def __str__(self) -> str:
+        return self.pretty_str()
+
+    def pretty_str(
+        self,
+        indent: int = 0,
+        depth: Optional[int] = None
+    ) -> str:
+        string = (
+            f'Image: size: {self.size} px, mpp: {self.mpp} um/px'
+        )
+        if depth is not None:
+            depth -= 1
+            if(depth < 0):
+                return string
+        string += (
+            ' Instances: ' + dict_pretty_str(self.instances, indent, depth)
+        )
+        return string
+
+    def __getitem__(self, index: UID) -> WsiInstance:
+        return self.instances[index]
+
+    @property
+    def uids(self) -> BaseUids:
+        """Return uids"""
+        return self._uids
+
+    @property
+    def wsi_type(self) -> str:
+        """Return wsi type"""
+        return self._wsi_type
+
+    @property
+    def size(self) -> Size:
+        """Return image size in pixels"""
+        return self._size
+
+    @property
+    def mpp(self) -> SizeMm:
+        """Return pixel spacing in um/pixel"""
+        return self.pixel_spacing*1000.0
+
+    @property
+    def pixel_spacing(self) -> SizeMm:
+        """Return pixel spacing in mm/pixel"""
+        return self._pixel_spacing
+
+    @property
+    def instances(self) -> Dict[UID, WsiInstance]:
+        """Return contained instances"""
+        return self._instances
+
+    @property
+    def default_instance(self) -> WsiInstance:
+        """Return default instance"""
+        return self.instances[self._default_instance_uid]
+
+    @property
+    def files(self) -> List[Path]:
+        """Return contained files"""
+        instance_files = [
+            instance.image_data.files for instance in self.instances.values()
+        ]
+        return [file for sublist in instance_files for file in sublist]
+
+    @property
+    def datasets(self) -> List[WsiDataset]:
+        """Return contained datasets."""
+        instance_datasets = [
+            instance.datasets for instance in self.instances.values()
+        ]
+        return [
+            dataset for sublist in instance_datasets for dataset in sublist
+        ]
+
+    @property
+    def optical_paths(self) -> List[str]:
+        return list({
+            path
+            for instance in self.instances.values()
+            for path in instance.optical_paths
+        })
+
+    @property
+    def focal_planes(self) -> List[float]:
+        return list({
+            focal_plane
+            for innstance in self.instances.values()
+            for focal_plane in innstance.focal_planes
+        })
+
+    @classmethod
+    def open(
+        cls,
+        instances: List[WsiInstance],
+    ) -> List['WsiDicomGroup']:
+        """Return list of groups created from wsi instances.
+
+        Parameters
+        ----------
+        files: List[WsiInstance]
+            Instances to create groups from.
+
+        Returns
+        ----------
+        List[WsiDicomGroup]
+            List of created groups.
+
+        """
+        groups: List['WsiDicomGroup'] = []
+
+        grouped_instances = cls._group_instances(instances)
+
+        for group in grouped_instances.values():
+            groups.append(cls(group))
+
+        return groups
+
+    def matches(self, other_group: 'WsiDicomGroup') -> bool:
+        """Check if group is valid (Uids and tile size match).
+        The common Uids should match for all series.
+        """
+        return (
+            other_group.uids == self.uids and
+            other_group.wsi_type == self.wsi_type
+        )
+
+    def valid_pixels(self, region: Region) -> bool:
+        """Check if pixel region is withing the size of the group image size.
+
+        Parameters
+        ----------
+        region: Region
+            Pixel region to check
+
+        Returns
+        ----------
+        bool
+            True if pixel position and size is within image
+        """
+        # Return true if inside pixel plane.
+        image_region = Region(Point(0, 0), self.size)
+        return region.is_inside(image_region)
+
+    def get_instance(
+        self,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> WsiInstance:
+        """Search for instance fullfilling the parameters.
+        The behavior when z and/or path is none could be made more
+        clear.
+
+        Parameters
+        ----------
+        z: Optional[float] = None
+            Z coordinate of the searched instance
+        path: Optional[str] = None
+            Optical path of the searched instance
+
+        Returns
+        ----------
+        WsiInstance
+            The instance containing selected path and z coordinate
+        """
+        if z is None and path is None:
+            instance = self.default_instance
+            z = instance.default_z
+            path = instance.default_path
+
+            return self.default_instance
+
+        # Sort instances by number of focal planes (prefer simplest instance)
+        sorted_instances = sorted(
+            list(self._instances.values()),
+            key=lambda i: len(i.focal_planes)
+        )
+        try:
+            if z is None:
+                # Select the instance with selected optical path
+                instance = next(i for i in sorted_instances if
+                                path in i.optical_paths)
+            elif path is None:
+                # Select the instance with selected z
+                instance = next(i for i in sorted_instances
+                                if z in i.focal_planes)
+            else:
+                # Select by both path and z
+                instance = next(i for i in sorted_instances
+                                if (z in i.focal_planes and
+                                    path in i.optical_paths))
+        except StopIteration:
+            raise WsiDicomNotFoundError(
+                f"Instance for path: {path}, z: {z}", "group"
+            )
+        if z is None:
+            z = instance.default_z
+        if path is None:
+            path = instance.default_path
+        return instance
+
+    def get_default_full(self) -> Image.Image:
+        """Read full image using default z coordinate and path.
+
+        Returns
+        ----------
+        Image.Image
+            Full image of the group.
+        """
+        instance = self.default_instance
+        z = instance.default_z
+        path = instance.default_path
+        region = Region(position=Point(x=0, y=0), size=self.size)
+        image = self.get_region(region, z, path)
+        return image
+
+    def get_region(
+        self,
+        region: Region,
+        z: Optional[float] = None,
+        path: Optional[str] = None,
+    ) -> Image.Image:
+        """Read region defined by pixels.
+
+        Parameters
+        ----------
+        location: int, int
+            Upper left corner of region in pixels
+        size: int
+            Size of region in pixels
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Region as image
+        """
+
+        instance = self.get_instance(z, path)
+        if z is None:
+            z = instance.default_z
+        if path is None:
+            path = instance.default_path
+        image = instance.image_data.stitch_tiles(region, path, z)
+        return image
+
+    def get_region_mm(
+        self,
+        region: RegionMm,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read region defined by mm.
+
+        Parameters
+        ----------
+        location: float, float
+            Upper left corner of region in mm
+        size: float
+            Size of region in mm
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Region as image
+        """
+        pixel_region = self.mm_to_pixel(region)
+        image = self.get_region(pixel_region, z, path)
+        return image
+
+    def get_tile(
+        self,
+        tile: Point,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Return tile at tile coordinate x, y as image.
+
+        Parameters
+        ----------
+        tile: Point
+            Tile x, y coordinate
+        z: Optional[float] = None
+            Z coordinate
+        path: Optional[str] = None
+            Optical path
+
+        Returns
+        ----------
+        Image.Image
+            The tile as image
+        """
+
+        instance = self.get_instance(z, path)
+        if z is None:
+            z = instance.default_z
+        if path is None:
+            path = instance.default_path
+        return instance.image_data.get_tile(tile, z, path)
+
+    def get_encoded_tile(
+        self,
+        tile: Point,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> bytes:
+        """Return tile at tile coordinate x, y as bytes.
+
+        Parameters
+        ----------
+        tile: Point
+            Tile x, y coordinate
+        z: Optional[float] = None
+            Z coordinate
+        path: Optional[str] = None
+            Optical path
+
+        Returns
+        ----------
+        bytes
+            The tile as bytes
+        """
+        instance = self.get_instance(z, path)
+        if z is None:
+            z = instance.default_z
+        if path is None:
+            path = instance.default_path
+        return instance.image_data.get_encoded_tile(tile, z, path)
+
+    def mm_to_pixel(self, region: RegionMm) -> Region:
+        """Convert region in mm to pixel region.
+
+        Parameters
+        ----------
+        region: RegionMm
+            Region in mm
+
+        Returns
+        ----------
+        Region
+            Region in pixels
+        """
+        pixel_region = Region(
+            position=region.position // self.pixel_spacing,
+            size=region.size // self.pixel_spacing
+        )
+        if not self.valid_pixels(pixel_region):
+            raise WsiDicomOutOfBoundsError(
+                f"Region {region}", f"level size {self.size}"
+            )
+        return pixel_region
+
+    def close(self) -> None:
+        """Close all instances on the group."""
+        for instance in self._instances.values():
+            instance.close()
+
+    def _validate_group(self):
+        """Check that no file or instance in group is duplicate, instances in
+        group matches and that the optical manager matches by base uid.
+        Raises WsiDicomMatchError otherwise.
+        """
+        WsiDataset.check_duplicate_dataset(self.datasets, self)
+        instances = list(self.instances.values())
+        base_instance = instances[0]
+        for instance in instances[1:]:
+            if not base_instance.matches(instance):
+                raise WsiDicomMatchError(str(instance), str(self))
+        WsiInstance.check_duplicate_instance(instances, self)
+
+    @classmethod
+    def _group_instances(
+        cls,
+        instances: List[WsiInstance]
+    ) -> OrderedDict[Size, List[WsiInstance]]:
+        """Return instances grouped and sorted by image size.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to group by image size.
+
+        Returns
+        ----------
+        OrderedDict[Size, List[WsiInstance]]:
+            Instances grouped by size, with size as key.
+
+        """
+        grouped_instances: Dict[Size, List[WsiInstance]] = {}
+        for instance in instances:
+            try:
+                grouped_instances[instance.size].append(instance)
+            except KeyError:
+                grouped_instances[instance.size] = [instance]
+        return OrderedDict(sorted(
+            grouped_instances.items(),
+            key=lambda item: item[0].width,
+            reverse=True)
+        )
+
+    def _group_instances_to_file(
+        self,
+    ) -> List[List[WsiInstance]]:
+        """Group instances by properties that can't differ in a DICOM-file,
+        i.e. the instances are grouped by output file.
+
+        Returns
+        ----------
+        List[List[WsiInstance]]
+            Instances grouped by common properties.
+        """
+        groups: DefaultDict[Any, List[WsiInstance]] = (
+            defaultdict(list)
+        )
+        for instance in self.instances.values():
+            groups[
+                instance.image_data.photometric_interpretation,
+                instance.image_data.transfer_syntax,
+                instance.ext_depth_of_field,
+                instance.ext_depth_of_field_planes,
+                instance.ext_depth_of_field_plane_distance,
+                instance.focus_method,
+                instance.slice_spacing
+            ].append(
+                instance
+            )
+        return list(groups.values())
+
+    @staticmethod
+    def _list_image_data(
+        instances: List[WsiInstance]
+    ) -> List[Tuple[Tuple[str, float], ImageData]]:
+        """Sort ImageData in instances by optical path and focal
+        plane.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            List of instances with optical paths and focal planes to list and
+            sort.
+
+        Returns
+        ----------
+        Tuple[Tuple[str, float], ImageData]
+            ImageData sorted by optical path and focal plane.
+        """
+        output: Dict[Tuple[str, float], ImageData] = {}
+        for instance in instances:
+            for optical_path in instance.optical_paths:
+                for z in instance.focal_planes:
+                    if (optical_path, z) not in output:
+                        output[optical_path, z] = instance.image_data
+        return list(OrderedDict(output).items())
+
+    def save(
+        self,
+        output_path: str,
+        uid_generator: Callable[..., UID],
+        workers: int,
+        chunk_size: int
+    ) -> List[Path]:
+        """Save a WsiDicomGroup to files in output_path. Instances are grouped
+        by properties that can differ in the same file:
+            - photometric interpretation
+            - transfer syntax
+            - extended depth of field (and planes and distance)
+            - focus method
+            - spacing between slices
+        Other properties are assumed to be equal or to be updated.
+
+        Parameters
+        ----------
+        output_path: str
+            Folder path to save files to.
+        uid_generator: Callable[..., UID]
+            Uid generator to use.
+        workers: int
+            Maximum number of thread workers to use.
+        chunk_size: int
+            Chunk size (number of tiles) to process at a time. Actual chunk
+            size also depends on minimun_chunk_size from image_data.
+
+        Returns
+        ----------
+        List[str]
+            List of paths of created files.
+        """
+        filepaths: List[Path] = []
+        for instances in self._group_instances_to_file():
+            uid = uid_generator()
+            if output_path == os.devnull:
+                filepath = Path(output_path)
+            else:
+                filepath = Path(os.path.join(output_path, uid + '.dcm'))
+
+            transfer_syntax = instances[0].image_data.transfer_syntax
+            dataset = deepcopy(instances[0].dataset)
+            with WsiDicomFileWriter(filepath) as wsi_file:
+                wsi_file.write_preamble()
+                wsi_file.write_file_meta(uid, transfer_syntax)
+                dataset.SOPInstanceUID = uid
+                wsi_file.write_base(dataset)
+                wsi_file.write_pixel_data_start()
+                for (path, z), image_data in self._list_image_data(instances):
+                    wsi_file.write_pixel_data(
+                        image_data,
+                        z,
+                        path,
+                        workers,
+                        chunk_size
+                    )
+                wsi_file.write_pixel_data_end()
+                wsi_file.close()
+            filepaths.append(filepath)
+        return filepaths
+
+
+class WsiDicomLevel(WsiDicomGroup):
+    """Represents a level in the pyramid and contains one or more instances
+    having the same pyramid level index, pixel spacing, and size but possibly
+    different focal planes and/or optical paths.
+    """
+    def __init__(
+        self,
+        instances: List[WsiInstance],
+        base_pixel_spacing: SizeMm
+    ):
+        """Create a level from list of WsiInstances. Asign the pyramid level
+        index from pixel spacing of base level.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to build the level.
+        base_pixel_spacing: SizeMm
+            Pixel spacing of base level.
+        """
+        super().__init__(instances)
+        self._base_pixel_spacing = base_pixel_spacing
+        self._level = self._assign_level(self._base_pixel_spacing)
+
+    def __repr__(self) -> str:
+        return (
+            f"{type(self).__name__}({self.instances}, "
+            f"{self._base_pixel_spacing})"
+        )
+
+    def __str__(self) -> str:
+        return self.pretty_str()
+
+    def pretty_str(
+        self,
+        indent: int = 0,
+        depth: Optional[int] = None
+    ) -> str:
+        string = (
+            f'Level: {self.level}, size: {self.size} px, mpp: {self.mpp} um/px'
+        )
+        if depth is not None:
+            depth -= 1
+            if(depth < 0):
+                return string
+        string += (
+            ' Instances: ' + dict_pretty_str(self.instances, indent, depth)
+        )
+        return string
+
+    @property
+    def pyramid(self) -> str:
+        """Return string representation of the level"""
+        return (
+            f'Level [{self.level}]'
+            f' tiles: {self.default_instance.tiled_size},'
+            f' size: {self.size}, mpp: {self.mpp} um/px'
+        )
+
+    @property
+    def tile_size(self) -> Size:
+        return self.default_instance.tile_size
+
+    @property
+    def level(self) -> int:
+        """Return pyramid level"""
+        return self._level
+
+    @classmethod
+    def open(
+        cls,
+        instances: List[WsiInstance],
+    ) -> List['WsiDicomLevel']:
+        """Return list of levels created wsi files.
+
+        Parameters
+        ----------
+        files: List[WsiInstance]
+            Instances to create levels from.
+
+        Returns
+        ----------
+        List[WsiDicomLevel]
+            List of created levels.
+
+        """
+        levels: List['WsiDicomLevel'] = []
+        instances_grouped_by_level = cls._group_instances(instances)
+
+        base_group = list(instances_grouped_by_level.values())[0]
+        base_pixel_spacing = base_group[0].pixel_spacing
+        for level in instances_grouped_by_level.values():
+            levels.append(cls(level, base_pixel_spacing))
+
+        return levels
+
+    def matches(self, other_group: 'WsiDicomGroup') -> bool:
+        """Check if group is valid (Uids and tile size match).
+        The common Uids should match for all series. For level series the tile
+        size should also match. It is assumed that the instances in the groups
+        are matching each other.
+        """
+        other_instance = other_group.default_instance
+        this_instance = self.default_instance
+        return (
+            other_group.uids == self.uids and
+            other_instance.tile_size == this_instance.tile_size
+        )
+
+    def get_highest_level(self) -> int:
+        """Return highest deep zoom scale that can be produced
+        from the image in the level.
+
+        Returns
+        ----------
+        int
+            Relative level where the pixel size becomes 1x1
+        """
+        return math.ceil(math.log2(max(self.size.width, self.size.height)))
+
+    def get_scaled_tile(
+        self,
+        tile: Point,
+        level: int,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Return tile in another level by scaling a region.
+        If the tile is an edge tile, the resulting tile is croped
+        to remove part outside of the image (as defiend by level size).
+
+        Parameters
+        ----------
+        tile: Point
+            Non scaled tile coordinate
+        level: int
+            Level to scale from
+        z: Optional[float] = None
+            Z coordinate
+        path: Optional[str] = None
+            Optical path
+
+        Returns
+        ----------
+        Image.Image
+            A tile image
+        """
+        scale = self.calculate_scale(level)
+        instance = self.get_instance(z, path)
+        scaled_region = Region.from_tile(tile, instance.tile_size) * scale
+        cropped_region = scaled_region.crop(instance.image_data.image_size)
+        if not self.valid_pixels(cropped_region):
+            raise WsiDicomOutOfBoundsError(
+                f"Region {cropped_region}", f"level size {self.size}"
+            )
+        image = self.get_region(cropped_region, z, path)
+        tile_size = cropped_region.size/scale
+        image = image.resize(
+            tile_size.to_tuple(),
+            resample=Image.BILINEAR
+        )
+        return image
+
+    def get_scaled_encoded_tile(
+        self,
+        tile: Point,
+        scale: int,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> bytes:
+        """Return encoded tile in another level by scaling a region.
+
+        Parameters
+        ----------
+        tile: Point
+            Non scaled tile coordinate
+        level: int
+           Level to scale from
+        z: Optional[float] = None
+            Z coordinate
+        path: Optional[str] = None
+            Optical path
+
+        Returns
+        ----------
+        bytes
+            A transfer syntax encoded tile
+        """
+        image = self.get_scaled_tile(tile, scale, z, path)
+        instance = self.get_instance(z, path)
+        return instance.image_data.encode(image)
+
+    def calculate_scale(self, level_to: int) -> int:
+        """Return scaling factor to given level.
+
+        Parameters
+        ----------
+        level_to -- index of level to scale to
+
+        Returns
+        ----------
+        int
+            Scaling factor between this level and given level
+        """
+        return int(2 ** (level_to - self.level))
+
+    def _assign_level(self, base_pixel_spacing: SizeMm) -> int:
+        """Return (2^level scale factor) based on pixel spacing.
+        Will round to closest integer. Raises NotImplementedError if level is
+        to far from integer.
+
+        Parameters
+        ----------
+        base_pixel_spacing: SizeMm
+            The pixel spacing of the base lavel
+
+        Returns
+        ----------
+        int
+            The pyramid order of the level
+        """
+        float_level = math.log2(
+            self.pixel_spacing.width/base_pixel_spacing.width
+        )
+        level = int(round(float_level))
+        TOLERANCE = 1e-2
+        if not math.isclose(float_level, level, rel_tol=TOLERANCE):
+            raise NotImplementedError(f"Levels needs to be integer")
+        return level
+
+    def create_child(
+        self,
+        scale: int,
+        uid_generator: Callable[..., UID],
+        workers: int,
+        chunk_size: int
+    ) -> 'WsiDicomLevel':
+        filepaths: List[Path] = []
+        if not isinstance(scale, int) or scale < 2:
+            raise ValueError(
+                "Scale must be integer and larger than 2"
+            )
+        if not isinstance(
+            self.default_instance.image_data,
+            WsiDicomImageData
+        ):
+            raise NotImplementedError(
+                "Can only construct pyramid from DICOM WSI files"
+            )
+        output_path = (
+            self.default_instance.image_data._files[0].filepath.parent
+        )
+        new_tiled_size = self.default_instance.tiled_size / scale
+        new_image_size = self.default_instance.size / scale
+        for instances in self._group_instances_to_file():
+            uid = uid_generator()
+            filepath = Path(os.path.join(output_path, uid + '.dcm'))
+            transfer_syntax = instances[0].image_data.transfer_syntax
+            dataset = deepcopy(instances[0].dataset)
+            # Modify dataset to reflect scaled data
+            dataset.NumberOfFrames = (
+                new_tiled_size.width * new_tiled_size.height
+            )
+            dataset.TotalPixelMatrixColumns = new_image_size.width
+            dataset.TotalPixelMatrixRows = new_image_size.height
+
+            new_pixel_spacing = self.pixel_spacing * scale
+            (
+                dataset.SharedFunctionalGroupsSequence[0].
+                PixelMeasuresSequence[0].PixelSpacing
+            ) = list(new_pixel_spacing.to_tuple())
+            with WsiDicomFileWriter(filepath) as wsi_file:
+                wsi_file = WsiDicomFileWriter(filepath)
+                wsi_file.write_preamble()
+                wsi_file.write_file_meta(uid, transfer_syntax)
+                dataset.SOPInstanceUID = uid
+                wsi_file.write_base(dataset)
+                wsi_file.write_pixel_data_start()
+                for (path, z), image_data in self._list_image_data(instances):
+                    wsi_file.write_pixel_data(
+                        image_data,
+                        z,
+                        path,
+                        workers,
+                        chunk_size,
+                        scale
+                    )
+                wsi_file.write_pixel_data_end()
+                wsi_file.close()
+            filepaths.append(filepath)
+
+        created_instances = WsiInstance.open(
+            [WsiDicomFile(filepath) for filepath in filepaths],
+            self.uids,
+            self.tile_size
+        )
+        return WsiDicomLevel(created_instances, self._base_pixel_spacing)
+
+
+class WsiDicomSeries(metaclass=ABCMeta):
+    """Represents a series of WsiDicomGroups with the same image flavor, e.g.
+    pyramidal levels, lables, or overviews.
+    """
+
+    def __init__(self, groups: List[WsiDicomGroup]):
+        """Create a WsiDicomSeries from list of WsiDicomGroups.
+
+        Parameters
+        ----------
+        groups: List[WsiDicomGroup]
+            List of groups to include in the series.
+        """
+        self._groups: List[WsiDicomGroup] = groups
+
+        if len(self.groups) != 0 and self.groups[0].uids is not None:
+            self._uids = self._validate_series(self.groups)
+        else:
+            self._uids = None
+
+    def __repr__(self) -> str:
+        return f"{type(self).__name__}({self.groups})"
+
+    def __str__(self) -> str:
+        return f"{type(self).__name__} of groups {self.groups}"
+
+    def __getitem__(self, index: int) -> WsiDicomGroup:
+        """Get group by index.
+
+        Parameters
+        ----------
+        index: int
+            Index in series to get
+
+        Returns
+        ----------
+        WsiDicomGroup
+            The group at index in the series
+        """
+        return self.groups[index]
+
+    def __len__(self) -> int:
+        return len(self.groups)
+
+    @property
+    @abstractmethod
+    def wsi_type(self) -> str:
+        """Should return the wsi type of the series ('VOLUME', 'LABEL', or
+        'OVERVIEW'"""
+        raise NotImplementedError
+
+    @property
+    def groups(self) -> List[WsiDicomGroup]:
+        """Return contained groups."""
+        return self._groups
+
+    @property
+    def uids(self) -> Optional[BaseUids]:
+        """Return uids."""
+        return self._uids
+
+    @property
+    def mpps(self) -> List[SizeMm]:
+        """Return contained mpp (um/px)."""
+        return [group.mpp for group in self.groups]
+
+    @property
+    def files(self) -> List[Path]:
+        """Return contained files."""
+        series_files = [series.files for series in self.groups]
+        return [file for sublist in series_files for file in sublist]
+
+    @property
+    def datasets(self) -> List[WsiDataset]:
+        """Return contained datasets."""
+
+        series_datasets = [
+            series.datasets for series in self.groups
+        ]
+        return [
+            dataset for sublist in series_datasets for dataset in sublist
+        ]
+
+    @property
+    def instances(self) -> List[WsiInstance]:
+        """Return contained instances"""
+        series_instances = [
+            series.instances.values() for series in self.groups
+        ]
+        return [
+            instance for sublist in series_instances for instance in sublist
+        ]
+
+    @classmethod
+    @abstractmethod
+    def open(cls, instances: List[WsiInstance]) -> 'WsiDicomSeries':
+        raise NotImplementedError
+
+    def _validate_series(
+            self,
+            groups: Union[List[WsiDicomGroup], List[WsiDicomLevel]]
+    ) -> Optional[BaseUids]:
+        """Check that no files or instances in series is duplicate and that
+        all groups in series matches.
+        Raises WsiDicomMatchError otherwise.
+        Returns the matching base uid. If list of groups is empty, return None.
+
+        Parameters
+        ----------
+        groups: Union[List[WsiDicomGroup], List[WsiDicomLevel]]
+            List of groups or levels to check
+
+        Returns
+        ----------
+        Optional[BaseUids]:
+            Matching uids
+        """
+        WsiDataset.check_duplicate_dataset(self.datasets, self)
+        WsiInstance.check_duplicate_instance(self.instances, self)
+
+        try:
+            base_group = groups[0]
+            if base_group.wsi_type != self.wsi_type:
+                raise WsiDicomMatchError(
+                    str(base_group), str(self)
+                )
+            for group in groups[1:]:
+                if not group.matches(base_group):
+                    raise WsiDicomMatchError(
+                        str(group), str(self)
+                    )
+            return base_group.uids
+        except IndexError:
+            return None
+
+    def close(self) -> None:
+        """Close all groups in the series."""
+        for group in self.groups:
+            group.close()
+
+    def save(
+        self,
+        output_path: str,
+        uid_generator: Callable[..., UID],
+        workers: int,
+        chunk_size: int
+    ) -> List[Path]:
+        """Save WsiDicomSeries as DICOM-files in path.
+
+        Parameters
+        ----------
+        output_path: str
+        uid_generator: Callable[..., UID]
+             Function that can gernerate unique identifiers.
+        workers: int
+            Maximum number of thread workers to use.
+        chunk_size:
+            Chunk size (number of tiles) to process at a time. Actual chunk
+            size also depends on minimun_chunk_size from image_data.
+
+        Returns
+        ----------
+        List[Path]
+            List of paths of created files.
+        """
+        filepaths: List[Path] = []
+        for group in self.groups:
+            group_file_paths = group.save(
+                output_path,
+                uid_generator,
+                workers,
+                chunk_size
+            )
+            filepaths.extend(group_file_paths)
+        return filepaths
+
+
+class WsiDicomLabels(WsiDicomSeries):
+    """Represents a series of WsiDicomGroups of the label wsi flavor."""
+    @property
+    def wsi_type(self) -> str:
+        return 'LABEL'
+
+    @classmethod
+    def open(cls, instances: List[WsiInstance]) -> 'WsiDicomLabels':
+        """Return labels created from wsi files.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to create labels from.
+
+        Returns
+        ----------
+        WsiDicomOverviews
+            Created labels.
+        """
+        labels = WsiDicomGroup.open(instances)
+        return cls(labels)
+
+
+class WsiDicomOverviews(WsiDicomSeries):
+    """Represents a series of WsiDicomGroups of the overview wsi flavor."""
+    @property
+    def wsi_type(self) -> str:
+        return 'OVERVIEW'
+
+    @classmethod
+    def open(cls, instances: List[WsiInstance]) -> 'WsiDicomOverviews':
+        """Return overviews created from wsi files.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to create overviews from.
+
+        Returns
+        ----------
+        WsiDicomOverviews
+            Created overviews.
+        """
+        overviews = WsiDicomGroup.open(instances)
+        return cls(overviews)
+
+
+class WsiDicomLevels(WsiDicomSeries):
+    """Represents a series of WsiDicomGroups of the volume (e.g. pyramidal
+    level) wsi flavor."""
+    @property
+    def wsi_type(self) -> str:
+        return 'VOLUME'
+
+    @classmethod
+    def open(cls, instances: List[WsiInstance]) -> 'WsiDicomLevels':
+        """Return overviews created from wsi files.
+
+        Parameters
+        ----------
+        instances: List[WsiInstance]
+            Instances to create levels from.
+
+        Returns
+        ----------
+        WsiDicomOverviews
+            Created levels.
+        """
+        levels = WsiDicomLevel.open(instances)
+        return cls(levels)
+
+    def __init__(self, levels: List[WsiDicomLevel]):
+        """Holds a stack of levels.
+
+        Parameters
+        ----------
+        levels: List[WsiDicomLevel]
+            List of levels to include in series
+        """
+        self._levels = OrderedDict(
+            (level.level, level)
+            for level in sorted(levels, key=lambda level: level.level)
+        )
+        if len(self.groups) != 0 and self.groups[0].uids is not None:
+            self._uids = self._validate_series(self.groups)
+        else:
+            self._uids = None
+
+    @property
+    def pyramid(self) -> str:
+        """Return string representation of pyramid"""
+        return (
+            'Pyramid levels in file:\n'
+            + '\n'.join(
+                [str_indent(2) + level.pyramid
+                 for level in self._levels.values()]
+            )
+        )
+
+    @property
+    def groups(self) -> List[WsiDicomGroup]:
+        """Return contained groups"""
+        return list(self._levels.values())
+
+    @property
+    def levels(self) -> List[int]:
+        """Return contained levels"""
+        return list(self._levels.keys())
+
+    @property
+    def highest_level(self) -> int:
+        """Return highest valid pyramid level (which results in a 1x1 image)"""
+        return self.base_level.get_highest_level()
+
+    @property
+    def base_level(self) -> WsiDicomLevel:
+        """Return the base level of the pyramid"""
+        return self._levels[0]
+
+    def valid_level(self, level: int) -> bool:
+        """Check that given level is less or equal to the highest level
+        (1x1 pixel level).
+
+        Parameters
+        ----------
+        level: int
+            The level to check
+
+        Returns
+        ----------
+        bool
+            True if level is valid
+        """
+        return level <= self.highest_level
+
+    def get_level(self, level: int) -> WsiDicomLevel:
+        """Return wsi level.
+
+        Parameters
+        ----------
+        level: int
+            The level of the wsi level to return
+
+        Returns
+        ----------
+        WsiDicomLevel
+            The searched level
+        """
+        try:
+            return self._levels[level]
+        except KeyError:
+            raise WsiDicomNotFoundError(
+                f"Level of {level}", "level series"
+            )
+
+    def get_closest_by_level(self, level: int) -> WsiDicomLevel:
+        """Search for level that is closest to and smaller than the given
+        level.
+
+        Parameters
+        ----------
+        level: int
+            The level to search for
+
+        Returns
+        ----------
+        WsiDicomLevel
+            The level closest to searched level
+        """
+        if not self.valid_level(level):
+            raise WsiDicomOutOfBoundsError(
+                f"Level {level}", f"maximum level {self.highest_level}"
+            )
+        closest_level = 0
+        closest = None
+        for wsi_level in self._levels.values():
+            if((level >= wsi_level.level) and
+               (closest_level <= wsi_level.level)):
+                closest_level = wsi_level.level
+                closest = wsi_level
+        if closest is None:
+            raise WsiDicomNotFoundError(
+                f"Level for {level}", "level series"
+            )
+        return closest
+
+    def get_closest_by_size(self, size: Size) -> WsiDicomLevel:
+        """Search for level that by size is closest to and larger than the
+        given size.
+
+        Parameters
+        ----------
+        size: Size
+            The size to search for
+
+        Returns
+        ----------
+        WsiDicomLevel
+            The level with size closest to searched size
+        """
+        closest_size = self.groups[0].size
+        closest = None
+        for wsi_level in self._levels.values():
+            if((size.width <= wsi_level.size.width) and
+               (wsi_level.size.width <= closest_size.width)):
+                closest_size = wsi_level.size
+                closest = wsi_level
+        if closest is None:
+            raise WsiDicomNotFoundError(
+                f"Level for size {size}", "level series"
+            )
+        return closest
+
+    def get_closest_by_pixel_spacing(
+        self,
+        pixel_spacing: SizeMm
+    ) -> WsiDicomLevel:
+        """Search for level that by pixel spacing is closest to and smaller
+        than the given pixel spacing. Only the spacing in x-axis is used.
+
+        Parameters
+        ----------
+        pixel_spacing: SizeMm
+            Pixel spacing to search for
+
+        Returns
+        ----------
+        WsiDicomLevel
+            The level with pixel spacing closest to searched spacing
+        """
+        closest_pixel_spacing: float = 0
+        closest = None
+        for wsi_level in self._levels.values():
+            if((pixel_spacing.width >= wsi_level.pixel_spacing.width) and
+               (closest_pixel_spacing <= wsi_level.pixel_spacing.width)):
+                closest_pixel_spacing = wsi_level.pixel_spacing.width
+                closest = wsi_level
+        if closest is None:
+            raise WsiDicomNotFoundError(
+                f"Level for pixel spacing {pixel_spacing}", "level series")
+        return closest
+
+    def construct_pyramid(
+        self,
+        highest_level: int,
+        uid_generator: Callable[..., UID] = generate_uid,
+        workers: Optional[int] = None,
+        chunk_size: int = 100
+    ) -> List[Path]:
+        """Construct missing pyramid levels from excisting levels.
+
+        Parameters
+        ----------
+        highest_level: int
+        uid_generator: Callable[..., UID] = pydicom.uid.generate_uid
+             Function that can gernerate unique identifiers.
+        workers: Optional[int] = None
+            Maximum number of thread workers to use.
+        chunk_size: int = 100
+            Chunk size (number of tiles) to process at a time. Actual chunk
+            size also depends on minimun_chunk_size from image_data.
+
+        Returns
+        ----------
+        List[Path]
+            List of paths of created files.
+        """
+        if workers is None:
+            cpus = os.cpu_count()
+            if cpus is None:
+                workers = 1
+            else:
+                workers = cpus
+
+        filepaths: List[Path] = []
+
+        for pyramid_level in range(highest_level):
+            if pyramid_level not in self._levels.keys():
+                # Find the closest larger level for missing level
+                closest_level = self.get_closest_by_level(pyramid_level)
+                # Create scaled level
+                new_level = closest_level.create_child(
+                    scale=2,
+                    uid_generator=uid_generator,
+                    workers=workers,
+                    chunk_size=chunk_size
+                )
+                # Add level to available levels
+                self._levels[new_level.level] = new_level
+                filepaths += new_level.files
+        return filepaths
+
+
+class WsiDicom:
+    """Represent a wsi slide containing pyramidal levels and optionally
+    labels and/or overviews."""
+    def __init__(
+        self,
+        levels: WsiDicomLevels,
+        labels: WsiDicomLabels,
+        overviews: WsiDicomOverviews,
+        annotations: List[AnnotationInstance] = []
+
+    ):
+        """Holds wsi dicom levels, labels and overviews.
+
+        Parameters
+        ----------
+        levels: WsiDicomLevels
+            Series of pyramidal levels.
+        labels: WsiDicomLabels
+            Series of label images.
+        overviews: WsiDicomOverviews
+            Series of overview images
+        annotations: List[AnnotationInstance] = []
+            Sup-222 annotation instances.
+        """
+
+        self._levels = levels
+        self._labels = labels
+        self._overviews = overviews
+        self.annotations = annotations
+
+        self.uids = self._validate_collection(
+            [self.levels, self.labels, self.overviews],
+        )
+
+        self.optical = OpticalManager.open(
+            levels.instances + labels.instances + overviews.instances
+        )
+
+        if self.annotations != []:
+            for annotation in self.annotations:
+                if annotation.base_uids != self.uids:
+                    warnings.warn("Annotations uids does not match")
+
+        self.__enter__()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close()
+
+    def __repr__(self) -> str:
+        return (
+            f"{type(self).__name__}({self.levels}, {self.labels}"
+            f"{self.overviews}, {self.annotations})"
+        )
+
+    def __str__(self) -> str:
+        return self.pretty_str()
+
+    @property
+    def base_level(self) -> WsiDicomLevel:
+        return self.levels.base_level
+
+    @property
+    def size(self) -> Size:
+        """Return pixel size of base level."""
+        return self.base_level.size
+
+    @property
+    def tile_size(self) -> Size:
+        """Return tile size of levels."""
+        return self.base_level.tile_size
+
+    @property
+    def levels(self) -> WsiDicomLevels:
+        """Return contained levels"""
+        if self._levels is not None:
+            return self._levels
+        raise WsiDicomNotFoundError("levels", str(self))
+
+    @property
+    def labels(self) -> WsiDicomLabels:
+        """Return contained labels"""
+        if self._labels is not None:
+            return self._labels
+        raise WsiDicomNotFoundError("labels", str(self))
+
+    @property
+    def overviews(self) -> WsiDicomOverviews:
+        """Return contained overviews"""
+        if self._overviews is not None:
+            return self._overviews
+        raise WsiDicomNotFoundError("overviews", str(self))
+
+    @property
+    def files(self) -> List[Path]:
+        """Return contained files"""
+        return self.levels.files + self.labels.files + self.overviews.files
+
+    @property
+    def datasets(self) -> List[WsiDataset]:
+        """Return contained datasets."""
+        return (
+            self.levels.datasets
+            + self.labels.datasets
+            + self.overviews.datasets
+        )
+
+    @property
+    def instances(self) -> List[WsiInstance]:
+        """Return contained instances"""
+        return (
+            self.levels.instances
+            + self.labels.instances
+            + self.overviews.instances
+        )
+
+    @property
+    def frame_of_reference(self) -> Optional[UID]:
+        return self.uids.frame_of_reference
+
+    def pretty_str(
+        self,
+        indent: int = 0,
+        depth: Optional[int] = None
+    ) -> str:
+        string = self.__class__.__name__
+        if depth is not None:
+            depth -= 1
+            if(depth < 0):
+                return string
+        return (
+            string + ' of levels:\n'
+            + list_pretty_str(self.levels.groups, indent, depth, 0, 2)
+        )
+
+    @classmethod
+    def open(
+        cls,
+        path: Union[str, List[str], Path, List[Path]]
+    ) -> 'WsiDicom':
+        """Open valid wsi dicom files in path and return a WsiDicom object.
+        Non-valid files are ignored.
+
+        Parameters
+        ----------
+        path: Union[str, List[str], Path, List[Path]]
+            Path to files to open
+
+        Returns
+        ----------
+        WsiDicom
+            Object created from wsi dicom files in path
+        """
+        filepaths = cls._get_filepaths(path)
+        level_files: List[WsiDicomFile] = []
+        label_files: List[WsiDicomFile] = []
+        overview_files: List[WsiDicomFile] = []
+        annotation_files: List[Path] = []
+
+        for filepath in cls._filter_paths(filepaths):
+            sop_class_uid = cls._get_sop_class_uid(filepath)
+            if sop_class_uid == WSI_SOP_CLASS_UID:
+                wsi_file = WsiDicomFile(filepath)
+                if(wsi_file.wsi_type == 'VOLUME'):
+                    level_files.append(wsi_file)
+                elif(wsi_file.wsi_type == 'LABEL'):
+                    label_files.append(wsi_file)
+                elif(wsi_file.wsi_type == 'OVERVIEW'):
+                    overview_files.append(wsi_file)
+                else:
+                    wsi_file.close()
+            elif sop_class_uid == ANN_SOP_CLASS_UID:
+                annotation_files.append(filepath)
+
+        base_dataset = cls._get_base_dataset(level_files)
+        base_uids = base_dataset.base_uids
+        base_tile_size = base_dataset.tile_size
+        level_instances = WsiInstance.open(
+            level_files,
+            base_uids,
+            base_tile_size
+        )
+        label_instances = WsiInstance.open(label_files, base_uids)
+        overview_instances = WsiInstance.open(overview_files, base_uids)
+
+        levels = WsiDicomLevels.open(level_instances)
+        labels = WsiDicomLabels.open(label_instances)
+        overviews = WsiDicomOverviews.open(overview_instances)
+        annotations = AnnotationInstance.open(annotation_files)
+
+        return cls(levels, labels, overviews, annotations)
+
+    def read_label(self, index: int = 0) -> Image.Image:
+        """Read label image of the whole slide. If several label
+        images are present, index can be used to select a specific image.
+
+        Parameters
+        ----------
+        index: int
+            Index of the label image to read
+
+        Returns
+        ----------
+        Image.Image
+            label as image
+        """
+        try:
+            label = self.labels[index]
+            return label.get_default_full()
+        except IndexError:
+            raise WsiDicomNotFoundError("label", "series")
+
+    def read_overview(self, index: int = 0) -> Image.Image:
+        """Read overview image of the whole slide. If several overview
+        images are present, index can be used to select a specific image.
+
+        Parameters
+        ----------
+        index: int
+            Index of the overview image to read
+
+        Returns
+        ----------
+        Image.Image
+            Overview as image
+        """
+        try:
+            overview = self.overviews[index]
+            return overview.get_default_full()
+        except IndexError:
+            raise WsiDicomNotFoundError("overview", "series")
+
+    def read_thumbnail(
+        self,
+        size: Tuple[int, int],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read thumbnail image of the whole slide with dimensions
+        no larger than given size.
+
+        Parameters
+        ----------
+        size: int, int
+            Upper size limit for thumbnail
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Thumbnail as image
+        """
+        thumbnail_size = Size.from_tuple(size)
+        level = self.levels.get_closest_by_size(thumbnail_size)
+        region = Region(position=Point(0, 0), size=level.size)
+        image = level.get_region(region, z, path)
+        image.thumbnail((size), resample=Image.BILINEAR)
+        return image
+
+    def read_region(
+        self,
+        location: Tuple[int, int],
+        level: int,
+        size: Tuple[int, int],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read region defined by pixels.
+
+        Parameters
+        ----------
+        location: int, int
+            Upper left corner of region in pixels
+        level: int
+            Level in pyramid
+        size: int
+            Size of region in pixels
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Region as image
+        """
+        wsi_level = self.levels.get_closest_by_level(level)
+        scale_factor = wsi_level.calculate_scale(level)
+        scaled_region = Region(
+            position=Point.from_tuple(location),
+            size=Size.from_tuple(size)
+        ) * scale_factor
+
+        if not wsi_level.valid_pixels(scaled_region):
+            raise WsiDicomOutOfBoundsError(
+                f"Region {scaled_region}", f"level size {wsi_level.size}"
+            )
+        image = wsi_level.get_region(scaled_region, z, path)
+        if(scale_factor != 1):
+            image = image.resize((size), resample=Image.BILINEAR)
+        return image
+
+    def read_region_mm(
+        self,
+        location: Tuple[float, float],
+        level: int,
+        size: Tuple[float, float],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read image from region defined in mm.
+
+        Parameters
+        ----------
+        location: float, float
+            Upper left corner of region in mm
+        level: int
+            Level in pyramid
+        size: float
+            Size of region in mm
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Region as image
+        """
+        wsi_level = self.levels.get_closest_by_level(level)
+        scale_factor = wsi_level.calculate_scale(level)
+        region = RegionMm(
+            position=PointMm.from_tuple(location),
+            size=SizeMm.from_tuple(size)
+        )
+        image = wsi_level.get_region_mm(region, z, path)
+        image_size = (
+            Size(width=image.size[0], height=image.size[1]) // scale_factor
+        )
+        return image.resize(image_size.to_tuple(), resample=Image.BILINEAR)
+
+    def read_region_mpp(
+        self,
+        location: Tuple[float, float],
+        mpp: float,
+        size: Tuple[float, float],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read image from region defined in mm with set pixel spacing.
+
+        Parameters
+        ----------
+        location: float, float
+            Upper left corner of region in mm
+        mpp: float
+            Requested pixel spacing (um/pixel)
+        size: float
+            Size of region in mm
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Region as image
+        """
+        pixel_spacing = mpp/1000.0
+        wsi_level = self.levels.get_closest_by_pixel_spacing(
+            SizeMm(pixel_spacing, pixel_spacing)
+        )
+        region = RegionMm(
+            position=PointMm.from_tuple(location),
+            size=SizeMm.from_tuple(size)
+        )
+        image = wsi_level.get_region_mm(region, z, path)
+        image_size = SizeMm(width=size[0], height=size[1]) // pixel_spacing
+        return image.resize(image_size.to_tuple(), resample=Image.BILINEAR)
+
+    def read_tile(
+        self,
+        level: int,
+        tile: Tuple[int, int],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> Image.Image:
+        """Read tile in pyramid level as image.
+
+        Parameters
+        ----------
+        level: int
+            Pyramid level
+        tile: int, int
+            tile xy coordinate
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        Image.Image
+            Tile as image
+        """
+        tile_point = Point.from_tuple(tile)
+        try:
+            wsi_level = self.levels.get_level(level)
+            return wsi_level.get_tile(tile_point, z, path)
+        except WsiDicomNotFoundError:
+            # Scale from closest level
+            wsi_level = self.levels.get_closest_by_level(level)
+            return wsi_level.get_scaled_tile(
+                tile_point,
+                level,
+                z,
+                path)
+
+    def read_encoded_tile(
+        self,
+        level: int,
+        tile: Tuple[int, int],
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> bytes:
+        """Read tile in pyramid level as encoded bytes. For non-existing levels
+        the tile is scaled down from a lower level, using the similar encoding.
+
+        Parameters
+        ----------
+        level: int
+            Pyramid level
+        tile: int, int
+            tile xy coordinate
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        bytes
+            Tile in file encoding.
+        """
+        tile_point = Point.from_tuple(tile)
+        try:
+            wsi_level = self.levels.get_level(level)
+            return wsi_level.get_encoded_tile(tile_point, z, path)
+        except WsiDicomNotFoundError:
+            # Scale from closest level
+            wsi_level = self.levels.get_closest_by_level(level)
+            return wsi_level.get_scaled_encoded_tile(
+                tile_point,
+                level,
+                z,
+                path
+            )
+
+    def get_instance(
+        self,
+        level: int,
+        z: Optional[float] = None,
+        path: Optional[str] = None
+    ) -> WsiInstance:
+
+        """Return instance fullfilling level, z and/or path.
+
+        Parameters
+        ----------
+        level: int
+            Pyramid level
+        z: Optional[float] = None
+            Z coordinate, optional
+        path: Optional[str] = None
+            optical path, optional
+
+        Returns
+        ----------
+        WsiInstance:
+            Instance
+        """
+        wsi_level = self.levels.get_level(level)
+        return wsi_level.get_instance(z, path)
+
+    def close(self) -> None:
+        """Close all files."""
+        for series in [self.levels, self.overviews, self.labels]:
+            series.close()
+
+    def save(
+        self,
+        output_path: str,
+        uid_generator: Callable[..., UID] = generate_uid,
+        workers: Optional[int] = None,
+        chunk_size: Optional[int] = None
+    ) -> List[Path]:
+        """Save wsi as DICOM-files in path.
+
+        Parameters
+        ----------
+        output_path: str
+        uid_generator: Callable[..., UID] = pydicom.uid.generate_uid
+             Function that can gernerate unique identifiers.
+        workers: Optional[int] = None
+            Maximum number of thread workers to use.
+        chunk_size: Optional[int] = None
+            Chunk size (number of tiles) to process at a time. Actual chunk
+            size also depends on minimun_chunk_size from image_data.
+
+        Returns
+        ----------
+        List[Path]
+            List of paths of created files.
+        """
+        if workers is None:
+            cpus = os.cpu_count()
+            if cpus is None:
+                workers = 1
+            else:
+                workers = cpus
+        if chunk_size is None:
+            chunk_size = 100
+
+        collections: List[WsiDicomSeries] = [
+            self.levels, self.labels, self.overviews
+        ]
+
+        filepaths: List[Path] = []
+        for collection in collections:
+            collection_filepaths = collection.save(
+                output_path,
+                uid_generator,
+                workers,
+                chunk_size
+            )
+            filepaths.extend(collection_filepaths)
+        return filepaths
+
+    @staticmethod
+    def _get_sop_class_uid(path: Path) -> UID:
+        metadata: FileMetaDataset = read_file_meta_info(path)
+        return metadata.MediaStorageSOPClassUID
+
+    @staticmethod
+    def _get_filepaths(path: Union[str, List[str], Path, List[Path]]):
+        """Return file paths to files in path.
+        If path is folder, return list of folder files in path.
+        If path is single file, return list of that path.
+        If path is list, return list of paths that are files.
+        Raises WsiDicomNotFoundError if no files found
+
+        Parameters
+        ----------
+        path: path: Union[str, List[str], Path, List[Path]]
+            Path to folder, file or list of files
+
+        Returns
+        ----------
+        List[Path]
+            List of found file paths
+        """
+        if isinstance(path, (str, Path)):
+            single_path = Path(path)
+            if single_path.is_dir():
+                return list(single_path.iterdir())
+            elif single_path.is_file():
+                return [single_path]
+        elif isinstance(path, list):
+            multiple_paths = [
+                Path(file_path) for file_path in path
+                if Path(file_path).is_file()
+            ]
+            if multiple_paths != []:
+                return multiple_paths
+
+        raise WsiDicomNotFoundError("No files found", str(path))
+
+    @staticmethod
+    def _get_base_dataset(
+        files: List[WsiDicomFile]
+    ) -> WsiDataset:
+        """Return file with largest image (width) from list of files.
+
+        Parameters
+        ----------
+        files: List[WsiDicomFile]
+           List of files.
+
+        Returns
+        ----------
+        WsiDataset
+            Base layer dataset.
+        """
+        base_size = Size(0, 0)
+        base_dataset = files[0].dataset
+        for file in files[1:]:
+            if file.dataset.image_size.width > base_size.width:
+                base_dataset = file.dataset
+                base_size = file.dataset.image_size
+        return base_dataset
+
+    @staticmethod
+    def _filter_paths(filepaths: List[Path]) -> List[Path]:
+        """Filter list of paths to only include valid dicom files.
+
+        Parameters
+        ----------
+        filepaths: List[Path]
+            Paths to filter
+
+        Returns
+        ----------
+        List[Path]
+            List of paths with dicom files
+        """
+        return [
+            path for path in filepaths if path.is_file() and is_dicom(path)
+        ]
+
+    def _validate_collection(
+        self,
+        series: List[WsiDicomSeries]
+    ) -> BaseUids:
+        """Check that no files or instance in collection is duplicate, that all
+        series have the same base uids. Raises WsiDicomMatchError otherwise.
+        Returns base uid for collection.
+
+        Parameters
+        ----------
+        series: List[WsiDicomSeries]
+            List of series to check
+
+        Returns
+        ----------
+        BaseUids
+            Matching uids
+        """
+        WsiDataset.check_duplicate_dataset(self.datasets, self)
+        WsiInstance.check_duplicate_instance(self.instances, self)
+
+        try:
+            base_uids = next(
+                item.uids for item in series if item.uids is not None
+            )
+        except StopIteration:
+            raise WsiDicomNotFoundError("Valid series", "in collection")
+        for item in series:
+            if item.uids is not None and item.uids != base_uids:
+                raise WsiDicomMatchError(str(item), str(self))
+        return base_uids


Test patch:
diff --git a/test/data_gen.py b/test/data_gen.py
index 75a6e7e2..7184dfd5 100644
--- a/test/data_gen.py
+++ b/test/data_gen.py
@@ -1,6 +1,4 @@
-# Coded version of DICOM file 'C:\temp\example_dicom_image\example_dicom_image\test_im\10_0'
-# Produced by pydicom codify utility script
-import pydicom
+# type: ignore
 from pydicom.dataset import Dataset, FileMetaDataset
 from pydicom.sequence import Sequence
 
diff --git a/test/test_annotation.py b/test/test_annotation.py
index 14f0bc8a..c03577db 100644
--- a/test/test_annotation.py
+++ b/test/test_annotation.py
@@ -3,13 +3,14 @@
 import unittest
 from collections import OrderedDict
 from copy import deepcopy
-from dataclasses import make_dataclass
+from dataclasses import dataclass
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Any, Dict, List, NamedTuple, Tuple, Union, get_origin
+from typing import Any, Dict, List, Tuple, Union
+
+from pydicom.uid import generate_uid
 
 import numpy as np
-import pydicom
 import pytest
 import shapely
 import xmltodict
@@ -20,7 +21,7 @@
                                             Point, PointAnnotationGroup,
                                             Polygon, PolygonAnnotationGroup,
                                             Polyline, PolylineAnnotationGroup)
-from wsidicom.interface import WsiDicom
+from wsidicom import WsiDicom
 from wsidicom.uid import BaseUids
 
 from .data_gen import create_layer_file
@@ -31,9 +32,9 @@
 typecode = ConceptCode.type('Nucleus')
 categorycode = ConceptCode.category('Tissue')
 base_uids = BaseUids(
-    study_instance=pydicom.uid.generate_uid(),
-    series_instance=pydicom.uid.generate_uid(),
-    frame_of_reference=pydicom.uid.generate_uid()
+    study_instance=generate_uid(),
+    series_instance=generate_uid(),
+    frame_of_reference=generate_uid()
 )
 
 
@@ -106,12 +107,21 @@ def dicom_round_trip(
     @staticmethod
     def annotation_group_to_qupath(
         group: AnnotationGroup
-    ) -> Tuple[str, List[Union[float, List[float]]]]:
-        coordinates: List[Union[float, List[float]]]
+    ) -> Tuple[str, Union[
+            List[float],
+            List[List[float]],
+            List[List[List[float]]]
+    ]]:
+        coordinates: Union[
+            List[float],
+            List[List[float]],
+            List[List[List[float]]]
+        ]
         if(isinstance(group, PointAnnotationGroup)):
             if len(group) == 1:
                 geometry_type = 'Point'
-                coordinates = group[0].geometry.to_list_coords()[0]
+                annotation = group.annotations[0]
+                coordinates = annotation.geometry.to_list_coords()[0]
             else:
                 geometry_type = 'MultiPoint'
                 coordinates = []
@@ -119,10 +129,12 @@ def annotation_group_to_qupath(
                     coordinates += annotation.geometry.to_list_coords()
         elif(isinstance(group, PolylineAnnotationGroup)):
             geometry_type = 'LineString'
-            coordinates = group[0].geometry.to_list_coords()
+            annotation = group.annotations[0]
+            coordinates = annotation.geometry.to_list_coords()
         elif(isinstance(group, PolygonAnnotationGroup)):
             geometry_type = 'Polygon'
-            coordinates = [group[0].geometry.to_list_coords()]
+            annotation = group.annotations[0]
+            coordinates = [annotation.geometry.to_list_coords()]
         else:
             raise NotImplementedError
         return (geometry_type, coordinates)
@@ -173,24 +185,18 @@ def test_qupath_geojson_advanced(self):
                 print(file_path)
                 input_dict: Dict = json.load(f)
                 # Group annotations by type and type using key
-                Key = make_dataclass(
-                    'Key',
-                    [
-                        ('annotation_type', type),
-                        ('typecode', str)
-                    ],
-                    unsafe_hash=True
-                )
 
-                # Each group has a label, color and list of geometries
-                Value = make_dataclass(
-                    'Value',
-                    [
-                        ('label', str),
-                        ('color', LabColor),
-                        ('geometrires', List[Geometry])
-                    ]
-                )
+                @dataclass(unsafe_hash=True)
+                class Key:
+                    annotation_type: type
+                    typecode: ConceptCode
+
+                @dataclass(unsafe_hash=True)
+                class Value:
+                    label: str
+                    color: LabColor
+                    geometries: List[Geometry]
+
                 grouped_annotations: Dict[Key, Value] = {}
 
                 # For each annotation, make a type-category key and insert the
@@ -209,23 +215,28 @@ def test_qupath_geojson_advanced(self):
                     try:
                         group = grouped_annotations[group_key]
                     except KeyError:
-                        group = {
-                            'label': self.qupath_get_label(input_annotation),
-                            'geometries': []
-                        }
-                    group['geometries'] += geometries
+                        group = Value(
+                            label=self.qupath_get_label(input_annotation),
+                            color=LabColor(0, 0, 0),
+                            geometries=[]
+                        )
+                        grouped_annotations[group_key] = group
+                    group.geometries += geometries
+
+                self.assertNotEqual(grouped_annotations, {})
 
                 # For each group of annotations (same type and category) make
                 # an annotation group
                 annotation_groups: List[AnnotationGroup] = []
                 for group_keys, group_values in grouped_annotations.items():
                     annotation_group = AnnotationGroup.from_geometries(
-                        geometries=group_values['geometries'],
-                        label=group_values['label'],
+                        geometries=group_values.geometries,
+                        label=group_values.label,
                         categorycode=categorycode,
-                        typecode=group_keys.type
+                        typecode=group_keys.typecode
                     )
                     annotation_groups.append(annotation_group)
+                self.assertNotEqual(annotation_groups, [])
 
                 # Make a group collection and do dicom round-trip
                 dicom = AnnotationInstance(
@@ -235,18 +246,24 @@ def test_qupath_geojson_advanced(self):
                 )
                 dicom = self.dicom_round_trip(dicom)
 
+                self.assertNotEqual(dicom.groups, [])
                 # For each annotation group, produce a type-categorycode key.
                 # Get the original group using the key and check that the
                 # annotations are the same.
-                for output_group in dicom:
+                for output_group in dicom.groups:
+                    self.assertNotEqual(output_group.annotations, [])
                     key = Key(
-                        annotation_typecode=typecode(output_group[0].geometry),
-                        type=output_group.specifc_categorycode
+                        annotation_type=output_group.geometry_type,
+                        typecode=output_group.typecode
                     )
                     input_group = grouped_annotations[key]
-                    for i, output_annotation in enumerate(output_group):
-                        input_annotation = input_group['annotations'][i]
-                        self.assertEqual(output_annotation, input_annotation)
+                    output = enumerate(output_group.annotations)
+                    for i, output_annotation in output:
+                        input_annotation = input_group.geometries[i]
+                        self.assertEqual(
+                            output_annotation.geometry,
+                            input_annotation
+                        )
 
     @staticmethod
     def asap_annotation_group_to_geometries(
@@ -284,7 +301,8 @@ def asap_to_geometries(cls, dictionary: Dict[str, Any]) -> List[Geometry]:
         if(annotation_type == 'Dot'):
             return [Point.from_dict(coordinate_dict, "@X", "@Y")]
         elif(annotation_type == 'PointSet'):
-            return Point.multiple_from_dict(coordinate_dict, "@X", "@Y")
+            points = Point.multiple_from_dict(coordinate_dict, "@X", "@Y")
+            return points  # type: ignore
         elif(annotation_type == 'Polygon'):
             return [Polygon.from_dict(coordinate_dict, "@X", "@Y")]
 
@@ -353,7 +371,9 @@ def test_sectra(self):
         for file_path in files:
             with open(file_path) as f:
                 print(file_path)
-                input_dict: Dict[str, any] = json.load(f)
+                input_dict: Union[Dict[str, Any], List[Dict[str, Any]]] = (
+                    json.load(f)
+                )
                 if(isinstance(input_dict, list)):
                     input_dict = input_dict[0]
                 group = AnnotationGroup.from_geometries(
@@ -365,7 +385,7 @@ def test_sectra(self):
                 dicom = AnnotationInstance([group], 'volume', base_uids)
                 dicom = self.dicom_round_trip(dicom)
                 output_group = dicom[0]
-                output_annotation = output_group[0]
+                output_annotation = output_group.annotations[0]
                 geometry_type, result_coordinates = (
                     self.annotation_to_sectra(output_annotation)
                 )
@@ -398,7 +418,7 @@ def test_cytomine(self):
                 dicom = AnnotationInstance([group], 'volume', base_uids)
                 dicom = self.dicom_round_trip(dicom)
                 output_group = dicom[0]
-                output_annotation = output_group[0]
+                output_annotation = output_group.annotations[0]
                 output_geometry = self.annotation_to_shapely(output_annotation)
                 output_dict = deepcopy(input_dict)
                 output_dict["annotation"]["location"] = output_geometry.wkt
@@ -444,7 +464,7 @@ def test_shapely(self):
             dicom = AnnotationInstance([group], 'volume', base_uids)
             dicom = self.dicom_round_trip(dicom)
             output_group = dicom[0]
-            output_annotation = output_group[0]
+            output_annotation = output_group.annotations[0]
             output_geometry = self.annotation_to_shapely(output_annotation)
             self.assertEqual(input_geometry, output_geometry)
 
@@ -533,7 +553,7 @@ def test_float_32_to_32(self):
         output_group = dicom[0]
 
         if(isinstance(output_group, AnnotationGroup)):
-            output_point = output_group[0].geometry.to_coords()[0]
+            output_point = output_group.annotations[0].geometry.to_coords()[0]
             np_output = np.array(output_point, dtype=np.float32)
             self.assertEqual(np_input.all(), np_output.all())
 
@@ -553,7 +573,7 @@ def test_float_32_to_64(self):
         output_group = dicom[0]
 
         if(isinstance(output_group, AnnotationGroup)):
-            output_point = output_group[0].geometry.to_coords()[0]
+            output_point = output_group.annotations[0].geometry.to_coords()[0]
             np_output = np.array(output_point, dtype=np.float32)
             self.assertEqual(np_input.all(), np_output.all())
 
@@ -574,7 +594,7 @@ def test_float_64_to_64(self):
         output_group = dicom[0]
 
         if(isinstance(output_group, AnnotationGroup)):
-            output_point = output_group[0].geometry.to_coords()[0]
+            output_point = output_group.annotations[0].geometry.to_coords()[0]
             np_output = np.array(output_point, dtype=np.float64)
             self.assertEqual(np_input.all(), np_output.all())
 
@@ -596,7 +616,7 @@ def test_float_64_to_32(self):
         output_group = dicom[0]
 
         if(isinstance(output_group, AnnotationGroup)):
-            output_point = output_group[0].geometry.to_coords()[0]
+            output_point = output_group.annotations[0].geometry.to_coords()[0]
             np_output = np.array(output_point, dtype=np.float64)
             self.assertEqual(np_input.all(), np_output.all())
 
@@ -680,7 +700,7 @@ def test_measurement_cycle(self):
         output_group = dicom[0]
 
         if(isinstance(output_group, PointAnnotationGroup)):
-            annotation0 = output_group[0]
+            annotation0 = output_group.annotations[0]
 
             self.assertEqual(
                 measurement0,
@@ -713,7 +733,7 @@ def test_large_number_of_annotations(self):
             for i in range(count):
                 self.assertEqual(
                     Point(float(i), float(i)),
-                    output_group[i].geometry
+                    output_group.annotations[i].geometry
                 )
 
     def test_make_annotated_wsi_slide(self):
diff --git a/test/test_dcmfiles.py b/test/test_dcmfiles.py
index f8953e3e..fdf8e6d8 100644
--- a/test/test_dcmfiles.py
+++ b/test/test_dcmfiles.py
@@ -4,11 +4,11 @@
 import os
 import unittest
 from pathlib import Path
-from typing import Sequence, Tuple, TypedDict, Dict
+from typing import TypedDict, Dict
 
 import pytest
 from PIL import Image, ImageChops
-from wsidicom.interface import WsiDicom
+from wsidicom import WsiDicom
 
 wsidicom_test_data_dir = os.environ.get("WSIDICOM_TESTDIR", "C:/temp/wsidicom")
 sub_data_dir = "interface"
diff --git a/test/test_interface.py b/test/test_interface.py
index 8dd0dbf5..87e308a7 100644
--- a/test/test_interface.py
+++ b/test/test_interface.py
@@ -1,17 +1,18 @@
 import unittest
 from pathlib import Path
 from tempfile import TemporaryDirectory
+from PIL.Image import Image
 
 import numpy as np
-import pydicom
 import pytest
 from pydicom.dataset import Dataset
 from pydicom.sequence import Sequence as DicomSequence
 from wsidicom.conceptcode import (CidConceptCode, IlluminationCode,
                                   IlluminationColorCode)
 from wsidicom.errors import WsiDicomNotFoundError
-from wsidicom.interface import (DicomImageData, Point, PointMm, Region,
-                                RegionMm, Size, SizeMm, WsiDicom)
+from wsidicom.geometry import Point, PointMm, Region, RegionMm, Size, SizeMm
+from wsidicom import WsiDicom
+from wsidicom.instance import WsiDicomImageData
 from wsidicom.optical import Illumination, Lut, OpticalManager, OpticalPath
 
 from .data_gen import create_layer_file, create_main_dataset
@@ -71,14 +72,15 @@ def test_calculate_scale(self):
     def test_get_frame_number(self):
         base_level = self.slide.levels.get_level(0)
         instance = base_level.get_instance()
-        image_data: DicomImageData = instance._image_data
+        image_data = instance._image_data
+        assert(isinstance(image_data, WsiDicomImageData))
         number = image_data.tiles.get_frame_index(Point(0, 0), 0, '0')
         self.assertEqual(number, 0)
 
     def test_get_blank_color(self):
         base_level = self.slide.levels.get_level(0)
         instance = base_level.get_instance()
-        image_data: DicomImageData = instance.image_data
+        image_data = instance.image_data
         color = image_data._get_blank_color(
             image_data.photometric_interpretation)
         self.assertEqual(color, (255, 255, 255))
@@ -86,7 +88,8 @@ def test_get_blank_color(self):
     def test_get_frame_file(self):
         base_level = self.slide.levels.get_level(0)
         instance = base_level.get_instance()
-        image_data: DicomImageData = instance._image_data
+        image_data = instance._image_data
+        assert(isinstance(image_data, WsiDicomImageData))
         file = image_data._get_file(0)
         self.assertEqual(file, (image_data._files[0]))
 
@@ -99,7 +102,7 @@ def test_get_frame_file(self):
     def test_valid_tiles(self):
         base_level = self.slide.levels.get_level(0)
         instance = base_level.get_instance()
-        image_data: DicomImageData = instance._image_data
+        image_data = instance._image_data
         test = image_data.valid_tiles(
 
             Region(Point(0, 0), Size(0, 0)), 0, '0'
@@ -128,7 +131,10 @@ def test_crop_tile(self):
             position=Point(x=0, y=0),
             size=Size(width=100, height=100)
         )
-        cropped_region = instance.crop_tile(Point(x=0, y=0), region)
+        cropped_region = region.inside_crop(
+            Point(0, 0),
+            instance.tile_size
+        )
         expected = Region(
             position=Point(0, 0),
             size=Size(100, 100)
@@ -139,7 +145,10 @@ def test_crop_tile(self):
             position=Point(x=0, y=0),
             size=Size(width=1500, height=1500)
         )
-        cropped_region = instance.crop_tile(Point(x=0, y=0), region)
+        cropped_region = region.inside_crop(
+            Point(0, 0),
+            instance.tile_size
+        )
         expected = Region(
             position=Point(0, 0),
             size=Size(1024, 1024)
@@ -150,7 +159,10 @@ def test_crop_tile(self):
             position=Point(x=1200, y=1200),
             size=Size(width=300, height=300)
         )
-        cropped_region = instance.crop_tile(Point(x=1, y=1), region)
+        cropped_region = region.inside_crop(
+            Point(1, 1),
+            instance.tile_size
+        )
         expected = Region(
             position=Point(176, 176),
             size=Size(300, 300)
@@ -164,7 +176,7 @@ def test_get_tiles(self):
             position=Point(0, 0),
             size=Size(100, 100)
         )
-        get_tiles = instance.get_tile_range(region, 0, '0')
+        get_tiles = instance.image_data.get_tile_range(region, 0, '0')
         expected = Region(Point(0, 0), Size(0, 0))
         self.assertEqual(get_tiles, expected)
 
@@ -172,7 +184,7 @@ def test_get_tiles(self):
             position=Point(0, 0),
             size=Size(1024, 1024)
         )
-        get_tiles = instance.get_tile_range(region, 0, '0')
+        get_tiles = instance.image_data.get_tile_range(region, 0, '0')
         expected = Region(Point(0, 0), Size(0, 0))
         self.assertEqual(get_tiles, expected)
 
@@ -180,31 +192,30 @@ def test_get_tiles(self):
             position=Point(300, 400),
             size=Size(500, 500)
         )
-        get_tiles = instance.get_tile_range(region, 0, '0')
+        get_tiles = instance.image_data.get_tile_range(region, 0, '0')
         expected = Region(Point(0, 0), Size(0, 0))
         self.assertEqual(get_tiles, expected)
 
     def test_crop_region_to_level_size(self):
         base_level = self.slide.levels.get_level(0)
-        instance = base_level.get_instance()
         image_size = base_level.size
         region = Region(
             position=Point(0, 0),
             size=Size(100, 100)
         )
-        cropped_region = instance.crop_to_level_size(region)
+        cropped_region = region.crop(image_size)
         self.assertEqual(region.size, cropped_region.size)
         region = Region(
             position=Point(0, 0),
             size=Size(2000, 2000)
         )
-        cropped_region = instance.crop_to_level_size(region)
+        cropped_region = region.crop(image_size)
         self.assertEqual(image_size - region.position, cropped_region.size)
         region = Region(
             position=Point(200, 300),
             size=Size(100, 100)
         )
-        cropped_region = instance.crop_to_level_size(region)
+        cropped_region = region.crop(image_size)
         self.assertEqual(Size(0, 0), cropped_region.size)
 
     def test_size_class(self):
@@ -265,8 +276,8 @@ def test_write_indexer(self):
             position=Point(0, 0),
             size=Size(2048, 2048)
         )
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -274,8 +285,8 @@ def test_write_indexer(self):
         self.assertEqual(write_index, Point(1024, 0))
 
         tile = Point(1, 0)
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -283,8 +294,8 @@ def test_write_indexer(self):
         self.assertEqual(write_index, Point(0, 1024))
 
         tile = Point(0, 1)
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -297,8 +308,8 @@ def test_write_indexer(self):
             position=Point(512, 512),
             size=Size(1024, 1024)
         )
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -306,8 +317,8 @@ def test_write_indexer(self):
         self.assertEqual(write_index, Point(512, 0))
 
         tile = Point(1, 0)
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -315,8 +326,8 @@ def test_write_indexer(self):
         self.assertEqual(write_index, Point(0, 512))
 
         tile = Point(0, 1)
-        tile_crop = instance.crop_tile(tile, region)
-        write_index = instance._write_indexer(
+        tile_crop = region.inside_crop(tile, instance.tile_size)
+        write_index = instance.image_data._write_indexer(
             write_index,
             tile_crop.size,
             region.size,
@@ -337,7 +348,7 @@ def test_get_instance(self):
         self.assertEqual(instance, wsi_level.default_instance)
 
     def test_parse_lut(self):
-        ds = pydicom.dataset.Dataset()
+        ds = Dataset()
         ds.RedPaletteColorLookupTableDescriptor = [256, 0, 8]
         ds.SegmentedRedPaletteColorLookupTableData = (
             b'\x00\x00\x01\x00\x00\x00\x01\x00\xff\x00\x00\x00'
@@ -355,7 +366,7 @@ def test_parse_lut(self):
         print(test)
         self.assertTrue(np.array_equal(lut.get(), test))
 
-        ds = pydicom.dataset.Dataset()
+        ds = Dataset()
         ds.RedPaletteColorLookupTableDescriptor = [256, 0, 16]
         ds.SegmentedRedPaletteColorLookupTableData = (
             b'\x01\x00\x00\x01\xff\xff'