forked from akej74/hdri-sun-aligner
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathoperators.py
384 lines (285 loc) · 13.3 KB
/
operators.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# Blender imports
import bpy.types
# Other imports
import numpy as np
from math import pi, cos, sin
import mathutils
class HDRISA_OT_preview(bpy.types.Operator):
"""Open a new window to display a preview of the sun position in the HDRI image"""
bl_idname = "hdrisa.preview"
bl_label = "Preview"
bl_options = {'REGISTER'}
def execute(self, context):
# Open a new Preferences window
bpy.ops.screen.userpref_show("INVOKE_DEFAULT")
# Change to an Image Editor
area = bpy.context.window_manager.windows[-1].screen.areas[0]
area.type = "IMAGE_EDITOR"
# Open HDRI preview
for img in bpy.data.images:
name = img.name
if name.startswith("hdri_sa_preview"):
area.spaces.active.image = img
return {'FINISHED'}
@classmethod
def poll(cls, context):
# Only enable preview if preview image is available
for img in bpy.data.images:
name = img.name
if name.startswith("hdri_sa_preview"):
return True
return False
class HDRISA_OT_rotate(bpy.types.Operator):
"""Rotate active object in alignment with sun position"""
bl_idname = "hdrisa.rotate"
bl_label = "Rotate active object in alignment with sun position."
bl_options = {'REGISTER'}
# Only enable operator if an object is selected
@classmethod
def poll(cls, context):
if bpy.context.selected_objects:
return True
else:
return False
def execute(self, context):
scene = context.scene
object = context.object
longitude = scene.hdri_sa_props.long_deg * (pi/180) # Convert to radians
latitude = scene.hdri_sa_props.lat_deg * (pi/180)
# Calculate a vector pointing from the longitude and latitude to origo
# See https://vvvv.org/blog/polar-spherical-and-geographic-coordinates
x = cos(latitude) * cos(longitude)
y = cos(latitude) * sin(longitude)
z = sin(latitude)
# Define euler rotation according to the vector
vector = mathutils.Vector([x, -y, z]) # "-y" to match Blender coordinate system
up_axis = mathutils.Vector([0.0, 0.0, 1.0])
angle = vector.angle(up_axis, 0)
axis = up_axis.cross(vector)
euler = mathutils.Matrix.Rotation(angle, 4, axis).to_euler()
# Store z-rotation value as property, used for driver calculation
scene.hdri_sa_props.z_org = euler.z
# Rotate selected object
object.rotation_euler = euler
return {'FINISHED'}
class HDRISA_OT_add_new_sun(bpy.types.Operator):
"""Add a new sun, rotated in alignment with current sun position"""
bl_idname = "hdrisa.add_new_sun"
bl_label = "Add a new sun, rotated in alignment with current sun position."
bl_options = {'REGISTER'}
def execute(self, context):
# Deselect objects
for obj in context.selected_objects:
obj.select_set(state=False)
# Create new collection if it doesn't exist
new_collection = self.make_collection("HDRI Sun Aligner")
# Create new sun object in the collection
sun_data = bpy.data.lights.new(name="HDRI Sun", type='SUN')
sun_object = bpy.data.objects.new(name="HDRI Sun", object_data=sun_data)
new_collection.objects.link(sun_object)
# Select sun object and rotate
sun_object.select_set(state=True)
context.view_layer.objects.active = sun_object
bpy.ops.hdrisa.rotate()
return {'FINISHED'}
def make_collection(self, collection_name):
# Check if collection already exists
if collection_name in bpy.data.collections:
return bpy.data.collections[collection_name]
# If not, create new collection
else:
new_collection = bpy.data.collections.new(collection_name)
bpy.context.scene.collection.children.link(new_collection)
return new_collection
class HDRISA_OT_add_rotation_driver(bpy.types.Operator):
"""Add a a driver to the active object z-rotation, based on HDRI mapping node"""
bl_idname = "hdrisa.add_rotation_driver"
bl_label = "Add a a driver to the active object z-rotation, based on HDRI rotation using mapping node."
bl_options = {'REGISTER'}
# Only enable operator if an object is selected
@classmethod
def poll(cls, context):
if bpy.context.selected_objects:
return True
else:
return False
def execute(self, context):
scene = context.scene
object = context.object
mapping_node = None
world_nodes = scene.world.node_tree.nodes # All nodes for the World
for node in world_nodes:
# Find the Vector Mapping node
if isinstance(node, bpy.types.ShaderNodeMapping):
mapping_node = node.name
break
if mapping_node:
# Check for mapping node attributes in Blender 2.80
if hasattr(world_nodes[mapping_node], 'rotation'):
# Path to HDRI mapping node z-rotation value
data_path = f'node_tree.nodes["{mapping_node}"].rotation[2]'
# If not, assume Blender 2.81 mapping node attributes
else:
# Path to HDRI mapping node z-rotation value
data_path = f'node_tree.nodes["{mapping_node}"].inputs["Rotation"].default_value[2]'
# Driver for z rotation
z_rotation_driver = object.driver_add('rotation_euler', 2)
hdri_z = z_rotation_driver.driver.variables.new() # HDRI mapping node
obj_z = z_rotation_driver.driver.variables.new() # Object original rotation
hdri_z.name = "hdri_z"
hdri_z.targets[0].id_type = 'WORLD'
hdri_z.targets[0].id = scene.world
hdri_z.targets[0].data_path = data_path
obj_z.name = "obj_z"
obj_z.targets[0].id_type = 'SCENE'
obj_z.targets[0].id = scene
obj_z.targets[0].data_path = 'hdri_sa_props.z_org'
z_rotation_driver.driver.expression = obj_z.name + '-' + hdri_z.name
else:
msg = "No Mapping node defined for HDRI rotation."
bpy.ops.message.messagebox('INVOKE_DEFAULT', message=msg)
self.report({'WARNING'}, msg)
return {'CANCELLED'}
return {'FINISHED'}
class HDRISA_OT_message_box(bpy.types.Operator):
"""Show a message box"""
bl_idname = "message.messagebox"
bl_label = ""
message: bpy.props.StringProperty(
name="message",
description="message",
default=''
)
def execute(self, context):
self.report({'INFO'}, self.message)
print(self.message)
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=300)
def draw(self, context):
self.layout.label(text=self.message)
self.layout.label(text="")
class HDRISA_OT_calculate_sun_position(bpy.types.Operator):
"""Calculate the brightest spot in the HDRI image used for the environment"""
bl_idname = "hdrisa.calculate_sun_position"
bl_label = "Calculate sun position."
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
scene = context.scene
screen = context.screen
world_nodes = scene.world.node_tree.nodes # All nodes for the World
image = None
# Cleanup to prevent duplicate images
for img in bpy.data.images:
name = img.name
if name.startswith("hdri_sa_preview"):
bpy.data.images.remove(img)
# Check if an environmental image is defined
for node in world_nodes:
# Find the Environment Texture node
if isinstance(node, bpy.types.ShaderNodeTexEnvironment):
image = node.image
if image:
# Make a copy of the original HDRI
hdri_preview = image.copy()
hdri_preview.name = "hdri_sa_preview." + image.file_format
# Get image dimensions
org_width = hdri_preview.size[0]
org_height = hdri_preview.size[1]
# Scale image if it's larger than 1k for improving performance
if org_width > 1024:
new_width = 1024
new_height = int(org_height * (new_width / org_width))
hdri_preview.scale(new_width, new_height)
else:
msg = "Please add an Environment Texture for the world."
bpy.ops.message.messagebox('INVOKE_DEFAULT', message=msg)
self.report({'WARNING'}, msg)
return {'CANCELLED'}
# Calculate longitude, latitude and update HDRI preview image
long_deg, lat_deg = self.process_hdri(hdri_preview)
# Update properties
scene.hdri_sa_props.long_deg = long_deg
scene.hdri_sa_props.lat_deg = lat_deg
scene.hdri_sa_props.sun_position_calculated = True
return {'FINISHED'}
def process_hdri(self, image):
"""
Calculate the brightest point in the equirectangular HDRI image (i.e. the sun or brightest light).
A gaussian blur is applied to the image to prevent errors from single bright pixels.
Update the "hdri_preview" image and return the longitude and latitude in degrees.
"""
# Get a flat Numpy array with the image pixels
hdri_img = np.array(image.pixels[:])
width, height = image.size
depth = 4 # RGBA
# Reshape to RGBA matrix
hdri_img = np.array(hdri_img).reshape([height, width, depth])
# Get image dimensions
height, width = hdri_img.shape[:2]
# Convert to grayscale
gray_img = np.dot(hdri_img[...,:3], [0.299, 0.587, 0.114])
# Apply gaussian blur
gray_img = self.gaussian_blur(gray_img, sigma=100)
# Find index of maximum value from 2D numpy array
result = np.where(gray_img == np.amax(gray_img))
# zip the 2 arrays to get the exact coordinates
list_of_coordinates = list(zip(result[0], result[1]))
# Assume only one maximum, use the first found
max_loc_new = list_of_coordinates[0]
# Get x and y coordinates for the brightest pixel
max_x = max_loc_new[1]
max_y = max_loc_new[0]
# Create masks to indicate sun position
circle_mask = self.create_circular_mask(height, width, thickness=4, center=[max_x, max_y], radius=50)
point_mask = self.create_circular_mask(height, width, thickness=4, center=[max_x, max_y], radius=5)
# Draw circle
hdri_img[:, :, 0][circle_mask] = 1 # Red
hdri_img[:, :, 1][circle_mask] = 0 # Green
hdri_img[:, :, 2][circle_mask] = 0 # Blue
# Draw center dot
hdri_img[:, :, 0][point_mask] = 1
hdri_img[:, :, 1][point_mask] = 0
hdri_img[:, :, 2][point_mask] = 0
# Find the point in longitude and latitude (degrees)
long_deg = ((max_x * 360) / width) - 180
lat_deg = -(((max_y * -180) / height) + 90)
# Flatten array and update the blender image object
image.pixels = hdri_img.ravel()
return long_deg, lat_deg
def create_circular_mask(self, h, w, thickness, center=None, radius=None):
"""Create a circular mask used for drawing on the HDRI preview."""
if center is None: # use the middle of the image
center = [int(w/2), int(h/2)]
if radius is None: # use the smallest distance between the center and image walls
radius = min(center[0], center[1], w-center[0], h-center[1])
Y, X = np.ogrid[:h, :w]
dist_from_center = np.sqrt((X - center[0])**2 + (Y-center[1])**2)
mask = np.logical_and(dist_from_center <= radius, dist_from_center >= (radius - thickness))
return mask
def gaussian_blur(self, gray_image, sigma):
""" Apply gaussion blur to a grayscale image.
Input:
- 2D Numpy array
- sigma (gaussian blur radius)
Return:
- 2D Numpy array (blurred image)
See https://scipython.com/book/chapter-6-numpy/examples/blurring-an-image-with-a-two-dimensional-fft/
"""
rows, cols = gray_image.shape
# Take the 2-dimensional DFT and centre the frequencies
ftimage = np.fft.fft2(gray_image)
ftimage = np.fft.fftshift(ftimage)
# Build and apply a Gaussian filter.
sigmax = sigma
sigmay = sigma
cy, cx = rows/2, cols/2
y = np.linspace(0, rows, rows)
x = np.linspace(0, cols, cols)
X, Y = np.meshgrid(x, y)
gmask = np.exp(-(((X-cx)/sigmax)**2 + ((Y-cy)/sigmay)**2))
ftimagep = ftimage * gmask
# Take the inverse transform
imagep = np.fft.ifft2(ftimagep)
imagep = np.abs(imagep)
return imagep