-
Notifications
You must be signed in to change notification settings - Fork 15
/
Copy pathfisheye.py
989 lines (916 loc) · 43.5 KB
/
fisheye.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
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
# -*- coding: utf-8 -*-
# Dual-fisheye to 360-photo conversion tool
# Supports equirectangular and cubemap output formats
#
# Usage instructions:
# python fisheye.py'
# Start interactive alignment GUI.
# python fisheye.py -help
# Print this help message.
# python fisheye.py lens.cfg in1.jpg in2.jpg gui
# Launch interactive GUI with specified default options
# python fisheye.py lens.cfg in1.jpg in2.jpg rect=out.png
# Render and save equirectangular panorama using specified
# lens configuration and source images.'
# python fisheye.py lens.cfg in1.jpg in2.jpg cube=out.png
# Render and save cubemap panorama using specified
# lens configuration and source images.
#
# Copyright (c) 2016 Alexander C. Utter
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
import json
import numpy as np
import Tkinter as tk
import tkFileDialog
import tkMessageBox
import sys
import traceback
from copy import deepcopy
from math import pi
from PIL import Image, ImageTk
from scipy.optimize import minimize
from threading import Thread
# Create rotation matrix from an arbitrary quaternion. See also:
# https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation#Conversion_to_and_from_the_matrix_representation
def get_rotation_matrix(qq):
# Normalize matrix and extract individual items.
qq_norm = np.sqrt(np.sum(np.square(qq)))
w = qq[0] / qq_norm
x = qq[1] / qq_norm
y = qq[2] / qq_norm
z = qq[3] / qq_norm
# Convert to rotation matrix.
return np.matrix([[w*w+x*x-y*y-z*z, 2*x*y-2*w*z, 2*x*z+2*w*y],
[2*x*y+2*w*z, w*w-x*x+y*y-z*z, 2*y*z-2*w*x],
[2*x*z-2*w*y, 2*y*z+2*w*x, w*w-x*x-y*y+z*z]], dtype='float32')
# Conjugate a quaternion to apply the opposite rotation.
def conj_qq(qq):
return np.array([qq[0], -qq[1], -qq[2], -qq[3]])
# Multiply two quaternions:ab = (a0b0 - av dot bv; a0*bv + b0av + av cross bv)
# https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation#Conversion_to_and_from_the_matrix_representation
def mul_qq(qa, qb):
return np.array([qa[0]*qb[0] - qa[1]*qb[1] - qa[2]*qb[2] - qa[3]*qb[3],
qa[0]*qb[1] + qa[1]*qb[0] + qa[2]*qb[3] - qa[3]*qb[2],
qa[0]*qb[2] + qa[2]*qb[0] + qa[3]*qb[1] - qa[1]*qb[3],
qa[0]*qb[3] + qa[3]*qb[0] + qa[1]*qb[2] - qa[2]*qb[1]])
# Generate a normalized quaternion [W,X,Y,Z] from [X,Y,Z]
def norm_qq(x, y, z):
rsq = x**2 + y**2 + z**2
if rsq < 1:
w = np.sqrt(1-rsq)
return [w, x, y, z]
else:
r = np.sqrt(rsq)
return [0, x/r, y/r, z/r]
# Return length of every column in an MxN matrix.
def matrix_len(x):
#return np.sqrt(np.sum(np.square(x), axis=0))
return np.linalg.norm(x, axis=0)
# Normalize an MxN matrix such that all N columns have unit length.
def matrix_norm(x):
return x / (matrix_len(x) + 1e-9)
# Parameters for a fisheye lens, including its orientation.
class FisheyeLens:
def __init__(self, rows=1024, cols=1024):
# Fisheye lens parameters.
self.fov_deg = 180
self.radius_px = min(rows,cols) / 2
# Pixel coordinates of the optical axis (X,Y).
self.center_px = np.matrix([[cols/2], [rows/2]])
# Quaternion mapping intended to actual optical axis.
self.center_qq = [1, 0, 0, 0]
def downsample(self, dsamp):
self.radius_px /= dsamp
self.center_px /= dsamp
def get_x(self):
return np.asscalar(self.center_px[0])
def get_y(self):
return np.asscalar(self.center_px[1])
def to_dict(self):
return {'cx':self.get_x(),
'cy':self.get_y(),
'cr':self.radius_px,
'cf':self.fov_deg,
'qw':self.center_qq[0],
'qx':self.center_qq[1],
'qy':self.center_qq[2],
'qz':self.center_qq[3]}
def from_dict(self, data):
self.center_px[0] = data['cx']
self.center_px[1] = data['cy']
self.radius_px = data['cr']
self.fov_deg = data['cf']
self.center_qq[0] = data['qw']
self.center_qq[1] = data['qx']
self.center_qq[2] = data['qy']
self.center_qq[3] = data['qz']
# Load or save lens configuration and alignment.
def load_config(file_obj, lens1, lens2):
[data1, data2] = json.load(file_obj)
lens1.from_dict(data1)
lens2.from_dict(data2)
def save_config(file_obj, lens1, lens2):
data = [lens1.to_dict(), lens2.to_dict()]
json.dump(data, file_obj, indent=2, sort_keys=True)
# Fisheye source image, with lens and rotation parameters.
# Contains functions for extracting pixel data given direction vectors.
class FisheyeImage:
# Load image file and set default parameters
def __init__(self, src_file, lens=None):
# Load the image file, and convert to a numpy matrix.
self._update_img(Image.open(src_file))
# Set lens parameters.
if lens is None:
self.lens = FisheyeLens(self.rows, self.cols)
else:
self.lens = lens
# Update image matrix and corresponding size variables.
def _update_img(self, img):
self.img = np.array(img)
self.rows = self.img.shape[0]
self.cols = self.img.shape[1]
self.clrs = self.img.shape[2]
# Shrink source image and adjust lens accordingly.
def downsample(self, dsamp):
# Adjust lens parameters.
self.lens.downsample(dsamp)
# Determine the new image dimensions.
# Note: PIL uses cols, rows whereas numpy uses rows, cols
shape = (self.img.shape[1] / dsamp, # Cols
self.img.shape[0] / dsamp) # Rows
# Convert matrix back to PIL Image and resample.
img = Image.fromarray(self.img)
img.thumbnail(shape, Image.BICUBIC)
# Convert back and update size.
self._update_img(img)
# Given an 3xN array of "XYZ" vectors in panorama space (+X = Front),
# convert each ray to 2xN coordinates in "UV" fisheye image space.
def get_uv(self, xyz_vec):
# Extract lens parameters of interest.
fov_rad = self.lens.fov_deg * pi / 180
fov_scale = np.float32(2 * self.lens.radius_px / fov_rad)
# Normalize the input vector and rotate to match lens reference axes.
xyz_rot = get_rotation_matrix(self.lens.center_qq) * matrix_norm(xyz_vec)
# Convert to polar coordinates relative to lens boresight.
# (In lens coordinates, unit vector's X axis gives boresight angle;
# normalize Y/Z to get a planar unit vector for the bearing.)
# Note: Image +Y maps to 3D +Y, and image +X maps to 3D +Z.
theta_rad = np.arccos(xyz_rot[0,:])
proj_vec = matrix_norm(np.concatenate((xyz_rot[2,:], xyz_rot[1,:])))
# Fisheye lens maps 3D angle to focal-plane radius.
# TODO: Do we need a better model for lens distortion?
rad_px = theta_rad * fov_scale
# Convert back to focal-plane rectangular coordinates.
uv = np.multiply(rad_px, proj_vec) + self.lens.center_px
return np.asarray(uv + 0.5, dtype=int)
# Given an 2xN array of UV pixel coordinates, check if each pixel is
# within the fisheye field of view. Returns N-element boolean mask.
def get_mask(self, uv_px):
# Check whether each coordinate is within outer image bounds,
# and within the illuminated area under the fisheye lens.
x_mask = np.logical_and(0 <= uv_px[0], uv_px[0] < self.cols)
y_mask = np.logical_and(0 <= uv_px[1], uv_px[1] < self.rows)
# Check whether each coordinate is within the illuminated area.
r_mask = matrix_len(uv_px - self.lens.center_px) < self.lens.radius_px
# All three checks must pass to be considered visible.
all_mask = np.logical_and(r_mask, np.logical_and(x_mask, y_mask))
return np.squeeze(np.asarray(all_mask))
# Given an 2xN array of UV pixel coordinates, return a weight score
# that is proportional to the distance from the edge.
def get_weight(self, uv_px):
mm = self.get_mask(uv_px)
rr = self.lens.radius_px - matrix_len(uv_px - self.lens.center_px)
rr[~mm] = 0
return rr
# Given a 2xN array of UV pixel coordinates, return the value of each
# corresponding pixel. Output format is Nx1 (grayscale) or Nx3 (color).
# Pixels outside the fisheye's field of view are pure black (0) or (0,0,0).
def get_pixels(self, uv_px):
# Create output array with default pixel values.
pcount = uv_px.shape[1]
result = np.zeros((pcount, self.clrs), dtype=self.img.dtype)
# Overwrite in-bounds pixels as specified above.
self.add_pixels(uv_px, result)
return result
# Given a 2xN array of UV pixel coordinates, write the value of each
# corresponding pixel to the linearized input/output image (Nx3).
# Several weighting modes are available.
def add_pixels(self, uv_px, img1d, weight=None):
# Lookup row & column for each in-bounds coordinate.
mask = self.get_mask(uv_px)
xx = uv_px[0,mask]
yy = uv_px[1,mask]
# Update matrix according to assigned weight.
if weight is None:
img1d[mask] = self.img[yy,xx]
elif np.isscalar(weight):
img1d[mask] += self.img[yy,xx] * weight
else:
w1 = np.asmatrix(weight, dtype='float32')
w3 = w1.transpose() * np.ones((1,3))
img1d[mask] += np.multiply(self.img[yy,xx], w3[mask])
# A panorama image made from several FisheyeImage sources.
# TODO: Add support for supersampled anti-aliasing filters.
class PanoramaImage:
def __init__(self, src_list):
self.debug = True
self.sources = src_list
self.dtype = self.sources[0].img.dtype
self.clrs = self.sources[0].clrs
# Downsample each source image.
def downsample(self, dsamp):
for src in self.sources:
src.downsample(dsamp)
# Return a list of 'mode' strings suitable for render_xx() methods.
def get_render_modes(self):
return ['overwrite', 'align', 'blend']
# Retrieve a scaled copy of lens parameters for the Nth source.
def scale_lens(self, idx, scale=None):
temp = deepcopy(self.sources[idx].lens)
temp.downsample(1.0 / scale)
return temp
# Using current settings as an initial guess, use an iterative optimizer
# to better align the source images. Adjusts FOV of each lens, as well
# as the rotation quaternions for all lenses except the first.
# TODO: Implement a higher-order loop that iterates this step with
# progressively higher resolution. (See also: create_panorama)
# TODO: Find a better scoring heuristic. Present solution always
# converges on either FOV=0 or FOV=9999, depending on wt_pixel.
def optimize(self, psize=256, wt_pixel=1000, wt_blank=1000):
# Precalculate raster-order XYZ coordinates at given resolution.
[xyz, rows, cols] = self._get_equirectangular_raster(psize)
# Scoring function gives bonus points per overlapping pixel.
score = lambda svec: self._score(svec, xyz, wt_pixel, wt_blank)
# Multivariable optimization using gradient-descent or similar.
# https://docs.scipy.org/doc/scipy/reference/tutorial/optimize.html
svec0 = self._get_state_vector()
final = minimize(score, svec0, method='Nelder-Mead',
options={'xtol':1e-4, 'disp':True})
# Store final lens parameters.
self._set_state_vector(final.x)
# Render combined panorama in equirectangular projection mode.
# See also: https://en.wikipedia.org/wiki/Equirectangular_projection
def render_equirectangular(self, out_size, mode='blend'):
# Render the entire output in a single pass.
[xyz, rows, cols] = self._get_equirectangular_raster(out_size)
return Image.fromarray(self._render(xyz, rows, cols, mode))
# Render combined panorama in cubemap projection mode.
# See also: https://en.wikipedia.org/wiki/Cube_mapping
def render_cubemap(self, out_size, mode='blend'):
# Create coordinate arrays.
cvec = np.arange(out_size, dtype='float32') - out_size/2 # Coordinate range [-S/2, S/2)
vec0 = np.ones(out_size*out_size, dtype='float32') * out_size/2 # Constant vector +S/2
vec1 = np.repeat(cvec, out_size) # Increment every N steps
vec2 = np.tile(cvec, out_size) # Sweep N times
# Create XYZ coordinate vectors and render each cubemap face.
render = lambda(xyz): self._render(xyz, out_size, out_size, mode)
xm = render(np.matrix([-vec0, vec1, vec2])) # -X face
xp = render(np.matrix([vec0, vec1, -vec2])) # +X face
ym = render(np.matrix([-vec1, -vec0, vec2])) # -Y face
yp = render(np.matrix([vec1, vec0, vec2])) # +Y face
zm = render(np.matrix([-vec2, vec1, -vec0])) # -Z face
zp = render(np.matrix([vec2, vec1, vec0])) # +Z face
# Concatenate the individual faces in canonical order:
# https://en.wikipedia.org/wiki/Cube_mapping#Memory_Addressing
img_mat = np.concatenate([zp, zm, ym, yp, xm, xp], axis=0)
return Image.fromarray(img_mat)
# Get XYZ vectors for an equirectangular render, in raster order.
# (Each row left to right, with rows concatenates from top to bottom.)
def _get_equirectangular_raster(self, out_size):
# Set image size (2x1 aspect ratio)
rows = out_size
cols = 2*out_size
# Calculate longitude of each column.
theta_x = np.linspace(-pi, pi, cols, endpoint=False, dtype='float32')
cos_x = np.cos(theta_x).reshape(1,cols)
sin_x = np.sin(theta_x).reshape(1,cols)
# Calculate lattitude of each row.
ystep = pi / rows
theta_y = np.linspace(-pi/2 + ystep/2, pi/2 - ystep/2, rows, dtype='float32')
cos_y = np.cos(theta_y).reshape(rows,1)
sin_y = np.sin(theta_y).reshape(rows,1)
# Calculate X, Y, and Z coordinates for each output pixel.
x = cos_y * cos_x
y = sin_y * np.ones((1,cols), dtype='float32')
z = cos_y * sin_x
# Vectorize the coordinates in raster order.
xyz = np.matrix([x.ravel(), y.ravel(), z.ravel()])
return [xyz, rows, cols]
# Convert all lens parameters to a state vector. See also: optimize()
def _get_state_vector(self):
nsrc = len(self.sources)
assert nsrc > 0
svec = np.zeros(4*nsrc - 3)
# First lens: Only the FOV is stored.
svec[0] = self.sources[0].lens.fov_deg - 180
# All other lenses: Store FOV and quaternion parameters.
for n in range(1, nsrc):
svec[4*n-3] = self.sources[n].lens.fov_deg - 180
svec[4*n-2] = self.sources[n].lens.center_qq[1]
svec[4*n-1] = self.sources[n].lens.center_qq[2]
svec[4*n-0] = self.sources[n].lens.center_qq[3]
return svec
# Update lens parameters based on state vector. See also: optimize()
def _set_state_vector(self, svec):
# Sanity check on input vector.
nsrc = len(self.sources)
assert len(svec) == (4*nsrc - 3)
# First lens: Only the FOV is changed.
self.sources[0].lens.fov_deg = svec[0] + 180
# All other lenses: Update FOV and quaternion parameters.
for n in range(1, nsrc):
self.sources[n].lens.fov_deg = svec[4*n-3] + 180
self.sources[n].lens.center_qq[1] = svec[4*n-2]
self.sources[n].lens.center_qq[2] = svec[4*n-1]
self.sources[n].lens.center_qq[3] = svec[4*n-0]
# Add pixels from every source to form a complete output image.
# Several blending modes are available. See also: get_render_modes()
def _render(self, xyz, rows, cols, mode):
# Allocate Nx3 or Nx1 "1D" pixel-list (raster-order).
img1d = np.zeros((rows*cols, self.clrs), dtype='float32')
# Determine rendering mode:
if mode == 'overwrite':
# Simplest mode: Draw first, then blindly overwrite second.
for src in self.sources:
uv = src.get_uv(xyz)
src.add_pixels(uv, img1d)
elif mode == 'align':
# Alignment mode: Draw each one at 50% intensity.
for src in self.sources:
uv = src.get_uv(xyz)
src.add_pixels(uv, img1d, 0.5)
elif mode == 'blend':
# Linear nearest-source blending.
uv_list = []
wt_list = []
wt_total = np.zeros(rows*cols, dtype='float32')
# Calculate per-image and total weight matrices.
for src in self.sources:
uv = src.get_uv(xyz)
wt = src.get_weight(uv)
uv_list.append(uv)
wt_list.append(wt)
wt_total += wt
# Render overall image using calculated weights.
for n in range(len(self.sources)):
wt_norm = wt_list[n] / wt_total
self.sources[n].add_pixels(uv_list[n], img1d, wt_norm)
else:
raise ValueError('Invalid render mode.')
# Convert to fixed-point image matrix and return.
img2d = np.reshape(img1d, (rows, cols, self.clrs))
return np.asarray(img2d, dtype=self.dtype)
# Compute a normalized alignment score, based on size of overlap and
# the pixel-differences in that region. Note: Lower = Better.
def _score(self, svec, xyz, wt_pixel, wt_blank):
# Update lens parameters from state vector.
self._set_state_vector(svec)
# Determine masks for each input image.
uv0 = self.sources[0].get_uv(xyz)
uv1 = self.sources[1].get_uv(xyz)
wt0 = self.sources[0].get_weight(uv0) > 0
wt1 = self.sources[1].get_weight(uv1) > 0
# Count overlapping pixels.
ovr_mask = np.logical_and(wt0, wt1) # Overlapping pixel
pix_count = np.sum(wt0) + np.sum(wt1) # Total drawn pixels
blk_count = np.sum(np.logical_and(~wt0, ~wt1)) # Number of blank pixels
# Allocate Nx3 or Nx1 "1D" pixel-list (raster-order).
pcount = max(xyz.shape)
img1d = np.zeros((pcount, self.clrs), dtype='float32')
# Render the difference image, overlapping region only.
self.sources[0].add_pixels(uv0, img1d, 1.0*ovr_mask)
self.sources[1].add_pixels(uv1, img1d, -1.0*ovr_mask)
# Sum-of-differences.
sum_sqd = np.sum(np.sum(np.sum(np.square(img1d))))
# Compute overall score. (Note: Higher = Better)
score = sum_sqd + wt_blank * blk_count - wt_pixel * pix_count
# (Debug) Print status information.
if (self.debug):
print str(svec) + ' --> ' + str(score)
return score
# Tkinter GUI window for loading a fisheye image.
class FisheyeAlignmentGUI:
def __init__(self, parent, src_file, lens):
# Set flag once all window objects created.
self.init_done = False
# Final result is the lens object.
self.lens = lens
# Load the input file.
self.img = Image.open(src_file)
# Create frame for this window with two vertical panels...
parent.wm_title('Fisheye Alignment')
self.frame = tk.Frame(parent)
self.controls = tk.Frame(self.frame)
# Make sliders for adjusting the lens parameters quaternion.
self.x = self._make_slider(self.controls, 0, 'Center-X (px)',
lens.get_x(), self.img.size[0])
self.y = self._make_slider(self.controls, 1, 'Center-Y (px)',
lens.get_y(), self.img.size[1])
self.r = self._make_slider(self.controls, 2, 'Radius (px)',
lens.radius_px, self.img.size[0])
self.f = self._make_slider(self.controls, 3, 'Field of view (deg)',
lens.fov_deg, 240, res=0.1)
# Create a frame for the preview image, which resizes based on the
# outer frame but does not respond to the contained preview size.
self.preview_frm = tk.Frame(self.frame)
self.preview_frm.bind('<Configure>', self._update_callback) # Update on resize
# Create the canvas object for the preview image.
self.preview = tk.Canvas(self.preview_frm)
# Finish frame creation.
self.controls.pack(side=tk.LEFT)
self.preview.pack(fill=tk.BOTH, expand=1)
self.preview_frm.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)
self.frame.pack(fill=tk.BOTH, expand=1)
# Render the image once at default size
self.init_done = True
self.update_preview((800,800))
# Disable further size propagation.
self.preview_frm.update()
self.preview_frm.pack_propagate(0)
# Redraw the preview image using latest GUI parameters.
def update_preview(self, psize):
# Safety check: Ignore calls during construction/destruction.
if not self.init_done: return
# Copy latest user settings to the lens object.
self.lens.fov_deg = self.f.get()
self.lens.radius_px = self.r.get()
self.lens.center_px[0] = self.x.get()
self.lens.center_px[1] = self.y.get()
# Re-scale the image to match the canvas size.
# Note: Make a copy first, because thumbnail() operates in-place.
self.img_sc = self.img.copy()
self.img_sc.thumbnail(psize, Image.NEAREST)
self.img_tk = ImageTk.PhotoImage(self.img_sc)
# Re-scale the x/y/r parameters to match the preview scale.
pre_scale = float(psize[0]) / float(self.img.size[0])
x = self.x.get() * pre_scale
y = self.y.get() * pre_scale
r = self.r.get() * pre_scale
# Clear and redraw the canvas.
self.preview.delete('all')
self.preview.create_image(0, 0, anchor=tk.NW, image=self.img_tk)
self.preview.create_oval(x-r, y-r, x+r, y+r,
outline='#C00000', width=3)
# Make a combined label/textbox/slider for a given variable:
def _make_slider(self, parent, rowidx, label, inival, maxval, res=0.5):
# Create shared variable and set initial value.
tkvar = tk.DoubleVar()
tkvar.set(inival)
# Set a callback for whenever tkvar is changed.
# (The 'command' callback on the SpinBox only applies to the buttons.)
tkvar.trace('w', self._update_callback)
# Create the Label, SpinBox, and Scale objects.
label = tk.Label(parent, text=label)
spbox = tk.Spinbox(parent,
textvariable=tkvar,
from_=0, to=maxval, increment=res)
slide = tk.Scale(parent,
orient=tk.HORIZONTAL,
showvalue=0,
variable=tkvar,
from_=0, to=maxval, resolution=res)
label.grid(row=rowidx, column=0)
spbox.grid(row=rowidx, column=1)
slide.grid(row=rowidx, column=2)
return tkvar
# Find the largest output size that fits within the given bounds and
# matches the aspect ratio of the original source image.
def _get_aspect_size(self, max_size):
img_ratio = float(self.img.size[1]) / float(self.img.size[0])
return (min(max_size[0], max_size[1] / img_ratio),
min(max_size[1], max_size[0] * img_ratio))
# Thin wrapper for update_preview(), used to strip Tkinter arguments.
def _update_callback(self, *args):
# Sanity check that initialization is completed:
if not self.init_done: return
# Determine the render size. (Always 2:1 aspect ratio.)
psize = self._get_aspect_size((self.preview_frm.winfo_width(),
self.preview_frm.winfo_height()))
# Render the preview at the given size.
if psize[0] >= 10 and psize[1] >= 10:
self.update_preview(psize)
# Tkinter GUI window for calibrating fisheye alignment.
class PanoramaAlignmentGUI:
def __init__(self, parent, panorama, psize=512):
self.init_done = False
# Store source and preview size
self.panorama = panorama
# Create frame for this window with two vertical panels...
parent.wm_title('Panorama Alignment')
self.frame = tk.Frame(parent)
self.controls = tk.Frame(self.frame)
# Make a drop-menu to select the rendering mode.
tk.Label(self.controls, text='Preview mode').grid(row=0, column=0, sticky=tk.W)
self.mode = tk.StringVar()
self.mode.set('align')
self.mode.trace('w', self._update_callback)
mode_list = self.panorama.get_render_modes()
mode_drop = tk.OptionMenu(self.controls, self.mode, *mode_list)
mode_drop.grid(row=0, column=1, columnspan=2, sticky='NESW')
# Determine which axis marks the main 180 degree rotation.
front_qq = panorama.sources[0].lens.center_qq
back_qq = panorama.sources[1].lens.center_qq
diff_qq = mul_qq(front_qq, back_qq)
# Create the axis selection toggle. (Flip on Y or Z)
self.flip_axis = tk.BooleanVar()
self.flip_axis.trace('w', self._update_callback)
if abs(diff_qq[2]) > abs(diff_qq[3]):
self.flip_axis.set(False)
flip_qq = [0,0,1,0]
else:
self.flip_axis.set(True)
flip_qq = [0,0,0,1]
tk.Label(self.controls, text='Flip axis').grid(row=1, column=0, sticky=tk.W)
axis_chk = tk.Checkbutton(self.controls, variable=self.flip_axis)
axis_chk.grid(row=1, column=1, columnspan=2, sticky='NESW')
# Extract the (hopefully small) alignment offset.
flip_conj = conj_qq(mul_qq(flip_qq, front_qq))
align_qq = mul_qq(back_qq, flip_conj)
# Make three sliders for adjusting the relative alignment.
self.slide_rx = self._make_slider(self.controls, 2, 'Rotate X', front_qq[1])
self.slide_ry = self._make_slider(self.controls, 3, 'Rotate Y', front_qq[2])
self.slide_rz = self._make_slider(self.controls, 4, 'Rotate Z', front_qq[3])
self.slide_ax = self._make_slider(self.controls, 5, 'Align X', align_qq[1])
self.slide_ay = self._make_slider(self.controls, 6, 'Align Y', align_qq[2])
self.slide_az = self._make_slider(self.controls, 7, 'Align Z', align_qq[3])
# Finish control-frame creation.
self.controls.pack(side=tk.LEFT)
# Create a frame for the preview image, which resizes based on the
# outer frame but does not respond to the contained preview size.
self.preview_frm = tk.Frame(self.frame)
self.preview_frm.bind('<Configure>', self._update_callback) # Update on resize
# Add the preview.
self.preview_lbl = tk.Label(self.preview_frm) # Label displays image
self.preview_lbl.pack()
self.preview_frm.pack(fill=tk.BOTH, expand=1)
# Finish frame creation.
self.frame.pack(fill=tk.BOTH, expand=1)
# Render the image once at default size
self.init_done = True
self.update_preview(psize)
# Disable further size propagation.
self.preview_frm.update()
self.preview_frm.pack_propagate(0)
# Update the GUI preview using latest alignment parameters.
def update_preview(self, psize):
# Sanity check that initialization is completed:
if not self.init_done: return
# Determine the primary axis of rotation.
if self.flip_axis.get():
flip_qq = [0,0,0,1]
else:
flip_qq = [0,0,1,0]
# Calculate the orientation of both lenses.
front_qq = norm_qq(self.slide_rx.get(),
self.slide_ry.get(),
self.slide_rz.get())
align_qq = norm_qq(self.slide_ax.get(),
self.slide_ay.get(),
self.slide_az.get())
back_qq = mul_qq(align_qq, mul_qq(flip_qq, front_qq))
self.panorama.sources[0].lens.center_qq = front_qq
self.panorama.sources[1].lens.center_qq = back_qq
# Render the preview.
# Note: The Tk-Label doesn't maintain a reference to the image object.
# To avoid garbage-collection, keep one in this class.
self.preview_img = ImageTk.PhotoImage(
self.panorama.render_equirectangular(psize, self.mode.get()))
# Assign the new icon.
self.preview_lbl.configure(image=self.preview_img)
# Find the largest output size that fits within the given bounds and
# matches the 2:1 aspect ratio of the equirectangular preview.
def _get_aspect_size(self, max_size):
return (min(max_size[0], max_size[1] / 2),
min(max_size[1], max_size[0] * 2))
# Make a combined label/textbox/slider for a given variable:
def _make_slider(self, parent, rowidx, label, inival):
# Set limits and resolution.
lim = 1.0
res = 0.001
# Create shared variable.
tkvar = tk.DoubleVar()
tkvar.set(inival)
# Set a callback for whenever tkvar is changed.
# (The 'command' callback on the SpinBox only applies to the buttons.)
tkvar.trace('w', self._update_callback)
# Create the Label, SpinBox, and Scale objects.
label = tk.Label(parent, text=label)
spbox = tk.Spinbox(parent,
textvariable=tkvar,
from_=-lim, to=lim, increment=res)
slide = tk.Scale(parent,
orient=tk.HORIZONTAL,
showvalue=0,
variable=tkvar,
from_=-lim, to=lim, resolution=res)
label.grid(row=rowidx, column=0, sticky='W')
spbox.grid(row=rowidx, column=1)
slide.grid(row=rowidx, column=2)
return tkvar
# Thin wrapper for update_preview(), used to strip Tkinter arguments.
def _update_callback(self, *args):
# Sanity check that initialization is completed:
if not self.init_done: return
# Determine the render size. (Always 2:1 aspect ratio.)
psize = min(self.preview_frm.winfo_width()/2,
self.preview_frm.winfo_height())
# Render the preview at the given size.
# TODO: Fudge factor of -2 avoids infinite resize loop.
# Is there a better way?
if psize >= 10:
self.update_preview(psize-2)
# Tkinter GUI window for end-to-end alignment and rendering.
class PanoramaGUI:
def __init__(self, parent):
# Store reference object for creating child dialogs.
self.parent = parent
self.win_lens1 = None
self.win_lens2 = None
self.win_align = None
self.work_done = False
self.work_error = None
self.work_status = None
# Create dummy lens configuration.
self.lens1 = FisheyeLens()
self.lens2 = FisheyeLens()
self.lens2.center_qq = [0,0,1,0] # Default flip along Y axis.
# Create frame for this GUI.
parent.wm_title('Panorama Creation Tool')
frame = tk.Frame(parent)
# Make file-selection inputs for the two images.
img_frame = tk.LabelFrame(frame, text='Input Images')
self.img1 = self._make_file_select(img_frame, 0, 'Image #1')
self.img2 = self._make_file_select(img_frame, 1, 'Image #2')
img_frame.pack()
# Make buttons to load, save, and adjust the lens configuration.
lens_frame = tk.LabelFrame(frame, text='Lens Configuration and Alignment')
btn_lens1 = tk.Button(lens_frame, text='Lens 1', command=self._adjust_lens1)
btn_lens2 = tk.Button(lens_frame, text='Lens 2', command=self._adjust_lens2)
btn_align = tk.Button(lens_frame, text='Align', command=self._adjust_align)
btn_auto = tk.Button(lens_frame, text='Auto', command=self._auto_align_start)
btn_load = tk.Button(lens_frame, text='Load', command=self.load_config)
btn_save = tk.Button(lens_frame, text='Save', command=self.save_config)
btn_lens1.grid(row=0, column=0, sticky='NESW')
btn_lens2.grid(row=0, column=1, sticky='NESW')
btn_align.grid(row=0, column=2, sticky='NESW')
btn_auto.grid(row=0, column=3, sticky='NESW')
btn_load.grid(row=1, column=0, columnspan=2, sticky='NESW')
btn_save.grid(row=1, column=2, columnspan=2, sticky='NESW')
lens_frame.pack(fill=tk.BOTH)
# Buttons to render the final output in different modes.
out_frame = tk.LabelFrame(frame, text='Final output rendering')
btn_rect = tk.Button(out_frame, text='Equirectangular',
command=self._render_rect)
btn_cube = tk.Button(out_frame, text='Cubemap',
command=self._render_cube)
btn_rect.pack(fill=tk.BOTH)
btn_cube.pack(fill=tk.BOTH)
out_frame.pack(fill=tk.BOTH)
# Status indicator box.
self.status = tk.Label(frame, relief=tk.SUNKEN,
text='Select input images to begin.')
self.status.pack(fill=tk.BOTH)
# Finish frame creation.
frame.pack()
# Helper function to destroy an object.
def _destroy(self, obj):
if obj is not None:
obj.destroy()
# Popup dialogs for each alignment step.
def _adjust_lens1(self):
self._destroy(self.win_lens1)
try:
self.win_lens1 = tk.Toplevel(self.parent)
FisheyeAlignmentGUI(self.win_lens1, self.img1.get(), self.lens1)
except IOError:
self._destroy(self.win_lens1)
tkMessageBox.showerror('Error', 'Unable to read image file #1.')
except:
self._destroy(self.win_lens1)
tkMessageBox.showerror('Dialog creation error', traceback.format_exc())
def _adjust_lens2(self):
self._destroy(self.win_lens2)
try:
self.win_lens2 = tk.Toplevel(self.parent)
FisheyeAlignmentGUI(self.win_lens2, self.img2.get(), self.lens2)
except IOError:
self._destroy(self.win_lens2)
tkMessageBox.showerror('Error', 'Unable to read image file #2.')
except:
self._destroy(self.win_lens2)
tkMessageBox.showerror('Dialog creation error', traceback.format_exc())
def _adjust_align(self):
self._destroy(self.win_align)
try:
pan = self._create_panorama()
self.win_align = tk.Toplevel(self.parent)
PanoramaAlignmentGUI(self.win_align, pan)
except:
self._destroy(self.win_align)
tkMessageBox.showerror('Dialog creation error', traceback.format_exc())
# Automatic alignment.
# Use worker thread, because this may take a while.
def _auto_align_start(self):
try:
# Create panorama object from within GUI thread, since it depends
# on Tk variables which are NOT thread-safe.
pan = self._create_panorama()
# Display status message and display hourglass...
self._set_status('Starting auto-alignment...', 'wait')
# Create a new worker thread.
work = Thread(target=self._auto_align_work, args=[pan])
work.start()
# Set a timer to periodically check for completion.
self.parent.after(200, self._auto_align_timer)
except:
tkMessageBox.showerror('Auto-alignment error', traceback.format_exc())
def _auto_align_work(self, pan):
try:
# Repeat alignment at progressively higher resolution.
self._auto_align_step(pan, 16, 128, 'Stage 1/4')
self._auto_align_step(pan, 8, 128, 'Stage 2/4')
self._auto_align_step(pan, 4, 192, 'Stage 3/4')
self._auto_align_step(pan, 2, 256, 'Stage 4/4')
# Signal success!
self.work_status = 'Auto-alignment completed.'
self.work_error = None
self.work_done = True
except:
# Signal error.
self.work_status = 'Auto-alignment failed.'
self.work_error = traceback.format_exc()
self.work_done = True
def _auto_align_step(self, pan, scale, psize, label):
# Update status message.
self.work_status = 'Auto-alignment: ' + str(label)
# Create a panorama object at 1/scale times original resolution.
pan_sc = deepcopy(pan)
pan_sc.downsample(scale)
# Run optimization, rendering each hypothesis at the given resolution.
pan_sc.optimize(psize)
# Update local lens parameters.
# Note: These are not Tk variables, so are safe to change.
self.lens1 = pan_sc.scale_lens(0, scale)
self.lens2 = pan_sc.scale_lens(1, scale)
# Timer callback object checks outputs from worker thread.
# (Tkinter objects are NOT thread safe.)
def _auto_align_timer(self, *args):
# Check thread status.
if self.work_done:
# Update status message, with popup on error.
if self.work_status is not None:
self._set_status(self.work_status)
if self.work_error is not None:
self._set_status('Auto-alignment failed.')
tkMessageBox.showerror('Auto-alignment error', self.work_error)
# Clear the 'done' flag for future runs.
self.work_done = False
else:
# Update status message and keep hourglass.
if self.work_status is not None:
self._set_status(self.work_status, 'wait')
# Reset timer to be called again.
self.parent.after(200, self._auto_align_timer)
# Create panorama object using current settings.
def _create_panorama(self):
img1 = FisheyeImage(self.img1.get(), self.lens1)
img2 = FisheyeImage(self.img2.get(), self.lens2)
return PanoramaImage((img1, img2))
# Load or save lens configuration and alignment.
def load_config(self, filename=None):
if filename is None:
file_obj = tkFileDialog.askopenfile()
if file_obj is None: return
else:
file_obj = open(filename, 'r')
try:
load_config(file_obj, self.lens1, self.lens2)
except:
tkMessageBox.showerror('Config load error', traceback.format_exc())
def save_config(self, filename=None):
if filename is None:
file_obj = tkFileDialog.asksaveasfile()
if file_obj is None: return
else:
file_obj = open(filename, 'w')
try:
save_config(file_obj, self.lens1, self.lens2)
except:
tkMessageBox.showerror('Config save error', traceback.format_exc())
# Render and save output in various modes.
def _render_generic(self, render_type, render_size=1024):
# Popup asks user for output file.
file_obj = tkFileDialog.asksaveasfile(mode='wb')
# Abort if user clicks 'cancel'.
if file_obj is None: return
# Proceed with rendering...
self._set_status('Rendering image: ' + file_obj.name, 'wait')
try:
panorama = self._create_panorama()
render_func = getattr(panorama, render_type)
render_func(render_size).save(file_obj)
self._set_status('Done!')
except:
tkMessageBox.showerror('Render error', traceback.format_exc())
self._set_status('Render failed.')
def _render_rect(self):
self._render_generic('render_equirectangular')
def _render_cube(self):
self._render_generic('render_cubemap')
# Callback to create a file-selection popup.
def _file_select(self, tkstr):
result = tkFileDialog.askopenfile()
if result is not None:
tkstr.set(result.name)
result.close()
# Make a combined label/textbox/slider for a given variable:
def _make_file_select(self, parent, rowidx, label):
# Create string variable.
tkstr = tk.StringVar()
# Create callback event handler.
cmd = lambda: self._file_select(tkstr)
# Create the Label, Entry, and Button objects.
label = tk.Label(parent, text=label)
entry = tk.Entry(parent, textvariable=tkstr)
button = tk.Button(parent, text='...', command=cmd)
label.grid(row=rowidx, column=0, sticky='W')
entry.grid(row=rowidx, column=1)
button.grid(row=rowidx, column=2)
return tkstr
# Set status text, and optionally update cursor.
def _set_status(self, status, cursor='arrow'):
self.parent.config(cursor=cursor)
self.status.configure(text=status)
def launch_tk_gui(flens='', fimg1='', fimg2=''):
# Create TK root object and GUI window.
root = tk.Tk()
gui = PanoramaGUI(root)
# Load parameters if specified.
if flens is not None and len(flens) > 0:
gui.load_config(flens)
if fimg1 is not None and len(fimg1) > 0:
gui.img1.set(fimg1)
if fimg2 is not None and len(fimg2) > 0:
gui.img2.set(fimg2)
# Start main loop.
root.mainloop()
if __name__ == "__main__":
# If we have exactly four arguments, run command-line version.
if len(sys.argv) == 5 and sys.argv[4].startswith('gui'):
# Special case for interactive mode.
launch_tk_gui(sys.argv[1], sys.argv[2], sys.argv[3])
elif len(sys.argv) == 5:
# First argument is the lens alignment file.
lens1 = FisheyeLens()
lens2 = FisheyeLens()
cfg = open(sys.argv[1], 'r')
load_config(cfg, lens1, lens2)
# Second and third arguments are the source files.
img1 = FisheyeImage(sys.argv[2], lens1)
img2 = FisheyeImage(sys.argv[3], lens2)
# Fourth argument is the mode and output filename.
if sys.argv[4].startswith('cube='):
out = sys.argv[5:]
pan = PanoramaImage((img1, img2))
pan.render_cubemap(1024).save(out)
elif sys.argv[4].startswith('rect='):
out = sys.argv[5:]
pan = PanoramaImage((img1, img2))
pan.render_equirectangular(1024).save(out)
else:
print 'Unrecognized render mode (cube=, rect=, gui)'
elif len(sys.argv) > 1:
# If requested, print command-line usage information.
print 'Usage instructions:'
print ' python fisheye.py'
print ' Start interactive alignment GUI.'
print ' python fisheye.py -help'
print ' Print this help message.'
print ' python fisheye.py lens.cfg in1.jpg in2.jpg gui'
print ' Launch interactive GUI with specified default options'
print ' python fisheye.py lens.cfg in1.jpg in2.jpg rect=out.png'
print ' Render and save equirectangular panorama using specified'
print ' lens configuration and source images.'
print ' python fisheye.py lens.cfg in1.jpg in2.jpg cube=out.png'
print ' Render and save cubemap panorama using specified'
print ' lens configuration and source images.'
else:
# Otherwise, start the interactive GUI with all fields blank.
launch_tk_gui()