Repository: aeye-lab/pymovements
Pull request #583 (2023-10-03 11:57:32)

feat: variable eye to screen distance
## Description

Fixes issue #495 

## Implemented changes

Insert a description of the changes implemented in the pull request.

- Add functionality for distance values in a column to `transforms.pix2deg()`
- Make `distance_cm` optional in `Experiment` and `Screen`
- Add parameter for `distance_column` to  classes `DatasetDefinition`, `GazeDataFrame`.
- Add support for distance column in `GazeDataFrame.transform()`
- Add support for distance column in Dataset loading method `dataset_files.load_gaze_files()` and in loading methods for GazeDataframe `from_numpy()`, `from_pandas()`, `from_csv()`
- Changed a lot of tests..

...

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [ ] Test A
- [ ] Test B

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: `2 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`dc3a32d`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/dc3a32dd93264606dce6404f99e35ff4e7aaee31?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`b62a25b`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 99.82%.

> :exclamation: Current head b62a25b differs from pull request most recent head d1dac30. Consider uploading reports for the commit d1dac30 to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #583      +/-   ##
===========================================
- Coverage   100.00%   99.82%   -0.18%     
===========================================
  Files           52       52              
  Lines         2289     2334      +45     
  Branches       570      585      +15     
===========================================
+ Hits          2289     2330      +41     
- Misses           0        2       +2     
- Partials         0        2       +2     
```


