diff --git a/docs/assets/preview/axis1.png b/docs/assets/preview/axis1.png
new file mode 100644
index 0000000000000000000000000000000000000000..47d398bdf337513be4407536d804e0311c3f11fc
Binary files /dev/null and b/docs/assets/preview/axis1.png differ
diff --git a/docs/assets/preview/default.png b/docs/assets/preview/default.png
new file mode 100644
index 0000000000000000000000000000000000000000..fefc0905f558add0c6c744b2e3760d386ef91739
Binary files /dev/null and b/docs/assets/preview/default.png differ
diff --git a/docs/assets/preview/lowIntensity.png b/docs/assets/preview/lowIntensity.png
new file mode 100644
index 0000000000000000000000000000000000000000..9d9f7a78942bc1d028fc6acdd177f288e3e66bb7
Binary files /dev/null and b/docs/assets/preview/lowIntensity.png differ
diff --git a/docs/assets/preview/qimLogo.png b/docs/assets/preview/qimLogo.png
new file mode 100644
index 0000000000000000000000000000000000000000..08be918c6fa9eb4484b945c54b80fc5c17efdea3
Binary files /dev/null and b/docs/assets/preview/qimLogo.png differ
diff --git a/docs/assets/preview/relativeIntensity.png b/docs/assets/preview/relativeIntensity.png
new file mode 100644
index 0000000000000000000000000000000000000000..7ae5f39bda7152d2ce9551bdb7bb954d0ce3f512
Binary files /dev/null and b/docs/assets/preview/relativeIntensity.png differ
diff --git a/docs/assets/preview/res30.png b/docs/assets/preview/res30.png
new file mode 100644
index 0000000000000000000000000000000000000000..02090f44b8eab20b2607490bec34dedb56828676
Binary files /dev/null and b/docs/assets/preview/res30.png differ
diff --git a/docs/cli.md b/docs/cli.md
index c45d7036f64b8bf77423bd77c27ed04c0481bcf1..4b0ed8a8ce388db4700db38a5aeb0c0c730b7cd6 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -146,3 +146,47 @@ Or an specific path for destination can be used. We can also choose to not open
 
     This writes to disk the `my_plot.html` file.
 
+## File preview
+Command line interface, which allows users to preview 3D structers or 2D images directly in command line.
+###  `qim3d preview <filename>`
+| Arguments | Description |
+| --------- | ----------- |
+| `--axis` | Specifies from which axis the slice will be taken. If the object is 2D image, then this is ignored. Defaults to 0.|
+| `--slice` | Specifies which slice will be displayed. If the number exceeds number of slices, the last one is taken. Defaults to the middle slice.|
+| `--resolution` | How many characters will be used to display the image in command line. Defaults to 80.|
+| `--absolute_values` |If values are low the image might be just black square. By default maximum value is set to 255. This flag turns this behaviour off.|
+
+!!! Example
+    ```
+    qim3d preview blobs_256x256x256.tif 
+    ```
+
+    ![CLI k3d](assets/preview/default.png){ width="512" }
+
+!!! Example
+    ```
+    qim3d preview blobs_256x256x256.tif --resolution 30
+    ```
+
+    ![CLI k3d](assets/preview/res30.png){ width="512" }
+
+!!! Example
+    ```
+    qim3d preview blobs_256x256x256.tif --resolution 50 --axis 1
+    ```
+
+    ![CLI k3d](assets/preview/axis1.png){ width="512" }
+
+!!! Example
+    ```
+    qim3d preview blobs_256x256x256.tif --resolution 50 --axis 2 --slice 0
+    ```
+
+    ![CLI k3d](assets/preview/relativeIntensity.png){ width="512" }
+
+!!! Example
+    ```
+    qim3d preview qim_logo.png --resolution 40
+    ```
+
+    ![CLI k3d](assets/preview/qimLogo.png){ width="512" }
diff --git a/qim3d/utils/__init__.py b/qim3d/utils/__init__.py
index 3459794553284d61b6a79dbca693bcf4aeb52ea6..5b4866e28b024633a564f662517f78cb9d80bf59 100644
--- a/qim3d/utils/__init__.py
+++ b/qim3d/utils/__init__.py
@@ -5,3 +5,4 @@ from .data import Dataset, prepare_dataloaders, prepare_datasets
 from .img import overlay_rgb_images
 from .models import inference, model_summary, train_model
 from .system import Memory
