Skip to content

Commit

Permalink
Add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ChenglongMa committed Nov 23, 2023
1 parent 16a53ac commit bccbb88
Show file tree
Hide file tree
Showing 29 changed files with 181 additions and 35 deletions.
23 changes: 10 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
setuptools>=65.6.3
colorama>=0.4.6
11 changes: 6 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/stone/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/stone/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__version__ = "1.1.1"
__package_name__ = "skin-tone-classifier"
79 changes: 65 additions & 14 deletions src/stone/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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"',
Expand All @@ -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'].",
)
Expand Down Expand Up @@ -164,14 +173,15 @@ 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,
)

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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
1 change: 0 additions & 1 deletion src/stone/version.py

This file was deleted.

Empty file added tests/__init__.py
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 92 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit bccbb88

Please sign in to comment.