Skip to content

Commit 7d3a14a

Browse files
committed
Rename to auto.py with tests
1 parent 06726f1 commit 7d3a14a

File tree

11 files changed

+191
-35
lines changed

11 files changed

+191
-35
lines changed
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""class Defaults: apply default actions to the image file(s):
1+
"""class Auto - do auto actions to the image file(s):
22
* Resize to 1280 pixels as the max length
33
* Add the border of 5 pixel width in green color
44
* Auto-rotate if upside down or sideways
@@ -20,7 +20,7 @@
2020
pillow_heif.register_heif_opener() # allow Pillow to open HEIC files
2121

2222

23-
class Defaults:
23+
class Auto:
2424
@staticmethod
2525
def resize_add_border(in_path: Path, out_path: Path) -> tuple:
2626
"""Resize and add border to an image file:
@@ -105,8 +105,8 @@ def do_actions(in_path: Path, out_path: Path) -> tuple:
105105
Returns:
106106
tuple: bool, str
107107
"""
108-
_, file = Defaults.rotate_if_needed(in_path, out_path)
109-
return Defaults.resize_add_border(file, out_path)
108+
_, file = Auto.rotate_if_needed(in_path, out_path)
109+
return Auto.resize_add_border(file, out_path)
110110

111111
@staticmethod
112112
def run_on_all(in_path: Path, out_path: Path) -> bool:
@@ -128,7 +128,7 @@ def run_on_all(in_path: Path, out_path: Path) -> bool:
128128

129129
logger.info(f"Do default actions on {files_cnt} files in multiprocess ...")
130130
success_cnt = Common.multiprocess_progress_bar(
131-
Defaults.do_actions, "Default action on image files", tasks
131+
Auto.do_actions, "Default action on image files", tasks
132132
)
133133
logger.info(f"\nFinished default actions on {success_cnt}/{files_cnt} files")
134134
return True

batch_img/orientation.py

Lines changed: 133 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,11 @@
1414

1515
pillow_heif.register_heif_opener() # allow Pillow to open HEIC files
1616

17-
ORIENTATION_MAP = {
18-
1: "normal",
19-
2: "mirrored_horizontal",
20-
3: "upside_down",
21-
4: "mirrored_vertical",
22-
5: "rotated_left_mirrored",
23-
6: "rotated_left",
24-
7: "rotated_right_mirrored",
25-
8: "rotated_right",
17+
ROTATION_MAP = { # map to the clockwise angle to correct
18+
"bottom": 0,
19+
"top": 180,
20+
"left": 270, # rotated right
21+
"right": 90, # rotated left
2622
}
2723
EXIF_CW_ANGLE = {
2824
1: 0,
@@ -81,7 +77,7 @@ def _rotate_image(img, angle: int):
8177
return img
8278

8379
def get_cw_angle_by_face(self, file: Path) -> int:
84-
"""Detect orientation by face in mage by Haar Cascades:
80+
"""Get image orientation by face using Haar Cascades:
8581
* Fastest but least accurate
8682
* Works best with frontal faces
8783
* May produce false positives
@@ -105,8 +101,134 @@ def get_cw_angle_by_face(self, file: Path) -> int:
105101
faces = face_cascade.detectMultiScale(
106102
gray, scaleFactor=1.2, minNeighbors=6
107103
)
108-
# logger.info(f"{len(faces)=}")
109104
if len(faces) > 0:
110105
return angle_cw
111106
logger.warning(f"Found no face in {file}")
112107
return -1
108+
109+
@staticmethod
110+
def get_orientation_by_floor(file: Path) -> int:
111+
"""Get image orientation by floor
112+
113+
Args:
114+
file: image file path
115+
116+
Returns:
117+
int: clockwise angle: 0, 90, 180, 270
118+
"""
119+
with Image.open(file) as img:
120+
opencv_img = np.array(img)
121+
if opencv_img is None:
122+
raise ValueError(f"Failed to load {file}")
123+
124+
# Convert to HSV for color-based floor detection
125+
hsv = cv2.cvtColor(opencv_img, cv2.COLOR_BGR2HSV)
126+
# Heuristic: floors are often low saturation, medium-low value (gray/brown)
127+
lower_floor = np.array([0, 0, 30])
128+
upper_floor = np.array([180, 100, 180])
129+
mask = cv2.inRange(hsv, lower_floor, upper_floor)
130+
131+
h, w, _ = opencv_img.shape
132+
regions = { # Divide image into 4 regions
133+
"top": mask[0 : h // 3, :],
134+
"bottom": mask[2 * h // 3 :, :],
135+
"left": mask[:, 0 : w // 3],
136+
"right": mask[:, 2 * w // 3 :],
137+
}
138+
counts = {k: cv2.countNonZero(v) for k, v in regions.items()}
139+
logger.info(f"Floor pixels cnt: {counts=}")
140+
141+
max_region = max(counts, key=counts.get)
142+
cw_angle = ROTATION_MAP.get(max_region, -1)
143+
return cw_angle
144+
145+
@staticmethod
146+
def get_cw_angle_by_sky(file: Path) -> int:
147+
"""Get image orientation by sky, clouds
148+
149+
Args:
150+
file: image file path
151+
152+
Returns:
153+
int: clockwise angle: 0, 90, 180, 270
154+
"""
155+
with Image.open(file) as img:
156+
opencv_img = np.array(img)
157+
if opencv_img is None:
158+
raise ValueError(f"Failed to load {file}")
159+
160+
# Convert to HSV to detect sky (blue-ish) and cloud (white-ish)
161+
hsv = cv2.cvtColor(opencv_img, cv2.COLOR_BGR2HSV)
162+
163+
# sky_lower = np.array([90, 20, 70])
164+
# sky_upper = np.array([140, 255, 255])
165+
sky_lower = np.array([80, 40, 100])
166+
sky_upper = np.array([140, 200, 255])
167+
168+
# cloud_lower = np.array([0, 0, 180])
169+
# cloud_upper = np.array([180, 70, 255])
170+
cloud_lower = np.array([0, 0, 180])
171+
cloud_upper = np.array([180, 70, 255])
172+
173+
# Masks
174+
sky_mask = cv2.inRange(hsv, sky_lower, sky_upper)
175+
cloud_mask = cv2.inRange(hsv, cloud_lower, cloud_upper)
176+
sky_cloud_mask = cv2.bitwise_or(sky_mask, cloud_mask)
177+
178+
h, w, _ = opencv_img.shape
179+
regions = { # Divide image into 4 regions
180+
"top": sky_cloud_mask[0 : h // 3, :],
181+
"bottom": sky_cloud_mask[2 * h // 3 :, :],
182+
"left": sky_cloud_mask[:, 0 : w // 3],
183+
"right": sky_cloud_mask[:, 2 * w // 3 :],
184+
}
185+
counts = {k: cv2.countNonZero(v) for k, v in regions.items()}
186+
logger.info(f"Sky/Cloud pixels cnt: {counts=}")
187+
return ROTATION_MAP.get(max(counts, key=counts.get), -1)
188+
189+
@staticmethod
190+
def get_sky_score(img):
191+
h, _ = img.shape[:2]
192+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
193+
194+
# Sky: blue range
195+
sky_lower = np.array([90, 20, 70])
196+
sky_upper = np.array([140, 255, 255])
197+
198+
# Clouds: low saturation, high value (white)
199+
cloud_lower = np.array([0, 0, 180])
200+
cloud_upper = np.array([180, 50, 255])
201+
202+
sky_mask = cv2.inRange(hsv, sky_lower, sky_upper)
203+
cloud_mask = cv2.inRange(hsv, cloud_lower, cloud_upper)
204+
205+
combined_mask = cv2.bitwise_or(sky_mask, cloud_mask)
206+
207+
# Only consider the top 1/3 region of the image
208+
top_region = combined_mask[0 : h // 3, :]
209+
score = cv2.countNonZero(top_region)
210+
return score
211+
212+
def get_cw_angle_sky_by_rotate(self, file: Path) -> int:
213+
"""Get image orientation by sky, clouds
214+
215+
Args:
216+
file: image file path
217+
218+
Returns:
219+
int: clockwise angle: 0, 90, 180, 270
220+
"""
221+
with Image.open(file) as img:
222+
opencv_img = np.array(img)
223+
if opencv_img is None:
224+
raise ValueError(f"Failed to load {file}")
225+
scores = {}
226+
for angle in [0, 90, 180, 270]:
227+
rotated = self._rotate_image(opencv_img, angle)
228+
score = self.get_sky_score(rotated)
229+
scores[angle] = score
230+
logger.info(f"Sky/Cloud: {scores=}")
231+
232+
# Best orientation is the one with most sky at the top
233+
best_angle = max(scores, key=scores.get)
234+
return best_angle
216 KB
Binary file not shown.
219 KB
Binary file not shown.

tests/data/HEIC/IMG_2529_90cw.HEIC

219 KB
Binary file not shown.

tests/data/HEIC/chef2_90cw.heic

223 KB
Binary file not shown.

tests/data/HEIC/chef_180cw.heic

224 KB
Binary file not shown.
-168 KB
Binary file not shown.
-177 KB
Binary file not shown.
Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""Test defaults.py
2-
pytest -sv tests/test_defaults.py
1+
"""Test auto.py
2+
pytest -sv tests/test_auto.py
33
Copyright © 2025 John Liu
44
"""
55

@@ -8,7 +8,7 @@
88
from unittest.mock import patch
99

1010
import pytest
11-
from batch_img.defaults import Defaults
11+
from batch_img.auto import Auto
1212

1313

1414
@pytest.fixture(
@@ -26,24 +26,23 @@ def data_resize_add_border(request):
2626

2727
def test_resize_add_border(data_resize_add_border):
2828
in_path, out_path, expected = data_resize_add_border
29-
actual = Defaults.resize_add_border(in_path, out_path)
29+
actual = Auto.resize_add_border(in_path, out_path)
3030
assert actual == expected
3131

3232

3333
@patch("PIL.Image.open")
3434
def test_error_resize_add_border(mock_open):
3535
mock_open.side_effect = ValueError("VE")
36-
actual = Defaults.resize_add_border(Path("img/file"), Path("out/path"))
36+
actual = Auto.resize_add_border(Path("img/file"), Path("out/path"))
3737
assert "img/file" in actual[1]
3838

3939

4040
@pytest.fixture(
4141
params=[
42-
# JL 2025-08-18: race condition when pytest --cov-report=term --cov=batch_img tests
4342
# (
44-
# Path(f"{dirname(__file__)}/data/HEIC/chef_orientation_3.heic"),
43+
# Path(f"{dirname(__file__)}/data/HEIC/chef_180cw.heic"),
4544
# Path(f"{dirname(__file__)}/.out/"),
46-
# (True, Path(f"{dirname(__file__)}/.out/chef_orientation_3_180cw.heic")),
45+
# (True, Path(f"{dirname(__file__)}/.out/chef_180cw_180cw.heic")),
4746
# ),
4847
(
4948
Path(f"{dirname(__file__)}/data/HEIC/chef_show2.heic"),
@@ -58,7 +57,7 @@ def data_rotate_if_needed(request):
5857

5958
def test_rotate_if_needed(data_rotate_if_needed):
6059
in_path, out_path, expected = data_rotate_if_needed
61-
actual = Defaults.rotate_if_needed(in_path, out_path)
60+
actual = Auto.rotate_if_needed(in_path, out_path)
6261
assert actual == expected
6362

6463

@@ -80,11 +79,11 @@ def data_do_actions(request):
8079
return request.param
8180

8281

83-
# JL 2025-08-18: race condition when pytest --cov-report=term --cov=batch_img tests
84-
# def test_do_actions(data_do_actions):
85-
# in_path, out_path, expected = data_do_actions
86-
# actual = Defaults.do_actions(in_path, out_path)
87-
# assert actual == expected
82+
@pytest.mark.slow(reason="This test modifies test data file.")
83+
def test_do_actions(data_do_actions):
84+
in_path, out_path, expected = data_do_actions
85+
actual = Auto.do_actions(in_path, out_path)
86+
assert actual == expected
8887

8988

9089
@pytest.fixture(
@@ -105,8 +104,8 @@ def data_run_on_all(request):
105104
return request.param
106105

107106

108-
# JL 2025-08-18: race condition when pytest --cov-report=term --cov=batch_img tests
109-
# def test_run_on_all(data_run_on_all):
110-
# in_path, out_path, expected = data_run_on_all
111-
# actual = Defaults.run_on_all(in_path, out_path)
112-
# assert actual == expected
107+
@pytest.mark.slow(reason="This test modifies test data file.")
108+
def test_run_on_all(data_run_on_all):
109+
in_path, out_path, expected = data_run_on_all
110+
actual = Auto.run_on_all(in_path, out_path)
111+
assert actual == expected

0 commit comments

Comments
 (0)