Deploy inference-only Gradio demo
Browse files- .gitattributes +5 -0
- README.md +8 -5
- app.py +174 -0
- architectures.py +25 -0
- examples/COCO_val2014_000000016995.jpg +3 -0
- examples/COCO_val2014_000000122166.jpg +3 -0
- examples/COCO_val2014_000000168337.jpg +3 -0
- examples/COCO_val2014_000000446053.jpg +3 -0
- examples/COCO_val2014_000000477919.jpg +3 -0
- model_cgd.py +225 -0
- requirements.txt +10 -0
- rotation_utils.py +71 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
|
|
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
| 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
|
examples/COCO_val2014_000000122166.jpg
ADDED
|
Git LFS Details
|
examples/COCO_val2014_000000168337.jpg
ADDED
|
Git LFS Details
|
examples/COCO_val2014_000000446053.jpg
ADDED
|
Git LFS Details
|
examples/COCO_val2014_000000477919.jpg
ADDED
|
Git LFS Details
|
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]
|