maxwoe commited on
Commit
c2f46fa
·
1 Parent(s): 7dabfe2

Deploy inference-only Gradio demo

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/COCO_val2014_000000016995.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/COCO_val2014_000000122166.jpg filter=lfs diff=lfs merge=lfs -text
38
+ examples/COCO_val2014_000000168337.jpg filter=lfs diff=lfs merge=lfs -text
39
+ examples/COCO_val2014_000000446053.jpg filter=lfs diff=lfs merge=lfs -text
40
+ examples/COCO_val2014_000000477919.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,12 +1,15 @@
1
  ---
2
  title: Image Rotation Angle Estimation
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.10.0
8
  app_file: app.py
 
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
  title: Image Rotation Angle Estimation
3
+ emoji: "\U0001F504"
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "5.23.0"
8
  app_file: app.py
9
+ python_version: "3.11"
10
  pinned: false
11
  ---
12
 
13
+ Upload an image to predict and correct its rotation angle. Uses the CGD (Circular Gaussian Distribution) method with a MambaOut Base backbone, achieving 2.84° MAE on COCO.
14
+
15
+ [GitHub](https://github.com/maxwoe/image-rotation-angle-estimation) | [Paper](https://arxiv.org/abs/2603.25351) | [Pretrained Models](https://huggingface.co/maxwoe/image-rotation-angle-estimation)
app.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace Spaces demo for Image Rotation Angle Estimation.
2
+
3
+ Two-step interactive demo:
4
+ 1. Upload an image and click "Random Rotate" to apply a random rotation
5
+ 2. Click "Correct Orientation" to see the model predict and correct the angle
6
+ """
7
+
8
+ import json
9
+
10
+ import gradio as gr
11
+ import torch
12
+ from PIL import Image
13
+ import os
14
+ import random
15
+ import numpy as np
16
+ from loguru import logger
17
+ from huggingface_hub import hf_hub_download
18
+
19
+ from model_cgd import CGDAngleEstimation
20
+ from architectures import get_default_input_size
21
+ from rotation_utils import rotate_image_crop_max_area
22
+
23
+ # HuggingFace Hub configuration
24
+ HF_REPO_ID = os.environ.get("HF_MODEL_REPO", "maxwoe/image-rotation-angle-estimation")
25
+
26
+ # Fetch config.json from model repo (this is the HF download-tracking query file)
27
+ _config_path = hf_hub_download(repo_id=HF_REPO_ID, filename="config.json")
28
+ with open(_config_path) as _f:
29
+ _config = json.load(_f)
30
+ HF_MODELS = _config["models"]
31
+ HF_DEFAULT_MODEL = _config["default_model"]
32
+
33
+ # Global model state
34
+ model = None
35
+ current_model_name = None
36
+
37
+
38
+ def get_device():
39
+ return "cuda:0" if torch.cuda.is_available() else "cpu"
40
+
41
+
42
+ def load_model(name):
43
+ """Download and load a model from HuggingFace Hub."""
44
+ global model, current_model_name
45
+ if name == current_model_name and model is not None:
46
+ return gr.Info(f"Model already loaded: {name}")
47
+
48
+ if name not in HF_MODELS:
49
+ return gr.Warning(f"Unknown model: {name}")
50
+
51
+ info = HF_MODELS[name]
52
+ logger.info(f"Downloading {info['filename']} from {HF_REPO_ID}...")
53
+ local_path = hf_hub_download(repo_id=HF_REPO_ID, filename=info["filename"])
54
+
55
+ architecture = info["architecture"]
56
+ image_size = get_default_input_size(architecture)
57
+
58
+ logger.info(f"Loading model from {local_path}...")
59
+ new_model = CGDAngleEstimation.try_load(checkpoint_path=local_path, image_size=image_size)
60
+ new_model.eval()
61
+
62
+ device = get_device()
63
+ if device.startswith("cuda"):
64
+ new_model = new_model.to(device)
65
+
66
+ model = new_model
67
+ current_model_name = name
68
+ logger.info(f"Model loaded: {name} on {device}")
69
+ return gr.Info(f"Loaded: {name} ({device})")
70
+
71
+
72
+ def store_original(image):
73
+ """Store the uploaded image as the original for rotation."""
74
+ if image is None:
75
+ return None, ""
76
+ if isinstance(image, np.ndarray):
77
+ image = Image.fromarray(image)
78
+ return image, ""
79
+
80
+
81
+ def random_rotate(original):
82
+ """Apply a random rotation to the original uploaded image."""
83
+ if original is None:
84
+ return None, None, ""
85
+
86
+ angle = random.uniform(0, 360)
87
+ img_array = np.array(original)
88
+ rotated_array = rotate_image_crop_max_area(img_array, angle)
89
+ rotated = Image.fromarray(rotated_array)
90
+ return rotated, angle, f"{angle:.1f}°"
91
+
92
+
93
+ def correct_orientation(image):
94
+ """Predict the rotation angle and correct the image."""
95
+ if image is None:
96
+ return None, "Please upload and rotate an image first."
97
+ if model is None:
98
+ return None, "Model is still loading, please wait..."
99
+
100
+ if isinstance(image, np.ndarray):
101
+ image = Image.fromarray(image)
102
+
103
+ predicted_angle = model.predict_angle(image)
104
+
105
+ corrected = image.rotate(-predicted_angle, expand=True, fillcolor=(255, 255, 255))
106
+
107
+ return corrected, f"Predicted rotation: {predicted_angle:.2f}°"
108
+
109
+
110
+ # Build UI
111
+ app = gr.Blocks(title="Image Rotation Angle Estimation")
112
+ with app:
113
+ gr.HTML("<h1>Image Rotation Angle Estimation</h1>")
114
+ gr.Markdown(
115
+ "Upload an image, apply a random rotation, and see the model predict and correct the angle.\n\n"
116
+ "Uses the **CGD** (Circular Gaussian Distribution) method with **MambaOut Base** architecture. "
117
+ )
118
+
119
+ original_image_state = gr.State(value=None)
120
+ actual_angle_state = gr.State(value=None)
121
+
122
+ model_dropdown = gr.Dropdown(
123
+ choices=list(HF_MODELS.keys()),
124
+ value=HF_DEFAULT_MODEL,
125
+ label="Model",
126
+ )
127
+ model_dropdown.change(load_model, inputs=[model_dropdown])
128
+
129
+ with gr.Row():
130
+ with gr.Column():
131
+ input_image = gr.Image(type="pil", label="Upload Image", height=400, format="png")
132
+ rotate_btn = gr.Button("Random Rotate", variant="secondary", size="lg")
133
+ with gr.Column():
134
+ corrected_image = gr.Image(label="Corrected Image", height=400, interactive=False, format="png")
135
+ correct_btn = gr.Button("Correct Orientation", variant="primary", size="lg")
136
+
137
+ with gr.Row():
138
+ rotation_info = gr.Textbox(label="Applied Rotation", lines=1, interactive=False)
139
+ result_text = gr.Textbox(label="Predicted Rotation", lines=1, interactive=False)
140
+
141
+ gr.Examples(
142
+ examples=[
143
+ "examples/COCO_val2014_000000168337.jpg",
144
+ "examples/COCO_val2014_000000122166.jpg",
145
+ "examples/COCO_val2014_000000446053.jpg",
146
+ "examples/COCO_val2014_000000477919.jpg",
147
+ "examples/COCO_val2014_000000016995.jpg",
148
+ ],
149
+ inputs=input_image,
150
+ fn=store_original,
151
+ outputs=[original_image_state, rotation_info],
152
+ cache_examples=False,
153
+ run_on_click=True,
154
+ )
155
+
156
+ input_image.upload(
157
+ store_original,
158
+ inputs=[input_image],
159
+ outputs=[original_image_state, rotation_info],
160
+ )
161
+ rotate_btn.click(
162
+ random_rotate,
163
+ inputs=[original_image_state],
164
+ outputs=[input_image, actual_angle_state, rotation_info],
165
+ )
166
+ correct_btn.click(
167
+ correct_orientation,
168
+ inputs=[input_image],
169
+ outputs=[corrected_image, result_text],
170
+ )
171
+
172
+ app.load(lambda: load_model(HF_DEFAULT_MODEL))
173
+
174
+ app.launch()
architectures.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Architecture configurations for inference (input sizes only)."""
2
+
3
+ ARCHITECTURES = {
4
+ "vit_tiny_patch16_224.augreg_in21k_ft_in1k": {"input_size": 224},
5
+ "vit_base_patch16_224.augreg_in21k_ft_in1k": {"input_size": 224},
6
+ "efficientvit_b0.r224_in1k": {"input_size": 224},
7
+ "efficientvit_b3.r224_in1k": {"input_size": 224},
8
+ "convnextv2_atto.fcmae_ft_in1k": {"input_size": 224},
9
+ "convnextv2_base.fcmae_ft_in22k_in1k": {"input_size": 224},
10
+ "efficientnetv2_rw_t.ra2_in1k": {"input_size": 224},
11
+ "efficientnetv2_rw_m.agc_in1k": {"input_size": 320},
12
+ "mambaout_tiny.in1k": {"input_size": 224},
13
+ "mambaout_base.in1k": {"input_size": 224},
14
+ "focalnet_tiny_lrf.ms_in1k": {"input_size": 224},
15
+ "focalnet_base_lrf.ms_in1k": {"input_size": 224},
16
+ "edgenext_xx_small.in1k": {"input_size": 256},
17
+ "edgenext_base.in21k_ft_in1k": {"input_size": 256},
18
+ "swin_tiny_patch4_window7_224": {"input_size": 224},
19
+ "swin_base_patch4_window7_224": {"input_size": 224},
20
+ }
21
+
22
+
23
+ def get_default_input_size(architecture_name):
24
+ """Get default input size for an architecture."""
25
+ return ARCHITECTURES.get(architecture_name, {}).get("input_size", 224)
examples/COCO_val2014_000000016995.jpg ADDED

Git LFS Details

  • SHA256: be0f2c417eca5516d06e67d6cf6c6ed031520e2a5ac019fbd35c19bd45d82e7d
  • Pointer size: 131 Bytes
  • Size of remote file: 271 kB
examples/COCO_val2014_000000122166.jpg ADDED

Git LFS Details

  • SHA256: 914e39f9c79ced1a519f1003c410b3ed7bfbc8315df4e0e0c35ac421079e9c6d
  • Pointer size: 131 Bytes
  • Size of remote file: 231 kB
examples/COCO_val2014_000000168337.jpg ADDED

Git LFS Details

  • SHA256: bcdd0a74f8a7964c8ceb703c08adf3554eb7ee8fe942629e4aedb9ffc648fbdc
  • Pointer size: 131 Bytes
  • Size of remote file: 176 kB
examples/COCO_val2014_000000446053.jpg ADDED

Git LFS Details

  • SHA256: 0044e1c1f46a61d134db533543bd159ba0c54e79a33c4f1648bcff15b8813834
  • Pointer size: 131 Bytes
  • Size of remote file: 223 kB
examples/COCO_val2014_000000477919.jpg ADDED

Git LFS Details

  • SHA256: 914b2ff8ae2c20ee24de115c37c116ee6e60884642611aa93651e1e5d0113394
  • Pointer size: 131 Bytes
  • Size of remote file: 253 kB
model_cgd.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Circular Gaussian Distribution (CGD) for Image Orientation Estimation (Inference Only)
3
+
4
+ Represents angles as probability distributions over discretized angle bins.
5
+ Model output: Probability distribution over 360 angle bins (1 degree resolution)
6
+ """
7
+
8
+ import math
9
+ from typing import Dict, Any
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ import torchvision.transforms as transforms
15
+ import pytorch_lightning as pl
16
+ import timm
17
+ import timm.data
18
+ from PIL import Image
19
+ import numpy as np
20
+ from loguru import logger
21
+
22
+
23
+ class CircularGaussianDistribution(nn.Module):
24
+ """Circular Gaussian Distribution module for 360 degree image orientation."""
25
+
26
+ def __init__(self, num_bins: int = 360, sigma: float = 6.0):
27
+ super().__init__()
28
+ self.num_bins = num_bins
29
+ self.sigma = sigma
30
+ self.bin_size = 360.0 / num_bins
31
+
32
+ bin_centers = torch.arange(0, 360, self.bin_size)
33
+ self.register_buffer('bin_centers', bin_centers)
34
+
35
+ logger.info(f"CGD: {num_bins} bins, range [0, 360), sigma={sigma}")
36
+
37
+ def distribution_to_angle(self, distributions: torch.Tensor, method: str = 'argmax') -> torch.Tensor:
38
+ """Extract angles from probability distributions.
39
+
40
+ Args:
41
+ distributions: Probability distributions [B, num_bins]
42
+ method: 'argmax', 'weighted_average', or 'peak_fitting'
43
+
44
+ Returns:
45
+ angles: Extracted angles in degrees [B] in [0, 360)
46
+ """
47
+ if method == 'argmax':
48
+ peak_indices = torch.argmax(distributions, dim=1)
49
+ angles = self.bin_centers[peak_indices]
50
+
51
+ elif method == 'weighted_average':
52
+ weights = distributions / (distributions.sum(dim=1, keepdim=True) + 1e-8)
53
+ bin_angles_rad = self.bin_centers * torch.pi / 180.0
54
+ cos_components = torch.cos(bin_angles_rad)
55
+ sin_components = torch.sin(bin_angles_rad)
56
+ avg_cos = torch.sum(weights * cos_components.unsqueeze(0), dim=1)
57
+ avg_sin = torch.sum(weights * sin_components.unsqueeze(0), dim=1)
58
+ angles = torch.atan2(avg_sin, avg_cos) * 180.0 / torch.pi
59
+ angles = angles % 360.0
60
+
61
+ elif method == 'peak_fitting':
62
+ peak_indices = torch.argmax(distributions, dim=1)
63
+ angles = torch.zeros_like(peak_indices, dtype=torch.float)
64
+ for i in range(distributions.shape[0]):
65
+ peak_idx = peak_indices[i].item()
66
+ if 0 < peak_idx < self.num_bins - 1:
67
+ y1 = distributions[i, peak_idx - 1]
68
+ y2 = distributions[i, peak_idx]
69
+ y3 = distributions[i, peak_idx + 1]
70
+ a = 0.5 * (y1 - 2*y2 + y3)
71
+ b = 0.5 * (y3 - y1)
72
+ if abs(a) > 1e-8:
73
+ offset = -b / (2 * a)
74
+ offset = torch.clamp(offset, -0.5, 0.5)
75
+ else:
76
+ offset = 0
77
+ angles[i] = self.bin_centers[peak_idx] + offset * self.bin_size
78
+ else:
79
+ angles[i] = self.bin_centers[peak_idx]
80
+ else:
81
+ raise ValueError(f"Unknown extraction method: {method}")
82
+
83
+ angles = angles % 360.0
84
+ return angles
85
+
86
+ def get_distribution_uncertainty(self, distributions: torch.Tensor) -> torch.Tensor:
87
+ """Calculate entropy-based uncertainty from distribution."""
88
+ log_probs = torch.log(distributions + 1e-8)
89
+ entropy = -torch.sum(distributions * log_probs, dim=1)
90
+ max_entropy = math.log(self.num_bins)
91
+ return entropy / max_entropy
92
+
93
+
94
+ class CGDAngleEstimation(pl.LightningModule):
95
+ """CGD model for 360 degree image orientation estimation (inference only)."""
96
+
97
+ def __init__(
98
+ self,
99
+ batch_size: int = 16,
100
+ train_dir: str = "",
101
+ model_name: str = "vit_tiny_patch16_224",
102
+ learning_rate: float = 0.001,
103
+ validation_split: float = 0.1,
104
+ random_seed: int = 42,
105
+ image_size: int = 224,
106
+ num_bins: int = 360,
107
+ sigma: float = 6.0,
108
+ inference_method: str = 'argmax',
109
+ loss_type: str = 'kl_divergence',
110
+ test_dir=None,
111
+ test_rotation_range=360.0,
112
+ test_random_seed=42,
113
+ ) -> None:
114
+ super().__init__()
115
+ self.save_hyperparameters()
116
+
117
+ self.model_name = model_name
118
+ self.learning_rate = learning_rate
119
+ self.batch_size = batch_size
120
+ self.train_dir = train_dir
121
+ self.validation_split = validation_split
122
+ self.random_seed = random_seed
123
+ self.image_size = image_size
124
+ self.num_bins = num_bins
125
+ self.sigma = sigma
126
+ self.inference_method = inference_method
127
+ self.loss_type = loss_type
128
+
129
+ self.model = timm.create_model(model_name, pretrained=True, num_classes=num_bins)
130
+ self.cgd = CircularGaussianDistribution(num_bins=num_bins, sigma=sigma)
131
+
132
+ @classmethod
133
+ def try_load(cls, checkpoint_path=None, **kwargs):
134
+ """Load model from checkpoint."""
135
+ if checkpoint_path:
136
+ logger.info(f"Loading model from checkpoint: {checkpoint_path}")
137
+ model = cls.load_from_checkpoint(checkpoint_path, **kwargs)
138
+ logger.info("Model loaded successfully from checkpoint")
139
+ return model
140
+ raise FileNotFoundError("Checkpoint file not found")
141
+
142
+ @classmethod
143
+ def from_pretrained(cls, repo_id, model_name=None):
144
+ """Load a pretrained model from HuggingFace Hub.
145
+
146
+ Args:
147
+ repo_id: HuggingFace repo ID (e.g. "maxwoe/image-rotation-angle-estimation")
148
+ model_name: Display name or checkpoint filename from config.json.
149
+ Defaults to the default model.
150
+ """
151
+ import json
152
+ from huggingface_hub import hf_hub_download
153
+
154
+ config_path = hf_hub_download(repo_id=repo_id, filename="config.json")
155
+ with open(config_path) as f:
156
+ config = json.load(f)
157
+
158
+ if model_name is None:
159
+ model_name = config["default_model"]
160
+
161
+ # Look up by display name or by filename
162
+ if model_name in config["models"]:
163
+ model_info = config["models"][model_name]
164
+ else:
165
+ model_info = None
166
+ for info in config["models"].values():
167
+ if info["filename"] == model_name:
168
+ model_info = info
169
+ break
170
+ if model_info is None:
171
+ available = [i["filename"] for i in config["models"].values()]
172
+ raise ValueError(f"Unknown model: {model_name}. Available: {available}")
173
+
174
+ ckpt_path = hf_hub_download(repo_id=repo_id, filename=model_info["filename"])
175
+ model = cls.try_load(checkpoint_path=ckpt_path, image_size=model_info["input_size"])
176
+ model.eval()
177
+ return model
178
+
179
+ def forward(self, x: torch.Tensor, return_logits: bool = False) -> torch.Tensor:
180
+ """Forward pass returning probability distribution over angles."""
181
+ logits = self.model(x)
182
+ if return_logits:
183
+ return logits
184
+ return F.softmax(logits, dim=1)
185
+
186
+ def predict_angle(self, image) -> float:
187
+ """Detect the current orientation angle of an image.
188
+
189
+ Args:
190
+ image: PIL Image, numpy array, or file path string.
191
+ For best results, pass PIL Image or numpy array directly.
192
+
193
+ Returns:
194
+ Predicted rotation angle in degrees [0, 360).
195
+ """
196
+ self.eval()
197
+
198
+ if isinstance(image, str):
199
+ image = Image.open(image).convert('RGB')
200
+ elif isinstance(image, np.ndarray):
201
+ image = Image.fromarray(image).convert('RGB')
202
+ elif not isinstance(image, Image.Image):
203
+ raise TypeError(f"Expected PIL Image, numpy array, or file path, got {type(image)}")
204
+ else:
205
+ image = image.convert('RGB')
206
+
207
+ try:
208
+ data_config = timm.data.resolve_model_data_config(self.hparams.model_name)
209
+ data_config['crop_pct'] = 1.0
210
+ data_config['input_size'] = (3, self.image_size, self.image_size)
211
+ transform = timm.data.create_transform(**data_config, is_training=False)
212
+ except Exception:
213
+ transform = transforms.Compose([
214
+ transforms.Resize((self.image_size, self.image_size)),
215
+ transforms.ToTensor(),
216
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
217
+ ])
218
+
219
+ image_tensor = transform(image).unsqueeze(0)
220
+
221
+ with torch.no_grad():
222
+ pred_distributions = self(image_tensor)
223
+ angle = self.cgd.distribution_to_angle(pred_distributions, method=self.inference_method).item()
224
+
225
+ return angle
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.1.0
2
+ torchvision>=0.16.0
3
+ pytorch-lightning>=2.1.0
4
+ timm>=0.9.0
5
+ gradio>=4.0.0
6
+ Pillow>=10.0.0
7
+ loguru>=0.7.0
8
+ numpy>=1.24.0
9
+ opencv-python>=4.5.0
10
+ huggingface_hub>=0.20.0
rotation_utils.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rotation utilities that preserve image content without artificial borders."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+ import math
6
+
7
+
8
+ def rotate_image(img, angle, rotation_center=None, expand=False, border_mode=cv2.BORDER_CONSTANT, border_value=0):
9
+ """Rotates an image (angle in degrees) and optionally expands to avoid cropping."""
10
+ h, w = img.shape[:2]
11
+ if rotation_center is None:
12
+ rotation_center = (w/2, h/2)
13
+
14
+ M = cv2.getRotationMatrix2D(rotation_center, angle, 1.0)
15
+
16
+ if expand:
17
+ abs_cos = abs(M[0, 0])
18
+ abs_sin = abs(M[0, 1])
19
+ wn = int(h * abs_sin + w * abs_cos)
20
+ hn = int(h * abs_cos + w * abs_sin)
21
+ M[0, 2] += wn/2 - rotation_center[0]
22
+ M[1, 2] += hn/2 - rotation_center[1]
23
+ else:
24
+ wn, hn = w, h
25
+
26
+ rotated = cv2.warpAffine(
27
+ img, M, (wn, hn), borderMode=border_mode, borderValue=border_value)
28
+
29
+ return rotated, M
30
+
31
+
32
+ def largest_rotated_rect(w, h, angle):
33
+ """Compute the largest axis-aligned rectangle within a rotated rectangle."""
34
+ if w <= 0 or h <= 0:
35
+ return 0, 0
36
+
37
+ width_is_longer = w >= h
38
+ side_long, side_short = (w, h) if width_is_longer else (h, w)
39
+
40
+ sin_a, cos_a = abs(math.sin(angle)), abs(math.cos(angle))
41
+ if side_short <= 2.*sin_a*cos_a*side_long or abs(sin_a-cos_a) < 1e-10:
42
+ x = 0.5*side_short
43
+ wr, hr = (x/sin_a, x/cos_a) if width_is_longer else (x/cos_a, x/sin_a)
44
+ else:
45
+ cos_2a = cos_a*cos_a - sin_a*sin_a
46
+ wr, hr = (w*cos_a - h*sin_a)/cos_2a, (h*cos_a - w*sin_a)/cos_2a
47
+
48
+ return wr, hr
49
+
50
+
51
+ def rotate_image_crop_max_area(image, angle):
52
+ """Rotate image and crop to the largest inscribed rectangle (no borders).
53
+
54
+ Args:
55
+ image: numpy array (OpenCV image)
56
+ angle: Rotation angle in degrees
57
+
58
+ Returns:
59
+ Rotated and cropped numpy array
60
+ """
61
+ h, w = image.shape[:2]
62
+ rotated, _ = rotate_image(image, angle, expand=True)
63
+ wr, hr = largest_rotated_rect(w, h, math.radians(angle))
64
+
65
+ h_rot, w_rot = rotated.shape[:2]
66
+ y1 = h_rot//2 - int(hr/2)
67
+ y2 = y1 + int(hr)
68
+ x1 = w_rot//2 - int(wr/2)
69
+ x2 = x1 + int(wr)
70
+
71
+ return rotated[y1:y2, x1:x2]