img_phy_sim.ism

Image Source Method (ISM) Ray Propagation and Visibility Maps

This module implements a fast 2D Image Source Method (ISM) solver to simulate specular reflections and line-of-sight propagation inside environments described by images (raster maps) that contain wall/obstacle pixels.

The scene is interpreted as a 2D grid where specific pixel values represent walls. From this raster representation, geometric wall boundary segments are extracted and used to construct image sources for reflection sequences up to a given order. For each receiver position on a grid, candidate reflection paths are constructed, validated with a raster-based visibility test, and accumulated into a path-count map.

Core pipeline:

  1. Build a wall mask from an input image
  2. Extract wall boundary segments (OpenCV contours -> polyline edges)
  3. Build a binary occlusion map for raster visibility tests
  4. Enumerate reflection sequences and precompute image source positions
  5. Evaluate receiver points on a grid:
    • Construct candidate reflection paths (segment intersection + reflection)
    • Validate segment visibility against the occlusion raster
    • Accumulate valid paths into an output map

As ASCII model:

                 ┌──────────────────────────────┐
                 │        Input: Scene          │
                 │    (image, walls, source)    │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │   Raster → Wall Geometry     │
                 │   Extract wall segments      │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │     Build Occlusion Map      │
                 │   (for visibility checks)    │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │  Precompute Image Sources    │
                 │  (all reflection sequences)  │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │     Define Receiver Grid     │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────────────────┐
                 │ For each Receiver position R on the grid │
                 └──────────────┬───────────────────────────┘
                                │
                                v
                 ┌──────────────────────────────────────────┐
                 │ For each Image Source (reflection seq)   │
                 └──────────────┬───────────────────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │  Construct reflection path   │
                 │   (geometry / intersections) │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │     Check path visibility    │
                 │    (raster occlusion test)   │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │  Accumulate contribution     │
                 └──────────────┬───────────────┘
                                │
                                v
                 ┌──────────────────────────────┐
                 │          Output Map          │
                 └──────────────────────────────┘



Numba/parallelization notes:
The hot loop is executed inside Numba kernels.

  • eval_receivers_to_map_kernel:
    Computes and writes directly into output_map inside Numba. This is safe when receiver coordinates are unique (e.g., a regular grid where each receiver maps to a distinct pixel).

Reflection sequences are packed into flat arrays (seq_data, seq_off, seq_len) to avoid Python containers and to keep Numba in nopython mode.

Example:

reflection_map = ips.ism.compute_reflection_map(
    source_rel=(0.5, 0.5),
    img=input_,
    wall_values=[0],   
    wall_thickness=1,
    max_order=1,
    step_px=1
)

Dependencies:

  • numpy
  • OpenCV (cv2)
  • numba

Public API:

  • compute_reflection_map(...)
  • build_wall_mask(...)
  • get_wall_segments_from_mask(...)
  • build_occlusion_from_wallmask(...)
  • precompute_image_sources(...)
  • enumerate_wall_sequences_indices(...)