+from .preview import image_preview
diff --git a/qim3d/utils/cli.py b/qim3d/utils/cli.py
index 37336e8cf3de2c161a19945f72cf09ef34de2d70..6ec221fa6223a0a4c7d38228f85c195f4ba5f187 100644
--- a/qim3d/utils/cli.py
+++ b/qim3d/utils/cli.py
@@ -3,6 +3,8 @@ import webbrowser
 
 import qim3d
 from qim3d.gui import annotation_tool, data_explorer, iso3d, local_thickness
+from qim3d.io.loading import DataLoader
+from qim3d.utils import image_preview
 
 
 def main():
@@ -26,6 +28,14 @@ def main():
     viz_parser.add_argument('--destination', default='k3d.html', help='Path to save html file.')
     viz_parser.add_argument('--no-browser', action='store_true', help='Do not launch browser.')
 
+    # Preview
+    preview_parser = subparsers.add_parser('preview', help= 'Preview of the image in CLI')
+    preview_parser.add_argument('filename',type = str, metavar = 'FILENAME', help = 'Path to image that will be displayed')
+    preview_parser.add_argument('--slice',type = int, metavar ='S', default = None, help = 'Specifies which slice of the image will be displayed.\nDefaults to middle slice. If number exceeds number of slices, last slice will be displayed.' )
+    preview_parser.add_argument('--axis', type = int, metavar = 'AX', default=0, help = 'Specifies from which axis will be the slice taken. Defaults to 0.')
+    preview_parser.add_argument('--resolution',type = int, metavar = 'RES', default = 80, help = 'Resolution of displayed image. Defaults to 80.')
+    preview_parser.add_argument('--absolute_values', action='store_false', help = 'By default set the maximum value to be 255 so the contrast is strong. This turns it off.')
+
     args = parser.parse_args()
 
     if args.subcommand == 'gui':
@@ -75,6 +85,10 @@ def main():
         if not args.no_browser:
             print("Opening in default browser...")
             webbrowser.open_new_tab(args.destination)
+
+    if args.subcommand == 'preview':
+        image = DataLoader().load(args.filename)
+        image_preview(image, image_width = args.resolution, axis =  args.axis, slice = args.slice, relative_intensity= args.absolute_values)
         
 if __name__ == '__main__':
     main()
