# -*- coding: utf-8 -*-
"""
Part of MarcheALOmbre QGIS Plugin
Copyright (C) 2025 Yolanda Seifert
Licensed under GPL v2+
"""
import numpy as np
import math
from osgeo import gdal
[docs]
class ShadowCalculator:
def __init__(self, high_res_path, low_res_path):
"""
Initialize the calculator with paths to High-Res and Low-Res MNS files
Args:
high_res_path (str): File path to the High Resolution MNS
low_res_path (str): File path to the Low Resolution MNS
Raises:
Exception: If a raster file cannot be opened by GDAL
"""
self.high_ds = gdal.Open(high_res_path)
if not self.high_ds:
raise Exception(f"Could not open High-Res MNS: {high_res_path}")
self.high_gt = self.high_ds.GetGeoTransform()
self.high_res = self.high_gt[1]
self.high_data = self.high_ds.GetRasterBand(1).ReadAsArray()
self.high_rows, self.high_cols = self.high_data.shape
self.low_ds = gdal.Open(low_res_path)
if not self.low_ds:
raise Exception(f"Could not open Low-Res MNS: {low_res_path}")
self.low_gt = self.low_ds.GetGeoTransform()
self.low_res = self.low_gt[1]
self.low_data = self.low_ds.GetRasterBand(1).ReadAsArray()
self.low_rows, self.low_cols = self.low_data.shape
def _to_pixel(self, x, y, gt):
"""
Convert world coordinates to raster pixel coordinates
Args:
x (float): x coordinate
y (float): y coordinate
gt (tuple): gdal GeoTransform
Returns:
tuple[int, int]: (column, row)
"""
col = int((x - gt[0]) / gt[1])
row = int((y - gt[3]) / gt[5])
return col, row
[docs]
def draw_bresenham_line(self, x0, y0, max_dist_pixels, azimuth, rows, cols):
"""Draw bresenham line on a raster with given starting point
Args:
x0 (int): start x value
y0 (int): start y value
max_dist_pixels (float): maximum distance in pixels
azimuth (float): direction angle of line in radians
rows (int): number of rows in raster
cols (int): number of columns in raster
Returns:
(int,int)[]: list of indices of resulting line
"""
delta_x = max_dist_pixels * np.sin(azimuth)
delta_y = max_dist_pixels * np.cos(azimuth)
x1 = int(x0 + delta_x)
y1 = int(y0 - delta_y)
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
D = dx - dy
line = []
while True:
# Boundary check
if not (0 <= x0 < cols and 0 <= y0 < rows):
break
line.append((x0, y0))
if x0 == x1 and y0 == y1:
break
D2 = 2 * D
# Adjust x
if D2 > -dy:
D = D - dy
x0 = x0 + sx
# Adjust y
if D2 < dx:
D = D + dx
y0 = y0 + sy
return line
[docs]
def calc_angle(self, trail_point, index_list, start_px, mns_data, resolution, min_dist_m=0):
"""
Calculates angles for all points in bresenham line at once using numpy
Args:
trail_point: trail point from where angles are calculated
index_list (int,int)[]: index list from bresenham line
start_px (int, int): starting pixel (col, row)
mns_data (array): MNS raster data
resolution (float): resolution of the raster
min_dist_m (float): minimum distance to check (overlap)
Returns:
(float[], float): list of angles, furthest distance checked
"""
if not index_list:
return np.array([]), 0.0
start_x, start_y = start_px
last_x, last_y = index_list[-1]
dist_end_sq = (last_x - start_x)**2 + (last_y - start_y)**2
max_dist = math.sqrt(dist_end_sq) * resolution
path = np.array(index_list)
path_x = path[:, 0]
path_y = path[:, 1]
h_viewer = trail_point.z + 1.7
h_obstacles = mns_data[path_y, path_x]
# Filter only obstacles higher than viewer
mask = h_obstacles > h_viewer
if not np.any(mask):
return np.array([]), max_dist
rel_x = path_x[mask]
rel_y = path_y[mask]
rel_h = h_obstacles[mask]
# Calculate Euclidean Distances
dist_sq = (rel_x - start_x)**2 + (rel_y - start_y)**2
dist_mask = dist_sq > 0
dist_pixels = np.sqrt(dist_sq[dist_mask])
rel_h = rel_h[dist_mask]
if len(dist_pixels) == 0:
return np.array([]), max_dist
dist_m = dist_pixels * resolution
# Filter min_dist_m (for Low Res)
if min_dist_m > 0:
dist_filter = dist_m > min_dist_m
if not np.any(dist_filter):
return np.array([]), max_dist
dist_m = dist_m[dist_filter]
rel_h = rel_h[dist_filter]
# Calculate angles
height_diffs = rel_h - h_viewer
angles_rad = np.arctan(height_diffs / dist_m)
return angles_rad, max_dist
[docs]
def calculate_shadows(self, trail_points, max_dist_m=20000):
"""
Calculate if trail points are in shadow or sun along a trail.
Iterates through every trail point, casts a line in the direction of the sun azimuth,
and checks if any obstacle (from High-Res or Low-Res MNS) has an elevation angle
greater than the sun's current elevation
Args:
trail_points [TrailPoint]: trail points
max_dist_m (int, optional): maximum distance in which an obstacle which could cause shadow is searched
Returns:
int[]: list of shadows (0=sunny,1=shady)
"""
results = []
high_max_px = int(max_dist_m / self.high_res)
low_max_px = int(max_dist_m / self.low_res)
for tp in trail_points:
# Night check
sun_elevation_rad = tp.solar_pos[0]
if sun_elevation_rad < 0:
results.append(1) # It is night/shady
continue
max_angle = -np.inf
covered_dist = 0.0
# High-Res
h_col, h_row = self._to_pixel(tp.x, tp.y, self.high_gt)
# Boundary check
if 0 <= h_col < self.high_cols and 0 <= h_row < self.high_rows:
# Draw line in azimuth direction
indices = self.draw_bresenham_line(
h_col, h_row, high_max_px, tp.azimuth_grid, self.high_rows, self.high_cols
)
# Calculate angles of line
angles_rad, covered_dist = self.calc_angle(
tp, indices, (h_col, h_row), self.high_data, self.high_res, min_dist_m=0
)
# Check shadow
if len(angles_rad) > 0:
max_obstacle_angle = np.max(angles_rad)
if max_obstacle_angle > sun_elevation_rad:
results.append(1) # Shady
continue # Skip check in Low-Res
max_angle = max_obstacle_angle
# Low-Res
l_col, l_row = self._to_pixel(tp.x, tp.y, self.low_gt)
# Boundary check
if 0 <= l_col < self.low_cols and 0 <= l_row < self.low_rows:
# Draw line in azimuth direction
indices = self.draw_bresenham_line(
l_col, l_row, low_max_px, tp.solar_pos[1], self.low_rows, self.low_cols
)
# Calculate angles of line, skipping covered_dist
angles_rad, _ = self.calc_angle(
tp, indices, (l_col, l_row), self.low_data, self.low_res, min_dist_m=covered_dist
)
# Check shadow
if len(angles_rad) > 0:
max_obstacle_angle = np.max(angles_rad)
if max_obstacle_angle > max_angle:
max_angle = max_obstacle_angle
# Final check
if max_angle > sun_elevation_rad:
results.append(1) # Shady
else:
results.append(0) # Sunny
return results