Main internal kernels/utilities:

  • reflect_point_across_infinite_line(...)
  • reflect_point_across_infinite_line_numba(...)
  • seg_seg_intersection_xy(...)
  • is_visible_raster(...)
  • build_path_for_sequence_packed(...)
  • check_path_visibility_raster_arr(...)
  • pack_precomputed(...)
  • build_receivers_grid(...)
  • eval_receivers_to_map_kernel(...)
   1"""
   2**Image Source Method (ISM) Ray Propagation and Visibility Maps**
   3
   4This module implements a fast 2D Image Source Method (ISM) solver to simulate
   5specular reflections and line-of-sight propagation inside environments described
   6by images (raster maps) that contain wall/obstacle pixels.
   7
   8The scene is interpreted as a 2D grid where specific pixel values represent
   9walls. From this raster representation, geometric wall boundary segments are
  10extracted and used to construct image sources for reflection sequences up to a
  11given order. For each receiver position on a grid, candidate reflection paths
  12are constructed, validated with a raster-based visibility test, and accumulated
  13into a path-count map.
  14
  15Core pipeline:
  161. Build a wall mask from an input image
  172. Extract wall boundary segments (OpenCV contours -> polyline edges)
  183. Build a binary occlusion map for raster visibility tests
  194. Enumerate reflection sequences and precompute image source positions
  205. Evaluate receiver points on a grid:
  21   - Construct candidate reflection paths (segment intersection + reflection)
  22   - Validate segment visibility against the occlusion raster
  23   - Accumulate valid paths into an output map
  24
  25As ASCII model:
  26```text
  27                 ┌──────────────────────────────┐
  28                 │        Input: Scene          │
  29                 │    (image, walls, source)    │
  30                 └──────────────┬───────────────┘
  31
  32                                v
  33                 ┌──────────────────────────────┐
  34                 │   Raster → Wall Geometry     │
  35                 │   Extract wall segments      │
  36                 └──────────────┬───────────────┘
  37
  38                                v
  39                 ┌──────────────────────────────┐
  40                 │     Build Occlusion Map      │
  41                 │   (for visibility checks)    │
  42                 └──────────────┬───────────────┘
  43
  44                                v
  45                 ┌──────────────────────────────┐
  46                 │  Precompute Image Sources    │
  47                 │  (all reflection sequences)  │
  48                 └──────────────┬───────────────┘
  49
  50                                v
  51                 ┌──────────────────────────────┐
  52                 │     Define Receiver Grid     │
  53                 └──────────────┬───────────────┘
  54
  55                                v
  56                 ┌──────────────────────────────────────────┐
  57                 │ For each Receiver position R on the grid │
  58                 └──────────────┬───────────────────────────┘
  59
  60                                v
  61                 ┌──────────────────────────────────────────┐
  62                 │ For each Image Source (reflection seq)   │
  63                 └──────────────┬───────────────────────────┘
  64
  65                                v
  66                 ┌──────────────────────────────┐
  67                 │  Construct reflection path   │
  68                 │   (geometry / intersections) │
  69                 └──────────────┬───────────────┘
  70
  71                                v
  72                 ┌──────────────────────────────┐
  73                 │     Check path visibility    │
  74                 │    (raster occlusion test)   │
  75                 └──────────────┬───────────────┘
  76
  77                                v
  78                 ┌──────────────────────────────┐
  79                 │  Accumulate contribution     │
  80                 └──────────────┬───────────────┘
  81
  82                                v
  83                 ┌──────────────────────────────┐
  84                 │          Output Map          │
  85                 └──────────────────────────────┘
  86```
  87
  88<br><br>
  89
  90Numba/parallelization notes:<br>
  91The hot loop is executed inside Numba kernels.
  92
  93- `eval_receivers_to_map_kernel`:<br>
  94  Computes and writes directly into `output_map` inside Numba. This is safe when
  95  receiver coordinates are unique (e.g., a regular grid where each receiver maps to
  96  a distinct pixel).
  97
  98Reflection sequences are packed into flat arrays (`seq_data`, `seq_off`,
  99``seq_len``) to avoid Python containers and to keep Numba in nopython mode.
 100
 101Example:
 102```python
 103reflection_map = ips.ism.compute_reflection_map(
 104    source_rel=(0.5, 0.5),
 105    img=input_,
 106    wall_values=[0],   
 107    wall_thickness=1,
 108    max_order=1,
 109    step_px=1
 110)
 111```
 112
 113Dependencies:
 114- numpy
 115- OpenCV (cv2)
 116- numba
 117
 118
 119Public API:
 120- compute_reflection_map(...)
 121- build_wall_mask(...)
 122- get_wall_segments_from_mask(...)
 123- build_occlusion_from_wallmask(...)
 124- precompute_image_sources(...)
 125- enumerate_wall_sequences_indices(...)
 126
 127Main internal kernels/utilities:
 128- reflect_point_across_infinite_line(...)
 129- reflect_point_across_infinite_line_numba(...)
 130- seg_seg_intersection_xy(...)
 131- is_visible_raster(...)
 132- build_path_for_sequence_packed(...)
 133- check_path_visibility_raster_arr(...)
 134- pack_precomputed(...)
 135- build_receivers_grid(...)
 136- eval_receivers_to_map_kernel(...)
 137"""
 138
 139
 140
 141# ---------------
 142# >>> Imports <<<
 143# ---------------
 144from __future__ import annotations
 145
 146import math
 147from dataclasses import dataclass
 148from typing import List, Tuple, Optional
 149
 150import numpy as np
 151import cv2
 152
 153# optimization
 154import numba
 155
 156
 157
 158# --------------
 159# >>> Helper <<<
 160# --------------
 161
 162def reflect_point_across_infinite_line(P: Tuple[float, float], A: Tuple[float, float], B: Tuple[float, float]) -> Tuple[float, float]:
 163    """
 164    Reflect a 2D point across an infinite line.
 165
 166    Computes the mirror image of point P with respect to the infinite line
 167    passing through points A and B.
 168
 169                  P
 170                  *
 171                   \
 172                    \
 173    -----------------*----------------   Wand (A-B)
 174                    proj
 175                      \
 176                       \
 177                        *
 178                        P'
 179
 180    Parameters:
 181    - P (Tuple[float, float]): <br>
 182        The point to reflect (x, y).
 183    - A (Tuple[float, float]): <br>
 184        First point defining the line (x, y).
 185    - B (Tuple[float, float]): <br>
 186        Second point defining the line (x, y).
 187
 188    Returns:
 189    - Tuple[float, float]: <br>
 190        The reflected point (x, y) as floats.
 191    """
 192    # var setup
 193    #    -> A/B are both points on the wall
 194    #    -> P is the point we want to mirror (not receiver, source ,...)
 195    x1, y1 = A
 196    x2, y2 = B
 197    px, py = P
 198
 199    # get direction of the wall
 200    #    -> from point to the wall
 201    ABx = x2 - x1
 202    ABy = y2 - y1
 203    # norm this wall direction
 204    n = math.hypot(ABx, ABy) + 1e-12
 205    ux, uy = ABx / n, ABy / n
 206
 207    # direction/vector from the wall to the point
 208    APx = px - x1
 209    APy = py - y1
 210
 211    # projection of point to wall direction to the wall direction
 212    #   -> finding sweetspot where we are directly under P on wall line/direction
 213    t = APx * ux + APy * uy
 214    # point on wall direction/line which is directly under P
 215    projx = x1 + t * ux
 216    projy = y1 + t * uy
 217
 218    # calc reflection/mirror
 219    rx = projx + (projx - px)
 220    ry = projy + (projy - py)
 221
 222    return (float(rx), float(ry))
 223
 224
 225
 226@numba.njit(cache=True, fastmath=True)
 227def reflect_point_across_infinite_line_numba(px, py, ax, ay, bx, by):
 228    """
 229    Reflect a 2D point across an infinite line defined by two points.
 230
 231    Computes the mirror image of point P = (px, py) with respect to the
 232    infinite line passing through A = (ax, ay) and B = (bx, by).
 233    The function first projects P orthogonally onto the line AB to obtain
 234    the foot point (projection), and then reflects P across this point
 235    using the relation:
 236
 237        P' = 2 * proj - P
 238
 239    This implementation is written in a Numba-friendly style and avoids
 240    expensive operations such as `math.hypot`, using only basic arithmetic
 241    and a square root for normalization.
 242
 243    Parameters:
 244    - px (float): <br>
 245        x-coordinate of the point to reflect.
 246    - py (float): <br>
 247        y-coordinate of the point to reflect.
 248    - ax (float): <br>
 249        x-coordinate of the first point defining the line.
 250    - ay (float): <br>
 251        y-coordinate of the first point defining the line.
 252    - bx (float): <br>
 253        x-coordinate of the second point defining the line.
 254    - by (float): <br>
 255        y-coordinate of the second point defining the line.
 256
 257    Returns:
 258    - Tuple[float, float]: <br>
 259        The reflected point (rx, ry) across the infinite line AB.
 260    """
 261    # direction vector of the wall segment A -> B
 262    abx = bx - ax
 263    aby = by - ay
 264
 265    # normalize to unit direction vector
 266    # small epsilon avoids division by zero for degenerate segments
 267    n = math.sqrt(abx * abx + aby * aby) + 1e-12
 268    ux = abx / n
 269    uy = aby / n
 270
 271    # vector from A to the point P
 272    apx = px - ax
 273    apy = py - ay
 274
 275    # projection of AP onto the wall direction
 276    t = apx * ux + apy * uy
 277    projx = ax + t * ux
 278    projy = ay + t * uy
 279
 280    # reflect point across the infinite line defined by A-B
 281    # P' = 2 * projection - P
 282    rx = 2.0 * projx - px
 283    ry = 2.0 * projy - py
 284
 285    return rx, ry
 286
 287
 288
 289def reflection_map_to_img(reflection_map):
 290    """
 291    Convert an reflection map to a uint8 visualization image.
 292
 293    Normalizes the input reflection_map to [0, 255] by dividing by its maximum
 294    value (with an epsilon for numerical stability), and converts the result
 295    to uint8.
 296
 297    Parameters:
 298    - reflection_map (np.ndarray): <br>
 299        A numeric array representing reflection values.
 300
 301    Returns:
 302    - np.ndarray: <br>
 303        A uint8 image array with values in [0, 255].
 304    """
 305    vis = reflection_map.copy()
 306    vis = vis / (vis.max() + 1e-9)
 307    return (vis * 255).astype(np.float64)  # .astype(np.uint8)
 308
 309
 310
 311# -----------------------------------------
 312# >>> Segment representation & geometry <<<
 313# -----------------------------------------
 314
 315@numba.njit(cache=True, fastmath=True)
 316def seg_seg_intersection_xy(x1, y1, x2, y2, x3, y3, x4, y4, eps=1e-9):
 317    """
 318    Compute the intersection point of two 2D line segments.
 319
 320    Determines whether the segment P1→P2 intersects with the segment Q1→Q2.
 321    The computation is performed using a parametric form and 2D cross products.
 322    If a unique intersection point exists within both segment bounds
 323    (including small numerical tolerances), the intersection coordinates are
 324    returned together with a success flag.
 325
 326    Parallel or colinear segments are treated as non-intersecting for the
 327    purposes of the Image Source Model, since such cases are ambiguous for
 328    specular reflection path construction.
 329
 330    Parameters:
 331    - x1 (float): <br>
 332        x-coordinate of the first endpoint of segment P.
 333    - y1 (float): <br>
 334        y-coordinate of the first endpoint of segment P.
 335    - x2 (float): <br>
 336        x-coordinate of the second endpoint of segment P.
 337    - y2 (float): <br>
 338        y-coordinate of the second endpoint of segment P.
 339    - x3 (float): <br>
 340        x-coordinate of the first endpoint of segment Q.
 341    - y3 (float): <br>
 342        y-coordinate of the first endpoint of segment Q.
 343    - x4 (float): <br>
 344        x-coordinate of the second endpoint of segment Q.
 345    - y4 (float): <br>
 346        y-coordinate of the second endpoint of segment Q.
 347    - eps (float): <br>
 348        Numerical tolerance used to handle floating point inaccuracies
 349        when testing for parallelism and segment bounds.
 350
 351    Returns:
 352    - Tuple[float, float, bool]: <br>
 353        (ix, iy, ok) where (ix, iy) is the intersection point if one exists,
 354        and `ok` indicates whether a valid intersection was found.
 355    """
 356    # r = segment p1 -> p2
 357    rx = x2 - x1
 358    ry = y2 - y1
 359
 360    # s = segment q1 -> q2
 361    sx = x4 - x3
 362    sy = y4 - y3
 363
 364    # cross product r x s used to detect parallelism
 365    rxs = rx * sy - ry * sx
 366
 367    # vector from p1 to q1
 368    qpx = x3 - x1
 369    qpy = y3 - y1
 370
 371    # cross product (q1 - p1) x r
 372    qpxr = qpx * ry - qpy * rx
 373
 374    # if r x s is near zero the segments are parallel or colinear
 375    # for the reflection model this is treated as an invalid intersection
 376    if abs(rxs) <= eps:
 377        # parallel or colinear -> treat as invalid for your ISM
 378        return 0.0, 0.0, False
 379
 380    # solve intersection parameters for the parametric segment equations
 381    t = (qpx * sy - qpy * sx) / rxs
 382    u = (qpx * ry - qpy * rx) / rxs
 383
 384    # check whether intersection lies within both segments (with epsilon tolerance)
 385    if (-eps <= t <= 1.0 + eps) and (-eps <= u <= 1.0 + eps):
 386        ix = x1 + t * rx
 387        iy = y1 + t * ry
 388        return ix, iy, True
 389
 390    # no valid intersection on the finite segments
 391    return 0.0, 0.0, False
 392
 393
 394
 395# -------------------------------
 396# >>> Raster-based visibility <<<
 397# -------------------------------
 398
 399@numba.njit(cache=True, fastmath=True)
 400def is_visible_raster(p1x:float, p1y:float, p2x:float, p2y:float, occ:np.ndarray, ignore_ends:int=1) -> bool:
 401    """
 402    Check line-of-sight visibility between two points using a raster occlusion map.
 403
 404    This function traces a discrete line between two pixel positions using
 405    Bresenham's line algorithm and tests whether any raster cell along this
 406    line is marked as occluded in the provided occlusion map `occ`.
 407    The algorithm operates entirely in integer pixel space and is written in
 408    a Numba-friendly style without temporary allocations.
 409
 410    To allow valid reflection paths that touch wall endpoints, a configurable
 411    number of pixels at the start and end of the line can be ignored via
 412    `ignore_ends`. This prevents false occlusion detections when rays start
 413    or end directly on wall pixels.
 414
 415    The Bresenham traversal is executed twice:
 416    1. First pass: counts the number of raster points along the line to
 417    determine the valid range after removing the ignored endpoints.
 418    2. Second pass: checks only the relevant interior pixels for occlusion.
 419
 420    Taken from https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm -> last code shown on the website.
 421
 422    Parameters:
 423    - p1x (float): <br>
 424        x-coordinate of the start point in pixel space.
 425    - p1y (float): <br>
 426        y-coordinate of the start point in pixel space.
 427    - p2x (float): <br>
 428        x-coordinate of the end point in pixel space.
 429    - p2y (float): <br>
 430        y-coordinate of the end point in pixel space.
 431    - occ (np.ndarray): <br>
 432        Occlusion map where nonzero values represent blocked pixels.
 433        Expected indexing is `occ[y, x]`.
 434    - ignore_ends (int): <br>
 435        Number of raster pixels to ignore at both ends of the line.
 436
 437    Returns:
 438    - bool: <br>
 439        True if the path between the two points is free of occlusions,
 440        otherwise False.
 441    """
 442    H = occ.shape[0]
 443    W = occ.shape[1]
 444
 445    # round always up -> so + 0.5
 446    x0 = int(p1x + 0.5)
 447    y0 = int(p1y + 0.5)
 448    x1 = int(p2x + 0.5)
 449    y1 = int(p2y + 0.5)
 450
 451    # clamp endpoints -> should be inside
 452    if x0 < 0: x0 = 0
 453    if x0 >= W: x0 = W - 1
 454    if y0 < 0: y0 = 0
 455    if y0 >= H: y0 = H - 1
 456    if x1 < 0: x1 = 0
 457    if x1 >= W: x1 = W - 1
 458    if y1 < 0: y1 = 0
 459    if y1 >= H: y1 = H - 1
 460
 461    # preperation for breseham algorithm
 462    # err -> accumulator to decide to go in x or y direction
 463    dx = abs(x1 - x0)
 464    dy = abs(y1 - y0)
 465
 466    sx = 1 if x0 < x1 else -1
 467    sy = 1 if y0 < y1 else -1
 468
 469    err = dx - dy
 470
 471    # 1. calc length (amount of grid points -> for ignore ends
 472    # => count grid points between the 2 points
 473    # => later we can then say ignore the first and last N pixels
 474    tx, ty = x0, y0
 475    terr = err
 476    npts = 1
 477    while not (tx == x1 and ty == y1):
 478        # bresenham algorithm to move in pixel-space
 479        e2 = terr * 2
 480        if e2 > -dy:
 481            terr -= dy
 482            tx += sx
 483        if e2 < dx:
 484            terr += dx
 485            ty += sy
 486        # increase grid points
 487        npts += 1
 488
 489    # not enough grid points -> visible!
 490    # only start and end point, there cant be something in between
 491    if npts <= 2:
 492        return True
 493
 494    # if the ignore parts is cut away
 495    # is there even something left to check?
 496    start_i = ignore_ends
 497    end_i = npts - ignore_ends
 498    if start_i >= end_i:
 499        return True
 500    
 501    # 2. run again -> but this time check occlusion
 502    tx, ty = x0, y0
 503    terr = err
 504    i = 0
 505    while True:
 506        if i >= start_i and i < end_i:
 507            # wall between detected?
 508            if occ[ty, tx] != 0:
 509                return False
 510
 511        # end of line reached?
 512        if tx == x1 and ty == y1:
 513            break
 514
 515        # breseham -> continue line
 516        e2 = terr * 2
 517        if e2 > -dy:
 518            terr -= dy
 519            tx += sx
 520        if e2 < dx:
 521            terr += dx
 522            ty += sy
 523
 524        i += 1
 525
 526    return True
 527
 528
 529# -------------------------------------
 530# >>> Wall extraction from an image <<<
 531# -------------------------------------
 532
 533@dataclass(frozen=True)
 534class Segment:
 535    """
 536    Represent a 2D line segment.
 537
 538    A lightweight immutable segment representation used for wall geometry
 539    and intersection tests.
 540
 541    Attributes:
 542    - ax (float): x-coordinate of the first endpoint.
 543    - ay (float): y-coordinate of the first endpoint.
 544    - bx (float): x-coordinate of the second endpoint.
 545    - by (float): y-coordinate of the second endpoint.
 546
 547    Properties:
 548    - A (Tuple[float, float]): <br>
 549        First endpoint (ax, ay).
 550    - B (Tuple[float, float]): <br>
 551        Second endpoint (bx, by).
 552    """
 553    ax: float
 554    ay: float
 555    bx: float
 556    by: float
 557
 558    @property
 559    def A(self): return (self.ax, self.ay)
 560
 561    @property
 562    def B(self): return (self.bx, self.by)
 563
 564
 565
 566@numba.njit(cache=True, fastmath=True)
 567def _max_u8(img_u8):
 568    """
 569    Compute the maximum uint8 value in a 2D image using explicit loops
 570
 571    This helper avoids NumPy reductions so it can be used inside
 572    Numba-compatible code paths
 573
 574    Parameters:
 575    - img_u8 (np.ndarray): <br>
 576        2D uint8 image
 577
 578    Returns:
 579    - int: <br>
 580        Maximum pixel value found in the image
 581    """
 582    m = 0
 583    H, W = img_u8.shape[0], img_u8.shape[1]
 584    for y in range(H):
 585        for x in range(W):
 586            v = int(img_u8[y, x])
 587            if v > m:
 588                m = v
 589    return m
 590
 591
 592
 593@numba.njit(cache=True, fastmath=True)
 594def build_wall_mask_numba_no_values(img_u8):
 595    """
 596    Build a 0/255 wall mask from a uint8 image when no explicit wall values are given.
 597
 598    This function assumes that the input image is already mask-like. If the
 599    maximum pixel value is small (e.g. < 64), the image is treated as a
 600    binary or low-range mask and scaled to the range {0, 255}. Otherwise,
 601    the values are copied directly, assuming the image already represents
 602    a proper wall mask.
 603
 604    The implementation is written in a Numba-friendly style and operates
 605    purely with explicit loops for maximum compatibility and performance.
 606
 607    Parameters:
 608    - img_u8 (np.ndarray): <br>
 609        2D uint8 image interpreted as a mask-like input.
 610
 611    Returns:
 612    - np.ndarray: <br>
 613        A uint8 wall mask with values 0 (free space) and 255 (wall).
 614    """
 615    # img_u8: uint8 2D
 616    H, W = img_u8.shape[0], img_u8.shape[1]
 617
 618    # determine maximum value in the image to infer its encoding
 619    m = _max_u8(img_u8)
 620
 621    out = np.empty((H, W), dtype=np.uint8)
 622
 623    if m < 64:
 624        # image likely represents a 0/1 style mask
 625        # scale any non-zero value up to 255 for consistency
 626        for y in range(H):
 627            for x in range(W):
 628                out[y, x] = 255 if img_u8[y, x] != 0 else 0
 629    else:
 630        # image already looks like a proper uint8 mask or label image
 631        # simply copy values over
 632        for y in range(H):
 633            for x in range(W):
 634                out[y, x] = img_u8[y, x]
 635    
 636    return out
 637
 638
 639
 640@numba.njit(cache=True, fastmath=True)
 641def build_wall_mask_numba_values(img_u8, wall_values_i32):
 642    """
 643    Build a 0/255 wall mask from a uint8 image using explicit wall label values.
 644
 645    Each pixel in the input image is compared against a list of wall label
 646    values. If a pixel matches any of the provided values, it is marked as
 647    a wall (255), otherwise it is marked as free space (0).
 648
 649    This replaces the typical `np.isin` operation with explicit loops to
 650    remain fully compatible with Numba's nopython mode.
 651
 652    Parameters:
 653    - img_u8 (np.ndarray): <br>
 654        2D uint8 image containing label values.
 655    - wall_values_i32 (np.ndarray): <br>
 656        1D array of int32 values that should be interpreted as walls.
 657
 658    Returns:
 659    - np.ndarray: <br>
 660        A uint8 wall mask with values 0 (free space) and 255 (wall).
 661    """
 662    # img_u8: uint8 2D
 663    # wall_values_i32: int32 1D, e.g. np.array([3,7,9], np.int32)
 664    H, W = img_u8.shape[0], img_u8.shape[1]
 665    out = np.empty((H, W), dtype=np.uint8)
 666    # number of wall values to compare against
 667    nvals = wall_values_i32.shape[0]
 668
 669    for y in range(H):
 670        for x in range(W):
 671            # current pixel value
 672            v = int(img_u8[y, x])
 673
 674            # check if this pixel matches any of the wall values
 675            is_wall = False
 676            for k in range(nvals):
 677                if v == int(wall_values_i32[k]):
 678                    is_wall = True
 679                    break
 680
 681            # write wall mask pixel
 682            out[y, x] = 255 if is_wall else 0
 683    return out
 684
 685
 686
 687def build_wall_mask(img: np.ndarray, wall_values=None) -> np.ndarray:
 688    """
 689    Build a 0/255 wall mask from an input image.
 690
 691    This function acts as a Python wrapper that prepares the input data and
 692    delegates the actual computation to Numba-optimized kernels.
 693
 694    If `wall_values` is None, the image is assumed to be mask-like and
 695    processed accordingly. Otherwise, the provided label values are used
 696    to determine which pixels represent walls.
 697
 698    The input image must be two-dimensional. If it is not of type uint8,
 699    it is converted before processing.
 700
 701    Parameters:
 702    - img (np.ndarray): <br>
 703        2D input image representing either a mask-like image or a label map.
 704    - wall_values (optional): <br>
 705        Iterable of label values that should be interpreted as walls.
 706
 707    Returns:
 708    - np.ndarray: <br>
 709        A uint8 wall mask with values 0 (free space) and 255 (wall).
 710    """
 711    if img.ndim != 2:
 712        raise ValueError("build_wall_mask: erwartet 2D img (H,W). RGB vorher konvertieren.")
 713
 714    if img.dtype != np.uint8:
 715        img_u8 = img.astype(np.uint8)
 716    else:
 717        img_u8 = img
 718
 719    # if no explicit wall values are given, fall back to a default wall detection routine
 720    if wall_values is None:
 721        return build_wall_mask_numba_no_values(img_u8)
 722
 723    # convert wall values to a compact int array for numba-friendly lookup
 724    wall_vals = np.asarray(wall_values, dtype=np.int32)
 725
 726    # build wall mask by checking pixels against the provided wall values
 727    return build_wall_mask_numba_values(img_u8, wall_vals)
 728
 729
 730
 731def get_wall_segments_from_mask(mask_255: np.ndarray, thickness: int = 1, approx_epsilon: float = 1.5) -> List[Segment]:
 732    """
 733    Extract wall boundary segments from a binary wall mask.
 734
 735    Runs edge detection (Canny) and contour extraction to find wall boundaries,
 736    then approximates contours to polylines and converts edges into Segment
 737    instances.
 738
 739    Parameters:
 740    - mask_255 (np.ndarray): <br>
 741        Binary wall mask with values 0 and 255.
 742    - thickness (int): <br>
 743        Optional dilation thickness applied to edges before contour extraction.
 744        Values > 1 thicken edges to improve contour continuity.
 745    - approx_epsilon (float): <br>
 746        Epsilon value for contour polygon approximation (cv2.approxPolyDP).
 747        Higher values simplify contours more aggressively.
 748
 749    Returns:
 750    - List[Segment]: <br>
 751        List of wall boundary segments in pixel coordinates.
 752    """
 753    # detect wall boundaries from the binary mask using Canny edge detection
 754    edges = cv2.Canny(mask_255, 100, 200)
 755
 756    # optionally thicken edges to merge small gaps and stabilize contour extraction
 757    if thickness and thickness > 1:
 758        k = np.ones((thickness, thickness), np.uint8)
 759        edges = cv2.dilate(edges, k, iterations=1)
 760
 761    # extract external contours of the edge map
 762    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
 763
 764    walls: List[Segment] = []
 765    for c in contours:
 766        # simplify contour to a polygon with fewer points
 767        c2 = cv2.approxPolyDP(c, epsilon=approx_epsilon, closed=True)
 768
 769        # convert polygon points to float tuples (x, y)
 770        pts = [tuple(p[0].astype(float)) for p in c2]
 771        if len(pts) < 2:
 772            continue
 773
 774        # create segments along the polygon polyline
 775        for i in range(len(pts) - 1):
 776            (x1, y1), (x2, y2) = pts[i], pts[i + 1]
 777
 778            # skip degenerate segments
 779            if x1 == x2 and y1 == y2:
 780                continue
 781            walls.append(Segment(x1, y1, x2, y2))
 782
 783        # close the loop by connecting the last point back to the first
 784        # only do this if we have a valid polygon-like shape
 785        if len(pts) >= 3:
 786            (x1, y1), (x2, y2) = pts[-1], pts[0]
 787            if x1 != x2 or y1 != y2:
 788                walls.append(Segment(x1, y1, x2, y2))
 789
 790    return walls
 791
 792
 793
 794def segments_to_walls4(walls):
 795    """
 796    Convert a list of wall segments into a compact float32 array representation.
 797
 798    Transforms a Python list of `Segment` objects (with endpoints ax/ay and bx/by)
 799    into a contiguous NumPy array of shape (N, 4), where each row encodes one wall
 800    segment as:
 801
 802        [ax, ay, bx, by]
 803
 804    This representation is convenient for passing wall geometry into Numba-compiled
 805    kernels, which cannot efficiently work with Python objects or dataclasses.
 806
 807    Parameters:
 808    - walls (List[Segment]): <br>
 809        A list of wall segments represented as `Segment` objects.
 810
 811    Returns:
 812    - np.ndarray: <br>
 813        A float32 array of shape (N, 4) containing wall endpoints.
 814    """
 815    walls4 = np.empty((len(walls), 4), dtype=np.float32)
 816
 817    for i, w in enumerate(walls):
 818        walls4[i,0] = w.ax 
 819        walls4[i,1] = w.ay
 820        walls4[i,2] = w.bx
 821        walls4[i,3] = w.by
 822
 823    return walls4
 824
 825
 826
 827@numba.njit(cache=True, fastmath=True)
 828def dilate_binary_square(src, k):
 829    """
 830    Perform binary dilation using a square structuring element.
 831
 832    Dilates a binary image `src` (values 0/1) using a kxk square kernel for a
 833    single iteration. A pixel in the output is set to 1 if any pixel in its
 834    kxk neighborhood in the input is nonzero.
 835
 836    This implementation is written in a Numba-friendly style (explicit loops,
 837    no OpenCV dependency) and is useful for thickening walls in an occlusion map.
 838
 839    Parameters:
 840    - src (np.ndarray): <br>
 841        2D uint8 array containing binary values {0, 1}.
 842    - k (int): <br>
 843        Kernel size (wall thickness). Values <= 1 return a copy of `src`.
 844
 845    Returns:
 846    - np.ndarray: <br>
 847        A uint8 binary array (0/1) of the same shape as `src` after dilation.
 848    """
 849    # src: uint8 0/1
 850    # k: wall_thickness (>=1)
 851
 852    # for thickness 1 nothing to do, just return a copy
 853    if k <= 1:
 854        return src.copy()
 855
 856    H, W = src.shape[0], src.shape[1]
 857
 858    # radius of the square neighborhood around each pixel
 859    r = k // 2
 860
 861    # output mask after dilation
 862    out = np.zeros((H, W), dtype=np.uint8)
 863
 864    for y in range(H):
 865        # clamp vertical neighborhood to image bounds
 866        y0 = y - r
 867        y1 = y + r
 868        if y0 < 0: y0 = 0
 869        if y1 >= H: y1 = H - 1
 870
 871        for x in range(W):
 872            # clamp horizontal neighborhood to image bounds
 873            x0 = x - r
 874            x1 = x + r
 875            if x0 < 0: x0 = 0
 876            if x1 >= W: x1 = W - 1
 877
 878            # check if any pixel in the k x k neighborhood is set
 879            # this effectively performs a square dilation of the wall mask
 880            found = 0
 881            for yy in range(y0, y1 + 1):
 882                for xx in range(x0, x1 + 1):
 883                    if src[yy, xx] != 0:
 884                        found = 1
 885                        break
 886                if found == 1:
 887                    break
 888
 889            # mark output pixel as wall if any neighbor was a wall
 890            out[y, x] = found
 891    
 892    return out
 893
 894
 895
 896@numba.njit(cache=True, fastmath=True)
 897def apply_mask_to_binary(mask_255):
 898    """
 899    Convert a 0/255 wall mask into a binary occlusion map.
 900
 901    Creates a binary occlusion map from a wall mask where nonzero pixels indicate
 902    walls. The output contains 0 for free space and 1 for occluded pixels.
 903
 904    This function is implemented using explicit loops for Numba compatibility.
 905
 906    Parameters:
 907    - mask_255 (np.ndarray): <br>
 908        2D uint8 wall mask, typically with values {0, 255}.
 909
 910    Returns:
 911    - np.ndarray: <br>
 912        A uint8 binary array of shape (H, W) with values:
 913        - 0 for free space
 914        - 1 for occluded (wall) pixels
 915    """
 916    H, W = mask_255.shape[0], mask_255.shape[1]
 917    occ = np.empty((H, W), dtype=np.uint8)
 918    for y in range(H):
 919        for x in range(W):
 920            occ[y, x] = 1 if mask_255[y, x] != 0 else 0
 921    return occ
 922
 923
 924
 925@numba.njit(cache=True, fastmath=True)
 926def build_occlusion_from_wallmask(mask_255, wall_thickness=1):
 927    """
 928    Build a binary occlusion map from a 0/255 wall mask, with optional wall dilation.
 929
 930    First converts the input wall mask (0/255) into a binary occlusion map (0/1).
 931    If `wall_thickness` is greater than 1, the binary map is dilated using a
 932    square kernel to increase the effective wall thickness for subsequent
 933    visibility checks.
 934
 935    All computation is performed in Numba-compatible kernels without relying
 936    on OpenCV operations.
 937
 938    Parameters:
 939    - mask_255 (np.ndarray): <br>
 940        2D uint8 wall mask, typically with values {0, 255}.
 941    - wall_thickness (int): <br>
 942        If > 1, applies a single binary dilation step with a square kernel of size
 943        (wall_thickness x wall_thickness).
 944
 945    Returns:
 946    - np.ndarray: <br>
 947        A uint8 binary occlusion map with values:
 948        - 0 for free space
 949        - 1 for occluded (wall) pixels
 950    """
 951    occ = apply_mask_to_binary(mask_255)
 952    if wall_thickness > 1:
 953        occ = dilate_binary_square(occ, wall_thickness)
 954    return occ
 955
 956
 957
 958# -----------------------------------------
 959# >>> ISM sequence enumeration & precompute
 960# -----------------------------------------
 961
 962def enumerate_wall_sequences_indices(n_walls: int, max_order: int, forbid_immediate_repeat: bool = True) -> List[Tuple[int, ...]]:
 963    """
 964    Enumerate reflection sequences over wall indices up to a given order.
 965
 966    Generates all sequences of wall indices representing reflection orders
 967    from 0 up to `max_order`. The empty sequence () represents the direct path.
 968
 969    Parameters:
 970    - n_walls (int): <br>
 971        Number of available wall segments.
 972    - max_order (int): <br>
 973        Maximum reflection order (length of sequence).
 974    - forbid_immediate_repeat (bool): <br>
 975        If True, prevents sequences with the same wall repeated consecutively,
 976        e.g., (..., 3, 3) is disallowed.
 977
 978    Returns:
 979    - List[Tuple[int, ...]]: <br>
 980        List of sequences, each a tuple of wall indices.
 981    """
 982    # start with the empty sequence which represents the direct path (no reflections)
 983    seqs = [()]
 984
 985    # iteratively build sequences up to the desired reflection order
 986    for _ in range(max_order):
 987        new = []
 988
 989        # extend all previously known sequences by one additional wall index
 990        for seq in seqs:
 991            for wi in range(n_walls):
 992
 993                # optionally forbid using the same wall twice in a row
 994                # this avoids degenerate mirror-back reflections
 995                if forbid_immediate_repeat and len(seq) > 0 and seq[-1] == wi:
 996                    continue
 997
 998                # create a new sequence with one more reflection
 999                new.append(seq + (wi,))
