-
Notifications
You must be signed in to change notification settings - Fork 162
/
Copy pathstereoimage_generation.py
307 lines (281 loc) · 15.5 KB
/
stereoimage_generation.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
try:
from numba import njit, prange
except Exception as e:
print(f"WARINING! Numba failed to import! Stereoimage generation will be much slower! ({str(e)})")
from builtins import range as prange
def njit(parallel=False):
def Inner(func): return lambda *args, **kwargs: func(*args, **kwargs)
return Inner
import numpy as np
from PIL import Image
def create_stereoimages(original_image, depthmap, divergence, separation=0.0, modes=None,
stereo_balance=0.0, stereo_offset_exponent=1.0, fill_technique='polylines_sharp'):
"""Creates stereoscopic images.
An effort is made to make them look nice, but beware that the resulting image will have some distortion.
The correctness was not rigorously tested.
:param original_image: original image from which the 3D image (stereoimage) will be created
:param depthmap: depthmap corresponding to the original image. White = near, black = far.
:param float divergence: the measure of 3D effect, in percentages.
A good value will likely be somewhere in the [0.05; 10.0) interval.
:param float separation: measure by how much to move two halves of the stereoimage apart from each-other.
Measured in percentages. Negative values move two parts closer together.
Affects which parts of the image will be visible in left and/or right half.
:param list modes: how the result will look like. By default only 'left-right' is generated
- a picture for the left eye will be on the left and the picture from the right eye - on the right.
Some of the supported modes are: 'left-right', 'right-left', 'top-bottom', 'bottom-top', 'red-cyan-anaglyph'.
:param float stereo_balance: has to do with how the divergence will be split among the two parts of the image,
must be in the [-1.0; 1.0] interval.
:param float stereo_offset_exponent: Higher values move objects residing
between close and far plane more to the far plane
:param str fill_technique: applying divergence inevitably creates some gaps in the image.
This parameter specifies the technique that will be used to fill in the blanks in the two resulting images.
Must be one of the following: 'none', 'naive', 'naive_interpolating', 'polylines_soft', 'polylines_sharp'.
"""
if modes is None:
modes = ['left-right']
if not isinstance(modes, list):
modes = [modes]
if len(modes) == 0:
return []
original_image = np.asarray(original_image)
balance = (stereo_balance + 1) / 2
left_eye = original_image if balance < 0.001 else \
apply_stereo_divergence(original_image, depthmap, +1 * divergence * balance, -1 * separation,
stereo_offset_exponent, fill_technique)
right_eye = original_image if balance > 0.999 else \
apply_stereo_divergence(original_image, depthmap, -1 * divergence * (1 - balance), separation,
stereo_offset_exponent, fill_technique)
results = []
for mode in modes:
if mode == 'left-right': # Most popular format. Common use case: displaying in HMD.
results.append(np.hstack([left_eye, right_eye]))
elif mode == 'right-left': # Cross-viewing
results.append(np.hstack([right_eye, left_eye]))
elif mode == 'top-bottom':
results.append(np.vstack([left_eye, right_eye]))
elif mode == 'bottom-top':
results.append(np.vstack([right_eye, left_eye]))
elif mode == 'red-cyan-anaglyph': # Anaglyth glasses
results.append(overlap_red_cyan(left_eye, right_eye))
elif mode == 'left-only':
results.append(left_eye)
elif mode == 'only-right':
results.append(right_eye)
elif mode == 'cyan-red-reverseanaglyph': # Anaglyth glasses worn upside down
# Better for people whose main eye is left
results.append(overlap_red_cyan(right_eye, left_eye))
else:
raise Exception('Unknown mode')
return [Image.fromarray(r) for r in results]
def apply_stereo_divergence(original_image, depth, divergence, separation, stereo_offset_exponent, fill_technique):
assert original_image.shape[:2] == depth.shape, 'Depthmap and the image must have the same size'
depth_min = depth.min()
depth_max = depth.max()
normalized_depth = (depth - depth_min) / (depth_max - depth_min)
divergence_px = (divergence / 100.0) * original_image.shape[1]
separation_px = (separation / 100.0) * original_image.shape[1]
if fill_technique in ['none', 'naive', 'naive_interpolating']:
return apply_stereo_divergence_naive(
original_image, normalized_depth, divergence_px, separation_px, stereo_offset_exponent, fill_technique
)
if fill_technique in ['polylines_soft', 'polylines_sharp']:
return apply_stereo_divergence_polylines(
original_image, normalized_depth, divergence_px, separation_px, stereo_offset_exponent, fill_technique
)
@njit(parallel=False)
def apply_stereo_divergence_naive(
original_image, normalized_depth, divergence_px: float, separation_px: float, stereo_offset_exponent: float,
fill_technique: str):
h, w, c = original_image.shape
derived_image = np.zeros_like(original_image)
filled = np.zeros(h * w, dtype=np.uint8)
for row in prange(h):
# Swipe order should ensure that pixels that are closer overwrite
# (at their destination) pixels that are less close
for col in range(w) if divergence_px < 0 else range(w - 1, -1, -1):
col_d = col + int((normalized_depth[row][col] ** stereo_offset_exponent) * divergence_px + separation_px)
if 0 <= col_d < w:
derived_image[row][col_d] = original_image[row][col]
filled[row * w + col_d] = 1
# Fill the gaps
if fill_technique == 'naive_interpolating':
for row in range(h):
for l_pointer in range(w):
# This if (and the next if) performs two checks that are almost the same - for performance reasons
if sum(derived_image[row][l_pointer]) != 0 or filled[row * w + l_pointer]:
continue
l_border = derived_image[row][l_pointer - 1] if l_pointer > 0 else np.zeros(3, dtype=np.uint8)
r_border = np.zeros(3, dtype=np.uint8)
r_pointer = l_pointer + 1
while r_pointer < w:
if sum(derived_image[row][r_pointer]) != 0 and filled[row * w + r_pointer]:
r_border = derived_image[row][r_pointer]
break
r_pointer += 1
if sum(l_border) == 0:
l_border = r_border
elif sum(r_border) == 0:
r_border = l_border
# Example illustrating positions of pointers at this point in code:
# is filled? : + - - - - +
# pointers : l r
# interpolated: 0 1 2 3 4 5
# In total: 5 steps between two filled pixels
total_steps = 1 + r_pointer - l_pointer
step = (r_border.astype(np.float_) - l_border) / total_steps
for col in range(l_pointer, r_pointer):
derived_image[row][col] = l_border + (step * (col - l_pointer + 1)).astype(np.uint8)
return derived_image
elif fill_technique == 'naive':
derived_fix = np.copy(derived_image)
for pos in np.where(filled == 0)[0]:
row = pos // w
col = pos % w
row_times_w = row * w
for offset in range(1, abs(int(divergence_px)) + 2):
r_offset = col + offset
l_offset = col - offset
if r_offset < w and filled[row_times_w + r_offset]:
derived_fix[row][col] = derived_image[row][r_offset]
break
if 0 <= l_offset and filled[row_times_w + l_offset]:
derived_fix[row][col] = derived_image[row][l_offset]
break
return derived_fix
else: # none
return derived_image
@njit(parallel=True) # fastmath=True does not reasonably improve performance
def apply_stereo_divergence_polylines(
original_image, normalized_depth, divergence_px: float, separation_px: float, stereo_offset_exponent: float,
fill_technique: str):
# This code treats rows of the image as polylines
# It generates polylines, morphs them (applies divergence) to them, and then rasterizes them
EPSILON = 1e-7
PIXEL_HALF_WIDTH = 0.45 if fill_technique == 'polylines_sharp' else 0.0
# PERF_COUNTERS = [0, 0, 0]
h, w, c = original_image.shape
derived_image = np.zeros_like(original_image)
for row in prange(h):
# generating the vertices of the morphed polyline
# format: new coordinate of the vertex, divergence (closeness), column of pixel that contains the point's color
pt = np.zeros((5 + 2 * w, 3), dtype=np.float_)
pt_end: int = 0
pt[pt_end] = [-1.0 * w, 0.0, 0.0]
pt_end += 1
for col in range(0, w):
coord_d = (normalized_depth[row][col] ** stereo_offset_exponent) * divergence_px
coord_x = col + 0.5 + coord_d + separation_px
if PIXEL_HALF_WIDTH < EPSILON:
pt[pt_end] = [coord_x, abs(coord_d), col]
pt_end += 1
else:
pt[pt_end] = [coord_x - PIXEL_HALF_WIDTH, abs(coord_d), col]
pt[pt_end + 1] = [coord_x + PIXEL_HALF_WIDTH, abs(coord_d), col]
pt_end += 2
pt[pt_end] = [2.0 * w, 0.0, w - 1]
pt_end += 1
# generating the segments of the morphed polyline
# format: coord_x, coord_d, color_i of the first point, then the same for the second point
sg_end: int = pt_end - 1
sg = np.zeros((sg_end, 6), dtype=np.float_)
for i in range(sg_end):
sg[i] += np.concatenate((pt[i], pt[i + 1]))
# Here is an informal proof that this (morphed) polyline does not self-intersect:
# Draw a plot with two axes: coord_x and coord_d. Now draw the original line - it will be positioned at the
# bottom of the graph (that is, for every point coord_d == 0). Now draw the morphed line using the vertices of
# the original polyline. Observe that for each vertex in the new polyline, its increments
# (from the corresponding vertex in the old polyline) over coord_x and coord_d are in direct proportion.
# In fact, this proportion is equal for all the vertices and it is equal either -1 or +1,
# depending on the sign of divergence_px. Now draw the lines from each old vertex to a corresponding new vertex.
# Since the proportions are equal, these lines have the same angle with an axe and are parallel.
# So, these lines do not intersect. Now rotate the plot by 45 or -45 degrees and observe that
# each dot of the polyline is further right from the last dot,
# which makes it impossible for the polyline to self-intersect. QED.
# sort segments and points using insertion sort
# has a very good performance in practice, since these are almost sorted to begin with
for i in range(1, sg_end):
u = i - 1
while pt[u][0] > pt[u + 1][0] and 0 <= u:
pt[u], pt[u + 1] = np.copy(pt[u + 1]), np.copy(pt[u])
sg[u], sg[u + 1] = np.copy(sg[u + 1]), np.copy(sg[u])
u -= 1
# rasterizing
# at each point in time we keep track of segments that are "active" (or "current")
csg = np.zeros((5 * int(abs(divergence_px)) + 25, 6), dtype=np.float_)
csg_end: int = 0
sg_pointer: int = 0
# and index of the point that should be processed next
pt_i: int = 0
for col in range(w): # iterate over regions (that will be rasterized into pixels)
color = np.full(c, 0.5, dtype=np.float_) # we start with 0.5 because of how floats are converted to ints
while pt[pt_i][0] < col:
pt_i += 1
pt_i -= 1 # pt_i now points to the dot before the region start
# Finding segment' parts that contribute color to the region
while pt[pt_i][0] < col + 1:
coord_from = max(col, pt[pt_i][0]) + EPSILON
coord_to = min(col + 1, pt[pt_i + 1][0]) - EPSILON
significance = coord_to - coord_from
# the color at center point is the same as the average of color of segment part
coord_center = coord_from + 0.5 * significance
# adding segments that now may contribute
while sg_pointer < sg_end and sg[sg_pointer][0] < coord_center:
csg[csg_end] = sg[sg_pointer]
sg_pointer += 1
csg_end += 1
# removing segments that will no longer contribute
csg_i = 0
while csg_i < csg_end:
if csg[csg_i][3] < coord_center:
csg[csg_i] = csg[csg_end - 1]
csg_end -= 1
else:
csg_i += 1
# finding the closest segment (segment with most divergence)
# note that this segment will be the closest from coord_from right up to coord_to, since there
# no new segments "appearing" inbetween these two and _the polyline does not self-intersect_
best_csg_i: int = 0
# PERF_COUNTERS[0] += 1
if csg_end != 1:
# PERF_COUNTERS[1] += 1
best_csg_closeness: float = -EPSILON
for csg_i in range(csg_end):
ip_k = (coord_center - csg[csg_i][0]) / (csg[csg_i][3] - csg[csg_i][0])
# assert 0.0 <= ip_k <= 1.0
closeness = (1.0 - ip_k) * csg[csg_i][1] + ip_k * csg[csg_i][4]
if best_csg_closeness < closeness and 0.0 < ip_k < 1.0:
best_csg_closeness = closeness
best_csg_i = csg_i
# getting the color
col_l: int = int(csg[best_csg_i][2] + EPSILON)
col_r: int = int(csg[best_csg_i][5] + EPSILON)
if col_l == col_r:
color += original_image[row][col_l] * significance
else:
# PERF_COUNTERS[2] += 1
ip_k = (coord_center - csg[best_csg_i][0]) / (csg[best_csg_i][3] - csg[best_csg_i][0])
color += (original_image[row][col_l] * (1.0 - ip_k) +
original_image[row][col_r] * ip_k
) * significance
pt_i += 1
derived_image[row][col] = np.asarray(color, dtype=np.uint8)
# print(PERF_COUNTERS)
return derived_image
@njit(parallel=True)
def overlap_red_cyan(im1, im2):
width1 = im1.shape[1]
height1 = im1.shape[0]
width2 = im2.shape[1]
height2 = im2.shape[0]
# final image
composite = np.zeros((height2, width2, 3), np.uint8)
# iterate through "left" image, filling in red values of final image
for i in prange(height1):
for j in range(width1):
composite[i, j, 0] = im1[i, j, 0]
# iterate through "right" image, filling in blue/green values of final image
for i in prange(height2):
for j in range(width2):
composite[i, j, 1] = im2[i, j, 1]
composite[i, j, 2] = im2[i, j, 2]
return composite