From 64d177e79172066c5a2f1fabc92a62b035e6716e Mon Sep 17 00:00:00 2001 From: Daniel Bedeleanu Date: Wed, 22 Apr 2026 08:50:25 +0300 Subject: [PATCH] fix: complete image pipeline - rotation, URL, preview - Add rotation_degrees parameter to ImageProcessor.process_photo() - Pass rotation through _auto_save_photo_from_extraction() to processor - Allow no-crop fallback when crop_bounds is None - Add buildPhotoUrl() helper to resolve backend URLs correctly - Update frontend components to use backend URL for image sources - Replace Use/Skip Photo buttons with checkbox in AI extraction UI - Add images/ to .gitignore to prevent accidental commits Addresses: rotation never applied, image 404s (relative to Next.js not backend), preview blank in edit form --- .claude/settings.local.json | 4 +- .gitignore | 1 + backend/routers/items.py | 82 ++++++++++++------------- backend/services/image_processing.py | 8 ++- config/ai_prompt.md | 2 +- frontend/app/inventory/page.tsx | 7 ++- frontend/components/AIOnboarding.tsx | 26 +++----- frontend/components/InventoryTable.tsx | 8 ++- frontend/components/ItemDetailModal.tsx | 6 +- frontend/lib/api.ts | 5 ++ 10 files changed, 79 insertions(+), 70 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0bd4583c..91e8e3ab 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -95,7 +95,9 @@ "Bash(npx vitest *)", "Bash(sed -i 's/jest\\\\.fn\\(\\)/vi.fn\\(\\)/g' tests/hooks/useAIExtraction.test.ts)", "Bash(sed -i 's/as jest\\\\.Mock/as any/g' tests/hooks/useAIExtraction.test.ts)", - "Bash(grep -E \"\\\\.tsx?$\")" + "Bash(grep -E \"\\\\.tsx?$\")", + "Bash(sqlite3 *)", + "Skill(gsd-resume-work)" ] } } diff --git a/.gitignore b/.gitignore index e6d9e4f4..50641428 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ backend/.env.test # Test images uploaded during development _images.tests/ _images/ +images/ # ── Local AI Tooling & Persistent Paths ────────────────────── .tool_paths diff --git a/backend/routers/items.py b/backend/routers/items.py index 3414c362..5a1a29a8 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -503,54 +503,48 @@ def _auto_save_photo_from_extraction( "reason": "Empty image bytes" } - # Graceful skip if crop_bounds is None - if crop_bounds is None: - log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping") - return { - "status": "skipped", - "reason": "crop_bounds is None" - } + # Validate crop_bounds (if provided) + crop_bounds_validated = None + if crop_bounds is not None: + if not isinstance(crop_bounds, dict): + log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping") + return { + "status": "skipped", + "reason": "crop_bounds must be a dict" + } - # Validate crop_bounds - if not isinstance(crop_bounds, dict): - log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping") - return { - "status": "skipped", - "reason": "crop_bounds must be a dict" - } + # Check for required keys + required_keys = {'x', 'y', 'width', 'height'} + if not required_keys.issubset(crop_bounds.keys()): + missing = required_keys - set(crop_bounds.keys()) + log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}") + return { + "status": "skipped", + "reason": f"Missing crop_bounds keys: {missing}" + } - # Check for required keys - required_keys = {'x', 'y', 'width', 'height'} - if not required_keys.issubset(crop_bounds.keys()): - missing = required_keys - set(crop_bounds.keys()) - log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}") - return { - "status": "skipped", - "reason": f"Missing crop_bounds keys: {missing}" - } + # Validate all values are integers >= 0 + try: + crop_bounds_validated = {} + for key in required_keys: + val = crop_bounds[key] + # Convert to int if it's numeric + if isinstance(val, (int, float)): + int_val = int(val) + else: + raise ValueError(f"Non-numeric value for {key}: {val}") - # Validate all values are integers >= 0 - try: - crop_bounds_validated = {} - for key in required_keys: - val = crop_bounds[key] - # Convert to int if it's numeric - if isinstance(val, (int, float)): - int_val = int(val) - else: - raise ValueError(f"Non-numeric value for {key}: {val}") + if int_val < 0: + raise ValueError(f"Negative value for {key}: {int_val}") - if int_val < 0: - raise ValueError(f"Negative value for {key}: {int_val}") + crop_bounds_validated[key] = int_val - crop_bounds_validated[key] = int_val - - except (ValueError, TypeError) as e: - log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}") - return { - "status": "skipped", - "reason": f"Invalid crop_bounds: {str(e)}" - } + except (ValueError, TypeError) as e: + log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}") + return { + "status": "skipped", + "reason": f"Invalid crop_bounds: {str(e)}" + } # Validate rotation_degrees (optional but if provided, must be valid) if rotation_degrees is not None: @@ -573,7 +567,7 @@ def _auto_save_photo_from_extraction( try: # Process image (crop + rotation + compression + thumbnail) processor = ImageProcessor() - process_result = processor.process_photo(image_bytes, crop_bounds_validated) + process_result = processor.process_photo(image_bytes, crop_bounds_validated, rotation_degrees=rotation_degrees or 0) if process_result.get('status') != 'success': error_msg = process_result.get('error', 'Unknown error') diff --git a/backend/services/image_processing.py b/backend/services/image_processing.py index 54d95cf3..e9282544 100644 --- a/backend/services/image_processing.py +++ b/backend/services/image_processing.py @@ -43,7 +43,7 @@ class ImageProcessor: self.logger = logger def process_photo( - self, file_bytes: bytes, crop_bounds: Optional[Dict] = None + self, file_bytes: bytes, crop_bounds: Optional[Dict] = None, rotation_degrees: float = 0 ) -> Dict: """ Process a photo with EXIF rotation, smart cropping, and compression. @@ -51,6 +51,7 @@ class ImageProcessor: Args: file_bytes: Raw image file bytes crop_bounds: Optional manual crop bounds {x, y, width, height} + rotation_degrees: Optional manual rotation in degrees (applied after crop) Returns: { @@ -136,6 +137,11 @@ class ImageProcessor: ) crop_method = 'pillow' + # Apply manual rotation if provided + if abs(rotation_degrees) > 0.5: + cropped_image = cropped_image.rotate(-rotation_degrees, expand=True) + self.logger.info(f"Applied manual rotation: {rotation_degrees}°") + # Resize and compress compressed_bytes = self._resize_and_compress(cropped_image) diff --git a/config/ai_prompt.md b/config/ai_prompt.md index a115cfd1..af936416 100644 --- a/config/ai_prompt.md +++ b/config/ai_prompt.md @@ -18,10 +18,10 @@ - **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m" - **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB" + - ``: Part number ONLY if visible. If the item has an identifiable Part Number, search web for what item this is and extract needed informations or compare with what was already identified, and correct all fields. **Omit serial numbers.** - ``: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc. - ``: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom) - ``: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A. - - ``: Part number ONLY if visible. **Omit serial numbers.** **Item Examples (WITH HUMAN-READABLE SIZES):** - `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB) diff --git a/frontend/app/inventory/page.tsx b/frontend/app/inventory/page.tsx index 346bec76..e162bf8a 100644 --- a/frontend/app/inventory/page.tsx +++ b/frontend/app/inventory/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { db, Item } from '@/lib/db'; -import { inventoryApi } from '@/lib/api'; +import { inventoryApi, getBackendUrl } from '@/lib/api'; import PageShell from '@/components/PageShell'; import Scanner from '@/components/Scanner'; import StatCard from '@/components/StatCard'; @@ -41,6 +41,7 @@ export default function InventoryPage() { const [inventory, setInventory] = useState([]); const [stats, setStats] = useState(null); const [currentUser, setCurrentUser] = useState(null); + const [backendUrl, setBackendUrl] = useState(''); const { searchQuery, @@ -85,7 +86,8 @@ export default function InventoryPage() { if (savedUser) { setCurrentUser(JSON.parse(savedUser)); } - + + getBackendUrl().then(setBackendUrl); loadData(); }, []); @@ -311,6 +313,7 @@ export default function InventoryPage() { } }} categoriesList={categoriesList} + backendUrl={backendUrl} /> diff --git a/frontend/components/AIOnboarding.tsx b/frontend/components/AIOnboarding.tsx index a9b2d1b0..10978a99 100644 --- a/frontend/components/AIOnboarding.tsx +++ b/frontend/components/AIOnboarding.tsx @@ -237,23 +237,15 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento

