diff --git a/README.md b/README.md index 657d957..a195298 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,11 @@ panorama = stitcher.stitch(["img1.jpg", "img2.jpg", "img3.jpg"]) ``` -or using the [command line -tool](https://github.com/lukasalexanderweber/stitching/blob/main/stitching_tool.py) +or using the command line interface ([cli](https://github.com/lukasalexanderweber/stitching/tree/main/stitching/cli/stitch.py)) which is available after installation ```bash -python stitching_tool.py -h -python stitching_tool.py img1.jpg img2.jpg img3.jpg +stitch -h +stitch img1.jpg img2.jpg img3.jpg ``` ## Tutorial diff --git a/setup.cfg b/setup.cfg index 5306583..509dbc1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,9 @@ install_requires = include_package_data = True zip_safe = False +[options.entry_points] +console_scripts = + stitch = stitching.cli.stitch:main + [options.packages.find] -include = stitching exclude = tests diff --git a/stitching/__init__.py b/stitching/__init__.py index d80b262..c461c4f 100644 --- a/stitching/__init__.py +++ b/stitching/__init__.py @@ -1,3 +1,3 @@ from .stitcher import Stitcher -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/stitching/cli/__init__.py b/stitching/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stitching_tool.py b/stitching/cli/stitch.py similarity index 52% rename from stitching_tool.py rename to stitching/cli/stitch.py index 9615a6c..949d5b8 100644 --- a/stitching_tool.py +++ b/stitching/cli/stitch.py @@ -2,223 +2,258 @@ Command line tool for the stitching package """ -# Python 2/3 compatibility -from __future__ import print_function - import argparse import cv2 as cv import numpy as np from stitching import Stitcher - -from stitching.image_handler import ImageHandler -from stitching.feature_detector import FeatureDetector -from stitching.feature_matcher import FeatureMatcher -from stitching.subsetter import Subsetter -from stitching.camera_estimator import CameraEstimator +from stitching.blender import Blender from stitching.camera_adjuster import CameraAdjuster +from stitching.camera_estimator import CameraEstimator from stitching.camera_wave_corrector import WaveCorrector -from stitching.warper import Warper from stitching.cropper import Cropper -from stitching.exposure_error_compensator import ExposureErrorCompensator # noqa +from stitching.exposure_error_compensator import ExposureErrorCompensator +from stitching.feature_detector import FeatureDetector +from stitching.feature_matcher import FeatureMatcher +from stitching.image_handler import ImageHandler from stitching.seam_finder import SeamFinder -from stitching.blender import Blender +from stitching.subsetter import Subsetter from stitching.timelapser import Timelapser +from stitching.warper import Warper -parser = argparse.ArgumentParser( - prog="stitching.py", - description="Rotation model image stitcher" -) +parser = argparse.ArgumentParser(prog="stitch.py") +parser.add_argument("img_names", nargs="+", help="Files to stitch", type=str) parser.add_argument( - 'img_names', nargs='+', - help="Files to stitch", type=str -) -parser.add_argument( - '--medium_megapix', action='store', + "--medium_megapix", + action="store", default=ImageHandler.DEFAULT_MEDIUM_MEGAPIX, help="Resolution for image registration step. " "The default is %s Mpx" % ImageHandler.DEFAULT_MEDIUM_MEGAPIX, - type=float, dest='medium_megapix' + type=float, ) parser.add_argument( - '--detector', action='store', + "--detector", + action="store", default=FeatureDetector.DEFAULT_DETECTOR, help="Type of features used for images matching. " - "The default is '%s'." % FeatureDetector.DEFAULT_DETECTOR, + "The default is '%s'." % FeatureDetector.DEFAULT_DETECTOR, choices=FeatureDetector.DETECTOR_CHOICES.keys(), - type=str, dest='detector' + type=str, ) parser.add_argument( - '--nfeatures', action='store', + "--nfeatures", + action="store", default=500, - help="Type of features used for images matching. " - "The default is 500.", - type=int, dest='nfeatures' + help="Type of features used for images matching. The default is 500.", + type=int, ) parser.add_argument( - '--matcher_type', action='store', default=FeatureMatcher.DEFAULT_MATCHER, + "--matcher_type", + action="store", + default=FeatureMatcher.DEFAULT_MATCHER, help="Matcher used for pairwise image matching. " - "The default is '%s'." % FeatureMatcher.DEFAULT_MATCHER, + "The default is '%s'." % FeatureMatcher.DEFAULT_MATCHER, choices=FeatureMatcher.MATCHER_CHOICES, - type=str, dest='matcher_type' + type=str, ) parser.add_argument( - '--range_width', action='store', + "--range_width", + action="store", default=FeatureMatcher.DEFAULT_RANGE_WIDTH, help="uses range_width to limit number of images to match with.", - type=int, dest='range_width' + type=int, ) parser.add_argument( - '--try_use_gpu', action='store', default=False, - help="Try to use CUDA. The default value is no. " - "All default values are for CPU mode.", - type=bool, dest='try_use_gpu' + "--try_use_gpu", + action="store_true", + help="Try to use CUDA", ) parser.add_argument( - '--match_conf', action='store', + "--match_conf", + action="store", help="Confidence for feature matching step. " - "The default is 0.3 for ORB and 0.65 for other feature types.", - type=float, dest='match_conf' + "The default is 0.3 for ORB and 0.65 for other feature types.", + type=float, ) parser.add_argument( - '--confidence_threshold', action='store', + "--confidence_threshold", + action="store", default=Subsetter.DEFAULT_CONFIDENCE_THRESHOLD, help="Threshold for two images are from the same panorama confidence. " - "The default is '%s'." % Subsetter.DEFAULT_CONFIDENCE_THRESHOLD, - type=float, dest='confidence_threshold' + "The default is '%s'." % Subsetter.DEFAULT_CONFIDENCE_THRESHOLD, + type=float, ) parser.add_argument( - '--matches_graph_dot_file', action='store', + "--matches_graph_dot_file", + action="store", default=Subsetter.DEFAULT_MATCHES_GRAPH_DOT_FILE, help="Save matches graph represented in DOT language to file.", - type=str, dest='matches_graph_dot_file' + type=str, ) parser.add_argument( - '--estimator', action='store', + "--estimator", + action="store", default=CameraEstimator.DEFAULT_CAMERA_ESTIMATOR, help="Type of estimator used for transformation estimation. " - "The default is '%s'." % CameraEstimator.DEFAULT_CAMERA_ESTIMATOR, + "The default is '%s'." % CameraEstimator.DEFAULT_CAMERA_ESTIMATOR, choices=CameraEstimator.CAMERA_ESTIMATOR_CHOICES.keys(), - type=str, dest='estimator' + type=str, ) parser.add_argument( - '--adjuster', action='store', + "--adjuster", + action="store", default=CameraAdjuster.DEFAULT_CAMERA_ADJUSTER, help="Bundle adjustment cost function. " - "The default is '%s'." % CameraAdjuster.DEFAULT_CAMERA_ADJUSTER, + "The default is '%s'." % CameraAdjuster.DEFAULT_CAMERA_ADJUSTER, choices=CameraAdjuster.CAMERA_ADJUSTER_CHOICES.keys(), - type=str, dest='adjuster' + type=str, ) parser.add_argument( - '--refinement_mask', action='store', + "--refinement_mask", + action="store", default=CameraAdjuster.DEFAULT_REFINEMENT_MASK, help="Set refinement mask for bundle adjustment. It looks like 'x_xxx', " - "where 'x' means refine respective parameter and '_' means don't " - "refine, and has the following format:. " - "The default mask is '%s'. " - "If bundle adjustment doesn't support estimation of selected " - "parameter then the respective flag is ignored." - "" % CameraAdjuster.DEFAULT_REFINEMENT_MASK, - type=str, dest='refinement_mask' + "where 'x' means refine respective parameter and '_' means don't " + "refine, and has the following format:. " + "The default mask is '%s'. " + "If bundle adjustment doesn't support estimation of selected " + "parameter then the respective flag is ignored." + "" % CameraAdjuster.DEFAULT_REFINEMENT_MASK, + type=str, ) parser.add_argument( - '--wave_correct_kind', action='store', + "--wave_correct_kind", + action="store", default=WaveCorrector.DEFAULT_WAVE_CORRECTION, help="Perform wave effect correction. " - "The default is '%s'" % WaveCorrector.DEFAULT_WAVE_CORRECTION, + "The default is '%s'" % WaveCorrector.DEFAULT_WAVE_CORRECTION, choices=WaveCorrector.WAVE_CORRECT_CHOICES.keys(), - type=str, dest='wave_correct_kind' + type=str, ) parser.add_argument( - '--warper_type', action='store', default=Warper.DEFAULT_WARP_TYPE, + "--warper_type", + action="store", + default=Warper.DEFAULT_WARP_TYPE, help="Warp surface type. The default is '%s'." % Warper.DEFAULT_WARP_TYPE, choices=Warper.WARP_TYPE_CHOICES, - type=str, dest='warper_type' + type=str, ) parser.add_argument( - '--low_megapix', action='store', default=ImageHandler.DEFAULT_LOW_MEGAPIX, + "--low_megapix", + action="store", + default=ImageHandler.DEFAULT_LOW_MEGAPIX, help="Resolution for seam estimation and exposure estimation step. " "The default is %s Mpx." % ImageHandler.DEFAULT_LOW_MEGAPIX, - type=float, dest='low_megapix' + type=float, ) parser.add_argument( - '--crop', action='store', default=Cropper.DEFAULT_CROP, + "--crop", + action="store_true", help="Crop black borders around images caused by warping using the " "largest interior rectangle. " "Default is '%s'." % Cropper.DEFAULT_CROP, - type=bool, dest='crop' ) parser.add_argument( - '--compensator', action='store', + "--no-crop", + action="store_false", + help="Don't Crop black borders around images caused by warping using the " + "largest interior rectangle. " + "Default is '%s'." % (not Cropper.DEFAULT_CROP), + dest="crop", +) +parser.set_defaults(crop=Cropper.DEFAULT_CROP) +parser.add_argument( + "--compensator", + action="store", default=ExposureErrorCompensator.DEFAULT_COMPENSATOR, help="Exposure compensation method. " - "The default is '%s'." % ExposureErrorCompensator.DEFAULT_COMPENSATOR, + "The default is '%s'." % ExposureErrorCompensator.DEFAULT_COMPENSATOR, choices=ExposureErrorCompensator.COMPENSATOR_CHOICES.keys(), - type=str, dest='compensator' + type=str, ) parser.add_argument( - '--nr_feeds', action='store', + "--nr_feeds", + action="store", default=ExposureErrorCompensator.DEFAULT_NR_FEEDS, help="Number of exposure compensation feed.", - type=np.int32, dest='nr_feeds' + type=np.int32, ) parser.add_argument( - '--block_size', action='store', + "--block_size", + action="store", default=ExposureErrorCompensator.DEFAULT_BLOCK_SIZE, help="BLock size in pixels used by the exposure compensator. " - "The default is '%s'." % ExposureErrorCompensator.DEFAULT_BLOCK_SIZE, - type=np.int32, dest='block_size' + "The default is '%s'." % ExposureErrorCompensator.DEFAULT_BLOCK_SIZE, + type=np.int32, ) parser.add_argument( - '--finder', action='store', default=SeamFinder.DEFAULT_SEAM_FINDER, + "--finder", + action="store", + default=SeamFinder.DEFAULT_SEAM_FINDER, help="Seam estimation method. " - "The default is '%s'." % SeamFinder.DEFAULT_SEAM_FINDER, + "The default is '%s'." % SeamFinder.DEFAULT_SEAM_FINDER, choices=SeamFinder.SEAM_FINDER_CHOICES.keys(), - type=str, dest='finder' + type=str, ) parser.add_argument( - '--final_megapix', action='store', + "--final_megapix", + action="store", default=ImageHandler.DEFAULT_FINAL_MEGAPIX, help="Resolution for compositing step. Use -1 for original resolution. " - "The default is %s" % ImageHandler.DEFAULT_FINAL_MEGAPIX, - type=float, dest='final_megapix' + "The default is %s" % ImageHandler.DEFAULT_FINAL_MEGAPIX, + type=float, ) parser.add_argument( - '--blender_type', action='store', default=Blender.DEFAULT_BLENDER, + "--blender_type", + action="store", + default=Blender.DEFAULT_BLENDER, help="Blending method. The default is '%s'." % Blender.DEFAULT_BLENDER, choices=Blender.BLENDER_CHOICES, - type=str, dest='blender_type' + type=str, ) parser.add_argument( - '--blend_strength', action='store', default=Blender.DEFAULT_BLEND_STRENGTH, + "--blend_strength", + action="store", + default=Blender.DEFAULT_BLEND_STRENGTH, help="Blending strength from [0,100] range. " - "The default is '%s'." % Blender.DEFAULT_BLEND_STRENGTH, - type=np.int32, dest='blend_strength' + "The default is '%s'." % Blender.DEFAULT_BLEND_STRENGTH, + type=np.int32, ) parser.add_argument( - '--timelapse', action='store', default=Timelapser.DEFAULT_TIMELAPSE, + "--timelapse", + action="store", + default=Timelapser.DEFAULT_TIMELAPSE, help="Output warped images separately as frames of a time lapse movie, " - "with 'fixed_' prepended to input file names. " - "The default is '%s'." % Timelapser.DEFAULT_TIMELAPSE, + "with 'fixed_' prepended to input file names. " + "The default is '%s'." % Timelapser.DEFAULT_TIMELAPSE, choices=Timelapser.TIMELAPSE_CHOICES, - type=str, dest='timelapse' + type=str, +) +parser.add_argument( + "--preview", + action="store_true", + help="Opens a preview window with the stitched result", ) parser.add_argument( - '--output', action='store', default='result.jpg', + "--output", + action="store", + default="result.jpg", help="The default is 'result.jpg'", - type=str, dest='output' + type=str, ) -__doc__ += '\n' + parser.format_help() +__doc__ += "\n" + parser.format_help() -if __name__ == '__main__': + +def main(): args = parser.parse_args() args_dict = vars(args) # Extract In- and Output img_names = args_dict.pop("img_names") img_names = [cv.samples.findFile(img_name) for img_name in img_names] + preview = args_dict.pop("preview") output = args_dict.pop("output") stitcher = Stitcher(**args_dict) @@ -226,9 +261,14 @@ cv.imwrite(output, panorama) - zoom_x = 600.0 / panorama.shape[1] - preview = cv.resize(panorama, dsize=None, fx=zoom_x, fy=zoom_x) + if preview: + zoom_x = 600.0 / panorama.shape[1] + preview = cv.resize(panorama, dsize=None, fx=zoom_x, fy=zoom_x) + + cv.imshow(output, preview) + cv.waitKey() + cv.destroyAllWindows() + - cv.imshow(output, preview) - cv.waitKey() - cv.destroyAllWindows() +if __name__ == "__main__": + main()