square-up-cli-tool 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: square-up-cli-tool
3
+ Version: 1.0.0
4
+ Summary: Image Rotator & Cropper Tool
5
+ Requires-Dist: Pillow
6
+ Requires-Dist: pillow-heif
7
+ Requires-Dist: opencv-python
8
+ Requires-Dist: numpy
9
+ Requires-Dist: streamlit
@@ -0,0 +1,65 @@
1
+ # Square Up (Image Rotator & Cropper)
2
+
3
+ A production-ready CLI and Web UI tool for automatically or manually deskewing scanned pages, photographs, postcards, and books. Leverages advanced OpenCV contour detection to dynamically isolate objects from flatbed scanner backgrounds, dropping messy edges gracefully. Natively supports formats including JPG, PNG, TIFF, TIF, HEIC, and WEBP.
4
+
5
+ ## Features
6
+ - **Auto-Deskew**: Automatically detects the physical edges of items against a scanner bed to perfectly upright tilted scans using trigonometric geometry.
7
+ - **Manual Rotation**: Manually set precision rotation angles.
8
+ - **Smart Cropping**: Professional modes for `close` (leaves a tiny sliver of scanner bed) and `flush` (cuts exactly inside the physical boundary to bleed out all background).
9
+ - **Web UI**: Interactive Streamlit app for simple drag-and-drop viewing and processing.
10
+ - **Raw Processing**: Processes and outputs `.tif` and `.tiff` formats completely uncompressed.
11
+
12
+ ## Installation (macOS / Linux / Windows)
13
+
14
+ To install this tool safely on your system without conflicting with global packages, we strongly recommend using a Python Virtual Environment.
15
+
16
+ **1. Clone or Download the Repository**
17
+ Navigate to the directory where you downloaded these files in your terminal:
18
+ ```bash
19
+ cd /path/to/where/you/saved/this
20
+ ```
21
+
22
+ **2. Create and Activate a Virtual Environment**
23
+ Creating an isolated environment ensures `pip` behaves nicely with your system (especially on newer versions of macOS).
24
+ ```bash
25
+ python3 -m venv venv
26
+ source venv/bin/activate
27
+ ```
28
+ *(On Windows, activate using: `venv\Scripts\activate`)*
29
+
30
+ **3. Install the Tool**
31
+ This project uses a standard `pyproject.toml` file. You can install the tool directly into your environment like this:
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+ This automatically installs all requirements (OpenCV, Streamlit, Pillow, etc.) and registers the custom command `sup` to your terminal.
36
+
37
+ ---
38
+
39
+ ## Usage
40
+
41
+ ### 1. Command Line Interface (`sup` command)
42
+ Because you installed the tool via `pip`, you don't need to run messy python scripts. You can just use the short command `sup`!
43
+
44
+ ```bash
45
+ # Auto-Deskew and flush cut a vintage postcard to remove the scanner bed completely
46
+ sup -i your_postcard.tif --auto-deskew --mode flush
47
+
48
+ # Auto-Deskew and leave a tiny 1-2% physical border around the object
49
+ sup -i scan.jpg --auto-deskew --mode close
50
+
51
+ # Manual rotation by -10.5 degrees and exact flush crop
52
+ sup -i image.heic -r -10.5 --mode flush
53
+
54
+ # Specify a custom output path
55
+ sup -i input.png -o output_dir/fixed.jpg --auto-deskew
56
+ ```
57
+
58
+ ### 2. Interactive Web UI
59
+ If you prefer a visual approach where you can drag and drop your images and use sliders to fix your scans dynamically, start the interactive web app:
60
+
61
+ ```bash
62
+ streamlit run app.py
63
+ ```
64
+
65
+ This will automatically launch the tool in your web browser!
@@ -0,0 +1,89 @@
1
+ import streamlit as st
2
+ import os
3
+ import tempfile
4
+ from PIL import Image
5
+ from image_processor import process_image
6
+
7
+ st.set_page_config(page_title="Image Rotator & Cropper", layout="wide", page_icon="📸")
8
+
9
+ st.title("📸 Image Rotator & Cropper")
10
+ st.markdown("Easily process scanned documents or photographs. Upload an image, choose a cropping mode, and either let Auto-Deskew fix the alignment or adjust it manually!")
11
+
12
+ # Sidebar styling
13
+ st.sidebar.title("Configuration")
14
+ st.sidebar.markdown("Use these options to configure exactly how your image is processed.")
15
+
16
+ rotation_type = st.sidebar.radio(
17
+ "Rotation Mode",
18
+ ["Auto-Deskew", "Manual Rotation"],
19
+ help="Auto-Deskew detects text layout angle. Manual lets you slide to your perfect angle."
20
+ )
21
+
22
+ rotation_angle = 0.0
23
+ if rotation_type == "Manual Rotation":
24
+ rotation_angle = st.sidebar.slider("Rotation Angle (Degrees)", -180.0, 180.0, 0.0, step=0.5)
25
+
26
+ crop_mode = st.sidebar.selectbox(
27
+ "Cropping Mode",
28
+ ["close", "flush"],
29
+ format_func=lambda x: "Close (leaves a tiny sliver of scanner bed)" if x == "close" else "Flush (crops INSIDE the shape to remove all background)",
30
+ help="How closely should we crop to the detected content block?"
31
+ )
32
+
33
+ uploaded_file = st.file_uploader("Drag and drop your image (JPG, PNG, TIFF, WEBP, HEIC)", type=['jpg', 'jpeg', 'png', 'tiff', 'webp', 'heic'])
34
+
35
+ if uploaded_file is not None:
36
+ # Build columns for preview
37
+ col_input, col_output = st.columns(2)
38
+
39
+ with col_input:
40
+ st.subheader("Original Preview")
41
+ st.image(uploaded_file, use_container_width=True)
42
+
43
+ process_btn = st.sidebar.button("Process Image", type="primary", use_container_width=True)
44
+
45
+ if process_btn:
46
+ with st.spinner("Processing..."):
47
+ # Write uploaded buffer to a named temp file
48
+ ext = uploaded_file.name.split('.')[-1]
49
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp_in:
50
+ tmp_in.write(uploaded_file.getbuffer())
51
+ input_path = tmp_in.name
52
+
53
+ output_path = input_path + "_processed.jpg"
54
+ auto_deskew = (rotation_type == "Auto-Deskew")
55
+
56
+ # Process using our core logic
57
+ res_path = process_image(
58
+ input_path=input_path,
59
+ output_path=output_path,
60
+ rotation_angle=rotation_angle,
61
+ auto_deskew=auto_deskew,
62
+ crop_mode=crop_mode
63
+ )
64
+
65
+ if res_path and os.path.exists(res_path):
66
+ with col_output:
67
+ st.subheader("Processed Result")
68
+ result_img = Image.open(res_path)
69
+ st.image(result_img, use_container_width=True)
70
+
71
+ with open(res_path, "rb") as file:
72
+ st.download_button(
73
+ label="⬇️ Download Processed Image",
74
+ data=file,
75
+ file_name=f"processed_{uploaded_file.name}.jpg",
76
+ mime="image/jpeg",
77
+ use_container_width=True,
78
+ type="primary"
79
+ )
80
+ else:
81
+ st.error("Failed to process image. Make sure the content has distinct elements that can be thresholded or provide a different cropping mode.")
82
+
83
+ # Clean up temp files
84
+ try:
85
+ os.remove(input_path)
86
+ if os.path.exists(output_path):
87
+ os.remove(output_path)
88
+ except:
89
+ pass
@@ -0,0 +1,225 @@
1
+ import argparse
2
+ import os
3
+ import cv2
4
+ import numpy as np
5
+ from PIL import Image
6
+ from pillow_heif import register_heif_opener
7
+
8
+ # Register HEIF opener for Pillow to handle HEIC files automatically
9
+ register_heif_opener()
10
+
11
+ def get_object_mask(image_cv):
12
+ """
13
+ Finds the largest distinct object against a flatbed background using dynamic Canny edge detection.
14
+ """
15
+ gray = cv2.cvtColor(image_cv, cv2.COLOR_BGR2GRAY)
16
+ blurred = cv2.GaussianBlur(gray, (7, 7), 0)
17
+
18
+ # Use Otsu's thresholding to dynamically find optimal Canny thresholds
19
+ val, _ = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
20
+ lower = int(max(0, 0.5 * val))
21
+ upper = int(min(255, 1.5 * val))
22
+ edges = cv2.Canny(blurred, lower, upper)
23
+
24
+ # Thicken edges and close shapes to form a solid blob
25
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
26
+ dilated = cv2.dilate(edges, kernel, iterations=3)
27
+ closed = cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, kernel, iterations=2)
28
+
29
+ contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
30
+ if not contours:
31
+ return None
32
+
33
+ # Sort contours by area descending to find the largest object
34
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)
35
+ return contours[0]
36
+
37
+ def get_deskew_angle(image_cv):
38
+ """
39
+ Computes pure geometric angle to orthogonally align the detected bounding box.
40
+ """
41
+ contour = get_object_mask(image_cv)
42
+ if contour is None or cv2.contourArea(contour) < 500:
43
+ return 0.0
44
+
45
+ rect = cv2.minAreaRect(contour)
46
+ box = cv2.boxPoints(rect)
47
+
48
+ # Calculate angles for all 4 edges
49
+ edges_angles = []
50
+ for i in range(4):
51
+ p1 = box[i]
52
+ p2 = box[(i+1)%4]
53
+ dx = p2[0] - p1[0]
54
+ dy = p2[1] - p1[1]
55
+
56
+ # Skip zero length edges just in case
57
+ if dx == 0 and dy == 0:
58
+ continue
59
+
60
+ angle_deg = np.degrees(np.arctan2(dy, dx))
61
+
62
+ # Normalize angle to [-45, 45) range (difference from horizontal/vertical)
63
+ norm_angle = angle_deg % 90
64
+ if norm_angle > 45:
65
+ norm_angle -= 90
66
+
67
+ edges_angles.append(norm_angle)
68
+
69
+ # Python median effectively averages the remaining sides, ignoring noise on 1 weird edge
70
+ skew_angle = np.median(edges_angles) if edges_angles else 0.0
71
+ return skew_angle
72
+
73
+ def rotate_image(image, angle):
74
+ """Rotate PIL image by given angle, seamlessly blending with scanner background."""
75
+ bg = image.getpixel((5, 5))
76
+ if isinstance(bg, int):
77
+ bg = (bg, bg, bg)
78
+ elif len(bg) > 3:
79
+ bg = bg[:3]
80
+
81
+ return image.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC, fillcolor=bg)
82
+
83
+ def crop_image(image_cv, crop_mode):
84
+ """
85
+ Finds the deskewed object's boundary and applies exact relative percentage padding.
86
+ """
87
+ contour = get_object_mask(image_cv)
88
+ if contour is None:
89
+ print("[WARNING] No object found to crop. Returning original.")
90
+ return image_cv
91
+
92
+ x, y, w, h = cv2.boundingRect(contour)
93
+ img_h, img_w = image_cv.shape[:2]
94
+
95
+ # Safety feature for extremely huge padding
96
+ if w > img_w * 0.98 and h > img_h * 0.98:
97
+ print("[WARNING] Object boundary merges with image boundaries (or is identically sized). Proceeding as-is.")
98
+ return image_cv
99
+
100
+ if crop_mode == "flush":
101
+ pad_pct = -0.015 # -1.5% (Cuts cleanly into the physical postcard)
102
+ else:
103
+ pad_pct = 0.015 # 1.5% (Leaves a tiny border)
104
+
105
+ pad_y = int(h * pad_pct)
106
+ pad_x = int(w * pad_pct)
107
+
108
+ new_x = max(0, x - pad_x)
109
+ new_y = max(0, y - pad_y)
110
+ new_w = min(img_w - new_x, w + 2 * pad_x)
111
+ new_h = min(img_h - new_y, h + 2 * pad_y)
112
+
113
+ # Final Crop
114
+ return image_cv[new_y:new_y+new_h, new_x:new_x+new_w]
115
+
116
+ def process_image(input_path, output_path=None, rotation_angle=None, auto_deskew=False, crop_mode='close'):
117
+ """
118
+ Main pipeline to run rotation, then cropping.
119
+ """
120
+ try:
121
+ if not os.path.exists(input_path):
122
+ raise FileNotFoundError(f"File {input_path} not found.")
123
+
124
+ pil_img = Image.open(input_path)
125
+
126
+ # Ensure we have RGB
127
+ if pil_img.mode != "RGB":
128
+ pil_img = pil_img.convert("RGB")
129
+
130
+ final_angle = 0.0
131
+
132
+ # Determine rotation
133
+ if auto_deskew:
134
+ cv_img_for_deskew = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
135
+ final_angle = get_deskew_angle(cv_img_for_deskew)
136
+ print(f"[INFO] Auto-deskew detected skew. Correcting by {final_angle:.2f} degrees")
137
+ elif rotation_angle is not None and rotation_angle != 0.0:
138
+ final_angle = rotation_angle
139
+ print(f"[INFO] Applying manual rotation: {final_angle} degrees")
140
+
141
+ # Rotate
142
+ if final_angle != 0.0:
143
+ pil_img = rotate_image(pil_img, final_angle)
144
+
145
+ # Convert to CV2 format for cropping
146
+ cv_img_to_crop = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
147
+ print(f"[INFO] Cropping image using '{crop_mode}' mode.")
148
+ cropped_cv_img = crop_image(cv_img_to_crop, crop_mode=crop_mode)
149
+
150
+ # Convert back to PIL for output serialization
151
+ final_pil_img = Image.fromarray(cv2.cvtColor(cropped_cv_img, cv2.COLOR_BGR2RGB))
152
+
153
+ # Generate target path
154
+ if output_path is None:
155
+ base, ext = os.path.splitext(input_path)
156
+ # Standardize exotic types to JPG by default (keeping TIFF uncompressed)
157
+ if ext.lower() in ['.heic', '.webp']:
158
+ ext = '.jpg'
159
+ output_path = f"{base}_rotated_cropped{ext}"
160
+
161
+ # Ensure directory exists
162
+ out_dir = os.path.dirname(os.path.abspath(output_path))
163
+ if out_dir:
164
+ os.makedirs(out_dir, exist_ok=True)
165
+
166
+ save_kwargs = {}
167
+ if output_path.lower().endswith(('.jpg', '.jpeg')):
168
+ save_kwargs['quality'] = 95
169
+
170
+ final_pil_img.save(output_path, **save_kwargs)
171
+ print(f"[SUCCESS] Saved processed image to: {output_path}")
172
+ return output_path
173
+
174
+ except Exception as e:
175
+ print(f"[ERROR] Process failed for {input_path}: {e}")
176
+ return None
177
+
178
+ def main():
179
+ parser = argparse.ArgumentParser(description="Image Rotator & Cropper")
180
+ parser.add_argument("--input", "-i", type=str, required=True, help="Path to input image file OR a directory of images.")
181
+ parser.add_argument("--output", "-o", type=str, default=None, help="Path to output file or directory. Appends _rotated_cropped if omitted.")
182
+ parser.add_argument("--rotate", "-r", type=float, default=0.0, help="Manual rotation angle in degrees (positive or negative)")
183
+ parser.add_argument("--auto-deskew", "-a", action="store_true", help="Automatically detect and correct layout skew")
184
+ parser.add_argument("--mode", "-m", choices=["close", "flush"], default="close", help="Crop mode: 'close' (leaves tiny outer border) or 'flush' (cuts slightly inward to eliminate scanner bed completely)")
185
+
186
+ args = parser.parse_args()
187
+
188
+ if os.path.isdir(args.input):
189
+ valid_exts = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.heic'}
190
+ if args.output and not os.path.isdir(args.output):
191
+ os.makedirs(args.output, exist_ok=True)
192
+
193
+ count = 0
194
+ for f in os.listdir(args.input):
195
+ ext = os.path.splitext(f)[1].lower()
196
+ if ext in valid_exts:
197
+ in_path = os.path.join(args.input, f)
198
+ out_path = None
199
+
200
+ if args.output:
201
+ base = os.path.splitext(f)[0]
202
+ out_ext = ext if ext in ['.tif', '.tiff'] else '.jpg'
203
+ out_path = os.path.join(args.output, f"{base}_rotated_cropped{out_ext}")
204
+
205
+ print(f"\n--- Processing {f} ---")
206
+ process_image(
207
+ input_path=in_path,
208
+ output_path=out_path,
209
+ rotation_angle=args.rotate,
210
+ auto_deskew=args.auto_deskew,
211
+ crop_mode=args.mode
212
+ )
213
+ count += 1
214
+ print(f"\n[DONE] Successfully batch-processed {count} images from '{args.input}'!")
215
+ else:
216
+ process_image(
217
+ input_path=args.input,
218
+ output_path=args.output,
219
+ rotation_angle=args.rotate,
220
+ auto_deskew=args.auto_deskew,
221
+ crop_mode=args.mode
222
+ )
223
+
224
+ if __name__ == "__main__":
225
+ main()
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "square-up-cli-tool"
7
+ version = "1.0.0"
8
+ description = "Image Rotator & Cropper Tool"
9
+ dependencies = [
10
+ "Pillow",
11
+ "pillow-heif",
12
+ "opencv-python",
13
+ "numpy",
14
+ "streamlit"
15
+ ]
16
+
17
+ [project.scripts]
18
+ sup = "image_processor:main"
19
+
20
+ [tool.setuptools]
21
+ py-modules = ["image_processor", "app"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: square-up-cli-tool
3
+ Version: 1.0.0
4
+ Summary: Image Rotator & Cropper Tool
5
+ Requires-Dist: Pillow
6
+ Requires-Dist: pillow-heif
7
+ Requires-Dist: opencv-python
8
+ Requires-Dist: numpy
9
+ Requires-Dist: streamlit
@@ -0,0 +1,10 @@
1
+ README.md
2
+ app.py
3
+ image_processor.py
4
+ pyproject.toml
5
+ square_up_cli_tool.egg-info/PKG-INFO
6
+ square_up_cli_tool.egg-info/SOURCES.txt
7
+ square_up_cli_tool.egg-info/dependency_links.txt
8
+ square_up_cli_tool.egg-info/entry_points.txt
9
+ square_up_cli_tool.egg-info/requires.txt
10
+ square_up_cli_tool.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sup = image_processor:main
@@ -0,0 +1,5 @@
1
+ Pillow
2
+ pillow-heif
3
+ opencv-python
4
+ numpy
5
+ streamlit
@@ -0,0 +1,2 @@
1
+ app
2
+ image_processor