img_phy_sim.ray_tracing

Beam Tracing and Visualization Utilities

This module provides a set of tools for simulating and visualizing the propagation of rays (beams) through 2D images that may contain reflective or obstructive walls. It includes methods for tracing ray paths, handling reflections, scaling and normalizing ray coordinates, and drawing the resulting beam paths onto images.

The core idea is to represent a 2D environment as an image where certain pixel values correspond to walls or obstacles. Rays are emitted from a relative position and traced in specified directions, optionally reflecting off walls multiple times. The results can then be visualized or further processed.

Main features:

  • Ray tracing with customizable reflection order and wall detection
  • Support for relative and absolute coordinate systems
  • Ray scaling utilities for normalization or resizing
  • Image rendering functions to visualize rays, walls, and reflection paths
  • Flexible output modes: single image, multi-channel, or multiple separate images

Typical workflow:

  1. Use trace_beams() to simulate multiple beams across an image.
  2. Render the rays on an image using draw_rays().

Dependencies:

  • numpy
  • cv2 (OpenCV)
  • internal math + img modules

Example:

img = ips.img.open("scene.png")
rays = ips.ray_tracing.trace_beams(
    rel_position=(0.5, 0.5),
    img_src=img,
    directions_in_degree=[0, 45, 90, 135],
    wall_values=[0],
    wall_thickness=2,
    reflexion_order=2
)
output = ips.ray_tracing.draw_rays(rays, img_shape=img.shape, ray_value=255, ray_thickness=1)
ips.img.imshow(output, size=5)