1000
1001        # append the newly created sequences to the master list
1002        seqs += new
1003
1004    # contains sequences of length 0..max_order
1005    return seqs
1006
1007
1008def precompute_image_sources(
1009    source_xy: Tuple[float, float],
1010    walls: List[Segment],
1011    max_order: int,
1012    forbid_immediate_repeat: bool = True,
1013    max_candidates: Optional[int] = None,
1014) -> List[Tuple[Tuple[int, ...], Tuple[float, float]]]:
1015    """
1016    Precompute image source positions for reflection sequences.
1017
1018    For every reflection sequence up to `max_order`, the source point is
1019    reflected across the corresponding wall lines to produce an image source
1020    position S_img.
1021
1022    Parameters:
1023    - source_xy (Tuple[float, float]): <br>
1024        Source position in pixel coordinates (x, y).
1025    - walls (List[Segment]): <br>
1026        Wall segments used for reflection.
1027    - max_order (int): <br>
1028        Maximum reflection order to consider.
1029    - forbid_immediate_repeat (bool): <br>
1030        If True, prevents immediate repetition of the same wall in sequences.
1031    - max_candidates (Optional[int]): <br>
1032        If provided, truncates the generated sequence list to at most this many
1033        candidates (useful as a speed cap).
1034
1035    Returns:
1036    - List[Tuple[Tuple[int, ...], Tuple[float, float]]]: <br>
1037        A list of tuples (seq, S_img) where: 
1038        - seq is the wall-index sequence
1039        - S_img is the resulting image source position
1040    """
1041    # enumerate all candidate wall index sequences up to the desired reflection order
1042    seqs = enumerate_wall_sequences_indices(len(walls), max_order, forbid_immediate_repeat)
1043
1044    # optionally cap how many sequences we keep to limit runtime and memory
1045    if max_candidates is not None and len(seqs) > max_candidates:
1046        seqs = seqs[:max_candidates]
1047
1048    # build a list of (sequence, image_source_xy) pairs
1049    # the image source is obtained by reflecting the real source across each wall
1050    # in the sequence in forward order
1051    pre: List[Tuple[Tuple[int, ...], Tuple[float, float]]] = []
1052    for seq in seqs:
1053        # start from the real source position
1054        S_img = source_xy
1055
1056        # apply successive reflections across each wall line
1057        for wi in seq:
1058            w = walls[wi]
1059            S_img = reflect_point_across_infinite_line(S_img, w.A, w.B)
1060
1061        # store the sequence and its final image source position
1062        pre.append((seq, S_img))
1063
1064    return pre
1065
1066
1067
1068def pack_precomputed(pre_py):
1069    """
1070    Pack Python reflection sequences into flat arrays for Numba kernels.
1071
1072    The input is a list of tuples where each entry contains:<br>
1073        (sequence_of_wall_indices, (image_source_x, image_source_y))
1074
1075    Since Numba cannot efficiently work with Python lists of variable-length
1076    tuples, this function converts the structure into contiguous flat arrays
1077    plus offset and length metadata so each sequence can be accessed quickly.
1078
1079    The result allows any sequence `i` to be reconstructed as:<br>
1080        seq_data[ seq_off[i] : seq_off[i] + seq_len[i] ]
1081
1082    Parameters:
1083    - pre_py (List[Tuple[Tuple[int], Tuple[float, float]]]): <br>
1084        Python list where each element contains a wall index sequence and
1085        the corresponding precomputed image source position
1086
1087    Returns:
1088    - seq_data (np.ndarray): <br>
1089        Flat int32 array containing all wall indices of all sequences
1090        concatenated back-to-back
1091    - seq_off (np.ndarray): <br>
1092        int32 array giving the start offset of each sequence in `seq_data`
1093    - seq_len (np.ndarray): <br>
1094        int32 array giving the length of each sequence
1095    - simg_x (np.ndarray): <br>
1096        float32 array of image source x-coordinates per sequence
1097    - simg_y (np.ndarray): <br>
1098        float32 array of image source y-coordinates per sequence
1099    """
1100    n = len(pre_py)
1101
1102    # allocate metadata arrays per sequence
1103    seq_len = np.empty(n, dtype=np.int32)
1104    seq_off = np.empty(n, dtype=np.int32)
1105    simg_x  = np.empty(n, dtype=np.float32)
1106    simg_y  = np.empty(n, dtype=np.float32)
1107
1108    # first pass computes lengths, offsets and image source positions
1109    total = 0
1110    for i, (seq_tuple, S_img) in enumerate(pre_py):
1111        L = len(seq_tuple)
1112        seq_len[i] = L
1113        seq_off[i] = total
1114        total += L
1115        simg_x[i] = float(S_img[0])
1116        simg_y[i] = float(S_img[1])
1117
1118    # allocate the flat wall-index array
1119    seq_data = np.empty(total, dtype=np.int32)
1120
1121    # second pass writes all sequences consecutively into seq_data
1122    k = 0
1123    for (seq_tuple, _S_img) in pre_py:
1124        for wi in seq_tuple:
1125            seq_data[k] = int(wi)
1126            k += 1
1127
1128    return seq_data, seq_off, seq_len, simg_x, simg_y
1129
1130
1131
1132# ----------------------------------
1133# >>> ISM path building (no shapely)
1134# ----------------------------------
1135
1136@numba.njit(cache=True, fastmath=True)
1137def build_path_for_sequence_packed(
1138        sx, sy, rx, ry,
1139        seq_data, off0, seq_len,
1140        s_imgx, s_imgy,
1141        walls4,
1142        path_out
1143    ):
1144    """
1145    Construct a specular reflection path for a packed wall-index sequence.
1146
1147    Builds the geometric reflection path between a real source position and
1148    a receiver position using a precomputed image source and a sequence of
1149    wall indices describing the reflection order.
1150
1151    The algorithm works backwards from the receiver by repeatedly
1152    1. Intersecting the line from the image source to the current virtual
1153      receiver with the corresponding wall segment
1154    2. Reflecting the virtual receiver across that wall line
1155
1156    This produces reflection points in reverse order which are then reversed
1157    in-place to form the final path
1158
1159        [source, r1, r2, ..., receiver]
1160
1161    The function is designed for Numba and writes into a preallocated array
1162    `path_out` instead of creating Python lists.
1163
1164    Parameters:
1165    - sx (float): <br>
1166        x-coordinate of the real source position
1167    - sy (float): <br>
1168        y-coordinate of the real source position
1169    - rx (float): <br>
1170        x-coordinate of the receiver position
1171    - ry (float): <br>
1172        y-coordinate of the receiver position
1173    - seq_data (np.ndarray): <br>
1174        Flat int array containing all sequences concatenated
1175    - off0 (int): <br>
1176        Start offset of this sequence inside `seq_data`
1177    - seq_len (int): <br>
1178        Number of wall indices in this sequence
1179    - s_imgx (float): <br>
1180        x-coordinate of the precomputed image source for this sequence
1181    - s_imgy (float): <br>
1182        y-coordinate of the precomputed image source for this sequence
1183    - walls4 (np.ndarray): <br>
1184        Array of shape (N,4) containing wall segments as [ax, ay, bx, by]
1185    - path_out (np.ndarray): <br>
1186        Preallocated float array of shape (>= seq_len+2, 2) used to store
1187        the resulting path points
1188
1189    Returns:
1190    - Tuple[int, bool]: <br>
1191        (path_length, ok) where `path_length` is the number of valid points
1192        written into `path_out`, and `ok` indicates whether a valid path
1193        could be constructed
1194    """
1195    # direct path with no reflections
1196    if seq_len == 0:
1197        path_out[0, 0] = sx; path_out[0, 1] = sy
1198        path_out[1, 0] = rx; path_out[1, 1] = ry
1199        return 2, True
1200
1201    # start from the real receiver as the current virtual receiver
1202    rvirt_x = rx
1203    rvirt_y = ry
1204
1205    for kk in range(seq_len - 1, -1, -1):
1206        # wall index for this bounce
1207        wi = seq_data[off0 + kk]
1208
1209        # wall segment endpoints
1210        ax = walls4[wi, 0]; ay = walls4[wi, 1]
1211        bx = walls4[wi, 2]; by = walls4[wi, 3]
1212
1213        # intersect ray from image source to virtual receiver with the wall segment
1214        ix, iy, ok = seg_seg_intersection_xy(
1215            s_imgx, s_imgy, rvirt_x, rvirt_y,
1216            ax, ay, bx, by
1217        )
1218        if not ok:
1219            return 0, False
1220
1221        # write intersection as a reflection point in reverse order
1222        path_out[1 + (seq_len - 1 - kk), 0] = ix
1223        path_out[1 + (seq_len - 1 - kk), 1] = iy
1224
1225        # update the virtual receiver by reflecting it across the infinite wall line
1226        rvirt_x, rvirt_y = reflect_point_across_infinite_line_numba(
1227            rvirt_x, rvirt_y, ax, ay, bx, by
1228        )
1229
1230    # reverse reflection points so they go from first bounce to last bounce
1231    i = 1
1232    j = seq_len
1233    while i < j:
1234        tmpx = path_out[i, 0]; tmpy = path_out[i, 1]
1235        path_out[i, 0] = path_out[j, 0]; path_out[i, 1] = path_out[j, 1]
1236        path_out[j, 0] = tmpx;          path_out[j, 1] = tmpy
1237        i += 1
1238        j -= 1
1239
1240    # insert real endpoints to finalize the path
1241    path_out[0, 0] = sx; path_out[0, 1] = sy
1242    path_out[seq_len + 1, 0] = rx
1243    path_out[seq_len + 1, 1] = ry
1244    return seq_len + 2, True
1245
1246
1247
1248@numba.njit(cache=True, fastmath=True)
1249def check_path_visibility_raster_arr(path_xy: np.ndarray, path_len: int, occ: np.ndarray, ignore_ends: int) -> bool:
1250    """
1251    Check whether a reconstructed reflection path is fully visible
1252    in a rasterized occlusion map.
1253
1254    The path consists of consecutive line segments:
1255        [source -> r1 -> r2 -> ... -> receiver]
1256
1257    Each segment is tested against the binary occlusion grid `occ`
1258    using a raster visibility test. If any segment intersects an
1259    occupied pixel (wall), the whole path is considered invalid.
1260
1261    This function is designed for Numba and operates on a preallocated
1262    path buffer without creating Python objects.
1263
1264    Parameters:
1265    - path_xy (np.ndarray): <br>
1266        Array of shape (max_order+2, 2) containing path points as (x, y).
1267        Only the first `path_len` entries are valid.
1268    - path_len (int): <br>
1269        Number of valid points in `path_xy` describing the path.
1270    - occ (np.ndarray): <br>
1271        2D uint8 occlusion map where non-zero values represent walls.
1272    - ignore_ends (int): <br>
1273        Number of pixels near segment endpoints to ignore during
1274        raster testing. Helps avoid false occlusion from grazing
1275        or endpoint-touching cases.
1276
1277    Returns:
1278    - bool: <br>
1279        True if all path segments are visible, False otherwise.
1280    """
1281    # paths with 0 or 1 point cannot be occluded
1282    if path_len <= 1:
1283        return True
1284    
1285    # check each consecutive segment of the path
1286    for i in range(path_len - 1):
1287        p1x = path_xy[i, 0]
1288        p1y = path_xy[i, 1]
1289        p2x = path_xy[i + 1, 0]
1290        p2y = path_xy[i + 1, 1]
1291
1292        # raster visibility test for this segment
1293        if not is_visible_raster(p1x, p1y, p2x, p2y, occ, ignore_ends):
1294            return False
1295        
1296    # all segments are visible
1297    return True
1298
1299
1300
1301def build_receivers_grid(H: int, W: int, step_px: int) -> np.ndarray:
1302    """
1303    Build a sparse grid of receiver positions over the image domain.
1304
1305    The receivers are placed on a regular grid with spacing `step_px`
1306    in both x and y directions. Each receiver is positioned at the
1307    pixel center using the convention (x + 0.5, y + 0.5), which matches
1308    how raster pixels are typically interpreted in the reflection kernel.
1309
1310    The result is a flat list of 2D receiver coordinates that can be
1311    iterated efficiently inside Numba kernels without additional
1312    meshgrid logic.
1313
1314    Parameters:
1315    - H (int): <br>
1316        Image height in pixels.
1317    - W (int): <br>
1318        Image width in pixels.
1319    - step_px (int): <br>
1320        Spacing between receivers in pixels. Larger values reduce the
1321        number of receivers and speed up computation at the cost of
1322        spatial resolution.
1323
1324    Returns:
1325    - np.ndarray: <br>
1326        Array of shape (N, 2) with dtype float32 containing receiver
1327        coordinates as (x+0.5, y+0.5).
1328    """
1329    # generate x and y sample positions at pixel centers
1330    xs = (np.arange(0, W, step_px, dtype=np.float32) + 0.5)
1331    ys = (np.arange(0, H, step_px, dtype=np.float32) + 0.5)
1332    
1333    # create a full grid of receiver coordinates
1334    xx, yy = np.meshgrid(xs, ys)
1335
1336    # pack into a flat (N,2) array for efficient iteration
1337    receivers = np.empty((xx.size, 2), dtype=np.float32)
1338    receivers[:, 0] = xx.reshape(-1)
1339    receivers[:, 1] = yy.reshape(-1)
1340    return receivers
1341
1342
1343
1344# -------------------------------
1345# >>> Main: noise / hit maps
1346# -------------------------------
1347
1348@numba.njit(cache=True, fastmath=True, parallel=True)
1349def eval_receivers_to_map_kernel(
1350    receivers: np.ndarray,      # (N,2) float32; contains x+0.5, y+0.5
1351    sx: float, sy: float,
1352    walls4: np.ndarray,         # (nWalls,4) float32
1353    occ: np.ndarray,            # (H,W) uint8
1354    max_order: int,
1355    ignore_zero_order: int,     # 0/1
1356    ignore_ends: int,
1357    seq_data: np.ndarray,       # (total,) int32
1358    seq_off: np.ndarray,        # (nSeq,)  int32  (start offset per seq)
1359    seq_len: np.ndarray,        # (nSeq,)  int32
1360    simg_x: np.ndarray,         # (nSeq,)  float32
1361    simg_y: np.ndarray,         # (nSeq,)  float32
1362    start_seq: int,
1363    end_seq: int,
1364    output_map: np.ndarray      # (H,W) float32
1365):
1366    H = output_map.shape[0]
1367    W = output_map.shape[1]
1368    nR = receivers.shape[0]
1369
1370    # iterate over all receiver positions in parallel
1371    for ri in numba.prange(nR):
1372        rx = float(receivers[ri, 0])
1373        ry = float(receivers[ri, 1])
1374
1375        # convert receiver center coordinate back to integer pixel index
1376        ix = int(rx)
1377        iy = int(ry)
1378
1379        # skip if receiver lies outside the image bounds
1380        if ix < 0 or ix >= W or iy < 0 or iy >= H:
1381            continue
1382
1383        # accumulate contribution for this receiver pixel
1384        val = 0.0
1385
1386        # local buffer for reconstructed reflection path
1387        # size is max_order reflections + source + receiver
1388        path_out = np.empty((max_order + 2, 2), dtype=np.float32)
1389
1390        # iterate over the subset of precomputed reflection sequences
1391        for si in range(start_seq, end_seq):
1392            L = int(seq_len[si])
1393            # optionally skip direct line-of-sight paths
1394            if ignore_zero_order == 1 and L == 0:
1395                continue
1396
1397            # offset into the packed wall-index array for this sequence
1398            off0 = int(seq_off[si])
1399
1400            # precomputed image source position for this wall sequence
1401            s_imgx = float(simg_x[si])
1402            s_imgy = float(simg_y[si])
1403
1404            # reconstruct the geometric reflection path for this receiver
1405            path_len, ok = build_path_for_sequence_packed(
1406                sx, sy,
1407                rx, ry,
1408                seq_data, off0, L,
1409                s_imgx, s_imgy,
1410                walls4,
1411                path_out
1412            )
1413            if not ok:
1414                continue
1415
1416            # check whether the path is occluded by any wall pixels
1417            if not check_path_visibility_raster_arr(path_out, path_len, occ, ignore_ends):
1418                continue
1419
1420            # valid visible reflection path contributes to this pixel
1421            val += 1.0
1422
1423        # safe to write because each receiver maps to a unique pixel
1424        output_map[iy, ix] += val
1425
1426
1427
1428def compute_reflection_map(
1429    source_rel,
1430    img,
1431    wall_values,
1432    wall_thickness:int=1,
1433    approx_epsilon:float=1.5,
1434    max_order:int=1,
1435    ignore_zero_order=False,
1436    step_px:int=1,
1437    forbid_immediate_repeat: bool = True,
1438    max_candidates=None,
1439    ignore_ends:int=1,
1440    iterative_tracking=False,
1441    iterative_steps=None
1442):
1443    """
1444    Compute a 2D "reflection contribution" map over an image grid using
1445    precomputed specular image sources and wall reflections.
1446
1447    The function:
1448    1. Converts the input image to grayscale (if needed)
1449    2. Builds a wall mask from the image based on provided wall pixel values
1450    3. Extracts wall segments from that mask and builds an occlusion structure
1451    4. Precomputes valid wall-reflection sequences (up to `max_order`) and their
1452       corresponding image-source positions
1453    5. Evaluates reflection paths from a real source to a grid of receiver points,
1454       accumulating per-receiver contributions into `output_map`
1455
1456    Optionally, the computation can be performed in chunks and intermediate
1457    snapshots of the partially accumulated `output_map` can be returned
1458    (`iterative_tracking=True`).
1459
1460    Parameters:
1461    - source_rel (Tuple[float, float] | np.ndarray): <br>
1462        Source position in *relative* image coordinates (x_rel, y_rel) in [0, 1].
1463        Converted internally to pixel coordinates via (x_rel*W, y_rel*H).
1464    - img (np.ndarray): <br>
1465        Input image. If 3D (H, W, C), only the first channel is used.
1466        The wall mask is derived from this image.
1467    - wall_values (Iterable[int] | np.ndarray): <br>
1468        Pixel values in `img` that should be treated as "wall" when building the
1469        wall mask (exact matching logic depends on `build_wall_mask`).
1470    - wall_thickness (int): <br>
1471        Thickness (in pixels) used when extracting wall segments and building
1472        the occlusion representation.
1473    - approx_epsilon (float): <br>
1474        Polygon/segment simplification tolerance used during wall extraction
1475        (`get_wall_segments_from_mask`).
1476    - max_order (int): <br>
1477        Maximum reflection order (number of wall bounces) to consider when
1478        precomputing image sources.
1479    - ignore_zero_order (bool): <br>
1480        If True, exclude the direct (0-bounce / line-of-sight) contribution.
1481    - step_px (int): <br>
1482        Receiver grid spacing in pixels. Larger values reduce runtime by
1483        evaluating fewer receiver positions.
1484    - forbid_immediate_repeat (bool): <br>
1485        If True, disallow reflection sequences that repeat the same wall in
1486        consecutive steps (helps avoid degenerate/near-degenerate paths).
1487    - max_candidates (int | None): <br>
1488        Optional cap on the number of candidate sequences/image sources kept
1489        during precomputation.
1490    - ignore_ends (int): <br>
1491        Number of wall endpoints to ignore during intersection tests (used to
1492        stabilize grazing / endpoint-hitting cases).
1493    - iterative_tracking (bool): <br>
1494        If True, compute in chunks and return intermediate `output_map` snapshots
1495        as a list of arrays. Useful for debugging/profiling progress.
1496    - iterative_steps (int | None): <br>
1497        Controls how many snapshots to collect when `iterative_tracking=True`:
1498        - None  -> treated as -1 (snapshot every chunk of size 1 sequence)
1499        - -1    -> snapshot after every sequence (finest granularity)
1500        - 1     -> snapshot only once at the end (same as full run)
1501        - k>1   -> snapshot roughly `k` times over the sequence list
1502
1503    Returns:
1504    - np.ndarray | List[np.ndarray]: <br>
1505        If `iterative_tracking` is False: returns `output_map` (H, W) float32.<br>
1506        If `iterative_tracking` is True: returns a list of snapshot maps, each
1507        shaped (H, W) float32, showing accumulation progress.
1508    """
1509    # If we received a color image, use only the first channel (expected: walls encoded per-pixel)
1510    if img.ndim == 3:
1511        img = img[..., 0]
1512
1513    # Image dimensions and source location in absolute pixel coordinates
1514    H, W = img.shape[:2]
1515    sx = float(source_rel[0] * W)
1516    sy = float(source_rel[1] * H)
1517
1518    # Build a binary wall mask (0/255), then extract simplified wall segments
1519    wall_mask_255 = build_wall_mask(img, wall_values=wall_values)
1520    walls = get_wall_segments_from_mask(
1521        wall_mask_255,
1522        thickness=wall_thickness,
1523        approx_epsilon=approx_epsilon
1524    )
1525
1526    # Precompute an occlusion structure derived from the wall mask
1527    occ = build_occlusion_from_wallmask(wall_mask_255, wall_thickness=wall_thickness)
1528
1529    # Convert wall segments into a packed (N,4) representation [ax, ay, bx, by]
1530    #    -> for numba usage
1531    walls4 = segments_to_walls4(walls)
1532
1533    # Precompute image sources + wall reflection sequences up to `max_order`
1534    # Each sequence describes the ordered wall indices to reflect against
1535    pre_py = precompute_image_sources(
1536        source_xy=(sx, sy),
1537        walls=walls,
1538        max_order=max_order,
1539        forbid_immediate_repeat=forbid_immediate_repeat,
1540        max_candidates=max_candidates,
1541    )
1542
1543    # Pack variable-length sequences into flat arrays for fast/Numba-friendly access
1544    seq_data, seq_off, seq_len, simg_x, simg_y = pack_precomputed(pre_py)
1545
1546    # Create a grid of receiver points
1547    receivers = build_receivers_grid(H, W, step_px)
1548
1549    # Accumulated output map (same resolution as the input image)
1550    output_map = np.zeros((H, W), dtype=np.float32)
1551
1552    # Optional progressive snapshots of accumulation -> iterative output
1553    if iterative_tracking:
1554        snapshots = []
1555
1556    # Default: snapshot every sequence (finest granularity) if tracking is enabled.
1557    if iterative_steps is None:
1558        iterative_steps = -1
1559
1560    nseq = len(pre_py)
1561
1562    # Decide chunk size (how many sequences per kernel call) based on snapshot settings
1563    if (not iterative_tracking) or (iterative_tracking and iterative_steps == 1):
1564        # Single call covering all sequences (or one snapshot at the end)
1565        snapshot_every_x_steps = nseq
1566    elif iterative_tracking and iterative_steps == -1:
1567        # Snapshot after every sequence
1568        snapshot_every_x_steps = 1
1569    else:
1570        # Snapshot about `iterative_steps` times, compute chunk size accordingly
1571        snapshot_every_x_steps = max(1, int(nseq // iterative_steps))
1572
1573    # Convert boolean to int for Numba kernel
1574    ignore_zero_order_i = 1 if ignore_zero_order else 0
1575
1576    # Evaluate reflection contributions in chunks of sequences for better control over snapshots
1577    for start_idx in range(0, nseq, snapshot_every_x_steps):
1578        end_idx = min(nseq, start_idx + snapshot_every_x_steps)
1579
1580        # Core kernel: for each receiver and each sequence in [start_idx, end_idx),
1581        # validate/specular-trace the path (including occlusion) and accumulate into output_map
1582        eval_receivers_to_map_kernel(
1583            receivers,
1584            sx, sy,
1585            walls4,
1586            occ,
1587            max_order,
1588            ignore_zero_order_i,
1589            ignore_ends,
1590            seq_data,
1591            seq_off,
1592            seq_len,
1593            simg_x,
1594            simg_y,
1595            start_idx,
1596            end_idx,
1597            output_map
1598        )
1599
1600        # Store a copy so later updates don't mutate previous snapshots
1601        if iterative_tracking:
1602            snapshots.append(output_map.copy())
1603
1604    # Return either the final map or the progressive snapshots
1605    if not iterative_tracking:
1606        return output_map
1607    else:
1608        return snapshots
def reflect_point_across_infinite_line( P: Tuple[float, float], A: Tuple[float, float], B: Tuple[float, float]) -> Tuple[float, float]:
163def reflect_point_across_infinite_line(P: Tuple[float, float], A: Tuple[float, float], B: Tuple[float, float]) -> Tuple[float, float]:
164    """
165    Reflect a 2D point across an infinite line.
166
167    Computes the mirror image of point P with respect to the infinite line
168    passing through points A and B.
169
170                  P
171                  *
172                   \
173                    \
174    -----------------*----------------   Wand (A-B)
175                    proj
176                      \
177                       \
178                        *
179                        P'
180
181    Parameters:
182    - P (Tuple[float, float]): <br>
183        The point to reflect (x, y).
184    - A (Tuple[float, float]): <br>
185        First point defining the line (x, y).
186    - B (Tuple[float, float]): <br>
187        Second point defining the line (x, y).
188
189    Returns:
190    - Tuple[float, float]: <br>
191        The reflected point (x, y) as floats.
192    """
193    # var setup
194    #    -> A/B are both points on the wall
195    #    -> P is the point we want to mirror (not receiver, source ,...)
196    x1, y1 = A
197    x2, y2 = B
198    px, py = P
199
200    # get direction of the wall
201    #    -> from point to the wall
202    ABx = x2 - x1
203    ABy = y2 - y1
204    # norm this wall direction
205    n = math.hypot(ABx, ABy) + 1e-12
206    ux, uy = ABx / n, ABy / n
207
208    # direction/vector from the wall to the point
209    APx = px - x1
210    APy = py - y1
211
212    # projection of point to wall direction to the wall direction
213    #   -> finding sweetspot where we are directly under P on wall line/direction
214    t = APx * ux + APy * uy
215    # point on wall direction/line which is directly under P
216    projx = x1 + t * ux
217    projy = y1 + t * uy
218
219    # calc reflection/mirror
220    rx = projx + (projx - px)
221    ry = projy + (projy - py)
222
223    return (float(rx), float(ry))

Reflect a 2D point across an infinite line.

Computes the mirror image of point P with respect to the infinite line passing through points A and B.

          P
          *
                                   -----------------*----------------   Wand (A-B)
            proj
                                                             *
                P'

Parameters:

  • P (Tuple[float, float]):
    The point to reflect (x, y).
  • A (Tuple[float, float]):
    First point defining the line (x, y).
  • B (Tuple[float, float]):
    Second point defining the line (x, y).

Returns:

  • Tuple[float, float]:
    The reflected point (x, y) as floats.
@numba.njit(cache=True, fastmath=True)
def reflect_point_across_infinite_line_numba(px, py, ax, ay, bx, by):
227@numba.njit(cache=True, fastmath=True)
228def reflect_point_across_infinite_line_numba(px, py, ax, ay, bx, by):
229    """
230    Reflect a 2D point across an infinite line defined by two points.
231
232    Computes the mirror image of point P = (px, py) with respect to the
233    infinite line passing through A = (ax, ay) and B = (bx, by).
234    The function first projects P orthogonally onto the line AB to obtain
235    the foot point (projection), and then reflects P across this point
236    using the relation:
237
238        P' = 2 * proj - P
239
240    This implementation is written in a Numba-friendly style and avoids
241    expensive operations such as `math.hypot`, using only basic arithmetic
242    and a square root for normalization.
243
244    Parameters:
245    - px (float): <br>
246        x-coordinate of the point to reflect.
247    - py (float): <br>
248        y-coordinate of the point to reflect.
249    - ax (float): <br>
250        x-coordinate of the first point defining the line.
251    - ay (float): <br>
252        y-coordinate of the first point defining the line.
253    - bx (float): <br>
254        x-coordinate of the second point defining the line.
255    - by (float): <br>
256        y-coordinate of the second point defining the line.
257
258    Returns:
259    - Tuple[float, float]: <br>
260        The reflected point (rx, ry) across the infinite line AB.
261    """
262    # direction vector of the wall segment A -> B
263    abx = bx - ax
264    aby = by - ay
265
266    # normalize to unit direction vector
267    # small epsilon avoids division by zero for degenerate segments
268    n = math.sqrt(abx * abx + aby * aby) + 1e-12
269    ux = abx / n
270    uy = aby / n
271
272    # vector from A to the point P
273    apx = px - ax
274    apy = py - ay
275
276    # projection of AP onto the wall direction
277    t = apx * ux + apy * uy
278    projx = ax + t * ux
279    projy = ay + t * uy
280
281    # reflect point across the infinite line defined by A-B
282    # P' = 2 * projection - P
283    rx = 2.0 * projx - px
284    ry = 2.0 * projy - py
285
286    return rx, ry

Reflect a 2D point across an infinite line defined by two points.

Computes the mirror image of point P = (px, py) with respect to the infinite line passing through A = (ax, ay) and B = (bx, by). The function first projects P orthogonally onto the line AB to obtain the foot point (projection), and then reflects P across this point using the relation:

P' = 2 * proj - P

This implementation is written in a Numba-friendly style and avoids expensive operations such as math.hypot, using only basic arithmetic and a square root for normalization.

Parameters:

  • px (float):
    x-coordinate of the point to reflect.
  • py (float):
    y-coordinate of the point to reflect.
  • ax (float):
    x-coordinate of the first point defining the line.
  • ay (float):
    y-coordinate of the first point defining the line.
  • bx (float):
    x-coordinate of the second point defining the line.
  • by (float):
    y-coordinate of the second point defining the line.

Returns:

  • Tuple[float, float]:
    The reflected point (rx, ry) across the infinite line AB.
def reflection_map_to_img(reflection_map):
290def reflection_map_to_img(reflection_map):
291    """
292    Convert an reflection map to a uint8 visualization image.
293
294    Normalizes the input reflection_map to [0, 255] by dividing by its maximum
295    value (with an epsilon for numerical stability), and converts the result
296    to uint8.
297
298    Parameters:
299    - reflection_map (np.ndarray): <br>
300        A numeric array representing reflection values.
301
302    Returns:
303    - np.ndarray: <br>
304        A uint8 image array with values in [0, 255].
305    """
306    vis = reflection_map.copy()
307    vis = vis / (vis.max() + 1e-9)
308    return (vis * 255).astype(np.float64)  # .astype(np.uint8)

Convert an reflection map to a uint8 visualization image.

Normalizes the input reflection_map to [0, 255] by dividing by its maximum value (with an epsilon for numerical stability), and converts the result to uint8.

Parameters:

  • reflection_map (np.ndarray):
    A numeric array representing reflection values.

Returns:

  • np.ndarray:
    A uint8 image array with values in [0, 255].
@numba.njit(cache=True, fastmath=True)
def seg_seg_intersection_xy(x1, y1, x2, y2, x3, y3, x4, y4, eps=1e-09):
316@numba.njit(cache=True, fastmath=True)
317def seg_seg_intersection_xy(x1, y1, x2, y2, x3, y3, x4, y4, eps=1e-9):
318    """
319    Compute the intersection point of two 2D line segments.
320
321    Determines whether the segment P1→P2 intersects with the segment Q1→Q2.
322    The computation is performed using a parametric form and 2D cross products.
323    If a unique intersection point exists within both segment bounds
324    (including small numerical tolerances), the intersection coordinates are
325    returned together with a success flag.
326
327    Parallel or colinear segments are treated as non-intersecting for the
328    purposes of the Image Source Model, since such cases are ambiguous for
329    specular reflection path construction.
330
331    Parameters:
332    - x1 (float): <br>
333        x-coordinate of the first endpoint of segment P.
334    - y1 (float): <br>
335        y-coordinate of the first endpoint of segment P.
336    - x2 (float): <br>
337        x-coordinate of the second endpoint of segment P.
338    - y2 (float): <br>
339        y-coordinate of the second endpoint of segment P.
340    - x3 (float): <br>
341        x-coordinate of the first endpoint of segment Q.
342    - y3 (float): <br>
343        y-coordinate of the first endpoint of segment Q.
344    - x4 (float): <br>
345        x-coordinate of the second endpoint of segment Q.
346    - y4 (float): <br>
347        y-coordinate of the second endpoint of segment Q.
348    - eps (float): <br>
349        Numerical tolerance used to handle floating point inaccuracies
350        when testing for parallelism and segment bounds.
351
352    Returns:
353    - Tuple[float, float, bool]: <br>
354        (ix, iy, ok) where (ix, iy) is the intersection point if one exists,
355        and `ok` indicates whether a valid intersection was found.
356    """
357    # r = segment p1 -> p2
358    rx = x2 - x1
359    ry = y2 - y1
360
361    # s = segment q1 -> q2
362    sx = x4 - x3
363    sy = y4 - y3
364
365    # cross product r x s used to detect parallelism
366    rxs = rx * sy - ry * sx
367
368    # vector from p1 to q1
369    qpx = x3 - x1
370    qpy = y3 - y1
371
372    # cross product (q1 - p1) x r
373    qpxr = qpx * ry - qpy * rx
374
375    # if r x s is near zero the segments are parallel or colinear
376    # for the reflection model this is treated as an invalid intersection
377    if abs(rxs) <= eps:
378        # parallel or colinear -> treat as invalid for your ISM
379        return 0.0, 0.0, False
380
381    # solve intersection parameters for the parametric segment equations
382    t = (qpx * sy - qpy * sx) / rxs
383    u = (qpx * ry - qpy * rx) / rxs
384
385    # check whether intersection lies within both segments (with epsilon tolerance)
386    if (-eps <= t <= 1.0 + eps) and (-eps <= u <= 1.0 + eps):
387        ix = x1 + t * rx
388        iy = y1 + t * ry
389        return ix, iy, True
390
391    # no valid intersection on the finite segments
392    return 0.0, 0.0, False

Compute the intersection point of two 2D line segments.

Determines whether the segment P1→P2 intersects with the segment Q1→Q2. The computation is performed using a parametric form and 2D cross products. If a unique intersection point exists within both segment bounds (including small numerical tolerances), the intersection coordinates are returned together with a success flag.

Parallel or colinear segments are treated as non-intersecting for the purposes of the Image Source Model, since such cases are ambiguous for specular reflection path construction.

Parameters:

  • x1 (float):
    x-coordinate of the first endpoint of segment P.
  • y1 (float):
    y-coordinate of the first endpoint of segment P.
  • x2 (float):
    x-coordinate of the second endpoint of segment P.
  • y2 (float):
    y-coordinate of the second endpoint of segment P.
  • x3 (float):
    x-coordinate of the first endpoint of segment Q.
  • y3 (float):
    y-coordinate of the first endpoint of segment Q.
  • x4 (float):
    x-coordinate of the second endpoint of segment Q.
  • y4 (float):
    y-coordinate of the second endpoint of segment Q.
  • eps (float):
    Numerical tolerance used to handle floating point inaccuracies when testing for parallelism and segment bounds.

Returns:

  • Tuple[float, float, bool]:
    (ix, iy, ok) where (ix, iy) is the intersection point if one exists, and ok indicates whether a valid intersection was found.
@numba.njit(cache=True, fastmath=True)
def is_visible_raster( p1x: float, p1y: float, p2x: float, p2y: float, occ: numpy.ndarray, ignore_ends: int = 1) -> bool:
400@numba.njit(cache=True, fastmath=True)
401def is_visible_raster(p1x:float, p1y:float, p2x:float, p2y:float, occ:np.ndarray, ignore_ends:int=1) -> bool:
402    """
403    Check line-of-sight visibility between two points using a raster occlusion map.
404
405    This function traces a discrete line between two pixel positions using
406    Bresenham's line algorithm and tests whether any raster cell along this
407    line is marked as occluded in the provided occlusion map `occ`.
408    The algorithm operates entirely in integer pixel space and is written in
409    a Numba-friendly style without temporary allocations.
410
411    To allow valid reflection paths that touch wall endpoints, a configurable
412    number of pixels at the start and end of the line can be ignored via
413    `ignore_ends`. This prevents false occlusion detections when rays start
414    or end directly on wall pixels.
415
416    The Bresenham traversal is executed twice:
417    1. First pass: counts the number of raster points along the line to
418    determine the valid range after removing the ignored endpoints.
419    2. Second pass: checks only the relevant interior pixels for occlusion.
420
421    Taken from https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm -> last code shown on the website.
422
423    Parameters:
424    - p1x (float): <br>
425        x-coordinate of the start point in pixel space.
426    - p1y (float): <br>
427        y-coordinate of the start point in pixel space.
428    - p2x (float): <br>
429        x-coordinate of the end point in pixel space.
430    - p2y (float): <br>
431        y-coordinate of the end point in pixel space.
432    - occ (np.ndarray): <br>
433        Occlusion map where nonzero values represent blocked pixels.
434        Expected indexing is `occ[y, x]`.
435    - ignore_ends (int): <br>
436        Number of raster pixels to ignore at both ends of the line.
437
438    Returns:
439    - bool: <br>
440        True if the path between the two points is free of occlusions,
441        otherwise False.
442    """
443    H = occ.shape[0]
444    W = occ.shape[1]
445
446    # round always up -> so + 0.5
447    x0 = int(p1x + 0.5)
448    y0 = int(p1y + 0.5)
449    x1 = int(p2x + 0.5)
450    y1 = int(p2y + 0.5)
451
452    # clamp endpoints -> should be inside
453    if x0 < 0: x0 = 0
454    if x0 >= W: x0 = W - 1
455    if y0 < 0: y0 = 0
456    if y0 >= H: y0 = H - 1
457    if x1 < 0: x1 = 0
458    if x1 >= W: x1 = W - 1
459    if y1 < 0: y1 = 0
460    if y1 >= H: y1 = H - 1
461
462    # preperation for breseham algorithm
463    # err -> accumulator to decide to go in x or y direction
464    dx = abs(x1 - x0)
465    dy = abs(y1 - y0)
466
467    sx = 1 if x0 < x1 else -1
468    sy = 1 if y0 < y1 else -1
469
470    err = dx - dy
471
472    # 1. calc length (amount of grid points -> for ignore ends
473    # => count grid points between the 2 points
474    # => later we can then say ignore the first and last N pixels
475    tx, ty = x0, y0
476    terr = err
477    npts = 1
478    while not (tx == x1 and ty == y1):
479        # bresenham algorithm to move in pixel-space
480        e2 = terr * 2
481        if e2 > -dy:
482            terr -= dy
483            tx += sx
484        if e2 < dx:
485            terr += dx
486            ty += sy
487        # increase grid points
488        npts += 1
489
490    # not enough grid points -> visible!
491    # only start and end point, there cant be something in between
492    if npts <= 2:
493        return True
494
495    # if the ignore parts is cut away
496    # is there even something left to check?
497    start_i = ignore_ends
498    end_i = npts - ignore_ends
499    if start_i >= end_i:
500        return True
501    
502    # 2. run again -> but this time check occlusion
503    tx, ty = x0, y0
504    terr = err
505    i = 0
506    while True:
507        if i >= start_i and i < end_i:
508            # wall between detected?
509            if occ[ty, tx] != 0:
510                return False
511
512        # end of line reached?
513        if tx == x1 and ty == y1:
514            break
515
516        # breseham -> continue line
517        e2 = terr * 2
518        if e2 > -dy:
519            terr -= dy
520            tx += sx
521        if e2 < dx:
522            terr += dx
523            ty += sy
524
525        i += 1
526
527    return True

Check line-of-sight visibility between two points using a raster occlusion map.

This function traces a discrete line between two pixel positions using Bresenham's line algorithm and tests whether any raster cell along this line is marked as occluded in the provided occlusion map occ. The algorithm operates entirely in integer pixel space and is written in a Numba-friendly style without temporary allocations.

To allow valid reflection paths that touch wall endpoints, a configurable number of pixels at the start and end of the line can be ignored via ignore_ends. This prevents false occlusion detections when rays start or end directly on wall pixels.

The Bresenham traversal is executed twice:

  1. First pass: counts the number of raster points along the line to determine the valid range after removing the ignored endpoints.
  2. Second pass: checks only the relevant interior pixels for occlusion.

Taken from https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm -> last code shown on the website.

Parameters:

  • p1x (float):
    x-coordinate of the start point in pixel space.
  • p1y (float):
    y-coordinate of the start point in pixel space.
  • p2x (float):
    x-coordinate of the end point in pixel space.
  • p2y (float):
    y-coordinate of the end point in pixel space.
  • occ (np.ndarray):
    Occlusion map where nonzero values represent blocked pixels. Expected indexing is occ[y, x].
  • ignore_ends (int):
    Number of raster pixels to ignore at both ends of the line.

Returns:

  • bool:
    True if the path between the two points is free of occlusions, otherwise False.
@dataclass(frozen=True)
class Segment:
534@dataclass(frozen=True)
535class Segment:
536    """
537    Represent a 2D line segment.
538
539    A lightweight immutable segment representation used for wall geometry
540    and intersection tests.
541
542    Attributes:
543    - ax (float): x-coordinate of the first endpoint.
544    - ay (float): y-coordinate of the first endpoint.
545    - bx (float): x-coordinate of the second endpoint.
546    - by (float): y-coordinate of the second endpoint.
547
548    Properties:
549    - A (Tuple[float, float]): <br>
550        First endpoint (ax, ay).
551    - B (Tuple[float, float]): <br>
552        Second endpoint (bx, by).
553    """
554    ax: float
555    ay: float
556    bx: float
557    by: float
558
559    @property
560    def A(self): return (self.ax, self.ay)
561
562    @property
563    def B(self): return (self.bx, self.by)

Represent a 2D line segment.

A lightweight immutable segment representation used for wall geometry and intersection tests.

Attributes:

  • ax (float): x-coordinate of the first endpoint.
  • ay (float): y-coordinate of the first endpoint.
  • bx (float): x-coordinate of the second endpoint.
  • by (float): y-coordinate of the second endpoint.

Properties:

  • A (Tuple[float, float]):
    First endpoint (ax, ay).
  • B (Tuple[float, float]):
    Second endpoint (bx, by).
Segment(ax: float, ay: float, bx: float, by: float)
ax: float
ay: float
bx: float
by: float
A
559    @property
560    def A(self): return (self.ax, self.ay)
B
562    @property
563    def B(self): return (self.bx, self.by)
@numba.njit(cache=True, fastmath=True)
def build_wall_mask_numba_no_values(img_u8):
594@numba.njit(cache=True, fastmath=True)
595def build_wall_mask_numba_no_values(img_u8):
596    """
597    Build a 0/255 wall mask from a uint8 image when no explicit wall values are given.
598
599    This function assumes that the input image is already mask-like. If the
600    maximum pixel value is small (e.g. < 64), the image is treated as a
601    binary or low-range mask and scaled to the range {0, 255}. Otherwise,
602    the values are copied directly, assuming the image already represents
603    a proper wall mask.
604
605    The implementation is written in a Numba-friendly style and operates
606    purely with explicit loops for maximum compatibility and performance.
607
608    Parameters:
609    - img_u8 (np.ndarray): <br>
610        2D uint8 image interpreted as a mask-like input.
611
612    Returns:
613    - np.ndarray: <br>
614        A uint8 wall mask with values 0 (free space) and 255 (wall).
615    """
616    # img_u8: uint8 2D
617    H, W = img_u8.shape[0], img_u8.shape[1]
618
619    # determine maximum value in the image to infer its encoding
620    m = _max_u8(img_u8)
621
622    out = np.empty((H, W), dtype=np.uint8)
623
624    if m < 64:
625        # image likely represents a 0/1 style mask
626        # scale any non-zero value up to 255 for consistency
627        for y in range(H):
628            for x in range(W):
629                out[y, x] = 255 if img_u8[y, x] != 0 else 0
630    else:
631        # image already looks like a proper uint8 mask or label image
632        # simply copy values over
633        for y in range(H):
634            for x in range(W):
635                out[y, x] = img_u8[y, x]
636    
637    return out

Build a 0/255 wall mask from a uint8 image when no explicit wall values are given.

This function assumes that the input image is already mask-like. If the maximum pixel value is small (e.g. < 64), the image is treated as a binary or low-range mask and scaled to the range {0, 255}. Otherwise, the values are copied directly, assuming the image already represents a proper wall mask.

The implementation is written in a Numba-friendly style and operates purely with explicit loops for maximum compatibility and performance.

Parameters:

  • img_u8 (np.ndarray):
    2D uint8 image interpreted as a mask-like input.

Returns:

  • np.ndarray:
    A uint8 wall mask with values 0 (free space) and 255 (wall).
@numba.njit(cache=True, fastmath=True)
def build_wall_mask_numba_values(img_u8, wall_values_i32):
641@numba.njit(cache=True, fastmath=True)
642def build_wall_mask_numba_values(img_u8, wall_values_i32):
643    """
644    Build a 0/255 wall mask from a uint8 image using explicit wall label values.
645
646    Each pixel in the input image is compared against a list of wall label
647    values. If a pixel matches any of the provided values, it is marked as
648    a wall (255), otherwise it is marked as free space (0).
649
650    This replaces the typical `np.isin` operation with explicit loops to
651    remain fully compatible with Numba's nopython mode.
652
653    Parameters:
654    - img_u8 (np.ndarray): <br>
655        2D uint8 image containing label values.
656    - wall_values_i32 (np.ndarray): <br>
657        1D array of int32 values that should be interpreted as walls.
658
659    Returns:
660    - np.ndarray: <br>
661        A uint8 wall mask with values 0 (free space) and 255 (wall).
662    """
663    # img_u8: uint8 2D
664    # wall_values_i32: int32 1D, e.g. np.array([3,7,9], np.int32)
665    H, W = img_u8.shape[0], img_u8.shape[1]
666    out = np.empty((H, W), dtype=np.uint8)
667    # number of wall values to compare against
668    nvals = wall_values_i32.shape[0]
669
670    for y in range(H):
671        for x in range(W):
672            # current pixel value
673            v = int(img_u8[y, x])
674
675            # check if this pixel matches any of the wall values
676            is_wall = False
677            for k in range(nvals):
678                if v == int(wall_values_i32[k]):
679                    is_wall = True
680                    break
681
682            # write wall mask pixel
683            out[y, x] = 255 if is_wall else 0
684    return out

Build a 0/255 wall mask from a uint8 image using explicit wall label values.

Each pixel in the input image is compared against a list of wall label values. If a pixel matches any of the provided values, it is marked as a wall (255), otherwise it is marked as free space (0).

This replaces the typical np.isin operation with explicit loops to remain fully compatible with Numba's nopython mode.

Parameters:

  • img_u8 (np.ndarray):
    2D uint8 image containing label values.
  • wall_values_i32 (np.ndarray):
    1D array of int32 values that should be interpreted as walls.

Returns:

  • np.ndarray:
    A uint8 wall mask with values 0 (free space) and 255 (wall).
def build_wall_mask(img: numpy.ndarray, wall_values=None) -> numpy.ndarray:
688def build_wall_mask(img: np.ndarray, wall_values=None) -> np.ndarray:
689    """
690    Build a 0/255 wall mask from an input image.
691
692    This function acts as a Python wrapper that prepares the input data and
693    delegates the actual computation to Numba-optimized kernels.
694
695    If `wall_values` is None, the image is assumed to be mask-like and
696    processed accordingly. Otherwise, the provided label values are used
697    to determine which pixels represent walls.
698
699    The input image must be two-dimensional. If it is not of type uint8,
700    it is converted before processing.
701
702    Parameters:
703    - img (np.ndarray): <br>
704        2D input image representing either a mask-like image or a label map.
705    - wall_values (optional): <br>
706        Iterable of label values that should be interpreted as walls.
707
708    Returns:
709    - np.ndarray: <br>
710        A uint8 wall mask with values 0 (free space) and 255 (wall).
711    """
712    if img.ndim != 2:
713        raise ValueError("build_wall_mask: erwartet 2D img (H,W). RGB vorher konvertieren.")
714
715    if img.dtype != np.uint8:
716        img_u8 = img.astype(np.uint8)
717    else:
718        img_u8 = img
719
720    # if no explicit wall values are given, fall back to a default wall detection routine
721    if wall_values is None:
722        return build_wall_mask_numba_no_values(img_u8)
723
724    # convert wall values to a compact int array for numba-friendly lookup
725    wall_vals = np.asarray(wall_values, dtype=np.int32)
726
727    # build wall mask by checking pixels against the provided wall values
728    return build_wall_mask_numba_values(img_u8, wall_vals)

Build a 0/255 wall mask from an input image.

This function acts as a Python wrapper that prepares the input data and delegates the actual computation to Numba-optimized kernels.

If wall_values is None, the image is assumed to be mask-like and processed accordingly. Otherwise, the provided label values are used to determine which pixels represent walls.

The input image must be two-dimensional. If it is not of type uint8, it is converted before processing.

Parameters:

  • img (np.ndarray):
    2D input image representing either a mask-like image or a label map.
  • wall_values (optional):
    Iterable of label values that should be interpreted as walls.

Returns:

  • np.ndarray:
    A uint8 wall mask with values 0 (free space) and 255 (wall).
def get_wall_segments_from_mask( mask_255: numpy.ndarray, thickness: int = 1, approx_epsilon: float = 1.5) -> List[Segment]:
732def get_wall_segments_from_mask(mask_255: np.ndarray, thickness: int = 1, approx_epsilon: float = 1.5) -> List[Segment]:
733    """
734    Extract wall boundary segments from a binary wall mask.
735
736    Runs edge detection (Canny) and contour extraction to find wall boundaries,
737    then approximates contours to polylines and converts edges into Segment
738    instances.
739
740    Parameters:
741    - mask_255 (np.ndarray): <br>
742        Binary wall mask with values 0 and 255.
743    - thickness (int): <br>
744        Optional dilation thickness applied to edges before contour extraction.
745        Values > 1 thicken edges to improve contour continuity.
746    - approx_epsilon (float): <br>
747        Epsilon value for contour polygon approximation (cv2.approxPolyDP).
748        Higher values simplify contours more aggressively.
749
750    Returns:
751    - List[Segment]: <br>
752        List of wall boundary segments in pixel coordinates.
753    """
754    # detect wall boundaries from the binary mask using Canny edge detection
755    edges = cv2.Canny(mask_255, 100, 200)
756
757    # optionally thicken edges to merge small gaps and stabilize contour extraction
758    if thickness and thickness > 1:
759        k = np.ones((thickness, thickness), np.uint8)
760        edges = cv2.dilate(edges, k, iterations=1)
761
762    # extract external contours of the edge map
763    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
764
765    walls: List[Segment] = []
766    for c in contours:
767        # simplify contour to a polygon with fewer points
768        c2 = cv2.approxPolyDP(c, epsilon=approx_epsilon, closed=True)
769
770        # convert polygon points to float tuples (x, y)
771        pts = [tuple(p[0].astype(float)) for p in c2]
772        if len(pts) < 2:
773            continue
774
775        # create segments along the polygon polyline
776        for i in range(len(pts) - 1):
777            (x1, y1), (x2, y2) = pts[i], pts[i + 1]
778
779            # skip degenerate segments
780            if x1 == x2 and y1 == y2:
781                continue
782            walls.append(Segment(x1, y1, x2, y2))
783
784        # close the loop by connecting the last point back to the first
785        # only do this if we have a valid polygon-like shape
786        if len(pts) >= 3:
787            (x1, y1), (x2, y2) = pts[-1], pts[0]
788            if x1 != x2 or y1 != y2:
789                walls.append(Segment(x1, y1, x2, y2))
790
791    return walls

Extract wall boundary segments from a binary wall mask.

Runs edge detection (Canny) and contour extraction to find wall boundaries, then approximates contours to polylines and converts edges into Segment instances.

Parameters:

  • mask_255 (np.ndarray):
    Binary wall mask with values 0 and 255.
  • thickness (int):
    Optional dilation thickness applied to edges before contour extraction. Values > 1 thicken edges to improve contour continuity.
  • approx_epsilon (float):
    Epsilon value for contour polygon approximation (cv2.approxPolyDP). Higher values simplify contours more aggressively.

Returns:

  • List[Segment]:
    List of wall boundary segments in pixel coordinates.
def segments_to_walls4(walls):
795def segments_to_walls4(walls):
796    """
797    Convert a list of wall segments into a compact float32 array representation.
798
799    Transforms a Python list of `Segment` objects (with endpoints ax/ay and bx/by)
800    into a contiguous NumPy array of shape (N, 4), where each row encodes one wall
801    segment as:
802
803        [ax, ay, bx, by]
804
805    This representation is convenient for passing wall geometry into Numba-compiled
806    kernels, which cannot efficiently work with Python objects or dataclasses.
807
808    Parameters:
809    - walls (List[Segment]): <br>
810        A list of wall segments represented as `Segment` objects.
811
812    Returns:
813    - np.ndarray: <br>
814        A float32 array of shape (N, 4) containing wall endpoints.
815    """
816    walls4 = np.empty((len(walls), 4), dtype=np.float32)
817
818    for i, w in enumerate(walls):
819        walls4[i,0] = w.ax 
820        walls4[i,1] = w.ay
821        walls4[i,2] = w.bx
822        walls4[i,3] = w.by
823
824    return walls4

Convert a list of wall segments into a compact float32 array representation.

Transforms a Python list of Segment objects (with endpoints ax/ay and bx/by) into a contiguous NumPy array of shape (N, 4), where each row encodes one wall segment as:

[ax, ay, bx, by]

This representation is convenient for passing wall geometry into Numba-compiled kernels, which cannot efficiently work with Python objects or dataclasses.

Parameters:

  • walls (List[Segment]):
    A list of wall segments represented as Segment objects.

Returns:

  • np.ndarray:
    A float32 array of shape (N, 4) containing wall endpoints.
@numba.njit(cache=True, fastmath=True)
def dilate_binary_square(src, k):
828@numba.njit(cache=True, fastmath=True)
829def dilate_binary_square(src, k):
830    """
831    Perform binary dilation using a square structuring element.
832
833    Dilates a binary image `src` (values 0/1) using a kxk square kernel for a
834    single iteration. A pixel in the output is set to 1 if any pixel in its
835    kxk neighborhood in the input is nonzero.
836
837    This implementation is written in a Numba-friendly style (explicit loops,
838    no OpenCV dependency) and is useful for thickening walls in an occlusion map.
839
840    Parameters:
841    - src (np.ndarray): <br>
842        2D uint8 array containing binary values {0, 1}.
843    - k (int): <br>
844        Kernel size (wall thickness). Values <= 1 return a copy of `src`.
845
846    Returns:
847    - np.ndarray: <br>
848        A uint8 binary array (0/1) of the same shape as `src` after dilation.
849    """
850    # src: uint8 0/1
851    # k: wall_thickness (>=1)
852
853    # for thickness 1 nothing to do, just return a copy
854    if k <= 1:
855        return src.copy()
856
857    H, W = src.shape[0], src.shape[1]
858
859    # radius of the square neighborhood around each pixel
860    r = k // 2
861
862    # output mask after dilation
863    out = np.zeros((H, W), dtype=np.uint8)
864
865    for y in range(H):
866        # clamp vertical neighborhood to image bounds
867        y0 = y - r
868        y1 = y + r
869        if y0 < 0: y0 = 0
870        if y1 >= H: y1 = H - 1
871
872        for x in range(W):
873            # clamp horizontal neighborhood to image bounds
874            x0 = x - r
875            x1 = x + r
876            if x0 < 0: x0 = 0
877            if x1 >= W: x1 = W - 1
878
879            # check if any pixel in the k x k neighborhood is set
880            # this effectively performs a square dilation of the wall mask
881            found = 0
882            for yy in range(y0, y1 + 1):
883                for xx in range(x0, x1 + 1):
884                    if src[yy, xx] != 0:
885                        found = 1
886                        break
887                if found == 1:
888                    break
889
890            # mark output pixel as wall if any neighbor was a wall
891            out[y, x] = found
892    
893    return out

Perform binary dilation using a square structuring element.

Dilates a binary image src (values 0/1) using a kxk square kernel for a single iteration. A pixel in the output is set to 1 if any pixel in its kxk neighborhood in the input is nonzero.

This implementation is written in a Numba-friendly style (explicit loops, no OpenCV dependency) and is useful for thickening walls in an occlusion map.

Parameters:

  • src (np.ndarray):
    2D uint8 array containing binary values {0, 1}.
  • k (int):
    Kernel size (wall thickness). Values <= 1 return a copy of src.

Returns:

  • np.ndarray:
    A uint8 binary array (0/1) of the same shape as src after dilation.
@numba.njit(cache=True, fastmath=True)
def apply_mask_to_binary(mask_255):
897@numba.njit(cache=True, fastmath=True)
898def apply_mask_to_binary(mask_255):
899    """
900    Convert a 0/255 wall mask into a binary occlusion map.
901
902    Creates a binary occlusion map from a wall mask where nonzero pixels indicate
903    walls. The output contains 0 for free space and 1 for occluded pixels.
904
905    This function is implemented using explicit loops for Numba compatibility.
906
907    Parameters:
908    - mask_255 (np.ndarray): <br>
909        2D uint8 wall mask, typically with values {0, 255}.
910
911    Returns:
912    - np.ndarray: <br>
913        A uint8 binary array of shape (H, W) with values:
914        - 0 for free space
915        - 1 for occluded (wall) pixels
916    """
917    H, W = mask_255.shape[0], mask_255.shape[1]
918    occ = np.empty((H, W), dtype=np.uint8)
919    for y in range(H):
920        for x in range(W):
921            occ[y, x] = 1 if mask_255[y, x] != 0 else 0
922    return occ

Convert a 0/255 wall mask into a binary occlusion map.

Creates a binary occlusion map from a wall mask where nonzero pixels indicate walls. The output contains 0 for free space and 1 for occluded pixels.

This function is implemented using explicit loops for Numba compatibility.

Parameters:

  • mask_255 (np.ndarray):
    2D uint8 wall mask, typically with values {0, 255}.

Returns:

  • np.ndarray:
    A uint8 binary array of shape (H, W) with values:
    • 0 for free space
    • 1 for occluded (wall) pixels
@numba.njit(cache=True, fastmath=True)
def build_occlusion_from_wallmask(mask_255, wall_thickness=1):
926@numba.njit(cache=True, fastmath=True)
927def build_occlusion_from_wallmask(mask_255, wall_thickness=1):
928    """
929    Build a binary occlusion map from a 0/255 wall mask, with optional wall dilation.
930
931    First converts the input wall mask (0/255) into a binary occlusion map (0/1).
932    If `wall_thickness` is greater than 1, the binary map is dilated using a
933    square kernel to increase the effective wall thickness for subsequent
934    visibility checks.
935
936    All computation is performed in Numba-compatible kernels without relying
937    on OpenCV operations.
938
939    Parameters:
940    - mask_255 (np.ndarray): <br>
941        2D uint8 wall mask, typically with values {0, 255}.
942    - wall_thickness (int): <br>
943        If > 1, applies a single binary dilation step with a square kernel of size
944        (wall_thickness x wall_thickness).
945
946    Returns:
947    - np.ndarray: <br>
948        A uint8 binary occlusion map with values:
949        - 0 for free space
950        - 1 for occluded (wall) pixels
951    """
952    occ = apply_mask_to_binary(mask_255)
953    if wall_thickness > 1:
954        occ = dilate_binary_square(occ, wall_thickness)
955    return occ

Build a binary occlusion map from a 0/255 wall mask, with optional wall dilation.

First converts the input wall mask (0/255) into a binary occlusion map (0/1). If wall_thickness is greater than 1, the binary map is dilated using a square kernel to increase the effective wall thickness for subsequent visibility checks.

All computation is performed in Numba-compatible kernels without relying on OpenCV operations.

Parameters:

  • mask_255 (np.ndarray):
    2D uint8 wall mask, typically with values {0, 255}.
  • wall_thickness (int):
    If > 1, applies a single binary dilation step with a square kernel of size (wall_thickness x wall_thickness).

Returns:

  • np.ndarray:
    A uint8 binary occlusion map with values:
    • 0 for free space
    • 1 for occluded (wall) pixels
def enumerate_wall_sequences_indices( n_walls: int, max_order: int, forbid_immediate_repeat: bool = True) -> List[Tuple[int, ...]]:
 963def enumerate_wall_sequences_indices(n_walls: int, max_order: int, forbid_immediate_repeat: bool = True) -> List[Tuple[int, ...]]:
 964    """
 965    Enumerate reflection sequences over wall indices up to a given order.
 966
 967    Generates all sequences of wall indices representing reflection orders
 968    from 0 up to `max_order`. The empty sequence () represents the direct path.
 969
 970    Parameters:
 971    - n_walls (int): <br>
 972        Number of available wall segments.
 973    - max_order (int): <br>
 974        Maximum reflection order (length of sequence).
 975    - forbid_immediate_repeat (bool): <br>
 976        If True, prevents sequences with the same wall repeated consecutively,
 977        e.g., (..., 3, 3) is disallowed.
 978
 979    Returns:
 980    - List[Tuple[int, ...]]: <br>
 981        List of sequences, each a tuple of wall indices.
 982    """
 983    # start with the empty sequence which represents the direct path (no reflections)
 984    seqs = [()]
 985
 986    # iteratively build sequences up to the desired reflection order
 987    for _ in range(max_order):
 988        new = []
 989
 990        # extend all previously known sequences by one additional wall index
 991        for seq in seqs:
 992            for wi in range(n_walls):
 993
 994                # optionally forbid using the same wall twice in a row
 995                # this avoids degenerate mirror-back reflections
 996                if forbid_immediate_repeat and len(seq) > 0 and seq[-1] == wi:
 997                    continue
 998
 999                # create a new sequence with one more reflection
1000                new.append(seq + (wi,))
1001
1002        # append the newly created sequences to the master list
1003        seqs += new
1004
1005    # contains sequences of length 0..max_order
1006    return seqs

Enumerate reflection sequences over wall indices up to a given order.

Generates all sequences of wall indices representing reflection orders from 0 up to max_order. The empty sequence () represents the direct path.

Parameters:

  • n_walls (int):
    Number of available wall segments.
  • max_order (int):
    Maximum reflection order (length of sequence).
  • forbid_immediate_repeat (bool):
    If True, prevents sequences with the same wall repeated consecutively, e.g., (..., 3, 3) is disallowed.

Returns:

  • List[Tuple[int, ...]]:
    List of sequences, each a tuple of wall indices.
def precompute_image_sources( source_xy: Tuple[float, float], walls: List[Segment], max_order: int, forbid_immediate_repeat: bool = True, max_candidates: Optional[int] = None) -> List[Tuple[Tuple[int, ...], Tuple[float, float]]]:
1009def precompute_image_sources(
1010    source_xy: Tuple[float, float],
1011    walls: List[Segment],
1012    max_order: int,
1013    forbid_immediate_repeat: bool = True,
1014    max_candidates: Optional[int] = None,
1015) -> List[Tuple[Tuple[int, ...], Tuple[float, float]]]:
1016    """
1017    Precompute image source positions for reflection sequences.
1018
1019    For every reflection sequence up to `max_order`, the source point is
1020    reflected across the corresponding wall lines to produce an image source
1021    position S_img.
1022
1023    Parameters:
1024    - source_xy (Tuple[float, float]): <br>
1025        Source position in pixel coordinates (x, y).
1026    - walls (List[Segment]): <br>
1027        Wall segments used for reflection.
1028    - max_order (int): <br>
1029        Maximum reflection order to consider.
1030    - forbid_immediate_repeat (bool): <br>
1031        If True, prevents immediate repetition of the same wall in sequences.
1032    - max_candidates (Optional[int]): <br>
1033        If provided, truncates the generated sequence list to at most this many
1034        candidates (useful as a speed cap).
1035
1036    Returns:
1037    - List[Tuple[Tuple[int, ...], Tuple[float, float]]]: <br>
1038        A list of tuples (seq, S_img) where: 
1039        - seq is the wall-index sequence
1040        - S_img is the resulting image source position
1041    """
1042    # enumerate all candidate wall index sequences up to the desired reflection order
1043    seqs = enumerate_wall_sequences_indices(len(walls), max_order, forbid_immediate_repeat)
1044
1045    # optionally cap how many sequences we keep to limit runtime and memory
1046    if max_candidates is not None and len(seqs) > max_candidates:
1047        seqs = seqs[:max_candidates]
1048
1049    # build a list of (sequence, image_source_xy) pairs
1050    # the image source is obtained by reflecting the real source across each wall
1051    # in the sequence in forward order
1052    pre: List[Tuple[Tuple[int, ...], Tuple[float, float]]] = []
1053    for seq in seqs:
1054        # start from the real source position
1055        S_img = source_xy
1056
1057        # apply successive reflections across each wall line
1058        for wi in seq:
1059            w = walls[wi]
1060            S_img = reflect_point_across_infinite_line(S_img, w.A, w.B)
1061
1062        # store the sequence and its final image source position
1063        pre.append((seq, S_img))
1064
1065    return pre

Precompute image source positions for reflection sequences.

For every reflection sequence up to max_order, the source point is reflected across the corresponding wall lines to produce an image source position S_img.

Parameters:

  • source_xy (Tuple[float, float]):
    Source position in pixel coordinates (x, y).
  • walls (List[Segment]):
    Wall segments used for reflection.
  • max_order (int):
    Maximum reflection order to consider.
  • forbid_immediate_repeat (bool):
    If True, prevents immediate repetition of the same wall in sequences.
  • max_candidates (Optional[int]):
    If provided, truncates the generated sequence list to at most this many candidates (useful as a speed cap).

Returns:

  • List[Tuple[Tuple[int, ...], Tuple[float, float]]]:
    A list of tuples (seq, S_img) where:
    • seq is the wall-index sequence
    • S_img is the resulting image source position
def pack_precomputed(pre_py):
1069def pack_precomputed(pre_py):
1070    """
1071    Pack Python reflection sequences into flat arrays for Numba kernels.
1072
1073    The input is a list of tuples where each entry contains:<br>
1074        (sequence_of_wall_indices, (image_source_x, image_source_y))
1075
1076    Since Numba cannot efficiently work with Python lists of variable-length
1077    tuples, this function converts the structure into contiguous flat arrays
1078    plus offset and length metadata so each sequence can be accessed quickly.
1079
1080    The result allows any sequence `i` to be reconstructed as:<br>
1081        seq_data[ seq_off[i] : seq_off[i] + seq_len[i] ]
1082
1083    Parameters:
1084    - pre_py (List[Tuple[Tuple[int], Tuple[float, float]]]): <br>
1085        Python list where each element contains a wall index sequence and
1086        the corresponding precomputed image source position
1087
1088    Returns:
1089    - seq_data (np.ndarray): <br>
1090        Flat int32 array containing all wall indices of all sequences
1091        concatenated back-to-back
1092    - seq_off (np.ndarray): <br>
1093        int32 array giving the start offset of each sequence in `seq_data`
1094    - seq_len (np.ndarray): <br>
1095        int32 array giving the length of each sequence
1096    - simg_x (np.ndarray): <br>
1097        float32 array of image source x-coordinates per sequence
1098    - simg_y (np.ndarray): <br>
1099        float32 array of image source y-coordinates per sequence
1100    """
1101    n = len(pre_py)
1102
1103    # allocate metadata arrays per sequence
1104    seq_len = np.empty(n, dtype=np.int32)
1105    seq_off = np.empty(n, dtype=np.int32)
1106    simg_x  = np.empty(n, dtype=np.float32)
1107    simg_y  = np.empty(n, dtype=np.float32)
1108
1109    # first pass computes lengths, offsets and image source positions
1110    total = 0
1111    for i, (seq_tuple, S_img) in enumerate(pre_py):
1112        L = len(seq_tuple)
1113        seq_len[i] = L
1114        seq_off[i] = total
1115        total += L
1116        simg_x[i] = float(S_img[0])
1117        simg_y[i] = float(S_img[1])
1118
1119    # allocate the flat wall-index array
1120    seq_data = np.empty(total, dtype=np.int32)
1121
1122    # second pass writes all sequences consecutively into seq_data
1123    k = 0
1124    for (seq_tuple, _S_img) in pre_py:
1125        for wi in seq_tuple:
1126            seq_data[k] = int(wi)
1127            k += 1
1128
1129    return seq_data, seq_off, seq_len, simg_x, simg_y

Pack Python reflection sequences into flat arrays for Numba kernels.

The input is a list of tuples where each entry contains:
(sequence_of_wall_indices, (image_source_x, image_source_y))

Since Numba cannot efficiently work with Python lists of variable-length tuples, this function converts the structure into contiguous flat arrays plus offset and length metadata so each sequence can be accessed quickly.

The result allows any sequence i to be reconstructed as:
seq_data[ seq_off[i] : seq_off[i] + seq_len[i] ]

Parameters:

  • pre_py (List[Tuple[Tuple[int], Tuple[float, float]]]):
    Python list where each element contains a wall index sequence and the corresponding precomputed image source position

Returns:

  • seq_data (np.ndarray):
    Flat int32 array containing all wall indices of all sequences concatenated back-to-back
  • seq_off (np.ndarray):
    int32 array giving the start offset of each sequence in seq_data
  • seq_len (np.ndarray):
    int32 array giving the length of each sequence
  • simg_x (np.ndarray):
    float32 array of image source x-coordinates per sequence
  • simg_y (np.ndarray):
    float32 array of image source y-coordinates per sequence
@numba.njit(cache=True, fastmath=True)
def build_path_for_sequence_packed( sx, sy, rx, ry, seq_data, off0, seq_len, s_imgx, s_imgy, walls4, path_out):
1137@numba.njit(cache=True, fastmath=True)
1138def build_path_for_sequence_packed(
1139        sx, sy, rx, ry,
1140        seq_data, off0, seq_len,
1141        s_imgx, s_imgy,
1142        walls4,
1143        path_out
1144    ):
1145    """
1146    Construct a specular reflection path for a packed wall-index sequence.
1147
1148    Builds the geometric reflection path between a real source position and
1149    a receiver position using a precomputed image source and a sequence of
1150    wall indices describing the reflection order.
1151
1152    The algorithm works backwards from the receiver by repeatedly
1153    1. Intersecting the line from the image source to the current virtual
1154      receiver with the corresponding wall segment
1155    2. Reflecting the virtual receiver across that wall line
1156
1157    This produces reflection points in reverse order which are then reversed
1158    in-place to form the final path
1159
1160        [source, r1, r2, ..., receiver]
1161
1162    The function is designed for Numba and writes into a preallocated array
1163    `path_out` instead of creating Python lists.
1164
1165    Parameters:
1166    - sx (float): <br>
1167        x-coordinate of the real source position
1168    - sy (float): <br>
1169        y-coordinate of the real source position
1170    - rx (float): <br>
1171        x-coordinate of the receiver position
1172    - ry (float): <br>
1173        y-coordinate of the receiver position
1174    - seq_data (np.ndarray): <br>
1175        Flat int array containing all sequences concatenated
1176    - off0 (int): <br>
1177        Start offset of this sequence inside `seq_data`
1178    - seq_len (int): <br>
1179        Number of wall indices in this sequence
1180    - s_imgx (float): <br>
1181        x-coordinate of the precomputed image source for this sequence
1182    - s_imgy (float): <br>
1183        y-coordinate of the precomputed image source for this sequence
1184    - walls4 (np.ndarray): <br>
1185        Array of shape (N,4) containing wall segments as [ax, ay, bx, by]
1186    - path_out (np.ndarray): <br>
1187        Preallocated float array of shape (>= seq_len+2, 2) used to store
1188        the resulting path points
1189
1190    Returns:
1191    - Tuple[int, bool]: <br>
1192        (path_length, ok) where `path_length` is the number of valid points
1193        written into `path_out`, and `ok` indicates whether a valid path
1194        could be constructed
1195    """
1196    # direct path with no reflections
1197    if seq_len == 0:
1198        path_out[0, 0] = sx; path_out[0, 1] = sy
1199        path_out[1, 0] = rx; path_out[1, 1] = ry
1200        return 2, True
1201
1202    # start from the real receiver as the current virtual receiver
1203    rvirt_x = rx
1204    rvirt_y = ry
1205
1206    for kk in range(seq_len - 1, -1, -1):
1207        # wall index for this bounce
1208        wi = seq_data[off0 + kk]
1209
1210        # wall segment endpoints
1211        ax = walls4[wi, 0]; ay = walls4[wi, 1]
1212        bx = walls4[wi, 2]; by = walls4[wi, 3]
1213
1214        # intersect ray from image source to virtual receiver with the wall segment
1215        ix, iy, ok = seg_seg_intersection_xy(
1216            s_imgx, s_imgy, rvirt_x, rvirt_y,
1217            ax, ay, bx, by
1218        )
1219        if not ok:
1220            return 0, False
1221
1222        # write intersection as a reflection point in reverse order
1223        path_out[1 + (seq_len - 1 - kk), 0] = ix
1224        path_out[1 + (seq_len - 1 - kk), 1] = iy
1225
1226        # update the virtual receiver by reflecting it across the infinite wall line
1227        rvirt_x, rvirt_y = reflect_point_across_infinite_line_numba(
1228            rvirt_x, rvirt_y, ax, ay, bx, by
1229        )
1230
1231    # reverse reflection points so they go from first bounce to last bounce
1232    i = 1
1233    j = seq_len
1234    while i < j:
1235        tmpx = path_out[i, 0]; tmpy = path_out[i, 1]
1236        path_out[i, 0] = path_out[j, 0]; path_out[i, 1] = path_out[j, 1]
1237        path_out[j, 0] = tmpx;          path_out[j, 1] = tmpy
1238        i += 1
1239        j -= 1
1240
1241    # insert real endpoints to finalize the path
1242    path_out[0, 0] = sx; path_out[0, 1] = sy
1243    path_out[seq_len + 1, 0] = rx
1244    path_out[seq_len + 1, 1] = ry
1245    return seq_len + 2, True

Construct a specular reflection path for a packed wall-index sequence.

Builds the geometric reflection path between a real source position and a receiver position using a precomputed image source and a sequence of wall indices describing the reflection order.

The algorithm works backwards from the receiver by repeatedly

  1. Intersecting the line from the image source to the current virtual receiver with the corresponding wall segment
  2. Reflecting the virtual receiver across that wall line

This produces reflection points in reverse order which are then reversed in-place to form the final path

[source, r1, r2, ..., receiver]

The function is designed for Numba and writes into a preallocated array path_out instead of creating Python lists.

Parameters:

  • sx (float):
    x-coordinate of the real source position
  • sy (float):
    y-coordinate of the real source position
  • rx (float):
    x-coordinate of the receiver position
  • ry (float):
    y-coordinate of the receiver position
  • seq_data (np.ndarray):
    Flat int array containing all sequences concatenated
  • off0 (int):
    Start offset of this sequence inside seq_data
  • seq_len (int):
    Number of wall indices in this sequence
  • s_imgx (float):
    x-coordinate of the precomputed image source for this sequence
  • s_imgy (float):
    y-coordinate of the precomputed image source for this sequence
  • walls4 (np.ndarray):
    Array of shape (N,4) containing wall segments as [ax, ay, bx, by]
  • path_out (np.ndarray):
    Preallocated float array of shape (>= seq_len+2, 2) used to store the resulting path points

Returns:

  • Tuple[int, bool]:
    (path_length, ok) where path_length is the number of valid points written into path_out, and ok indicates whether a valid path could be constructed
@numba.njit(cache=True, fastmath=True)
def check_path_visibility_raster_arr( path_xy: numpy.ndarray, path_len: int, occ: numpy.ndarray, ignore_ends: int) -> bool:
1249@numba.njit(cache=True, fastmath=True)
1250def check_path_visibility_raster_arr(path_xy: np.ndarray, path_len: int, occ: np.ndarray, ignore_ends: int) -> bool:
1251    """
1252    Check whether a reconstructed reflection path is fully visible
1253    in a rasterized occlusion map.
1254
1255    The path consists of consecutive line segments:
1256        [source -> r1 -> r2 -> ... -> receiver]
1257
1258    Each segment is tested against the binary occlusion grid `occ`
1259    using a raster visibility test. If any segment intersects an
1260    occupied pixel (wall), the whole path is considered invalid.
1261
1262    This function is designed for Numba and operates on a preallocated
1263    path buffer without creating Python objects.
1264
1265    Parameters:
1266    - path_xy (np.ndarray): <br>
1267        Array of shape (max_order+2, 2) containing path points as (x, y).
1268        Only the first `path_len` entries are valid.
1269    - path_len (int): <br>
1270        Number of valid points in `path_xy` describing the path.
1271    - occ (np.ndarray): <br>
1272        2D uint8 occlusion map where non-zero values represent walls.
1273    - ignore_ends (int): <br>
1274        Number of pixels near segment endpoints to ignore during
1275        raster testing. Helps avoid false occlusion from grazing
1276        or endpoint-touching cases.
1277
1278    Returns:
1279    - bool: <br>
1280        True if all path segments are visible, False otherwise.
1281    """
1282    # paths with 0 or 1 point cannot be occluded
1283    if path_len <= 1:
1284        return True
1285    
1286    # check each consecutive segment of the path
1287    for i in range(path_len - 1):
1288        p1x = path_xy[i, 0]
1289        p1y = path_xy[i, 1]
1290        p2x = path_xy[i + 1, 0]
1291        p2y = path_xy[i + 1, 1]
1292
1293        # raster visibility test for this segment
1294        if not is_visible_raster(p1x, p1y, p2x, p2y, occ, ignore_ends):
1295            return False
1296        
1297    # all segments are visible
1298    return True

Check whether a reconstructed reflection path is fully visible in a rasterized occlusion map.

The path consists of consecutive line segments: [source -> r1 -> r2 -> ... -> receiver]

Each segment is tested against the binary occlusion grid occ using a raster visibility test. If any segment intersects an occupied pixel (wall), the whole path is considered invalid.

This function is designed for Numba and operates on a preallocated path buffer without creating Python objects.

Parameters:

  • path_xy (np.ndarray):
    Array of shape (max_order+2, 2) containing path points as (x, y). Only the first path_len entries are valid.
  • path_len (int):
    Number of valid points in path_xy describing the path.
  • occ (np.ndarray):
    2D uint8 occlusion map where non-zero values represent walls.
  • ignore_ends (int):
    Number of pixels near segment endpoints to ignore during raster testing. Helps avoid false occlusion from grazing or endpoint-touching cases.

Returns:

  • bool:
    True if all path segments are visible, False otherwise.
def build_receivers_grid(H: int, W: int, step_px: int) -> numpy.ndarray:
1302def build_receivers_grid(H: int, W: int, step_px: int) -> np.ndarray:
1303    """
1304    Build a sparse grid of receiver positions over the image domain.
1305
1306    The receivers are placed on a regular grid with spacing `step_px`
1307    in both x and y directions. Each receiver is positioned at the
1308    pixel center using the convention (x + 0.5, y + 0.5), which matches
1309    how raster pixels are typically interpreted in the reflection kernel.
1310
1311    The result is a flat list of 2D receiver coordinates that can be
1312    iterated efficiently inside Numba kernels without additional
1313    meshgrid logic.
1314
1315    Parameters:
1316    - H (int): <br>
1317        Image height in pixels.
1318    - W (int): <br>
1319        Image width in pixels.
1320    - step_px (int): <br>
1321        Spacing between receivers in pixels. Larger values reduce the
1322        number of receivers and speed up computation at the cost of
1323        spatial resolution.
1324
1325    Returns:
1326    - np.ndarray: <br>
1327        Array of shape (N, 2) with dtype float32 containing receiver
1328        coordinates as (x+0.5, y+0.5).
1329    """
1330    # generate x and y sample positions at pixel centers
1331    xs = (np.arange(0, W, step_px, dtype=np.float32) + 0.5)
1332    ys = (np.arange(0, H, step_px, dtype=np.float32) + 0.5)
1333    
1334    # create a full grid of receiver coordinates
1335    xx, yy = np.meshgrid(xs, ys)
1336
1337    # pack into a flat (N,2) array for efficient iteration
1338    receivers = np.empty((xx.size, 2), dtype=np.float32)
1339    receivers[:, 0] = xx.reshape(-1)
1340    receivers[:, 1] = yy.reshape(-1)
1341    return receivers

Build a sparse grid of receiver positions over the image domain.

The receivers are placed on a regular grid with spacing step_px in both x and y directions. Each receiver is positioned at the pixel center using the convention (x + 0.5, y + 0.5), which matches how raster pixels are typically interpreted in the reflection kernel.

The result is a flat list of 2D receiver coordinates that can be iterated efficiently inside Numba kernels without additional meshgrid logic.

Parameters:

  • H (int):
    Image height in pixels.
  • W (int):
    Image width in pixels.
  • step_px (int):
    Spacing between receivers in pixels. Larger values reduce the number of receivers and speed up computation at the cost of spatial resolution.

Returns:

  • np.ndarray:
    Array of shape (N, 2) with dtype float32 containing receiver coordinates as (x+0.5, y+0.5).
@numba.njit(cache=True, fastmath=True, parallel=True)
def eval_receivers_to_map_kernel( receivers: numpy.ndarray, sx: float, sy: float, walls4: numpy.ndarray, occ: numpy.ndarray, max_order: int, ignore_zero_order: int, ignore_ends: int, seq_data: numpy.ndarray, seq_off: numpy.ndarray, seq_len: numpy.ndarray, simg_x: numpy.ndarray, simg_y: numpy.ndarray, start_seq: int, end_seq: int, output_map: numpy.ndarray):
1349@numba.njit(cache=True, fastmath=True, parallel=True)
1350def eval_receivers_to_map_kernel(
1351    receivers: np.ndarray,      # (N,2) float32; contains x+0.5, y+0.5
1352    sx: float, sy: float,
1353    walls4: np.ndarray,         # (nWalls,4) float32
1354    occ: np.ndarray,            # (H,W) uint8
1355    max_order: int,
1356    ignore_zero_order: int,     # 0/1
1357    ignore_ends: int,
1358    seq_data: np.ndarray,       # (total,) int32
1359    seq_off: np.ndarray,        # (nSeq,)  int32  (start offset per seq)
1360    seq_len: np.ndarray,        # (nSeq,)  int32
1361    simg_x: np.ndarray,         # (nSeq,)  float32
1362    simg_y: np.ndarray,         # (nSeq,)  float32
1363    start_seq: int,
1364    end_seq: int,
1365    output_map: np.ndarray      # (H,W) float32
1366):
1367    H = output_map.shape[0]
1368    W = output_map.shape[1]
1369    nR = receivers.shape[0]
1370
1371    # iterate over all receiver positions in parallel
1372    for ri in numba.prange(nR):
1373        rx = float(receivers[ri, 0])
1374        ry = float(receivers[ri, 1])
1375
1376        # convert receiver center coordinate back to integer pixel index
1377        ix = int(rx)
1378        iy = int(ry)
1379
1380        # skip if receiver lies outside the image bounds
1381        if ix < 0 or ix >= W or iy < 0 or iy >= H:
1382            continue
1383
1384        # accumulate contribution for this receiver pixel
1385        val = 0.0
1386
1387        # local buffer for reconstructed reflection path
1388        # size is max_order reflections + source + receiver
1389        path_out = np.empty((max_order + 2, 2), dtype=np.float32)
1390
1391        # iterate over the subset of precomputed reflection sequences
1392        for si in range(start_seq, end_seq):
1393            L = int(seq_len[si])
1394            # optionally skip direct line-of-sight paths
1395            if ignore_zero_order == 1 and L == 0:
1396                continue
1397
1398            # offset into the packed wall-index array for this sequence
1399            off0 = int(seq_off[si])
1400
1401            # precomputed image source position for this wall sequence
1402            s_imgx = float(simg_x[si])
1403            s_imgy = float(simg_y[si])
1404
1405            # reconstruct the geometric reflection path for this receiver
1406            path_len, ok = build_path_for_sequence_packed(
1407                sx, sy,
1408                rx, ry,
1409                seq_data, off0, L,
1410                s_imgx, s_imgy,
1411                walls4,
1412                path_out
1413            )
1414            if not ok:
1415                continue
1416
1417            # check whether the path is occluded by any wall pixels
1418            if not check_path_visibility_raster_arr(path_out, path_len, occ, ignore_ends):
1419                continue
1420
1421            # valid visible reflection path contributes to this pixel
1422            val += 1.0
1423
1424        # safe to write because each receiver maps to a unique pixel
1425        output_map[iy, ix] += val
def compute_reflection_map( source_rel, img, wall_values, wall_thickness: int = 1, approx_epsilon: float = 1.5, max_order: int = 1, ignore_zero_order=False, step_px: int = 1, forbid_immediate_repeat: bool = True, max_candidates=None, ignore_ends: int = 1, iterative_tracking=False, iterative_steps=None):
1429def compute_reflection_map(
1430    source_rel,
1431    img,
1432    wall_values,
1433    wall_thickness:int=1,
1434    approx_epsilon:float=1.5,
1435    max_order:int=1,
1436    ignore_zero_order=False,
1437    step_px:int=1,
1438    forbid_immediate_repeat: bool = True,
1439    max_candidates=None,
1440    ignore_ends:int=1,
1441    iterative_tracking=False,
1442    iterative_steps=None
1443):
1444    """
1445    Compute a 2D "reflection contribution" map over an image grid using
1446    precomputed specular image sources and wall reflections.
1447
1448    The function:
1449    1. Converts the input image to grayscale (if needed)
1450    2. Builds a wall mask from the image based on provided wall pixel values
1451    3. Extracts wall segments from that mask and builds an occlusion structure
1452    4. Precomputes valid wall-reflection sequences (up to `max_order`) and their
1453       corresponding image-source positions
1454    5. Evaluates reflection paths from a real source to a grid of receiver points,
1455       accumulating per-receiver contributions into `output_map`
1456
1457    Optionally, the computation can be performed in chunks and intermediate
1458    snapshots of the partially accumulated `output_map` can be returned
1459    (`iterative_tracking=True`).
1460
1461    Parameters:
1462    - source_rel (Tuple[float, float] | np.ndarray): <br>
1463        Source position in *relative* image coordinates (x_rel, y_rel) in [0, 1].
1464        Converted internally to pixel coordinates via (x_rel*W, y_rel*H).
1465    - img (np.ndarray): <br>
1466        Input image. If 3D (H, W, C), only the first channel is used.
1467        The wall mask is derived from this image.
1468    - wall_values (Iterable[int] | np.ndarray): <br>
1469        Pixel values in `img` that should be treated as "wall" when building the
1470        wall mask (exact matching logic depends on `build_wall_mask`).
1471    - wall_thickness (int): <br>
1472        Thickness (in pixels) used when extracting wall segments and building
1473        the occlusion representation.
1474    - approx_epsilon (float): <br>
1475        Polygon/segment simplification tolerance used during wall extraction
1476        (`get_wall_segments_from_mask`).
1477    - max_order (int): <br>
1478        Maximum reflection order (number of wall bounces) to consider when
1479        precomputing image sources.
1480    - ignore_zero_order (bool): <br>
1481        If True, exclude the direct (0-bounce / line-of-sight) contribution.
1482    - step_px (int): <br>
1483        Receiver grid spacing in pixels. Larger values reduce runtime by
1484        evaluating fewer receiver positions.
1485    - forbid_immediate_repeat (bool): <br>
1486        If True, disallow reflection sequences that repeat the same wall in
1487        consecutive steps (helps avoid degenerate/near-degenerate paths).
1488    - max_candidates (int | None): <br>
1489        Optional cap on the number of candidate sequences/image sources kept
1490        during precomputation.
1491    - ignore_ends (int): <br>
1492        Number of wall endpoints to ignore during intersection tests (used to
1493        stabilize grazing / endpoint-hitting cases).
1494    - iterative_tracking (bool): <br>
1495        If True, compute in chunks and return intermediate `output_map` snapshots
1496        as a list of arrays. Useful for debugging/profiling progress.
1497    - iterative_steps (int | None): <br>
1498        Controls how many snapshots to collect when `iterative_tracking=True`:
1499        - None  -> treated as -1 (snapshot every chunk of size 1 sequence)
1500        - -1    -> snapshot after every sequence (finest granularity)
1501        - 1     -> snapshot only once at the end (same as full run)
1502        - k>1   -> snapshot roughly `k` times over the sequence list
1503
1504    Returns:
1505    - np.ndarray | List[np.ndarray]: <br>
1506        If `iterative_tracking` is False: returns `output_map` (H, W) float32.<br>
1507        If `iterative_tracking` is True: returns a list of snapshot maps, each
1508        shaped (H, W) float32, showing accumulation progress.
1509    """
1510    # If we received a color image, use only the first channel (expected: walls encoded per-pixel)
1511    if img.ndim == 3:
1512        img = img[..., 0]
1513
1514    # Image dimensions and source location in absolute pixel coordinates
1515    H, W = img.shape[:2]
1516    sx = float(source_rel[0] * W)
1517    sy = float(source_rel[1] * H)
1518
1519    # Build a binary wall mask (0/255), then extract simplified wall segments
1520    wall_mask_255 = build_wall_mask(img, wall_values=wall_values)
1521    walls = get_wall_segments_from_mask(
1522        wall_mask_255,
1523        thickness=wall_thickness,
1524        approx_epsilon=approx_epsilon
1525    )
1526
1527    # Precompute an occlusion structure derived from the wall mask
1528    occ = build_occlusion_from_wallmask(wall_mask_255, wall_thickness=wall_thickness)
1529
1530    # Convert wall segments into a packed (N,4) representation [ax, ay, bx, by]
1531    #    -> for numba usage
1532    walls4 = segments_to_walls4(walls)
1533
1534    # Precompute image sources + wall reflection sequences up to `max_order`
1535    # Each sequence describes the ordered wall indices to reflect against
1536    pre_py = precompute_image_sources(
1537        source_xy=(sx, sy),
1538        walls=walls,
1539        max_order=max_order,
1540        forbid_immediate_repeat=forbid_immediate_repeat,
1541        max_candidates=max_candidates,
1542    )
1543
1544    # Pack variable-length sequences into flat arrays for fast/Numba-friendly access
1545    seq_data, seq_off, seq_len, simg_x, simg_y = pack_precomputed(pre_py)
1546
1547    # Create a grid of receiver points
1548    receivers = build_receivers_grid(H, W, step_px)
1549
1550    # Accumulated output map (same resolution as the input image)
1551    output_map = np.zeros((H, W), dtype=np.float32)
1552
1553    # Optional progressive snapshots of accumulation -> iterative output
1554    if iterative_tracking:
1555        snapshots = []
1556
1557    # Default: snapshot every sequence (finest granularity) if tracking is enabled.
1558    if iterative_steps is None:
1559        iterative_steps = -1
1560
1561    nseq = len(pre_py)
1562
1563    # Decide chunk size (how many sequences per kernel call) based on snapshot settings
1564    if (not iterative_tracking) or (iterative_tracking and iterative_steps == 1):
1565        # Single call covering all sequences (or one snapshot at the end)
1566        snapshot_every_x_steps = nseq
1567    elif iterative_tracking and iterative_steps == -1:
1568        # Snapshot after every sequence
1569        snapshot_every_x_steps = 1
1570    else:
1571        # Snapshot about `iterative_steps` times, compute chunk size accordingly
1572        snapshot_every_x_steps = max(1, int(nseq // iterative_steps))
1573
1574    # Convert boolean to int for Numba kernel
1575    ignore_zero_order_i = 1 if ignore_zero_order else 0
1576
1577    # Evaluate reflection contributions in chunks of sequences for better control over snapshots
1578    for start_idx in range(0, nseq, snapshot_every_x_steps):
1579        end_idx = min(nseq, start_idx + snapshot_every_x_steps)
1580
1581        # Core kernel: for each receiver and each sequence in [start_idx, end_idx),
1582        # validate/specular-trace the path (including occlusion) and accumulate into output_map
1583        eval_receivers_to_map_kernel(
1584            receivers,
1585            sx, sy,
1586            walls4,
1587            occ,
1588            max_order,
1589            ignore_zero_order_i,
1590            ignore_ends,
1591            seq_data,
1592            seq_off,
1593            seq_len,
1594            simg_x,
1595            simg_y,
1596            start_idx,
1597            end_idx,
1598            output_map
1599        )
1600
1601        # Store a copy so later updates don't mutate previous snapshots
1602        if iterative_tracking:
1603            snapshots.append(output_map.copy())
1604
1605    # Return either the final map or the progressive snapshots
1606    if not iterative_tracking:
1607        return output_map
1608    else:
1609        return snapshots

Compute a 2D "reflection contribution" map over an image grid using precomputed specular image sources and wall reflections.

The function:

  1. Converts the input image to grayscale (if needed)
  2. Builds a wall mask from the image based on provided wall pixel values
  3. Extracts wall segments from that mask and builds an occlusion structure
  4. Precomputes valid wall-reflection sequences (up to max_order) and their corresponding image-source positions
  5. Evaluates reflection paths from a real source to a grid of receiver points, accumulating per-receiver contributions into output_map

Optionally, the computation can be performed in chunks and intermediate snapshots of the partially accumulated output_map can be returned (iterative_tracking=True).

Parameters:

  • source_rel (Tuple[float, float] | np.ndarray):
    Source position in *relative* image coordinates (x_rel, y_rel) in [0, 1]. Converted internally to pixel coordinates via (x_relW, y_relH).
  • img (np.ndarray):
    Input image. If 3D (H, W, C), only the first channel is used. The wall mask is derived from this image.
  • wall_values (Iterable[int] | np.ndarray):
    Pixel values in img that should be treated as "wall" when building the wall mask (exact matching logic depends on build_wall_mask).
  • wall_thickness (int):
    Thickness (in pixels) used when extracting wall segments and building the occlusion representation.
  • approx_epsilon (float):
    Polygon/segment simplification tolerance used during wall extraction (get_wall_segments_from_mask).
  • max_order (int):
    Maximum reflection order (number of wall bounces) to consider when precomputing image sources.
  • ignore_zero_order (bool):
    If True, exclude the direct (0-bounce / line-of-sight) contribution.
  • step_px (int):
    Receiver grid spacing in pixels. Larger values reduce runtime by evaluating fewer receiver positions.
  • forbid_immediate_repeat (bool):
    If True, disallow reflection sequences that repeat the same wall in consecutive steps (helps avoid degenerate/near-degenerate paths).
  • max_candidates (int | None):
    Optional cap on the number of candidate sequences/image sources kept during precomputation.
  • ignore_ends (int):
    Number of wall endpoints to ignore during intersection tests (used to stabilize grazing / endpoint-hitting cases).
  • iterative_tracking (bool):
    If True, compute in chunks and return intermediate output_map snapshots as a list of arrays. Useful for debugging/profiling progress.
  • iterative_steps (int | None):
    Controls how many snapshots to collect when iterative_tracking=True:
    • None -> treated as -1 (snapshot every chunk of size 1 sequence)
    • -1 -> snapshot after every sequence (finest granularity)
    • 1 -> snapshot only once at the end (same as full run)
    • k>1 -> snapshot roughly k times over the sequence list

Returns:

  • np.ndarray | List[np.ndarray]:
    If iterative_tracking is False: returns output_map (H, W) float32.
    If iterative_tracking is True: returns a list of snapshot maps, each shaped (H, W) float32, showing accumulation progress.