| [Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/dataset/dataset\_definition.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldF9kZWZpbml0aW9uLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/dataset/dataset\_files.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldF9maWxlcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/experiment.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZXhwZXJpbWVudC5weQ==) | `100.00% <ø> (ø)` | |
| [src/pymovements/gaze/integration.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvaW50ZWdyYXRpb24ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/io.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvaW8ucHk=) | `100.00% <ø> (ø)` | |
| [src/pymovements/gaze/screen.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvc2NyZWVuLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/transforms.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvdHJhbnNmb3Jtcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | `99.09% <80.00%> (-0.91%)` | :arrow_down: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/583?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

jakobchwastek: Thanks a lot for your comments @dkrako. Please let me know if any further change is required.

Patch:
diff --git a/src/pymovements/dataset/dataset_definition.py b/src/pymovements/dataset/dataset_definition.py
index 018f806..4c123a2 100644
--- a/src/pymovements/dataset/dataset_definition.py
+++ b/src/pymovements/dataset/dataset_definition.py
@@ -61,6 +61,12 @@ class DatasetDefinition:
 
     custom_read_kwargs : dict[str, Any], optional
         If specified, these keyword arguments will be passed to the file reading function.
+
+    distance_column : str | None
+        The name of the column containing eye-to-screen distance in millimeters for each sample
+        in the input data frame. If specified, the column will be used for pixel to dva
+        transformations. If not specified, the constant eye-to-screen distance will be taken from
+        the experiment definition.
     """
 
     # pylint: disable=too-many-instance-attributes
@@ -86,3 +92,4 @@ class DatasetDefinition:
     position_columns: list[str] | None = None
     velocity_columns: list[str] | None = None
     acceleration_columns: list[str] | None = None
+    distance_column: str | None = None
diff --git a/src/pymovements/dataset/dataset_files.py b/src/pymovements/dataset/dataset_files.py
index 9fa942b..0bdd4e5 100644
--- a/src/pymovements/dataset/dataset_files.py
+++ b/src/pymovements/dataset/dataset_files.py
@@ -232,9 +232,14 @@ def load_gaze_files(
 
         elif preprocessed and extension == 'csv':
             time_column = None
+            distance_column = None
+
             if 'time' in gaze_data.columns:
                 time_column = 'time'
 
+            if 'distance' in gaze_data.columns:
+                distance_column = 'distance'
+
             pixel_columns: list[str] = list(
                 set(GazeDataFrame.valid_pixel_position_columns) & set(gaze_data.columns),
             )
@@ -253,6 +258,7 @@ def load_gaze_files(
                 experiment=definition.experiment,
                 trial_columns=definition.trial_columns,
                 time_column=time_column,
+                distance_column=distance_column,
                 pixel_columns=pixel_columns,
                 position_columns=position_columns,
                 velocity_columns=velocity_columns,
@@ -265,6 +271,7 @@ def load_gaze_files(
                 experiment=definition.experiment,
                 trial_columns=definition.trial_columns,
                 time_column=definition.time_column,
+                distance_column=definition.distance_column,
                 pixel_columns=definition.pixel_columns,
                 position_columns=definition.position_columns,
                 velocity_columns=definition.velocity_columns,
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index e89b655..1f65e33 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -24,6 +24,7 @@ import numpy as np
 
 from pymovements.gaze import transforms_numpy
 from pymovements.gaze.screen import Screen
+from pymovements.utils import checks
 from pymovements.utils import decorators
 
 
@@ -40,9 +41,14 @@ class Experiment:
     """
 
     def __init__(
-            self, screen_width_px: int, screen_height_px: int,
-            screen_width_cm: float, screen_height_cm: float,
-            distance_cm: float, origin: str, sampling_rate: float,
+            self,
+            screen_width_px: int,
+            screen_height_px: int,
+            screen_width_cm: float,
+            screen_height_cm: float,
+            distance_cm: float | None = None,
+            origin: str = 'lower left',
+            sampling_rate: float | None = None,
     ):
         """Initialize Experiment.
 
@@ -56,8 +62,10 @@ class Experiment:
             Screen width in centimeters
         screen_height_cm : float
             Screen height in centimeters
-        distance_cm : float
-            Eye-to-screen distance in centimeters
+        distance_cm : float | None
+            Eye-to-screen distance in centimeters. If None, a `distance_column` must be provided
+            in the `DatasetDefinition` or `GazeDataFrame`, which contains the eye-to-screen
+            distance for each sample in millimeters.
         origin : str
             Specifies the screen location of the origin of the pixel coordinate system.
         sampling_rate : float
@@ -76,8 +84,20 @@ class Experiment:
         ... )
         >>> print(experiment)
         Experiment(screen=Screen(width_px=1280, height_px=1024, width_cm=38,
-        height_cm=30, distance_cm=68, origin=lower left, x_max_dva=15.60, y_max_dva=12.43,
-        x_min_dva=-15.60, y_min_dva=-12.43), sampling_rate=1000.00)
+        height_cm=30, distance_cm=68, origin=lower left), sampling_rate=1000.00)
+
+        We can also access the screen boundaries in degrees of visual angle via the
+        :py:attr:`~pymovements.gaze.Screen` object. This only works if the
+        `distance_cm` attribute is specified.
+
+        >>> experiment.screen.x_min_dva# doctest:+ELLIPSIS
+        -15.59...
+        >>> experiment.screen.x_max_dva# doctest:+ELLIPSIS
+        15.59...
+        >>> experiment.screen.y_min_dva# doctest:+ELLIPSIS
+        -12.42...
+        >>> experiment.screen.y_max_dva# doctest:+ELLIPSIS
+        12.42...
 
         """
         self.screen = Screen(
@@ -88,6 +108,12 @@ class Experiment:
             distance_cm=distance_cm,
             origin=origin,
         )
+
+        checks.check_is_not_none(sampling_rate=sampling_rate)
+        assert sampling_rate is not None
+
+        checks.check_is_greater_than_zero(sampling_rate=sampling_rate)
+
         self.sampling_rate = sampling_rate
 
     def pos2vel(
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index ddb6a66..a8ea385 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -21,6 +21,7 @@
 from __future__ import annotations
 
 import inspect
+import warnings
 from collections.abc import Callable
 from copy import deepcopy
 from typing import Any
@@ -89,6 +90,7 @@ class GazeDataFrame:
             position_columns: list[str] | None = None,
             velocity_columns: list[str] | None = None,
             acceleration_columns: list[str] | None = None,
+            distance_column: str | None = None,
     ):
         """Initialize a :py:class:`pymovements.gaze.gaze_dataframe.GazeDataFrame`.
 
@@ -123,6 +125,11 @@ class GazeDataFrame:
             The name of the acceleration columns in the input data frame. These columns will be
             nested into the column ``acceleration``. If the list is empty or None, the nested
             ``acceleration`` column will not be created.
+        distance_column:
+            The name of the column containing eye-to-screen distance in millimeters for each sample
+            in the input data frame. If specified, the column will be used for pixel to dva
+            transformations. If not specified, the constant eye-to-screen distance will be taken
+            from the experiment definition.
 
         Notes
         -----
@@ -192,6 +199,9 @@ class GazeDataFrame:
         if time_column is not None:
             self.frame = self.frame.rename({time_column: 'time'})
 
+        if distance_column is not None:
+            self.frame = self.frame.rename({distance_column: 'distance'})
+
         # List of passed not-None column specifier lists.
         # The list will be used for inferring n_components.
         column_specifiers: list[list[str]] = []
@@ -286,7 +296,24 @@ class GazeDataFrame:
             if 'distance' in method_kwargs and 'distance' not in kwargs:
                 self._check_experiment()
                 assert self.experiment is not None
-                kwargs['distance'] = self.experiment.screen.distance_cm
+
+                if 'distance' in self.frame.columns:
+                    kwargs['distance'] = 'distance'
+
+                    if self.experiment.screen.distance_cm:
+                        warnings.warn(
+                            "Both a distance column and experiment's "
+                            'eye-to-screen distance are specified. '
+                            'Using eye-to-screen distances from column '
+                            "'distance' in the dataframe.",
+                        )
+                elif self.experiment.screen.distance_cm:
+                    kwargs['distance'] = self.experiment.screen.distance_cm
+                else:
+                    raise AttributeError(
+                        'Neither eye-to-screen distance is in the columns of the dataframe '
+                        'nor experiment eye-to-screen distance is specified.',
+                    )
 
             if 'sampling_rate' in method_kwargs and 'sampling_rate' not in kwargs:
                 self._check_experiment()
diff --git a/src/pymovements/gaze/integration.py b/src/pymovements/gaze/integration.py
index 0d3554c..e716d80 100644
--- a/src/pymovements/gaze/integration.py
+++ b/src/pymovements/gaze/integration.py
@@ -42,6 +42,7 @@ def from_numpy(
         position: np.ndarray | None = None,
         velocity: np.ndarray | None = None,
         acceleration: np.ndarray | None = None,
+        distance: np.ndarray | None = None,
         schema: list[str] | None = None,
         orient: Literal['col', 'row'] = 'col',
         time_column: str | None = None,
@@ -49,6 +50,7 @@ def from_numpy(
         position_columns: list[str] | None = None,
         velocity_columns: list[str] | None = None,
         acceleration_columns: list[str] | None = None,
+        distance_column: str | None = None,
 ) -> GazeDataFrame:
     """Get a :py:class:`~pymovements.gaze.gaze_dataframe.GazeDataFrame` from a numpy array.
 
@@ -80,6 +82,8 @@ def from_numpy(
         Array of gaze velocities in degrees of visual angle per second.
     acceleration:
         Array of gaze accelerations in degrees of visual angle per square second.
+    distance:
+        Array of eye-to-screen distances in millimiters.
     schema:
         A list of column names.
     orient:
@@ -94,6 +98,11 @@ def from_numpy(
         The name of the dva velocity columns in the input data frame.
     acceleration_columns:
         The name of the dva acceleration columns in the input data frame.
+    distance_column:
+        The name of the column containing eye-to-screen distance in millimiters for each sample
+        in the input data frame. If specified, the column will be used for pixel to dva
+        transformations. If not specified, the constant eye-to-screen distance will be taken from
+        the experiment definition.
 
     Returns
     -------
@@ -198,6 +207,7 @@ def from_numpy(
     checks.check_is_mutual_exclusive(data=data, position=position)
     checks.check_is_mutual_exclusive(data=data, velocity=velocity)
     checks.check_is_mutual_exclusive(data=data, acceleration=acceleration)
+    checks.check_is_mutual_exclusive(data=data, distance=distance)
 
     if data is not None:
         df = pl.from_numpy(data=data, schema=schema, orient=orient)
@@ -210,6 +220,7 @@ def from_numpy(
             position_columns=position_columns,
             velocity_columns=velocity_columns,
             acceleration_columns=acceleration_columns,
+            distance_column=distance_column,
         )
 
     # Initialize with an empty DataFrame, as every column specifier could be None.
@@ -246,6 +257,12 @@ def from_numpy(
         dfs.append(df)
         acceleration_columns = df.columns
 
+    distance_column = None
+    if distance is not None:
+        df = pl.from_numpy(data=distance, schema=['distance'], orient=orient)
+        dfs.append(df)
+        distance_column = 'distance'
+
     df = pl.concat(dfs, how='horizontal')
     return GazeDataFrame(
         data=df,
@@ -256,6 +273,7 @@ def from_numpy(
         position_columns=position_columns,
         velocity_columns=velocity_columns,
         acceleration_columns=acceleration_columns,
+        distance_column=distance_column,
     )
 
 
@@ -269,6 +287,7 @@ def from_pandas(
         position_columns: list[str] | None = None,
         velocity_columns: list[str] | None = None,
         acceleration_columns: list[str] | None = None,
+        distance_column: str | None = None,
 ) -> GazeDataFrame:
     """Get a :py:class:`~pymovements.gaze.gaze_dataframe.GazeDataFrame` from a pandas DataFrame.
 
@@ -290,6 +309,11 @@ def from_pandas(
         The name of the dva velocity columns in the input data frame.
     acceleration_columns:
         The name of the dva acceleration columns in the input data frame.
+    distance_column:
+        The name of the column containing eye-to-screen distance in millimeters for each sample
+        in the input data frame. If specified, the column will be used for pixel to dva
+        transformations. If not specified, the constant eye-to-screen distance will be taken from
+        the experiment definition.
 
     Returns
     -------
@@ -305,4 +329,5 @@ def from_pandas(
         position_columns=position_columns,
         velocity_columns=velocity_columns,
         acceleration_columns=acceleration_columns,
+        distance_column=distance_column,
     )
diff --git a/src/pymovements/gaze/io.py b/src/pymovements/gaze/io.py
index 9135864..f9f2022 100644
--- a/src/pymovements/gaze/io.py
+++ b/src/pymovements/gaze/io.py
@@ -39,6 +39,7 @@ def from_csv(
         position_columns: list[str] | None = None,
         velocity_columns: list[str] | None = None,
         acceleration_columns: list[str] | None = None,
+        distance_column: str | None = None,
         **read_csv_kwargs: Any,
 ) -> GazeDataFrame:
     """Initialize a :py:class:`pymovements.gaze.gaze_dataframe.GazeDataFrame`.
@@ -72,6 +73,10 @@ def from_csv(
         The name of the acceleration columns in the input data frame. These columns will be
         nested into the column ``acceleration``. If the list is empty or None, the nested
         ``acceleration`` column will not be created.
+    distance_column:
+        The name of the eye-to-screen distance column in the input data frame. If specified,
+        the column will be used for pixel to dva transformations. If not specified, the
+        constant eye-to-screen distance will be taken from the experiment definition.
     **read_csv_kwargs:
             Additional keyword arguments to be passed to polars to read in the csv.
 
@@ -156,6 +161,7 @@ def from_csv(
         position_columns=position_columns,
         velocity_columns=velocity_columns,
         acceleration_columns=acceleration_columns,
+        distance_column=distance_column,
     )
     return gaze_df
 
diff --git a/src/pymovements/gaze/screen.py b/src/pymovements/gaze/screen.py
index 772827c..4052c87 100644
--- a/src/pymovements/gaze/screen.py
+++ b/src/pymovements/gaze/screen.py
@@ -64,8 +64,8 @@ class Screen:
             height_px: int,
             width_cm: float,
             height_cm: float,
-            distance_cm: float,
-            origin: str,
+            distance_cm: float | None = None,
+            origin: str = 'lower left',
     ):
         """Initialize Screen.
 
@@ -79,8 +79,10 @@ class Screen:
             Screen width in centimeters
         height_cm : float
             Screen height in centimeters
-        distance_cm : float
-            Eye-to-screen distance in centimeters
+        distance_cm : float | None
+            Eye-to-screen distance in centimeters. If None, a `distance_column` must be provided
+            in the `DatasetDefinition` or `GazeDataFrame`, which contains the eye-to-screen
+            distance for each sample in millimeters.
         origin : str
             Specifies the screen location of the origin of the pixel coordinate system.
 
@@ -96,15 +98,28 @@ class Screen:
         ... )
         >>> print(screen)
         Screen(width_px=1280, height_px=1024, width_cm=38.00,
-        height_cm=30.00, distance_cm=68.00, origin=lower left,
-        x_max_dva=15.60, y_max_dva=12.43, x_min_dva=-15.60,
-        y_min_dva=-12.43)
+        height_cm=30.00, distance_cm=68.00, origin=lower left)
+
+        We can also access the screen boundaries in degrees of visual angle. This only works if the
+        `distance_cm` attribute is specified.
+
+        >>> screen.x_min_dva# doctest:+ELLIPSIS
+        -15.59...
+        >>> screen.x_max_dva# doctest:+ELLIPSIS
+        15.59...
+        >>> screen.y_min_dva# doctest:+ELLIPSIS
+        -12.42...
+        >>> screen.y_max_dva# doctest:+ELLIPSIS
+        12.42...
+
         """
         checks.check_no_zeros(width_px, 'width_px')
         checks.check_no_zeros(height_px, 'height_px')
         checks.check_no_zeros(width_cm, 'width_cm')
         checks.check_no_zeros(height_cm, 'height_cm')
-        checks.check_no_zeros(distance_cm, 'distance_cm')
+
+        if distance_cm is not None:
+            checks.check_no_zeros(distance_cm, 'distance_cm')
 
         self.width_px = width_px
         self.height_px = height_px
@@ -113,22 +128,68 @@ class Screen:
         self.distance_cm = distance_cm
         self.origin = origin
 
-        # calculate screen boundary coordinates in degrees of visual angle
-        self.x_max_dva = transforms_numpy.pix2deg(
-            width_px - 1,
-            screen_px=width_px, screen_cm=width_cm, distance_cm=distance_cm, origin=origin,
+    @property
+    def x_max_dva(self) -> float:
+        """Maximum screen x-coordinate in degrees of visual angle."""
+        self._check_distance_cm()
+        assert self.distance_cm is not None
+
+        return float(
+            transforms_numpy.pix2deg(
+                self.width_px - 1,
+                screen_px=self.width_px,
+                screen_cm=self.width_cm,
+                distance_cm=self.distance_cm,
+                origin=self.origin,
+            ),
         )
-        self.y_max_dva = transforms_numpy.pix2deg(
-            height_px - 1,
-            screen_px=height_px, screen_cm=height_cm, distance_cm=distance_cm, origin=origin,
+
+    @property
+    def y_max_dva(self) -> float:
+        """Maximum screen y-coordinate in degrees of visual angle."""
+        self._check_distance_cm()
+        assert self.distance_cm is not None
+
+        return float(
+            transforms_numpy.pix2deg(
+                self.height_px - 1,
+                screen_px=self.height_px,
+                screen_cm=self.height_cm,
+                distance_cm=self.distance_cm,
+                origin=self.origin,
+            ),
         )
-        self.x_min_dva = transforms_numpy.pix2deg(
-            0,
-            screen_px=width_px, screen_cm=width_cm, distance_cm=distance_cm, origin=origin,
+
+    @property
+    def x_min_dva(self) -> float:
+        """Minimum screen x-coordinate in degrees of visual angle."""
+        self._check_distance_cm()
+        assert self.distance_cm is not None
+
+        return float(
+            transforms_numpy.pix2deg(
+                0,
+                screen_px=self.width_px,
+                screen_cm=self.width_cm,
+                distance_cm=self.distance_cm,
+                origin=self.origin,
+            ),
         )
-        self.y_min_dva = transforms_numpy.pix2deg(
-            0,
-            screen_px=height_px, screen_cm=height_cm, distance_cm=distance_cm, origin=origin,
+
+    @property
+    def y_min_dva(self) -> float:
+        """Minimum screen y-coordinate in degrees of visual angle."""
+        self._check_distance_cm()
+        assert self.distance_cm is not None
+
+        return float(
+            transforms_numpy.pix2deg(
+                0,
+                screen_px=self.height_px,
+                screen_cm=self.height_cm,
+                distance_cm=self.distance_cm,
+                origin=self.origin,
+            ),
         )
 
     def pix2deg(
@@ -177,6 +238,9 @@ class Screen:
         >>> screen.pix2deg(arr=arr)
         array([[ 3.07379946, 20.43909054]])
         """
+        self._check_distance_cm()
+        assert self.distance_cm is not None
+
         return transforms_numpy.pix2deg(
             arr=arr,
             screen_px=(self.width_px, self.height_px),
@@ -184,3 +248,10 @@ class Screen:
             distance_cm=self.distance_cm,
             origin=self.origin,
         )
+
+    def _check_distance_cm(self) -> None:
+        """Check if distance_cm is not None."""
+        if self.distance_cm is None:
+            raise ValueError(
+                'distance_cm must not be None when using this method',
+            )
diff --git a/src/pymovements/gaze/transforms.py b/src/pymovements/gaze/transforms.py
index 3713367..8401837 100644
--- a/src/pymovements/gaze/transforms.py
+++ b/src/pymovements/gaze/transforms.py
@@ -184,7 +184,7 @@ def pix2deg(
         *,
         screen_resolution: tuple[int, int],
         screen_size: tuple[float, float],
-        distance: float,
+        distance: float | str,
         origin: str,
         n_components: int,
         pixel_column: str = 'pixel',
@@ -199,7 +199,9 @@ def pix2deg(
     screen_size:
         Screen size in centimeters as tuple (width, height).
     distance:
-        Eye-to-screen distance in centimeters
+        Must be either a scalar or a string. If a scalar is passed, it is interpreted as the
+        Eye-to-screen distance in centimeters. If a string is passed, it is interpreted as the name
+        of a column containing the Eye-to-screen distance in millimiters for each sample.
     origin:
         The location of the pixel origin. Supported values: ``center``, ``lower left``. See also
         py:func:`~pymovements.gaze.transform.center_origin` for more information.
@@ -212,7 +214,6 @@ def pix2deg(
     """
     _check_screen_resolution(screen_resolution)
     _check_screen_size(screen_size)
-    _check_distance(distance)
 
     centered_pixels = center_origin(
         screen_resolution=screen_resolution,
@@ -221,15 +222,26 @@ def pix2deg(
         pixel_column=pixel_column,
     )
 
-    # Compute eye-to-screen-distance in pixel units.
-    distance_pixels = tuple(
-        distance * (screen_px / screen_cm)
-        for screen_px, screen_cm in zip(screen_resolution, screen_size)
-    )
+    if isinstance(distance, (float, int)):
+        _check_distance(distance)
+        distance_series = pl.lit(distance)
+    elif isinstance(distance, str):
+        # True division by 10 is needed to convert distance from mm to cm
+        distance_series = pl.col(distance).truediv(10)
+    else:
+        raise TypeError(
+            f'`distance` must be of type `float`, `int` or `str`, but is of type'
+            f'`{type(distance).__name__}`',
+        )
+
+    distance_pixels = pl.concat_list([
+        distance_series.mul(screen_resolution[component % 2] / screen_size[component % 2])
+        for component in range(n_components)
+    ])
 
     degree_components = [
-        centered_pixels.list.get(component).map(
-            _arctan2_helper(distance_pixels[component % 2]),
+        pl.arctan2(
+            centered_pixels.list.get(component), distance_pixels.list.get(component),
         ) * (180 / np.pi)
         for component in range(n_components)
     ]
@@ -237,11 +249,6 @@ def pix2deg(
     return pl.concat_list(list(degree_components)).alias(position_column)
 
 
-def _arctan2_helper(distance: float) -> Callable:
-    """Return single-argument lambda function with fixed second argument."""
-    return lambda s: np.arctan2(s, distance)
-
-
 def _check_distance(distance: float) -> None:
     """Check if all screen values are scalars and are greather than zero."""
     checks.check_is_scalar(distance=distance)


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index a287c39..ec31658 100644
--- a/tests/dataset/dataset_test.py
+++ b/tests/dataset/dataset_test.py
@@ -72,7 +72,7 @@ def create_event_files_from_fileinfo(gaze_dfs, fileinfo, rootpath):
         gaze_df.write_ipc(rootpath / filepath)
 
 
-def mock_toy(rootpath, raw_fileformat, eyes):
+def mock_toy(rootpath, raw_fileformat, eyes, remote=False):
     subject_ids = list(range(1, 21))
 
     fileinfo = pl.DataFrame(data={'subject_id': subject_ids}, schema={'subject_id': pl.Int64})
@@ -184,6 +184,17 @@ def mock_toy(rootpath, raw_fileformat, eyes):
         else:
             raise ValueError(f'invalid value for eyes: {eyes}')
 
+        if remote:
+            gaze_df = gaze_df.with_columns([
+                pl.lit(680.).alias('distance'),
+            ])
+
+            distance_column = 'distance'
+            distance_cm = None
+        else:
+            distance_column = None
+            distance_cm = 68
+
         gaze_dfs.append(gaze_df)
 
     create_raw_gaze_files_from_fileinfo(gaze_dfs, fileinfo, rootpath / 'raw')
@@ -258,13 +269,14 @@ def mock_toy(rootpath, raw_fileformat, eyes):
             screen_height_px=1024,
             screen_width_cm=38,
             screen_height_cm=30.2,
-            distance_cm=68,
+            distance_cm=distance_cm,
             origin='lower left',
             sampling_rate=1000,
         ),
         filename_format=r'{subject_id:d}.' + raw_fileformat,
         filename_format_dtypes={'subject_id': pl.Int64},
         time_column='time',
+        distance_column=distance_column,
         pixel_columns=pixel_columns,
     )
 
@@ -283,7 +295,7 @@ def mock_toy(rootpath, raw_fileformat, eyes):
 
 @pytest.fixture(
     name='dataset_configuration',
-    params=['ToyMono', 'ToyBino', 'ToyLeft', 'ToyRight', 'ToyBino+Avg'],
+    params=['ToyMono', 'ToyBino', 'ToyLeft', 'ToyRight', 'ToyBino+Avg', 'ToyRemote'],
 )
 def fixture_dataset(request, tmp_path):
     rootpath = tmp_path
@@ -300,6 +312,8 @@ def fixture_dataset(request, tmp_path):
         dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='right')
     elif request.param == 'ToyMat':
         dataset_dict = mock_toy(rootpath, raw_fileformat='mat', eyes='both')
+    elif request.param == 'ToyRemote':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='both', remote=True)
     else:
         raise ValueError(f'{request.param} not supported as dataset mock')
 
diff --git a/tests/gaze/gaze_transform_test.py b/tests/gaze/gaze_transform_test.py
index 8ec8b03..ae677e3 100644
--- a/tests/gaze/gaze_transform_test.py
+++ b/tests/gaze/gaze_transform_test.py
@@ -287,6 +287,208 @@ def fixture_experiment():
             id='pix2deg_origin_lower_left',
         ),
 
+        pytest.param(
+            {
+                'data': pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [(100 - 1) / 2, (100 - 1) / 2],
+                        'y_pix': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                'experiment': pm.Experiment(
+                    sampling_rate=1000,
+                    screen_width_px=100,
+                    screen_height_px=100,
+                    screen_width_cm=100,
+                    screen_height_cm=100,
+                    distance_cm=None,
+                    origin='center',
+                ),
+                'pixel_columns': ['x_pix', 'y_pix'],
+                'distance_column': 'distance',
+            },
+            'pix2deg', {},
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [49.5, 49.5],
+                        'y_pix': [0.0, 0.0],
+                        'x_dva': [26.3354, 26.3354],
+                        'y_dva': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                pixel_columns=['x_pix', 'y_pix'],
+                position_columns=['x_dva', 'y_dva'],
+            ),
+            id='pix2deg_origin_center_distance_column',
+        ),
+
+        pytest.param(
+            {
+                'data': pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [(100 - 1) / 2, (100 - 1) / 2],
+                        'y_pix': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                'experiment': pm.Experiment(
+                    sampling_rate=1000,
+                    screen_width_px=100,
+                    screen_height_px=100,
+                    screen_width_cm=100,
+                    screen_height_cm=100,
+                    distance_cm=None,
+                    origin='center',
+                ),
+                'pixel_columns': ['x_pix', 'y_pix'],
+                'distance_column': 'distance',
+            },
+            'pix2deg', {'n_components': 2},
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [49.5, 49.5],
+                        'y_pix': [0.0, 0.0],
+                        'x_dva': [26.3354, 26.3354],
+                        'y_dva': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                pixel_columns=['x_pix', 'y_pix'],
+                position_columns=['x_dva', 'y_dva'],
+                distance_column='distance',
+            ),
+            id='pix2deg_origin_center_explicit_n_components_distance_column',
+        ),
+
+        pytest.param(
+            {
+                'data': pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [(100 - 1) / 2, (100 - 1) / 2],
+                        'y_pix': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                'experiment': pm.Experiment(
+                    sampling_rate=1000,
+                    screen_width_px=100,
+                    screen_height_px=100,
+                    screen_width_cm=100,
+                    screen_height_cm=100,
+                    distance_cm=None,
+                    origin='center',
+                ),
+                'pixel_columns': ['x_pix', 'y_pix'],
+            },
+            'pix2deg', {'distance': 'distance'},
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    {
+                        'time': [1000, 1000],
+                        'x_pix': [49.5, 49.5],
+                        'y_pix': [0.0, 0.0],
+                        'x_dva': [26.3354, 26.3354],
+                        'y_dva': [0.0, 0.0],
+                        'distance': [1000, 1000],
+                    },
+                ),
+                pixel_columns=['x_pix', 'y_pix'],
+                position_columns=['x_dva', 'y_dva'],
+            ),
+            id='pix2deg_origin_center_explicit_distance_column',
+        ),
+
+        pytest.param(
+            {
+                'data': pl.from_dict(
+                    {
+                        'time': [1000],
+                        'x_pix': [(100 - 1) / 2],
+                        'y_pix': [0.0],
+                        'distance': [1000],
+                    },
+                ),
+                'experiment': pm.Experiment(
+                    sampling_rate=1000,
+                    screen_width_px=100,
+                    screen_height_px=100,
+                    screen_width_cm=100,
+                    screen_height_cm=100,
+                    distance_cm=None,
+                    origin='lower left',
+                ),
+                'pixel_columns': ['x_pix', 'y_pix'],
+                'distance_column': 'distance',
+            },
+            'pix2deg', {},
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    {
+                        'time': [1000],
+                        'x_pix': [49.5],
+                        'y_pix': [0.0],
+                        'x_dva': [0.0],
+                        'y_dva': [-26.3354],
+                        'distance': [1000],
+                    },
+                ),
+                pixel_columns=['x_pix', 'y_pix'],
+                position_columns=['x_dva', 'y_dva'],
+                distance_column='distance',
+            ),
+            id='pix2deg_origin_lower_left_distance_column',
+        ),
+
+        pytest.param(
+            {
+                'data': pl.from_dict(
+                    {
+                        'time': [1000],
+                        'x_pix': [(100 - 1) / 2],
+                        'y_pix': [0.0],
+                        'distance': [1000],
+                    },
+                ),
+                'experiment': pm.Experiment(
+                    sampling_rate=1000,
+                    screen_width_px=100,
+                    screen_height_px=100,
+                    screen_width_cm=100,
+                    screen_height_cm=100,
+                    distance_cm=1,
+                    origin='lower left',
+                ),
+                'pixel_columns': ['x_pix', 'y_pix'],
+                'distance_column': 'distance',
+            },
+            'pix2deg', {},
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    {
+                        'time': [1000],
+                        'x_pix': [49.5],
+                        'y_pix': [0.0],
+                        'x_dva': [0.0],
+                        'y_dva': [-26.3354],
+                        'distance': [1000],
+                    },
+                ),
+                pixel_columns=['x_pix', 'y_pix'],
+                position_columns=['x_dva', 'y_dva'],
+                distance_column='distance',
+            ),
+            id='pix2deg_distance_experiment_and_distance_column_defaults_to_column',
+        ),
+
         pytest.param(
             {
                 'data': pl.from_dict(
@@ -588,6 +790,29 @@ def test_gaze_dataframe_pix2deg_creates_position_column(data, experiment, pixel_
             'experiment must not be None for this method to work',
             id='no_experiment',
         ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, None, 'center', 1000),
+                'pixel_columns': ['x', 'y'],
+            },
+            AttributeError,
+            'Neither eye-to-screen distance is in the columns of the dataframe '
+            'nor experiment eye-to-screen distance is specified.',
+            id='no_distance_column_no_experiment_distance',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, None, 'center', 1000),
+                'pixel_columns': ['x', 'y'],
+                'distance_column': 'distance',
+            },
+            AttributeError,
+            'Neither eye-to-screen distance is in the columns of the dataframe '
+            'nor experiment eye-to-screen distance is specified.',
+            id='distance_column_not_in_dataframe',
+        ),
     ],
 )
 def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, expected_msg):
