diff --git a/README.md b/README.md index 4127e81..b55dcfc 100644 --- a/README.md +++ b/README.md @@ -249,18 +249,16 @@ Furthermore, there will be a report file named `result.csv` which contains more To see the usage and parameters, run: ```shell -stone -h +stone -h (or --help) ``` Output in console: ```text -usage: __main__.py [-h] [-i IMAGE FILENAME [IMAGE FILENAME ...]] - [-t IMAGE TYPE] [-p COLOR [COLOR ...]] - [-l LABEL [LABEL ...]] [-d] [-bw] [-o DIRECTORY] - [--n_workers N_WORKERS] [--n_colors N] [--new_width WIDTH] - [--scale SCALE] [--min_nbrs NEIGHBORS] - [--min_size WIDTH [HEIGHT ...]] [--threshold THRESHOLD] +usage: stone [-h] [-i IMAGE FILENAME [IMAGE FILENAME ...]] [-r] [-t IMAGE TYPE] [-p PALETTE [PALETTE ...]] + [-l LABELS [LABELS ...]] [-d] [-bw] [-o DIRECTORY] [--n_workers WORKERS] [--n_colors COLORS] + [--new_width WIDTH] [--scale SCALE] [--min_nbrs NEIGHBORS] [--min_size WIDTH [HEIGHT ...]] + [--threshold THRESHOLD] [-v] Skin Tone Classifier @@ -277,20 +275,19 @@ options: Specify whether the input image(s) is/are colored or black/white. Valid choices are: "auto", "color" or "bw", Defaults to "auto", which will be detected automatically. - -p COLOR [COLOR ...], --palette COLOR [COLOR ...] + -p PALETTE [PALETTE ...], --palette PALETTE [PALETTE ...] Skin tone palette; Supports RGB hex value leading by "#" or RGB values separated by comma(,), E.g., "-p #373028 #422811" or "-p 255,255,255 100,100,100" - -l LABEL [LABEL ...], --labels LABEL [LABEL ...] + -l LABELS [LABELS ...], --labels LABELS [LABELS ...] Skin tone labels; default values are the uppercase alphabet list leading by the image type ('C' for 'color'; 'B' for 'Black&White'), e.g., ['CA', 'CB', ..., 'CZ'] or ['BA', 'BB', ..., 'BZ']. -d, --debug Whether to generate report images, used for debugging and verification.The report images will be saved in the './debug' directory. -bw, --black_white Whether to convert the input to black/white image(s). If true, the app will use the black/white palette to classify the image. -o DIRECTORY, --output DIRECTORY The path of output file, defaults to current directory. - --n_workers N_WORKERS - The number of workers to process the images, defaults to the number of CPUs in the system. - --n_colors N CONFIG: the number of dominant colors to be extracted, defaults to 2. + --n_workers WORKERS The number of workers to process the images, defaults to the number of CPUs in the system. + --n_colors COLORS CONFIG: the number of dominant colors to be extracted, defaults to 2. --new_width WIDTH CONFIG: resize the images with the specified width. Negative value will be ignored, defaults to 250. --scale SCALE CONFIG: how much the image size is reduced at each image scale, defaults to 1.1 --min_nbrs NEIGHBORS CONFIG: how many neighbors each candidate rectangle should have to retain it. @@ -299,7 +296,7 @@ options: CONFIG: minimum possible face size. Faces smaller than that are ignored, defaults to "90 90". --threshold THRESHOLD CONFIG: what percentage of the skin area is required to identify the face, defaults to 0.3. - -v, --version Show the version number and exit. + -v, --version Show the version number and exit. ``` ### Use Cases diff --git a/requirements.txt b/requirements.txt index 1f82f5a..f683a6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ opencv-python>=4.6.0.66 numpy>=1.21.5 colormath>=3.0.0 tqdm>=4.64.1 -setuptools>=65.6.3 \ No newline at end of file +setuptools>=65.6.3 +colorama>=0.4.6 \ No newline at end of file diff --git a/setup.py b/setup.py index 1fb1350..2042f72 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,16 @@ from setuptools import setup from setuptools import find_packages -VERSION = {} -with open("src/stone/version.py", "r", encoding="utf-8") as fp: - exec(fp.read(), VERSION) +PACKAGE = {} +with open("src/stone/package.py", "r", encoding="utf-8") as fp: + exec(fp.read(), PACKAGE) with open("README.md", "r", encoding="utf-8") as f: LONG_DESCRIPTION = f.read() setup( - name="skin-tone-classifier", - version=VERSION["__version__"], + name=PACKAGE["__package_name__"], + version=PACKAGE["__version__"], description="An easy-to-use library for skin tone classification", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", @@ -34,6 +34,7 @@ "numpy>=1.21.5", "colormath>=3.0.0", "tqdm>=4.64.0", + "colorama>=0.4.6", ], classifiers=[ "Programming Language :: Python :: 3", diff --git a/src/stone/__init__.py b/src/stone/__init__.py index 8d684b0..5208450 100644 --- a/src/stone/__init__.py +++ b/src/stone/__init__.py @@ -1,7 +1,10 @@ import numpy as np from stone.api import process from stone.image import DEFAULT_TONE_PALETTE, DEFAULT_TONE_LABELS, show +from stone.utils import __version__, check_version setattr(np, "asscalar", lambda x: np.asarray(x).item()) -__all__ = ["process", "DEFAULT_TONE_PALETTE", "DEFAULT_TONE_LABELS", "show"] +__all__ = ["process", "DEFAULT_TONE_PALETTE", "DEFAULT_TONE_LABELS", "show", "__version__"] + +check_version() diff --git a/src/stone/package.py b/src/stone/package.py new file mode 100644 index 0000000..8237859 --- /dev/null +++ b/src/stone/package.py @@ -0,0 +1,2 @@ +__version__ = "1.1.1" +__package_name__ = "skin-tone-classifier" diff --git a/src/stone/utils.py b/src/stone/utils.py index e1885d8..4f9519b 100644 --- a/src/stone/utils.py +++ b/src/stone/utils.py @@ -9,7 +9,7 @@ from typing import Union from urllib.parse import urlparse -from stone.version import __version__ +from stone.package import __version__, __package_name__ class ArgumentError(ValueError): @@ -57,17 +57,26 @@ def extract_filename_and_extension(url): def build_image_paths(images_paths, recursive=False): filenames, urls = [], [] valid_images = ["*.jpg", "*.gif", "*.png", "*.jpeg", "*.webp", "*.tif"] + excluded_folders = ["debug", "log"] if isinstance(images_paths, str): images_paths = [images_paths] - recursive_flag = "**" if recursive else "" - for name in images_paths: - if os.path.isdir(name): - filenames.extend([glob.glob(os.path.join(name, recursive_flag, i), recursive=recursive) for i in valid_images]) - elif os.path.isfile(name): - filenames.append([name]) - elif is_url(name): - urls.append(name) - paths = [Path(f) for fs in filenames for f in fs] + urls + + for filename in images_paths: + if is_url(filename): + urls.append(filename) + continue + p = Path(filename) + if p.is_dir(): + images = [p.glob(pattern) for pattern in valid_images] + if recursive: + subfolders = [f for f in p.glob("*/") if f.name not in excluded_folders] + images.extend([sp.rglob(pattern) for pattern in valid_images for sp in subfolders]) + + filenames.extend(images) + elif p.is_file(): + filenames.append([p]) + paths = set([f for fs in filenames for f in fs] + urls) + paths = list(paths) if len(paths) == 0: raise FileNotFoundError("No valid images in the specified path.") # Sort paths by (first) number extracted from the filename string @@ -81,7 +90,7 @@ def sort_file(path: Union[str, Path]): else: basename, *_ = extract_filename_and_extension(path) nums = re.findall(r"\d+", basename) - return int(nums[0]) if nums else 0 + return (int(nums[0]) if nums else float("inf")), basename def is_windows(): @@ -127,7 +136,7 @@ def build_arguments(): "-p", "--palette", nargs="+", - metavar="COLOR", + metavar="PALETTE", help="Skin tone palette;\n" 'Supports RGB hex value leading by "#" or RGB values separated by comma(,),\n' 'E.g., "-p #373028 #422811" or "-p 255,255,255 100,100,100"', @@ -136,7 +145,7 @@ def build_arguments(): "-l", "--labels", nargs="+", - metavar="LABEL", + metavar="LABELS", help="Skin tone labels; default values are the uppercase alphabet list leading by the image type ('C' for 'color'; 'B' for 'Black&White'), " "e.g., ['CA', 'CB', ..., 'CZ'] or ['BA', 'BB', ..., 'BZ'].", ) @@ -164,6 +173,7 @@ def build_arguments(): parser.add_argument( "--n_workers", type=int, + metavar="WORKERS", help="The number of workers to process the images, defaults to the number of CPUs in the system.", default=0, ) @@ -171,7 +181,7 @@ def build_arguments(): parser.add_argument( "--n_colors", type=int, - metavar="N", + metavar="COLORS", help="CONFIG: the number of dominant colors to be extracted, defaults to 2.", default=2, ) @@ -187,6 +197,7 @@ def build_arguments(): parser.add_argument( "--scale", type=float, + metavar="SCALE", help="CONFIG: how much the image size is reduced at each image scale, defaults to 1.1", default=1.1, ) @@ -222,3 +233,43 @@ def build_arguments(): ) return parser.parse_args() + + +def get_latest_version_from_pypi(package_name): + try: + import requests + + response = requests.get(f"https://pypi.org/pypi/{package_name}/json") + response.raise_for_status() + + data = response.json() + latest_version = data["info"]["version"] + return latest_version + except Exception: + pass + + +def check_version(): + if "STONE_UPGRADE_FLAG" in os.environ: + return + try: + from packaging.version import parse + import importlib.metadata + + latest_version = get_latest_version_from_pypi(__package_name__) + if not latest_version: + return + distribution = importlib.metadata.distribution(__package_name__) + installed_version = distribution.version + if parse(installed_version) < parse(latest_version): + from colorama import just_fix_windows_console, Fore + + just_fix_windows_console() + print( + Fore.YELLOW + f"You are using an outdated version of {__package_name__} ({installed_version}).\n" + f"Please upgrade to the latest version ({latest_version}) with the following command:\n", + Fore.GREEN + f"pip install {__package_name__} --upgrade\n" + Fore.RESET, + ) + os.environ["STONE_UPGRADE_FLAG"] = "1" + except Exception: + pass diff --git a/src/stone/version.py b/src/stone/version.py deleted file mode 100644 index a82b376..0000000 --- a/src/stone/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.1.1" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/debug/excluded_fake_img.gif b/tests/mock_data/images/debug/excluded_fake_img.gif new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/debug/excluded_fake_img.jpeg b/tests/mock_data/images/debug/excluded_fake_img.jpeg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/debug/excluded_fake_img.jpg b/tests/mock_data/images/debug/excluded_fake_img.jpg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/debug/excluded_fake_img.png b/tests/mock_data/images/debug/excluded_fake_img.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/debug/excluded_fake_img.webp b/tests/mock_data/images/debug/excluded_fake_img.webp new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/fake_img_1.gif b/tests/mock_data/images/fake_img_1.gif new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/fake_img_10.webp b/tests/mock_data/images/fake_img_10.webp new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/fake_img_100.jpg b/tests/mock_data/images/fake_img_100.jpg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/fake_img_2.jpeg b/tests/mock_data/images/fake_img_2.jpeg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/fake_img_22.png b/tests/mock_data/images/fake_img_22.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/log/excluded_fake_img.gif b/tests/mock_data/images/log/excluded_fake_img.gif new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/log/excluded_fake_img.jpeg b/tests/mock_data/images/log/excluded_fake_img.jpeg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/log/excluded_fake_img.jpg b/tests/mock_data/images/log/excluded_fake_img.jpg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/log/excluded_fake_img.png b/tests/mock_data/images/log/excluded_fake_img.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/log/excluded_fake_img.webp b/tests/mock_data/images/log/excluded_fake_img.webp new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/subfolder/sub_fake_img_10.jpg b/tests/mock_data/images/subfolder/sub_fake_img_10.jpg new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/subfolder/sub_fake_img_101.webp b/tests/mock_data/images/subfolder/sub_fake_img_101.webp new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/subfolder/sub_fake_img_21.png b/tests/mock_data/images/subfolder/sub_fake_img_21.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/subfolder/sub_fake_img_3.gif b/tests/mock_data/images/subfolder/sub_fake_img_3.gif new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data/images/subfolder/sub_fake_img_4.jpeg b/tests/mock_data/images/subfolder/sub_fake_img_4.jpeg new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b5bbbc1 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,92 @@ +import unittest +from pathlib import Path +from unittest.mock import patch + +from stone.utils import build_image_paths + + +class TestUtils(unittest.TestCase): + def setUp(self): + self.image_path = "./mock_data/images" + # Sorted image paths + self.expected_recursive_image_paths = [ + f"{self.image_path}/fake_img_1.gif", # In default, sorted by the trailing number + f"{self.image_path}/fake_img_2.jpeg", + f"{self.image_path}/subfolder/sub_fake_img_3.gif", + f"{self.image_path}/subfolder/sub_fake_img_4.jpeg", + f"{self.image_path}/fake_img_10.webp", # Sorted by length of the filename if the trailing number is the same + f"{self.image_path}/subfolder/sub_fake_img_10.jpg", + f"{self.image_path}/subfolder/sub_fake_img_21.png", + f"{self.image_path}/fake_img_22.png", + f"{self.image_path}/fake_img_100.jpg", + f"{self.image_path}/subfolder/sub_fake_img_101.webp", + ] + + self.expected_non_recursive_image_paths = [ + p for p in self.expected_recursive_image_paths if "subfolder" not in p + ] + + def should_exclude_folder(self, paths, excluded_folders): + """ + Check if the paths do not contain any of the excluded folders. + :param paths: + :param excluded_folders: + :return: + """ + self.assertTrue( + all( + [ + excluded_folder != path.relative_to(self.image_path).parts[0] + for path in paths + for excluded_folder in excluded_folders + ] + ) + ) + + def test_single_directory_recursive(self): + image_paths = build_image_paths(self.image_path, recursive=True) + self.assertTrue(isinstance(image_paths, list)) + self.assertEqual(len(image_paths), 10) + self.should_exclude_folder(image_paths, ["debug", "log"]) + for i in range(len(image_paths)): + actual = image_paths[i] + expected = Path(self.expected_recursive_image_paths[i]) + self.assertTrue(actual.samefile(expected), msg=f"{i}: {actual} != {expected}") + + def test_single_directory_non_recursive(self): + image_paths = build_image_paths(self.image_path, recursive=False) + self.assertTrue(isinstance(image_paths, list)) + self.assertEqual(len(image_paths), 5) + self.should_exclude_folder(image_paths, ["subfolder", "debug", "log"]) + self.assertListEqual( + image_paths, + [Path(p) for p in self.expected_non_recursive_image_paths], + ) + + def test_multiple_directories_recursive(self): + paths = build_image_paths([self.image_path, f"{self.image_path}/subfolder"], recursive=True) + self.assertTrue(isinstance(paths, list)) + self.assertEqual(len(paths), 10) + + def test_single_file(self): + paths = build_image_paths(self.expected_recursive_image_paths[0]) + self.assertTrue(isinstance(paths, list)) + self.assertEqual(len(paths), 1) + + def test_multiple_files(self): + paths = build_image_paths(self.expected_recursive_image_paths) + self.assertTrue(isinstance(paths, list)) + self.assertEqual(len(paths), len(self.expected_recursive_image_paths)) + + def test_single_url(self): + paths = build_image_paths("http://example.com/image.jpg") + self.assertTrue(isinstance(paths, list)) + self.assertEqual(len(paths), 1) + + def test_no_valid_images(self): + with self.assertRaises(FileNotFoundError): + build_image_paths("/path/to/nonexistent") + + +if __name__ == "__main__": + unittest.main()