1414
1515pillow_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}
2723EXIF_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
0 commit comments