Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
334
backend/services/pdf_rotation.py
Normal file
334
backend/services/pdf_rotation.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""
|
||||
PDF Rotation Service
|
||||
|
||||
Handles rotation of PDF pages based on Azure OCR angle data and transforms
|
||||
OCR coordinates to match the corrected orientation.
|
||||
|
||||
This should be called as the FIRST post-processing step after Azure returns,
|
||||
before any other processing extracts data from raw_json.
|
||||
"""
|
||||
import fitz # PyMuPDF
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_angle(angle: Optional[float]) -> int:
|
||||
"""
|
||||
Round angle to nearest 90 degrees.
|
||||
|
||||
Azure returns angle in degrees (can be negative for CCW rotation).
|
||||
We round to nearest 90° for correction.
|
||||
"""
|
||||
if angle is None or angle == 0:
|
||||
return 0
|
||||
# Round to nearest 90 degrees, preserving sign
|
||||
rounded = round(angle / 90) * 90
|
||||
# Normalize to -180 to 180 range for cleaner math
|
||||
while rounded > 180:
|
||||
rounded -= 360
|
||||
while rounded < -180:
|
||||
rounded += 360
|
||||
return int(rounded)
|
||||
|
||||
|
||||
def transform_polygon(polygon: list, rotation: int, page_width: float, page_height: float) -> list:
|
||||
"""
|
||||
Transform polygon coordinates based on rotation.
|
||||
|
||||
The rotation is the detected angle (can be negative for CCW).
|
||||
We compute the correction and transform coordinates to match the corrected PDF.
|
||||
|
||||
Args:
|
||||
polygon: List of [x, y] coordinate pairs in inches
|
||||
rotation: Detected angle in degrees (can be negative, e.g., -90 for CCW)
|
||||
page_width: Original page width in inches (before rotation)
|
||||
page_height: Original page height in inches (before rotation)
|
||||
|
||||
Returns:
|
||||
Transformed polygon with coordinates adjusted for rotation
|
||||
"""
|
||||
# Calculate correction angle (opposite of detected)
|
||||
# -90° detected -> +90° correction
|
||||
correction = -rotation
|
||||
# Normalize to positive 0-360 range
|
||||
correction = int(((correction % 360) + 360) % 360)
|
||||
|
||||
transformed = []
|
||||
for point in polygon:
|
||||
x, y = point[0], point[1]
|
||||
|
||||
if correction == 90:
|
||||
# Applied 90° CW rotation to PDF
|
||||
# For +90° CW: (x, y) -> (height - y, x)
|
||||
new_x = page_height - y
|
||||
new_y = x
|
||||
elif correction == 180:
|
||||
# Applied 180° rotation to PDF
|
||||
# (x, y) -> (width - x, height - y)
|
||||
new_x = page_width - x
|
||||
new_y = page_height - y
|
||||
elif correction == 270:
|
||||
# Applied 270° CW (90° CCW) rotation to PDF
|
||||
# For +270° CW: (x, y) -> (y, width - x)
|
||||
new_x = y
|
||||
new_y = page_width - x
|
||||
else:
|
||||
new_x, new_y = x, y
|
||||
|
||||
transformed.append([new_x, new_y])
|
||||
|
||||
return transformed
|
||||
|
||||
|
||||
def transform_bounding_regions(regions: list, rotation: int, page_width: float, page_height: float) -> list:
|
||||
"""Transform bounding regions for a rotated page."""
|
||||
for region in regions:
|
||||
if 'polygon' in region:
|
||||
region['polygon'] = transform_polygon(
|
||||
region['polygon'],
|
||||
rotation,
|
||||
page_width,
|
||||
page_height
|
||||
)
|
||||
return regions
|
||||
|
||||
|
||||
def transform_fields_coordinates(fields: dict, rotations: dict[int, int], pages: list):
|
||||
"""
|
||||
Recursively transform bounding regions in fields.
|
||||
|
||||
Args:
|
||||
fields: Dictionary of field name -> field data
|
||||
rotations: Dictionary of page_number -> rotation angle
|
||||
pages: List of page info dictionaries (with original dimensions)
|
||||
"""
|
||||
for field_name, field_data in fields.items():
|
||||
if not isinstance(field_data, dict):
|
||||
continue
|
||||
|
||||
# Get page info for this field
|
||||
page_num = 1
|
||||
if 'bounding_regions' in field_data and field_data['bounding_regions']:
|
||||
page_num = field_data['bounding_regions'][0].get('page_number', 1)
|
||||
|
||||
rotation = rotations.get(page_num, 0)
|
||||
if rotation != 0:
|
||||
page_info = next((p for p in pages if p.get('page_number') == page_num), None)
|
||||
if page_info and 'bounding_regions' in field_data:
|
||||
transform_bounding_regions(
|
||||
field_data['bounding_regions'],
|
||||
rotation,
|
||||
page_info.get('width', 8.5),
|
||||
page_info.get('height', 11)
|
||||
)
|
||||
|
||||
# Recurse into nested value
|
||||
if 'value' in field_data:
|
||||
val = field_data['value']
|
||||
if isinstance(val, dict):
|
||||
transform_fields_coordinates(val, rotations, pages)
|
||||
elif isinstance(val, list):
|
||||
for item in val:
|
||||
if isinstance(item, dict):
|
||||
# Handle bounding_regions at item level
|
||||
item_page = 1
|
||||
if 'bounding_regions' in item and item['bounding_regions']:
|
||||
item_page = item['bounding_regions'][0].get('page_number', 1)
|
||||
|
||||
item_rotation = rotations.get(item_page, 0)
|
||||
if item_rotation != 0 and 'bounding_regions' in item:
|
||||
item_page_info = next((p for p in pages if p.get('page_number') == item_page), None)
|
||||
if item_page_info:
|
||||
transform_bounding_regions(
|
||||
item['bounding_regions'],
|
||||
item_rotation,
|
||||
item_page_info.get('width', 8.5),
|
||||
item_page_info.get('height', 11)
|
||||
)
|
||||
|
||||
# Recurse into item value
|
||||
if 'value' in item and isinstance(item['value'], dict):
|
||||
transform_fields_coordinates(item['value'], rotations, pages)
|
||||
|
||||
|
||||
def transform_ocr_coordinates(ocr_json: dict, rotations: dict[int, int]) -> dict:
|
||||
"""
|
||||
Transform all coordinates in OCR JSON based on page rotations.
|
||||
|
||||
Args:
|
||||
ocr_json: The raw OCR JSON from Azure
|
||||
rotations: Dictionary mapping page_number to rotation angle
|
||||
|
||||
Returns:
|
||||
Updated OCR JSON with transformed coordinates
|
||||
"""
|
||||
# Store original dimensions before updating
|
||||
original_pages = []
|
||||
for page_info in ocr_json.get('pages', []):
|
||||
original_pages.append({
|
||||
'page_number': page_info.get('page_number', 1),
|
||||
'width': page_info.get('width'),
|
||||
'height': page_info.get('height')
|
||||
})
|
||||
|
||||
# Update page dimensions (swap width/height for 90/270 corrections)
|
||||
for page_info in ocr_json.get('pages', []):
|
||||
page_num = page_info.get('page_number', 1)
|
||||
rotation = rotations.get(page_num, 0)
|
||||
# Calculate correction angle (opposite of detected)
|
||||
correction = int(((-rotation % 360) + 360) % 360)
|
||||
if correction in (90, 270):
|
||||
# Swap width and height
|
||||
old_width = page_info.get('width')
|
||||
old_height = page_info.get('height')
|
||||
page_info['width'] = old_height
|
||||
page_info['height'] = old_width
|
||||
logger.debug(f"Page {page_num}: swapped dimensions {old_width}x{old_height} -> {old_height}x{old_width}")
|
||||
# Reset angle to 0 since we've corrected it
|
||||
page_info['angle'] = 0
|
||||
|
||||
# Transform bounding regions in documents using ORIGINAL dimensions
|
||||
for doc in ocr_json.get('documents', []):
|
||||
transform_fields_coordinates(doc.get('fields', {}), rotations, original_pages)
|
||||
|
||||
return ocr_json
|
||||
|
||||
|
||||
def rotate_pdf_pages(pdf_path: str, ocr_raw_json: dict) -> tuple[bool, dict]:
|
||||
"""
|
||||
Rotate PDF pages based on OCR angle data and transform coordinates.
|
||||
|
||||
This should be called immediately after Azure OCR returns, before any
|
||||
other post-processing extracts data from raw_json.
|
||||
|
||||
Args:
|
||||
pdf_path: Path to the PDF file
|
||||
ocr_raw_json: The serialized OCR result containing page angles
|
||||
|
||||
Returns:
|
||||
Tuple of (modified: bool, updated_ocr_json: dict)
|
||||
- modified: True if any pages were rotated
|
||||
- updated_ocr_json: OCR JSON with transformed coordinates
|
||||
"""
|
||||
pages_info = ocr_raw_json.get('pages', [])
|
||||
rotations_needed = {}
|
||||
|
||||
# Check which pages need rotation
|
||||
for page_info in pages_info:
|
||||
page_num = page_info.get('page_number', 1)
|
||||
raw_angle = page_info.get('angle', 0)
|
||||
angle = normalize_angle(raw_angle)
|
||||
logger.info(f"Page {page_num}: raw angle={raw_angle}, normalized={angle}")
|
||||
if angle != 0:
|
||||
rotations_needed[page_num] = angle
|
||||
|
||||
if not rotations_needed:
|
||||
logger.debug("No page rotations needed")
|
||||
return False, ocr_raw_json
|
||||
|
||||
logger.info(f"Rotating pages: {rotations_needed}")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
|
||||
# Open source PDF for reading
|
||||
src_doc = fitz.open(pdf_path)
|
||||
|
||||
# Create a new document for output
|
||||
out_doc = fitz.open()
|
||||
|
||||
# Process each page in order
|
||||
for page_idx in range(len(src_doc)):
|
||||
page_num = page_idx + 1
|
||||
src_page = src_doc[page_idx]
|
||||
rect = src_page.rect
|
||||
|
||||
rotation = rotations_needed.get(page_num, 0)
|
||||
if rotation == 0:
|
||||
# No rotation needed - just copy the page as-is
|
||||
out_doc.insert_pdf(src_doc, from_page=page_idx, to_page=page_idx)
|
||||
logger.debug(f"Page {page_num}: no rotation needed, copied as-is")
|
||||
else:
|
||||
# Calculate correction: -90° content needs +90° rotation to appear upright
|
||||
correction = -rotation
|
||||
# Normalize to 0-360 for PyMuPDF
|
||||
correction = int(((correction % 360) + 360) % 360)
|
||||
|
||||
logger.info(f"Page {page_num}: detected angle={rotation}°, applying correction={correction}°")
|
||||
logger.info(f"Page {page_num}: original size {rect.width}x{rect.height}")
|
||||
|
||||
# Render page to pixmap at high resolution
|
||||
# Use 2x scale for better quality
|
||||
mat = fitz.Matrix(2, 2)
|
||||
pix = src_page.get_pixmap(matrix=mat)
|
||||
|
||||
# Rotate the pixmap
|
||||
# PyMuPDF Pixmap doesn't have direct rotate, so we use PIL
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Convert pixmap to PIL Image
|
||||
img_data = pix.tobytes("png")
|
||||
img = Image.open(io.BytesIO(img_data))
|
||||
|
||||
# Rotate image (PIL rotates counter-clockwise, we need clockwise)
|
||||
# correction=90 means rotate 90° CW, which is -90° in PIL (or 270° CCW)
|
||||
pil_rotation = (360 - correction) % 360
|
||||
if pil_rotation != 0:
|
||||
img = img.rotate(pil_rotation, expand=True)
|
||||
|
||||
logger.info(f"Page {page_num}: rotated image {pil_rotation}° CCW (={correction}° CW)")
|
||||
|
||||
# For 90° or 270° rotation, swap width and height
|
||||
if correction in (90, 270):
|
||||
new_width, new_height = rect.height, rect.width
|
||||
else:
|
||||
new_width, new_height = rect.width, rect.height
|
||||
|
||||
# Create new page with correct dimensions
|
||||
new_page = out_doc.new_page(width=new_width, height=new_height)
|
||||
|
||||
# Convert PIL image back to bytes
|
||||
img_buffer = io.BytesIO()
|
||||
img.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
|
||||
# Insert the rotated image into the new page
|
||||
new_page.insert_image(
|
||||
fitz.Rect(0, 0, new_width, new_height),
|
||||
stream=img_buffer.read()
|
||||
)
|
||||
|
||||
logger.info(f"Page {page_num}: re-rendered with {correction}° rotation, new size {new_width}x{new_height}")
|
||||
|
||||
src_doc.close()
|
||||
|
||||
# Save to temp file first, then replace original
|
||||
temp_fd, temp_path = tempfile.mkstemp(suffix='.pdf')
|
||||
try:
|
||||
os.close(temp_fd) # Close the file descriptor, we just need the path
|
||||
out_doc.save(temp_path, garbage=4, deflate=True)
|
||||
out_doc.close()
|
||||
# Replace original with rotated version
|
||||
shutil.move(temp_path, pdf_path)
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
raise
|
||||
|
||||
logger.info(f"PDF saved with rotated pages: {pdf_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rotating PDF: {e}")
|
||||
# Return original JSON if rotation fails
|
||||
return False, ocr_raw_json
|
||||
|
||||
# Transform OCR coordinates to match new orientation
|
||||
updated_json = transform_ocr_coordinates(ocr_raw_json, rotations_needed)
|
||||
|
||||
return True, updated_json
|
||||
Loading…
Add table
Add a link
Reference in a new issue