SolarumAsteridion commited on
Commit
05991fd
·
0 Parent(s):

Initial commit with preprocessing and base64 forwarding

Browse files
Files changed (6) hide show
  1. .gitignore +12 -0
  2. Dockerfile +19 -0
  3. README.md +52 -0
  4. app.py +80 -0
  5. processor.py +353 -0
  6. requirements.txt +6 -0
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI / Uvicorn
2
+ __pycache__/
3
+ *.pyc
4
+
5
+ # Local Config / Testing
6
+ receiver.py
7
+ .env
8
+
9
+ # Image Preprocessing Debug Output
10
+ *_debug.jpg
11
+ *_binary_debug.jpg
12
+ *_cropped.jpg
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.9 for compatibility with standard HF Docker Spaces
2
+ FROM python:3.9
3
+
4
+ # Create a non-root user to avoid permission issues
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ # Copy and install requirements
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ # Copy the application code
16
+ COPY --chown=user . /app
17
+
18
+ # Run the application
19
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Backend
3
+ emoji: 🐳
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # SolarumAsteridion-Backend
12
+
13
+ A minimalist FastAPI application running in a Docker Space that performs **exact image preprocessing** (auto-crop + rotation) and handles **Base64 forwarding**.
14
+
15
+ ## Setup
16
+ 1. Go to your **Space Settings** -> **Variables and Secrets**.
17
+ 2. Add a new secret named `API_KEY`.
18
+ 3. Set its value to your preferred API key.
19
+
20
+ ## Usage
21
+
22
+ ### 1. Uploading an Image (`POST /upload`)
23
+ Send a standard image file using `curl`. The server will preprocess it and encode it to Base64 for the internal receiver.
24
+
25
+ ```bash
26
+ curl -X POST "https://solarumasteridion-backend.hf.space/upload" \
27
+ -H "X-API-Key: YOUR_API_KEY_HERE" \
28
+ -F "file=@/path/to/your/image.jpg"
29
+ ```
30
+
31
+ ### 2. Python Example
32
+ Using the `requests` library:
33
+
34
+ ```python
35
+ import requests
36
+
37
+ url = "https://solarumasteridion-backend.hf.space/upload"
38
+ headers = {"X-API-Key": "YOUR_API_KEY_HERE"}
39
+ files = {"file": open("sample.jpg", "rb")}
40
+
41
+ response = requests.post(url, headers=headers, files=files)
42
+ print(response.json())
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Workflow Details
48
+ 1. **Authentication**: Checks the `X-API-Key` header against the `API_KEY` environment variable.
49
+ 2. **Preprocessing**: Applies the logic from `processor.py` (90° CCW rotation + auto-crop).
50
+ 3. **Encoding**: Converts the processed binary image to a **Base64 string**.
51
+ 4. **Forwarding**: Sends a JSON payload `{"image_base64": "..."}` to the internal `/receiver`.
52
+ 5. **Acknowledgment**: Retries until `/receiver` returns `"YES RECEIVED"`.
app.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ import httpx
4
+ import base64
5
+ from fastapi import FastAPI, HTTPException, Header, UploadFile, File, Form, Depends
6
+ from fastapi.responses import JSONResponse, PlainTextResponse
7
+ from pydantic import BaseModel
8
+ import uvicorn
9
+
10
+ # Import the exact processing logic from processor.py
11
+ from processor import auto_crop_process
12
+
13
+ app = FastAPI()
14
+
15
+ # API Key from environment variable
16
+ # Ensure it is set in Space Settings
17
+ API_KEY = os.getenv("API_KEY")
18
+
19
+ class ImageBase64Payload(BaseModel):
20
+ """Payload for Base64 image data"""
21
+ image_base64: str
22
+
23
+ @app.post("/upload")
24
+ async def upload_image(file: UploadFile = File(...), x_api_key: str = Header(None)):
25
+ """
26
+ Receives image, processes it via processor.py,
27
+ encodes to Base64, and forwards to /receiver.
28
+ """
29
+ # Authentication check
30
+ if API_KEY and x_api_key != API_KEY:
31
+ raise HTTPException(status_code=401, detail="Unauthorized")
32
+
33
+ # Read binary content
34
+ image_bytes = await file.read()
35
+
36
+ # 1. PREPROCESS using the exact logic in processor.py
37
+ # (Includes 90-degree CCW rotation and auto-crop)
38
+ try:
39
+ processed_bytes = auto_crop_process(image_bytes)
40
+ except Exception as e:
41
+ # If preprocessing fails, we stop or log?
42
+ # Requirement: "USE THE EXACT LOGIC".
43
+ raise HTTPException(status_code=500, detail=f"Preprocessing failed: {str(e)}")
44
+
45
+ # 2. CONVERT TO BASE64
46
+ encoded_image = base64.b64encode(processed_bytes).decode('utf-8')
47
+
48
+ # 3. FORWARD TO /RECEIVER
49
+ # Minimalist retry loop as requested
50
+ while True:
51
+ try:
52
+ # Using internal httpx call to the app itself
53
+ async with httpx.AsyncClient(app=app, base_url="http://internal") as client:
54
+ payload = {"image_base64": encoded_image}
55
+ response = await client.post("/receiver", json=payload)
56
+
57
+ # Check if receiver said the magic words
58
+ if response.status_code == 200 and response.text.strip() == "YES RECEIVED":
59
+ return JSONResponse(content={
60
+ "status": "SUCCESS",
61
+ "message": "Image preprocessed, converted to Base64, and forwarded successfully."
62
+ })
63
+ except Exception:
64
+ # Silence and wait/retry as per "wait until it does say that"
65
+ pass
66
+
67
+ # Wait 1 second before retrying
68
+ await asyncio.sleep(1)
69
+
70
+ @app.post("/receiver", response_class=PlainTextResponse)
71
+ async def receiver(payload: ImageBase64Payload):
72
+ """
73
+ Minimalist receiver that acknowledges the Base64 image.
74
+ """
75
+ # The image is available in payload.image_base64
76
+ return "YES RECEIVED"
77
+
78
+ if __name__ == "__main__":
79
+ # For local testing
80
+ uvicorn.run(app, host="0.0.0.0", port=7860)
processor.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Notebook Auto-Crop Tool v5 — Tight-Crop Fix
4
+ """
5
+
6
+ import cv2
7
+ import numpy as np
8
+ import sys
9
+ import os
10
+ from pathlib import Path
11
+
12
+
13
+ def order_points(pts):
14
+ rect = np.zeros((4, 2), dtype="float32")
15
+ s = pts.sum(axis=1)
16
+ rect[0] = pts[np.argmin(s)]
17
+ rect[2] = pts[np.argmax(s)]
18
+ diff = np.diff(pts, axis=1)
19
+ rect[1] = pts[np.argmin(diff)]
20
+ rect[3] = pts[np.argmax(diff)]
21
+ return rect
22
+
23
+
24
+ def four_point_transform(image, pts):
25
+ rect = order_points(pts)
26
+ (tl, tr, br, bl) = rect
27
+ maxW = max(int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))), 1)
28
+ maxH = max(int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))), 1)
29
+ dst = np.array([[0, 0], [maxW-1, 0], [maxW-1, maxH-1], [0, maxH-1]], dtype="float32")
30
+ M = cv2.getPerspectiveTransform(rect, dst)
31
+ return cv2.warpPerspective(image, M, (maxW, maxH))
32
+
33
+
34
+ def is_valid_quad(quad, img_shape):
35
+ ordered = order_points(quad.astype(np.float32))
36
+ for i in range(4):
37
+ v1 = ordered[(i - 1) % 4] - ordered[i]
38
+ v2 = ordered[(i + 1) % 4] - ordered[i]
39
+ denom = np.linalg.norm(v1) * np.linalg.norm(v2)
40
+ if denom < 1e-6:
41
+ return False
42
+ angle = np.degrees(np.arccos(np.clip(np.dot(v1, v2) / denom, -1, 1)))
43
+ if angle < 30 or angle > 150:
44
+ return False
45
+ w1 = np.linalg.norm(ordered[1] - ordered[0])
46
+ w2 = np.linalg.norm(ordered[2] - ordered[3])
47
+ h1 = np.linalg.norm(ordered[3] - ordered[0])
48
+ h2 = np.linalg.norm(ordered[2] - ordered[1])
49
+ avg_w, avg_h = (w1 + w2) / 2, (h1 + h2) / 2
50
+ if min(avg_w, avg_h) < 1:
51
+ return False
52
+ return max(avg_w, avg_h) / min(avg_w, avg_h) <= 5.0
53
+
54
+
55
+ def expand_quad(quad, img_shape, margin_frac=0.025):
56
+ center = quad.mean(axis=0)
57
+ expanded = quad.copy().astype(np.float32)
58
+ for i in range(len(quad)):
59
+ vec = quad[i] - center
60
+ expanded[i] = quad[i] + vec * margin_frac
61
+ h, w = img_shape[:2]
62
+ expanded[:, 0] = np.clip(expanded[:, 0], 0, w - 1)
63
+ expanded[:, 1] = np.clip(expanded[:, 1], 0, h - 1)
64
+ return expanded
65
+
66
+
67
+ def get_binary_strategies(work_img):
68
+ gray = cv2.cvtColor(work_img, cv2.COLOR_BGR2GRAY)
69
+ h, w = gray.shape
70
+ k_close = np.ones((15, 15), np.uint8)
71
+ k_open = np.ones((5, 5), np.uint8)
72
+ strats = []
73
+
74
+ blurred = cv2.GaussianBlur(gray, (15, 15), 0)
75
+ _, otsu = cv2.threshold(blurred, 0, 255,
76
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
77
+ otsu = cv2.morphologyEx(otsu, cv2.MORPH_CLOSE, k_close, iterations=3)
78
+ otsu = cv2.morphologyEx(otsu, cv2.MORPH_OPEN, k_open, iterations=1)
79
+ strats.append(("Otsu", otsu))
80
+
81
+ hsv = cv2.cvtColor(work_img, cv2.COLOR_BGR2HSV)
82
+ v_ch = cv2.GaussianBlur(hsv[:, :, 2], (15, 15), 0)
83
+ _, v_t = cv2.threshold(v_ch, 0, 255,
84
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
85
+ v_t = cv2.morphologyEx(v_t, cv2.MORPH_CLOSE, k_close, iterations=3)
86
+ v_t = cv2.morphologyEx(v_t, cv2.MORPH_OPEN, k_open, iterations=1)
87
+ strats.append(("HSV-V", v_t))
88
+
89
+ bilateral = cv2.bilateralFilter(gray, 9, 75, 75)
90
+ bilateral = cv2.GaussianBlur(bilateral, (11, 11), 0)
91
+ _, bil_t = cv2.threshold(bilateral, 0, 255,
92
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
93
+ bil_t = cv2.morphologyEx(bil_t, cv2.MORPH_CLOSE, k_close, iterations=3)
94
+ bil_t = cv2.morphologyEx(bil_t, cv2.MORPH_OPEN, k_open, iterations=1)
95
+ strats.append(("Bilateral", bil_t))
96
+
97
+ b2 = cv2.GaussianBlur(gray, (9, 9), 0)
98
+ edges = cv2.Canny(b2, 25, 80)
99
+ edges = cv2.dilate(edges, np.ones((7, 7), np.uint8), iterations=3)
100
+ edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE,
101
+ np.ones((13, 13), np.uint8), iterations=2)
102
+ flood = edges.copy()
103
+ fmask = np.zeros((h + 2, w + 2), np.uint8)
104
+ step = max(1, min(w, h) // 20)
105
+ for x in range(0, w, step):
106
+ if flood[0, x] == 0:
107
+ cv2.floodFill(flood, fmask, (x, 0), 128)
108
+ if flood[h - 1, x] == 0:
109
+ cv2.floodFill(flood, fmask, (x, h - 1), 128)
110
+ for y in range(0, h, step):
111
+ if flood[y, 0] == 0:
112
+ cv2.floodFill(flood, fmask, (0, y), 128)
113
+ if flood[y, w - 1] == 0:
114
+ cv2.floodFill(flood, fmask, (w - 1, y), 128)
115
+ doc = np.where(flood == 128, 0, 255).astype(np.uint8)
116
+ doc = cv2.morphologyEx(doc, cv2.MORPH_CLOSE, k_close, iterations=2)
117
+ strats.append(("FloodFill", doc))
118
+
119
+ return strats
120
+
121
+
122
+ def find_notebook_contour(work_img):
123
+ strategies = get_binary_strategies(work_img)
124
+ img_area = work_img.shape[0] * work_img.shape[1]
125
+ best_quad = None
126
+ best_area = 0
127
+ all_quads = []
128
+ is_fallback = False
129
+ max_cnt = None
130
+ max_cnt_area = 0
131
+
132
+ for name, binary in strategies:
133
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
134
+ cv2.CHAIN_APPROX_SIMPLE)
135
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
136
+
137
+ for cnt in contours:
138
+ area = cv2.contourArea(cnt)
139
+ if area > max_cnt_area:
140
+ max_cnt_area = area
141
+ max_cnt = cnt
142
+ if area < 0.15 * img_area:
143
+ continue
144
+
145
+ peri = cv2.arcLength(cnt, True)
146
+
147
+ for eps in np.linspace(0.01, 0.1, 20):
148
+ approx = cv2.approxPolyDP(cnt, eps * peri, True)
149
+ if len(approx) == 4:
150
+ q = approx.reshape(4, 2).astype(np.float32)
151
+ if is_valid_quad(q, work_img.shape):
152
+ all_quads.append(q)
153
+ if area > best_area:
154
+ best_area = area
155
+ best_quad = q
156
+ break
157
+ elif len(approx) < 4:
158
+ break
159
+
160
+ hull = cv2.convexHull(cnt)
161
+ peri_h = cv2.arcLength(hull, True)
162
+ for eps in np.linspace(0.01, 0.1, 20):
163
+ approx = cv2.approxPolyDP(hull, eps * peri_h, True)
164
+ if len(approx) == 4:
165
+ q = approx.reshape(4, 2).astype(np.float32)
166
+ if is_valid_quad(q, work_img.shape):
167
+ all_quads.append(q)
168
+ if area > best_area:
169
+ best_area = area
170
+ best_quad = q
171
+ break
172
+ elif len(approx) < 4:
173
+ break
174
+
175
+ if area > 0.20 * img_area:
176
+ box = cv2.boxPoints(cv2.minAreaRect(cnt)).astype(np.float32)
177
+ if is_valid_quad(box, work_img.shape):
178
+ all_quads.append(box)
179
+ if area * 0.90 > best_area:
180
+ best_area = area * 0.90
181
+ best_quad = box
182
+
183
+ if best_quad is None and max_cnt is not None \
184
+ and max_cnt_area > 0.10 * img_area:
185
+ box = cv2.boxPoints(cv2.minAreaRect(max_cnt)).astype(np.float32)
186
+ best_quad = box
187
+ all_quads.append(box)
188
+ is_fallback = True
189
+
190
+ return best_quad, all_quads, is_fallback
191
+
192
+
193
+ def draw_debug_image(work_img, corners, all_quads, is_fallback):
194
+ debug = work_img.copy()
195
+ h, w = debug.shape[:2]
196
+ for q in all_quads:
197
+ cv2.polylines(debug, [q.astype(np.int32)], True, (0, 255, 255), 1)
198
+ if corners is not None:
199
+ color = (0, 165, 255) if is_fallback else (0, 255, 0)
200
+ cv2.polylines(debug, [corners.astype(np.int32)], True, color, 3)
201
+ ordered = order_points(corners)
202
+ for i, (pt, lbl, c) in enumerate(zip(
203
+ ordered, ["TL","TR","BR","BL"],
204
+ [(255,0,0),(0,0,255),(255,0,255),(0,255,0)])):
205
+ cx, cy = int(pt[0]), int(pt[1])
206
+ cv2.circle(debug, (cx, cy), 8, c, -1)
207
+ cv2.putText(debug, lbl, (cx+10, cy+5),
208
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, c, 2)
209
+ cv2.rectangle(debug, (0, 0), (w, 40), (0, 0, 0), -1)
210
+ if corners is not None:
211
+ s, c = ("FALLBACK", (0,165,255)) if is_fallback \
212
+ else ("QUAD DETECTED (green outline)", (0,255,0))
213
+ else:
214
+ s, c = "NOTHING DETECTED", (0, 0, 255)
215
+ cv2.putText(debug, s, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, c, 2)
216
+ return debug
217
+
218
+
219
+ def save_binary_debug(work_img, debug_path):
220
+ strategies = get_binary_strategies(work_img)
221
+ panels = []
222
+ tw = 300
223
+ for name, pan in strategies:
224
+ r = tw / pan.shape[1]
225
+ res = cv2.resize(pan, (tw, int(pan.shape[0] * r)))
226
+ cp = cv2.cvtColor(res, cv2.COLOR_GRAY2BGR)
227
+ cv2.putText(cp, name, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7,
228
+ (0, 255, 0), 2)
229
+ panels.append(cp)
230
+ mh = max(p.shape[0] for p in panels)
231
+ padded = []
232
+ for p in panels:
233
+ if p.shape[0] < mh:
234
+ p = np.vstack([p, np.zeros((mh - p.shape[0], p.shape[1], 3),
235
+ np.uint8)])
236
+ padded.append(p)
237
+ cv2.imwrite(debug_path.replace("_debug.", "_binary_debug."),
238
+ np.hstack(padded), [cv2.IMWRITE_JPEG_QUALITY, 85])
239
+
240
+
241
+ def process_image(input_path: str):
242
+ script_dir = os.path.dirname(os.path.abspath(__file__))
243
+ image = cv2.imread(input_path)
244
+ if image is None:
245
+ print(f"[ERROR] Cannot read: {input_path}")
246
+ return
247
+
248
+ rotated = cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
249
+
250
+ orig_h, orig_w = rotated.shape[:2]
251
+
252
+ max_dim = 800.0
253
+ ratio = max(orig_h, orig_w) / max_dim
254
+ work_w = int(orig_w / ratio)
255
+ work_h = int(orig_h / ratio)
256
+ work_img = cv2.resize(rotated, (work_w, work_h))
257
+
258
+ corners, all_quads, is_fallback = find_notebook_contour(work_img)
259
+ stem = Path(input_path).stem
260
+ debug_path = os.path.join(script_dir, f"{stem}_debug.jpg")
261
+
262
+ if corners is not None:
263
+ corners_exp = expand_quad(corners, work_img.shape, margin_frac=0.025)
264
+
265
+ scale_x = orig_w / work_w
266
+ scale_y = orig_h / work_h
267
+ corners_orig = corners_exp.copy()
268
+ corners_orig[:, 0] *= scale_x
269
+ corners_orig[:, 1] *= scale_y
270
+ corners_orig[:, 0] = np.clip(corners_orig[:, 0], 0, orig_w - 1)
271
+ corners_orig[:, 1] = np.clip(corners_orig[:, 1], 0, orig_h - 1)
272
+
273
+ cropped = four_point_transform(rotated, corners_orig)
274
+ print("[INFO] Success! Applied crop.")
275
+ else:
276
+ print("[WARN] Total failure. Returning full rotated image.")
277
+ cropped = rotated
278
+
279
+ debug_img = draw_debug_image(work_img, corners, all_quads, is_fallback)
280
+ save_binary_debug(work_img, debug_path)
281
+ cv2.imwrite(debug_path, debug_img, [cv2.IMWRITE_JPEG_QUALITY, 90])
282
+
283
+ out_path = os.path.join(script_dir, f"{stem}_cropped.jpg")
284
+ cv2.imwrite(out_path, cropped, [cv2.IMWRITE_JPEG_QUALITY, 95])
285
+ print(f"[INFO] Saved cropped: {out_path}")
286
+
287
+
288
+ if __name__ == "__main__":
289
+ if len(sys.argv) < 2:
290
+ script_dir = os.path.dirname(os.path.abspath(__file__))
291
+ exts = (".jpg", ".jpeg", ".png", ".bmp", ".webp")
292
+ skip = ("_cropped", "_debug", "_binary_debug")
293
+ files = [f for f in os.listdir(script_dir)
294
+ if f.lower().endswith(exts)
295
+ and not any(s in f for s in skip)]
296
+ if not files:
297
+ print("Place images next to script or provide paths.")
298
+ sys.exit(1)
299
+ for fn in sorted(files):
300
+ print(f"\nProcessing: {fn}")
301
+ process_image(os.path.join(script_dir, fn))
302
+ else:
303
+ for p in sys.argv[1:]:
304
+ print(f"\nProcessing: {p}")
305
+ process_image(p)
306
+
307
+
308
+ def auto_crop_process(image_bytes: bytes) -> bytes:
309
+ """
310
+ Exact logic from processor.py, but for in-memory bytes.
311
+ 1. Decode JPEG/PNG bytes.
312
+ 2. Rotate 90 deg CCW.
313
+ 3. Detect and crop.
314
+ 4. Return JPEG bytes.
315
+ """
316
+ nparr = np.frombuffer(image_bytes, np.uint8)
317
+ image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
318
+ if image is None:
319
+ return image_bytes
320
+
321
+ # 1. Rotate
322
+ rotated = cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
323
+ orig_h, orig_w = rotated.shape[:2]
324
+
325
+ # 2. Resize for detection
326
+ max_dim = 800.0
327
+ ratio = max(orig_h, orig_w) / max_dim
328
+ work_w = int(orig_w / ratio)
329
+ work_h = int(orig_h / ratio)
330
+ work_img = cv2.resize(rotated, (work_w, work_h))
331
+
332
+ # 3. Find contour
333
+ corners, all_quads, is_fallback = find_notebook_contour(work_img)
334
+
335
+ # 4. Transform
336
+ if corners is not None:
337
+ corners_exp = expand_quad(corners, work_img.shape, margin_frac=0.025)
338
+
339
+ scale_x = orig_w / work_w
340
+ scale_y = orig_h / work_h
341
+ corners_orig = corners_exp.copy()
342
+ corners_orig[:, 0] *= scale_x
343
+ corners_orig[:, 1] *= scale_y
344
+ corners_orig[:, 0] = np.clip(corners_orig[:, 0], 0, orig_w - 1)
345
+ corners_orig[:, 1] = np.clip(corners_orig[:, 1], 0, orig_h - 1)
346
+
347
+ cropped = four_point_transform(rotated, corners_orig)
348
+ else:
349
+ cropped = rotated
350
+
351
+ # 5. Encode back to bytes
352
+ _, result_bytes = cv2.imencode('.jpg', cropped, [cv2.IMWRITE_JPEG_QUALITY, 95])
353
+ return result_bytes.tobytes()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-multipart
4
+ httpx
5
+ opencv-python-headless
6
+ numpy