avans06 commited on
Commit
55ee165
·
1 Parent(s): f19f17a

feat: add experimental fixed base image mechanism

Browse files

Introduces a `fixed_base_image_index_input` option. When an index is specified, that image acts as the bottom-most layer (background), allowing other images to overlay it.

Note: This is an experimental feature and may not be fully effective in all stitching scenarios yet.

Files changed (1) hide show
  1. app.py +157 -74
app.py CHANGED
@@ -518,7 +518,8 @@ def stitch_pairwise_images(img_composite, img_new,
518
  max_blending_width=10000,
519
  max_blending_height=10000,
520
  blend_smooth_ksize=15,
521
- num_blend_levels=4
 
522
  ):
523
  """
524
  Stitches a new image (img_new) onto an existing composite image (img_composite)
@@ -879,14 +880,14 @@ def stitch_pairwise_images(img_composite, img_new,
879
  # --- Create Masks for Blending ---
880
  # Create mask for the warped image 1 using Alpha Channel
881
  if warped_img1_u8 is not None:
882
- # Check alpha channel (index 3)
883
- mask_warped = (warped_img1_u8[:, :, 3] > 0).astype(np.uint8) * 255
884
- # Erode the warped mask slightly to remove semi-transparent edge artifacts
885
- # This removes the 1-pixel border caused by linear interpolation blending with transparency.
886
- erosion_kernel = np.ones((3, 3), np.uint8)
887
- mask_warped = cv2.erode(mask_warped, erosion_kernel, iterations=3)
888
  else:
889
- mask_warped = np.zeros(output_img.shape[:2], dtype=np.uint8) # Empty mask if warp failed
890
 
891
  # Find overlapping region mask (uint8 0 or 255)
892
  overlap_mask = cv2.bitwise_and(mask_warped, mask_img2)
@@ -935,8 +936,14 @@ def stitch_pairwise_images(img_composite, img_new,
935
  mean2 = np.sum(img2_roi[overlap_mask_gain > 0]) / overlap_pixel_count if img2_roi is not None else 0
936
 
937
  if mean1 > 1e-5 and mean2 > 1e-5:
938
- gain = mean2 / mean1
939
- log_message = log_and_print(f"Calculated Gain: {gain:.2f}\n", log_message)
 
 
 
 
 
 
940
  gain = np.clip(gain, 0.5, 2.0) # Clamp gain
941
  log_message = log_and_print(f"Clamped Gain: {gain:.2f}\n", log_message)
942
  else:
@@ -951,16 +958,26 @@ def stitch_pairwise_images(img_composite, img_new,
951
 
952
  # Apply gain ONLY if calculated and different from 1.0
953
  if abs(gain - 1.0) > 1e-5: # Check float difference
954
- gain_applied_float = warped_img1_u8.astype(np.float32)
955
- # Apply gain to RGB channels (0,1,2), Leave Alpha (3) untouched
956
- gain_applied_float[:, :, :3] *= gain
 
 
 
 
 
 
 
 
 
957
 
958
- # *** Create new array for gain applied result ***
959
- temp_gain_applied = gain_applied_float.clip(0, 255).astype(np.uint8)
960
- # If gain_applied_warped_img1_u8 wasn't the original, delete it before reassigning
961
- if gain_applied_warped_img1_u8 is not warped_img1_u8:
962
- del gain_applied_warped_img1_u8
963
- gain_applied_warped_img1_u8 = temp_gain_applied # Assign the new gain-applied image
 
964
  del gain_applied_float, temp_gain_applied
965
  gc.collect()
966
  log_message = log_and_print(f"Gain applied to warped image (RGB channels).\n", log_message)
@@ -1027,6 +1044,14 @@ def stitch_pairwise_images(img_composite, img_new,
1027
  weights_overlap = d1_overlap / (total_dist + 1e-7) # Epsilon for stability
1028
  weight1_norm[overlap_indices] = np.clip(weights_overlap, 0.0, 1.0)
1029
  log_message = log_and_print(f"Calculated distance transform weights for {num_overlap_pixels} overlap pixels.\n", log_message)
 
 
 
 
 
 
 
 
1030
  else:
1031
  log_message = log_and_print("Warning: No overlap pixels found for distance transform weight calculation.\n", log_message)
1032
 
@@ -1191,11 +1216,10 @@ def stitch_pairwise_images(img_composite, img_new,
1191
  # Create weight maps (float32)
1192
  weight1 = np.zeros(output_img.shape[:2], dtype=np.float32)
1193
  weight2 = np.zeros(output_img.shape[:2], dtype=np.float32)
1194
- blend_axis = 0 if w_overlap >= h_overlap else 1
1195
- overlap_region_mask = overlap_mask[y_overlap : y_overlap + h_overlap, x_overlap : x_overlap + w_overlap]
1196
 
1197
  # Generate gradient for the overlap box
1198
  gradient = None
 
1199
  if blend_axis == 0: # Horizontal blend
1200
  gradient = np.tile(np.linspace(1.0, 0.0, w_overlap, dtype=np.float32), (h_overlap, 1))
1201
  else: # Vertical blend
@@ -1204,6 +1228,8 @@ def stitch_pairwise_images(img_composite, img_new,
1204
  weight1_region = gradient
1205
  weight2_region = 1.0 - gradient
1206
 
 
 
1207
  # Apply weights only where the overlap mask is valid within the bounding box
1208
  valid_overlap = overlap_region_mask > 0
1209
  weight1[y_overlap : y_overlap + h_overlap, x_overlap : x_overlap + w_overlap][valid_overlap] = weight1_region[valid_overlap]
@@ -1353,7 +1379,17 @@ def stitch_pairwise_images(img_composite, img_new,
1353
  log_message = log_and_print(f"Blending method '{effective_blend_method}' or overlap condition not met. Performing simple overlay.\n", log_message)
1354
 
1355
  if gain_applied_warped_img1_u8 is not None: # Only copy if we have something to copy
1356
- output_img = cv2.copyTo(gain_applied_warped_img1_u8, mask_warped, output_img)
 
 
 
 
 
 
 
 
 
 
1357
 
1358
  # --- Final Result Assignment ---
1359
  final_output_img = output_img # Assign the final blended/overlaid image
@@ -1472,7 +1508,8 @@ def stitch_multiple_images(images, # List of NumPy images (BGR/BGRA, potentially
1472
  max_blending_width=10000,
1473
  max_blending_height=10000,
1474
  blend_smooth_ksize=15,
1475
- num_blend_levels=4
 
1476
  ):
1477
  """