Extracted Photo

-
- - -
- {extractedItems[editingIndex]._skipPhoto && ( -

Photo will not be saved

- )} +
)} diff --git a/frontend/components/InventoryTable.tsx b/frontend/components/InventoryTable.tsx index 41748600..2d5ba936 100644 --- a/frontend/components/InventoryTable.tsx +++ b/frontend/components/InventoryTable.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Item } from '@/lib/db'; +import { buildPhotoUrl } from '@/lib/api'; import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -20,6 +21,7 @@ interface InventoryTableProps { onItemClick: (item: Item) => void; onEditCategory?: (category: string) => void; categoriesList?: any[]; + backendUrl?: string; } export default function InventoryTable({ @@ -29,7 +31,8 @@ export default function InventoryTable({ onExpandCategory, onItemClick, onEditCategory, - categoriesList = [] + categoriesList = [], + backendUrl = '' }: InventoryTableProps) { const [selectedItemDetail, setSelectedItemDetail] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -115,7 +118,7 @@ export default function InventoryTable({ className="w-12 h-12 rounded-xl shrink-0 border-2 border-slate-300 overflow-hidden cursor-pointer hover:border-primary transition-colors active:scale-95" > {item.name} )} diff --git a/frontend/components/ItemDetailModal.tsx b/frontend/components/ItemDetailModal.tsx index e8c88e60..1841901e 100644 --- a/frontend/components/ItemDetailModal.tsx +++ b/frontend/components/ItemDetailModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react'; import { Item } from '@/lib/db'; -import { inventoryApi } from '@/lib/api'; +import { inventoryApi, buildPhotoUrl } from '@/lib/api'; import ItemPhotoUpload from '@/components/ItemPhotoUpload'; import { X, Camera, Trash2 } from 'lucide-react'; import { toast } from 'react-hot-toast'; @@ -12,6 +12,7 @@ interface ItemDetailModalProps { onClose: () => void; onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void; onItemRefresh?: () => void; + backendUrl?: string; } export default function ItemDetailModal({ @@ -19,6 +20,7 @@ export default function ItemDetailModal({ onClose, onPhotoUpdated, onItemRefresh, + backendUrl = '', }: ItemDetailModalProps) { const [showPhotoUpload, setShowPhotoUpload] = useState(false); const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>( @@ -119,7 +121,7 @@ export default function ItemDetailModal({
{item.name} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index b9541f46..936616a4 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -47,6 +47,11 @@ export const getBackendUrl = async () => { return `http://${host}:${config.BACKEND_PORT}`; }; +export const buildPhotoUrl = (backendUrl: string, photoPath: string): string => { + if (!photoPath) return ''; + return `${backendUrl}${photoPath}`; +}; + /** * [C-01] Axios instance cu JWT Bearer token în header * și interceptor pentru 401 Unauthorized (token expired)