@@ -600,6 +825,35 @@ def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, expected_msg)
     assert msg == expected_msg
 
 
+@pytest.mark.parametrize(
+    ('init_kwargs', 'warning', 'expected_msg'),
+    [
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'d': pl.Float64, 'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                'pixel_columns': ['x', 'y'],
+                'distance_column': 'd',
+            },
+            UserWarning,
+            "Both a distance column and experiment's "
+            'eye-to-screen distance are specified. '
+            'Using eye-to-screen distances from column '
+            "'distance' in the dataframe.",
+            id='both_distance_column_and_experiment_distance',
+        ),
+    ],
+)
+def test_gaze_dataframe_pix2deg_warnings(init_kwargs, warning, expected_msg):
+    gaze_df = pm.GazeDataFrame(**init_kwargs)
+
+    with pytest.warns(warning) as excinfo:
+        gaze_df.pix2deg()
+
+    msg = excinfo[0].message.args[0]
+    assert expected_msg == msg
+
+
 @pytest.mark.parametrize(
     ('data', 'position_columns'),
     [
diff --git a/tests/gaze/integration_numpy_test.py b/tests/gaze/integration_numpy_test.py
index ed61afe..8e6d2e0 100644
--- a/tests/gaze/integration_numpy_test.py
+++ b/tests/gaze/integration_numpy_test.py
@@ -62,6 +62,7 @@ def test_from_numpy_with_schema():
     array = np.array(
         [
             [101, 102, 103, 104],
+            [100, 100, 100, 100],
             [0, 1, 2, 3],
             [4, 5, 6, 7],
             [9, 8, 7, 6],
@@ -74,14 +75,14 @@ def test_from_numpy_with_schema():
         dtype=np.float64,
     )
 
-    schema = ['t', 'x_pix', 'y_pix', 'x_pos', 'y_pos', 'x_vel', 'y_vel', 'x_acc', 'y_acc']
+    schema = ['t', 'd', 'x_pix', 'y_pix', 'x_pos', 'y_pos', 'x_vel', 'y_vel', 'x_acc', 'y_acc']
 
     experiment = pm.Experiment(
         screen_width_px=1280,
         screen_height_px=1024,
         screen_width_cm=38,
         screen_height_cm=30,
-        distance_cm=68,
+        distance_cm=None,
         origin='lower left',
         sampling_rate=1000.0,
     )
@@ -91,6 +92,7 @@ def test_from_numpy_with_schema():
         schema=schema,
         experiment=experiment,
         time_column='t',
+        distance_column='d',
         pixel_columns=['x_pix', 'y_pix'],
         position_columns=['x_pos', 'y_pos'],
         velocity_columns=['x_vel', 'y_vel'],
@@ -100,6 +102,7 @@ def test_from_numpy_with_schema():
     expected = pl.DataFrame(
         {
             'time': [101, 102, 103, 104],
+            'distance': [100, 100, 100, 100],
             'pixel': [[0, 4], [1, 5], [2, 6], [3, 7]],
             'position': [[9, 5], [8, 4], [7, 3], [6, 2]],
             'velocity': [[1, 5], [2, 6], [3, 7], [4, 8]],
@@ -107,6 +110,7 @@ def test_from_numpy_with_schema():
         },
         schema={
             'time': pl.Float64,
+            'distance': pl.Float64,
             'pixel': pl.List(pl.Float64),
             'position': pl.List(pl.Float64),
             'velocity': pl.List(pl.Float64),
@@ -120,6 +124,7 @@ def test_from_numpy_with_schema():
 
 def test_from_numpy_explicit_columns():
     time = np.array([101, 102, 103, 104], dtype=np.int64)
+    distance = np.array([100, 100, 100, 100], dtype=np.float64)
     pixel = np.array([[0, 1, 2, 3], [4, 5, 6, 7]], dtype=np.int64)
     position = np.array([[9, 8, 7, 6], [5, 4, 3, 2]], dtype=np.float64)
     velocity = np.array([[1, 2, 3, 4], [5, 6, 7, 8]], dtype=np.float64)
@@ -130,13 +135,14 @@ def test_from_numpy_explicit_columns():
         screen_height_px=1024,
         screen_width_cm=38,
         screen_height_cm=30,
-        distance_cm=68,
+        distance_cm=None,
         origin='lower left',
         sampling_rate=1000.0,
     )
 
     gaze = pm.gaze.from_numpy(
         time=time,
+        distance=distance,
         pixel=pixel,
         position=position,
         velocity=velocity,
@@ -147,6 +153,7 @@ def test_from_numpy_explicit_columns():
     expected = pl.DataFrame(
         {
             'time': [101, 102, 103, 104],
+            'distance': [100, 100, 100, 100],
             'pixel': [[0, 4], [1, 5], [2, 6], [3, 7]],
             'position': [[9, 5], [8, 4], [7, 3], [6, 2]],
             'velocity': [[1, 5], [2, 6], [3, 7], [4, 8]],
@@ -154,6 +161,7 @@ def test_from_numpy_explicit_columns():
         },
         schema={
             'time': pl.Int64,
+            'distance': pl.Float64,
             'pixel': pl.List(pl.Int64),
             'position': pl.List(pl.Float64),
             'velocity': pl.List(pl.Float64),
diff --git a/tests/gaze/screen_test.py b/tests/gaze/screen_test.py
new file mode 100644
index 0000000..95c01f6
--- /dev/null
+++ b/tests/gaze/screen_test.py
@@ -0,0 +1,48 @@
+# Copyright (c) 2023 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test for Screen class."""
+import pytest
+
+import pymovements as pm
+
+
+@pytest.mark.parametrize('property_name', ['x_max_dva', 'y_max_dva', 'x_min_dva', 'y_min_dva'])
+def test_dva_properties_with_no_distance_cm(property_name):
+    screen = pm.Screen(1920, 1080, 30, 20, None, 'lower left')
+    with pytest.raises(ValueError):
+        getattr(screen, property_name)
+
+
+@pytest.mark.parametrize('property_name', ['x_max_dva', 'y_max_dva', 'x_min_dva', 'y_min_dva'])
+def test_dva_properties_with_distance_cm(property_name):
+    screen = pm.Screen(1920, 1080, 30, 20, 60, 'lower left')
+
+    getattr(screen, property_name)
+
+
+def test_screen_pix2deg_with_no_distance_cm():
+    screen = pm.Screen(1920, 1080, 30, 20, None, 'lower left')
+    with pytest.raises(ValueError):
+        screen.pix2deg([[0, 0]])
+
+
+def test_screen_pix2deg_with_distance_cm():
+    screen = pm.Screen(1920, 1080, 30, 20, 60, 'lower left')
+    screen.pix2deg([[0, 0]])
diff --git a/tests/gaze/transforms/pix2deg_test.py b/tests/gaze/transforms/pix2deg_test.py
index 0ec5b77..9866777 100644
--- a/tests/gaze/transforms/pix2deg_test.py
+++ b/tests/gaze/transforms/pix2deg_test.py
@@ -307,7 +307,7 @@ def test_pix2deg_raises_error(kwargs, series, exception, msg_substrings):
             {
                 'screen_resolution': (100, 100),
                 'screen_size': (100, 100),
-                'distance': 100,
+                'distance': 100.,
                 'origin': 'center',
                 'pixel_column': 'pixel',
                 'position_column': 'position',
@@ -501,8 +501,21 @@ def test_pix2deg_raises_error(kwargs, series, exception, msg_substrings):
         ),
     ],
 )
-def test_pix2deg_returns(kwargs, series, expected_df):
-    df = series.to_frame()
+def test_pix2deg_returns(kwargs, series, expected_df, distance_as_column):
+    df = series.to_frame().clone()
+    kwargs = kwargs.copy()
+
+    # Decide whether to pass distance as column or scalar
+    if distance_as_column:
+
+        # unit of distance values has to be in mm when passing as column
+        distance_value = kwargs['distance'] * 10
+
+        df = df.with_columns(
+            pl.Series('distance', [distance_value], pl.Float64),
+        )
+
+        kwargs['distance'] = 'distance'
 
     result_df = df.select(
         pm.gaze.transforms.pix2deg(**kwargs),
@@ -510,9 +523,125 @@ def test_pix2deg_returns(kwargs, series, expected_df):
     assert_frame_equal(result_df, expected_df.to_frame())
 
 
-def test__arctan2_helper():
-    x1 = 2
-    x2 = 100
+@pytest.mark.parametrize(
+    ('kwargs', 'data', 'expected_df'),
+    [
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'center',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[0., 0.], [0., 0.], [0., 0.]],
+                'distance': [100., 100., 100.],
+            },
+            pl.Series('position', [[0., 0.], [0., 0.], [0., 0.]], pl.List(pl.Float64)),
+            id='origin_center_constant_distance_and_pixel_returns_constant_position',
+        ),
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'center',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[0., 0.], [0., 0.], [0., 0.]],
+                'distance': [1000., 100., 10.],
+            },
+            pl.Series('position', [[0., 0.], [0., 0.], [0., 0.]], pl.List(pl.Float64)),
+            id='origin_center_constant_centered_pixel_changing_distance_returns_constant_position',
+        ),
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'lower left',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[49.5, 49.5], [49.5, 49.5], [49.5, 49.5]],
+                'distance': [100., 100., 100.],
+            },
+            pl.Series('position', [[0., 0.], [0., 0.], [0., 0.]], pl.List(pl.Float64)),
+            id='origin_lower_left_constant_distance_and_pixel_'
+               'returns_constant_position',
+        ),
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'lower left',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[49.5, 49.5], [49.5, 49.5], [49.5, 49.5]],
+                'distance': [1000., 100., 10.],
+            },
+            pl.Series('position', [[0., 0.], [0., 0.], [0., 0.]], pl.List(pl.Float64)),
+            id='origin_lower_left_constant_centered_pixel_changing_distance_'
+               'returns_constant_position',
+        ),
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'center',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[0., 50.], [0., 50.], [0., 50.]],
+                'distance': [250., 500., 1000.],
+            },
+            pl.Series(
+                'position', [[0., 63.4349], [0., 45.], [0., 26.5650]], pl.List(pl.Float64),
+            ),
+            id='origin_center_constant_pixel_centered_x_changing_distance_returns',
+        ),
+        pytest.param(
+            {
+                'screen_resolution': (100, 100),
+                'screen_size': (100, 100),
+                'origin': 'center',
+                'pixel_column': 'pixel',
+                'position_column': 'position',
+                'n_components': 2,
+            },
+            {
+                'pixel': [[50., 0.], [50., 0.], [50., 0.]],
+                'distance': [250., 500., 1000.],
+            },
+            pl.Series(
+                'position', [[63.4349, 0.], [45., 0.], [26.5650, 0.]], pl.List(pl.Float64),
+            ),
+            id='origin_center_constant_pixel_centered_y_changing_distance_returns',
+        ),
+    ],
+)
+def test_pix2deg_distance_as_colum_returns(kwargs, data, expected_df):
+    df = pl.DataFrame(
+        data, schema={'pixel': pl.List(pl.Float64), 'distance': pl.Float64},
+    )
+
+    result_df = df.select(
+        pm.gaze.transforms.pix2deg(**kwargs, distance='distance'),
+    )
+
+    assert_frame_equal(result_df, expected_df.to_frame())
+
 
-    f = pm.gaze.transforms._arctan2_helper(x2)
-    assert np.allclose(f(x1), np.arctan2(x1, x2))
+@pytest.fixture(params=[True, False], ids=['column_distance', 'scalar_distance'])
+def distance_as_column(request):
+    return request.param
diff --git a/tests/utils/checks_test.py b/tests/utils/checks_test.py
index f3f9f57..f93002e 100644
--- a/tests/utils/checks_test.py
+++ b/tests/utils/checks_test.py
@@ -204,3 +204,8 @@ def test_check_two_kwargs_with_three_kwargs_raises_value_error() -> None:
 def test_check_is_not_none_raises_type_error() -> None:
     with pytest.raises(TypeError):
         checks.check_is_not_none(a=1, b=None)
+
+
+def test_check_is_scalar_raises_type_error() -> None:
+    with pytest.raises(TypeError):
+        checks.check_is_scalar(a=1, b=None)