1478
  Stitches a list of images. Tries cv2.Stitcher first (unless 'DIRECT_PAIRWISE'),
@@ -1681,6 +1718,20 @@ def stitch_multiple_images(images, # List of NumPy images (BGR/BGRA, potentially
1681
  else:
1682
  next_image = images[i] # Can use directly if already uint8
1683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1684
  result, pairwise_log = stitch_pairwise_images(
1685
  current_stitched_image, # BGR uint8
1686
  next_image, # BGR uint8
@@ -1694,7 +1745,8 @@ def stitch_multiple_images(images, # List of NumPy images (BGR/BGRA, potentially
1694
  max_blending_width=max_blending_width,
1695
  max_blending_height=max_blending_height,
1696
  blend_smooth_ksize=blend_smooth_ksize,
1697
- num_blend_levels=num_blend_levels
 
1698
  )
1699
  log += pairwise_log
1700
 
@@ -1769,12 +1821,12 @@ def stitch_multiple_images(images, # List of NumPy images (BGR/BGRA, potentially
1769
  return stitched_img_rgba, log
1770
  except cv2.error as e_cvt:
1771
  log = log_and_print(f"\nError converting final image: {e_cvt}. Returning None.\n", log)
1772
- if 'stitched_img_bgr' in locals(): del stitched_img_bgra
1773
  gc.collect()
1774
  return None, log
1775
  else:
1776
  log = log_and_print("Error: Stitching failed. No final image generated.", log)
1777
- if 'stitched_img_bgr' in locals() and stitched_img_bgra is not None:
1778
  del stitched_img_bgra
1779
  gc.collect()
1780
  return None, log
@@ -2051,7 +2103,7 @@ def stitch_video_frames(video_path,
2051
  if last_saved_composite is not None:
2052
  del last_saved_composite
2053
  last_saved_composite = post_cropped_composite.copy() # Store BGR/BGRA
2054
- log = log_and_print(f"Saved composite image {len(stitched_results_rgb)} (Post-Cropped Shape: {post_cropped_composite.shape}).\n", log)
2055
  else:
2056
  log = log_and_print("Skipping save: Result identical to previously saved image.\n", log)
2057
 
@@ -2113,9 +2165,9 @@ def stitch_video_frames(video_path,
2113
  # Clean up potentially lingering frame data from the failed iteration
2114
  if 'frame_bgr_raw' in locals() and frame_bgr_raw is not None:
2115
  del frame_bgr_raw
2116
- if 'frame_bgr' in locals() and frame_bgra is not None:
2117
  del frame_bgra
2118
- if 'cropped_frame_bgr' in locals() and cropped_frame_bgra is not None:
2119
  del cropped_frame_bgra
2120
  if 'current_frame_for_stitch' in locals() and current_frame_for_stitch is not None and current_frame_for_stitch is not anchor_frame:
2121
  del current_frame_for_stitch
@@ -2145,7 +2197,11 @@ def stitch_video_frames(video_path,
2145
  is_duplicate = True
2146
  if not is_duplicate:
2147
  append_result(post_cropped_final) # RGBA append
2148
- log = log_and_print(f"Saved final composite image {len(stitched_results_rgba)} (Post-Cropped Shape: {post_cropped_composite.shape}).\n", log)
 
 
 
 
2149
  # No need to update last_saved_composite here, loop is finished
2150
  else:
2151
  log = log_and_print("Skipping save of final composite: Result identical to previously saved image.\n", log)
@@ -2174,15 +2230,15 @@ def stitch_video_frames(video_path,
2174
  gc.collect()
2175
 
2176
  total_end_time = time.time()
2177
- log = log_and_print(f"\nVideo stitching process finished. Found {len(stitched_results_rgb)} stitched image(s).", log)
2178
  log = log_and_print(f"\nTotal processing time: {total_end_time - total_start_time:.2f} seconds.\n", log)
2179
 
2180
  # Filter out potential None entries just before returning
2181
- final_results = [img for img in stitched_results_rgb if img is not None and img.size > 0]
2182
- if len(final_results) != len(stitched_results_rgb):
2183
- log = log_and_print(f"Warning: Filtered out {len(stitched_results_rgb) - len(final_results)} None or empty results before final return.\n", log)
2184
  # Clean up the original list with potential Nones
2185
- del stitched_results_rgb
2186
  gc.collect()
2187
 
2188
  return final_results, log
@@ -2200,6 +2256,7 @@ def run_stitching_interface(input_files,
2200
  exposure_comp_type_str, # For cv2.Stitcher
2201
  enable_cropping, # Post-stitch black border crop
2202
  strict_no_black_edges_input,
 
2203
  # Detailed Stitcher Settings
2204
  transform_model_str,
2205
  blend_method_str,
@@ -2230,10 +2287,11 @@ def run_stitching_interface(input_files,
2230
  return [], "Please upload images or a video file."
2231
 
2232
  # Convert Gradio inputs to correct types
2233
- blend_smooth_ksize = int(blend_smooth_ksize_input) if blend_smooth_ksize_input is not None else -1
2234
- num_blend_levels = int(num_blend_levels_input) if num_blend_levels_input is not None else 4
2235
  ransac_reproj_thresh = float(ransac_reproj_thresh_input) if ransac_reproj_thresh_input is not None else 3.0
2236
  max_distance_coeff = float(max_distance_coeff_input) if max_distance_coeff_input is not None else 0.5
 
 
2237
 
2238
  log = f"Received {len(input_files)} file(s).\n"
2239
  log = log_and_print(f"Pre-Crop Settings: Top={crop_top_percent}%, Bottom={crop_bottom_percent}%\n", log)
@@ -2457,7 +2515,7 @@ def run_stitching_interface(input_files,
2457
  blend_method_lower = blend_method_str.lower() if blend_method_str else "multi-band"
2458
 
2459
  # Call the modified stitch_multiple_images function
2460
- stitched_single_rgb, stitch_log_img = stitch_multiple_images(
2461
  images_bgr_cropped, # Pass the list of cropped images (BGRA)
2462
  stitcher_mode_str=stitcher_mode_str,
2463
  registration_resol=registration_resol,
@@ -2477,11 +2535,12 @@ def run_stitching_interface(input_files,
2477
  max_blending_width=max_blending_width,
2478
  max_blending_height=max_blending_height,
2479
  blend_smooth_ksize=blend_smooth_ksize,
2480
- num_blend_levels=num_blend_levels
 
2481
  )
2482
  stitch_log += stitch_log_img # Append log from stitching function
2483
- if stitched_single_rgb is not None:
2484
- final_stitched_images_rgba = [stitched_single_rgb] # Result is a list containing the single image
2485
 
2486
  # Clean up loaded images for list mode after stitching attempt
2487
  if 'images_bgr_cropped' in locals():
@@ -2529,24 +2588,45 @@ def run_stitching_interface(input_files,
2529
  temp_dir = tempfile.mkdtemp(prefix="stitch_run_", dir=target_temp_dir_base)
2530
  final_log = log_and_print(f"\nInfo: Saving output images to temporary directory: {temp_dir}\n", final_log)
2531
 
2532
- for i, img_rgb in enumerate(final_stitched_images_rgba):
2533
- if img_rgb is None or img_rgb.size == 0:
2534
  final_log = log_and_print(f"Warning: Skipping saving image index {i} because it is None or empty.\n", final_log)
2535
  continue
2536
- filename = f"stitched_image_{i+1:03d}.png" # PNG is required for transparency
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2537
  # Use os.path.join for cross-platform compatibility
2538
  full_path = os.path.join(temp_dir, filename)
2539
- img_bgr = None # Initialize for finally block
2540
  try:
2541
  # Handle RGBA to BGRA for saving
2542
  # The result coming from stitcher is in RGB(A) format
2543
- if img_rgb.shape[2] == 4:
2544
- img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGBA2BGRA)
2545
  else:
2546
- img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
2547
 
2548
  # Use imencode -> write pattern for better handling of paths/special chars
2549
- is_success, buf = cv2.imencode('.png', img_bgr)
2550
  if is_success:
2551
  with open(full_path, 'wb') as f:
2552
  f.write(buf)
@@ -2562,15 +2642,15 @@ def run_stitching_interface(input_files,
2562
  except Exception as e_write:
2563
  final_log = log_and_print(f"Unexpected error writing image {filename} to {full_path}: {e_write}\n", final_log)
2564
  finally:
2565
- if img_bgr is not None:
2566
- del img_bgr
2567
  gc.collect()
2568
  except Exception as e_tempdir:
2569
  final_log = log_and_print(f"Error creating temporary directory or saving output: {e_tempdir}\n", final_log)
2570
  output_file_paths = [] # Fallback to empty list
2571
 
2572
  # --- Final Cleanup of RGB images list ---
2573
- if 'final_stitched_images_rgb' in locals():
2574
  for img_del in final_stitched_images_rgba:
2575
  if img_del is not None:
2576
  del img_del
@@ -2632,42 +2712,44 @@ with gr.Blocks() as demo:
2632
 
2633
  # --- Detailed Stitcher Settings (Used for Video, DIRECT_PAIRWISE, and Fallback) ---
2634
  with gr.Accordion("Pairwise Stitching Settings (Video / Direct / Fallback)", open=True):
 
 
2635
  transform_model = gr.Radio(["Homography", "Affine_Partial", "Affine_Full"], label="Pairwise Transform Model", value="Homography", # Default to Homography
2636
- info="Geometric model for pairwise alignment. 'Homography' handles perspective. 'Affine' (Partial/Full) handles translation, rotation, scale, shear (better for scans, less distortion risk). If stitching fails with one model, try another.")
2637
  blend_method = gr.Radio(["Linear", "Multi-Band"], label="Blending Method", value="Multi-Band",
2638
- info="Algorithm for smoothing seams in overlapping regions when using the detailed stitcher (for video or image list fallback). 'Multi-Band' is often better but slower.")
2639
  enable_gain_compensation = gr.Checkbox(value=True, label="Enable Gain Compensation",
2640
- info="Adjusts overall brightness difference *before* blending when using the detailed stitcher. Recommended.")
2641
  orb_nfeatures = gr.Slider(500, 10000, step=100, value=2000, label="ORB Features",
2642
- info="Maximum ORB keypoints detected per image/frame. Used by the detailed stitcher (for video or image list fallback).")
2643
  match_ratio_thresh = gr.Slider(0.5, 0.95, step=0.01, value=0.75, label="Match Ratio Threshold",
2644
- info="Lowe's ratio test threshold for filtering feature matches (lower = stricter). Used by the detailed stitcher (for video or image list fallback).")
2645
  ransac_reproj_thresh = gr.Slider(1.0, 10.0, step=0.1, value=5.0, label="RANSAC Reproj Threshold",
2646
- info="Maximum reprojection error (pixels) allowed for a match to be considered an inlier by RANSAC during transformation estimation. Lower values are stricter.")
2647
  max_distance_coeff = gr.Slider(0.1, 2.0, step=0.05, value=0.5, label="Max Distance Coeff",
2648
- info="Multiplier for image diagonal used to filter initial matches. Limits the pixel distance between matched keypoints (0.5 means half the diagonal).")
2649
  max_blending_width = gr.Number(value=10000, label="Max Blending Width", precision=0,
2650
- info="Limits the canvas width during the detailed pairwise blending step to prevent excessive memory usage. Relevant for the detailed stitcher.")
2651
  max_blending_height = gr.Number(value=10000, label="Max Blending Height", precision=0,
2652
- info="Limits the canvas height during the detailed pairwise blending step to prevent excessive memory usage. Relevant for the detailed stitcher.")
2653
  blend_smooth_ksize = gr.Number(value=15, label="Blend Smooth Kernel Size", precision=0,
2654
- info="Size of Gaussian kernel to smooth blend mask/weights. Must be POSITIVE ODD integer to enable smoothing (e.g., 5, 15, 21). Set to -1 or an even number to disable smoothing.")
2655
  num_blend_levels = gr.Slider(2, 7, step=1, value=4, label="Multi-Band Blend Levels",
2656
- info="Number of pyramid levels for Multi-Band blending. Fewer levels are faster but might have less smooth transitions.")
2657
 
2658
  with gr.Accordion("Video Stitcher Settings", open=False):
2659
  sample_interval_ms = gr.Number(value=3000, label="Sample Interval (ms)", precision=0,
2660
- info="Time interval (in milliseconds) between sampled frames for video stitching. Smaller values sample more frames, increasing processing time but potentially improving tracking.")
2661
  max_composite_width_video = gr.Number(value=10000, label="Max Composite Width (Video)", precision=0,
2662
- info="Limits the width of the stitched output during video processing. If exceeded, the current result is saved and stitching restarts with the next frame. 0 = no limit.")
2663
  max_composite_height_video = gr.Number(value=10000, label="Max Composite Height (Video)", precision=0,
2664
- info="Limits the height of the stitched output during video processing. If exceeded, the current result is saved and stitching restarts with the next frame. 0 = no limit.")
2665
 
2666
  with gr.Accordion("Postprocessing Settings", open=False):
2667
  enable_cropping = gr.Checkbox(value=True, label="Crop Black Borders (Post-Stitch)",
2668
- info="Automatically remove black border areas from the final stitched image(s) AFTER stitching.")
2669
  strict_no_black_edges_checkbox = gr.Checkbox(value=False, label="Strict No Black Edges (Post-Crop)",
2670
- info="If 'Crop Black Borders' is enabled, this forces removal of *any* remaining black pixels directly on the image edges after the main crop. Might slightly shrink the image further.")
2671
 
2672
  with gr.Column(scale=1):
2673
  output_gallery = gr.Gallery(
@@ -2692,6 +2774,7 @@ with gr.Blocks() as demo:
2692
  # Postprocessing
2693
  enable_cropping,
2694
  strict_no_black_edges_checkbox,
 
2695
  # Detailed Stitcher Settings
2696
  transform_model,
2697
  blend_method,
@@ -2716,7 +2799,7 @@ with gr.Blocks() as demo:
2716
  ["examples/Wetter-Panorama/Wetter-Panorama1[NIuO6hrFTrg].mp4"],
2717
  0, 20,
2718
  "DIRECT_PAIRWISE", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2719
- True, False,
2720
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2721
  2500, 10000, 10000,
2722
  ],
@@ -2724,7 +2807,7 @@ with gr.Blocks() as demo:
2724
  ["examples/Wetter-Panorama/Wetter-Panorama2[NIuO6hrFTrg].mp4"],
2725
  0, 20,
2726
  "DIRECT_PAIRWISE", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2727
- True, False,
2728
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2729
  2500, 10000, 10000,
2730
  ],
@@ -2733,7 +2816,7 @@ with gr.Blocks() as demo:
2733
  "examples/NieRAutomata/nier2B_06.jpg", "examples/NieRAutomata/nier2B_07.jpg", "examples/NieRAutomata/nier2B_08.jpg", "examples/NieRAutomata/nier2B_09.jpg", "examples/NieRAutomata/nier2B_10.jpg", ],
2734
  0, 0,
2735
  "PANORAMA", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2736
- True, False,
2737
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2738
  5000, 10000, 10000,
2739
  ],
@@ -2741,7 +2824,7 @@ with gr.Blocks() as demo:
2741
  ["examples/cat/cat_left.jpg", "examples/cat/cat_right.jpg"],
2742
  0, 0,
2743
  "SCANS", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2744
- True, False,
2745
  "Affine_Partial", "Linear", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2746
  5000, 10000, 10000,
2747
  ],
@@ -2749,7 +2832,7 @@ with gr.Blocks() as demo:
2749
  ["examples/ギルドの受付嬢ですが/Girumasu_1.jpg", "examples/ギルドの受付嬢ですが/Girumasu_2.jpg", "examples/ギルドの受付嬢ですが/Girumasu_3.jpg"],
2750
  0, 0,
2751
  "PANORAMA", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2752
- True, False,
2753
  "Affine_Partial", "Linear", True, 5000, 0.65, 5.0, 0.5, 10000, 10000, 15, 4,
2754
  5000, 10000, 10000,
2755
  ],
@@ -2757,7 +2840,7 @@ with gr.Blocks() as demo:
2757
  ["examples/photographs1/img1.jpg", "examples/photographs1/img2.jpg", "examples/photographs1/img3.jpg", "examples/photographs1/img4.jpg"],
2758
  0, 0,
2759
  "PANORAMA", 0.6, 0.1, -1, True, "GAIN_BLOCKS",
2760
- True, False,
2761
  "Homography", "Linear", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2762
  5000, 10000, 10000,
2763
  ]
 
518
  max_blending_width=10000,
519
  max_blending_height=10000,
520
  blend_smooth_ksize=15,
521
+ num_blend_levels=4,
522
+ fixed_layer_type="none" # _type: "none", "img1", "img2"
523
  ):
524
  """
525
  Stitches a new image (img_new) onto an existing composite image (img_composite)
 
880
  # --- Create Masks for Blending ---
881
  # Create mask for the warped image 1 using Alpha Channel
882
  if warped_img1_u8 is not None:
883
+ # Check alpha channel (index 3)
884
+ mask_warped = (warped_img1_u8[:, :, 3] > 0).astype(np.uint8) * 255
885
+ # Erode the warped mask slightly to remove semi-transparent edge artifacts
886
+ # This removes the 1-pixel border caused by linear interpolation blending with transparency.
887
+ erosion_kernel = np.ones((3, 3), np.uint8)
888
+ mask_warped = cv2.erode(mask_warped, erosion_kernel, iterations=3)
889
  else:
890
+ mask_warped = np.zeros(output_img.shape[:2], dtype=np.uint8) # Empty mask if warp failed
891
 
892
  # Find overlapping region mask (uint8 0 or 255)
893
  overlap_mask = cv2.bitwise_and(mask_warped, mask_img2)
 
936
  mean2 = np.sum(img2_roi[overlap_mask_gain > 0]) / overlap_pixel_count if img2_roi is not None else 0
937
 
938
  if mean1 > 1e-5 and mean2 > 1e-5:
939
+ if fixed_layer_type == "img1":
940
+ # If the composite (Img1) is the base image, do not touch Img1, adjust Img2 instead
941
+ # We need Img2's brightness to match Img1
942
+ gain = mean1 / mean2
943
+ log_message = log_and_print(f"Fixed Base (Img1): Adjusting Img2 brightness to match Base. Gain={gain:.2f}\n", log_message)
944
+ else:
945
+ gain = mean2 / mean1
946
+ log_message = log_and_print(f"Calculated Gain: {gain:.2f}\n", log_message)
947
  gain = np.clip(gain, 0.5, 2.0) # Clamp gain
948
  log_message = log_and_print(f"Clamped Gain: {gain:.2f}\n", log_message)
949
  else:
 
958
 
959
  # Apply gain ONLY if calculated and different from 1.0
960
  if abs(gain - 1.0) > 1e-5: # Check float difference
961
+ if fixed_layer_type == "img1":
962
+ # Directly modify the Img2 region in output_img (RGB channels only)
963
+ # Note: output_img currently contains only Img2 pixels
964
+ gain_applied_float = output_img.astype(np.float32)
965
+ gain_applied_float[:, :, :3] *= gain
966
+
967
+ output_img = np.clip(gain_applied_float, 0, 255).astype(np.uint8)
968
+ gain_applied_warped_img1_u8 = warped_img1_u8
969
+ else:
970
+ gain_applied_float = warped_img1_u8.astype(np.float32)
971
+ # Apply gain to RGB channels (0,1,2), Leave Alpha (3) untouched
972
+ gain_applied_float[:, :, :3] *= gain
973
 
974
+ # *** Create new array for gain applied result ***
975
+ temp_gain_applied = gain_applied_float.clip(0, 255).astype(np.uint8)
976
+ # If gain_applied_warped_img1_u8 wasn't the original, delete it before reassigning
977
+ if gain_applied_warped_img1_u8 is not warped_img1_u8:
978
+ del gain_applied_warped_img1_u8
979
+ gain_applied_warped_img1_u8 = temp_gain_applied # Assign the new gain-applied image
980
+
981
  del gain_applied_float, temp_gain_applied
982
  gc.collect()
983
  log_message = log_and_print(f"Gain applied to warped image (RGB channels).\n", log_message)
 
1044
  weights_overlap = d1_overlap / (total_dist + 1e-7) # Epsilon for stability
1045
  weight1_norm[overlap_indices] = np.clip(weights_overlap, 0.0, 1.0)
1046
  log_message = log_and_print(f"Calculated distance transform weights for {num_overlap_pixels} overlap pixels.\n", log_message)
1047
+ # If a base image is specified, we force the overlap area to fully show the "top" layer (weight 0 or 1)
1048
+ # This solves the "semi-transparent/ghosting" issue
1049
+ if fixed_layer_type == "img2":
1050
+ # Img2 is Base, Img1 is Top -> Show Img1 (Weight 1.0)
1051
+ weight1_norm[overlap_indices] = 1.0
1052
+ elif fixed_layer_type == "img1":
1053
+ # Img1 is Base, Img2 is Top -> Show Img2 (Weight 0.0)
1054
+ weight1_norm[overlap_indices] = 0.0
1055
  else:
1056
  log_message = log_and_print("Warning: No overlap pixels found for distance transform weight calculation.\n", log_message)
1057
 
 
1216
  # Create weight maps (float32)
1217
  weight1 = np.zeros(output_img.shape[:2], dtype=np.float32)
1218
  weight2 = np.zeros(output_img.shape[:2], dtype=np.float32)
 
 
1219
 
1220
  # Generate gradient for the overlap box
1221
  gradient = None
1222
+ blend_axis = 0 if w_overlap >= h_overlap else 1
1223
  if blend_axis == 0: # Horizontal blend
1224
  gradient = np.tile(np.linspace(1.0, 0.0, w_overlap, dtype=np.float32), (h_overlap, 1))
1225
  else: # Vertical blend
 
1228
  weight1_region = gradient
1229
  weight2_region = 1.0 - gradient
1230
 
1231
+ overlap_region_mask = overlap_mask[y_overlap : y_overlap + h_overlap, x_overlap : x_overlap + w_overlap]
1232
+
1233
  # Apply weights only where the overlap mask is valid within the bounding box
1234
  valid_overlap = overlap_region_mask > 0
1235
  weight1[y_overlap : y_overlap + h_overlap, x_overlap : x_overlap + w_overlap][valid_overlap] = weight1_region[valid_overlap]
 
1379
  log_message = log_and_print(f"Blending method '{effective_blend_method}' or overlap condition not met. Performing simple overlay.\n", log_message)
1380
 
1381
  if gain_applied_warped_img1_u8 is not None: # Only copy if we have something to copy
1382
+ # No blend or no overlap: Simple Overlay
1383
+ # Decide who covers whom based on Z-order
1384
+ # output_img already has Img2
1385
+ if fixed_layer_type == "img2":
1386
+ # Img2 is base, Img1 covers it
1387
+ output_img = cv2.copyTo(gain_applied_warped_img1_u8, mask_warped, output_img)
1388
+ else:
1389
+ # Img1 is base, Img2 covers it
1390
+ # output_img already has Img2, we just need to fill in where Img1 is not covered by Img2
1391
+ # Or place Img1 first, then Img2
1392
+ output_img = cv2.copyTo(output_img, mask_img2, gain_applied_warped_img1_u8)
1393
 
1394
  # --- Final Result Assignment ---
1395
  final_output_img = output_img # Assign the final blended/overlaid image
 
1508
  max_blending_width=10000,
1509
  max_blending_height=10000,
1510
  blend_smooth_ksize=15,
1511
+ num_blend_levels=4,
1512
+ fixed_base_image_index=-1
1513
  ):
1514
  """
1515
  Stitches a list of images. Tries cv2.Stitcher first (unless 'DIRECT_PAIRWISE'),
 
1718
  else:
1719
  next_image = images[i] # Can use directly if already uint8
1720
 
1721
+ # Determine Fixed Layer Type for this pair
1722
+ # fixed_layer_type logic:
1723
+ # - "img2": The new image currently being added (index i) is the specified base map.
1724
+ # - "img1": The current composite image (index < i) contains the specified base map.
1725
+ # - "none": No base map specified, perform normal blending.
1726
+ current_fixed_type = "none"
1727
+ if fixed_base_image_index != -1:
1728
+ if i == fixed_base_image_index:
1729
+ current_fixed_type = "img2" # The new image being added IS the fixed base
1730
+ elif fixed_base_image_index < i:
1731
+ current_fixed_type = "img1" # The composite (img1) contains the fixed base, so it should stay at bottom
1732
+ elif fixed_base_image_index == 0:
1733
+ current_fixed_type = "img1" # Special case: First image is base, so composite is always base
1734
+
1735
  result, pairwise_log = stitch_pairwise_images(
1736
  current_stitched_image, # BGR uint8
1737
  next_image, # BGR uint8
 
1745
  max_blending_width=max_blending_width,
1746
  max_blending_height=max_blending_height,
1747
  blend_smooth_ksize=blend_smooth_ksize,
1748
+ num_blend_levels=num_blend_levels,
1749
+ fixed_layer_type=current_fixed_type
1750
  )
1751
  log += pairwise_log
1752
 
 
1821
  return stitched_img_rgba, log
1822
  except cv2.error as e_cvt:
1823
  log = log_and_print(f"\nError converting final image: {e_cvt}. Returning None.\n", log)
1824
+ if 'stitched_img_bgra' in locals(): del stitched_img_bgra
1825
  gc.collect()
1826
  return None, log
1827
  else:
1828
  log = log_and_print("Error: Stitching failed. No final image generated.", log)
1829
+ if 'stitched_img_bgra' in locals() and stitched_img_bgra is not None:
1830
  del stitched_img_bgra
1831
  gc.collect()
1832
  return None, log
 
2103
  if last_saved_composite is not None:
2104
  del last_saved_composite
2105
  last_saved_composite = post_cropped_composite.copy() # Store BGR/BGRA
2106
+ log = log_and_print(f"Saved composite image {len(stitched_results_rgba)} (Post-Cropped Shape: {post_cropped_composite.shape}).\n", log)
2107
  else:
2108
  log = log_and_print("Skipping save: Result identical to previously saved image.\n", log)
2109
 
 
2165
  # Clean up potentially lingering frame data from the failed iteration
2166
  if 'frame_bgr_raw' in locals() and frame_bgr_raw is not None:
2167
  del frame_bgr_raw
2168
+ if 'frame_bgra' in locals() and frame_bgra is not None:
2169
  del frame_bgra
2170
+ if 'cropped_frame_bgra' in locals() and cropped_frame_bgra is not None:
2171
  del cropped_frame_bgra
2172
  if 'current_frame_for_stitch' in locals() and current_frame_for_stitch is not None and current_frame_for_stitch is not anchor_frame:
2173
  del current_frame_for_stitch
 
2197
  is_duplicate = True
2198
  if not is_duplicate:
2199
  append_result(post_cropped_final) # RGBA append
2200
+ message = f"Saved final composite image {len(stitched_results_rgba)}"
2201
+ if 'post_cropped_composite' in locals():
2202
+ message += f" (Post-Cropped Shape: {post_cropped_composite.shape})"
2203
+
2204
+ log = log_and_print(f"{message}.\n", log)
2205
  # No need to update last_saved_composite here, loop is finished
2206
  else:
2207
  log = log_and_print("Skipping save of final composite: Result identical to previously saved image.\n", log)
 
2230
  gc.collect()
2231
 
2232
  total_end_time = time.time()
2233
+ log = log_and_print(f"\nVideo stitching process finished. Found {len(stitched_results_rgba)} stitched image(s).", log)
2234
  log = log_and_print(f"\nTotal processing time: {total_end_time - total_start_time:.2f} seconds.\n", log)
2235
 
2236
  # Filter out potential None entries just before returning
2237
+ final_results = [img for img in stitched_results_rgba if img is not None and img.size > 0]
2238
+ if len(final_results) != len(stitched_results_rgba):
2239
+ log = log_and_print(f"Warning: Filtered out {len(stitched_results_rgba) - len(final_results)} None or empty results before final return.\n", log)
2240
  # Clean up the original list with potential Nones
2241
+ del stitched_results_rgba
2242
  gc.collect()
2243
 
2244
  return final_results, log
 
2256
  exposure_comp_type_str, # For cv2.Stitcher
2257
  enable_cropping, # Post-stitch black border crop
2258
  strict_no_black_edges_input,
2259
+ fixed_base_image_index_input,
2260
  # Detailed Stitcher Settings
2261
  transform_model_str,
2262
  blend_method_str,
 
2287
  return [], "Please upload images or a video file."
2288
 
2289
  # Convert Gradio inputs to correct types
2290
+ fixed_base_image_index = int(fixed_base_image_index_input) if fixed_base_image_index_input is not None else -1
 
2291
  ransac_reproj_thresh = float(ransac_reproj_thresh_input) if ransac_reproj_thresh_input is not None else 3.0
2292
  max_distance_coeff = float(max_distance_coeff_input) if max_distance_coeff_input is not None else 0.5
2293
+ blend_smooth_ksize = int(blend_smooth_ksize_input) if blend_smooth_ksize_input is not None else -1
2294
+ num_blend_levels = int(num_blend_levels_input) if num_blend_levels_input is not None else 4
2295
 
2296
  log = f"Received {len(input_files)} file(s).\n"
2297
  log = log_and_print(f"Pre-Crop Settings: Top={crop_top_percent}%, Bottom={crop_bottom_percent}%\n", log)
 
2515
  blend_method_lower = blend_method_str.lower() if blend_method_str else "multi-band"
2516
 
2517
  # Call the modified stitch_multiple_images function
2518
+ stitched_single_rgba, stitch_log_img = stitch_multiple_images(
2519
  images_bgr_cropped, # Pass the list of cropped images (BGRA)
2520
  stitcher_mode_str=stitcher_mode_str,
2521
  registration_resol=registration_resol,
 
2535
  max_blending_width=max_blending_width,
2536
  max_blending_height=max_blending_height,
2537
  blend_smooth_ksize=blend_smooth_ksize,
2538
+ num_blend_levels=num_blend_levels,
2539
+ fixed_base_image_index=fixed_base_image_index
2540
  )
2541
  stitch_log += stitch_log_img # Append log from stitching function
2542
+ if stitched_single_rgba is not None:
2543
+ final_stitched_images_rgba = [stitched_single_rgba] # Result is a list containing the single image
2544
 
2545
  # Clean up loaded images for list mode after stitching attempt
2546
  if 'images_bgr_cropped' in locals():
 
2588
  temp_dir = tempfile.mkdtemp(prefix="stitch_run_", dir=target_temp_dir_base)
2589
  final_log = log_and_print(f"\nInfo: Saving output images to temporary directory: {temp_dir}\n", final_log)
2590
 
2591
+ for i, img_rgba in enumerate(final_stitched_images_rgba):
2592
+ if img_rgba is None or img_rgba.size == 0:
2593
  final_log = log_and_print(f"Warning: Skipping saving image index {i} because it is None or empty.\n", final_log)
2594
  continue
2595
+
2596
+ # Default filename
2597
+ filename = f"stitched_{i+1:03d}.png" # PNG is required for transparency
2598
+
2599
+ # If in multi-image mode (not video mode), and input paths exist
2600
+ if not is_video_input and image_paths:
2601
+ try:
2602
+ # Get the path of the last image
2603
+ last_image_path = image_paths[-1]
2604
+ # Get the filename and remove the extension (e.g., "filename.jpg" -> "filename")
2605
+ last_image_name = os.path.splitext(os.path.basename(last_image_path))[0]
2606
+
2607
+ if len(final_stitched_images_rgba) > 1:
2608
+ # In case multiple results are produced, add numbering to avoid overwriting
2609
+ filename = f"{last_image_name}_stitched_{i+1:03d}.png"
2610
+ else:
2611
+ # Normal single result, use requested format: [last_image_filename]_stitched.png
2612
+ filename = f"{last_image_name}_stitched.png"
2613
+ except Exception as e_name:
2614
+ # If an error occurs, keep the default filename
2615
+ pass
2616
+
2617
  # Use os.path.join for cross-platform compatibility
2618
  full_path = os.path.join(temp_dir, filename)
2619
+ img_bgra = None # Initialize for finally block
2620
  try:
2621
  # Handle RGBA to BGRA for saving
2622
  # The result coming from stitcher is in RGB(A) format
2623
+ if img_rgba.shape[2] == 4:
2624
+ img_bgra = cv2.cvtColor(img_rgba, cv2.COLOR_RGBA2BGRA)
2625
  else:
2626
+ img_bgra = cv2.cvtColor(img_rgba, cv2.COLOR_RGB2BGR)
2627
 
2628
  # Use imencode -> write pattern for better handling of paths/special chars
2629
+ is_success, buf = cv2.imencode('.png', img_bgra)
2630
  if is_success:
2631
  with open(full_path, 'wb') as f:
2632
  f.write(buf)
 
2642
  except Exception as e_write:
2643
  final_log = log_and_print(f"Unexpected error writing image {filename} to {full_path}: {e_write}\n", final_log)
2644
  finally:
2645
+ if img_bgra is not None:
2646
+ del img_bgra
2647
  gc.collect()
2648
  except Exception as e_tempdir:
2649
  final_log = log_and_print(f"Error creating temporary directory or saving output: {e_tempdir}\n", final_log)
2650
  output_file_paths = [] # Fallback to empty list
2651
 
2652
  # --- Final Cleanup of RGB images list ---
2653
+ if 'final_stitched_images_rgba' in locals():
2654
  for img_del in final_stitched_images_rgba:
2655
  if img_del is not None:
2656
  del img_del
 
2712
 
2713
  # --- Detailed Stitcher Settings (Used for Video, DIRECT_PAIRWISE, and Fallback) ---
2714
  with gr.Accordion("Pairwise Stitching Settings (Video / Direct / Fallback)", open=True):
2715
+ fixed_base_image_index_input = gr.Number(value=-1, label="Fixed Base Image Index (0-based)", precision=0,
2716
+ info="Specify the index of the image (0, 1, 2...) that should serve as the 'Bottom/Base' layer without blending. Overlapping images will cover it, but brightness will still adjust. Set -1 to disable.")
2717
  transform_model = gr.Radio(["Homography", "Affine_Partial", "Affine_Full"], label="Pairwise Transform Model", value="Homography", # Default to Homography
2718
+ info="Geometric model for pairwise alignment. 'Homography' handles perspective. 'Affine' (Partial/Full) handles translation, rotation, scale, shear (better for scans, less distortion risk). If stitching fails with one model, try another.")
2719
  blend_method = gr.Radio(["Linear", "Multi-Band"], label="Blending Method", value="Multi-Band",
2720
+ info="Algorithm for smoothing seams in overlapping regions when using the detailed stitcher (for video or image list fallback). 'Multi-Band' is often better but slower.")
2721
  enable_gain_compensation = gr.Checkbox(value=True, label="Enable Gain Compensation",
2722
+ info="Adjusts overall brightness difference *before* blending when using the detailed stitcher. Recommended.")
2723
  orb_nfeatures = gr.Slider(500, 10000, step=100, value=2000, label="ORB Features",
2724
+ info="Maximum ORB keypoints detected per image/frame. Used by the detailed stitcher (for video or image list fallback).")
2725
  match_ratio_thresh = gr.Slider(0.5, 0.95, step=0.01, value=0.75, label="Match Ratio Threshold",
2726
+ info="Lowe's ratio test threshold for filtering feature matches (lower = stricter). Used by the detailed stitcher (for video or image list fallback).")
2727
  ransac_reproj_thresh = gr.Slider(1.0, 10.0, step=0.1, value=5.0, label="RANSAC Reproj Threshold",
2728
+ info="Maximum reprojection error (pixels) allowed for a match to be considered an inlier by RANSAC during transformation estimation. Lower values are stricter.")
2729
  max_distance_coeff = gr.Slider(0.1, 2.0, step=0.05, value=0.5, label="Max Distance Coeff",
2730
+ info="Multiplier for image diagonal used to filter initial matches. Limits the pixel distance between matched keypoints (0.5 means half the diagonal).")
2731
  max_blending_width = gr.Number(value=10000, label="Max Blending Width", precision=0,
2732
+ info="Limits the canvas width during the detailed pairwise blending step to prevent excessive memory usage. Relevant for the detailed stitcher.")
2733
  max_blending_height = gr.Number(value=10000, label="Max Blending Height", precision=0,
2734
+ info="Limits the canvas height during the detailed pairwise blending step to prevent excessive memory usage. Relevant for the detailed stitcher.")
2735
  blend_smooth_ksize = gr.Number(value=15, label="Blend Smooth Kernel Size", precision=0,
2736
+ info="Size of Gaussian kernel to smooth blend mask/weights. Must be POSITIVE ODD integer to enable smoothing (e.g., 5, 15, 21). Set to -1 or an even number to disable smoothing.")
2737
  num_blend_levels = gr.Slider(2, 7, step=1, value=4, label="Multi-Band Blend Levels",
2738
+ info="Number of pyramid levels for Multi-Band blending. Fewer levels are faster but might have less smooth transitions.")
2739
 
2740
  with gr.Accordion("Video Stitcher Settings", open=False):
2741
  sample_interval_ms = gr.Number(value=3000, label="Sample Interval (ms)", precision=0,
2742
+ info="Time interval (in milliseconds) between sampled frames for video stitching. Smaller values sample more frames, increasing processing time but potentially improving tracking.")
2743
  max_composite_width_video = gr.Number(value=10000, label="Max Composite Width (Video)", precision=0,
2744
+ info="Limits the width of the stitched output during video processing. If exceeded, the current result is saved and stitching restarts with the next frame. 0 = no limit.")
2745
  max_composite_height_video = gr.Number(value=10000, label="Max Composite Height (Video)", precision=0,
2746
+ info="Limits the height of the stitched output during video processing. If exceeded, the current result is saved and stitching restarts with the next frame. 0 = no limit.")
2747
 
2748
  with gr.Accordion("Postprocessing Settings", open=False):
2749
  enable_cropping = gr.Checkbox(value=True, label="Crop Black Borders (Post-Stitch)",
2750
+ info="Automatically remove black border areas from the final stitched image(s) AFTER stitching.")
2751
  strict_no_black_edges_checkbox = gr.Checkbox(value=False, label="Strict No Black Edges (Post-Crop)",
2752
+ info="If 'Crop Black Borders' is enabled, this forces removal of *any* remaining black pixels directly on the image edges after the main crop. Might slightly shrink the image further.")
2753
 
2754
  with gr.Column(scale=1):
2755
  output_gallery = gr.Gallery(
 
2774
  # Postprocessing
2775
  enable_cropping,
2776
  strict_no_black_edges_checkbox,
2777
+ fixed_base_image_index_input,
2778
  # Detailed Stitcher Settings
2779
  transform_model,
2780
  blend_method,
 
2799
  ["examples/Wetter-Panorama/Wetter-Panorama1[NIuO6hrFTrg].mp4"],
2800
  0, 20,
2801
  "DIRECT_PAIRWISE", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2802
+ True, False, -1,
2803
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2804
  2500, 10000, 10000,
2805
  ],
 
2807
  ["examples/Wetter-Panorama/Wetter-Panorama2[NIuO6hrFTrg].mp4"],
2808
  0, 20,
2809
  "DIRECT_PAIRWISE", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2810
+ True, False, -1,
2811
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2812
  2500, 10000, 10000,
2813
  ],
 
2816
  "examples/NieRAutomata/nier2B_06.jpg", "examples/NieRAutomata/nier2B_07.jpg", "examples/NieRAutomata/nier2B_08.jpg", "examples/NieRAutomata/nier2B_09.jpg", "examples/NieRAutomata/nier2B_10.jpg", ],
2817
  0, 0,
2818
  "PANORAMA", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2819
+ True, False, -1,
2820
  "Homography", "Multi-Band", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2821
  5000, 10000, 10000,
2822
  ],
 
2824
  ["examples/cat/cat_left.jpg", "examples/cat/cat_right.jpg"],
2825
  0, 0,
2826
  "SCANS", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2827
+ True, False, -1,
2828
  "Affine_Partial", "Linear", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2829
  5000, 10000, 10000,
2830
  ],
 
2832
  ["examples/ギルドの受付嬢ですが/Girumasu_1.jpg", "examples/ギルドの受付嬢ですが/Girumasu_2.jpg", "examples/ギルドの受付嬢ですが/Girumasu_3.jpg"],
2833
  0, 0,
2834
  "PANORAMA", 0.6, 0.1, -1, False, "GAIN_BLOCKS",
2835
+ True, False, -1,
2836
  "Affine_Partial", "Linear", True, 5000, 0.65, 5.0, 0.5, 10000, 10000, 15, 4,
2837
  5000, 10000, 10000,
2838
  ],
 
2840
  ["examples/photographs1/img1.jpg", "examples/photographs1/img2.jpg", "examples/photographs1/img3.jpg", "examples/photographs1/img4.jpg"],
2841
  0, 0,
2842
  "PANORAMA", 0.6, 0.1, -1, True, "GAIN_BLOCKS",
2843
+ True, False, -1,
2844
  "Homography", "Linear", True, 5000, 0.5, 5.0, 0.5, 10000, 10000, 15, 4,
2845
  5000, 10000, 10000,
2846
  ]