Repository: eyecan-ai/pipelime (language: python)

Issue:
Feature/porting cwl tools daniele



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 48a4e18..583ec58 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,7 +1,13 @@
 [report]
 exclude_lines =
+    pass
+    __str__
+    __repr__
     # Have to re-enable the standard pragma
     pragma: no cover
 
     # Don't complain if tests don't hit defensive assertion code:
-    raise NotImplementedError
\ No newline at end of file
+    raise NotImplementedError
+
+omit =
+    */cli/*
\ No newline at end of file
diff --git a/.github/python-package.yml b/.github/workflows/main.yml
similarity index 82%
rename from .github/python-package.yml
rename to .github/workflows/main.yml
index bd67ed6..77f0ada 100644
--- a/.github/python-package.yml
+++ b/.github/workflows/main.yml
@@ -1,13 +1,11 @@
 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
 
-name: Python package
+name: Python package develop
 
 on:
   push:
-    branches: [ main, develop ]
-  pull_request:
-    branches: [ main, develop ]
+    branches: [ develop ]
 
 jobs:
   build:
@@ -15,10 +13,13 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.7, 3.8, 3.9]
+        #python-version: [3.7, 3.8, 3.9]
+        python-version: [3.8]
 
     steps:
     - uses: actions/checkout@v2
+    - name: Install graphviz
+      run: sudo apt-get install graphviz libgraphviz-dev pkg-config
     - name: Set up Python ${{ matrix.python-version }}
       uses: actions/setup-python@v2
       with:
@@ -36,4 +37,4 @@ jobs:
         flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
     - name: Test with pytest
       run: |
-        pytest
+        pytest
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..2f48af7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,58 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: PyPI Uploade
+
+on:
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        #python-version: [3.7, 3.8, 3.9]
+        python-version: [3.8]
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Install graphviz
+      run: sudo apt-get install graphviz libgraphviz-dev pkg-config
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Test with pytest
+      run: |
+        pytest
+    - name: Install pypa/build
+      run: >-
+        python -m
+        pip install
+        build
+        --user
+    - name: Build a binary wheel and a source tarball
+      run: >-
+        python -m
+        build
+        --sdist
+        --wheel
+        --outdir dist/
+        .
+    - name: Publish distribution 📦 to PyPI
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        password: ${{ secrets.PYPI_PASSWORD }}
diff --git a/examples/data/underfolder/example_dataset/data/000_meta.yml b/examples/data/underfolder/example_dataset/data/000_meta.yml
new file mode 100644
index 0000000..19d5db3
--- /dev/null
+++ b/examples/data/underfolder/example_dataset/data/000_meta.yml
@@ -0,0 +1,1 @@
+number: 0
\ No newline at end of file
diff --git a/examples/data/underfolder/example_dataset/data/000_value.txt b/examples/data/underfolder/example_dataset/data/000_value.txt
new file mode 100644
index 0000000..171538e
--- /dev/null
+++ b/examples/data/underfolder/example_dataset/data/000_value.txt
@@ -0,0 +1,1 @@
+0.0
\ No newline at end of file
diff --git a/examples/data/underfolder/example_dataset/data/001_meta.yml b/examples/data/underfolder/example_dataset/data/001_meta.yml
new file mode 100644
index 0000000..0de20f7
--- /dev/null
+++ b/examples/data/underfolder/example_dataset/data/001_meta.yml
@@ -0,0 +1,1 @@
+number: 1
\ No newline at end of file
diff --git a/examples/data/underfolder/example_dataset/data/001_value.txt b/examples/data/underfolder/example_dataset/data/001_value.txt
new file mode 100644
index 0000000..9f8e9b6
--- /dev/null
+++ b/examples/data/underfolder/example_dataset/data/001_value.txt
@@ -0,0 +1,1 @@
+1.0
\ No newline at end of file
diff --git a/examples/data/underfolder/example_dataset/metadata.yml b/examples/data/underfolder/example_dataset/metadata.yml
new file mode 100644
index 0000000..f9c5255
--- /dev/null
+++ b/examples/data/underfolder/example_dataset/metadata.yml
@@ -0,0 +1,1 @@
+sample_dataset: True
\ No newline at end of file
diff --git a/examples/garbage/_schema.schema b/examples/garbage/_schema.schema
new file mode 100644
index 0000000..a114c07
--- /dev/null
+++ b/examples/garbage/_schema.schema
@@ -0,0 +1,20 @@
+from typing import Any
+from schema import And, Optional
+import numpy as np
+
+# schema = {
+#     'image': And(
+#         np.ndarray,
+#         lambda x: len(x.shape) >= 3
+#     ),
+#     'metadata': {
+#         any: object,
+#         'counter': int
+#     },
+#     any: object
+# }
+schema = {
+    'image': object,
+    'metadata': object,
+    Optional(str): Optional(object)
+}
diff --git a/examples/albumentations_tst.py b/examples/garbage/albumentations_tst.py
similarity index 100%
rename from examples/albumentations_tst.py
rename to examples/garbage/albumentations_tst.py
diff --git a/examples/example_underfolder.py b/examples/garbage/example_underfolder.py
similarity index 100%
rename from examples/example_underfolder.py
rename to examples/garbage/example_underfolder.py
diff --git a/examples/gkit_example.py b/examples/garbage/gkit_example.py
similarity index 100%
rename from examples/gkit_example.py
rename to examples/garbage/gkit_example.py
diff --git a/examples/groupby_example.py b/examples/garbage/groupby_example.py
similarity index 100%
rename from examples/groupby_example.py
rename to examples/garbage/groupby_example.py
diff --git a/examples/garbage/json_schema_example.py b/examples/garbage/json_schema_example.py
new file mode 100644
index 0000000..697c56c
--- /dev/null
+++ b/examples/garbage/json_schema_example.py
@@ -0,0 +1,15 @@
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.samples import Sample
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+
+s = SchemaLoader().load(filename='_schema.schema')
+
+folder = '/home/daniele/Desktop/experiments/2021-01-28.PlaygroundDatasets/lego_00'
+reader = UnderfolderReader(folder=folder)
+
+for sample in reader:
+    sample: Sample
+    print(sample.id)
+
+    sample.validate(s, deep=False)
diff --git a/examples/garbage/main.py b/examples/garbage/main.py
new file mode 100644
index 0000000..c800538
--- /dev/null
+++ b/examples/garbage/main.py
@@ -0,0 +1,13 @@
+
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+
+folder = '/tmp/gino'
+r = UnderfolderReader(folder=folder)
+
+print(len(r))
+
+for sample in r:
+    for k in sample:
+        if k == 'metadata':
+            print(sample[k])
diff --git a/examples/pipe_in_python.py b/examples/garbage/pipe_in_python.py
similarity index 100%
rename from examples/pipe_in_python.py
rename to examples/garbage/pipe_in_python.py
diff --git a/examples/pipe_tst_with_graph.py b/examples/garbage/pipe_tst_with_graph.py
similarity index 100%
rename from examples/pipe_tst_with_graph.py
rename to examples/garbage/pipe_tst_with_graph.py
diff --git a/examples/garbage/pipes.py b/examples/garbage/pipes.py
new file mode 100644
index 0000000..7575f65
--- /dev/null
+++ b/examples/garbage/pipes.py
@@ -0,0 +1,151 @@
+
+
+from pipelime.factories import BeanFactory
+from pipelime.sequences.pipes import NodeGraph
+
+graph = {
+    '__type__': 'NodeGraph',
+    'options': {
+        'nodes': [
+            {
+                '__type__': 'ReaderNode',
+                'options': {
+                    'output_data': 'A',
+                    'reader': {
+                        '__type__': 'UnderfolderReader',
+                        'options': {
+                            'folder': '/Users/daniele/Downloads/lego_dataset/lego_00',
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'ReaderNode',
+                'options': {
+                    'output_data': 'B',
+                    'reader': {
+                        '__type__': 'UnderfolderReader',
+                        'options': {
+                            'folder': '/Users/daniele/Downloads/lego_dataset/lego_01'
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'OperationNode',
+                'options': {
+                    'input_data': 'A',
+                    'output_data': 'A1',
+                    'operation': {
+                        '__type__': 'OperationFilterByQuery',
+                        'options': {
+                            'query': '`metadata.tag` == "black"'
+                        }
+                    }
+                }
+            },
+
+            {
+                '__type__': 'OperationNode',
+                'options': {
+                    'input_data': ['A1', 'B'],
+                    'output_data': 'C',
+                    'operation': {
+                        '__type__': 'OperationSum',
+                        'options': {}
+                    }
+                }
+            },
+            {
+                '__type__': 'OperationNode',
+                'options': {
+                    'input_data': 'C',
+                    'output_data': 'D',
+                    'operation': {
+                        '__type__': 'OperationFilterByQuery',
+                        'options': {
+                            'query': '`metadata.tag` == "black"'
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'OperationNode',
+                'options': {
+                    'input_data': 'D',
+                    'output_data': 'D1',
+                    'operation': {
+                        '__type__': 'OperationSubsample',
+                        'options': {
+                            'factor': 0.1
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'OperationNode',
+                'options': {
+                    'input_data': 'D1',
+                    'output_data': {
+                        'a': 'train',
+                        'b': 'test',
+                        'c': 'val'
+                    },
+                    'operation': {
+                        '__type__': 'OperationSplits',
+                        'options': {
+                            'split_map': {
+                                'a': 0.45,
+                                'b': 0.45,
+                                'c': 0.1
+                            }
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'WriterNode',
+                'options': {
+                    'input_data': 'train',
+                    'writer': {
+                        '__type__': 'UnderfolderWriter',
+                        'options': {
+                            'folder': '/tmp/zizzino/train'
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'WriterNode',
+                'options': {
+                    'input_data': 'test',
+                    'writer': {
+                        '__type__': 'UnderfolderWriter',
+                        'options': {
+                            'folder': '/tmp/zizzino/test'
+                        }
+                    }
+                }
+            },
+            {
+                '__type__': 'WriterNode',
+                'options': {
+                    'input_data': 'val',
+                    'writer': {
+                        '__type__': 'UnderfolderWriter',
+                        'options': {
+                            'folder': '/tmp/zizzino/val'
+                        }
+                    }
+                }
+            },
+        ]
+    }
+}
+
+
+graph: NodeGraph = BeanFactory.create(graph)
+
+graph.execute()
+# f, a = graph.draw_to_file()
+# print(f, a)
diff --git a/examples/garbage/writer.py b/examples/garbage/writer.py
new file mode 100644
index 0000000..1fa0442
--- /dev/null
+++ b/examples/garbage/writer.py
@@ -0,0 +1,45 @@
+import re
+from pipelime.factories import Bean, BeanFactory
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from pipelime.sequences.operations import OperationFilterByQuery, OperationSubsample
+
+folder = '/Users/daniele/Downloads/lego_dataset/lego_00'
+d = UnderfolderReader(folder=folder, copy_root_files=True)
+print(len(d))
+
+
+op = OperationFilterByQuery(query='`metadata.tag` == "black"')
+op2 = OperationSubsample(factor=0.1)
+d = op2(op(d))
+print(len(d))
+
+extensions_map = {
+    '.*image.*': 'jpg',
+    '.*mask.*': 'png',
+    '.*pose.*|.*keypoint.*': 'txt',
+    '.*metadata.*|.*camera.*|.*charuco.*': 'yml',
+}
+
+w = UnderfolderWriter(
+    folder='/tmp/gino',
+    root_files_keys=['.*camera.*|.*charuco.*|.*keypoints.*'],
+    extensions_map=None
+)
+w(d)
+
+# print(re.match('.*image.*', '_image0'))
+# writer = UnderfolderWriter(
+#     folder='/tmp/gino',
+#     root_files_keys=['camera', 'charuco', 'keypoints'],
+#     extensions_map={
+#     }
+# )
+# print(len(d))
+
+
+# writer(d)
+
+# for sample in d:
+#     print(list(sample.keys()))
+#     break
diff --git a/examples/main.py b/examples/main.py
deleted file mode 100644
index b76ab14..0000000
--- a/examples/main.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from numpy.lib.utils import source
-from pipelime.lib import FileSystemSample, PlainSample, Sample, SamplesSequence
-import sys
-from memory_profiler import profile
-from pathlib import Path
-
-# @profile
-
-
-def my_func():
-
-    dataset_folder = Path('/home/daniele/Desktop/experiments/2021-01-28.PlaygroundDatasets/lego_00')
-    fmap = {
-        'image': dataset_folder / 'data/00000_image.jpg',
-        'xray': dataset_folder / 'data/00000_xray.png',
-        'metadata': dataset_folder / 'data/00000_metadata.yml',
-        'pose': dataset_folder / 'data/00000_pose.txt'
-    }
-
-    # s = FileSystemSample(data_map=fmap, preload=True)
-    # s = PlainSample(data={'a': 2})
-    #samples = [PlainSample(data={'idx': idx, 'v': str(idx)}) for idx in range(25)]
-    samples = [FileSystemSample(data_map=fmap) for idx in range(250)]
-    d = SamplesSequence(samples=samples)
-    d = d + d
-    d += d
-    d = d % 4
-    d = d % "`metadata.counter` == 0"
-    d = d % 0.5
-    d = d.shuffle()
-
-    d_a, d_b = d.splits(percentages=(0.51, 0.49))
-    d_a, d_b = d / (0.51, 0.49)
-
-    d_out = d / {'train': 0.9, 'val': 0.1}
-    for k, v in d_out.items():
-        print(k, len(v))
-
-    # for idx in range(len(d)):
-    #     d[idx]['gino'] = 2.22
-
-    # for s in d:
-    #     s: Sample
-    #     print(list(s.keys()))
-
-    print(len(d), len(d_a), len(d_b))
-
-
-if __name__ == '__main__':
-    my_func()
diff --git a/examples/pipe_tst.py b/examples/pipe_tst.py
deleted file mode 100644
index 6886fef..0000000
--- a/examples/pipe_tst.py
+++ /dev/null
@@ -1,188 +0,0 @@
-
-
-import rich
-from pipelime.lib import AddOp, PlainSample, SamplesSequence, SequenceOpFactory
-from choixe.configurations import XConfig
-
-pipe = {
-    'input': {
-        'GOOD': None,
-        'BAD': None
-    },
-    'modules': [
-        {
-            'input': 'BAD',
-            'output': {
-                'a': 'BAD_0',
-                'b': 'BAD_1'
-            },
-            'op': {
-                'type': 'SplitsOp',
-                'options': {
-                    'split_map': {
-                        'a': 0.5,
-                        'b': 0.5
-                    }
-                }
-            }
-        },
-        {
-            'input': 'GOOD',
-            'output': {
-                'a': 'GOOD_0',
-                'b': 'GOOD_1'
-            },
-            'op': {
-                'type': 'SplitsOp',
-                'options': {
-                    'split_map': {
-                        'a': 0.5,
-                        'b': 0.5
-                    }
-                }
-            }
-        },
-        {
-            'input': ['BAD_0', 'GOOD_0'],
-            'output': 'train',
-            'op': {
-                'type': 'AddOp',
-                'options': {}
-            }
-        },
-        {
-            'input': ['BAD_1', 'GOOD_1'],
-            'output': 'testval',
-            'op': {
-                'type': 'AddOp',
-                'options': {}
-            }
-        },
-        {
-            'input': 'testval',
-            'output': 'testval',
-            'op': {
-                'type': 'ShuffleOp',
-                'options': {
-                    'seed': -1
-                }
-            }
-        },
-        {
-            'input': 'testval',
-            'output': {
-                'a': 'test',
-                'b': 'val'
-            },
-            'op': {
-                'type': 'SplitsOp',
-                'options': {
-                    'split_map': {
-                        'a': 0.5,
-                        'b': 0.5
-                    }
-                }
-            }
-        },
-        # {
-        #     'input': 's',
-        #     'output': 's',
-        #     'op': {
-        #         'type': 'SubsampleOp',
-        #         'options': {
-        #             'factor': 0.5
-        #         }
-        #     }
-        # },
-        # {
-        #     'input': 's',
-        #     'output': {
-        #         'a': 'train',
-        #         'b': 'val',
-        #         'c': 'test',
-        #     },
-        #     'op': {
-        #         'type': 'SplitsOp',
-        #         'options': {
-        #             'split_map': {
-        #                 'a': 0.8,
-        #                 'b': 0.1,
-        #                 'c': 0.1
-        #             }
-        #         }
-        #     }
-        # }
-    ]
-}
-
-cfg = XConfig.from_dict(pipe)
-cfg.save_to('/tmp/cfgs/gino.yml')
-
-pipe = XConfig(filename='/tmp/cfgs/gino.yml')
-rich.print(SequenceOpFactory.FACTORY_MAP)
-
-N = 500
-samples_a = [PlainSample(data={'idx': idx, 'metadata': {'idx': idx, 'name': str(idx), 'label': 0}}) for idx in range(N)]
-samples_b = [PlainSample(data={'idx': idx, 'metadata': {'idx': idx, 'name': str(idx), 'label': 1}}) for idx in range(N)]
-
-dataset_a = SamplesSequence(samples=samples_a)
-dataset_b = SamplesSequence(samples=samples_b)
-
-
-pipe['input']['GOOD'] = dataset_a
-pipe['input']['BAD'] = dataset_b
-
-data_map = {}
-
-
-def check_input(i):
-    if isinstance(i, str):
-        assert i in data_map, f'Input "{i}" not present'
-    elif isinstance(i, list):
-        [check_input(x) for x in i]
-    elif isinstance(i, dict):
-        [check_input(x) for x, _ in i.items()]
-
-
-def parse_input(i):
-    if isinstance(i, str):
-        return data_map[i]
-    elif isinstance(i, list):
-        return [parse_input(x) for x in i]
-    elif isinstance(i, dict):
-        return {k: parse_input(x) for k, x in i.items()}
-
-
-def push_output(o, data):
-    if isinstance(o, str):
-        data_map[o] = data
-    elif isinstance(o, list):
-        for idx, name in enumerate(o):
-            data_map[name] = data[idx]
-    elif isinstance(o, dict):
-        for k, name in o.items():
-            data_map[name] = data[k]
-
-
-for name, d in pipe['input'].items():
-    data_map[name] = d
-
-for m in pipe['modules']:
-    i = m['input']
-    o = m['output']
-    op = SequenceOpFactory.create(m['op'])
-
-    try:
-        check_input(i)
-        i = parse_input(i)
-
-        out = op(i)
-        push_output(o, out)
-    except Exception as e:
-        print(f"Error on module: {m}. {e}")
-        break
-
-for k, v in data_map.items():
-    print(k, len(v))
-    for s in v:
-        print(s)
diff --git a/examples/pipelines/pipeline_example.py b/examples/pipelines/pipeline_example.py
new file mode 100644
index 0000000..2230ff7
--- /dev/null
+++ b/examples/pipelines/pipeline_example.py
@@ -0,0 +1,101 @@
+
+
+import os
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from pipelime.sequences.operations import OperationFilterByQuery, OperationOrderBy, OperationResetIndices, OperationSubsample, OperationSum
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.tools.idgenerators import IdGeneratorInteger, IdGeneratorUUID
+from pipelime.sequences.pipes import NodeGraph, OperationNode, ReaderNode, WriterNode
+
+
+folder_a = '/home/daniele/Desktop/experiments/2021-02-10.TMLightsExperiments/datasets/tap_00'
+folder_b = '/home/daniele/Desktop/experiments/2021-02-10.TMLightsExperiments/datasets/tap_01'
+
+idg = IdGeneratorUUID()
+subsampling = 1.0
+nodes = [
+
+    ReaderNode(
+        id=idg.generate(),
+        output_data='A',
+        reader=UnderfolderReader(
+            folder=folder_a
+        )
+    ),
+
+    ReaderNode(
+        id=idg.generate(),
+        output_data='B',
+        reader=UnderfolderReader(
+            folder=folder_b
+        )
+    ),
+    OperationNode(
+        id=idg.generate(),
+        input_data='A',
+        output_data='A_',
+        operation=OperationSubsample(
+            factor=subsampling
+        )
+    ),
+    OperationNode(
+        id=idg.generate(),
+        input_data='B',
+        output_data='B_',
+        operation=OperationSubsample(
+            factor=subsampling
+        )
+    ),
+    OperationNode(
+        id=idg.generate(),
+        input_data=['A_', 'B_'],
+        output_data='X',
+        operation=OperationSum()
+    ),
+
+    OperationNode(
+        id=idg.generate(),
+        input_data='X',
+        output_data='X_filtered',
+        operation=OperationFilterByQuery(
+            query='`metadata.counter` >= 0'
+        )
+    ),
+    OperationNode(
+        id=idg.generate(),
+        input_data='X_filtered',
+        output_data='X_ordered',
+        operation=OperationOrderBy(
+            order_keys=['counter']
+        )
+    ),
+    OperationNode(
+        id=idg.generate(),
+        input_data='X_ordered',
+        output_data='X_reset',
+        operation=OperationResetIndices(
+            generator=IdGeneratorInteger()
+        )
+    ),
+    WriterNode(
+        id=idg.generate(),
+        input_data='X_reset',
+        writer=UnderfolderWriter(
+            folder='/tmp/zzizzino',
+            root_files_keys=['camera', 'charuco'],
+            extensions_map={
+                '.*image.*': 'jpg',
+                '.*pose.*': 'txt',
+                '.*camera.*|.*charuco.*|.*metadata.*': 'yml',
+            }
+        )
+    )
+]
+
+graph = NodeGraph(nodes=nodes)
+
+
+graph.draw_to_file('/tmp/graph.png')
+os.system('eog /tmp/graph.png')
+
+# graph.execute()
diff --git a/examples/samples/validation/deep_schema.schema b/examples/samples/validation/deep_schema.schema
new file mode 100644
index 0000000..98be1f5
--- /dev/null
+++ b/examples/samples/validation/deep_schema.schema
@@ -0,0 +1,9 @@
+from schema import And
+import numpy as np
+
+deep = True
+schema = {
+    'counter': int,
+    'name': str,
+    'data': And(np.ndarray, lambda x: len(x.shape) == 3)
+}
diff --git a/examples/samples/validation/simple_schema.schema b/examples/samples/validation/simple_schema.schema
new file mode 100644
index 0000000..406892f
--- /dev/null
+++ b/examples/samples/validation/simple_schema.schema
@@ -0,0 +1,8 @@
+from schema import Optional
+
+deep = False
+schema = {
+    'counter': object,
+    'name': object,
+    Optional(str): object
+}
diff --git a/examples/samples/validation/validate_samples.py b/examples/samples/validation/validate_samples.py
new file mode 100644
index 0000000..b0f2f3c
--- /dev/null
+++ b/examples/samples/validation/validate_samples.py
@@ -0,0 +1,31 @@
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.samples import PlainSample, SamplesSequence
+import numpy as np
+import rich
+
+# Creates samples sequence
+N = 25
+samples = []
+for i in range(N):
+    samples.append(
+        PlainSample({
+            'counter': i,
+            'name': f'sample_{str(i).zfill(10)}',
+            'data': np.random.uniform(-1, 1, (100, 100, 3))
+        })
+    )
+sequence = SamplesSequence(samples=samples)
+
+# validate sample (should be used to check keys only)
+rich.print("Simple validation:")
+schema = SchemaLoader.load('simple_schema.schema')
+for idx, sample in enumerate(sequence):
+    schema.validate(sample)
+    rich.print("\tValidating", idx, 'OK')
+
+# validate sample in depth
+rich.print("In-depth validation:")
+schema = SchemaLoader.load('deep_schema.schema')
+for idx, sample in enumerate(sequence):
+    schema.validate(sample)
+    rich.print("\tValidating", idx, 'OK')
diff --git a/examples/underfolder/reader_writer_template/README.md b/examples/underfolder/reader_writer_template/README.md
new file mode 100644
index 0000000..66a4ce7
--- /dev/null
+++ b/examples/underfolder/reader_writer_template/README.md
@@ -0,0 +1,7 @@
+# Underfolder Writer based on Underfolder Reader Template
+
+How to use the template of UnderfolderReader to populate writer extensions map and root files
+
+```sh
+python example.py
+```
diff --git a/examples/underfolder/reader_writer_template/example.py b/examples/underfolder/reader_writer_template/example.py
new file mode 100644
index 0000000..57b5b96
--- /dev/null
+++ b/examples/underfolder/reader_writer_template/example.py
@@ -0,0 +1,25 @@
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+import tempfile
+import rich
+
+# ➡️➡️➡️ Reader
+reader = UnderfolderReader(folder='../../data/underfolder/example_dataset', copy_root_files=True)
+reader_template = reader.get_filesystem_template()
+
+# ➡️➡️➡️ Writer with manual template input
+writer_folder = tempfile.mkdtemp()
+writer = UnderfolderWriter(
+    folder=writer_folder,
+    root_files_keys=reader_template.root_files_keys,
+    extensions_map=reader_template.extensions_map,
+    zfill=reader_template.idx_length
+)
+writer(reader)
+rich.print("Writer output to:", writer_folder)
+
+# ➡️➡️➡️ Writer with auto template
+writer_folder = tempfile.mkdtemp()
+writer = UnderfolderWriter(folder=writer_folder)
+writer(reader)
+rich.print("Writer output to:", writer_folder)
diff --git a/examples/underfolder/writer_copy_and_symlinks/README.md b/examples/underfolder/writer_copy_and_symlinks/README.md
new file mode 100644
index 0000000..22350fc
--- /dev/null
+++ b/examples/underfolder/writer_copy_and_symlinks/README.md
@@ -0,0 +1,29 @@
+# UnderfolderWriter: copy samples and symlinks
+
+How to pevent a UnderfloderWriter from Reading/Writing data if samples are intact
+FileSystemSample (not manipulated).
+
+## `copy_files`
+
+```python
+UnderfolderWriter(
+    folder=writer_folder,
+    copy_files=True
+)
+```
+
+sets `copy_files=True` to speedup writer. Intact samples (not manipulated after UnderfolderReader) are
+just copied to destination folder.
+
+## `use_symlinks`
+
+```python
+UnderfolderWriter(
+    folder=writer_folder,
+    copy_files=True,
+    use_symlinks=True
+)
+```
+
+sets `copy_files=True` and `use_symlinks=True` to further speedup writer and prevent memory consumption
+if your writer output is meant to be a transient dataset.
diff --git a/pipelime/__init__.py b/pipelime/__init__.py
index 4042d27..09aaa3d 100644
--- a/pipelime/__init__.py
+++ b/pipelime/__init__.py
@@ -1,9 +1,19 @@
 """Top-level package for pipelime."""
 
-__author__ = """daniele de gregorio"""
+__author__ = "eyecan"
 __email__ = 'daniele.degregorio@eyecan.ai'
-__version__ = '0.0.1'
+__version__ = '0.1.0'
 
 
 # Import transforms in order to register classes for serialization
-from pipelime.augmentations.transforms import *
+from pathlib import Path
+from pipelime.augmentations.transforms import PadIfNeededV2
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from appdirs import AppDirs
+import pipelime
+
+
+def user_data_dir() -> Path:
+    # TODO: esurface and eyecan: move strings as package dunder
+    return Path(AppDirs(appname=pipelime.__name__, appauthor=__author__).user_data_dir)
diff --git a/pipelime/cli/datasets/datasets.py b/pipelime/cli/datasets/datasets.py
new file mode 100644
index 0000000..ee66817
--- /dev/null
+++ b/pipelime/cli/datasets/datasets.py
@@ -0,0 +1,12 @@
+import click
+
+from pipelime.cli.datasets.generate_toy_dataset import generate_toy_dataset
+
+
+
+@click.group()
+def datasets():
+    pass
+
+
+datasets.add_command(generate_toy_dataset)
diff --git a/pipelime/cli/datasets/generate_toy_dataset.py b/pipelime/cli/datasets/generate_toy_dataset.py
index 533422a..344ba18 100644
--- a/pipelime/cli/datasets/generate_toy_dataset.py
+++ b/pipelime/cli/datasets/generate_toy_dataset.py
@@ -1,10 +1,5 @@
 
-import numpy as np
-import cv2
 import click
-from pathlib import Path
-import yaml
-import json
 from pipelime.tools.toydataset import ToyDatasetGenerator
 
 
diff --git a/pipelime/cli/main.py b/pipelime/cli/main.py
new file mode 100644
index 0000000..94bedc4
--- /dev/null
+++ b/pipelime/cli/main.py
@@ -0,0 +1,13 @@
+import click
+
+from pipelime.cli.datasets.datasets import datasets
+from pipelime.cli.workflow.workflow import workflow
+
+
+@click.group()
+def pipelime():
+    pass
+
+
+pipelime.add_command(datasets)
+pipelime.add_command(workflow)
diff --git a/tests/pipelime/sequences/readers/__init__.py b/pipelime/cli/workflow/__init__.py
similarity index 100%
rename from tests/pipelime/sequences/readers/__init__.py
rename to pipelime/cli/workflow/__init__.py
diff --git a/pipelime/cli/workflow/click2cwl.py b/pipelime/cli/workflow/click2cwl.py
new file mode 100644
index 0000000..234e02e
--- /dev/null
+++ b/pipelime/cli/workflow/click2cwl.py
@@ -0,0 +1,62 @@
+import click
+
+
+@click.command("click2cwl", help="Converts a click script to a cwl file")
+@click.option("--script", required=True, type=str, help="Script filename")
+@click.option("--name", required=True, type=str, help="Cwl template output name")
+@click.option("--alias", required=True, type=str, help="Alias command associated to the cwl, e.g.: 'command1 subcommand leaf' with quotes.")
+@click.option("--fchoice/--no-fchoice", default=True, type=bool, help="TRUE to activate forward interactive choice")
+@click.option("-f", multiple=True, type=str, help="Forwards, used only if the interactive choice is disabled")
+@click.option("--folder", default="", type=str, help="Output folder to save the cwl. Leave empty to use default folder")
+def click2cwl(
+    script,
+    name,
+    alias,
+    fchoice,
+    f,
+    folder
+):
+    from pipelime.workflow.cwl import CwlNodesManager, CwlTemplate
+
+    # Create template
+    cwl_template = CwlTemplate(
+        script=script,
+        alias=alias.split(' '),
+        forwards=None
+    )
+
+    if fchoice:
+        import inquirer
+        if len(cwl_template.inputs_keys) > 0:
+
+            # prompt
+            answers = inquirer.prompt([
+                inquirer.Checkbox(
+                    'forwards',
+                    message="Which input forward onto output?",
+                    choices=cwl_template.inputs_keys,
+                ),
+            ])
+            forwards = answers['forwards']
+    else:
+        forwards = list(f)
+
+    # Create template
+    cwl_template = CwlTemplate(
+        script=script,
+        alias=alias.split(' '),
+        forwards=forwards
+    )
+
+    try:
+        CwlNodesManager.create_node(
+            name=name,
+            cwl_template=cwl_template,
+            folder=folder if len(folder) > 0 else None
+        )
+    except Exception as e:
+        print(e)
+
+
+if __name__ == "__main__":
+    click2cwl()
diff --git a/pipelime/cli/workflow/delete_nodes.py b/pipelime/cli/workflow/delete_nodes.py
new file mode 100644
index 0000000..6b0fcef
--- /dev/null
+++ b/pipelime/cli/workflow/delete_nodes.py
@@ -0,0 +1,29 @@
+import click
+
+
+@click.command("delete_nodes", help="Delete nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def delete_nodes(folder):
+
+    from pipelime.workflow.cwl import CwlNodesManager
+    import inquirer
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # prompt
+    answers = inquirer.prompt([
+        inquirer.Checkbox(
+            'nodes',
+            message="Which nodes do you want to delete?",
+            choices=nodes.keys(),
+        ),
+    ])
+
+    # delete nodes
+    for node in answers['nodes']:
+        CwlNodesManager.delete_node(node, folder)
+
+
+if __name__ == "__main__":
+    delete_nodes()
diff --git a/pipelime/cli/workflow/fill_workflow.py b/pipelime/cli/workflow/fill_workflow.py
new file mode 100644
index 0000000..2147087
--- /dev/null
+++ b/pipelime/cli/workflow/fill_workflow.py
@@ -0,0 +1,19 @@
+import click
+
+
+@click.command("fill", help="Fill a workflow with the cwl paths")
+@click.option("--workflow", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--output_filename", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def fill(workflow, output_filename, folder):
+
+    from pipelime.workflow.cwl import CwlNodesManager
+    from pipelime.workflow.cwl import CwlWorkflowTemplate
+
+    workflow_template = CwlWorkflowTemplate.from_file(workflow)
+    workflow_template = CwlNodesManager.fill_workflow(workflow_template)
+    workflow_template.dumps(output_filename)
+
+
+if __name__ == "__main__":
+    fill()
diff --git a/pipelime/cli/workflow/init_workflow.py b/pipelime/cli/workflow/init_workflow.py
new file mode 100644
index 0000000..6563bb5
--- /dev/null
+++ b/pipelime/cli/workflow/init_workflow.py
@@ -0,0 +1,31 @@
+import click
+
+
+@click.command("init", help="Initialize a workflow")
+@click.option("--output_filename", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def init(output_filename, folder):
+
+    import inquirer
+    from pipelime.workflow.cwl import CwlNodesManager
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    choosen_nodes = []
+    available_nodes = [k for k, v in nodes.items() if v.is_valid]
+    available_nodes.append('END WORKFLOW')
+    while True:
+        answer = inquirer.prompt([
+            inquirer.List('nodes', message='Choose a node', choices=available_nodes)
+        ])
+        if answer['nodes'] == 'END WORKFLOW':
+            break
+        choosen_nodes.append(answer['nodes'])
+
+    worlkflow_template = CwlNodesManager.initialize_workflow(choosen_nodes)
+    worlkflow_template.dumps(output_filename)
+
+
+if __name__ == "__main__":
+    init()
diff --git a/pipelime/cli/workflow/list_nodes.py b/pipelime/cli/workflow/list_nodes.py
new file mode 100644
index 0000000..ae4f7c0
--- /dev/null
+++ b/pipelime/cli/workflow/list_nodes.py
@@ -0,0 +1,33 @@
+import click
+
+
+@click.command("list_nodes", help="List available nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def list_nodes(folder):
+
+    import rich
+    from pipelime.workflow.cwl import CwlNodesManager
+    from rich.tree import Tree
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # draw tree
+    t_root = Tree('🧩 [green]Nodes')
+    for name, node in nodes.items():
+        if not node.is_valid:
+            t_node = Tree(f"[red][u]{name} (node NOT valid, can't load the script)[/u][/red]")
+        else:
+            t_node = Tree(f'[u]{name}[/u]')
+        t_node.add(f'[blue]Path:[/blue] {node.cwl_path}')
+        t_template = Tree('[blue]Template[/blue]')
+        t_template.add(f'Script: {node.cwl_template.script}')
+        t_template.add(f'Alias: {node.cwl_template.alias}')
+        t_template.add(f'Forwards: {node.cwl_template.forwards}')
+        t_node.add(t_template)
+        t_root.add(t_node)
+    rich.print(t_root)
+
+
+if __name__ == "__main__":
+    list_nodes()
diff --git a/pipelime/cli/workflow/run_workflow.py b/pipelime/cli/workflow/run_workflow.py
new file mode 100644
index 0000000..04ad4fe
--- /dev/null
+++ b/pipelime/cli/workflow/run_workflow.py
@@ -0,0 +1,40 @@
+import click
+
+
+@click.command("run", help="Runs a cwl workflow")
+@click.option("--cwl_file", required=True, type=str, help="The cwl workflow file")
+@click.option("--yml_file", required=True, type=str, help="The XConfig input file, placeholders will be prompted to the user")
+@click.option("--parallel", required=False, is_flag=True, help="Enable parallel execution")
+@click.option("--debug", required=False, is_flag=True, help="Print even more logging")
+def run(
+    cwl_file,
+    yml_file,
+    parallel,
+    debug
+):
+    import tempfile
+    import subprocess
+    from choixe.configurations import XConfig
+    from choixe.inquirer import XInquirer
+
+    # reads and prompts the XConfig
+    cfg = XConfig(filename=yml_file)
+    cfg = XInquirer.prompt(cfg)
+    compiled_cfg_file = f'{tempfile.NamedTemporaryFile().name}.yml'
+    cfg.save_to(compiled_cfg_file)
+
+    # prepares the cwl-runner
+    cmd = ['cwl-runner']
+    if parallel:
+        cmd.append('--parallel')
+    if debug:
+        cmd.append('--debug')
+    cmd.append(cwl_file)
+    cmd.append(compiled_cfg_file)
+
+    # runs workflow
+    subprocess.run(cmd)
+
+
+if __name__ == "__main__":
+    run()
diff --git a/pipelime/cli/workflow/show_graph.py b/pipelime/cli/workflow/show_graph.py
new file mode 100644
index 0000000..3b15fd8
--- /dev/null
+++ b/pipelime/cli/workflow/show_graph.py
@@ -0,0 +1,27 @@
+import click
+
+
+@click.command("show_graph", help="Show cwl workflow graph with using dot notation")
+@click.option("--cwl_file", required=True, type=str, help="The cwl workflow file")
+def show_graph(cwl_file):
+    import subprocess
+    import tempfile
+    import os
+    from networkx.drawing.nx_agraph import read_dot, to_agraph
+
+    def show_nx_graph(graph):
+        agraph = to_agraph(graph)
+        agraph.layout('dot')
+        agraph_file = f'{tempfile.NamedTemporaryFile().name}.svg'
+        agraph.draw(agraph_file)
+        os.system(f'eog {agraph_file}')
+
+    dot_file = f'{tempfile.NamedTemporaryFile().name}.txt'
+    with open(dot_file, 'w') as f:
+        subprocess.run(['cwl-runner', '--print-dot', cwl_file], stdout=f)
+    graph = read_dot(dot_file)
+    show_nx_graph(graph)
+
+
+if __name__ == "__main__":
+    show_graph()
diff --git a/pipelime/cli/workflow/update_nodes.py b/pipelime/cli/workflow/update_nodes.py
new file mode 100644
index 0000000..39917b4
--- /dev/null
+++ b/pipelime/cli/workflow/update_nodes.py
@@ -0,0 +1,47 @@
+import click
+
+
+@click.command("update_nodes", help="Updates nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def update_nodes(folder):
+
+    import inquirer
+    import rich
+    from pipelime.workflow.cwl import CwlNodesManager
+    from pipelime.workflow.cwl import CwlTemplate
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # prompt
+    answers = inquirer.prompt([
+        inquirer.Checkbox(
+            'nodes',
+            message="Which nodes do you want to update?",
+            choices=nodes.keys(),
+        ),
+    ])
+
+    # update nodes
+    for name, node in nodes.items():
+        if name in answers['nodes']:
+            rich.print(f'[green] Update node "{name}"')
+            available_names = [x for x in nodes.keys() if x != name]
+            update_answers = inquirer.prompt([
+                inquirer.Text('name', message='Update name', default=node.name, validate=lambda _, x: x not in available_names),
+                inquirer.Text('script', message='Update script', default=node.cwl_template.script),
+                inquirer.Text('alias', message='Update alias', default=" ".join(node.cwl_template.alias)),
+                inquirer.Checkbox('forwards', message='Update forwards', choices=node.cwl_template.inputs_keys, default=node.cwl_template.forwards)
+            ])
+
+            CwlNodesManager.delete_node(name, folder=folder)
+            cwl_template = CwlTemplate(
+                script=update_answers['script'],
+                alias=update_answers['alias'].split(' '),
+                forwards=update_answers['forwards']
+            )
+            CwlNodesManager.create_node(update_answers['name'], cwl_template=cwl_template, folder=folder)
+
+
+if __name__ == "__main__":
+    update_nodes()
diff --git a/pipelime/cli/workflow/workflow.py b/pipelime/cli/workflow/workflow.py
new file mode 100644
index 0000000..89b193a
--- /dev/null
+++ b/pipelime/cli/workflow/workflow.py
@@ -0,0 +1,25 @@
+import click
+
+from pipelime.cli.workflow.run_workflow import run
+from pipelime.cli.workflow.init_workflow import init
+from pipelime.cli.workflow.fill_workflow import fill
+from pipelime.cli.workflow.show_graph import show_graph
+from pipelime.cli.workflow.click2cwl import click2cwl
+from pipelime.cli.workflow.list_nodes import list_nodes
+from pipelime.cli.workflow.delete_nodes import delete_nodes
+from pipelime.cli.workflow.update_nodes import update_nodes
+
+
+@click.group()
+def workflow():
+    pass
+
+
+workflow.add_command(run)
+workflow.add_command(init)
+workflow.add_command(fill)
+workflow.add_command(show_graph)
+workflow.add_command(click2cwl)
+workflow.add_command(list_nodes)
+workflow.add_command(delete_nodes)
+workflow.add_command(update_nodes)
diff --git a/pipelime/factories.py b/pipelime/factories.py
new file mode 100644
index 0000000..9784d4b
--- /dev/null
+++ b/pipelime/factories.py
@@ -0,0 +1,100 @@
+
+from functools import wraps
+import typing
+from abc import ABC
+from schema import Schema
+
+
+class Bean(ABC):
+    TYPE_FIELD = '__type__'
+    ARGS_FIELD = 'options'
+
+    @classmethod
+    def bean_name(cls) -> str:
+        return cls.__name__
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {}
+
+    @classmethod
+    def full_bean_schema(cls) -> Schema:
+        return Schema({
+            Bean.TYPE_FIELD: cls.bean_name(),
+            Bean.ARGS_FIELD: cls.bean_schema()
+        })
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return cls(**d)
+
+    def to_dict(self) -> dict:
+        return {}
+
+    def serialize(self, validate: bool = True) -> dict:
+        out = {
+            Bean.TYPE_FIELD: self.bean_name(),
+            Bean.ARGS_FIELD: self.to_dict()
+        }
+        if validate:
+            self.full_bean_schema().validate(out)
+        return out
+
+    @classmethod
+    def hydrate(cls, d: dict, validate: bool = True) -> any:
+        if validate:
+            cls.full_bean_schema().validate(d)
+        return cls.from_dict(d[Bean.ARGS_FIELD])
+
+
+class BeanFactory(object):
+
+    CLASSES_MAP: typing.Dict[str, Bean] = {}
+
+    @classmethod
+    def register_bean(cls, x: Bean):
+        cls.CLASSES_MAP[x.bean_name()] = x
+
+    @classmethod
+    def serialize(cls, x: Bean, validate: bool = True):
+        return x.serialize(validate=validate)
+
+    @classmethod
+    def create(cls, d: dict, validate: bool = True):
+        if d[Bean.TYPE_FIELD] in cls.CLASSES_MAP:
+            bt = cls.CLASSES_MAP[d[Bean.TYPE_FIELD]]
+            return bt.hydrate(d, validate=validate)
+        raise RuntimeError(f'Non serializable data: {d}')
+
+    # @staticmethod
+    # def make_serializable(x: type):
+    #     BeanFactory.register_bean(x)
+    #     return x
+
+    # @staticmethod
+    # def make_serializable(f):
+    #     BeanFactory.register_bean(f)
+
+    #     def _decorator():
+    #         return f()
+    #     _decorator.__doc__ = f.__doc__
+    #     return _decorator
+
+    # @staticmethod
+    # def make_serializable(x: type):
+    #     BeanFactory.register_bean(x)
+
+    #     @wraps(x)
+    #     class wrapper(object): *args, **kwargs):
+    #         return x
+    #         # _x = x(*args, **kwargs)
+    #         # return _x
+    #     return wrapper
+
+    @staticmethod
+    def make_serializable(x: type):
+        BeanFactory.register_bean(x)
+        @wraps(x, updated=())
+        class D(x):
+            decorated = 1
+        return D
diff --git a/pipelime/filesystem/toolkit.py b/pipelime/filesystem/toolkit.py
index 460fb64..8c0c565 100644
--- a/pipelime/filesystem/toolkit.py
+++ b/pipelime/filesystem/toolkit.py
@@ -1,5 +1,7 @@
 from pathlib import Path
 import imghdr
+import pickle
+from pipelime.tools.bytes import DataCoding
 from typing import Union
 import numpy as np
 import imageio
@@ -69,11 +71,11 @@ def is_metadata_file(cls, filename: str) -> bool:
         return ext in ['yml', 'json', 'toml', 'tml']
 
     @classmethod
-    def is_file_image(cls, filename: str) -> bool:
+    def is_image_file(cls, filename: str) -> bool:
         return imghdr.what(filename) is not None
 
     @classmethod
-    def is_file_numpy_array(cls, filename: str) -> bool:
+    def is_numpy_array_file(cls, filename: str) -> bool:
         ext = cls.get_file_extension(filename)
         if ext in ['txt', 'data']:
             try:
@@ -89,6 +91,11 @@ def is_file_numpy_array(cls, filename: str) -> bool:
                 return False
         return False
 
+    @classmethod
+    def is_picke_file(cls, filename: str):
+        ext = cls.get_file_extension(filename)
+        return ext == 'pkl'
+
     @classmethod
     def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
         """ Load data from file based on its extension
@@ -102,10 +109,10 @@ def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
         extension = cls.get_file_extension(filename)
         data = None
 
-        if cls.is_file_image(filename):
+        if cls.is_image_file(filename):
             data = np.array(imageio.imread(filename))
 
-        if cls.is_file_numpy_array(filename):
+        elif cls.is_numpy_array_file(filename):
             if extension in ['txt']:
                 data = np.loadtxt(filename)
             elif extension in ['npy', 'npz']:
@@ -113,10 +120,34 @@ def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
             if data is not None:
                 data = np.atleast_2d(data)
 
-        if cls.is_metadata_file(filename):
+        elif cls.is_metadata_file(filename):
             if extension in ['yml']:
                 data = yaml.safe_load(open(filename, 'r'))
             elif extension in ['json']:
                 data = json.load(open(filename))
-
+        elif cls.is_picke_file(filename):
+            data = pickle.load(open(filename, 'rb'))
+        else:
+            raise NotImplementedError(f'Unknown file extension: {filename}')
         return data
+
+    @classmethod
+    def store_data(cls, filename: str, data: any):
+
+        extension = cls.get_file_extension(filename)
+
+        if DataCoding.is_image_extension(extension):
+            imageio.imwrite(filename, data)
+        elif DataCoding.is_text_extension(extension):
+            np.savetxt(filename, data)
+        elif DataCoding.is_numpy_extension(extension):
+            np.save(filename, data)
+        elif DataCoding.is_metadata_extension(extension):
+            if extension in ['yml']:
+                yaml.safe_dump(data, open(filename, 'w'))
+            elif extension in ['json']:
+                json.dump(data, open(filename, 'w'))
+        elif DataCoding.is_pickle_extension(extension):
+            pickle.dump(data, open(filename, 'wb'))
+        else:
+            raise NotImplementedError(f'Unknown file extension: {filename}')
diff --git a/pipelime/sequences/operations.py b/pipelime/sequences/operations.py
index 1334bb9..79d0f17 100644
--- a/pipelime/sequences/operations.py
+++ b/pipelime/sequences/operations.py
@@ -1,9 +1,12 @@
+from pipelime.sequences.stages import StageKeysFilter
+from pipelime.tools.idgenerators import IdGenerator, IdGeneratorUUID
+from pipelime.factories import Bean, BeanFactory
 import pydash as py_
 import dictquery as dq
 import numpy as np
 import random
-from typing import Any, Dict, Sequence, Union
-from pipelime.sequences.samples import GroupedSample, SamplesSequence
+from typing import Any, Dict, List, Optional, Sequence, Union
+from pipelime.sequences.samples import GroupedSample, Sample, SamplesSequence
 from abc import ABC, abstractmethod
 from schema import Or, Schema
 import rich
@@ -61,61 +64,9 @@ def print(self):
             'output': self.output_port()
         })
 
-    @classmethod
-    @abstractmethod
-    def op_name(cls) -> str:
-        raise NotImplementedError()
 
-    @classmethod
-    @abstractmethod
-    def factory_schema(cls) -> Schema:
-        raise NotImplementedError()
-
-    @classmethod
-    def build_from_dict(cls, d: dict):
-        cls.factory_schema().validate(d)
-
-    @abstractmethod
-    def to_dict(self) -> dict:
-        pass
-
-
-class SequenceOperationFactory(object):
-
-    FACTORY_MAP: Dict[str, SequenceOperation] = {}
-
-    @classmethod
-    def generic_op_schema(cls) -> Schema:
-        return Schema({
-            'type': str,
-            'options': dict
-        })
-
-    @classmethod
-    def register_op(cls, op: SequenceOperation):
-        cls.FACTORY_MAP[op.__name__] = op
-
-    @classmethod
-    def create(cls, cfg: dict) -> SequenceOperation:
-        cls.generic_op_schema().validate(cfg)
-        _t = cls.FACTORY_MAP[cfg['type']]
-        return _t.build_from_dict(cfg)
-
-
-def register_operation_factory(o: SequenceOperation) -> SequenceOperation:
-    """ Register a SequenceOperation to the factory
-
-    :param o: operation to register
-    :type o: SequenceOperation
-    :return: the same operation. It is intent to be used as decorator
-    :rtype: SequenceOperation
-    """
-    SequenceOperationFactory.register_op(o)
-    return o
-
-
-@register_operation_factory
-class OperationSum(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationSum(SequenceOperation, Bean):
 
     def __init__(self) -> None:
         """ Concatenatas multiple sequences
@@ -136,30 +87,19 @@ def __call__(self, x: Sequence[SamplesSequence]) -> SamplesSequence:
         return SamplesSequence(samples=new_samples)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationSum.__name__
+    def bean_schema(cls) -> dict:
+        return {}
 
     @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': dict
-        })
-
-    @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
+    def from_dict(cls, d: dict):
         return OperationSum()
 
     def to_dict(self):
-        return {
-            'type': self.op_name(),
-            'options': {}
-        }
+        return {}
 
 
-@register_operation_factory
-class OperationIdentity(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationIdentity(SequenceOperation, Bean):
 
     def __init__(self) -> None:
         """ No Op
@@ -177,30 +117,58 @@ def __call__(self, x: any) -> any:
         return x
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationIdentity.__name__
+    def bean_schema(cls) -> dict:
+        return {}
 
     @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': dict
-        })
+    def from_dict(cls, d: dict):
+        return OperationIdentity()
+
+    def to_dict(self):
+        return {}
+
+
+@BeanFactory.make_serializable
+class OperationResetIndices(SequenceOperation, Bean):  # TODO: unit test!
+
+    def __init__(self, generator: Union[IdGenerator, None] = None) -> None:
+        """ Reset indices of sample
+        """
+        super().__init__()
+        self._generator: Optional[IdGenerator] = generator if generator is not None else IdGeneratorUUID()
+
+    def input_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    def output_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    def __call__(self, x: SamplesSequence) -> SamplesSequence:
+        super().__call__(x)
+        for idx in range(len(x)):
+            x[idx].id = self._generator.generate()
+        return x
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationIdentity()
+    def bean_schema(cls) -> dict:
+        return {
+            'generator': dict
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return OperationResetIndices(
+            generator=BeanFactory.create(d['generator'])
+        )
 
     def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {}
+            'generator': self._generator.serialize()
         }
 
 
-@register_operation_factory
-class OperationSubsample(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationSubsample(SequenceOperation, Bean):
     def __init__(self, factor: Union[int, float]) -> None:
         """ Subsample an input sequence elements
 
@@ -229,34 +197,25 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return SamplesSequence(samples=new_samples)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationSubsample.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'factor': Or(float, int)
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'factor': Or(float, int)
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationSubsample(d['options']['factor'])
+    def from_dict(cls, d: dict):
+        return OperationSubsample(
+            factor=d['factor']
+        )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'factor': self._factor
-            }
+            'factor': self._factor
         }
 
 
-@register_operation_factory
-class OperationShuffle(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationShuffle(SequenceOperation, Bean):
 
     def __init__(self, seed=-1) -> None:
         """ Shuffle input sequence elements
@@ -281,34 +240,25 @@ def __call__(self, x: SamplesSequence) -> Any:
         return SamplesSequence(samples=new_data)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationShuffle.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'seed': int
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'seed': int
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationShuffle(d['options']['seed'])
+    def from_dict(cls, d: dict):
+        return OperationShuffle(
+            seed=d['seed']
+        )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'seed': self._seed
-            }
+            'seed': self._seed
         }
 
 
-@register_operation_factory
-class OperationSplits(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationSplits(SequenceOperation, Bean):
 
     def __init__(self, split_map: Dict[str, float]) -> None:
         """ Splits an input sequence in multiple sub-sequences in a key/sequence map
@@ -370,34 +320,25 @@ def __call__(self, x: SamplesSequence) -> Dict[str, SamplesSequence]:
         return self._splits_as_dict(x, self._split_map)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationSplits.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'split_map': {str: float}
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'split_map': {str: float}
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationSplits(d['options']['split_map'])
+    def from_dict(cls, d: dict):
+        return OperationSplits(
+            split_map=d['split_map']
+        )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'split_map': self._split_map
-            }
+            'split_map': self._split_map
         }
 
 
-@register_operation_factory
-class OperationDict2List(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationDict2List(SequenceOperation, Bean):
 
     def __init__(self) -> None:
         """ Converts a Dict of sequences into a play list of sequences
@@ -415,30 +356,15 @@ def __call__(self, x: Dict[str, SamplesSequence]) -> Sequence[SamplesSequence]:
         return list(x.values())
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationDict2List.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': dict
-        })
+    def bean_schema(cls) -> dict:
+        return {}
 
-    @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationDict2List()
-
-    def to_dict(self) -> dict:
-        return {
-            'type': self.op_name(),
-            'options': {}
-        }
+    def to_dict(self):
+        return {}
 
 
-@register_operation_factory
-class OperationFilterByQuery(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationFilterByQuery(SequenceOperation, Bean):
 
     def __init__(self, query: str) -> None:
         """ Filter sequence elements based on query string. If sample contains a 'metadata' item
@@ -465,34 +391,25 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return SamplesSequence(samples=filtered_samples)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationFilterByQuery.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'query': str
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'query': str
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationFilterByQuery(query=d['options']['query'])
+    def from_dict(cls, d: dict):
+        return OperationFilterByQuery(
+            query=d['query']
+        )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'query': self._query
-            }
+            'query': self._query
         }
 
 
-@register_operation_factory
-class OperationSplitByQuery(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationSplitByQuery(SequenceOperation, Bean):  # TODO: Replace dictquery with pydash?
 
     def __init__(self, query: str) -> None:
         """ Splits sequence elements in two sub-sequences based on an input query. The first list
@@ -525,34 +442,25 @@ def __call__(self, x: SamplesSequence) -> Sequence[SamplesSequence]:
         )
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationSplitByQuery.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'query': str
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'query': str
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationSplitByQuery(query=d['options']['query'])
+    def from_dict(cls, d: dict):
+        return OperationSplitByQuery(
+            query=d['query']
+        )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'query': self._query
-            }
+            'query': self._query
         }
 
 
-@register_operation_factory
-class OperationGroupBy(SequenceOperation):
+@BeanFactory.make_serializable
+class OperationGroupBy(SequenceOperation, Bean):
 
     def __init__(self, field: str, ungrouped: bool = False) -> None:
         """ Groups sequence elements accoring to specific field
@@ -590,39 +498,137 @@ def __call__(self, x: SamplesSequence) -> Sequence[SamplesSequence]:
         out_samples = []
         for k, samples in groups_map.items():
             g = GroupedSample(samples=samples)
-            g['__groupbyvalue__'] = k
+            # g['__groupbyvalue__'] = k
             out_samples.append(g)
         if len(none_group) > 0 and self._ungrouped:
             g = GroupedSample(samples=none_group)
-            g['__groupbyvalue__'] = None
+            # g['__groupbyvalue__'] = None
             out_samples.append(g)
 
         return SamplesSequence(samples=out_samples)
 
     @classmethod
-    def op_name(cls) -> str:
-        return OperationGroupBy.__name__
+    def bean_schema(cls) -> dict:
+        return {
+            'field': str,
+            'ungrouped': bool
+        }
 
     @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.op_name(),
-            'options': {
-                'field': str,
-                'ungrouped': bool
-            }
-        })
+    def from_dict(cls, d: dict):
+        return OperationGroupBy(
+            field=d['field'],
+            ungrouped=d['ungrouped']
+        )
+
+    def to_dict(self):
+        return {
+            'field': self._field,
+            'ungrouped': self._ungrouped
+        }
+
+
+@BeanFactory.make_serializable
+class OperationOrderBy(SequenceOperation, Bean):
+
+    class reversor:
+
+        def __init__(self, obj):
+            self.obj = obj
+
+        def __eq__(self, other):
+            return other.obj == self.obj
+
+        def __lt__(self, other):
+            return other.obj < self.obj
+
+    def __init__(self, order_keys: Sequence[str]) -> None:
+        """ Order sequence elements based on pydash dict key . like '`metadata.num`'.
+
+        :param order_keys: list of keys to order by
+        :type order_keys: Sequence[str]
+        """
+        self._order_keys = order_keys
+
+    def input_port(self):
+        return OperationPort(SamplesSequence)
+
+    def output_port(self):
+        return OperationPort(SamplesSequence)
+
+    def _order_pair_from_string(self, v: str):
+        if v.startswith('+') or v.startswith('-'):
+            return True if v[0] == '-' else False, v[1:]
+        else:
+            return False, v
+
+    def _order_by(self, x: Sample):
+        chunks = []
+        for orderby in self._order_keys:
+            reverse, orderby_path = self._order_pair_from_string(orderby)
+            picked_val = py_.get(x, orderby_path)
+            chunks.append(picked_val if not reverse else self.reversor(picked_val))
+        return tuple(chunks)
+
+    def __call__(self, x: SamplesSequence) -> SamplesSequence:
+        super().__call__(x)
+        return SamplesSequence(samples=sorted(x, key=self._order_by))
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'order_keys': [str]
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return OperationOrderBy(
+            order_keys=d['order_keys']
+        )
+
+    def to_dict(self):
+        return {
+            'order_keys': self._order_keys
+        }
+
+
+@BeanFactory.make_serializable
+class OperationFilterKeys(SequenceOperation, Bean):
+
+    def __init__(self, keys: list, negate: bool = False) -> None:
+        """ Filter sequence elements by keys
+
+        :param keys: list of keys to preserve
+        :type keys: list
+        :param negate: TRUE to delete input keys, FALSE delete all but keys
+        :type negate: bool
+        """
+        self._keys = keys
+        self._negate = negate
+        self._stage = StageKeysFilter(keys=keys, negate=negate)
+
+    def input_port(self):
+        return OperationPort(SamplesSequence)
+
+    def output_port(self):
+        return OperationPort(SamplesSequence)
+
+    def __call__(self, x: SamplesSequence) -> SamplesSequence:
+        super().__call__(x)
+        filtered_samples = []
+        for sample in x.samples:
+            filtered_samples.append(self._stage(sample))
+        return SamplesSequence(samples=filtered_samples)
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
-        return OperationGroupBy(field=d['options']['field'], ungrouped=d['options']['ungrouped'])
+    def bean_schema(cls) -> dict:
+        return {
+            'keys': list,
+            'negate': bool
+        }
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.op_name(),
-            'options': {
-                'field': self._field,
-                'ungrouped': self._ungrouped
-            }
+            'keys': self._keys,
+            'negate': self._negate
         }
diff --git a/pipelime/sequences/pipes.py b/pipelime/sequences/pipes.py
new file mode 100644
index 0000000..aa6598c
--- /dev/null
+++ b/pipelime/sequences/pipes.py
@@ -0,0 +1,303 @@
+
+
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.writers.base import BaseWriter
+from pipelime.tools.idgenerators import IdGeneratorUUID
+import tempfile
+from networkx.drawing.nx_agraph import to_agraph
+from pipelime.factories import Bean, BeanFactory
+import networkx as nx
+from schema import Or
+from typing import Hashable, Sequence, Union
+from pipelime.sequences.operations import SequenceOperation
+from pipelime.sequences.samples import SamplesSequence
+
+
+class PlaceholderDataNode(object):
+
+    def __init__(self, name: str) -> None:
+        self.name = name
+
+    def __repr__(self) -> str:
+        return self.name
+
+    def __hash__(self) -> int:
+        return hash(self.name)
+
+    def __eq__(self, o: object) -> bool:
+        return self.name == o.name
+
+
+class PipeNode(object):
+
+    def __init__(
+            self,
+            id: Hashable,
+            input_data: Union[str, list, dict],
+            output_data: Union[str, list, dict]
+    ) -> None:
+
+        self._id = id
+        self._input_data = input_data
+        self._output_data = output_data
+
+    @property
+    def id(self):
+        return self._id
+
+    def input_data(self, as_list: bool = False):
+        if as_list:
+            return self._data_as_list(self._input_data)
+        return self._input_data
+
+    def output_data(self, as_list: bool = False):
+        if as_list:
+            return self._data_as_list(self._output_data)
+        return self._output_data
+
+    def _data_as_list(self, v: any):
+        if v is None:
+            return []
+        elif isinstance(v, str):
+            return [v]
+        elif isinstance(v, list):
+            return v
+        elif isinstance(v, dict):
+            return list(v.values())
+        else:
+            raise NotImplementedError(f'{type(v)}')
+
+    def __repr__(self) -> str:
+        return str(self.id)
+
+
+@BeanFactory.make_serializable
+class OperationNode(PipeNode, Bean):
+
+    def __init__(
+            self,
+            id: Hashable,
+            input_data: Union[str, list, dict],
+            output_data: Union[str, list, dict],
+            operation: SequenceOperation
+    ) -> None:
+
+        super().__init__(id=id, input_data=input_data, output_data=output_data)
+        self._operation = operation
+
+    @property
+    def operation(self):
+        return self._operation
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'input_data': Or(str, list, dict),
+            'output_data': Or(str, list, dict),
+            'operation': dict
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return OperationNode(
+            id=IdGeneratorUUID().generate(),
+            input_data=d['input_data'],
+            output_data=d['output_data'],
+            operation=BeanFactory.create(d['operation'])
+        )
+
+    def to_dict(self):
+        return {
+            'input_data': self._input_data,
+            'output_data': self._output_data,
+            'operation': self.operation.serialize()
+        }
+
+
+@BeanFactory.make_serializable
+class ReaderNode(PipeNode, Bean):
+
+    def __init__(
+            self,
+            id: Hashable,
+            output_data: str,
+            reader: BaseReader
+    ) -> None:
+
+        super().__init__(id=id, input_data=None, output_data=output_data)
+        self._reader = reader
+
+    @property
+    def reader(self):
+        return self._reader
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'output_data': Or(None, str, list, dict),
+            'reader': dict
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return ReaderNode(
+            id=IdGeneratorUUID().generate(),
+            output_data=d['output_data'],
+            reader=BeanFactory.create(d['reader'])
+        )
+
+    def to_dict(self):
+        return {
+            'output_data': self._output_data,
+            'reader': self.reader.serialize()
+        }
+
+
+@BeanFactory.make_serializable
+class WriterNode(PipeNode, Bean):
+
+    def __init__(
+            self,
+            id: Hashable,
+            input_data: str,
+            writer: BaseWriter
+    ) -> None:
+
+        super().__init__(id=id, output_data=None, input_data=input_data)
+        self._writer = writer
+
+    @property
+    def writer(self):
+        return self._writer
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'input_data': Or(None, str, list, dict),
+            'writer': dict
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return WriterNode(
+            id=IdGeneratorUUID().generate(),
+            input_data=d['input_data'],
+            writer=BeanFactory.create(d['writer'])
+        )
+
+    def to_dict(self):
+        return {
+            'input_data': self._input_data,
+            'writer': self.writer.serialize()
+        }
+
+
+@BeanFactory.make_serializable
+class NodeGraph(Bean):
+
+    def __init__(self, nodes: Sequence[PipeNode]):
+        self._nodes = nodes
+        self._nodes_map = {}
+        self._graph = nx.DiGraph()
+
+        for node in self._nodes:
+            node: PipeNode
+            [self._graph.add_edge(PlaceholderDataNode(x), node) for x in node.input_data(as_list=True)]
+            [self._graph.add_edge(node, PlaceholderDataNode(x)) for x in node.output_data(as_list=True)]
+            self._nodes_map[node.id] = node
+
+        self._data_cache = {}
+        self._sorted_graph = nx.topological_sort(self._graph)
+
+    def clear(self):
+        self._data_cache.clear()
+
+    def execute(self):
+        self.clear()
+        for node in self._sorted_graph:
+            if isinstance(node, ReaderNode):
+                for data in node.output_data(as_list=True):
+                    self._data_cache[data] = node.reader
+
+            if isinstance(node, OperationNode):
+                data = self._cache_to_node(node, self._data_cache)
+                out_data = node.operation(data)
+                self._node_to_cache(out_data, node, self._data_cache)
+
+            if isinstance(node, WriterNode):
+                data = self._cache_to_node(node, self._data_cache)
+                node.writer(data)
+
+    def _cache_to_node(self, node: PipeNode, cache: dict) -> Union[SamplesSequence, list, dict]:
+        v = node.input_data(as_list=False)
+        if v is None:
+            return None
+        elif isinstance(v, str):
+            return cache[v]
+        elif isinstance(v, list):
+            return [cache[x] for x in v]
+        elif isinstance(v, dict):
+            return {k: cache[v] for k, v in v.items()}
+        else:
+            raise NotImplementedError(f'{type(v)}')
+
+    def _node_to_cache(self, data: Union[SamplesSequence, list, dict], node: PipeNode, cache: dict):
+        v = node.output_data(as_list=False)
+        if v is None:
+            pass
+        elif isinstance(v, str):
+            cache[v] = data
+        elif isinstance(v, list):
+            for idx, name in enumerate(v):
+                cache[name] = data[idx]
+        elif isinstance(v, dict):
+            for k, name in v.items():
+                cache[name] = data[k]
+        else:
+            raise NotImplementedError(f'{type(v)}')
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'nodes': list
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        nodes = [BeanFactory.create(c) for c in d['nodes']]
+        return NodeGraph(nodes=nodes)
+
+    def to_dict(self):
+        return {
+            'nodes': [x.serialize() for x in self._nodes]
+        }
+
+    def draw_to_file(self, filename: str = None):
+        A = to_agraph(self._graph)
+        for i, node in enumerate(A.iternodes()):
+            print(i, node)
+            if node in self._nodes_map:
+                ref = self._nodes_map[node]
+                if isinstance(ref, OperationNode):
+                    node.attr['label'] = self._nodes_map[node].operation.__class__.__name__
+                    node.attr['style'] = 'filled'
+                    node.attr['fillcolor'] = '#ffd54f'
+                    node.attr['shape'] = 'box'
+                elif isinstance(ref, ReaderNode):
+                    node.attr['label'] = self._nodes_map[node].reader.__class__.__name__
+                    node.attr['style'] = 'filled'
+                    node.attr['fillcolor'] = '#009688'
+                    node.attr['shape'] = 'box'
+                elif isinstance(ref, WriterNode):
+                    node.attr['label'] = self._nodes_map[node].writer.__class__.__name__
+                    node.attr['style'] = 'filled'
+                    node.attr['fillcolor'] = '#9c27b0'
+                    node.attr['shape'] = 'box'
+
+            else:
+                node.attr['shape'] = 'circle'
+
+        A.layout('dot')
+        fname = f'{tempfile.NamedTemporaryFile().name}.png' if filename is None else filename
+        A.draw(fname)
+        return A, fname
diff --git a/pipelime/sequences/readers/base.py b/pipelime/sequences/readers/base.py
new file mode 100644
index 0000000..6b39ab6
--- /dev/null
+++ b/pipelime/sequences/readers/base.py
@@ -0,0 +1,7 @@
+from pipelime.factories import Bean
+from pipelime.sequences.samples import SamplesSequence
+
+
+class BaseReader(SamplesSequence, Bean):
+
+    pass
diff --git a/pipelime/sequences/readers/filesystem.py b/pipelime/sequences/readers/filesystem.py
index 79bcfcf..dc27048 100644
--- a/pipelime/sequences/readers/filesystem.py
+++ b/pipelime/sequences/readers/filesystem.py
@@ -1,14 +1,32 @@
 from pathlib import Path
-from pipelime.sequences.samples import FileSystemSample, SamplesSequence
+from typing import Dict, Sequence, Union
+from pipelime.factories import Bean, BeanFactory
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import FileSystemSample
 from pipelime.filesystem.toolkit import FSToolkit
-import rich
+from schema import Optional
 
 
-class UnderfolderReader(SamplesSequence):
+class UnderfolderReaderTemplate(object):
+
+    def __init__(
+        self,
+        extensions_map: Dict[str, any],
+        root_files_keys: Sequence[str],
+        idx_length: int = 5
+    ):
+        self.extensions_map = extensions_map
+        self.root_files_keys = root_files_keys
+        self.idx_length = idx_length
+
+
+class UnderfolderReader(BaseReader):
     DATA_SUBFOLDER = 'data'
 
-    def __init__(self, folder: str, copy_root_files: bool = True) -> None:
+    def __init__(self, folder: str, copy_root_files: bool = True, lazy_samples: bool = True) -> None:
+
         self._folder = Path(folder)
+        self._copy_root_files = copy_root_files
         self._datafolder = self._folder / self.DATA_SUBFOLDER
         assert self._datafolder.exists(), f'No data folder found: "{self._datafolder}"'
 
@@ -16,17 +34,70 @@ def __init__(self, folder: str, copy_root_files: bool = True) -> None:
         self._tree = FSToolkit.tree_from_underscore_notation_files(self._datafolder)
         self._ids = list(sorted(self._tree.keys()))
         self._root_files = [x for x in Path(self._folder).glob('*') if x.is_file()]
+        self._root_files = [x for x in self._root_files if not x.name.startswith('.')]
         self._root_data = {}
+        self._root_files_keys = set()
         for f in self._root_files:
+            self._root_files_keys.add(f.stem)
             self._root_data[f.stem] = str(f)  # FSToolkit.load_data(f)
 
         samples = []
         for idx in range(len(self._ids)):
             data = dict(self._tree[self._ids[idx]])
-            if copy_root_files:
+            if self._copy_root_files:
                 data.update(self._root_data)
 
-            sample = FileSystemSample(data_map=data, lazy=True, id=self._ids[idx])
+            sample = FileSystemSample(data_map=data, lazy=lazy_samples, id=self._ids[idx])
             samples.append(sample)
 
         super().__init__(samples=samples)
+
+    def get_filesystem_template(self) -> Union[UnderfolderReaderTemplate, None]:
+        """ Retrieves the template of the underfolder reader, i.e. a mapping
+        between sample_key/file_extension and a list of root files keys
+
+        :raises TypeError: If first sample is not a FileSystemSample
+        :return: None if dataset is empty, otherwise an UnderfolderReaderTemplate
+        :rtype: Union[UnderfolderReaderTemplate, None]
+        """
+
+        if len(self) > 0:
+            sample = self[0]
+            if not isinstance(sample, FileSystemSample):
+                raise TypeError(f'Anomalous sample type found: {type(sample)}')
+
+            extensions_map = {}
+            idx_length = len(str(sample.id))
+            for key, filename in sample.filesmap.items():
+                extensions_map[key] = Path(filename).suffix.replace('.', '')
+
+            return UnderfolderReaderTemplate(
+                extensions_map=extensions_map,
+                root_files_keys=list(self._root_files_keys),
+                idx_length=idx_length
+            )
+        else:
+            None
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'folder': str,
+            Optional('copy_root_files'): bool
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return UnderfolderReader(
+            folder=d.get('folder'),
+            copy_root_files=d.get('copy_root_files', True)
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            'folder': str(self._folder),
+            'copy_root_files': self._copy_root_files
+        }
+
+
+BeanFactory.register_bean(UnderfolderReader)
diff --git a/pipelime/sequences/samples.py b/pipelime/sequences/samples.py
index 33761a4..264e676 100644
--- a/pipelime/sequences/samples.py
+++ b/pipelime/sequences/samples.py
@@ -4,7 +4,7 @@
 from collections.abc import MutableMapping
 from abc import abstractmethod
 from pathlib import Path
-from typing import Any, Hashable, Sequence
+from typing import Hashable, Sequence
 
 
 @dataclass
@@ -46,6 +46,10 @@ def __init__(self, id: Hashable = None) -> None:
     def id(self):
         return self._id
 
+    @id.setter
+    def id(self, v: Hashable):
+        self._id = v
+
     @abstractmethod
     def rename(self, old_key: str, new_key: str):
         pass
@@ -58,6 +62,10 @@ def copy(self):
     def metaitem(self, key: any) -> MetaItem:
         pass
 
+    @property
+    def skeleton(self) -> dict:
+        return {x: None for x in self.keys()}
+
 
 class GroupedSample(Sample):
 
@@ -153,7 +161,7 @@ def __repr__(self) -> str:
         return str(self._data)
 
     def copy(self):
-        return PlainSample(data=self._data.copy())
+        return PlainSample(data=self._data.copy(), id=self.id)
 
     def rename(self, old_key: str, new_key: str):
         if new_key not in self._data and old_key in self._data:
@@ -183,8 +191,15 @@ def __init__(self, data_map: dict, lazy: bool = True, id: Hashable = None):
             for k in self.keys():
                 d = self[k]
 
+    @property
+    def filesmap(self):
+        return self._filesmap
+
+    def is_cached(self, key) -> bool:
+        return key in self._cached
+
     def __getitem__(self, key):
-        if key not in self._cached:
+        if not self.is_cached(key):
             self._cached[key] = FSToolkit.load_data(self._filesmap[key])
         return self._cached[key]
 
@@ -204,7 +219,7 @@ def __len__(self):
         return len(self._filesmap)
 
     def copy(self):
-        newsample = FileSystemSample(self._filesmap)
+        newsample = FileSystemSample(self._filesmap, id=self.id)
         newsample._cached = self._cached.copy()
         return newsample
 
@@ -220,6 +235,10 @@ def metaitem(self, key: any):
         else:
             return MemoryItem()
 
+    @property
+    def skeleton(self) -> dict:
+        return {x: None for x in self._filesmap.keys()}
+
 
 class SamplesSequence(Sequence):
 
@@ -238,3 +257,21 @@ def __getitem__(self, idx: int):
             raise IndexError
 
         return self._samples[idx]
+
+    def is_normalized(self) -> bool:
+        """ Checks for normalization i.e. each sample has to contain same keys.
+        !!This method could be very slow if samples are lazy!!
+
+        :return: TRUE if sequence is normalized
+        :rtype: bool
+        """
+
+        keys_set = None
+        for sample in self:
+            if keys_set is None:
+                keys_set = set(sample.keys())
+            else:
+                new_keys_set = set(sample.keys())
+                if new_keys_set != keys_set:
+                    return False
+        return True
diff --git a/pipelime/sequences/stages.py b/pipelime/sequences/stages.py
index 3a5c9d6..b372f7b 100644
--- a/pipelime/sequences/stages.py
+++ b/pipelime/sequences/stages.py
@@ -1,8 +1,7 @@
 import albumentations as A
 from abc import ABC, abstractmethod
 from typing import Dict, Hashable, Sequence, Union
-
-from schema import Schema
+from pipelime.factories import Bean, BeanFactory
 from pipelime.sequences.samples import Sample
 
 
@@ -24,61 +23,39 @@ def __call__(self, x:
     ]:
         pass
 
-    @classmethod
-    @abstractmethod
-    def stage_name(cls) -> str:
-        raise NotImplementedError()
-
-    @classmethod
-    @abstractmethod
-    def factory_schema(cls) -> Schema:
-        raise NotImplementedError()
-
-    @classmethod
-    def build_from_dict(cls, d: dict):
-        cls.factory_schema().validate(d)
-
-    @abstractmethod
-    def to_dict(self) -> dict:
-        pass
-
 
-class SampleStagesFactory(object):
+@BeanFactory.make_serializable
+class StageCompose(SampleStage, Bean):
 
-    FACTORY_MAP: Dict[str, SampleStage] = {}
+    def __init__(self, stages: Sequence[SampleStage]):
+        super().__init__()
+        self._stages = stages
 
-    @classmethod
-    def generic_op_schema(cls) -> Schema:
-        return Schema({
-            'type': str,
-            'options': dict
-        })
+    def __call__(self, x: Sample) -> Sample:
+        out = x
+        for s in self._stages:
+            out = s(out)
+        return out
 
     @classmethod
-    def register_stage(cls, stage: SampleStage):
-        cls.FACTORY_MAP[stage.__name__] = stage
+    def bean_schema(cls) -> dict:
+        return {
+            'stages': list
+        }
 
     @classmethod
-    def create(cls, cfg: dict) -> SampleStage:
-        cls.generic_op_schema().validate(cfg)
-        _t = cls.FACTORY_MAP[cfg['type']]
-        return _t.build_from_dict(cfg)
+    def from_dict(cls, d: dict):
+        stages = [BeanFactory.create(s) for s in d['stages']]
+        return StageCompose(stages=stages)
 
-
-def register_stage_factory(s: SampleStage) -> SampleStage:
-    """ Register a SampleStage to the factory
-
-    :param s: stage to register
-    :type s: SampleStage
-    :return: the same stage. It is intent to be used as decorator
-    :rtype: SampleStage
-    """
-    SampleStagesFactory.register_stage(s)
-    return s
+    def to_dict(self):
+        return {
+            'stages': [s.serialize() for s in self._stages]
+        }
 
 
-@register_stage_factory
-class StageIdentity(SampleStage):
+@BeanFactory.make_serializable
+class StageIdentity(SampleStage, Bean):
 
     def __init__(self):
         super().__init__()
@@ -87,30 +64,19 @@ def __call__(self, x: any) -> any:
         return x
 
     @classmethod
-    def stage_name(cls) -> str:
-        return StageIdentity.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.stage_name(),
-            'options': {}
-        })
+    def bean_schema(cls) -> dict:
+        return {}
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
+    def from_dict(cls, d: dict):
         return StageIdentity()
 
-    def to_dict(self) -> dict:
-        return {
-            'type': self.stage_name(),
-            'options': {}
-        }
+    def to_dict(self):
+        return {}
 
 
-@register_stage_factory
-class StageRemap(SampleStage):
+@BeanFactory.make_serializable
+class StageRemap(SampleStage, Bean):
 
     def __init__(self, remap: dict, remove_missing: bool = True):
         """ Remaps keys in sample
@@ -137,39 +103,66 @@ def __call__(self, x: Sample) -> Sample:
         return out
 
     @classmethod
-    def stage_name(cls) -> str:
-        return StageRemap.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.stage_name(),
-            'options': {
-                'remap': dict,
-                'remove_missing': bool
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'remap': dict,
+            'remove_missing': bool
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
+    def from_dict(cls, d: dict):
         return StageRemap(
-            remap=d['options']['remap'],
-            remove_missing=d['options']['remove_missing']
+            remap=d['remap'],
+            remove_missing=d['remove_missing']
         )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
+        return {
+            'remap': self._remap,
+            'remove_missing': self._remove_missing
+        }
+
+
+@BeanFactory.make_serializable
+class StageKeysFilter(SampleStage, Bean):
+
+    def __init__(self, keys: list, negate: bool = False):
+        """ Filter sample keys
+
+        :param keys: list of keys to preserve
+        :type keys: list
+        :param negate: TRUE to delete input keys, FALSE delete all but keys
+        :type negate: bool
+        """
+        super().__init__()
+        self._keys = keys
+        self._negate = negate
+
+    def __call__(self, x: Sample) -> Sample:
+
+        out: Sample = x.copy()
+        for k in x.keys():
+            condition = (k in self._keys) if self._negate else (k not in self._keys)
+            if condition:
+                del out[k]
+        return out
+
+    @classmethod
+    def bean_schema(cls) -> dict:
         return {
-            'type': self.stage_name(),
-            'options': {
-                'remap': self._remap,
-                'remove_missing': self._remove_missing
-            }
+            'keys': list,
+            'negate': bool
+        }
+
+    def to_dict(self):
+        return {
+            'keys': self._keys,
+            'negate': self._negate
         }
 
 
-@register_stage_factory
-class StageAugmentations(SampleStage):
+@BeanFactory.make_serializable
+class StageAugmentations(SampleStage, Bean):
 
     def __init__(self, transform_cfg: dict, targets: dict):
         super().__init__()
@@ -200,32 +193,21 @@ def __call__(self, x: Sample) -> Sample:
             raise Exception(f'Stage[{self.__class__.__name__}] -> {e}')
 
     @classmethod
-    def stage_name(cls) -> str:
-        return StageAugmentations.__name__
-
-    @classmethod
-    def factory_schema(cls) -> Schema:
-        return Schema({
-            'type': cls.stage_name(),
-            'options': {
-                'transform_cfg': dict,
-                'targets': dict
-            }
-        })
+    def bean_schema(cls) -> dict:
+        return {
+            'transform_cfg': dict,
+            'targets': dict
+        }
 
     @classmethod
-    def build_from_dict(cls, d: dict):
-        super().build_from_dict(d)
+    def from_dict(cls, d: dict):
         return StageAugmentations(
-            transform_cfg=d['options']['transform_cfg'],
-            targets=d['options']['targets']
+            transform_cfg=d['transform_cfg'],
+            targets=d['targets']
         )
 
-    def to_dict(self) -> dict:
+    def to_dict(self):
         return {
-            'type': self.stage_name(),
-            'options': {
-                'transform_cfg': self._transform_cfg,
-                'targets': self._targets
-            }
+            'transform_cfg': self._transform_cfg,
+            'targets': self._targets
         }
diff --git a/pipelime/sequences/validation.py b/pipelime/sequences/validation.py
new file mode 100644
index 0000000..d4ab5f7
--- /dev/null
+++ b/pipelime/sequences/validation.py
@@ -0,0 +1,49 @@
+from pipelime.sequences.samples import Sample
+from schema import Schema
+import uuid
+import types
+import importlib.machinery
+
+
+class SampleSchema:
+
+    def __init__(self, schema: Schema, deep: bool = True):
+        self._schema = schema
+        self._deep = deep
+
+    @property
+    def deep(self) -> bool:
+        return self._deep
+
+    @property
+    def schema(self) -> Schema:
+        return self._schema
+
+    def validate(self, sample: Sample):
+        if not self.deep:
+            self.schema.validate(sample.skeleton)
+        else:
+            self.schema.validate(dict(sample))
+
+
+class SchemaLoader:
+    DEFAULT_SCHEMA_VARIABLE_NAME = 'schema'
+    DEFAULT_DEEP_VARIABLE_NAME = 'deep'
+
+    @classmethod
+    def load(cls, filename: str) -> SampleSchema:
+        module = cls._load_module(str(filename))
+        sname = SchemaLoader.DEFAULT_SCHEMA_VARIABLE_NAME
+        if not hasattr(module, sname):
+            raise ModuleNotFoundError(f'No variable with name {sname} found in file: {filename}')
+
+        schema = Schema(getattr(module, sname))
+        deep = getattr(module, SchemaLoader.DEFAULT_DEEP_VARIABLE_NAME, True)
+        return SampleSchema(schema=schema, deep=deep)
+
+    @classmethod
+    def _load_module(cls, path: str):
+        loader = importlib.machinery.SourceFileLoader(str(uuid.uuid1()), path)
+        mod = types.ModuleType(loader.name)
+        loader.exec_module(mod)
+        return mod
diff --git a/pipelime/sequences/writers/__init__.py b/pipelime/sequences/writers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/sequences/writers/base.py b/pipelime/sequences/writers/base.py
new file mode 100644
index 0000000..291111a
--- /dev/null
+++ b/pipelime/sequences/writers/base.py
@@ -0,0 +1,10 @@
+from pipelime.factories import Bean
+from pipelime.sequences.samples import SamplesSequence
+from abc import abstractmethod
+
+
+class BaseWriter(Bean):
+
+    @abstractmethod
+    def __call__(self, x: SamplesSequence) -> None:
+        pass
diff --git a/pipelime/sequences/writers/filesystem.py b/pipelime/sequences/writers/filesystem.py
new file mode 100644
index 0000000..2423ec5
--- /dev/null
+++ b/pipelime/sequences/writers/filesystem.py
@@ -0,0 +1,145 @@
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from rich.progress import track
+from pipelime.filesystem.toolkit import FSToolkit
+import re
+from pathlib import Path
+from pipelime.factories import Bean, BeanFactory
+from pipelime.sequences.writers.base import BaseWriter
+from pipelime.sequences.samples import FileSystemSample, FilesystemItem, Sample, SamplesSequence
+from schema import Optional, Or
+import shutil
+import os
+
+
+class UnderfolderWriter(BaseWriter):
+    DATA_SUBFOLDER = 'data'
+    DEFAULT_EXTENSION = 'pkl'
+
+    def __init__(
+        self,
+        folder: str,
+        root_files_keys: list = None,
+        extensions_map: dict = None,
+        zfill: int = 5,
+        copy_files: bool = True,
+        use_symlinks: bool = False
+    ) -> None:
+        """ UnderfolderWriter for an input SamplesSequence
+
+        :param folder: destiantion folder
+        :type folder: str
+        :param root_files_keys: list of keys to write as underfolder root files (only the first element
+        encountered will be stored as root file, the others will be discarded), defaults to None
+        :type root_files_keys: list, optional
+        :param extensions_map: dictionary of regex/extension to retrieve extension for each key matching
+        the corresponding regex. Unmatched keys will be stored as PICKLE object file, defaults to None
+        :type extensions_map: dict, optional
+        :param zfill: Length of zero padding in case of integer sample indices, defaults to 5
+        :type zfill: int, optional
+        :param copy_files: TRUE to copy FileSystemSample directly if not cached before, defaults to True
+        :type copy_files: bool, optional
+        :param use_symlinks: if TRUE (and copy_files == TRUE) the copy will be replaced with a symlink, defaults to False
+        :type use_symlinks: bool, optional
+        """
+        self._folder = Path(folder)
+
+        self._empty_template = root_files_keys is None and extensions_map is None
+        self._root_files_keys = root_files_keys if root_files_keys is not None else []
+        self._extensions_map = extensions_map if extensions_map is not None else {}
+        self._zfill = zfill
+        self._copy_files = copy_files
+        self._use_symlinks = use_symlinks
+        self._datafolder = self._folder / self.DATA_SUBFOLDER
+        if not self._datafolder.exists():
+            self._datafolder.mkdir(parents=True, exist_ok=True)
+
+    def _build_sample_basename(self, sample: Sample):
+        if isinstance(sample.id, int):
+            return str(sample.id).zfill(self._zfill)
+        else:
+            return str(sample.id)
+
+    def _build_item_extension(self, key: str):
+        extension = UnderfolderWriter.DEFAULT_EXTENSION
+        for r, ext in self._extensions_map.items():
+            if re.match(r, key) is not None:
+                extension = ext
+                break
+        return extension
+
+    def _is_root_key(self, key: str):
+        for r in self._root_files_keys:
+            if re.match(r, key) is not None:
+                return True
+        return False
+
+    def __call__(self, x: SamplesSequence) -> None:
+
+        if isinstance(x, UnderfolderReader) and self._empty_template:
+            template = x.get_filesystem_template()
+            if template is not None:
+                self._extensions_map = template.extensions_map
+                self._root_files_keys = list(template.root_files_keys)
+                self._zfill = template.idx_length
+
+        saved_root_keys = set()
+        for sample in track(x):
+            basename = self._build_sample_basename(sample)
+
+            for key in sample.keys():
+                if self._is_root_key(key):
+                    if key not in saved_root_keys:
+                        saved_root_keys.add(key)
+                        itemname = f'{key}.{self._build_item_extension(key)}'
+                        output_file = Path(self._folder) / itemname
+                        self._write_sample_item(output_file, sample, key)
+                else:
+                    itemname = f'{basename}_{key}.{self._build_item_extension(key)}'
+                    output_file = Path(self._datafolder) / itemname
+
+                    self._write_sample_item(output_file, sample, key)
+
+    def _write_sample_item(self, output_file: Path, sample: Sample, key: str):
+
+        if self._copy_files:
+            if isinstance(sample, FileSystemSample):
+                item = sample.metaitem(key)
+                if not sample.is_cached(key) and isinstance(item, FilesystemItem):
+                    path = item.source()
+                    if not self._use_symlinks:
+                        shutil.copy(path, output_file)
+                    else:
+                        os.symlink(path, output_file)
+                    return
+
+        # Default action
+        FSToolkit.store_data(output_file, sample[key])
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            'folder': str,
+            Optional('root_files_keys'): Or(None, list),
+            Optional('extensions_map'): Or(None, dict),
+            Optional('zfill'): int,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return UnderfolderWriter(
+            folder=d.get('folder'),
+            root_files_keys=d.get('root_files_keys', None),
+            extensions_map=d.get('extensions_map', None),
+            zfill=d.get('zfill', 5),
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            'folder': str(self._folder),
+            'root_files_keys': self._root_files_keys,
+            'extensions_map': self._extensions_map,
+            'zfill': self._zfill
+        }
+
+
+BeanFactory.register_bean(UnderfolderWriter)
diff --git a/pipelime/tools/bytes.py b/pipelime/tools/bytes.py
new file mode 100644
index 0000000..d4f21fa
--- /dev/null
+++ b/pipelime/tools/bytes.py
@@ -0,0 +1,151 @@
+
+from io import BytesIO
+import imageio
+import numpy as np
+from pathlib import Path
+from typing import Tuple
+
+
+class DataCoding(object):
+
+    IMAGE_CODECS = ['jpg', 'jpeg', 'png', 'tiff', 'bmp']
+    IMAGE_CODECS_IS_LOSSY = {
+        'jpg': True,
+        'jpeg': True
+    }
+    IMAGE_CODECS_HAS_ALPHA = {
+        'png': True
+    }
+
+    NUMPY_CODECS = ['npy']
+    TEXT_CODECS = ['txt']
+    METADATA_CODECS = ['json', 'yml', 'yaml']
+    PICKLE_CODECS = ['pkl']
+
+    @classmethod
+    def is_image_extension(cls, extension: str):
+        return extension in DataCoding.IMAGE_CODECS
+
+    @classmethod
+    def is_text_extension(cls, extension: str):
+        return extension in DataCoding.TEXT_CODECS
+
+    @classmethod
+    def is_numpy_extension(cls, extension: str):
+        return extension in DataCoding.NUMPY_CODECS
+
+    @classmethod
+    def is_metadata_extension(cls, extension: str):
+        return extension in DataCoding.METADATA_CODECS
+
+    @classmethod
+    def is_pickle_extension(cls, extension: str):
+        return extension in DataCoding.PICKLE_CODECS
+
+    @classmethod
+    def bytes_to_data(cls, data: bytes, data_encoding: str) -> np.ndarray:
+        """ Converts bytes with corresponding encoding into numpy array
+
+        :param data: source bytes
+        :type data: bytes
+        :param data_encoding: bytes encoding
+        :type data_encoding: str
+        :return: numpy array
+        :rtype: np.ndarray
+        """
+        data_encoding = data_encoding.replace('.', '')
+
+        if data_encoding in cls.IMAGE_CODECS:
+            buffer = BytesIO(data)
+            return imageio.imread(buffer.getbuffer(), format=data_encoding)
+        elif data_encoding in cls.NUMPY_CODECS:
+            buffer = BytesIO(data)
+            return np.load(buffer)
+        elif data_encoding in cls.TEXT_CODECS:
+            buffer = BytesIO(data)
+            return np.loadtxt(buffer)
+        else:
+            return None
+
+    @classmethod
+    def file_to_bytes(cls, filename: str) -> Tuple[bytes, str]:
+        """ Converts image from file as (bytes, encoding)
+
+        :param filename: source filename
+        :type filename: str
+        :return: pair (bytes, codec string)
+        :rtype: Tuple[bytes, str]
+        """
+        filename = Path(filename)
+        extension = filename.suffix.replace('.', '')
+        return open(filename, 'rb').read(), extension
+
+    @classmethod
+    def numpy_image_to_bytes(cls, array: np.ndarray, data_encoding: str) -> bytes:
+        """ Converts image stored as numpy array into bytes with custom data encoding
+
+        :param array: source image numpy array
+        :type array: np.ndarray
+        :param data_encoding: codec string representation
+        :type data_encoding: str
+        :return: bytes representation
+        :rtype: bytes
+        """
+        data_encoding = data_encoding.replace('.', '')
+
+        data = bytes()
+        if data_encoding in cls.IMAGE_CODECS:
+            buffer = BytesIO(data)
+            imageio.imwrite(buffer, array, format=data_encoding)
+            return buffer.getvalue()
+        else:
+            return None
+
+    @classmethod
+    def numpy_array_to_bytes(cls, array: np.ndarray, data_encoding: str = 'npy') -> bytes:
+        """ Converts array data stored as numpy array into bytes with custom data encoding
+
+        :param array: source generic numpy array
+        :type array: np.ndarray
+        :param data_encoding: codec string representation
+        :type data_encoding: str
+        :return: bytes representation
+        :rtype: bytes
+        """
+        data_encoding = data_encoding.replace('.', '')
+
+        data = bytes()
+        if data_encoding in cls.NUMPY_CODECS:
+            buffer = BytesIO(data)
+            np.save(buffer, array, allow_pickle=True)
+            return buffer.getvalue()
+        else:
+            return None
+
+    @classmethod
+    def is_codec_lossy(cls, codec: str) -> bool:
+        """ Checks if codec should be lossy
+
+        :param codec: [description]
+        :type codec: str
+        :return: TRUE if codec is lossy
+        :rtype: bool
+        """
+
+        if codec in cls.IMAGE_CODECS_IS_LOSSY:
+            return cls.IMAGE_CODECS_IS_LOSSY[codec]
+        return False
+
+    @classmethod
+    def has_codec_alpha(cls, codec: str) -> bool:
+        """ Checks if codec has alpha channel
+
+        :param codec: [description]
+        :type codec: str
+        :return: TRUE for alpha channel available
+        :rtype: bool
+        """
+
+        if codec in cls.IMAGE_CODECS_HAS_ALPHA:
+            return cls.IMAGE_CODECS_HAS_ALPHA[codec]
+        return False
diff --git a/pipelime/tools/idgenerators.py b/pipelime/tools/idgenerators.py
new file mode 100644
index 0000000..bd28b9c
--- /dev/null
+++ b/pipelime/tools/idgenerators.py
@@ -0,0 +1,30 @@
+
+from abc import ABC, abstractmethod
+from itertools import count
+from pipelime.factories import Bean, BeanFactory
+import uuid
+
+
+class IdGenerator(ABC):
+
+    @abstractmethod
+    def generate(self):
+        pass
+
+
+@BeanFactory.make_serializable
+class IdGeneratorInteger(IdGenerator, Bean):
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.COUNTER = count()
+
+    def generate(self):
+        return next(self.COUNTER)
+
+
+@BeanFactory.make_serializable
+class IdGeneratorUUID(IdGenerator, Bean):
+
+    def generate(self):
+        return str(uuid.uuid1())
diff --git a/pipelime/tools/toydataset.py b/pipelime/tools/toydataset.py
index d395fee..3a8554b 100644
--- a/pipelime/tools/toydataset.py
+++ b/pipelime/tools/toydataset.py
@@ -57,11 +57,11 @@ def generate_2d_object_bbox(self, size, obj):
 
     def generate_2d_object_keypoints(self, size, obj):
 
-        center = obj['center']
+        # center = obj['center']
         tl = obj['tl']
         br = obj['br']
         diag = obj['diag']
-        obj_size = obj['size']
+        # obj_size = obj['size']
         w, h = size
         label = obj['label']
 
@@ -149,7 +149,7 @@ def generate_toy_dataset(cls, output_folder: str, size: int, image_size: Sequenc
 
             # Naming
             name = str(idx).zfill(zfill)
-            image_name = f'{name}_image.jpg'
+            image_name = f'{name}_image.png'
             mask_name = f'{name}_mask.png'
             instances_name = f'{name}_inst.png'
             metadata_name = f'{name}_metadata.yml'
diff --git a/pipelime/workflow/cwl.py b/pipelime/workflow/cwl.py
new file mode 100644
index 0000000..a6815e0
--- /dev/null
+++ b/pipelime/workflow/cwl.py
@@ -0,0 +1,631 @@
+import typing
+import os
+from choixe.spooks import Spook
+import importlib.util
+from typing import Dict, Union, Sequence
+from pathlib import Path
+import click
+from schema import Or
+import yaml
+from pipelime import user_data_dir
+from choixe.configurations import XConfig
+
+
+class CwlNodesManager(object):
+
+    DEFAULT_CWL_EXTENSION = 'cwl'
+    DEFAULT_META_EXTENSION = 'yml'
+
+    @classmethod
+    def nodes_folder(cls) -> Path:
+        """ Default nodes folder
+
+        :return: folder
+        :rtype: Path
+        """
+
+        folder = user_data_dir() / cls.__name__
+        if not folder.exists():
+            folder.mkdir(parents=True, exist_ok=True)
+        return folder
+
+    @classmethod
+    def get_associated_meta_path(cls, p: Path) -> Path:
+        """ Gets cwl file associated metadata path
+
+        :param p: cwl file
+        :type p: Path
+        :return: metadata file
+        :rtype: Path
+        """
+
+        return p.parent / f'{p.stem}.{cls.DEFAULT_META_EXTENSION}'
+
+    @classmethod
+    def is_valid_cwl_file(cls, p: Path) -> bool:
+        """ Checks for valid cwl file
+
+        :param p: cwl file
+        :type p: Path
+        :return: TRUE if is valid cwl file
+        :rtype: bool
+        """
+
+        return p.is_file() and cls.get_associated_meta_path(p).exists()
+
+    @classmethod
+    def load_cwl_node(cls, p: Path) -> 'CwlNode':
+        """ Loads cwl node from path
+
+        :return: loaded CwlNode
+        :rtype: CwlNode
+        """
+
+        if cls.is_valid_cwl_file(p):
+            meta_p = cls.get_associated_meta_path(p)
+            cwl_template = Spook.create(d=XConfig(filename=meta_p).to_dict())
+            cwl_node = CwlNode(name=p.stem, cwl_path=str(p), cwl_template=cwl_template)
+            return cwl_node
+        return None
+
+    @classmethod
+    def available_nodes(cls, folder: Union[str, None] = None) -> Dict[str, 'CwlNode']:
+        """ Gets a map of stored CwlNodes
+
+        :return: stored nodes map [node_name: node]
+        :rtype: Dict[str, 'CwlNode']
+        """
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        cwls = folder.glob(f'*.{cls.DEFAULT_CWL_EXTENSION}')
+        cwls = [x for x in cwls if cls.is_valid_cwl_file(x)]
+        nodes = [cls.load_cwl_node(x) for x in cwls]
+        nodes = {x.name: x for x in nodes}
+        return nodes
+
+    @classmethod
+    def get_node_by_name(cls, name: str, folder: Union[str, None] = None) -> Union['CwlNode', None]:
+        """ Gets a node with the given name
+
+        :param name: node name
+        :type name: str
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :return: the node with given name if exists, else None
+        :rtype: Union['CwlNode', None]
+        """
+
+        nodes = cls.available_nodes(folder)
+        node = [v for k, v in nodes.items() if k == name]
+        if len(node) == 1:
+            node = node[0]
+        else:
+            node = None
+
+        return node
+
+    @classmethod
+    def create_node(cls, name: str, cwl_template: 'CwlTemplate', folder: Union[str, None] = None) -> 'CwlNode':
+        """ Creates a node with input name and CwlTemplate
+
+        :param name: input node name
+        :type name: str
+        :param cwl_template: input cwl template
+        :type cwl_template: CwlTemplate
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with same name found
+        :return: created node
+        :rtype: CwlNode
+        """
+
+        nodes = cls.available_nodes(folder=folder)
+        if name in nodes:
+            raise RuntimeError(f'Node with same name "{name}" found')
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        folder.mkdir(parents=True, exist_ok=True)
+        cwl_filename = folder / f'{name}.{cls.DEFAULT_CWL_EXTENSION}'
+        meta_filename = folder / f'{name}.{cls.DEFAULT_META_EXTENSION}'
+
+        cwl_template.dumps(cwl_filename)
+        XConfig.from_dict(d=cwl_template.serialize()).save_to(meta_filename)
+
+        return CwlNode(
+            name=name,
+            cwl_path=str(cwl_filename),
+            cwl_template=cwl_template
+        )
+
+    @classmethod
+    def delete_node(cls, name: str, folder: Union[str, None] = None):
+        """ Deletes a node given the name
+
+        :param name: node to delete name
+        :type name: str
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with this name not found
+        """
+
+        nodes = cls.available_nodes(folder=folder)
+        if name not in nodes:
+            raise RuntimeError(f'Node with name "{name}" not found')
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        cwl_filename = folder / f'{name}.{cls.DEFAULT_CWL_EXTENSION}'
+        meta_filename = folder / f'{name}.{cls.DEFAULT_META_EXTENSION}'
+
+        os.remove(cwl_filename)
+        os.remove(meta_filename)
+
+    @classmethod
+    def initialize_workflow(cls, names: Sequence[str], folder: Union[str, None] = None) -> 'CwlWorkflowTemplate':
+        """ Initializes a cwl worflow given a list of node names
+
+        :param names: names of the nodes to create the cwl workflow
+        :type names: Sequence[str]
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with one of these names not found
+        :return: the cwl workflow
+        :rtype: CwlWorkflowTemplate
+        """
+
+        nodes = cls.available_nodes(folder)
+        workflow_nodes = []
+        for name in names:
+            if name not in nodes:
+                raise RuntimeError(f'Node with name "{name}" not found')
+            workflow_nodes.append(cls.get_node_by_name(name, folder=folder))
+
+        workflow_template = CwlWorkflowTemplate(workflow_nodes)
+
+        return workflow_template
+
+    @classmethod
+    def fill_workflow(cls, workflow: 'CwlWorkflowTemplate', folder: Union[str, None] = None) -> 'CwlWorkflowTemplate':
+        """ Fills a cwl workflow adding the cwl path for each step
+
+        :param workflow: thw cwl workflow to be filled
+        :type workflow: CwlWorkflowTemplate
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :return: the cwl workflow
+        :rtype: CwlWorkflowTemplate
+        """
+
+        for _, opts in workflow.template['steps'].items():
+            node_name = opts['run']
+            node = cls.get_node_by_name(node_name, folder=folder)
+            opts['run'] = node.cwl_path
+
+        return workflow
+
+
+class CwlTemplate(Spook):
+
+    def __init__(self, script: str = None, alias: Sequence[str] = None, forwards: Sequence[str] = None):
+        """ Creates a cwl template from script containing click.Command
+
+        :param script: the script, defaults to None
+        :type script: str, optional
+        :param alias: the commands needed to call the script, defaults to None
+        :type alias: Sequence[str], optional
+        :param forwards: the input parameters to forward to output, defaults to None
+        :type forwards: Sequence[str], optional
+        """
+
+        super().__init__()
+
+        self._script = script
+        self._alias = alias
+        self._forwards = forwards
+
+        self._cmd = None
+        if self._script is not None:
+            self._cmd = self._load_click_command(self.resolved_script)
+        self._template = dict()
+        if self._cmd is not None:
+            self._fill()
+
+    @ classmethod
+    def spook_schema(cls) -> typing.Union[None, dict]:
+        return {
+            'script': Or(str, None),
+            'alias': Or([str], None),
+            'forwards': Or([str], None),
+        }
+
+    @ classmethod
+    def from_dict(cls, d: dict):
+        return cls(**d)
+
+    def to_dict(self) -> dict:
+        return {
+            'script': self._script,
+            'alias': self._alias,
+            'forwards': self._forwards
+        }
+
+    @property
+    def script(self):
+        return self._script
+
+    @property
+    def resolved_script(self):
+        return Path(self._script).absolute().resolve()
+
+    @property
+    def command(self):
+        return self._cmd
+
+    @property
+    def template(self):
+        return self._template
+
+    @property
+    def alias(self):
+        return self._alias
+
+    @property
+    def forwards(self):
+        return self._forwards
+
+    @property
+    def inputs(self):
+        return self._template.get('inputs', None)
+
+    @property
+    def inputs_keys(self):
+        if self.inputs is not None:
+            return list(self.inputs.keys())
+        return []
+
+    @property
+    def outputs(self):
+        return self._template.get('outputs', None)
+
+    @property
+    def outputs_keys(self):
+        if self.outputs is not None:
+            return list(self.outputs.keys())
+        return []
+
+    @classmethod
+    def from_command(cls, command: click.Command, alias: list = None, forwards: list = None) -> 'CwlTemplate':
+        """ Creates a cwl template directly from click.Command
+
+        :param command: the click command
+        :type command: click.Command
+        :param alias: the commands needed to call the script, defaults to None
+        :type alias: list, optional
+        :param forwards: the input parameters to forward to output, defaults to None
+        :type forwards: list, optional
+        :return: a cwl template
+        :rtype: CwlTemplate
+        """
+
+        cwl_template = CwlTemplate(alias=alias, forwards=forwards)
+        cwl_template._cmd = command
+        cwl_template._fill()
+        return cwl_template
+
+    def _fill(self):
+        """ Fills the template
+        """
+
+        self._init_template()
+        if self._alias is not None:
+            self._fill_command(self._alias)
+        self._fill_inputs(self._cmd)
+        if self._forwards is not None:
+            self._fill_outputs(self._forwards)
+
+    def _load_click_command(self, script: str) -> Union[click.Command, None]:
+        """ Loads a click.Command from file, it assumes
+        that the script contains only one command,
+        if there are no commands it will return None
+
+        :param script: the script filename
+        :type script: str
+        :return: the click command
+        :rtype: Union[click.Command, None]
+        """
+        try:
+            spec = importlib.util.spec_from_file_location(script.stem, script)
+            module = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(module)
+            cmd = None
+            for x in dir(module):
+                x = getattr(module, x)
+                if isinstance(x, click.core.Command):
+                    cmd = x
+                    break
+        except Exception:
+            cmd = None
+
+        return cmd
+
+    def _init_template(self):
+        """ Initializes the cwl template for a CommandLineTool
+        """
+
+        self._template['cwlVersion'] = 'v1.0'
+        self._template['class'] = 'CommandLineTool'
+        self._template['doc'] = ''
+        self._template['baseCommand'] = ''
+        self._template['inputs'] = dict()
+        self._template['outputs'] = dict()
+
+    def _fill_command(self, commands: list):
+        """ Fills the cwl template with the commands informations
+
+        :param commands: the commands needed to call the script
+        :type commands: list
+        """
+
+        self._template['baseCommand'] = commands
+
+    def _fill_inputs(self, cmd: click.Command):
+        """ Fills the cwl template with input informations
+
+        :param cmd: the click command
+        :type cmd: click.Command
+        """
+
+        self._template['doc'] = cmd.help
+        for param in cmd.params:
+            param: click.Option
+            cwl_param = dict()
+            cwl_param['doc'] = param.help
+            cwl_type = self._convert_type(param)
+            cwl_param.update(cwl_type)
+            self._template['inputs'][param.name] = cwl_param
+
+    def _convert_type(self, param: click.Option) -> dict:
+        """ Converts a click type into a cwl type
+
+        :param param: the click option
+        :type param: click.Option
+        :return: the cwl type
+        :rtype: dict
+        """
+
+        cwl_types = dict()
+        cwl_types['type'] = []
+        if not param.required:
+            cwl_types['type'].append('null')
+
+        if not param.multiple and param.nargs == 1:
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type = 'float'
+            elif isinstance(param.type, click.types.BoolParamType):
+                cwl_type = 'boolean'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type = 'string'
+
+        elif (not param.multiple and param.nargs > 1) or (param.multiple and param.nargs == 1):
+            cwl_type = dict()
+            cwl_type['type'] = 'array'
+
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type['items'] = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type['items'] = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type['items'] = 'float'
+            elif isinstance(param.type, click.types.BoolParamType):
+                cwl_type['items'] = 'boolean'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type['items'] = 'string'
+
+        # can't have bool with nargs > 1
+        elif param.multiple and param.nargs > 1:
+            cwl_type = dict()
+            cwl_type['type'] = 'array'
+
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'float'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'string'
+
+        cwl_types['type'].append(cwl_type)
+
+        if not param.multiple:
+            if not param.required and param.default is not None:
+                cwl_types['default'] = param.default
+            cwl_types['inputBinding'] = dict()
+            cwl_types['inputBinding']['prefix'] = param.opts[0]
+        elif param.multiple:
+            if not param.required and param.default is not None:
+                cwl_types['type'][-1]['default'] = param.default
+            cwl_types['type'][-1]['inputBinding'] = dict()
+            cwl_types['type'][-1]['inputBinding']['prefix'] = param.opts[0]
+
+        return cwl_types
+
+    def _fill_outputs(self, forwards: list):
+        """ Fills the cwl template with outputs informations
+
+        :param forwards: the input parameters to forward to output
+        :type forwards: list
+        """
+
+        for fw in forwards:
+            output_name = f'_{fw}'
+            self._template['outputs'][output_name] = dict()
+            output_type = self._template['inputs'][fw]['type']
+            output_type = output_type[-1]
+            if isinstance(output_type, str):
+                self._template['outputs'][output_name]['type'] = output_type
+            elif isinstance(output_type, dict):
+                self._template['outputs'][output_name]['type'] = dict()
+                self._template['outputs'][output_name]['type']['type'] = output_type['type']
+                self._template['outputs'][output_name]['type']['items'] = output_type['items']
+            self._template['outputs'][output_name]['outputBinding'] = dict()
+            self._template['outputs'][output_name]['outputBinding']['outputEval'] = f'$(inputs.{fw})'
+
+    def dumps(self, path: str):
+        """ Dumps the cwl template to target file
+
+        :param path: the output complete filename
+        :type path: str
+        """
+
+        class NoAliasDumper(yaml.SafeDumper):
+            def ignore_aliases(self, data):
+                return True
+
+        with open(path, 'w') as f:
+            yaml.dump(self._template, f, Dumper=NoAliasDumper, sort_keys=False)
+        # reads and writes again to add the first line
+        with open(path, 'r') as f:
+            lines = f.readlines()
+        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
+        with open(path, 'w') as f:
+            f.writelines(lines)
+
+
+class CwlNode:
+
+    def __init__(self, name: str, cwl_path: str, cwl_template: CwlTemplate) -> None:
+        self._name = name
+        self._cwl_path = cwl_path
+        self._cwl_template = cwl_template
+        self._is_valid = cwl_template.command is not None
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def cwl_path(self):
+        return self._cwl_path
+
+    @property
+    def cwl_template(self):
+        return self._cwl_template
+
+    @property
+    def is_valid(self):
+        return self._is_valid
+
+
+class CwlWorkflowTemplate(object):
+
+    def __init__(self, nodes: Sequence[CwlNode] = None):
+        """ Creates a cwl workflow template from list of CwlNode
+
+        :param nodes: steps of the workflow
+        :type nodes: Sequence[CwlNode]
+        """
+
+        self._nodes = nodes
+        self._template = dict()
+
+        if self._nodes is not None:
+            self._fill()
+
+    @property
+    def template(self):
+        return self._template
+
+    @classmethod
+    def from_file(cls, filename: str) -> 'CwlWorkflowTemplate':
+        """ Creates a cwl workflow template from a file
+
+        :param filename: the file template
+        :type filename: str
+        :return: a cwl workflow template
+        :rtype: CwlWorkflowTemplate
+        """
+
+        with open(filename, 'r') as f:
+            template = yaml.safe_load(f)
+
+        workflow_template = CwlWorkflowTemplate()
+        workflow_template._template = template
+        return workflow_template
+
+    def _fill(self):
+        """ Fills the template
+        """
+
+        self._init_template()
+        self._fill_steps(self._nodes)
+
+    def _init_template(self):
+        """ Initializes the cwl workflow template for a CommandLineTool
+        """
+
+        self._template['cwlVersion'] = 'v1.0'
+        self._template['class'] = 'Workflow'
+        self._template['requirements'] = {
+            'StepInputExpressionRequirement': dict(),
+            'InlineJavascriptRequirement': dict(),
+            'MultipleInputFeatureRequirement': dict(),
+            'ScatterFeatureRequirement': dict()
+        }
+        self._template['inputs'] = dict()
+        self._template['outputs'] = list()
+        self._template['steps'] = dict()
+
+    def _fill_steps(self, steps: Sequence[CwlNode]):
+        """ Fills the cwl workflow template with steps informations
+
+        :param steps: the steos of the workflow
+        :type steps: Sequence[CwlNode]
+        """
+
+        # avoids name conflicts when a node is used more than one time
+        counter = {x.name: 0 for x in steps}
+
+        for step in steps:
+            cwl_step = dict()
+            cwl_step['run'] = step.name
+            cwl_step['in'] = {k: '' for k in step.cwl_template.inputs_keys}
+            cwl_step['out'] = step.cwl_template.outputs_keys
+            step_name = f'{step.name}{counter[step.name]}'
+            self._template['steps'][step_name] = cwl_step
+            counter[step.name] += 1
+
+            # fill worfkflow inputs based on step inputs
+            for input_name, input_opt in step.cwl_template.inputs.items():
+                self._template['inputs'][f'{step_name}_{input_name}'] = input_opt['type']
+
+    def dumps(self, path: str):
+        """ Dumps the cwl workflow template to target file
+
+        :param path: the output complete filename
+        :type path: str
+        """
+
+        class NoAliasDumper(yaml.SafeDumper):
+            def ignore_aliases(self, data):
+                return True
+
+        with open(path, 'w') as f:
+            yaml.dump(self._template, f, Dumper=NoAliasDumper, sort_keys=False)
+        # reads and writes again to add the first line
+        with open(path, 'r') as f:
+            lines = f.readlines()
+        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
+        with open(path, 'w') as f:
+            f.writelines(lines)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b5a3c46
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = [
+    "setuptools>=42",
+    "wheel"
+]
+build-backend = "setuptools.build_meta"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 9dbc08f..0ed6bf8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,4 +6,6 @@ albumentations>=0.5.2
 choixe
 imageio
 pydash
-Pillow
\ No newline at end of file
+Pillow>=8.0.0
+pygraphviz
+appdirs
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index 66aaf1f..26de452 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.0.1
+current_version = 0.1.0
 commit = True
 tag = True
 
@@ -18,7 +18,6 @@ universal = 1
 exclude = docs
 
 [aliases]
-# Define setup.py command aliases here
 test = pytest
 
 [tool:pytest]
diff --git a/setup.py b/setup.py
index ca07be8..9d0f5e1 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@
     description="data pipeline 101",
     entry_points={
         'console_scripts': [
-            'pipelime=pipelime.cli:main',
+            'pipelime=pipelime.cli.main:pipelime',
         ],
     },
     install_requires=requirements,
@@ -46,6 +46,6 @@
     test_suite='tests',
     tests_require=test_requirements,
     url='https://github.com/eyecan-ai/pipelime',
-    version='0.0.1',
+    version='0.1.0',
     zip_safe=False,
 )


Test patch:
diff --git a/examples/op_tests.py b/examples/garbage/op_tests.py
similarity index 100%
rename from examples/op_tests.py
rename to examples/garbage/op_tests.py
diff --git a/examples/pipe_test_2.py b/examples/garbage/pipe_test_2.py
similarity index 100%
rename from examples/pipe_test_2.py
rename to examples/garbage/pipe_test_2.py
diff --git a/examples/test_cfg.yml b/examples/garbage/test_cfg.yml
similarity index 100%
rename from examples/test_cfg.yml
rename to examples/garbage/test_cfg.yml
diff --git a/tests/conftest.py b/tests/conftest.py
index ca1f096..ced2333 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+import yaml
 from pipelime.tools.toydataset import ToyDatasetGenerator
 import pytest
 import os
@@ -12,7 +13,7 @@ def data_folder():
 
 @pytest.fixture(scope='function')
 def toy_dataset_small(tmpdir):
-    folder = Path(tmpdir)
+    folder = Path(tmpdir.mkdir("toy_dataset"))
     datafolder = folder / 'data'
     size = 32
     image_size = 256
@@ -24,6 +25,14 @@ def toy_dataset_small(tmpdir):
         zfill=zfill
     )
 
+    global_meta = {
+        'name': 'toy_dataset_small',
+        'numbers': [1, 2, 3, 4, 5, 6]
+    }
+
+    global_meta_name = folder / 'global_meta.yml'
+    yaml.safe_dump(global_meta, open(global_meta_name, 'w'))
+
     return {
         'folder': folder,
         'data_folder': datafolder,
@@ -32,5 +41,38 @@ def toy_dataset_small(tmpdir):
         'zfill': zfill,
         'keypoints_format': 'xyas',
         'bboxes_format': 'pascal_voc',
-        'expected_keys': ['image', 'mask', 'inst', 'keypoints', 'bboxes']
+        'expected_keys': ['image', 'mask', 'inst', 'keypoints', 'metadata', 'bboxes'],
+        'root_keys': ['global_meta']
+    }
+
+
+@pytest.fixture(scope='session')
+def filesystem_datasets(data_folder):
+    return {
+        'minimnist_underfolder': {
+            'folder': Path(data_folder) / 'datasets' / 'underfolder_minimnist',
+            'type': 'Undefolder',
+            'schemas': {
+                'simple': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'simple.schema',
+                    'valid': True,
+                    'should_pass': True
+                },
+                'deep': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'deep.schema',
+                    'valid': True,
+                    'should_pass': True
+                },
+                'invalid': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'invalid.schema',
+                    'valid': True,
+                    'should_pass': False
+                },
+                'bad_file': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'bad.schema',
+                    'valid': False,
+                    'should_pass': False
+                }
+            }
+        }
     }
diff --git a/tests/pipelime/sequences/conftest.py b/tests/pipelime/sequences/conftest.py
index 9ab921e..90b0f98 100644
--- a/tests/pipelime/sequences/conftest.py
+++ b/tests/pipelime/sequences/conftest.py
@@ -1,3 +1,4 @@
+
 from pathlib import Path
 from pipelime.tools.toydataset import ToyDatasetGenerator
 from pipelime.sequences.samples import PlainSample, SamplesSequence
@@ -19,6 +20,7 @@ def generate_plain_samples(
             data = {
                 'idx': f'{namespace}{i}',
                 'number': i,
+                'reverse_number': N - i,
                 'fraction': i / 1000.,
                 'odd': i % 2 == 1,
                 'data0': np.random.uniform(0.0, 1.0, (64, 64, 3)) if heavy_data else 1.64643,
diff --git a/tests/pipelime/sequences/io/__init__.py b/tests/pipelime/sequences/io/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/pipelime/sequences/io/test_filesystem.py b/tests/pipelime/sequences/io/test_filesystem.py
new file mode 100644
index 0000000..b371302
--- /dev/null
+++ b/tests/pipelime/sequences/io/test_filesystem.py
@@ -0,0 +1,183 @@
+
+
+import uuid
+import numpy as np
+from pathlib import Path
+from pipelime.sequences.operations import OperationFilterKeys
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from pipelime.factories import BeanFactory
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import FileSystemSample, FilesystemItem, Sample
+from pipelime.sequences.readers.filesystem import UnderfolderReader, UnderfolderReaderTemplate
+from schema import Schema
+
+
+def _plug_test(reader: BaseReader):
+    """ Test what a generic BaseReader should do
+
+    :param reader: input BaseReader
+    :type reader: BaseReader
+    """
+
+    assert isinstance(reader, BaseReader)
+
+    print(reader.serialize())
+    rereader = reader.hydrate(reader.serialize())
+    assert isinstance(rereader, BaseReader)
+
+    factored = BeanFactory.create(reader.serialize())
+    assert isinstance(factored, BaseReader)
+
+
+class TestUnderfolder(object):
+
+    def test_reader(self, toy_dataset_small):
+
+        size = toy_dataset_small['size']
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        copies = [True, False]
+        for copy in copies:
+            reader = UnderfolderReader(folder=folder, copy_root_files=copy)
+            _plug_test(reader)
+            assert len(reader) == size
+
+            for sample in reader:
+                assert isinstance(sample, Sample)
+                assert isinstance(sample, FileSystemSample)
+
+                for key in keys:
+                    assert key in sample
+                    assert isinstance(sample.metaitem(key), FilesystemItem)
+
+                if copy:
+                    for key in root_keys:
+                        assert key in sample
+                        assert isinstance(sample.metaitem(key), FilesystemItem)
+
+
+class TestUnderfolderReaderWriterTemplating(object):
+
+    def test_reader_writer(self, toy_dataset_small, tmpdir_factory):
+
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        reader = UnderfolderReader(folder=folder, copy_root_files=True)
+        template = reader.get_filesystem_template()
+        assert template is not None
+        assert isinstance(template, UnderfolderReaderTemplate)
+
+        assert set(template.extensions_map.keys()) == set(keys + root_keys)
+        assert set(template.root_files_keys) == set(root_keys)
+
+        writer_folder = Path(tmpdir_factory.mktemp("writer_folder"))
+        writer = UnderfolderWriter(
+            folder=writer_folder,
+            root_files_keys=template.root_files_keys,
+            extensions_map=template.extensions_map,
+            zfill=template.idx_length
+        )
+        writer(reader)
+        print("Writer", writer_folder)
+
+        re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+        re_template = re_reader.get_filesystem_template()
+
+        assert set(template.extensions_map.keys()) == set(re_template.extensions_map.keys())
+        assert set(template.root_files_keys) == set(re_template.root_files_keys)
+        assert template.idx_length == re_template.idx_length
+
+    def test_reader_writer_without_explicit_template(self, toy_dataset_small, tmpdir_factory):
+
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        reader = UnderfolderReader(folder=folder, copy_root_files=True)
+        template = reader.get_filesystem_template()
+        assert template is not None
+        assert isinstance(template, UnderfolderReaderTemplate)
+        assert set(template.extensions_map.keys()) == set(keys + root_keys)
+        assert set(template.root_files_keys) == set(root_keys)
+
+        writer_folder = Path(tmpdir_factory.mktemp("writer_folder"))
+        writer = UnderfolderWriter(folder=writer_folder)
+        writer(reader)
+        print("Writer", writer_folder)
+
+        re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+        re_template = re_reader.get_filesystem_template()
+
+        assert set(template.extensions_map.keys()) == set(re_template.extensions_map.keys())
+        assert set(template.root_files_keys) == set(re_template.root_files_keys)
+        assert template.idx_length == re_template.idx_length
+
+    def test_filtered_reader_writer_without_explicit_template(self, toy_dataset_small, tmpdir_factory):
+
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        reader = UnderfolderReader(folder=folder, copy_root_files=True)
+        op = OperationFilterKeys(keys=keys[0], negate=False)
+
+        filtered_reader = op(reader)
+
+        writer_folder = Path(tmpdir_factory.mktemp("writer_folder"))
+        print("Filtered writer", writer_folder)
+        writer = UnderfolderWriter(
+            folder=writer_folder,
+            extensions_map=reader.get_filesystem_template().extensions_map,
+            root_files_keys=reader.get_filesystem_template().root_files_keys,
+            zfill=reader.get_filesystem_template().idx_length
+        )
+        writer(filtered_reader)
+
+
+class TestUnderfolderReaderWriterConsistency(object):
+
+    def test_reader_writer_content(self, toy_dataset_small, tmpdir_factory):
+
+        # ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
+        # be careful comparing the content of the samples!
+        # For example, make sure they are not JPEGs because they could be compressed
+        # when being written and therefore change their numeric content.
+        # ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
+
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        combo_items = [
+            {'copy_files': True, 'use_symlinks': False},
+            {'copy_files': False, 'use_symlinks': False},
+            {'copy_files': False, 'use_symlinks': True},
+            {'copy_files': True, 'use_symlinks': True},
+        ]
+
+        for combo in combo_items:
+
+            reader = UnderfolderReader(folder=folder, copy_root_files=True)
+            template = reader.get_filesystem_template()
+
+            print("\nCombo", combo)
+            writer_folder = Path(tmpdir_factory.mktemp(str(uuid.uuid1())))
+            print(writer_folder)
+            writer = UnderfolderWriter(
+                folder=writer_folder,
+                copy_files=combo['copy_files'],
+                use_symlinks=combo['use_symlinks']
+            )
+            writer(reader)
+
+            re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+            re_template = re_reader.get_filesystem_template()
+
+            for idx in range(len(re_reader)):
+                data = reader[idx]['image']
+                re_data = re_reader[idx]['image']
+                assert np.array_equal(data, re_data)
diff --git a/tests/pipelime/sequences/readers/test_filesystem.py b/tests/pipelime/sequences/readers/test_filesystem.py
deleted file mode 100644
index 6becbb9..0000000
--- a/tests/pipelime/sequences/readers/test_filesystem.py
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-from pipelime.sequences.samples import FileSystemSample, FilesystemItem, Sample
-from pipelime.sequences.readers.filesystem import UnderfolderReader
-
-
-class TestUnderfolder(object):
-
-    def test_reader(self, toy_dataset_small):
-        print("OK", toy_dataset_small)
-
-        size = toy_dataset_small['size']
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-
-        reader = UnderfolderReader(folder=folder)
-        assert len(reader) == size
-
-        for sample in reader:
-            assert isinstance(sample, Sample)
-            assert isinstance(sample, FileSystemSample)
-
-            for key in keys:
-                assert key in sample
-                assert isinstance(sample.metaitem(key), FilesystemItem)
diff --git a/tests/pipelime/sequences/test_operations.py b/tests/pipelime/sequences/test_operations.py
index 23166c9..43fabdb 100644
--- a/tests/pipelime/sequences/test_operations.py
+++ b/tests/pipelime/sequences/test_operations.py
@@ -1,12 +1,19 @@
 import pydash
+from pipelime.tools.idgenerators import IdGeneratorInteger, IdGeneratorUUID
 import functools
 import hashlib
 from typing import Dict, Sequence
 import pytest
 import rich
-from schema import Schema
-from pipelime.sequences.samples import PlainSample, SamplesSequence
-from pipelime.sequences.operations import OperationDict2List, OperationFilterByQuery, OperationGroupBy, OperationIdentity, OperationPort, OperationShuffle, OperationSplitByQuery, OperationSplits, OperationSubsample, OperationSum, SequenceOperation, SequenceOperationFactory
+from pipelime.sequences.samples import SamplesSequence
+from pipelime.sequences.operations import (
+    OperationDict2List, OperationFilterByQuery, OperationFilterKeys, OperationGroupBy,
+    OperationIdentity, OperationOrderBy, OperationPort, OperationResetIndices, OperationShuffle,
+    OperationSplitByQuery, OperationSplits, OperationSubsample,
+    OperationSum, SequenceOperation
+)
+
+from pipelime.factories import BeanFactory
 
 
 def _plug_test(op: SequenceOperation):
@@ -22,12 +29,12 @@ def _plug_test(op: SequenceOperation):
     assert op.input_port().match(op.input_port())
     assert op.output_port().match(op.output_port())
 
-    reop = op.build_from_dict(op.to_dict())
+    reop = op.from_dict(op.to_dict())
     assert isinstance(reop, SequenceOperation)
-    assert isinstance(op.factory_schema(), Schema)
+    assert isinstance(op.bean_schema(), dict)
     op.print()
 
-    factored = SequenceOperationFactory.create(op.to_dict())
+    factored = BeanFactory.create(op.serialize())
     assert isinstance(factored, SequenceOperation)
 
 
@@ -140,6 +147,75 @@ def test_shuffle(self, plain_samples_sequence_generator):
             assert len(dataset) == len(out)
 
 
+class TestOperationResetIndices(object):
+
+    def test_reset(self, plain_samples_sequence_generator):
+
+        generators = [
+            IdGeneratorUUID(),
+            IdGeneratorInteger()
+        ]
+        N = 32
+        for generator in generators:
+            dataset = plain_samples_sequence_generator('d0_', N)
+            dataset_clone = plain_samples_sequence_generator('d0_', N)
+
+            op = OperationResetIndices(generator=generator)
+            _plug_test(op)
+            out = op(dataset)
+
+            for idx in range(len(dataset)):
+                assert dataset[idx].id != dataset_clone[idx].id
+
+            assert len(dataset) == len(out)
+
+
+class TestOperationOrderBy(object):
+
+    def _sign(self, dataset: SamplesSequence):
+        names = [x['idx'] for x in dataset.samples]
+        return hashlib.md5(bytes('_'.join(names), encoding='utf-8')).hexdigest()
+
+    def test_orderby(self, plain_samples_sequence_generator):
+
+        N = 32
+        dataset = plain_samples_sequence_generator('d0_', N)
+
+        order_items = [
+            {'keys': ['reverse_number'], 'different': True},
+            {'keys': ['+reverse_number'], 'different': True},
+            {'keys': ['-reverse_number'], 'different': False},
+            {'keys': ['-reverse_number', 'metadata.deep.groupby_field'], 'different': False},
+            {'keys': ['metadata.deep.groupby_field', '-reverse_number'], 'different': False},
+            {'keys': ['metadata.deep.groupby_field', 'reverse_number'], 'different': True},
+            {'keys': ['-metadata.deep.groupby_field'], 'different': True},
+        ]
+
+        for order_item in order_items:
+            order_keys = order_item['keys']
+            should_be_different = order_item['different']
+
+            op = OperationOrderBy(order_keys=order_keys)
+            _plug_test(op)
+            out = op(dataset)
+
+            if should_be_different:
+                assert self._sign(out) != self._sign(dataset)
+            else:
+                assert self._sign(out) == self._sign(dataset)
+
+            # for idx in range(len(dataset)):
+            #     print("Sample", idx)
+            #     print(pydash.get(dataset[idx], 'metadata.deep.groupby_field'), dataset[idx]['reverse_number'])
+            #     print(pydash.get(out[idx], 'metadata.deep.groupby_field'), out[idx]['reverse_number'])
+            #     if should_be_different:
+            #         assert dataset[idx].id != out[idx].id
+            #     else:
+            #         assert dataset[idx].id == out[idx].id
+
+            assert len(dataset) == len(out)
+
+
 class TestOperationSplits(object):
 
     def test_splits(self, plain_samples_sequence_generator):
@@ -285,6 +361,38 @@ def test_split_by_query(self, plain_samples_sequence_generator):
             assert sumup == sumup_exp
 
 
+class TestOperationFilterKeys(object):
+
+    def test_filter_keys(self, plain_samples_sequence_generator):
+
+        N = 128
+        dataset = plain_samples_sequence_generator('d0_', N)
+
+        keys_to_filter = [
+            {'keys': ['idx'], 'negate': False},
+            {'keys': ['idx'], 'negate': True},
+            {'keys': ['number', 'data0'], 'negate': False},
+            {'keys': ['number', 'data0'], 'negate': True},
+            {'keys': [], 'negate': False},
+        ]
+
+        for item in keys_to_filter:
+            keys = item['keys']
+            negate = item['negate']
+            op = OperationFilterKeys(keys=keys, negate=negate)
+            _plug_test(op)
+            out = op(dataset)
+
+            assert len(out) == len(dataset)
+
+            for sample in out:
+                for key in keys:
+                    if not negate:
+                        assert key in sample
+                    else:
+                        assert key not in sample
+
+
 class TestOperationGroupBy(object):
 
     def test_group_by(self, plain_samples_sequence_generator):
diff --git a/tests/pipelime/sequences/test_pipes.py b/tests/pipelime/sequences/test_pipes.py
new file mode 100644
index 0000000..642e446
--- /dev/null
+++ b/tests/pipelime/sequences/test_pipes.py
@@ -0,0 +1,211 @@
+from pipelime.sequences.operations import OperationFilterByQuery, OperationResetIndices, OperationSplitByQuery, OperationSplits, OperationSubsample, OperationSum
+from pipelime.tools.idgenerators import IdGeneratorUUID
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from pipelime.factories import BeanFactory
+from pipelime.sequences.pipes import NodeGraph, ReaderNode, OperationNode, WriterNode
+import pytest
+
+
+# @pytest.fixture
+# def simple_graph():
+#     return {
+#         '__type__': 'NodeGraph',
+#         'options': {
+#             'nodes': [
+#                 {
+#                     '__type__': 'ReaderNode',
+#                     'options': {
+#                         'output_data': 'A',
+#                         'reader': {
+#                             '__type__': 'UnderfolderReader',
+#                             'options': {
+#                                 'folder': '/Users/daniele/Downloads/lego_dataset/lego_00',
+#                             }
+#                         }
+#                     }
+#                 },
+#                 {
+#                     '__type__': 'ReaderNode',
+#                     'options': {
+#                         'output_data': 'B',
+#                         'reader': {
+#                             '__type__': 'UnderfolderReader',
+#                             'options': {
+#                                 'folder': '/Users/daniele/Downloads/lego_dataset/lego_01'
+#                             }
+#                         }
+#                     }
+#                 },
+#                 {
+#                     '__type__': 'OperationNode',
+#                     'options': {
+#                         'input_data': 'A',
+#                         'output_data': 'A_filtered',
+#                         'operation': {
+#                             '__type__': 'OperationFilterByQuery',
+#                             'options': {
+#                                 'query': '`metadata.tag` == "image"'
+#                             }
+#                         }
+#                     }
+#                 },
+#                 {
+#                     '__type__': 'OperationNode',
+#                     'options': {
+#                         'input_data': ['A_filtered', 'B'],
+#                         'output_data': 'C',
+#                         'operation': {
+#                             '__type__': 'OperationSum',
+#                             'options': {}
+#                         }
+#                     }
+#                 },
+#                 {
+#                     '__type__': 'OperationNode',
+#                     'options': {
+#                         'input_data': 'C',
+#                         'output_data': 'D',
+#                         'operation': {
+#                             '__type__': 'OperationSplits',
+#                             'options': {
+#                                 'split_map': {
+#                                     'train': 0.45,
+#                                     'val': 0.45,
+#                                     'test': 0.1
+#                                 }
+#                             }
+#                         }
+#                     }
+#                 },
+
+#             ]
+#         }
+#     }
+
+
+class TestPipes(object):
+
+    def test_simple_graph(self, filesystem_datasets, tmp_path_factory):
+
+        print(filesystem_datasets)
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+
+        out_folders = {k: tmp_path_factory.mktemp(k) for k in ['D0', 'D1', 'D2']}
+        r = UnderfolderReader(folder=dataset_folder)
+        N = len(r)
+        EXPECTED = N * 3 - (2 * 3)  # '`metadatay.half` >= 1.0' query removes 2 samples per input dataset (3)
+        generator = IdGeneratorUUID()
+
+        nodes = [
+            ReaderNode(
+                id=generator.generate(),
+                output_data='A',
+                reader=UnderfolderReader(folder=dataset_folder)
+            ),
+            ReaderNode(
+                id=generator.generate(),
+                output_data='B',
+                reader=UnderfolderReader(folder=dataset_folder)
+            ),
+            ReaderNode(
+                id=generator.generate(),
+                output_data='C',
+                reader=UnderfolderReader(folder=dataset_folder)
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data=['A', 'B', 'C'],
+                output_data='X',
+                operation=OperationSum()
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data='X',
+                output_data=['X0', 'X1'],
+                operation=OperationSplitByQuery(query='`metadatay.sample_id` <= 5')
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data='X0',
+                output_data='X0_f',
+                operation=OperationFilterByQuery(query='`metadatay.half` >= 1.0')
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data='X1',
+                output_data='X1_f',
+                operation=OperationFilterByQuery(query='`metadatay.half` >= 1.0')
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data=['X0_f', 'X1_f'],
+                output_data='Y',
+                operation=OperationSum()
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data='Y',
+                output_data='OUT',
+                operation=OperationResetIndices(
+                    generator=IdGeneratorUUID()
+                )
+            ),
+            OperationNode(
+                id=generator.generate(),
+                input_data='OUT',
+                output_data={
+                    'a': 'D0',
+                    'b': 'D1',
+                    'c': 'D2',
+                },
+                operation=OperationSplits(split_map={
+                    'a': 0.25,
+                    'b': 0.25,
+                    'c': 0.50
+                })
+            )
+        ]
+
+        for k, v in out_folders.items():
+            nodes.append(
+                WriterNode(
+                    id=generator.generate(),
+                    input_data=k,
+                    writer=UnderfolderWriter(
+                        folder=out_folders[k],
+                        root_files_keys=['cfg', 'numbers', 'pose']
+                    )
+                )
+            )
+
+        # print(n)
+        graph = NodeGraph(nodes=nodes)
+        import rich
+        rich.print(graph.serialize())
+        graph = BeanFactory.create(graph.serialize())
+
+        f, a = graph.draw_to_file()
+        print(f, a)
+
+        graph.execute()
+
+        counter = 0
+        for k, v in out_folders.items():
+            out_reader = UnderfolderReader(folder=v, copy_root_files=True)
+            counter += len(out_reader)
+            print("OUT", k, v)
+
+        assert counter == EXPECTED
+        print("COUNTER", N, '/', counter)
+        print(len(graph._data_cache['X']))
+        print(len(graph._data_cache['X0']))
+        print(len(graph._data_cache['X1']))
+        print(len(graph._data_cache['X0_f']))
+        print(len(graph._data_cache['X1_f']))
+        print(len(graph._data_cache['Y']))
+        print(len(graph._data_cache['D0']))
+        print(len(graph._data_cache['D1']))
+        print(len(graph._data_cache['D2']))
+        # g = BeanFactory.create(simple_graph)
+        # assert isinstance(g, NodeGraph)
diff --git a/tests/pipelime/sequences/test_samples.py b/tests/pipelime/sequences/test_samples.py
index 0c1abd2..ccec1dc 100644
--- a/tests/pipelime/sequences/test_samples.py
+++ b/tests/pipelime/sequences/test_samples.py
@@ -1,18 +1,76 @@
+import numpy as np
+from pipelime.sequences.readers.filesystem import UnderfolderReader
 import rich
-from pipelime.sequences.samples import GroupedSample, PlainSample, SamplesSequence
+from pipelime.sequences.samples import FileSystemSample, FilesystemItem, GroupedSample, MemoryItem, PlainSample, SamplesSequence
+
+
+class TestPlainSamples(object):
+
+    def test_plain_sample(self, plain_samples_generator):
+
+        dataset = plain_samples_generator('d0_', 10)
+        for sample in dataset:
+            assert set(sample.keys()) == set(sample.skeleton.keys())
+            for key in sample.keys():
+                assert isinstance(sample.metaitem(key), MemoryItem)
 
 
 class TestGroupedSamples(object):
 
+    def _empty(*args):
+        pass
+
     def test_groupby_samples(self, plain_samples_generator):
 
         dataset = plain_samples_generator('d0_', 10)
 
-        g = GroupedSample(samples=dataset[::2])
-
-        for sample in dataset:
-            for key in sample.keys():
-                print(key, sample[key])
+        samples = dataset[::2]
+        g = GroupedSample(samples=samples)
+        g_copy = g.copy()
 
         for key in g.keys():
-            rich.print(g[key])
+            self._empty(key, g[key], len(g))
+            assert isinstance(g.metaitem(key), type(samples[0].metaitem(key)))
+
+        g.rename('image', 'NEW_IMAGE')
+        assert 'image' not in g
+
+        for key in list(g.keys()):
+            del g[key]
+
+        assert len(g.keys()) == 0
+        assert len(g_copy.keys()) == 0
+
+        g['my_new_key'] = 112.3
+        assert len(g.keys()) == 1
+
+        assert len(GroupedSample(samples=[])) == 0
+        assert len(GroupedSample(samples=[]).keys()) == 0
+
+
+class TestFilesystemSample(object):
+
+    def test_filesystem_sample(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+
+        reader = UnderfolderReader(folder=dataset_folder)
+
+        sample = reader[0]
+
+        for key in sample.keys():
+            assert isinstance(sample.metaitem(key), FilesystemItem)
+
+        assert len(sample) > 0
+        assert isinstance(sample['image'], np.ndarray)
+        assert isinstance(sample, FileSystemSample)
+
+        sample.rename('image', '_NEW_IMAGE_')
+        assert 'image' not in sample
+
+        del sample['_NEW_IMAGE_']
+
+    def test_filesystem_sample_nonlazy(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        reader = UnderfolderReader(folder=dataset_folder, lazy_samples=False)
diff --git a/tests/pipelime/sequences/test_sequence.py b/tests/pipelime/sequences/test_sequence.py
new file mode 100644
index 0000000..067f895
--- /dev/null
+++ b/tests/pipelime/sequences/test_sequence.py
@@ -0,0 +1,34 @@
+from pipelime.sequences.samples import SamplesSequence
+
+
+class TestSequenceNormalized(object):
+
+    def test_normalized(self, plain_samples_sequence_generator):
+
+        N = 100
+        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        assert dataset.is_normalized()
+
+        # Changes all keys change should be normalized as well
+        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        for i in range(N):
+            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{i}'}
+        assert dataset.is_normalized()
+
+        # Changes All keys but last should be not normalized
+        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        for i in range(N - 2):
+            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{i}'}
+        assert not dataset.is_normalized()
+
+        # Changes All keys but first should be not normalized
+        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        for i in range(1, N):
+            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{0}'}
+        assert not dataset.is_normalized()
+
+        # Changes first key should be not normalized
+        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        for i in range(1):
+            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{0}'}
+        assert not dataset.is_normalized()
diff --git a/tests/pipelime/sequences/test_stages.py b/tests/pipelime/sequences/test_stages.py
index 83efc1f..487a0bc 100644
--- a/tests/pipelime/sequences/test_stages.py
+++ b/tests/pipelime/sequences/test_stages.py
@@ -1,7 +1,11 @@
 from pipelime.sequences.readers.filesystem import UnderfolderReader
 from pipelime.sequences.samples import PlainSample
 from schema import Schema
-from pipelime.sequences.stages import SampleStage, SampleStagesFactory, StageAugmentations, StageIdentity, StageRemap
+from pipelime.sequences.stages import (
+    SampleStage, StageAugmentations,
+    StageCompose, StageIdentity, StageKeysFilter, StageRemap
+)
+from pipelime.factories import BeanFactory
 
 
 def _plug_test(stage: SampleStage):
@@ -13,11 +17,11 @@ def _plug_test(stage: SampleStage):
 
     assert isinstance(stage, SampleStage)
 
-    restage = stage.build_from_dict(stage.to_dict())
+    restage = stage.from_dict(stage.to_dict())
     assert isinstance(restage, SampleStage)
-    assert isinstance(restage.factory_schema(), Schema)
+    assert isinstance(restage.bean_schema(), dict)
 
-    factored = SampleStagesFactory.create(stage.to_dict())
+    factored = BeanFactory.create(stage.serialize())
     assert isinstance(factored, SampleStage)
 
 
@@ -66,6 +70,24 @@ def test_remap_remove(self):
         assert 'idx' not in out
 
 
+class TestStageKeysFilter(object):
+
+    def test_filter(self):
+
+        s = PlainSample(data={'name': 'sample', 'idx': 111, 'tail': 2.2})
+
+        negates = [True, False]
+        for negate in negates:
+            stage = StageKeysFilter(keys=['name', 'idx'], negate=negate)
+            _plug_test(stage)
+
+            out = stage(s)
+
+            assert ('name' in out) if not negate else ('name' not in out)
+            assert ('idx' in out) if not negate else ('idx' not in out)
+            assert ('tail' in out) if negate else ('tail' not in out)
+
+
 class TestStageAugmentations(object):
 
     def test_augmentations(self, toy_dataset_small):
@@ -104,3 +126,29 @@ def test_augmentations(self, toy_dataset_small):
 
             for key in sample.keys():
                 assert key in out
+
+
+class TestStageCompose(object):
+
+    def test_compose(self):
+
+        s = PlainSample(data={'name': 'sample', 'idx': 111, 'float': 2.3, 'tail': True})
+
+        stages = [
+            StageIdentity(),
+            StageRemap(remap={'name': 'a'}, remove_missing=False),
+            StageRemap(remap={'idx': 'b'}, remove_missing=False),
+            StageRemap(remap={'float': 'c'}, remove_missing=False),
+            StageKeysFilter(keys=['a', 'b', 'c'], negate=False),
+            StageIdentity(),
+        ]
+
+        stage = StageCompose(stages=stages)
+        _plug_test(stage)
+
+        out = stage(s)
+
+        assert 'a' in out
+        assert 'b' in out
+        assert 'c' in out
+        assert 'tail' not in out
diff --git a/tests/pipelime/sequences/test_validation.py b/tests/pipelime/sequences/test_validation.py
new file mode 100644
index 0000000..eaede92
--- /dev/null
+++ b/tests/pipelime/sequences/test_validation.py
@@ -0,0 +1,35 @@
+
+
+from schema import SchemaError
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+import pytest
+
+
+class TestSampleSchemaValidation(object):
+
+    def test_samples_validation(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        schemas = filesystem_datasets['minimnist_underfolder']['schemas']
+
+        reader = UnderfolderReader(folder=dataset_folder)
+
+        for schema_name, schema in schemas.items():
+            filename = schema['filename']
+            valid = schema['valid']
+            should_pass = schema['should_pass']
+
+            if valid:
+                schema = SchemaLoader.load(filename)
+            else:
+                with pytest.raises(ModuleNotFoundError):
+                    SchemaLoader.load(filename)
+                continue
+
+            for sample in reader:
+                if should_pass:
+                    schema.validate(sample)
+                else:
+                    with pytest.raises(SchemaError):
+                        schema.validate(sample)
diff --git a/tests/pipelime/workflow/tools/test_cwl_template.py b/tests/pipelime/workflow/tools/test_cwl_template.py
new file mode 100644
index 0000000..3363ab2
--- /dev/null
+++ b/tests/pipelime/workflow/tools/test_cwl_template.py
@@ -0,0 +1,507 @@
+from pathlib import Path
+from re import template
+from pipelime.cli.workflow.workflow import workflow
+import click
+import yaml
+from pipelime.workflow.cwl import CwlNode, CwlNodesManager, CwlTemplate, CwlWorkflowTemplate
+
+
+class TestClick2Cwl(object):
+
+    def test_cwl_template(self, tmpdir_factory):
+
+        # int
+        opt01 = click.Option(param_decls=['--opt01'], required=True, type=int, help='help opt01')
+        opt01not = click.Option(param_decls=['--opt01not'], required=False, default=10, type=int, help='help opt01not')
+        opt02 = click.Option(param_decls=['--opt02'], required=True, nargs=2, type=int, help='help opt02')
+        opt03 = click.Option(param_decls=['--opt03'], required=True, multiple=True, type=int, help='help opt03')
+        opt04 = click.Option(param_decls=['--opt04'], required=True, nargs=2, multiple=True, type=int, help='help opt04')
+        # float
+        opt11 = click.Option(param_decls=['--opt11'], required=True, type=float, help='help opt11')
+        opt12 = click.Option(param_decls=['--opt12'], required=True, nargs=2, type=float, help='help opt12')
+        opt13 = click.Option(param_decls=['--opt13'], required=True, multiple=True, type=float, help='help opt13')
+        opt14 = click.Option(param_decls=['--opt14'], required=True, nargs=2, multiple=True, type=float, help='help opt14')
+        # str
+        opt21 = click.Option(param_decls=['--opt21'], required=True, type=str, help='help opt21')
+        opt22 = click.Option(param_decls=['--opt22'], required=True, nargs=2, type=str, help='help opt22')
+        opt23 = click.Option(param_decls=['--opt23'], required=True, multiple=True, type=str, help='help opt23')
+        opt24 = click.Option(param_decls=['--opt24'], required=True, nargs=2, multiple=True, type=str, help='help opt24')
+        # bool
+        opt31 = click.Option(param_decls=['--opt31'], required=True, is_flag=True, help='help opt31')
+        opt33 = click.Option(param_decls=['--opt33'], required=True, multiple=True, is_flag=True, help='help opt33')
+        # choice
+        opt41 = click.Option(param_decls=['--opt41'], required=True, type=click.Choice(['a', 'b']), help='help opt41')
+        opt42 = click.Option(param_decls=['--opt42'], required=True, nargs=2, type=click.Choice(['a', 'b']), help='help opt42')
+        opt43 = click.Option(param_decls=['--opt43'], required=True, multiple=True, type=click.Choice(['a', 'b']), help='help opt43')
+        opt44 = click.Option(param_decls=['--opt44'], required=True, nargs=2, multiple=True, type=click.Choice(['a', 'b']), help='help opt44')
+        # tuple
+        opt51 = click.Option(param_decls=['--opt51'], required=True, type=(str, str), help='help opt51')
+        opt52 = click.Option(param_decls=['--opt52'], required=True, nargs=2, type=(str, str), help='help opt52')
+        opt53 = click.Option(param_decls=['--opt53'], required=True, multiple=True, type=(str, str), help='help opt53')
+        opt54 = click.Option(param_decls=['--opt54'], required=True, nargs=2, multiple=True, type=(str, str), help='help opt54')
+
+        params = [opt01, opt01not, opt02, opt03, opt04,
+                  opt11, opt12, opt13, opt14,
+                  opt21, opt22, opt23, opt24,
+                  opt31, opt33,
+                  opt41, opt42, opt43, opt44,
+                  opt51, opt52, opt53, opt54]
+
+        click_help = 'this is the click help'
+        cmd = click.Command('this is the click name', help=click_help, params=params)
+        alias = ['first', 'second', 'third']
+        forwards = ['opt01', 'opt02', 'opt03', 'opt04']
+        cwl_template = CwlTemplate.from_command(cmd, alias=alias, forwards=forwards)
+
+        cwl_keys = ['cwlVersion', 'class', 'doc', 'baseCommand', 'inputs', 'outputs']
+        for key in cwl_keys:
+            assert key in cwl_template.template.keys()
+        assert cwl_template.template['doc'] == click_help
+        assert cwl_template.template['baseCommand'][0] == 'first'
+        assert cwl_template.template['baseCommand'][1] == 'second'
+        assert cwl_template.template['baseCommand'][2] == 'third'
+
+        # int
+        expected_cwl = {
+            'doc': 'help opt01',
+            'type': [
+                'int'
+            ],
+            'inputBinding': {'prefix': '--opt01'}
+        }
+        assert 'opt01' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt01'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt01not',
+            'type': [
+                'null',
+                'int'
+            ],
+            'default': 10,
+            'inputBinding': {'prefix': '--opt01not'}
+        }
+        assert 'opt01not' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt01not'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt02',
+            'type': [{
+                'type': 'array',
+                'items': 'int'
+            }],
+            'inputBinding': {'prefix': '--opt02'}
+        }
+        assert 'opt02' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt02'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt03',
+            'type': [{
+                'type': 'array',
+                'items': 'int',
+                'inputBinding': {'prefix': '--opt03'}
+            }]
+        }
+        assert 'opt03' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt03'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt04',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'int'
+                },
+                'inputBinding': {'prefix': '--opt04'}
+            }]
+        }
+        assert 'opt04' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt04'] == expected_cwl
+
+        # float
+        expected_cwl = {
+            'doc': 'help opt11',
+            'type': [
+                'float'
+            ],
+            'inputBinding': {'prefix': '--opt11'}
+        }
+        assert 'opt11' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt11'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt12',
+            'type': [{
+                'type': 'array',
+                'items': 'float'
+            }],
+            'inputBinding': {'prefix': '--opt12'}
+        }
+        assert 'opt12' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt12'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt13',
+            'type': [{
+                'type': 'array',
+                'items': 'float',
+                'inputBinding': {'prefix': '--opt13'}
+            }]
+        }
+        assert 'opt13' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt13'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt14',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'float'
+                },
+                'inputBinding': {'prefix': '--opt14'}
+            }]
+        }
+        assert 'opt14' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt14'] == expected_cwl
+
+        # string
+        expected_cwl = {
+            'doc': 'help opt21',
+            'type': [
+                'string'
+            ],
+            'inputBinding': {'prefix': '--opt21'}
+        }
+        assert 'opt21' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt21'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt22',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt22'}
+        }
+        assert 'opt22' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt22'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt23',
+            'type': [{
+                'type': 'array',
+                'items': 'string',
+                'inputBinding': {'prefix': '--opt23'}
+            }]
+        }
+        assert 'opt23' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt23'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt24',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt24'}
+            }]
+        }
+        assert 'opt24' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt24'] == expected_cwl
+
+        # bool
+        expected_cwl = {
+            'doc': 'help opt31',
+            'type': [
+                'boolean'
+            ],
+            'inputBinding': {'prefix': '--opt31'}
+        }
+        assert 'opt31' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt31'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt33',
+            'type': [{
+                'type': 'array',
+                'items': 'boolean',
+                'inputBinding': {'prefix': '--opt33'}
+            }]
+        }
+        assert 'opt33' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt33'] == expected_cwl
+
+        # choice
+        expected_cwl = {
+            'doc': 'help opt41',
+            'type': [
+                'string'
+            ],
+            'inputBinding': {'prefix': '--opt41'}
+        }
+        assert 'opt41' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt41'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt42',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt42'}
+        }
+        assert 'opt42' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt42'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt43',
+            'type': [{
+                'type': 'array',
+                'items': 'string',
+                'inputBinding': {'prefix': '--opt43'}
+            }]
+        }
+        assert 'opt43' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt43'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt44',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt44'}
+            }]
+        }
+        assert 'opt44' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt44'] == expected_cwl
+
+        # tuple
+        expected_cwl = {
+            'doc': 'help opt51',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt51'}
+        }
+        assert 'opt51' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt51'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt52',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt52'}
+        }
+        assert 'opt52' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt52'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt53',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt53'}
+            }]
+        }
+        assert 'opt53' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt53'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt54',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt54'}
+            }]
+        }
+        assert 'opt54' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt54'] == expected_cwl
+
+        assert '_opt01' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt01']['type'] == 'int'
+        assert cwl_template.template['outputs']['_opt01']['outputBinding']['outputEval'] == '$(inputs.opt01)'
+
+        assert '_opt02' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt02']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt02']['type']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt02']['outputBinding']['outputEval'] == '$(inputs.opt02)'
+
+        assert '_opt03' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt03']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt03']['type']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt03']['outputBinding']['outputEval'] == '$(inputs.opt03)'
+
+        assert '_opt04' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt04']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt04']['type']['items']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt04']['type']['items']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt04']['outputBinding']['outputEval'] == '$(inputs.opt04)'
+
+        # cwl_filled2 = Click2Cwl.convert_click_to_cwl(cmd, commands, forwards)
+        # assert cwl_filled == cwl_filled2
+
+        output_cwl_file = Path(tmpdir_factory.mktemp('cwl')) / f'output.cwl'
+        cwl_template.dumps(output_cwl_file)
+        with open(output_cwl_file, 'r') as f:
+            lines = f.readlines()
+            assert lines[0] == '#!/usr/bin/env cwl-runner\n'
+            loaded_cwl = yaml.safe_load(''.join(lines))
+            assert cwl_template.template == loaded_cwl
+
+        click_command = f"""
+import click
+@click.command('this is the click name', help='{click_help}')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+@click.option('--opt1', required=True, type=float, help='help opt1')
+@click.option('--opt2', required=False, type=str, default='default2', help='help opt2')
+@click.option('--opt3', required=False, is_flag=True, help='help opt3')
+@click.option('--opt4', required=False, type=(int, str), help='help opt4')
+@click.option('--opt5', required=False, default='c1', type=click.Choice(['c1', 'c2']), help='help opt5')
+@click.option('--opt6', required=False, type=int, nargs=3, help='help opt6')
+@click.option('--opt7', required=False, multiple=True, type=int, help='help opt7')
+@click.option('--opt8', required=False, multiple=True, type=(int, str), help='help opt7')
+def a_click_command(opt0, opt1, opt2, opt3, opt4, opt5, opt6, opt7, opt8):
+    pass
+        """
+
+        output_click_file = Path(tmpdir_factory.mktemp('click')) / f'output.py'
+        with open(output_click_file, 'w') as f:
+            f.write(click_command)
+        loaded_template = CwlTemplate(output_click_file)
+
+        cwl_keys = ['cwlVersion', 'class', 'doc', 'baseCommand', 'inputs', 'outputs']
+        for key in cwl_keys:
+            assert key in loaded_template.template.keys()
+        assert loaded_template.template['doc'] == click_help
+        assert len(loaded_template.template['inputs'].keys()) == 9
+
+    def test_cwl_workflow_template(self, tmpdir_factory):
+        opt0_1 = click.Option(param_decls=['--opt01'], required=True, type=int, help='help opt01')
+        opt0_2 = click.Option(param_decls=['--opt02'], required=True, type=str, help='help opt02')
+        command0 = click.Command(name='cmd0', params=[opt0_1, opt0_2])
+        cwl_template0 = CwlTemplate.from_command(command0, forwards=['opt01'])
+        cwl_node0 = CwlNode('node0', '/path/to/cwl0', cwl_template0)
+
+        opt1_1 = click.Option(param_decls=['--opt11'], required=True, type=int, help='help opt11')
+        opt1_2 = click.Option(param_decls=['--opt12'], required=True, type=str, help='help opt12')
+        command1 = click.Command(name='cmd1', params=[opt1_1, opt1_2])
+        cwl_template1 = CwlTemplate.from_command(command1, forwards=['opt11'])
+        cwl_node1 = CwlNode('node1', '/path/to/cwl1', cwl_template1)
+
+        cwl_nodes = [cwl_node0, cwl_node1, cwl_node0]
+        cwl_workflow_template = CwlWorkflowTemplate(cwl_nodes)
+
+        cwl_keys = ['cwlVersion', 'class', 'requirements', 'inputs', 'outputs', 'steps']
+        for key in cwl_keys:
+            assert key in cwl_workflow_template.template.keys()
+
+        assert len(cwl_workflow_template.template['steps'].keys()) == 3
+
+        assert 'node00' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node00']['run'] == 'node0'
+        assert 'opt01' in cwl_workflow_template.template['steps']['node00']['in'].keys()
+        assert 'opt02' in cwl_workflow_template.template['steps']['node00']['in'].keys()
+        assert '_opt01' in cwl_workflow_template.template['steps']['node00']['out']
+
+        assert 'node10' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node10']['run'] == 'node1'
+        assert 'opt11' in cwl_workflow_template.template['steps']['node10']['in'].keys()
+        assert 'opt12' in cwl_workflow_template.template['steps']['node10']['in'].keys()
+        assert '_opt11' in cwl_workflow_template.template['steps']['node10']['out']
+
+        assert 'node01' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node01']['run'] == 'node0'
+        assert 'opt01' in cwl_workflow_template.template['steps']['node01']['in'].keys()
+        assert 'opt02' in cwl_workflow_template.template['steps']['node01']['in'].keys()
+        assert '_opt01' in cwl_workflow_template.template['steps']['node01']['out']
+
+        assert 'node00_opt01' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node00_opt01']
+        assert 'node00_opt02' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node00_opt02']
+        assert 'node10_opt11' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node10_opt11']
+        assert 'node10_opt12' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node10_opt12']
+        assert 'node01_opt01' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node01_opt01']
+        assert 'node01_opt02' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node01_opt02']
+
+        output_template = Path(tmpdir_factory.mktemp('workflow')) / f'template.cwl'
+        cwl_workflow_template.dumps(output_template)
+        loaded_template = CwlWorkflowTemplate.from_file(output_template)
+        assert cwl_workflow_template.template == loaded_template.template
+
+    def test_cwl_nodes_manager(self, tmpdir_factory):
+        output_folder = Path(tmpdir_factory.mktemp('cwl'))
+
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 0
+        click_command = click.Command('command', params=[click.Option(param_decls=['--opt'], type=int)])
+        cwl_template = CwlTemplate.from_command(click_command)
+        CwlNodesManager.create_node('first', cwl_template, folder=output_folder)
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 1
+        node = CwlNodesManager.get_node_by_name('first', folder=output_folder)
+        assert node is not None
+        assert node.name == 'first'
+        assert CwlNodesManager.get_node_by_name('second', folder=output_folder) is None
+        CwlNodesManager.delete_node('first', folder=output_folder)
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 0
+
+        click_command1 = f"""
+import click
+@click.command('command1')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+def command1(opt0):
+    pass
+        """
+        output_click_file1 = Path(tmpdir_factory.mktemp('click')) / f'command1.py'
+        with open(output_click_file1, 'w') as f:
+            f.write(click_command1)
+        cwl_template1 = CwlTemplate(script=str(output_click_file1))  #, alias=['one', 'two'], forwards=['opt0'])
+        CwlNodesManager.create_node('first', cwl_template1, folder=output_folder)
+
+        click_command2 = f"""
+import click
+@click.command('command2')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+def command2(opt0):
+    pass
+        """
+        output_click_file2 = Path(tmpdir_factory.mktemp('click')) / f'command2.py'
+        with open(output_click_file2, 'w') as f:
+            f.write(click_command2)
+        cwl_template2 = CwlTemplate(script=str(output_click_file2))
+        CwlNodesManager.create_node('second', cwl_template2, folder=output_folder)
+
+        workflow_template = CwlNodesManager.initialize_workflow(['first', 'first', 'second'], folder=output_folder)
+        assert workflow_template.template['steps']['first0']['run'] == 'first'
+        assert workflow_template.template['steps']['first1']['run'] == 'first'
+        assert workflow_template.template['steps']['second0']['run'] == 'second'
+
+        first_node_path = CwlNodesManager.get_node_by_name('first', folder=output_folder).cwl_path
+        second_node_path = CwlNodesManager.get_node_by_name('second', folder=output_folder).cwl_path
+        filled_workflow_template = CwlNodesManager.fill_workflow(workflow_template, folder=output_folder)
+        assert filled_workflow_template.template['steps']['first0']['run'] == first_node_path
+        assert filled_workflow_template.template['steps']['first1']['run'] == first_node_path
+        assert filled_workflow_template.template['steps']['second0']['run'] == second_node_path
diff --git a/tests/sample_data/datasets/underfolder_minimnist/cfg.yml b/tests/sample_data/datasets/underfolder_minimnist/cfg.yml
new file mode 100644
index 0000000..a5c6480
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/cfg.yml
@@ -0,0 +1,3 @@
+old: False
+base_folder: minimnist
+index: id
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image.jpg
new file mode 100644
index 0000000..9852495
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_mask.png
new file mode 100644
index 0000000..d7c22df
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_maskinv.png
new file mode 100644
index 0000000..a1a510e
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000000_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000000_label.txt
new file mode 100644
index 0000000..0595590
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000000_label.txt
@@ -0,0 +1,1 @@
+0.000000000000000000e+00
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadata.json
new file mode 100644
index 0000000..8b590ee
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 0, "double": 0.0, "half": 0.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadatay.yml
new file mode 100644
index 0000000..5e7b4b7
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000000_metadatay.yml
@@ -0,0 +1,3 @@
+double: 0.0
+half: 0.0
+sample_id: 0
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000000_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000000_points.txt
new file mode 100644
index 0000000..5b1958f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000000_points.txt
@@ -0,0 +1,1 @@
+4.000000000000000000e+00 6.000000000000000000e+00 2.400000000000000000e+01 6.000000000000000000e+00 2.400000000000000000e+01 2.300000000000000000e+01 4.000000000000000000e+00 2.300000000000000000e+01 1.400000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image.jpg
new file mode 100644
index 0000000..d4ae539
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_mask.png
new file mode 100644
index 0000000..d7c22df
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_maskinv.png
new file mode 100644
index 0000000..a1a510e
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000001_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000001_label.txt
new file mode 100644
index 0000000..0595590
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000001_label.txt
@@ -0,0 +1,1 @@
+0.000000000000000000e+00
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadata.json
new file mode 100644
index 0000000..f7e5592
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 1, "double": 2.0, "half": 0.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadatay.yml
new file mode 100644
index 0000000..300ca9b
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000001_metadatay.yml
@@ -0,0 +1,3 @@
+double: 2.0
+half: 0.5
+sample_id: 1
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000001_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000001_points.txt
new file mode 100644
index 0000000..5b1958f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000001_points.txt
@@ -0,0 +1,1 @@
+4.000000000000000000e+00 6.000000000000000000e+00 2.400000000000000000e+01 6.000000000000000000e+00 2.400000000000000000e+01 2.300000000000000000e+01 4.000000000000000000e+00 2.300000000000000000e+01 1.400000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image.jpg
new file mode 100644
index 0000000..5a0f422
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_mask.png
new file mode 100644
index 0000000..d8dd95b
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_maskinv.png
new file mode 100644
index 0000000..f3c1ca4
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000002_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000002_label.txt
new file mode 100644
index 0000000..d00491f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000002_label.txt
@@ -0,0 +1,1 @@
+1
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadata.json
new file mode 100644
index 0000000..de3c44e
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 2, "double": 4.0, "half": 1.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadatay.yml
new file mode 100644
index 0000000..35b3ae4
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000002_metadatay.yml
@@ -0,0 +1,3 @@
+double: 4.0
+half: 1.0
+sample_id: 2
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000002_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000002_points.txt
new file mode 100644
index 0000000..0b57aa9
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000002_points.txt
@@ -0,0 +1,1 @@
+1.200000000000000000e+01 4.000000000000000000e+00 1.700000000000000000e+01 4.000000000000000000e+00 1.700000000000000000e+01 2.300000000000000000e+01 1.200000000000000000e+01 2.300000000000000000e+01 1.450000000000000000e+01 1.350000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image.jpg
new file mode 100644
index 0000000..80cef02
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_mask.png
new file mode 100644
index 0000000..d8dd95b
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_maskinv.png
new file mode 100644
index 0000000..f3c1ca4
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000003_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000003_label.txt
new file mode 100644
index 0000000..d00491f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000003_label.txt
@@ -0,0 +1,1 @@
+1
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadata.json
new file mode 100644
index 0000000..7a05900
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 3, "double": 6.0, "half": 1.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadatay.yml
new file mode 100644
index 0000000..ec2809f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000003_metadatay.yml
@@ -0,0 +1,3 @@
+double: 6.0
+half: 1.5
+sample_id: 3
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000003_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000003_points.txt
new file mode 100644
index 0000000..0b57aa9
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000003_points.txt
@@ -0,0 +1,1 @@
+1.200000000000000000e+01 4.000000000000000000e+00 1.700000000000000000e+01 4.000000000000000000e+00 1.700000000000000000e+01 2.300000000000000000e+01 1.200000000000000000e+01 2.300000000000000000e+01 1.450000000000000000e+01 1.350000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image.jpg
new file mode 100644
index 0000000..e740792
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_mask.png
new file mode 100644
index 0000000..5e21b16
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_maskinv.png
new file mode 100644
index 0000000..fe5a574
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000004_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000004_label.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000004_label.txt
@@ -0,0 +1,1 @@
+2
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadata.json
new file mode 100644
index 0000000..fd6e423
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 4, "double": 8.0, "half": 2.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadatay.yml
new file mode 100644
index 0000000..604a485
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000004_metadatay.yml
@@ -0,0 +1,3 @@
+double: 8.0
+half: 2.0
+sample_id: 4
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000004_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000004_points.txt
new file mode 100644
index 0000000..ea1fe95
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000004_points.txt
@@ -0,0 +1,1 @@
+1.000000000000000000e+00 2.000000000000000000e+00 2.300000000000000000e+01 2.000000000000000000e+00 2.300000000000000000e+01 2.300000000000000000e+01 1.000000000000000000e+00 2.300000000000000000e+01 1.200000000000000000e+01 1.250000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image.jpg
new file mode 100644
index 0000000..64b58d8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_mask.png
new file mode 100644
index 0000000..5e21b16
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_maskinv.png
new file mode 100644
index 0000000..fe5a574
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000005_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000005_label.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000005_label.txt
@@ -0,0 +1,1 @@
+2
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadata.json
new file mode 100644
index 0000000..db06a86
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 5, "double": 10.0, "half": 2.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadatay.yml
new file mode 100644
index 0000000..a94ea32
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000005_metadatay.yml
@@ -0,0 +1,3 @@
+double: 10.0
+half: 2.5
+sample_id: 5
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000005_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000005_points.txt
new file mode 100644
index 0000000..ea1fe95
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000005_points.txt
@@ -0,0 +1,1 @@
+1.000000000000000000e+00 2.000000000000000000e+00 2.300000000000000000e+01 2.000000000000000000e+00 2.300000000000000000e+01 2.300000000000000000e+01 1.000000000000000000e+00 2.300000000000000000e+01 1.200000000000000000e+01 1.250000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image.jpg
new file mode 100644
index 0000000..b79ba97
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_mask.png
new file mode 100644
index 0000000..6d838c2
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_maskinv.png
new file mode 100644
index 0000000..669bd24
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000006_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000006_label.txt
new file mode 100644
index 0000000..e440e5c
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000006_label.txt
@@ -0,0 +1,1 @@
+3
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadata.json
new file mode 100644
index 0000000..ab20a99
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 6, "double": 12.0, "half": 3.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadatay.yml
new file mode 100644
index 0000000..1636b79
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000006_metadatay.yml
@@ -0,0 +1,3 @@
+double: 12.0
+half: 3.0
+sample_id: 6
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000006_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000006_points.txt
new file mode 100644
index 0000000..fa3a128
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000006_points.txt
@@ -0,0 +1,1 @@
+7.000000000000000000e+00 3.000000000000000000e+00 1.900000000000000000e+01 3.000000000000000000e+00 1.900000000000000000e+01 2.200000000000000000e+01 7.000000000000000000e+00 2.200000000000000000e+01 1.300000000000000000e+01 1.250000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image.jpg
new file mode 100644
index 0000000..16a1e94
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_mask.png
new file mode 100644
index 0000000..6d838c2
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_maskinv.png
new file mode 100644
index 0000000..669bd24
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000007_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000007_label.txt
new file mode 100644
index 0000000..e440e5c
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000007_label.txt
@@ -0,0 +1,1 @@
+3
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadata.json
new file mode 100644
index 0000000..4ea435e
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 7, "double": 14.0, "half": 3.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadatay.yml
new file mode 100644
index 0000000..626b9c8
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000007_metadatay.yml
@@ -0,0 +1,3 @@
+double: 14.0
+half: 3.5
+sample_id: 7
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000007_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000007_points.txt
new file mode 100644
index 0000000..fa3a128
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000007_points.txt
@@ -0,0 +1,1 @@
+7.000000000000000000e+00 3.000000000000000000e+00 1.900000000000000000e+01 3.000000000000000000e+00 1.900000000000000000e+01 2.200000000000000000e+01 7.000000000000000000e+00 2.200000000000000000e+01 1.300000000000000000e+01 1.250000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image.jpg
new file mode 100644
index 0000000..82fe4d1
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_mask.png
new file mode 100644
index 0000000..755c393
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_maskinv.png
new file mode 100644
index 0000000..c491af8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000008_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000008_label.txt
new file mode 100644
index 0000000..bf0d87a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000008_label.txt
@@ -0,0 +1,1 @@
+4
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadata.json
new file mode 100644
index 0000000..cbb71be
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 8, "double": 16.0, "half": 4.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadatay.yml
new file mode 100644
index 0000000..1c590e4
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000008_metadatay.yml
@@ -0,0 +1,3 @@
+double: 16.0
+half: 4.0
+sample_id: 8
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000008_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000008_points.txt
new file mode 100644
index 0000000..6ea3c82
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000008_points.txt
@@ -0,0 +1,1 @@
+5.000000000000000000e+00 1.000000000000000000e+00 2.200000000000000000e+01 1.000000000000000000e+00 2.200000000000000000e+01 2.500000000000000000e+01 5.000000000000000000e+00 2.500000000000000000e+01 1.350000000000000000e+01 1.300000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image.jpg
new file mode 100644
index 0000000..11e15cc
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_mask.png
new file mode 100644
index 0000000..755c393
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_maskinv.png
new file mode 100644
index 0000000..c491af8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000009_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000009_label.txt
new file mode 100644
index 0000000..bf0d87a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000009_label.txt
@@ -0,0 +1,1 @@
+4
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadata.json
new file mode 100644
index 0000000..b3ddab6
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 9, "double": 18.0, "half": 4.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadatay.yml
new file mode 100644
index 0000000..80ca4ee
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000009_metadatay.yml
@@ -0,0 +1,3 @@
+double: 18.0
+half: 4.5
+sample_id: 9
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000009_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000009_points.txt
new file mode 100644
index 0000000..6ea3c82
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000009_points.txt
@@ -0,0 +1,1 @@
+5.000000000000000000e+00 1.000000000000000000e+00 2.200000000000000000e+01 1.000000000000000000e+00 2.200000000000000000e+01 2.500000000000000000e+01 5.000000000000000000e+00 2.500000000000000000e+01 1.350000000000000000e+01 1.300000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image.jpg
new file mode 100644
index 0000000..97380d4
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_mask.png
new file mode 100644
index 0000000..061cc48
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_maskinv.png
new file mode 100644
index 0000000..462dcc8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000010_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000010_label.txt
new file mode 100644
index 0000000..7813681
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000010_label.txt
@@ -0,0 +1,1 @@
+5
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadata.json
new file mode 100644
index 0000000..9ebb63c
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 10, "double": 20.0, "half": 5.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadatay.yml
new file mode 100644
index 0000000..b6ee60b
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000010_metadatay.yml
@@ -0,0 +1,3 @@
+double: 20.0
+half: 5.0
+sample_id: 10
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000010_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000010_points.txt
new file mode 100644
index 0000000..9bd020e
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000010_points.txt
@@ -0,0 +1,1 @@
+8.000000000000000000e+00 5.000000000000000000e+00 2.100000000000000000e+01 5.000000000000000000e+00 2.100000000000000000e+01 2.400000000000000000e+01 8.000000000000000000e+00 2.400000000000000000e+01 1.450000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image.jpg
new file mode 100644
index 0000000..295c55a
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_mask.png
new file mode 100644
index 0000000..061cc48
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_maskinv.png
new file mode 100644
index 0000000..462dcc8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000011_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000011_label.txt
new file mode 100644
index 0000000..7813681
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000011_label.txt
@@ -0,0 +1,1 @@
+5
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadata.json
new file mode 100644
index 0000000..731ad01
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 11, "double": 22.0, "half": 5.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadatay.yml
new file mode 100644
index 0000000..a992dd4
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000011_metadatay.yml
@@ -0,0 +1,3 @@
+double: 22.0
+half: 5.5
+sample_id: 11
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000011_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000011_points.txt
new file mode 100644
index 0000000..9bd020e
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000011_points.txt
@@ -0,0 +1,1 @@
+8.000000000000000000e+00 5.000000000000000000e+00 2.100000000000000000e+01 5.000000000000000000e+00 2.100000000000000000e+01 2.400000000000000000e+01 8.000000000000000000e+00 2.400000000000000000e+01 1.450000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image.jpg
new file mode 100644
index 0000000..69a7c5d
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_mask.png
new file mode 100644
index 0000000..ca52921
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_maskinv.png
new file mode 100644
index 0000000..f402a67
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000012_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000012_label.txt
new file mode 100644
index 0000000..62f9457
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000012_label.txt
@@ -0,0 +1,1 @@
+6
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadata.json
new file mode 100644
index 0000000..fa89981
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 12, "double": 24.0, "half": 6.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadatay.yml
new file mode 100644
index 0000000..915695a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000012_metadatay.yml
@@ -0,0 +1,3 @@
+double: 24.0
+half: 6.0
+sample_id: 12
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000012_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000012_points.txt
new file mode 100644
index 0000000..e79cafa
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000012_points.txt
@@ -0,0 +1,1 @@
+6.000000000000000000e+00 2.000000000000000000e+00 2.200000000000000000e+01 2.000000000000000000e+00 2.200000000000000000e+01 2.200000000000000000e+01 6.000000000000000000e+00 2.200000000000000000e+01 1.400000000000000000e+01 1.200000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image.jpg
new file mode 100644
index 0000000..777ad10
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_mask.png
new file mode 100644
index 0000000..ca52921
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_maskinv.png
new file mode 100644
index 0000000..f402a67
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000013_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000013_label.txt
new file mode 100644
index 0000000..62f9457
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000013_label.txt
@@ -0,0 +1,1 @@
+6
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadata.json
new file mode 100644
index 0000000..05ac6e0
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 13, "double": 26.0, "half": 6.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadatay.yml
new file mode 100644
index 0000000..1b8d75d
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000013_metadatay.yml
@@ -0,0 +1,3 @@
+double: 26.0
+half: 6.5
+sample_id: 13
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000013_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000013_points.txt
new file mode 100644
index 0000000..e79cafa
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000013_points.txt
@@ -0,0 +1,1 @@
+6.000000000000000000e+00 2.000000000000000000e+00 2.200000000000000000e+01 2.000000000000000000e+00 2.200000000000000000e+01 2.200000000000000000e+01 6.000000000000000000e+00 2.200000000000000000e+01 1.400000000000000000e+01 1.200000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image.jpg
new file mode 100644
index 0000000..1b721b1
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_mask.png
new file mode 100644
index 0000000..95e5f06
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_maskinv.png
new file mode 100644
index 0000000..5fda2dd
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000014_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000014_label.txt
new file mode 100644
index 0000000..c793025
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000014_label.txt
@@ -0,0 +1,1 @@
+7
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadata.json
new file mode 100644
index 0000000..dd117a8
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 14, "double": 28.0, "half": 7.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadatay.yml
new file mode 100644
index 0000000..55f7a16
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000014_metadatay.yml
@@ -0,0 +1,3 @@
+double: 28.0
+half: 7.0
+sample_id: 14
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000014_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000014_points.txt
new file mode 100644
index 0000000..7cb9f93
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000014_points.txt
@@ -0,0 +1,1 @@
+3.000000000000000000e+00 5.000000000000000000e+00 2.000000000000000000e+01 5.000000000000000000e+00 2.000000000000000000e+01 2.700000000000000000e+01 3.000000000000000000e+00 2.700000000000000000e+01 1.150000000000000000e+01 1.600000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image.jpg
new file mode 100644
index 0000000..4dca0bf
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_mask.png
new file mode 100644
index 0000000..a697311
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_maskinv.png
new file mode 100644
index 0000000..b69030e
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000015_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000015_label.txt
new file mode 100644
index 0000000..c793025
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000015_label.txt
@@ -0,0 +1,1 @@
+7
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadata.json
new file mode 100644
index 0000000..2853350
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 15, "double": 30.0, "half": 7.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadatay.yml
new file mode 100644
index 0000000..cfafe30
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000015_metadatay.yml
@@ -0,0 +1,3 @@
+double: 30.0
+half: 7.5
+sample_id: 15
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000015_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000015_points.txt
new file mode 100644
index 0000000..9db7abe
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000015_points.txt
@@ -0,0 +1,1 @@
+1.000000000000000000e+00 7.000000000000000000e+00 2.400000000000000000e+01 7.000000000000000000e+00 2.400000000000000000e+01 2.700000000000000000e+01 1.000000000000000000e+00 2.700000000000000000e+01 1.250000000000000000e+01 1.700000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image.jpg
new file mode 100644
index 0000000..85af773
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_mask.png
new file mode 100644
index 0000000..1dd7e83
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_maskinv.png
new file mode 100644
index 0000000..0876558
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000016_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000016_label.txt
new file mode 100644
index 0000000..301160a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000016_label.txt
@@ -0,0 +1,1 @@
+8
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadata.json
new file mode 100644
index 0000000..cbfad77
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 16, "double": 32.0, "half": 8.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadatay.yml
new file mode 100644
index 0000000..381a981
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000016_metadatay.yml
@@ -0,0 +1,3 @@
+double: 32.0
+half: 8.0
+sample_id: 16
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000016_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000016_points.txt
new file mode 100644
index 0000000..a5f9636
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000016_points.txt
@@ -0,0 +1,1 @@
+1.000000000000000000e+01 5.000000000000000000e+00 1.900000000000000000e+01 5.000000000000000000e+00 1.900000000000000000e+01 2.400000000000000000e+01 1.000000000000000000e+01 2.400000000000000000e+01 1.450000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image.jpg
new file mode 100644
index 0000000..b73655f
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_mask.png
new file mode 100644
index 0000000..1dd7e83
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_maskinv.png
new file mode 100644
index 0000000..0876558
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000017_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000017_label.txt
new file mode 100644
index 0000000..301160a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000017_label.txt
@@ -0,0 +1,1 @@
+8
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadata.json
new file mode 100644
index 0000000..b6173ab
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 17, "double": 34.0, "half": 8.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadatay.yml
new file mode 100644
index 0000000..f95ee7b
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000017_metadatay.yml
@@ -0,0 +1,3 @@
+double: 34.0
+half: 8.5
+sample_id: 17
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000017_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000017_points.txt
new file mode 100644
index 0000000..a5f9636
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000017_points.txt
@@ -0,0 +1,1 @@
+1.000000000000000000e+01 5.000000000000000000e+00 1.900000000000000000e+01 5.000000000000000000e+00 1.900000000000000000e+01 2.400000000000000000e+01 1.000000000000000000e+01 2.400000000000000000e+01 1.450000000000000000e+01 1.450000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image.jpg
new file mode 100644
index 0000000..0867dd8
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_mask.png
new file mode 100644
index 0000000..182238a
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_maskinv.png
new file mode 100644
index 0000000..860dfd0
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000018_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000018_label.txt
new file mode 100644
index 0000000..f11c82a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000018_label.txt
@@ -0,0 +1,1 @@
+9
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadata.json
new file mode 100644
index 0000000..fb96526
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 18, "double": 36.0, "half": 9.0}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadatay.yml
new file mode 100644
index 0000000..d933afa
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000018_metadatay.yml
@@ -0,0 +1,3 @@
+double: 36.0
+half: 9.0
+sample_id: 18
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000018_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000018_points.txt
new file mode 100644
index 0000000..56762f3
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000018_points.txt
@@ -0,0 +1,1 @@
+7.000000000000000000e+00 6.000000000000000000e+00 1.800000000000000000e+01 6.000000000000000000e+00 1.800000000000000000e+01 2.500000000000000000e+01 7.000000000000000000e+00 2.500000000000000000e+01 1.250000000000000000e+01 1.550000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_image.jpg b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image.jpg
new file mode 100644
index 0000000..c0ce3fd
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image.jpg differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_mask.png b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_mask.png
new file mode 100644
index 0000000..279adfc
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_mask.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_maskinv.png b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_maskinv.png
new file mode 100644
index 0000000..20a68ee
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist/data/000019_image_maskinv.png differ
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_label.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000019_label.txt
new file mode 100644
index 0000000..f11c82a
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000019_label.txt
@@ -0,0 +1,1 @@
+9
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadata.json b/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadata.json
new file mode 100644
index 0000000..e77b30f
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadata.json
@@ -0,0 +1,1 @@
+{"sample_id": 19, "double": 38.0, "half": 9.5}
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadatay.yml b/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadatay.yml
new file mode 100644
index 0000000..c255e79
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000019_metadatay.yml
@@ -0,0 +1,3 @@
+double: 38.0
+half: 9.5
+sample_id: 19
diff --git a/tests/sample_data/datasets/underfolder_minimnist/data/000019_points.txt b/tests/sample_data/datasets/underfolder_minimnist/data/000019_points.txt
new file mode 100644
index 0000000..a215503
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/data/000019_points.txt
@@ -0,0 +1,1 @@
+8.000000000000000000e+00 2.000000000000000000e+00 2.200000000000000000e+01 2.000000000000000000e+00 2.200000000000000000e+01 2.500000000000000000e+01 8.000000000000000000e+00 2.500000000000000000e+01 1.500000000000000000e+01 1.350000000000000000e+01
diff --git a/tests/sample_data/datasets/underfolder_minimnist/numbers.txt b/tests/sample_data/datasets/underfolder_minimnist/numbers.txt
new file mode 100644
index 0000000..0246698
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/numbers.txt
@@ -0,0 +1,3 @@
+0.1 0.1 0.2 0.2 0.3 0.4 0.5
+0.1 0.1 0.2 0.2 0.3 0.4 0.7
+0.1 0.1 0.2 0.2 0.3 0.4 0.8
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist/pose.txt b/tests/sample_data/datasets/underfolder_minimnist/pose.txt
new file mode 100644
index 0000000..a067909
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist/pose.txt
@@ -0,0 +1,4 @@
+9.975294325464288958e-01 6.760711053306790730e-02 -1.908690150248902595e-02 -3.584983950621601755e-03
+-6.734271198122841751e-02 9.976292508622911503e-01 1.417169597998962915e-02 -6.301910434275505291e-02
+1.999975866377137410e-02 -1.285132013864587891e-02 9.997173866768973571e-01 1.282372274249416466e-01
+0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 1.000000000000000000e+00
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema
new file mode 100644
index 0000000..1c4704b
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema
@@ -0,0 +1,1 @@
+print("😈")
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema
new file mode 100644
index 0000000..bfe456c
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema
@@ -0,0 +1,12 @@
+import numpy as np
+from schema import And, Optional
+
+deep = True
+schema = {
+    'cfg': object,
+    'numbers': object,
+    'image': np.ndarray,
+    'image_mask': And(np.ndarray, lambda x: len(x.shape) >= 2),
+    'image_maskinv': And(np.ndarray, lambda x: x.shape[0] == x.shape[1]),
+    Optional(str): object
+}
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema
new file mode 100644
index 0000000..a5aa0d6
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema
@@ -0,0 +1,7 @@
+from schema import Optional
+
+
+deep = False
+schema = {
+    'image': object
+}
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema
new file mode 100644
index 0000000..23cfbdc
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema
@@ -0,0 +1,9 @@
+from schema import Optional
+
+deep = False
+schema = {
+    'cfg': object,
+    'numbers': object,
+    'image': object,
+    Optional(str): object
+}