@@ -38,13 +38,29 @@ class CylindricalStitcher:
3838 def __init__ (self , focal_length = 700 ):
3939 self .focal_length = focal_length
4040 self .finder = cv2 .SIFT_create ()
41- self .bf = cv2 .BFMatcher (cv2 .NORM_L2 , crossCheck = False )
41+ # Używamy FLANN dla szybszego i dokładniejszego dopasowywania przy dużych zestawach cech
42+ index_params = dict (algorithm = 1 , trees = 5 )
43+ search_params = dict (checks = 50 )
44+ self .flann = cv2 .FlannBasedMatcher (index_params , search_params )
45+
4246 self .panorama = None
4347 self .added_frames_count = 0
48+ self .is_saved_360 = False
49+
50+ # Teoretyczna szerokość pełnego obrotu 360 stopni
51+ self .target_width_360 = int (2 * np .pi * self .focal_length )
52+
53+ # Przechowywanie punktów kluczowych pierwszej klatki
54+ self .first_frame_kp = None
55+ self .first_frame_des = None
4456
4557 def reset (self ):
4658 self .panorama = None
4759 self .added_frames_count = 0
60+ self .is_saved_360 = False
61+ self .first_frame_kp = None
62+ self .first_frame_des = None
63+ print ("[System] Resetowanie panoramy..." )
4864
4965 def rotate_frame (self , img , angle ):
5066 if angle == 90 :
@@ -77,25 +93,88 @@ def warp_cylinder(self, img):
7793
7894 return cylindrical_img , (mask * 255 ).astype (np .uint8 )
7995
96+ def check_loop_closure (self , frame_cyl ):
97+ """Sprawdza powrót do startu po przekroczeniu 50% szerokości przy użyciu Homografii."""
98+ if self .panorama is None or self .first_frame_des is None :
99+ return False
100+
101+ current_width = self .panorama .shape [1 ]
102+ half_width_threshold = self .target_width_360 * 0.5
103+
104+ # WARUNEK 1: Sprawdzamy dopiero, gdy jesteśmy przynajmniej w połowie oczekiwanej szerokości
105+ if current_width < half_width_threshold :
106+ return False
107+
108+ kp_frame , des_frame = self .finder .detectAndCompute (frame_cyl , None )
109+ if des_frame is None :
110+ return False
111+
112+ # Dopasowanie przy użyciu FLANN (stabilniejsze niż podstawowy BFMatcher)
113+ matches = self .flann .knnMatch (des_frame , self .first_frame_des , k = 2 )
114+ good_matches = []
115+ for match in matches :
116+ if len (match ) == 2 :
117+ m , n = match
118+ # Wybór najbardziej unikalnych punktów (test Ratio Lowe'a)
119+ if m .distance < 0.7 * n .distance :
120+ good_matches .append (m )
121+
122+ # WARUNEK 2: Jeśli mamy potencjalne punkty wspólne, weryfikujemy je Homografią
123+ if len (good_matches ) >= 15 :
124+ src_pts = np .float32 (
125+ [kp_frame [m .queryIdx ].pt for m in good_matches ]
126+ ).reshape (- 1 , 1 , 2 )
127+ dst_pts = np .float32 (
128+ [self .first_frame_kp [m .trainIdx ].pt for m in good_matches ]
129+ ).reshape (- 1 , 1 , 2 )
130+
131+ # Homografia (findHomography + RANSAC) wybacza drobne różnice w kącie patrzenia kamery
132+ H , mask = cv2 .findHomography (src_pts , dst_pts , cv2 .RANSAC , 5.0 )
133+
134+ if H is not None :
135+ num_inliers = np .sum (mask )
136+ # Jeżeli poprawnie przetłumaczono geometrię dla min. 12 punktów - mamy to!
137+ if num_inliers >= 12 :
138+ print (
139+ f"\n [Loop Closure] Znaleziono dopasowanie perspektywiczne! Poprawne punkty (inliers): { num_inliers } "
140+ )
141+ return True
142+ return False
143+
80144 def try_stitch (self , frame_cyl , frame_mask ):
145+ # Inicjalizacja pierwszej klatki
81146 if self .panorama is None :
82147 self .panorama = frame_cyl .copy ()
83148 self .added_frames_count = 1
149+ # Konwertujemy deskryptory na float32 (wymóg dla FLANN Matchera)
150+ kp , des = self .finder .detectAndCompute (frame_cyl , None )
151+ if des is not None :
152+ self .first_frame_kp = kp
153+ self .first_frame_des = des .astype (np .float32 )
84154 return True
85155
156+ # Sprawdzamy pętlę przed modyfikacją panoramy
157+ if self .is_saved_360 is False and self .check_loop_closure (frame_cyl ):
158+ self .is_saved_360 = True
159+
160+ # Konwersja deskryptorów panoramy i klatki na float32 do dopasowania standardowego
86161 kp1 , des1 = self .finder .detectAndCompute (self .panorama , None )
87162 kp2 , des2 = self .finder .detectAndCompute (frame_cyl , None )
88163
89164 if des1 is None or des2 is None :
90165 return False
91166
92- matches = self .bf .knnMatch (des2 , des1 , k = 2 )
167+ des1_f = des1 .astype (np .float32 )
168+ des2_f = des2 .astype (np .float32 )
169+
170+ matches = self .flann .knnMatch (des2_f , des1_f , k = 2 )
93171 good_matches = []
94172 for match in matches :
95173 if len (match ) == 2 :
96174 m , n = match
97175 if m .distance < 0.7 * n .distance :
98176 good_matches .append (m )
177+
99178 if len (good_matches ) <= 10 :
100179 print ("Skipped frame - too few common points." )
101180 return False
@@ -143,7 +222,18 @@ def try_stitch(self, frame_cyl, frame_mask):
143222
144223 self .panorama = large_canvas
145224 self .added_frames_count += 1
146- print (f"Added frame. New width: { self .panorama .shape [1 ]} px" )
225+
226+ # Informacja o postępie szerokości w konsoli
227+ progress_pct = (self .panorama .shape [1 ] / self .target_width_360 ) * 100
228+ print (
229+ f"Added frame. Width: { self .panorama .shape [1 ]} px ({ progress_pct :.1f} % of 360° target)"
230+ )
231+
232+ # Zapis automatyczny po wykryciu pętli
233+ if self .is_saved_360 is True :
234+ self .save_to_file (prefix = "panorama_360_hybrid" )
235+ self .is_saved_360 = "done"
236+
147237 return True
148238
149239 def get_preview (self , max_width = 1366 ):
@@ -156,11 +246,11 @@ def get_preview(self, max_width=1366):
156246 return cv2 .resize (self .panorama , (preview_w , preview_h ))
157247 return self .panorama .copy ()
158248
159- def save_to_file (self ):
249+ def save_to_file (self , prefix = "panorama" ):
160250 if self .panorama is not None :
161- filename = f"panorama_ { datetime .now ().strftime ('%H%M%S' )} .png"
251+ filename = f"{ prefix } _ { datetime .now ().strftime ('%H%M%S' )} .png"
162252 cv2 .imwrite (filename , self .panorama )
163- print (f"Saved : { filename } " )
253+ print (f"====== AUTO-SAVE SUCCESFUL : { filename } ====== " )
164254
165255
166256def rotate_frame (img , angle ):
@@ -287,9 +377,9 @@ def main(config):
287377
288378if __name__ == "__main__" :
289379 config = {
290- "video_port" : 1 , # video port
380+ "video_port" : 2 , # video port
291381 "capture_interval" : 15 , # capture interval
292382 "focal_length" : 700 , # focal length typical [600-800]
293- "camera_rotation" : 270 , # camera rotation
383+ "camera_rotation" : 90 , # camera rotation
294384 }
295385 main (config )
0 commit comments