\ No newline at end of file
diff --git a/qim3d/utils/preview.py b/qim3d/utils/preview.py
new file mode 100644
index 0000000000000000000000000000000000000000..a41b7b2ae51ea4154db50804f7940e01e4fe2fe1
--- /dev/null
+++ b/qim3d/utils/preview.py
@@ -0,0 +1,380 @@
+import numpy as np
+from PIL import Image
+
+# These are fixed because of unicode characters bitmaps. 
+# It could only be flexible if each character had a function that generated the bitmap based on size
+X_STRIDE = 4
+Y_STRIDE = 8 
+
+
+BACK_TO_NORMAL = "\u001b[0m"
+END_MARKER = -10
+
+"""
+For each unicode character that we can print (and is not inverse of another unicode character)
+there is a numnber which serves as a bitmap. That bitmap says how does the unicode character looks
+like in a field 4x8.
+"""
+BITMAPS = [
+    # Block graphics
+    # 0xffff0000, 0x2580,  // upper 1/2; redundant with inverse lower 1/2
+    0x00000000, '\u00a0',
+    0x0000000f, '\u2581',  # lower 1/8
+    0x000000ff, '\u2582',  # lower 1/4
+    0x00000fff, '\u2583', 
+    0x0000ffff, '\u2584',  # lower 1/2
+    0x000fffff, '\u2585', 
+    0x00ffffff, '\u2586',  # lower 3/4
+    0x0fffffff, '\u2587',
+    # 0xffffffff, 0x2588,  # full; redundant with inverse space
+
+    0xeeeeeeee, '\u258a',  # left 3/4
+    0xcccccccc, '\u258c',  # left 1/2
+    0x88888888, '\u258e',  # left 1/4
+
+    0x0000cccc, '\u2596',  # quadrant lower left
+    0x00003333, '\u2597',  # quadrant lower right
+    0xcccc0000, '\u2598',  # quadrant upper left
+    # 0xccccffff, 0x2599,  # 3/4 redundant with inverse 1/4
+    0xcccc3333, '\u259a',  # diagonal 1/2
+    # 0xffffcccc, 0x259b,  # 3/4 redundant
+    # 0xffff3333, 0x259c,  # 3/4 redundant
+    0x33330000, '\u259d',  # quadrant upper right
+    # 0x3333cccc, 0x259e,  # 3/4 redundant
+    # 0x3333ffff, 0x259f,  # 3/4 redundant
+
+    # Line drawing subset: no double lines, no complex light lines
+
+    0x000ff000, '\u2501',  # Heavy horizontal
+    0x66666666, '\u2503',  # Heavy vertical
+
+    0x00077666, '\u250f',  # Heavy down and right
+    0x000ee666, '\u2513',  # Heavy down and left
+    0x66677000, '\u2517',  # Heavy up and right
+    0x666ee000, '\u251b',  # Heavy up and left
+
+    0x66677666, '\u2523',  # Heavy vertical and right
+    0x666ee666, '\u252b',  # Heavy vertical and left
+    0x000ff666, '\u2533',  # Heavy down and horizontal
+    0x666ff000, '\u253b',  # Heavy up and horizontal
+    0x666ff666, '\u254b',  # Heavy cross
+
+    0x000cc000, '\u2578',  # Bold horizontal left
+    0x00066000, '\u2579',  # Bold horizontal up
+    0x00033000, '\u257a',  # Bold horizontal right
+    0x00066000, '\u257b',  # Bold horizontal down
+
+    0x06600660, '\u254f',  # Heavy double dash vertical
+
+    0x000f0000, '\u2500',  # Light horizontal
+    0x0000f000, '\u2500',  #
+    0x44444444, '\u2502',  # Light vertical
+    0x22222222, '\u2502',
+
+    0x000e0000, '\u2574',  # light left
+    0x0000e000, '\u2574',  # light left
+    0x44440000, '\u2575',  # light up
+    0x22220000, '\u2575',  # light up
+    0x00030000, '\u2576',  # light right
+    0x00003000, '\u2576',  # light right
+    0x00004444, '\u2577',  # light down
+    0x00002222, '\u2577',  # light down
+
+    0x11224488, '\u2571',  # diagonals
+    0x88442211, '\u2572',
+    0x99666699, '\u2573',
+
+    0, END_MARKER, 0  # End marker 
+]
+
+class Color:
+    def __init__(self, red:int, green:int, blue:int):
+        self.check_value(red)
+        self.check_value(green)
+        self.check_value(blue)
+        self.red = red
+        self.green = green
+        self.blue = blue
+
+    def check_value(sel, value:int):
+        assert isinstance(value, int), F"Color value has to be integer, this is {type(value)}"
+        assert value < 256, F"Color value has to be between 0 and 255, this is {value}"
+        assert value >= 0, F"Color value has to be between 0 and 255, this is {value}"
+    
+    def __str__(self):
+        """
+        Returns the string in ansi color format
+        """
+        return F"{self.red};{self.green};{self.blue}"
+
+
+def chardata(unicodeChar: str, character_color:Color, background_color:Color) -> str:
+    """
+    Given the character and colors, it creates the string, which when printed in terminal simulates pixels.
+    """
+    # ESC[38;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB foreground color
+    # ESC[48;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB background color
+    assert isinstance(character_color, Color)
+    assert isinstance(background_color, Color)
+    assert isinstance(unicodeChar, str)
+    return F"\033[38;2;{character_color}m\033[48;2;{background_color}m{unicodeChar}"
+
+def get_best_unicode_pattern(bitmap:int) -> tuple[int, str, bool]:
+    """
+    Goes through the list of unicode characters and looks for the best match for bitmap representing the given segment
+    It computes the difference by counting 1s after XORing the two. If they are identical, the count will be 0.
+    This character will be printed
+
+    Parameters:
+    -----------
+    - bitmap (int): int representing the bitmap the image segment.
+
+    Returns:
+    ----------
+    - best_pattern (int): int representing the pattern that was the best match, is then used to calculate colors
+    - unicode (str): the unicode character that represents the given bitmap the best and is then printed
+    - inverse (bool): The list does't contain unicode characters that are inverse of each other. The match can be achieved by simply using 
+        the inversed bitmap. But then we need to know if we have to switch background and foreground color.
+    """
+    best_diff = 8
+    best_pattern = 0x0000ffff
+    unicode = '\u2584'
+    inverse = False
+
+    bit_not = lambda n: (1 << 32) - 1 - n 
+
+    i = 0
+    while BITMAPS[i+1] != END_MARKER:
+        pattern = BITMAPS[i]
+        for j in range(2):
+            diff = (pattern ^ bitmap).bit_count()
+            if diff < best_diff:
+                best_pattern = pattern
+                unicode = BITMAPS[i+1]
+                best_diff = diff
+                inverse = bool(j)
+            pattern = bit_not(pattern)
+
+        i += 2
+
+    return best_pattern, unicode, inverse
+    
+def int_bitmap_from_ndarray(array_bitmap:np.ndarray)->int:
+    """
+    Flattens the array
+    Changes all numbers to strings
+    Creates a string representing binary number
+    Casts it to integer
+    """
+    return int(F"0b{''.join([str(i) for i in array_bitmap.flatten()])}", base = 2)
+
+def ndarray_from_int_bitmap(bitmap:int, shape:tuple = (8, 4))-> np.ndarray:
+    """
+    Gets the binary representation
+    Gets rid of leading '0b
+    Fill in leading zeros so its correct length
+    Make it list of integers
+    Make it numpy array
+    """
+    string = str(bin(bitmap))[2:].zfill(shape[0] * shape[1])
+    return np.array([int(i) for i in string]).reshape(shape)
+    
+def create_bitmap(image_segment:np.ndarray)->int:
+    """
+    Parameters:
+    ------------
+    image_segment: np.ndarray of shape (x, y, 3)
+
+    Returns:
+    ----------
+    bitmap: int, each bit says if the unicode character should cover this bit or not
+    """
+
+    max_color = np.max(np.max(image_segment, axis=0), axis = 0)
+    min_color = np.min(np.min(image_segment, axis=0), axis = 0)
+    rng = np.absolute(max_color - min_color)
+    max_index = np.argmax(rng)
+    if np.sum(rng) == 0:
+        return 0
+    split_threshold = rng[max_index]/2 + min_color[max_index]
+    bitmap = np.array(image_segment[:, :, max_index] <= split_threshold, dtype = int)
+
+
+    return int_bitmap_from_ndarray(bitmap)
+
+def get_color(image_segment:np.ndarray, char_array:np.ndarray) -> Color:
+    """
+    Computes the average color of the segment from pixels specified in charr_array
+    The color is then average over the part then unicode character covers or the background
+
+    Parameters:
+    -----------
+    - image_segment: 4x8 part of the image with the original values so average color can be calculated
+    - char_array: indices saying which pixels out of the 4x8 should be used for color calculation
+
+    Returns:
+    ---------
+    - color: containing the average color over defined pixels
+    """
+    colors = []
+    for channel_index in range(image_segment.shape[2]):
+        channel = image_segment[:,:,channel_index]
+        colors.append(int(np.average(channel[char_array])))
+
+    return Color(colors[0], colors[1], colors[2]) if len(colors) == 3 else Color(colors[0], colors[0], colors[0])
+
+def get_colors(image_segment:np.ndarray, char_array:np.ndarray) -> tuple[Color, Color]:
+    """
+    Parameters:
+    ----------
+    - image_segment
+    - char_array
+
+
+    Returns:
+    ----------
+    - Foreground color
+    - Background color
+    """
+    return get_color(image_segment, char_array == 1), get_color(image_segment, char_array == 0)
+
+def segment_string(image_segment:np.ndarray)-> str:
+    """
+    Creates bitmap so its best represent the color distribution
+    Finds the best match in unicode characters
+    If the best match is character taking up the whole field, then both colors are the same (it doesn't matter)
+    If the best match was inverted unicode character, background and foreground colors need to be switched,
+        otherwise it is not smooth
+    Creates and returns the ansi string to be printed
+    """
+    bitmap = create_bitmap(image_segment)
+    bitmap, unicode, reverse = get_best_unicode_pattern(bitmap)
+    if unicode == '\u00a0':
+        bg_color = fg_color = get_color(image_segment, ndarray_from_int_bitmap(bitmap))
+    else:
+        fg_color, bg_color = get_colors(image_segment, ndarray_from_int_bitmap(bitmap))
+        if reverse:
+            bg_color, fg_color = fg_color, bg_color
+    return chardata(unicode, fg_color, bg_color)
+
+def image_ansi_string(image:np.ndarray) -> str:
+    """
+    For each segment 4x8 finds the string with colored unicode character
+    Create the string for whole image
+
+    Parameters:
+    -----------
+    - image: image to be displayed in terminal
+
+    Returns:
+    ----------
+    - ansi_string: when printed, will render the image
+    """
+    string = []
+    for y in range(0, image.shape[0], Y_STRIDE):
+        for x in range(0, image.shape[1], X_STRIDE):
+
+            this_segment = image[y:y+Y_STRIDE, x:x+X_STRIDE, :]
+            if this_segment.shape[0] != Y_STRIDE:
+                segment = np.zeros((Y_STRIDE, X_STRIDE, this_segment.shape[2]))
+                segment[:this_segment.shape[0], :, :] = this_segment
+                this_segment = segment
+            string.append(segment_string(this_segment))
+
+        string.append(F"{BACK_TO_NORMAL}\n")
+
+    return ''.join(string)
+
+
+
+
+###################################################################
+#               Image preparation
+###################################################################
+
+def rescale_image(image:np.ndarray, size:tuple)->np.ndarray:
+    """
+    The unicode bitmaps are hardcoded for 4x8 segments, they cannot be scaled
+    Thus the image must be scaled to fit the desired resolution
+    """
+    if image.shape[2] == 1:
+        image = np.squeeze(image)
+    image = Image.fromarray(image)
+    image = np.array(image.resize(size))
+    if image.ndim != 3:
+        image = np.expand_dims(image, 2)
+    return image
+
+
+def check_and_adjust_image_dims(image:np.ndarray) -> np.ndarray:
+    if image.ndim == 2:
+        image = np.expand_dims(image, 2)
+    elif image.ndim == 3:
+        if image.shape[2] == 1:  # grayscale image
+            pass
+        elif image.shape[2] == 3: # colorful image
+            pass
+        elif image.shape[2] == 4: # contains alpha channel
+            image = image[:,:,:3]
+        elif image.shape[0] == 3: # torch images have color channels as the first axis
+            image = np.moveaxis(image, 0, -1)
+    else:
+        raise ValueError(F"Image must have 2 (grayscale) or 3 (colorful) dimensions. Yours has {image.ndim}")
+    
+    return image
+
+def check_and_adjust_values(image:np.ndarray, relative_intensity:bool = True) -> np.ndarray:
+    """
+    Checks if the values are between 0 and 255
+    If not, normalizes the values so they are in that interval
+
+    Parameters:
+    -------------
+    - image
+    - relative_intensity: If maximum values are pretty low, they will be barely visible. If true, it normalizes 
+        the values, so that the maximum is at 255
+
+    Returns:
+    -----------
+    - adjusted_image
+    """
+
+    m = np.max(image)
+    if m > 255:
+        image = np.array(255*image/m, dtype = np.uint8)
+    elif m < 1:
+        image = np.array(255*image, dtype = np.uint8)
+
+    if relative_intensity:
+        m = np.max(image)
+        image = np.array((image/m)*255, dtype = np.uint8)
+
+    return image
+
+def choose_slice(image:np.ndarray, axis:int = None, slice:int = None):
+    """
+    Preview give the possibility to choose axis to be sliced and slice to be displayed
+    """
+    if axis is not None:
+        image = np.moveaxis(image, axis, -1)
+
+    if slice is None:
+        slice = image.shape[2]//2
+    else:
+        if slice > image.shape[2]:
+            slice = image.shape[2]-1
+    return image[:,:, slice]
+
+###################################################################
+#               Main function
+###################################################################
+
+def image_preview(image:np.ndarray, image_width:int = 80, axis:int = None, slice:int = None, relative_intensity:bool = True):
+    if image.ndim == 3 and image.shape[2] > 4:
+        image = choose_slice(image, axis, slice)
+    image = check_and_adjust_image_dims(image)
+    ratio = X_STRIDE*image_width/image.shape[1]
+    image = check_and_adjust_values(image, relative_intensity)
+    image = rescale_image(image, (X_STRIDE*image_width, int(ratio * image.shape[0])))
+    print(image_ansi_string(image))
+