Functions:

  • print_rays_info(...) - Pritn informations about created rays.
  • save(...) - Save rays into a file.
  • open(...) - Load saved rays.
  • merge(...) - Merge 2 or more rays together.
  • get_all_pixel_coordinates_in_between(...) - Use brahams algorithm for getting any line in a quantizied space.
  • get_wall_map(...) - Extract edges and get the wall-map with direction angles of walls.
  • update_pixel_position(...) - Get the next pixel to come from one point to another in a quantizied system.
  • calc_reflection(...) - Calculate the reflexion of 2 vectors.
  • get_img_border_vector(...) - Get the vector of a border of the image.
  • trace_beam(...) - Trace a single beam with reflexions.
  • trace_beams(...) - Trace multiple beams with reflections through an image.
  • scale_rays(...) - Normalize or rescale ray coordinates.
  • draw_rectangle_with_thickness(...) - Draw filled or thick rectangles.
  • draw_line_or_point(...) - Draw a single point or a line segment.
  • draw_rays(...) - Visualize traced rays as images or channels.
  • trace_and_draw_rays(...) - Compute and draw rays.
   1"""
   2**Beam Tracing and Visualization Utilities**
   3
   4This module provides a set of tools for simulating and visualizing the propagation
   5of rays (beams) through 2D images that may contain reflective or obstructive walls.
   6It includes methods for tracing ray paths, handling reflections, scaling and
   7normalizing ray coordinates, and drawing the resulting beam paths onto images.
   8
   9The core idea is to represent a 2D environment as an image where certain pixel
  10values correspond to walls or obstacles. Rays are emitted from a relative position
  11and traced in specified directions, optionally reflecting off walls multiple times.
  12The results can then be visualized or further processed.
  13
  14Main features:
  15- Ray tracing with customizable reflection order and wall detection
  16- Support for relative and absolute coordinate systems
  17- Ray scaling utilities for normalization or resizing
  18- Image rendering functions to visualize rays, walls, and reflection paths
  19- Flexible output modes: single image, multi-channel, or multiple separate images
  20
  21Typical workflow:
  221. Use `trace_beams()` to simulate multiple beams across an image.
  232. Render the rays on an image using `draw_rays()`.
  24
  25Dependencies:
  26- numpy
  27- cv2 (OpenCV)
  28- internal math + img modules
  29
  30Example:
  31```python
  32img = ips.img.open("scene.png")
  33rays = ips.ray_tracing.trace_beams(
  34    rel_position=(0.5, 0.5),
  35    img_src=img,
  36    directions_in_degree=[0, 45, 90, 135],
  37    wall_values=[0],
  38    wall_thickness=2,
  39    reflexion_order=2
  40)
  41output = ips.ray_tracing.draw_rays(rays, img_shape=img.shape, ray_value=255, ray_thickness=1)
  42ips.img.imshow(output, size=5)
  43```
  44
  45Functions:
  46- print_rays_info(...)  - Pritn informations about created rays.
  47- save(...)  - Save rays into a file.
  48- open(...)  - Load saved rays.
  49- merge(...)  - Merge 2 or more rays together.
  50- get_all_pixel_coordinates_in_between(...)  - Use brahams algorithm for getting any line in a quantizied space.
  51- get_wall_map(...)  - Extract edges and get the wall-map with direction angles of walls. 
  52- update_pixel_position(...)  - Get the next pixel to come from one point to another in a quantizied system.
  53- calc_reflection(...)  - Calculate the reflexion of 2 vectors.
  54- get_img_border_vector(...)  - Get the vector of a border of the image.
  55- trace_beam(...)  - Trace a single beam with reflexions.
  56- trace_beams(...)  - Trace multiple beams with reflections through an image.
  57- scale_rays(...)   - Normalize or rescale ray coordinates.
  58- draw_rectangle_with_thickness(...) - Draw filled or thick rectangles.
  59- draw_line_or_point(...) - Draw a single point or a line segment.
  60- draw_rays(...)    - Visualize traced rays as images or channels.
  61- trace_and_draw_rays(...)    - Compute and draw rays.
  62"""
  63
  64
  65
  66# ---------------
  67# >>> Imports <<<
  68# ---------------
  69from .img import open as img_open, get_width_height
  70from .math import degree_to_vector, vector_to_degree, normalize_point, \
  71                  degree_to_vector_numba, vector_to_degree_numba, normalize_point_numba
  72
  73import builtins
  74import pickle
  75import math
  76import copy
  77
  78import numpy as np
  79import cv2
  80
  81# performance optimization
  82from joblib import Parallel, delayed
  83import numba
  84from numba.typed import List
  85from numba import types
  86
  87
  88
  89# --------------
  90# >>> Helper <<<
  91# --------------
  92
  93class RayIterator:
  94    """
  95    A container class to save every step of a ray tracing process.
  96    """
  97    def __init__(self, other_ray_iterator=None):
  98        """
  99        Initialize a RayIterator instance.
 100        
 101        Parameters:
 102        - other_ray_iterator (RayIterator, optional): <br>
 103            An existing RayIterator to copy. If provided, creates a deep copy 
 104            of the other iterator's rays_collection. If None, creates an empty iterator.
 105            
 106        Returns: <br>
 107        None
 108        """
 109        if other_ray_iterator is None:
 110            self.rays_collection = []
 111        else:
 112            self.rays_collection = copy.deepcopy(other_ray_iterator.rays_collection)
 113
 114    def __repr__(self):
 115        """
 116        Return a string representation of the RayIterator.
 117        
 118        Returns:<br>
 119        str: String representation showing the number of iterations/time-steps.
 120        """
 121        return f"RayIterator with {self.len_iterations()} iterations (time-steps)."
 122
 123    def __iter__(self):
 124        """
 125        Make the RayIterator iterable over its rays collections.
 126        
 127        Yields:<br>
 128        list: Each iteration's collection of rays.
 129        """
 130        for rays in self.rays_collection:
 131            yield rays
 132
 133    def __getitem__(self, key):
 134        """
 135        Get rays from the latest iteration using key/index.
 136        
 137        Parameters:
 138        - key (int or slice): Index or slice to access rays in the latest iteration.
 139        
 140        Returns:<br>
 141        list: Rays from the latest iteration corresponding to the key.
 142        """
 143        return self.rays_collection[-1][key]
 144    
 145    def __len__(self):
 146        """
 147        Get the number of rays in the latest iteration.
 148        
 149        Returns:<br>
 150        int: Number of rays in the latest iteration.
 151        """
 152        return len(self.rays_collection[-1])
 153    
 154    def __add__(self, other):
 155        """
 156        Add another RayIterator or value to this RayIterator element-wise.
 157        
 158        Parameters:
 159        - other (RayIterator or any): <br>
 160            If RayIterator: combines ray collections from both iterators. 
 161            If other type: adds the value to each ray in all iterations.
 162        
 163        Returns:<br>
 164        RayIterator: New RayIterator containing the combined/adjusted results.
 165        
 166        Raises:<br>
 167        TypeError: If other is not a RayIterator and addition operation fails.
 168        """
 169        if isinstance(other, RayIterator):
 170            new_iterator = RayIterator()
 171            if self.len_iterations() > other.len_iterations():
 172                iter_1 = self
 173                iter_2 = other
 174            else:
 175                iter_1 = other
 176                iter_2 = self
 177
 178            iter_1 = copy.deepcopy(iter_1)
 179            iter_2 = copy.deepcopy(iter_2)
 180
 181            for idx in range(iter_1.len_iterations()):
 182                cur_addition = iter_1.get_iteration(idx)
 183                if iter_2.len_iterations() > idx:
 184                    cur_addition += iter_2.get_iteration(idx)
 185                elif iter_2.len_iterations() == 0:
 186                    pass
 187                else:
 188                    cur_addition += iter_2.get_iteration(-1)
 189
 190                new_iterator.add_iteration(cur_addition)
 191
 192            return new_iterator
 193        else:
 194            new_iterator = RayIterator()
 195
 196            for idx in range(self.len_iterations()):
 197                cur_addition = self.get_iteration(idx) + other
 198                new_iterator.add_iteration(cur_addition)
 199
 200            return new_iterator
 201        
 202    def __iadd__(self, other):
 203        """
 204        In-place addition of another RayIterator or value.
 205        
 206        Parameters:
 207        - other (RayIterator or any): 
 208            Object to add to this RayIterator.
 209        
 210        Returns:<br>
 211        RayIterator: self, after performing the in-place addition.
 212        """
 213        new_iterator = self.__add__(other) 
 214        self.rays_collection = new_iterator.rays_collection
 215        return self
 216    
 217    def len_iterations(self):
 218        """
 219        Get the total number of iterations/time-steps stored.
 220        
 221        Returns:<br>
 222        int: <br>
 223            Number of iterations in the rays_collection.
 224        """
 225        return len(self.rays_collection)
 226
 227    def add_iteration(self, rays):
 228        """
 229        Add a new iteration (collection of rays) to the iterator.
 230        
 231        Parameters:
 232        - rays (list): Collection of rays to add as a new iteration.
 233            Format: rays[ray][beam][point] = (x, y)
 234        
 235        Returns:<br>
 236        RayIterator: self, for method chaining.
 237        """
 238        self.rays_collection += [copy.deepcopy(rays)]
 239        return self
 240    
 241    def add_rays(self, rays):
 242        """
 243        Add rays to the every iteration (in-place modification).
 244        If one iterator have less steps, the last step will be used for all other iterations.
 245        Which equals no change for those iterations.
 246        
 247        Parameters:
 248        - rays (list): Rays to add to the latest iteration.
 249            Format: rays[ray][beam][point] = (x, y)
 250        
 251        Returns:<br>
 252        list: <br>
 253        The updated rays_collection.
 254        """
 255        self.rays_collection = self.__add__(copy.deepcopy(rays)).rays_collection
 256        return self.rays_collection
 257
 258    def print_info(self):
 259        """
 260        Print statistical information about the ray collections.
 261        
 262        Displays:
 263        - Number of iterations
 264        - Information about the latest iteration's rays including:
 265          * Number of rays, beams, reflexions, and points
 266          * Mean, median, max, min, and variance for beams per ray, 
 267            reflexions per ray, and points per beam
 268          * Value range for x and y coordinates
 269        
 270        Returns:<br>
 271        None
 272        """
 273        print(f"Ray Iterator with {self.len_iterations()} iterations (time-steps).")
 274        print("Latest Rays Info:\n")
 275        print_rays_info(self.rays_collection[-1])
 276        # for idx, rays in enumerate(self.rays_collection):
 277        #     print(f"--- Rays Set {idx} ---")
 278        #     print_rays_info(rays)
 279
 280    def reduce_to_x_steps(self, x_steps):
 281        """
 282        Reduce the number of stored iterations to approximately x_steps.
 283        
 284        Uses linear sampling to keep representative iterations while reducing
 285        memory usage. If x_steps is greater than current iterations, does nothing.
 286        
 287        Parameters:
 288        - x_steps (int): <br>
 289            Desired number of iterations to keep.
 290        
 291        Returns:<br>
 292        None
 293        """
 294        if x_steps >= self.len_iterations():
 295            return  # nothing to do
 296
 297        step_size = self.len_iterations() / x_steps
 298        new_rays_collection = []
 299        for i in range(x_steps):
 300            index = int(i * step_size)
 301            new_rays_collection += [self.get_iteration(index)]
 302
 303        self.rays_collection = new_rays_collection
 304
 305    def apply_and_update(self, func):
 306        """
 307        Apply a function to each iteration's rays and update in-place.
 308        
 309        Parameters:
 310        - func (callable): <br>
 311            Function that takes a rays collection and returns a modified rays collection. 
 312            Will be applied to each iteration.
 313        
 314        Returns:<br>
 315        None
 316        """
 317        for i in range(self.len_iterations()):
 318            self.rays_collection[i] = func(self.rays_collection[i])
 319    
 320    def apply_and_return(self, func):
 321        """
 322        Apply a function to each iteration's rays and return results.
 323        
 324        Parameters:
 325        - func (callable): <br>
 326            Function that takes a rays collection and returns some result. 
 327            Will be applied to each iteration.
 328        
 329        Returns:
 330        list: <br>
 331            Results of applying func to each iteration's rays.
 332        """
 333        results = []
 334        for i in range(self.len_iterations()):
 335            results += [func(self.rays_collection[i])]
 336
 337        return results
 338    
 339    def get_iteration(self, index):
 340        """
 341        Get a specific iteration's rays collection.
 342        
 343        Parameters:
 344        - index (int): <br>
 345            Index of the iteration to retrieve. Supports negative indexing (e.g., -1 for last iteration).
 346        
 347        Returns:
 348        list: <br>
 349            - Rays collection at the specified iteration.
 350        
 351        Raises: <br>
 352        IndexError: If index is out of range.
 353        """
 354        if index < -1 * self.len_iterations() or index >= self.len_iterations():
 355            raise IndexError("RayIterator index out of range.")
 356        return self.rays_collection[index]
 357
 358# def convert_rays_to_ray_iterator(rays):
 359#     """
 360#     Convert a list of rays into a RayIterator with a single iteration.
 361
 362#     Parameters:
 363#     - rays (list): 
 364#         Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
 365#     Returns:
 366#     - RayIterator:
 367#         RayIterator containing the provided rays as its only iteration.
 368#     """
 369#     ray_iterator = RayIterator()
 370#     cur_rays = []
 371
 372#     max_iterations
 373
 374#     for ray_idx in range(len(rays)):
 375#         max_beams = max([len(beams) for beams in rays[ray_idx]])
 376#         for cur_beam_idx in range(max_beams):
 377#             ray = []
 378#             for point in rays[ray_idx][cur_beam_idx]:
 379#                 cur_rays += [ray]
 380#         ray_iterator.add_iteration(cur_rays)
 381#     return ray_iterator
 382
 383def print_rays_info(rays):
 384    """
 385    Print statistical information about a collection of rays.
 386
 387    Each ray consists of multiple beams, and each beam consists of multiple points.
 388    The function computes and displays statistics such as:
 389    - Number of rays, beams, reflexions, and points
 390    - Mean, median, max, min, and variance for beams per ray, reflexions per ray, and points per beam
 391    - Value range for x and y coordinates
 392
 393    Parameters:
 394    - rays (list): <br>
 395        Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
 396    """
 397    if isinstance(rays, RayIterator):
 398        rays.print_info()
 399    else:
 400        nrays = 0
 401        nbeams = 0
 402        nbeams_per_ray = []
 403        nreflexions = 0
 404        nreflexions_per_ray = []
 405        npoints = 0
 406        npoints_per_beam_point = []
 407        values_per_point = []
 408        min_x_value = None
 409        max_x_value = None
 410        min_y_value = None
 411        max_y_value = None
 412        for ray in rays:
 413            nrays += 1
 414            cur_beams = 0
 415            cur_reflexions = 0
 416            for beam_points in ray:
 417                nbeams += 1
 418                nreflexions += 1
 419                cur_beams += 1
 420                cur_reflexions += 1
 421                cur_points = 0
 422                for x in beam_points:
 423                    npoints += 1
 424                    cur_points += 1
 425                    values_per_point += [len(x)]
 426                    min_x_value = x[0] if min_x_value is None else min(min_x_value, x[0])
 427                    max_x_value = x[0] if max_x_value is None else max(max_x_value, x[0])
 428                    min_y_value = x[1] if min_y_value is None else min(min_y_value, x[1])
 429                    max_y_value = x[1] if max_y_value is None else max(max_y_value, x[1])
 430                npoints_per_beam_point += [cur_points]
 431            nreflexions -= 1
 432            cur_reflexions -= 1
 433            nreflexions_per_ray += [cur_reflexions]
 434            nbeams_per_ray += [cur_beams]
 435
 436        print(f"Rays: {nrays}")
 437        print(f"Beams: {nbeams}")
 438        print(f"    - Mean Beams per Ray: {round(np.mean(nbeams_per_ray), 1)}")
 439        print(f"        - Median: {round(np.median(nbeams_per_ray), 1)}")
 440        print(f"        - Max: {round(np.max(nbeams_per_ray), 1)}")
 441        print(f"        - Min: {round(np.min(nbeams_per_ray), 1)}")
 442        print(f"        - Variance: {round(np.std(nbeams_per_ray), 1)}")
 443        print(f"Reflexions: {nreflexions}")
 444        print(f"    - Mean Reflexions per Ray: {round(np.mean(nreflexions_per_ray), 1)}")
 445        print(f"        - Median: {round(np.median(nreflexions_per_ray), 1)}")
 446        print(f"        - Max: {round(np.max(nreflexions_per_ray), 1)}")
 447        print(f"        - Min: {round(np.min(nreflexions_per_ray), 1)}")
 448        print(f"        - Variance: {round(np.std(nreflexions_per_ray), 1)}")
 449        print(f"Points: {npoints}")
 450        print(f"    - Mean Points per Beam: {round(np.mean(npoints_per_beam_point), 1)}")
 451        print(f"        - Median: {round(np.median(npoints_per_beam_point), 1)}")
 452        print(f"        - Max: {round(np.max(npoints_per_beam_point), 1)}")
 453        print(f"        - Min: {round(np.min(npoints_per_beam_point), 1)}")
 454        print(f"        - Variance: {round(np.std(npoints_per_beam_point), 1)}")
 455        print(f"    - Mean Point Values: {round(np.mean(values_per_point), 1)}")
 456        print(f"        - Median: {round(np.median(values_per_point), 1)}")
 457        print(f"        - Variance: {round(np.std(values_per_point), 1)}")
 458        print(f"\nValue-Range:\n  x ∈ [{min_x_value:.2f}, {max_x_value:.2f}]\n  y ∈ [{min_y_value:.2f}, {max_y_value:.2f}]")
 459        # [ inclusive, ( number is not included
 460
 461        if nrays > 0:
 462            print(f"\nExample:\nRay 1, beams: {len(rays[0])}")
 463            if nbeams > 0:
 464                print(f"Ray 1, beam 1, points: {len(rays[0][0])}")
 465                if npoints > 0:
 466                    print(f"Ray 1, beam 1, point 1: {len(rays[0][0][0])}")
 467        print("\n")
 468
 469
 470
 471def save(path, rays):
 472    """
 473    Save a list of rays to a text file.
 474
 475    The rays are serialized using a simple text-based format. 
 476    Each ray is delimited by '>' and '<', and each point is represented as "x | y".
 477
 478    Parameters:
 479    - path (str): <br>
 480        Path to the file where data should be saved. If no '.txt' extension is present, it will be appended automatically.
 481    - rays (list): <br>
 482        Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
 483
 484    Returns:<br>
 485        None
 486    """
 487    if isinstance(rays, RayIterator):
 488        if not path.endswith(".pkl"):
 489            path += ".pkl"
 490        pickle.dump(rays, builtins.open(path, "wb"))
 491        return
 492    
 493    # transform rays into an string
 494    rays_str = ""
 495    for ray in rays:
 496        rays_str += ">\n"
 497        for beam in ray:
 498           rays_str += "\n"
 499           for cur_point in beam:
 500               rays_str += f"{cur_point[0]} | {cur_point[1]}, " 
 501        rays_str += "<\n"
 502
 503    rays_str = rays_str.replace("\n\n", "\n")
 504
 505    if not path.endswith(".txt"):
 506        path += ".txt"
 507    
 508    with builtins.open(path, "w") as file_:
 509        file_.write(rays_str)
 510
 511
 512
 513def open(path, is_iterator=False) -> list:
 514    """
 515    Open and parse a ray text file into a structured list.
 516
 517    The file is expected to follow the same format as produced by `save()`.
 518
 519    Parameters:
 520    - path (str): <br>
 521        Path to the .txt file containing ray data.
 522
 523    Returns:
 524    - list: <br>
 525        Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
 526    """
 527    if is_iterator or path.endswith(".pkl"):
 528        if not path.endswith(".pkl"):
 529            path += ".pkl"
 530        rays = pickle.load(builtins.open(path, "rb"))
 531        return rays
 532
 533    if not path.endswith(".txt"):
 534        path += ".txt"
 535
 536    with builtins.open(path, "r") as file_:
 537        content = file_.read().strip()
 538
 539    rays = []
 540    for ray in content.split(">"):
 541        extracted_ray = []
 542        for beam in ray.split("\n"):
 543            extracted_beam = []
 544            beam = beam.strip()
 545            if not beam or beam == "<":
 546                continue
 547
 548            for point in beam.split(","):
 549                point = point.strip()
 550                if not point or point == "<":
 551                    continue
 552
 553                try:
 554                    point_x, point_y = point.strip().split("|")
 555                except Exception as e:
 556                    print("Point of error:", point)
 557                    raise e
 558
 559                extracted_beam += [(float(point_x), float(point_y))]
 560            if len(extracted_beam) > 0:
 561                extracted_ray += [extracted_beam]
 562        if len(extracted_ray) > 0:
 563            rays += [extracted_ray]
 564    return rays
 565
 566
 567
 568def merge(rays_1, rays_2, *other_rays_):
 569    """
 570    Merge multiple ray datasets into a single list.
 571
 572    Parameters:
 573    - rays_1 (list): <br>
 574        First set of rays.
 575    - rays_2 (list): <br>
 576        Second set of rays. 
 577    - *other_rays_ (list): <br>
 578        Additional ray lists to merge.
 579
 580    Returns:
 581    - list: 
 582        Combined list of all rays.
 583    """
 584    merged = rays_1 + rays_2
 585
 586    for rays in other_rays_:
 587        merged += rays
 588
 589    return merged
 590
 591
 592
 593
 594# ----------------------
 595# >>> Core Functions <<<
 596# ----------------------
 597
 598def get_all_pixel_coordinates_in_between(x1, y1, x2, y2):
 599    """
 600    Get all pixel coordinates along a line between two points using Bresenham's algorithm.
 601
 602    https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
 603
 604    Parameters:
 605    - x1 (int): <br>
 606        Starting x-coordinate.
 607    - y1 (int): <br>
 608        Starting y-coordinate.
 609    - x2 (int): <br>
 610        Ending x-coordinate.
 611    - y2 (int): <br>
 612        Ending y-coordinate.
 613
 614    Returns:
 615    - list: 
 616        List of (x, y) tuples representing all pixels between the start and end points
 617    """
 618    coordinates = []
 619
 620    dx = abs(x2 - x1)
 621    dy = abs(y2 - y1)
 622    x, y = x1, y1
 623
 624    sx = 1 if x1 < x2 else -1
 625    sy = 1 if y1 < y2 else -1
 626
 627    if dx > dy:
 628        err = dx / 2.0
 629        while x != x2:
 630            coordinates += [(x, y)]
 631            err -= dy
 632            if err < 0:
 633                y += sy
 634                err += dx
 635            x += sx
 636    else:
 637        err = dy / 2.0
 638        while y != y2:
 639            coordinates += [(x, y)]
 640            err -= dx
 641            if err < 0:
 642                x += sx
 643                err += dy
 644            y += sy
 645
 646    coordinates += [(x2, y2)]  # include the last point
 647    return coordinates
 648
 649
 650
 651def get_wall_map(img, wall_values=None, thickness=1,
 652                 use_numba_compilation=False):
 653    """
 654    Generate a wall map where each pixel encodes the wall orientation (in degrees).
 655
 656    Parameters:
 657    - img (numpy.ndarray): <br>
 658        Input image representing scene or segmentation mask.
 659    - wall_values (list, optional): <br>
 660        Specific pixel values considered as walls. If None, all non-zero pixels are treated as walls.
 661    - thickness (int, optional): <br>
 662        Thickness of wall lines (default is 1).
 663    - use_numba_compilation (bool, optional):<br>
 664        Whether to use the compiled (to machine code) version of compute heavy functions.
 665
 666    Returns:
 667    - numpy.ndarray: 
 668        2D array (same width and height as input) 
 669        where each wall pixel contains the wall angle in degrees (0-360), 
 670        and non-wall pixels are set to infinity (np.inf).
 671    """
 672    # numba optimization -> change function locally
 673    if use_numba_compilation:
 674        get_all_pixel_coordinates_in_between_ = get_all_pixel_coordinates_in_between_numba      
 675    else:
 676        get_all_pixel_coordinates_in_between_ = get_all_pixel_coordinates_in_between
 677
 678    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
 679    wall_map = np.full((IMG_HEIGHT, IMG_WIDTH), np.inf, dtype=np.uint16)  # uint16 to get at least 360 degree/value range
 680
 681    # only detect edges from objects with specific pixel values
 682    if wall_values is not None:
 683        mask = np.isin(img, wall_values).astype(np.uint8) * 255
 684    else:
 685        mask = img
 686        if np.max(mask) < 64:
 687            mask = mask.astype(np.uint8) * 255
 688
 689    # detect edges and contours
 690    edges = cv2.Canny(mask, 100, 200)
 691    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 692
 693    # convert contours to line segments
 694    for c in contours:
 695        for i in range(len(c)-1):
 696            x1, y1 = c[i][0]
 697            x2, y2 = c[i+1][0]
 698            dy = y2 - y1
 699            dx = x2 - x1
 700            angle = math.atan2(dy, dx)
 701            angle_deg = math.degrees(angle)
 702            for x, y in get_all_pixel_coordinates_in_between_(x1, y1, x2, y2):
 703                # wall_map[y, x] = int(angle_deg) % 360
 704
 705                for tx in range(-thickness, thickness+1):
 706                    for ty in range(-thickness, thickness+1):
 707                        nx, ny = x+tx, y+ty
 708                        if 0 <= nx < IMG_WIDTH and 0 <= ny < IMG_HEIGHT:
 709                            wall_map[ny, nx] = int(angle_deg) % 360
 710    return wall_map
 711
 712
 713
 714def update_pixel_position(direction_in_degree, cur_position, target_line):
 715    """
 716    Update the pixel position of a moving point toward a target line based on direction and proximity.
 717
 718    Combines the direction vector with a vector pointing toward the closest point
 719    on the target line, ensuring pixel-wise movement (discrete steps).
 720
 721    Parameters:
 722    - direction_in_degree (float): <br>
 723        Movement direction in degrees.
 724    - cur_position (tuple): <br>
 725        Current pixel position (x, y).
 726    - target_line (list): <br>
 727        Target line defined as [x1, y1, x2, y2].
 728
 729    Returns:
 730    - tuple: 
 731        Updated pixel position (x, y).
 732    """
 733    # 1. Calc distance from point to target line
 734
 735    # perpendicular vector to line (points toward line)
 736    point = np.array(cur_position)
 737    line_start_point = np.array(target_line[0:2])
 738    line_end_point = np.array(target_line[2:4])
 739
 740    # projection along the line -> throw the point vector vertical/perpendicular on the line and see where it cuts with normed AP to AB
 741    # t is the length from point to the line, therefore it gets normed
 742    t = np.dot(point - line_start_point, line_end_point - line_start_point) / (np.dot(line_end_point - line_start_point, line_end_point - line_start_point) + 1e-8)
 743    
 744    # limit it to the line id needed -> because we don't want smaller or bigger values than that
 745    #   -> 0 would be point A
 746    #   -> 1 would be point B 
 747    # t = np.clip(t, 0, 1)
 748    if t < 0.0:
 749        t = 0.0
 750    elif t > 1.0:
 751        t = 1.0
 752
 753    # get closest point by applying the found t as lentgh from startpoint in the line vector direction
 754    closest = line_start_point + t * (line_end_point - line_start_point)
 755
 756    # get the final vector to the line
 757    to_line = closest - point  # vector from current pos to closest point on line
 758    
 759    # 2. Calc vector to the degree
 760    # movement vector based on angle
 761    rad = math.radians(direction_in_degree)
 762    move_dir = np.array([math.cos(rad), math.sin(rad)])
 763    
 764    # 3. Combine vector to the line and degree vector
 765    # combine movement towards direction and towards line
 766    combined = move_dir + to_line * 0.5  # weighting factor
 767    
 768    # pick pixel step (continuous to discrete) -> [-1, 0, 1]
 769    step_x = np.sign(combined[0])
 770    step_y = np.sign(combined[1])
 771    
 772    # clamp to [-1, 1], if bigger/smaller
 773    # step_x = int(np.clip(step_x, -1, 1))
 774    if step_x < 0.0:
 775        step_x = 0.0
 776    elif step_x > 1.0:
 777        step_x = 1.0
 778    # step_y = int(np.clip(step_y, -1, 1))
 779    if step_y < 0.0:
 780        step_y = 0.0
 781    elif step_y > 1.0:
 782        step_y = 1.0
 783    
 784    return (int(cur_position[0] + step_x), int(cur_position[1] + step_y))
 785
 786
 787
 788def calc_reflection(collide_vector, wall_vector):
 789    """
 790    Calculate the reflection of a collision vector against a wall vector.
 791
 792    The reflection is computed using the wall's normal vector and the formula:
 793        r = v - 2 * (v · n) * n
 794
 795    Parameters:
 796    - collide_vector (array-like): <br>
 797        Incoming vector (2D).
 798    - wall_vector (array-like): <br>
 799        Wall direction vector (2D).
 800
 801    Returns:
 802    - numpy.ndarray: 
 803        Reflected 2D vector.
 804    """
 805    # normalize both
 806    collide_vector = np.array(collide_vector, dtype=np.float64)
 807    collide_vector /= np.linalg.norm(collide_vector)
 808    wall_vector = np.array(wall_vector, dtype=np.float64)
 809    wall_vector /= np.linalg.norm(wall_vector)
 810
 811    # calculate the normal of the wall
 812    normal_wall_vector_1 = np.array([-wall_vector[1], wall_vector[0]])  # rotated +90°
 813    normal_wall_vector_2 = np.array([wall_vector[1], -wall_vector[0]])  # rotated -90°
 814
 815    # decide which vector is the right one
 816    #   -> dot product tells which normal faces the incoming vector
 817    #   -> dor product shows how similiar 2 vectors are => smaller 0 means they show against each other => right vector
 818    if np.dot(collide_vector, normal_wall_vector_1) < 0:
 819        normal_wall_vector = normal_wall_vector_1
 820    else:
 821        normal_wall_vector = normal_wall_vector_2
 822    
 823    # calc the reflection
 824    return collide_vector - 2 * np.dot(collide_vector, normal_wall_vector) * normal_wall_vector
 825
 826
 827def get_img_border_vector(position, max_width, max_height):
 828    """
 829    Determine the wall normal vector for an image border collision.
 830
 831    Parameters:
 832    - position (tuple): <br>
 833        Current position (x, y).
 834    - max_width (int): <br>
 835        Image width.
 836    - max_height (int): <br>
 837        Image height.
 838
 839    Returns:
 840    - tuple: 
 841        Border wall vector corresponding to the collision side.
 842    """
 843    # print(f"got {position=}")
 844    if position[0] < 0:
 845        return (0, 1)
 846    elif position[0] >= max_width:
 847        return (0, 1)
 848    elif position[1] < 0:
 849        return (1, 0)
 850    elif position[1] >= max_height:
 851        return (1, 0)
 852    else:
 853        # should never reach that!
 854        return (0, 0)
 855
 856
 857def trace_beam(abs_position, 
 858               img, 
 859               direction_in_degree, 
 860               wall_map,
 861               wall_values, 
 862               img_border_also_collide=False,
 863               reflexion_order=3,
 864               should_scale=True,
 865               should_return_iterative=False,
 866               remove_iterative=True):
 867    """
 868    Trace a ray (beam) through an image with walls and reflections.
 869
 870    The beam starts from a given position and follows a direction until it hits
 871    a wall or border. On collisions, reflections are computed using wall normals.
 872
 873    Parameters:
 874    - abs_position (tuple): <br>
 875        Starting position (x, y) of the beam.
 876    - img (numpy.ndarray): <br>
 877        Input image or segmentation map.
 878    - direction_in_degree (float): <br>
 879        Initial direction angle of the beam.
 880    - wall_map (numpy.ndarray): <br>
 881        Map containing wall orientations in degrees.
 882    - wall_values (list): <br>
 883        List of pixel values representing walls.
 884    - img_border_also_collide (bool, optional): <br>
 885        Whether the image border acts as a collider (default: False).
 886    - reflexion_order (int, optional): <br>
 887        Number of allowed reflections (default: 3).
 888    - should_scale (bool, optional): <br>
 889        Whether to normalize positions to [0, 1] (default: True).
 890    - should_return_iterative (bool, optional): <br>
 891        Whether to return a RayIterator for step-by-step analysis (default: False).
 892    - remove_iterative (bool, optional): <br>
 893        Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.
 894
 895    Returns:
 896    - list: 
 897        Nested list structure representing the traced ray and its reflections. 
 898        Format: ray[beam][point] = (x, y)
 899    """
 900    reflexion_order += 1  # Reflexion Order == 0 means, no reflections, therefore only 1 loop
 901    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
 902
 903    ray = []
 904    if should_return_iterative or not remove_iterative:
 905        ray_iterator = RayIterator()
 906
 907    cur_target_abs_position = abs_position
 908    cur_direction_in_degree = direction_in_degree % 360
 909
 910    for cur_depth in range(reflexion_order):
 911        # print(f"(Reflexion Order '{cur_depth}') {ray=}")
 912        if should_scale:
 913            current_ray_line = [normalize_point(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
 914        else:
 915            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
 916        if should_return_iterative or not remove_iterative:
 917            ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
 918
 919        last_abs_position = cur_target_abs_position
 920
 921        # calculate a target line to update the pixels
 922        #   target vector
 923        dx = math.cos(math.radians(cur_direction_in_degree))
 924        dy = math.sin(math.radians(cur_direction_in_degree))
 925        target_line = [cur_target_abs_position[0], cur_target_abs_position[1], cur_target_abs_position[0], cur_target_abs_position[1]]
 926        while (0 <= target_line[2] <= IMG_WIDTH) and (0 <= target_line[3] <= IMG_HEIGHT):
 927            target_line[2] += 0.01 * dx
 928            target_line[3] += 0.01 * dy
 929
 930        # update current ray
 931        current_position = cur_target_abs_position
 932        while True:
 933            # update position
 934            current_position = update_pixel_position(direction_in_degree=cur_direction_in_degree, cur_position=current_position, target_line=target_line)
 935        # for current_position in get_all_pixel_coordinates_in_between(current_position[0], current_position[1], target_line[2], target_line[3]):
 936        #     last_position_saved = False
 937
 938            # check if ray is at end
 939            if not (0 <= current_position[0] < IMG_WIDTH and 0 <= current_position[1] < IMG_HEIGHT):
 940                ray += [current_ray_line]
 941
 942                if img_border_also_collide:
 943                     # get reflection angle
 944                    wall_vector = get_img_border_vector(position=current_position, 
 945                                                           max_width=IMG_WIDTH, 
 946                                                           max_height=IMG_HEIGHT)
 947
 948                    # calc new direct vector
 949                    new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
 950                    new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
 951
 952                    # start new beam calculation
 953                    cur_target_abs_position = last_abs_position
 954                    cur_direction_in_degree = new_direction_in_degree
 955                    break
 956                else:
 957                    if should_return_iterative:
 958                        return ray_iterator
 959                    return ray
 960
 961            next_pixel = img[int(current_position[1]), int(current_position[0])]
 962
 963            # check if hit building
 964            if float(next_pixel) in wall_values:
 965                last_abs_position = (current_position[0], current_position[1])
 966                ray += [current_ray_line]
 967
 968                # get building wall reflection angle
 969                building_angle = wall_map[int(current_position[1]), int(current_position[0])]
 970                if building_angle == np.inf:
 971                    raise Exception("Got inf value from Wall-Map.")
 972                wall_vector = degree_to_vector(building_angle)
 973
 974                # calc new direct vector
 975                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
 976                new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
 977
 978                # start new beam calculation
 979                cur_target_abs_position = last_abs_position
 980                cur_direction_in_degree = new_direction_in_degree
 981                break
 982            else:
 983                # update current ray
 984                if should_scale:
 985                    current_ray_line += [normalize_point(x=current_position[0], y=current_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
 986                else:
 987                    current_ray_line += [(current_position[0], current_position[1])]
 988                if should_return_iterative or not remove_iterative:
 989                    ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
 990                last_abs_position = (current_position[0], current_position[1])
 991    
 992    if should_return_iterative:
 993        return ray_iterator
 994    return ray
 995    
 996
 997
 998def trace_beam_with_DDA(abs_position, 
 999               img, 
1000               direction_in_degree, 
1001               wall_map,
1002               wall_values, 
1003               img_border_also_collide=False,
1004               reflexion_order=3,
1005               should_scale=True,
1006               should_return_iterative=False,
1007               remove_iterative=True):
1008    """
1009    Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer)
1010    algorithm with precise collision points and physically accurate reflections.
1011
1012    The beam starts at a given floating-point position and marches through the grid
1013    until it intersects a wall or exits the image. For each collision, the exact
1014    hit position is computed using the ray Parameters t_hit, ensuring that reflected
1015    segments contain meaningful geometry rather than single-point artifacts.
1016    Reflections are computed using wall normals derived from the `wall_map`.
1017
1018    Parameters:
1019    - abs_position (tuple of float): <br>
1020        Starting position (x, y) of the beam in absolute pixel space.
1021    - img (numpy.ndarray): <br>
1022        2D array representing the scene. Pixel values listed in `wall_values`
1023        are treated as solid walls.
1024    - direction_in_degree (float): <br>
1025        Initial direction of the beam in degrees (0° = right, 90° = down).
1026    - wall_map (numpy.ndarray): <br>
1027        A map storing wall orientations in degrees for each pixel marked as a wall.
1028        These angles define the wall normals used for reflection.
1029    - wall_values (list, tuple, set, float, optional): <br>
1030        Pixel values identifying walls. Any pixel in this list causes a collision.
1031        If None, pixel value 0.0 is treated as a wall.
1032    - img_border_also_collide (bool, optional): <br>
1033        If True, the image borders behave like reflective walls. If False,
1034        the ray terminates when leaving the image. Default: False.
1035    - reflexion_order (int, optional): <br>
1036        Maximum number of reflections. The ray can rebound this many times before
1037        the function terminates. Default: 3.
1038    - should_scale (bool, optional): <br>
1039        If True, all emitted points (x, y) are normalized to [0, 1] range.
1040        Otherwise absolute pixel positions are returned. Default: True.
1041    - should_return_iterative (bool, optional): <br>
1042        Whether to return a RayIterator for step-by-step analysis (default: False).
1043    - remove_iterative (bool, optional): <br>
1044        Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.
1045
1046    Returns:
1047    - list: 
1048        Nested list structure representing the traced ray and its reflections. 
1049        Format: ray[beam][point] = (x, y)
1050    """
1051    reflexion_order += 1
1052    IMG_WIDTH, IMG_HEIGHT = get_width_height(img)
1053
1054    ray = []
1055    if should_return_iterative or not remove_iterative:
1056        ray_iterator = RayIterator()
1057
1058    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
1059    cur_direction_in_degree = direction_in_degree % 360
1060
1061    # Normalize wall_values to set of floats
1062    if wall_values is None:
1063        wall_values_set = {0.0}
1064    elif isinstance(wall_values, (list, tuple, set)):
1065        for idx, v in enumerate(wall_values):
1066            if idx == 0:
1067                wall_values_set = {float(v)}
1068            else:
1069                wall_values_set.add(float(v))
1070    else:
1071        wall_values_set = {float(wall_values)}
1072
1073    # go through every reflection -> will early stop if hitting a wall (if wall-bouncing is deactivated)
1074    for cur_depth in range(reflexion_order):
1075        if should_scale:
1076            current_ray_line = [normalize_point(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
1077        else:
1078            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
1079        if should_return_iterative or not remove_iterative:
1080            ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1081
1082        last_abs_position = cur_target_abs_position
1083
1084        # direction
1085        rad = math.radians(cur_direction_in_degree)
1086        dx = math.cos(rad)
1087        dy = math.sin(rad)
1088
1089        eps = 1e-12
1090        if abs(dx) < eps: dx = 0.0
1091        if abs(dy) < eps: dy = 0.0
1092
1093        # start float pos and starting cell
1094        x0 = float(cur_target_abs_position[0])
1095        y0 = float(cur_target_abs_position[1])
1096        cell_x = int(math.floor(x0))
1097        cell_y = int(math.floor(y0))
1098
1099        # outside start -> handle border/reflection/exit
1100        if not (0 <= x0 < IMG_WIDTH and 0 <= y0 < IMG_HEIGHT):
1101            ray += [current_ray_line]
1102
1103            if img_border_also_collide:
1104                wall_vector = get_img_border_vector(position=(x0, y0), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
1105                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1106                cur_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1107                cur_target_abs_position = last_abs_position
1108                continue
1109            else:
1110                if should_return_iterative:
1111                    return ray_iterator
1112                return ray
1113
1114        # DDA parameters
1115        tDeltaX = math.inf if dx == 0.0 else abs(1.0 / dx)
1116        tDeltaY = math.inf if dy == 0.0 else abs(1.0 / dy)
1117
1118        if dx > 0:
1119            stepX = 1
1120            nextBoundaryX = cell_x + 1.0
1121            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
1122        elif dx < 0:
1123            stepX = -1
1124            nextBoundaryX = cell_x * 1.0  # left boundary of cell
1125            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
1126        else:
1127            stepX = 0
1128            tMaxX = math.inf
1129
1130        if dy > 0:
1131            stepY = 1
1132            nextBoundaryY = cell_y + 1.0
1133            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
1134        elif dy < 0:
1135            stepY = -1
1136            nextBoundaryY = cell_y * 1.0
1137            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
1138        else:
1139            stepY = 0
1140            tMaxY = math.inf
1141
1142        max_steps = (IMG_WIDTH + IMG_HEIGHT) * 6
1143        steps = 0
1144        last_position_saved = False
1145
1146        # immediate-start-in-wall handling
1147        if 0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT:
1148            start_pixel = float(img[cell_y, cell_x])
1149            if start_pixel in wall_values_set:
1150                # compute a collision point precisely at start (we'll use origin)
1151                # add collision point (start) and reflect
1152                hit_x = x0
1153                hit_y = y0
1154                if should_scale:
1155                    current_ray_line += [normalize_point(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT)]
1156                else:
1157                    current_ray_line += [(hit_x, hit_y)]
1158                if should_return_iterative or not remove_iterative:
1159                    ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1160                ray += [current_ray_line]
1161
1162                building_angle = float(wall_map[cell_y, cell_x])
1163                if not np.isfinite(building_angle):
1164                    raise Exception("Got non-finite value from Wall-Map.")
1165                wall_vector = degree_to_vector(building_angle)
1166                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1167                cur_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1168                ndx, ndy = new_direction[0], new_direction[1]
1169                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1170                continue
1171
1172        # DDA main loop
1173        while steps < max_steps:
1174            steps += 1
1175
1176            # choose axis to step and capture t_hit (distance along ray to boundary)
1177            if tMaxX < tMaxY:
1178                t_hit = tMaxX
1179                # step in x
1180                cell_x += stepX
1181                tMaxX += tDeltaX
1182            else:
1183                t_hit = tMaxY
1184                # step in y
1185                cell_y += stepY
1186                tMaxY += tDeltaY
1187
1188            # compute exact collision position along ray from origin (x0,y0)
1189            hit_x = x0 + dx * t_hit
1190            hit_y = y0 + dy * t_hit
1191
1192            # For recording the traversal we can append intermediate cell centers encountered so far.
1193            # But more importantly, append the collision point to the current segment BEFORE storing it.
1194            if should_scale:
1195                current_ray_line += [normalize_point(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT)]
1196            else:
1197                current_ray_line += [(hit_x, hit_y)]
1198            if should_return_iterative or not remove_iterative:
1199                ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1200
1201            # Now check if we've left the image bounds (cell_x, cell_y refer to the new cell we stepped into)
1202            if not (0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT):
1203                ray += [current_ray_line]
1204                last_position_saved = True
1205
1206                if img_border_also_collide:
1207                    wall_vector = get_img_border_vector(position=(cell_x, cell_y), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
1208                    new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1209                    new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1210                    # start next ray from last in-image position (hit_x, hit_y) nudged slightly
1211                    ndx, ndy = new_direction[0], new_direction[1]
1212                    cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1213                    cur_direction_in_degree = new_direction_in_degree
1214                    break
1215                else:
1216                    if should_return_iterative:
1217                        return ray_iterator
1218                    return ray
1219
1220            # sample the pixel in the cell we stepped into
1221            next_pixel = float(img[cell_y, cell_x])
1222            if next_pixel in wall_values_set:
1223                # we hit a wall — collision point already appended
1224                last_abs_position = (hit_x, hit_y)
1225                ray += [current_ray_line]
1226                last_position_saved = True
1227
1228                building_angle = float(wall_map[cell_y, cell_x])
1229                if not np.isfinite(building_angle):
1230                    raise Exception("Got non-finite value from Wall-Map.")
1231                wall_vector = degree_to_vector(building_angle)
1232
1233                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1234                new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1235
1236                # start next beam from collision point nudged outwards
1237                ndx, ndy = new_direction[0], new_direction[1]
1238                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1239                cur_direction_in_degree = new_direction_in_degree
1240                break
1241            else:
1242                # no hit -> continue marching; also add a representative point in the traversed cell (optional)
1243                # we already appended the exact hit point for this step; for smoother lines you may add cell center too
1244                last_abs_position = (hit_x, hit_y)
1245                # continue
1246
1247        # end DDA loop
1248        if not last_position_saved:
1249            ray.append(current_ray_line)
1250
1251    if should_return_iterative:
1252        return ray_iterator
1253    return ray
1254
1255
1256
1257def trace_beams(rel_position, 
1258                img_src,
1259                directions_in_degree, 
1260                wall_values, 
1261                wall_thickness=0,
1262                img_border_also_collide=False,
1263                reflexion_order=3,
1264                should_scale_rays=True,
1265                should_scale_img=True,
1266                use_dda=True,
1267                iterative_tracking=False,
1268                iterative_steps=None,
1269                parallelization=-1,
1270                parallelization_method="processes",  # "threads", "processes"
1271                use_numba_compilation=True,
1272                ignore_iterative_optimization=True):
1273    """
1274    Trace multiple rays (beams) from a single position through an image with walls and reflections.
1275
1276    Each beam starts from a given relative position and follows its assigned direction
1277    until it collides with a wall or image border. On collisions, reflections are
1278    computed based on local wall normals extracted from the image.
1279
1280    Parameters:
1281    - rel_position (tuple): <br>
1282        Relative starting position (x, y) in normalized coordinates [0-1].
1283    - img_src (str or numpy.ndarray): <br>
1284        Input image (array or file path) used for wall detection.
1285    - directions_in_degree (list): <br>
1286        List of beam direction angles (in degrees).
1287    - wall_values (list or float or None): <br>
1288        Pixel values representing walls or obstacles.
1289    - wall_thickness (int, optional): <br>
1290        Thickness (in pixels) of detected walls (default: 0).
1291    - img_border_also_collide (bool, optional): <br>
1292        Whether image borders act as colliders (default: False).
1293    - reflexion_order (int, optional): <br>
1294        Number of allowed reflections per beam (default: 3).
1295    - should_scale_rays (bool, optional): <br>
1296        Whether to normalize ray coordinates to [0, 1] (default: True).
1297    - should_scale_img (bool, optional): <br>
1298        Whether to scale the input image before wall detection (default: True).
1299    - use_dda (bool, optional): <br>
1300        Whether to use the DDA-based ray tracing method (default: True).
1301    - iterative_tracking (bool, optional): 
1302        Whether to return a RayIterator for step-by-step analysis (default: False).
1303    - iterative_steps (int, optional):<br>
1304        Number of steps for iterative reduction if using iterative tracking. `None` for all steps.
1305    - parallelization (int, optional):<br>
1306        The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
1307    - parallelization_method (str, optional):<br>
1308        Method to use for parallelization (as soft condition) -> "threads" or "processes"
1309    - use_numba_compilation (bool, optional):<br>
1310        Whether to use the compiled (to machine code) version of compute heavy functions.
1311    - ignore_iterative_optimization (bool, optional): <br>
1312        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1313
1314    Returns:
1315    - list: <br>
1316        Nested list of traced beams and their reflection segments. 
1317        Format: rays[beam][segment][point] = (x, y)
1318    """ 
1319    if use_numba_compilation and iterative_tracking:
1320        print("[WARNING] Iterative Return deactivates the use of numba.")
1321        use_numba_compilation = False
1322
1323    if isinstance(img_src, np.ndarray):
1324        img = img_src
1325    else:
1326        img = img_open(src=img_src, should_scale=should_scale_img, should_print=False)
1327    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
1328    abs_position = (rel_position[0] * IMG_WIDTH, rel_position[1] * IMG_HEIGHT)
1329
1330    if wall_values is not None and type(wall_values) not in [list, tuple]:
1331        wall_values = [wall_values]
1332    wall_map = get_wall_map(img=img, 
1333                            wall_values=wall_values, 
1334                            thickness=wall_thickness,
1335                            use_numba_compilation=use_numba_compilation)
1336    
1337    if wall_values is None:
1338        wall_values = [0.0]
1339
1340    # create function plan
1341    ray_planning = []
1342
1343    for direction_in_degree in directions_in_degree:
1344        if use_dda:
1345            if use_numba_compilation:
1346                ray_tracing_func = trace_beam_with_DDA_numba
1347            else:
1348                ray_tracing_func = trace_beam_with_DDA
1349        else:
1350            if use_numba_compilation:
1351                ray_tracing_func = trace_beam_numba
1352            else:
1353                ray_tracing_func = trace_beam
1354
1355        ray_planning.append(lambda dir=direction_in_degree: ray_tracing_func(
1356                                        abs_position=abs_position, 
1357                                        img=img,  
1358                                        direction_in_degree=dir,
1359                                        wall_map=wall_map,
1360                                        wall_values=np.array(wall_values, dtype=np.float64) if (use_numba_compilation and use_dda) else wall_values,
1361                                        img_border_also_collide=img_border_also_collide, 
1362                                        reflexion_order=reflexion_order,
1363                                        should_scale=should_scale_rays,
1364                                        should_return_iterative=iterative_tracking,
1365                                        remove_iterative=ignore_iterative_optimization
1366                                    )
1367        )
1368        
1369    if iterative_tracking:
1370        rays = RayIterator()
1371    else:
1372        rays = []
1373
1374    # compute
1375    if parallelization == 0:
1376
1377        for cur_ray_planning in ray_planning:
1378            cur_ray_result = cur_ray_planning()
1379            if use_numba_compilation:
1380                cur_ray_result = numba_to_py(cur_ray_result)
1381        
1382            if iterative_tracking:
1383                rays.add_rays(cur_ray_result)
1384            else:
1385                rays.append(cur_ray_result)
1386    else:
1387        result_rays = Parallel(n_jobs=parallelization, 
1388                                # backend="threading" if use_numba_compilation else "loky",     # process-based
1389                                prefer="threads" if use_numba_compilation else parallelization_method,
1390                                return_as="generator",
1391                                batch_size=1        # because of unequal ray lengths
1392                                )(
1393                                    delayed(ray_func)() for ray_func in ray_planning
1394                                )
1395        
1396        for cur_ray_result in result_rays:
1397            if use_numba_compilation:
1398                cur_ray_result = numba_to_py(cur_ray_result)
1399
1400            if iterative_tracking:
1401                rays.add_rays(cur_ray_result)
1402            else:
1403                rays.append(cur_ray_result)
1404
1405    if iterative_tracking and iterative_steps is not None:
1406        rays.reduce_rays_iteratively(steps=iterative_steps)
1407
1408    return rays
1409
1410
1411
1412def scale_rays(rays, 
1413               max_x=None, max_y=None, 
1414               new_max_x=None, new_max_y=None,
1415               detailed_scaling=True):
1416    """
1417    Scale ray coordinates between coordinate systems or image resolutions.
1418
1419    Optionally normalizes rays by old dimensions and rescales them to new ones.
1420    Can scale all points or just the start/end points of each beam.
1421
1422    Parameters:
1423    - rays (list): <br>
1424        Nested list of rays in the format rays[ray][beam][point] = (x, y).
1425    - max_x (float, optional): <br>
1426        Original maximum x-value for normalization.
1427    - max_y (float, optional): <br>
1428        Original maximum y-value for normalization.
1429    - new_max_x (float, optional): <br>
1430        New maximum x-value after rescaling.
1431    - new_max_y (float, optional): <br>
1432        New maximum y-value after rescaling.
1433    - detailed_scaling (bool, optional): <br>
1434        If True, scale every point in a beam; otherwise, only endpoints (default: True).
1435
1436    Returns:
1437    - list: <br>
1438        Scaled rays in the same nested format.
1439    """
1440    if isinstance(rays, RayIterator):
1441        rays.apply_and_update(lambda r: scale_rays(r, max_x=max_x, max_y=max_y, new_max_x=new_max_x, new_max_y=new_max_y, detailed_scaling=detailed_scaling))
1442        return rays
1443
1444    scaled_rays = []
1445    for ray in rays:
1446        scaled_ray = []
1447        for beams in ray:
1448            new_beams = copy.deepcopy(beams)
1449            if detailed_scaling:
1450                idx_to_process = list(range(len(beams)))
1451            else:
1452                idx_to_process = [0, len(beams)-1]
1453
1454            for idx in idx_to_process:
1455                x1, y1 = beams[idx] 
1456
1457                if max_x is not None and max_y is not None:
1458                    x1 /= max_x
1459                    y1 /= max_y
1460
1461                from_cache = (x1, y1)
1462                if new_max_x is not None and new_max_y is not None:
1463                    if x1 >= new_max_x/2:
1464                        print(f"[WARNING] Detected high values scaling. Are you sure you want to scale for example a ray with {x1} to a value like {x1*new_max_x}?")
1465                    if y1 >= new_max_y/2:
1466                        print(f"[WARNING] Detected high values scaling. Are you sure you want to scale for example a ray with {y1} to a value like {y1*new_max_y}?")
1467                    
1468                    x1 *= new_max_x
1469                    y1 *= new_max_y
1470
1471                new_beams[idx] = (x1, y1)
1472
1473            scaled_ray.append(new_beams)
1474        scaled_rays.append(scaled_ray)
1475    return scaled_rays
1476
1477
1478
1479def draw_rectangle_with_thickness(img, start_point, end_point, value, thickness=1):
1480    """
1481    Draw a filled or thick rectangle on an image.
1482
1483    Expands the given start and end points based on the desired thickness and
1484    clips coordinates to image bounds to avoid overflow.
1485
1486    Parameters:
1487    - img (numpy.ndarray): <br>
1488        Image array to draw on.
1489    - start_point (tuple): <br>
1490        Top-left corner of the rectangle (x, y).
1491    - end_point (tuple): <br>
1492        Bottom-right corner of the rectangle (x, y).
1493    - value (int or float): <br>
1494        Fill value or color intensity.
1495    - thickness (int, optional): <br>
1496        Rectangle border thickness; 
1497        if <= 0, the rectangle is filled (default: 1).
1498    """
1499    # Calculate the expansion -> "thickness"
1500    if thickness > 0:
1501        expand = thickness // 2
1502        x1, y1 = start_point[0] - expand, start_point[1] - expand
1503        x2, y2 = end_point[0] + expand, end_point[1] + expand
1504    else:
1505        # thickness <= 0 → filled rectangle, no expansion
1506        x1, y1 = start_point
1507        x2, y2 = end_point
1508
1509    # Clip coordinates to image bounds
1510    x1 = max(0, x1)
1511    y1 = max(0, y1)
1512    x2 = min(img.shape[1]-1, x2)
1513    y2 = min(img.shape[0]-1, y2)
1514
1515    cv2.rectangle(img, (x1, y1), (x2, y2), value, thickness=-1)
1516
1517
1518
1519def draw_line_or_point(img, start_point, end_point, fill_value, thickness):
1520    """
1521    Draw either a line or a single point on an image.
1522
1523    Determines whether to draw a point or a line based on whether the start and
1524    end coordinates are identical.
1525
1526    Parameters:
1527    - img (numpy.ndarray): <br>
1528        Image array to draw on.
1529    - start_point (tuple): <br>
1530        Starting pixel coordinate (x, y).
1531    - end_point (tuple): <br>
1532        Ending pixel coordinate (x, y).
1533    - fill_value (int or float): <br>
1534        Value or color used for drawing.
1535    - thickness (int): <br>
1536        Thickness of the line or point.
1537    """
1538    draw_point = (start_point == end_point)
1539
1540    if draw_point:
1541        draw_rectangle_with_thickness(img=img, start_point=start_point, end_point=end_point, value=fill_value, thickness=thickness)
1542    else:
1543        cv2.line(img, start_point, end_point, fill_value, thickness)
1544
1545
1546
1547def draw_rays(rays, detail_draw=True,
1548              output_format="single_image", # single_image, multiple_images, channels 
1549              img_background=None, ray_value=255, ray_thickness=1, 
1550              img_shape=(256, 256), dtype=float, standard_value=0,
1551              should_scale_rays_to_image=True, original_max_width=None, original_max_height=None,
1552              show_only_reflections=False):
1553    """
1554    Render rays onto an image or a set of images.
1555
1556    Each ray can be drawn in full detail (every point) or as straight lines between
1557    beam endpoints. Rays can be scaled to match image dimensions and drawn on a
1558    single image, multiple images, or separate channels.
1559
1560    Parameters:
1561    - rays (list): <br>
1562        Nested list of rays in the format rays[ray][beam][point] = (x, y).
1563    - detail_draw (bool, optional): <br>
1564        Whether to draw every point or just beam endpoints (default: True).
1565    - output_format (str, optional): <br>
1566        Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
1567    - img_background (numpy.ndarray, optional): <br>
1568        Background image; if None, a blank image is created.
1569    - ray_value (int, float, list, or numpy.ndarray, optional): <br>
1570        Pixel intensity or color per reflection (default: 255).
1571    - ray_thickness (int, optional): <br>
1572        Thickness of the drawn lines or points (default: 1).
1573    - img_shape (tuple, optional): <br>
1574        Shape of the generated image if no background is given (default: (256, 256)).
1575    - dtype (type, optional): <br>
1576        Data type for the output image (default: float).
1577    - standard_value (int or float, optional): <br>
1578        Background fill value (default: 0).
1579    - should_scale_rays_to_image (bool, optional): <br>
1580        Whether to scale ray coordinates to match the image (default: True).
1581    - original_max_width (float, optional): <br>
1582        Original image width before scaling.
1583    - original_max_height (float, optional): <br>
1584        Original image height before scaling.
1585    - show_only_reflections (bool, optional): <br>
1586        If True, draws only reflected beams (default: False).
1587
1588    Returns:
1589        numpy.ndarray or list: <br>
1590            - Single image if output_format == "single_image" or "channels".
1591            - List of images if output_format == "multiple_images".
1592    """
1593    if isinstance(rays, RayIterator):
1594        imgs = rays.apply_and_return(lambda r: draw_rays(r, detail_draw=detail_draw,
1595                                                            output_format=output_format,
1596                                                            img_background=img_background,
1597                                                            ray_value=ray_value,
1598                                                            ray_thickness=ray_thickness,
1599                                                            img_shape=img_shape,
1600                                                            dtype=dtype,
1601                                                            standard_value=standard_value,
1602                                                            should_scale_rays_to_image=should_scale_rays_to_image,
1603                                                            original_max_width=original_max_width,
1604                                                            original_max_height=original_max_height,
1605                                                            show_only_reflections=show_only_reflections))
1606        return imgs
1607
1608    # prepare background image
1609    if img_background is None:
1610        img = np.full(shape=img_shape, fill_value=dtype(standard_value), dtype=dtype)
1611    else:
1612        img = img_background.copy()
1613
1614    # rescale rays to fit inside image bounds if desired
1615    height, width = img.shape[:2]
1616    # print(f"{height, width=}")
1617    # print(f"{(original_max_width, original_max_height)=}")
1618    if should_scale_rays_to_image:
1619        rays = scale_rays(rays, max_x=original_max_width, max_y=original_max_height, new_max_x=width-1, new_max_y=height-1, detailed_scaling=detail_draw)
1620
1621    nrays = len(rays)
1622    if output_format == "channels":
1623        img = np.repeat(img[..., None], nrays, axis=-1)
1624        # img_shape += (nrays, )
1625        # img = np.full(shape=img_shape, fill_value=dtype(standard_value), dtype=dtype)
1626    elif output_format == "multiple_images":
1627        imgs = [np.copy(img) for _ in range(nrays)]
1628
1629    # draw on image
1630    for idx, ray in enumerate(rays):
1631        for reflexion_order, beam_points in enumerate(ray):
1632            
1633            if detail_draw:
1634                lines = []
1635                for cur_point in range(0, len(beam_points)):
1636                    start_point = tuple(map(lambda x:int(x), beam_points[cur_point]))
1637                    end_point = tuple(map(lambda x:int(x), beam_points[cur_point]))
1638                    # end_point = tuple(map(lambda x:int(x), beam_points[cur_point+1]))
1639                    # -> if as small lines then in range: range(0, len(beam_points)-1)
1640                    lines.append((start_point, end_point, reflexion_order))
1641            else:
1642                start_point = tuple(map(lambda x:int(x), beam_points[0]))
1643                end_point = tuple(map(lambda x:int(x), beam_points[-1]))
1644                lines = [(start_point, end_point, reflexion_order)]
1645            
1646            for start_point, end_point, reflexion_order in lines:
1647
1648                if show_only_reflections and reflexion_order == 0:
1649                    continue
1650
1651                # get cur ray value
1652                if type(ray_value) in [list, tuple, np.ndarray]:
1653                    # if we print without the first line: first value (index 0) belkongs to the reflexion order 1
1654                    cur_reflexion_index = reflexion_order-1 if show_only_reflections else reflexion_order
1655                    cur_ray_value = ray_value[min(len(ray_value)-1, cur_reflexion_index)]
1656                else:
1657                    cur_ray_value = ray_value
1658
1659                if output_format == "channels":
1660                    layer = np.ascontiguousarray(img[..., idx])
1661                    draw_line_or_point(img=layer, start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1662                    img[..., idx] = layer
1663                elif output_format == "multiple_images":
1664                    draw_line_or_point(img=imgs[idx], start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1665                else:
1666                    draw_line_or_point(img=img, start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1667
1668
1669    if output_format == "multiple_images":
1670        return imgs
1671    else:
1672        return img
1673
1674
1675
1676def trace_and_draw_rays(rel_position, 
1677                        img_src,
1678                        directions_in_degree, 
1679                        wall_values, 
1680                        wall_thickness=0,
1681                        img_border_also_collide=False,
1682                        reflexion_order=3,
1683                        should_scale_rays=True,
1684                        should_scale_img=True,
1685                        use_dda=True,
1686                        iterative_tracking=False,
1687                        iterative_steps=None,
1688                        parallelization=-1,
1689                        parallelization_method="processes",  # "threads", "processes"
1690                        use_numba_compilation=True,
1691                        ignore_iterative_optimization=True,
1692                        detail_draw=True,
1693                        output_format="single_image", # single_image, multiple_images, channels 
1694                        img_background=None, 
1695                        ray_value=255, 
1696                        ray_thickness=1, 
1697                        img_shape=(256, 256), 
1698                        dtype=float, 
1699                        standard_value=0,
1700                        should_scale_rays_to_image=True, 
1701                        original_max_width=None, 
1702                        original_max_height=None,
1703                        show_only_reflections=False):
1704    """
1705    Trace multiple rays (beams) from a single position through an image with walls and reflections AND 
1706    render rays onto an image or a set of images.
1707
1708    Calls internally `trace_beams` and then `draw_rays`.
1709
1710    Parameters:
1711    - rel_position (tuple): <br>
1712        Relative starting position (x, y) in normalized coordinates [0-1].
1713    - img_src (str or numpy.ndarray): <br>
1714        Input image (array or file path) used for wall detection.
1715    - directions_in_degree (list): <br>
1716        List of beam direction angles (in degrees).
1717    - wall_values (list or float or None): <br>
1718        Pixel values representing walls or obstacles.
1719    - wall_thickness (int, optional): <br>
1720        Thickness (in pixels) of detected walls (default: 0).
1721    - img_border_also_collide (bool, optional): <br>
1722        Whether image borders act as colliders (default: False).
1723    - reflexion_order (int, optional): <br>
1724        Number of allowed reflections per beam (default: 3).
1725    - should_scale_rays (bool, optional): <br>
1726        Whether to normalize ray coordinates to [0, 1] (default: True).
1727    - should_scale_img (bool, optional): <br>
1728        Whether to scale the input image before wall detection (default: True).
1729    - use_dda (bool, optional): <br>
1730        Whether to use the DDA-based ray tracing method (default: True).
1731    - iterative_tracking (bool, optional): 
1732        Whether to return a RayIterator for step-by-step analysis (default: False).
1733    - iterative_steps (int, optional):<br>
1734        Number of steps for iterative reduction if using iterative tracking. `None` for all steps.
1735    - parallelization (int, optional):<br>
1736        The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
1737    - parallelization_method (str, optional):<br>
1738        Method to use for parallelization (as soft condition) -> "threads" or "processes"
1739    - use_numba_compilation (bool, optional):<br>
1740        Whether to use the compiled (to machine code) version of compute heavy functions.
1741    - ignore_iterative_optimization (bool, optional): <br>
1742        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1743    - detail_draw (bool, optional): <br>
1744        Whether to draw every point or just beam endpoints (default: True).
1745    - output_format (str, optional): <br>
1746        Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
1747    - img_background (numpy.ndarray, optional): <br>
1748        Background image; if None, a blank image is created.
1749    - ray_value (int, float, list, or numpy.ndarray, optional): <br>
1750        Pixel intensity or color per reflection (default: 255).
1751    - ray_thickness (int, optional): <br>
1752        Thickness of the drawn lines or points (default: 1).
1753    - img_shape (tuple, optional): <br>
1754        Shape of the generated image if no background is given (default: (256, 256)).
1755    - dtype (type, optional): <br>
1756        Data type for the output image (default: float).
1757    - standard_value (int or float, optional): <br>
1758        Background fill value (default: 0).
1759    - should_scale_rays_to_image (bool, optional): <br>
1760        Whether to scale ray coordinates to match the image (default: True).
1761    - original_max_width (float, optional): <br>
1762        Original image width before scaling.
1763    - original_max_height (float, optional): <br>
1764        Original image height before scaling.
1765    - show_only_reflections (bool, optional): <br>
1766        If True, draws only reflected beams (default: False).
1767
1768    Returns:
1769        numpy.ndarray or list:
1770            - Single image if output_format == "single_image" or "channels".
1771            - List of images if output_format == "multiple_images".
1772    """
1773    rays = trace_beams(rel_position, 
1774                        img_src,
1775                        directions_in_degree, 
1776                        wall_values, 
1777                        wall_thickness=wall_thickness,
1778                        img_border_also_collide=img_border_also_collide,
1779                        reflexion_order=reflexion_order,
1780                        should_scale_rays=should_scale_rays,
1781                        should_scale_img=should_scale_img,
1782                        use_dda=use_dda,
1783                        iterative_tracking=iterative_tracking,
1784                        iterative_steps=iterative_steps,
1785                        parallelization=parallelization,
1786                        parallelization_method=parallelization_method,
1787                        use_numba_compilation=use_numba_compilation,
1788                        ignore_iterative_optimization=ignore_iterative_optimization)
1789    
1790    return draw_rays(rays, 
1791                     detail_draw=detail_draw,
1792                     output_format=output_format,
1793                     img_background=img_background, 
1794                     ray_value=ray_value, 
1795                     ray_thickness=ray_thickness, 
1796                     img_shape=img_shape, 
1797                     dtype=dtype, 
1798                     standard_value=standard_value,
1799                     should_scale_rays_to_image=should_scale_rays_to_image, 
1800                     original_max_width=original_max_width, 
1801                     original_max_height=original_max_height,
1802                     show_only_reflections=show_only_reflections)
1803
1804
1805
1806# --------------------------------
1807# >>> Numba Optimized Versions <<<
1808# --------------------------------
1809def numba_to_py(obj):
1810    """
1811    Converts numba.typed.List (possibly nested -> rays) to plain Python lists/tuples.
1812    """
1813    if isinstance(obj, List):
1814        return [numba_to_py(x) for x in obj]
1815
1816    
1817    if isinstance(obj, tuple):
1818        return tuple(numba_to_py(x) for x in obj)
1819
1820    return obj
1821
1822
1823TYPE_POINT_INT = types.UniTuple(types.int64, 2)
1824TYPE_POINT_FLOAT = types.UniTuple(types.float64, 2)
1825TYPE_LINE = types.ListType(TYPE_POINT_FLOAT)
1826
1827# trace_beam_numba = numba.njit(cache=True, fastmath=True)(trace_beam)
1828# trace_beam_with_DDA_numba = numba.njit(cache=True, fastmath=True)(trace_beam_with_DDA)
1829calc_reflection_numba = numba.njit(cache=True, fastmath=True)(calc_reflection)
1830# get_all_pixel_coordinates_in_between_numba = numba.njit(cache=True, fastmath=True)(get_all_pixel_coordinates_in_between)
1831get_img_border_vector_numba = numba.njit(cache=True, fastmath=True)(get_img_border_vector)
1832# update_pixel_position_numba = numba.njit(cache=True, fastmath=True)(update_pixel_position)
1833
1834
1835
1836@numba.njit(cache=True, fastmath=True)
1837def update_pixel_position_numba(direction_in_degree, cur_position, target_line):
1838    # cur_position: (x,y) tuple
1839    # target_line: array[4] => [x1,y1,x2,y2]
1840    px = cur_position[0]
1841    py = cur_position[1]
1842
1843    ax = target_line[0]
1844    ay = target_line[1]
1845    bx = target_line[2]
1846    by = target_line[3]
1847
1848    # Directionvector of the line
1849    vx = bx - ax
1850    vy = by - ay
1851
1852    # Projection: t = dot(P-A, V) / dot(V,V)
1853    wx = px - ax
1854    wy = py - ay
1855
1856    denom = vx*vx + vy*vy
1857    if denom == 0.0:
1858        # Degenerated Linie: just no movement
1859        return (px, py)
1860
1861    t = (wx*vx + wy*vy) / denom
1862
1863    # clip scalar (Numba-safe)
1864    if t < 0.0:
1865        t = 0.0
1866    elif t > 1.0:
1867        t = 1.0
1868
1869    # Point on the line
1870    lx = ax + t * vx
1871    ly = ay + t * vy
1872
1873    # Now make a step in direction_in_degree
1874    rad = math.radians(direction_in_degree)
1875    dx = math.cos(rad)
1876    dy = math.sin(rad)
1877
1878    nx = lx + dx
1879    ny = ly + dy
1880
1881    return (nx, ny)
1882
1883
1884
1885@numba.njit(cache=True, fastmath=True)
1886def _is_in_wall_values(pixel_value, wall_values):
1887    # Numba-save Membership-Check -> works with np.ndarray or numba List
1888    for i in range(len(wall_values)):
1889        if pixel_value == wall_values[i]:
1890            return True
1891    return False
1892
1893
1894
1895@numba.njit(cache=True, fastmath=True)
1896def _compute_target_line_outside(x0, y0, dx, dy, w, h):
1897    # w, h als float
1898    # Candidats: Intersection with vertical/horizontal borders
1899    # Taking the smallest t and continue going 0.01
1900    INF = 1e30
1901
1902    # X-Border
1903    if dx > 0.0:
1904        tx = (w - x0) / dx
1905    elif dx < 0.0:
1906        tx = (0.0 - x0) / dx
1907    else:
1908        tx = INF
1909
1910    # Y-Border
1911    if dy > 0.0:
1912        ty = (h - y0) / dy
1913    elif dy < 0.0:
1914        ty = (0.0 - y0) / dy
1915    else:
1916        ty = INF
1917
1918    # t have to be positive
1919    if tx <= 0.0:
1920        tx = INF
1921    if ty <= 0.0:
1922        ty = INF
1923
1924    t_hit = tx if tx < ty else ty
1925    if t_hit == INF:
1926        # Direction is 0-vector (should not happen)
1927        t_hit = 0.0
1928
1929    # the end point should be stay outside of the building/wall
1930    t_out = t_hit + 0.01
1931
1932    x2 = x0 + t_out * dx
1933    y2 = y0 + t_out * dy
1934
1935    return x0, y0, x2, y2
1936
1937
1938
1939@numba.njit(cache=True, fastmath=True)
1940def trace_beam_numba(abs_position,
1941                     img,
1942                     direction_in_degree,
1943                     wall_map,
1944                     wall_values,
1945                     img_border_also_collide=False,
1946                     reflexion_order=3,
1947                     should_scale=True,
1948                     should_return_iterative=False,
1949                     remove_iterative=True):
1950    """
1951    Trace a ray (beam) through an image with walls and reflections.
1952
1953    The beam starts from a given position and follows a direction until it hits
1954    a wall or border. On collisions, reflections are computed using wall normals.
1955
1956    Parameters:
1957    - abs_position (tuple): <br>
1958        Starting position (x, y) of the beam.
1959    - img (numpy.ndarray): <br>
1960        Input image or segmentation map.
1961    - direction_in_degree (float): <br>
1962        Initial direction angle of the beam.
1963    - wall_map (numpy.ndarray): <br>
1964        Map containing wall orientations in degrees.
1965    - wall_values (list): <br>
1966        List of pixel values representing walls.
1967    - img_border_also_collide (bool, optional): <br>
1968        Whether the image border acts as a collider (default: False).
1969    - reflexion_order (int, optional): <br>
1970        Number of allowed reflections (default: 3).
1971    - should_scale (bool, optional): <br>
1972        Whether to normalize positions to [0, 1] (default: True).
1973    - should_return_iterative (bool, optional): <br>
1974        Whether to return a RayIterator for step-by-step analysis (default: False).
1975    - ignore_iterative_optimization (bool, optional): <br>
1976        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1977    - remove_iterative (bool, optional): <br>
1978        Ignored in this numba version.
1979
1980    Returns:
1981    - list: 
1982        Nested list structure representing the traced ray and its reflections. 
1983        Format: ray[beam][point] = (x, y)
1984    """
1985    if should_return_iterative:
1986        print("[WARNING] Numba Version can't return a iterative version.")
1987
1988    reflexion_order += 1
1989    IMG_HEIGHT = img.shape[0]
1990    IMG_WIDTH  = img.shape[1]
1991
1992    ray = List.empty_list(TYPE_LINE)
1993
1994    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
1995    cur_direction_in_degree = float(direction_in_degree % 360.0)
1996
1997    w_f = float(IMG_WIDTH)
1998    h_f = float(IMG_HEIGHT)
1999
2000    inf_val = np.inf
2001
2002    for _ in range(reflexion_order):
2003        # current_ray_line als typed list
2004        current_ray_line = List.empty_list(TYPE_POINT_FLOAT)
2005
2006        if should_scale:
2007            p = normalize_point_numba(x=cur_target_abs_position[0],
2008                                 y=cur_target_abs_position[1],
2009                                 width=IMG_WIDTH,
2010                                 height=IMG_HEIGHT)
2011            current_ray_line.append((float(p[0]), float(p[1])))
2012        else:
2013            current_ray_line.append((cur_target_abs_position[0], cur_target_abs_position[1]))
2014
2015        last_abs_position = cur_target_abs_position
2016
2017        # Direction -> dx,dy
2018        rad = math.radians(cur_direction_in_degree)
2019        dx = math.cos(rad)
2020        dy = math.sin(rad)
2021
2022        # target_line
2023        x1, y1, x2, y2 = _compute_target_line_outside(cur_target_abs_position[0],
2024                                                      cur_target_abs_position[1],
2025                                                      dx, dy, w_f, h_f)
2026        # build target line -> start and end point
2027        target_line = np.empty(4, dtype=np.float64)
2028        target_line[0] = x1
2029        target_line[1] = y1
2030        target_line[2] = x2
2031        target_line[3] = y2
2032
2033        current_position = cur_target_abs_position
2034
2035        while True:
2036            current_position = update_pixel_position_numba(
2037                direction_in_degree=cur_direction_in_degree,
2038                cur_position=current_position,
2039                target_line=target_line
2040            )
2041
2042            x = current_position[0]
2043            y = current_position[1]
2044
2045            # Border check
2046            if not (0.0 <= x < w_f and 0.0 <= y < h_f):
2047                ray.append(current_ray_line)
2048
2049                if img_border_also_collide:
2050                    wall_vector = get_img_border_vector_numba(
2051                        position=current_position,
2052                        max_width=IMG_WIDTH,
2053                        max_height=IMG_HEIGHT
2054                    )
2055                    new_direction = calc_reflection_numba(
2056                        collide_vector=degree_to_vector_numba(cur_direction_in_degree),
2057                        wall_vector=wall_vector
2058                    )
2059                    new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2060
2061                    cur_target_abs_position = last_abs_position
2062                    cur_direction_in_degree = float(new_direction_in_degree)
2063                    break
2064                else:
2065                    return ray
2066
2067            ix = int(x)
2068            iy = int(y)
2069
2070            next_pixel = float(img[iy, ix])
2071
2072            # Wall check (Numba-save)
2073            if _is_in_wall_values(next_pixel, wall_values):
2074                last_abs_position = (x, y)
2075                ray.append(current_ray_line)
2076
2077                building_angle = wall_map[iy, ix]
2078                if building_angle == inf_val:
2079                    raise Exception("Got inf value from Wall-Map.")
2080
2081                wall_vector = degree_to_vector_numba(building_angle)
2082
2083                new_direction = calc_reflection_numba(
2084                    collide_vector=degree_to_vector_numba(cur_direction_in_degree),
2085                    wall_vector=wall_vector
2086                )
2087                new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2088
2089                cur_target_abs_position = last_abs_position
2090                cur_direction_in_degree = float(new_direction_in_degree)
2091                break
2092            else:
2093                if should_scale:
2094                    p = normalize_point_numba(x=x, y=y, width=IMG_WIDTH, height=IMG_HEIGHT)
2095                    current_ray_line.append((float(p[0]), float(p[1])))
2096                else:
2097                    current_ray_line.append((x, y))
2098                last_abs_position = (x, y)
2099
2100    return ray
2101
2102
2103
2104@numba.njit(cache=True, fastmath=True)
2105def trace_beam_with_DDA_numba(abs_position, 
2106                                img, 
2107                                direction_in_degree, 
2108                                wall_map,
2109                                wall_values, 
2110                                img_border_also_collide=False,
2111                                reflexion_order=3,
2112                                should_scale=True,
2113                                should_return_iterative=False,
2114                                remove_iterative=True):
2115    """
2116    Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer)
2117    algorithm with precise collision points and physically accurate reflections.
2118
2119    The beam starts at a given floating-point position and marches through the grid
2120    until it intersects a wall or exits the image. For each collision, the exact
2121    hit position is computed using the ray Parameters t_hit, ensuring that reflected
2122    segments contain meaningful geometry rather than single-point artifacts.
2123    Reflections are computed using wall normals derived from the `wall_map`.
2124
2125    Parameters:
2126    - abs_position (tuple of float): <br>
2127        Starting position (x, y) of the beam in absolute pixel space.
2128    - img (numpy.ndarray): <br>
2129        2D array representing the scene. Pixel values listed in `wall_values`
2130        are treated as solid walls.
2131    - direction_in_degree (float): <br>
2132        Initial direction of the beam in degrees (0° = right, 90° = down).
2133    - wall_map (numpy.ndarray): <br>
2134        A map storing wall orientations in degrees for each pixel marked as a wall.
2135        These angles define the wall normals used for reflection.
2136    - wall_values (list, tuple, set, float, optional): <br>
2137        Pixel values identifying walls. Any pixel in this list causes a collision.
2138        If None, pixel value 0.0 is treated as a wall.
2139    - img_border_also_collide (bool, optional): <br>
2140        If True, the image borders behave like reflective walls. If False,
2141        the ray terminates when leaving the image. Default: False.
2142    - reflexion_order (int, optional): <br>
2143        Maximum number of reflections. The ray can rebound this many times before
2144        the function terminates. Default: 3.
2145    - should_scale (bool, optional): <br>
2146        If True, all emitted points (x, y) are normalized to [0, 1] range.
2147        Otherwise absolute pixel positions are returned. Default: True.
2148    - should_return_iterative (bool, optional): <br>
2149        Whether to return a RayIterator for step-by-step analysis (default: False).
2150    - remove_iterative (bool, optional): <br>
2151        Ignored in this numba version.
2152
2153    Returns:
2154    - list: 
2155        Nested list structure representing the traced ray and its reflections. 
2156        Format: ray[beam][point] = (x, y)
2157    """
2158    if should_return_iterative:
2159        print("[WARNING] Numba Version can't return a iterative version.")
2160
2161    reflexion_order += 1
2162    # IMG_WIDTH, IMG_HEIGHT = get_width_height(img)
2163    IMG_HEIGHT = img.shape[0]
2164    IMG_WIDTH  = img.shape[1]
2165
2166    ray = []
2167
2168    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
2169    cur_direction_in_degree = direction_in_degree % 360
2170
2171    # go through every reflection -> will early stop if hitting a wall (if wall-bouncing is deactivated)
2172    for cur_depth in range(reflexion_order):
2173        if should_scale:
2174            current_ray_line = [normalize_point_numba(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
2175        else:
2176            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
2177
2178        last_abs_position = cur_target_abs_position
2179
2180        # direction
2181        rad = math.radians(cur_direction_in_degree)
2182        dx = math.cos(rad)
2183        dy = math.sin(rad)
2184
2185        eps = 1e-12
2186        if abs(dx) < eps: dx = 0.0
2187        if abs(dy) < eps: dy = 0.0
2188
2189        # start float pos and starting cell
2190        x0 = float(cur_target_abs_position[0])
2191        y0 = float(cur_target_abs_position[1])
2192        cell_x = int(math.floor(x0))
2193        cell_y = int(math.floor(y0))
2194
2195        # outside start -> handle border/reflection/exit
2196        if not (0 <= x0 < IMG_WIDTH and 0 <= y0 < IMG_HEIGHT):
2197            ray.append(current_ray_line)
2198            if img_border_also_collide:
2199                wall_vector = get_img_border_vector_numba(position=(x0, y0), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
2200                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2201                cur_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2202                cur_target_abs_position = last_abs_position
2203                continue
2204            else:
2205                return ray
2206
2207        # DDA parameters
2208        tDeltaX = math.inf if dx == 0.0 else abs(1.0 / dx)
2209        tDeltaY = math.inf if dy == 0.0 else abs(1.0 / dy)
2210
2211        if dx > 0:
2212            stepX = 1
2213            nextBoundaryX = cell_x + 1.0
2214            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
2215        elif dx < 0:
2216            stepX = -1
2217            nextBoundaryX = cell_x * 1.0  # left boundary of cell
2218            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
2219        else:
2220            stepX = 0
2221            tMaxX = math.inf
2222
2223        if dy > 0:
2224            stepY = 1
2225            nextBoundaryY = cell_y + 1.0
2226            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
2227        elif dy < 0:
2228            stepY = -1
2229            nextBoundaryY = cell_y * 1.0
2230            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
2231        else:
2232            stepY = 0
2233            tMaxY = math.inf
2234
2235        max_steps = (IMG_WIDTH + IMG_HEIGHT) * 6
2236        steps = 0
2237        last_position_saved = False
2238
2239        # immediate-start-in-wall handling
2240        if 0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT:
2241            start_pixel = float(img[cell_y, cell_x])
2242            # if start_pixel in wall_values_set:
2243            if _is_in_wall_values(start_pixel, wall_values):
2244                # compute a collision point precisely at start (we'll use origin)
2245                # add collision point (start) and reflect
2246                hit_x = x0
2247                hit_y = y0
2248                if should_scale:
2249                    current_ray_line.append(normalize_point_numba(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT))
2250                else:
2251                    current_ray_line.append((hit_x, hit_y))
2252                ray.append(current_ray_line)
2253
2254                building_angle = float(wall_map[cell_y, cell_x])
2255                if not np.isfinite(building_angle):
2256                    raise Exception("Got non-finite value from Wall-Map.")
2257                wall_vector = degree_to_vector_numba(building_angle)
2258                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2259                cur_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2260                ndx, ndy = new_direction[0], new_direction[1]
2261                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2262                continue
2263
2264        # DDA main loop
2265        while steps < max_steps:
2266            steps += 1
2267
2268            # choose axis to step and capture t_hit (distance along ray to boundary)
2269            if tMaxX < tMaxY:
2270                t_hit = tMaxX
2271                # step in x
2272                cell_x += stepX
2273                tMaxX += tDeltaX
2274                stepped_axis = 'x'
2275            else:
2276                t_hit = tMaxY
2277                # step in y
2278                cell_y += stepY
2279                tMaxY += tDeltaY
2280                stepped_axis = 'y'
2281
2282            # compute exact collision position along ray from origin (x0,y0)
2283            hit_x = x0 + dx * t_hit
2284            hit_y = y0 + dy * t_hit
2285
2286            # For recording the traversal we can append intermediate cell centers encountered so far.
2287            # But more importantly, append the collision point to the current segment BEFORE storing it.
2288            if should_scale:
2289                current_ray_line.append(normalize_point_numba(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT))
2290            else:
2291                current_ray_line.append((hit_x, hit_y))
2292
2293            # Now check if we've left the image bounds (cell_x, cell_y refer to the new cell we stepped into)
2294            if not (0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT):
2295                ray.append(current_ray_line)
2296                last_position_saved = True
2297
2298                if img_border_also_collide:
2299                    wall_vector = get_img_border_vector_numba(position=(cell_x, cell_y), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
2300                    new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2301                    new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2302                    # start next ray from last in-image position (hit_x, hit_y) nudged slightly
2303                    ndx, ndy = new_direction[0], new_direction[1]
2304                    cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2305                    cur_direction_in_degree = new_direction_in_degree
2306                    break
2307                else:
2308                    return ray
2309
2310            # sample the pixel in the cell we stepped into
2311            next_pixel = float(img[cell_y, cell_x])
2312            if _is_in_wall_values(next_pixel, wall_values):
2313                # we hit a wall — collision point already appended
2314                last_abs_position = (hit_x, hit_y)
2315                ray.append(current_ray_line)
2316                last_position_saved = True
2317
2318                building_angle = float(wall_map[cell_y, cell_x])
2319                if not np.isfinite(building_angle):
2320                    raise Exception("Got non-finite value from Wall-Map.")
2321                wall_vector = degree_to_vector_numba(building_angle)
2322
2323                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2324                new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2325
2326                # start next beam from collision point nudged outwards
2327                ndx, ndy = new_direction[0], new_direction[1]
2328                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2329                cur_direction_in_degree = new_direction_in_degree
2330                break
2331            else:
2332                # no hit -> continue marching; also add a representative point in the traversed cell (optional)
2333                # we already appended the exact hit point for this step; for smoother lines you may add cell center too
2334                last_abs_position = (hit_x, hit_y)
2335                # continue
2336
2337        # end DDA loop
2338        if not last_position_saved:
2339            ray.append(current_ray_line)
2340
2341    return ray
2342
2343
2344
2345@numba.njit(cache=True, fastmath=True)
2346def get_all_pixel_coordinates_in_between_numba(x1, y1, x2, y2):
2347    """
2348    Get all pixel coordinates along a line between two points using Bresenham's algorithm.
2349
2350    https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
2351
2352    Parameters:
2353    - x1 (int): <br>
2354        Starting x-coordinate.
2355    - y1 (int): <br>
2356        Starting y-coordinate.
2357    - x2 (int): <br>
2358        Ending x-coordinate.
2359    - y2 (int): <br>
2360        Ending y-coordinate.
2361
2362    Returns:
2363    - list: 
2364        List of (x, y) tuples representing all pixels between the start and end points
2365    """
2366    coordinates = List.empty_list(TYPE_POINT_INT)
2367
2368    dx = abs(x2 - x1)
2369    dy = abs(y2 - y1)
2370    x, y = x1, y1
2371
2372    sx = 1 if x1 < x2 else -1
2373    sy = 1 if y1 < y2 else -1
2374
2375    if dx > dy:
2376        err = dx / 2.0
2377        while x != x2:
2378            coordinates.append((x, y))
2379            err -= dy
2380            if err < 0:
2381                y += sy
2382                err += dx
2383            x += sx
2384    else:
2385        err = dy / 2.0
2386        while y != y2:
2387            coordinates.append((x, y))
2388            err -= dx
2389            if err < 0:
2390                x += sx
2391                err += dy
2392            y += sy
2393
2394    coordinates.append((x2, y2))  # include the last point
2395    return list(coordinates)
class RayIterator:
 94class RayIterator:
 95    """
 96    A container class to save every step of a ray tracing process.
 97    """
 98    def __init__(self, other_ray_iterator=None):
 99        """
100        Initialize a RayIterator instance.
101        
102        Parameters:
103        - other_ray_iterator (RayIterator, optional): <br>
104            An existing RayIterator to copy. If provided, creates a deep copy 
105            of the other iterator's rays_collection. If None, creates an empty iterator.
106            
107        Returns: <br>
108        None
109        """
110        if other_ray_iterator is None:
111            self.rays_collection = []
112        else:
113            self.rays_collection = copy.deepcopy(other_ray_iterator.rays_collection)
114
115    def __repr__(self):
116        """
117        Return a string representation of the RayIterator.
118        
119        Returns:<br>
120        str: String representation showing the number of iterations/time-steps.
121        """
122        return f"RayIterator with {self.len_iterations()} iterations (time-steps)."
123
124    def __iter__(self):
125        """
126        Make the RayIterator iterable over its rays collections.
127        
128        Yields:<br>
129        list: Each iteration's collection of rays.
130        """
131        for rays in self.rays_collection:
132            yield rays
133
134    def __getitem__(self, key):
135        """
136        Get rays from the latest iteration using key/index.
137        
138        Parameters:
139        - key (int or slice): Index or slice to access rays in the latest iteration.
140        
141        Returns:<br>
142        list: Rays from the latest iteration corresponding to the key.
143        """
144        return self.rays_collection[-1][key]
145    
146    def __len__(self):
147        """
148        Get the number of rays in the latest iteration.
149        
150        Returns:<br>
151        int: Number of rays in the latest iteration.
152        """
153        return len(self.rays_collection[-1])
154    
155    def __add__(self, other):
156        """
157        Add another RayIterator or value to this RayIterator element-wise.
158        
159        Parameters:
160        - other (RayIterator or any): <br>
161            If RayIterator: combines ray collections from both iterators. 
162            If other type: adds the value to each ray in all iterations.
163        
164        Returns:<br>
165        RayIterator: New RayIterator containing the combined/adjusted results.
166        
167        Raises:<br>
168        TypeError: If other is not a RayIterator and addition operation fails.
169        """
170        if isinstance(other, RayIterator):
171            new_iterator = RayIterator()
172            if self.len_iterations() > other.len_iterations():
173                iter_1 = self
174                iter_2 = other
175            else:
176                iter_1 = other
177                iter_2 = self
178
179            iter_1 = copy.deepcopy(iter_1)
180            iter_2 = copy.deepcopy(iter_2)
181
182            for idx in range(iter_1.len_iterations()):
183                cur_addition = iter_1.get_iteration(idx)
184                if iter_2.len_iterations() > idx:
185                    cur_addition += iter_2.get_iteration(idx)
186                elif iter_2.len_iterations() == 0:
187                    pass
188                else:
189                    cur_addition += iter_2.get_iteration(-1)
190
191                new_iterator.add_iteration(cur_addition)
192
193            return new_iterator
194        else:
195            new_iterator = RayIterator()
196
197            for idx in range(self.len_iterations()):
198                cur_addition = self.get_iteration(idx) + other
199                new_iterator.add_iteration(cur_addition)
200
201            return new_iterator
202        
203    def __iadd__(self, other):
204        """
205        In-place addition of another RayIterator or value.
206        
207        Parameters:
208        - other (RayIterator or any): 
209            Object to add to this RayIterator.
210        
211        Returns:<br>
212        RayIterator: self, after performing the in-place addition.
213        """
214        new_iterator = self.__add__(other) 
215        self.rays_collection = new_iterator.rays_collection
216        return self
217    
218    def len_iterations(self):
219        """
220        Get the total number of iterations/time-steps stored.
221        
222        Returns:<br>
223        int: <br>
224            Number of iterations in the rays_collection.
225        """
226        return len(self.rays_collection)
227
228    def add_iteration(self, rays):
229        """
230        Add a new iteration (collection of rays) to the iterator.
231        
232        Parameters:
233        - rays (list): Collection of rays to add as a new iteration.
234            Format: rays[ray][beam][point] = (x, y)
235        
236        Returns:<br>
237        RayIterator: self, for method chaining.
238        """
239        self.rays_collection += [copy.deepcopy(rays)]
240        return self
241    
242    def add_rays(self, rays):
243        """
244        Add rays to the every iteration (in-place modification).
245        If one iterator have less steps, the last step will be used for all other iterations.
246        Which equals no change for those iterations.
247        
248        Parameters:
249        - rays (list): Rays to add to the latest iteration.
250            Format: rays[ray][beam][point] = (x, y)
251        
252        Returns:<br>
253        list: <br>
254        The updated rays_collection.
255        """
256        self.rays_collection = self.__add__(copy.deepcopy(rays)).rays_collection
257        return self.rays_collection
258
259    def print_info(self):
260        """
261        Print statistical information about the ray collections.
262        
263        Displays:
264        - Number of iterations
265        - Information about the latest iteration's rays including:
266          * Number of rays, beams, reflexions, and points
267          * Mean, median, max, min, and variance for beams per ray, 
268            reflexions per ray, and points per beam
269          * Value range for x and y coordinates
270        
271        Returns:<br>
272        None
273        """
274        print(f"Ray Iterator with {self.len_iterations()} iterations (time-steps).")
275        print("Latest Rays Info:\n")
276        print_rays_info(self.rays_collection[-1])
277        # for idx, rays in enumerate(self.rays_collection):
278        #     print(f"--- Rays Set {idx} ---")
279        #     print_rays_info(rays)
280
281    def reduce_to_x_steps(self, x_steps):
282        """
283        Reduce the number of stored iterations to approximately x_steps.
284        
285        Uses linear sampling to keep representative iterations while reducing
286        memory usage. If x_steps is greater than current iterations, does nothing.
287        
288        Parameters:
289        - x_steps (int): <br>
290            Desired number of iterations to keep.
291        
292        Returns:<br>
293        None
294        """
295        if x_steps >= self.len_iterations():
296            return  # nothing to do
297
298        step_size = self.len_iterations() / x_steps
299        new_rays_collection = []
300        for i in range(x_steps):
301            index = int(i * step_size)
302            new_rays_collection += [self.get_iteration(index)]
303
304        self.rays_collection = new_rays_collection
305
306    def apply_and_update(self, func):
307        """
308        Apply a function to each iteration's rays and update in-place.
309        
310        Parameters:
311        - func (callable): <br>
312            Function that takes a rays collection and returns a modified rays collection. 
313            Will be applied to each iteration.
314        
315        Returns:<br>
316        None
317        """
318        for i in range(self.len_iterations()):
319            self.rays_collection[i] = func(self.rays_collection[i])
320    
321    def apply_and_return(self, func):
322        """
323        Apply a function to each iteration's rays and return results.
324        
325        Parameters:
326        - func (callable): <br>
327            Function that takes a rays collection and returns some result. 
328            Will be applied to each iteration.
329        
330        Returns:
331        list: <br>
332            Results of applying func to each iteration's rays.
333        """
334        results = []
335        for i in range(self.len_iterations()):
336            results += [func(self.rays_collection[i])]
337
338        return results
339    
340    def get_iteration(self, index):
341        """
342        Get a specific iteration's rays collection.
343        
344        Parameters:
345        - index (int): <br>
346            Index of the iteration to retrieve. Supports negative indexing (e.g., -1 for last iteration).
347        
348        Returns:
349        list: <br>
350            - Rays collection at the specified iteration.
351        
352        Raises: <br>
353        IndexError: If index is out of range.
354        """
355        if index < -1 * self.len_iterations() or index >= self.len_iterations():
356            raise IndexError("RayIterator index out of range.")
357        return self.rays_collection[index]

A container class to save every step of a ray tracing process.

RayIterator(other_ray_iterator=None)
 98    def __init__(self, other_ray_iterator=None):
 99        """
100        Initialize a RayIterator instance.
101        
102        Parameters:
103        - other_ray_iterator (RayIterator, optional): <br>
104            An existing RayIterator to copy. If provided, creates a deep copy 
105            of the other iterator's rays_collection. If None, creates an empty iterator.
106            
107        Returns: <br>
108        None
109        """
110        if other_ray_iterator is None:
111            self.rays_collection = []
112        else:
113            self.rays_collection = copy.deepcopy(other_ray_iterator.rays_collection)

Initialize a RayIterator instance.

Parameters:

  • other_ray_iterator (RayIterator, optional):
    An existing RayIterator to copy. If provided, creates a deep copy of the other iterator's rays_collection. If None, creates an empty iterator.

Returns:
None

def len_iterations(self):
218    def len_iterations(self):
219        """
220        Get the total number of iterations/time-steps stored.
221        
222        Returns:<br>
223        int: <br>
224            Number of iterations in the rays_collection.
225        """
226        return len(self.rays_collection)

Get the total number of iterations/time-steps stored.

Returns:
int:
Number of iterations in the rays_collection.

def add_iteration(self, rays):
228    def add_iteration(self, rays):
229        """
230        Add a new iteration (collection of rays) to the iterator.
231        
232        Parameters:
233        - rays (list): Collection of rays to add as a new iteration.
234            Format: rays[ray][beam][point] = (x, y)
235        
236        Returns:<br>
237        RayIterator: self, for method chaining.
238        """
239        self.rays_collection += [copy.deepcopy(rays)]
240        return self

Add a new iteration (collection of rays) to the iterator.

Parameters:

  • rays (list): Collection of rays to add as a new iteration. Format: rays[ray][beam][point] = (x, y)

Returns:
RayIterator: self, for method chaining.

def add_rays(self, rays):
242    def add_rays(self, rays):
243        """
244        Add rays to the every iteration (in-place modification).
245        If one iterator have less steps, the last step will be used for all other iterations.
246        Which equals no change for those iterations.
247        
248        Parameters:
249        - rays (list): Rays to add to the latest iteration.
250            Format: rays[ray][beam][point] = (x, y)
251        
252        Returns:<br>
253        list: <br>
254        The updated rays_collection.
255        """
256        self.rays_collection = self.__add__(copy.deepcopy(rays)).rays_collection
257        return self.rays_collection

Add rays to the every iteration (in-place modification). If one iterator have less steps, the last step will be used for all other iterations. Which equals no change for those iterations.

Parameters:

  • rays (list): Rays to add to the latest iteration. Format: rays[ray][beam][point] = (x, y)

Returns:
list:
The updated rays_collection.

def print_info(self):
259    def print_info(self):
260        """
261        Print statistical information about the ray collections.
262        
263        Displays:
264        - Number of iterations
265        - Information about the latest iteration's rays including:
266          * Number of rays, beams, reflexions, and points
267          * Mean, median, max, min, and variance for beams per ray, 
268            reflexions per ray, and points per beam
269          * Value range for x and y coordinates
270        
271        Returns:<br>
272        None
273        """
274        print(f"Ray Iterator with {self.len_iterations()} iterations (time-steps).")
275        print("Latest Rays Info:\n")
276        print_rays_info(self.rays_collection[-1])
277        # for idx, rays in enumerate(self.rays_collection):
278        #     print(f"--- Rays Set {idx} ---")
279        #     print_rays_info(rays)

Print statistical information about the ray collections.

Displays:

  • Number of iterations
  • Information about the latest iteration's rays including:
    • Number of rays, beams, reflexions, and points
    • Mean, median, max, min, and variance for beams per ray, reflexions per ray, and points per beam
    • Value range for x and y coordinates

Returns:
None

def reduce_to_x_steps(self, x_steps):
281    def reduce_to_x_steps(self, x_steps):
282        """
283        Reduce the number of stored iterations to approximately x_steps.
284        
285        Uses linear sampling to keep representative iterations while reducing
286        memory usage. If x_steps is greater than current iterations, does nothing.
287        
288        Parameters:
289        - x_steps (int): <br>
290            Desired number of iterations to keep.
291        
292        Returns:<br>
293        None
294        """
295        if x_steps >= self.len_iterations():
296            return  # nothing to do
297
298        step_size = self.len_iterations() / x_steps
299        new_rays_collection = []
300        for i in range(x_steps):
301            index = int(i * step_size)
302            new_rays_collection += [self.get_iteration(index)]
303
304        self.rays_collection = new_rays_collection

Reduce the number of stored iterations to approximately x_steps.

Uses linear sampling to keep representative iterations while reducing memory usage. If x_steps is greater than current iterations, does nothing.

Parameters:

  • x_steps (int):
    Desired number of iterations to keep.

Returns:
None

def apply_and_update(self, func):
306    def apply_and_update(self, func):
307        """
308        Apply a function to each iteration's rays and update in-place.
309        
310        Parameters:
311        - func (callable): <br>
312            Function that takes a rays collection and returns a modified rays collection. 
313            Will be applied to each iteration.
314        
315        Returns:<br>
316        None
317        """
318        for i in range(self.len_iterations()):
319            self.rays_collection[i] = func(self.rays_collection[i])

Apply a function to each iteration's rays and update in-place.

Parameters:

  • func (callable):
    Function that takes a rays collection and returns a modified rays collection. Will be applied to each iteration.

Returns:
None

def apply_and_return(self, func):
321    def apply_and_return(self, func):
322        """
323        Apply a function to each iteration's rays and return results.
324        
325        Parameters:
326        - func (callable): <br>
327            Function that takes a rays collection and returns some result. 
328            Will be applied to each iteration.
329        
330        Returns:
331        list: <br>
332            Results of applying func to each iteration's rays.
333        """
334        results = []
335        for i in range(self.len_iterations()):
336            results += [func(self.rays_collection[i])]
337
338        return results

Apply a function to each iteration's rays and return results.

Parameters:

  • func (callable):
    Function that takes a rays collection and returns some result. Will be applied to each iteration.

Returns: list:
Results of applying func to each iteration's rays.

def get_iteration(self, index):
340    def get_iteration(self, index):
341        """
342        Get a specific iteration's rays collection.
343        
344        Parameters:
345        - index (int): <br>
346            Index of the iteration to retrieve. Supports negative indexing (e.g., -1 for last iteration).
347        
348        Returns:
349        list: <br>
350            - Rays collection at the specified iteration.
351        
352        Raises: <br>
353        IndexError: If index is out of range.
354        """
355        if index < -1 * self.len_iterations() or index >= self.len_iterations():
356            raise IndexError("RayIterator index out of range.")
357        return self.rays_collection[index]

Get a specific iteration's rays collection.

Parameters:

  • index (int):
    Index of the iteration to retrieve. Supports negative indexing (e.g., -1 for last iteration).

Returns: list:
- Rays collection at the specified iteration.

Raises:
IndexError: If index is out of range.

def save(path, rays):
472def save(path, rays):
473    """
474    Save a list of rays to a text file.
475
476    The rays are serialized using a simple text-based format. 
477    Each ray is delimited by '>' and '<', and each point is represented as "x | y".
478
479    Parameters:
480    - path (str): <br>
481        Path to the file where data should be saved. If no '.txt' extension is present, it will be appended automatically.
482    - rays (list): <br>
483        Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
484
485    Returns:<br>
486        None
487    """
488    if isinstance(rays, RayIterator):
489        if not path.endswith(".pkl"):
490            path += ".pkl"
491        pickle.dump(rays, builtins.open(path, "wb"))
492        return
493    
494    # transform rays into an string
495    rays_str = ""
496    for ray in rays:
497        rays_str += ">\n"
498        for beam in ray:
499           rays_str += "\n"
500           for cur_point in beam:
501               rays_str += f"{cur_point[0]} | {cur_point[1]}, " 
502        rays_str += "<\n"
503
504    rays_str = rays_str.replace("\n\n", "\n")
505
506    if not path.endswith(".txt"):
507        path += ".txt"
508    
509    with builtins.open(path, "w") as file_:
510        file_.write(rays_str)

Save a list of rays to a text file.

The rays are serialized using a simple text-based format. Each ray is delimited by '>' and '<', and each point is represented as "x | y".

Parameters:

  • path (str):
    Path to the file where data should be saved. If no '.txt' extension is present, it will be appended automatically.
  • rays (list):
    Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)

Returns:
None

def open(path, is_iterator=False) -> list:
514def open(path, is_iterator=False) -> list:
515    """
516    Open and parse a ray text file into a structured list.
517
518    The file is expected to follow the same format as produced by `save()`.
519
520    Parameters:
521    - path (str): <br>
522        Path to the .txt file containing ray data.
523
524    Returns:
525    - list: <br>
526        Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
527    """
528    if is_iterator or path.endswith(".pkl"):
529        if not path.endswith(".pkl"):
530            path += ".pkl"
531        rays = pickle.load(builtins.open(path, "rb"))
532        return rays
533
534    if not path.endswith(".txt"):
535        path += ".txt"
536
537    with builtins.open(path, "r") as file_:
538        content = file_.read().strip()
539
540    rays = []
541    for ray in content.split(">"):
542        extracted_ray = []
543        for beam in ray.split("\n"):
544            extracted_beam = []
545            beam = beam.strip()
546            if not beam or beam == "<":
547                continue
548
549            for point in beam.split(","):
550                point = point.strip()
551                if not point or point == "<":
552                    continue
553
554                try:
555                    point_x, point_y = point.strip().split("|")
556                except Exception as e:
557                    print("Point of error:", point)
558                    raise e
559
560                extracted_beam += [(float(point_x), float(point_y))]
561            if len(extracted_beam) > 0:
562                extracted_ray += [extracted_beam]
563        if len(extracted_ray) > 0:
564            rays += [extracted_ray]
565    return rays

Open and parse a ray text file into a structured list.

The file is expected to follow the same format as produced by save().

Parameters:

  • path (str):
    Path to the .txt file containing ray data.

Returns:

  • list:
    Nested list structure representing rays. Format: rays[ray][beam][point] = (x, y)
def merge(rays_1, rays_2, *other_rays_):
569def merge(rays_1, rays_2, *other_rays_):
570    """
571    Merge multiple ray datasets into a single list.
572
573    Parameters:
574    - rays_1 (list): <br>
575        First set of rays.
576    - rays_2 (list): <br>
577        Second set of rays. 
578    - *other_rays_ (list): <br>
579        Additional ray lists to merge.
580
581    Returns:
582    - list: 
583        Combined list of all rays.
584    """
585    merged = rays_1 + rays_2
586
587    for rays in other_rays_:
588        merged += rays
589
590    return merged

Merge multiple ray datasets into a single list.

Parameters:

  • rays_1 (list):
    First set of rays.
  • rays_2 (list):
    Second set of rays.
  • *other_rays_ (list):
    Additional ray lists to merge.

Returns:

  • list: Combined list of all rays.
def get_all_pixel_coordinates_in_between(x1, y1, x2, y2):
599def get_all_pixel_coordinates_in_between(x1, y1, x2, y2):
600    """
601    Get all pixel coordinates along a line between two points using Bresenham's algorithm.
602
603    https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
604
605    Parameters:
606    - x1 (int): <br>
607        Starting x-coordinate.
608    - y1 (int): <br>
609        Starting y-coordinate.
610    - x2 (int): <br>
611        Ending x-coordinate.
612    - y2 (int): <br>
613        Ending y-coordinate.
614
615    Returns:
616    - list: 
617        List of (x, y) tuples representing all pixels between the start and end points
618    """
619    coordinates = []
620
621    dx = abs(x2 - x1)
622    dy = abs(y2 - y1)
623    x, y = x1, y1
624
625    sx = 1 if x1 < x2 else -1
626    sy = 1 if y1 < y2 else -1
627
628    if dx > dy:
629        err = dx / 2.0
630        while x != x2:
631            coordinates += [(x, y)]
632            err -= dy
633            if err < 0:
634                y += sy
635                err += dx
636            x += sx
637    else:
638        err = dy / 2.0
639        while y != y2:
640            coordinates += [(x, y)]
641            err -= dx
642            if err < 0:
643                x += sx
644                err += dy
645            y += sy
646
647    coordinates += [(x2, y2)]  # include the last point
648    return coordinates

Get all pixel coordinates along a line between two points using Bresenham's algorithm.

https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm

Parameters:

  • x1 (int):
    Starting x-coordinate.
  • y1 (int):
    Starting y-coordinate.
  • x2 (int):
    Ending x-coordinate.
  • y2 (int):
    Ending y-coordinate.

Returns:

  • list: List of (x, y) tuples representing all pixels between the start and end points
def get_wall_map(img, wall_values=None, thickness=1, use_numba_compilation=False):
652def get_wall_map(img, wall_values=None, thickness=1,
653                 use_numba_compilation=False):
654    """
655    Generate a wall map where each pixel encodes the wall orientation (in degrees).
656
657    Parameters:
658    - img (numpy.ndarray): <br>
659        Input image representing scene or segmentation mask.
660    - wall_values (list, optional): <br>
661        Specific pixel values considered as walls. If None, all non-zero pixels are treated as walls.
662    - thickness (int, optional): <br>
663        Thickness of wall lines (default is 1).
664    - use_numba_compilation (bool, optional):<br>
665        Whether to use the compiled (to machine code) version of compute heavy functions.
666
667    Returns:
668    - numpy.ndarray: 
669        2D array (same width and height as input) 
670        where each wall pixel contains the wall angle in degrees (0-360), 
671        and non-wall pixels are set to infinity (np.inf).
672    """
673    # numba optimization -> change function locally
674    if use_numba_compilation:
675        get_all_pixel_coordinates_in_between_ = get_all_pixel_coordinates_in_between_numba      
676    else:
677        get_all_pixel_coordinates_in_between_ = get_all_pixel_coordinates_in_between
678
679    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
680    wall_map = np.full((IMG_HEIGHT, IMG_WIDTH), np.inf, dtype=np.uint16)  # uint16 to get at least 360 degree/value range
681
682    # only detect edges from objects with specific pixel values
683    if wall_values is not None:
684        mask = np.isin(img, wall_values).astype(np.uint8) * 255
685    else:
686        mask = img
687        if np.max(mask) < 64:
688            mask = mask.astype(np.uint8) * 255
689
690    # detect edges and contours
691    edges = cv2.Canny(mask, 100, 200)
692    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
693
694    # convert contours to line segments
695    for c in contours:
696        for i in range(len(c)-1):
697            x1, y1 = c[i][0]
698            x2, y2 = c[i+1][0]
699            dy = y2 - y1
700            dx = x2 - x1
701            angle = math.atan2(dy, dx)
702            angle_deg = math.degrees(angle)
703            for x, y in get_all_pixel_coordinates_in_between_(x1, y1, x2, y2):
704                # wall_map[y, x] = int(angle_deg) % 360
705
706                for tx in range(-thickness, thickness+1):
707                    for ty in range(-thickness, thickness+1):
708                        nx, ny = x+tx, y+ty
709                        if 0 <= nx < IMG_WIDTH and 0 <= ny < IMG_HEIGHT:
710                            wall_map[ny, nx] = int(angle_deg) % 360
711    return wall_map

Generate a wall map where each pixel encodes the wall orientation (in degrees).

Parameters:

  • img (numpy.ndarray):
    Input image representing scene or segmentation mask.
  • wall_values (list, optional):
    Specific pixel values considered as walls. If None, all non-zero pixels are treated as walls.
  • thickness (int, optional):
    Thickness of wall lines (default is 1).
  • use_numba_compilation (bool, optional):
    Whether to use the compiled (to machine code) version of compute heavy functions.

Returns:

  • numpy.ndarray: 2D array (same width and height as input) where each wall pixel contains the wall angle in degrees (0-360), and non-wall pixels are set to infinity (np.inf).
def update_pixel_position(direction_in_degree, cur_position, target_line):
715def update_pixel_position(direction_in_degree, cur_position, target_line):
716    """
717    Update the pixel position of a moving point toward a target line based on direction and proximity.
718
719    Combines the direction vector with a vector pointing toward the closest point
720    on the target line, ensuring pixel-wise movement (discrete steps).
721
722    Parameters:
723    - direction_in_degree (float): <br>
724        Movement direction in degrees.
725    - cur_position (tuple): <br>
726        Current pixel position (x, y).
727    - target_line (list): <br>
728        Target line defined as [x1, y1, x2, y2].
729
730    Returns:
731    - tuple: 
732        Updated pixel position (x, y).
733    """
734    # 1. Calc distance from point to target line
735
736    # perpendicular vector to line (points toward line)
737    point = np.array(cur_position)
738    line_start_point = np.array(target_line[0:2])
739    line_end_point = np.array(target_line[2:4])
740
741    # projection along the line -> throw the point vector vertical/perpendicular on the line and see where it cuts with normed AP to AB
742    # t is the length from point to the line, therefore it gets normed
743    t = np.dot(point - line_start_point, line_end_point - line_start_point) / (np.dot(line_end_point - line_start_point, line_end_point - line_start_point) + 1e-8)
744    
745    # limit it to the line id needed -> because we don't want smaller or bigger values than that
746    #   -> 0 would be point A
747    #   -> 1 would be point B 
748    # t = np.clip(t, 0, 1)
749    if t < 0.0:
750        t = 0.0
751    elif t > 1.0:
752        t = 1.0
753
754    # get closest point by applying the found t as lentgh from startpoint in the line vector direction
755    closest = line_start_point + t * (line_end_point - line_start_point)
756
757    # get the final vector to the line
758    to_line = closest - point  # vector from current pos to closest point on line
759    
760    # 2. Calc vector to the degree
761    # movement vector based on angle
762    rad = math.radians(direction_in_degree)
763    move_dir = np.array([math.cos(rad), math.sin(rad)])
764    
765    # 3. Combine vector to the line and degree vector
766    # combine movement towards direction and towards line
767    combined = move_dir + to_line * 0.5  # weighting factor
768    
769    # pick pixel step (continuous to discrete) -> [-1, 0, 1]
770    step_x = np.sign(combined[0])
771    step_y = np.sign(combined[1])
772    
773    # clamp to [-1, 1], if bigger/smaller
774    # step_x = int(np.clip(step_x, -1, 1))
775    if step_x < 0.0:
776        step_x = 0.0
777    elif step_x > 1.0:
778        step_x = 1.0
779    # step_y = int(np.clip(step_y, -1, 1))
780    if step_y < 0.0:
781        step_y = 0.0
782    elif step_y > 1.0:
783        step_y = 1.0
784    
785    return (int(cur_position[0] + step_x), int(cur_position[1] + step_y))

Update the pixel position of a moving point toward a target line based on direction and proximity.

Combines the direction vector with a vector pointing toward the closest point on the target line, ensuring pixel-wise movement (discrete steps).

Parameters:

  • direction_in_degree (float):
    Movement direction in degrees.
  • cur_position (tuple):
    Current pixel position (x, y).
  • target_line (list):
    Target line defined as [x1, y1, x2, y2].

Returns:

  • tuple: Updated pixel position (x, y).
def calc_reflection(collide_vector, wall_vector):
789def calc_reflection(collide_vector, wall_vector):
790    """
791    Calculate the reflection of a collision vector against a wall vector.
792
793    The reflection is computed using the wall's normal vector and the formula:
794        r = v - 2 * (v · n) * n
795
796    Parameters:
797    - collide_vector (array-like): <br>
798        Incoming vector (2D).
799    - wall_vector (array-like): <br>
800        Wall direction vector (2D).
801
802    Returns:
803    - numpy.ndarray: 
804        Reflected 2D vector.
805    """
806    # normalize both
807    collide_vector = np.array(collide_vector, dtype=np.float64)
808    collide_vector /= np.linalg.norm(collide_vector)
809    wall_vector = np.array(wall_vector, dtype=np.float64)
810    wall_vector /= np.linalg.norm(wall_vector)
811
812    # calculate the normal of the wall
813    normal_wall_vector_1 = np.array([-wall_vector[1], wall_vector[0]])  # rotated +90°
814    normal_wall_vector_2 = np.array([wall_vector[1], -wall_vector[0]])  # rotated -90°
815
816    # decide which vector is the right one
817    #   -> dot product tells which normal faces the incoming vector
818    #   -> dor product shows how similiar 2 vectors are => smaller 0 means they show against each other => right vector
819    if np.dot(collide_vector, normal_wall_vector_1) < 0:
820        normal_wall_vector = normal_wall_vector_1
821    else:
822        normal_wall_vector = normal_wall_vector_2
823    
824    # calc the reflection
825    return collide_vector - 2 * np.dot(collide_vector, normal_wall_vector) * normal_wall_vector

Calculate the reflection of a collision vector against a wall vector.

The reflection is computed using the wall's normal vector and the formula: r = v - 2 * (v · n) * n

Parameters:

  • collide_vector (array-like):
    Incoming vector (2D).
  • wall_vector (array-like):
    Wall direction vector (2D).

Returns:

  • numpy.ndarray: Reflected 2D vector.
def get_img_border_vector(position, max_width, max_height):
828def get_img_border_vector(position, max_width, max_height):
829    """
830    Determine the wall normal vector for an image border collision.
831
832    Parameters:
833    - position (tuple): <br>
834        Current position (x, y).
835    - max_width (int): <br>
836        Image width.
837    - max_height (int): <br>
838        Image height.
839
840    Returns:
841    - tuple: 
842        Border wall vector corresponding to the collision side.
843    """
844    # print(f"got {position=}")
845    if position[0] < 0:
846        return (0, 1)
847    elif position[0] >= max_width:
848        return (0, 1)
849    elif position[1] < 0:
850        return (1, 0)
851    elif position[1] >= max_height:
852        return (1, 0)
853    else:
854        # should never reach that!
855        return (0, 0)

Determine the wall normal vector for an image border collision.

Parameters:

  • position (tuple):
    Current position (x, y).
  • max_width (int):
    Image width.
  • max_height (int):
    Image height.

Returns:

  • tuple: Border wall vector corresponding to the collision side.
def trace_beam( abs_position, img, direction_in_degree, wall_map, wall_values, img_border_also_collide=False, reflexion_order=3, should_scale=True, should_return_iterative=False, remove_iterative=True):
858def trace_beam(abs_position, 
859               img, 
860               direction_in_degree, 
861               wall_map,
862               wall_values, 
863               img_border_also_collide=False,
864               reflexion_order=3,
865               should_scale=True,
866               should_return_iterative=False,
867               remove_iterative=True):
868    """
869    Trace a ray (beam) through an image with walls and reflections.
870
871    The beam starts from a given position and follows a direction until it hits
872    a wall or border. On collisions, reflections are computed using wall normals.
873
874    Parameters:
875    - abs_position (tuple): <br>
876        Starting position (x, y) of the beam.
877    - img (numpy.ndarray): <br>
878        Input image or segmentation map.
879    - direction_in_degree (float): <br>
880        Initial direction angle of the beam.
881    - wall_map (numpy.ndarray): <br>
882        Map containing wall orientations in degrees.
883    - wall_values (list): <br>
884        List of pixel values representing walls.
885    - img_border_also_collide (bool, optional): <br>
886        Whether the image border acts as a collider (default: False).
887    - reflexion_order (int, optional): <br>
888        Number of allowed reflections (default: 3).
889    - should_scale (bool, optional): <br>
890        Whether to normalize positions to [0, 1] (default: True).
891    - should_return_iterative (bool, optional): <br>
892        Whether to return a RayIterator for step-by-step analysis (default: False).
893    - remove_iterative (bool, optional): <br>
894        Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.
895
896    Returns:
897    - list: 
898        Nested list structure representing the traced ray and its reflections. 
899        Format: ray[beam][point] = (x, y)
900    """
901    reflexion_order += 1  # Reflexion Order == 0 means, no reflections, therefore only 1 loop
902    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
903
904    ray = []
905    if should_return_iterative or not remove_iterative:
906        ray_iterator = RayIterator()
907
908    cur_target_abs_position = abs_position
909    cur_direction_in_degree = direction_in_degree % 360
910
911    for cur_depth in range(reflexion_order):
912        # print(f"(Reflexion Order '{cur_depth}') {ray=}")
913        if should_scale:
914            current_ray_line = [normalize_point(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
915        else:
916            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
917        if should_return_iterative or not remove_iterative:
918            ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
919
920        last_abs_position = cur_target_abs_position
921
922        # calculate a target line to update the pixels
923        #   target vector
924        dx = math.cos(math.radians(cur_direction_in_degree))
925        dy = math.sin(math.radians(cur_direction_in_degree))
926        target_line = [cur_target_abs_position[0], cur_target_abs_position[1], cur_target_abs_position[0], cur_target_abs_position[1]]
927        while (0 <= target_line[2] <= IMG_WIDTH) and (0 <= target_line[3] <= IMG_HEIGHT):
928            target_line[2] += 0.01 * dx
929            target_line[3] += 0.01 * dy
930
931        # update current ray
932        current_position = cur_target_abs_position
933        while True:
934            # update position
935            current_position = update_pixel_position(direction_in_degree=cur_direction_in_degree, cur_position=current_position, target_line=target_line)
936        # for current_position in get_all_pixel_coordinates_in_between(current_position[0], current_position[1], target_line[2], target_line[3]):
937        #     last_position_saved = False
938
939            # check if ray is at end
940            if not (0 <= current_position[0] < IMG_WIDTH and 0 <= current_position[1] < IMG_HEIGHT):
941                ray += [current_ray_line]
942
943                if img_border_also_collide:
944                     # get reflection angle
945                    wall_vector = get_img_border_vector(position=current_position, 
946                                                           max_width=IMG_WIDTH, 
947                                                           max_height=IMG_HEIGHT)
948
949                    # calc new direct vector
950                    new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
951                    new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
952
953                    # start new beam calculation
954                    cur_target_abs_position = last_abs_position
955                    cur_direction_in_degree = new_direction_in_degree
956                    break
957                else:
958                    if should_return_iterative:
959                        return ray_iterator
960                    return ray
961
962            next_pixel = img[int(current_position[1]), int(current_position[0])]
963
964            # check if hit building
965            if float(next_pixel) in wall_values:
966                last_abs_position = (current_position[0], current_position[1])
967                ray += [current_ray_line]
968
969                # get building wall reflection angle
970                building_angle = wall_map[int(current_position[1]), int(current_position[0])]
971                if building_angle == np.inf:
972                    raise Exception("Got inf value from Wall-Map.")
973                wall_vector = degree_to_vector(building_angle)
974
975                # calc new direct vector
976                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
977                new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
978
979                # start new beam calculation
980                cur_target_abs_position = last_abs_position
981                cur_direction_in_degree = new_direction_in_degree
982                break
983            else:
984                # update current ray
985                if should_scale:
986                    current_ray_line += [normalize_point(x=current_position[0], y=current_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
987                else:
988                    current_ray_line += [(current_position[0], current_position[1])]
989                if should_return_iterative or not remove_iterative:
990                    ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
991                last_abs_position = (current_position[0], current_position[1])
992    
993    if should_return_iterative:
994        return ray_iterator
995    return ray

Trace a ray (beam) through an image with walls and reflections.

The beam starts from a given position and follows a direction until it hits a wall or border. On collisions, reflections are computed using wall normals.

Parameters:

  • abs_position (tuple):
    Starting position (x, y) of the beam.
  • img (numpy.ndarray):
    Input image or segmentation map.
  • direction_in_degree (float):
    Initial direction angle of the beam.
  • wall_map (numpy.ndarray):
    Map containing wall orientations in degrees.
  • wall_values (list):
    List of pixel values representing walls.
  • img_border_also_collide (bool, optional):
    Whether the image border acts as a collider (default: False).
  • reflexion_order (int, optional):
    Number of allowed reflections (default: 3).
  • should_scale (bool, optional):
    Whether to normalize positions to [0, 1] (default: True).
  • should_return_iterative (bool, optional):
    Whether to return a RayIterator for step-by-step analysis (default: False).
  • remove_iterative (bool, optional):
    Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.

Returns:

  • list: Nested list structure representing the traced ray and its reflections. Format: ray[beam][point] = (x, y)
def trace_beam_with_DDA( abs_position, img, direction_in_degree, wall_map, wall_values, img_border_also_collide=False, reflexion_order=3, should_scale=True, should_return_iterative=False, remove_iterative=True):
 999def trace_beam_with_DDA(abs_position, 
1000               img, 
1001               direction_in_degree, 
1002               wall_map,
1003               wall_values, 
1004               img_border_also_collide=False,
1005               reflexion_order=3,
1006               should_scale=True,
1007               should_return_iterative=False,
1008               remove_iterative=True):
1009    """
1010    Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer)
1011    algorithm with precise collision points and physically accurate reflections.
1012
1013    The beam starts at a given floating-point position and marches through the grid
1014    until it intersects a wall or exits the image. For each collision, the exact
1015    hit position is computed using the ray Parameters t_hit, ensuring that reflected
1016    segments contain meaningful geometry rather than single-point artifacts.
1017    Reflections are computed using wall normals derived from the `wall_map`.
1018
1019    Parameters:
1020    - abs_position (tuple of float): <br>
1021        Starting position (x, y) of the beam in absolute pixel space.
1022    - img (numpy.ndarray): <br>
1023        2D array representing the scene. Pixel values listed in `wall_values`
1024        are treated as solid walls.
1025    - direction_in_degree (float): <br>
1026        Initial direction of the beam in degrees (0° = right, 90° = down).
1027    - wall_map (numpy.ndarray): <br>
1028        A map storing wall orientations in degrees for each pixel marked as a wall.
1029        These angles define the wall normals used for reflection.
1030    - wall_values (list, tuple, set, float, optional): <br>
1031        Pixel values identifying walls. Any pixel in this list causes a collision.
1032        If None, pixel value 0.0 is treated as a wall.
1033    - img_border_also_collide (bool, optional): <br>
1034        If True, the image borders behave like reflective walls. If False,
1035        the ray terminates when leaving the image. Default: False.
1036    - reflexion_order (int, optional): <br>
1037        Maximum number of reflections. The ray can rebound this many times before
1038        the function terminates. Default: 3.
1039    - should_scale (bool, optional): <br>
1040        If True, all emitted points (x, y) are normalized to [0, 1] range.
1041        Otherwise absolute pixel positions are returned. Default: True.
1042    - should_return_iterative (bool, optional): <br>
1043        Whether to return a RayIterator for step-by-step analysis (default: False).
1044    - remove_iterative (bool, optional): <br>
1045        Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.
1046
1047    Returns:
1048    - list: 
1049        Nested list structure representing the traced ray and its reflections. 
1050        Format: ray[beam][point] = (x, y)
1051    """
1052    reflexion_order += 1
1053    IMG_WIDTH, IMG_HEIGHT = get_width_height(img)
1054
1055    ray = []
1056    if should_return_iterative or not remove_iterative:
1057        ray_iterator = RayIterator()
1058
1059    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
1060    cur_direction_in_degree = direction_in_degree % 360
1061
1062    # Normalize wall_values to set of floats
1063    if wall_values is None:
1064        wall_values_set = {0.0}
1065    elif isinstance(wall_values, (list, tuple, set)):
1066        for idx, v in enumerate(wall_values):
1067            if idx == 0:
1068                wall_values_set = {float(v)}
1069            else:
1070                wall_values_set.add(float(v))
1071    else:
1072        wall_values_set = {float(wall_values)}
1073
1074    # go through every reflection -> will early stop if hitting a wall (if wall-bouncing is deactivated)
1075    for cur_depth in range(reflexion_order):
1076        if should_scale:
1077            current_ray_line = [normalize_point(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
1078        else:
1079            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
1080        if should_return_iterative or not remove_iterative:
1081            ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1082
1083        last_abs_position = cur_target_abs_position
1084
1085        # direction
1086        rad = math.radians(cur_direction_in_degree)
1087        dx = math.cos(rad)
1088        dy = math.sin(rad)
1089
1090        eps = 1e-12
1091        if abs(dx) < eps: dx = 0.0
1092        if abs(dy) < eps: dy = 0.0
1093
1094        # start float pos and starting cell
1095        x0 = float(cur_target_abs_position[0])
1096        y0 = float(cur_target_abs_position[1])
1097        cell_x = int(math.floor(x0))
1098        cell_y = int(math.floor(y0))
1099
1100        # outside start -> handle border/reflection/exit
1101        if not (0 <= x0 < IMG_WIDTH and 0 <= y0 < IMG_HEIGHT):
1102            ray += [current_ray_line]
1103
1104            if img_border_also_collide:
1105                wall_vector = get_img_border_vector(position=(x0, y0), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
1106                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1107                cur_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1108                cur_target_abs_position = last_abs_position
1109                continue
1110            else:
1111                if should_return_iterative:
1112                    return ray_iterator
1113                return ray
1114
1115        # DDA parameters
1116        tDeltaX = math.inf if dx == 0.0 else abs(1.0 / dx)
1117        tDeltaY = math.inf if dy == 0.0 else abs(1.0 / dy)
1118
1119        if dx > 0:
1120            stepX = 1
1121            nextBoundaryX = cell_x + 1.0
1122            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
1123        elif dx < 0:
1124            stepX = -1
1125            nextBoundaryX = cell_x * 1.0  # left boundary of cell
1126            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
1127        else:
1128            stepX = 0
1129            tMaxX = math.inf
1130
1131        if dy > 0:
1132            stepY = 1
1133            nextBoundaryY = cell_y + 1.0
1134            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
1135        elif dy < 0:
1136            stepY = -1
1137            nextBoundaryY = cell_y * 1.0
1138            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
1139        else:
1140            stepY = 0
1141            tMaxY = math.inf
1142
1143        max_steps = (IMG_WIDTH + IMG_HEIGHT) * 6
1144        steps = 0
1145        last_position_saved = False
1146
1147        # immediate-start-in-wall handling
1148        if 0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT:
1149            start_pixel = float(img[cell_y, cell_x])
1150            if start_pixel in wall_values_set:
1151                # compute a collision point precisely at start (we'll use origin)
1152                # add collision point (start) and reflect
1153                hit_x = x0
1154                hit_y = y0
1155                if should_scale:
1156                    current_ray_line += [normalize_point(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT)]
1157                else:
1158                    current_ray_line += [(hit_x, hit_y)]
1159                if should_return_iterative or not remove_iterative:
1160                    ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1161                ray += [current_ray_line]
1162
1163                building_angle = float(wall_map[cell_y, cell_x])
1164                if not np.isfinite(building_angle):
1165                    raise Exception("Got non-finite value from Wall-Map.")
1166                wall_vector = degree_to_vector(building_angle)
1167                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1168                cur_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1169                ndx, ndy = new_direction[0], new_direction[1]
1170                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1171                continue
1172
1173        # DDA main loop
1174        while steps < max_steps:
1175            steps += 1
1176
1177            # choose axis to step and capture t_hit (distance along ray to boundary)
1178            if tMaxX < tMaxY:
1179                t_hit = tMaxX
1180                # step in x
1181                cell_x += stepX
1182                tMaxX += tDeltaX
1183            else:
1184                t_hit = tMaxY
1185                # step in y
1186                cell_y += stepY
1187                tMaxY += tDeltaY
1188
1189            # compute exact collision position along ray from origin (x0,y0)
1190            hit_x = x0 + dx * t_hit
1191            hit_y = y0 + dy * t_hit
1192
1193            # For recording the traversal we can append intermediate cell centers encountered so far.
1194            # But more importantly, append the collision point to the current segment BEFORE storing it.
1195            if should_scale:
1196                current_ray_line += [normalize_point(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT)]
1197            else:
1198                current_ray_line += [(hit_x, hit_y)]
1199            if should_return_iterative or not remove_iterative:
1200                ray_iterator.add_iteration([copy.deepcopy(ray)+[current_ray_line]])
1201
1202            # Now check if we've left the image bounds (cell_x, cell_y refer to the new cell we stepped into)
1203            if not (0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT):
1204                ray += [current_ray_line]
1205                last_position_saved = True
1206
1207                if img_border_also_collide:
1208                    wall_vector = get_img_border_vector(position=(cell_x, cell_y), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
1209                    new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1210                    new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1211                    # start next ray from last in-image position (hit_x, hit_y) nudged slightly
1212                    ndx, ndy = new_direction[0], new_direction[1]
1213                    cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1214                    cur_direction_in_degree = new_direction_in_degree
1215                    break
1216                else:
1217                    if should_return_iterative:
1218                        return ray_iterator
1219                    return ray
1220
1221            # sample the pixel in the cell we stepped into
1222            next_pixel = float(img[cell_y, cell_x])
1223            if next_pixel in wall_values_set:
1224                # we hit a wall — collision point already appended
1225                last_abs_position = (hit_x, hit_y)
1226                ray += [current_ray_line]
1227                last_position_saved = True
1228
1229                building_angle = float(wall_map[cell_y, cell_x])
1230                if not np.isfinite(building_angle):
1231                    raise Exception("Got non-finite value from Wall-Map.")
1232                wall_vector = degree_to_vector(building_angle)
1233
1234                new_direction = calc_reflection(collide_vector=degree_to_vector(cur_direction_in_degree), wall_vector=wall_vector)
1235                new_direction_in_degree = vector_to_degree(new_direction[0], new_direction[1])
1236
1237                # start next beam from collision point nudged outwards
1238                ndx, ndy = new_direction[0], new_direction[1]
1239                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
1240                cur_direction_in_degree = new_direction_in_degree
1241                break
1242            else:
1243                # no hit -> continue marching; also add a representative point in the traversed cell (optional)
1244                # we already appended the exact hit point for this step; for smoother lines you may add cell center too
1245                last_abs_position = (hit_x, hit_y)
1246                # continue
1247
1248        # end DDA loop
1249        if not last_position_saved:
1250            ray.append(current_ray_line)
1251
1252    if should_return_iterative:
1253        return ray_iterator
1254    return ray

Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer) algorithm with precise collision points and physically accurate reflections.

The beam starts at a given floating-point position and marches through the grid until it intersects a wall or exits the image. For each collision, the exact hit position is computed using the ray Parameters t_hit, ensuring that reflected segments contain meaningful geometry rather than single-point artifacts. Reflections are computed using wall normals derived from the wall_map.

Parameters:

  • abs_position (tuple of float):
    Starting position (x, y) of the beam in absolute pixel space.
  • img (numpy.ndarray):
    2D array representing the scene. Pixel values listed in wall_values are treated as solid walls.
  • direction_in_degree (float):
    Initial direction of the beam in degrees (0° = right, 90° = down).
  • wall_map (numpy.ndarray):
    A map storing wall orientations in degrees for each pixel marked as a wall. These angles define the wall normals used for reflection.
  • wall_values (list, tuple, set, float, optional):
    Pixel values identifying walls. Any pixel in this list causes a collision. If None, pixel value 0.0 is treated as a wall.
  • img_border_also_collide (bool, optional):
    If True, the image borders behave like reflective walls. If False, the ray terminates when leaving the image. Default: False.
  • reflexion_order (int, optional):
    Maximum number of reflections. The ray can rebound this many times before the function terminates. Default: 3.
  • should_scale (bool, optional):
    If True, all emitted points (x, y) are normalized to [0, 1] range. Otherwise absolute pixel positions are returned. Default: True.
  • should_return_iterative (bool, optional):
    Whether to return a RayIterator for step-by-step analysis (default: False).
  • remove_iterative (bool, optional):
    Whether to optimize (ignore) ray-iterator -> only in use if should_return_iterative is False.

Returns:

  • list: Nested list structure representing the traced ray and its reflections. Format: ray[beam][point] = (x, y)
def trace_beams( rel_position, img_src, directions_in_degree, wall_values, wall_thickness=0, img_border_also_collide=False, reflexion_order=3, should_scale_rays=True, should_scale_img=True, use_dda=True, iterative_tracking=False, iterative_steps=None, parallelization=-1, parallelization_method='processes', use_numba_compilation=True, ignore_iterative_optimization=True):
1258def trace_beams(rel_position, 
1259                img_src,
1260                directions_in_degree, 
1261                wall_values, 
1262                wall_thickness=0,
1263                img_border_also_collide=False,
1264                reflexion_order=3,
1265                should_scale_rays=True,
1266                should_scale_img=True,
1267                use_dda=True,
1268                iterative_tracking=False,
1269                iterative_steps=None,
1270                parallelization=-1,
1271                parallelization_method="processes",  # "threads", "processes"
1272                use_numba_compilation=True,
1273                ignore_iterative_optimization=True):
1274    """
1275    Trace multiple rays (beams) from a single position through an image with walls and reflections.
1276
1277    Each beam starts from a given relative position and follows its assigned direction
1278    until it collides with a wall or image border. On collisions, reflections are
1279    computed based on local wall normals extracted from the image.
1280
1281    Parameters:
1282    - rel_position (tuple): <br>
1283        Relative starting position (x, y) in normalized coordinates [0-1].
1284    - img_src (str or numpy.ndarray): <br>
1285        Input image (array or file path) used for wall detection.
1286    - directions_in_degree (list): <br>
1287        List of beam direction angles (in degrees).
1288    - wall_values (list or float or None): <br>
1289        Pixel values representing walls or obstacles.
1290    - wall_thickness (int, optional): <br>
1291        Thickness (in pixels) of detected walls (default: 0).
1292    - img_border_also_collide (bool, optional): <br>
1293        Whether image borders act as colliders (default: False).
1294    - reflexion_order (int, optional): <br>
1295        Number of allowed reflections per beam (default: 3).
1296    - should_scale_rays (bool, optional): <br>
1297        Whether to normalize ray coordinates to [0, 1] (default: True).
1298    - should_scale_img (bool, optional): <br>
1299        Whether to scale the input image before wall detection (default: True).
1300    - use_dda (bool, optional): <br>
1301        Whether to use the DDA-based ray tracing method (default: True).
1302    - iterative_tracking (bool, optional): 
1303        Whether to return a RayIterator for step-by-step analysis (default: False).
1304    - iterative_steps (int, optional):<br>
1305        Number of steps for iterative reduction if using iterative tracking. `None` for all steps.
1306    - parallelization (int, optional):<br>
1307        The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
1308    - parallelization_method (str, optional):<br>
1309        Method to use for parallelization (as soft condition) -> "threads" or "processes"
1310    - use_numba_compilation (bool, optional):<br>
1311        Whether to use the compiled (to machine code) version of compute heavy functions.
1312    - ignore_iterative_optimization (bool, optional): <br>
1313        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1314
1315    Returns:
1316    - list: <br>
1317        Nested list of traced beams and their reflection segments. 
1318        Format: rays[beam][segment][point] = (x, y)
1319    """ 
1320    if use_numba_compilation and iterative_tracking:
1321        print("[WARNING] Iterative Return deactivates the use of numba.")
1322        use_numba_compilation = False
1323
1324    if isinstance(img_src, np.ndarray):
1325        img = img_src
1326    else:
1327        img = img_open(src=img_src, should_scale=should_scale_img, should_print=False)
1328    IMG_WIDTH, IMG_HEIGHT =  get_width_height(img)
1329    abs_position = (rel_position[0] * IMG_WIDTH, rel_position[1] * IMG_HEIGHT)
1330
1331    if wall_values is not None and type(wall_values) not in [list, tuple]:
1332        wall_values = [wall_values]
1333    wall_map = get_wall_map(img=img, 
1334                            wall_values=wall_values, 
1335                            thickness=wall_thickness,
1336                            use_numba_compilation=use_numba_compilation)
1337    
1338    if wall_values is None:
1339        wall_values = [0.0]
1340
1341    # create function plan
1342    ray_planning = []
1343
1344    for direction_in_degree in directions_in_degree:
1345        if use_dda:
1346            if use_numba_compilation:
1347                ray_tracing_func = trace_beam_with_DDA_numba
1348            else:
1349                ray_tracing_func = trace_beam_with_DDA
1350        else:
1351            if use_numba_compilation:
1352                ray_tracing_func = trace_beam_numba
1353            else:
1354                ray_tracing_func = trace_beam
1355
1356        ray_planning.append(lambda dir=direction_in_degree: ray_tracing_func(
1357                                        abs_position=abs_position, 
1358                                        img=img,  
1359                                        direction_in_degree=dir,
1360                                        wall_map=wall_map,
1361                                        wall_values=np.array(wall_values, dtype=np.float64) if (use_numba_compilation and use_dda) else wall_values,
1362                                        img_border_also_collide=img_border_also_collide, 
1363                                        reflexion_order=reflexion_order,
1364                                        should_scale=should_scale_rays,
1365                                        should_return_iterative=iterative_tracking,
1366                                        remove_iterative=ignore_iterative_optimization
1367                                    )
1368        )
1369        
1370    if iterative_tracking:
1371        rays = RayIterator()
1372    else:
1373        rays = []
1374
1375    # compute
1376    if parallelization == 0:
1377
1378        for cur_ray_planning in ray_planning:
1379            cur_ray_result = cur_ray_planning()
1380            if use_numba_compilation:
1381                cur_ray_result = numba_to_py(cur_ray_result)
1382        
1383            if iterative_tracking:
1384                rays.add_rays(cur_ray_result)
1385            else:
1386                rays.append(cur_ray_result)
1387    else:
1388        result_rays = Parallel(n_jobs=parallelization, 
1389                                # backend="threading" if use_numba_compilation else "loky",     # process-based
1390                                prefer="threads" if use_numba_compilation else parallelization_method,
1391                                return_as="generator",
1392                                batch_size=1        # because of unequal ray lengths
1393                                )(
1394                                    delayed(ray_func)() for ray_func in ray_planning
1395                                )
1396        
1397        for cur_ray_result in result_rays:
1398            if use_numba_compilation:
1399                cur_ray_result = numba_to_py(cur_ray_result)
1400
1401            if iterative_tracking:
1402                rays.add_rays(cur_ray_result)
1403            else:
1404                rays.append(cur_ray_result)
1405
1406    if iterative_tracking and iterative_steps is not None:
1407        rays.reduce_rays_iteratively(steps=iterative_steps)
1408
1409    return rays

Trace multiple rays (beams) from a single position through an image with walls and reflections.

Each beam starts from a given relative position and follows its assigned direction until it collides with a wall or image border. On collisions, reflections are computed based on local wall normals extracted from the image.

Parameters:

  • rel_position (tuple):
    Relative starting position (x, y) in normalized coordinates [0-1].
  • img_src (str or numpy.ndarray):
    Input image (array or file path) used for wall detection.
  • directions_in_degree (list):
    List of beam direction angles (in degrees).
  • wall_values (list or float or None):
    Pixel values representing walls or obstacles.
  • wall_thickness (int, optional):
    Thickness (in pixels) of detected walls (default: 0).
  • img_border_also_collide (bool, optional):
    Whether image borders act as colliders (default: False).
  • reflexion_order (int, optional):
    Number of allowed reflections per beam (default: 3).
  • should_scale_rays (bool, optional):
    Whether to normalize ray coordinates to [0, 1] (default: True).
  • should_scale_img (bool, optional):
    Whether to scale the input image before wall detection (default: True).
  • use_dda (bool, optional):
    Whether to use the DDA-based ray tracing method (default: True).
  • iterative_tracking (bool, optional): Whether to return a RayIterator for step-by-step analysis (default: False).
  • iterative_steps (int, optional):
    Number of steps for iterative reduction if using iterative tracking. None for all steps.
  • parallelization (int, optional):
    The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
  • parallelization_method (str, optional):
    Method to use for parallelization (as soft condition) -> "threads" or "processes"
  • use_numba_compilation (bool, optional):
    Whether to use the compiled (to machine code) version of compute heavy functions.
  • ignore_iterative_optimization (bool, optional):
    Whether to used optimized ignore iteration data if iterative_tracking is False.

Returns:

  • list:
    Nested list of traced beams and their reflection segments. Format: rays[beam][segment][point] = (x, y)
def scale_rays( rays, max_x=None, max_y=None, new_max_x=None, new_max_y=None, detailed_scaling=True):
1413def scale_rays(rays, 
1414               max_x=None, max_y=None, 
1415               new_max_x=None, new_max_y=None,
1416               detailed_scaling=True):
1417    """
1418    Scale ray coordinates between coordinate systems or image resolutions.
1419
1420    Optionally normalizes rays by old dimensions and rescales them to new ones.
1421    Can scale all points or just the start/end points of each beam.
1422
1423    Parameters:
1424    - rays (list): <br>
1425        Nested list of rays in the format rays[ray][beam][point] = (x, y).
1426    - max_x (float, optional): <br>
1427        Original maximum x-value for normalization.
1428    - max_y (float, optional): <br>
1429        Original maximum y-value for normalization.
1430    - new_max_x (float, optional): <br>
1431        New maximum x-value after rescaling.
1432    - new_max_y (float, optional): <br>
1433        New maximum y-value after rescaling.
1434    - detailed_scaling (bool, optional): <br>
1435        If True, scale every point in a beam; otherwise, only endpoints (default: True).
1436
1437    Returns:
1438    - list: <br>
1439        Scaled rays in the same nested format.
1440    """
1441    if isinstance(rays, RayIterator):
1442        rays.apply_and_update(lambda r: scale_rays(r, max_x=max_x, max_y=max_y, new_max_x=new_max_x, new_max_y=new_max_y, detailed_scaling=detailed_scaling))
1443        return rays
1444
1445    scaled_rays = []
1446    for ray in rays:
1447        scaled_ray = []
1448        for beams in ray:
1449            new_beams = copy.deepcopy(beams)
1450            if detailed_scaling:
1451                idx_to_process = list(range(len(beams)))
1452            else:
1453                idx_to_process = [0, len(beams)-1]
1454
1455            for idx in idx_to_process:
1456                x1, y1 = beams[idx] 
1457
1458                if max_x is not None and max_y is not None:
1459                    x1 /= max_x
1460                    y1 /= max_y
1461
1462                from_cache = (x1, y1)
1463                if new_max_x is not None and new_max_y is not None:
1464                    if x1 >= new_max_x/2:
1465                        print(f"[WARNING] Detected high values scaling. Are you sure you want to scale for example a ray with {x1} to a value like {x1*new_max_x}?")
1466                    if y1 >= new_max_y/2:
1467                        print(f"[WARNING] Detected high values scaling. Are you sure you want to scale for example a ray with {y1} to a value like {y1*new_max_y}?")
1468                    
1469                    x1 *= new_max_x
1470                    y1 *= new_max_y
1471
1472                new_beams[idx] = (x1, y1)
1473
1474            scaled_ray.append(new_beams)
1475        scaled_rays.append(scaled_ray)
1476    return scaled_rays

Scale ray coordinates between coordinate systems or image resolutions.

Optionally normalizes rays by old dimensions and rescales them to new ones. Can scale all points or just the start/end points of each beam.

Parameters:

  • rays (list):
    Nested list of rays in the format rays[ray][beam][point] = (x, y).
  • max_x (float, optional):
    Original maximum x-value for normalization.
  • max_y (float, optional):
    Original maximum y-value for normalization.
  • new_max_x (float, optional):
    New maximum x-value after rescaling.
  • new_max_y (float, optional):
    New maximum y-value after rescaling.
  • detailed_scaling (bool, optional):
    If True, scale every point in a beam; otherwise, only endpoints (default: True).

Returns:

  • list:
    Scaled rays in the same nested format.
def draw_rectangle_with_thickness(img, start_point, end_point, value, thickness=1):
1480def draw_rectangle_with_thickness(img, start_point, end_point, value, thickness=1):
1481    """
1482    Draw a filled or thick rectangle on an image.
1483
1484    Expands the given start and end points based on the desired thickness and
1485    clips coordinates to image bounds to avoid overflow.
1486
1487    Parameters:
1488    - img (numpy.ndarray): <br>
1489        Image array to draw on.
1490    - start_point (tuple): <br>
1491        Top-left corner of the rectangle (x, y).
1492    - end_point (tuple): <br>
1493        Bottom-right corner of the rectangle (x, y).
1494    - value (int or float): <br>
1495        Fill value or color intensity.
1496    - thickness (int, optional): <br>
1497        Rectangle border thickness; 
1498        if <= 0, the rectangle is filled (default: 1).
1499    """
1500    # Calculate the expansion -> "thickness"
1501    if thickness > 0:
1502        expand = thickness // 2
1503        x1, y1 = start_point[0] - expand, start_point[1] - expand
1504        x2, y2 = end_point[0] + expand, end_point[1] + expand
1505    else:
1506        # thickness <= 0 → filled rectangle, no expansion
1507        x1, y1 = start_point
1508        x2, y2 = end_point
1509
1510    # Clip coordinates to image bounds
1511    x1 = max(0, x1)
1512    y1 = max(0, y1)
1513    x2 = min(img.shape[1]-1, x2)
1514    y2 = min(img.shape[0]-1, y2)
1515
1516    cv2.rectangle(img, (x1, y1), (x2, y2), value, thickness=-1)

Draw a filled or thick rectangle on an image.

Expands the given start and end points based on the desired thickness and clips coordinates to image bounds to avoid overflow.

Parameters:

  • img (numpy.ndarray):
    Image array to draw on.
  • start_point (tuple):
    Top-left corner of the rectangle (x, y).
  • end_point (tuple):
    Bottom-right corner of the rectangle (x, y).
  • value (int or float):
    Fill value or color intensity.
  • thickness (int, optional):
    Rectangle border thickness; if <= 0, the rectangle is filled (default: 1).
def draw_line_or_point(img, start_point, end_point, fill_value, thickness):
1520def draw_line_or_point(img, start_point, end_point, fill_value, thickness):
1521    """
1522    Draw either a line or a single point on an image.
1523
1524    Determines whether to draw a point or a line based on whether the start and
1525    end coordinates are identical.
1526
1527    Parameters:
1528    - img (numpy.ndarray): <br>
1529        Image array to draw on.
1530    - start_point (tuple): <br>
1531        Starting pixel coordinate (x, y).
1532    - end_point (tuple): <br>
1533        Ending pixel coordinate (x, y).
1534    - fill_value (int or float): <br>
1535        Value or color used for drawing.
1536    - thickness (int): <br>
1537        Thickness of the line or point.
1538    """
1539    draw_point = (start_point == end_point)
1540
1541    if draw_point:
1542        draw_rectangle_with_thickness(img=img, start_point=start_point, end_point=end_point, value=fill_value, thickness=thickness)
1543    else:
1544        cv2.line(img, start_point, end_point, fill_value, thickness)

Draw either a line or a single point on an image.

Determines whether to draw a point or a line based on whether the start and end coordinates are identical.

Parameters:

  • img (numpy.ndarray):
    Image array to draw on.
  • start_point (tuple):
    Starting pixel coordinate (x, y).
  • end_point (tuple):
    Ending pixel coordinate (x, y).
  • fill_value (int or float):
    Value or color used for drawing.
  • thickness (int):
    Thickness of the line or point.
def draw_rays( rays, detail_draw=True, output_format='single_image', img_background=None, ray_value=255, ray_thickness=1, img_shape=(256, 256), dtype=<class 'float'>, standard_value=0, should_scale_rays_to_image=True, original_max_width=None, original_max_height=None, show_only_reflections=False):
1548def draw_rays(rays, detail_draw=True,
1549              output_format="single_image", # single_image, multiple_images, channels 
1550              img_background=None, ray_value=255, ray_thickness=1, 
1551              img_shape=(256, 256), dtype=float, standard_value=0,
1552              should_scale_rays_to_image=True, original_max_width=None, original_max_height=None,
1553              show_only_reflections=False):
1554    """
1555    Render rays onto an image or a set of images.
1556
1557    Each ray can be drawn in full detail (every point) or as straight lines between
1558    beam endpoints. Rays can be scaled to match image dimensions and drawn on a
1559    single image, multiple images, or separate channels.
1560
1561    Parameters:
1562    - rays (list): <br>
1563        Nested list of rays in the format rays[ray][beam][point] = (x, y).
1564    - detail_draw (bool, optional): <br>
1565        Whether to draw every point or just beam endpoints (default: True).
1566    - output_format (str, optional): <br>
1567        Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
1568    - img_background (numpy.ndarray, optional): <br>
1569        Background image; if None, a blank image is created.
1570    - ray_value (int, float, list, or numpy.ndarray, optional): <br>
1571        Pixel intensity or color per reflection (default: 255).
1572    - ray_thickness (int, optional): <br>
1573        Thickness of the drawn lines or points (default: 1).
1574    - img_shape (tuple, optional): <br>
1575        Shape of the generated image if no background is given (default: (256, 256)).
1576    - dtype (type, optional): <br>
1577        Data type for the output image (default: float).
1578    - standard_value (int or float, optional): <br>
1579        Background fill value (default: 0).
1580    - should_scale_rays_to_image (bool, optional): <br>
1581        Whether to scale ray coordinates to match the image (default: True).
1582    - original_max_width (float, optional): <br>
1583        Original image width before scaling.
1584    - original_max_height (float, optional): <br>
1585        Original image height before scaling.
1586    - show_only_reflections (bool, optional): <br>
1587        If True, draws only reflected beams (default: False).
1588
1589    Returns:
1590        numpy.ndarray or list: <br>
1591            - Single image if output_format == "single_image" or "channels".
1592            - List of images if output_format == "multiple_images".
1593    """
1594    if isinstance(rays, RayIterator):
1595        imgs = rays.apply_and_return(lambda r: draw_rays(r, detail_draw=detail_draw,
1596                                                            output_format=output_format,
1597                                                            img_background=img_background,
1598                                                            ray_value=ray_value,
1599                                                            ray_thickness=ray_thickness,
1600                                                            img_shape=img_shape,
1601                                                            dtype=dtype,
1602                                                            standard_value=standard_value,
1603                                                            should_scale_rays_to_image=should_scale_rays_to_image,
1604                                                            original_max_width=original_max_width,
1605                                                            original_max_height=original_max_height,
1606                                                            show_only_reflections=show_only_reflections))
1607        return imgs
1608
1609    # prepare background image
1610    if img_background is None:
1611        img = np.full(shape=img_shape, fill_value=dtype(standard_value), dtype=dtype)
1612    else:
1613        img = img_background.copy()
1614
1615    # rescale rays to fit inside image bounds if desired
1616    height, width = img.shape[:2]
1617    # print(f"{height, width=}")
1618    # print(f"{(original_max_width, original_max_height)=}")
1619    if should_scale_rays_to_image:
1620        rays = scale_rays(rays, max_x=original_max_width, max_y=original_max_height, new_max_x=width-1, new_max_y=height-1, detailed_scaling=detail_draw)
1621
1622    nrays = len(rays)
1623    if output_format == "channels":
1624        img = np.repeat(img[..., None], nrays, axis=-1)
1625        # img_shape += (nrays, )
1626        # img = np.full(shape=img_shape, fill_value=dtype(standard_value), dtype=dtype)
1627    elif output_format == "multiple_images":
1628        imgs = [np.copy(img) for _ in range(nrays)]
1629
1630    # draw on image
1631    for idx, ray in enumerate(rays):
1632        for reflexion_order, beam_points in enumerate(ray):
1633            
1634            if detail_draw:
1635                lines = []
1636                for cur_point in range(0, len(beam_points)):
1637                    start_point = tuple(map(lambda x:int(x), beam_points[cur_point]))
1638                    end_point = tuple(map(lambda x:int(x), beam_points[cur_point]))
1639                    # end_point = tuple(map(lambda x:int(x), beam_points[cur_point+1]))
1640                    # -> if as small lines then in range: range(0, len(beam_points)-1)
1641                    lines.append((start_point, end_point, reflexion_order))
1642            else:
1643                start_point = tuple(map(lambda x:int(x), beam_points[0]))
1644                end_point = tuple(map(lambda x:int(x), beam_points[-1]))
1645                lines = [(start_point, end_point, reflexion_order)]
1646            
1647            for start_point, end_point, reflexion_order in lines:
1648
1649                if show_only_reflections and reflexion_order == 0:
1650                    continue
1651
1652                # get cur ray value
1653                if type(ray_value) in [list, tuple, np.ndarray]:
1654                    # if we print without the first line: first value (index 0) belkongs to the reflexion order 1
1655                    cur_reflexion_index = reflexion_order-1 if show_only_reflections else reflexion_order
1656                    cur_ray_value = ray_value[min(len(ray_value)-1, cur_reflexion_index)]
1657                else:
1658                    cur_ray_value = ray_value
1659
1660                if output_format == "channels":
1661                    layer = np.ascontiguousarray(img[..., idx])
1662                    draw_line_or_point(img=layer, start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1663                    img[..., idx] = layer
1664                elif output_format == "multiple_images":
1665                    draw_line_or_point(img=imgs[idx], start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1666                else:
1667                    draw_line_or_point(img=img, start_point=start_point, end_point=end_point, fill_value=cur_ray_value, thickness=ray_thickness)
1668
1669
1670    if output_format == "multiple_images":
1671        return imgs
1672    else:
1673        return img

Render rays onto an image or a set of images.

Each ray can be drawn in full detail (every point) or as straight lines between beam endpoints. Rays can be scaled to match image dimensions and drawn on a single image, multiple images, or separate channels.

Parameters:

  • rays (list):
    Nested list of rays in the format rays[ray][beam][point] = (x, y).
  • detail_draw (bool, optional):
    Whether to draw every point or just beam endpoints (default: True).
  • output_format (str, optional):
    Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
  • img_background (numpy.ndarray, optional):
    Background image; if None, a blank image is created.
  • ray_value (int, float, list, or numpy.ndarray, optional):
    Pixel intensity or color per reflection (default: 255).
  • ray_thickness (int, optional):
    Thickness of the drawn lines or points (default: 1).
  • img_shape (tuple, optional):
    Shape of the generated image if no background is given (default: (256, 256)).
  • dtype (type, optional):
    Data type for the output image (default: float).
  • standard_value (int or float, optional):
    Background fill value (default: 0).
  • should_scale_rays_to_image (bool, optional):
    Whether to scale ray coordinates to match the image (default: True).
  • original_max_width (float, optional):
    Original image width before scaling.
  • original_max_height (float, optional):
    Original image height before scaling.
  • show_only_reflections (bool, optional):
    If True, draws only reflected beams (default: False).

Returns: numpy.ndarray or list:
- Single image if output_format == "single_image" or "channels". - List of images if output_format == "multiple_images".

def trace_and_draw_rays( rel_position, img_src, directions_in_degree, wall_values, wall_thickness=0, img_border_also_collide=False, reflexion_order=3, should_scale_rays=True, should_scale_img=True, use_dda=True, iterative_tracking=False, iterative_steps=None, parallelization=-1, parallelization_method='processes', use_numba_compilation=True, ignore_iterative_optimization=True, detail_draw=True, output_format='single_image', img_background=None, ray_value=255, ray_thickness=1, img_shape=(256, 256), dtype=<class 'float'>, standard_value=0, should_scale_rays_to_image=True, original_max_width=None, original_max_height=None, show_only_reflections=False):
1677def trace_and_draw_rays(rel_position, 
1678                        img_src,
1679                        directions_in_degree, 
1680                        wall_values, 
1681                        wall_thickness=0,
1682                        img_border_also_collide=False,
1683                        reflexion_order=3,
1684                        should_scale_rays=True,
1685                        should_scale_img=True,
1686                        use_dda=True,
1687                        iterative_tracking=False,
1688                        iterative_steps=None,
1689                        parallelization=-1,
1690                        parallelization_method="processes",  # "threads", "processes"
1691                        use_numba_compilation=True,
1692                        ignore_iterative_optimization=True,
1693                        detail_draw=True,
1694                        output_format="single_image", # single_image, multiple_images, channels 
1695                        img_background=None, 
1696                        ray_value=255, 
1697                        ray_thickness=1, 
1698                        img_shape=(256, 256), 
1699                        dtype=float, 
1700                        standard_value=0,
1701                        should_scale_rays_to_image=True, 
1702                        original_max_width=None, 
1703                        original_max_height=None,
1704                        show_only_reflections=False):
1705    """
1706    Trace multiple rays (beams) from a single position through an image with walls and reflections AND 
1707    render rays onto an image or a set of images.
1708
1709    Calls internally `trace_beams` and then `draw_rays`.
1710
1711    Parameters:
1712    - rel_position (tuple): <br>
1713        Relative starting position (x, y) in normalized coordinates [0-1].
1714    - img_src (str or numpy.ndarray): <br>
1715        Input image (array or file path) used for wall detection.
1716    - directions_in_degree (list): <br>
1717        List of beam direction angles (in degrees).
1718    - wall_values (list or float or None): <br>
1719        Pixel values representing walls or obstacles.
1720    - wall_thickness (int, optional): <br>
1721        Thickness (in pixels) of detected walls (default: 0).
1722    - img_border_also_collide (bool, optional): <br>
1723        Whether image borders act as colliders (default: False).
1724    - reflexion_order (int, optional): <br>
1725        Number of allowed reflections per beam (default: 3).
1726    - should_scale_rays (bool, optional): <br>
1727        Whether to normalize ray coordinates to [0, 1] (default: True).
1728    - should_scale_img (bool, optional): <br>
1729        Whether to scale the input image before wall detection (default: True).
1730    - use_dda (bool, optional): <br>
1731        Whether to use the DDA-based ray tracing method (default: True).
1732    - iterative_tracking (bool, optional): 
1733        Whether to return a RayIterator for step-by-step analysis (default: False).
1734    - iterative_steps (int, optional):<br>
1735        Number of steps for iterative reduction if using iterative tracking. `None` for all steps.
1736    - parallelization (int, optional):<br>
1737        The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
1738    - parallelization_method (str, optional):<br>
1739        Method to use for parallelization (as soft condition) -> "threads" or "processes"
1740    - use_numba_compilation (bool, optional):<br>
1741        Whether to use the compiled (to machine code) version of compute heavy functions.
1742    - ignore_iterative_optimization (bool, optional): <br>
1743        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1744    - detail_draw (bool, optional): <br>
1745        Whether to draw every point or just beam endpoints (default: True).
1746    - output_format (str, optional): <br>
1747        Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
1748    - img_background (numpy.ndarray, optional): <br>
1749        Background image; if None, a blank image is created.
1750    - ray_value (int, float, list, or numpy.ndarray, optional): <br>
1751        Pixel intensity or color per reflection (default: 255).
1752    - ray_thickness (int, optional): <br>
1753        Thickness of the drawn lines or points (default: 1).
1754    - img_shape (tuple, optional): <br>
1755        Shape of the generated image if no background is given (default: (256, 256)).
1756    - dtype (type, optional): <br>
1757        Data type for the output image (default: float).
1758    - standard_value (int or float, optional): <br>
1759        Background fill value (default: 0).
1760    - should_scale_rays_to_image (bool, optional): <br>
1761        Whether to scale ray coordinates to match the image (default: True).
1762    - original_max_width (float, optional): <br>
1763        Original image width before scaling.
1764    - original_max_height (float, optional): <br>
1765        Original image height before scaling.
1766    - show_only_reflections (bool, optional): <br>
1767        If True, draws only reflected beams (default: False).
1768
1769    Returns:
1770        numpy.ndarray or list:
1771            - Single image if output_format == "single_image" or "channels".
1772            - List of images if output_format == "multiple_images".
1773    """
1774    rays = trace_beams(rel_position, 
1775                        img_src,
1776                        directions_in_degree, 
1777                        wall_values, 
1778                        wall_thickness=wall_thickness,
1779                        img_border_also_collide=img_border_also_collide,
1780                        reflexion_order=reflexion_order,
1781                        should_scale_rays=should_scale_rays,
1782                        should_scale_img=should_scale_img,
1783                        use_dda=use_dda,
1784                        iterative_tracking=iterative_tracking,
1785                        iterative_steps=iterative_steps,
1786                        parallelization=parallelization,
1787                        parallelization_method=parallelization_method,
1788                        use_numba_compilation=use_numba_compilation,
1789                        ignore_iterative_optimization=ignore_iterative_optimization)
1790    
1791    return draw_rays(rays, 
1792                     detail_draw=detail_draw,
1793                     output_format=output_format,
1794                     img_background=img_background, 
1795                     ray_value=ray_value, 
1796                     ray_thickness=ray_thickness, 
1797                     img_shape=img_shape, 
1798                     dtype=dtype, 
1799                     standard_value=standard_value,
1800                     should_scale_rays_to_image=should_scale_rays_to_image, 
1801                     original_max_width=original_max_width, 
1802                     original_max_height=original_max_height,
1803                     show_only_reflections=show_only_reflections)

Trace multiple rays (beams) from a single position through an image with walls and reflections AND render rays onto an image or a set of images.

Calls internally trace_beams and then draw_rays.

Parameters:

  • rel_position (tuple):
    Relative starting position (x, y) in normalized coordinates [0-1].
  • img_src (str or numpy.ndarray):
    Input image (array or file path) used for wall detection.
  • directions_in_degree (list):
    List of beam direction angles (in degrees).
  • wall_values (list or float or None):
    Pixel values representing walls or obstacles.
  • wall_thickness (int, optional):
    Thickness (in pixels) of detected walls (default: 0).
  • img_border_also_collide (bool, optional):
    Whether image borders act as colliders (default: False).
  • reflexion_order (int, optional):
    Number of allowed reflections per beam (default: 3).
  • should_scale_rays (bool, optional):
    Whether to normalize ray coordinates to [0, 1] (default: True).
  • should_scale_img (bool, optional):
    Whether to scale the input image before wall detection (default: True).
  • use_dda (bool, optional):
    Whether to use the DDA-based ray tracing method (default: True).
  • iterative_tracking (bool, optional): Whether to return a RayIterator for step-by-step analysis (default: False).
  • iterative_steps (int, optional):
    Number of steps for iterative reduction if using iterative tracking. None for all steps.
  • parallelization (int, optional):
    The amount of workers for parallelization. 0 for no parallelization, -1 for max amount of workers.
  • parallelization_method (str, optional):
    Method to use for parallelization (as soft condition) -> "threads" or "processes"
  • use_numba_compilation (bool, optional):
    Whether to use the compiled (to machine code) version of compute heavy functions.
  • ignore_iterative_optimization (bool, optional):
    Whether to used optimized ignore iteration data if iterative_tracking is False.
  • detail_draw (bool, optional):
    Whether to draw every point or just beam endpoints (default: True).
  • output_format (str, optional):
    Output mode: "single_image", "multiple_images", or "channels" (default: "single_image").
  • img_background (numpy.ndarray, optional):
    Background image; if None, a blank image is created.
  • ray_value (int, float, list, or numpy.ndarray, optional):
    Pixel intensity or color per reflection (default: 255).
  • ray_thickness (int, optional):
    Thickness of the drawn lines or points (default: 1).
  • img_shape (tuple, optional):
    Shape of the generated image if no background is given (default: (256, 256)).
  • dtype (type, optional):
    Data type for the output image (default: float).
  • standard_value (int or float, optional):
    Background fill value (default: 0).
  • should_scale_rays_to_image (bool, optional):
    Whether to scale ray coordinates to match the image (default: True).
  • original_max_width (float, optional):
    Original image width before scaling.
  • original_max_height (float, optional):
    Original image height before scaling.
  • show_only_reflections (bool, optional):
    If True, draws only reflected beams (default: False).

Returns: numpy.ndarray or list: - Single image if output_format == "single_image" or "channels". - List of images if output_format == "multiple_images".

def numba_to_py(obj):
1810def numba_to_py(obj):
1811    """
1812    Converts numba.typed.List (possibly nested -> rays) to plain Python lists/tuples.
1813    """
1814    if isinstance(obj, List):
1815        return [numba_to_py(x) for x in obj]
1816
1817    
1818    if isinstance(obj, tuple):
1819        return tuple(numba_to_py(x) for x in obj)
1820
1821    return obj

Converts numba.typed.List (possibly nested -> rays) to plain Python lists/tuples.

TYPE_POINT_INT = UniTuple(int64, 2)
TYPE_POINT_FLOAT = UniTuple(float64, 2)
TYPE_LINE = ListType(UniTuple(float64, 2))
def calc_reflection_numba(collide_vector, wall_vector):
789def calc_reflection(collide_vector, wall_vector):
790    """
791    Calculate the reflection of a collision vector against a wall vector.
792
793    The reflection is computed using the wall's normal vector and the formula:
794        r = v - 2 * (v · n) * n
795
796    Parameters:
797    - collide_vector (array-like): <br>
798        Incoming vector (2D).
799    - wall_vector (array-like): <br>
800        Wall direction vector (2D).
801
802    Returns:
803    - numpy.ndarray: 
804        Reflected 2D vector.
805    """
806    # normalize both
807    collide_vector = np.array(collide_vector, dtype=np.float64)
808    collide_vector /= np.linalg.norm(collide_vector)
809    wall_vector = np.array(wall_vector, dtype=np.float64)
810    wall_vector /= np.linalg.norm(wall_vector)
811
812    # calculate the normal of the wall
813    normal_wall_vector_1 = np.array([-wall_vector[1], wall_vector[0]])  # rotated +90°
814    normal_wall_vector_2 = np.array([wall_vector[1], -wall_vector[0]])  # rotated -90°
815
816    # decide which vector is the right one
817    #   -> dot product tells which normal faces the incoming vector
818    #   -> dor product shows how similiar 2 vectors are => smaller 0 means they show against each other => right vector
819    if np.dot(collide_vector, normal_wall_vector_1) < 0:
820        normal_wall_vector = normal_wall_vector_1
821    else:
822        normal_wall_vector = normal_wall_vector_2
823    
824    # calc the reflection
825    return collide_vector - 2 * np.dot(collide_vector, normal_wall_vector) * normal_wall_vector

Calculate the reflection of a collision vector against a wall vector.

The reflection is computed using the wall's normal vector and the formula: r = v - 2 * (v · n) * n

Parameters:

  • collide_vector (array-like):
    Incoming vector (2D).
  • wall_vector (array-like):
    Wall direction vector (2D).

Returns:

  • numpy.ndarray: Reflected 2D vector.
def get_img_border_vector_numba(position, max_width, max_height):
828def get_img_border_vector(position, max_width, max_height):
829    """
830    Determine the wall normal vector for an image border collision.
831
832    Parameters:
833    - position (tuple): <br>
834        Current position (x, y).
835    - max_width (int): <br>
836        Image width.
837    - max_height (int): <br>
838        Image height.
839
840    Returns:
841    - tuple: 
842        Border wall vector corresponding to the collision side.
843    """
844    # print(f"got {position=}")
845    if position[0] < 0:
846        return (0, 1)
847    elif position[0] >= max_width:
848        return (0, 1)
849    elif position[1] < 0:
850        return (1, 0)
851    elif position[1] >= max_height:
852        return (1, 0)
853    else:
854        # should never reach that!
855        return (0, 0)

Determine the wall normal vector for an image border collision.

Parameters:

  • position (tuple):
    Current position (x, y).
  • max_width (int):
    Image width.
  • max_height (int):
    Image height.

Returns:

  • tuple: Border wall vector corresponding to the collision side.
@numba.njit(cache=True, fastmath=True)
def update_pixel_position_numba(direction_in_degree, cur_position, target_line):
1837@numba.njit(cache=True, fastmath=True)
1838def update_pixel_position_numba(direction_in_degree, cur_position, target_line):
1839    # cur_position: (x,y) tuple
1840    # target_line: array[4] => [x1,y1,x2,y2]
1841    px = cur_position[0]
1842    py = cur_position[1]
1843
1844    ax = target_line[0]
1845    ay = target_line[1]
1846    bx = target_line[2]
1847    by = target_line[3]
1848
1849    # Directionvector of the line
1850    vx = bx - ax
1851    vy = by - ay
1852
1853    # Projection: t = dot(P-A, V) / dot(V,V)
1854    wx = px - ax
1855    wy = py - ay
1856
1857    denom = vx*vx + vy*vy
1858    if denom == 0.0:
1859        # Degenerated Linie: just no movement
1860        return (px, py)
1861
1862    t = (wx*vx + wy*vy) / denom
1863
1864    # clip scalar (Numba-safe)
1865    if t < 0.0:
1866        t = 0.0
1867    elif t > 1.0:
1868        t = 1.0
1869
1870    # Point on the line
1871    lx = ax + t * vx
1872    ly = ay + t * vy
1873
1874    # Now make a step in direction_in_degree
1875    rad = math.radians(direction_in_degree)
1876    dx = math.cos(rad)
1877    dy = math.sin(rad)
1878
1879    nx = lx + dx
1880    ny = ly + dy
1881
1882    return (nx, ny)
@numba.njit(cache=True, fastmath=True)
def trace_beam_numba( abs_position, img, direction_in_degree, wall_map, wall_values, img_border_also_collide=False, reflexion_order=3, should_scale=True, should_return_iterative=False, remove_iterative=True):
1940@numba.njit(cache=True, fastmath=True)
1941def trace_beam_numba(abs_position,
1942                     img,
1943                     direction_in_degree,
1944                     wall_map,
1945                     wall_values,
1946                     img_border_also_collide=False,
1947                     reflexion_order=3,
1948                     should_scale=True,
1949                     should_return_iterative=False,
1950                     remove_iterative=True):
1951    """
1952    Trace a ray (beam) through an image with walls and reflections.
1953
1954    The beam starts from a given position and follows a direction until it hits
1955    a wall or border. On collisions, reflections are computed using wall normals.
1956
1957    Parameters:
1958    - abs_position (tuple): <br>
1959        Starting position (x, y) of the beam.
1960    - img (numpy.ndarray): <br>
1961        Input image or segmentation map.
1962    - direction_in_degree (float): <br>
1963        Initial direction angle of the beam.
1964    - wall_map (numpy.ndarray): <br>
1965        Map containing wall orientations in degrees.
1966    - wall_values (list): <br>
1967        List of pixel values representing walls.
1968    - img_border_also_collide (bool, optional): <br>
1969        Whether the image border acts as a collider (default: False).
1970    - reflexion_order (int, optional): <br>
1971        Number of allowed reflections (default: 3).
1972    - should_scale (bool, optional): <br>
1973        Whether to normalize positions to [0, 1] (default: True).
1974    - should_return_iterative (bool, optional): <br>
1975        Whether to return a RayIterator for step-by-step analysis (default: False).
1976    - ignore_iterative_optimization (bool, optional): <br>
1977        Whether to used optimized ignore iteration data if iterative_tracking is False. 
1978    - remove_iterative (bool, optional): <br>
1979        Ignored in this numba version.
1980
1981    Returns:
1982    - list: 
1983        Nested list structure representing the traced ray and its reflections. 
1984        Format: ray[beam][point] = (x, y)
1985    """
1986    if should_return_iterative:
1987        print("[WARNING] Numba Version can't return a iterative version.")
1988
1989    reflexion_order += 1
1990    IMG_HEIGHT = img.shape[0]
1991    IMG_WIDTH  = img.shape[1]
1992
1993    ray = List.empty_list(TYPE_LINE)
1994
1995    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
1996    cur_direction_in_degree = float(direction_in_degree % 360.0)
1997
1998    w_f = float(IMG_WIDTH)
1999    h_f = float(IMG_HEIGHT)
2000
2001    inf_val = np.inf
2002
2003    for _ in range(reflexion_order):
2004        # current_ray_line als typed list
2005        current_ray_line = List.empty_list(TYPE_POINT_FLOAT)
2006
2007        if should_scale:
2008            p = normalize_point_numba(x=cur_target_abs_position[0],
2009                                 y=cur_target_abs_position[1],
2010                                 width=IMG_WIDTH,
2011                                 height=IMG_HEIGHT)
2012            current_ray_line.append((float(p[0]), float(p[1])))
2013        else:
2014            current_ray_line.append((cur_target_abs_position[0], cur_target_abs_position[1]))
2015
2016        last_abs_position = cur_target_abs_position
2017
2018        # Direction -> dx,dy
2019        rad = math.radians(cur_direction_in_degree)
2020        dx = math.cos(rad)
2021        dy = math.sin(rad)
2022
2023        # target_line
2024        x1, y1, x2, y2 = _compute_target_line_outside(cur_target_abs_position[0],
2025                                                      cur_target_abs_position[1],
2026                                                      dx, dy, w_f, h_f)
2027        # build target line -> start and end point
2028        target_line = np.empty(4, dtype=np.float64)
2029        target_line[0] = x1
2030        target_line[1] = y1
2031        target_line[2] = x2
2032        target_line[3] = y2
2033
2034        current_position = cur_target_abs_position
2035
2036        while True:
2037            current_position = update_pixel_position_numba(
2038                direction_in_degree=cur_direction_in_degree,
2039                cur_position=current_position,
2040                target_line=target_line
2041            )
2042
2043            x = current_position[0]
2044            y = current_position[1]
2045
2046            # Border check
2047            if not (0.0 <= x < w_f and 0.0 <= y < h_f):
2048                ray.append(current_ray_line)
2049
2050                if img_border_also_collide:
2051                    wall_vector = get_img_border_vector_numba(
2052                        position=current_position,
2053                        max_width=IMG_WIDTH,
2054                        max_height=IMG_HEIGHT
2055                    )
2056                    new_direction = calc_reflection_numba(
2057                        collide_vector=degree_to_vector_numba(cur_direction_in_degree),
2058                        wall_vector=wall_vector
2059                    )
2060                    new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2061
2062                    cur_target_abs_position = last_abs_position
2063                    cur_direction_in_degree = float(new_direction_in_degree)
2064                    break
2065                else:
2066                    return ray
2067
2068            ix = int(x)
2069            iy = int(y)
2070
2071            next_pixel = float(img[iy, ix])
2072
2073            # Wall check (Numba-save)
2074            if _is_in_wall_values(next_pixel, wall_values):
2075                last_abs_position = (x, y)
2076                ray.append(current_ray_line)
2077
2078                building_angle = wall_map[iy, ix]
2079                if building_angle == inf_val:
2080                    raise Exception("Got inf value from Wall-Map.")
2081
2082                wall_vector = degree_to_vector_numba(building_angle)
2083
2084                new_direction = calc_reflection_numba(
2085                    collide_vector=degree_to_vector_numba(cur_direction_in_degree),
2086                    wall_vector=wall_vector
2087                )
2088                new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2089
2090                cur_target_abs_position = last_abs_position
2091                cur_direction_in_degree = float(new_direction_in_degree)
2092                break
2093            else:
2094                if should_scale:
2095                    p = normalize_point_numba(x=x, y=y, width=IMG_WIDTH, height=IMG_HEIGHT)
2096                    current_ray_line.append((float(p[0]), float(p[1])))
2097                else:
2098                    current_ray_line.append((x, y))
2099                last_abs_position = (x, y)
2100
2101    return ray

Trace a ray (beam) through an image with walls and reflections.

The beam starts from a given position and follows a direction until it hits a wall or border. On collisions, reflections are computed using wall normals.

Parameters:

  • abs_position (tuple):
    Starting position (x, y) of the beam.
  • img (numpy.ndarray):
    Input image or segmentation map.
  • direction_in_degree (float):
    Initial direction angle of the beam.
  • wall_map (numpy.ndarray):
    Map containing wall orientations in degrees.
  • wall_values (list):
    List of pixel values representing walls.
  • img_border_also_collide (bool, optional):
    Whether the image border acts as a collider (default: False).
  • reflexion_order (int, optional):
    Number of allowed reflections (default: 3).
  • should_scale (bool, optional):
    Whether to normalize positions to [0, 1] (default: True).
  • should_return_iterative (bool, optional):
    Whether to return a RayIterator for step-by-step analysis (default: False).
  • ignore_iterative_optimization (bool, optional):
    Whether to used optimized ignore iteration data if iterative_tracking is False.
  • remove_iterative (bool, optional):
    Ignored in this numba version.

Returns:

  • list: Nested list structure representing the traced ray and its reflections. Format: ray[beam][point] = (x, y)
@numba.njit(cache=True, fastmath=True)
def trace_beam_with_DDA_numba( abs_position, img, direction_in_degree, wall_map, wall_values, img_border_also_collide=False, reflexion_order=3, should_scale=True, should_return_iterative=False, remove_iterative=True):
2105@numba.njit(cache=True, fastmath=True)
2106def trace_beam_with_DDA_numba(abs_position, 
2107                                img, 
2108                                direction_in_degree, 
2109                                wall_map,
2110                                wall_values, 
2111                                img_border_also_collide=False,
2112                                reflexion_order=3,
2113                                should_scale=True,
2114                                should_return_iterative=False,
2115                                remove_iterative=True):
2116    """
2117    Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer)
2118    algorithm with precise collision points and physically accurate reflections.
2119
2120    The beam starts at a given floating-point position and marches through the grid
2121    until it intersects a wall or exits the image. For each collision, the exact
2122    hit position is computed using the ray Parameters t_hit, ensuring that reflected
2123    segments contain meaningful geometry rather than single-point artifacts.
2124    Reflections are computed using wall normals derived from the `wall_map`.
2125
2126    Parameters:
2127    - abs_position (tuple of float): <br>
2128        Starting position (x, y) of the beam in absolute pixel space.
2129    - img (numpy.ndarray): <br>
2130        2D array representing the scene. Pixel values listed in `wall_values`
2131        are treated as solid walls.
2132    - direction_in_degree (float): <br>
2133        Initial direction of the beam in degrees (0° = right, 90° = down).
2134    - wall_map (numpy.ndarray): <br>
2135        A map storing wall orientations in degrees for each pixel marked as a wall.
2136        These angles define the wall normals used for reflection.
2137    - wall_values (list, tuple, set, float, optional): <br>
2138        Pixel values identifying walls. Any pixel in this list causes a collision.
2139        If None, pixel value 0.0 is treated as a wall.
2140    - img_border_also_collide (bool, optional): <br>
2141        If True, the image borders behave like reflective walls. If False,
2142        the ray terminates when leaving the image. Default: False.
2143    - reflexion_order (int, optional): <br>
2144        Maximum number of reflections. The ray can rebound this many times before
2145        the function terminates. Default: 3.
2146    - should_scale (bool, optional): <br>
2147        If True, all emitted points (x, y) are normalized to [0, 1] range.
2148        Otherwise absolute pixel positions are returned. Default: True.
2149    - should_return_iterative (bool, optional): <br>
2150        Whether to return a RayIterator for step-by-step analysis (default: False).
2151    - remove_iterative (bool, optional): <br>
2152        Ignored in this numba version.
2153
2154    Returns:
2155    - list: 
2156        Nested list structure representing the traced ray and its reflections. 
2157        Format: ray[beam][point] = (x, y)
2158    """
2159    if should_return_iterative:
2160        print("[WARNING] Numba Version can't return a iterative version.")
2161
2162    reflexion_order += 1
2163    # IMG_WIDTH, IMG_HEIGHT = get_width_height(img)
2164    IMG_HEIGHT = img.shape[0]
2165    IMG_WIDTH  = img.shape[1]
2166
2167    ray = []
2168
2169    cur_target_abs_position = (float(abs_position[0]), float(abs_position[1]))
2170    cur_direction_in_degree = direction_in_degree % 360
2171
2172    # go through every reflection -> will early stop if hitting a wall (if wall-bouncing is deactivated)
2173    for cur_depth in range(reflexion_order):
2174        if should_scale:
2175            current_ray_line = [normalize_point_numba(x=cur_target_abs_position[0], y=cur_target_abs_position[1], width=IMG_WIDTH, height=IMG_HEIGHT)]
2176        else:
2177            current_ray_line = [(cur_target_abs_position[0], cur_target_abs_position[1])]
2178
2179        last_abs_position = cur_target_abs_position
2180
2181        # direction
2182        rad = math.radians(cur_direction_in_degree)
2183        dx = math.cos(rad)
2184        dy = math.sin(rad)
2185
2186        eps = 1e-12
2187        if abs(dx) < eps: dx = 0.0
2188        if abs(dy) < eps: dy = 0.0
2189
2190        # start float pos and starting cell
2191        x0 = float(cur_target_abs_position[0])
2192        y0 = float(cur_target_abs_position[1])
2193        cell_x = int(math.floor(x0))
2194        cell_y = int(math.floor(y0))
2195
2196        # outside start -> handle border/reflection/exit
2197        if not (0 <= x0 < IMG_WIDTH and 0 <= y0 < IMG_HEIGHT):
2198            ray.append(current_ray_line)
2199            if img_border_also_collide:
2200                wall_vector = get_img_border_vector_numba(position=(x0, y0), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
2201                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2202                cur_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2203                cur_target_abs_position = last_abs_position
2204                continue
2205            else:
2206                return ray
2207
2208        # DDA parameters
2209        tDeltaX = math.inf if dx == 0.0 else abs(1.0 / dx)
2210        tDeltaY = math.inf if dy == 0.0 else abs(1.0 / dy)
2211
2212        if dx > 0:
2213            stepX = 1
2214            nextBoundaryX = cell_x + 1.0
2215            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
2216        elif dx < 0:
2217            stepX = -1
2218            nextBoundaryX = cell_x * 1.0  # left boundary of cell
2219            tMaxX = (nextBoundaryX - x0) / dx if dx != 0 else math.inf
2220        else:
2221            stepX = 0
2222            tMaxX = math.inf
2223
2224        if dy > 0:
2225            stepY = 1
2226            nextBoundaryY = cell_y + 1.0
2227            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
2228        elif dy < 0:
2229            stepY = -1
2230            nextBoundaryY = cell_y * 1.0
2231            tMaxY = (nextBoundaryY - y0) / dy if dy != 0 else math.inf
2232        else:
2233            stepY = 0
2234            tMaxY = math.inf
2235
2236        max_steps = (IMG_WIDTH + IMG_HEIGHT) * 6
2237        steps = 0
2238        last_position_saved = False
2239
2240        # immediate-start-in-wall handling
2241        if 0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT:
2242            start_pixel = float(img[cell_y, cell_x])
2243            # if start_pixel in wall_values_set:
2244            if _is_in_wall_values(start_pixel, wall_values):
2245                # compute a collision point precisely at start (we'll use origin)
2246                # add collision point (start) and reflect
2247                hit_x = x0
2248                hit_y = y0
2249                if should_scale:
2250                    current_ray_line.append(normalize_point_numba(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT))
2251                else:
2252                    current_ray_line.append((hit_x, hit_y))
2253                ray.append(current_ray_line)
2254
2255                building_angle = float(wall_map[cell_y, cell_x])
2256                if not np.isfinite(building_angle):
2257                    raise Exception("Got non-finite value from Wall-Map.")
2258                wall_vector = degree_to_vector_numba(building_angle)
2259                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2260                cur_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2261                ndx, ndy = new_direction[0], new_direction[1]
2262                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2263                continue
2264
2265        # DDA main loop
2266        while steps < max_steps:
2267            steps += 1
2268
2269            # choose axis to step and capture t_hit (distance along ray to boundary)
2270            if tMaxX < tMaxY:
2271                t_hit = tMaxX
2272                # step in x
2273                cell_x += stepX
2274                tMaxX += tDeltaX
2275                stepped_axis = 'x'
2276            else:
2277                t_hit = tMaxY
2278                # step in y
2279                cell_y += stepY
2280                tMaxY += tDeltaY
2281                stepped_axis = 'y'
2282
2283            # compute exact collision position along ray from origin (x0,y0)
2284            hit_x = x0 + dx * t_hit
2285            hit_y = y0 + dy * t_hit
2286
2287            # For recording the traversal we can append intermediate cell centers encountered so far.
2288            # But more importantly, append the collision point to the current segment BEFORE storing it.
2289            if should_scale:
2290                current_ray_line.append(normalize_point_numba(x=hit_x, y=hit_y, width=IMG_WIDTH, height=IMG_HEIGHT))
2291            else:
2292                current_ray_line.append((hit_x, hit_y))
2293
2294            # Now check if we've left the image bounds (cell_x, cell_y refer to the new cell we stepped into)
2295            if not (0 <= cell_x < IMG_WIDTH and 0 <= cell_y < IMG_HEIGHT):
2296                ray.append(current_ray_line)
2297                last_position_saved = True
2298
2299                if img_border_also_collide:
2300                    wall_vector = get_img_border_vector_numba(position=(cell_x, cell_y), max_width=IMG_WIDTH, max_height=IMG_HEIGHT)
2301                    new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2302                    new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2303                    # start next ray from last in-image position (hit_x, hit_y) nudged slightly
2304                    ndx, ndy = new_direction[0], new_direction[1]
2305                    cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2306                    cur_direction_in_degree = new_direction_in_degree
2307                    break
2308                else:
2309                    return ray
2310
2311            # sample the pixel in the cell we stepped into
2312            next_pixel = float(img[cell_y, cell_x])
2313            if _is_in_wall_values(next_pixel, wall_values):
2314                # we hit a wall — collision point already appended
2315                last_abs_position = (hit_x, hit_y)
2316                ray.append(current_ray_line)
2317                last_position_saved = True
2318
2319                building_angle = float(wall_map[cell_y, cell_x])
2320                if not np.isfinite(building_angle):
2321                    raise Exception("Got non-finite value from Wall-Map.")
2322                wall_vector = degree_to_vector_numba(building_angle)
2323
2324                new_direction = calc_reflection_numba(collide_vector=degree_to_vector_numba(cur_direction_in_degree), wall_vector=wall_vector)
2325                new_direction_in_degree = vector_to_degree_numba(new_direction[0], new_direction[1])
2326
2327                # start next beam from collision point nudged outwards
2328                ndx, ndy = new_direction[0], new_direction[1]
2329                cur_target_abs_position = (hit_x + ndx * 1e-3, hit_y + ndy * 1e-3)
2330                cur_direction_in_degree = new_direction_in_degree
2331                break
2332            else:
2333                # no hit -> continue marching; also add a representative point in the traversed cell (optional)
2334                # we already appended the exact hit point for this step; for smoother lines you may add cell center too
2335                last_abs_position = (hit_x, hit_y)
2336                # continue
2337
2338        # end DDA loop
2339        if not last_position_saved:
2340            ray.append(current_ray_line)
2341
2342    return ray

Trace a ray (beam) through a 2D image using a DDA (Digital Differential Analyzer) algorithm with precise collision points and physically accurate reflections.

The beam starts at a given floating-point position and marches through the grid until it intersects a wall or exits the image. For each collision, the exact hit position is computed using the ray Parameters t_hit, ensuring that reflected segments contain meaningful geometry rather than single-point artifacts. Reflections are computed using wall normals derived from the wall_map.

Parameters:

  • abs_position (tuple of float):
    Starting position (x, y) of the beam in absolute pixel space.
  • img (numpy.ndarray):
    2D array representing the scene. Pixel values listed in wall_values are treated as solid walls.
  • direction_in_degree (float):
    Initial direction of the beam in degrees (0° = right, 90° = down).
  • wall_map (numpy.ndarray):
    A map storing wall orientations in degrees for each pixel marked as a wall. These angles define the wall normals used for reflection.
  • wall_values (list, tuple, set, float, optional):
    Pixel values identifying walls. Any pixel in this list causes a collision. If None, pixel value 0.0 is treated as a wall.
  • img_border_also_collide (bool, optional):
    If True, the image borders behave like reflective walls. If False, the ray terminates when leaving the image. Default: False.
  • reflexion_order (int, optional):
    Maximum number of reflections. The ray can rebound this many times before the function terminates. Default: 3.
  • should_scale (bool, optional):
    If True, all emitted points (x, y) are normalized to [0, 1] range. Otherwise absolute pixel positions are returned. Default: True.
  • should_return_iterative (bool, optional):
    Whether to return a RayIterator for step-by-step analysis (default: False).
  • remove_iterative (bool, optional):
    Ignored in this numba version.

Returns:

  • list: Nested list structure representing the traced ray and its reflections. Format: ray[beam][point] = (x, y)
@numba.njit(cache=True, fastmath=True)
def get_all_pixel_coordinates_in_between_numba(x1, y1, x2, y2):
2346@numba.njit(cache=True, fastmath=True)
2347def get_all_pixel_coordinates_in_between_numba(x1, y1, x2, y2):
2348    """
2349    Get all pixel coordinates along a line between two points using Bresenham's algorithm.
2350
2351    https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
2352
2353    Parameters:
2354    - x1 (int): <br>
2355        Starting x-coordinate.
2356    - y1 (int): <br>
2357        Starting y-coordinate.
2358    - x2 (int): <br>
2359        Ending x-coordinate.
2360    - y2 (int): <br>
2361        Ending y-coordinate.
2362
2363    Returns:
2364    - list: 
2365        List of (x, y) tuples representing all pixels between the start and end points
2366    """
2367    coordinates = List.empty_list(TYPE_POINT_INT)
2368
2369    dx = abs(x2 - x1)
2370    dy = abs(y2 - y1)
2371    x, y = x1, y1
2372
2373    sx = 1 if x1 < x2 else -1
2374    sy = 1 if y1 < y2 else -1
2375
2376    if dx > dy:
2377        err = dx / 2.0
2378        while x != x2:
2379            coordinates.append((x, y))
2380            err -= dy
2381            if err < 0:
2382                y += sy
2383                err += dx
2384            x += sx
2385    else:
2386        err = dy / 2.0
2387        while y != y2:
2388            coordinates.append((x, y))
2389            err -= dx
2390            if err < 0:
2391                x += sx
2392                err += dy
2393            y += sy
2394
2395    coordinates.append((x2, y2))  # include the last point
2396    return list(coordinates)

Get all pixel coordinates along a line between two points using Bresenham's algorithm.

https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm

Parameters:

  • x1 (int):
    Starting x-coordinate.
  • y1 (int):
    Starting y-coordinate.
  • x2 (int):
    Ending x-coordinate.
  • y2 (int):
    Ending y-coordinate.

Returns:

  • list: List of (x, y) tuples representing all pixels between the start and end points